0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

生成AIの発注候補を確定前に重複検知するPython

0
Posted at

生成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-101RQ-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で下書きが増えるほど、確認の入口を一つ置く作業が効いてきます。ここ地味に効きます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?