feat: 接口2组装加购结算报文,接口3接收采购合同

从表2取 push_status=0 拼文档报文,同 omsId 不拆批。
POST /api/oms/v1/purchase-contracts 按对方 fid 幂等落表3。
This commit is contained in:
Yuuuu 2026-10-04 23:57:17 +08:00
parent 0379f78fe2
commit abf26b4259
21 changed files with 1134 additions and 26 deletions

View File

@ -21,6 +21,9 @@
| 接口1 收件契约 | `rpa_oms/app/inbound/sales_goods.py`、`envelope.py` | `rpa_oms/tests/test_sales_goods_receive.py::点名` | | 接口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 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 段 | | 接口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/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_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` | | 合同页面步骤 | `rpa_contract/contract_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt` |
@ -37,7 +40,7 @@
## 接口1 落点(订单中心) ## 接口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**。 运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。
@ -52,11 +55,15 @@
| 字段不过 | `_parse_row` | `REJECTED`,不入库 | | 字段不过 | `_parse_row` | `REJECTED`,不入库 |
| 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` | | 混批 | `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`;禁止为「显得全面」跑整包再去连真库。 - 接口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`。 - 选品:点名 `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`。 - 合同页:只读控制台 `ACCEPT` 或 `rpa_contract/screenshots/` 下 `ACCEPT.txt`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。 - 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
@ -123,4 +132,5 @@
- 不要扫根目录,不要通读 `docs/`。需要背景就点名打开一节。 - 不要扫根目录,不要通读 `docs/`。需要背景就点名打开一节。
- 不要把 Oracle 密码写进仓库、README、示例 JSON。 - 不要把 Oracle 密码写进仓库、README、示例 JSON。
- 不要在接口1 请求路径里跑选品。 - 不要在接口1 请求路径里跑选品。
- 不要在接口3 请求路径里录合同。
- 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。 - 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。

View File

@ -8,9 +8,9 @@
| 口 | 方向 | 底表 | 状态 | | 口 | 方向 | 底表 | 状态 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 进行中(`rpa_oms/`) | | 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | `POST /api/oms/v1/sales-goods` |
| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 选品已落表,push_status=0,路径仍空 | | 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 取 `push_status=0` 组装报文;路径仍空,不发 HTTP |
| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 复用接口 1 接收壳 | | 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | `POST /api/oms/v1/purchase-contracts` |
| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 路径空,先造数 | | 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 路径空,先造数 |
作业互不串联:选品读表 1,合同/返利读表 3。HTTP 收件不同步跑 RPA。 作业互不串联:选品读表 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_selection/` | 选品班。读表 1 `deal_flag=0`,双平台选品,写表 2 |
| `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 | | `rpa_cart/` | 加购(未做)+ 1 号药城支付落表。表 2 → 口 2 仍未推 |
| `rpa_contract/` | 合同班。页面录入在本包;表 3 合单、接口 4 尚未接 | | `rpa_contract/` | 合同班。页面录入在本包;表 3 合单、接口 4 尚未接 |

View File

@ -6,19 +6,28 @@
| 口 | 方向 | 底表 | 代码 | | 口 | 方向 | 底表 | 代码 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | 已有 `app/inbound/sales_goods.py`,`POST /api/oms/v1/sales-goods` | | 1 | 订单中心 → 本方 | `rpa_sales_goods_mapping_data` | `POST /api/oms/v1/sales-goods` |
| 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | 未做。将来 `app/outbound/`,由 `rpa_cart` 调用 | | 2 | 本方 → 订单中心 | `rpa_cart_settlement_goods_data` | `app/outbound/cart_settlement.py` 取 `push_status=0` 组装报文。路径仍空,不发 HTTP |
| 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | 未做。将来 `app/inbound/`,复用接口 1 信封/鉴权 | | 3 | 订单中心 → 本方 | `rpa_purchase_contract_data` | `POST /api/oms/v1/purchase-contracts`,按对方 `fid` 幂等 |
| 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 未做。将来 `app/outbound/`,由合同/返利班调用 | | 4 | 本方 → 订单中心 | `rpa_contract_agreement_result` | 未做。将来由合同/返利班调用 |
本期只开放接口 1: 入站(1、3):
- 路径:`POST /api/oms/v1/sales-goods`
- 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN` - 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN`
- 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`) - 信封:`{"data":[...]}`,一批最多 100 条
- 请求里不跑选品。选品班次在 `rpa_selection/`,按条形码搜两平台、四级仓序拆量后写 `rpa_cart_settlement_goods_data` - 接口 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" ` -H "X-Oms-Token: dev-oms-token" `
--data-binary "@examples/sales_goods_batch.json" --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"
```

View File

@ -1 +1 @@
"""入站收件:拆信封、逐条校验、按 omsId 幂等落表。""" """入站收件:拆信封、逐条校验。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。"""

View File

@ -1,6 +1,6 @@
"""批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。 """批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。
单条字段错误不走这里,由 sales_goods 逐条 REJECTED,HTTP 仍 200。 单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
""" """
MAX_BATCH = 100 MAX_BATCH = 100

View File

@ -4,7 +4,7 @@
""" """
from __future__ import annotations from __future__ import annotations
from .repo import DuplicateOmsId from .repo import DuplicateFid, DuplicateOmsId
class MemorySalesGoodsRepo: class MemorySalesGoodsRepo:
@ -23,3 +23,21 @@ class MemorySalesGoodsRepo:
def count(self) -> int: def count(self) -> int:
return len(self._rows) 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)

View File

@ -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()

View File

@ -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}

View File

@ -1,6 +1,7 @@
"""收件仓库契约。 """收件仓库契约。
DuplicateOmsId:oms_id 已存在,调用方回 DUPLICATE,不更新。 DuplicateOmsId:oms_id 已存在,调用方回 DUPLICATE,不更新。
DuplicateFid:接口 3 来源 fid 已存在,调用方回 DUPLICATE,不更新。
RepoUnavailable:连不上库或未配置,整包 HTTP 503,一条都不落。 RepoUnavailable:连不上库或未配置,整包 HTTP 503,一条都不落。
""" """
@ -10,6 +11,11 @@ class DuplicateOmsId(Exception):
self.oms_id = oms_id self.oms_id = oms_id
class DuplicateFid(Exception):
def __init__(self, fid: int):
self.fid = fid
class RepoUnavailable(Exception): class RepoUnavailable(Exception):
def __init__(self, message: str = "数据库不可用"): def __init__(self, message: str = "数据库不可用"):
self.message = message self.message = message

View File

@ -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 鉴权:X-Oms-Token
落表:rpa_sales_goods_mapping_data,deal_flag=0 请求里不跑选品、不录合同。
请求里不跑选品。
""" """
from __future__ import annotations from __future__ import annotations
@ -17,6 +18,7 @@ from .local_env import load_local_env
load_local_env() load_local_env()
from .inbound.envelope import EnvelopeError, parse_envelope from .inbound.envelope import EnvelopeError, parse_envelope
from .inbound.purchase_contract import receive_purchase_contracts
from .inbound.repo import RepoUnavailable from .inbound.repo import RepoUnavailable
from .inbound.sales_goods import receive_sales_goods from .inbound.sales_goods import receive_sales_goods
@ -30,6 +32,12 @@ def get_repo():
return OracleSalesGoodsRepo.from_env() return OracleSalesGoodsRepo.from_env()
def get_contract_repo():
from .inbound.oracle_contract_repo import OraclePurchaseContractRepo
return OraclePurchaseContractRepo.from_env()
@app.get("/health") @app.get("/health")
def health(): def health():
return {"ok": True} return {"ok": True}
@ -41,6 +49,27 @@ def post_sales_goods(
x_oms_token: Annotated[str | None, Header()] = None, x_oms_token: Annotated[str | None, Header()] = None,
repo=Depends(get_repo), 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): if not token_ok(x_oms_token):
return JSONResponse( return JSONResponse(
status_code=401, status_code=401,
@ -54,7 +83,7 @@ def post_sales_goods(
content={"code": 422, "msg": exc.message, "results": []}, content={"code": 422, "msg": exc.message, "results": []},
) )
try: try:
results = receive_sales_goods(rows, repo) results = receive(rows)
except RepoUnavailable as exc: except RepoUnavailable as exc:
return JSONResponse( return JSONResponse(
status_code=503, status_code=503,

View File

@ -0,0 +1 @@
"""出站:从表组装订单中心报文。本期接口 2 只取数组装,不发 HTTP。"""

View File

@ -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}"

View File

@ -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]

View File

@ -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()

View File

@ -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"
}
]
}

View File

@ -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"
}
]
}

View File

@ -1,7 +1,7 @@
[project] [project]
name = "rpa-oms" name = "rpa-oms"
version = "0.1.0" version = "0.1.0"
description = "洁达 RPA × 订单中心接口。先做接口 1 收件。" description = "洁达 RPA × 订单中心接口。接口 1、3 收件;接口 2 组装报文。"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.115.0", "fastapi>=0.115.0",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"] == []