生成AIの発注候補を確定前に重複検知するPython
発注候補の重複は、生成AIの精度だけを責めてもなくなりません。受付の入口に照合がなければ、同じ品目の下書きが二つできた時点で、担当者が見落とします。
そこで、発注を確定する前のCSVを一度だけ検査し、同じ日・同じ取引先・同じ品目で、金額が5%以内の組み合わせを「重複候補」として出すようにしました。ここで削除まで自動化しないのがポイントです。候補を人が見るだけでも、二重発注はかなり止めやすくなります。
全角と半角が混じった品目名は、見た目より厄介です。
まずは発注候補CSVを決める
今回のCSVは次の5列です。requested_on は発注予定日です。毎月買う消耗品まで候補に出すと邪魔なので、同じ日付のものだけを比べます。
request_id,vendor,item,amount,requested_on
RQ-101,北星文具,A4コピー用紙 500枚,"4,980",2026-07-31
RQ-102,北星文具,A4コピー用紙 500枚,4980,2026-07-31
RQ-103,北星文具,A4コピー用紙 500枚,4980,2026-08-01
RQ-104,南山商事,HDMIケーブル,6980,2026-07-31
RQ-105,北星文具,A4コピー用紙 500枚,未定,2026-07-31
RQ-101 と RQ-102 は、AIが別の依頼文から作ったとしても、実質は同じ発注です。RQ-103 は日付が違うので候補にしません。ここを品目名だけで機械的にまとめると、定期発注まで止めてしまいます。
Pythonで重複候補と入力ミスを分ける
下のコードを check_orders.py、CSVを orders.csv として同じフォルダに置きます。外部ライブラリは使いません。
import csv
import itertools
import unicodedata
from collections import defaultdict
from datetime import date
from decimal import Decimal, InvalidOperation
REQUIRED_COLUMNS = {"request_id", "vendor", "item", "amount", "requested_on"}
def normalize_text(value: str) -> str:
value = unicodedata.normalize("NFKC", value)
return "".join(value.split()).casefold()
def parse_amount(value: str) -> Decimal:
cleaned = value.replace(",", "").replace("¥", "").strip()
try:
amount = Decimal(cleaned)
except InvalidOperation as error:
raise ValueError("amount が数値ではありません") from error
if amount < 0:
raise ValueError("amount は0以上にしてください")
return amount
def find_duplicates(reader):
valid_rows = []
errors = []
for line_no, row in enumerate(reader, start=2):
try:
amount = parse_amount(row["amount"])
date.fromisoformat(row["requested_on"])
if not row["request_id"].strip() or not row["vendor"].strip() or not row["item"].strip():
raise ValueError("request_id, vendor, item は空にできません")
except (InvalidOperation, ValueError) as error:
errors.append((line_no, str(error), row))
continue
row["amount_decimal"] = amount
valid_rows.append(row)
groups = defaultdict(list)
for row in valid_rows:
key = (
normalize_text(row["vendor"]),
normalize_text(row["item"]),
row["requested_on"],
)
groups[key].append(row)
candidates = []
for rows in groups.values():
for left, right in itertools.combinations(rows, 2):
smaller = min(left["amount_decimal"], right["amount_decimal"])
difference = abs(left["amount_decimal"] - right["amount_decimal"])
similar_price = difference == 0 if smaller == 0 else difference / smaller <= Decimal("0.05")
if similar_price:
candidates.append((left, right))
return candidates, errors
with open("orders.csv", newline="", encoding="utf-8-sig") as file:
reader = csv.DictReader(file)
if not reader.fieldnames or set(reader.fieldnames) != REQUIRED_COLUMNS:
raise ValueError(f"CSVの列は {sorted(REQUIRED_COLUMNS)} にしてください")
candidates, errors = find_duplicates(reader)
print(f"重複候補 {len(candidates)}件")
for left, right in candidates:
print(
f'{left["request_id"]} / {right["request_id"]} | '
f'{left["vendor"]} | {left["item"]} | '
f'{left["amount_decimal"]} / {right["amount_decimal"]} | '
f'{left["requested_on"]}'
)
print(f"エラー {len(errors)}件")
for line_no, message, row in errors:
print(f'行 {line_no}: {message}: {row["amount"]!r}')
この照合は、発注確定ボタンの直前に置くのがいちばん扱いやすいです。
python3 check_orders.py を実行すると、上のCSVではこうなります。手元では、全角の A4 と全角スペースを含む RQ-102 を重複候補にし、金額が 未定 の行だけを入力エラーとして分けられました。
重複候補 1件
RQ-101 / RQ-102 | 北星文具 | A4コピー用紙 500枚 | 4980 / 4980 | 2026-07-31
エラー 1件
行 6: amount が数値ではありません: '未定'
品目名は unicodedata.normalize("NFKC", ...) で全角英数字を半角へ寄せ、空白も取り除いています。金額を float ではなく Decimal で扱うのも地味に効きます。小数を含む単価を比べる時に、二進数の丸め誤差を持ち込みません。
5%は正解ではなく、確認の幅です
5%という数字には業務上の正解がありません。同じ型番で数量が一つ違うと金額が動く会社なら、2%に下げた方がよいです。送料込みの見積もりが混じるなら、10%でも候補を拾い切れないかもしれません。
ここでやりがちなのが、候補になった片方を自動で消すことです。発注の重複と見えて、実際には拠点違い・部署違いの正しい依頼だった、ということがあるからです。スクリプトの役目は「止める」より「先に並べる」。確認する人が二つの依頼を開ける状態にするまでで十分です。
AIへの依頼文にも、発注候補を作る段階で次の一文を足しています。
同じ取引先・品目・発注予定日の候補が既にある場合は、新規行を作らず、既存のrequest_idを示してください。
ただし、この一文だけに任せるのは危ないです。AIは入力が分かれていれば別件として扱えますし、表記ゆれまでは保証しません。生成時の指示と、確定前の照合を分けると運用が安定します。
発注フローに残すもの
発注候補CSVには、依頼IDを必ず残します。候補が出た時に「どちらを消すか」ではなく、「誰に確認するか」までたどれるためです。
最初は同日・同一取引先・同一品目だけで十分です。候補が多すぎるなら金額幅を狭め、少なすぎるなら取引先の表記ゆれを辞書で寄せます。生成AIで下書きが増えるほど、確認の入口を一つ置く作業が効いてきます。ここ地味に効きます。