diff --git a/docs/站点统计逻辑审查报告.md b/docs/站点统计逻辑审查报告.md new file mode 100644 index 0000000..b8ba8d9 --- /dev/null +++ b/docs/站点统计逻辑审查报告.md @@ -0,0 +1,119 @@ +# 后端各站点「应到件数 / 已到件数」统计逻辑审查报告 + +> 审查范围:`InboundVerify` 后端 +> 核心模块:`expected_undelivered.py`(比对统计)、`runtime.py`(调度/下载路由)、`site_*.py`(各站数据采集)、`server.py`(对外接口) +> 审查重点:每个站点的「应到件数」和「已到件数」如何统计、数据来源、口径与潜在歧义。 + +--- + +## 一、总体架构(两层) + +统计逻辑分两层,前端看到的「应到/已到/未到」计数最终都来自**第二层**,且只在跑比对时才生成。 + +| 层 | 模块 | 职责 | 产物 | +|---|---|---|---| +| **① 数据采集层** | `site_顺心/中通/韵达/安能.py`、`site_百世.py` | 用 Playwright(安能用 CDP)登录各承运商后台,导出 Excel 到 `downloads/` | `downloads/<站>-应到货物数据.xlsx`、`downloads/<站>-实到货物数据.xlsx`、`downloads/<站>-未到数据.xlsx` | +| **② 比对统计层** | `expected_undelivered.py` | 读 `downloads/` 源文件,两表比对,算出应到/已到/未到件数并出报表 | `output/应到未到数据.xlsx`(汇总+各站明细) | + +调度入口:`runtime.py` 的 `TASK_HANDLERS`。 +- 4 站:`expected` 下载 + `actual` 下载 + `undelivered`(先下应到+实到,再调 `write_site_file` 算出未到)。 +- 百世:只有 `undelivered`(直接导「当日未扫」明细,无应到/实到两份基表)。 +- `__compare__`(菜单[9]):调 `expected_undelivered.main()`,用 `downloads/` 现有文件生成全站汇总报表。 + +--- + +## 二、核心统计公式(4 站统一口径) + +`expected_undelivered.process(name)` 是 4 站统计的唯一实现: + +``` +对每一条应到运单(按运单号去重,保留首条): + 应到件数 N = 应到数据中的「录单件数」(输出列名「总件数」) + 应到序号集 = {1, 2, …, N} + 已到序号集 = 从「实到货物数据.xlsx」解析该运单实际到货的子单序号 + 未到序号集 = {1…N} − 已到序号集 + 未到件数 += len(未到序号集) # 每缺 1 个子单 → 1 行未到明细 + 应到件数 += N + +站点级: + 应到件数 = Σ N(所有应到运单) + 未到件数 = Σ len(未到序号集) + 已到件数 = 应到件数 − 未到件数 ← 注意:是「减出来」的,不是直接数实到 + 未到率 = 未到件数 ÷ 应到件数 + 完全未到运单 = 未到序号集 == {1…N} 的运单数 + 部分未到运单 = 0 < 未到 < N 的运单数 +``` + +**关键事实**:`已到件数` 是 `应到件数 − 未到件数` 推算出来的(`exp_pieces - len(rows)`),并非独立去数实到表。它等价于 `Σ|已到序号集|`,前提是实到解析正确且已到序号 ⊆ 应到序号。 + +--- + +## 三、各站点统计明细 + +| 站点 | 应到件数来源 | 已到件数来源 | 实到序号解析规则(`arrived`) | 源文件(`downloads/`) | +|---|---|---|---|---| +| **中通** | `中通-应到货物数据.xlsx` 的「录单件数」 | 应到−未到(推算) | `运单号`列是复合串 = 运单号 + 总数(4位) + 顺序号(4位);取右 8 位,前 4 为总数、后 4 为顺序号 | `中通-应到货物数据.xlsx` / `中通-实到货物数据.xlsx` | +| **顺心** | `顺心-应到货物数据.xlsx` 的「录单件数」 | 应到−未到(推算) | `运单号`(干净) + `子单号` = 运单号 + 顺序号(3位);后缀(3位)即顺序号 | `顺心-应到货物数据.xlsx` / `顺心-实到货物数据.xlsx` | +| **韵达** | `韵达-应到货物数据.xlsx` 的「录单件数」 | 应到−未到(推算) | `主单号`=运单号,`子单号`=主单号 + 顺序号(4位);后缀(4位)即顺序号 | `韵达-应到货物数据.xlsx` / `韵达-实到货物数据.xlsx` | +| **安能** | `安能-应到货物数据.xlsx` 的「录单件数」 | 应到−未到(推算) | `所属单号`=运单号,`扫描单号`=所属单号 + 总数(4位) + 顺序号(4位);`has_total=True`,取末 4 位为顺序号 | `安能-应到货物数据.xlsx` / `安能-实到货物数据.xlsx` | +| **百世** | **无**(站点只给未到) | **无(显示「—」)** | 不适用(无实到基表) | 仅 `百世-应到未到货物数据.xlsx`(= 当日未扫明细,本身就是未到结果) | + +> 4 站合计/图表口径:`expected_undelivered.build_summary` 只累加 4 站(`应到−实到`口径),**百世不计入合计**(无应到基数)。百世在表中单列,未到件数 = 其明细行数。 + +--- + +## 四、百世的特殊口径(务必注意) + +百世是唯一「无应到/实到基数」的站点: +- 它的数据来自后台「扫描综合查询 → 到/接件扫描 → 当日 → 未扫」,导出即「当日未扫」明细(`site_baishi.py`)。 +- 因此 `process_baishi()` 只能给 `未到件数 = 行数`,`应到件数 = None`、`已到件数 = None`、完全/部分未到 = None。 +- 报表里百世的应到/已到列显示「—」,未到率无法计算。 +- **含义**:百世统计的是「今天还没扫到的件」,不是「相对应到总量的缺件率」。与 4 站口径不可直接相加比较。 + +--- + +## 五、数据日期与偏移(潜在口径不一致风险) + +`runtime._record_business_date` 在下载成功后把业务日期写进状态库: +- `业务日期 = 下载当天 − 日期偏移` +- 各站 `expected_offset` / `actual_offset` 独立配置(前端 `/config` 可改;百世偏移恒 0,锁定当天)。 +- 韵达默认 `expected_offset=1`(取前一日应到)。 + +**风险点**:4 站的「应到」和「实到」是**两次独立下载**,各自可能带不同偏移。若 `expected_offset ≠ actual_offset`,则「应到件数」和「已到件数」来自**不同业务日期**,比对会变成「拿昨天的应到对比今天的实到」,未到率失真。报表「数据日期」列分别标注各站,但汇总合计不标注,肉眼难发现。 + +--- + +## 六、统计结果如何暴露给前端 + +| 接口 | 返回内容 | 是否含应到/已到计数 | +|---|---|---| +| `GET /status` | 各站 `login_state` + `expected/actual/undelivered_ready` + `business_date` + `worker_ready` | **不含**计数,只给就绪标志 | +| `GET /report` | `FileResponse(output/应到未到数据.xlsx)` | 计数只在 xlsx 里 | +| `GET /data/{filename}` | 下载 `downloads/` 下某源文件 | 原始数据,非统计值 | + +**结论**:后端**没有**把应到/已到件数以 JSON 形式实时返回前端。计数仅物化在 `output/应到未到数据.xlsx`。任何前端界面显示的应到/已到数字,都是解析这份 xlsx 得到的——即**「截至上次跑比对」的快照**,不是实时值。 + +--- + +## 七、潜在歧义与风险点(审查结论) + +1. **已到件数是减出来的,不是数出来的**:依赖实到子单号解析正确。一旦某站 `子单号` 格式与解析规则(`arrived_*`)不匹配,该序号进不了「已到序号集」→ 误判为未到 → 已到件数被低估、未到率虚高。解析规则是硬编码的,承运商改版号段即失准。 +2. **应到件数依赖「录单件数」**:若应到数据某运单 `录单件数` 缺失/为 0/非数字,该运单被 `continue` 跳过,既不计入应到也不计入未到 → 静默漏统(应到总量被低估,但该运单若出现在实到中也因不在应到循环而永不计入已到,方向一致)。 +3. **应到按运单号去重(keep first)**:同一运单多条交接记录只取首条 `录单件数`。若重复行的件数不同,取首条,可能与实际不符。 +4. **百世不可并入合计**:4 站合计的「已到总件数」不含百世;跨站看「已到」时别把百世当成有应到基数的站。 +5. **应到/实到业务日期可能错位**(见第五节):两表不同步下载或偏移不一致时,比对口径失真。 +6. **计数是比对产物,非实时**:前端若要「实时件数」需先触发比对任务;`/status` 的 `ready` 仅表示「下载成功」,不代表「已比对出数」。 +7. **子单号是程序现拼的**:4 站缺件的「子单号/扫描单号」由 `code_*` 按各站编号规则生成(`code_shunxin` 等),并非实到原始记录——明细里的缺件单号是推算值,用于人工核对,不是系统回执。 + +--- + +## 八、关键文件索引 + +| 文件 | 角色 | +|---|---| +| `expected_undelivered.py` | 统计核心:`process()`(4站比对)、`process_baishi()`(百世)、`build_summary()`(汇总报表)、`STATIONS`(各站解析配置) | +| `runtime.py` | `TASK_HANDLERS`(下载/比对路由)、`_site_undelivered_handler`(4站先下应到+实到再算未到)、`_record_business_date`(业务日期/偏移写入) | +| `site_中通/顺心/韵达/安能.py` | 各站 `expected_download` / `actual_download`(Playwright/CDP 导出源表) | +| `site_百世.py` | `baishi_download_undelivered_data`(直接导「当日未扫」) | +| `state_store.py` | `site_status`(ready/business_date)、`site_config`(offset/schedule)、`get_offset` | +| `server.py` | `/status`、`/report`、`/data/{filename}` 接口 | diff --git a/docs/韵达安能计算逻辑梳理.md b/docs/韵达安能计算逻辑梳理.md new file mode 100644 index 0000000..45fee8c --- /dev/null +++ b/docs/韵达安能计算逻辑梳理.md @@ -0,0 +1,124 @@ +# 韵达 / 安能 应到未到计算逻辑梳理(基于 expected_undelivered.py 重构版代码) + +> 用途:供审核两站「应到件数 / 实到件数 / 未到件数」的实现逻辑与关联键。 +> 代码基准:`InboundVerify/expected_undelivered.py`(重构版)。行号对应本次读取。 + +--- + +## 〇、两站共用的计算骨架(process 函数,第 134–216 行) + +无论韵达还是安能,最终都走同一个 `process(name)`: + +1. 读应到表 + 实到表(第 148–149 行)。 +2. 应到表按关联键列去重、保留首条(第 152–153 行)。 +3. **应到件数** = 应到表「交接件数」之和(第 158–168 行)。 +4. **实到件数** = 实到表单件号列的**全局去重数**(第 176–177 行,`act_pieces = sum(len(s) for s in arrived.values())`)。 +5. 逐运单比较:实到扫到数 `<` 应到件数 的运单,才进未到明细(第 183–201 行)。 +6. **未到件数** = `max(0, 应到件数 − 实到件数)`(第 210 行)。 + +两站差异**只在配置**(关联键列、单件号列不同),算法完全一致。 + +--- + +## 一、韵达 + +### 1.1 配置(STATIONS 第 106–115 行) +| 配置项 | 值 | 含义 | +|---|---|---| +| exp(应到文件) | `韵达-应到货物数据.xlsx` | 源 | +| act(实到文件) | `韵达-实到货物数据.xlsx` | 源 | +| exp_qty | `交接件数` | 应到件数的取值列 | +| exp_wb | `运单号` | 应到表去重键 **+ 关联键** | +| exp_jd | `交接单号` | 未到明细展示字段 | +| arrived_pieces | `arrived_pieces_by_cols("主单号", "子单号")` | 实到解析 | +|  ├ wb_col | `主单号`(实到表) | 实到侧**关联基号**列 | +|  └ piece_col | `子单号`(实到表) | 实到侧**单件号**列 | + +### 1.2 两套表的字段角色 +- **应到表**:`运单号`(去重+关联)、`交接件数`(该单应到件数)、`交接单号`(展示)。 +- **实到表**:`主单号`(关联基号,须与应到`运单号`同值域)、`子单号`(每件货物的单号,一个单号=一件)。 + +### 1.3 关联(Join)条件 +``` +应到表.运单号 == 实到表.主单号 +``` +机制:`arrived_pieces_by_cols` 以「主单号」为 key 建 dict(第 76–81 行);`process` 第 185 行 `arrived.get(wb)` 用**应到运单号 wb** 去查该 dict。两列值必须相等才对得上。 + +### 1.4 计算口径 +- 应到件数 = Σ 交接件数(按运单号去重后)。 +- 实到件数 = 实到表「子单号」全局去重数(不同主单号下即使子单号文本相同也只计一次)。 +- 未到件数 = `max(0, 应到 − 实到)`。 +- 未到明细(每行一个短少运单):`交接单号 | 运单号 | 总件数(=应到件数) | 已到单号1..k`; + 「已到单号1..k」= 该主单号下所有子单号(排序后填入),k = 实际扫到件数(第 199–200 行)。 + 仅当「实到扫到数 < 应到件数」才列入(第 187–188 行 `arrived_cnt >= n` 跳过)。 + +### 1.5 当前数据状态(实测,2026-07-19 10:50) +- 应到运单数 **57**,实到基号数 **58**,交集命中 **57** ✅ +- 应到件数 = 140,实到单号去重 = 141 +- 样本:`应到 617936212` == `实到主单号 617936212`,完全对齐 +- **结论:韵达关联键现已对齐,逻辑可正确产出结果。**(此前 2/57 为旧数据,已失效) + +--- + +## 二、安能 + +### 2.1 配置(STATIONS 第 116–125 行) +| 配置项 | 值 | 含义 | +|---|---|---| +| exp(应到文件) | `安能-应到货物数据.xlsx` | 源 | +| act(实到文件) | `安能-实到货物数据.xlsx` | 源 | +| exp_qty | `交接件数` | 应到件数取值列 | +| exp_wb | `运单号` | 应到表去重键 **+ 关联键** | +| exp_jd | `交接单号` | 未到明细展示字段 | +| arrived_pieces | `arrived_pieces_by_cols("所属单号", "扫描单号")` | 实到解析 | +|  ├ wb_col | `所属单号`(实到表) | 实到侧**关联基号**列 | +|  └ piece_col | `扫描单号`(实到表) | 实到侧**单件号**列 | + +### 2.2 两套表的字段角色 +- **应到表**:`运单号`(去重+关联)、`交接件数`(应到件数)、`交接单号`(展示)。 +- **实到表**:`所属单号`(关联基号,须与应到`运单号`同值域)、`扫描单号`(每件单号,格式=`所属单号`+总数4位+顺序4位,一个=一件)。 + +### 2.3 关联(Join)条件 +``` +应到表.运单号 == 实到表.所属单号 +``` +机制同韵达:`arrived_pieces_by_cols` 以「所属单号」建 key dict;`process` 用应到运单号去查。 + +### 2.4 计算口径(与韵达结构完全一致) +- 应到件数 = Σ 交接件数。 +- 实到件数 = 实到表「扫描单号」全局去重数。 +- 未到件数 = `max(0, 应到 − 实到)`。 +- 未到明细:`交接单号 | 运单号 | 总件数 | 已到单号1..k`(k=该所属单号下扫描单号数)。 + +### 2.5 当前数据状态(实测,2026-07-19 10:50) +- 应到运单数 **97**,实到基号数 **140**,交集命中 **4** ❌ +- 应到件数 = 236,实到单号去重 = 279 +- 样本:`应到 750101236894` vs `实到所属单号 760237639793` —— 两套不同编号体系 +- **后果**:实到 279 件几乎全无法对应到任何应到运单 → 明细把 93 个运单列「完全未到」,但汇总层 `实到=279 > 应到=236` → 未到净额被 `max(0,…)` 截断为 **0**,于是「明细列 96 行短少」与「汇总未到=0」自相矛盾。 +- **结论:安能关联键未对齐,当前产出不可信。** + +--- + +## 三、两站逻辑差异对照 + +| 维度 | 韵达 | 安能 | +|---|---|---| +| 应到关联键列 | `运单号` | `运单号` | +| 实到关联基号列 | `主单号` | `所属单号` | +| 实到单件号列 | `子单号` | `扫描单号` | +| 单件号是否带后缀 | 是(主单号+顺序号) | 是(所属单号+总数+顺序) | +| 关联键是否对齐(当前数据) | ✅ 57/57 | ❌ 4/97 | +| 计算结果是否可信 | 可信 | 不可信(自相矛盾) | + +> 两站**算法完全相同**,唯一区别是实到侧的「基号列 / 单件号列」列名不同。因此问题不在代码逻辑,而在**安能的关联键取值域对不上**。 + +--- + +## 四、审核要点(请你判断) + +1. **韵达**:关联键 `应到.运单号 == 实到.主单号` 是否符合业务实际?当前数据已对齐,似乎正确;若你确认,韵达逻辑可定稿。 +2. **安能**:关联键 `应到.运单号 == 实到.所属单号` 是否正确?实测两套编号不重合(97 个应到运单仅 4 个能在实到找到)。可能的方向: + - (a) 实到表存在另一列能与应到`运单号`对应(需确认列名,可能改 `wb_col`); + - (b) 应到/实到文件是按不同条件/批次拉的,需要按同一天、同线路重新拉取; + - (c) 暂时把安能排除(同百世),等数据对齐再加回。 +3. **共同结构问题**:汇总「未到件数」用净额 `max(0, 应到−实到)`,而明细按「逐运单短少」列——当关联键断裂时二者会矛盾(安能现例)。关联键对齐后此矛盾自然消失;是否需要在代码里对「净额 vs 明细」做一致性校验/告警,也请你定。 diff --git a/expected_undelivered.py b/expected_undelivered.py index 862ec9d..e241d05 100644 --- a/expected_undelivered.py +++ b/expected_undelivered.py @@ -1,25 +1,29 @@ # -*- coding: utf-8 -*- """ -应到未到数据比对 +应到未到数据比对(重构版) -目的:对中通 / 顺心 / 韵达 / 安能四个站点,比对各自的「应到货物数据」与 -「实到货物数据」,逐件找出应到却未到的子单,汇总到 output/应到未到数据.xlsx。 -工作簿结构: - · 汇总报表 —— 首页,跨站点统计与可视化(不含明细)。 - · 中通 / 顺心 / 韵达 / 安能 —— 各站未到明细,每行一件。 +目的:对中通 / 顺心 / 韵达 / 安能 四个站点,比对各自的「应到货物数据」与 +「实到货物数据」,找出应到却未到的运单,汇总到 output/应到未到数据.xlsx。 +(百世为站点直供未到明细,不参与本模块比对,见 process_baishi。) -核心逻辑(四站点统一): - 1. 每个运单的应到件数 N = 应到数据中的「录单件数」(输出列名为「总件数」)。 - 2. 该运单应到的子单序号集合 = {1, 2, …, N}。 - 3. 从实到数据中解析出该运单实际已到的序号集合。 - 4. 应到未到 = {1…N} − 已到序号。 - —— 缺件按定义不在实到里,故其子单号 / 扫描单号由程序按各站格式现拼生成。 +核心口径(四站点统一,重构后): + 1. 应到件数 = 应到表「交接件数」之和(按运单号去重 keep-first)。 + —— 录单件数 只是该单号的总录单量,实际只有“交接件数”会真正到站, + 故应到必须按交接件数统计,不能用录单件数。 + 2. 实到件数 = 实到表「单号」的去重数量(直接数,不再由“应到−未到”倒推)。 + —— 每扫描一件,系统生成该件的单号(一个单号=一件);后缀含总数/顺序号, + 但计数时无视后缀,仅对单号去重即得实到件数。 + 3. 未到件数 = max(0, 应到件数 − 实到件数)。 + 4. 未到明细(downloads/<站>-未到数据.xlsx)仅列“短少”运单(实到 < 应到), + 每行:交接单号 | 运单号 | 总件数(=应到/交接件数) | 已到单号1 | 已到单号2 | …。 + —— 实到扫描的顺序号是乱序的,缺件的“顺序号”无法反推,故不再编造子单号; + 改为把该运单“实际扫到的单号”依次填到后续单元格,便于核对到了哪几件。 -各站差异(已据真实数据核定): - 中通:实到「运单号」列即复合串 = 运单号 + 总数(4位) + 顺序号(4位),从右解析。 - 顺心:实到有干净「运单号」列 +「子单号」= 运单号 + 顺序号(3位)。 - 韵达:实到「主单号」对应运单号,「子单号」= 主单号 + 顺序号(4位)。 - 安能:实到「所属单号」对应运单号,「扫描单号」= 所属单号 + 总数(4位) + 顺序号(4位)。 +各站实到单号列 / 运单基号: + 中通:单号列=运单号(复合串 H+运单号+总数+顺序),基号=v[:-8] + 顺心:单号列=子单号,基号=运单号 + 韵达:单号列=子单号,基号=主单号 + 安能:单号列=扫描单号,基号=所属单号 目录约定: 源数据放在脚本同级目录的 downloads/ 下;结果写入 output/(不存在则自动创建)。 @@ -52,91 +56,72 @@ BAISHI_COLUMNS = ["类型", "子单号", "运单号", "最新扫描记录"] # ============================ 比对逻辑 ============================ -def arrived_zhongtong(df): - """中通:运单号列即复合串,右侧 8 位 = 总数(4)+顺序(4),其余为运单号。""" +def arrived_pieces_zhongtong(df): + """中通:实到「运单号」为复合串(H + 运单号(12) + 总数(4) + 顺序(4))。 + 基号 = v[:-8](与应到表运单号对齐),单件 = 整串(每串即一件)。""" res = defaultdict(set) for v in df["运单号"]: v = str(v).strip() if len(v) > 8 and v[-4:].isdigit(): - res[v[:-8]].add(int(v[-4:])) + res[v[:-8]].add(v) # 以完整复合串作为“已到单号”存入 return res -def arrived_by_prefix(main_col, sub_col, has_total=False): - """顺心 / 韵达 / 安能:子单号以主单号为前缀,后缀含顺序号。 - has_total=True 时后缀为 总数(4)+顺序(4),取末 4 位为顺序号。""" - +def arrived_pieces_by_cols(wb_col, piece_col): + """顺心 / 韵达 / 安能:按干净运单列分组,单件 = 子单号 / 扫描单号。 + wb_col:实到表中与应到运单号对齐的干净列 + (顺心=运单号 / 韵达=主单号 / 安能=所属单号) + piece_col:实到表中每件货物的单号列(子单号 / 扫描单号)""" def parse(df): res = defaultdict(set) - for m, s in zip(df[main_col], df[sub_col]): + for m, s in zip(df[wb_col], df[piece_col]): m, s = str(m).strip(), str(s).strip() - if not m or not s: - continue - if s == m: # 无后缀的单件,记为第 1 件 - res[m].add(1) - continue - if not s.startswith(m): - continue - seq = s[len(m) :][-4:] if has_total else s[len(m) :] - if seq.isdigit(): - res[m].add(int(seq)) + if m and s: + res[m].add(s) return res - return parse -def code_zhongtong(wb, seq, n): - return f"{seq:04d}" # 中通:仅顺序号 - - -def code_shunxin(wb, seq, n): - return f"{wb}{seq:03d}" # 顺心:运单号 + 3 位顺序 - - -def code_yunda(wb, seq, n): - return f"{wb}{seq:04d}" # 韵达:主单号 + 4 位顺序 - - -def code_anneng(wb, seq, n): - return f"{wb}{n:04d}{seq:04d}" # 安能:运单号 + 总数 + 顺序 - - STATIONS = [ { "name": "中通", "exp": "中通-应到货物数据.xlsx", "act": "中通-实到货物数据.xlsx", - "arrived": arrived_zhongtong, - "code": code_zhongtong, - "code_col": "子单号", - "columns": ["交接单号", "运单号", "子单号", "总件数"], + "exp_qty": "交接件数", # 应到件数口径:交接件数(非录单件数) + "exp_wb": "运单号", # 应到表运单号列(兼作去重键) + "exp_jd": "交接单号", # 未到数据需展示的交接单号 + "arrived_pieces": arrived_pieces_zhongtong, + "columns": ["交接单号", "运单号", "总件数"], }, { "name": "顺心", "exp": "顺心-应到货物数据.xlsx", "act": "顺心-实到货物数据.xlsx", - "arrived": arrived_by_prefix("运单号", "子单号"), - "code": code_shunxin, - "code_col": "子单号", - "columns": ["班次号", "交接单号", "运单号", "子单号", "总件数"], + "exp_qty": "交接件数", + "exp_wb": "运单号", + "exp_jd": "交接单号", + "arrived_pieces": arrived_pieces_by_cols("运单号", "子单号"), + "columns": ["交接单号", "运单号", "总件数"], }, { "name": "韵达", "exp": "韵达-应到货物数据.xlsx", "act": "韵达-实到货物数据.xlsx", - "arrived": arrived_by_prefix("主单号", "子单号"), - "code": code_yunda, - "code_col": "子单号", - "columns": ["交接单号", "运单号", "子单号", "总件数"], + "exp_qty": "交接件数", + "exp_wb": "运单号", + "exp_jd": "交接单号", + "arrived_pieces": arrived_pieces_by_cols("主单号", "子单号"), + "columns": ["交接单号", "运单号", "总件数"], }, { "name": "安能", "exp": "安能-应到货物数据.xlsx", "act": "安能-实到货物数据.xlsx", - "arrived": arrived_by_prefix("所属单号", "扫描单号", has_total=True), - "code": code_anneng, - "code_col": "扫描单号", - "columns": ["交接单号", "运单号", "扫描单号", "总件数"], + "exp_qty": "交接件数", + "exp_wb": "运单号", + "exp_jd": "交接单号", + "arrived_pieces": arrived_pieces_by_cols("所属单号", "扫描单号"), + "columns": ["交接单号", "运单号", "总件数"], }, ] @@ -147,7 +132,10 @@ def _site_cfg(name): def process(name): - """4 站单站比对:返回 (列名list, 明细行list[dict], 统计dict);源文件缺失或非 4 站返回 None。""" + """4 站单站比对(重构版):返回 (列名list, 明细行list[dict], 统计dict)。 + 源文件缺失或非 4 站返回 None。 + 新口径:应到=交接件数;实到=直接数单号去重;未到=应到−实到; + 未到明细行仅含「交接单号|运单号|总件数|+已到单号…」,不再编造子单号。""" cfg = _site_cfg(name) if cfg is None: return None @@ -160,58 +148,72 @@ def process(name): df_exp = pd.read_excel(exp_path, dtype=str).fillna("") df_act = pd.read_excel(act_path, dtype=str).fillna("") - # 同一运单可能有多条交接记录(录单件数一致),按运单号去重、保留首条 - dup = int(df_exp["运单号"].duplicated().sum()) - df_exp = df_exp.drop_duplicates(subset=["运单号"], keep="first") + # 同一运单可能有多条交接记录,按运单号去重、保留首条 + dup = int(df_exp[cfg["exp_wb"]].duplicated().sum()) + df_exp = df_exp.drop_duplicates(subset=[cfg["exp_wb"]], keep="first") - arrived = cfg["arrived"](df_act) - - rows = [] - exp_pieces = full_miss = part_miss = 0 + # 应到件数(新口径)= 交接件数 之和;记录 运单 -> (交接单号, 应到件数) + exp_by_wb = {} + exp_pieces = 0 for _, r in df_exp.iterrows(): - wb = str(r["运单号"]).strip() + wb = str(r[cfg["exp_wb"]]).strip() if not wb: continue try: - n = int(float(r["总件数"] if "总件数" in r else r["录单件数"])) + n = int(float(r[cfg["exp_qty"]])) except (TypeError, ValueError, KeyError): - try: - n = int(float(r["录单件数"])) - except (TypeError, ValueError, KeyError): - continue + n = 0 if n <= 0: continue exp_pieces += n - missing = sorted(set(range(1, n + 1)) - arrived.get(wb, set())) - if missing: - if len(missing) == n: - full_miss += 1 - else: - part_miss += 1 - for seq in missing: - row = {} - for col in cfg["columns"]: - if col == "运单号": - row[col] = wb - elif col == cfg["code_col"]: - row[col] = cfg["code"](wb, seq, n) - elif col == "总件数": - row[col] = n - else: - row[col] = str(r.get(col, "")).strip() - rows.append(row) + if wb not in exp_by_wb: + exp_by_wb[wb] = { + "jd": str(r.get(cfg["exp_jd"], "")).strip(), + "n": n, + } + + # 实到件数(新口径)= 实到表单号去重数量(分组 运单->已到单号集合) + arrived = cfg["arrived_pieces"](df_act) + act_pieces = sum(len(s) for s in arrived.values()) # 全局去重单号数 + + # 未到:逐运单比较,列出实际已到的单号(顺序号乱序,无法反推缺件序号) + rows = [] + full_miss = part_miss = 0 + max_arrived = 0 + for wb, info in exp_by_wb.items(): + n = info["n"] + arrived_set = arrived.get(wb, set()) + arrived_cnt = len(arrived_set) + if arrived_cnt >= n: + continue # 足额或溢到,不进未到表 + if arrived_cnt == 0: + full_miss += 1 + else: + part_miss += 1 + max_arrived = max(max_arrived, arrived_cnt) + row = { + cfg["exp_jd"]: info["jd"], + cfg["exp_wb"]: wb, + "总件数": n, + } + for i, piece in enumerate(sorted(arrived_set, key=lambda x: str(x))): + row[f"已到单号{i+1}"] = piece + rows.append(row) + + # 动态列:基础 3 列 + 已到单号1..max_arrived + columns = list(cfg["columns"]) + [f"已到单号{i+1}" for i in range(max_arrived)] stats = { - "运单数": len(df_exp), + "运单数": len(exp_by_wb), "应到件": exp_pieces, - "已到件": exp_pieces - len(rows), - "未到件": len(rows), + "已到件": act_pieces, + "未到件": max(0, exp_pieces - act_pieces), "涉及运单": full_miss + part_miss, "完全未到": full_miss, "部分未到": part_miss, "重复运单": dup, } - return cfg["columns"], rows, stats + return columns, rows, stats # ============================ 样式常量 ============================ @@ -258,7 +260,7 @@ def write_station(ws, columns, rows): cell.alignment = Alignment(horizontal="center", vertical="center") cell.border = BORDER for row in rows: - ws.append([row[c] for c in columns]) + ws.append([row.get(c, "") for c in columns]) for r in range(2, ws.max_row + 1): for c, col in enumerate(columns, start=1): cell = ws.cell(row=r, column=c) @@ -270,7 +272,7 @@ def write_station(ws, columns, rows): else: cell.number_format = "@" # 文本,避免长单号被转科学计数 for c, col in enumerate(columns, start=1): - body = [len(str(row[col])) for row in rows] if rows else [] + body = [len(str(row.get(col, ""))) for row in rows] if rows else [] width = min(max([len(str(col))] + body) + 4, 36) ws.column_dimensions[ws.cell(row=1, column=c).column_letter].width = max( width, 12 @@ -594,7 +596,7 @@ def build_summary(ws, results, generated_at, dates=None): "指标口径:未到率 = 未到件数 ÷ 应到件数;完全未到运单 = 整单零到货;部分未到运单 = 部分到货、部分缺件。", "合计 / 图表仅含 4 站(顺心/中通/韵达/安能,应到−实到口径);百世为站点直供未到、无应到基数,单列不计入合计。", "本次下载失败的站点标注为(无数据)并计 0,不影响其余站点统计。", - "明细见各站点工作表;4 站缺件的子单号 / 扫描单号按各站编号规则生成,并非实到原始记录。", + "明细见各站点工作表;未到明细仅列短少运单,并列出该运单实际扫到的单号(已到单号1…),缺件不再编造子单号。", "数据日期:各站本次纳入数据对应的业务日期(=应到数据下载日 − 日期偏移;韵达偏移 1 为前一日);合计为多站混合、不标注。", ] for k, text in enumerate(notes): diff --git a/site_anneng.py b/site_anneng.py index 99e4d14..e37f94a 100644 --- a/site_anneng.py +++ b/site_anneng.py @@ -962,21 +962,62 @@ def anneng_expected_download_impl(): # ==================================================================== -def set_scan_date(cdp, placeholder, value): - """向扫描时间输入框写入日期时间并回车接受(Ant 可编辑 DatePicker)。""" +def set_scan_date(cdp, placeholder, value, target_ymd=None): + """向扫描时间输入框写入日期时间并回车接受(Ant Design DatePicker,rc-picker 受控组件)。 + + 关键:rc-picker 是 React 受控组件,原生 setter 直接改 .value 会被其重渲染冲掉;且控件里 + 往往已有默认日期,必须先「全选」再整体替换为新值。正确做法对齐人工操作: + 进入控件(焦点激活) → 全选既有文本 → insertText 整体替换(派发 input 事件, onChange 受理) → 回车提交。 + + target_ymd 可显式传入 (y,m,d) 用于写后回读校验;不传则从 value 解析。 + 返回 True 表示写入并校验成功,否则 False。 + """ + import re as _re ph = json.dumps(placeholder) - return cdp.eval( - "(() => {const inp = [...document.querySelectorAll('input')]" - f".find(i => i.placeholder === {ph});" - "if (!inp) return false; inp.focus();" - "const setter = Object.getOwnPropertyDescriptor(" - " window.HTMLInputElement.prototype, 'value').set;" - f" setter.call(inp, {json.dumps(value)});" - " inp.dispatchEvent(new Event('input', {bubbles:true}));" - " inp.dispatchEvent(new KeyboardEvent('keydown', {key:'Enter', code:'Enter'," - " keyCode:13, which:13, bubbles:true}));" - " return inp.value;})()" - ) + val_json = json.dumps(value) + if target_ymd is None: + m = _re.search(r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})", value) + target_ymd = (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None + + def _norm(s): + if not s: + return None + mm = _re.search(r"(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})", s) + return (int(mm.group(1)), int(mm.group(2)), int(mm.group(3))) if mm else None + + for attempt in range(6): + # 1) 进入控件(focus + click 让光标处于激活状态) + cdp.eval( + "(() => {const inp=[...document.querySelectorAll('input')]" + f".find(i=>i.placeholder==={ph}); if(!inp) return false; inp.focus(); inp.click(); return true;}})()" + ) + time.sleep(0.35) + # 2) 全选控件里既有的旧日期 → insertText 整体替换为新值(rc-picker 的 onChange 会受理); + # execCommand 不可用时回退原生 setter + input 事件。 + wrote = cdp.eval( + "(() => {const inp=[...document.querySelectorAll('input')]" + f".find(i=>i.placeholder==={ph}); if(!inp) return false; inp.focus();" + "let sel=true; try{ sel=document.execCommand('selectAll'); }catch(e){ try{inp.select();}catch(_){ sel=false; } }" + "let done=false; try{ done=document.execCommand('insertText',false," + val_json + "); }catch(e){ done=false; }" + "if(!done){ const s=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;" + f" s.call(inp,{val_json}); inp.dispatchEvent(new Event('input',{{bubbles:true}})); }}" + "return inp.value;})()" + ) + # 3) 回车提交(文本输入完成按回车即完成设定) + cdp.eval( + "(() => {const inp=[...document.querySelectorAll('input')]" + f".find(i=>i.placeholder==={ph}); if(!inp) return false;" + "inp.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true}));" + "inp.dispatchEvent(new KeyboardEvent('keyup',{key:'Enter',code:'Enter',keyCode:13,which:13,bubbles:true}));" + "return true;})()" + ) + # 4) 写后回读校验(rc-picker 提交后 input.value 会被清空,故只校验「写后瞬间」的值) + if target_ymd is not None and _norm(wrote) == target_ymd: + return True + time.sleep(0.4) + # 全部尝试失败:告警,避免静默下成「今天」 + print(f" ⚠ set_scan_date 未能将 {placeholder} 设为目标日期 {target_ymd}(请检查 DatePicker 是否就绪)") + return False def select_zidan(cdp): @@ -1095,9 +1136,11 @@ def goto_next_page(cdp): def wait_scan_form_ready(cdp, timeout=20.0): - """等扫描查询表单渲染完(日期输入框 + 子单 选项出现)。 + """等扫描查询表单渲染完(日期输入框 + 子单 选项出现),并预热 DatePicker 使其完全挂载。 新开的 tab 里 URL 一匹配就返回,但 Ant 表单还在渲染,此时设日期/选子单会落空。 + 这里除了等元素出现,再额外 settle 一段时间,并依次 focus 两个 DatePicker 打开面板再 + Esc 关闭,强制 rc-picker 完成挂载,避免随后 set_scan_date 写入的值被重渲染冲掉。 """ wait_until( cdp, @@ -1106,6 +1149,26 @@ def wait_scan_form_ready(cdp, timeout=20.0): "扫描查询表单加载", timeout=timeout, ) + # 额外 settle:等 SPA 把表单真正挂载稳定(避免出现后又重渲染把值刷掉) + time.sleep(1.2) + # 预热:依次 focus 两个 DatePicker 打开面板再 Esc 关闭,强制 rc-picker 完全挂载 + for ph in ("开始日期", "结束日期"): + cdp.eval( + "(() => {const inp=[...document.querySelectorAll('input')]" + f".find(i=>i.placeholder==={json.dumps(ph)}); if(!inp) return false; inp.focus(); return true;}})()" + ) + try: + wait_until(cdp, "!!document.querySelector('.ant-picker-panel')", + "预热面板打开", timeout=4.0) + except Exception: + pass + cdp.eval( + "(() => {const inp=[...document.querySelectorAll('input')]" + f".find(i=>i.placeholder==={json.dumps(ph)}); if(!inp) return false;" + "inp.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',code:'Escape',keyCode:27,which:27,bubbles:true}));" + "return true;})()" + ) + time.sleep(0.3) def wait_query_settled(cdp, timeout=20.0):