Compare commits

..

No commits in common. "fa4df0f27bfbf771ae8ad2f8943ff747378b1eef" and "60c2d6d0f019ffbbdc3fbe7cd1f7f0feea7cd187" have entirely different histories.

18 changed files with 27 additions and 474 deletions

126
AGENTS.md
View File

@ -1,126 +0,0 @@
# AutoRpa
洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)和**选品班次**(`rpa_selection/`):收件落 Oracle,请求里不跑选品;选品作业另起进程捞 `deal_flag=0`。合同页面在 `rpa_contract/`;加购 `rpa_cart/`、返利 `rpa_rebate/` 仍是占位。根上不再有 `选品流程/`、`state_center/`、`rpa_yisaiqi/`。
智力用在**定落点、想清楚对错**;速度和 token 靠下面这张地图定点打开,禁止用扫仓代替思考。改对应了,反工和消耗都会少。
## 怎么干活
1. **先定落点**(不开文件):哪个函数、成功长什么样(哪条 ACCEPT / 哪个点名用例)、这次不碰什么。方向说不清就先问,不要靠翻目录找感觉。
2. **只在落点上想透**:规则、边界、会不会拿错号/写错状态。打开文件是为了验证落点,不是再想要不要改别处。
3. **薄验收就停**:控制台 `ACCEPT SUMMARY` 或一条点名用例。过了收工。
问「法子 / 怎么优化」先答方案;用户说「改 / 重构 / 收口」再动代码。一件事一个会话,换任务 `/new`。
## 项目地图
按问题打开一行,不要 `list_dir` 根目录。
| 问题 | 打开 | 验收 |
| --- | --- | --- |
| 接口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 段 |
| 选品规则 / 拆量 | `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` |
| 合同入参 / 客商草稿 | `rpa_contract/contract_draft.py` | `rpa_contract` 点名用例 |
| 节点入口 | `rpa_contract/contract_create_node.py`、`main.py` | 读到正式合同号 |
| 浏览器适配 | `rpa_contract/apa_browser_adapter.py` | `rpa_contract/tests/test_apa_browser_adapter.py::点名` |
| 文字验收 / 截图策略 | `rpa_contract/accept.py` | 只读 `ACCEPT.txt`,禁止读 png |
| 加购 / 支付回传 | `rpa_cart/README.md` | 占位,未实现 |
| 返利班 | `rpa_rebate/README.md` | 占位,未实现 |
| 四个口地图 | `rpa_oms/README.md`、根 `README.md` | — |
| 节点业务规则(点名一节) | `docs/RPA订单中心接口开发口径_20260930.md` | 禁止通读 `docs/` |
禁止打开:`node_modules/`、`chrome-profile/`、任何 png。Chrome 用户目录只在仓库外 `D:\work\rpa-chrome-profile`。Oracle 密码放本机 `rpa_oms/.env`(选品空缺时复用);商城账号放 `rpa_selection/.env`。都已 gitignore,不要写进仓库、README、示例 JSON,不要 `git add` / commit / push。
## 接口1 落点(订单中心)
订单中心四接口,本期只开放接口 1:销售商品对码数据接收。`POST /api/oms/v1/sales-goods`,鉴权 header `X-Oms-Token`(`OMS_INBOUND_TOKEN`)。落表 `rpa_sales_goods_mapping_data`,`deal_flag=0`,等选品作业捞;**请求里不跑选品**。
运行时只落 Oracle。单测用内存替身,不连库。服务端口 **8001**。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 信封 | `parse_envelope` | 缺 `data` / 空数组 / 超过 100 条:整包 422,一条都不落 |
| 逐条校验 | `receive_sales_goods` | 入参 camelCase,落表 snake_case;长度跟 VARCHAR2 |
| 鉴权 | `token_ok` | 未配 token 一律失败 |
| 落表 | `OracleSalesGoodsRepo.insert` | `fid` 由序列触发器生成,INSERT 不绑这一列 |
| 幂等 | 同 `omsId` | 已存在 → `DUPLICATE`,不更新、不重跑 |
| 同批冲突 | 同一 POST 里 `omsId` 再现 | 后一条 `CONFLICT`,只落第一条 |
| 字段不过 | `_parse_row` | `REJECTED`,不入库 |
| 混批 | `post_sales_goods` | 逐条有成功/重复/拒绝时 HTTP 仍 200,看 `results[].resultCode` |
`omsId` / `saleQty` / `planPurchaseQty` 必须是 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`)。
修接口1不要顺手改 `rpa_selection/`、`rpa_cart/`、`rpa_contract/`、`rpa_rebate/`。接口 2–4 未开放,不要先写。
## 选品落点
班次作业在 `rpa_selection/`,不在接口 1 请求里跑。从表 1 领 `deal_flag=0`,按**条形码**搜 1 号药城 / 药帮忙,两平台合并后按四级仓序贪心拆量,写入表 2。凑齐才落成功行;凑不齐不落部分成功。
口径:`docs/superpowers/specs/2026-10-02-selection-split-allocation-design.md`。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| 领取 | `claim_pending` | `deal_flag` 0→3;中途崩溃停在 3,改回 0 再跑 |
| 规则 | `allocate` / `run_selection` | 自营仓全称匹配、毛利 ≥ 4%、效期 ≥ 12 月;同仓只留低价;按仓序拆量 |
| 落表 | `build_result` + `save_result` | 成功每仓一行 `status=1`,合计=计划量;`push_status=0` |
| 库存不足 | `KIND_BUSINESS_FAIL` | 两边都搜到但合计不够:`deal_flag=2`,该 `omsId` 不重跑 |
| 对端故障 | `KIND_SYSTEM_FAIL` | 一边故障且剩下的凑不齐:可把 `deal_flag` 改回 0;禁止写成库存不足 |
湖南壹号、甘肃壹号云不在自营集合。不加购,不推接口 2。
验收:`cd rpa_selection` → `uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q`(杭州 3 + 广东 7 = 计划 10)。
## CONTRACT_CREATE 落点
页面脚本在 `rpa_contract/`。`run_contract_create_flow` 串起来;修一步只打开对应函数。从表 3 合单、写表 4 尚未接。
| 步 | 函数 | 要点 |
| --- | --- | --- |
| S02 打开 | `open_contract_system` | `http://contract-test.gzmpc.com:8080/` |
| S03 登录 | `login_contract_system` | `test002` / `1` |
| S04–S05 起草 | `enter_contract_create`、`select_contract_template` | 直达 `FORM_PAGE_URL`,不要点 SPA 菜单 |
| S06 签约方 | `fill_contract_signatory_info` | 客商 `DW0100046361` / `重庆亿昊药业有限公司` |
| S07 基础 | `fill_contract_basic_info` | 标题 `_expected_title` |
| S08 金额日期 | `fill_contract_amount_and_dates` | 签约日=订单日 |
| S09 货品 | `fill_contract_goods_lines` | 弹窗按操作码搜 |
| S10 付款 | `fill_contract_payment_info` | |
| S11 PDF | `upload_contract_document_if_needed` | 仅 YYC 传合并 PDF |
| S12 提交 | `submit_contract_for_approval` | 成功后**不要等详情页** |
| S13 合同号 | `read_contract_number` | 回「我的合同」,`fdId` > 标题+客户编码;禁止盲取最新一条 |
成功路径默认不截图(`RPA_SCREENSHOT=always` 才全截)。页面等待用 `wait_for_text` / `wait_for_element`,不要固定长睡。
## 返利中心及之后
`rpa_rebate/` 仍是占位。合同审批通过后才进这一段。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。开做时按合同同款:一个节点一份 `*_flow_steps.py`,验收只看 ACCEPT / 点名用例。
| 节点 | 谁做 | 要回传 | 页面要做什么(尚未拆函数) |
| --- | --- | --- | --- |
| `REBATE_HEADER` | RPA | `rebateOrderNo` | 登录返利中心 → 按合同编号查总单 → 填必填 → 保存拿总单号 |
| `REBATE_IMPORT` | RPA | 成功/失败行 | 下模板 → 按字段填 Excel → 上传 → 读导入结果 |
| `REBATE_SUBMIT` | RPA | `submitted` | 点提交送审 |
| `M5_CREATE_ORDER` | RPA 桌面端 | `m5OrderNo` | 录单后**停、不保存**。`M5_QC_MISSING` 必须识别准(直接转人工、不重试) |
| `M5_MANUAL_CONFIRM` | 采购 | — | 人在 M5 里保存签订 |
| `M5_RECEIPT_NOTICE` | 采购 | `receiptNoticeNo` | 人填收货通知单号,全链终点钥匙 |
不要连续轮询审批;返利段开始前大约 30 分钟查一次。
## 验收
- 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_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`。
- 单测只跑点名用例。禁止跑整份带页面等待的套件当回归。
## 不要
- 不要扫根目录,不要通读 `docs/`。需要背景就点名打开一节。
- 不要把 Oracle 密码写进仓库、README、示例 JSON。
- 不要在接口1 请求路径里跑选品。
- 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。

View File

@ -1,9 +0,0 @@
OMS_REPO=oracle
OMS_INBOUND_TOKEN=dev-oms-token
RPA_ORACLE_HOST=192.168.12.244
RPA_ORACLE_PORT=1521
RPA_ORACLE_SERVICE=rpa
RPA_ORACLE_USER=rpainit
RPA_ORACLE_PASSWORD=
RPA_ORACLE_THICK=1
RPA_ORACLE_CLIENT_LIB=C:\oracle\instantclient

View File

@ -16,13 +16,13 @@
- 路径:`POST /api/oms/v1/sales-goods` - 路径:`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`) - 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`)
- 请求里不跑选品。选品班次在 `rpa_selection/`,按条形码搜两平台、四级仓序拆量后写 `rpa_cart_settlement_goods_data` - 请求里不跑选品
运行时只落 Oracle。单测用内存替身,不连库:`uv run pytest -v`。 运行时只落 Oracle。单测用内存替身,不连库:`uv run pytest -v`。
## 真库 ## 真库
Oracle 11.2 需要 Instant Client(thick)。密码只放本机 `rpa_oms/.env`(已 gitignore),不要写进仓库、README、命令行历史。 Oracle 11.2 需要 Instant Client(thick)。密码只放环境变量。
```bash ```bash
cd rpa_oms cd rpa_oms
@ -50,9 +50,15 @@ PowerShell:
```powershell ```powershell
cd rpa_oms cd rpa_oms
uv sync --python 3.12 uv sync --python 3.12
Copy-Item -LiteralPath .env.example -Destination .env $env:OMS_INBOUND_TOKEN = "dev-oms-token"
# 只编辑本机 .env 的 RPA_ORACLE_PASSWORD,不要 git add .env $env:RPA_ORACLE_HOST = "192.168.12.244"
.\start.ps1 $env:RPA_ORACLE_PORT = "1521"
$env:RPA_ORACLE_SERVICE = "rpa"
$env:RPA_ORACLE_USER = "rpainit"
$env:RPA_ORACLE_PASSWORD = "这里填密码"
$env:RPA_ORACLE_THICK = "1"
$env:RPA_ORACLE_CLIENT_LIB = "C:\oracle\instantclient"
uv run uvicorn app.main:app --host 0.0.0.0 --port 8001
``` ```
先 `GET http://127.0.0.1:8001/health`,再把 `examples/sales_goods_batch.json` POST 两次: 先 `GET http://127.0.0.1:8001/health`,再把 `examples/sales_goods_batch.json` POST 两次:

View File

@ -11,22 +11,17 @@ from collections.abc import Callable
from .repo import DuplicateOmsId, RepoUnavailable from .repo import DuplicateOmsId, RepoUnavailable
# 接口入站无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。
_INSERT_SQL = """ _INSERT_SQL = """
INSERT INTO rpa_sales_goods_mapping_data ( INSERT INTO rpa_sales_goods_mapping_data (
oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty, oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty,
goods_id, goods_name, goods_spec, manufacturer, approval_no, unit, goods_id, goods_name, goods_spec, manufacturer, approval_no, unit,
customer_id, customer_name, org_code, goods_mdm_code, customer_mdm_code, customer_id, customer_name, org_code, goods_mdm_code, customer_mdm_code,
create_time, create_user_id, create_user_name, create_time, deal_flag
update_time, update_user_id, update_user_name,
deal_flag
) VALUES ( ) VALUES (
:oms_id, :bar_code, :order_date, :sale_price, :sale_qty, :plan_purchase_qty, :oms_id, :bar_code, :order_date, :sale_price, :sale_qty, :plan_purchase_qty,
:goods_id, :goods_name, :goods_spec, :manufacturer, :approval_no, :unit, :goods_id, :goods_name, :goods_spec, :manufacturer, :approval_no, :unit,
:customer_id, :customer_name, :org_code, :goods_mdm_code, :customer_mdm_code, :customer_id, :customer_name, :org_code, :goods_mdm_code, :customer_mdm_code,
SYSDATE, 0, '系统管理员', SYSDATE, 0
SYSDATE, 0, '系统管理员',
0
) )
""" """

View File

@ -1,24 +0,0 @@
"""从 rpa_oms/.env 读本机配置。该文件已 gitignore,不覆盖已有环境变量。"""
from __future__ import annotations
import os
from pathlib import Path
_ENV_PATH = Path(__file__).resolve().parents[1] / ".env"
def load_local_env(path: Path | None = None) -> None:
env_path = path if path is not None else _ENV_PATH
if not env_path.is_file():
return
for raw in env_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
if key and key not in os.environ:
os.environ[key] = value

View File

@ -13,9 +13,6 @@ from fastapi import Depends, FastAPI, Header
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from .auth import token_ok from .auth import token_ok
from .local_env import load_local_env
load_local_env()
from .inbound.envelope import EnvelopeError, parse_envelope from .inbound.envelope import EnvelopeError, parse_envelope
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

View File

@ -1,52 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $root
$envFile = Join-Path $root '.env'
if (-not (Test-Path -LiteralPath $envFile)) {
throw "缺少 $envFile 。复制 .env.example 为 .env,只在本机填 RPA_ORACLE_PASSWORD,不要 git add。"
}
foreach ($raw in Get-Content -LiteralPath $envFile) {
$line = $raw.Trim()
if ($line -eq '' -or $line.StartsWith('#')) {
continue
}
$eq = $line.IndexOf('=')
if ($eq -lt 1) {
continue
}
$key = $line.Substring(0, $eq).Trim()
$value = $line.Substring($eq + 1).Trim()
if ($value.Length -ge 2) {
$quote = $value[0]
if (($quote -eq '"' -or $quote -eq "'") -and $value[-1] -eq $quote) {
$value = $value.Substring(1, $value.Length - 2)
}
}
Set-Item -Path "Env:$key" -Value $value
}
if ([string]::IsNullOrWhiteSpace($env:RPA_ORACLE_PASSWORD)) {
throw "rpa_oms/.env 里 RPA_ORACLE_PASSWORD 为空。只写本机文件,不要提交。"
}
$venvPy = Join-Path $root '.venv\Scripts\python.exe'
if (Test-Path -LiteralPath $venvPy) {
& $venvPy @('-m', 'uvicorn', 'app.main:app', '--host', '0.0.0.0', '--port', '8001')
if ($LASTEXITCODE -ne 0) {
throw "uvicorn 退出码 $LASTEXITCODE"
}
return
}
$uv = Get-Command uv -ErrorAction SilentlyContinue
if ($null -eq $uv) {
throw "找不到 uv,也没有 rpa_oms\.venv\Scripts\python.exe"
}
& $uv.Source @('run', '--python', '3.12', 'uvicorn', 'app.main:app', '--host', '0.0.0.0', '--port', '8001')
if ($LASTEXITCODE -ne 0) {
throw "uvicorn 退出码 $LASTEXITCODE"
}

View File

@ -1,19 +0,0 @@
"""本机 .env 只补缺,不覆盖已有环境变量。"""
from __future__ import annotations
import os
from app.local_env import load_local_env
def test_load_local_env_fills_missing_and_keeps_existing(tmp_path, monkeypatch):
monkeypatch.setenv("OMS_REPO", "memory")
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
env_file = tmp_path / ".env"
env_file.write_text(
"OMS_REPO=oracle\nRPA_ORACLE_PASSWORD=not-for-git\n# comment\n\n",
encoding="utf-8",
)
load_local_env(env_file)
assert os.environ["OMS_REPO"] == "memory"
assert os.environ["RPA_ORACLE_PASSWORD"] == "not-for-git"

View File

@ -78,44 +78,3 @@ def test_insert_unique_violation_is_duplicate():
repo = OracleSalesGoodsRepo(connect=lambda: FakeConn()) repo = OracleSalesGoodsRepo(connect=lambda: FakeConn())
with pytest.raises(DuplicateOmsId): with pytest.raises(DuplicateOmsId):
repo.insert(_record()) repo.insert(_record())
def test_insert_sql_includes_select_job_columns():
from app.inbound.oracle_repo import _BIND_KEYS, _INSERT_SQL
sql = " ".join(_INSERT_SQL.split())
for column in (
"oms_id",
"bar_code",
"sale_price",
"plan_purchase_qty",
"goods_name",
"goods_spec",
"manufacturer",
"approval_no",
"org_code",
"deal_flag",
):
assert column in sql
for key in (
"oms_id",
"bar_code",
"sale_price",
"plan_purchase_qty",
"org_code",
):
assert key in _BIND_KEYS
assert "deal_flag" in sql
def test_insert_sql_sets_system_auditor():
from app.inbound.oracle_repo import _INSERT_SQL
sql = " ".join(_INSERT_SQL.split())
assert "create_user_id" in sql
assert "create_user_name" in sql
assert "update_time" in sql
assert "update_user_id" in sql
assert "update_user_name" in sql
assert "SYSDATE, 0, '系统管理员'" in sql
assert sql.count("SYSDATE") == 2

View File

@ -9,4 +9,4 @@ RPA_ORACLE_SERVICE=rpa
RPA_ORACLE_USER= RPA_ORACLE_USER=
RPA_ORACLE_PASSWORD= RPA_ORACLE_PASSWORD=
RPA_ORACLE_THICK=1 RPA_ORACLE_THICK=1
RPA_ORACLE_CLIENT_LIB=C:\oracle\instantclient # RPA_ORACLE_CLIENT_LIB=

View File

@ -38,7 +38,7 @@ uv sync --python 3.12
uv run playwright install chromium uv run playwright install chromium
``` ```
Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / `USER` / `PASSWORD`,11.2 用 thick。Windows 还要 `RPA_ORACLE_CLIENT_LIB`。选品 `.env` 里 Oracle 密码留空时,会补 `rpa_oms/.env` 里已有的值。 Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / `USER` / `PASSWORD`,11.2 用 thick。
商城账号(任选其一,不要提交真实密码): 商城账号(任选其一,不要提交真实密码):
@ -56,16 +56,6 @@ uv run python main.py --limit 5
uv run python main.py --platform yaoex uv run python main.py --platform yaoex
``` ```
Windows:
```powershell
cd rpa_selection
uv sync --python 3.12
Copy-Item -LiteralPath .env.example -Destination .env
# 商城账号写本机 .env;Oracle 也可只留在 rpa_oms/.env
.\start.ps1 --limit 5
```
`--limit` 只领取本班前 N 条 `deal_flag=0`。跑的过程中新进入的留给下一班。 `--limit` 只领取本班前 N 条 `deal_flag=0`。跑的过程中新进入的留给下一班。
中途崩溃会停在 `deal_flag=3`。改回 `0` 再跑。系统故障不能靠订单中心重推同一 `oms_id`(接口 1 幂等),同样把 `deal_flag` 改回 `0`。 中途崩溃会停在 `deal_flag=3`。改回 `0` 再跑。系统故障不能靠订单中心重推同一 `oms_id`(接口 1 幂等),同样把 `deal_flag` 改回 `0`。

View File

@ -2,13 +2,10 @@
from __future__ import annotations from __future__ import annotations
import os import os
from pathlib import Path
DEAL_USER_NAME = "rpa-select" DEAL_USER_NAME = "rpa-select"
_client_ready = False _client_ready = False
_PROJECT_ROOT = Path(__file__).resolve().parent
_OMS_ENV = _PROJECT_ROOT.parent / "rpa_oms" / ".env"
class RepoUnavailable(Exception): class RepoUnavailable(Exception):
@ -17,32 +14,6 @@ class RepoUnavailable(Exception):
super().__init__(message) super().__init__(message)
def load_runtime_env(paths: list[Path] | None = None) -> None:
"""先读选品 .env,空缺再补 rpa_oms/.env。不覆盖已有非空环境变量。"""
env_paths = paths if paths is not None else [_PROJECT_ROOT / ".env", _OMS_ENV]
for path in env_paths:
_fill_env(path)
def _fill_env(path: Path) -> None:
if not path.is_file():
return
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
value = value[1:-1]
if not key or not value:
continue
current = os.environ.get(key, "")
if not current:
os.environ[key] = value
def oracle_dsn() -> tuple[str, str, str]: def oracle_dsn() -> tuple[str, str, str]:
user = os.getenv("RPA_ORACLE_USER", "") user = os.getenv("RPA_ORACLE_USER", "")
password = os.getenv("RPA_ORACLE_PASSWORD", "") password = os.getenv("RPA_ORACLE_PASSWORD", "")

View File

@ -9,15 +9,17 @@ import requests
from auth_login import ( from auth_login import (
DEFAULT_YAOEX_AUTH_PATH, DEFAULT_YAOEX_AUTH_PATH,
DEFAULT_YBM_COOKIE_PATH, DEFAULT_YBM_COOKIE_PATH,
PROJECT_ROOT,
BrowserRuntime, BrowserRuntime,
load_cookie_file, load_cookie_file,
load_dotenv,
load_yaoex_auth_file, load_yaoex_auth_file,
login_yaoex, login_yaoex,
login_ybm, login_ybm,
save_cookie_file, save_cookie_file,
save_yaoex_auth, save_yaoex_auth,
) )
from db import RepoUnavailable, load_runtime_env from db import RepoUnavailable
from job import run_batch from job import run_batch
from mall_config import YAOEX_MALL_NAME, YBM_MALL_NAME from mall_config import YAOEX_MALL_NAME, YBM_MALL_NAME
from repo import OracleSelectRepo from repo import OracleSelectRepo
@ -80,7 +82,7 @@ def _platforms(choice: str) -> list[str]:
def main(argv: list[str] | None = None, repo=None) -> None: def main(argv: list[str] | None = None, repo=None) -> None:
args = parse_args(argv) args = parse_args(argv)
load_runtime_env() load_dotenv(PROJECT_ROOT / ".env")
flow_start = time.perf_counter() flow_start = time.perf_counter()
if repo is None: if repo is None:
try: try:

View File

@ -48,7 +48,7 @@ def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectRe
return SelectResult( return SelectResult(
oms_id=purchase.oms_id, oms_id=purchase.oms_id,
deal_flag=DEAL_OK, deal_flag=DEAL_OK,
deal_result=_clip_deal_result(f"选品成功:{'、'.join(malls)}"), deal_result=_clip(f"选品成功:{'、'.join(malls)}", DEAL_RESULT_MAX),
cart_rows=rows, cart_rows=rows,
) )
@ -59,11 +59,11 @@ def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectRe
for mall in malls for mall in malls
] ]
detail = "; ".join(selection.mall_errors.values()) or "未知错误" detail = "; ".join(selection.mall_errors.values()) or "未知错误"
deal_result = _clip_deal_result(f"系统故障:{detail}") deal_result = _clip(f"系统故障:{detail}", DEAL_RESULT_MAX)
else: else:
reason = _business_fail_reason(selection.reason) reason = _business_fail_reason(selection.reason)
rows = [_fail_row(purchase, mall, reason) for mall in malls] rows = [_fail_row(purchase, mall, reason) for mall in malls]
deal_result = _clip_deal_result(reason) deal_result = _clip(reason, DEAL_RESULT_MAX)
_set_multi_flag(rows) _set_multi_flag(rows)
return SelectResult( return SelectResult(
@ -184,25 +184,3 @@ def _clip(text: str, max_len: int) -> str:
if len(text) <= max_len: if len(text) <= max_len:
return text return text
return text[: max_len - 1] + "…" return text[: max_len - 1] + "…"
def _clip_deal_result(text: str) -> str:
"""表 1 deal_result 是 VARCHAR2(200 BYTE) + ZHS16GBK,按字节截。"""
return _clip_encoded(text, DEAL_RESULT_MAX, "gbk")
def _clip_encoded(text: str, max_bytes: int, encoding: str) -> str:
text = text.strip()
raw = text.encode(encoding, errors="replace")
if len(raw) <= max_bytes:
return text
ellipsis = "…"
ell = ellipsis.encode(encoding, errors="replace")
budget = max(0, max_bytes - len(ell))
cut = raw[:budget]
while cut:
try:
return cut.decode(encoding) + ellipsis
except UnicodeDecodeError:
cut = cut[:-1]
return ellipsis if len(ell) <= max_bytes else ""

View File

@ -39,20 +39,15 @@ SET deal_flag = 3,
WHERE fid = :fid AND deal_flag = 0 WHERE fid = :fid AND deal_flag = 0
""" """
# 选品无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。
_INSERT_CART_SQL = """ _INSERT_CART_SQL = """
INSERT INTO rpa_cart_settlement_goods_data ( INSERT INTO rpa_cart_settlement_goods_data (
operate_type, oms_id, platform_type, supplier_name, bar_code, operate_type, oms_id, platform_type, supplier_name, bar_code,
purchase_price, purchase_qty, purchase_total, status, fail_reason, purchase_price, purchase_qty, purchase_total, status, fail_reason,
push_status, org_code, multi_platform_flag, push_status, create_time, org_code, multi_platform_flag, create_user_name
create_time, create_user_id, create_user_name,
update_time, update_user_id, update_user_name
) VALUES ( ) VALUES (
:operate_type, :oms_id, :platform_type, :supplier_name, :bar_code, :operate_type, :oms_id, :platform_type, :supplier_name, :bar_code,
:purchase_price, :purchase_qty, :purchase_total, :status, :fail_reason, :purchase_price, :purchase_qty, :purchase_total, :status, :fail_reason,
:push_status, :org_code, :multi_platform_flag, :push_status, SYSDATE, :org_code, :multi_platform_flag, :create_user_name
SYSDATE, 0, '系统管理员',
SYSDATE, 0, '系统管理员'
) )
""" """
@ -178,6 +173,7 @@ class OracleSelectRepo:
with conn.cursor() as cur: with conn.cursor() as cur:
for row in cart_rows: for row in cart_rows:
payload = {key: row.get(key) for key in _CART_BIND_KEYS} payload = {key: row.get(key) for key in _CART_BIND_KEYS}
payload["create_user_name"] = DEAL_USER_NAME
cur.execute(_INSERT_CART_SQL, payload) cur.execute(_INSERT_CART_SQL, payload)
cur.execute( cur.execute(
_COMPLETE_SQL, _COMPLETE_SQL,

View File

@ -1,63 +0,0 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location -LiteralPath $root
function Import-DotEnv {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
if (-not (Test-Path -LiteralPath $Path)) {
return
}
foreach ($raw in Get-Content -LiteralPath $Path) {
$line = $raw.Trim()
if ($line -eq '' -or $line.StartsWith('#')) {
continue
}
$eq = $line.IndexOf('=')
if ($eq -lt 1) {
continue
}
$key = $line.Substring(0, $eq).Trim()
$value = $line.Substring($eq + 1).Trim()
if ($value.Length -ge 2) {
$quote = $value[0]
if (($quote -eq '"' -or $quote -eq "'") -and $value[-1] -eq $quote) {
$value = $value.Substring(1, $value.Length - 2)
}
}
if ($key -eq '' -or $value -eq '') {
continue
}
$current = [Environment]::GetEnvironmentVariable($key, 'Process')
if ([string]::IsNullOrWhiteSpace($current)) {
Set-Item -Path "Env:$key" -Value $value
}
}
}
Import-DotEnv -Path (Join-Path $root '.env')
Import-DotEnv -Path (Join-Path (Split-Path -Parent $root) 'rpa_oms\.env')
$venvPy = Join-Path $root '.venv\Scripts\python.exe'
$pyArgs = @('main.py') + @($args)
if (Test-Path -LiteralPath $venvPy) {
& $venvPy @pyArgs
if ($LASTEXITCODE -ne 0) {
throw "选品退出码 $LASTEXITCODE"
}
return
}
$uv = Get-Command uv -ErrorAction SilentlyContinue
if ($null -eq $uv) {
throw "找不到 uv,也没有 rpa_selection\.venv\Scripts\python.exe"
}
$uvArgs = @('run', '--python', '3.12', 'python') + $pyArgs
& $uv.Source @uvArgs
if ($LASTEXITCODE -ne 0) {
throw "选品退出码 $LASTEXITCODE"
}

View File

@ -1,33 +1,11 @@
from __future__ import annotations from __future__ import annotations
import os
import pytest import pytest
from db import RepoUnavailable, load_runtime_env from db import RepoUnavailable
from repo import OracleSelectRepo from repo import OracleSelectRepo
def test_load_runtime_env_fills_blank_from_oms_env(tmp_path, monkeypatch):
monkeypatch.delenv("RPA_ORACLE_HOST", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
monkeypatch.setenv("RPA_ORACLE_USER", "keep-me")
select_env = tmp_path / "select.env"
oms_env = tmp_path / "oms.env"
select_env.write_text(
"RPA_ORACLE_HOST=192.168.12.244\nRPA_ORACLE_PASSWORD=\nRPA_ORACLE_USER=from-select\n",
encoding="utf-8",
)
oms_env.write_text(
"RPA_ORACLE_PASSWORD=not-for-git\nRPA_ORACLE_USER=from-oms\n",
encoding="utf-8",
)
load_runtime_env([select_env, oms_env])
assert os.environ["RPA_ORACLE_HOST"] == "192.168.12.244"
assert os.environ["RPA_ORACLE_PASSWORD"] == "not-for-git"
assert os.environ["RPA_ORACLE_USER"] == "keep-me"
def test_from_env_without_user_raises(monkeypatch): def test_from_env_without_user_raises(monkeypatch):
monkeypatch.delenv("RPA_ORACLE_USER", raising=False) monkeypatch.delenv("RPA_ORACLE_USER", raising=False)
monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False) monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False)
@ -90,16 +68,7 @@ def test_save_result_inserts_cart_then_updates_mapping():
], ],
) )
assert conn.committed is True assert conn.committed is True
sql = " ".join(executed[0][0].split()) assert "rpa_cart_settlement_goods_data" in executed[0][0].lower()
assert "rpa_cart_settlement_goods_data" in sql.lower()
assert "create_user_id" in sql
assert "create_user_name" in sql
assert "update_time" in sql
assert "update_user_id" in sql
assert "update_user_name" in sql
assert "SYSDATE, 0, '系统管理员'" in sql
assert sql.count("SYSDATE") == 2
assert executed[0][1]["oms_id"] == 2026100201 assert executed[0][1]["oms_id"] == 2026100201
assert "create_user_name" not in executed[0][1]
assert "rpa_sales_goods_mapping_data" in executed[1][0].lower() assert "rpa_sales_goods_mapping_data" in executed[1][0].lower()
assert executed[1][1]["deal_flag"] == 1 assert executed[1][1]["deal_flag"] == 1

View File

@ -146,23 +146,6 @@ def test_margin_fail_text_is_4_percent():
assert "毛利不足 4%" in (result.cart_rows[0]["fail_reason"] or "") assert "毛利不足 4%" in (result.cart_rows[0]["fail_reason"] or "")
def test_system_fault_deal_result_fits_varchar2_200_byte():
blob = "药帮忙接口返回非JSON" + ("<html>probe" * 40)
result = build_result(
_purchase(),
SelectionResult(
kind=KIND_SYSTEM_FAIL,
reason="SYSTEM_FAULT",
allocations=[],
rejected=[],
malls=["1号药城", "药帮忙"],
mall_errors={"药帮忙": f"QUERY_FAILED:{blob}"},
),
)
assert len(result.deal_result.encode("gbk")) <= 200
assert result.deal_result.startswith("系统故障")
def test_system_fault_uses_per_mall_errors(): def test_system_fault_uses_per_mall_errors():
result = build_result( result = build_result(
_purchase(), _purchase(),