diff --git a/AGENTS.md b/AGENTS.md index bdc6534..ee1ff3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,9 @@ | 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py` | `rpa_oms/tests/test_sales_goods_receive.py::点名` | | 接口1 Oracle 落表 | `rpa_oms/app/inbound/oracle_repo.py` | `rpa_oms/tests/test_oracle_repo.py::点名` | | 接口1 入口 / 鉴权 | `rpa_oms/app/main.py`、`auth.py` | 同上;真库见 `rpa_oms/README.md` Windows 段 | +| 接口2 取数组装 | `rpa_oms/app/outbound/cart_settlement.py` | `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope` | +| 接口3 收件契约 | `rpa_oms/app/inbound/purchase_contract.py` | `rpa_oms/tests/test_purchase_contract_receive.py::点名` | +| 接口3 Oracle 落表 | `rpa_oms/app/inbound/oracle_contract_repo.py` | `rpa_oms/tests/test_oracle_contract_repo.py::点名` | | 选品规则 / 拆量 | `rpa_selection/rules.py`、`pipeline.py` | `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` | | 选品班次落表 | `rpa_selection/job.py`、`persist.py`、`repo.py` | `rpa_selection/tests/test_job.py::test_job_searches_barcode_not_approval_no` | | 合同页面步骤 | `rpa_contract/contract_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt` | @@ -37,7 +40,7 @@ ## 接口1 落点(订单中心) -订单中心四接口,本期只开放接口 1:销售商品对码数据接收。`POST /api/oms/v1/sales-goods`,鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,等选品作业捞;**请求里不跑选品**。 +订单中心四接口,本期开放接口 1、3 收件。鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。接口 1:`POST /api/oms/v1/sales-goods`,落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,**请求里不跑选品**。接口 3:`POST /api/oms/v1/purchase-contracts`,落表 `rpa_purchase_contract_data`,`process_status=0`,按对方 `fid` 幂等,**请求里不录合同**。接口 2 只从表 2 取 `push_status=0` 组装报文,路径仍空,不发 HTTP。 运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。 @@ -52,11 +55,15 @@ | 字段不过 | `_parse_row` | `REJECTED`,不入库 | | 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` | -`omsId` / `saleQty` / `planPurchaseQty` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。 +`omsId` / `saleQty` / `planPurchaseQty` / 接口 3 的 `fid` 必须是 JSON 整数,不把 `"10"` 当 10。Windows 连真库测;Instant Client 位数与 Python 一致。 -验收(内存、不连库):`cd rpa_oms` → `uv run pytest tests/test_sales_goods_receive.py::点名 -q`。真库:`GET /health` 后把 `examples/sales_goods_batch.json` POST 两次(第一次两条 `ACCEPTED`,第二次两条 `DUPLICATE`)。 +接口 2:`load_cart_settlement_envelope` 捞 `push_status=0`,同一 `omsId` 整组进一批,上限 500;金额四位小数字符串。不改 `push_status`,不 POST 订单中心。 -修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2–4 未开放,不要先写。 +接口 3:`receive_purchase_contracts` 按 `fid` 幂等;同批 `fid` 再现 → `CONFLICT`;`contractEntryStatus=0` 必须带 `contractNo`;结束日不得早于开始日。`INSERT` 绑 `fid`。 + +验收(内存、不连库):`cd rpa_oms` → `uv run pytest tests/test_sales_goods_receive.py::点名 -q`。真库:`GET /health` 后把 `examples/sales_goods_batch.json` POST 两次(第一次两条 `ACCEPTED`,第二次两条 `DUPLICATE`)。接口 3 点名 `tests/test_purchase_contract_receive.py`;真库 POST `examples/purchase_contract_batch.json` 两次。接口 2 点名 `tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。 + +修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 4 未开放;接口 2 只组装不推送。 ## 选品落点 @@ -114,6 +121,8 @@ ## 验收 - 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。 +- 接口2:点名 `rpa_oms/tests/test_cart_settlement_outbound.py::test_assembles_unpushed_rows_as_document_envelope`。 +- 接口3:点名 `rpa_oms/tests/test_purchase_contract_receive.py` 或 `test_oracle_contract_repo.py`。 - 选品:点名 `rpa_selection/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。 - 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 @@ -123,4 +132,5 @@ - 不要扫根目录,不要通读 `docs/`。需要背景就点名打开一节。 - 不要把 Oracle 密码写进仓库、README、示例 JSON。 - 不要在接口1 请求路径里跑选品。 +- 不要在接口3 请求路径里录合同。 - 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。 diff --git a/README.md b/README.md index e609a2b..9411b57 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ | 口 | 方向 | 底表 | 状态 | | --- | --- | --- | --- | -| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 进行中(`rpa_oms/`) | -| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 选品已落表,push_status=0,路径仍空 | -| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 复用接口 1 接收壳 | +| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | `POST /api/oms/v1/sales-goods` | +| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 取 `push_status=0` 组装报文;路径仍空,不发 HTTP | +| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | `POST /api/oms/v1/purchase-contracts` | | 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 路径空,先造数 | 作业互不串联:选品读表 1,合同/返利读表 3。HTTP 收件不同步跑 RPA。 @@ -19,7 +19,7 @@ | 目录 | 身份 | | --- | --- | -| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。先做接口 1 | +| `rpa_oms/` | 四个 HTTP 口,一个 FastAPI。接口 1、3 收件;接口 2 组装报文 | | `rpa_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 | | `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 | | `rpa_contract/` | 合同班。页面录入在本包;表 3 合单、接口 4 尚未接 | diff --git a/rpa_oms/README.md b/rpa_oms/README.md index 630a46b..5fa9b33 100644 --- a/rpa_oms/README.md +++ b/rpa_oms/README.md @@ -6,19 +6,28 @@ | 口 | 方向 | 底表 | 代码 | | --- | --- | --- | --- | -| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 已有 `app/inbound/sales_goods.py`,`POST /api/oms/v1/sales-goods` | -| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 未做。将来 `app/outbound/`,由 `rpa_cart` 调用 | -| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 未做。将来 `app/inbound/`,复用接口 1 信封/鉴权 | -| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 未做。将来 `app/outbound/`,由合同/返利班调用 | +| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | `POST /api/oms/v1/sales-goods` | +| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | `app/outbound/cart_settlement.py` 取 `push_status=0` 组装报文。路径仍空,不发 HTTP | +| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | `POST /api/oms/v1/purchase-contracts`,按对方 `fid` 幂等 | +| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 未做。将来由合同/返利班调用 | -本期只开放接口 1: +入站(1、3): -- 路径:`POST /api/oms/v1/sales-goods` - 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN` -- 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`) -- 请求里不跑选品。选品班次在 `rpa_selection/`,按条形码搜两平台、四级仓序拆量后写 `rpa_cart_settlement_goods_data` +- 信封:`{"data":[...]}`,一批最多 100 条 +- 接口 1 落表 `deal_flag=0`,请求里不跑选品 +- 接口 3 落表 `process_status=0`,请求里不录合同 -运行时只落 Oracle。单测用内存替身,不连库:`uv run pytest -v`。 +接口 2 出站组装:`load_cart_settlement_envelope(repo)`,同一 `omsId` 不拆批,一批最多 500 条。不改 `push_status`。 + +```python +from app.outbound.cart_settlement import load_cart_settlement_envelope +from app.outbound.oracle_repo import OracleCartSettlementRepo + +envelope = load_cart_settlement_envelope(OracleCartSettlementRepo.from_env()) +``` + +运行时只落 / 读 Oracle。单测用内存替身,不连库:`uv run pytest -v`。 ## 真库 @@ -66,3 +75,12 @@ curl.exe -sS http://127.0.0.1:8001/api/oms/v1/sales-goods ` -H "X-Oms-Token: dev-oms-token" ` --data-binary "@examples/sales_goods_batch.json" ``` + +接口 3 同样 POST 两次 `examples/purchase_contract_batch.json`:第一次两条 `ACCEPTED`(`process_status=0`),第二次两条 `DUPLICATE`。 + +```powershell +curl.exe -sS http://127.0.0.1:8001/api/oms/v1/purchase-contracts ` + -H "Content-Type: application/json" ` + -H "X-Oms-Token: dev-oms-token" ` + --data-binary "@examples/purchase_contract_batch.json" +``` diff --git a/rpa_oms/app/inbound/__init__.py b/rpa_oms/app/inbound/__init__.py index 58358cf..0646d92 100644 --- a/rpa_oms/app/inbound/__init__.py +++ b/rpa_oms/app/inbound/__init__.py @@ -1 +1 @@ -"""入站收件:拆信封、逐条校验、按 omsId 幂等落表。""" +"""入站收件:拆信封、逐条校验。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。""" diff --git a/rpa_oms/app/inbound/envelope.py b/rpa_oms/app/inbound/envelope.py index ab61d63..a555f69 100644 --- a/rpa_oms/app/inbound/envelope.py +++ b/rpa_oms/app/inbound/envelope.py @@ -1,6 +1,6 @@ """批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。 -单条字段错误不走这里,由 sales_goods 逐条 REJECTED,HTTP 仍 200。 +单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。 """ MAX_BATCH = 100 diff --git a/rpa_oms/app/inbound/memory_repo.py b/rpa_oms/app/inbound/memory_repo.py index f731a82..7a9f8f8 100644 --- a/rpa_oms/app/inbound/memory_repo.py +++ b/rpa_oms/app/inbound/memory_repo.py @@ -4,7 +4,7 @@ """ from __future__ import annotations -from .repo import DuplicateOmsId +from .repo import DuplicateFid, DuplicateOmsId class MemorySalesGoodsRepo: @@ -23,3 +23,21 @@ class MemorySalesGoodsRepo: def count(self) -> int: return len(self._rows) + + +class MemoryPurchaseContractRepo: + def __init__(self): + self._rows: dict[int, dict] = {} + + def insert(self, record: dict) -> None: + fid = record["fid"] + if fid in self._rows: + raise DuplicateFid(fid) + self._rows[fid] = dict(record) + + def get(self, fid: int) -> dict | None: + row = self._rows.get(fid) + return dict(row) if row is not None else None + + def count(self) -> int: + return len(self._rows) diff --git a/rpa_oms/app/inbound/oracle_contract_repo.py b/rpa_oms/app/inbound/oracle_contract_repo.py new file mode 100644 index 0000000..bd23bd6 --- /dev/null +++ b/rpa_oms/app/inbound/oracle_contract_repo.py @@ -0,0 +1,103 @@ +"""接口 3 Oracle 落表。fid 是订单中心来源序号,INSERT 必须绑这一列。""" +from __future__ import annotations + +import os +from collections.abc import Callable + +from .oracle_repo import _ensure_client, _ora_code +from .repo import DuplicateFid, RepoUnavailable + +_INSERT_SQL = """ +INSERT INTO rpa_purchase_contract_data ( + fid, oms_id, supplier_id, supplier_name, + contract_sign_date, contract_start_date, contract_end_date, + payment_term, goods_id, goods_name, reg_no, spec, unit, + qty, purchase_tax_rate, unit_price, goods_source_id, + purchaser_id, purchaser_name, planner_id, planner_name, + contract_entry_status, contract_no, org_code, customer_type, + goods_mdm_code, supplier_mdm_code, + process_status, create_time +) VALUES ( + :fid, :oms_id, :supplier_id, :supplier_name, + :contract_sign_date, :contract_start_date, :contract_end_date, + :payment_term, :goods_id, :goods_name, :reg_no, :spec, :unit, + :qty, :purchase_tax_rate, :unit_price, :goods_source_id, + :purchaser_id, :purchaser_name, :planner_id, :planner_name, + :contract_entry_status, :contract_no, :org_code, :customer_type, + :goods_mdm_code, :supplier_mdm_code, + 0, SYSTIMESTAMP +) +""" + +_BIND_KEYS = ( + "fid", + "oms_id", + "supplier_id", + "supplier_name", + "contract_sign_date", + "contract_start_date", + "contract_end_date", + "payment_term", + "goods_id", + "goods_name", + "reg_no", + "spec", + "unit", + "qty", + "purchase_tax_rate", + "unit_price", + "goods_source_id", + "purchaser_id", + "purchaser_name", + "planner_id", + "planner_name", + "contract_entry_status", + "contract_no", + "org_code", + "customer_type", + "goods_mdm_code", + "supplier_mdm_code", +) + + +class OraclePurchaseContractRepo: + def __init__(self, connect: Callable): + self._connect = connect + + @classmethod + def from_env(cls) -> "OraclePurchaseContractRepo": + user = os.getenv("RPA_ORACLE_USER", "") + password = os.getenv("RPA_ORACLE_PASSWORD", "") + host = os.getenv("RPA_ORACLE_HOST", "") + if not user or not password or not host: + raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD") + port = os.getenv("RPA_ORACLE_PORT", "1521") + service = os.getenv("RPA_ORACLE_SERVICE", "rpa") + dsn = f"{host}:{port}/{service}" + + def connect(): + _ensure_client() + import oracledb + + return oracledb.connect(user=user, password=password, dsn=dsn) + + return cls(connect=connect) + + def insert(self, record: dict) -> None: + conn = self._connect() + try: + with conn.cursor() as cur: + cur.execute(_INSERT_SQL, {key: record[key] for key in _BIND_KEYS}) + conn.commit() + except DuplicateFid: + raise + except Exception as exc: + try: + conn.rollback() + except Exception: + pass + if _ora_code(exc) == 1: + raise DuplicateFid(record["fid"]) from exc + raise RepoUnavailable(str(exc)) from exc + finally: + conn.close() diff --git a/rpa_oms/app/inbound/purchase_contract.py b/rpa_oms/app/inbound/purchase_contract.py new file mode 100644 index 0000000..1a96b0c --- /dev/null +++ b/rpa_oms/app/inbound/purchase_contract.py @@ -0,0 +1,168 @@ +"""接口 3 逐条校验与落表。不跑合同作业。 + +入参 camelCase,落表 snake_case。幂等键是对方 fid。 +请求里不录合同。 +""" +from __future__ import annotations + +from datetime import date, datetime + +from .repo import DuplicateFid +from .sales_goods import _parse_decimal, _parse_int, _parse_oms_id, _parse_str + +_STRING_FIELDS = ( + ("supplierId", "supplier_id", 20), + ("supplierName", "supplier_name", 200), + ("goodsId", "goods_id", 20), + ("goodsName", "goods_name", 200), + ("regNo", "reg_no", 100), + ("spec", "spec", 100), + ("unit", "unit", 20), + ("goodsSourceId", "goods_source_id", 20), + ("purchaserId", "purchaser_id", 20), + ("purchaserName", "purchaser_name", 100), + ("plannerId", "planner_id", 20), + ("plannerName", "planner_name", 100), + ("orgCode", "org_code", 10), + ("goodsMdmCode", "goods_mdm_code", 100), + ("supplierMdmCode", "supplier_mdm_code", 100), +) + +_DATE_FIELDS = ( + ("contractSignDate", "contract_sign_date"), + ("contractStartDate", "contract_start_date"), + ("contractEndDate", "contract_end_date"), +) + +_DECIMAL_FIELDS = ( + ("qty", "qty"), + ("purchaseTaxRate", "purchase_tax_rate"), + ("unitPrice", "unit_price"), +) + + +def receive_purchase_contracts(rows: list, repo) -> list[dict]: + seen: set[int] = set() + results: list[dict] = [] + for row in rows: + results.append(_receive_one(row, repo, seen)) + return results + + +def _receive_one(row: object, repo, seen: set[int]) -> dict: + if not isinstance(row, dict): + return _result(None, "REJECTED", "明细必须是对象") + + fid, fid_error = _parse_fid(row.get("fid")) + if fid is not None and fid in seen: + return _result(fid, "CONFLICT", "同一请求中 fid 重复") + if fid is not None: + seen.add(fid) + + parsed, error = _parse_row(row, fid, fid_error) + if error: + _camel, message = error + return _result(row.get("fid"), "REJECTED", message) + + try: + repo.insert(parsed) + except DuplicateFid: + return _result(parsed["fid"], "DUPLICATE", "序号已存在,未重复入库") + + return _result(parsed["fid"], "ACCEPTED", "采购合同数据已接收,等待处理") + + +def _parse_row(row: dict, fid: int | None, fid_error: str | None): + if fid_error: + return None, ("fid", fid_error) + + oms_id, oms_error = _parse_oms_id(row.get("omsId")) + if oms_error: + return None, ("omsId", oms_error) + + record: dict = {"fid": fid, "oms_id": oms_id, "process_status": 0} + for camel, snake, max_len in _STRING_FIELDS: + value, error = _parse_str(row.get(camel), camel, max_len) + if error: + return None, (camel, error) + record[snake] = value + + for camel, snake in _DATE_FIELDS: + value, error = _parse_date(row.get(camel), camel) + if error: + return None, (camel, error) + record[snake] = value + + if record["contract_end_date"] < record["contract_start_date"]: + return None, ("contractEndDate", "contractEndDate 不得早于 contractStartDate") + + payment_term, error = _parse_int(row.get("paymentTerm"), "paymentTerm") + if error: + return None, ("paymentTerm", error) + record["payment_term"] = payment_term + + for camel, snake in _DECIMAL_FIELDS: + value, error = _parse_decimal(row.get(camel), camel) + if error: + return None, (camel, error) + record[snake] = value + + customer_type, error = _parse_int(row.get("customerType"), "customerType") + if error: + return None, ("customerType", error) + record["customer_type"] = customer_type + + entry_status, error = _parse_entry_status(row.get("contractEntryStatus")) + if error: + return None, ("contractEntryStatus", error) + record["contract_entry_status"] = entry_status + + contract_no, error = _parse_contract_no(row.get("contractNo"), entry_status) + if error: + return None, ("contractNo", error) + record["contract_no"] = contract_no + + return record, None + + +def _parse_fid(value) -> tuple[int | None, str | None]: + if value is None or value == "": + return None, "缺少必填字段 fid" + if type(value) is int and 0 < value < 10**19: + return value, None + return None, "fid 必须是整数" + + +def _parse_date(value, camel: str) -> tuple[date | None, str | None]: + if value is None or value == "": + return None, f"缺少必填字段 {camel}" + if isinstance(value, datetime): + return value.date(), None + if isinstance(value, date): + return value, None + if not isinstance(value, str): + return None, f"{camel} 必须是 YYYY-MM-DD" + try: + return date.fromisoformat(value.strip()), None + except ValueError: + return None, f"{camel} 必须是 YYYY-MM-DD" + + +def _parse_entry_status(value) -> tuple[int | None, str | None]: + if value is None or value == "": + return None, None + if type(value) is int and value in (0, 1): + return value, None + return None, "contractEntryStatus 必须是 0 或 1" + + +def _parse_contract_no(value, entry_status: int | None) -> tuple[str | None, str | None]: + if entry_status == 0: + return _parse_str(value, "contractNo", 40) + if value is None or value == "": + return None, None + return _parse_str(value, "contractNo", 40) + + +def _result(fid, result_code: str, message: str) -> dict: + return {"fid": fid, "resultCode": result_code, "message": message} diff --git a/rpa_oms/app/inbound/repo.py b/rpa_oms/app/inbound/repo.py index ae33ee4..606f50f 100644 --- a/rpa_oms/app/inbound/repo.py +++ b/rpa_oms/app/inbound/repo.py @@ -1,6 +1,7 @@ """收件仓库契约。 DuplicateOmsId:oms_id 已存在,调用方回 DUPLICATE,不更新。 +DuplicateFid:接口 3 来源 fid 已存在,调用方回 DUPLICATE,不更新。 RepoUnavailable:连不上库或未配置,整包 HTTP 503,一条都不落。 """ @@ -10,6 +11,11 @@ class DuplicateOmsId(Exception): self.oms_id = oms_id +class DuplicateFid(Exception): + def __init__(self, fid: int): + self.fid = fid + + class RepoUnavailable(Exception): def __init__(self, message: str = "数据库不可用"): self.message = message diff --git a/rpa_oms/app/main.py b/rpa_oms/app/main.py index f967490..b1c313b 100644 --- a/rpa_oms/app/main.py +++ b/rpa_oms/app/main.py @@ -1,9 +1,10 @@ -"""订单中心接口服务。本期只开放接口 1 收件。 +"""订单中心接口服务。本期开放接口 1、3 收件;接口 2 只在 outbound 组装报文。 -路径:POST /api/oms/v1/sales-goods +路径: +- POST /api/oms/v1/sales-goods +- POST /api/oms/v1/purchase-contracts 鉴权:X-Oms-Token -落表:rpa_sales_goods_mapping_data,deal_flag=0 -请求里不跑选品。 +请求里不跑选品、不录合同。 """ from __future__ import annotations @@ -17,6 +18,7 @@ from .local_env import load_local_env load_local_env() from .inbound.envelope import EnvelopeError, parse_envelope +from .inbound.purchase_contract import receive_purchase_contracts from .inbound.repo import RepoUnavailable from .inbound.sales_goods import receive_sales_goods @@ -30,6 +32,12 @@ def get_repo(): return OracleSalesGoodsRepo.from_env() +def get_contract_repo(): + from .inbound.oracle_contract_repo import OraclePurchaseContractRepo + + return OraclePurchaseContractRepo.from_env() + + @app.get("/health") def health(): return {"ok": True} @@ -41,6 +49,27 @@ def post_sales_goods( x_oms_token: Annotated[str | None, Header()] = None, repo=Depends(get_repo), ): + return _post_inbound( + payload, + x_oms_token, + receive=lambda rows: receive_sales_goods(rows, repo), + ) + + +@app.post("/api/oms/v1/purchase-contracts") +def post_purchase_contracts( + payload: dict[str, Any] | None = None, + x_oms_token: Annotated[str | None, Header()] = None, + repo=Depends(get_contract_repo), +): + return _post_inbound( + payload, + x_oms_token, + receive=lambda rows: receive_purchase_contracts(rows, repo), + ) + + +def _post_inbound(payload, x_oms_token, receive): if not token_ok(x_oms_token): return JSONResponse( status_code=401, @@ -54,7 +83,7 @@ def post_sales_goods( content={"code": 422, "msg": exc.message, "results": []}, ) try: - results = receive_sales_goods(rows, repo) + results = receive(rows) except RepoUnavailable as exc: return JSONResponse( status_code=503, diff --git a/rpa_oms/app/outbound/__init__.py b/rpa_oms/app/outbound/__init__.py new file mode 100644 index 0000000..a8971bc --- /dev/null +++ b/rpa_oms/app/outbound/__init__.py @@ -0,0 +1 @@ +"""出站:从表组装订单中心报文。本期接口 2 只取数组装,不发 HTTP。""" diff --git a/rpa_oms/app/outbound/cart_settlement.py b/rpa_oms/app/outbound/cart_settlement.py new file mode 100644 index 0000000..d81d8e4 --- /dev/null +++ b/rpa_oms/app/outbound/cart_settlement.py @@ -0,0 +1,62 @@ +"""接口 2:从表 2 取未推行,组装文档报文。 + +同一 omsId 的加购行必须在同一批 data 里。一批最多 500 条。 +不改 push_status,不向订单中心发 HTTP(路径仍空)。 +""" +from __future__ import annotations + +from collections import OrderedDict +from decimal import Decimal, ROUND_HALF_UP + +MAX_BATCH = 500 + + +def load_cart_settlement_envelope(repo, limit: int = MAX_BATCH) -> dict: + return build_cart_settlement_envelope(repo.list_unpushed(), limit=limit) + + +def build_cart_settlement_envelope(rows: list[dict], limit: int = MAX_BATCH) -> dict: + chosen = _take_complete_groups(rows, limit) + return {"data": [_to_item(row) for row in chosen]} + + +def _take_complete_groups(rows: list[dict], limit: int) -> list[dict]: + groups: OrderedDict = OrderedDict() + for row in rows: + groups.setdefault(row["oms_id"], []).append(row) + batch: list[dict] = [] + for group in groups.values(): + if len(batch) + len(group) > limit and batch: + break + batch.extend(group) + return batch + + +def _to_item(row: dict) -> dict: + return { + "fid": _as_int(row["fid"]), + "operateType": _as_int(row["operate_type"]), + "omsId": _as_int(row["oms_id"]), + "platformType": _as_int(row["platform_type"]), + "supplierName": row["supplier_name"], + "barCode": row["bar_code"], + "purchasePrice": _money4(row["purchase_price"]), + "purchaseQty": _as_int(row["purchase_qty"]), + "purchaseTotal": _money4(row["purchase_total"]), + "status": _as_int(row["status"]) if row.get("status") is not None else None, + "failReason": row.get("fail_reason"), + "payOrderNo": row.get("pay_order_no"), + "payOrderDtlId": row.get("pay_order_dtl_id"), + "orgCode": row.get("org_code"), + } + + +def _as_int(value): + if value is None: + return None + return int(value) + + +def _money4(value) -> str: + amount = Decimal(str(value)).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP) + return f"{amount:.4f}" diff --git a/rpa_oms/app/outbound/memory_repo.py b/rpa_oms/app/outbound/memory_repo.py new file mode 100644 index 0000000..8b9996c --- /dev/null +++ b/rpa_oms/app/outbound/memory_repo.py @@ -0,0 +1,10 @@ +"""内存表 2。只给单测用。""" +from __future__ import annotations + + +class MemoryCartSettlementRepo: + def __init__(self, rows: list[dict] | None = None): + self._rows = [dict(row) for row in (rows or [])] + + def list_unpushed(self) -> list[dict]: + return [dict(row) for row in self._rows if row.get("push_status", 0) == 0] diff --git a/rpa_oms/app/outbound/oracle_repo.py b/rpa_oms/app/outbound/oracle_repo.py new file mode 100644 index 0000000..0c7bb41 --- /dev/null +++ b/rpa_oms/app/outbound/oracle_repo.py @@ -0,0 +1,54 @@ +"""接口 2:从表 2 读取 push_status=0。不更新推送状态。""" +from __future__ import annotations + +import os +from collections.abc import Callable + +from ..inbound.oracle_repo import _ensure_client +from ..inbound.repo import RepoUnavailable + +_LIST_UNPUSHED_SQL = """ +SELECT + fid, operate_type, oms_id, platform_type, supplier_name, bar_code, + purchase_price, purchase_qty, purchase_total, status, fail_reason, + pay_order_no, pay_order_dtl_id, org_code, push_status +FROM rpa_cart_settlement_goods_data +WHERE push_status = 0 +ORDER BY oms_id, fid +""" + + +class OracleCartSettlementRepo: + def __init__(self, connect: Callable): + self._connect = connect + + @classmethod + def from_env(cls) -> "OracleCartSettlementRepo": + user = os.getenv("RPA_ORACLE_USER", "") + password = os.getenv("RPA_ORACLE_PASSWORD", "") + host = os.getenv("RPA_ORACLE_HOST", "") + if not user or not password or not host: + raise RepoUnavailable("未配置 RPA_ORACLE_HOST / RPA_ORACLE_USER / RPA_ORACLE_PASSWORD") + port = os.getenv("RPA_ORACLE_PORT", "1521") + service = os.getenv("RPA_ORACLE_SERVICE", "rpa") + dsn = f"{host}:{port}/{service}" + + def connect(): + _ensure_client() + import oracledb + + return oracledb.connect(user=user, password=password, dsn=dsn) + + return cls(connect=connect) + + def list_unpushed(self) -> list[dict]: + conn = self._connect() + try: + with conn.cursor() as cur: + cur.execute(_LIST_UNPUSHED_SQL) + cols = [item[0].lower() for item in cur.description] + return [dict(zip(cols, row)) for row in cur.fetchall()] + except Exception as exc: + raise RepoUnavailable(str(exc)) from exc + finally: + conn.close() diff --git a/rpa_oms/examples/cart_settlement_batch.json b/rpa_oms/examples/cart_settlement_batch.json new file mode 100644 index 0000000..654b4ca --- /dev/null +++ b/rpa_oms/examples/cart_settlement_batch.json @@ -0,0 +1,36 @@ +{ + "data": [ + { + "fid": 100001, + "operateType": 1, + "omsId": 20260928000001, + "platformType": 48, + "supplierName": "示例供应商", + "barCode": "06901234567890", + "purchasePrice": "128.5000", + "purchaseQty": 10, + "purchaseTotal": "1285.0000", + "status": 1, + "failReason": null, + "payOrderNo": null, + "payOrderDtlId": null, + "orgCode": "901" + }, + { + "fid": 100002, + "operateType": 2, + "omsId": 20260928000001, + "platformType": 66, + "supplierName": "示例供应商", + "barCode": "06901234567890", + "purchasePrice": "128.5000", + "purchaseQty": 10, + "purchaseTotal": "1285.0000", + "status": null, + "failReason": null, + "payOrderNo": "PAY20260928000001", + "payOrderDtlId": "1233", + "orgCode": "901" + } + ] +} diff --git a/rpa_oms/examples/purchase_contract_batch.json b/rpa_oms/examples/purchase_contract_batch.json new file mode 100644 index 0000000..3bfbaff --- /dev/null +++ b/rpa_oms/examples/purchase_contract_batch.json @@ -0,0 +1,62 @@ +{ + "data": [ + { + "fid": 200001, + "omsId": 20260928000001, + "supplierId": "SUP001", + "supplierName": "示例供应商", + "contractSignDate": "2026-09-28", + "contractStartDate": "2026-10-01", + "contractEndDate": "2027-09-30", + "paymentTerm": 90, + "goodsId": "G000123456789", + "goodsName": "阿莫西林胶囊", + "regNo": "国药准字H20201234", + "spec": "0.25g*24粒", + "unit": "盒", + "qty": "10.0000", + "purchaseTaxRate": "0.13", + "unitPrice": "100.0000", + "goodsSourceId": "GS001", + "purchaserId": "P001", + "purchaserName": "张三", + "plannerId": "PL001", + "plannerName": "李四", + "contractEntryStatus": null, + "contractNo": null, + "orgCode": "901", + "customerType": 1, + "goodsMdmCode": "QX0100167583", + "supplierMdmCode": "DW0100046361" + }, + { + "fid": 200002, + "omsId": 20260928000002, + "supplierId": "SUP001", + "supplierName": "示例供应商", + "contractSignDate": "2026-09-28", + "contractStartDate": "2026-10-01", + "contractEndDate": "2027-09-30", + "paymentTerm": 60, + "goodsId": "G000987654321", + "goodsName": "注射用头孢曲松钠", + "regNo": "国药准字H20123456", + "spec": "1.0g", + "unit": "支", + "qty": "5.0000", + "purchaseTaxRate": "0.13", + "unitPrice": "45.0000", + "goodsSourceId": "GS002", + "purchaserId": "P001", + "purchaserName": "张三", + "plannerId": "PL001", + "plannerName": "李四", + "contractEntryStatus": 0, + "contractNo": "HT20260928002", + "orgCode": "901", + "customerType": 1, + "goodsMdmCode": "QX0100167583", + "supplierMdmCode": "DW0100046361" + } + ] +} diff --git a/rpa_oms/pyproject.toml b/rpa_oms/pyproject.toml index 8c0bd07..9d467eb 100644 --- a/rpa_oms/pyproject.toml +++ b/rpa_oms/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "rpa-oms" version = "0.1.0" -description = "洁达 RPA × 订单中心接口。先做接口 1 收件。" +description = "洁达 RPA × 订单中心接口。接口 1、3 收件;接口 2 组装报文。" requires-python = ">=3.12" dependencies = [ "fastapi>=0.115.0", diff --git a/rpa_oms/tests/test_cart_settlement_outbound.py b/rpa_oms/tests/test_cart_settlement_outbound.py new file mode 100644 index 0000000..b0342a1 --- /dev/null +++ b/rpa_oms/tests/test_cart_settlement_outbound.py @@ -0,0 +1,137 @@ +"""接口 2:从表 2 取 push_status=0,组装文档报文。不连 Oracle,不发 HTTP。""" +from __future__ import annotations + +from decimal import Decimal + +from app.outbound.cart_settlement import ( + MAX_BATCH, + build_cart_settlement_envelope, + load_cart_settlement_envelope, +) +from app.outbound.memory_repo import MemoryCartSettlementRepo + + +def _row(**overrides): + row = { + "fid": 100001, + "operate_type": 1, + "oms_id": 20260928000001, + "platform_type": 48, + "supplier_name": "示例供应商", + "bar_code": "06901234567890", + "purchase_price": Decimal("128.5000"), + "purchase_qty": 10, + "purchase_total": Decimal("1285.0000"), + "status": 1, + "fail_reason": None, + "pay_order_no": None, + "pay_order_dtl_id": None, + "org_code": "901", + "push_status": 0, + "multi_platform_flag": 0, + } + row.update(overrides) + return row + + +def test_assembles_unpushed_rows_as_document_envelope(): + repo = MemoryCartSettlementRepo( + [ + _row(), + _row( + fid=100002, + operate_type=2, + platform_type=66, + status=None, + pay_order_no="PAY20260928000001", + pay_order_dtl_id="1233", + ), + ] + ) + envelope = load_cart_settlement_envelope(repo) + assert envelope == { + "data": [ + { + "fid": 100001, + "operateType": 1, + "omsId": 20260928000001, + "platformType": 48, + "supplierName": "示例供应商", + "barCode": "06901234567890", + "purchasePrice": "128.5000", + "purchaseQty": 10, + "purchaseTotal": "1285.0000", + "status": 1, + "failReason": None, + "payOrderNo": None, + "payOrderDtlId": None, + "orgCode": "901", + }, + { + "fid": 100002, + "operateType": 2, + "omsId": 20260928000001, + "platformType": 66, + "supplierName": "示例供应商", + "barCode": "06901234567890", + "purchasePrice": "128.5000", + "purchaseQty": 10, + "purchaseTotal": "1285.0000", + "status": None, + "failReason": None, + "payOrderNo": "PAY20260928000001", + "payOrderDtlId": "1233", + "orgCode": "901", + }, + ] + } + + +def test_skips_already_pushed_rows(): + repo = MemoryCartSettlementRepo( + [ + _row(fid=1, push_status=1), + _row(fid=2, oms_id=20260928000002, push_status=0), + ] + ) + envelope = load_cart_settlement_envelope(repo) + assert [item["fid"] for item in envelope["data"]] == [2] + + +def test_keeps_same_oms_id_in_one_envelope(): + rows = [ + _row(fid=1, oms_id=1, platform_type=48, multi_platform_flag=1), + _row(fid=2, oms_id=1, platform_type=66, multi_platform_flag=1), + _row(fid=3, oms_id=2, platform_type=48), + ] + envelope = build_cart_settlement_envelope(rows, limit=2) + fids = [item["fid"] for item in envelope["data"]] + assert fids == [1, 2] + + +def test_does_not_split_oms_id_group_across_batch_limit(): + rows = [ + _row(fid=1, oms_id=1, platform_type=48), + _row(fid=2, oms_id=2, platform_type=48, multi_platform_flag=1), + _row(fid=3, oms_id=2, platform_type=66, multi_platform_flag=1), + ] + envelope = build_cart_settlement_envelope(rows, limit=2) + assert [item["fid"] for item in envelope["data"]] == [1] + + +def test_batch_cap_is_500(): + assert MAX_BATCH == 500 + rows = [_row(fid=i, oms_id=i) for i in range(1, 502)] + envelope = build_cart_settlement_envelope(rows) + assert len(envelope["data"]) == 500 + assert envelope["data"][0]["fid"] == 1 + assert envelope["data"][-1]["fid"] == 500 + + +def test_payload_omits_table_only_columns(): + envelope = build_cart_settlement_envelope([_row()]) + item = envelope["data"][0] + assert "pushStatus" not in item + assert "push_status" not in item + assert "multiPlatformFlag" not in item + assert "createTime" not in item diff --git a/rpa_oms/tests/test_oracle_cart_repo.py b/rpa_oms/tests/test_oracle_cart_repo.py new file mode 100644 index 0000000..98d8695 --- /dev/null +++ b/rpa_oms/tests/test_oracle_cart_repo.py @@ -0,0 +1,77 @@ +"""接口 2 Oracle 取数:只捞 push_status=0。不连真库。""" +from __future__ import annotations + +from app.outbound.oracle_repo import _LIST_UNPUSHED_SQL, OracleCartSettlementRepo + + +def test_list_sql_filters_unpushed_and_orders_by_oms_id(): + sql = " ".join(_LIST_UNPUSHED_SQL.split()) + assert "rpa_cart_settlement_goods_data" in sql.lower() + assert "push_status = 0" in sql + assert "order by oms_id" in sql.lower() + assert "fid" in sql.lower() + + +def test_list_unpushed_maps_columns(): + class FakeCursor: + description = ( + ("FID",), + ("OPERATE_TYPE",), + ("OMS_ID",), + ("PLATFORM_TYPE",), + ("SUPPLIER_NAME",), + ("BAR_CODE",), + ("PURCHASE_PRICE",), + ("PURCHASE_QTY",), + ("PURCHASE_TOTAL",), + ("STATUS",), + ("FAIL_REASON",), + ("PAY_ORDER_NO",), + ("PAY_ORDER_DTL_ID",), + ("ORG_CODE",), + ("PUSH_STATUS",), + ) + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql, params=None): + self.sql = sql + + def fetchall(self): + return [ + ( + 100001, + 1, + 20260928000001, + 48, + "示例供应商", + "06901234567890", + 128.5, + 10, + 1285, + 1, + None, + None, + None, + "901", + 0, + ) + ] + + class FakeConn: + def cursor(self): + return FakeCursor() + + def close(self): + pass + + repo = OracleCartSettlementRepo(connect=lambda: FakeConn()) + rows = repo.list_unpushed() + assert rows[0]["fid"] == 100001 + assert rows[0]["operate_type"] == 1 + assert rows[0]["bar_code"] == "06901234567890" + assert rows[0]["push_status"] == 0 diff --git a/rpa_oms/tests/test_oracle_contract_repo.py b/rpa_oms/tests/test_oracle_contract_repo.py new file mode 100644 index 0000000..e3de554 --- /dev/null +++ b/rpa_oms/tests/test_oracle_contract_repo.py @@ -0,0 +1,99 @@ +"""接口 3 Oracle 仓库:INSERT 绑 fid;ORA-00001 映射成 DuplicateFid。不连真库。""" +from __future__ import annotations + +from datetime import date +from decimal import Decimal + +import pytest + +from app.inbound.repo import DuplicateFid, RepoUnavailable + + +def _record(): + return { + "fid": 200001, + "oms_id": 20260928000001, + "supplier_id": "SUP001", + "supplier_name": "示例供应商", + "contract_sign_date": date(2026, 9, 28), + "contract_start_date": date(2026, 10, 1), + "contract_end_date": date(2027, 9, 30), + "payment_term": 90, + "goods_id": "G000123456789", + "goods_name": "阿莫西林胶囊", + "reg_no": "国药准字H20201234", + "spec": "0.25g*24粒", + "unit": "盒", + "qty": Decimal("10.0000"), + "purchase_tax_rate": Decimal("0.1300"), + "unit_price": Decimal("100.0000"), + "goods_source_id": "GS001", + "purchaser_id": "P001", + "purchaser_name": "张三", + "planner_id": "PL001", + "planner_name": "李四", + "contract_entry_status": None, + "contract_no": None, + "org_code": "901", + "customer_type": 1, + "goods_mdm_code": "QX0100167583", + "supplier_mdm_code": "DW0100046361", + "process_status": 0, + } + + +def test_from_env_without_host_raises(monkeypatch): + monkeypatch.delenv("RPA_ORACLE_HOST", raising=False) + from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo + + with pytest.raises(RepoUnavailable): + OraclePurchaseContractRepo.from_env() + + +def test_insert_unique_violation_is_duplicate_fid(): + from app.inbound.oracle_contract_repo import OraclePurchaseContractRepo + + class FakeError(Exception): + def __init__(self): + self.args = (type("Info", (), {"code": 1})(),) + + class FakeCursor: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def execute(self, sql, params=None): + raise FakeError() + + class FakeConn: + def cursor(self): + return FakeCursor() + + def commit(self): + raise AssertionError("unique 冲突不应 commit") + + def rollback(self): + self.rolled_back = True + + def close(self): + pass + + repo = OraclePurchaseContractRepo(connect=lambda: FakeConn()) + with pytest.raises(DuplicateFid): + repo.insert(_record()) + + +def test_insert_sql_binds_source_fid_and_pending_status(): + from app.inbound.oracle_contract_repo import _BIND_KEYS, _INSERT_SQL + + sql = " ".join(_INSERT_SQL.split()) + assert "fid" in sql + assert ":fid" in sql + assert "process_status" in sql + assert "SYSTIMESTAMP" in sql + assert "fid" in _BIND_KEYS + assert "oms_id" in _BIND_KEYS + assert "goods_mdm_code" in _BIND_KEYS + assert "supplier_mdm_code" in _BIND_KEYS diff --git a/rpa_oms/tests/test_purchase_contract_receive.py b/rpa_oms/tests/test_purchase_contract_receive.py new file mode 100644 index 0000000..4228c51 --- /dev/null +++ b/rpa_oms/tests/test_purchase_contract_receive.py @@ -0,0 +1,218 @@ +"""接口 3 收件契约。内存仓库,不连 Oracle。按对方 fid 幂等。""" +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + +os.environ["OMS_INBOUND_TOKEN"] = "test-oms-token" + +from app.inbound.memory_repo import MemoryPurchaseContractRepo +from app.inbound.repo import RepoUnavailable +from app.main import app, get_contract_repo + +TOKEN = "test-oms-token" +PATH = "/api/oms/v1/purchase-contracts" + + +def _item(**overrides): + row = { + "fid": 200001, + "omsId": 20260928000001, + "supplierId": "SUP001", + "supplierName": "示例供应商", + "contractSignDate": "2026-09-28", + "contractStartDate": "2026-10-01", + "contractEndDate": "2027-09-30", + "paymentTerm": 90, + "goodsId": "G000123456789", + "goodsName": "阿莫西林胶囊", + "regNo": "国药准字H20201234", + "spec": "0.25g*24粒", + "unit": "盒", + "qty": "10.0000", + "purchaseTaxRate": "0.13", + "unitPrice": "100.0000", + "goodsSourceId": "GS001", + "purchaserId": "P001", + "purchaserName": "张三", + "plannerId": "PL001", + "plannerName": "李四", + "contractEntryStatus": None, + "contractNo": None, + "orgCode": "901", + "customerType": 1, + "goodsMdmCode": "QX0100167583", + "supplierMdmCode": "DW0100046361", + } + row.update(overrides) + return row + + +@pytest.fixture +def client(): + repo = MemoryPurchaseContractRepo() + app.dependency_overrides[get_contract_repo] = lambda: repo + with TestClient(app) as test_client: + yield test_client, repo + app.dependency_overrides.clear() + + +def _post(client, body, token=TOKEN): + headers = {"X-Oms-Token": token} if token is not None else {} + return client.post(PATH, json=body, headers=headers) + + +def test_missing_token_returns_401(client): + test_client, _ = client + resp = _post(test_client, {"data": [_item()]}, token=None) + assert resp.status_code == 401 + assert resp.json()["results"] == [] + + +def test_missing_data_returns_422(client): + test_client, repo = client + resp = _post(test_client, {}) + assert resp.status_code == 422 + assert repo.count() == 0 + + +def test_over_100_rows_returns_422(client): + test_client, repo = client + rows = [_item(fid=i, omsId=i) for i in range(1, 102)] + resp = _post(test_client, {"data": rows}) + assert resp.status_code == 422 + assert repo.count() == 0 + + +def test_accepts_valid_row(client): + test_client, repo = client + resp = _post(test_client, {"data": [_item()]}) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["results"] == [ + { + "fid": 200001, + "resultCode": "ACCEPTED", + "message": "采购合同数据已接收,等待处理", + } + ] + stored = repo.get(200001) + assert stored["process_status"] == 0 + assert stored["oms_id"] == 20260928000001 + assert stored["supplier_id"] == "SUP001" + assert stored["goods_mdm_code"] == "QX0100167583" + assert stored["contract_entry_status"] is None + assert stored["contract_no"] is None + + +def test_existing_fid_is_duplicate_and_not_updated(client): + test_client, repo = client + _post(test_client, {"data": [_item()]}) + resp = _post(test_client, {"data": [_item(goodsName="不应覆盖")]}) + assert resp.status_code == 200 + result = resp.json()["results"][0] + assert result["resultCode"] == "DUPLICATE" + assert result["message"] == "序号已存在,未重复入库" + assert repo.get(200001)["goods_name"] == "阿莫西林胶囊" + + +def test_duplicate_fid_in_same_batch_is_conflict(client): + test_client, repo = client + resp = _post( + test_client, + {"data": [_item(), _item(omsId=20260928000002)]}, + ) + assert resp.status_code == 200 + results = resp.json()["results"] + assert results[0]["resultCode"] == "ACCEPTED" + assert results[1]["resultCode"] == "CONFLICT" + assert results[1]["fid"] == 200001 + assert repo.count() == 1 + + +def test_retry_protocol_without_contract_no_is_rejected(client): + test_client, repo = client + resp = _post(test_client, {"data": [_item(contractEntryStatus=0, contractNo=None)]}) + assert resp.status_code == 200 + result = resp.json()["results"][0] + assert result["resultCode"] == "REJECTED" + assert "contractNo" in result["message"] + assert repo.count() == 0 + + +def test_retry_protocol_with_contract_no_is_accepted(client): + test_client, repo = client + resp = _post( + test_client, + {"data": [_item(fid=200002, contractEntryStatus=0, contractNo="HT20260928002")]}, + ) + assert resp.status_code == 200 + assert resp.json()["results"][0]["resultCode"] == "ACCEPTED" + stored = repo.get(200002) + assert stored["contract_entry_status"] == 0 + assert stored["contract_no"] == "HT20260928002" + + +def test_end_date_before_start_is_rejected(client): + test_client, repo = client + resp = _post( + test_client, + {"data": [_item(contractStartDate="2027-09-30", contractEndDate="2026-10-01")]}, + ) + assert resp.status_code == 200 + assert resp.json()["results"][0]["resultCode"] == "REJECTED" + assert repo.count() == 0 + + +def test_string_oms_id_is_rejected(client): + test_client, repo = client + resp = _post(test_client, {"data": [_item(omsId="20260928000001")]}) + assert resp.status_code == 200 + assert resp.json()["results"][0]["resultCode"] == "REJECTED" + assert repo.count() == 0 + + +def test_missing_mdm_is_rejected_and_not_stored(client): + test_client, repo = client + resp = _post(test_client, {"data": [_item(goodsMdmCode="")]}) + assert resp.status_code == 200 + result = resp.json()["results"][0] + assert result["resultCode"] == "REJECTED" + assert "goodsMdmCode" in result["message"] + assert repo.count() == 0 + + +def test_mixed_batch_still_http_200(client): + test_client, repo = client + _post(test_client, {"data": [_item(fid=1, omsId=1)]}) + resp = _post( + test_client, + { + "data": [ + _item(fid=1, omsId=1), + _item(fid=2, omsId=2), + _item(fid=3, omsId=3, orgCode=""), + ] + }, + ) + assert resp.status_code == 200 + codes = [r["resultCode"] for r in resp.json()["results"]] + assert codes == ["DUPLICATE", "ACCEPTED", "REJECTED"] + assert repo.count() == 2 + + +def test_repo_unavailable_returns_503(client): + test_client, _ = client + + class Boom: + def insert(self, record): + raise RepoUnavailable("数据库不可用") + + app.dependency_overrides[get_contract_repo] = lambda: Boom() + resp = _post(test_client, {"data": [_item()]}) + assert resp.status_code == 503 + assert resp.json()["code"] == 503 + assert resp.json()["results"] == []