From 7e6f7cc8fb2aa981e81fc061a91a3ce3e7b03f61 Mon Sep 17 00:00:00 2001 From: 34BFF76918A9449ABAB323149DDCEA73 Date: Fri, 2 Oct 2026 22:46:22 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=AC=E6=9C=BA.env=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=EF=BC=8C=E5=85=A5=E7=AB=99=E4=B8=8E=E9=80=89=E5=93=81?= =?UTF-8?q?=E8=90=BD=E8=A1=A8=E8=A1=A5=E5=AE=A1=E8=AE=A1=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 接口1/选品 INSERT 写入创建人更新人 0/系统管理员 - 选品 deal_result 按 GBK 字节截到 VARCHAR2(200) - Windows 从本机 .env 启动;选品空缺时复用 rpa_oms/.env --- .gitignore | 3 + AGENTS.md | 131 +++++++++++++++++++++++++++++ rpa_oms/.env.example | 9 ++ rpa_oms/README.md | 17 ++-- rpa_oms/app/inbound/oracle_repo.py | 9 +- rpa_oms/app/local_env.py | 24 ++++++ rpa_oms/app/main.py | 3 + rpa_oms/start.ps1 | 52 ++++++++++++ rpa_oms/tests/test_local_env.py | 19 +++++ rpa_oms/tests/test_oracle_repo.py | 41 +++++++++ 选品流程/.env.example | 2 +- 选品流程/README.md | 12 ++- 选品流程/db.py | 29 +++++++ 选品流程/main.py | 6 +- 选品流程/persist.py | 28 +++++- 选品流程/repo.py | 10 ++- 选品流程/start.ps1 | 63 ++++++++++++++ 选品流程/tests/test_oracle_repo.py | 35 +++++++- 选品流程/tests/test_persist.py | 17 ++++ 19 files changed, 482 insertions(+), 28 deletions(-) create mode 100644 AGENTS.md create mode 100644 rpa_oms/.env.example create mode 100644 rpa_oms/app/local_env.py create mode 100644 rpa_oms/start.ps1 create mode 100644 rpa_oms/tests/test_local_env.py create mode 100644 选品流程/start.ps1 diff --git a/.gitignore b/.gitignore index dc26bc1..868fc3a 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,9 @@ state_center/fixtures/m5/connection.local.json .env.* !.env.example +# Mall login cache (tokens / cookies) +**/data/.auth/ + # Media (keep docs, drop recordings and extracted frames) *.mp4 video_frames_v2/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5732f9c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,131 @@ +# AutoRpa + +洁达 RPA。当前主线是**订单中心入站**(`rpa_oms`)和**选品班次**(`选品流程/`):收件落 Oracle,请求里不跑选品;选品作业另起进程捞 `deal_flag=0`。采购主链(状态中心编排、艺赛旗/Selenium 点合同系统)仍在仓库里,按问题打开对应行。 + +智力用在**定落点、想清楚对错**;速度和 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 段 | +| 选品规则 / 拆量 | `选品流程/rules.py`、`pipeline.py` | `选品流程/tests/test_pipeline.py::test_split_across_malls_is_success` | +| 选品班次落表 | `选品流程/job.py`、`persist.py`、`repo.py` | `选品流程/tests/test_job.py::test_job_searches_barcode_not_approval_no` | +| 合同页面步骤 S02–S13 | `rpa_yisaiqi/contract_flow_steps.py` 里对应函数 | `ACCEPT SUMMARY` 或 `rpa_yisaiqi/screenshots/CONTRACT_CREATE/ACCEPT.txt` | +| 合同入参 / 客商草稿 | `rpa_yisaiqi/contract_draft.py` | `state_center/tests/test_yisaiqi_contract_draft.py::点名` | +| 节点入口、回调 complete | `rpa_yisaiqi/contract_create_node.py`、`callback_client.py` | 回调 JSON 含 `contractSystemNo` | +| 浏览器适配 | `rpa_yisaiqi/apa_browser_adapter.py` | `rpa_yisaiqi/tests/test_apa_browser_adapter.py::点名` | +| 文字验收 / 截图策略 | `rpa_yisaiqi/runtime/accept.py` | 只读 `ACCEPT.txt`,禁止读 png | +| 艺赛旗画布代码块 | `rpa_yisaiqi/build_multinode_seq.py`(源)→ `studio/`(编译产物) | 未说画布不要改这两处 | +| 跨节点共享状态 | `rpa_yisaiqi/_flow_state.py` | — | +| 状态机 / API | `state_center/app/main.py`、`web_state_machine.py`、`match_engine.py` | `state_center/tests/test_web_state_machine.py::点名` 等 | +| 前端看板 | `state_center_web/src/stateMachine.ts`、`App.tsx` | 浏览器点一遍相关页 | +| 一键启动(状态中心) | `scripts/dev.py start` | 状态中心 :8000,前端 :5173 | +| 节点业务规则(点名一节) | `资料/采购主链RPA一期实施与验收规范.md` | 禁止通读 `资料/`、`docs/` | + +禁止打开:`node_modules/`、`chrome-profile/`、任何 png。Chrome 用户目录只在仓库外 `D:\work\rpa-chrome-profile`。Oracle 密码放本机 `rpa_oms/.env`(选品空缺时复用);商城账号放 `选品流程/.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`,等选品作业捞;**请求里不跑选品**。 + +默认 `OMS_REPO=oracle`,本机连不上库用 `OMS_REPO=memory`。服务端口 **8001**,不走 `scripts/dev.py`。 + +| 步 | 函数 | 要点 | +| --- | --- | --- | +| 信封 | `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不要顺手改 `state_center/`、`rpa_yisaiqi/`、`选品流程/`。接口 2–4 未开放,不要先写。 + +## 选品落点 + +班次作业在 `选品流程/`,不在接口 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 选品流程` → `uv run pytest tests/test_pipeline.py::test_split_across_malls_is_success -q`(杭州 3 + 广东 7 = 计划 10)。 + +## CONTRACT_CREATE 落点 + +`run_contract_create_flow` 串起来;修一步只打开对应函数。 + +| 步 | 函数 | 要点 | +| --- | --- | --- | +| 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`,不要固定长睡。 + +## 返利中心及之后 + +合同审批通过后才进这一段。三把钥匙:`contractSystemNo` → `rebateOrderNo` → `receiptNoticeNo`。页面步骤未建,联调 `rpa_yisaiqi/runners/mock.py`。开做时按合同同款:一个节点一份 `*_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` | 人填收货通知单号,全链终点钥匙 | +| `ARCHIVE_JOB` | 状态中心 | — | 无页面 | + +调度:`rpa_yisaiqi/agent.py`。人节点在 `HUMAN_NODES`。不要连续轮询审批;返利段开始前的 `CONTRACT_APPROVE_WAIT` 约 30 分钟查一次。 + +## 验收 + +- 接口1:点名 `rpa_oms/tests/test_sales_goods_receive.py` 或 `test_oracle_repo.py`;禁止为「显得全面」跑整包再去连真库。 +- 选品:点名 `选品流程/tests/test_pipeline.py::test_split_across_malls_is_success` 或 `test_rules.py::test_margin_exactly_4_percent_passes`。 +- 合同页:只读控制台 `ACCEPT` 或 `screenshots/<节点>/ACCEPT.txt`。 +- 单测只跑点名用例,例如 `state_center/tests/test_yisaiqi_contract_flow_steps.py::test_read_contract_number_falls_back_to_my_contracts_list`。禁止跑整份该文件。 +- 该文件已打掉 `time.sleep`;不要再拿全量带睡的套件当回归。 + +## 不要 + +- 不要扫根目录,不要通读 `资料/`、`docs/`。需要背景就点名打开一节。 +- 不要顺手改 `studio/`、`build_multinode_seq.py`(画布任务除外)。 +- 不要把 Oracle 密码写进仓库、README、示例 JSON。 +- 不要在接口1 请求路径里跑选品。 +- 不要为「显得全面」多开文件、重跑、看图。落点外的改动会制造反工。 diff --git a/rpa_oms/.env.example b/rpa_oms/.env.example new file mode 100644 index 0000000..ad1e271 --- /dev/null +++ b/rpa_oms/.env.example @@ -0,0 +1,9 @@ +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 diff --git a/rpa_oms/README.md b/rpa_oms/README.md index e9578b0..ebf4606 100644 --- a/rpa_oms/README.md +++ b/rpa_oms/README.md @@ -5,7 +5,7 @@ - 路径:`POST /api/oms/v1/sales-goods` - 鉴权:header `X-Oms-Token`,环境变量 `OMS_INBOUND_TOKEN` - 落表:`rpa_sales_goods_mapping_data`(`deal_flag=0`) -- 请求里不跑选品 +- 请求里不跑选品。选品班次在仓库根目录 `选品流程/`,按条形码搜两平台、四级仓序拆量后写 `rpa_cart_settlement_goods_data` ## 本地(内存仓库) @@ -28,7 +28,7 @@ curl -sS http://127.0.0.1:8001/api/oms/v1/sales-goods \ ## 真库 -Oracle 11.2 需要 Instant Client(thick)。密码只放环境变量。 +Oracle 11.2 需要 Instant Client(thick)。密码只放本机 `rpa_oms/.env`(已 gitignore),不要写进仓库、README、命令行历史。 ```bash export OMS_REPO=oracle @@ -54,16 +54,9 @@ PowerShell: ```powershell cd rpa_oms uv sync --python 3.12 -$env:OMS_REPO = "oracle" -$env:OMS_INBOUND_TOKEN = "dev-oms-token" -$env:RPA_ORACLE_HOST = "192.168.12.244" -$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 +Copy-Item -LiteralPath .env.example -Destination .env +# 只编辑本机 .env 的 RPA_ORACLE_PASSWORD,不要 git add .env +.\start.ps1 ``` 先 `GET http://127.0.0.1:8001/health`,再把 `examples/sales_goods_batch.json` POST 两次: diff --git a/rpa_oms/app/inbound/oracle_repo.py b/rpa_oms/app/inbound/oracle_repo.py index 62a1740..cbc1dce 100644 --- a/rpa_oms/app/inbound/oracle_repo.py +++ b/rpa_oms/app/inbound/oracle_repo.py @@ -11,17 +11,22 @@ from collections.abc import Callable from .repo import DuplicateOmsId, RepoUnavailable +# 接口入站无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。 _INSERT_SQL = """ INSERT INTO rpa_sales_goods_mapping_data ( oms_id, bar_code, order_date, sale_price, sale_qty, plan_purchase_qty, goods_id, goods_name, goods_spec, manufacturer, approval_no, unit, customer_id, customer_name, org_code, goods_mdm_code, customer_mdm_code, - create_time, deal_flag + create_time, create_user_id, create_user_name, + update_time, update_user_id, update_user_name, + deal_flag ) VALUES ( :oms_id, :bar_code, :order_date, :sale_price, :sale_qty, :plan_purchase_qty, :goods_id, :goods_name, :goods_spec, :manufacturer, :approval_no, :unit, :customer_id, :customer_name, :org_code, :goods_mdm_code, :customer_mdm_code, - SYSDATE, 0 + SYSDATE, 0, '系统管理员', + SYSDATE, 0, '系统管理员', + 0 ) """ diff --git a/rpa_oms/app/local_env.py b/rpa_oms/app/local_env.py new file mode 100644 index 0000000..08d1540 --- /dev/null +++ b/rpa_oms/app/local_env.py @@ -0,0 +1,24 @@ +"""从 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 diff --git a/rpa_oms/app/main.py b/rpa_oms/app/main.py index 45a0ea8..fefd5d7 100644 --- a/rpa_oms/app/main.py +++ b/rpa_oms/app/main.py @@ -14,6 +14,9 @@ from fastapi import Depends, FastAPI, Header from fastapi.responses import JSONResponse from .auth import token_ok +from .local_env import load_local_env + +load_local_env() from .inbound.envelope import EnvelopeError, parse_envelope from .inbound.memory_repo import MemorySalesGoodsRepo from .inbound.repo import RepoUnavailable diff --git a/rpa_oms/start.ps1 b/rpa_oms/start.ps1 new file mode 100644 index 0000000..bceee96 --- /dev/null +++ b/rpa_oms/start.ps1 @@ -0,0 +1,52 @@ +$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" +} diff --git a/rpa_oms/tests/test_local_env.py b/rpa_oms/tests/test_local_env.py new file mode 100644 index 0000000..523f752 --- /dev/null +++ b/rpa_oms/tests/test_local_env.py @@ -0,0 +1,19 @@ +"""本机 .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" diff --git a/rpa_oms/tests/test_oracle_repo.py b/rpa_oms/tests/test_oracle_repo.py index 07b7d1c..7682177 100644 --- a/rpa_oms/tests/test_oracle_repo.py +++ b/rpa_oms/tests/test_oracle_repo.py @@ -78,3 +78,44 @@ def test_insert_unique_violation_is_duplicate(): repo = OracleSalesGoodsRepo(connect=lambda: FakeConn()) with pytest.raises(DuplicateOmsId): 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 diff --git a/选品流程/.env.example b/选品流程/.env.example index 93fd4c9..2c8229e 100644 --- a/选品流程/.env.example +++ b/选品流程/.env.example @@ -9,4 +9,4 @@ RPA_ORACLE_SERVICE=rpa RPA_ORACLE_USER= RPA_ORACLE_PASSWORD= RPA_ORACLE_THICK=1 -# RPA_ORACLE_CLIENT_LIB= +RPA_ORACLE_CLIENT_LIB=C:\oracle\instantclient diff --git a/选品流程/README.md b/选品流程/README.md index 23fc3d3..06a1edc 100644 --- a/选品流程/README.md +++ b/选品流程/README.md @@ -38,7 +38,7 @@ uv sync --python 3.12 uv run playwright install chromium ``` -Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / `USER` / `PASSWORD`,11.2 用 thick。 +Oracle 与 `rpa_oms` 同一套变量:`RPA_ORACLE_HOST` / `PORT` / `SERVICE` / `USER` / `PASSWORD`,11.2 用 thick。Windows 还要 `RPA_ORACLE_CLIENT_LIB`。选品 `.env` 里 Oracle 密码留空时,会补 `rpa_oms/.env` 里已有的值。 商城账号(任选其一,不要提交真实密码): @@ -56,6 +56,16 @@ uv run python main.py --limit 5 uv run python main.py --platform yaoex ``` +Windows: + +```powershell +cd 选品流程 +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`。跑的过程中新进入的留给下一班。 中途崩溃会停在 `deal_flag=3`。改回 `0` 再跑。系统故障不能靠订单中心重推同一 `oms_id`(接口 1 幂等),同样把 `deal_flag` 改回 `0`。 diff --git a/选品流程/db.py b/选品流程/db.py index d016099..7037514 100644 --- a/选品流程/db.py +++ b/选品流程/db.py @@ -2,10 +2,13 @@ from __future__ import annotations import os +from pathlib import Path DEAL_USER_NAME = "rpa-select" _client_ready = False +_PROJECT_ROOT = Path(__file__).resolve().parent +_OMS_ENV = _PROJECT_ROOT.parent / "rpa_oms" / ".env" class RepoUnavailable(Exception): @@ -14,6 +17,32 @@ class RepoUnavailable(Exception): 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]: user = os.getenv("RPA_ORACLE_USER", "") password = os.getenv("RPA_ORACLE_PASSWORD", "") diff --git a/选品流程/main.py b/选品流程/main.py index 3d57992..225d07e 100644 --- a/选品流程/main.py +++ b/选品流程/main.py @@ -9,17 +9,15 @@ import requests from auth_login import ( DEFAULT_YAOEX_AUTH_PATH, DEFAULT_YBM_COOKIE_PATH, - PROJECT_ROOT, BrowserRuntime, load_cookie_file, - load_dotenv, load_yaoex_auth_file, login_yaoex, login_ybm, save_cookie_file, save_yaoex_auth, ) -from db import RepoUnavailable +from db import RepoUnavailable, load_runtime_env from job import run_batch from mall_config import YAOEX_MALL_NAME, YBM_MALL_NAME from repo import OracleSelectRepo @@ -82,7 +80,7 @@ def _platforms(choice: str) -> list[str]: def main(argv: list[str] | None = None, repo=None) -> None: args = parse_args(argv) - load_dotenv(PROJECT_ROOT / ".env") + load_runtime_env() flow_start = time.perf_counter() if repo is None: try: diff --git a/选品流程/persist.py b/选品流程/persist.py index 4d235ea..0d07a03 100644 --- a/选品流程/persist.py +++ b/选品流程/persist.py @@ -48,7 +48,7 @@ def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectRe return SelectResult( oms_id=purchase.oms_id, deal_flag=DEAL_OK, - deal_result=_clip(f"选品成功:{'、'.join(malls)}", DEAL_RESULT_MAX), + deal_result=_clip_deal_result(f"选品成功:{'、'.join(malls)}"), cart_rows=rows, ) @@ -59,11 +59,11 @@ def build_result(purchase: PurchaseLine, selection: SelectionResult) -> SelectRe for mall in malls ] detail = "; ".join(selection.mall_errors.values()) or "未知错误" - deal_result = _clip(f"系统故障:{detail}", DEAL_RESULT_MAX) + deal_result = _clip_deal_result(f"系统故障:{detail}") else: reason = _business_fail_reason(selection.reason) rows = [_fail_row(purchase, mall, reason) for mall in malls] - deal_result = _clip(reason, DEAL_RESULT_MAX) + deal_result = _clip_deal_result(reason) _set_multi_flag(rows) return SelectResult( @@ -184,3 +184,25 @@ def _clip(text: str, max_len: int) -> str: if len(text) <= max_len: return text 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 "" diff --git a/选品流程/repo.py b/选品流程/repo.py index 894ac4e..9d63321 100644 --- a/选品流程/repo.py +++ b/选品流程/repo.py @@ -39,15 +39,20 @@ SET deal_flag = 3, WHERE fid = :fid AND deal_flag = 0 """ +# 选品无登录用户:创建人/更新人固定 0 / 系统管理员,时间用库时钟。 _INSERT_CART_SQL = """ INSERT INTO rpa_cart_settlement_goods_data ( operate_type, oms_id, platform_type, supplier_name, bar_code, purchase_price, purchase_qty, purchase_total, status, fail_reason, - push_status, create_time, org_code, multi_platform_flag, create_user_name + push_status, org_code, multi_platform_flag, + create_time, create_user_id, create_user_name, + update_time, update_user_id, update_user_name ) VALUES ( :operate_type, :oms_id, :platform_type, :supplier_name, :bar_code, :purchase_price, :purchase_qty, :purchase_total, :status, :fail_reason, - :push_status, SYSDATE, :org_code, :multi_platform_flag, :create_user_name + :push_status, :org_code, :multi_platform_flag, + SYSDATE, 0, '系统管理员', + SYSDATE, 0, '系统管理员' ) """ @@ -173,7 +178,6 @@ class OracleSelectRepo: with conn.cursor() as cur: for row in cart_rows: 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( _COMPLETE_SQL, diff --git a/选品流程/start.ps1 b/选品流程/start.ps1 new file mode 100644 index 0000000..cda89a0 --- /dev/null +++ b/选品流程/start.ps1 @@ -0,0 +1,63 @@ +$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,也没有 选品流程\.venv\Scripts\python.exe" +} + +$uvArgs = @('run', '--python', '3.12', 'python') + $pyArgs +& $uv.Source @uvArgs +if ($LASTEXITCODE -ne 0) { + throw "选品退出码 $LASTEXITCODE" +} diff --git a/选品流程/tests/test_oracle_repo.py b/选品流程/tests/test_oracle_repo.py index ebc6e15..c00f849 100644 --- a/选品流程/tests/test_oracle_repo.py +++ b/选品流程/tests/test_oracle_repo.py @@ -1,11 +1,33 @@ from __future__ import annotations +import os + import pytest -from db import RepoUnavailable +from db import RepoUnavailable, load_runtime_env 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): monkeypatch.delenv("RPA_ORACLE_USER", raising=False) monkeypatch.delenv("RPA_ORACLE_PASSWORD", raising=False) @@ -68,7 +90,16 @@ def test_save_result_inserts_cart_then_updates_mapping(): ], ) assert conn.committed is True - assert "rpa_cart_settlement_goods_data" in executed[0][0].lower() + sql = " ".join(executed[0][0].split()) + 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 "create_user_name" not in executed[0][1] assert "rpa_sales_goods_mapping_data" in executed[1][0].lower() assert executed[1][1]["deal_flag"] == 1 diff --git a/选品流程/tests/test_persist.py b/选品流程/tests/test_persist.py index cf945dc..7d1bd95 100644 --- a/选品流程/tests/test_persist.py +++ b/选品流程/tests/test_persist.py @@ -146,6 +146,23 @@ def test_margin_fail_text_is_4_percent(): assert "毛利不足 4%" in (result.cart_rows[0]["fail_reason"] or "") +def test_system_fault_deal_result_fits_varchar2_200_byte(): + blob = "药帮忙接口返回非JSON" + ("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(): result = build_result( _purchase(),