feat: 接口2组装加购结算报文,接口3接收采购合同
从表2取 push_status=0 拼文档报文,同 omsId 不拆批。 POST /api/oms/v1/purchase-contracts 按对方 fid 幂等落表3。
This commit is contained in:
parent
0379f78fe2
commit
abf26b4259
18
AGENTS.md
18
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 请求路径里录合同。
|
||||
- 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。
|
||||
|
|
|
|||
|
|
@ -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 尚未接 |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
"""入站收件:拆信封、逐条校验、按 omsId 幂等落表。"""
|
||||
"""入站收件:拆信封、逐条校验。接口 1 按 omsId 幂等,接口 3 按 fid 幂等。"""
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""批量信封。缺 data、空数组、超过 100 条:整包 422,一条都不落。
|
||||
|
||||
单条字段错误不走这里,由 sales_goods 逐条 REJECTED,HTTP 仍 200。
|
||||
单条字段错误不走这里,由各收件函数逐条 REJECTED,HTTP 仍 200。
|
||||
"""
|
||||
|
||||
MAX_BATCH = 100
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
"""出站:从表组装订单中心报文。本期接口 2 只取数组装,不发 HTTP。"""
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"] == []
|
||||
Loading…
Reference in New Issue