更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
+10 -17
View File
@@ -1,21 +1,14 @@
MIT License # ShiErFeng 专有版权声明
Copyright (c) 2024 insistence Copyright (c) 2024-2026 Shierfeng International Biotechnology Co., Ltd.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy 除 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md) 或具体文件另有说明的
of this software and associated documentation files (the "Software"), to deal 第三方代码外,本仓库中的源代码、业务逻辑、界面设计、文档及其他原创内容
in the Software without restriction, including without limitation the rights 均为施尔丰国际生物科技有限公司的专有内容。
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all 未经版权持有人书面许可,不得复制、修改、分发、再许可或将上述原创内容
copies or substantial portions of the Software. 用于其他项目或商业用途。
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 本文件不改变第三方代码所适用的原始许可证。RuoYi 来源代码的版权声明和
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, MIT License 请参阅 [THIRD-PARTY-NOTICES.md](./THIRD-PARTY-NOTICES.md)。
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+32
View File
@@ -0,0 +1,32 @@
# 第三方软件声明
本文件用于说明本项目中涉及的第三方来源代码及其适用许可证。本项目自身的业务代码、界面设计、文档和品牌内容仍以仓库根目录 [LICENSE](./LICENSE) 中的专有版权声明为准。
## RuoYi-Vue3-FastAPI
本项目 Web 前端部分参考并包含来源于 [RuoYi-Vue3-FastAPI](https://github.com/insistence/RuoYi-Vue3-FastAPI) 的代码及其衍生部分。
来源项目保留以下版权声明和 MIT 许可证文本:
```text
The MIT License (MIT)
Copyright (c) 2018 RuoYi
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
+61 -1
View File
@@ -1,5 +1,65 @@
# 托盘流转业务部署说明 # 托盘流转业务部署说明
## 2026-10-08 会议流程补齐(本机开发库已执行)
本轮源码增加物料日期、原发货标签、收货核对、隧道报表,以及“允许跳过先到托盘但必须填写原因”。用户明确批准开发库迁移和该 FIFO 规则后,已在本机开发库执行下列三份增量补丁并恢复9099后端。其他环境未执行,不能沿用本记录当作已迁移证明。
### 本机开发库执行与恢复验证记录
- 目标:`.env.dev` 对应 `127.0.0.1:3306/shierfeng-fastapi`,MySQL 8.0.36;43张业务及系统表均为 InnoDB。
- 已核实并停止本项目 `app.py --env=dev` 服务;迁移前无其他活跃目标库查询、无启用的 MySQL Event。
- 完整备份:`/private/tmp/shierfeng-dev-migration-20261008-6ce_sy6c/before.sql`,262663字节,文件权限600、父目录700。SHA-256:`c70393e09574f24820e252c9cfb084e9e15d86b185c8a7aa981a8088cd21a15b`。备份包括全部表、触发器、存储过程和事件;口令不进入命令行,临时口令文件已删除。
- 备份已实际恢复到隔离库 `shierfeng_rehearsal_20261008_6ce_sy6c`;43张表逐行摘要及数量与源库一致。三份补丁先在隔离库试跑通过,然后核对源库未变化、脚本摘要一致,再执行开发库迁移。
- 开发库执行顺序:`20261008_material_dates_mysql.sql` → `20261008_shipment_receipt_mysql.sql` → `20261008_usage_fifo_mysql.sql`,均成功。没有重跑初始化或旧隧道脚本。
- 迁移后按迁移前列集比对43张表的全部原数据摘要,结果一致;仅新增收货菜单1119。原库存、包数、编号、批号及历史事件未变;旧确认发货单收货状态为 `LEGACY`,其他为 `PENDING`;新增日期、收货人/时间和 FIFO 历史列均为空。新 CHECK 约束已核对;未自动授予任何普通角色。
- 后端已按原 Python 环境与 `--env=dev` 恢复,MySQL、Redis连接正常,`/docs` HTTP200;未登录访问收货接口返回业务401。没有修改 `.env.dev`。
- 实际恢复的隔离 MySQL 库还运行了完整业务服务验收:两托100包、原标签/日期、在途禁止入隧、无现场权限拒绝、确认收货、跳过原因校验、同隧道跨批使用、20包使用/80包原托退回、原标签快照不变、收货幂等不恢复已消耗数量,以及三条独立连接同时重放同键只扣减一次。数据仅新建于隔离库;另有一组新建夹具使用正式scope验证两托并发,排除测试批次总锁掩盖冲突,未向实际开发库灌入测试业务。
- 真实 MySQL 验收同时发现并修复入隧事务结束后访问过期 ORM 属性导致的 `MissingGreenlet`;ID及返回模型现在在事务结束前取值。验收使用与应用一致的提交后属性过期行为。
备份与验收产物位于系统临时目录,可能被系统清理;需长期留存时应转存到受控备份位置,不提交仓库。隔离演练库暂保留用于复核,不接业务服务。恢复到业务库将覆盖迁移后变化,必须另行确认停写及恢复目标;失败时不得直接重跑增量补丁。
### 开发库只读预检记录(2026-10-08)
使用 `.env.dev` 指向的 `127.0.0.1:3306/shierfeng-fastapi`,在只读事务中查询版本、结构元数据和收货菜单冲突,随后回滚并关闭连接;未执行备份、DDL、业务数据写入、角色授权或服务重启。
- 实际版本:MySQL 8.0.36;相关业务表均为 InnoDB。
- `biz_test_batch`、`biz_tunnel`、库存隧道/溯源列和事件隧道/溯源快照列已存在。
- `biz_filling_record.production_date/expiry_date` 尚不存在。
- 发货单5个收货字段以及 `biz_pallet_inventory.site_arrived_at` 尚不存在。
- 父菜单510为预期的 `business:shipment:list` 页面;菜单1119、`business:shipment:receive` 权限及1119的孤立角色授权均不存在。
- 后端9099端口仍有服务监听。执行迁移前仍须获准停写、完成备份与恢复方案核对;上述检查不代表已进入维护窗口,也不替代脚本内完整前置约束检查。
上述为执行前的只读预检历史记录;实际执行结果见上节。不要直接重跑已有补丁或初始化脚本。其他环境必须重新核对现状。
### 迁移顺序与风险
1. 只读核对目标库、MySQL版本、既有补丁和约束;不要仅凭文件存在判断已迁移。旧库需先具备 `20260922_test_data_batch_mysql.sql` 及 `20261005_tunnel_material_flow_mysql.sql` 的结构,不可重复执行已落库补丁。
2. 确认维护窗口、停写,完整备份结构、业务数据、菜单及角色关联,并验证恢复方案。
3. 执行 `sql/patches/20261008_material_dates_mysql.sql`:新增生产日期、有效期及日期关系约束;历史记录保持空值,不按装填时间补造日期。
4. 执行 `sql/patches/20261008_shipment_receipt_mysql.sql`:新增收货状态、人员、时间和幂等字段,库存实际收货时间,以及 `IN_TRANSIT` 状态和 `SHIPMENT_RECEIVED` 事件。旧已确认发货单标记 `LEGACY`;原库存位置、状态、包数和历史事件不变。
5. 执行 `sql/patches/20261008_usage_fifo_mysql.sql`,为使用记录和托盘事件新增可空原因及推荐快照列;旧记录保持空值。三份脚本均应在代码启用前完成。
6. 新权限 `business:shipment:receive` 不自动授予普通角色。先核对菜单 ID 1119 未被其他菜单占用;按职责授权发货页面入口、收货核对按钮及目标现场数据范围。接收方不需要来源仓库权限,发货方原双范围校验不放宽。
7. 逐项核对表结构、约束、历史保留结果后再发布三端代码。旧数据库直接运行新后端会因缺少列而报错,不能仅重启服务解决。MySQL DDL 隐式提交,出错必须停止;不可依赖 `ROLLBACK` 恢复整个迁移。
新建空库可使用已同步的 `sql/shierfeng-fastapi.sql`;禁止将包含重建语句的初始化脚本用于现有业务库升级。
### 新流程与历史兼容
- 新发货:仓库有料 → 出库在途 → 接收方逐托核对、整单确认 → 现场未使用 → 扫码入隧 → 实际使用 → 原托盘余料退回。未收货不可入隧;未全部核对不能确认,不支持部分收货。
- 收货不新增托盘号、生产批号或装填记录,不增加/扣减发货快照数量;重复确认采用独立幂等键,不把后续已消耗的余量恢复。
- 标签从原发货装填快照读取品种、生产批号、日期、数量;可补打,历史日期显示“未登记”。印刷托盘号可作为扫码入口,写操作统一使用返回的规范二维码标识。
- 混批与隧道溯源保留在后台及报表;普通操作页面不以内部溯源号代替生产批号。
- 先进先出采用已批准的“允许跳过但必须记录原因”:入隧和实际使用均在后端锁定原业务记录后用当前读复核同现场、同品种的可用候选;其他隧道或旧溯源周期物料不推荐。存在严格更早的实际到货候选时,必须填写不超过500字的原因,原因和推荐托盘/批号/数量/到货时间快照随原事件及使用记录提交。相同到货时间不强排ID;当前历史到货时间未知时不伪造先后,保留人工核对提示。
- FIFO 原因参与幂等摘要;无原因时保持旧摘要兼容。已成功请求重试直接返回原结果,不因候选变化再次扣减。仅服务端明确返回 `code=500, data=FIFO_SKIP_REASON_REQUIRED` 的已回滚请求可解除本机对应凭据以便补原因;网络结果未知和其他失败继续沿用原payload/key。
- 候选当前读使用 `FOR UPDATE NOWAIT`,锁冲突不会跳过早到候选或持有现场/隧道锁等待候选锁。仅 MySQL 3572/1213/1205 触发最多3次整事务原键重试,每次先完成回滚;断连等提交结果未知错误不会自动重放。持续繁忙时仍需客户端按原键恢复核对。
- APP 的 HTTP 非2xx或不完整业务结果不视为成功,入隧、使用及恢复重试均在确认结果后才解除原凭据。PC 成功回调仅清理与该请求完全一致的持久化记录,避免旧页面响应删除新操作;恢复时已确认的 FIFO 必填要求,不会因推荐刷新失败或延迟而丢失。
### 运行态验收门禁
迁移获批且完成后,用获准测试批次验证:两托各100包 → 标签与收货核对 → 同隧道跨批使用 → 20包使用/80包原托退回 → 原批号不变;同时验证重复点击、断网结果未知后重试、无现场权限、收货前使用、漏扫与错扫均被正确处理。核对四类隧道报表和两端日期展示。
本机隔离 MySQL 已验证完整流程、同键并发,以及正式scope两托交错入隧/使用的锁冲突回滚重试。普通角色菜单配置、打印机出纸和东集 CRUISE Gep/D737P_V1.0.5 实机 HID/回车行为仍需现场验收;构建成功不能替代设备验证,未接入设备原生广播 SDK。
## 适用范围与当前状态 ## 适用范围与当前状态
本文适用于 MySQL 增量补丁: 本文适用于 MySQL 增量补丁:
@@ -76,7 +136,7 @@ MySQL DDL 会隐式提交,因此必须将补丁的 DDL、DML 分节执行,
本地环境已使用独立查询参数验证管理员 `/getRouters`:包含 3 个新业务目录和 17 个业务页面,不包含 AI 管理或“施尔丰仓库”。该结果不等于已完成 Redis 清理,也不代表普通角色已完成授权验收。 本地环境已使用独立查询参数验证管理员 `/getRouters`:包含 3 个新业务目录和 17 个业务页面,不包含 AI 管理或“施尔丰仓库”。该结果不等于已完成 Redis 清理,也不代表普通角色已完成授权验收。
## 本轮未验证项 ## 2026-07-27 当时未验证项(历史记录)
以下事项需要在后续真实环境中验证,本轮没有完成: 以下事项需要在后续真实环境中验证,本轮没有完成:
@@ -0,0 +1,136 @@
# 会议物料流转缺口补齐 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 完整补齐会议纪要中现有三个业务模块的缺口,并对齐 PC、APP/PDA 与后端。
**Architecture:** 复用现有 controller/service/dao/vo/do 分层、库存状态机、作用域与事件流水。报表和 APP 接口纠偏先独立完成;日期、收货与 FIFO 在共享模型和迁移中顺序集成。
**Tech Stack:** FastAPI、SQLAlchemy、Pydantic、MySQL、Vue 3、Element Plus、uni-app、Python unittest、Node 20。
**Spec:** docs/superpowers/specs/2026-10-08-meeting-flow-completion-design.md
## Global Constraints
- 托盘号沿用发货方录入值;生产批号、托盘号和内部溯源号互不替代。
- 正式数据与测试批次隔离;权限、仓库/现场数据范围、事务锁、幂等和事件流水继续在后端执行。
- 不安装依赖、不修改运行环境、不修改无关文件、不提交既有暂存内容。
- 仅准备初始化 SQL 和增量迁移;连接真实数据库执行结构变更、补数或业务写入前另行获得明确确认。
## Task 1: 隧道报表与混批明细
**Files:** backend module_business/dao/report_dao.py、entity/vo/report_vo.py、service/report_service.py;frontend src/views/report;相关测试。
**Interfaces:** 复用现有 tunnel_id、trace_no、batch_no_snapshot;增加 camelCase 隧道筛选和名称字段;保持所有既有报表路径。
- [x] 用数据库查询生成或隔离内存数据测试证明:line_id=NULL 的使用记录仍出现,跨测试批次关联不泄漏。
- [x] 先运行新增测试确认失败,再修改产线可选关联、隧道与溯源筛选/输出、退回快照。
- [x] PC 报表改为可读隧道编号/名称,提供同隧道/同溯源关系的多批次明细,不混加使用量与退回量。
- [x] 运行 `python -B -m unittest tests.module_business.test_report_service -q` 及新增专项测试;Node 20 运行 PC 契约测试。
## Task 2: APP/PDA 隧道操作
**Files:** app src/api/business/usage.js、src/pages/business/usage/index.vue、tests 下专项测试。
**Interfaces:** GET /business/usage/tunnel-options?siteId;POST /business/usage/enter-tunnel;POST /business/usage {qrToken,tunnelId,lineId?,usedPackages,idempotencyKey}。
- [x] 用真实页面脚本/API封装的行为测试覆盖未入隧不能使用、选隧道、失败重试键不变、切换输入旧响应无效、产线可空。
- [x] 运行新增测试确认旧实现失败。
- [x] 对齐后端接口,入隧独立提交;不在前端推算最终库存;内部溯源码不占普通操作主界面。
- [x] Node 20 运行 APP 契约测试、H5 与 APP 资源构建。
## Task 3: 批次日期与完整标签
**Files:** operation_do.py、operation_vo.py、filling_service.py、各扫描响应、PC/APP filling 页面、PC shipment 标签、SQL与测试。
**Interfaces:** productionDate、expiryDate(YYYY-MM-DD);历史读取可空,新增录入必须完整且有效期不早于生产日期。
- [x] 写日期缺失、倒置、合法日期与幂等内容变化的失败测试。
- [x] 录入并保留原生产批次,日期参与请求摘要;扫描与发货标签读同一装填记录。
- [x] 实现发货标签,不以操作日期推导历史生产日期。
- [x] 运行 filling、shipment、模型与PC/APP相关测试。
## Task 4: 接收核对闭环
**Files:** shipment controller/service/dao/model、state machine、PC/APP shipment 页面、权限 SQL、事件流水与相关测试。
**Interfaces:** 接收确认附带原单据与核对托盘集合、独立幂等键;服务端复核目标现场权限和全部物料身份。
- [x] 写发货未接收不可入隧、越权接收、漏扫/错扫、重复确认、同键不同内容的失败测试。
- [x] 增加接收动作与时间/操作人/状态留痕;接收不新增托盘、批号或重复扣减包数。
- [x] 同步PC/PDA状态显示、按钮权限及扫码核对交互。
- [x] 准备历史数据兼容迁移和回滚/备份说明,执行前停在确认门。
- [x] 运行 shipment、usage、return、状态机、权限、测试批次图与清理回归测试。
## Task 5: 印刷托盘号解析
**Files:** inventory DAO、业务扫描服务/响应、PC/APP扫码消费端与测试。
**Interfaces:** 扫描入口接受二维码固定标识或印刷托盘号,返回 canonical qrToken;写命令保持二维码身份一致。
- [x] 写二维码输入、托盘号输入、两者歧义、跨范围/跨批次的失败测试。
- [x] 服务端解析并执行原业务权限检查;客户端提交规范化标识。
- [x] 运行五类扫码服务与PC/APP组件行为测试。
## Task 6: 先到先用与界面简化
**Files:** usage service/dao/vo、PC/APP usage、相关测试。
**Interfaces:** 排序依据实际接收时间;同现场同品种。用户已批准“允许跳过但必须记录原因”。入隧与使用写命令新增可选 `fifoSkipReason`(去首尾空白,最多500字);服务端在原锁与幂等流程内复核,仅存在严格更早的已知到货候选时要求原因。同时间不按ID强迫先后;历史到货未知时保留人工核对提示。原因及推荐快照随原事件/使用记录同事务保存。
- [x] 完成只读推荐边界测试:实际到货顺序、部分使用、同现场同品种、在途/停用/零库存排除、测试隔离、旧溯源周期排除和历史时间未知。
- [x] 实现推荐提示,保留权限与幂等;界面显示推荐托盘,不暴露长内部码。
- [x] 用户批准允许跳过但必须记录原因;完成两端输入/恢复、后端入隧及使用复核、原因与推荐快照同事务留痕和增量迁移。
- [x] 运行 usage、return 与PC/APP行为测试;退回清空本轮到货时间,新收货记录实际时间。
## Task 7: 全量覆盖审计与迁移交付
- [x] 逐条对照设计,独立复查数据闭环、权限、测试隔离、重试与历史兼容性。
- [x] 运行业务单元测试、PC与APP契约测试、构建;记录真实命令及结果。
- [x] 获得“批准开发库迁移,FIFO按推荐规则执行”授权后,完成停写、43表完整备份、隔离恢复演练、三份补丁试跑、开发库迁移与历史数据摘要核对、后端恢复。
- [x] 更新部署文档,明确实机扫码和HTTP联调尚未覆盖的部分。
## 首轮执行记录(迁移授权前,历史)
- 已确认工作分支为 codex/test-data-batch-cleanup;当前未提交隧道改动是本次基础,不回滚或复制丢失。
- 基线:后端 report_service + tunnel_flow_contract 共36项通过。
- 默认 shell Node 是16;现有 Node20 位于 /Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin,后续显式使用,不安装或修改全局环境。
- 用户已要求直接实施评估中缺口;唯一待业务选择项为FIFO执行强度。代码之外的数据库执行仍需独立确认。
- 726项后端业务回归通过;新增收货读取/权限/幂等24项包含SQLite与ASGI验证,不等于真实MySQL并发验证。
- 复查补上退回确认事件的原隧道字段;回归先复现缺失,再验证48项退回服务测试通过。
- 两端未知请求凭据落盘、离页恢复与可选产线失败降级均已有真实页面行为测试。PC静态使用断言改为执行行为套件,避免旧源码匹配要求与安全重试冲突。
- 原许可证、登录页面、缓存/日志和既有隧道改动均保留;本轮未提交、暂存、推送、执行迁移、授角色或重启服务。
## 首轮集成验证记录(2026-10-08,迁移授权前)
- 后端:`LOG_FILE_ENABLED=false PYTHONDONTWRITEBYTECODE=1 /Users/jiaolongyan/miniconda3/envs/ShiErFeng/bin/python -B -m unittest discover -s tests/module_business -q`,727项通过。
- PC、APP分别在项目目录,使用现有Node20执行 `npm run test:business`,分别208项、75项通过;PC套件含两端收货组件行为测试。
- PC `npm run build:prod -- --outDir /private/tmp/shierfeng-meeting-pc.kB6NUN` 退出0。
- APP `UNI_OUTPUT_DIR=/private/tmp/shierfeng-meeting-app.oow8jU npm run build:app` 退出0。此为APP资源构建,不是签名APK;未覆盖当前 `dist/dev/app`。
- 主线程修改涉及的收货/FIFO分层文件、库存DAO、报表DAO、装填/入库/发货/使用/退回服务及物料模型定向Ruff检查通过;限定任务路径 `git diff --check` 通过。
- 审查发现的旧溯源FIFO候选已修复:已入隧候选必须匹配当前隧道和已验证trace,未知数量统计复用同一过滤;先观察失败,再运行7项FIFO测试通过,并经只读复核关闭问题。
- 未执行真实MySQL迁移、行锁竞争验收、生产HTTP联调、角色授予、打印机和PDA实机测试。迁移和FIFO执行强度仍待用户明确选择,不能将这些状态写为已完成。
- 最终前端独立复审无Critical/Important;补验两端×账号/批次切换×恢复/人工解除的8种交错场景通过。人工解除仅清理核对后的本机待确认记录,不发业务写请求,不宣称原操作成功。
## 数据库只读预检补充(2026-10-08)
- 继续轮完成了新证据核对,不是重复报告状态:实际开发目标为 `127.0.0.1:3306/shierfeng-fastapi`,MySQL 8.0.36,已有测试批次和隧道结构。
- 两个日期列、发货单5个收货列、库存实际收货时间列尚未落库;收货菜单1119和同名权限均未占用,也无1119孤立角色授权。
- 查询采用只读事务,仅结构元数据和菜单冲突检查,未做备份、DDL、业务写入、授权或重启。后端9099仍有监听,不能在未获准停写时执行补丁。
- 余下目标仍依赖用户确认两份增量迁移的维护窗口,以及FIFO跳过策略;未将自动目标续跑当作数据库写入授权。
## 迁移授权后实施与最终验证(2026-10-08)
本节是用户明确“批准开发库迁移,FIFO按推荐规则执行”后的当前状态;上文待授权、未迁移等描述保留为历史,不代表仍需重复审批。
- 已对本机 `127.0.0.1:3306/shierfeng-fastapi` 完成停写、43表完整备份、隔离库恢复及逐行摘要比对、三份补丁试跑、开发库迁移、原数据摘要核对和9099服务恢复。依次执行日期、收货、FIFO补丁;没有重跑初始化、清空业务表或自动授予普通角色。备份路径及SHA-256见 `docs/pallet-workflow-deployment.md`。
- FIFO已实现为“允许跳过严格更早到货的可用托盘,但必须填写最多500字原因”。入隧、使用两个写入口强制校验;原因及推荐快照同事务保存,参与幂等摘要;同时间和历史未知时间不伪造顺序。PC、APP均接入。
- 真实MySQL发现的提交/回滚后ORM过期属性读取已修复;返回值和ID在事务结束前取值。FIFO候选改为 `FOR UPDATE NOWAIT`,只对3572/1213/1205最多三次整事务原键重试,避免反向锁等待且不跳过早到候选。
- 前端审查发现的3项问题已补回归并修复:APP无业务code的HTTP5xx及不完整结果误清凭据、PC跨页面旧成功删除新待确认记录、PC恢复FIFO拒绝后推荐失败丢失已知必填要求。
- 主线程在最终代码运行后端 `LOG_FILE_ENABLED=false PYTHONDONTWRITEBYTECODE=1 /Users/jiaolongyan/miniconda3/envs/ShiErFeng/bin/python -B -m unittest discover -s tests/module_business -q`:741项,0失败,其中2项需显式隔离库而跳过。出现一条asyncio慢任务警告,不影响测试结果。
- 随后在已恢复的本机隔离库,设置 `SHIERFENG_MYSQL_FLOW_TEST_DATABASE=shierfeng_rehearsal_20261008_6ce_sy6c`,使用同一Python、`LOG_FILE_ENABLED=false DB_ECHO=false PYTHONDONTWRITEBYTECODE=1` 执行 `-m unittest discover -s tests/module_business -p test_material_flow_mysql.py -v`:2项均通过。覆盖完整物料流程、四类报表、三连接同键不重复扣减,以及正式scope两托交错入隧/使用的真实锁冲突回滚重试。未写实际开发库业务数据。
- 两端分别使用 `PATH=/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin:$PATH npm run test:business`:PC 243/243、APP 144/144通过,无跳过。
- PC `npm run build:prod -- --outDir /private/tmp/shierfeng-fifo-final-pc.kMm0to` 退出0;APP `UNI_OUTPUT_DIR=/private/tmp/shierfeng-fifo-final-app.BepwcI npm run build:app` 退出0,均使用上述Node20。构建到独立临时目录,未覆盖用户当前 `dist/dev/app`;APP产物为资源包,不是签名APK。
- 最终FIFO生产文件定向Ruff通过,限定任务路径 `git diff --check` 通过。`GET /docs` 返回HTTP200;未登录收货列表返回业务401,验证运行态及访问保护正常。
- 独立复审已关闭后端候选锁顺序及部署清单2项、前端F1/F2/F3三项问题,限定范围内无剩余阻断项;复审另行实跑后端11项及3组补充检查、PC 12项、APP 42项定向回归。没有用审查报告替代上述主线程最终全套验证。
- 普通角色配置、真实登录账号的HTTP写入联调、打印机及东集PDA实机仍未验收;未新增依赖、未改生产环境、未暂存/提交/推送,原有许可证、登录页、日志、缓存等改动均保留。
@@ -0,0 +1,44 @@
# 会议物料流转缺口补齐设计
## 目标与依据
依据 10 月 4 日会议纪要及用户要求“直接修改所缺的相关功能”,在现有基础资料维护、业务管理、报表查询三个一级模块内补齐功能,不重建业务系统。
## 不变量
- 托盘号沿用发货方录入值;生产批号、托盘号和内部溯源号互不替代。
- 新装填固定 100 包;使用按实际包数扣减;余料退回不换托盘、不换原物料批次。
- 正式数据与测试批次隔离;权限、仓库/现场数据范围、事务锁、幂等和事件流水继续在后端执行。
- 不安装依赖、不修改运行环境、不修改无关文件、不提交既有暂存内容。
- 仅准备初始化 SQL 和增量迁移;连接真实数据库执行结构变更、补数或业务写入前另行获得明确确认。
- 在当前 codex/test-data-batch-cleanup 工作区增量修改,保留已有未提交隧道功能,不另建遗漏这些修改的工作树。
## 功能设计
### 1. 报表与混批追溯
消耗报表允许产线为空,以隧道为主要查询维度。库存、消耗、退回、托盘追溯返回隧道编号/名称与内部溯源关系;按同一溯源关系展示多托盘、多批次的使用与退回明细,避免将不同业务阶段的数量重复相加。普通操作页显示业务名称,内部码仅在查询/详情中使用。
### 2. APP/PDA
对齐现有后端 tunnel-options、enter-tunnel 和 usage 接口。先扫码、选择隧道、确认入隧,再提交实际使用量;产线可选。入隧与使用独立幂等键,失败重试保持原键,改动业务输入后更新键。继续支持现有扫描头与手工输入,不新增原生 SDK。
### 3. 批次信息与标签
装填记录增加 production_date(生产日期)和 expiry_date(有效期至),日期不得倒置。历史记录允许为空并显示“未登记”,不得用装填时间伪造生产日期。PC 与 APP 新录入一致;扫描、单据详情和相关报表读同一记录。发货标签包含原托盘号、客户/现场、品种、批号、生产日期、有效期、包数以及固定二维码。
### 4. 发货与接收核对
在现有发货模块内增加接收方核对确认,不新建一级菜单。发货确认与接收确认分别留痕,接收按原发货单的托盘和物料快照处理,不能新编托盘/批号;未核对接收的物料不能投入使用。接收权限必须受目标现场范围控制。历史已发货业务通过明确迁移策略保持可用,不重扣库存。
### 5. 无二维码录入
允许在已有扫码入口输入印刷托盘号,由服务端在当前测试批次与业务数据范围内解析。实际提交使用规范二维码标识,避免同一托盘用两种输入生成不同业务身份。若托盘号与其他二维码标识冲突,明确拒绝歧义,不能随意取第一条。
### 6. 先到先用
同现场、同品种、可用库存按真实接收时间排序推荐;用户已批准“允许跳过但必须记录原因”。入隧和实际使用均由后端复核,存在严格更早候选时要求最多500字的原因,随事件/使用记录保存推荐快照并参与幂等摘要。到货时间相同或当前历史时间未知时不以托盘ID强排先后,未知时间提示人工核对。保留跨托盘、跨批次混用及余料原托退回。
## 验收
先写回归测试再实现,覆盖无产线报表、数据隔离、混批追溯、日期边界、二维码/托盘号歧义、收货权限/状态/幂等与 FIFO。PC 和 APP 运行现有契约测试及构建。最终单独列出已验证代码、待执行迁移和待实机验收,不把它们混为完成。
+1 -1
View File
@@ -3,7 +3,7 @@
"version": "1.9.0", "version": "1.9.0",
"packageManager": "pnpm@10.28.1", "packageManager": "pnpm@10.28.1",
"scripts": { "scripts": {
"test:business": "node --test tests/business-contract.test.mjs", "test:business": "node --test tests/*.test.mjs",
"dev": "uni -p mp-weixin", "dev": "uni -p mp-weixin",
"build": "npm run build:mp-weixin", "build": "npm run build:mp-weixin",
"dev:app": "uni -p app", "dev:app": "uni -p app",
@@ -17,7 +17,7 @@ export function getFillingStrainOptions() {
export function scanFillingPallet(qrToken) { export function scanFillingPallet(qrToken) {
return request({ return request({
url: "/business/filling/scan/" + qrToken, url: "/business/filling/scan/" + encodeURIComponent(qrToken),
method: "get", method: "get",
}); });
} }
@@ -17,7 +17,7 @@ export function getInboundWarehouseOptions() {
export function scanInboundPallet(qrToken, query) { export function scanInboundPallet(qrToken, query) {
return request({ return request({
url: "/business/inbound/scan/" + qrToken, url: "/business/inbound/scan/" + encodeURIComponent(qrToken),
method: "get", method: "get",
params: query, params: query,
}); });
@@ -17,7 +17,7 @@ export function getReturnWarehouseOptions() {
export function scanReturnPallet(qrToken, query) { export function scanReturnPallet(qrToken, query) {
return request({ return request({
url: "/business/return/scan/" + qrToken, url: "/business/return/scan/" + encodeURIComponent(qrToken),
method: "get", method: "get",
params: query, params: query,
}); });
@@ -24,7 +24,7 @@ export function getShipmentSiteOptions() {
export function scanShipmentPallet(qrToken, query) { export function scanShipmentPallet(qrToken, query) {
return request({ return request({
url: "/business/shipment/scan/" + qrToken, url: "/business/shipment/scan/" + encodeURIComponent(qrToken),
method: "get", method: "get",
params: query, params: query,
}); });
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
export function listShipmentReceipts(query) {
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
}
export function getShipmentReceipt(shipmentId) {
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
}
export function scanShipmentReceipt(shipmentId, identifier) {
return request({
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
method: 'get'
})
}
export function confirmShipmentReceipt(shipmentId, data) {
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
}
@@ -16,13 +16,37 @@ export function getUsageLineOptions(siteId) {
}); });
} }
export function getUsageTunnelOptions(siteId) {
return request({
url: "/business/usage/tunnel-options",
method: "get",
params: { siteId },
});
}
export function enterUsageTunnel(data) {
return request({
url: "/business/usage/enter-tunnel",
method: "post",
data,
});
}
export function scanUsagePallet(qrToken) { export function scanUsagePallet(qrToken) {
return request({ return request({
url: "/business/usage/scan/" + qrToken, url: "/business/usage/scan/" + encodeURIComponent(qrToken),
method: "get", method: "get",
}); });
} }
export function getUsageFifoRecommendation(qrToken, tunnelId) {
return request({
url: "/business/usage-fifo/recommendation",
method: "get",
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) },
});
}
export function getUsage(usageId) { export function getUsage(usageId) {
return request({ return request({
url: "/business/usage/" + usageId, url: "/business/usage/" + usageId,
@@ -9,7 +9,7 @@
maxlength="128" maxlength="128"
:focus="focusRequested" :focus="focusRequested"
:disabled="disabled || loading" :disabled="disabled || loading"
placeholder="请扫描或输入托盘二维码后按回车" placeholder="扫描二维码或输入托盘号后按回车"
@confirm="handleSubmit" @confirm="handleSubmit"
@input="clearError" @input="clearError"
/> />
@@ -0,0 +1,302 @@
<template>
<view v-if="canReceive" class="space-y-4">
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">收货单</text>
<picker :range="receiptStateOptions" range-key="label" :value="receiptStateIndex" @change="changeReceiptState">
<view class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ receiptStateOptions[receiptStateIndex].label }}</view>
</picker>
<input v-model="queryParams.shipmentNo" class="mb-3 rounded-xl bg-gray-50 px-3 py-3 text-sm" maxlength="64" placeholder="输入原发货单号" confirm-type="search" @confirm="handleQuery" />
<button size="mini" class="rounded-lg bg-blue-50 text-blue-600" :disabled="loading" @click="handleQuery">查询收货</button>
<text v-if="listError" class="mt-3 block text-xs text-red-500">{{ listError }}</text>
<text v-if="loading" class="mt-3 block text-xs text-gray-400">加载中...</text>
<text v-else-if="!receiptList.length" class="mt-3 block text-xs text-gray-400">暂无收货单</text>
<view v-for="row in receiptList" :key="row.shipmentId" class="mt-3 rounded-xl border border-gray-100 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ row.shipmentNo }}</text>
<text class="text-xs text-orange-600">{{ receiptStateLabel(row.receiptState) }}</text>
</view>
<text class="mt-2 block text-xs text-gray-500">仓库 #{{ row.sourceWarehouseId }} → 现场 #{{ row.targetSiteId }}</text>
<text v-if="row.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ row.receivedAt || '未登记' }}</text>
<button size="mini" class="mt-3 rounded-lg bg-orange-50 text-orange-600" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</button>
</view>
<view class="mt-3 flex items-center justify-between gap-2">
<button size="mini" :disabled="loading || queryParams.pageNum <= 1" @click="changePage(-1)">上一页</button>
<text class="text-xs text-gray-500">第 {{ queryParams.pageNum }} 页 · 共 {{ total }} 单</text>
<button size="mini" :disabled="loading || queryParams.pageNum * queryParams.pageSize >= total" @click="changePage(1)">下一页</button>
</view>
</view>
<view v-if="activeShipmentId !== null" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">收货核对</text>
<button size="mini" class="text-xs text-gray-500" @click="closeReceipt">关闭</button>
</view>
<text v-if="detailLoading" class="block text-xs text-gray-400">加载原单明细...</text>
<text v-if="detailError" class="mb-3 block text-xs text-red-500">{{ detailError }}</text>
<button v-if="detailError" size="mini" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</button>
<template v-if="receiptDetail">
<text class="block text-sm font-medium text-gray-700">{{ receiptDetail.shipmentNo }} · {{ receiptStateLabel(receiptDetail.receiptState) }}</text>
<text class="mt-1 block text-xs text-gray-500">目标现场 #{{ receiptDetail.targetSiteId }}</text>
<text v-if="receiptDetail.receiptState === 'RECEIVED'" class="mt-1 block text-xs text-gray-500">收货时间:{{ receiptDetail.receivedAt || '未登记' }}</text>
<text v-if="receiptDetail.receiptState === 'LEGACY'" class="mt-3 block text-xs text-gray-500">历史未登记收货:仅供查询原单明细,不补造收货时间。</text>
<view v-if="receiptDetail.receiptState === 'PENDING'" class="mt-3">
<PalletScanInput :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
<text class="mt-2 block text-xs text-gray-500">逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托,全部核对后才可确认。</text>
</view>
<text v-if="scanError" class="mt-3 block text-xs text-red-500">{{ scanError }}</text>
<text v-if="!receiptItems.length" class="mt-3 block text-xs text-red-500">原单无托盘明细,无法确认收货</text>
<view v-for="item in receiptItems" :key="item.palletId" class="mt-3 rounded-xl bg-gray-50 p-3">
<view class="flex items-center justify-between gap-2">
<text class="text-sm font-medium text-gray-700">{{ item.palletCode }}</text>
<text v-if="receiptDetail.receiptState === 'PENDING'" class="text-xs text-orange-600">{{ scannedPalletIds.includes(item.palletId) ? '已核对' : '待扫描' }}</text>
</view>
<text class="mt-2 block text-xs text-gray-600">品种:{{ item.strainName || '未登记' }} · 原发货 {{ item.packageQty }} 包</text>
<text class="mt-1 block text-xs text-gray-500">批号:{{ item.batchNo || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">生产日期:{{ item.productionDate || '未登记' }}</text>
<text class="mt-1 block text-xs text-gray-500">失效日期:{{ item.expiryDate || '未登记' }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING' && scannedPalletIds.includes(item.palletId)" size="mini" class="mt-2 text-xs text-red-500" :disabled="!canScan || scanLoading" @click="removeScannedPallet(item.palletId)">撤销核对</button>
</view>
<text v-if="confirmError" class="mt-3 block text-xs text-red-500">{{ confirmError }}</text>
<button v-if="receiptDetail.receiptState === 'PENDING'" class="mt-4 rounded-xl bg-orange-500 py-3 text-sm text-white disabled:bg-gray-300" :disabled="!canConfirmReceipt" :loading="confirmLoading" @click="confirmReceipt">确认收货</button>
</template>
</view>
</view>
</template>
<script setup>
import { computed, getCurrentInstance, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
import PalletScanInput from '@/components/business/PalletScanInput.vue'
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
import { createIdempotencyKey } from '@/utils/palletScan'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['received'])
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
const receiptList = ref([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const activeShipmentId = ref(null)
const receiptDetail = ref(null)
const detailLoading = ref(false)
const detailError = ref('')
const scannedPalletIds = ref([])
const scanLoading = ref(false)
const scanError = ref('')
const confirmLoading = ref(false)
const confirmError = ref('')
const receiptAccepted = ref(false)
let session = 0
let listGeneration = 0
let suspended = false
let idempotencyKey = ''
const receiptItems = computed(() => receiptDetail.value?.items || [])
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
function receiptStateLabel(state) {
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback
}
function isCurrent(currentSession) {
return currentSession === session && !suspended && canReceive.value
}
async function loadReceipts() {
if (!canReceive.value || suspended) return
const generation = ++listGeneration
loading.value = true
listError.value = ''
try {
const response = await listShipmentReceipts({ ...queryParams })
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = Array.isArray(response.rows) ? response.rows : []
total.value = response.total || 0
} catch (error) {
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = []
total.value = 0
listError.value = errorMessage(error, '收货列表加载失败,请重试')
} finally {
if (generation === listGeneration) loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
return loadReceipts()
}
function closeReceipt() {
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
session += 1
activeShipmentId.value = null
receiptDetail.value = null
scannedPalletIds.value = []
detailLoading.value = false
scanLoading.value = false
confirmLoading.value = false
receiptAccepted.value = false
detailError.value = ''
scanError.value = ''
confirmError.value = ''
idempotencyKey = ''
}
async function loadReceiptDetail(shipmentId, currentSession) {
detailLoading.value = true
detailError.value = ''
try {
const response = await getShipmentReceipt(shipmentId)
if (!isCurrent(currentSession)) return
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
throw new Error('收货详情与当前单据不一致,请重新打开')
}
receiptDetail.value = response.data
} catch (error) {
if (!isCurrent(currentSession)) return
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
} finally {
if (isCurrent(currentSession)) detailLoading.value = false
}
}
async function openReceipt(row) {
if (!canReceive.value || suspended || row.shipmentId == null) return
closeReceipt()
activeShipmentId.value = row.shipmentId
await loadReceiptDetail(row.shipmentId, session)
}
function retryDetail() {
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
return loadReceiptDetail(activeShipmentId.value, session)
}
async function handleReceiptScan(identifier) {
if (!canScan.value || scanLoading.value) return
const value = String(identifier ?? '').trim()
if (!value) {
scanError.value = '请扫描二维码或输入托盘号'
return
}
const currentSession = session
scanLoading.value = true
scanError.value = ''
try {
const response = await scanShipmentReceipt(activeShipmentId.value, value)
if (!isCurrent(currentSession)) return
const scanned = response.data
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
scanError.value = '该托盘或装填记录不属于当前发货单'
return
}
if (scannedPalletIds.value.includes(original.palletId)) {
scanError.value = '该托盘已核对,请勿重复扫描'
return
}
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
scannedPalletIds.value.push(original.palletId)
idempotencyKey = ''
confirmError.value = ''
} catch (error) {
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
} finally {
if (isCurrent(currentSession)) scanLoading.value = false
}
}
function removeScannedPallet(palletId) {
if (!canScan.value || scanLoading.value) return
if (!scannedPalletIds.value.includes(palletId)) return
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
idempotencyKey = ''
confirmError.value = ''
}
async function confirmReceipt() {
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
if (!canConfirmReceipt.value) {
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
return
}
const currentSession = session
const shipmentId = activeShipmentId.value
confirmLoading.value = true
confirmError.value = ''
try {
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
const response = await confirmShipmentReceipt(shipmentId, {
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
})
if (!isCurrent(currentSession)) return
receiptAccepted.value = true
proxy.$modal.msgSuccess(response.msg || '收货成功')
emit('received')
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
} catch (error) {
if (isCurrent(currentSession)) {
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
}
} finally {
if (isCurrent(currentSession)) confirmLoading.value = false
}
}
function suspendPanel() {
suspended = true
listGeneration += 1
loading.value = false
closeReceipt()
}
function resumePanel() {
if (!suspended) return
suspended = false
loadReceipts()
}
onMounted(loadReceipts)
onBeforeUnmount(suspendPanel)
const receiptStateOptions = [
{ label: '待收货', value: 'PENDING' },
{ label: '已收货', value: 'RECEIVED' },
{ label: '历史未登记收货', value: 'LEGACY' },
{ label: '全部', value: undefined }
]
const receiptStateIndex = computed(() => receiptStateOptions.findIndex(item => item.value === queryParams.receiptState))
function changeReceiptState(event) {
const option = receiptStateOptions[Number(event.detail.value)]
if (!option) return
queryParams.receiptState = option.value
handleQuery()
}
function changePage(offset) {
const page = queryParams.pageNum + offset
if (loading.value || page < 1 || (page > 1 && (page - 1) * queryParams.pageSize >= total.value)) return
queryParams.pageNum = page
loadReceipts()
}
onShow(resumePanel)
onHide(suspendPanel)
onUnload(suspendPanel)
</script>
@@ -22,6 +22,7 @@ export const businessMenuEntries = [
icon: "i-mdi-truck-delivery", icon: "i-mdi-truck-delivery",
iconClass: "bg-orange-50 text-orange-600", iconClass: "bg-orange-50 text-orange-600",
permission: "business:shipment:list", permission: "business:shipment:list",
permissions: ["business:shipment:list", "business:shipment:receive"],
}, },
{ {
path: "/pages/business/usage/index", path: "/pages/business/usage/index",
@@ -80,6 +80,18 @@
@input="markPayloadChanged" @input="markPayloadChanged"
/> />
</view> </view>
<view>
<text class="mb-2 block text-xs text-gray-500">生产日期</text>
<picker mode="date" :value="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('productionDate', $event)">
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.productionDate || '选择来源生产日期' }}</view>
</picker>
</view>
<view>
<text class="mb-2 block text-xs text-gray-500">有效期至</text>
<picker mode="date" :value="form.expiryDate" :start="form.productionDate" :disabled="operationPending || !scannedPallet" @change="handleMaterialDateChange('expiryDate', $event)">
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">{{ form.expiryDate || '选择有效期截止日期' }}</view>
</picker>
</view>
<view> <view>
<text class="mb-2 block text-xs text-gray-500">包装规格</text> <text class="mb-2 block text-xs text-gray-500">包装规格</text>
<input <input
@@ -97,9 +109,8 @@
v-model="form.initialPackages" v-model="form.initialPackages"
class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700" class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
type="number" type="number"
placeholder="请输入正整数" placeholder="固定 100 包"
:disabled="operationPending || !scannedPallet" disabled
@input="markPayloadChanged"
/> />
<text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400"> <text v-if="scannedPallet" class="mt-1 block text-xs text-gray-400">
托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验 托盘容量 {{ scannedPallet.capacityPackages }} 包,最终容量由服务端校验
@@ -177,8 +188,10 @@ const form = reactive({
warehouseId: null, warehouseId: null,
strainId: null, strainId: null,
batchNo: "", batchNo: "",
productionDate: "",
expiryDate: "",
packageSpec: "", packageSpec: "",
initialPackages: "", initialPackages: 100,
}); });
let listGeneration = 0; let listGeneration = 0;
let scanGeneration = 0; let scanGeneration = 0;
@@ -251,8 +264,10 @@ function resetOperation() {
form.warehouseId = null; form.warehouseId = null;
form.strainId = null; form.strainId = null;
form.batchNo = ""; form.batchNo = "";
form.productionDate = "";
form.expiryDate = "";
form.packageSpec = ""; form.packageSpec = "";
form.initialPackages = ""; form.initialPackages = 100;
scanError.value = ""; scanError.value = "";
formError.value = ""; formError.value = "";
scanLoading.value = false; scanLoading.value = false;
@@ -280,7 +295,7 @@ function handleFillingScan(qrToken) {
flowState: pallet.flowState, flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages, capacityPackages: pallet.capacityPackages,
}; };
form.qrToken = qrToken; form.qrToken = pallet.qrToken || qrToken;
form.warehouseId = pallet.warehouseId; form.warehouseId = pallet.warehouseId;
loadStrainOptions(); loadStrainOptions();
}) })
@@ -313,6 +328,12 @@ function markPayloadChanged() {
if (!submitLoading.value) idempotencyKey.value = ""; if (!submitLoading.value) idempotencyKey.value = "";
} }
function handleMaterialDateChange(field, event) {
if (operationPending.value) return;
form[field] = event.detail.value;
markPayloadChanged();
}
function ensureIdempotencyKey() { function ensureIdempotencyKey() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey(); if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey();
return idempotencyKey.value; return idempotencyKey.value;
@@ -329,8 +350,12 @@ function submitFilling() {
formError.value = "请完整填写菌种、批次和包装规格"; formError.value = "请完整填写菌种、批次和包装规格";
return; return;
} }
if (!Number.isInteger(initialPackages) || initialPackages <= 0) { if (!form.productionDate || !form.expiryDate || form.expiryDate < form.productionDate) {
formError.value = "初始包数必须为正整数"; formError.value = "请填写生产日期和有效期,且有效期不能早于生产日期";
return;
}
if (initialPackages !== 100) {
formError.value = "新装填固定为 100 包";
return; return;
} }
submitLoading.value = true; submitLoading.value = true;
@@ -340,6 +365,8 @@ function submitFilling() {
warehouseId: form.warehouseId, warehouseId: form.warehouseId,
strainId: form.strainId, strainId: form.strainId,
batchNo: form.batchNo.trim(), batchNo: form.batchNo.trim(),
productionDate: form.productionDate,
expiryDate: form.expiryDate,
packageSpec: form.packageSpec.trim(), packageSpec: form.packageSpec.trim(),
initialPackages, initialPackages,
idempotencyKey: ensureIdempotencyKey(), idempotencyKey: ensureIdempotencyKey(),
@@ -4,13 +4,13 @@
<view class="space-y-4 p-4 pb-24"> <view class="space-y-4 p-4 pb-24">
<view class="flex items-center justify-between"> <view class="flex items-center justify-between">
<view> <view>
<text class="block text-xl font-bold text-gray-800">发货出库</text> <text class="block text-xl font-bold text-gray-800">{{ activeTab === 'receipt' ? '收货核对' : '发货出库' }}</text>
<text class="mt-1 block text-xs text-gray-500"> <text class="mt-1 block text-xs text-gray-500">
选择来源仓库和目标现场,扫码创建整托发货单 {{ activeTab === 'receipt' ? '扫描原托盘标签,逐托核对目标现场收货' : '选择来源仓库和目标现场,扫码创建整托发货单' }}
</text> </text>
</view> </view>
<button <button
v-if="canDraft" v-if="canDraft && activeTab === 'shipment'"
class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80" class="rounded-lg bg-orange-500 px-3 py-2 text-xs text-white active:opacity-80"
size="mini" size="mini"
:disabled="operationPending" :disabled="operationPending"
@@ -20,12 +20,17 @@
</button> </button>
</view> </view>
<TestBatchBanner /> <TestBatchBanner />
<view v-if="canReceive" class="flex gap-3">
<view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm"> <button v-if="canShipment" size="mini" :class="activeTab === 'shipment' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'shipment'">发货出库</button>
<text class="text-sm text-gray-500">当前账号没有发货操作权限</text> <button size="mini" :disabled="operationPending || editorVisible" :class="activeTab === 'receipt' ? 'bg-orange-500 text-white' : 'bg-white text-gray-600'" @click="activeTab = 'receipt'">收货核对</button>
</view> </view>
<template v-else> <view v-if="!canPage" class="rounded-2xl bg-white p-6 text-center shadow-sm">
<text class="text-sm text-gray-500">当前账号没有发货或收货权限</text>
</view>
<ShipmentReceiptPanel v-else-if="canReceive && activeTab === 'receipt'" @received="loadShipmentList" />
<template v-else-if="canShipment && activeTab === 'shipment'">
<view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm"> <view v-if="editorVisible" class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-4 flex items-center justify-between"> <view class="mb-4 flex items-center justify-between">
<text class="font-bold text-gray-800"> <text class="font-bold text-gray-800">
@@ -155,8 +160,8 @@
> >
<view class="flex items-center justify-between"> <view class="flex items-center justify-between">
<text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text> <text class="font-medium text-gray-700">{{ row.shipmentNo || `#${row.shipmentId}` }}</text>
<text :class="row.status === 'DRAFT' ? 'text-orange-500' : 'text-green-500'" class="text-xs"> <text :class="row.status === 'DRAFT' || row.receiptState === 'PENDING' ? 'text-orange-500' : row.receiptState === 'LEGACY' ? 'text-gray-500' : 'text-green-500'" class="text-xs">
{{ row.status === "DRAFT" ? "草稿" : "已确认" }} {{ shipmentStatusLabel(row) }}
</text> </text>
</view> </view>
<text class="mt-1 block text-xs text-gray-500"> <text class="mt-1 block text-xs text-gray-500">
@@ -195,6 +200,7 @@ import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app"; import { onShow } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue"; import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue"; import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import ShipmentReceiptPanel from "@/components/business/ShipmentReceiptPanel.vue";
import { import {
addShipment, addShipment,
confirmShipment, confirmShipment,
@@ -229,7 +235,7 @@ const confirmState = reactive({ shipmentId: null, key: "" });
let listGeneration = 0; let listGeneration = 0;
let scanGeneration = 0; let scanGeneration = 0;
const canPage = computed(() => const canShipment = computed(() =>
proxy.$auth.hasPermiOr([ proxy.$auth.hasPermiOr([
"business:shipment:list", "business:shipment:list",
"business:shipment:query", "business:shipment:query",
@@ -238,6 +244,9 @@ const canPage = computed(() =>
"business:shipment:confirm", "business:shipment:confirm",
]), ]),
); );
const canReceive = computed(() => proxy.$auth.hasPermi("business:shipment:receive"));
const canPage = computed(() => canShipment.value || canReceive.value);
const activeTab = ref(canShipment.value ? "shipment" : "receipt");
const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list")); const canList = computed(() => proxy.$auth.hasPermi("business:shipment:list"));
const canDraft = computed(() => const canDraft = computed(() =>
proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]), proxy.$auth.hasPermiAnd(["business:shipment:query", "business:shipment:add"]),
@@ -274,6 +283,11 @@ function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback; return error?.response?.data?.msg || error?.message || fallback;
} }
function shipmentStatusLabel(row) {
if (row.status === "DRAFT") return "草稿";
return { PENDING: "待收货", RECEIVED: "已收货", LEGACY: "历史未登记收货" }[row.receiptState] || "已确认";
}
function warehouseName(id) { function warehouseName(id) {
return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`; return warehouseOptions.value.find((item) => item.id === id)?.name || `仓库 #${id || "-"}`;
} }
@@ -5,7 +5,7 @@
<view> <view>
<text class="block text-xl font-bold text-gray-800">客户现场使用</text> <text class="block text-xl font-bold text-gray-800">客户现场使用</text>
<text class="mt-1 block text-xs text-gray-500"> <text class="mt-1 block text-xs text-gray-500">
扫描现场托盘,首次使用绑定产线并记录本次消耗包数 扫描现场托盘,确认入隧后记录实际使用包数,产线可选
</text> </text>
</view> </view>
<TestBatchBanner /> <TestBatchBanner />
@@ -15,6 +15,25 @@
</view> </view>
<template v-else> <template v-else>
<view v-if="pendingOperations.length || recoveryError || recoveryMessage" class="rounded-2xl bg-amber-50 p-4 shadow-sm">
<text class="block font-bold text-gray-800">提交结果核对</text>
<text v-if="pendingOperations.length" class="mt-2 block text-xs text-gray-600">
以下操作结果尚未确认。恢复重试会核对原操作,请先确认结果再录入下一次使用。
</text>
<view v-for="item in pendingOperations" :key="item.payload.idempotencyKey" class="mt-3">
<text class="block text-sm text-gray-700">
{{ item.palletCode }} · {{ item.tunnelName }} · {{ item.action === "usage" ? `使用 ${item.payload.usedPackages} 包` : "托盘入隧" }}
</text>
<button size="mini" class="mt-2 text-blue-600" :disabled="!canOperate || operationPending" @click="retryPendingOperation(item)">
恢复重试 / 核对
</button>
<button size="mini" class="mt-2 text-gray-600" :disabled="operationPending" @click="releasePendingOperation(item)">
已人工核对,解除待确认
</button>
</view>
<text v-if="recoveryError" class="mt-2 block text-xs text-red-500">{{ recoveryError }}</text>
<text v-if="recoveryMessage" class="mt-2 block text-xs text-gray-700">{{ recoveryMessage }}</text>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm"> <view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="mb-3 flex items-center justify-between"> <view class="mb-3 flex items-center justify-between">
<text class="font-bold text-gray-800">扫描待使用托盘</text> <text class="font-bold text-gray-800">扫描待使用托盘</text>
@@ -44,31 +63,81 @@
<text class="mt-1 block text-xs text-gray-500"> <text class="mt-1 block text-xs text-gray-500">
{{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }} {{ scannedPallet.strainName || scannedPallet.strainCode || "未提供菌种" }} · 批次 {{ scannedPallet.batchNo || "—" }}
</text> </text>
<text class="mt-1 block text-xs text-gray-500">
生产日期:{{ scannedPallet.productionDate || "未登记" }} · 有效期至:{{ scannedPallet.expiryDate || "未登记" }}
</text>
<text class="mt-1 block text-xs text-gray-500"> <text class="mt-1 block text-xs text-gray-500">
当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }} 当前剩余:{{ scannedPallet.remainingPackages }} 包 · 现场 #{{ scannedPallet.siteId }}
</text> </text>
</view> </view>
<view v-if="scannedPallet" class="mt-3 rounded-xl bg-blue-50 p-3">
<view class="flex items-center justify-between">
<text class="text-sm text-gray-700">先到先用建议</text>
<button size="mini" class="text-xs text-blue-600" :disabled="fifoLoading || operationPending" @click="loadFifoRecommendation">
刷新建议
</button>
</view>
<text class="mt-2 block text-xs text-gray-600">{{ fifoLoading ? "建议加载中,提交时由服务端复核" : fifoMessage }}</text>
</view>
</view>
<view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">托盘入隧</text>
<view v-if="tunnelLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
{{ selectedTunnelName }}(已入隧,不可切换)
</view>
<picker
v-else
:range="tunnelPickerOptions"
range-key="label"
:value="tunnelIndex"
:disabled="!canOperate || operationPending || optionLoading || !scannedPallet"
@change="handleTunnelChange"
>
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedTunnelName || "请选择使用隧道" }}
</view>
</picker>
<text v-if="scannedPallet && !tunnelLocked" class="mt-2 block text-xs text-gray-500">
先确认入隧,再提交使用包数。
</text>
<view class="mt-3">
<text class="mb-2 block text-xs text-gray-500">跳过先到托盘原因({{ fifoRequiresSkipReason ? "必填" : "可选" }})</text>
<textarea v-model="form.fifoSkipReason" class="w-full rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"
:maxlength="500" placeholder="跳过先到托盘时请填写原因,最多500字"
:disabled="operationPending || !scannedPallet" @input="markPayloadChanged" />
<text class="mt-1 block text-xs text-gray-400">最多500字;提交时由服务端复核先到先用要求。</text>
</view>
<button
v-if="!tunnelLocked"
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
@click="enterTunnel"
>
{{ entryLoading ? "入隧中" : "确认入隧" }}
</button>
</view> </view>
<view class="rounded-2xl bg-white p-4 shadow-sm"> <view class="rounded-2xl bg-white p-4 shadow-sm">
<text class="mb-3 block font-bold text-gray-800">使用信息</text> <text class="mb-3 block font-bold text-gray-800">使用信息</text>
<view class="mb-3"> <view class="mb-3">
<text class="mb-2 block text-xs text-gray-500">使用产线</text> <text class="mb-2 block text-xs text-gray-500">使用产线(可选)</text>
<view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600"> <view v-if="lineLocked" class="rounded-xl bg-gray-100 px-3 py-3 text-sm text-gray-600">
{{ selectedLineName }}(已由托盘绑定) {{ selectedLineName }}(已由托盘绑定)
</view> </view>
<picker <picker
v-else v-else
:range="lineOptions" :range="linePickerOptions"
range-key="name" range-key="name"
:value="lineIndex" :value="lineIndex"
:disabled="operationPending || optionLoading || !scannedPallet" :disabled="operationPending || lineOptionLoading || !scannedPallet"
@change="handleLineChange" @change="handleLineChange"
> >
<view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700"> <view class="rounded-xl bg-gray-50 px-3 py-3 text-sm text-gray-700">
{{ selectedLineName || "请选择现场产线" }} {{ selectedLineName || "不选择产线" }}
</view> </view>
</picker> </picker>
<text v-if="lineOptionError" class="mt-2 block text-xs text-amber-600">{{ lineOptionError }}</text>
</view> </view>
<view> <view>
<text class="mb-2 block text-xs text-gray-500">本次使用包数</text> <text class="mb-2 block text-xs text-gray-500">本次使用包数</text>
@@ -93,7 +162,7 @@
</view> </view>
<button <button
class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300" class="mt-4 w-full rounded-xl bg-purple-500 py-3 text-sm text-white active:opacity-80 disabled:bg-gray-300"
:disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !!usageResult" :disabled="!canOperate || operationPending || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || !form.tunnelId || !!usageResult"
@click="submitUsage" @click="submitUsage"
> >
{{ submitLoading ? "提交中" : "确认使用" }} {{ submitLoading ? "提交中" : "确认使用" }}
@@ -120,11 +189,15 @@
> >
<text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text> <text class="block font-medium text-gray-700">{{ row.usageNo || `#${row.usageId}` }}</text>
<text class="mt-1 block text-xs text-gray-500"> <text class="mt-1 block text-xs text-gray-500">
托盘 #{{ row.palletId }} · 产线 #{{ row.lineId }} · 使用 {{ row.usedPackages }} 包 托盘 #{{ row.palletId }} · {{ row.tunnelName || row.tunnelCode || (row.tunnelId ? `隧道 #${row.tunnelId}` : "未登记隧道") }} · 使用 {{ row.usedPackages }} 包
</text>
<text v-if="row.lineId" class="mt-1 block text-xs text-gray-500">
产线 #{{ row.lineId }}
</text> </text>
<text class="mt-1 block text-xs text-gray-400"> <text class="mt-1 block text-xs text-gray-400">
剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }} 剩余 {{ row.remainingPackagesAfter }} 包 · {{ row.occurredAt || "—" }}
</text> </text>
<text class="mt-1 block text-xs text-gray-500">跳过先到托盘原因:{{ row.fifoSkipReason || "—" }}</text>
</view> </view>
</view> </view>
</template> </template>
@@ -135,35 +208,54 @@
<script setup> <script setup>
import { computed, getCurrentInstance, reactive, ref } from "vue"; import { computed, getCurrentInstance, reactive, ref } from "vue";
import { onShow } from "@dcloudio/uni-app"; import { onHide, onShow, onUnload } from "@dcloudio/uni-app";
import PalletScanInput from "@/components/business/PalletScanInput.vue"; import PalletScanInput from "@/components/business/PalletScanInput.vue";
import TestBatchBanner from "@/components/business/TestBatchBanner.vue"; import TestBatchBanner from "@/components/business/TestBatchBanner.vue";
import { import {
addUsage, addUsage,
enterUsageTunnel,
getUsageFifoRecommendation,
getUsageLineOptions, getUsageLineOptions,
getUsageTunnelOptions,
listUsage, listUsage,
scanUsagePallet, scanUsagePallet,
} from "@/api/business/usage"; } from "@/api/business/usage";
import { createIdempotencyKey } from "@/utils/palletScan"; import { createIdempotencyKey } from "@/utils/palletScan";
import config from "@/config";
import constant from "@/utils/constant";
import storage from "@/utils/storage";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const usageList = ref([]); const usageList = ref([]);
const lineOptions = ref([]); const lineOptions = ref([]);
const tunnelOptions = ref([]);
const scannedPallet = ref(null); const scannedPallet = ref(null);
const usageResult = ref(null); const usageResult = ref(null);
const lineLocked = ref(false); const lineLocked = ref(false);
const tunnelLocked = ref(false);
const loading = ref(false); const loading = ref(false);
const optionLoading = ref(false); const optionLoading = ref(false);
const lineOptionLoading = ref(false);
const lineOptionError = ref("");
const fifoLoading = ref(false);
const fifoMessage = ref("");
const fifoRequiresSkipReason = ref(false);
const scanLoading = ref(false); const scanLoading = ref(false);
const submitLoading = ref(false); const submitLoading = ref(false);
const entryLoading = ref(false);
const listError = ref(""); const listError = ref("");
const operationError = ref(""); const operationError = ref("");
const submitError = ref(""); const submitError = ref("");
const idempotencyKey = ref(""); const pendingOperations = ref([]);
const form = reactive({ qrToken: "", lineId: null, usedPackages: "" }); const recoveryStorageKey = ref("");
const recoveryLoading = ref(false);
const recoveryError = ref("");
const recoveryMessage = ref("");
const form = reactive({ qrToken: "", tunnelId: null, lineId: null, usedPackages: "", fifoSkipReason: "" });
let listGeneration = 0; let listGeneration = 0;
let scanGeneration = 0; let scanGeneration = 0;
let optionGeneration = 0; let optionGeneration = 0;
let fifoGeneration = 0;
let operationSession = 0; let operationSession = 0;
const canPage = computed(() => const canPage = computed(() =>
@@ -177,19 +269,225 @@ const canList = computed(() => proxy.$auth.hasPermi("business:usage:list"));
const canOperate = computed(() => const canOperate = computed(() =>
proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]), proxy.$auth.hasPermiAnd(["business:usage:query", "business:usage:add"]),
); );
const operationPending = computed(() => submitLoading.value); const operationPending = computed(() => submitLoading.value || entryLoading.value || recoveryLoading.value);
const linePickerOptions = computed(() => [
{ id: null, name: "不选择产线" },
...lineOptions.value,
]);
const lineIndex = computed(() => { const lineIndex = computed(() => {
const index = lineOptions.value.findIndex((item) => item.id === form.lineId); const index = linePickerOptions.value.findIndex((item) => item.id === form.lineId);
return index >= 0 ? index : 0; return index >= 0 ? index : 0;
}); });
const selectedLineName = computed( const selectedLineName = computed(
() => lineOptions.value.find((item) => item.id === form.lineId)?.name || "", () => lineOptions.value.find((item) => item.id === form.lineId)?.name || "",
); );
const tunnelPickerOptions = computed(() => tunnelOptions.value.map((item) => ({
...item,
label: formatTunnelName(item),
})));
const tunnelIndex = computed(() => {
const index = tunnelOptions.value.findIndex((item) => item.id === form.tunnelId);
return index >= 0 ? index : 0;
});
const selectedTunnelName = computed(() => {
const option = tunnelOptions.value.find((item) => item.id === form.tunnelId);
if (option) return formatTunnelName(option);
if (!form.tunnelId) return "";
return formatTunnelName({
id: form.tunnelId,
name: scannedPallet.value?.tunnelName,
code: scannedPallet.value?.tunnelCode,
});
});
function formatTunnelName(option) {
return [option.name, option.code].filter(Boolean).join(" · ") || `隧道 #${option.id}`;
}
function errorMessage(error, fallback) { function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback; return error?.response?.data?.msg || error?.message || fallback;
} }
function currentRecoveryStorageKey() {
const userId = storage.get(constant.id);
if (!userId) throw new Error("账号信息未就绪,无法保存操作恢复记录");
return `business-usage-pending-v1:${JSON.stringify([config.baseUrl, userId, storage.get(constant.testBatchId) || "formal"])}`;
}
function loadPendingOperations() {
try {
const key = currentRecoveryStorageKey();
const records = uni.getStorageSync(key) || [];
if (!Array.isArray(records) || records.some((item) =>
!["entry", "usage"].includes(item?.action) || !item.payload?.qrToken || !item.payload?.idempotencyKey
)) throw new Error("本机操作恢复记录异常,请先联系管理员核对");
recoveryStorageKey.value = key;
pendingOperations.value = records;
recoveryError.value = "";
return true;
} catch (error) {
pendingOperations.value = [];
recoveryError.value = errorMessage(error, "本机操作恢复记录读取失败,请先核对");
return false;
}
}
function savePendingOperations(records) {
// 先持久化,再发送命令;页面隐藏、销毁或响应丢失都不能丢掉重试身份。
uni.setStorageSync(recoveryStorageKey.value, records);
pendingOperations.value = records;
}
function pendingForPallet() {
return pendingOperations.value.find((item) => item.palletId === scannedPallet.value?.palletId || item.payload.qrToken === form.qrToken);
}
function operationSignature(action, payload) {
return JSON.stringify([action, payload.qrToken, payload.tunnelId, payload.lineId ?? null, payload.usedPackages ?? null, payload.fifoSkipReason || ""]);
}
function prepareOperation(action, payload) {
if (!loadPendingOperations()) throw new Error(recoveryError.value);
const pending = pendingForPallet();
if (pending) {
if (operationSignature(pending.action, pending.payload) !== operationSignature(action, payload)) {
throw new Error("该托盘有结果未确认的操作,请先恢复重试并核对原操作");
}
return pending;
}
const operation = {
action,
palletId: scannedPallet.value.palletId,
palletCode: scannedPallet.value.palletCode,
tunnelName: selectedTunnelName.value,
payload: { ...payload, idempotencyKey: createIdempotencyKey() },
};
savePendingOperations([...pendingOperations.value, operation]);
return operation;
}
function completeOperation(operation, response) {
const result = response?.data;
// 先核实完整业务结果,再解除原凭据;空响应和不完整响应仍属于结果未知。
const confirmed = result && (operation.action === "usage"
? Number.isInteger(result.usageId) && result.usageId > 0 &&
Number.isInteger(result.remainingPackages) && result.remainingPackages >= 0 &&
typeof result.flowState === "string" && result.flowState.trim()
: result.palletId === operation.palletId && result.tunnelId === operation.payload.tunnelId &&
typeof result.traceNo === "string" && result.traceNo.trim());
if (!confirmed) throw new Error("服务端确认结果不完整,原操作凭据已保留");
if (!loadPendingOperations()) throw new Error(recoveryError.value);
savePendingOperations(pendingOperations.value.filter((item) => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
return result;
}
function isCurrentOperation(session, scopeKey) {
try {
return session === operationSession && scopeKey === recoveryStorageKey.value && scopeKey === currentRecoveryStorageKey();
} catch {
return false;
}
}
function validatedFifoSkipReason() {
const reason = form.fifoSkipReason.trim();
if (Array.from(reason).length > 500) throw new Error("跳过先到托盘原因最多500字");
if (fifoRequiresSkipReason.value && !reason) throw new Error("请填写跳过先到托盘原因");
return reason;
}
function handleFifoRejection(error, operation) {
// 与 request 的 getResponseBody 一致:兼容 uni 响应外层及直接业务 body。
const response = error?.response;
const body = response?.data && typeof response.data === "object" ? response.data : response;
if (!operation || body?.code !== 500 || body.data !== "FIFO_SKIP_REASON_REQUIRED") return false;
try {
if (!loadPendingOperations()) return true;
const current = pendingOperations.value.find(item => item.payload.idempotencyKey === operation.payload.idempotencyKey);
if (!current || JSON.stringify(current) !== JSON.stringify(operation)) {
recoveryError.value = "本机待确认记录已变化,请重新核对后操作";
return true;
}
// 仅此明确业务错误保证事务回滚,可解除对应原凭据并允许修改原因。
savePendingOperations(pendingOperations.value.filter(item => item.payload.idempotencyKey !== operation.payload.idempotencyKey));
} catch (storageError) {
recoveryError.value = `${errorMessage(storageError, "本机待确认记录清理失败")};原凭据已保留,请恢复重试`;
return true;
}
if (scannedPallet.value?.palletId === operation.palletId && form.qrToken === operation.payload.qrToken) {
fifoRequiresSkipReason.value = true;
loadFifoRecommendation();
} else {
recoveryLoading.value = false;
handleUsageScan(operation.payload.qrToken, operation);
}
operationError.value = "请补充跳过先到托盘原因后重新提交,本次操作未生效";
submitError.value = operationError.value;
recoveryMessage.value = operationError.value;
return true;
}
async function releasePendingOperation(item) {
if (operationPending.value) return;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
if (!loadPendingOperations() || recoveryStorageKey.value !== scopeKey) return;
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
if (!operation) return;
recoveryLoading.value = true;
try {
const confirmed = await new Promise((resolve, reject) => {
uni.showModal({
title: "解除本机待确认记录",
content: `请先核对使用记录和当前库存,确认 ${operation.palletCode} 的原操作结果。此操作仅移除对应本机待确认记录,不撤销或确认服务端业务。若原操作已成功,再次提交可能重复扣减库存。确认已人工核对并解除?`,
cancelText: "取消",
confirmText: "确认解除",
success: (result) => resolve(Boolean(result.confirm)),
fail: reject,
});
});
// 弹窗期间可能换账号、批次或离页;不能把旧确认应用到新的范围。
if (!confirmed || !isCurrentOperation(session, scopeKey) || currentRecoveryStorageKey() !== scopeKey) return;
if (!loadPendingOperations()) return;
savePendingOperations(pendingOperations.value.filter((record) => record.payload.idempotencyKey !== operation.payload.idempotencyKey));
if (scannedPallet.value?.palletId === operation.palletId || form.qrToken === operation.payload.qrToken) {
resetUsageOperation();
}
recoveryMessage.value = `${operation.palletCode} 仅已解除本机待确认记录,不代表原操作成功。请依据人工核对结果重新扫码后继续。`;
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
recoveryError.value = errorMessage(error, "解除本机待确认失败,原记录仍保留");
} finally {
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
}
}
async function retryPendingOperation(item) {
if (!canOperate.value || operationPending.value || !loadPendingOperations()) return;
const operation = pendingOperations.value.find((record) => record.payload.idempotencyKey === item.payload.idempotencyKey);
if (!operation) return;
resetUsageOperation();
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
recoveryLoading.value = true;
recoveryMessage.value = "";
try {
const response = await (operation.action === "usage" ? addUsage : enterUsageTunnel)({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
const result = completeOperation(operation, response);
recoveryMessage.value = operation.action === "usage"
? `${operation.palletCode} 原使用已确认,剩余 ${result.remainingPackages} 包。请重新扫码后继续。`
: `${operation.palletCode} 原入隧已确认。请重新扫码后继续。`;
loadUsageList();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
recoveryError.value = `${errorMessage(error, "原操作核对失败")};原操作已保留,请重试或联系管理员核对`;
} finally {
if (isCurrentOperation(session, scopeKey)) recoveryLoading.value = false;
}
}
function loadUsageList() { function loadUsageList() {
if (!canList.value) return; if (!canList.value) return;
const generation = ++listGeneration; const generation = ++listGeneration;
@@ -214,56 +512,104 @@ function resetUsageOperation() {
operationSession += 1; operationSession += 1;
scanGeneration += 1; scanGeneration += 1;
optionGeneration += 1; optionGeneration += 1;
fifoGeneration += 1;
scannedPallet.value = null; scannedPallet.value = null;
usageResult.value = null; usageResult.value = null;
lineOptions.value = []; lineOptions.value = [];
tunnelOptions.value = [];
lineLocked.value = false; lineLocked.value = false;
idempotencyKey.value = ""; tunnelLocked.value = false;
form.qrToken = ""; form.qrToken = "";
form.tunnelId = null;
form.lineId = null; form.lineId = null;
form.usedPackages = ""; form.usedPackages = "";
form.fifoSkipReason = "";
operationError.value = ""; operationError.value = "";
submitError.value = ""; submitError.value = "";
scanLoading.value = false; scanLoading.value = false;
optionLoading.value = false; optionLoading.value = false;
lineOptionLoading.value = false;
lineOptionError.value = "";
fifoLoading.value = false;
fifoMessage.value = "";
fifoRequiresSkipReason.value = false;
submitLoading.value = false;
entryLoading.value = false;
recoveryLoading.value = false;
} }
function loadLineOptions(siteId, session, scanRequestGeneration) { function loadUsageOptions(pallet, session, scanRequestGeneration) {
const generation = ++optionGeneration; const generation = ++optionGeneration;
const isCurrent = () => generation === optionGeneration &&
session === operationSession && scanRequestGeneration === scanGeneration;
optionLoading.value = true; optionLoading.value = true;
getUsageLineOptions(siteId) lineOptionLoading.value = true;
.then((response) => { const lineRequest = pallet.currentLineId == null
if ( ? getUsageLineOptions(pallet.siteId)
generation !== optionGeneration || : Promise.resolve({ data: lineOptions.value });
session !== operationSession || getUsageTunnelOptions(pallet.siteId)
scanRequestGeneration !== scanGeneration .then((tunnels) => {
) { if (!isCurrent()) return;
return; tunnelOptions.value = Array.isArray(tunnels.data) ? tunnels.data : [];
if (!tunnelLocked.value && !tunnelOptions.value.length) {
operationError.value = "该现场暂无可用隧道";
} }
lineOptions.value = Array.isArray(response.data) ? response.data : [];
if (!lineOptions.value.length) operationError.value = "该现场暂无可用产线";
}) })
.catch((error) => { .catch((error) => {
if (generation !== optionGeneration || session !== operationSession) return; if (!isCurrent()) return;
lineOptions.value = []; operationError.value = errorMessage(error, "隧道选项加载失败,请稍后重新扫码");
operationError.value = errorMessage(error, "产线选项加载失败,请稍后重新扫码");
}) })
.finally(() => { .finally(() => {
if (generation === optionGeneration && session === operationSession) { if (isCurrent()) optionLoading.value = false;
optionLoading.value = false; });
} lineRequest
.then((lines) => {
if (isCurrent()) lineOptions.value = Array.isArray(lines.data) ? lines.data : [];
})
.catch((error) => {
if (!isCurrent()) return;
lineOptions.value = [];
lineOptionError.value = `${errorMessage(error, "产线选项加载失败")};可不选产线继续操作`;
})
.finally(() => {
if (isCurrent()) lineOptionLoading.value = false;
}); });
} }
function handleUsageScan(qrToken) { async function loadFifoRecommendation() {
if (!scannedPallet.value || !form.qrToken) return;
const generation = ++fifoGeneration;
const session = operationSession;
const scopeKey = recoveryStorageKey.value;
const { qrToken, tunnelId } = form;
const isCurrent = () => generation === fifoGeneration && isCurrentOperation(session, scopeKey) &&
qrToken === form.qrToken && tunnelId === form.tunnelId;
fifoLoading.value = true;
fifoMessage.value = "";
try {
const response = await getUsageFifoRecommendation(qrToken, tunnelId);
if (!isCurrent()) return;
fifoMessage.value = response.data?.message || "当前暂无先到先用建议";
fifoRequiresSkipReason.value = response.data?.requiresSkipReason === true;
} catch {
if (!isCurrent()) return;
fifoMessage.value = "先到先用建议暂不可用,请人工核对;提交时由服务端复核,跳过先到托盘可能需要填写原因";
} finally {
if (isCurrent()) fifoLoading.value = false;
}
}
function handleUsageScan(qrToken, rejectedOperation = null) {
if (operationPending.value || scanLoading.value || optionLoading.value) return; if (operationPending.value || scanLoading.value || optionLoading.value) return;
resetUsageOperation(); resetUsageOperation();
if (!loadPendingOperations()) return;
const session = operationSession; const session = operationSession;
const scopeKey = recoveryStorageKey.value;
const generation = ++scanGeneration; const generation = ++scanGeneration;
scanLoading.value = true; scanLoading.value = true;
scanUsagePallet(qrToken) scanUsagePallet(qrToken)
.then((response) => { .then((response) => {
if (generation !== scanGeneration || session !== operationSession) return; if (generation !== scanGeneration || !isCurrentOperation(session, scopeKey)) return;
const pallet = response.data; const pallet = response.data;
if ( if (
!["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) || !["AT_SITE_UNUSED", "AT_SITE_PARTIAL"].includes(pallet?.flowState) ||
@@ -277,13 +623,26 @@ function handleUsageScan(qrToken) {
palletCode: pallet.palletCode || `#${pallet.palletId}`, palletCode: pallet.palletCode || `#${pallet.palletId}`,
siteId: pallet.siteId, siteId: pallet.siteId,
currentLineId: pallet.currentLineId, currentLineId: pallet.currentLineId,
tunnelId: pallet.tunnelId,
tunnelName: pallet.tunnelName,
tunnelCode: pallet.tunnelCode,
strainCode: pallet.strainCode, strainCode: pallet.strainCode,
strainName: pallet.strainName, strainName: pallet.strainName,
batchNo: pallet.batchNo, batchNo: pallet.batchNo,
productionDate: pallet.productionDate,
expiryDate: pallet.expiryDate,
remainingPackages: pallet.remainingPackages, remainingPackages: pallet.remainingPackages,
flowState: pallet.flowState, flowState: pallet.flowState,
}; };
form.qrToken = qrToken; form.qrToken = pallet.qrToken || qrToken;
form.tunnelId = pallet.tunnelId ?? null;
tunnelLocked.value = pallet.tunnelId != null;
if (rejectedOperation) {
form.tunnelId = pallet.tunnelId ?? rejectedOperation.payload.tunnelId;
form.usedPackages = rejectedOperation.payload.usedPackages ?? "";
form.fifoSkipReason = rejectedOperation.payload.fifoSkipReason || "";
fifoRequiresSkipReason.value = true;
}
if (pallet.currentLineId != null) { if (pallet.currentLineId != null) {
lineLocked.value = true; lineLocked.value = true;
form.lineId = pallet.currentLineId; form.lineId = pallet.currentLineId;
@@ -292,17 +651,18 @@ function handleUsageScan(qrToken) {
]; ];
} else { } else {
lineLocked.value = false; lineLocked.value = false;
form.lineId = null; form.lineId = rejectedOperation?.payload.lineId ?? null;
loadLineOptions(pallet.siteId, session, generation);
} }
loadUsageOptions(pallet, session, generation);
loadFifoRecommendation();
}) })
.catch((error) => { .catch((error) => {
if (generation === scanGeneration && session === operationSession) { if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试"); operationError.value = errorMessage(error, "托盘扫码校验失败,请检查后重试");
} }
}) })
.finally(() => { .finally(() => {
if (generation === scanGeneration && session === operationSession) { if (generation === scanGeneration && isCurrentOperation(session, scopeKey)) {
scanLoading.value = false; scanLoading.value = false;
} }
}); });
@@ -318,63 +678,143 @@ function handleScanDuplicate() {
} }
function handleLineChange(event) { function handleLineChange(event) {
if (lineLocked.value) return; if (lineLocked.value || operationPending.value) return;
const option = lineOptions.value[Number(event.detail.value)]; const option = linePickerOptions.value[Number(event.detail.value)];
form.lineId = option?.id || null; form.lineId = option?.id ?? null;
markPayloadChanged(); markPayloadChanged();
} }
function handleTunnelChange(event) {
if (tunnelLocked.value || operationPending.value) return;
const option = tunnelOptions.value[Number(event.detail.value)];
if (form.tunnelId === (option?.id ?? null)) return;
form.tunnelId = option?.id ?? null;
markPayloadChanged();
fifoRequiresSkipReason.value = false;
loadFifoRecommendation();
}
function markPayloadChanged() { function markPayloadChanged() {
if (!submitLoading.value) idempotencyKey.value = ""; if (operationPending.value) return;
submitError.value = "";
} }
function ensureIdempotencyKey() { async function enterTunnel() {
if (!idempotencyKey.value) idempotencyKey.value = createIdempotencyKey(); if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || tunnelLocked.value) return;
return idempotencyKey.value; if (!scannedPallet.value || !form.qrToken || !form.tunnelId) {
operationError.value = "请先扫描托盘并选择隧道";
return;
}
let fifoSkipReason;
try {
fifoSkipReason = validatedFifoSkipReason();
} catch (error) {
operationError.value = error.message;
return;
}
const session = operationSession;
entryLoading.value = true;
operationError.value = "";
let scopeKey = recoveryStorageKey.value;
let operation;
try {
operation = prepareOperation("entry", {
qrToken: form.qrToken,
tunnelId: form.tunnelId,
...(fifoSkipReason ? { fifoSkipReason } : {}),
});
scopeKey = recoveryStorageKey.value;
const response = await enterUsageTunnel({ ...operation.payload });
if (!isCurrentOperation(session, scopeKey)) return;
const result = completeOperation(operation, response);
form.tunnelId = result.tunnelId;
scannedPallet.value.tunnelId = result.tunnelId;
tunnelLocked.value = true;
submitError.value = "";
proxy.$modal.msgSuccess(response.msg || "托盘入隧成功");
loadFifoRecommendation();
} catch (error) {
if (!isCurrentOperation(session, scopeKey)) return;
if (handleFifoRejection(error, operation)) return;
operationError.value = `${errorMessage(error, "托盘入隧失败")};结果未确认时请恢复重试核对原操作`;
} finally {
if (isCurrentOperation(session, scopeKey)) entryLoading.value = false;
}
} }
function submitUsage() { async function submitUsage() {
if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value) return; if (!canOperate.value || operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return;
const usedPackages = Number(form.usedPackages); const usedPackages = Number(form.usedPackages);
if (!scannedPallet.value || !form.qrToken) { if (!scannedPallet.value || !form.qrToken) {
submitError.value = "请先扫描待使用托盘"; submitError.value = "请先扫描待使用托盘";
return; return;
} }
if (!form.lineId) { if (!tunnelLocked.value || !form.tunnelId) {
submitError.value = "请选择使用产线"; submitError.value = "请先选择隧道并确认入隧";
return; return;
} }
if (!Number.isInteger(usedPackages) || usedPackages <= 0) { if (!Number.isInteger(usedPackages) || usedPackages <= 0) {
submitError.value = "使用包数必须为正整数"; submitError.value = "使用包数必须为正整数";
return; return;
} }
if (usedPackages > scannedPallet.value.remainingPackages) { let fifoSkipReason;
try {
fifoSkipReason = validatedFifoSkipReason();
} catch (error) {
submitError.value = error.message;
return;
}
if (!loadPendingOperations()) {
submitError.value = recoveryError.value;
return;
}
// 服务端可能已扣减;已有原命令的重试由服务端幂等结果确认,不能用新余量拒绝。
if (usedPackages > scannedPallet.value.remainingPackages && !pendingForPallet()) {
submitError.value = "使用包数不能超过当前剩余包数"; submitError.value = "使用包数不能超过当前剩余包数";
return; return;
} }
const session = operationSession;
submitLoading.value = true; submitLoading.value = true;
submitError.value = ""; submitError.value = "";
addUsage({ const scopeKey = recoveryStorageKey.value;
let operation;
try {
operation = prepareOperation("usage", {
qrToken: form.qrToken, qrToken: form.qrToken,
tunnelId: form.tunnelId,
lineId: form.lineId, lineId: form.lineId,
usedPackages, usedPackages,
idempotencyKey: ensureIdempotencyKey(), ...(fifoSkipReason ? { fifoSkipReason } : {}),
}) });
.then((response) => { const response = await addUsage({ ...operation.payload });
usageResult.value = response.data; if (!isCurrentOperation(session, scopeKey)) return;
idempotencyKey.value = ""; usageResult.value = completeOperation(operation, response);
proxy.$modal.msgSuccess(response.msg || "现场使用记录成功"); proxy.$modal.msgSuccess(response.msg || "现场使用记录成功");
loadUsageList(); loadUsageList();
}) } catch (error) {
.catch((error) => { if (!isCurrentOperation(session, scopeKey)) return;
submitError.value = `${errorMessage(error, "现场使用提交失败")};直接重试将复用本次幂等键,修改输入后会生成新键`; if (handleFifoRejection(error, operation)) return;
}) submitError.value = `${errorMessage(error, "现场使用提交失败")};结果未确认时请恢复重试核对原操作`;
.finally(() => { } finally {
submitLoading.value = false; if (isCurrentOperation(session, scopeKey)) submitLoading.value = false;
}); }
} }
function leaveUsagePage() {
// uni-app 页面可能缓存;离开时使所有旧请求失效,不能回写下一次操作。
resetUsageOperation();
listGeneration += 1;
loading.value = false;
usageList.value = [];
listError.value = "";
recoveryMessage.value = "";
}
onHide(leaveUsagePage);
onUnload(leaveUsagePage);
onShow(() => { onShow(() => {
loadPendingOperations();
loadUsageList(); loadUsageList();
}); });
loadPendingOperations();
</script> </script>
@@ -290,6 +290,7 @@ const INVENTORY_STATE_META = {
UNSTOCKED: { name: "未入库", tone: "slate" }, UNSTOCKED: { name: "未入库", tone: "slate" },
EMPTY_IN_WAREHOUSE: { name: "仓库空托盘", tone: "teal" }, EMPTY_IN_WAREHOUSE: { name: "仓库空托盘", tone: "teal" },
FILLED_IN_WAREHOUSE: { name: "仓库有料", tone: "blue" }, FILLED_IN_WAREHOUSE: { name: "仓库有料", tone: "blue" },
IN_TRANSIT: { name: "在途待收货", tone: "blue" },
AT_SITE_UNUSED: { name: "现场未使用", tone: "purple" }, AT_SITE_UNUSED: { name: "现场未使用", tone: "purple" },
AT_SITE_PARTIAL: { name: "现场部分使用", tone: "orange" }, AT_SITE_PARTIAL: { name: "现场部分使用", tone: "orange" },
AT_SITE_EMPTY: { name: "现场空托盘", tone: "red" }, AT_SITE_EMPTY: { name: "现场空托盘", tone: "red" },
@@ -72,11 +72,17 @@ import { businessMenuEntries } from "@/config/businessMenu";
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const visibleBusinessEntries = computed(() => const visibleBusinessEntries = computed(() =>
businessMenuEntries.filter((entry) => proxy.$auth.hasPermi(entry.permission)), businessMenuEntries.filter(canOpenBusinessEntry),
); );
function canOpenBusinessEntry(entry) {
return entry.permissions
? proxy.$auth.hasPermiOr(entry.permissions)
: proxy.$auth.hasPermi(entry.permission);
}
function handleBusinessEntry(entry) { function handleBusinessEntry(entry) {
if (!proxy.$auth.hasPermi(entry.permission)) { if (!canOpenBusinessEntry(entry)) {
proxy.$modal.msgError("当前账号没有该业务菜单权限"); proxy.$modal.msgError("当前账号没有该业务菜单权限");
return; return;
} }
@@ -101,6 +101,7 @@ const request = async (config) => {
} }
const body = getResponseBody(res); const body = getResponseBody(res);
const status = res.statusCode ?? res.status;
const code = body.code || 200; const code = body.code || 200;
const msg = errorCode[code] || body.msg || errorCode["default"]; const msg = errorCode[code] || body.msg || errorCode["default"];
if (code === 401) { if (code === 401) {
@@ -126,6 +127,11 @@ const request = async (config) => {
const error = new Error(msg); const error = new Error(msg);
error.response = res; error.response = res;
reject(error); reject(error);
} else if (status != null && (status < 200 || status >= 300)) {
// uni.request 的 success 仅表示收到 HTTP 响应,不能据此确认业务成功。
const error = new Error(body.msg || `Request failed with status code ${status}`);
error.response = res;
reject(error);
} else { } else {
resolve(body); resolve(body);
} }
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { test } from "node:test"; import { test } from "node:test";
import JSON5 from "json5";
const appRoot = fileURLToPath(new URL("..", import.meta.url)); const appRoot = fileURLToPath(new URL("..", import.meta.url));
@@ -33,7 +34,7 @@ const apiContracts = {
"src/api/business/inbound.js": [ "src/api/business/inbound.js": [
["listInbound", "'/business/inbound/list'", "get"], ["listInbound", "'/business/inbound/list'", "get"],
["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"], ["getInboundWarehouseOptions", "'/business/inbound/warehouse-options'", "get"],
["scanInboundPallet", "'/business/inbound/scan/' + qrToken", "get"], ["scanInboundPallet", "'/business/inbound/scan/' + encodeURIComponent(qrToken)", "get"],
["getInbound", "'/business/inbound/' + inboundId", "get"], ["getInbound", "'/business/inbound/' + inboundId", "get"],
["addInbound", "'/business/inbound'", "post"], ["addInbound", "'/business/inbound'", "post"],
["updateInbound", "'/business/inbound'", "put"], ["updateInbound", "'/business/inbound'", "put"],
@@ -42,7 +43,7 @@ const apiContracts = {
"src/api/business/filling.js": [ "src/api/business/filling.js": [
["listFilling", "'/business/filling/list'", "get"], ["listFilling", "'/business/filling/list'", "get"],
["getFillingStrainOptions", "'/business/filling/strain-options'", "get"], ["getFillingStrainOptions", "'/business/filling/strain-options'", "get"],
["scanFillingPallet", "'/business/filling/scan/' + qrToken", "get"], ["scanFillingPallet", "'/business/filling/scan/' + encodeURIComponent(qrToken)", "get"],
["getFilling", "'/business/filling/' + fillingId", "get"], ["getFilling", "'/business/filling/' + fillingId", "get"],
["addFilling", "'/business/filling'", "post"], ["addFilling", "'/business/filling'", "post"],
], ],
@@ -50,7 +51,7 @@ const apiContracts = {
["listShipment", "'/business/shipment/list'", "get"], ["listShipment", "'/business/shipment/list'", "get"],
["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"], ["getShipmentWarehouseOptions", "'/business/shipment/warehouse-options'", "get"],
["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"], ["getShipmentSiteOptions", "'/business/shipment/site-options'", "get"],
["scanShipmentPallet", "'/business/shipment/scan/' + qrToken", "get"], ["scanShipmentPallet", "'/business/shipment/scan/' + encodeURIComponent(qrToken)", "get"],
["getShipment", "'/business/shipment/' + shipmentId", "get"], ["getShipment", "'/business/shipment/' + shipmentId", "get"],
["addShipment", "'/business/shipment'", "post"], ["addShipment", "'/business/shipment'", "post"],
["updateShipment", "'/business/shipment'", "put"], ["updateShipment", "'/business/shipment'", "put"],
@@ -59,14 +60,14 @@ const apiContracts = {
"src/api/business/usage.js": [ "src/api/business/usage.js": [
["listUsage", "'/business/usage/list'", "get"], ["listUsage", "'/business/usage/list'", "get"],
["getUsageLineOptions", "'/business/usage/line-options'", "get"], ["getUsageLineOptions", "'/business/usage/line-options'", "get"],
["scanUsagePallet", "'/business/usage/scan/' + qrToken", "get"], ["scanUsagePallet", "'/business/usage/scan/' + encodeURIComponent(qrToken)", "get"],
["getUsage", "'/business/usage/' + usageId", "get"], ["getUsage", "'/business/usage/' + usageId", "get"],
["addUsage", "'/business/usage'", "post"], ["addUsage", "'/business/usage'", "post"],
], ],
"src/api/business/return.js": [ "src/api/business/return.js": [
["listReturn", "'/business/return/list'", "get"], ["listReturn", "'/business/return/list'", "get"],
["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"], ["getReturnWarehouseOptions", "'/business/return/warehouse-options'", "get"],
["scanReturnPallet", "'/business/return/scan/' + qrToken", "get"], ["scanReturnPallet", "'/business/return/scan/' + encodeURIComponent(qrToken)", "get"],
["requestReturn", "'/business/return/request'", "post"], ["requestReturn", "'/business/return/request'", "post"],
["getReturn", "'/business/return/' + returnId", "get"], ["getReturn", "'/business/return/' + returnId", "get"],
["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"], ["confirmReturn", "'/business/return/' + returnId + '/confirm'", "put"],
@@ -145,7 +146,8 @@ test("PalletScanInput 使用 HID 回车提交并由 loading 锁定输入", async
}); });
test("PDA 路由和工作台入口覆盖五类现场业务", async () => { test("PDA 路由和工作台入口覆盖五类现场业务", async () => {
const pages = JSON.parse( // uni-app 的 pages.json 允许开发模式注释,与构建端保持相同解析语义。
const pages = JSON5.parse(
await readFile(path.join(appRoot, "src/pages.json"), "utf8"), await readFile(path.join(appRoot, "src/pages.json"), "utf8"),
); );
const pagePaths = new Set(pages.pages.map((page) => page.path)); const pagePaths = new Set(pages.pages.map((page) => page.path));
@@ -242,7 +244,7 @@ test("运营驾驶舱 API 对接现有扫码异常接口", async () => {
test("APP 页面标题与后端业务菜单名称一致", async () => { test("APP 页面标题与后端业务菜单名称一致", async () => {
const [pages, fillingSource] = await Promise.all([ const [pages, fillingSource] = await Promise.all([
JSON.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")), JSON5.parse(await readFile(path.join(appRoot, "src/pages.json"), "utf8")),
readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"), readFile(path.join(appRoot, "src/pages/business/filling/index.vue"), "utf8"),
]); ]);
const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index"); const fillingPage = pages.pages.find((page) => page.path === "pages/business/filling/index");
File diff suppressed because it is too large Load Diff
@@ -57,3 +57,38 @@
2026-09-28 15:44:48.546 | f3d841a80e0c49b6a2ef12e0fb03c0e9 | d09057ad24a24e7ea4a22f44065f962d | 40ab25364cde46b29603eec07bb0410f | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录 2026-09-28 15:44:48.546 | f3d841a80e0c49b6a2ef12e0fb03c0e9 | d09057ad24a24e7ea4a22f44065f962d | 40ab25364cde46b29603eec07bb0410f | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-28 18:15:15.372 | 55fe7aed5a5049eabdae0edd5e13b9c6 | e70f5cad542640e5b84a4a8f7bcbf349 | c96b83685c474b3fa2eb47238c10b3c3 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录 2026-09-28 18:15:15.372 | 55fe7aed5a5049eabdae0edd5e13b9c6 | e70f5cad542640e5b84a4a8f7bcbf349 | c96b83685c474b3fa2eb47238c10b3c3 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-28 18:24:37.733 | | | | 19536-893d88 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 19536-893d88 失去 Application 锁 2026-09-28 18:24:37.733 | | | | 19536-893d88 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 19536-893d88 失去 Application 锁
2026-09-29 08:31:19.884 | ecd7f13ca30e45b294640064c6541d62 | c138e385f39748c2a69c9647d5f62168 | 730afa863d39409385556bd9e7a9aa74 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.884 | 8a5b859f4b5b42f587f3c68f80df2a29 | ffec21d61d124182814ca9e284d064c5 | e7b7bc0477ed4d06a3f722a03213f7df | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.887 | 42584df6b4524e5ab17b92ab7bbad363 | 9e7dd342207143cfbf58edb837db9bc8 | fe765689650d4074b3214fb8385a891c | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.887 | 6ace3b292707421699b77bbab6cc2c17 | ef13031420d84be8bd1b6bb949cc2e73 | ee66dbbf2cb34f0e881d9b8ff1720ca4 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.888 | ab831cab113b475fab50d19f98ae5ad5 | ec6c14b01c0f462f8f68487ffdc2e7a8 | 2c6e0349e8544bb99b1407297b2934ff | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.888 | 9a3993f714a0480599a69853c7a160b7 | 7639accb89c04e3eab0cecb39b4222d6 | 5f1da52c460d45db94c6f753e760d1ff | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.889 | 8bfdefdcc33d4a02a58ff162d637d78e | 695c2b2b62644fcb83ca330ed6c35377 | a9890dfac0be43f1af1a706257ad90f7 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.889 | 8074c1fbe9324750a34f1b41701ae10a | c8db0c5645af428aab6f891676743e90 | 1522f21948074184944eda7bb2c7a457 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 08:31:19.890 | 311c84b119c74c34926e8f105cb6b89d | 80faa8776a174e848a7ea949d49650a9 | 4e0791453ffd4109a7b057a6a1f9c89d | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 10:36:18.935 | f336ad9851934b909fb22f7dfc4b3b65 | 8a0adcc4279e4a90ac233295a77dc47b | fdbf1ccbb5db4fc18600351d0e947184 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.408 | b5bd453c58164df3888a79f9347b08f2 | f2f3b6f4a9c94a038970ebe3c4cb4178 | 1d6fa79fe14a428189f4f10cbe02ffbb | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.411 | ac47fd90ab8643a0b769eaee235c45cb | bab76393434043f4835d06527b31c85f | d154b8c62bf045e484bf4d0dd149e37a | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.412 | 9a9f12f1f0a34a8fbe9cad915594afc6 | a054fea4972c4c2c8e7d96a4383e6974 | a7724448dd3b40a7aa90356608b39c37 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.413 | dc6317ef7e6e4196823e65609cd37af4 | cac0c26cbd5c47f194d66af70744a317 | 27c38b6b3345428981d4226abb767e68 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.414 | 43fd8348a96a43c486c5bbf15388af25 | 146ae1ecb2bc45dd827136680e972f48 | 5a59b2426cd74877aa7e1795317b3ede | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.414 | c2aab8152a00445881b6fcab4936339c | 59a37c27d2b14dc9a3df640123dd48cb | 56b5f26cc25d475faf7dc7eb085afd7d | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.414 | e2e16657d6bc4986ac054e12547cde7e | 80b7928ebcd141c89b9be78a5f451f31 | 7ddf26d600534e36936101698cb41375 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.414 | 5a0a281ce5ca4dc086a5e97e34f54064 | 6a2e8aad8ab648cd8c1f1145061b8617 | 169b721c5b184e058ef422f7a4dfa2d5 | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:34:17.414 | a6848b04d69744c6903ff8f5488aff2a | 455b6c7725124b528d632116e50d8692 | 1d4829970328409a9c4241da7ef0e3df | 19536-893d88 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-09-29 14:44:44.532 | | | | 19536-893d88 | ERROR | sqlalchemy.pool.base:_close_connection:376 - Exception closing connection <AdaptedConnection <asyncmy.connection.Connection object at 0x12526bf40>>
Traceback (most recent call last):
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/pool/base.py", line 374, in _close_connection
self._dialect.do_close(connection)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 721, in do_close
dbapi_connection.close()
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 114, in close
self.await_(self._connection.ensure_closed())
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
File "asyncmy/connection.pyx", line 367, in ensure_closed
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/asyncio/streams.py", line 343, in wait_closed
await self._protocol._get_close_waiter(self)
BrokenPipeError: [Errno 32] Broken pipe
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
2026-09-29 15:19:51.438 | 989860659b114ab38204ade1141ea3c1 | 6064d458f0404b84a1d7848686d38762 | 36ef966879ce4a618a18c71affcb9e5d | 55906-91d48f | WARNING | module_admin.service.login_service:get_current_user:219 - 用户token已失效,请重新登录
2026-09-29 17:02:23.083 | 63ef7d446d464450b9b1d1965ff974da | e8f78596035e433686c099886c38dd09 | 05a6cd1aec2f42b79ce34b5c1afeee25 | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=32s
2026-09-29 17:02:23.881 | dfad946f3e3b4ad39a2824ff3843d26e | 6ac7acb690a94b28adc82485c2ffc24d | 13afc3a1f3c742eba1fa824b5c3b1c3d | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=31s
2026-09-29 17:02:45.046 | 5be0428cea524497b4a6174bb9f1a6ed | d7189467f9a544bca5adc90241a1c82d | e25f7456285147a990a16c907e9930c4 | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=10s
2026-09-29 17:02:49.279 | ca8fee87f80a4984ba64adea0235615a | 995b7c31d34c4af3b91e7e38691c55ed | 9840642de3244d5a83607d5fa4931f54 | 55906-91d48f | WARNING | common.annotation.rate_limit_annotation:_log_rate_limit_hit:652 - 接口限流命中: namespace=captcha:image preset=ANON_AUTH_CAPTCHA algorithm=sliding_window fail_strategy=local_fallback method=GET path=/captchaImage scope=ip scope_value=ip:127.0.0.1 current=36 limit=36 retry_after=6s
2026-09-29 18:46:30.336 | | | | 55906-91d48f | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 55906-91d48f 失去 Application 锁
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
2026-10-05 10:32:04.685 | 5ce457e932e34f669b2c7fda06bab450 | 5e433faf6cb54daa8fefbd591de4a42a | 30356484b94b4f1d8bd4b734ee876c59 | 21381-1d9e7d | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-10-05 13:18:49.387 | | | | 95189-da1f7e | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 95189-da1f7e 失去 Application 锁
2026-10-05 13:23:35.421 | | | | 34715-45b610 | ERROR | exceptions.handle:exception_handler:71 - (asyncmy.errors.OperationalError) (1054, "Unknown column 'biz_usage_record.tunnel_id' in 'field list'")
[SQL: SELECT count(%s) AS count_1
FROM (SELECT biz_usage_record.usage_id AS usage_id, biz_usage_record.test_batch_id AS test_batch_id, biz_usage_record.usage_no AS usage_no, biz_usage_record.pallet_id AS pallet_id, biz_usage_record.active_filling_id AS active_filling_id, biz_usage_record.site_id AS site_id, biz_usage_record.tunnel_id AS tunnel_id, biz_usage_record.trace_no AS trace_no, biz_usage_record.batch_no_snapshot AS batch_no_snapshot, biz_usage_record.line_id AS line_id, biz_usage_record.used_packages AS used_packages, biz_usage_record.remaining_packages_after AS remaining_packages_after, biz_usage_record.idempotency_key AS idempotency_key, biz_usage_record.request_hash AS request_hash, biz_usage_record.operator_id AS operator_id, biz_usage_record.occurred_at AS occurred_at
FROM biz_usage_record
WHERE biz_usage_record.test_batch_id IS NULL ORDER BY biz_usage_record.usage_id) AS anon_1]
[parameters: ('*',)]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
Traceback (most recent call last):
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
self.dialect.do_execute(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
cursor.execute(statement, parameters)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
self._adapt_connection._handle_exception(error)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
raise error
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
return self.await_(self._execute_async(operation, parameters))
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
result = await self._cursor.execute(operation, parameters)
File "asyncmy/cursors.pyx", line 179, in execute
File "asyncmy/cursors.pyx", line 364, in _query
File "asyncmy/connection.pyx", line 500, in query
File "asyncmy/connection.pyx", line 688, in _read_query_result
File "asyncmy/connection.pyx", line 1075, in read
File "asyncmy/connection.pyx", line 650, in read_packet
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
asyncmy.errors.OperationalError: (1054, "Unknown column 'biz_usage_record.tunnel_id' in 'field list'")
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/errors.py", line 164, in __call__
await self.app(scope, receive, _send)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/transport_crypto_middleware.py", line 53, in __call__
await self.app(scope, receive, send)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/trace_middleware/middle.py", line 44, in __call__
await self.app(scope, handle_outgoing_receive, handle_outgoing_request)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 191, in __call__
with recv_stream, send_stream, collapse_excgroups():
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/contextlib.py", line 153, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
raise exc
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 193, in __call__
response = await self.dispatch_func(request, call_next)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/api_response_header_middleware.py", line 15, in dispatch
response = await call_next(request)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 168, in call_next
raise app_exc from app_exc.__cause__ or app_exc.__context__
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 144, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/gzip.py", line 29, in __call__
await responder(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/gzip.py", line 130, in __call__
await super().__call__(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/gzip.py", line 46, in __call__
await self.app(scope, receive, self.send_with_compression)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/cors.py", line 85, in __call__
await self.app(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 191, in __call__
with recv_stream, send_stream, collapse_excgroups():
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/contextlib.py", line 153, in __exit__
self.gen.throw(typ, value, traceback)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_utils.py", line 85, in collapse_excgroups
raise exc
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 193, in __call__
response = await self.dispatch_func(request, call_next)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/middlewares/context_middleware.py", line 18, in dispatch
return await call_next(request)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 168, in call_next
raise app_exc from app_exc.__cause__ or app_exc.__context__
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/base.py", line 144, in coro
await self.app(scope, receive_or_disconnect, send_no_error)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
await self.app(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/routing.py", line 716, in __call__
await self.middleware_stack(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/routing.py", line 736, in app
await route.handle(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/routing.py", line 290, in handle
await self.app(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 115, in app
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 101, in app
response = await f(request)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 355, in app
raw_response = await run_endpoint_function(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/fastapi/routing.py", line 243, in run_endpoint_function
return await dependant.call(**values)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/controller/usage_controller.py", line 52, in get_business_usage_list
result = await UsageService.get_usage_list_services(
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/service/usage_service.py", line 259, in get_usage_list_services
result = await OperationDao.get_usage_list(
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/dao/operation_dao.py", line 835, in get_usage_list
return await cls._paginate(db, statement, query_object, is_page)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/dao/operation_dao.py", line 142, in _paginate
return await PageUtil.paginate(
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/utils/page_util.py", line 83, in paginate
total = (await db.execute(select(func.count('*')).select_from(query.subquery()))).scalar()
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/ext/asyncio/session.py", line 449, in execute
result = await greenlet_spawn(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 203, in greenlet_spawn
result = context.switch(value)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 2351, in execute
return self._execute_internal(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 2249, in _execute_internal
result: Result[Any] = compile_state_cls.orm_execute_statement(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/context.py", line 306, in orm_execute_statement
result = conn.execute(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1419, in execute
return meth(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/sql/elements.py", line 527, in _execute_on_connection
return connection._execute_clauseelement(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1641, in _execute_clauseelement
ret = self._execute_context(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1846, in _execute_context
return self._exec_single_context(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1986, in _exec_single_context
self._handle_dbapi_exception(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception
raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
self.dialect.do_execute(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
cursor.execute(statement, parameters)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
self._adapt_connection._handle_exception(error)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
raise error
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
return self.await_(self._execute_async(operation, parameters))
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
result = await self._cursor.execute(operation, parameters)
File "asyncmy/cursors.pyx", line 179, in execute
File "asyncmy/cursors.pyx", line 364, in _query
File "asyncmy/connection.pyx", line 500, in query
File "asyncmy/connection.pyx", line 688, in _read_query_result
File "asyncmy/connection.pyx", line 1075, in read
File "asyncmy/connection.pyx", line 650, in read_packet
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
sqlalchemy.exc.OperationalError: (asyncmy.errors.OperationalError) (1054, "Unknown column 'biz_usage_record.tunnel_id' in 'field list'")
[SQL: SELECT count(%s) AS count_1
FROM (SELECT biz_usage_record.usage_id AS usage_id, biz_usage_record.test_batch_id AS test_batch_id, biz_usage_record.usage_no AS usage_no, biz_usage_record.pallet_id AS pallet_id, biz_usage_record.active_filling_id AS active_filling_id, biz_usage_record.site_id AS site_id, biz_usage_record.tunnel_id AS tunnel_id, biz_usage_record.trace_no AS trace_no, biz_usage_record.batch_no_snapshot AS batch_no_snapshot, biz_usage_record.line_id AS line_id, biz_usage_record.used_packages AS used_packages, biz_usage_record.remaining_packages_after AS remaining_packages_after, biz_usage_record.idempotency_key AS idempotency_key, biz_usage_record.request_hash AS request_hash, biz_usage_record.operator_id AS operator_id, biz_usage_record.occurred_at AS occurred_at
FROM biz_usage_record
WHERE biz_usage_record.test_batch_id IS NULL ORDER BY biz_usage_record.usage_id) AS anon_1]
[parameters: ('*',)]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-10-05 14:58:00.827 | 30919192f3594497a59fc913008fda10 | 53240ccf884a443ba4ada988273cbd6e | dcdb5888a906403b894cda1fb4d83974 | 34715-45b610 | WARNING | module_admin.service.login_service:get_current_user:277 - 用户token已失效,请重新登录
2026-10-05 15:23:25.800 | | | | 34715-45b610 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 34715-45b610 失去 Application 锁
2026-10-05 17:57:42.275 | | | | 38941-a4ab07 | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 38941-a4ab07 失去 Application 锁
2026-10-05 17:58:13.287 | eba30441d8d247f1a71c18ca28b545e3 | a66289a299b54b39949859fc20d7c49e | cdc11db34b914473b5bfe9f4cc8bf96a | 46633-a22437 | WARNING | module_admin.service.login_service:authenticate_user:133 - 密码错误
2026-10-05 17:58:13.287 | eba30441d8d247f1a71c18ca28b545e3 | a66289a299b54b39949859fc20d7c49e | cdc11db34b914473b5bfe9f4cc8bf96a | 46633-a22437 | WARNING | common.annotation.log_annotation:wrapper:197 - 密码错误
2026-10-05 18:01:12.069 | 15caaca0a4cc4c6382be942118562672 | af3f9aab3ded4ab0b4ffd746dff5a6c2 | 47b5ecf753d44f11b5812048deee1540 | 46633-a22437 | ERROR | common.annotation.log_annotation:wrapper:205 - (asyncmy.errors.OperationalError) (1054, "Unknown column 'tunnel_id' in 'field list'")
[SQL: INSERT INTO biz_pallet_inventory (pallet_id, test_batch_id, active_filling_id, warehouse_id, site_id, tunnel_id, trace_no, current_line_id, flow_state, remaining_packages, last_event_id, version, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)]
[parameters: (5, None, None, None, None, None, None, None, 'UNSTOCKED', 0, None, 0, datetime.datetime(2026, 10, 5, 18, 1, 12, 36126))]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
Traceback (most recent call last):
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
self.dialect.do_execute(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
cursor.execute(statement, parameters)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
self._adapt_connection._handle_exception(error)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
raise error
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
return self.await_(self._execute_async(operation, parameters))
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
result = await self._cursor.execute(operation, parameters)
File "asyncmy/cursors.pyx", line 179, in execute
File "asyncmy/cursors.pyx", line 364, in _query
File "asyncmy/connection.pyx", line 500, in query
File "asyncmy/connection.pyx", line 688, in _read_query_result
File "asyncmy/connection.pyx", line 1075, in read
File "asyncmy/connection.pyx", line 650, in read_packet
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
asyncmy.errors.OperationalError: (1054, "Unknown column 'tunnel_id' in 'field list'")
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/common/annotation/log_annotation.py", line 195, in wrapper
result = await func(*args, **kwargs)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/controller/pallet_controller.py", line 98, in add_master_pallet
result = await PalletService.add_pallet(query_db, add_pallet, current_user, scope)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/service/pallet_service.py", line 147, in add_pallet
await MasterDao.add_pallet_inventory(db, inventory)
File "/Users/jiaolongyan/PycharmProjects/牛牛小屋/ShiErFeng/shierfeng-fastapi-backend/module_business/dao/master_dao.py", line 568, in add_pallet_inventory
await db.flush()
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/ext/asyncio/session.py", line 787, in flush
await greenlet_spawn(self.sync_session.flush, objects=objects)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 203, in greenlet_spawn
result = context.switch(value)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 4331, in flush
self._flush(objects)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 4466, in _flush
with util.safe_reraise():
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/langhelpers.py", line 224, in __exit__
raise exc_value.with_traceback(exc_tb)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/session.py", line 4427, in _flush
flush_context.execute()
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/unitofwork.py", line 466, in execute
rec.execute(self)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/unitofwork.py", line 642, in execute
util.preloaded.orm_persistence.save_obj(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/persistence.py", line 93, in save_obj
_emit_insert_statements(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/orm/persistence.py", line 1048, in _emit_insert_statements
result = connection.execute(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1419, in execute
return meth(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/sql/elements.py", line 527, in _execute_on_connection
return connection._execute_clauseelement(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1641, in _execute_clauseelement
ret = self._execute_context(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1846, in _execute_context
return self._exec_single_context(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1986, in _exec_single_context
self._handle_dbapi_exception(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 2363, in _handle_dbapi_exception
raise sqlalchemy_exception.with_traceback(exc_info[2]) from e
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/base.py", line 1967, in _exec_single_context
self.dialect.do_execute(
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/engine/default.py", line 952, in do_execute
cursor.execute(statement, parameters)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 230, in execute
self._adapt_connection._handle_exception(error)
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/dialects/mysql/asyncmy.py", line 91, in _handle_exception
raise error
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 228, in execute
return self.await_(self._execute_async(operation, parameters))
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 132, in await_only
return current.parent.switch(awaitable) # type: ignore[no-any-return,attr-defined] # noqa: E501
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/util/_concurrency_py3k.py", line 196, in greenlet_spawn
value = await result
File "/Users/jiaolongyan/miniconda3/envs/ShiErFeng/lib/python3.10/site-packages/sqlalchemy/connectors/asyncio.py", line 251, in _execute_async
result = await self._cursor.execute(operation, parameters)
File "asyncmy/cursors.pyx", line 179, in execute
File "asyncmy/cursors.pyx", line 364, in _query
File "asyncmy/connection.pyx", line 500, in query
File "asyncmy/connection.pyx", line 688, in _read_query_result
File "asyncmy/connection.pyx", line 1075, in read
File "asyncmy/connection.pyx", line 650, in read_packet
File "asyncmy/protocol.pyx", line 190, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/protocol.pyx", line 194, in asyncmy.protocol.MysqlPacket.raise_for_error
File "asyncmy/errors.pyx", line 128, in asyncmy.errors.raise_mysql_exception
File "asyncmy/errors.pyx", line 137, in asyncmy.errors.raise_mysql_exception
sqlalchemy.exc.OperationalError: (asyncmy.errors.OperationalError) (1054, "Unknown column 'tunnel_id' in 'field list'")
[SQL: INSERT INTO biz_pallet_inventory (pallet_id, test_batch_id, active_filling_id, warehouse_id, site_id, tunnel_id, trace_no, current_line_id, flow_state, remaining_packages, last_event_id, version, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)]
[parameters: (5, None, None, None, None, None, None, None, 'UNSTOCKED', 0, None, 0, datetime.datetime(2026, 10, 5, 18, 1, 12, 36126))]
(Background on this error at: https://sqlalche.me/e/20/e3q8)
2026-10-05 18:02:21.594 | f09ec57e4f274520839c9684c6dbc940 | 793ffb29205b4a5888c569a22c678400 | 60b65cd4baeb4392a338a65fc6c32904 | 46633-a22437 | ERROR | common.annotation.log_annotation:wrapper:202 - 目标客户不存在
2026-10-05 18:37:04.581 | | | | 52026-91894b | WARNING | config.get_scheduler:on_lock_lost:273 - ⚠️ Worker 52026-91894b 失去 Application 锁
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -72,7 +72,7 @@ async def get_business_filling_strain_options(
@filling_controller.get( @filling_controller.get(
'/scan/{qr_token}', '/scan/{qr_token:path}',
summary='扫描待装填托盘接口', summary='扫描待装填托盘接口',
description='按托盘当前仓库数据范围判断空托盘是否可装填', description='按托盘当前仓库数据范围判断空托盘是否可装填',
response_model=DataResponseModel[PalletScanResponseModel], response_model=DataResponseModel[PalletScanResponseModel],
@@ -75,7 +75,7 @@ async def get_business_inbound_warehouse_options(
@inbound_controller.get( @inbound_controller.get(
'/scan/{qr_token}', '/scan/{qr_token:path}',
summary='扫描首次入库托盘接口', summary='扫描首次入库托盘接口',
description='查询托盘是否可加入所选仓库的首次入库单', description='查询托盘是否可加入所选仓库的首次入库单',
response_model=DataResponseModel[PalletScanResponseModel], response_model=DataResponseModel[PalletScanResponseModel],
@@ -90,7 +90,7 @@ async def get_shipment_report_list(
@report_controller.get( @report_controller.get(
'/consumption/list', '/consumption/list',
summary='获取产线消耗报表分页列表接口', summary='获取隧道消耗报表分页列表接口(兼容产线)',
response_model=PageResponseModel[ConsumptionReportModel], response_model=PageResponseModel[ConsumptionReportModel],
dependencies=[UserInterfaceAuthDependency('report:consumption:list')], dependencies=[UserInterfaceAuthDependency('report:consumption:list')],
) )
@@ -76,7 +76,7 @@ async def get_business_return_warehouse_options(
@return_controller.get( @return_controller.get(
'/scan/{qr_token}', '/scan/{qr_token:path}',
summary='扫描退回托盘接口', summary='扫描退回托盘接口',
description='按申请现场或确认仓库数据范围重新查询托盘退回状态', description='按申请现场或确认仓库数据范围重新查询托盘退回状态',
response_model=DataResponseModel[PalletScanResponseModel], response_model=DataResponseModel[PalletScanResponseModel],
@@ -23,6 +23,8 @@ from module_business.entity.vo.operation_vo import (
ShipmentScanQueryModel, ShipmentScanQueryModel,
ShipmentUpdateModel, ShipmentUpdateModel,
) )
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
from module_business.service.shipment_label_service import ShipmentLabelService
from module_business.service.shipment_service import ShipmentService from module_business.service.shipment_service import ShipmentService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -92,7 +94,7 @@ async def get_business_shipment_site_options(
@shipment_controller.get( @shipment_controller.get(
'/scan/{qr_token}', '/scan/{qr_token:path}',
summary='扫描整托发货托盘接口', summary='扫描整托发货托盘接口',
description='查询托盘是否可加入指定仓库至现场的整托发货单', description='查询托盘是否可加入指定仓库至现场的整托发货单',
response_model=DataResponseModel[PalletScanResponseModel], response_model=DataResponseModel[PalletScanResponseModel],
@@ -126,6 +128,24 @@ async def scan_business_shipment_pallet(
return ResponseUtil.success(data=result) return ResponseUtil.success(data=result)
@shipment_controller.get(
'/{shipment_id}/labels',
summary='获取整托发货标签接口',
description='在发货查询权限与仓库、现场、数据批次范围内读取原托盘标签',
response_model=DataResponseModel[list[ShipmentLabelModel]],
dependencies=[UserInterfaceAuthDependency('business:shipment:query')],
)
async def get_business_shipment_labels(
request: Request,
shipment_id: Annotated[int, Path(gt=0, description='发货单ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await ShipmentLabelService.get_labels_services(query_db, shipment_id, current_user, test_scope)
return ResponseUtil.success(data=result, headers={'Cache-Control': 'no-store'})
@shipment_controller.get( @shipment_controller.get(
'/{shipment_id}', '/{shipment_id}',
summary='获取整托发货单详情接口', summary='获取整托发货单详情接口',
@@ -0,0 +1,108 @@
from typing import Annotated
from fastapi import Path, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import ShipmentModel
from module_business.entity.vo.shipment_receipt_vo import (
ShipmentReceiptCommandModel,
ShipmentReceiptItemModel,
ShipmentReceiptModel,
ShipmentReceiptQueryModel,
)
from module_business.service.shipment_receipt_service import ShipmentReceiptService
from utils.response_util import ResponseUtil
shipment_receipt_controller = APIRouterPro(
prefix='/business/shipment-receipt',
tags=['业务管理-发货收货核对'],
dependencies=[PreAuthDependency()],
)
@shipment_receipt_controller.get(
'/list',
response_model=PageResponseModel[ShipmentModel],
summary='查询目标现场收货单',
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
)
async def list_shipment_receipts(
request: Request,
query: Annotated[ShipmentReceiptQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await ShipmentReceiptService.list_services(query_db, query, current_user, test_scope)
return ResponseUtil.success(model_content=result)
@shipment_receipt_controller.get(
'/{shipment_id}',
response_model=DataResponseModel[ShipmentReceiptModel],
summary='查询原发货核对明细',
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
)
async def get_shipment_receipt(
request: Request,
shipment_id: Annotated[int, Path(gt=0)],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await ShipmentReceiptService.detail_services(query_db, shipment_id, current_user, test_scope)
return ResponseUtil.success(data=result)
@shipment_receipt_controller.get(
'/{shipment_id}/scan/{identifier:path}',
response_model=DataResponseModel[ShipmentReceiptItemModel],
summary='扫码或录入原托盘号核对',
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
)
@Log(
title='收货扫码',
business_type=BusinessType.OTHER,
request_log_mode='none',
response_log_mode='summary',
oper_url_mode='route',
)
async def scan_shipment_receipt(
request: Request,
shipment_id: Annotated[int, Path(gt=0)],
identifier: Annotated[str, Path(min_length=1, max_length=128, pattern=r'\S')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await ShipmentReceiptService.scan_services(query_db, shipment_id, identifier, current_user, test_scope)
return ResponseUtil.success(data=result)
@shipment_receipt_controller.put(
'/{shipment_id}/confirm',
response_model=ResponseBaseModel,
summary='接收方确认核对收货',
dependencies=[UserInterfaceAuthDependency('business:shipment:receive')],
)
@Log(title='发货收货核对', business_type=BusinessType.UPDATE)
async def confirm_shipment_receipt(
request: Request,
shipment_id: Annotated[int, Path(gt=0)],
command: ShipmentReceiptCommandModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await ShipmentReceiptService.confirm_services(query_db, shipment_id, command, current_user, test_scope)
return ResponseUtil.success(msg=result.message)
@@ -0,0 +1,126 @@
from datetime import datetime
from typing import Annotated
from fastapi import Path, Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.annotation.log_annotation import Log
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.enums import BusinessType
from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
from module_business.service.master_service import MasterService
from utils.response_util import ResponseUtil
tunnel_controller = APIRouterPro(
prefix='/master/tunnel',
tags=['基础资料-隧道管理'],
dependencies=[PreAuthDependency()],
)
@tunnel_controller.get(
'/list',
summary='获取隧道分页列表接口',
description='用于获取客户现场隧道分页列表',
response_model=PageResponseModel[TunnelModel],
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
)
async def get_master_tunnel_list(
request: Request,
tunnel_page_query: Annotated[TunnelPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True)
return ResponseUtil.success(model_content=result)
@tunnel_controller.get(
'/{tunnel_id}',
summary='获取隧道详情接口',
description='用于获取指定隧道的详细信息',
response_model=DataResponseModel[TunnelModel],
dependencies=[UserInterfaceAuthDependency('master:tunnel:query')],
)
async def query_detail_master_tunnel(
request: Request,
tunnel_id: Annotated[int, Path(gt=0, description='隧道ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await MasterService.detail_tunnel(query_db, tunnel_id, scope)
return ResponseUtil.success(data=result)
@tunnel_controller.post(
'',
summary='新增隧道接口',
description='用于新增客户现场隧道',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:tunnel:add')],
)
@Log(title='隧道管理', business_type=BusinessType.INSERT)
async def add_master_tunnel(
request: Request,
add_tunnel: TunnelModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
current_time = datetime.now()
add_tunnel.create_by = current_user.user.user_name
add_tunnel.create_time = current_time
add_tunnel.update_by = current_user.user.user_name
add_tunnel.update_time = current_time
result = await MasterService.add_tunnel(query_db, add_tunnel, current_user, scope)
return ResponseUtil.success(msg=result.message)
@tunnel_controller.put(
'',
summary='编辑隧道接口',
description='用于编辑客户现场隧道',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:tunnel:edit')],
)
@Log(title='隧道管理', business_type=BusinessType.UPDATE)
async def edit_master_tunnel(
request: Request,
edit_tunnel: TunnelModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
edit_tunnel.update_by = current_user.user.user_name
edit_tunnel.update_time = datetime.now()
result = await MasterService.edit_tunnel(query_db, edit_tunnel, current_user, scope)
return ResponseUtil.success(msg=result.message)
@tunnel_controller.delete(
'/{tunnel_ids}',
summary='兼容旧接口,固定拒绝,请使用测试数据批次清理',
description='兼容旧接口,固定拒绝单条物理删除,请使用测试数据批次清理',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:tunnel:remove')],
)
@Log(title='隧道管理', business_type=BusinessType.DELETE)
async def delete_master_tunnel(
request: Request,
tunnel_ids: Annotated[
str,
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的隧道ID'),
],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
delete_tunnel = DeleteTunnelModel(tunnelIds=[int(tunnel_id) for tunnel_id in tunnel_ids.split(',')])
result = await MasterService.remove_tunnel(query_db, delete_tunnel)
return ResponseUtil.success(msg=result.message)
@@ -18,6 +18,8 @@ from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
@@ -76,7 +78,44 @@ async def get_business_usage_line_options(
@usage_controller.get( @usage_controller.get(
'/scan/{qr_token}', '/tunnel-options',
summary='获取客户现场启用隧道选项接口',
description='仅返回当前用户有权访问的指定客户现场启用隧道',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
)
async def get_business_usage_tunnel_options(
request: Request,
site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await UsageService.get_tunnel_options_services(query_db, site_id, current_user, test_scope)
return ResponseUtil.success(data=result)
@usage_controller.post(
'/enter-tunnel',
summary='扫码入隧接口',
description='将单个已到客户现场的托盘绑定到指定隧道,并生成或复用隧道内部溯源号',
response_model=DataResponseModel[TunnelEntrySuccessResponseModel],
dependencies=[UserInterfaceAuthDependency('business:usage:add')],
)
@Log(title='扫码入隧', business_type=BusinessType.INSERT)
async def enter_business_usage_tunnel(
request: Request,
entry_command: TunnelEntryCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
) -> Response:
result = await UsageService.enter_tunnel_services(query_db, entry_command, current_user, test_scope)
return ResponseUtil.success(msg=result.message, data=result.result)
@usage_controller.get(
'/scan/{qr_token:path}',
summary='扫描客户现场待使用托盘接口', summary='扫描客户现场待使用托盘接口',
description='按托盘当前客户现场数据范围判断是否允许记录使用', description='按托盘当前客户现场数据范围判断是否允许记录使用',
response_model=DataResponseModel[PalletScanResponseModel], response_model=DataResponseModel[PalletScanResponseModel],
@@ -0,0 +1,39 @@
"""只读先进先出建议;不强行阻断会议要求的多批次混用。"""
from typing import Annotated
from fastapi import Query, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from common.aspect.db_seesion import DBSessionDependency
from common.aspect.interface_auth import UserInterfaceAuthDependency
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
from common.router import APIRouterPro
from common.vo import DataResponseModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
from module_business.service.usage_fifo_service import UsageFifoService
from utils.response_util import ResponseUtil
usage_fifo_controller = APIRouterPro(
prefix='/business/usage-fifo',
tags=['业务管理-客户现场使用'],
dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('business:usage:query')],
)
@usage_fifo_controller.get('/recommendation', response_model=DataResponseModel[UsageFifoRecommendationModel])
async def get_usage_fifo_recommendation(
request: Request,
qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
tunnel_id: Annotated[int | None, Query(alias='tunnelId', gt=0)] = None,
) -> Response:
result = await UsageFifoService.recommendation(
query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id,
)
return ResponseUtil.success(data=result)
@@ -1,6 +1,8 @@
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.exc import MultipleResultsFound
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import ServiceException
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizPallet from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory
@@ -57,13 +59,18 @@ class InventoryDao:
qr_token: str, qr_token: str,
scope: TestBatchScope, scope: TestBatchScope,
) -> BizPallet | None: ) -> BizPallet | None:
"""兼容印刷托盘号;调用方仍须复核仓库/现场权限,写入使用规范二维码。"""
identifier = qr_token.strip()
result = await db.execute( result = await db.execute(
select(BizPallet).where( select(BizPallet).where(
BizPallet.qr_token == qr_token, or_(BizPallet.qr_token == identifier, BizPallet.pallet_code == identifier),
batch_scope_condition(BizPallet.test_batch_id, scope), batch_scope_condition(BizPallet.test_batch_id, scope),
) )
) )
try:
return result.scalar_one_or_none() return result.scalar_one_or_none()
except MultipleResultsFound:
raise ServiceException(message='托盘号与二维码标识存在歧义,请联系管理员核对后重试') from None
@classmethod @classmethod
async def get_pallets_by_ids( async def get_pallets_by_ids(
@@ -2,7 +2,7 @@ from datetime import datetime
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import ColumnElement, Select, select, update from sqlalchemy import ColumnElement, Select, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
BizPallet, BizPallet,
BizProductionLine, BizProductionLine,
BizStrain, BizStrain,
BizTunnel,
BizUserSite, BizUserSite,
BizUserWarehouse, BizUserWarehouse,
BizWarehouse, BizWarehouse,
@@ -31,6 +32,8 @@ from module_business.entity.vo.master_vo import (
SitePageQueryModel, SitePageQueryModel,
StrainModel, StrainModel,
StrainPageQueryModel, StrainPageQueryModel,
TunnelModel,
TunnelPageQueryModel,
WarehouseModel, WarehouseModel,
WarehousePageQueryModel, WarehousePageQueryModel,
) )
@@ -38,7 +41,7 @@ from utils.page_util import PageUtil
class MasterDao: class MasterDao:
"""菌种、客户、现场、产线和仓库主数据数据库操作层。""" """菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
@staticmethod @staticmethod
async def _paginate( async def _paginate(
@@ -339,6 +342,67 @@ class MasterDao:
async def update_line(cls, db: AsyncSession, line: LineModel, scope: TestBatchScope) -> None: async def update_line(cls, db: AsyncSession, line: LineModel, scope: TestBatchScope) -> None:
await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line, scope) await cls._update(db, BizProductionLine, BizProductionLine.line_id, 'line_id', line, scope)
@classmethod
async def list_tunnel(
cls,
db: AsyncSession,
query_object: TunnelPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = [batch_scope_condition(BizTunnel.test_batch_id, scope)]
if query_object.site_id:
conditions.append(BizTunnel.site_id == query_object.site_id)
if query_object.tunnel_code:
conditions.append(BizTunnel.tunnel_code.like(f'%{query_object.tunnel_code}%'))
if query_object.tunnel_name:
conditions.append(BizTunnel.tunnel_name.like(f'%{query_object.tunnel_name}%'))
if query_object.status:
conditions.append(BizTunnel.status == query_object.status)
statement = select(BizTunnel).where(*conditions).order_by(BizTunnel.tunnel_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_tunnel(
cls,
db: AsyncSession,
tunnel_id: int,
scope: TestBatchScope,
*,
for_update: bool = False,
) -> BizTunnel | None:
return await cls._get(
db,
BizTunnel,
BizTunnel.tunnel_id,
tunnel_id,
batch_scope_condition(BizTunnel.test_batch_id, scope),
for_update=for_update,
)
@classmethod
async def find_tunnel_by_code(
cls,
db: AsyncSession,
tunnel_code: str,
site_id: int,
) -> BizTunnel | None:
return await cls._find(
db,
BizTunnel,
BizTunnel.tunnel_code,
tunnel_code,
BizTunnel.site_id == site_id,
)
@classmethod
async def add_tunnel(cls, db: AsyncSession, tunnel: TunnelModel, test_batch_id: int | None) -> BizTunnel:
return await cls._add(db, BizTunnel, 'tunnel_id', tunnel, test_batch_id)
@classmethod
async def update_tunnel(cls, db: AsyncSession, tunnel: TunnelModel, scope: TestBatchScope) -> None:
await cls._update(db, BizTunnel, BizTunnel.tunnel_id, 'tunnel_id', tunnel, scope)
@classmethod @classmethod
async def list_warehouse( async def list_warehouse(
cls, cls,
@@ -475,6 +539,25 @@ class MasterDao:
result = await db.execute(statement) result = await db.execute(statement)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@classmethod
async def count_tunnel_trace_members(
cls,
db: AsyncSession,
tunnel_id: int,
trace_no: str,
scope: TestBatchScope,
) -> int:
result = await db.execute(
select(func.count())
.select_from(BizPalletInventory)
.where(
BizPalletInventory.tunnel_id == tunnel_id,
BizPalletInventory.trace_no == trace_no,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
)
return int(result.scalar_one() or 0)
@classmethod @classmethod
async def add_pallet_inventory( async def add_pallet_inventory(
cls, cls,
@@ -810,6 +810,10 @@ class OperationDao:
conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%')) conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%'))
if query_object.site_id: if query_object.site_id:
conditions.append(BizUsageRecord.site_id == query_object.site_id) conditions.append(BizUsageRecord.site_id == query_object.site_id)
if query_object.tunnel_id:
conditions.append(BizUsageRecord.tunnel_id == query_object.tunnel_id)
if query_object.trace_no:
conditions.append(BizUsageRecord.trace_no.like(f'%{query_object.trace_no}%'))
if query_object.line_id: if query_object.line_id:
conditions.append(BizUsageRecord.line_id == query_object.line_id) conditions.append(BizUsageRecord.line_id == query_object.line_id)
if query_object.pallet_code: if query_object.pallet_code:
@@ -1,7 +1,7 @@
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from typing import Any from typing import Any
from sqlalchemy import ColumnElement, Select, and_, false, or_, select from sqlalchemy import ColumnElement, Select, and_, case, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
BizCustomerSite, BizCustomerSite,
BizPallet, BizPallet,
BizProductionLine, BizProductionLine,
BizTunnel,
BizWarehouse, BizWarehouse,
) )
from module_business.entity.do.operation_do import ( from module_business.entity.do.operation_do import (
@@ -40,6 +41,7 @@ from module_business.entity.vo.report_vo import (
ReturnReportQueryModel, ReturnReportQueryModel,
ShipmentReportModel, ShipmentReportModel,
ShipmentReportQueryModel, ShipmentReportQueryModel,
TunnelReportQueryModel,
) )
from utils.page_util import PageUtil from utils.page_util import PageUtil
@@ -84,6 +86,31 @@ class ReportDao:
"""保留空的可选 join,同时拒绝已存在的跨批次关联记录。""" """保留空的可选 join,同时拒绝已存在的跨批次关联记录。"""
return or_(id_column.is_(None), batch_scope_condition(scope_column, scope)) return or_(id_column.is_(None), batch_scope_condition(scope_column, scope))
@staticmethod
def _tunnel_conditions(
query: TunnelReportQueryModel,
trace_column: ColumnElement,
*tunnels: tuple[ColumnElement, Any],
) -> list[ColumnElement[bool]]:
"""历史事件可匹配变更前/后隧道;同一组编号、名称须命中同一个隧道。"""
conditions = []
if query.trace_no:
conditions.append(trace_column == query.trace_no)
matches = []
for id_column, tunnel in tunnels:
filters = []
if query.tunnel_id:
filters.append(id_column == query.tunnel_id)
if query.tunnel_code:
filters.append(tunnel.tunnel_code.like(f'%{query.tunnel_code}%'))
if query.tunnel_name:
filters.append(tunnel.tunnel_name.like(f'%{query.tunnel_name}%'))
if filters:
matches.append(and_(*filters))
if matches:
conditions.append(or_(*matches))
return conditions
@classmethod @classmethod
def _or_scope_conditions( def _or_scope_conditions(
cls, cls,
@@ -158,6 +185,7 @@ class ReportDao:
current_warehouse = aliased(BizWarehouse, name='current_warehouse') current_warehouse = aliased(BizWarehouse, name='current_warehouse')
current_site = aliased(BizCustomerSite, name='current_site') current_site = aliased(BizCustomerSite, name='current_site')
current_line = aliased(BizProductionLine, name='current_line') current_line = aliased(BizProductionLine, name='current_line')
current_tunnel = aliased(BizTunnel, name='current_tunnel')
active_filling = aliased(BizFillingRecord, name='active_filling') active_filling = aliased(BizFillingRecord, name='active_filling')
conditions = [ conditions = [
@@ -167,7 +195,11 @@ class ReportDao:
cls._joined_scope_condition(current_warehouse.warehouse_id, current_warehouse.test_batch_id, scope), cls._joined_scope_condition(current_warehouse.warehouse_id, current_warehouse.test_batch_id, scope),
cls._joined_scope_condition(current_site.site_id, current_site.test_batch_id, scope), cls._joined_scope_condition(current_site.site_id, current_site.test_batch_id, scope),
cls._joined_scope_condition(current_line.line_id, current_line.test_batch_id, scope), cls._joined_scope_condition(current_line.line_id, current_line.test_batch_id, scope),
cls._joined_scope_condition(current_tunnel.tunnel_id, current_tunnel.test_batch_id, scope),
] ]
conditions.extend(cls._tunnel_conditions(
query_object, BizPalletInventory.trace_no, (BizPalletInventory.tunnel_id, current_tunnel),
))
conditions.extend( conditions.extend(
cls._or_scope_conditions( cls._or_scope_conditions(
(BizPalletInventory.warehouse_id, warehouse_ids), (BizPalletInventory.warehouse_id, warehouse_ids),
@@ -214,6 +246,12 @@ class ReportDao:
active_filling.initial_packages.label('initial_packages'), active_filling.initial_packages.label('initial_packages'),
BizPalletInventory.remaining_packages.label('remaining_packages'), BizPalletInventory.remaining_packages.label('remaining_packages'),
BizPalletInventory.updated_at.label('updated_at'), BizPalletInventory.updated_at.label('updated_at'),
BizPalletInventory.tunnel_id.label('tunnel_id'),
current_tunnel.tunnel_code.label('tunnel_code'),
current_tunnel.tunnel_name.label('tunnel_name'),
BizPalletInventory.trace_no.label('trace_no'),
active_filling.production_date.label('production_date'),
active_filling.expiry_date.label('expiry_date'),
) )
.select_from(BizPalletInventory) .select_from(BizPalletInventory)
.join(BizPallet, BizPallet.pallet_id == BizPalletInventory.pallet_id) .join(BizPallet, BizPallet.pallet_id == BizPalletInventory.pallet_id)
@@ -221,6 +259,7 @@ class ReportDao:
.outerjoin(current_warehouse, current_warehouse.warehouse_id == BizPalletInventory.warehouse_id) .outerjoin(current_warehouse, current_warehouse.warehouse_id == BizPalletInventory.warehouse_id)
.outerjoin(current_site, current_site.site_id == BizPalletInventory.site_id) .outerjoin(current_site, current_site.site_id == BizPalletInventory.site_id)
.outerjoin(current_line, current_line.line_id == BizPalletInventory.current_line_id) .outerjoin(current_line, current_line.line_id == BizPalletInventory.current_line_id)
.outerjoin(current_tunnel, current_tunnel.tunnel_id == BizPalletInventory.tunnel_id)
.where(*conditions) .where(*conditions)
.order_by(BizPalletInventory.pallet_id) .order_by(BizPalletInventory.pallet_id)
) )
@@ -374,14 +413,21 @@ class ReportDao:
site_ids: list[int] | None = None, site_ids: list[int] | None = None,
) -> PageModel[ConsumptionReportModel]: ) -> PageModel[ConsumptionReportModel]:
operator_user = aliased(SysUser, name='operator_user') operator_user = aliased(SysUser, name='operator_user')
usage_tunnel = aliased(BizTunnel, name='usage_tunnel')
# 新记录优先使用业务快照;历史空值只回查该次使用引用的装填记录。
batch_no = func.coalesce(BizUsageRecord.batch_no_snapshot, BizFillingRecord.batch_no)
conditions = [ conditions = [
batch_scope_condition(BizUsageRecord.test_batch_id, scope), batch_scope_condition(BizUsageRecord.test_batch_id, scope),
batch_scope_condition(BizPallet.test_batch_id, scope), batch_scope_condition(BizPallet.test_batch_id, scope),
batch_scope_condition(BizFillingRecord.test_batch_id, scope), batch_scope_condition(BizFillingRecord.test_batch_id, scope),
batch_scope_condition(BizCustomerSite.test_batch_id, scope), batch_scope_condition(BizCustomerSite.test_batch_id, scope),
batch_scope_condition(BizProductionLine.test_batch_id, scope), cls._joined_scope_condition(BizProductionLine.line_id, BizProductionLine.test_batch_id, scope),
cls._joined_scope_condition(usage_tunnel.tunnel_id, usage_tunnel.test_batch_id, scope),
cls._joined_scope_condition(BizCustomer.customer_id, BizCustomer.test_batch_id, scope), cls._joined_scope_condition(BizCustomer.customer_id, BizCustomer.test_batch_id, scope),
] ]
conditions.extend(cls._tunnel_conditions(
query_object, BizUsageRecord.trace_no, (BizUsageRecord.tunnel_id, usage_tunnel),
))
site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids) site_scope = cls._scope_condition(BizUsageRecord.site_id, site_ids)
if site_scope is not None: if site_scope is not None:
conditions.append(site_scope) conditions.append(site_scope)
@@ -396,7 +442,7 @@ class ReportDao:
if query_object.strain_id: if query_object.strain_id:
conditions.append(BizFillingRecord.strain_id == query_object.strain_id) conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
if query_object.batch_no: if query_object.batch_no:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%')) conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
conditions.extend( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizUsageRecord.occurred_at, BizUsageRecord.occurred_at,
@@ -421,17 +467,25 @@ class ReportDao:
BizFillingRecord.strain_id.label('strain_id'), BizFillingRecord.strain_id.label('strain_id'),
BizFillingRecord.strain_code_snapshot.label('strain_code'), BizFillingRecord.strain_code_snapshot.label('strain_code'),
BizFillingRecord.strain_name_snapshot.label('strain_name'), BizFillingRecord.strain_name_snapshot.label('strain_name'),
BizFillingRecord.batch_no.label('batch_no'), batch_no.label('batch_no'),
BizUsageRecord.used_packages.label('used_packages'), BizUsageRecord.used_packages.label('used_packages'),
BizUsageRecord.remaining_packages_after.label('remaining_packages_after'), BizUsageRecord.remaining_packages_after.label('remaining_packages_after'),
BizUsageRecord.operator_id.label('operator_id'), BizUsageRecord.operator_id.label('operator_id'),
operator_user.user_name.label('operator_name'), operator_user.user_name.label('operator_name'),
BizUsageRecord.tunnel_id.label('tunnel_id'),
usage_tunnel.tunnel_code.label('tunnel_code'),
usage_tunnel.tunnel_name.label('tunnel_name'),
BizUsageRecord.trace_no.label('trace_no'),
BizUsageRecord.batch_no_snapshot.label('batch_no_snapshot'),
BizFillingRecord.production_date.label('production_date'),
BizFillingRecord.expiry_date.label('expiry_date'),
) )
.select_from(BizUsageRecord) .select_from(BizUsageRecord)
.join(BizPallet, BizPallet.pallet_id == BizUsageRecord.pallet_id) .join(BizPallet, BizPallet.pallet_id == BizUsageRecord.pallet_id)
.join(BizFillingRecord, BizFillingRecord.filling_id == BizUsageRecord.active_filling_id) .join(BizFillingRecord, BizFillingRecord.filling_id == BizUsageRecord.active_filling_id)
.join(BizCustomerSite, BizCustomerSite.site_id == BizUsageRecord.site_id) .join(BizCustomerSite, BizCustomerSite.site_id == BizUsageRecord.site_id)
.join(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id) .outerjoin(BizProductionLine, BizProductionLine.line_id == BizUsageRecord.line_id)
.outerjoin(usage_tunnel, usage_tunnel.tunnel_id == BizUsageRecord.tunnel_id)
.outerjoin(BizCustomer, BizCustomer.customer_id == BizCustomerSite.customer_id) .outerjoin(BizCustomer, BizCustomer.customer_id == BizCustomerSite.customer_id)
.outerjoin(operator_user, operator_user.user_id == BizUsageRecord.operator_id) .outerjoin(operator_user, operator_user.user_id == BizUsageRecord.operator_id)
.where(*conditions) .where(*conditions)
@@ -450,13 +504,23 @@ class ReportDao:
) -> PageModel[ReturnReportModel]: ) -> PageModel[ReturnReportModel]:
source_site = aliased(BizCustomerSite, name='source_site') source_site = aliased(BizCustomerSite, name='source_site')
target_warehouse = aliased(BizWarehouse, name='target_warehouse') target_warehouse = aliased(BizWarehouse, name='target_warehouse')
return_tunnel = aliased(BizTunnel, name='return_tunnel')
return_filling = aliased(BizFillingRecord, name='return_filling')
batch_no = func.coalesce(BizReturnItem.batch_no_snapshot, return_filling.batch_no)
conditions = [ conditions = [
batch_scope_condition(BizReturnOrder.test_batch_id, scope), batch_scope_condition(BizReturnOrder.test_batch_id, scope),
batch_scope_condition(BizReturnItem.test_batch_id, scope), batch_scope_condition(BizReturnItem.test_batch_id, scope),
batch_scope_condition(BizPallet.test_batch_id, scope), batch_scope_condition(BizPallet.test_batch_id, scope),
cls._joined_scope_condition(source_site.site_id, source_site.test_batch_id, scope), cls._joined_scope_condition(source_site.site_id, source_site.test_batch_id, scope),
cls._joined_scope_condition(target_warehouse.warehouse_id, target_warehouse.test_batch_id, scope), cls._joined_scope_condition(target_warehouse.warehouse_id, target_warehouse.test_batch_id, scope),
cls._joined_scope_condition(return_tunnel.tunnel_id, return_tunnel.test_batch_id, scope),
cls._joined_scope_condition(return_filling.filling_id, return_filling.test_batch_id, scope),
] ]
conditions.extend(cls._tunnel_conditions(
query_object, BizReturnItem.trace_no_snapshot, (BizReturnItem.tunnel_id_snapshot, return_tunnel),
))
if query_object.batch_no:
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
conditions.extend( conditions.extend(
cls._or_scope_conditions( cls._or_scope_conditions(
(BizReturnOrder.source_site_id, site_ids), (BizReturnOrder.source_site_id, site_ids),
@@ -496,12 +560,22 @@ class ReportDao:
BizPallet.pallet_code.label('pallet_code'), BizPallet.pallet_code.label('pallet_code'),
BizReturnItem.active_filling_id_snapshot.label('active_filling_id_snapshot'), BizReturnItem.active_filling_id_snapshot.label('active_filling_id_snapshot'),
BizReturnItem.remaining_packages_snapshot.label('remaining_packages_snapshot'), BizReturnItem.remaining_packages_snapshot.label('remaining_packages_snapshot'),
BizReturnItem.tunnel_id_snapshot.label('tunnel_id'),
return_tunnel.tunnel_code.label('tunnel_code'),
return_tunnel.tunnel_name.label('tunnel_name'),
BizReturnItem.trace_no_snapshot.label('trace_no'),
BizReturnItem.batch_no_snapshot.label('batch_no_snapshot'),
batch_no.label('batch_no'),
return_filling.production_date.label('production_date'),
return_filling.expiry_date.label('expiry_date'),
) )
.select_from(BizReturnOrder) .select_from(BizReturnOrder)
.join(BizReturnItem, BizReturnItem.return_id == BizReturnOrder.return_id) .join(BizReturnItem, BizReturnItem.return_id == BizReturnOrder.return_id)
.join(BizPallet, BizPallet.pallet_id == BizReturnItem.pallet_id) .join(BizPallet, BizPallet.pallet_id == BizReturnItem.pallet_id)
.outerjoin(source_site, source_site.site_id == BizReturnOrder.source_site_id) .outerjoin(source_site, source_site.site_id == BizReturnOrder.source_site_id)
.outerjoin(target_warehouse, target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id) .outerjoin(target_warehouse, target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id)
.outerjoin(return_tunnel, return_tunnel.tunnel_id == BizReturnItem.tunnel_id_snapshot)
.outerjoin(return_filling, return_filling.filling_id == BizReturnItem.active_filling_id_snapshot)
.where(*conditions) .where(*conditions)
.order_by(BizReturnOrder.return_id, BizReturnItem.return_item_id) .order_by(BizReturnOrder.return_id, BizReturnItem.return_item_id)
) )
@@ -522,6 +596,41 @@ class ReportDao:
after_site = aliased(BizCustomerSite, name='after_site') after_site = aliased(BizCustomerSite, name='after_site')
before_line = aliased(BizProductionLine, name='before_line') before_line = aliased(BizProductionLine, name='before_line')
after_line = aliased(BizProductionLine, name='after_line') after_line = aliased(BizProductionLine, name='after_line')
before_tunnel = aliased(BizTunnel, name='before_tunnel')
after_tunnel = aliased(BizTunnel, name='after_tunnel')
trace_return_item = aliased(BizReturnItem, name='trace_return_item')
trace_usage = aliased(BizUsageRecord, name='trace_usage')
trace_shipment_item = aliased(BizShipmentItem, name='trace_shipment_item')
trace_filling = aliased(BizFillingRecord, name='trace_filling')
prior_filling_event = aliased(BizPalletEvent, name='prior_filling_event')
# 入隧事件来源是隧道而非装填记录,按托盘事件序号回查当时已发生的最后一次装填。
# 其他事件只使用来源单据的装填引用;引用缺失时保留空日期,不借用后续重装填。
prior_filling_id = (
select(prior_filling_event.source_id)
.where(
prior_filling_event.pallet_id == BizPalletEvent.pallet_id,
prior_filling_event.event_seq < BizPalletEvent.event_seq,
prior_filling_event.event_type == PalletEventType.FILLING_CONFIRMED.value,
prior_filling_event.source_type == 'FILLING',
batch_scope_condition(prior_filling_event.test_batch_id, scope),
)
.order_by(prior_filling_event.event_seq.desc())
.limit(1)
.correlate(BizPalletEvent)
.scalar_subquery()
)
trace_filling_id = case(
(BizPalletEvent.source_type == 'FILLING', BizPalletEvent.source_id),
(BizPalletEvent.source_type == 'USAGE', trace_usage.active_filling_id),
(BizPalletEvent.source_type == 'RETURN', trace_return_item.active_filling_id_snapshot),
(BizPalletEvent.source_type == 'SHIPMENT', trace_shipment_item.active_filling_id),
(BizPalletEvent.event_type == PalletEventType.TUNNEL_ENTERED.value, prior_filling_id),
else_=None,
)
# 部分退回事件未保存隧道;只从同单同托盘的冻结退回项补读,不读取当前库存。
before_tunnel_id = func.coalesce(BizPalletEvent.before_tunnel_id, trace_return_item.tunnel_id_snapshot)
trace_no = func.coalesce(BizPalletEvent.trace_no, trace_return_item.trace_no_snapshot)
batch_no = func.coalesce(BizPalletEvent.batch_no_snapshot, trace_return_item.batch_no_snapshot)
operator_user = aliased(SysUser, name='operator_user') operator_user = aliased(SysUser, name='operator_user')
conditions = [ conditions = [
batch_scope_condition(BizPalletEvent.test_batch_id, scope), batch_scope_condition(BizPalletEvent.test_batch_id, scope),
@@ -532,7 +641,20 @@ class ReportDao:
cls._joined_scope_condition(after_site.site_id, after_site.test_batch_id, scope), cls._joined_scope_condition(after_site.site_id, after_site.test_batch_id, scope),
cls._joined_scope_condition(before_line.line_id, before_line.test_batch_id, scope), cls._joined_scope_condition(before_line.line_id, before_line.test_batch_id, scope),
cls._joined_scope_condition(after_line.line_id, after_line.test_batch_id, scope), cls._joined_scope_condition(after_line.line_id, after_line.test_batch_id, scope),
cls._joined_scope_condition(before_tunnel.tunnel_id, before_tunnel.test_batch_id, scope),
cls._joined_scope_condition(after_tunnel.tunnel_id, after_tunnel.test_batch_id, scope),
cls._joined_scope_condition(trace_return_item.return_item_id, trace_return_item.test_batch_id, scope),
cls._joined_scope_condition(trace_usage.usage_id, trace_usage.test_batch_id, scope),
cls._joined_scope_condition(trace_shipment_item.shipment_item_id, trace_shipment_item.test_batch_id, scope),
cls._joined_scope_condition(trace_filling.filling_id, trace_filling.test_batch_id, scope),
] ]
conditions.extend(cls._tunnel_conditions(
query_object, trace_no,
(before_tunnel_id, before_tunnel),
(BizPalletEvent.after_tunnel_id, after_tunnel),
))
if query_object.batch_no:
conditions.append(batch_no.like(f'%{query_object.batch_no}%'))
conditions.extend(cls._pallet_trace_scope_conditions(warehouse_ids, site_ids)) conditions.extend(cls._pallet_trace_scope_conditions(warehouse_ids, site_ids))
if query_object.pallet_code: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
@@ -574,6 +696,19 @@ class ReportDao:
BizPalletEvent.operator_id.label('operator_id'), BizPalletEvent.operator_id.label('operator_id'),
operator_user.user_name.label('operator_name'), operator_user.user_name.label('operator_name'),
BizPalletEvent.occurred_at.label('occurred_at'), BizPalletEvent.occurred_at.label('occurred_at'),
func.coalesce(BizPalletEvent.after_tunnel_id, before_tunnel_id).label('tunnel_id'),
func.coalesce(after_tunnel.tunnel_code, before_tunnel.tunnel_code).label('tunnel_code'),
func.coalesce(after_tunnel.tunnel_name, before_tunnel.tunnel_name).label('tunnel_name'),
trace_no.label('trace_no'),
batch_no.label('batch_no_snapshot'),
before_tunnel_id.label('before_tunnel_id'),
before_tunnel.tunnel_code.label('before_tunnel_code'),
before_tunnel.tunnel_name.label('before_tunnel_name'),
BizPalletEvent.after_tunnel_id.label('after_tunnel_id'),
after_tunnel.tunnel_code.label('after_tunnel_code'),
after_tunnel.tunnel_name.label('after_tunnel_name'),
trace_filling.production_date.label('production_date'),
trace_filling.expiry_date.label('expiry_date'),
) )
.select_from(BizPalletEvent) .select_from(BizPalletEvent)
.join(BizPallet, BizPallet.pallet_id == BizPalletEvent.pallet_id) .join(BizPallet, BizPallet.pallet_id == BizPalletEvent.pallet_id)
@@ -583,6 +718,27 @@ class ReportDao:
.outerjoin(after_site, after_site.site_id == BizPalletEvent.after_site_id) .outerjoin(after_site, after_site.site_id == BizPalletEvent.after_site_id)
.outerjoin(before_line, before_line.line_id == BizPalletEvent.before_line_id) .outerjoin(before_line, before_line.line_id == BizPalletEvent.before_line_id)
.outerjoin(after_line, after_line.line_id == BizPalletEvent.after_line_id) .outerjoin(after_line, after_line.line_id == BizPalletEvent.after_line_id)
.outerjoin(trace_return_item, and_(
BizPalletEvent.source_type == 'RETURN',
trace_return_item.return_id == BizPalletEvent.source_id,
trace_return_item.pallet_id == BizPalletEvent.pallet_id,
))
.outerjoin(trace_usage, and_(
BizPalletEvent.source_type == 'USAGE',
trace_usage.usage_id == BizPalletEvent.source_id,
trace_usage.pallet_id == BizPalletEvent.pallet_id,
))
.outerjoin(trace_shipment_item, and_(
BizPalletEvent.source_type == 'SHIPMENT',
trace_shipment_item.shipment_id == BizPalletEvent.source_id,
trace_shipment_item.pallet_id == BizPalletEvent.pallet_id,
))
.outerjoin(trace_filling, and_(
trace_filling.filling_id == trace_filling_id,
trace_filling.pallet_id == BizPalletEvent.pallet_id,
))
.outerjoin(before_tunnel, before_tunnel.tunnel_id == before_tunnel_id)
.outerjoin(after_tunnel, after_tunnel.tunnel_id == BizPalletEvent.after_tunnel_id)
.outerjoin(operator_user, operator_user.user_id == BizPalletEvent.operator_id) .outerjoin(operator_user, operator_user.user_id == BizPalletEvent.operator_id)
.where(*conditions) .where(*conditions)
.order_by(BizPalletEvent.occurred_at, BizPalletEvent.event_id) .order_by(BizPalletEvent.occurred_at, BizPalletEvent.event_id)
@@ -0,0 +1,62 @@
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_business.domain.enums import DocumentStatus, PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletInventory,
BizShipmentItem,
BizShipmentOrder,
)
class ShipmentLabelDao:
"""只读标签资料;外连接保留失效明细,由服务拒绝不完整的整单打印。"""
@staticmethod
async def get_label_sources(
db: AsyncSession,
shipment: BizShipmentOrder,
scope: TestBatchScope,
) -> list[tuple[BizShipmentItem, BizPallet | None, BizFillingRecord | None, int | None]]:
draft = shipment.status == DocumentStatus.DRAFT.value
quantity = BizPalletInventory.remaining_packages if draft else BizShipmentItem.package_qty
statement = (
select(BizShipmentItem, BizPallet, BizFillingRecord, quantity)
.select_from(BizShipmentItem)
.outerjoin(BizPallet, and_(
BizShipmentItem.pallet_id == BizPallet.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
))
)
if draft:
statement = statement.outerjoin(BizPalletInventory, and_(
BizShipmentItem.pallet_id == BizPalletInventory.pallet_id,
BizPalletInventory.warehouse_id == shipment.source_warehouse_id,
BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value,
BizPalletInventory.site_id.is_(None),
BizPalletInventory.current_line_id.is_(None),
BizPalletInventory.active_filling_id.is_not(None),
BizPalletInventory.remaining_packages > 0,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
))
# 已确认单只关联确认时的装填记录,即使周期已关闭、托盘已重新装填也可补打。
filling_id = BizPalletInventory.active_filling_id if draft else BizShipmentItem.active_filling_id
filling_conditions = [
filling_id == BizFillingRecord.filling_id,
BizShipmentItem.pallet_id == BizFillingRecord.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
]
if draft:
filling_conditions.append(BizFillingRecord.active_slot == 'ACTIVE')
statement = (
statement.outerjoin(BizFillingRecord, and_(*filling_conditions))
.where(
BizShipmentItem.shipment_id == shipment.shipment_id,
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
)
.order_by(BizShipmentItem.shipment_item_id)
)
return list((await db.execute(statement)).all())
@@ -0,0 +1,36 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_business.dao.operation_dao import OperationDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.operation_do import BizShipmentOrder
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptQueryModel
from utils.page_util import PageUtil
class ShipmentReceiptDao:
@staticmethod
async def list_orders(
db: AsyncSession, query: ShipmentReceiptQueryModel, scope: TestBatchScope, site_ids: list[int] | None
) -> PageModel:
# 接收方只需要目标现场范围,不扩大原发货操作的双范围权限。
conditions = OperationDao._shipment_scope_conditions(None, site_ids, scope)
conditions.append(BizShipmentOrder.status == 'CONFIRMED')
if query.shipment_no:
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query.shipment_no}%'))
if query.target_site_id:
conditions.append(BizShipmentOrder.target_site_id == query.target_site_id)
if query.source_warehouse_id:
conditions.append(BizShipmentOrder.source_warehouse_id == query.source_warehouse_id)
if query.receipt_state:
conditions.append(BizShipmentOrder.receipt_state == query.receipt_state)
conditions.extend(
OperationDao._time_conditions(BizShipmentOrder.confirmed_at, query.begin_time, query.end_time)
)
statement = (
select(BizShipmentOrder)
.where(*conditions)
.order_by(BizShipmentOrder.confirmed_at, BizShipmentOrder.shipment_id)
)
return await PageUtil.paginate(db, statement, query.page_num, query.page_size, is_page=True)
@@ -0,0 +1,85 @@
"""按真实收货时间推荐可用物料,不推断历史到货时间。"""
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
class UsageFifoDao:
@staticmethod
async def recommendation(
db: AsyncSession,
*,
site_id: int,
strain_id: int,
tunnel_id: int | None,
trace_no: str | None,
scope: TestBatchScope,
for_update: bool = False,
) -> tuple[dict[str, Any] | None, int]:
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
conditions = [
inventory.site_id == site_id,
inventory.warehouse_id.is_(None),
inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']),
inventory.remaining_packages > 0,
inventory.remaining_packages <= pallet.capacity_packages,
filling.strain_id == strain_id,
filling.active_slot == 'ACTIVE',
filling.closed_at.is_(None),
pallet.asset_status == '0',
batch_scope_condition(inventory.test_batch_id, scope),
batch_scope_condition(filling.test_batch_id, scope),
batch_scope_condition(pallet.test_batch_id, scope),
(
unbound
if tunnel_id is None
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
),
]
base = (
select(
pallet.pallet_id,
pallet.pallet_code,
filling.batch_no,
inventory.remaining_packages,
inventory.site_arrived_at,
)
.select_from(inventory)
.join(pallet, pallet.pallet_id == inventory.pallet_id)
.join(
filling,
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
)
.where(*conditions)
)
statement = (
base.where(inventory.site_arrived_at.is_not(None))
.order_by(
inventory.site_arrived_at,
inventory.pallet_id,
)
.limit(1)
)
if for_update:
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
# 此时已持有所选库存及现场/隧道锁,不能等待其他候选库存造成反向锁依赖。
# NOWAIT 不会跳过早到托盘:冲突令整个命令回滚,再按原幂等键有限重试。
statement = statement.with_for_update(nowait=True)
known = await db.execute(statement)
first = known.mappings().first()
if for_update:
return (dict(first) if first is not None else None, 0)
unknown = await db.execute(
select(func.count()).select_from(
base.where(inventory.site_arrived_at.is_(None)).subquery(),
)
)
return (dict(first) if first is not None else None, unknown.scalar_one())
@@ -5,6 +5,7 @@ class PalletFlowState(str, Enum):
UNSTOCKED = 'UNSTOCKED' UNSTOCKED = 'UNSTOCKED'
EMPTY_IN_WAREHOUSE = 'EMPTY_IN_WAREHOUSE' EMPTY_IN_WAREHOUSE = 'EMPTY_IN_WAREHOUSE'
FILLED_IN_WAREHOUSE = 'FILLED_IN_WAREHOUSE' FILLED_IN_WAREHOUSE = 'FILLED_IN_WAREHOUSE'
IN_TRANSIT = 'IN_TRANSIT'
AT_SITE_UNUSED = 'AT_SITE_UNUSED' AT_SITE_UNUSED = 'AT_SITE_UNUSED'
AT_SITE_PARTIAL = 'AT_SITE_PARTIAL' AT_SITE_PARTIAL = 'AT_SITE_PARTIAL'
AT_SITE_EMPTY = 'AT_SITE_EMPTY' AT_SITE_EMPTY = 'AT_SITE_EMPTY'
@@ -25,6 +26,8 @@ class PalletEventType(str, Enum):
INBOUND_CONFIRMED = 'INBOUND_CONFIRMED' INBOUND_CONFIRMED = 'INBOUND_CONFIRMED'
FILLING_CONFIRMED = 'FILLING_CONFIRMED' FILLING_CONFIRMED = 'FILLING_CONFIRMED'
SHIPMENT_CONFIRMED = 'SHIPMENT_CONFIRMED' SHIPMENT_CONFIRMED = 'SHIPMENT_CONFIRMED'
SHIPMENT_RECEIVED = 'SHIPMENT_RECEIVED'
TUNNEL_ENTERED = 'TUNNEL_ENTERED'
USAGE_RECORDED = 'USAGE_RECORDED' USAGE_RECORDED = 'USAGE_RECORDED'
RETURN_REQUESTED = 'RETURN_REQUESTED' RETURN_REQUESTED = 'RETURN_REQUESTED'
RETURN_CONFIRMED = 'RETURN_CONFIRMED' RETURN_CONFIRMED = 'RETURN_CONFIRMED'
@@ -0,0 +1,15 @@
"""只比较真实到货时间,不将未知时间或托盘编号作为先后依据。"""
from datetime import datetime
from typing import Any
def requires_fifo_skip_reason(
pallet_id: int, arrived_at: datetime | None, candidate: dict[str, Any] | None
) -> bool:
return bool(
arrived_at is not None
and candidate is not None
and candidate['pallet_id'] != pallet_id
and candidate['site_arrived_at'] < arrived_at
)
@@ -13,7 +13,8 @@ class PalletStateMachine:
PalletFlowState.EMPTY_IN_WAREHOUSE, PalletFlowState.EMPTY_IN_WAREHOUSE,
PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.FILLED_IN_WAREHOUSE,
), ),
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.AT_SITE_UNUSED), (PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.IN_TRANSIT),
(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL), (PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY), (PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING), (PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING),
@@ -15,6 +15,7 @@ from module_business.entity.do.master_do import (
BizPallet, BizPallet,
BizProductionLine, BizProductionLine,
BizStrain, BizStrain,
BizTunnel,
BizWarehouse, BizWarehouse,
) )
from module_business.entity.do.operation_do import ( from module_business.entity.do.operation_do import (
@@ -42,6 +43,7 @@ BATCH_MODELS = {
BizProductionLine.line_id, BizProductionLine.line_id,
BizProductionLine.line_code, BizProductionLine.line_code,
), ),
'biz_tunnel': (BizTunnel, BizTunnel.tunnel_id, BizTunnel.tunnel_code),
'biz_warehouse': (BizWarehouse, BizWarehouse.warehouse_id, BizWarehouse.warehouse_code), 'biz_warehouse': (BizWarehouse, BizWarehouse.warehouse_id, BizWarehouse.warehouse_code),
'biz_pallet': (BizPallet, BizPallet.pallet_id, BizPallet.pallet_code), 'biz_pallet': (BizPallet, BizPallet.pallet_id, BizPallet.pallet_code),
'biz_inbound_order': (BizInboundOrder, BizInboundOrder.inbound_id, BizInboundOrder.inbound_no), 'biz_inbound_order': (BizInboundOrder, BizInboundOrder.inbound_id, BizInboundOrder.inbound_no),
@@ -85,6 +87,7 @@ BATCH_LABELS = {
'biz_customer': '客户', 'biz_customer': '客户',
'biz_customer_site': '客户现场', 'biz_customer_site': '客户现场',
'biz_production_line': '生产线', 'biz_production_line': '生产线',
'biz_tunnel': '隧道',
'biz_warehouse': '仓库', 'biz_warehouse': '仓库',
'biz_pallet': '托盘', 'biz_pallet': '托盘',
'biz_inbound_order': '入库单', 'biz_inbound_order': '入库单',
@@ -111,6 +114,7 @@ _DELETE_TIE_BREAK_ORDER = (
'biz_pallet_event', 'biz_pallet_event',
'biz_filling_record', 'biz_filling_record',
'biz_production_line', 'biz_production_line',
'biz_tunnel',
'biz_customer_site', 'biz_customer_site',
'biz_customer', 'biz_customer',
'biz_warehouse', 'biz_warehouse',
@@ -123,6 +127,7 @@ ROOT_TABLES = {
'customer': 'biz_customer', 'customer': 'biz_customer',
'site': 'biz_customer_site', 'site': 'biz_customer_site',
'line': 'biz_production_line', 'line': 'biz_production_line',
'tunnel': 'biz_tunnel',
'warehouse': 'biz_warehouse', 'warehouse': 'biz_warehouse',
'pallet': 'biz_pallet', 'pallet': 'biz_pallet',
'inbound': 'biz_inbound_order', 'inbound': 'biz_inbound_order',
@@ -136,6 +141,7 @@ _EVENT_SOURCE_TABLES = {
'INBOUND': 'biz_inbound_order', 'INBOUND': 'biz_inbound_order',
'FILLING': 'biz_filling_record', 'FILLING': 'biz_filling_record',
'SHIPMENT': 'biz_shipment_order', 'SHIPMENT': 'biz_shipment_order',
'TUNNEL': 'biz_tunnel',
'USAGE': 'biz_usage_record', 'USAGE': 'biz_usage_record',
'RETURN': 'biz_return_order', 'RETURN': 'biz_return_order',
} }
@@ -193,6 +193,47 @@ class BizProductionLine(Base):
) )
class BizTunnel(Base):
"""客户现场隧道主数据表。"""
__tablename__ = 'biz_tunnel'
__table_args__ = (
UniqueConstraint('site_id', 'tunnel_code', name='uq_biz_tunnel_site_code'),
CheckConstraint("status IN ('0', '1')", name='ck_biz_tunnel_status'),
{'comment': '客户现场隧道主数据表'},
)
tunnel_id = Column(BigInteger, primary_key=True, nullable=False, autoincrement=True, comment='隧道ID')
test_batch_id = Column(
BigInteger,
ForeignKey('biz_test_batch.batch_id'),
nullable=True,
index=True,
comment='测试数据批次ID;NULL表示正式数据',
)
site_id = Column(
BigInteger,
ForeignKey('biz_customer_site.site_id'),
nullable=False,
comment='现场ID',
)
tunnel_code = Column(String(64), nullable=False, comment='隧道编号')
tunnel_name = Column(String(100), nullable=False, comment='隧道名称')
current_trace_no = Column(String(64), nullable=True, comment='当前隧道物料溯源号')
current_trace_started_at = Column(DateTime, nullable=True, comment='当前溯源批次开始时间')
status = Column(CHAR(1), nullable=False, server_default='0', comment='状态(0正常 1停用)')
create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, server_default='', comment='更新者')
update_time = Column(DateTime, nullable=True, default=datetime.now, comment='更新时间')
remark = Column(
String(500),
nullable=True,
server_default=SqlalchemyUtil.get_server_default_null(DataBaseConfig.db_type),
comment='备注',
)
class BizPallet(Base): class BizPallet(Base):
"""托盘资产主数据表。""" """托盘资产主数据表。"""
@@ -1,6 +1,17 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy import (
JSON,
BigInteger,
CheckConstraint,
Column,
Date,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from config.database import Base from config.database import Base
from config.env import DataBaseConfig from config.env import DataBaseConfig
@@ -99,6 +110,11 @@ class BizFillingRecord(Base):
), ),
UniqueConstraint('pallet_id', 'active_slot', name='uq_biz_filling_record_pallet_active_slot'), UniqueConstraint('pallet_id', 'active_slot', name='uq_biz_filling_record_pallet_active_slot'),
CheckConstraint('initial_packages > 0', name='ck_biz_filling_record_initial_packages'), CheckConstraint('initial_packages > 0', name='ck_biz_filling_record_initial_packages'),
CheckConstraint(
'(production_date IS NULL AND expiry_date IS NULL) OR '
'(production_date IS NOT NULL AND expiry_date IS NOT NULL AND expiry_date >= production_date)',
name='ck_biz_filling_record_material_dates',
),
CheckConstraint( CheckConstraint(
"active_slot IS NULL OR active_slot = 'ACTIVE'", "active_slot IS NULL OR active_slot = 'ACTIVE'",
name='ck_biz_filling_record_active_slot', name='ck_biz_filling_record_active_slot',
@@ -126,6 +142,8 @@ class BizFillingRecord(Base):
strain_code_snapshot = Column(String(64), nullable=False, comment='菌种编码快照') strain_code_snapshot = Column(String(64), nullable=False, comment='菌种编码快照')
strain_name_snapshot = Column(String(100), nullable=False, comment='菌种名称快照') strain_name_snapshot = Column(String(100), nullable=False, comment='菌种名称快照')
batch_no = Column(String(64), nullable=False, comment='生产批次') batch_no = Column(String(64), nullable=False, comment='生产批次')
production_date = Column(Date, nullable=True, comment='来源生产日期;历史未登记为空')
expiry_date = Column(Date, nullable=True, comment='有效期至;历史未登记为空')
package_spec = Column(String(64), nullable=False, comment='包装规格') package_spec = Column(String(64), nullable=False, comment='包装规格')
initial_packages = Column(Integer, nullable=False, comment='初始包数') initial_packages = Column(Integer, nullable=False, comment='初始包数')
active_slot = Column(String(16), nullable=True, comment='活动装填槽') active_slot = Column(String(16), nullable=True, comment='活动装填槽')
@@ -158,6 +176,8 @@ class BizShipmentOrder(Base):
name='uq_biz_shipment_order_route_confirm_idempotency', name='uq_biz_shipment_order_route_confirm_idempotency',
), ),
CheckConstraint("status IN ('DRAFT', 'CONFIRMED')", name='ck_biz_shipment_order_status'), CheckConstraint("status IN ('DRAFT', 'CONFIRMED')", name='ck_biz_shipment_order_status'),
CheckConstraint("receipt_state IN ('PENDING', 'RECEIVED', 'LEGACY')", name='ck_biz_shipment_order_receipt_state'),
UniqueConstraint('target_site_id', 'receipt_idempotency_key', name='uq_biz_shipment_receipt_site_key'),
{'comment': '整托发货单'}, {'comment': '整托发货单'},
) )
@@ -187,6 +207,11 @@ class BizShipmentOrder(Base):
confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要') confirm_request_hash = Column(String(64), nullable=True, comment='确认请求摘要')
confirmed_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='确认人ID') confirmed_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='确认人ID')
confirmed_at = Column(DateTime, nullable=True, comment='确认时间') confirmed_at = Column(DateTime, nullable=True, comment='确认时间')
receipt_state = Column(String(16), nullable=False, server_default='PENDING', comment='收货状态;LEGACY表示历史未登记')
received_by = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=True, comment='收货核对人ID')
received_at = Column(DateTime, nullable=True, comment='实际收货核对时间')
receipt_idempotency_key = Column(String(64), nullable=True, comment='收货确认幂等键')
receipt_request_hash = Column(String(64), nullable=True, comment='收货核对请求摘要')
create_by = Column(String(64), nullable=True, server_default='', comment='创建者') create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
update_by = Column(String(64), nullable=True, server_default='', comment='更新者') update_by = Column(String(64), nullable=True, server_default='', comment='更新者')
@@ -275,13 +300,23 @@ class BizUsageRecord(Base):
comment='活动装填记录ID', comment='活动装填记录ID',
) )
site_id = Column(BigInteger, ForeignKey('biz_customer_site.site_id'), nullable=False, comment='现场ID') site_id = Column(BigInteger, ForeignKey('biz_customer_site.site_id'), nullable=False, comment='现场ID')
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=False, comment='产线ID') tunnel_id = Column(
BigInteger,
ForeignKey('biz_tunnel.tunnel_id'),
nullable=True,
comment='隧道ID;历史记录可能为空',
)
trace_no = Column(String(64), nullable=True, comment='隧道物料内部溯源号;历史记录可能为空')
batch_no_snapshot = Column(String(64), nullable=True, comment='生产批次快照;历史记录可能为空')
line_id = Column(BigInteger, ForeignKey('biz_production_line.line_id'), nullable=True, comment='产线ID(可选)')
used_packages = Column(Integer, nullable=False, comment='本次使用包数') used_packages = Column(Integer, nullable=False, comment='本次使用包数')
remaining_packages_after = Column(Integer, nullable=False, comment='使用后剩余包数') remaining_packages_after = Column(Integer, nullable=False, comment='使用后剩余包数')
idempotency_key = Column(String(64), nullable=False, comment='幂等键') idempotency_key = Column(String(64), nullable=False, comment='幂等键')
request_hash = Column(String(64), nullable=False, comment='请求摘要') request_hash = Column(String(64), nullable=False, comment='请求摘要')
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID') operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
occurred_at = Column(DateTime, nullable=False, comment='发生时间') occurred_at = Column(DateTime, nullable=False, comment='发生时间')
fifo_skip_reason = Column(String(500), nullable=True, comment='跳过先到托盘原因')
fifo_recommendation = Column(JSON, nullable=True, comment='跳过时先到托盘身份及到货时间快照')
class BizReturnOrder(Base): class BizReturnOrder(Base):
@@ -387,6 +422,14 @@ class BizReturnItem(Base):
nullable=True, nullable=True,
comment='申请时活动装填记录快照', comment='申请时活动装填记录快照',
) )
tunnel_id_snapshot = Column(
BigInteger,
ForeignKey('biz_tunnel.tunnel_id'),
nullable=True,
comment='申请时隧道ID快照',
)
trace_no_snapshot = Column(String(64), nullable=True, comment='申请时物料溯源号快照')
batch_no_snapshot = Column(String(64), nullable=True, comment='申请时生产批次快照')
remaining_packages_snapshot = Column(Integer, nullable=False, comment='申请时剩余包数快照') remaining_packages_snapshot = Column(Integer, nullable=False, comment='申请时剩余包数快照')
create_by = Column(String(64), nullable=True, server_default='', comment='创建者') create_by = Column(String(64), nullable=True, server_default='', comment='创建者')
create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间') create_time = Column(DateTime, nullable=True, default=datetime.now, comment='创建时间')
@@ -399,7 +442,7 @@ class BizPalletInventory(Base):
__table_args__ = ( __table_args__ = (
CheckConstraint( CheckConstraint(
"flow_state IN ('UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE', " "flow_state IN ('UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE', "
"'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')", "'IN_TRANSIT', 'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')",
name='ck_biz_pallet_inventory_flow_state', name='ck_biz_pallet_inventory_flow_state',
), ),
CheckConstraint('remaining_packages >= 0', name='ck_biz_pallet_inventory_remaining_packages'), CheckConstraint('remaining_packages >= 0', name='ck_biz_pallet_inventory_remaining_packages'),
@@ -444,6 +487,13 @@ class BizPalletInventory(Base):
nullable=True, nullable=True,
comment='当前现场ID', comment='当前现场ID',
) )
tunnel_id = Column(
BigInteger,
ForeignKey('biz_tunnel.tunnel_id'),
nullable=True,
comment='当前隧道ID',
)
trace_no = Column(String(64), nullable=True, comment='当前隧道物料溯源号')
current_line_id = Column( current_line_id = Column(
BigInteger, BigInteger,
ForeignKey('biz_production_line.line_id'), ForeignKey('biz_production_line.line_id'),
@@ -452,6 +502,7 @@ class BizPalletInventory(Base):
) )
flow_state = Column(String(32), nullable=False, server_default='UNSTOCKED', comment='托盘流转状态') flow_state = Column(String(32), nullable=False, server_default='UNSTOCKED', comment='托盘流转状态')
remaining_packages = Column(Integer, nullable=False, server_default='0', comment='当前剩余包数') remaining_packages = Column(Integer, nullable=False, server_default='0', comment='当前剩余包数')
site_arrived_at = Column(DateTime, nullable=True, comment='本轮实际收货核对时间;历史未登记为空')
last_event_id = Column( last_event_id = Column(
BigInteger, BigInteger,
ForeignKey('biz_pallet_event.event_id'), ForeignKey('biz_pallet_event.event_id'),
@@ -471,7 +522,7 @@ class BizPalletEvent(Base):
UniqueConstraint('pallet_id', 'idempotency_key', name='uq_biz_pallet_event_pallet_idempotency'), UniqueConstraint('pallet_id', 'idempotency_key', name='uq_biz_pallet_event_pallet_idempotency'),
CheckConstraint( CheckConstraint(
"event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED', " "event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED', "
"'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')", "'SHIPMENT_RECEIVED', 'TUNNEL_ENTERED', 'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')",
name='ck_biz_pallet_event_event_type', name='ck_biz_pallet_event_event_type',
), ),
{'comment': '托盘只追加流转事件'}, {'comment': '托盘只追加流转事件'},
@@ -504,6 +555,12 @@ class BizPalletEvent(Base):
nullable=True, nullable=True,
comment='变更前现场ID', comment='变更前现场ID',
) )
before_tunnel_id = Column(
BigInteger,
ForeignKey('biz_tunnel.tunnel_id'),
nullable=True,
comment='变更前隧道ID',
)
before_line_id = Column( before_line_id = Column(
BigInteger, BigInteger,
ForeignKey('biz_production_line.line_id'), ForeignKey('biz_production_line.line_id'),
@@ -522,6 +579,12 @@ class BizPalletEvent(Base):
nullable=True, nullable=True,
comment='变更后现场ID', comment='变更后现场ID',
) )
after_tunnel_id = Column(
BigInteger,
ForeignKey('biz_tunnel.tunnel_id'),
nullable=True,
comment='变更后隧道ID',
)
after_line_id = Column( after_line_id = Column(
BigInteger, BigInteger,
ForeignKey('biz_production_line.line_id'), ForeignKey('biz_production_line.line_id'),
@@ -530,6 +593,10 @@ class BizPalletEvent(Base):
) )
before_remaining_packages = Column(Integer, nullable=False, comment='变更前剩余包数') before_remaining_packages = Column(Integer, nullable=False, comment='变更前剩余包数')
after_remaining_packages = Column(Integer, nullable=False, comment='变更后剩余包数') after_remaining_packages = Column(Integer, nullable=False, comment='变更后剩余包数')
trace_no = Column(String(64), nullable=True, comment='事件关联物料溯源号')
batch_no_snapshot = Column(String(64), nullable=True, comment='事件生产批次快照')
fifo_skip_reason = Column(String(500), nullable=True, comment='跳过先到托盘原因')
fifo_recommendation = Column(JSON, nullable=True, comment='跳过时先到托盘身份及到货时间快照')
operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID') operator_id = Column(BigInteger, ForeignKey('sys_user.user_id'), nullable=False, comment='操作人ID')
occurred_at = Column(DateTime, nullable=False, comment='发生时间') occurred_at = Column(DateTime, nullable=False, comment='发生时间')
idempotency_key = Column(String(64), nullable=False, comment='幂等键') idempotency_key = Column(String(64), nullable=False, comment='幂等键')
@@ -240,6 +240,64 @@ class DeleteLineModel(BaseModel):
_validate_unique_ids = field_validator('line_ids')(_ensure_unique_ids) _validate_unique_ids = field_validator('line_ids')(_ensure_unique_ids)
class TunnelModel(BaseModel):
"""隧道主数据写入及展示模型。"""
model_config = ConfigDict(
alias_generator=to_camel,
from_attributes=True,
populate_by_name=True,
extra='forbid',
)
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
site_id: int = Field(gt=0, description='现场ID')
tunnel_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
tunnel_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='隧道名称')
status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)')
current_trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号')
current_trace_started_at: datetime | None = Field(default=None, description='当前溯源批次开始时间')
create_by: str | None = Field(default=None, max_length=64, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间')
update_by: str | None = Field(default=None, max_length=64, description='更新者')
update_time: datetime | None = Field(default=None, description='更新时间')
remark: str | None = Field(default=None, max_length=500, description='备注')
class TunnelPageQueryModel(BaseModel):
"""隧道分页查询模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
site_id: int | None = Field(default=None, gt=0, description='现场ID')
tunnel_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
tunnel_name: str | None = Field(
default=None,
min_length=1,
max_length=100,
pattern=r'\S',
description='隧道名称',
)
status: Literal['0', '1'] | None = Field(default=None, description='状态')
class DeleteTunnelModel(BaseModel):
"""删除隧道模型。"""
model_config = ConfigDict(
alias_generator=to_camel,
from_attributes=True,
populate_by_name=True,
extra='forbid',
)
tunnel_ids: list[PositiveId] = Field(min_length=1, description='需要删除的隧道ID')
_validate_unique_ids = field_validator('tunnel_ids')(_ensure_unique_ids)
class WarehouseModel(BaseModel): class WarehouseModel(BaseModel):
"""仓库主数据写入及展示模型。""" """仓库主数据写入及展示模型。"""
@@ -329,7 +387,7 @@ class PalletCreateModel(BaseModel):
) )
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
capacity_packages: int = Field(strict=True, gt=0, description='容量包数') capacity_packages: Literal[100] = Field(description='容量包数(固定 100 包)')
remark: str | None = Field(default=None, max_length=500, description='备注') remark: str | None = Field(default=None, max_length=500, description='备注')
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime
from typing import Annotated, Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
@@ -16,6 +16,10 @@ def _ensure_unique_ids(value: list[int]) -> list[int]:
return value return value
def _normalize_fifo_reason(value: str | None) -> str | None:
return (value.strip() or None) if isinstance(value, str) else value
def _parse_page_query_date(value: str) -> datetime: def _parse_page_query_date(value: str) -> datetime:
try: try:
parsed = datetime.strptime(value, '%Y-%m-%d') parsed = datetime.strptime(value, '%Y-%m-%d')
@@ -98,10 +102,18 @@ class FillingCreateModel(BaseModel):
warehouse_id: int = Field(gt=0, description='仓库ID') warehouse_id: int = Field(gt=0, description='仓库ID')
strain_id: int = Field(gt=0, description='菌种ID') strain_id: int = Field(gt=0, description='菌种ID')
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次') batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
production_date: date = Field(description='发货来源生产日期')
expiry_date: date = Field(description='有效期至')
package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格') package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格')
initial_packages: int = Field(strict=True, gt=0, description='初始包数') initial_packages: Literal[100] = Field(description='初始包数(固定 100 包)')
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
@model_validator(mode='after')
def validate_material_dates(self) -> 'FillingCreateModel':
if self.expiry_date < self.production_date:
raise ValueError('有效期不能早于生产日期')
return self
class ShipmentCreateModel(BaseModel): class ShipmentCreateModel(BaseModel):
"""整托发货草稿新增模型。""" """整托发货草稿新增模型。"""
@@ -149,9 +161,41 @@ class UsageCreateModel(BaseModel):
) )
qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识') qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识')
line_id: int = Field(gt=0, description='产线ID') tunnel_id: int = Field(gt=0, description='隧道ID')
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
used_packages: int = Field(strict=True, gt=0, description='本次使用包数') used_packages: int = Field(strict=True, gt=0, description='本次使用包数')
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
_normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason)
class TunnelEntryCreateModel(BaseModel):
"""托盘扫码入隧命令模型。"""
model_config = ConfigDict(
alias_generator=to_camel,
from_attributes=True,
populate_by_name=True,
extra='forbid',
)
qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识')
tunnel_id: int = Field(gt=0, description='隧道ID')
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
_normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason)
class TunnelEntrySuccessResponseModel(BaseModel):
"""托盘扫码入隧成功响应模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
pallet_id: int = Field(gt=0, description='托盘ID')
tunnel_id: int = Field(gt=0, description='隧道ID')
trace_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
class UsageSuccessResponseModel(BaseModel): class UsageSuccessResponseModel(BaseModel):
@@ -244,6 +288,8 @@ class FillingModel(BaseModel):
strain_code_snapshot: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码快照') strain_code_snapshot: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码快照')
strain_name_snapshot: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称快照') strain_name_snapshot: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称快照')
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次') batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
production_date: date | None = Field(default=None, description='生产日期;历史未登记时为空')
expiry_date: date | None = Field(default=None, description='有效期至;历史未登记时为空')
package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格') package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格')
initial_packages: int = Field(strict=True, gt=0, description='初始包数') initial_packages: int = Field(strict=True, gt=0, description='初始包数')
active_slot: Literal['ACTIVE'] | None = Field(default=None, description='活动装填槽') active_slot: Literal['ACTIVE'] | None = Field(default=None, description='活动装填槽')
@@ -293,6 +339,9 @@ class ShipmentModel(BaseModel):
confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要') confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要')
confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID') confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID')
confirmed_at: datetime | None = Field(default=None, description='确认时间') confirmed_at: datetime | None = Field(default=None, description='确认时间')
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, description='收货核对状态')
received_by: int | None = Field(default=None, gt=0, description='收货核对人')
received_at: datetime | None = Field(default=None, description='实际收货核对时间')
create_by: str | None = Field(default=None, max_length=64, description='创建者') create_by: str | None = Field(default=None, max_length=64, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间') create_time: datetime | None = Field(default=None, description='创建时间')
update_by: str | None = Field(default=None, max_length=64, description='更新者') update_by: str | None = Field(default=None, max_length=64, description='更新者')
@@ -311,13 +360,17 @@ class UsageModel(BaseModel):
pallet_id: int = Field(gt=0, description='托盘ID') pallet_id: int = Field(gt=0, description='托盘ID')
active_filling_id: int = Field(gt=0, description='活动装填记录ID') active_filling_id: int = Field(gt=0, description='活动装填记录ID')
site_id: int = Field(gt=0, description='现场ID') site_id: int = Field(gt=0, description='现场ID')
line_id: int = Field(gt=0, description='产线ID') tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
trace_no: str | None = Field(default=None, max_length=64, description='物料溯源号')
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='生产批次快照')
line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
used_packages: int = Field(strict=True, gt=0, description='本次使用包数') used_packages: int = Field(strict=True, gt=0, description='本次使用包数')
remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数') remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数')
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键')
request_hash: str = Field(min_length=1, max_length=64, pattern=r'\S', description='请求摘要') request_hash: str = Field(min_length=1, max_length=64, pattern=r'\S', description='请求摘要')
operator_id: int = Field(gt=0, description='操作人ID') operator_id: int = Field(gt=0, description='操作人ID')
occurred_at: datetime = Field(description='发生时间') occurred_at: datetime = Field(description='发生时间')
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
class ReturnItemModel(BaseModel): class ReturnItemModel(BaseModel):
@@ -329,6 +382,9 @@ class ReturnItemModel(BaseModel):
return_id: int = Field(gt=0, description='退回单ID') return_id: int = Field(gt=0, description='退回单ID')
pallet_id: int = Field(gt=0, description='托盘ID') pallet_id: int = Field(gt=0, description='托盘ID')
active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='申请时活动装填记录快照') active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='申请时活动装填记录快照')
tunnel_id_snapshot: int | None = Field(default=None, gt=0, description='申请时隧道ID快照')
trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照')
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时生产批次快照')
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照') remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照')
create_by: str | None = Field(default=None, max_length=64, description='创建者') create_by: str | None = Field(default=None, max_length=64, description='创建者')
create_time: datetime | None = Field(default=None, description='创建时间') create_time: datetime | None = Field(default=None, description='创建时间')
@@ -420,6 +476,8 @@ class UsagePageQueryModel(BaseModel):
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号') usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号')
site_id: int | None = Field(default=None, gt=0, description='现场ID') site_id: int | None = Field(default=None, gt=0, description='现场ID')
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
line_id: int | None = Field(default=None, gt=0, description='产线ID') line_id: int | None = Field(default=None, gt=0, description='产线ID')
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
@@ -496,15 +554,20 @@ class PalletScanResponseModel(BaseModel):
pallet_id: int = Field(gt=0, description='托盘ID') pallet_id: int = Field(gt=0, description='托盘ID')
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
qr_token: str | None = Field(default=None, min_length=1, max_length=128, description='解析后的固定二维码标识,写入时使用')
flow_state: PalletFlowState = Field(description='托盘流转状态') flow_state: PalletFlowState = Field(description='托盘流转状态')
warehouse_id: int | None = Field(default=None, gt=0, description='当前仓库ID') warehouse_id: int | None = Field(default=None, gt=0, description='当前仓库ID')
site_id: int | None = Field(default=None, gt=0, description='当前现场ID') site_id: int | None = Field(default=None, gt=0, description='当前现场ID')
tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID')
trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号')
current_line_id: int | None = Field(default=None, gt=0, description='当前绑定产线ID') current_line_id: int | None = Field(default=None, gt=0, description='当前绑定产线ID')
active_filling_id: int | None = Field(default=None, gt=0, description='当前活动装填记录ID') active_filling_id: int | None = Field(default=None, gt=0, description='当前活动装填记录ID')
strain_id: int | None = Field(default=None, gt=0, description='菌种ID') strain_id: int | None = Field(default=None, gt=0, description='菌种ID')
strain_code: str | None = Field(default=None, max_length=64, description='菌种编码') strain_code: str | None = Field(default=None, max_length=64, description='菌种编码')
strain_name: str | None = Field(default=None, max_length=100, description='菌种名称') strain_name: str | None = Field(default=None, max_length=100, description='菌种名称')
batch_no: str | None = Field(default=None, max_length=64, description='生产批次') batch_no: str | None = Field(default=None, max_length=64, description='生产批次')
production_date: date | None = Field(default=None, description='生产日期')
expiry_date: date | None = Field(default=None, description='有效期至')
package_spec: str | None = Field(default=None, max_length=64, description='包装规格') package_spec: str | None = Field(default=None, max_length=64, description='包装规格')
capacity_packages: int = Field(strict=True, gt=0, description='容量包数') capacity_packages: int = Field(strict=True, gt=0, description='容量包数')
remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数') remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数')
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus
@@ -28,7 +28,18 @@ def _validate_report_query_date_range(value: BaseModel) -> BaseModel:
return value return value
class PalletInventoryReportQueryModel(BaseModel): class TunnelReportQueryModel(BaseModel):
"""隧道报表共享筛选;编号/名称可直接输入,溯源号按完整值匹配。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID')
tunnel_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='隧道编号')
tunnel_name: str | None = Field(default=None, min_length=1, max_length=100, pattern=r'\S', description='隧道名称')
trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='内部溯源号(精确匹配)')
class PalletInventoryReportQueryModel(TunnelReportQueryModel):
"""托盘库存报表查询模型。""" """托盘库存报表查询模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
@@ -69,6 +80,12 @@ class PalletInventoryReportModel(BaseModel):
initial_packages: int | None = Field(default=None, strict=True, gt=0, description='初始包数') initial_packages: int | None = Field(default=None, strict=True, gt=0, description='初始包数')
remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数') remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数')
updated_at: datetime = Field(description='库存更新时间') updated_at: datetime = Field(description='库存更新时间')
tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID')
tunnel_code: str | None = Field(default=None, max_length=64, description='当前隧道编号')
tunnel_name: str | None = Field(default=None, max_length=100, description='当前隧道名称')
trace_no: str | None = Field(default=None, max_length=64, description='当前内部溯源号')
production_date: date | None = Field(default=None, description='当前物料生产日期,历史未登记为空')
expiry_date: date | None = Field(default=None, description='当前物料有效期至,历史未登记为空')
class FillingReportQueryModel(BaseModel): class FillingReportQueryModel(BaseModel):
@@ -148,8 +165,8 @@ class ShipmentReportModel(BaseModel):
package_qty: int = Field(strict=True, gt=0, description='发货时包数快照') package_qty: int = Field(strict=True, gt=0, description='发货时包数快照')
class ConsumptionReportQueryModel(BaseModel): class ConsumptionReportQueryModel(TunnelReportQueryModel):
"""产线消耗报表查询模型。""" """隧道消耗报表查询模型,兼容历史产线筛选。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
@@ -168,7 +185,7 @@ class ConsumptionReportQueryModel(BaseModel):
class ConsumptionReportModel(BaseModel): class ConsumptionReportModel(BaseModel):
"""产线消耗报表行模型。""" """隧道消耗报表行模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
@@ -179,7 +196,7 @@ class ConsumptionReportModel(BaseModel):
customer_name: str | None = Field(default=None, max_length=100, description='客户名称') customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
site_id: int = Field(gt=0, description='现场ID') site_id: int = Field(gt=0, description='现场ID')
site_name: str | None = Field(default=None, max_length=100, description='现场名称') site_name: str | None = Field(default=None, max_length=100, description='现场名称')
line_id: int = Field(gt=0, description='产线ID') line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)')
line_name: str | None = Field(default=None, max_length=100, description='产线名称') line_name: str | None = Field(default=None, max_length=100, description='产线名称')
pallet_id: int = Field(gt=0, description='托盘ID') pallet_id: int = Field(gt=0, description='托盘ID')
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
@@ -191,9 +208,16 @@ class ConsumptionReportModel(BaseModel):
remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数') remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数')
operator_id: int = Field(gt=0, description='操作人ID') operator_id: int = Field(gt=0, description='操作人ID')
operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') operator_name: str | None = Field(default=None, max_length=100, description='操作人名称')
tunnel_id: int | None = Field(default=None, gt=0, description='使用时隧道ID')
tunnel_code: str | None = Field(default=None, max_length=64, description='使用时隧道编号')
tunnel_name: str | None = Field(default=None, max_length=100, description='使用时隧道名称')
trace_no: str | None = Field(default=None, max_length=64, description='使用时内部溯源号')
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='使用时生产批次快照')
production_date: date | None = Field(default=None, description='使用时原装填物料生产日期')
expiry_date: date | None = Field(default=None, description='使用时原装填物料有效期至')
class ReturnReportQueryModel(BaseModel): class ReturnReportQueryModel(TunnelReportQueryModel):
"""退回仓库报表查询模型。""" """退回仓库报表查询模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
@@ -207,6 +231,7 @@ class ReturnReportQueryModel(BaseModel):
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID') target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
status: ReturnStatus | None = Field(default=None, description='退回状态') status: ReturnStatus | None = Field(default=None, description='退回状态')
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回生产批次')
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
@@ -229,9 +254,17 @@ class ReturnReportModel(BaseModel):
pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='活动装填记录快照') active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='活动装填记录快照')
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='剩余包数快照') remaining_packages_snapshot: int = Field(strict=True, ge=0, description='剩余包数快照')
tunnel_id: int | None = Field(default=None, gt=0, description='退回时隧道ID快照')
tunnel_code: str | None = Field(default=None, max_length=64, description='退回隧道编号')
tunnel_name: str | None = Field(default=None, max_length=100, description='退回隧道名称')
trace_no: str | None = Field(default=None, max_length=64, description='退回时内部溯源号快照')
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='退回时生产批次快照')
batch_no: str | None = Field(default=None, max_length=64, description='生产批次,历史记录回查原装填记录')
production_date: date | None = Field(default=None, description='退回时原装填物料生产日期')
expiry_date: date | None = Field(default=None, description='退回时原装填物料有效期至')
class PalletTraceReportQueryModel(BaseModel): class PalletTraceReportQueryModel(TunnelReportQueryModel):
"""托盘追溯报表查询模型。""" """托盘追溯报表查询模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
@@ -242,6 +275,7 @@ class PalletTraceReportQueryModel(BaseModel):
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
event_type: PalletEventType | None = Field(default=None, description='事件类型') event_type: PalletEventType | None = Field(default=None, description='事件类型')
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='事件生产批次快照')
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
@@ -277,3 +311,29 @@ class PalletTraceReportModel(BaseModel):
operator_id: int = Field(gt=0, description='操作人ID') operator_id: int = Field(gt=0, description='操作人ID')
operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') operator_name: str | None = Field(default=None, max_length=100, description='操作人名称')
occurred_at: datetime = Field(description='发生时间') occurred_at: datetime = Field(description='发生时间')
tunnel_id: int | None = Field(default=None, gt=0, description='事件隧道ID,优先变更后隧道')
tunnel_code: str | None = Field(default=None, max_length=64, description='事件隧道编号')
tunnel_name: str | None = Field(default=None, max_length=100, description='事件隧道名称')
trace_no: str | None = Field(default=None, max_length=64, description='事件内部溯源号')
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='事件生产批次快照')
before_tunnel_id: int | None = Field(default=None, gt=0, description='变更前隧道ID')
before_tunnel_code: str | None = Field(default=None, max_length=64, description='变更前隧道编号')
before_tunnel_name: str | None = Field(default=None, max_length=100, description='变更前隧道名称')
after_tunnel_id: int | None = Field(default=None, gt=0, description='变更后隧道ID')
after_tunnel_code: str | None = Field(default=None, max_length=64, description='变更后隧道编号')
after_tunnel_name: str | None = Field(default=None, max_length=100, description='变更后隧道名称')
production_date: date | None = Field(default=None, description='事件原装填物料生产日期')
expiry_date: date | None = Field(default=None, description='事件原装填物料有效期至')
@computed_field(description='本事件实际使用包数,仅使用事件计入')
@property
def used_packages(self) -> int:
return (
self.before_remaining_packages - self.after_remaining_packages
if self.event_type == PalletEventType.USAGE_RECORDED else 0
)
@computed_field(description='本事件确认退回包数,退回申请不重复计入')
@property
def returned_packages(self) -> int:
return self.before_remaining_packages if self.event_type == PalletEventType.RETURN_CONFIRMED else 0
@@ -0,0 +1,24 @@
from datetime import date
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from module_business.domain.enums import DocumentStatus
class ShipmentLabelModel(BaseModel):
"""独立打印载荷,二维码由发货查询授权后生成,无需基础资料权限。"""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
shipment_no: str
shipment_status: DocumentStatus
pallet_code: str
customer_name: str
site_name: str
strain_name: str
batch_no: str
production_date: date | None = None
expiry_date: date | None = None
package_qty: int = Field(strict=True, gt=0)
qr_code_data_url: str
@@ -0,0 +1,38 @@
from datetime import date
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic.alias_generators import to_camel
from module_business.entity.vo.operation_vo import PositiveId, ShipmentItemModel, ShipmentModel, ShipmentPageQueryModel
class ShipmentReceiptCommandModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra='forbid')
scanned_pallet_ids: list[PositiveId] = Field(min_length=1, description='实际核对的托盘集合')
idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S')
@field_validator('scanned_pallet_ids')
@classmethod
def unique_pallets(cls, value: list[int]) -> list[int]:
if len(value) != len(set(value)):
raise ValueError('核对托盘不能重复')
return value
class ShipmentReceiptQueryModel(ShipmentPageQueryModel):
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = None
class ShipmentReceiptItemModel(ShipmentItemModel):
pallet_code: str = Field(description='原托盘号')
qr_token: str = Field(description='规范二维码标识')
strain_name: str | None = None
batch_no: str | None = None
production_date: date | None = None
expiry_date: date | None = None
class ShipmentReceiptModel(ShipmentModel):
items: list[ShipmentReceiptItemModel] = Field(default_factory=list)
@@ -88,6 +88,7 @@ AdoptionRootType = Literal[
'customer', 'customer',
'site', 'site',
'line', 'line',
'tunnel',
'warehouse', 'warehouse',
'pallet', 'pallet',
'inbound', 'inbound',
@@ -0,0 +1,23 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel
class UsageFifoCandidateModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
pallet_id: int
pallet_code: str
batch_no: str
remaining_packages: int
site_arrived_at: datetime
class UsageFifoRecommendationModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
recommended: UsageFifoCandidateModel | None = None
unknown_arrival_count: int = 0
requires_skip_reason: bool = False
message: str
@@ -63,6 +63,8 @@ class FillingService:
'warehouse_id': page_object.warehouse_id, 'warehouse_id': page_object.warehouse_id,
'strain_id': page_object.strain_id, 'strain_id': page_object.strain_id,
'batch_no': page_object.batch_no.strip(), 'batch_no': page_object.batch_no.strip(),
'production_date': page_object.production_date.isoformat(),
'expiry_date': page_object.expiry_date.isoformat(),
'package_spec': page_object.package_spec.strip(), 'package_spec': page_object.package_spec.strip(),
'initial_packages': page_object.initial_packages, 'initial_packages': page_object.initial_packages,
}, },
@@ -147,10 +149,11 @@ class FillingService:
@staticmethod @staticmethod
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None: def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
if type(initial_packages) is not int or initial_packages <= 0: standard_packages = 100
raise ServiceException(message='装填包数必须为正整数') if type(initial_packages) is not int or initial_packages != standard_packages:
if initial_packages > pallet.capacity_packages: raise ServiceException(message='新装填包数固定为 100 包')
raise ServiceException(message='装填包数不能超过托盘容量') if pallet.capacity_packages != standard_packages:
raise ServiceException(message='历史非 100 包托盘不允许新增装填')
@staticmethod @staticmethod
def _validate_active_strain(strain: BizStrain | None) -> BizStrain: def _validate_active_strain(strain: BizStrain | None) -> BizStrain:
@@ -276,10 +279,11 @@ class FillingService:
pallet, pallet,
inventory, inventory,
inventory.warehouse_id, inventory.warehouse_id,
qr_token, pallet.qr_token,
) )
return PalletScanResponseModel( return PalletScanResponseModel(
pallet_id=pallet.pallet_id, pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code, pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE, flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE,
warehouse_id=inventory.warehouse_id, warehouse_id=inventory.warehouse_id,
@@ -472,6 +476,8 @@ class FillingService:
strain_code_snapshot=strain.strain_code, strain_code_snapshot=strain.strain_code,
strain_name_snapshot=strain.strain_name, strain_name_snapshot=strain.strain_name,
batch_no=page_object.batch_no.strip(), batch_no=page_object.batch_no.strip(),
production_date=page_object.production_date,
expiry_date=page_object.expiry_date,
package_spec=page_object.package_spec.strip(), package_spec=page_object.package_spec.strip(),
initial_packages=page_object.initial_packages, initial_packages=page_object.initial_packages,
active_slot='ACTIVE', active_slot='ACTIVE',
@@ -349,6 +349,7 @@ class InboundService:
cls._validate_pallet_pair(pallet, inventory) cls._validate_pallet_pair(pallet, inventory)
return PalletScanResponseModel( return PalletScanResponseModel(
pallet_id=pallet.pallet_id, pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code, pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.UNSTOCKED, flow_state=PalletFlowState.UNSTOCKED,
warehouse_id=None, warehouse_id=None,
@@ -16,6 +16,7 @@ from module_business.entity.vo.master_vo import (
DeleteLineModel, DeleteLineModel,
DeleteSiteModel, DeleteSiteModel,
DeleteStrainModel, DeleteStrainModel,
DeleteTunnelModel,
DeleteWarehouseModel, DeleteWarehouseModel,
LineModel, LineModel,
LinePageQueryModel, LinePageQueryModel,
@@ -23,12 +24,14 @@ from module_business.entity.vo.master_vo import (
SitePageQueryModel, SitePageQueryModel,
StrainModel, StrainModel,
StrainPageQueryModel, StrainPageQueryModel,
TunnelModel,
TunnelPageQueryModel,
WarehouseModel, WarehouseModel,
WarehousePageQueryModel, WarehousePageQueryModel,
) )
from module_business.service.test_batch_access_service import TestBatchAccessService from module_business.service.test_batch_access_service import TestBatchAccessService
MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse'] MasterResource = Literal['strain', 'customer', 'site', 'line', 'tunnel', 'warehouse']
_RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = { _RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
@@ -60,6 +63,13 @@ _RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
'ids_field': 'line_ids', 'ids_field': 'line_ids',
'vo': LineModel, 'vo': LineModel,
}, },
'tunnel': {
'label': '隧道',
'id_field': 'tunnel_id',
'code_field': 'tunnel_code',
'ids_field': 'tunnel_ids',
'vo': TunnelModel,
},
'warehouse': { 'warehouse': {
'label': '仓库', 'label': '仓库',
'id_field': 'warehouse_id', 'id_field': 'warehouse_id',
@@ -105,6 +115,13 @@ class MasterService:
assert_record_in_scope(site, scope, '目标客户现场') assert_record_in_scope(site, scope, '目标客户现场')
if site.status != '0': if site.status != '0':
raise ServiceException(message='目标客户现场已停用') raise ServiceException(message='目标客户现场已停用')
elif resource == 'tunnel':
site = await MasterDao.get_site(db, page_object.site_id, scope)
if site is None:
raise ServiceException(message='目标客户现场不存在')
assert_record_in_scope(site, scope, '目标客户现场')
if site.status != '0':
raise ServiceException(message='目标客户现场已停用')
@staticmethod @staticmethod
async def _find_duplicate( async def _find_duplicate(
@@ -124,6 +141,12 @@ class MasterService:
page_object.line_code, page_object.line_code,
page_object.site_id, page_object.site_id,
) )
if resource == 'tunnel':
return await MasterDao.find_tunnel_by_code(
db,
page_object.tunnel_code,
page_object.site_id,
)
code_field = _RESOURCE_CONFIG[resource]['code_field'] code_field = _RESOURCE_CONFIG[resource]['code_field']
return await getattr(MasterDao, f'find_{resource}_by_code')( return await getattr(MasterDao, f'find_{resource}_by_code')(
db, db,
@@ -413,6 +436,48 @@ class MasterService:
) -> NoReturn: ) -> NoReturn:
return await cls.reject_physical_delete() return await cls.reject_physical_delete()
@classmethod
async def list_tunnel(
cls,
db: AsyncSession,
query_object: TunnelPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[TunnelModel]:
return await cls._list(db, 'tunnel', query_object, scope, is_page)
@classmethod
async def detail_tunnel(cls, db: AsyncSession, tunnel_id: int, scope: TestBatchScope) -> TunnelModel:
return await cls._detail(db, 'tunnel', tunnel_id, scope)
@classmethod
async def add_tunnel(
cls,
db: AsyncSession,
tunnel: TunnelModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'tunnel', tunnel, current_user, scope)
@classmethod
async def edit_tunnel(
cls,
db: AsyncSession,
tunnel: TunnelModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'tunnel', tunnel, current_user, scope)
@classmethod
async def remove_tunnel(
cls,
db: AsyncSession,
value: int | DeleteTunnelModel,
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod @classmethod
async def list_warehouse( async def list_warehouse(
cls, cls,
@@ -116,6 +116,8 @@ class PalletService:
) -> CrudResponseModel: ) -> CrudResponseModel:
try: try:
await cls._prepare_test_write(db, current_user, scope) await cls._prepare_test_write(db, current_user, scope)
if pallet.capacity_packages != 100:
raise ServiceException(message='新托盘容量固定为 100 包')
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None: if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
raise ServiceException(message='托盘编号已存在') raise ServiceException(message='托盘编号已存在')
@@ -162,7 +164,11 @@ class PalletService:
) -> CrudResponseModel: ) -> CrudResponseModel:
try: try:
await cls._prepare_test_write(db, current_user, scope) await cls._prepare_test_write(db, current_user, scope)
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope) current_pallet, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
if current_pallet.capacity_packages == 100 and pallet.capacity_packages != 100:
raise ServiceException(message='新规格托盘容量固定为 100 包')
if current_pallet.capacity_packages != pallet.capacity_packages:
raise ServiceException(message='历史托盘容量不允许修改')
if pallet.capacity_packages < inventory.remaining_packages: if pallet.capacity_packages < inventory.remaining_packages:
raise ServiceException(message='托盘容量不能小于当前剩余包数') raise ServiceException(message='托盘容量不能小于当前剩余包数')
await MasterDao.update_pallet( await MasterDao.update_pallet(
@@ -19,7 +19,7 @@ from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus
from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.do.master_do import BizPallet from module_business.entity.do.master_do import BizPallet, BizTunnel
from module_business.entity.do.operation_do import ( from module_business.entity.do.operation_do import (
BizFillingRecord, BizFillingRecord,
BizPalletEvent, BizPalletEvent,
@@ -421,16 +421,21 @@ class ReturnService:
) -> PalletScanResponseModel: ) -> PalletScanResponseModel:
return PalletScanResponseModel( return PalletScanResponseModel(
pallet_id=pallet.pallet_id, pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code, pallet_code=pallet.pallet_code,
flow_state=PalletFlowState(inventory.flow_state), flow_state=PalletFlowState(inventory.flow_state),
warehouse_id=inventory.warehouse_id, warehouse_id=inventory.warehouse_id,
site_id=inventory.site_id, site_id=inventory.site_id,
tunnel_id=inventory.tunnel_id,
trace_no=inventory.trace_no,
current_line_id=inventory.current_line_id, current_line_id=inventory.current_line_id,
active_filling_id=inventory.active_filling_id, active_filling_id=inventory.active_filling_id,
strain_id=filling.strain_id if filling is not None else None, strain_id=filling.strain_id if filling is not None else None,
strain_code=filling.strain_code_snapshot if filling is not None else None, strain_code=filling.strain_code_snapshot if filling is not None else None,
strain_name=filling.strain_name_snapshot if filling is not None else None, strain_name=filling.strain_name_snapshot if filling is not None else None,
batch_no=filling.batch_no if filling is not None else None, batch_no=filling.batch_no if filling is not None else None,
production_date=filling.production_date if filling is not None else None,
expiry_date=filling.expiry_date if filling is not None else None,
package_spec=filling.package_spec if filling is not None else None, package_spec=filling.package_spec if filling is not None else None,
capacity_packages=pallet.capacity_packages, capacity_packages=pallet.capacity_packages,
remaining_packages=inventory.remaining_packages, remaining_packages=inventory.remaining_packages,
@@ -543,12 +548,22 @@ class ReturnService:
user_name: str, user_name: str,
current_time: datetime, current_time: datetime,
test_batch_id: int | None, test_batch_id: int | None,
filling_by_id: dict[int, BizFillingRecord | None] | None = None,
) -> list[BizReturnItem]: ) -> list[BizReturnItem]:
return [ return [
BizReturnItem( BizReturnItem(
return_id=return_id, return_id=return_id,
pallet_id=inventory.pallet_id, pallet_id=inventory.pallet_id,
active_filling_id_snapshot=inventory.active_filling_id, active_filling_id_snapshot=inventory.active_filling_id,
tunnel_id_snapshot=inventory.tunnel_id,
trace_no_snapshot=inventory.trace_no,
batch_no_snapshot=(
filling_by_id.get(inventory.active_filling_id).batch_no
if filling_by_id
and inventory.active_filling_id is not None
and filling_by_id.get(inventory.active_filling_id) is not None
else None
),
remaining_packages_snapshot=inventory.remaining_packages, remaining_packages_snapshot=inventory.remaining_packages,
create_by=user_name, create_by=user_name,
create_time=current_time, create_time=current_time,
@@ -788,15 +803,23 @@ class ReturnService:
) )
return_order = await OperationDao.add_return_order(query_db, return_order) return_order = await OperationDao.add_return_order(query_db, return_order)
return_id = int(return_order.return_id) return_id = int(return_order.return_id)
await OperationDao.add_return_items( return_items = cls._build_return_items(
query_db, return_id,
cls._build_return_items(return_id, inventories, user_name, current_time, test_scope.batch_id), inventories,
user_name,
current_time,
test_scope.batch_id,
filling_by_id,
) )
await OperationDao.add_return_items(query_db, return_items)
return_order.items = return_items
for inventory in inventories: for inventory in inventories:
before_state = inventory.flow_state before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id before_site_id = inventory.site_id
before_tunnel_id = inventory.tunnel_id
before_trace_no = inventory.trace_no
before_line_id = inventory.current_line_id before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages before_remaining_packages = inventory.remaining_packages
inventory.flow_state = PalletFlowState.RETURNING.value inventory.flow_state = PalletFlowState.RETURNING.value
@@ -811,12 +834,23 @@ class ReturnService:
after_state=PalletFlowState.RETURNING.value, after_state=PalletFlowState.RETURNING.value,
before_warehouse_id=before_warehouse_id, before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id, before_site_id=before_site_id,
before_tunnel_id=before_tunnel_id,
before_line_id=before_line_id, before_line_id=before_line_id,
after_warehouse_id=None, after_warehouse_id=None,
after_site_id=inventory.site_id, after_site_id=inventory.site_id,
after_tunnel_id=inventory.tunnel_id,
after_line_id=inventory.current_line_id, after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages, before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages, after_remaining_packages=inventory.remaining_packages,
trace_no=before_trace_no,
batch_no_snapshot=next(
(
item.batch_no_snapshot
for item in getattr(return_order, 'items', [])
if item.pallet_id == inventory.pallet_id
),
None,
),
operator_id=user_id, operator_id=user_id,
occurred_at=current_time, occurred_at=current_time,
idempotency_key=page_object.idempotency_key, idempotency_key=page_object.idempotency_key,
@@ -860,6 +894,8 @@ class ReturnService:
if ( if (
inventory.remaining_packages != item.remaining_packages_snapshot inventory.remaining_packages != item.remaining_packages_snapshot
or inventory.active_filling_id != item.active_filling_id_snapshot or inventory.active_filling_id != item.active_filling_id_snapshot
or inventory.tunnel_id != item.tunnel_id_snapshot
or inventory.trace_no != item.trace_no_snapshot
): ):
raise ServiceException(message='托盘库存与退回申请快照不一致') raise ServiceException(message='托盘库存与退回申请快照不一致')
if ( if (
@@ -881,7 +917,7 @@ class ReturnService:
return_order: BizReturnOrder, return_order: BizReturnOrder,
items: list[BizReturnItem], items: list[BizReturnItem],
test_scope: TestBatchScope, test_scope: TestBatchScope,
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord]]: ) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord], dict[int, BizTunnel]]:
ordered_ids = sorted(item.pallet_id for item in items) ordered_ids = sorted(item.pallet_id for item in items)
assert_record_in_scope(return_order, test_scope, '退回单') assert_record_in_scope(return_order, test_scope, '退回单')
for item in items: for item in items:
@@ -911,6 +947,21 @@ class ReturnService:
inventory_by_id[pallet_id], inventory_by_id[pallet_id],
) )
tunnel_by_id: dict[int, BizTunnel] = {}
tunnel_ids = sorted(
{
item.tunnel_id_snapshot
for item in items
if item.tunnel_id_snapshot is not None
}
)
for tunnel_id in tunnel_ids:
tunnel = await MasterDao.get_tunnel(query_db, tunnel_id, test_scope, for_update=True)
if tunnel is None:
raise ServiceException(message='退回单关联隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
tunnel_by_id[tunnel_id] = tunnel
filling_by_id = {} filling_by_id = {}
filling_ids = sorted( filling_ids = sorted(
{ {
@@ -928,7 +979,11 @@ class ReturnService:
for inventory in ordered_inventories: for inventory in ordered_inventories:
if inventory.active_filling_id is not None: if inventory.active_filling_id is not None:
cls._validate_active_filling(filling_by_id[inventory.active_filling_id], inventory) cls._validate_active_filling(filling_by_id[inventory.active_filling_id], inventory)
return ordered_inventories, filling_by_id item = item_by_id[inventory.pallet_id]
filling = filling_by_id[inventory.active_filling_id]
if item.batch_no_snapshot is not None and filling.batch_no != item.batch_no_snapshot:
raise ServiceException(message='托盘生产批次与退回申请快照不一致')
return ordered_inventories, filling_by_id, tunnel_by_id
@classmethod @classmethod
async def _lock_and_reauthorize_confirm_warehouse( async def _lock_and_reauthorize_confirm_warehouse(
@@ -1121,7 +1176,7 @@ class ReturnService:
if scanned_ids != sorted(item_ids): if scanned_ids != sorted(item_ids):
raise ServiceException(message='确认扫码托盘集合与退回单明细不一致') raise ServiceException(message='确认扫码托盘集合与退回单明细不一致')
inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items, test_scope) inventories, _, tunnels = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
await cls._lock_and_reauthorize_confirm_warehouse( await cls._lock_and_reauthorize_confirm_warehouse(
query_db, query_db,
locked, locked,
@@ -1151,6 +1206,8 @@ class ReturnService:
before_state = inventory.flow_state before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id before_site_id = inventory.site_id
before_tunnel_id = inventory.tunnel_id
before_trace_no = inventory.trace_no
before_line_id = inventory.current_line_id before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages before_remaining_packages = inventory.remaining_packages
if inventory.remaining_packages > 0: if inventory.remaining_packages > 0:
@@ -1162,6 +1219,9 @@ class ReturnService:
inventory.flow_state = target_state inventory.flow_state = target_state
inventory.warehouse_id = locked.target_warehouse_id inventory.warehouse_id = locked.target_warehouse_id
inventory.site_id = None inventory.site_id = None
inventory.site_arrived_at = None
inventory.tunnel_id = None
inventory.trace_no = None
inventory.current_line_id = None inventory.current_line_id = None
inventory.version += 1 inventory.version += 1
inventory.updated_at = current_time inventory.updated_at = current_time
@@ -1176,10 +1236,21 @@ class ReturnService:
before_site_id=before_site_id, before_site_id=before_site_id,
before_line_id=before_line_id, before_line_id=before_line_id,
after_warehouse_id=locked.target_warehouse_id, after_warehouse_id=locked.target_warehouse_id,
before_tunnel_id=before_tunnel_id,
after_site_id=None, after_site_id=None,
after_tunnel_id=None,
after_line_id=None, after_line_id=None,
before_remaining_packages=before_remaining_packages, before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages, after_remaining_packages=inventory.remaining_packages,
trace_no=before_trace_no,
batch_no_snapshot=next(
(
item.batch_no_snapshot
for item in items
if item.pallet_id == inventory.pallet_id
),
None,
),
operator_id=user_id, operator_id=user_id,
occurred_at=current_time, occurred_at=current_time,
idempotency_key=page_object.idempotency_key, idempotency_key=page_object.idempotency_key,
@@ -1188,6 +1259,27 @@ class ReturnService:
) )
await InventoryDao.add_event(query_db, inventory, event, test_scope) await InventoryDao.add_event(query_db, inventory, event, test_scope)
for tunnel_id, tunnel in tunnels.items():
trace_nos = {
item.trace_no_snapshot
for item in items
if item.tunnel_id_snapshot == tunnel_id and item.trace_no_snapshot
}
if (
tunnel.current_trace_no in trace_nos
and await MasterDao.count_tunnel_trace_members(
query_db,
tunnel_id,
tunnel.current_trace_no,
test_scope,
)
== 0
):
tunnel.current_trace_no = None
tunnel.current_trace_started_at = None
tunnel.update_by = user_name
tunnel.update_time = current_time
await query_db.commit() await query_db.commit()
return cls._success('确认成功', return_id) return cls._success('确认成功', return_id)
except IntegrityError: except IntegrityError:
@@ -0,0 +1,90 @@
import base64
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.dao.shipment_label_dao import ShipmentLabelDao
from module_business.domain.enums import DocumentStatus
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
from module_business.service.pallet_service import PalletService
from module_business.service.shipment_service import ShipmentService
class ShipmentLabelService:
"""复用发货双数据范围读取原始物料资料,不改变托盘、批号或库存。"""
@staticmethod
async def get_labels_services(
query_db: AsyncSession,
shipment_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[ShipmentLabelModel]:
warehouse_ids, site_ids = await ShipmentService._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_by_id(
query_db, shipment_id, test_scope, warehouse_ids=warehouse_ids, site_ids=site_ids,
)
if shipment is None:
ShipmentService._raise_shipment_not_accessible(current_user)
assert_record_in_scope(shipment, test_scope, '发货单')
if shipment.status not in (DocumentStatus.DRAFT.value, DocumentStatus.CONFIRMED.value):
raise ServiceException(message='当前发货单状态不支持打印标签')
if not shipment.items:
raise ServiceException(message='发货单没有可打印的托盘')
for item in shipment.items:
assert_record_in_scope(item, test_scope, '发货明细')
# 先授权单据,再读取其目标现场与所属客户,避免通过标签枚举跨范围资料。
site = await MasterDao.get_site(query_db, shipment.target_site_id, test_scope)
assert_record_in_scope(site, test_scope, '发货现场')
customer = await MasterDao.get_customer(query_db, site.customer_id, test_scope)
assert_record_in_scope(customer, test_scope, '发货客户')
rows = await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope)
if (
len(rows) != len(shipment.items)
or {row[0].shipment_item_id for row in rows} != {item.shipment_item_id for item in shipment.items}
):
raise ServiceException(message='发货明细已变化,请刷新后重新打印')
draft = shipment.status == DocumentStatus.DRAFT.value
# 整单全部验证后才生成任何二维码,缺少关联资料时不得输出部分标签。
for item, pallet, filling, quantity in rows:
for record, name in ((item, '发货明细'), (pallet, '托盘'), (filling, '装填记录')):
assert_record_in_scope(record, test_scope, name)
if (
item.shipment_id != shipment.shipment_id
or item.pallet_id != pallet.pallet_id
or filling.pallet_id != pallet.pallet_id
or type(quantity) is not int
or quantity <= 0
or not pallet.qr_token
):
raise ServiceException(message='发货标签资料不完整,请核对托盘与装填记录')
if draft:
if pallet.asset_status != '0' or filling.active_slot != 'ACTIVE' or quantity > pallet.capacity_packages:
raise ServiceException(message='草稿托盘已不满足发货条件,请刷新后重新打印')
elif item.active_filling_id != filling.filling_id or quantity != item.package_qty:
raise ServiceException(message='发货快照不完整,无法打印标签')
return [
ShipmentLabelModel(
shipment_no=shipment.shipment_no,
shipment_status=shipment.status,
pallet_code=pallet.pallet_code,
customer_name=customer.customer_name,
site_name=site.site_name,
strain_name=filling.strain_name_snapshot,
batch_no=filling.batch_no,
production_date=filling.production_date,
expiry_date=filling.expiry_date,
package_qty=quantity,
qr_code_data_url='data:image/png;base64,' + base64.b64encode(
PalletService.render_qr_png(pallet.qr_token),
).decode('ascii'),
)
for item, pallet, filling, quantity in rows
]
@@ -0,0 +1,264 @@
"""接收方按原发货单核对收货;不重新编托盘号、批号或装填记录。"""
import hashlib
import json
from datetime import datetime
from typing import NoReturn
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.dao.shipment_receipt_dao import ShipmentReceiptDao
from module_business.domain.enums import PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory, BizShipmentItem, BizShipmentOrder
from module_business.entity.vo.operation_vo import ShipmentModel
from module_business.entity.vo.shipment_receipt_vo import (
ShipmentReceiptCommandModel,
ShipmentReceiptItemModel,
ShipmentReceiptModel,
ShipmentReceiptQueryModel,
)
from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService
class ShipmentReceiptService:
@staticmethod
async def _site_scope(db: AsyncSession, user: CurrentUserModel) -> list[int] | None:
if user.user is None or user.user.user_id is None:
raise PermissionException(data='', message='当前用户信息无效')
if user.user.admin:
return None
return sorted(set(await AccessDao.list_user_site_ids(db, user.user.user_id)))
@staticmethod
def _missing() -> NoReturn:
raise PermissionException(data='', message='无权访问该收货单或单据不存在')
@classmethod
async def list_services(
cls, db: AsyncSession, query: ShipmentReceiptQueryModel, user: CurrentUserModel, scope: TestBatchScope
) -> PageModel:
site_ids = await cls._site_scope(db, user)
if query.target_site_id and site_ids is not None and query.target_site_id not in site_ids:
cls._missing()
result = await ShipmentReceiptDao.list_orders(db, query, scope, site_ids)
return result.model_copy(update={'rows': [ShipmentModel.model_validate(row) for row in result.rows]})
@classmethod
async def _order(
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
) -> BizShipmentOrder:
site_ids = await cls._site_scope(db, user)
order = await OperationDao.get_shipment_by_id(db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids)
if order is None:
cls._missing()
assert_record_in_scope(order, scope, '发货单')
await AccessService.assert_site_access(db, user, order.target_site_id)
if order.status != 'CONFIRMED':
raise ServiceException(message='发货草稿尚未出库,不能核对收货')
return order
@staticmethod
async def _item_model(db: AsyncSession, item: BizShipmentItem, scope: TestBatchScope) -> ShipmentReceiptItemModel:
assert_record_in_scope(item, scope, '发货明细')
pallet = await MasterDao.get_pallet(db, item.pallet_id, scope)
if pallet is None or item.active_filling_id is None or item.package_qty is None:
raise ServiceException(message='发货物料快照不完整')
assert_record_in_scope(pallet, scope, '托盘')
filling = await OperationDao.get_filling_by_id(db, item.active_filling_id, scope)
if filling is None:
raise ServiceException(message='原发货装填记录不存在')
assert_record_in_scope(filling, scope, '装填记录')
if filling.pallet_id != item.pallet_id:
raise ServiceException(message='原发货托盘与批次不一致')
return ShipmentReceiptItemModel.model_validate(
{
'shipment_item_id': item.shipment_item_id,
'shipment_id': item.shipment_id,
'pallet_id': item.pallet_id,
'active_filling_id': item.active_filling_id,
'package_qty': item.package_qty,
'pallet_code': pallet.pallet_code,
'qr_token': pallet.qr_token,
'strain_name': filling.strain_name_snapshot,
'batch_no': filling.batch_no,
'production_date': filling.production_date,
'expiry_date': filling.expiry_date,
}
)
@classmethod
async def detail_services(
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
) -> ShipmentReceiptModel:
order = await cls._order(db, shipment_id, user, scope)
data = ShipmentModel.model_validate(order).model_dump()
data['items'] = [await cls._item_model(db, item, scope) for item in order.items]
return ShipmentReceiptModel.model_validate(data)
@staticmethod
def _validate_inventory(
order: BizShipmentOrder,
item: BizShipmentItem,
inventory: BizPalletInventory,
pallet: BizPallet,
scope: TestBatchScope,
) -> None:
assert_record_in_scope(item, scope, '发货明细')
assert_record_in_scope(inventory, scope, '库存')
assert_record_in_scope(pallet, scope, '托盘')
if (
item.shipment_id != order.shipment_id
or item.pallet_id != pallet.pallet_id
or inventory.pallet_id != pallet.pallet_id
or pallet.asset_status != '0'
or inventory.flow_state != PalletFlowState.IN_TRANSIT.value
or inventory.site_id != order.target_site_id
or inventory.warehouse_id is not None
or inventory.tunnel_id is not None
or inventory.trace_no is not None
or inventory.current_line_id is not None
or inventory.active_filling_id != item.active_filling_id
or item.active_filling_id is None
or type(item.package_qty) is not int
or item.package_qty <= 0
or inventory.remaining_packages != item.package_qty
or type(inventory.version) is not int
or inventory.version < 0
):
raise ServiceException(message='待收货托盘状态、批次或包数与原发货单不一致')
@classmethod
async def scan_services(
cls, db: AsyncSession, shipment_id: int, identifier: str, user: CurrentUserModel, scope: TestBatchScope
) -> ShipmentReceiptItemModel:
order = await cls._order(db, shipment_id, user, scope)
if order.receipt_state != 'PENDING':
raise ServiceException(message='该发货单无需再次核对收货')
pallet = await InventoryDao.get_pallet_by_qr_token(db, identifier, scope)
if pallet is None:
raise ServiceException(message='扫码托盘不属于该发货单')
items = [item for item in order.items if item.pallet_id == pallet.pallet_id]
if len(items) != 1:
raise ServiceException(message='扫码托盘不属于该发货单')
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
if inventory is None:
raise ServiceException(message='托盘库存不存在')
cls._validate_inventory(order, items[0], inventory, pallet, scope)
return await cls._item_model(db, items[0], scope)
@staticmethod
def _request_hash(shipment_id: int, command: ShipmentReceiptCommandModel) -> str:
payload = {'shipment_id': shipment_id, 'scanned_pallet_ids': sorted(command.scanned_pallet_ids)}
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
@classmethod
async def confirm_services( # noqa: PLR0912, PLR0915
cls,
db: AsyncSession,
shipment_id: int,
command: ShipmentReceiptCommandModel,
user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(db, scope)
site_ids = await cls._site_scope(db, user)
order = await OperationDao.get_shipment_for_update(
db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids
)
if order is None:
cls._missing()
assert_record_in_scope(order, scope, '发货单')
# 已有发货查询权限不能替代收货方的目标现场权限;锁后再次核验。
await AccessService.assert_site_access(db, user, order.target_site_id, for_update=True)
request_hash = cls._request_hash(shipment_id, command)
if order.status != 'CONFIRMED':
raise ServiceException(message='仅已出库发货单可核对收货')
if order.receipt_state == 'RECEIVED':
if (
order.receipt_idempotency_key != command.idempotency_key
or order.receipt_request_hash != request_hash
):
raise ServiceException(message='发货单已收货,幂等信息不匹配')
await db.rollback()
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
if order.receipt_state != 'PENDING':
raise ServiceException(message='历史发货数据不重复确认收货')
items = list(order.items)
expected_ids = sorted(item.pallet_id for item in items)
if (
not expected_ids
or len(expected_ids) != len(set(expected_ids))
or expected_ids != sorted(command.scanned_pallet_ids)
):
raise ServiceException(message='核对托盘集合与原发货明细不一致')
inventories = await InventoryDao.get_inventories_for_update(db, expected_ids, scope)
pallets = await InventoryDao.get_pallets_by_ids(db, expected_ids, scope)
if (
sorted(i.pallet_id for i in inventories) != expected_ids
or sorted(p.pallet_id for p in pallets) != expected_ids
):
raise ServiceException(message='待收货托盘或库存缺失')
site = await MasterDao.get_site(db, order.target_site_id, scope, for_update=True)
if site is None:
raise ServiceException(message='收货现场不存在')
assert_record_in_scope(site, scope, '收货现场')
if site.status != '0':
raise ServiceException(message='收货现场已停用')
by_inventory = {i.pallet_id: i for i in inventories}
by_pallet = {p.pallet_id: p for p in pallets}
for item in items:
cls._validate_inventory(order, item, by_inventory[item.pallet_id], by_pallet[item.pallet_id], scope)
PalletStateMachine.ensure_transition(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED)
now = datetime.now()
order.receipt_state = 'RECEIVED'
order.received_at = now
order.received_by = user.user.user_id
order.receipt_idempotency_key = command.idempotency_key
order.receipt_request_hash = request_hash
order.update_by = user.user.user_name
order.update_time = now
for inventory in inventories:
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
inventory.site_arrived_at = now
inventory.version += 1
inventory.updated_at = now
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.SHIPMENT_RECEIVED.value,
source_type='SHIPMENT',
source_id=shipment_id,
before_state=PalletFlowState.IN_TRANSIT.value,
after_state=inventory.flow_state,
before_site_id=inventory.site_id,
after_site_id=inventory.site_id,
before_remaining_packages=inventory.remaining_packages,
after_remaining_packages=inventory.remaining_packages,
operator_id=user.user.user_id,
occurred_at=now,
idempotency_key=command.idempotency_key,
request_hash=request_hash,
test_batch_id=scope.batch_id,
)
await InventoryDao.add_event(db, inventory, event, scope)
await db.commit()
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
except IntegrityError:
await db.rollback()
raise ServiceException(message='收货确认冲突,请刷新核对单后重试') from None
except Exception:
await db.rollback()
raise
@@ -460,6 +460,7 @@ class ShipmentService:
) )
return PalletScanResponseModel( return PalletScanResponseModel(
pallet_id=pallet.pallet_id, pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code, pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE, flow_state=PalletFlowState.FILLED_IN_WAREHOUSE,
warehouse_id=inventory.warehouse_id, warehouse_id=inventory.warehouse_id,
@@ -470,6 +471,8 @@ class ShipmentService:
strain_code=filling.strain_code_snapshot, strain_code=filling.strain_code_snapshot,
strain_name=filling.strain_name_snapshot, strain_name=filling.strain_name_snapshot,
batch_no=filling.batch_no, batch_no=filling.batch_no,
production_date=filling.production_date,
expiry_date=filling.expiry_date,
package_spec=filling.package_spec, package_spec=filling.package_spec,
capacity_packages=pallet.capacity_packages, capacity_packages=pallet.capacity_packages,
remaining_packages=inventory.remaining_packages, remaining_packages=inventory.remaining_packages,
@@ -902,12 +905,13 @@ class ShipmentService:
for inventory in inventories: for inventory in inventories:
PalletStateMachine.ensure_transition( PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state), PalletFlowState(inventory.flow_state),
PalletFlowState.AT_SITE_UNUSED, PalletFlowState.IN_TRANSIT,
) )
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories} inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
current_time = datetime.now() current_time = datetime.now()
locked_shipment.status = DocumentStatus.CONFIRMED.value locked_shipment.status = DocumentStatus.CONFIRMED.value
locked_shipment.receipt_state = 'PENDING'
locked_shipment.confirm_idempotency_key = idempotency_key locked_shipment.confirm_idempotency_key = idempotency_key
locked_shipment.confirm_request_hash = request_hash locked_shipment.confirm_request_hash = request_hash
locked_shipment.confirmed_by = user_id locked_shipment.confirmed_by = user_id
@@ -927,7 +931,8 @@ class ShipmentService:
before_line_id = inventory.current_line_id before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages before_remaining_packages = inventory.remaining_packages
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value inventory.flow_state = PalletFlowState.IN_TRANSIT.value
inventory.site_arrived_at = None
inventory.warehouse_id = None inventory.warehouse_id = None
inventory.site_id = locked_shipment.target_site_id inventory.site_id = locked_shipment.target_site_id
inventory.current_line_id = None inventory.current_line_id = None
@@ -939,7 +944,7 @@ class ShipmentService:
source_type='SHIPMENT', source_type='SHIPMENT',
source_id=shipment_id, source_id=shipment_id,
before_state=before_state, before_state=before_state,
after_state=PalletFlowState.AT_SITE_UNUSED.value, after_state=PalletFlowState.IN_TRANSIT.value,
before_warehouse_id=before_warehouse_id, before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id, before_site_id=before_site_id,
before_line_id=before_line_id, before_line_id=before_line_id,
@@ -0,0 +1,54 @@
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.master_dao import MasterDao
from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.fifo_policy import requires_fifo_skip_reason
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
from module_business.service.usage_service import UsageService
class UsageFifoService:
@staticmethod
async def recommendation(
db: AsyncSession, identifier: str, user: CurrentUserModel, scope: TestBatchScope,
*, tunnel_id: int | None = None,
) -> UsageFifoRecommendationModel:
# 复用扫码入口的现场授权、当前批次与可用状态校验;客户端不指定查询现场。
pallet = await UsageService.scan_usage_pallet_services(db, identifier, user, scope)
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
if inventory is None:
raise ServiceException(message='托盘库存不存在')
assert_record_in_scope(inventory, scope, '库存')
target_tunnel_id = pallet.tunnel_id
trace_no = pallet.trace_no
if tunnel_id is not None:
tunnel = await MasterDao.get_tunnel(db, tunnel_id, scope)
if tunnel is None or tunnel.site_id != pallet.site_id or tunnel.status != '0':
raise ServiceException(message='请选择托盘当前现场的启用隧道')
assert_record_in_scope(tunnel, scope, '隧道')
if pallet.tunnel_id is not None and pallet.tunnel_id != tunnel_id:
raise ServiceException(message='托盘已入其他隧道')
target_tunnel_id, trace_no = tunnel_id, tunnel.current_trace_no
candidate, unknown = await UsageFifoDao.recommendation(
db,
site_id=pallet.site_id,
strain_id=pallet.strain_id,
tunnel_id=target_tunnel_id,
trace_no=trace_no,
scope=scope,
)
message = '当前无可确定到货顺序的同品种托盘'
if candidate:
message = f'先到先用建议:托盘 {candidate["pallet_code"]},批次 {candidate["batch_no"]},余量 {candidate["remaining_packages"]} 包'
if unknown:
message += f';另有 {unknown} 托盘未登记实际收货时间,请人工核对先后顺序'
needs_reason = requires_fifo_skip_reason(pallet.pallet_id, inventory.site_arrived_at, candidate)
if needs_reason:
message += ';使用当前托盘需填写跳过先到托盘的原因'
return UsageFifoRecommendationModel(
recommended=candidate, unknown_arrival_count=unknown,
requires_skip_reason=needs_reason, message=message,
)
@@ -16,14 +16,18 @@ from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao from module_business.dao.operation_dao import OperationDao
from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.enums import PalletEventType, PalletFlowState
from module_business.domain.fifo_policy import requires_fifo_skip_reason
from module_business.domain.pallet_state_machine import PalletStateMachine from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.do.operation_do import BizPalletEvent, BizUsageRecord from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory, BizUsageRecord
from module_business.entity.vo.master_vo import LinePageQueryModel from module_business.entity.vo.master_vo import LinePageQueryModel, TunnelPageQueryModel
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
@@ -31,6 +35,7 @@ from module_business.entity.vo.operation_vo import (
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService from module_business.service.test_batch_access_service import TestBatchAccessService
from module_business.service.usage_transaction import retry_mysql_lock_conflicts
class UsageService: class UsageService:
@@ -110,17 +115,52 @@ class UsageService:
@staticmethod @staticmethod
def _build_request_hash(page_object: UsageCreateModel) -> str: def _build_request_hash(page_object: UsageCreateModel) -> str:
payload = json.dumps( fields = {
{
'qr_token': page_object.qr_token.strip(), 'qr_token': page_object.qr_token.strip(),
'tunnel_id': page_object.tunnel_id,
'line_id': page_object.line_id, 'line_id': page_object.line_id,
'used_packages': page_object.used_packages, 'used_packages': page_object.used_packages,
}, }
# 无原因时保留原摘要,迁移前已成功的请求仍可按原键安全重放。
if page_object.fifo_skip_reason:
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
payload = json.dumps(
fields,
sort_keys=True, sort_keys=True,
separators=(',', ':'), separators=(',', ':'),
).encode() ).encode()
return hashlib.sha256(payload).hexdigest() return hashlib.sha256(payload).hexdigest()
@staticmethod
async def _check_fifo_skip(
query_db: AsyncSession,
inventory: BizPalletInventory,
filling: BizFillingRecord,
tunnel_id: int,
trace_no: str | None,
reason: str | None,
test_scope: TestBatchScope,
) -> dict[str, Any] | None:
if inventory.site_arrived_at is None:
return None
candidate, _ = await UsageFifoDao.recommendation(
query_db, site_id=inventory.site_id, strain_id=filling.strain_id,
tunnel_id=tunnel_id, trace_no=trace_no, scope=test_scope, for_update=True,
)
if not requires_fifo_skip_reason(inventory.pallet_id, inventory.site_arrived_at, candidate):
return None
if not reason:
raise ServiceException(
data='FIFO_SKIP_REASON_REQUIRED',
message=f'存在先到托盘 {candidate["pallet_code"]},请填写跳过原因后重新提交',
)
return {
'palletId': candidate['pallet_id'], 'palletCode': candidate['pallet_code'],
'batchNo': candidate['batch_no'], 'remainingPackages': candidate['remaining_packages'],
'siteArrivedAt': candidate['site_arrived_at'].isoformat(),
'selectedSiteArrivedAt': inventory.site_arrived_at.isoformat(),
}
@staticmethod @staticmethod
def _is_exact_idempotent_result( def _is_exact_idempotent_result(
usage: BizUsageRecord, usage: BizUsageRecord,
@@ -143,6 +183,32 @@ class UsageService:
), ),
) )
@staticmethod
def _tunnel_entry_success(pallet_id: int, tunnel_id: int, trace_no: str) -> CrudResponseModel:
return CrudResponseModel(
is_success=True,
message='扫码入隧成功',
result=TunnelEntrySuccessResponseModel(
pallet_id=pallet_id,
tunnel_id=tunnel_id,
trace_no=trace_no,
),
)
@staticmethod
def _is_exact_tunnel_entry_result(
event: BizPalletEvent,
page_object: TunnelEntryCreateModel,
request_hash: str,
) -> bool:
return (
event.event_type == PalletEventType.TUNNEL_ENTERED.value
and event.after_tunnel_id == page_object.tunnel_id
and event.idempotency_key == page_object.idempotency_key
and event.request_hash == request_hash
and bool(event.trace_no)
)
@classmethod @classmethod
async def _lock_and_reauthorize_usage_site( async def _lock_and_reauthorize_usage_site(
cls, cls,
@@ -294,7 +360,274 @@ class UsageService:
return sorted(options, key=lambda option: option.id) return sorted(options, key=lambda option: option.id)
@classmethod @classmethod
async def scan_usage_pallet_services( async def get_tunnel_options_services(
cls,
query_db: AsyncSession,
site_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[BusinessOptionModel]:
cls._current_identity(current_user)
await AccessService.assert_site_access(query_db, current_user, site_id)
site = await MasterDao.get_site(query_db, site_id, test_scope)
if site is None:
raise ServiceException(message='客户现场不存在')
assert_record_in_scope(site, test_scope, '现场')
if site.status != '0':
raise ServiceException(message='客户现场已停用')
tunnels = await MasterDao.list_tunnel(
query_db,
TunnelPageQueryModel(siteId=site_id, status='0'),
test_scope,
False,
)
options = [
BusinessOptionModel(
id=cls._record_value(tunnel, 'tunnel_id'),
code=cls._record_value(tunnel, 'tunnel_code'),
name=cls._record_value(tunnel, 'tunnel_name'),
)
for tunnel in tunnels
if cls._record_value(tunnel, 'site_id') == site_id and cls._record_value(tunnel, 'status') == '0'
]
return sorted(options, key=lambda option: option.id)
@staticmethod
def _build_tunnel_entry_request_hash(page_object: TunnelEntryCreateModel) -> str:
fields = {'qr_token': page_object.qr_token.strip(), 'tunnel_id': page_object.tunnel_id}
if page_object.fifo_skip_reason:
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
payload = json.dumps(
fields,
sort_keys=True,
separators=(',', ':'),
).encode()
return hashlib.sha256(payload).hexdigest()
@classmethod
async def _recover_tunnel_entry_integrity_error(
cls,
query_db: AsyncSession,
page_object: TunnelEntryCreateModel,
request_hash: str,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
pallet = await InventoryDao.get_pallet_by_qr_token(
query_db,
page_object.qr_token.strip(),
test_scope,
)
if pallet is None:
cls._raise_pallet_not_accessible(current_user)
event = await InventoryDao.get_event_by_idempotency_key(
query_db,
pallet.pallet_id,
page_object.idempotency_key,
test_scope,
)
if event is None or not cls._is_exact_tunnel_entry_result(event, page_object, request_hash):
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
return cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, event.trace_no)
finally:
await query_db.rollback()
@classmethod
@retry_mysql_lock_conflicts
async def enter_tunnel_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
page_object: TunnelEntryCreateModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
request_hash = cls._build_tunnel_entry_request_hash(page_object)
user_id, user_name = cls._current_identity(current_user)
pallet = await InventoryDao.get_pallet_by_qr_token(
query_db,
page_object.qr_token.strip(),
test_scope,
)
if pallet is None:
cls._raise_pallet_not_accessible(current_user)
assert_record_in_scope(pallet, test_scope, '托盘')
tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
if tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0':
raise ServiceException(message='隧道已停用')
preflight_tunnel_site_id = tunnel.site_id
await cls._assert_pallet_site_access(query_db, current_user, tunnel.site_id)
existing = await InventoryDao.get_event_by_idempotency_key(
query_db,
pallet.pallet_id,
page_object.idempotency_key,
test_scope,
)
if existing is not None:
assert_record_in_scope(existing, test_scope, '托盘事件')
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
await query_db.rollback()
return result
# rollback 会令 ORM 属性过期;跨事务只传普通值,不触发隐式异步加载。
scanned_pallet_id = pallet.pallet_id
await query_db.rollback()
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
inventory = await InventoryDao.get_inventory_for_update(query_db, scanned_pallet_id, test_scope)
if inventory is None:
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
assert_record_in_scope(inventory, test_scope, '库存')
if inventory.site_id is None:
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
if inventory.site_id != preflight_tunnel_site_id:
raise ServiceException(message='隧道不属于托盘当前客户现场')
locked_pallets = await InventoryDao.get_pallets_by_ids(
query_db,
[inventory.pallet_id],
test_scope,
)
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id:
cls._raise_pallet_not_accessible(current_user)
pallet = locked_pallets[0]
assert_record_in_scope(pallet, test_scope, '托盘')
tunnel = await MasterDao.get_tunnel(
query_db,
page_object.tunnel_id,
test_scope,
for_update=True,
)
if tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0':
raise ServiceException(message='隧道已停用')
if tunnel.site_id != inventory.site_id:
raise ServiceException(message='隧道不属于托盘当前客户现场')
existing = await InventoryDao.get_event_by_idempotency_key(
query_db,
pallet.pallet_id,
page_object.idempotency_key,
test_scope,
)
if existing is not None:
assert_record_in_scope(existing, test_scope, '托盘事件')
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
await query_db.rollback()
return result
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许入隧')
if (
inventory.flow_state
not in {
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
}
or inventory.warehouse_id is not None
or inventory.active_filling_id is None
or type(inventory.remaining_packages) is not int
or inventory.remaining_packages <= 0
or inventory.remaining_packages > pallet.capacity_packages
):
raise ServiceException(message='托盘当前状态不允许入隧')
filling = await OperationDao.get_active_filling_for_update(
query_db,
inventory.active_filling_id,
test_scope,
)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
assert_record_in_scope(filling, test_scope, '活动装填')
if (
filling.pallet_id != pallet.pallet_id
or filling.active_slot != 'ACTIVE'
or filling.closed_at is not None
):
raise ServiceException(message='托盘活动装填周期无效')
if inventory.tunnel_id is not None:
if inventory.tunnel_id == tunnel.tunnel_id and inventory.trace_no == tunnel.current_trace_no:
raise ServiceException(message='托盘已入当前隧道')
raise ServiceException(message='托盘已入其他隧道')
if inventory.trace_no is not None:
raise ServiceException(message='托盘隧道溯源状态不一致')
fifo_snapshot = await cls._check_fifo_skip(
query_db, inventory, filling, tunnel.tunnel_id, tunnel.current_trace_no,
page_object.fifo_skip_reason, test_scope,
)
if tunnel.current_trace_no is None:
tunnel.current_trace_no = f'TN-{secrets.token_hex(16)}'
tunnel.current_trace_started_at = datetime.now()
elif not tunnel.current_trace_no.strip():
raise ServiceException(message='隧道当前溯源号无效')
current_time = datetime.now()
before_state = inventory.flow_state
before_remaining_packages = inventory.remaining_packages
inventory.tunnel_id = tunnel.tunnel_id
inventory.trace_no = tunnel.current_trace_no
inventory.version += 1
inventory.updated_at = current_time
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.TUNNEL_ENTERED.value,
source_type='TUNNEL',
source_id=tunnel.tunnel_id,
fifo_skip_reason=page_object.fifo_skip_reason,
fifo_recommendation=fifo_snapshot,
before_state=before_state,
after_state=inventory.flow_state,
before_warehouse_id=inventory.warehouse_id,
before_site_id=inventory.site_id,
before_tunnel_id=None,
before_line_id=inventory.current_line_id,
after_warehouse_id=inventory.warehouse_id,
after_site_id=inventory.site_id,
after_tunnel_id=inventory.tunnel_id,
after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages,
trace_no=inventory.trace_no,
batch_no_snapshot=filling.batch_no,
operator_id=user_id,
occurred_at=current_time,
idempotency_key=page_object.idempotency_key,
request_hash=request_hash,
test_batch_id=test_scope.batch_id,
)
await InventoryDao.add_event(query_db, inventory, event, test_scope)
tunnel.update_by = user_name
tunnel.update_time = current_time
result = cls._tunnel_entry_success(pallet.pallet_id, tunnel.tunnel_id, tunnel.current_trace_no)
await query_db.commit()
return result
except IntegrityError:
await query_db.rollback()
return await cls._recover_tunnel_entry_integrity_error(
query_db,
page_object,
cls._build_tunnel_entry_request_hash(page_object),
current_user,
test_scope,
)
except Exception:
await query_db.rollback()
raise
@classmethod
async def scan_usage_pallet_services( # noqa: PLR0912
cls, cls,
query_db: AsyncSession, query_db: AsyncSession,
qr_token: str, qr_token: str,
@@ -314,7 +647,7 @@ class UsageService:
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场') cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id) await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
if pallet.qr_token != normalized_qr or pallet.pallet_id != inventory.pallet_id: if normalized_qr not in {pallet.qr_token, pallet.pallet_code} or pallet.pallet_id != inventory.pallet_id:
raise ServiceException(message='托盘二维码与库存不一致') raise ServiceException(message='托盘二维码与库存不一致')
if pallet.asset_status != '0': if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许使用') raise ServiceException(message='托盘资产已停用,不允许使用')
@@ -342,25 +675,43 @@ class UsageService:
or filling.closed_at is not None or filling.closed_at is not None
): ):
raise ServiceException(message='托盘活动装填周期无效') raise ServiceException(message='托盘活动装填周期无效')
allowed_actions = ['usage']
if inventory.tunnel_id is None:
allowed_actions = ['enter_tunnel']
else:
if inventory.trace_no is None:
raise ServiceException(message='托盘隧道溯源状态不一致')
tunnel = await MasterDao.get_tunnel(query_db, inventory.tunnel_id, test_scope)
if tunnel is None or tunnel.site_id != inventory.site_id:
raise ServiceException(message='托盘当前隧道不存在或现场不一致')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0' or tunnel.current_trace_no != inventory.trace_no:
raise ServiceException(message='托盘当前隧道已停用或溯源状态不一致')
return PalletScanResponseModel( return PalletScanResponseModel(
pallet_id=pallet.pallet_id, pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code, pallet_code=pallet.pallet_code,
flow_state=PalletFlowState(inventory.flow_state), flow_state=PalletFlowState(inventory.flow_state),
warehouse_id=None, warehouse_id=None,
site_id=inventory.site_id, site_id=inventory.site_id,
tunnel_id=inventory.tunnel_id,
trace_no=inventory.trace_no,
current_line_id=inventory.current_line_id, current_line_id=inventory.current_line_id,
active_filling_id=inventory.active_filling_id, active_filling_id=inventory.active_filling_id,
strain_id=filling.strain_id, strain_id=filling.strain_id,
strain_code=filling.strain_code_snapshot, strain_code=filling.strain_code_snapshot,
strain_name=filling.strain_name_snapshot, strain_name=filling.strain_name_snapshot,
batch_no=filling.batch_no, batch_no=filling.batch_no,
production_date=filling.production_date,
expiry_date=filling.expiry_date,
package_spec=filling.package_spec, package_spec=filling.package_spec,
capacity_packages=pallet.capacity_packages, capacity_packages=pallet.capacity_packages,
remaining_packages=inventory.remaining_packages, remaining_packages=inventory.remaining_packages,
allowed_actions=['usage'], allowed_actions=allowed_actions,
) )
@classmethod @classmethod
@retry_mysql_lock_conflicts
async def record_usage_services( # noqa: PLR0912, PLR0915 async def record_usage_services( # noqa: PLR0912, PLR0915
cls, cls,
query_db: AsyncSession, query_db: AsyncSession,
@@ -374,11 +725,16 @@ class UsageService:
request_hash = cls._build_request_hash(page_object) request_hash = cls._build_request_hash(page_object)
user_id, user_name = cls._current_identity(current_user) user_id, user_name = cls._current_identity(current_user)
preflight_site_ids = await cls._site_scope(query_db, current_user) preflight_site_ids = await cls._site_scope(query_db, current_user)
# 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。 preflight_tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
if preflight_tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(preflight_tunnel, test_scope, '隧道')
preflight_site_id = preflight_tunnel.site_id
if page_object.line_id is not None:
# 仅在预检阶段读取产线以确定批次范围;状态和实际现场归属在锁定库存后复核。
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope) preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
if preflight_line is not None: if preflight_line is not None:
assert_record_in_scope(preflight_line, test_scope, '产线') assert_record_in_scope(preflight_line, test_scope, '产线')
preflight_site_id = preflight_line.site_id if preflight_line is not None else None
existing = None existing = None
if preflight_site_id is not None and ( if preflight_site_id is not None and (
preflight_site_ids is None or preflight_site_id in preflight_site_ids preflight_site_ids is None or preflight_site_id in preflight_site_ids
@@ -494,6 +850,26 @@ class UsageService:
assert_record_in_scope(site, test_scope, '现场') assert_record_in_scope(site, test_scope, '现场')
if site.status != '0': if site.status != '0':
raise ServiceException(message='客户现场已停用') raise ServiceException(message='客户现场已停用')
tunnel = await MasterDao.get_tunnel(
query_db,
page_object.tunnel_id,
test_scope,
for_update=True,
)
if tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0':
raise ServiceException(message='隧道已停用')
if tunnel.site_id != inventory.site_id:
raise ServiceException(message='隧道不属于托盘当前客户现场')
if inventory.tunnel_id != tunnel.tunnel_id or not inventory.trace_no:
raise ServiceException(message='托盘尚未扫码入当前隧道')
if tunnel.current_trace_no != inventory.trace_no:
raise ServiceException(message='托盘与隧道的物料溯源号不一致')
line = None
if page_object.line_id is not None:
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True) line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
if line is None: if line is None:
raise ServiceException(message='产线不存在') raise ServiceException(message='产线不存在')
@@ -527,13 +903,22 @@ class UsageService:
or filling.closed_at is not None or filling.closed_at is not None
): ):
raise ServiceException(message='托盘活动装填周期无效') raise ServiceException(message='托盘活动装填周期无效')
if inventory.current_line_id is not None and inventory.current_line_id != line.line_id: if (
line is not None
and inventory.current_line_id is not None
and inventory.current_line_id != line.line_id
):
raise ServiceException(message='托盘本次驻场周期已绑定其他产线') raise ServiceException(message='托盘本次驻场周期已绑定其他产线')
if type(page_object.used_packages) is not int or page_object.used_packages <= 0: if type(page_object.used_packages) is not int or page_object.used_packages <= 0:
raise ServiceException(message='使用包数必须为正整数') raise ServiceException(message='使用包数必须为正整数')
if page_object.used_packages > inventory.remaining_packages: if page_object.used_packages > inventory.remaining_packages:
raise ServiceException(message='使用包数不能超过当前余量') raise ServiceException(message='使用包数不能超过当前余量')
fifo_snapshot = await cls._check_fifo_skip(
query_db, inventory, filling, tunnel.tunnel_id, inventory.trace_no,
page_object.fifo_skip_reason, test_scope,
)
remaining_after = inventory.remaining_packages - page_object.used_packages remaining_after = inventory.remaining_packages - page_object.used_packages
target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL
PalletStateMachine.ensure_transition( PalletStateMachine.ensure_transition(
@@ -543,11 +928,16 @@ class UsageService:
current_time = datetime.now() current_time = datetime.now()
usage = BizUsageRecord( usage = BizUsageRecord(
fifo_skip_reason=page_object.fifo_skip_reason,
fifo_recommendation=fifo_snapshot,
usage_no=f'US-{secrets.token_hex(16)}', usage_no=f'US-{secrets.token_hex(16)}',
pallet_id=pallet.pallet_id, pallet_id=pallet.pallet_id,
active_filling_id=filling.filling_id, active_filling_id=filling.filling_id,
site_id=inventory.site_id, site_id=inventory.site_id,
line_id=line.line_id, tunnel_id=tunnel.tunnel_id,
trace_no=inventory.trace_no,
batch_no_snapshot=filling.batch_no,
line_id=line.line_id if line is not None else inventory.current_line_id,
used_packages=page_object.used_packages, used_packages=page_object.used_packages,
remaining_packages_after=remaining_after, remaining_packages_after=remaining_after,
idempotency_key=page_object.idempotency_key, idempotency_key=page_object.idempotency_key,
@@ -564,8 +954,11 @@ class UsageService:
before_state = inventory.flow_state before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id before_site_id = inventory.site_id
before_tunnel_id = inventory.tunnel_id
before_trace_no = inventory.trace_no
before_line_id = inventory.current_line_id before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages before_remaining_packages = inventory.remaining_packages
if line is not None:
inventory.current_line_id = line.line_id inventory.current_line_id = line.line_id
inventory.remaining_packages = remaining_after inventory.remaining_packages = remaining_after
inventory.flow_state = target_state.value inventory.flow_state = target_state.value
@@ -583,16 +976,22 @@ class UsageService:
event_type=PalletEventType.USAGE_RECORDED.value, event_type=PalletEventType.USAGE_RECORDED.value,
source_type='USAGE', source_type='USAGE',
source_id=usage_id, source_id=usage_id,
fifo_skip_reason=page_object.fifo_skip_reason,
fifo_recommendation=fifo_snapshot,
before_state=before_state, before_state=before_state,
after_state=target_state.value, after_state=target_state.value,
before_warehouse_id=before_warehouse_id, before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id, before_site_id=before_site_id,
before_tunnel_id=before_tunnel_id,
before_line_id=before_line_id, before_line_id=before_line_id,
after_warehouse_id=inventory.warehouse_id, after_warehouse_id=inventory.warehouse_id,
after_site_id=inventory.site_id, after_site_id=inventory.site_id,
after_tunnel_id=inventory.tunnel_id,
after_line_id=inventory.current_line_id, after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages, before_remaining_packages=before_remaining_packages,
after_remaining_packages=remaining_after, after_remaining_packages=remaining_after,
trace_no=before_trace_no,
batch_no_snapshot=filling.batch_no,
operator_id=user_id, operator_id=user_id,
occurred_at=current_time, occurred_at=current_time,
idempotency_key=page_object.idempotency_key, idempotency_key=page_object.idempotency_key,
@@ -0,0 +1,32 @@
"""入隧/使用命令的有限整事务锁冲突重试,不重放结果未知的连接错误。"""
import asyncio
import secrets
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import ParamSpec, TypeVar
from sqlalchemy.exc import OperationalError
P = ParamSpec('P')
R = TypeVar('R')
def retry_mysql_lock_conflicts(operation: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""仅用于异常出口已 rollback 的完整业务命令;所有重试沿用原payload/key。"""
max_attempts = 3
@wraps(operation)
async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(max_attempts):
try:
return await operation(*args, **kwargs)
except OperationalError as exc: # noqa: PERF203 -- 有限整事务重试必须逐次捕获锁冲突
errno = exc.orig.args[0] if getattr(exc.orig, 'args', ()) else None
# 3572=NOWAIT冲突,1213=死锁,1205=锁等待超时;不重试断连/未知提交结果。
if errno not in {3572, 1213, 1205} or attempt == max_attempts - 1:
raise
await asyncio.sleep(0.05 * (attempt + 1) + secrets.randbelow(20) / 1000)
raise AssertionError('锁冲突重试未返回结果')
return wrapped
@@ -0,0 +1,111 @@
-- 隧道物料流转:MySQL 增量补丁
-- 前置条件:已执行 20260922_test_data_batch_mysql.sql,且数据库中存在 biz_test_batch。
-- 本补丁只生成脚本,不在应用启动时自动执行;执行前请备份数据库并由 DBA 审核。
-- 业务层将新托盘/新装填固定为 100 包,数据库不增加 capacity_packages = 100
-- 约束,以兼容已有历史非 100 包托盘数据。
-- 隧道主数据:一个客户现场下的隧道编号唯一;current_trace_no 表示当前混批溯源号。
CREATE TABLE biz_tunnel (
tunnel_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '隧道ID',
test_batch_id BIGINT NULL COMMENT '测试数据批次ID;NULL表示正式数据',
site_id BIGINT NOT NULL COMMENT '现场ID',
tunnel_code VARCHAR(64) NOT NULL COMMENT '隧道编号',
tunnel_name VARCHAR(100) NOT NULL COMMENT '隧道名称',
current_trace_no VARCHAR(64) NULL COMMENT '当前隧道物料溯源号',
current_trace_started_at DATETIME NULL COMMENT '当前溯源批次开始时间',
status CHAR(1) NOT NULL DEFAULT '0' COMMENT '状态(0正常 1停用)',
create_by VARCHAR(64) NULL DEFAULT '' COMMENT '创建者',
create_time DATETIME NULL COMMENT '创建时间',
update_by VARCHAR(64) NULL DEFAULT '' COMMENT '更新者',
update_time DATETIME NULL COMMENT '更新时间',
remark VARCHAR(500) NULL COMMENT '备注',
PRIMARY KEY (tunnel_id),
KEY idx_biz_tunnel_test_batch_id (test_batch_id),
CONSTRAINT uq_biz_tunnel_site_code UNIQUE (site_id, tunnel_code),
CONSTRAINT fk_biz_tunnel_test_batch_id FOREIGN KEY (test_batch_id) REFERENCES biz_test_batch (batch_id),
CONSTRAINT fk_biz_tunnel_site FOREIGN KEY (site_id) REFERENCES biz_customer_site (site_id),
CONSTRAINT ck_biz_tunnel_status CHECK (status IN ('0', '1'))
) ENGINE=InnoDB COMMENT='客户现场隧道主数据表';
-- 使用记录:保留入隧隧道、溯源号和生产批次快照;产线改为可选。
ALTER TABLE biz_usage_record
ADD COLUMN tunnel_id BIGINT NULL COMMENT '隧道ID;历史记录可能为空' AFTER site_id,
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '隧道物料内部溯源号;历史记录可能为空' AFTER tunnel_id,
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '生产批次快照;历史记录可能为空' AFTER trace_no,
MODIFY COLUMN line_id BIGINT NULL COMMENT '产线ID(可选)',
ADD CONSTRAINT fk_biz_usage_record_tunnel FOREIGN KEY (tunnel_id) REFERENCES biz_tunnel (tunnel_id);
-- 退回明细:保存申请时的隧道、溯源号和批次快照。
ALTER TABLE biz_return_item
ADD COLUMN tunnel_id_snapshot BIGINT NULL COMMENT '申请时隧道ID快照' AFTER active_filling_id_snapshot,
ADD COLUMN trace_no_snapshot VARCHAR(64) NULL COMMENT '申请时物料溯源号快照' AFTER tunnel_id_snapshot,
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '申请时生产批次快照' AFTER trace_no_snapshot,
ADD CONSTRAINT fk_biz_return_item_tunnel FOREIGN KEY (tunnel_id_snapshot) REFERENCES biz_tunnel (tunnel_id);
-- 当前库存:隧道归属和当前溯源号随托盘一起锁定维护。
ALTER TABLE biz_pallet_inventory
ADD COLUMN tunnel_id BIGINT NULL COMMENT '当前隧道ID' AFTER site_id,
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '当前隧道物料溯源号' AFTER tunnel_id,
ADD CONSTRAINT fk_biz_pallet_inventory_tunnel FOREIGN KEY (tunnel_id) REFERENCES biz_tunnel (tunnel_id);
-- 流转事件:为入隧、使用、退回保留隧道和溯源快照。
ALTER TABLE biz_pallet_event
ADD COLUMN before_tunnel_id BIGINT NULL COMMENT '变更前隧道ID' AFTER before_site_id,
ADD COLUMN after_tunnel_id BIGINT NULL COMMENT '变更后隧道ID' AFTER after_site_id,
ADD COLUMN trace_no VARCHAR(64) NULL COMMENT '事件关联物料溯源号' AFTER after_remaining_packages,
ADD COLUMN batch_no_snapshot VARCHAR(64) NULL COMMENT '事件生产批次快照' AFTER trace_no,
ADD CONSTRAINT fk_biz_pallet_event_before_tunnel FOREIGN KEY (before_tunnel_id) REFERENCES biz_tunnel (tunnel_id),
ADD CONSTRAINT fk_biz_pallet_event_after_tunnel FOREIGN KEY (after_tunnel_id) REFERENCES biz_tunnel (tunnel_id);
ALTER TABLE biz_pallet_event
DROP CHECK ck_biz_pallet_event_event_type,
ADD CONSTRAINT ck_biz_pallet_event_event_type CHECK (
event_type IN (
'INBOUND_CONFIRMED',
'FILLING_CONFIRMED',
'SHIPMENT_CONFIRMED',
'TUNNEL_ENTERED',
'USAGE_RECORDED',
'RETURN_REQUESTED',
'RETURN_CONFIRMED'
)
);
-- 菜单:只提供隧道查询/新增/修改权限,不提供物理删除权限。
INSERT INTO sys_menu (
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
create_time, update_by, update_time, remark
)
SELECT 520, '隧道资料', 121, 7, 'tunnel', 'master/tunnel/index', '', '',
1, 0, 'C', '0', '0', 'master:tunnel:list', '#', 'admin', SYSDATE(), '', NULL, '隧道资料菜单'
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 520);
INSERT INTO sys_menu (
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
create_time, update_by, update_time, remark
)
SELECT 1116, '隧道查询', 520, 1, '#', '', '', '',
1, 0, 'F', '0', '0', 'master:tunnel:query', '#', 'admin', SYSDATE(), '', NULL, ''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1116);
INSERT INTO sys_menu (
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
create_time, update_by, update_time, remark
)
SELECT 1117, '隧道新增', 520, 2, '#', '', '', '',
1, 0, 'F', '0', '0', 'master:tunnel:add', '#', 'admin', SYSDATE(), '', NULL, ''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1117);
INSERT INTO sys_menu (
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
create_time, update_by, update_time, remark
)
SELECT 1118, '隧道修改', 520, 3, '#', '', '', '',
1, 0, 'F', '0', '0', 'master:tunnel:edit', '#', 'admin', SYSDATE(), '', NULL, ''
WHERE NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1118);
-- 重要:DDL 会隐式提交,不能依赖事务整体回滚;本文件未执行任何数据库操作。
@@ -0,0 +1,10 @@
-- 生产日期与有效期补齐;执行前备份,且仅执行一次。
-- 历史日期保留 NULL,禁止使用装填/发货时间自动推算真实生产日期。
-- 需在部署包含日期字段的新后端前执行;本文件不会由应用自动执行。
ALTER TABLE biz_filling_record
ADD COLUMN production_date DATE NULL COMMENT '来源生产日期;历史未登记为空' AFTER batch_no,
ADD COLUMN expiry_date DATE NULL COMMENT '有效期至;历史未登记为空' AFTER production_date,
ADD CONSTRAINT ck_biz_filling_record_material_dates CHECK (
(production_date IS NULL AND expiry_date IS NULL) OR
(production_date IS NOT NULL AND expiry_date IS NOT NULL AND expiry_date >= production_date)
);
@@ -0,0 +1,125 @@
-- 发货收货核对:MySQL 8 单次增量迁移(最低 8.0.16,需强制执行 CHECK)。
-- 执行顺序:20260922_test_data_batch_mysql.sql
-- -> 20261005_tunnel_material_flow_mysql.sql
-- -> 20261008_material_dates_mysql.sql -> 本脚本 -> 部署收货核对版本。
-- 执行前必须停写(业务接口、后台任务、导入及其他写入来源),完成备份并验证可恢复。
-- 由 DBA 审核后在明确选定的目标库单次执行;不得使用 --force 或遇错继续的 SQL 客户端模式。
-- 本脚本仅随代码交付,不在应用启动时执行;本次开发未连接数据库、未运行 SQL。
-- 警告:MySQL DDL 会隐式提交,下面的 ROLLBACK 仅能撤销 DML,不能整体回滚已完成的 DDL。
-- 中途失败应保持停写,依据备份和已完成步骤人工恢复;禁止直接重跑或启动新旧应用。
-- 旧 CONFIRMED 单只标 LEGACY;不迁移旧库存状态、不补扣库存、不造收货事件、不伪造收货时间。
-- 正式数据与各测试批次采用相同历史标记规则;所有原 test_batch_id 保持不变。
-- 权限只新增 sys_menu,不写 sys_role_menu;上线后由管理员按现场职责手动授权。
DELIMITER $$
CREATE PROCEDURE migrate_shipment_receipt_20261008()
BEGIN
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
-- 所有环境、前置结构、菜单冲突检查均先于业务表 DDL。
IF DATABASE() IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Select the target database before migration';
END IF;
IF SUBSTRING_INDEX(VERSION(), '.', 1) <> '8'
OR (CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(VERSION(), '.', 2), '.', -1) AS UNSIGNED) = 0
AND CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(VERSION(), '.', 3), '.', -1) AS UNSIGNED) < 16) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'MySQL 8.0.16 or newer MySQL 8 is required';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND engine = 'InnoDB'
AND table_name IN ('biz_shipment_order', 'biz_pallet_inventory', 'biz_pallet_event',
'biz_filling_record', 'biz_tunnel', 'sys_menu', 'sys_role_menu', 'sys_user')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required InnoDB tables or tunnel migration are missing';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'biz_filling_record' AND column_name IN ('production_date', 'expiry_date'))
OR (table_name = 'biz_pallet_inventory' AND column_name IN ('tunnel_id', 'trace_no'))
OR (table_name = 'biz_pallet_event'
AND column_name IN ('before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot')))) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Apply tunnel and material date migrations first';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_filling_record_material_dates')
OR NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_pallet_event_event_type'
AND UPPER(check_clause) LIKE '%TUNNEL_ENTERED%')
OR NOT EXISTS (SELECT 1 FROM information_schema.check_constraints
WHERE constraint_schema = DATABASE() AND constraint_name = 'ck_biz_pallet_inventory_flow_state') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required material date or flow CHECK constraints are missing';
END IF;
-- 任何新增字段已存在均说明已执行或部分执行,禁止将已收货数据重新标成 LEGACY。
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'biz_shipment_order'
AND column_name IN ('receipt_state', 'received_by', 'received_at',
'receipt_idempotency_key', 'receipt_request_hash'))
OR (table_name = 'biz_pallet_inventory' AND column_name = 'site_arrived_at'))) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Receipt columns already exist; do not rerun this migration';
END IF;
IF EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 1119 OR perms = 'business:shipment:receive') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Menu 1119 or shipment receive permission already exists';
END IF;
-- 避免已有孤立授权在插入 1119 时自动变成有效收货权限。
IF EXISTS (SELECT 1 FROM sys_role_menu WHERE menu_id = 1119) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Orphan role grants for menu 1119 must be reviewed first';
END IF;
IF NOT EXISTS (SELECT 1 FROM sys_menu WHERE menu_id = 510 AND perms = 'business:shipment:list'
AND menu_type = 'C') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Shipment parent menu 510 does not match the expected contract';
END IF;
ALTER TABLE biz_shipment_order
ADD COLUMN receipt_state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '收货状态;LEGACY表示历史未登记' AFTER confirmed_at,
ADD COLUMN received_by BIGINT NULL COMMENT '收货核对人ID' AFTER receipt_state,
ADD COLUMN received_at DATETIME NULL COMMENT '实际收货核对时间' AFTER received_by,
ADD COLUMN receipt_idempotency_key VARCHAR(64) NULL COMMENT '收货确认幂等键' AFTER received_at,
ADD COLUMN receipt_request_hash VARCHAR(64) NULL COMMENT '收货核对请求摘要' AFTER receipt_idempotency_key,
ADD CONSTRAINT uq_biz_shipment_receipt_site_key UNIQUE (target_site_id, receipt_idempotency_key),
ADD CONSTRAINT fk_biz_shipment_order_received_by FOREIGN KEY (received_by) REFERENCES sys_user (user_id),
ADD CONSTRAINT ck_biz_shipment_order_receipt_state CHECK (receipt_state IN ('PENDING', 'RECEIVED', 'LEGACY'));
ALTER TABLE biz_pallet_inventory
ADD COLUMN site_arrived_at DATETIME NULL COMMENT '本轮实际收货核对时间;历史未登记为空' AFTER remaining_packages,
DROP CHECK ck_biz_pallet_inventory_flow_state,
ADD CONSTRAINT ck_biz_pallet_inventory_flow_state CHECK (
flow_state IN ('UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE', 'IN_TRANSIT',
'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING')
);
ALTER TABLE biz_pallet_event
DROP CHECK ck_biz_pallet_event_event_type,
ADD CONSTRAINT ck_biz_pallet_event_event_type CHECK (
event_type IN ('INBOUND_CONFIRMED', 'FILLING_CONFIRMED', 'SHIPMENT_CONFIRMED', 'SHIPMENT_RECEIVED',
'TUNNEL_ENTERED', 'USAGE_RECORDED', 'RETURN_REQUESTED', 'RETURN_CONFIRMED')
);
-- DDL 已隐式提交;以下事务只覆盖历史标记及权限菜单插入。
START TRANSACTION;
UPDATE biz_shipment_order SET receipt_state = 'LEGACY' WHERE status = 'CONFIRMED';
INSERT INTO sys_menu (
menu_id, menu_name, parent_id, order_num, path, component, query, route_name,
is_frame, is_cache, menu_type, visible, status, perms, icon, create_by,
create_time, update_by, update_time, remark
) VALUES (
1119, '收货核对', 510, 6, '', '', '', '',
1, 0, 'F', '0', '0', 'business:shipment:receive', '#', 'admin',
SYSDATE(), '', NULL, ''
);
COMMIT;
END$$
DELIMITER ;
CALL migrate_shipment_receipt_20261008();
DROP PROCEDURE migrate_shipment_receipt_20261008;
-- 核验:历史确认单应为 LEGACY,新草稿为 PENDING,收货时间/人员和原库存保持未登记。
SELECT status, receipt_state, COUNT(*) AS order_count
FROM biz_shipment_order GROUP BY status, receipt_state;
SELECT menu_id, parent_id, perms FROM sys_menu WHERE menu_id = 1119;
@@ -0,0 +1,35 @@
-- 允许跳过先到托盘,但需填写原因并保存当时的推荐快照。
-- 在20261008日期、收货补丁之后执行;先停写、备份、恢复演练。
-- 单次增量;不修改历史事实,不为旧记录补造原因或真实收货时间。
-- DDL隐式提交,中途失败不得盲目重跑。
DELIMITER $$
CREATE PROCEDURE migrate_usage_fifo_20261008()
BEGIN
IF DATABASE() IS NULL THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Select target database first';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE() AND engine = 'InnoDB'
AND table_name IN ('biz_usage_record', 'biz_pallet_event')) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Required InnoDB usage and event tables are missing';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'biz_pallet_inventory'
AND column_name = 'site_arrived_at') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Apply shipment receipt migration first';
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name IN ('biz_usage_record', 'biz_pallet_event')
AND column_name IN ('fifo_skip_reason', 'fifo_recommendation')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'FIFO columns already exist; do not rerun';
END IF;
ALTER TABLE biz_usage_record
ADD COLUMN fifo_skip_reason VARCHAR(500) NULL COMMENT '跳过先到托盘原因',
ADD COLUMN fifo_recommendation JSON NULL COMMENT '跳过时先到托盘身份及到货时间快照';
ALTER TABLE biz_pallet_event
ADD COLUMN fifo_skip_reason VARCHAR(500) NULL COMMENT '跳过先到托盘原因',
ADD COLUMN fifo_recommendation JSON NULL COMMENT '跳过时先到托盘身份及到货时间快照';
END$$
DELIMITER ;
CALL migrate_usage_fifo_20261008();
DROP PROCEDURE migrate_usage_fifo_20261008;
@@ -303,6 +303,7 @@ insert into sys_menu values('504', '客户现场', '121', '3', 'site', 'master/s
insert into sys_menu values('505', '产线资料', '121', '4', 'line', 'master/line/index', '', '', 1, 0, 'C', '0', '0', 'master:line:list', '#', 'admin', sysdate(), '', null, '产线资料菜单'); insert into sys_menu values('505', '产线资料', '121', '4', 'line', 'master/line/index', '', '', 1, 0, 'C', '0', '0', 'master:line:list', '#', 'admin', sysdate(), '', null, '产线资料菜单');
insert into sys_menu values('506', '仓库资料', '121', '5', 'warehouse', 'master/warehouse/index', '', '', 1, 0, 'C', '0', '0', 'master:warehouse:list', '#', 'admin', sysdate(), '', null, '仓库资料菜单'); insert into sys_menu values('506', '仓库资料', '121', '5', 'warehouse', 'master/warehouse/index', '', '', 1, 0, 'C', '0', '0', 'master:warehouse:list', '#', 'admin', sysdate(), '', null, '仓库资料菜单');
insert into sys_menu values('507', '托盘资料', '121', '6', 'pallet', 'master/pallet/index', '', '', 1, 0, 'C', '0', '0', 'master:pallet:list', '#', 'admin', sysdate(), '', null, '托盘资料菜单'); insert into sys_menu values('507', '托盘资料', '121', '6', 'pallet', 'master/pallet/index', '', '', 1, 0, 'C', '0', '0', 'master:pallet:list', '#', 'admin', sysdate(), '', null, '托盘资料菜单');
insert into sys_menu values('520', '隧道资料', '121', '7', 'tunnel', 'master/tunnel/index', '', '', 1, 0, 'C', '0', '0', 'master:tunnel:list', '#', 'admin', sysdate(), '', null, '隧道资料菜单');
insert into sys_menu values('508', '仓库入库', '122', '1', 'inbound', 'business/inbound/index', '', '', 1, 0, 'C', '0', '0', 'business:inbound:list', '#', 'admin', sysdate(), '', null, '仓库入库菜单'); insert into sys_menu values('508', '仓库入库', '122', '1', 'inbound', 'business/inbound/index', '', '', 1, 0, 'C', '0', '0', 'business:inbound:list', '#', 'admin', sysdate(), '', null, '仓库入库菜单');
insert into sys_menu values('509', '工厂装填', '122', '2', 'filling', 'business/filling/index', '', '', 1, 0, 'C', '0', '0', 'business:filling:list', '#', 'admin', sysdate(), '', null, '工厂装填菜单'); insert into sys_menu values('509', '工厂装填', '122', '2', 'filling', 'business/filling/index', '', '', 1, 0, 'C', '0', '0', 'business:filling:list', '#', 'admin', sysdate(), '', null, '工厂装填菜单');
insert into sys_menu values('510', '发货出库', '122', '3', 'shipment', 'business/shipment/index', '', '', 1, 0, 'C', '0', '0', 'business:shipment:list', '#', 'admin', sysdate(), '', null, '发货出库菜单'); insert into sys_menu values('510', '发货出库', '122', '3', 'shipment', 'business/shipment/index', '', '', 1, 0, 'C', '0', '0', 'business:shipment:list', '#', 'admin', sysdate(), '', null, '发货出库菜单');
@@ -338,6 +339,9 @@ insert into sys_menu values('1087', '托盘查询', '507', '1', '', '', '', '',
insert into sys_menu values('1088', '托盘新增', '507', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:add', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1088', '托盘新增', '507', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1089', '托盘修改', '507', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:edit', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1089', '托盘修改', '507', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1091', '托盘打印', '507', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:print', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1091', '托盘打印', '507', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'master:pallet:print', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1116', '隧道查询', '520', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1117', '隧道新增', '520', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1118', '隧道修改', '520', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'master:tunnel:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1092', '入库查询', '508', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:query', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1092', '入库查询', '508', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1093', '入库新增', '508', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:add', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1093', '入库新增', '508', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1094', '入库修改', '508', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:edit', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1094', '入库修改', '508', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:inbound:edit', '#', 'admin', sysdate(), '', null, '');
@@ -348,6 +352,7 @@ insert into sys_menu values('1099', '发货查询', '510', '1', '', '', '', '',
insert into sys_menu values('1100', '发货新增', '510', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:add', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1100', '发货新增', '510', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1101', '发货修改', '510', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:edit', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1101', '发货修改', '510', '3', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1103', '发货确认', '510', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:confirm', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1103', '发货确认', '510', '5', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:confirm', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1119', '收货核对', '510', '6', '', '', '', '', 1, 0, 'F', '0', '0', 'business:shipment:receive', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1104', '使用查询', '511', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:query', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1104', '使用查询', '511', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1105', '使用新增', '511', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:add', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1105', '使用新增', '511', '2', '', '', '', '', 1, 0, 'F', '0', '0', 'business:usage:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('1106', '退回查询', '512', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:query', '#', 'admin', sysdate(), '', null, ''); insert into sys_menu values('1106', '退回查询', '512', '1', '', '', '', '', 1, 0, 'F', '0', '0', 'business:return:query', '#', 'admin', sysdate(), '', null, '');
@@ -960,6 +965,29 @@ create table biz_production_line (
constraint ck_biz_production_line_status check (status in ('0', '1')) constraint ck_biz_production_line_status check (status in ('0', '1'))
) engine=innodb comment = '客户现场产线主数据表'; ) engine=innodb comment = '客户现场产线主数据表';
-- 客户现场隧道主数据表
create table biz_tunnel (
tunnel_id bigint(20) not null auto_increment comment '隧道ID',
test_batch_id bigint(20) null comment '测试数据批次ID;NULL表示正式数据',
site_id bigint(20) not null comment '现场ID',
tunnel_code varchar(64) not null comment '隧道编号',
tunnel_name varchar(100) not null comment '隧道名称',
current_trace_no varchar(64) comment '当前隧道物料溯源号',
current_trace_started_at datetime comment '当前溯源批次开始时间',
status char(1) not null default '0' comment '状态(0正常 1停用)',
create_by varchar(64) default '' comment '创建者',
create_time datetime comment '创建时间',
update_by varchar(64) default '' comment '更新者',
update_time datetime comment '更新时间',
remark varchar(500) default null comment '备注',
primary key (tunnel_id),
key idx_biz_tunnel_test_batch_id (test_batch_id),
constraint uq_biz_tunnel_site_code unique (site_id, tunnel_code),
constraint fk_biz_tunnel_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
constraint fk_biz_tunnel_site foreign key (site_id) references biz_customer_site (site_id),
constraint ck_biz_tunnel_status check (status in ('0', '1'))
) engine=innodb comment = '客户现场隧道主数据表';
-- 托盘资产主数据表 -- 托盘资产主数据表
create table biz_pallet ( create table biz_pallet (
pallet_id bigint(20) not null auto_increment comment '托盘ID', pallet_id bigint(20) not null auto_increment comment '托盘ID',
@@ -1057,6 +1085,8 @@ create table biz_filling_record (
strain_code_snapshot varchar(64) not null comment '菌种编码快照', strain_code_snapshot varchar(64) not null comment '菌种编码快照',
strain_name_snapshot varchar(100) not null comment '菌种名称快照', strain_name_snapshot varchar(100) not null comment '菌种名称快照',
batch_no varchar(64) not null comment '生产批次', batch_no varchar(64) not null comment '生产批次',
production_date date comment '来源生产日期;历史未登记为空',
expiry_date date comment '有效期至;历史未登记为空',
package_spec varchar(64) not null comment '包装规格', package_spec varchar(64) not null comment '包装规格',
initial_packages int not null comment '初始包数', initial_packages int not null comment '初始包数',
active_slot varchar(16) comment '活动装填槽', active_slot varchar(16) comment '活动装填槽',
@@ -1081,6 +1111,10 @@ create table biz_filling_record (
constraint fk_biz_filling_record_operator foreign key (operator_id) references sys_user (user_id), constraint fk_biz_filling_record_operator foreign key (operator_id) references sys_user (user_id),
constraint fk_biz_filling_record_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id), constraint fk_biz_filling_record_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
constraint ck_biz_filling_record_initial_packages check (initial_packages > 0), constraint ck_biz_filling_record_initial_packages check (initial_packages > 0),
constraint ck_biz_filling_record_material_dates check (
(production_date is null and expiry_date is null) or
(production_date is not null and expiry_date is not null and expiry_date >= production_date)
),
constraint ck_biz_filling_record_active_slot check (active_slot is null or active_slot = 'ACTIVE') constraint ck_biz_filling_record_active_slot check (active_slot is null or active_slot = 'ACTIVE')
) engine=innodb comment = '托盘装填记录'; ) engine=innodb comment = '托盘装填记录';
@@ -1096,6 +1130,11 @@ create table biz_shipment_order (
confirm_request_hash varchar(64) comment '确认请求摘要', confirm_request_hash varchar(64) comment '确认请求摘要',
confirmed_by bigint(20) comment '确认人ID', confirmed_by bigint(20) comment '确认人ID',
confirmed_at datetime comment '确认时间', confirmed_at datetime comment '确认时间',
receipt_state varchar(16) not null default 'PENDING' comment '收货状态;LEGACY表示历史未登记',
received_by bigint(20) null comment '收货核对人ID',
received_at datetime null comment '实际收货核对时间',
receipt_idempotency_key varchar(64) null comment '收货确认幂等键',
receipt_request_hash varchar(64) null comment '收货核对请求摘要',
create_by varchar(64) default '' comment '创建者', create_by varchar(64) default '' comment '创建者',
create_time datetime comment '创建时间', create_time datetime comment '创建时间',
update_by varchar(64) default '' comment '更新者', update_by varchar(64) default '' comment '更新者',
@@ -1105,11 +1144,14 @@ create table biz_shipment_order (
key idx_biz_shipment_order_test_batch_id (test_batch_id), key idx_biz_shipment_order_test_batch_id (test_batch_id),
constraint uq_biz_shipment_order_no unique (shipment_no), constraint uq_biz_shipment_order_no unique (shipment_no),
constraint uq_biz_shipment_order_route_confirm_idempotency unique (source_warehouse_id, target_site_id, confirm_idempotency_key), constraint uq_biz_shipment_order_route_confirm_idempotency unique (source_warehouse_id, target_site_id, confirm_idempotency_key),
constraint uq_biz_shipment_receipt_site_key unique (target_site_id, receipt_idempotency_key),
constraint fk_biz_shipment_order_warehouse foreign key (source_warehouse_id) references biz_warehouse (warehouse_id), constraint fk_biz_shipment_order_warehouse foreign key (source_warehouse_id) references biz_warehouse (warehouse_id),
constraint fk_biz_shipment_order_site foreign key (target_site_id) references biz_customer_site (site_id), constraint fk_biz_shipment_order_site foreign key (target_site_id) references biz_customer_site (site_id),
constraint fk_biz_shipment_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id), constraint fk_biz_shipment_order_confirmed_by foreign key (confirmed_by) references sys_user (user_id),
constraint fk_biz_shipment_order_received_by foreign key (received_by) references sys_user (user_id),
constraint fk_biz_shipment_order_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id), constraint fk_biz_shipment_order_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
constraint ck_biz_shipment_order_status check (status in ('DRAFT', 'CONFIRMED')) constraint ck_biz_shipment_order_status check (status in ('DRAFT', 'CONFIRMED')),
constraint ck_biz_shipment_order_receipt_state check (receipt_state in ('PENDING', 'RECEIVED', 'LEGACY'))
) engine=innodb comment = '整托发货单'; ) engine=innodb comment = '整托发货单';
-- 整托发货明细 -- 整托发货明细
@@ -1140,8 +1182,13 @@ create table biz_usage_record (
pallet_id bigint(20) not null comment '托盘ID', pallet_id bigint(20) not null comment '托盘ID',
active_filling_id bigint(20) not null comment '活动装填记录ID', active_filling_id bigint(20) not null comment '活动装填记录ID',
site_id bigint(20) not null comment '现场ID', site_id bigint(20) not null comment '现场ID',
line_id bigint(20) not null comment '产线ID', tunnel_id bigint(20) comment '隧道ID;历史记录可能为空',
trace_no varchar(64) comment '隧道物料内部溯源号;历史记录可能为空',
batch_no_snapshot varchar(64) comment '生产批次快照;历史记录可能为空',
line_id bigint(20) comment '产线ID(可选)',
used_packages int not null comment '本次使用包数', used_packages int not null comment '本次使用包数',
fifo_skip_reason varchar(500) comment '跳过先到托盘原因',
fifo_recommendation json comment '跳过时先到托盘身份及到货时间快照',
remaining_packages_after int not null comment '使用后剩余包数', remaining_packages_after int not null comment '使用后剩余包数',
idempotency_key varchar(64) not null comment '幂等键', idempotency_key varchar(64) not null comment '幂等键',
request_hash varchar(64) not null comment '请求摘要', request_hash varchar(64) not null comment '请求摘要',
@@ -1154,6 +1201,7 @@ create table biz_usage_record (
constraint fk_biz_usage_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id), constraint fk_biz_usage_record_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
constraint fk_biz_usage_record_filling foreign key (active_filling_id) references biz_filling_record (filling_id), constraint fk_biz_usage_record_filling foreign key (active_filling_id) references biz_filling_record (filling_id),
constraint fk_biz_usage_record_site foreign key (site_id) references biz_customer_site (site_id), constraint fk_biz_usage_record_site foreign key (site_id) references biz_customer_site (site_id),
constraint fk_biz_usage_record_tunnel foreign key (tunnel_id) references biz_tunnel (tunnel_id),
constraint fk_biz_usage_record_line foreign key (line_id) references biz_production_line (line_id), constraint fk_biz_usage_record_line foreign key (line_id) references biz_production_line (line_id),
constraint fk_biz_usage_record_operator foreign key (operator_id) references sys_user (user_id), constraint fk_biz_usage_record_operator foreign key (operator_id) references sys_user (user_id),
constraint fk_biz_usage_record_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id), constraint fk_biz_usage_record_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
@@ -1202,6 +1250,9 @@ create table biz_return_item (
return_id bigint(20) not null comment '退回单ID', return_id bigint(20) not null comment '退回单ID',
pallet_id bigint(20) not null comment '托盘ID', pallet_id bigint(20) not null comment '托盘ID',
active_filling_id_snapshot bigint(20) comment '申请时活动装填记录快照', active_filling_id_snapshot bigint(20) comment '申请时活动装填记录快照',
tunnel_id_snapshot bigint(20) comment '申请时隧道ID快照',
trace_no_snapshot varchar(64) comment '申请时物料溯源号快照',
batch_no_snapshot varchar(64) comment '申请时生产批次快照',
remaining_packages_snapshot int not null comment '申请时剩余包数快照', remaining_packages_snapshot int not null comment '申请时剩余包数快照',
create_by varchar(64) default '' comment '创建者', create_by varchar(64) default '' comment '创建者',
create_time datetime comment '创建时间', create_time datetime comment '创建时间',
@@ -1211,6 +1262,7 @@ create table biz_return_item (
constraint fk_biz_return_item_order foreign key (return_id) references biz_return_order (return_id), constraint fk_biz_return_item_order foreign key (return_id) references biz_return_order (return_id),
constraint fk_biz_return_item_pallet foreign key (pallet_id) references biz_pallet (pallet_id), constraint fk_biz_return_item_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
constraint fk_biz_return_item_filling foreign key (active_filling_id_snapshot) references biz_filling_record (filling_id), constraint fk_biz_return_item_filling foreign key (active_filling_id_snapshot) references biz_filling_record (filling_id),
constraint fk_biz_return_item_tunnel foreign key (tunnel_id_snapshot) references biz_tunnel (tunnel_id),
constraint fk_biz_return_item_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id), constraint fk_biz_return_item_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
constraint ck_biz_return_item_remaining_packages_snapshot check (remaining_packages_snapshot >= 0) constraint ck_biz_return_item_remaining_packages_snapshot check (remaining_packages_snapshot >= 0)
) engine=innodb comment = '托盘退回明细'; ) engine=innodb comment = '托盘退回明细';
@@ -1228,12 +1280,18 @@ create table biz_pallet_event (
after_state varchar(32) not null comment '变更后状态', after_state varchar(32) not null comment '变更后状态',
before_warehouse_id bigint(20) comment '变更前仓库ID', before_warehouse_id bigint(20) comment '变更前仓库ID',
before_site_id bigint(20) comment '变更前现场ID', before_site_id bigint(20) comment '变更前现场ID',
before_tunnel_id bigint(20) comment '变更前隧道ID',
before_line_id bigint(20) comment '变更前产线ID', before_line_id bigint(20) comment '变更前产线ID',
after_warehouse_id bigint(20) comment '变更后仓库ID', after_warehouse_id bigint(20) comment '变更后仓库ID',
after_site_id bigint(20) comment '变更后现场ID', after_site_id bigint(20) comment '变更后现场ID',
after_tunnel_id bigint(20) comment '变更后隧道ID',
after_line_id bigint(20) comment '变更后产线ID', after_line_id bigint(20) comment '变更后产线ID',
before_remaining_packages int not null comment '变更前剩余包数', before_remaining_packages int not null comment '变更前剩余包数',
after_remaining_packages int not null comment '变更后剩余包数', after_remaining_packages int not null comment '变更后剩余包数',
trace_no varchar(64) comment '事件关联物料溯源号',
batch_no_snapshot varchar(64) comment '事件生产批次快照',
fifo_skip_reason varchar(500) comment '跳过先到托盘原因',
fifo_recommendation json comment '跳过时先到托盘身份及到货时间快照',
operator_id bigint(20) not null comment '操作人ID', operator_id bigint(20) not null comment '操作人ID',
occurred_at datetime not null comment '发生时间', occurred_at datetime not null comment '发生时间',
idempotency_key varchar(64) not null comment '幂等键', idempotency_key varchar(64) not null comment '幂等键',
@@ -1245,9 +1303,11 @@ create table biz_pallet_event (
constraint fk_biz_pallet_event_pallet foreign key (pallet_id) references biz_pallet (pallet_id), constraint fk_biz_pallet_event_pallet foreign key (pallet_id) references biz_pallet (pallet_id),
constraint fk_biz_pallet_event_before_warehouse foreign key (before_warehouse_id) references biz_warehouse (warehouse_id), constraint fk_biz_pallet_event_before_warehouse foreign key (before_warehouse_id) references biz_warehouse (warehouse_id),
constraint fk_biz_pallet_event_before_site foreign key (before_site_id) references biz_customer_site (site_id), constraint fk_biz_pallet_event_before_site foreign key (before_site_id) references biz_customer_site (site_id),
constraint fk_biz_pallet_event_before_tunnel foreign key (before_tunnel_id) references biz_tunnel (tunnel_id),
constraint fk_biz_pallet_event_before_line foreign key (before_line_id) references biz_production_line (line_id), constraint fk_biz_pallet_event_before_line foreign key (before_line_id) references biz_production_line (line_id),
constraint fk_biz_pallet_event_after_warehouse foreign key (after_warehouse_id) references biz_warehouse (warehouse_id), constraint fk_biz_pallet_event_after_warehouse foreign key (after_warehouse_id) references biz_warehouse (warehouse_id),
constraint fk_biz_pallet_event_after_site foreign key (after_site_id) references biz_customer_site (site_id), constraint fk_biz_pallet_event_after_site foreign key (after_site_id) references biz_customer_site (site_id),
constraint fk_biz_pallet_event_after_tunnel foreign key (after_tunnel_id) references biz_tunnel (tunnel_id),
constraint fk_biz_pallet_event_after_line foreign key (after_line_id) references biz_production_line (line_id), constraint fk_biz_pallet_event_after_line foreign key (after_line_id) references biz_production_line (line_id),
constraint fk_biz_pallet_event_operator foreign key (operator_id) references sys_user (user_id), constraint fk_biz_pallet_event_operator foreign key (operator_id) references sys_user (user_id),
constraint fk_biz_pallet_event_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id), constraint fk_biz_pallet_event_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
@@ -1256,6 +1316,8 @@ create table biz_pallet_event (
'INBOUND_CONFIRMED', 'INBOUND_CONFIRMED',
'FILLING_CONFIRMED', 'FILLING_CONFIRMED',
'SHIPMENT_CONFIRMED', 'SHIPMENT_CONFIRMED',
'SHIPMENT_RECEIVED',
'TUNNEL_ENTERED',
'USAGE_RECORDED', 'USAGE_RECORDED',
'RETURN_REQUESTED', 'RETURN_REQUESTED',
'RETURN_CONFIRMED' 'RETURN_CONFIRMED'
@@ -1270,9 +1332,12 @@ create table biz_pallet_inventory (
active_filling_id bigint(20) comment '当前活动装填记录ID', active_filling_id bigint(20) comment '当前活动装填记录ID',
warehouse_id bigint(20) comment '当前仓库ID', warehouse_id bigint(20) comment '当前仓库ID',
site_id bigint(20) comment '当前现场ID', site_id bigint(20) comment '当前现场ID',
tunnel_id bigint(20) comment '当前隧道ID',
trace_no varchar(64) comment '当前隧道物料溯源号',
current_line_id bigint(20) comment '当前驻场周期绑定产线ID', current_line_id bigint(20) comment '当前驻场周期绑定产线ID',
flow_state varchar(32) not null default 'UNSTOCKED' comment '托盘流转状态', flow_state varchar(32) not null default 'UNSTOCKED' comment '托盘流转状态',
remaining_packages int not null default 0 comment '当前剩余包数', remaining_packages int not null default 0 comment '当前剩余包数',
site_arrived_at datetime null comment '本轮实际收货核对时间;历史未登记为空',
last_event_id bigint(20) comment '最后事件ID', last_event_id bigint(20) comment '最后事件ID',
version int not null default 0 comment '并发版本号', version int not null default 0 comment '并发版本号',
updated_at datetime not null comment '更新时间', updated_at datetime not null comment '更新时间',
@@ -1282,6 +1347,7 @@ create table biz_pallet_inventory (
constraint fk_biz_pallet_inventory_filling foreign key (active_filling_id) references biz_filling_record (filling_id), constraint fk_biz_pallet_inventory_filling foreign key (active_filling_id) references biz_filling_record (filling_id),
constraint fk_biz_pallet_inventory_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id), constraint fk_biz_pallet_inventory_warehouse foreign key (warehouse_id) references biz_warehouse (warehouse_id),
constraint fk_biz_pallet_inventory_site foreign key (site_id) references biz_customer_site (site_id), constraint fk_biz_pallet_inventory_site foreign key (site_id) references biz_customer_site (site_id),
constraint fk_biz_pallet_inventory_tunnel foreign key (tunnel_id) references biz_tunnel (tunnel_id),
constraint fk_biz_pallet_inventory_line foreign key (current_line_id) references biz_production_line (line_id), constraint fk_biz_pallet_inventory_line foreign key (current_line_id) references biz_production_line (line_id),
constraint fk_biz_pallet_inventory_event foreign key (last_event_id) references biz_pallet_event (event_id), constraint fk_biz_pallet_inventory_event foreign key (last_event_id) references biz_pallet_event (event_id),
constraint fk_biz_pallet_inventory_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id), constraint fk_biz_pallet_inventory_test_batch_id foreign key (test_batch_id) references biz_test_batch (batch_id),
@@ -1290,6 +1356,7 @@ create table biz_pallet_inventory (
'UNSTOCKED', 'UNSTOCKED',
'EMPTY_IN_WAREHOUSE', 'EMPTY_IN_WAREHOUSE',
'FILLED_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE',
'IN_TRANSIT',
'AT_SITE_UNUSED', 'AT_SITE_UNUSED',
'AT_SITE_PARTIAL', 'AT_SITE_PARTIAL',
'AT_SITE_EMPTY', 'AT_SITE_EMPTY',
@@ -21,10 +21,14 @@ CONTROLLER_NAMES = (
'inbound', 'inbound',
'filling', 'filling',
'shipment', 'shipment',
'shipment_receipt',
'usage', 'usage',
'usage_fifo',
'return', 'return',
'report', 'report',
'test_batch', 'test_batch',
'dashboard',
'tunnel',
) )
TARGET_PREFIXES = ( TARGET_PREFIXES = (
'/master', '/master',
@@ -43,6 +47,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
'master:line:list', 'master:line:list',
'master:warehouse:list', 'master:warehouse:list',
'master:pallet:list', 'master:pallet:list',
'master:tunnel:list',
'business:inbound:list', 'business:inbound:list',
'business:filling:list', 'business:filling:list',
'business:shipment:list', 'business:shipment:list',
@@ -80,6 +85,10 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'master:pallet:add', 'master:pallet:add',
'master:pallet:edit', 'master:pallet:edit',
'master:pallet:print', 'master:pallet:print',
'master:tunnel:query',
'master:tunnel:add',
'master:tunnel:edit',
'master:tunnel:remove',
'business:inbound:query', 'business:inbound:query',
'business:inbound:add', 'business:inbound:add',
'business:inbound:edit', 'business:inbound:edit',
@@ -90,6 +99,7 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'business:shipment:add', 'business:shipment:add',
'business:shipment:edit', 'business:shipment:edit',
'business:shipment:confirm', 'business:shipment:confirm',
'business:shipment:receive',
'business:usage:query', 'business:usage:query',
'business:usage:add', 'business:usage:add',
'business:return:query', 'business:return:query',
@@ -108,7 +118,10 @@ EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
EXPECTED_WEB_PERMISSIONS = frozenset( EXPECTED_WEB_PERMISSIONS = frozenset(
permission permission
for permission in EXPECTED_PERMISSIONS for permission in EXPECTED_PERMISSIONS
if not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt' if (
(not permission.startswith('system:testbatch:') or permission == 'system:testbatch:adopt')
and permission != 'master:tunnel:remove'
)
) )
VUE_PAGE_PATHS = ( VUE_PAGE_PATHS = (
'src/views/master/strain/index.vue', 'src/views/master/strain/index.vue',
@@ -117,6 +130,7 @@ VUE_PAGE_PATHS = (
'src/views/master/line/index.vue', 'src/views/master/line/index.vue',
'src/views/master/warehouse/index.vue', 'src/views/master/warehouse/index.vue',
'src/views/master/pallet/index.vue', 'src/views/master/pallet/index.vue',
'src/views/master/tunnel/index.vue',
'src/views/business/inbound/index.vue', 'src/views/business/inbound/index.vue',
'src/views/business/filling/index.vue', 'src/views/business/filling/index.vue',
'src/views/business/shipment/index.vue', 'src/views/business/shipment/index.vue',
@@ -130,7 +144,10 @@ VUE_PAGE_PATHS = (
'src/views/report/palletTrace/index.vue', 'src/views/report/palletTrace/index.vue',
) )
PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report|system):[a-z]+:[a-z]+') PERMISSION_LITERAL_PATTERN = re.compile(r'(?:master|business|report|system):[a-z]+:[a-z]+')
UNGUARDED_AUTHENTICATED_ROUTES = {('/system/test-batch/options', 'GET')} UNGUARDED_AUTHENTICATED_ROUTES = {
('/system/test-batch/options', 'GET'),
('/business/dashboard/scan-exceptions', 'GET'),
}
REMOVED_DELETE_SYMBOLS = ( REMOVED_DELETE_SYMBOLS = (
'delStrain', 'delStrain',
'delCustomer', 'delCustomer',
@@ -170,6 +187,16 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
} }
) )
expected.update(
{
('/master/tunnel/list', 'GET'): 'master:tunnel:list',
('/master/tunnel/{tunnel_id}', 'GET'): 'master:tunnel:query',
('/master/tunnel', 'POST'): 'master:tunnel:add',
('/master/tunnel', 'PUT'): 'master:tunnel:edit',
('/master/tunnel/{tunnel_ids}', 'DELETE'): 'master:tunnel:remove',
}
)
expected.update( expected.update(
{ {
('/master/site/{site_id}/users', 'GET'): 'master:site:assign', ('/master/site/{site_id}/users', 'GET'): 'master:site:assign',
@@ -195,12 +222,20 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/business/shipment/site-options', 'GET'): 'business:shipment:query', ('/business/shipment/site-options', 'GET'): 'business:shipment:query',
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query', ('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query', ('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}/labels', 'GET'): 'business:shipment:query',
('/business/shipment-receipt/list', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}/scan/{identifier}', 'GET'): 'business:shipment:receive',
('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive',
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
('/business/shipment', 'POST'): 'business:shipment:add', ('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit', ('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list', ('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
('/business/shipment/{shipment_id}/confirm', 'PUT'): 'business:shipment:confirm', ('/business/shipment/{shipment_id}/confirm', 'PUT'): 'business:shipment:confirm',
('/business/usage/list', 'GET'): 'business:usage:list', ('/business/usage/list', 'GET'): 'business:usage:list',
('/business/usage/line-options', 'GET'): 'business:usage:query', ('/business/usage/line-options', 'GET'): 'business:usage:query',
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query', ('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
('/business/usage/{usage_id}', 'GET'): 'business:usage:query', ('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
('/business/usage', 'POST'): 'business:usage:add', ('/business/usage', 'POST'): 'business:usage:add',
@@ -249,6 +284,7 @@ STATIC_READS_BEFORE_DETAIL = {
), ),
'/business/usage/{usage_id}': ( '/business/usage/{usage_id}': (
'/business/usage/line-options', '/business/usage/line-options',
'/business/usage/tunnel-options',
'/business/usage/scan/{qr_token}', '/business/usage/scan/{qr_token}',
), ),
'/business/return/{return_id}': ( '/business/return/{return_id}': (
@@ -264,6 +300,7 @@ EXACT_QUERY_PARAMETERS = {
('/business/inbound/scan/{qr_token}', 'GET'): {'warehouseId'}, ('/business/inbound/scan/{qr_token}', 'GET'): {'warehouseId'},
('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'}, ('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'},
('/business/usage/line-options', 'GET'): {'siteId'}, ('/business/usage/line-options', 'GET'): {'siteId'},
('/business/usage/tunnel-options', 'GET'): {'siteId'},
('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'}, ('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'},
} }
REQUIRED_QUERY_PARAMETERS = { REQUIRED_QUERY_PARAMETERS = {
@@ -286,7 +323,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]:
def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None: def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values()) expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values())
test_case.assertEqual(85, expected.total()) test_case.assertEqual(98, expected.total())
test_case.assertEqual(expected, Counter(literals)) test_case.assertEqual(expected, Counter(literals))
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected)) test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected))
@@ -409,7 +446,7 @@ class ApiPermissionContractTest(unittest.TestCase):
cls.app = create_app() cls.app = create_app()
cls.schema = cls.app.openapi() cls.schema = cls.app.openapi()
cls.target_routes = [ cls.target_routes = [
route for route in cls.app.routes if isinstance(route, APIRoute) and _is_target_path(route.path) route for route in cls.app.routes if isinstance(route, APIRoute) and _is_target_path(route.path_format)
] ]
def test_target_path_selector_uses_namespace_boundaries(self) -> None: def test_target_path_selector_uses_namespace_boundaries(self) -> None:
@@ -438,11 +475,11 @@ class ApiPermissionContractTest(unittest.TestCase):
} }
self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations) self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations)
self.assertEqual(86, len(actual_operations)) self.assertEqual(100, len(actual_operations))
openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/')) openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/'))
app_ai_paths = sorted( app_ai_paths = sorted(
route.path route.path_format
for route in self.app.routes for route in self.app.routes
if getattr(route, 'path', None) == '/ai' or getattr(route, 'path', '').startswith('/ai/') if getattr(route, 'path', None) == '/ai' or getattr(route, 'path', '').startswith('/ai/')
) )
@@ -454,8 +491,8 @@ class ApiPermissionContractTest(unittest.TestCase):
operation_keys: list[tuple[str, str]] = [] operation_keys: list[tuple[str, str]] = []
for route in self.target_routes: for route in self.target_routes:
methods = route.methods & ROUTE_HTTP_METHODS methods = route.methods & ROUTE_HTTP_METHODS
self.assertEqual(1, len(methods), route.path) self.assertEqual(1, len(methods), route.path_format)
key = (route.path, next(iter(methods))) key = (route.path_format, next(iter(methods)))
operation_keys.append(key) operation_keys.append(key)
auth_dependencies = _auth_dependencies(route) auth_dependencies = _auth_dependencies(route)
@@ -491,19 +528,33 @@ class ApiPermissionContractTest(unittest.TestCase):
literals.append(node.args[0].value) literals.append(node.args[0].value)
self.assertEqual([], malformed_calls) self.assertEqual([], malformed_calls)
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS)) self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS)) self.assertEqual(48, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS) self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
self.assertEqual(61, len(EXPECTED_PERMISSIONS)) self.assertEqual(67, len(EXPECTED_PERMISSIONS))
_assert_controller_permission_literals(self, literals) _assert_controller_permission_literals(self, literals)
self.assertEqual( self.assertEqual(
{'add', 'adopt', 'assign', 'close', 'confirm', 'edit', 'list', 'print', 'purge', 'query', 'request'}, {
'add',
'adopt',
'assign',
'close',
'confirm',
'edit',
'list',
'print',
'purge',
'query',
'remove',
'request',
'receive',
},
{permission.rsplit(':', maxsplit=1)[1] for permission in literals}, {permission.rsplit(':', maxsplit=1)[1] for permission in literals},
) )
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None: def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
literals = list(EXPECTED_ROUTE_PERMISSIONS.values()) literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
self.assertEqual(85, len(literals)) self.assertEqual(98, len(literals))
literals.append(literals[0]) literals.append(literals[0])
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
@@ -521,8 +572,8 @@ class ApiPermissionContractTest(unittest.TestCase):
def test_vue_pages_have_exact_guarded_permission_contract(self) -> None: def test_vue_pages_have_exact_guarded_permission_contract(self) -> None:
actual_permissions = set() actual_permissions = set()
self.assertEqual(17, len(VUE_PAGE_PATHS)) self.assertEqual(18, len(VUE_PAGE_PATHS))
self.assertEqual(17, len(set(VUE_PAGE_PATHS))) self.assertEqual(18, len(set(VUE_PAGE_PATHS)))
for relative_path in VUE_PAGE_PATHS: for relative_path in VUE_PAGE_PATHS:
source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8') source = (FRONTEND_ROOT / relative_path).read_text(encoding='utf-8')
guarded = _extract_guarded_vue_permissions(source) guarded = _extract_guarded_vue_permissions(source)
@@ -663,7 +714,7 @@ class ApiPermissionContractTest(unittest.TestCase):
def test_static_scan_and_option_routes_precede_dynamic_details(self) -> None: def test_static_scan_and_option_routes_precede_dynamic_details(self) -> None:
route_order = { route_order = {
route.path: index route.path_format: index
for index, route in enumerate(self.app.routes) for index, route in enumerate(self.app.routes)
if isinstance(route, APIRoute) and 'GET' in route.methods if isinstance(route, APIRoute) and 'GET' in route.methods
} }
@@ -705,9 +756,9 @@ class ApiPermissionContractTest(unittest.TestCase):
actual_write_routes: dict[tuple[str, str], APIRoute] = {} actual_write_routes: dict[tuple[str, str], APIRoute] = {}
for route in self.target_routes: for route in self.target_routes:
for method in route.methods & WRITE_METHODS: for method in route.methods & WRITE_METHODS:
actual_write_routes[(route.path, method)] = route actual_write_routes[(route.path_format, method)] = route
self.assertEqual(41, len(expected_write_permissions)) self.assertEqual(46, len(expected_write_permissions))
self.assertEqual(set(expected_write_permissions), set(actual_write_routes)) self.assertEqual(set(expected_write_permissions), set(actual_write_routes))
for key, expected_permission in expected_write_permissions.items(): for key, expected_permission in expected_write_permissions.items():
with self.subTest(operation=key): with self.subTest(operation=key):
@@ -0,0 +1,155 @@
"""FIFO 跳过原因必须在真正写入处验证,并随业务事实留痕。"""
import unittest
from datetime import datetime
from unittest.mock import AsyncMock, patch
from pydantic import ValidationError
from sqlalchemy.exc import OperationalError
from exceptions.exception import ServiceException
from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import TunnelEntryCreateModel, UsageCreateModel, UsageModel
from module_business.service.usage_service import UsageService
from tests.module_business import test_usage_service as fixtures
class TestFifoSkipPolicy(unittest.IsolatedAsyncioTestCase):
def setUp(self):
fixtures.TestRecordUsageCore.setUp(self)
self.inventory.site_arrived_at = datetime(2026, 10, 8, 10)
self.candidate = {
'pallet_id': 50, 'pallet_code': 'P-050', 'batch_no': 'B-EARLY',
'remaining_packages': 80, 'site_arrived_at': datetime(2026, 10, 7, 10),
}
_patch_record_path = fixtures.TestRecordUsageCore._patch_record_path
def command(self, reason=None):
values = self.page.model_dump(by_alias=True)
if reason is not None:
values['fifoSkipReason'] = reason
return UsageCreateModel(**values)
async def test_earlier_available_pallet_requires_reason_before_any_write(self):
stack, calls = self._patch_record_path()
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
with self.assertRaises(ServiceException) as caught:
await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
self.assertEqual(caught.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
self.assertEqual(self.inventory.remaining_packages, 8)
calls['add_usage'].assert_not_awaited()
calls['add_event'].assert_not_awaited()
self.db.commit.assert_not_awaited()
async def test_reason_and_recommendation_are_saved_with_usage_and_event(self):
command = self.command(' 工艺需要混批补充 ')
stack, calls = self._patch_record_path()
fifo = AsyncMock(return_value=(self.candidate, 0))
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
usage = calls['add_usage'].call_args.args[1]
event = calls['add_event'].call_args.args[2]
for record in (usage, event):
self.assertEqual(record.fifo_skip_reason, '工艺需要混批补充')
self.assertEqual(record.fifo_recommendation['palletCode'], 'P-050')
self.assertEqual(record.fifo_recommendation['batchNo'], 'B-EARLY')
self.assertEqual(record.fifo_recommendation['siteArrivedAt'], '2026-10-07T10:00:00')
self.assertTrue(fifo.call_args.kwargs['for_update'])
self.assertEqual(self.inventory.remaining_packages, 5)
self.assertEqual(UsageModel.model_validate(usage).fifo_skip_reason, '工艺需要混批补充')
async def test_equal_arrival_does_not_force_pallet_id_order(self):
self.candidate['site_arrived_at'] = self.inventory.site_arrived_at
stack, calls = self._patch_record_path()
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertIsNone(calls['add_usage'].call_args.args[1].fifo_skip_reason)
async def test_unknown_current_arrival_does_not_fabricate_order(self):
self.inventory.site_arrived_at = None
stack, _ = self._patch_record_path()
with stack, patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 1))):
result = await UsageService.record_usage_services(self.db, self.page, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
async def test_idempotent_retry_never_rechecks_fifo_or_deducts_again(self):
command = self.command('工艺需要')
existing = fixtures.make_usage()
existing.request_hash = UsageService._build_request_hash(command)
stack, calls = self._patch_record_path(key_read=existing)
fifo = AsyncMock(side_effect=AssertionError('成功重试不能重做FIFO判断'))
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
result = await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
calls['add_usage'].assert_not_awaited()
fifo.assert_not_awaited()
def test_reason_changes_hash_but_absent_reason_keeps_legacy_hash(self):
original = UsageService._build_request_hash(self.page)
self.assertEqual(original, UsageService._build_request_hash(self.command(' ')))
self.assertNotEqual(original, UsageService._build_request_hash(self.command('工艺需要')))
self.assertNotEqual(UsageService._build_request_hash(self.command('工艺需要')),
UsageService._build_request_hash(self.command('质量检查')))
def test_entry_reason_is_validated_and_part_of_idempotent_identity(self):
fields = {'qrToken': 'qr-2', 'tunnelId': 9, 'idempotencyKey': 'entry-key'}
original = TunnelEntryCreateModel(**fields)
reason = TunnelEntryCreateModel(**fields, fifoSkipReason=' 工艺需要 ')
self.assertEqual(reason.fifo_skip_reason, '工艺需要')
self.assertNotEqual(UsageService._build_tunnel_entry_request_hash(original),
UsageService._build_tunnel_entry_request_hash(reason))
with self.assertRaises(ValidationError):
TunnelEntryCreateModel(**fields, fifoSkipReason='字' * 501)
async def test_entry_refuses_skip_before_inventory_binding(self):
self.inventory.tunnel_id = None
self.inventory.trace_no = None
self.tunnel.current_trace_no = 'TRACE-9'
command = TunnelEntryCreateModel(qrToken='qr-2', tunnelId=9, idempotencyKey='entry-key')
stack, calls = self._patch_record_path()
with stack, patch.object(fixtures.InventoryDao, 'get_event_by_idempotency_key', new=AsyncMock(return_value=None)), \
patch.object(UsageFifoDao, 'recommendation', new=AsyncMock(return_value=(self.candidate, 0))):
with self.assertRaises(ServiceException) as caught:
await UsageService.enter_tunnel_services(self.db, command, self.user, TestBatchScope.formal())
self.assertEqual(caught.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
self.assertIsNone(self.inventory.tunnel_id)
calls['add_event'].assert_not_awaited()
async def test_fifo_lock_conflict_retries_whole_usage_with_original_command(self):
stack, calls = self._patch_record_path()
conflict = OperationalError('candidate lock', {}, RuntimeError(3572, 'NOWAIT'))
fifo = AsyncMock(side_effect=[conflict, (self.candidate, 0)])
command = self.command('工艺需要')
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo):
result = await UsageService.record_usage_services(self.db, command, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertEqual(calls['inventory_lock'].await_count, 2)
self.assertEqual(calls['add_usage'].await_count, 1)
self.assertEqual(calls['add_usage'].call_args.args[1].idempotency_key, 'usage-key')
self.assertEqual(self.inventory.remaining_packages, 5)
async def test_persistent_lock_conflict_is_bounded_and_other_db_errors_are_not_retried(self):
for code, attempts in ((3572, 3), (1213, 3), (1205, 3), (1054, 1), (2013, 1)):
with self.subTest(code=code):
stack, calls = self._patch_record_path()
fifo = AsyncMock(side_effect=OperationalError('candidate lock', {}, RuntimeError(code, 'test')))
with stack, patch.object(UsageFifoDao, 'recommendation', new=fifo), self.assertRaises(OperationalError):
await UsageService.record_usage_services(self.db, self.command('工艺需要'), self.user, TestBatchScope.formal())
self.assertEqual(fifo.await_count, attempts)
calls['add_usage'].assert_not_awaited()
async def test_fifo_lock_conflict_retries_entry_before_binding(self):
self.inventory.tunnel_id = None
self.inventory.trace_no = None
command = TunnelEntryCreateModel(qrToken='qr-2', tunnelId=9, idempotencyKey='entry-key', fifoSkipReason='工艺需要')
stack, calls = self._patch_record_path()
fifo = AsyncMock(side_effect=[OperationalError('candidate lock', {}, RuntimeError(3572, 'NOWAIT')), (self.candidate, 0)])
with stack, patch.object(fixtures.InventoryDao, 'get_event_by_idempotency_key', new=AsyncMock(return_value=None)), \
patch.object(UsageFifoDao, 'recommendation', new=fifo):
result = await UsageService.enter_tunnel_services(self.db, command, self.user, TestBatchScope.formal())
self.assertTrue(result.is_success)
self.assertEqual(calls['inventory_lock'].await_count, 2)
self.assertEqual(calls['add_event'].await_count, 1)
@@ -2,7 +2,7 @@ import hashlib
import inspect import inspect
import json import json
import unittest import unittest
from datetime import datetime from datetime import date, datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any, get_type_hints from typing import Any, get_type_hints
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@@ -60,7 +60,7 @@ def make_page(
strain_id: int = 11, strain_id: int = 11,
batch_no: str = 'B-20260726', batch_no: str = 'B-20260726',
package_spec: str = '20kg', package_spec: str = '20kg',
initial_packages: int = 18, initial_packages: int = 100,
idempotency_key: str = 'fill-key', idempotency_key: str = 'fill-key',
) -> FillingCreateModel: ) -> FillingCreateModel:
return FillingCreateModel( return FillingCreateModel(
@@ -68,6 +68,8 @@ def make_page(
warehouseId=warehouse_id, warehouseId=warehouse_id,
strainId=strain_id, strainId=strain_id,
batchNo=batch_no, batchNo=batch_no,
productionDate='2026-07-20',
expiryDate='2027-01-20',
packageSpec=package_spec, packageSpec=package_spec,
initialPackages=initial_packages, initialPackages=initial_packages,
idempotencyKey=idempotency_key, idempotencyKey=idempotency_key,
@@ -78,7 +80,7 @@ def make_pallet(
pallet_id: int = 2, pallet_id: int = 2,
*, *,
qr_token: str = 'qr-2', qr_token: str = 'qr-2',
capacity_packages: int = 20, capacity_packages: int = 100,
asset_status: str = '0', asset_status: str = '0',
test_batch_id: int | None = None, test_batch_id: int | None = None,
) -> BizPallet: ) -> BizPallet:
@@ -200,7 +202,9 @@ class TestFillingPublicContract(unittest.TestCase):
expected_payload = json.dumps( expected_payload = json.dumps(
{ {
'batch_no': 'B-20260726', 'batch_no': 'B-20260726',
'initial_packages': 18, 'production_date': '2026-07-20',
'expiry_date': '2027-01-20',
'initial_packages': 100,
'package_spec': '20kg', 'package_spec': '20kg',
'qr_token': 'qr-2', 'qr_token': 'qr-2',
'strain_id': 11, 'strain_id': 11,
@@ -364,7 +368,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE, result.flow_state) self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE, result.flow_state)
self.assertEqual(['filling'], result.allowed_actions) self.assertEqual(['filling'], result.allowed_actions)
self.assertEqual(20, result.capacity_packages) self.assertEqual(100, result.capacity_packages)
self.assertEqual(0, result.remaining_packages) self.assertEqual(0, result.remaining_packages)
async def test_scan_checks_access_before_exposing_invalid_state(self) -> None: async def test_scan_checks_access_before_exposing_invalid_state(self) -> None:
@@ -534,8 +538,10 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertEqual('S-011', filling.strain_code_snapshot) self.assertEqual('S-011', filling.strain_code_snapshot)
self.assertEqual('香菇 11', filling.strain_name_snapshot) self.assertEqual('香菇 11', filling.strain_name_snapshot)
self.assertEqual('B-20260726', filling.batch_no) self.assertEqual('B-20260726', filling.batch_no)
self.assertEqual(date(2026, 7, 20), filling.production_date)
self.assertEqual(date(2027, 1, 20), filling.expiry_date)
self.assertEqual('20kg', filling.package_spec) self.assertEqual('20kg', filling.package_spec)
self.assertEqual(18, filling.initial_packages) self.assertEqual(100, filling.initial_packages)
self.assertEqual('ACTIVE', filling.active_slot) self.assertEqual('ACTIVE', filling.active_slot)
self.assertEqual('fill-key', filling.idempotency_key) self.assertEqual('fill-key', filling.idempotency_key)
self.assertEqual(FillingService._build_request_hash(self.page), filling.request_hash) self.assertEqual(FillingService._build_request_hash(self.page), filling.request_hash)
@@ -547,7 +553,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.fixed_time, filling.update_time) self.assertEqual(self.fixed_time, filling.update_time)
self.assertEqual(31, self.inventory.active_filling_id) self.assertEqual(31, self.inventory.active_filling_id)
self.assertEqual(18, self.inventory.remaining_packages) self.assertEqual(100, self.inventory.remaining_packages)
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, self.inventory.flow_state) self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, self.inventory.flow_state)
self.assertEqual(4, self.inventory.version) self.assertEqual(4, self.inventory.version)
self.assertEqual(self.fixed_time, self.inventory.updated_at) self.assertEqual(self.fixed_time, self.inventory.updated_at)
@@ -566,7 +572,7 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(event.before_line_id) self.assertIsNone(event.before_line_id)
self.assertIsNone(event.after_line_id) self.assertIsNone(event.after_line_id)
self.assertEqual(0, event.before_remaining_packages) self.assertEqual(0, event.before_remaining_packages)
self.assertEqual(18, event.after_remaining_packages) self.assertEqual(100, event.after_remaining_packages)
self.assertEqual(7, event.operator_id) self.assertEqual(7, event.operator_id)
self.assertEqual(self.fixed_time, event.occurred_at) self.assertEqual(self.fixed_time, event.occurred_at)
self.assertEqual('fill-key', event.idempotency_key) self.assertEqual('fill-key', event.idempotency_key)
@@ -632,8 +638,8 @@ class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once() self.db.rollback.assert_awaited_once()
async def test_fill_rejects_packages_over_persisted_pallet_capacity(self) -> None: async def test_fill_rejects_historical_nonstandard_pallet_capacity(self) -> None:
await self._assert_fill_rejected(page=make_page(initial_packages=21)) await self._assert_fill_rejected(pallet=make_pallet(capacity_packages=20))
async def test_fill_rejects_stopped_warehouse_only_after_core_locks(self) -> None: async def test_fill_rejects_stopped_warehouse_only_after_core_locks(self) -> None:
actions: list[str] = [] actions: list[str] = []
@@ -1152,7 +1158,7 @@ class TestFillingController(unittest.TestCase):
@staticmethod @staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]: def _routes() -> dict[tuple[str, str], APIRoute]:
return { return {
(route.path, next(iter(route.methods))): route (route.path_format, next(iter(route.methods))): route
for route in filling_controller_module.filling_controller.routes for route in filling_controller_module.filling_controller.routes
if isinstance(route, APIRoute) if isinstance(route, APIRoute)
} }
@@ -1189,7 +1195,7 @@ class TestFillingController(unittest.TestCase):
self.assertEqual(ResponseBaseModel, routes[('/business/filling', 'POST')].response_model) self.assertEqual(ResponseBaseModel, routes[('/business/filling', 'POST')].response_model)
def test_static_routes_are_declared_before_dynamic_detail(self) -> None: def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
paths = [route.path for route in filling_controller_module.filling_controller.routes] paths = [route.path_format for route in filling_controller_module.filling_controller.routes]
detail_index = paths.index('/business/filling/{filling_id}') detail_index = paths.index('/business/filling/{filling_id}')
self.assertLess(paths.index('/business/filling/strain-options'), detail_index) self.assertLess(paths.index('/business/filling/strain-options'), detail_index)
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index) self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index)
@@ -1218,7 +1224,7 @@ class TestFillingController(unittest.TestCase):
set(actual.values()), set(actual.values()),
) )
def test_all_routes_inject_current_user_and_only_post_has_log(self) -> None: def test_all_routes_inject_current_user_and_expected_routes_have_log(self) -> None:
for key, route in self._routes().items(): for key, route in self._routes().items():
self.assertTrue( self.assertTrue(
any( any(
@@ -1227,7 +1233,11 @@ class TestFillingController(unittest.TestCase):
), ),
key, key,
) )
self.assertEqual(key == ('/business/filling', 'POST'), hasattr(route.endpoint, '__wrapped__')) expected_logged = {
('/business/filling/scan/{qr_token}', 'GET'),
('/business/filling', 'POST'),
}
self.assertEqual(key in expected_logged, hasattr(route.endpoint, '__wrapped__'))
def test_controller_does_not_add_scan_export_or_draft_permissions(self) -> None: def test_controller_does_not_add_scan_export_or_draft_permissions(self) -> None:
source = inspect.getsource(filling_controller_module) source = inspect.getsource(filling_controller_module)
@@ -1889,7 +1889,7 @@ class TestInboundController(unittest.TestCase):
@staticmethod @staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]: def _routes() -> dict[tuple[str, str], APIRoute]:
return { return {
(route.path, next(iter(route.methods))): route (route.path_format, next(iter(route.methods))): route
for route in inbound_controller_module.inbound_controller.routes for route in inbound_controller_module.inbound_controller.routes
if isinstance(route, APIRoute) if isinstance(route, APIRoute)
} }
@@ -1935,7 +1935,7 @@ class TestInboundController(unittest.TestCase):
self.assertEqual(ResponseBaseModel, routes[key].response_model) self.assertEqual(ResponseBaseModel, routes[key].response_model)
def test_static_routes_are_declared_before_dynamic_detail(self) -> None: def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
paths = [route.path for route in inbound_controller_module.inbound_controller.routes] paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes]
detail_index = paths.index('/business/inbound/{inbound_id}') detail_index = paths.index('/business/inbound/{inbound_id}')
self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index) self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index)
self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index) self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index)
@@ -0,0 +1,60 @@
"""物料日期是发货来源数据,不能由装填操作时间推算。"""
import unittest
from datetime import date
from pydantic import ValidationError
from module_business.entity.vo.operation_vo import FillingCreateModel, FillingModel
from module_business.service.filling_service import FillingService
from tests.module_business.test_filling_service import make_filling
def command_data(**changes):
return {
'qrToken': 'qr-material',
'warehouseId': 7,
'strainId': 11,
'batchNo': 'B-20261001',
'packageSpec': '标准包',
'initialPackages': 100,
'idempotencyKey': 'material-date-key',
**changes,
}
class TestMaterialDates(unittest.TestCase):
def test_new_filling_requires_source_dates(self):
with self.assertRaises(ValidationError):
FillingCreateModel(**command_data())
def test_dates_round_trip_without_time_or_timezone_conversion(self):
command = FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2027-03-31'))
self.assertEqual(command.production_date, date(2026, 10, 1))
self.assertEqual(command.model_dump(mode='json', by_alias=True)['expiryDate'], '2027-03-31')
def test_expiry_cannot_precede_production(self):
with self.assertRaises(ValidationError) as raised:
FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2026-09-30'))
self.assertIn('有效期', str(raised.exception))
def test_invalid_calendar_dates_are_rejected(self):
with self.assertRaises(ValidationError):
FillingCreateModel(**command_data(productionDate='2026-02-30', expiryDate='2027-03-31'))
def test_changing_either_source_date_changes_idempotency_content(self):
original = FillingCreateModel(**command_data(productionDate='2026-10-01', expiryDate='2027-03-31'))
expected = FillingService._build_request_hash(original)
for field, value in [('productionDate', '2026-10-02'), ('expiryDate', '2027-04-01')]:
with self.subTest(field=field):
changed = original.model_dump(by_alias=True)
changed[field] = value
self.assertNotEqual(expected, FillingService._build_request_hash(FillingCreateModel(**changed)))
def test_historical_missing_dates_remain_unknown(self):
record = make_filling()
record.production_date = None
record.expiry_date = None
result = FillingModel.model_validate(record).model_dump(mode='json', by_alias=True)
self.assertIsNone(result['productionDate'])
self.assertIsNone(result['expiryDate'])
@@ -0,0 +1,301 @@
"""真实 MySQL 验收:仅允许显式指定的隔离演练库,绝不写开发/生产库。
运行前将备份恢复并迁移到 shierfeng_rehearsal_20261008_* 库,再设置
SHIERFENG_MYSQL_FLOW_TEST_DATABASE。测试只新建独立测试批次,保留现场用于复核。
"""
import asyncio
import os
import re
import secrets
import unittest
from datetime import date, datetime, timedelta
from unittest.mock import patch
from sqlalchemy import func, select, update
from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from config.env import DataBaseConfig
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.do import user_do # noqa: F401
from module_business.entity.do.master_do import (
BizCustomer, BizCustomerSite, BizPallet, BizStrain, BizTunnel, BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizPalletEvent, BizPalletInventory, BizReturnOrder, BizShipmentOrder, BizUsageRecord,
)
from module_business.entity.do.test_batch_do import BizTestBatch
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import (
ConfirmCommandModel, FillingCreateModel, ReturnConfirmModel, ReturnRequestModel,
ShipmentCreateModel, TunnelEntryCreateModel, UsageCreateModel,
)
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel
from module_business.entity.vo.report_vo import (
ConsumptionReportQueryModel, PalletInventoryReportQueryModel, PalletTraceReportQueryModel, ReturnReportQueryModel,
)
from module_business.service.filling_service import FillingService
from module_business.service.return_service import ReturnService
from module_business.service.report_service import ReportService
from module_business.service.shipment_label_service import ShipmentLabelService
from module_business.service.shipment_receipt_service import ShipmentReceiptService
from module_business.service.shipment_service import ShipmentService
from module_business.service.usage_fifo_service import UsageFifoService
from module_business.service.usage_service import UsageService
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
@unittest.skipUnless(os.environ.get('SHIERFENG_MYSQL_FLOW_TEST_DATABASE'), '需显式指定隔离MySQL演练库')
class TestMaterialFlowMysql(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
database = os.environ['SHIERFENG_MYSQL_FLOW_TEST_DATABASE']
if not re.fullmatch(r'shierfeng_rehearsal_20261008_[a-zA-Z0-9_]+', database):
raise RuntimeError('只允许隔离演练库,拒绝写开发/生产数据库')
if DataBaseConfig.db_host not in ('127.0.0.1', 'localhost'):
raise RuntimeError('只允许本机MySQL')
self.engine = create_async_engine(URL.create(
'mysql+asyncmy', username=DataBaseConfig.db_username, password=DataBaseConfig.db_password,
host=DataBaseConfig.db_host, port=DataBaseConfig.db_port, database=database,
query={'charset': 'utf8mb4'},
), echo=False)
self.sessions = async_sessionmaker(self.engine, autoflush=False)
self.admin = make_admin()
self.tag = 'FIFO-' + secrets.token_hex(6)
async with self.sessions() as db:
batch = BizTestBatch(batch_code=self.tag, batch_name='迁移隔离验收', status='ACTIVE')
db.add(batch)
await db.flush()
self.scope = make_test_scope(batch.batch_id)
self.batch_id = batch.batch_id
warehouse = BizWarehouse(warehouse_code=self.tag, warehouse_name='验收仓库', test_batch_id=self.batch_id)
customer = BizCustomer(customer_code=self.tag, customer_name='验收客户', test_batch_id=self.batch_id)
strain = BizStrain(strain_code=self.tag, strain_name='验收品种', test_batch_id=self.batch_id)
db.add_all([warehouse, customer, strain])
await db.flush()
site = BizCustomerSite(customer_id=customer.customer_id, site_code=self.tag,
site_name='验收现场', test_batch_id=self.batch_id)
db.add(site)
await db.flush()
tunnel = BizTunnel(site_id=site.site_id, tunnel_code=self.tag, tunnel_name='验收隧道',
test_batch_id=self.batch_id)
db.add(tunnel)
self.pallets = []
for index in (1, 2):
pallet = BizPallet(pallet_code=f'{self.tag}-{index}', qr_token=f'{self.tag}/qr/{index}',
capacity_packages=100, asset_status='0', test_batch_id=self.batch_id)
db.add(pallet)
await db.flush()
db.add(BizPalletInventory(pallet_id=pallet.pallet_id, warehouse_id=warehouse.warehouse_id,
flow_state='EMPTY_IN_WAREHOUSE', remaining_packages=0, version=0,
updated_at=datetime.now(), test_batch_id=self.batch_id))
self.pallets.append((pallet.pallet_id, pallet.pallet_code, pallet.qr_token))
await db.flush()
self.warehouse_id, self.site_id = warehouse.warehouse_id, site.site_id
self.strain_id, self.tunnel_id = strain.strain_id, tunnel.tunnel_id
await db.commit()
async def asyncTearDown(self):
await self.engine.dispose()
async def call(self, method, *args, user=None):
async with self.sessions() as db:
return await method(db, *args, user or self.admin, self.scope)
async def scalar(self, statement):
async with self.sessions() as db:
return await db.scalar(statement)
async def test_full_receipt_fifo_mix_return_and_concurrent_retry(self):
p1, p2 = self.pallets
for index, pallet in enumerate(self.pallets, 1):
await self.call(FillingService.fill_pallet_services, FillingCreateModel(
qrToken=pallet[2], warehouseId=self.warehouse_id, strainId=self.strain_id,
batchNo=f'{self.tag}-B{index}', productionDate='2026-10-01', expiryDate='2027-04-01',
packageSpec='标准包', initialPackages=100, idempotencyKey=f'{self.tag}-fill-{index}',
))
await self.call(ShipmentService.add_shipment_services, ShipmentCreateModel(
sourceWarehouseId=self.warehouse_id, targetSiteId=self.site_id, palletIds=[p1[0], p2[0]],
))
shipment_id = await self.scalar(select(BizShipmentOrder.shipment_id).where(
BizShipmentOrder.test_batch_id == self.batch_id))
await self.call(ShipmentService.confirm_shipment_services, shipment_id,
ConfirmCommandModel(idempotencyKey=self.tag + '-ship'))
labels = await self.call(ShipmentLabelService.get_labels_services, shipment_id)
self.assertEqual({l.pallet_code for l in labels}, {p1[1], p2[1]})
self.assertTrue(all(l.production_date == date(2026, 10, 1) and l.package_qty == 100 for l in labels))
entry = TunnelEntryCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry2')
with self.assertRaises(ServiceException):
await self.call(UsageService.enter_tunnel_services, entry) # 在途不允许使用
receipt = ShipmentReceiptCommandModel(scannedPalletIds=[p1[0], p2[0]], idempotencyKey=self.tag+'-receive')
with self.assertRaises(PermissionException):
await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt, user=make_current_user(7))
await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt)
# 两次实际到货的排序边界夹具,仅改本演练批次,不造开发库历史时间。
async with self.sessions() as db:
inventory = await db.get(BizPalletInventory, p1[0])
inventory.site_arrived_at -= timedelta(days=1)
await db.commit()
async with self.sessions() as db:
advice = await UsageFifoService.recommendation(
db, p2[1], self.admin, self.scope, tunnel_id=self.tunnel_id)
self.assertTrue(advice.requires_skip_reason)
self.assertEqual(advice.recommended.pallet_id, p1[0])
with self.assertRaises(ServiceException) as error:
await self.call(UsageService.enter_tunnel_services, entry)
self.assertEqual(error.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
self.assertIsNone(await self.scalar(select(BizPalletInventory.tunnel_id).where(BizPalletInventory.pallet_id==p2[0])))
entry = entry.model_copy(update={'fifo_skip_reason': '工艺混批'})
await self.call(UsageService.enter_tunnel_services, entry)
await self.call(UsageService.enter_tunnel_services, entry) # 已提交入隧幂等分支也不得触发过期加载
usage = UsageCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id,
usedPackages=20, idempotencyKey=self.tag+'-usage2')
with self.assertRaises(ServiceException) as error:
await self.call(UsageService.record_usage_services, usage)
self.assertEqual(error.exception.data, 'FIFO_SKIP_REASON_REQUIRED')
self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==p2[0])),100)
usage = usage.model_copy(update={'fifo_skip_reason': '工艺混批'})
# 独立MySQL连接同时重放相同业务命令,只能产生一条事实和一次扣减。
results = await asyncio.wait_for(asyncio.gather(*(
self.call(UsageService.record_usage_services, usage) for _ in range(3)
)), timeout=20)
self.assertTrue(all(result.result.remaining_packages == 80 for result in results))
self.assertEqual(await self.scalar(select(func.count()).select_from(BizUsageRecord).where(BizUsageRecord.test_batch_id==self.batch_id)),1)
record = await self.scalar(select(BizUsageRecord).where(BizUsageRecord.test_batch_id==self.batch_id))
self.assertEqual(record.fifo_skip_reason, '工艺混批')
self.assertEqual(record.fifo_recommendation['palletCode'], p1[1])
with self.assertRaises(ServiceException):
await self.call(UsageService.record_usage_services, usage.model_copy(update={'fifo_skip_reason':'更换原因'}))
await self.call(UsageService.enter_tunnel_services, TunnelEntryCreateModel(
qrToken=p1[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry1'))
await self.call(UsageService.record_usage_services, UsageCreateModel(
qrToken=p1[2], tunnelId=self.tunnel_id, usedPackages=20, idempotencyKey=self.tag+'-usage1'))
traces = [await self.scalar(select(BizPalletInventory.trace_no).where(BizPalletInventory.pallet_id==p[0])) for p in self.pallets]
self.assertTrue(traces[0] and traces[0] == traces[1])
await self.call(ShipmentReceiptService.confirm_services, shipment_id, receipt)
self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==p2[0])),80)
await self.call(ReturnService.request_return_services, ReturnRequestModel(
sourceSiteId=self.site_id, targetWarehouseId=self.warehouse_id,
palletIds=[p2[0]], idempotencyKey=self.tag+'-return'))
return_id = await self.scalar(select(BizReturnOrder.return_id).where(BizReturnOrder.test_batch_id==self.batch_id))
await self.call(ReturnService.confirm_return_services, return_id, ReturnConfirmModel(
scannedPalletIds=[p2[0]], idempotencyKey=self.tag+'-return-confirm'))
returned = await self.scalar(select(BizPalletInventory).where(BizPalletInventory.pallet_id==p2[0]))
self.assertEqual((returned.remaining_packages,returned.warehouse_id,returned.flow_state),
(80,self.warehouse_id,'FILLED_IN_WAREHOUSE'))
self.assertIsNone(returned.site_arrived_at)
self.assertEqual(await self.scalar(select(func.count()).select_from(BizPallet).where(BizPallet.test_batch_id==self.batch_id)),2)
labels_after = await self.call(ShipmentLabelService.get_labels_services, shipment_id)
self.assertEqual({(l.pallet_code,l.batch_no,l.package_qty) for l in labels_after},
{(p1[1],self.tag+'-B1',100),(p2[1],self.tag+'-B2',100)})
events = await self.scalar(select(func.count()).select_from(BizPalletEvent).where(
BizPalletEvent.test_batch_id==self.batch_id, BizPalletEvent.fifo_skip_reason=='工艺混批'))
self.assertEqual(events,2)
consumption = await self.call(ReportService.get_consumption_report_list_services,
ConsumptionReportQueryModel(tunnelId=self.tunnel_id))
self.assertEqual(consumption.total, 2)
self.assertEqual(sum(row.used_packages for row in consumption.rows), 40)
self.assertTrue(all(row.line_id is None and row.trace_no == traces[0] for row in consumption.rows))
self.assertEqual({row.batch_no for row in consumption.rows}, {self.tag+'-B1', self.tag+'-B2'})
returned_report = await self.call(ReportService.get_return_report_list_services,
ReturnReportQueryModel(tunnelId=self.tunnel_id))
self.assertEqual(returned_report.total, 1)
self.assertEqual(returned_report.rows[0].remaining_packages_snapshot, 80)
inventory_report = await self.call(ReportService.get_pallet_inventory_list_services,
PalletInventoryReportQueryModel())
self.assertEqual(inventory_report.total, 2)
self.assertTrue(all(row.remaining_packages == 80 and row.production_date == date(2026, 10, 1)
for row in inventory_report.rows))
trace_report = await self.call(ReportService.get_pallet_trace_list_services,
PalletTraceReportQueryModel(palletCode=p2[1], pageSize=100))
entered = [row for row in trace_report.rows if row.event_type.value == 'TUNNEL_ENTERED']
self.assertEqual(len(entered), 1)
self.assertEqual(entered[0].production_date, date(2026, 10, 1))
async def test_formal_two_pallet_lock_inversion_retries_without_wait_cycle(self):
# 仅在隔离演练库将本方法新建的夹具设为正式模式;排除测试批次总锁掩盖并发问题。
async with self.sessions() as db:
for model in (BizPalletInventory, BizPallet, BizTunnel, BizCustomerSite, BizCustomer, BizWarehouse, BizStrain):
await db.execute(update(model).where(model.test_batch_id == self.batch_id).values(test_batch_id=None))
await db.commit()
self.scope = TestBatchScope.formal()
p1, p2 = self.pallets
for index, pallet in enumerate(self.pallets):
await self.call(FillingService.fill_pallet_services, FillingCreateModel(
qrToken=pallet[2], warehouseId=self.warehouse_id, strainId=self.strain_id,
batchNo=f'{self.tag}-B{index}', productionDate='2026-10-01', expiryDate='2027-04-01',
packageSpec='标准包', initialPackages=100, idempotencyKey=f'{self.tag}-fill-{index}',
))
# 已收货夹具,仍使用真实表、约束与后续全部真实SQL。
async with self.sessions() as db:
for index, pallet in enumerate(self.pallets):
inventory = await db.get(BizPalletInventory, pallet[0])
inventory.site_id, inventory.warehouse_id = self.site_id, None
inventory.site_arrived_at = datetime(2026, 10, 6 + index)
inventory.flow_state = 'AT_SITE_UNUSED'
await db.commit()
async def overlap(method, earlier, later):
late_at_fifo, early_has_inventory = asyncio.Event(), asyncio.Event()
real_fifo = UsageFifoDao.recommendation
real_inventory = InventoryDao.get_inventory_for_update
late_calls = 0
observations = []
inventory_attempts = []
async def scheduled_fifo(*args, **kwargs):
nonlocal late_calls
if asyncio.current_task().get_name() == 'fifo-later':
late_calls += 1
if late_calls == 1:
late_at_fifo.set()
await early_has_inventory.wait()
try:
result = await real_fifo(*args, **kwargs)
observations.append((asyncio.current_task().get_name(), 'candidate', result[0]))
return result
except Exception as error:
observations.append((asyncio.current_task().get_name(), type(error).__name__, str(error)))
raise
async def scheduled_inventory(db, pallet_id, scope):
inventory_attempts.append((asyncio.current_task().get_name(), pallet_id))
result = await real_inventory(db, pallet_id, scope)
if asyncio.current_task().get_name() == 'fifo-earlier':
early_has_inventory.set()
return result
with patch.object(UsageFifoDao, 'recommendation', new=scheduled_fifo), \
patch.object(InventoryDao, 'get_inventory_for_update', new=scheduled_inventory):
late = asyncio.create_task(self.call(method, later), name='fifo-later')
early = None
try:
await asyncio.wait_for(late_at_fifo.wait(), 5)
early = asyncio.create_task(self.call(method, earlier), name='fifo-earlier')
results = await asyncio.wait_for(asyncio.gather(late, early), 5)
self.assertTrue(all(result.is_success for result in results))
# InnoDB 可以选择任一请求回滚;不能把“必须晚到请求重试”当业务约束。
self.assertGreaterEqual(len(inventory_attempts), 3, (observations, inventory_attempts))
for name in ('fifo-later', 'fifo-earlier'):
self.assertLessEqual(sum(task_name == name for task_name, _ in inventory_attempts), 3)
finally:
for task in (late, early):
if task is not None and not task.done():
task.cancel()
await asyncio.gather(*(task for task in (late, early) if task is not None), return_exceptions=True)
await overlap(UsageService.enter_tunnel_services,
TunnelEntryCreateModel(qrToken=p1[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry1'),
TunnelEntryCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, idempotencyKey=self.tag+'-entry2',fifoSkipReason='工艺混批'))
await overlap(UsageService.record_usage_services,
UsageCreateModel(qrToken=p1[2], tunnelId=self.tunnel_id, usedPackages=20,idempotencyKey=self.tag+'-usage1'),
UsageCreateModel(qrToken=p2[2], tunnelId=self.tunnel_id, usedPackages=20,idempotencyKey=self.tag+'-usage2',fifoSkipReason='工艺混批'))
for pallet in self.pallets:
self.assertEqual(await self.scalar(select(BizPalletInventory.remaining_packages).where(BizPalletInventory.pallet_id==pallet[0])),80)
self.assertEqual(await self.scalar(select(func.count()).select_from(BizUsageRecord).where(BizUsageRecord.pallet_id==pallet[0])),1)
@@ -1,6 +1,6 @@
import unittest import unittest
from sqlalchemy import CHAR, BigInteger, CheckConstraint, DateTime, Integer, String, Table, Text, UniqueConstraint from sqlalchemy import JSON, CHAR, BigInteger, CheckConstraint, Date, DateTime, Integer, String, Table, Text, UniqueConstraint
from sqlalchemy.dialects import mysql from sqlalchemy.dialects import mysql
from sqlalchemy.schema import CreateColumn, CreateTable from sqlalchemy.schema import CreateColumn, CreateTable
@@ -14,6 +14,7 @@ TEST_BATCH_OWNED_TABLES = {
'biz_customer', 'biz_customer',
'biz_customer_site', 'biz_customer_site',
'biz_production_line', 'biz_production_line',
'biz_tunnel',
'biz_warehouse', 'biz_warehouse',
'biz_pallet', 'biz_pallet',
'biz_inbound_order', 'biz_inbound_order',
@@ -88,6 +89,20 @@ EXPECTED_COLUMNS = {
'update_time', 'update_time',
'remark', 'remark',
}, },
'biz_tunnel': {
'tunnel_id',
'site_id',
'tunnel_code',
'tunnel_name',
'current_trace_no',
'current_trace_started_at',
'status',
'create_by',
'create_time',
'update_by',
'update_time',
'remark',
},
'biz_pallet': { 'biz_pallet': {
'pallet_id', 'pallet_id',
'pallet_code', 'pallet_code',
@@ -133,6 +148,8 @@ EXPECTED_COLUMNS = {
'strain_code_snapshot', 'strain_code_snapshot',
'strain_name_snapshot', 'strain_name_snapshot',
'batch_no', 'batch_no',
'production_date',
'expiry_date',
'package_spec', 'package_spec',
'initial_packages', 'initial_packages',
'active_slot', 'active_slot',
@@ -178,6 +195,9 @@ EXPECTED_COLUMNS = {
'pallet_id', 'pallet_id',
'active_filling_id', 'active_filling_id',
'site_id', 'site_id',
'tunnel_id',
'trace_no',
'batch_no_snapshot',
'line_id', 'line_id',
'used_packages', 'used_packages',
'remaining_packages_after', 'remaining_packages_after',
@@ -211,6 +231,9 @@ EXPECTED_COLUMNS = {
'return_id', 'return_id',
'pallet_id', 'pallet_id',
'active_filling_id_snapshot', 'active_filling_id_snapshot',
'tunnel_id_snapshot',
'trace_no_snapshot',
'batch_no_snapshot',
'remaining_packages_snapshot', 'remaining_packages_snapshot',
'create_by', 'create_by',
'create_time', 'create_time',
@@ -220,6 +243,8 @@ EXPECTED_COLUMNS = {
'active_filling_id', 'active_filling_id',
'warehouse_id', 'warehouse_id',
'site_id', 'site_id',
'tunnel_id',
'trace_no',
'current_line_id', 'current_line_id',
'flow_state', 'flow_state',
'remaining_packages', 'remaining_packages',
@@ -238,12 +263,16 @@ EXPECTED_COLUMNS = {
'after_state', 'after_state',
'before_warehouse_id', 'before_warehouse_id',
'before_site_id', 'before_site_id',
'before_tunnel_id',
'before_line_id', 'before_line_id',
'after_warehouse_id', 'after_warehouse_id',
'after_site_id', 'after_site_id',
'after_tunnel_id',
'after_line_id', 'after_line_id',
'before_remaining_packages', 'before_remaining_packages',
'after_remaining_packages', 'after_remaining_packages',
'trace_no',
'batch_no_snapshot',
'operator_id', 'operator_id',
'occurred_at', 'occurred_at',
'idempotency_key', 'idempotency_key',
@@ -263,6 +292,20 @@ EXPECTED_COLUMN_TYPES = {
'update_time': (DateTime, None), 'update_time': (DateTime, None),
'remark': (String, 500), 'remark': (String, 500),
}, },
'biz_tunnel': {
'tunnel_id': (BigInteger, None),
'site_id': (BigInteger, None),
'tunnel_code': (String, 64),
'tunnel_name': (String, 100),
'current_trace_no': (String, 64),
'current_trace_started_at': (DateTime, None),
'status': (CHAR, 1),
'create_by': (String, 64),
'create_time': (DateTime, None),
'update_by': (String, 64),
'update_time': (DateTime, None),
'remark': (String, 500),
},
'biz_warehouse': { 'biz_warehouse': {
'warehouse_id': (BigInteger, None), 'warehouse_id': (BigInteger, None),
'warehouse_code': (String, 64), 'warehouse_code': (String, 64),
@@ -366,6 +409,8 @@ EXPECTED_COLUMN_TYPES = {
'strain_code_snapshot': (String, 64), 'strain_code_snapshot': (String, 64),
'strain_name_snapshot': (String, 100), 'strain_name_snapshot': (String, 100),
'batch_no': (String, 64), 'batch_no': (String, 64),
'production_date': (Date, None),
'expiry_date': (Date, None),
'package_spec': (String, 64), 'package_spec': (String, 64),
'initial_packages': (Integer, None), 'initial_packages': (Integer, None),
'active_slot': (String, 16), 'active_slot': (String, 16),
@@ -411,6 +456,9 @@ EXPECTED_COLUMN_TYPES = {
'pallet_id': (BigInteger, None), 'pallet_id': (BigInteger, None),
'active_filling_id': (BigInteger, None), 'active_filling_id': (BigInteger, None),
'site_id': (BigInteger, None), 'site_id': (BigInteger, None),
'tunnel_id': (BigInteger, None),
'trace_no': (String, 64),
'batch_no_snapshot': (String, 64),
'line_id': (BigInteger, None), 'line_id': (BigInteger, None),
'used_packages': (Integer, None), 'used_packages': (Integer, None),
'remaining_packages_after': (Integer, None), 'remaining_packages_after': (Integer, None),
@@ -444,6 +492,9 @@ EXPECTED_COLUMN_TYPES = {
'return_id': (BigInteger, None), 'return_id': (BigInteger, None),
'pallet_id': (BigInteger, None), 'pallet_id': (BigInteger, None),
'active_filling_id_snapshot': (BigInteger, None), 'active_filling_id_snapshot': (BigInteger, None),
'tunnel_id_snapshot': (BigInteger, None),
'trace_no_snapshot': (String, 64),
'batch_no_snapshot': (String, 64),
'remaining_packages_snapshot': (Integer, None), 'remaining_packages_snapshot': (Integer, None),
'create_by': (String, 64), 'create_by': (String, 64),
'create_time': (DateTime, None), 'create_time': (DateTime, None),
@@ -453,6 +504,8 @@ EXPECTED_COLUMN_TYPES = {
'active_filling_id': (BigInteger, None), 'active_filling_id': (BigInteger, None),
'warehouse_id': (BigInteger, None), 'warehouse_id': (BigInteger, None),
'site_id': (BigInteger, None), 'site_id': (BigInteger, None),
'tunnel_id': (BigInteger, None),
'trace_no': (String, 64),
'current_line_id': (BigInteger, None), 'current_line_id': (BigInteger, None),
'flow_state': (String, 32), 'flow_state': (String, 32),
'remaining_packages': (Integer, None), 'remaining_packages': (Integer, None),
@@ -471,12 +524,16 @@ EXPECTED_COLUMN_TYPES = {
'after_state': (String, 32), 'after_state': (String, 32),
'before_warehouse_id': (BigInteger, None), 'before_warehouse_id': (BigInteger, None),
'before_site_id': (BigInteger, None), 'before_site_id': (BigInteger, None),
'before_tunnel_id': (BigInteger, None),
'before_line_id': (BigInteger, None), 'before_line_id': (BigInteger, None),
'after_warehouse_id': (BigInteger, None), 'after_warehouse_id': (BigInteger, None),
'after_site_id': (BigInteger, None), 'after_site_id': (BigInteger, None),
'after_tunnel_id': (BigInteger, None),
'after_line_id': (BigInteger, None), 'after_line_id': (BigInteger, None),
'before_remaining_packages': (Integer, None), 'before_remaining_packages': (Integer, None),
'after_remaining_packages': (Integer, None), 'after_remaining_packages': (Integer, None),
'trace_no': (String, 64),
'batch_no_snapshot': (String, 64),
'operator_id': (BigInteger, None), 'operator_id': (BigInteger, None),
'occurred_at': (DateTime, None), 'occurred_at': (DateTime, None),
'idempotency_key': (String, 64), 'idempotency_key': (String, 64),
@@ -496,6 +553,20 @@ EXPECTED_NULLABILITY = {
'update_time': True, 'update_time': True,
'remark': True, 'remark': True,
}, },
'biz_tunnel': {
'tunnel_id': False,
'site_id': False,
'tunnel_code': False,
'tunnel_name': False,
'current_trace_no': True,
'current_trace_started_at': True,
'status': False,
'create_by': True,
'create_time': True,
'update_by': True,
'update_time': True,
'remark': True,
},
'biz_warehouse': { 'biz_warehouse': {
'warehouse_id': False, 'warehouse_id': False,
'warehouse_code': False, 'warehouse_code': False,
@@ -599,6 +670,8 @@ EXPECTED_NULLABILITY = {
'strain_code_snapshot': False, 'strain_code_snapshot': False,
'strain_name_snapshot': False, 'strain_name_snapshot': False,
'batch_no': False, 'batch_no': False,
'production_date': True,
'expiry_date': True,
'package_spec': False, 'package_spec': False,
'initial_packages': False, 'initial_packages': False,
'active_slot': True, 'active_slot': True,
@@ -644,7 +717,10 @@ EXPECTED_NULLABILITY = {
'pallet_id': False, 'pallet_id': False,
'active_filling_id': False, 'active_filling_id': False,
'site_id': False, 'site_id': False,
'line_id': False, 'tunnel_id': True,
'trace_no': True,
'batch_no_snapshot': True,
'line_id': True,
'used_packages': False, 'used_packages': False,
'remaining_packages_after': False, 'remaining_packages_after': False,
'idempotency_key': False, 'idempotency_key': False,
@@ -677,6 +753,9 @@ EXPECTED_NULLABILITY = {
'return_id': False, 'return_id': False,
'pallet_id': False, 'pallet_id': False,
'active_filling_id_snapshot': True, 'active_filling_id_snapshot': True,
'tunnel_id_snapshot': True,
'trace_no_snapshot': True,
'batch_no_snapshot': True,
'remaining_packages_snapshot': False, 'remaining_packages_snapshot': False,
'create_by': True, 'create_by': True,
'create_time': True, 'create_time': True,
@@ -686,6 +765,8 @@ EXPECTED_NULLABILITY = {
'active_filling_id': True, 'active_filling_id': True,
'warehouse_id': True, 'warehouse_id': True,
'site_id': True, 'site_id': True,
'tunnel_id': True,
'trace_no': True,
'current_line_id': True, 'current_line_id': True,
'flow_state': False, 'flow_state': False,
'remaining_packages': False, 'remaining_packages': False,
@@ -704,12 +785,16 @@ EXPECTED_NULLABILITY = {
'after_state': False, 'after_state': False,
'before_warehouse_id': True, 'before_warehouse_id': True,
'before_site_id': True, 'before_site_id': True,
'before_tunnel_id': True,
'before_line_id': True, 'before_line_id': True,
'after_warehouse_id': True, 'after_warehouse_id': True,
'after_site_id': True, 'after_site_id': True,
'after_tunnel_id': True,
'after_line_id': True, 'after_line_id': True,
'before_remaining_packages': False, 'before_remaining_packages': False,
'after_remaining_packages': False, 'after_remaining_packages': False,
'trace_no': True,
'batch_no_snapshot': True,
'operator_id': False, 'operator_id': False,
'occurred_at': False, 'occurred_at': False,
'idempotency_key': False, 'idempotency_key': False,
@@ -728,6 +813,8 @@ EXPECTED_CALLABLE_DATETIME_DEFAULTS = {
('biz_customer_site', 'update_time'), ('biz_customer_site', 'update_time'),
('biz_production_line', 'create_time'), ('biz_production_line', 'create_time'),
('biz_production_line', 'update_time'), ('biz_production_line', 'update_time'),
('biz_tunnel', 'create_time'),
('biz_tunnel', 'update_time'),
('biz_pallet', 'create_time'), ('biz_pallet', 'create_time'),
('biz_pallet', 'update_time'), ('biz_pallet', 'update_time'),
('biz_user_warehouse', 'create_time'), ('biz_user_warehouse', 'create_time'),
@@ -757,6 +844,8 @@ EXPECTED_EMPTY_AUDIT_SERVER_DEFAULTS = {
('biz_customer_site', 'update_by'), ('biz_customer_site', 'update_by'),
('biz_production_line', 'create_by'), ('biz_production_line', 'create_by'),
('biz_production_line', 'update_by'), ('biz_production_line', 'update_by'),
('biz_tunnel', 'create_by'),
('biz_tunnel', 'update_by'),
('biz_pallet', 'create_by'), ('biz_pallet', 'create_by'),
('biz_pallet', 'update_by'), ('biz_pallet', 'update_by'),
('biz_user_warehouse', 'create_by'), ('biz_user_warehouse', 'create_by'),
@@ -780,6 +869,7 @@ EXPECTED_PRIMARY_KEYS = {
'biz_customer': ('customer_id',), 'biz_customer': ('customer_id',),
'biz_customer_site': ('site_id',), 'biz_customer_site': ('site_id',),
'biz_production_line': ('line_id',), 'biz_production_line': ('line_id',),
'biz_tunnel': ('tunnel_id',),
'biz_pallet': ('pallet_id',), 'biz_pallet': ('pallet_id',),
'biz_user_warehouse': ('user_id', 'warehouse_id'), 'biz_user_warehouse': ('user_id', 'warehouse_id'),
'biz_user_site': ('user_id', 'site_id'), 'biz_user_site': ('user_id', 'site_id'),
@@ -801,6 +891,9 @@ EXPECTED_FOREIGN_KEYS = {
'biz_customer': set(), 'biz_customer': set(),
'biz_customer_site': {('customer_id', 'biz_customer.customer_id')}, 'biz_customer_site': {('customer_id', 'biz_customer.customer_id')},
'biz_production_line': {('site_id', 'biz_customer_site.site_id')}, 'biz_production_line': {('site_id', 'biz_customer_site.site_id')},
'biz_tunnel': {
('site_id', 'biz_customer_site.site_id'),
},
'biz_pallet': set(), 'biz_pallet': set(),
'biz_user_warehouse': { 'biz_user_warehouse': {
('user_id', 'sys_user.user_id'), ('user_id', 'sys_user.user_id'),
@@ -838,6 +931,7 @@ EXPECTED_FOREIGN_KEYS = {
('pallet_id', 'biz_pallet.pallet_id'), ('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'), ('active_filling_id', 'biz_filling_record.filling_id'),
('site_id', 'biz_customer_site.site_id'), ('site_id', 'biz_customer_site.site_id'),
('tunnel_id', 'biz_tunnel.tunnel_id'),
('line_id', 'biz_production_line.line_id'), ('line_id', 'biz_production_line.line_id'),
('operator_id', 'sys_user.user_id'), ('operator_id', 'sys_user.user_id'),
}, },
@@ -851,12 +945,14 @@ EXPECTED_FOREIGN_KEYS = {
('return_id', 'biz_return_order.return_id'), ('return_id', 'biz_return_order.return_id'),
('pallet_id', 'biz_pallet.pallet_id'), ('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id_snapshot', 'biz_filling_record.filling_id'), ('active_filling_id_snapshot', 'biz_filling_record.filling_id'),
('tunnel_id_snapshot', 'biz_tunnel.tunnel_id'),
}, },
'biz_pallet_inventory': { 'biz_pallet_inventory': {
('pallet_id', 'biz_pallet.pallet_id'), ('pallet_id', 'biz_pallet.pallet_id'),
('active_filling_id', 'biz_filling_record.filling_id'), ('active_filling_id', 'biz_filling_record.filling_id'),
('warehouse_id', 'biz_warehouse.warehouse_id'), ('warehouse_id', 'biz_warehouse.warehouse_id'),
('site_id', 'biz_customer_site.site_id'), ('site_id', 'biz_customer_site.site_id'),
('tunnel_id', 'biz_tunnel.tunnel_id'),
('current_line_id', 'biz_production_line.line_id'), ('current_line_id', 'biz_production_line.line_id'),
('last_event_id', 'biz_pallet_event.event_id'), ('last_event_id', 'biz_pallet_event.event_id'),
}, },
@@ -864,9 +960,11 @@ EXPECTED_FOREIGN_KEYS = {
('pallet_id', 'biz_pallet.pallet_id'), ('pallet_id', 'biz_pallet.pallet_id'),
('before_warehouse_id', 'biz_warehouse.warehouse_id'), ('before_warehouse_id', 'biz_warehouse.warehouse_id'),
('before_site_id', 'biz_customer_site.site_id'), ('before_site_id', 'biz_customer_site.site_id'),
('before_tunnel_id', 'biz_tunnel.tunnel_id'),
('before_line_id', 'biz_production_line.line_id'), ('before_line_id', 'biz_production_line.line_id'),
('after_warehouse_id', 'biz_warehouse.warehouse_id'), ('after_warehouse_id', 'biz_warehouse.warehouse_id'),
('after_site_id', 'biz_customer_site.site_id'), ('after_site_id', 'biz_customer_site.site_id'),
('after_tunnel_id', 'biz_tunnel.tunnel_id'),
('after_line_id', 'biz_production_line.line_id'), ('after_line_id', 'biz_production_line.line_id'),
('operator_id', 'sys_user.user_id'), ('operator_id', 'sys_user.user_id'),
}, },
@@ -878,6 +976,7 @@ EXPECTED_UNIQUE_SETS = {
'biz_customer': {frozenset({'customer_code'})}, 'biz_customer': {frozenset({'customer_code'})},
'biz_customer_site': {frozenset({'customer_id', 'site_code'})}, 'biz_customer_site': {frozenset({'customer_id', 'site_code'})},
'biz_production_line': {frozenset({'site_id', 'line_code'})}, 'biz_production_line': {frozenset({'site_id', 'line_code'})},
'biz_tunnel': {frozenset({'site_id', 'tunnel_code'})},
'biz_pallet': {frozenset({'pallet_code'}), frozenset({'qr_token'})}, 'biz_pallet': {frozenset({'pallet_code'}), frozenset({'qr_token'})},
'biz_user_warehouse': set(), 'biz_user_warehouse': set(),
'biz_user_site': set(), 'biz_user_site': set(),
@@ -913,6 +1012,36 @@ EXPECTED_UNIQUE_SETS = {
}, },
} }
# 收货补齐:与既有日期、隧道契约并存,不用当前时间填充历史收货记录。
EXPECTED_COLUMNS['biz_shipment_order'].update({
'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash',
})
EXPECTED_COLUMN_TYPES['biz_shipment_order'].update({
'receipt_state': (String, 16),
'received_by': (BigInteger, None),
'received_at': (DateTime, None),
'receipt_idempotency_key': (String, 64),
'receipt_request_hash': (String, 64),
})
EXPECTED_NULLABILITY['biz_shipment_order'].update({
'receipt_state': False,
'received_by': True,
'received_at': True,
'receipt_idempotency_key': True,
'receipt_request_hash': True,
})
EXPECTED_FOREIGN_KEYS['biz_shipment_order'].add(('received_by', 'sys_user.user_id'))
EXPECTED_UNIQUE_SETS['biz_shipment_order'].add(frozenset({'target_site_id', 'receipt_idempotency_key'}))
EXPECTED_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
EXPECTED_COLUMN_TYPES['biz_pallet_inventory']['site_arrived_at'] = (DateTime, None)
EXPECTED_NULLABILITY['biz_pallet_inventory']['site_arrived_at'] = True
for fifo_table in ('biz_usage_record', 'biz_pallet_event'):
EXPECTED_COLUMNS[fifo_table].update({'fifo_skip_reason', 'fifo_recommendation'})
EXPECTED_NULLABILITY[fifo_table].update({'fifo_skip_reason': True, 'fifo_recommendation': True})
EXPECTED_COLUMN_TYPES[fifo_table]['fifo_skip_reason'] = (String, 500)
EXPECTED_COLUMN_TYPES[fifo_table]['fifo_recommendation'] = (JSON, None)
for table_name in TEST_BATCH_OWNED_TABLES: for table_name in TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id') EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_COLUMN_TYPES[table_name]['test_batch_id'] = (BigInteger, None) EXPECTED_COLUMN_TYPES[table_name]['test_batch_id'] = (BigInteger, None)
@@ -1064,7 +1193,7 @@ class BusinessModelContractTest(unittest.TestCase):
def test_batch_related_tables_compile_for_mysql(self) -> None: def test_batch_related_tables_compile_for_mysql(self) -> None:
self.assertIs(Base.metadata.tables['sys_user'], user_do.SysUser.__table__) self.assertIs(Base.metadata.tables['sys_user'], user_do.SysUser.__table__)
batch_tables = EXPECTED_TEST_BATCH_TABLES | TEST_BATCH_OWNED_TABLES batch_tables = EXPECTED_TEST_BATCH_TABLES | TEST_BATCH_OWNED_TABLES
self.assertEqual(18, len(batch_tables)) self.assertEqual(19, len(batch_tables))
for table_name in batch_tables: for table_name in batch_tables:
for dialect_name, dialect in (('mysql', mysql.dialect()),): for dialect_name, dialect in (('mysql', mysql.dialect()),):
with self.subTest(table_name=table_name, dialect=dialect_name): with self.subTest(table_name=table_name, dialect=dialect_name):
@@ -1078,6 +1207,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_customer', 'biz_customer',
'biz_customer_site', 'biz_customer_site',
'biz_production_line', 'biz_production_line',
'biz_tunnel',
'biz_pallet', 'biz_pallet',
'biz_user_warehouse', 'biz_user_warehouse',
'biz_user_site', 'biz_user_site',
@@ -1102,6 +1232,7 @@ class BusinessModelContractTest(unittest.TestCase):
master_do.BizCustomer: 'biz_customer', master_do.BizCustomer: 'biz_customer',
master_do.BizCustomerSite: 'biz_customer_site', master_do.BizCustomerSite: 'biz_customer_site',
master_do.BizProductionLine: 'biz_production_line', master_do.BizProductionLine: 'biz_production_line',
master_do.BizTunnel: 'biz_tunnel',
master_do.BizPallet: 'biz_pallet', master_do.BizPallet: 'biz_pallet',
master_do.BizUserWarehouse: 'biz_user_warehouse', master_do.BizUserWarehouse: 'biz_user_warehouse',
master_do.BizUserSite: 'biz_user_site', master_do.BizUserSite: 'biz_user_site',
@@ -1258,6 +1389,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_customer': {'ck_biz_customer_status': "statusIN('0','1')"}, 'biz_customer': {'ck_biz_customer_status': "statusIN('0','1')"},
'biz_customer_site': {'ck_biz_customer_site_status': "statusIN('0','1')"}, 'biz_customer_site': {'ck_biz_customer_site_status': "statusIN('0','1')"},
'biz_production_line': {'ck_biz_production_line_status': "statusIN('0','1')"}, 'biz_production_line': {'ck_biz_production_line_status': "statusIN('0','1')"},
'biz_tunnel': {'ck_biz_tunnel_status': "statusIN('0','1')"},
'biz_pallet': { 'biz_pallet': {
'ck_biz_pallet_capacity_packages': 'capacity_packages>0', 'ck_biz_pallet_capacity_packages': 'capacity_packages>0',
'ck_biz_pallet_asset_status': "asset_statusIN('0','1')", 'ck_biz_pallet_asset_status': "asset_statusIN('0','1')",
@@ -1267,10 +1399,12 @@ class BusinessModelContractTest(unittest.TestCase):
}, },
'biz_filling_record': { 'biz_filling_record': {
'ck_biz_filling_record_initial_packages': 'initial_packages>0', 'ck_biz_filling_record_initial_packages': 'initial_packages>0',
'ck_biz_filling_record_material_dates': '(production_dateISNULLANDexpiry_dateISNULL)OR(production_dateISNOTNULLANDexpiry_dateISNOTNULLANDexpiry_date>=production_date)',
'ck_biz_filling_record_active_slot': "active_slotISNULLORactive_slot='ACTIVE'", 'ck_biz_filling_record_active_slot': "active_slotISNULLORactive_slot='ACTIVE'",
}, },
'biz_shipment_order': { 'biz_shipment_order': {
'ck_biz_shipment_order_status': "statusIN('DRAFT','CONFIRMED')", 'ck_biz_shipment_order_status': "statusIN('DRAFT','CONFIRMED')",
'ck_biz_shipment_order_receipt_state': "receipt_stateIN('PENDING','RECEIVED','LEGACY')",
}, },
'biz_shipment_item': { 'biz_shipment_item': {
'ck_biz_shipment_item_package_qty': 'package_qtyISNULLORpackage_qty>0', 'ck_biz_shipment_item_package_qty': 'package_qtyISNULLORpackage_qty>0',
@@ -1288,7 +1422,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_pallet_inventory': { 'biz_pallet_inventory': {
'ck_biz_pallet_inventory_flow_state': ( 'ck_biz_pallet_inventory_flow_state': (
"flow_stateIN('UNSTOCKED','EMPTY_IN_WAREHOUSE','FILLED_IN_WAREHOUSE'," "flow_stateIN('UNSTOCKED','EMPTY_IN_WAREHOUSE','FILLED_IN_WAREHOUSE',"
"'AT_SITE_UNUSED','AT_SITE_PARTIAL','AT_SITE_EMPTY','RETURNING')" "'IN_TRANSIT','AT_SITE_UNUSED','AT_SITE_PARTIAL','AT_SITE_EMPTY','RETURNING')"
), ),
'ck_biz_pallet_inventory_remaining_packages': 'remaining_packages>=0', 'ck_biz_pallet_inventory_remaining_packages': 'remaining_packages>=0',
'ck_biz_pallet_inventory_version': 'version>=0', 'ck_biz_pallet_inventory_version': 'version>=0',
@@ -1296,7 +1430,7 @@ class BusinessModelContractTest(unittest.TestCase):
}, },
'biz_pallet_event': { 'biz_pallet_event': {
'ck_biz_pallet_event_event_type': ( 'ck_biz_pallet_event_event_type': (
"event_typeIN('INBOUND_CONFIRMED','FILLING_CONFIRMED','SHIPMENT_CONFIRMED'," "event_typeIN('INBOUND_CONFIRMED','FILLING_CONFIRMED','SHIPMENT_CONFIRMED','SHIPMENT_RECEIVED','TUNNEL_ENTERED',"
"'USAGE_RECORDED','RETURN_REQUESTED','RETURN_CONFIRMED')" "'USAGE_RECORDED','RETURN_REQUESTED','RETURN_CONFIRMED')"
), ),
}, },
@@ -1312,6 +1446,7 @@ class BusinessModelContractTest(unittest.TestCase):
'biz_customer': 'status', 'biz_customer': 'status',
'biz_customer_site': 'status', 'biz_customer_site': 'status',
'biz_production_line': 'status', 'biz_production_line': 'status',
'biz_tunnel': 'status',
'biz_pallet': 'asset_status', 'biz_pallet': 'asset_status',
} }
for table_name, column_name in char_statuses.items(): for table_name, column_name in char_statuses.items():
@@ -1375,15 +1510,19 @@ class BusinessModelContractTest(unittest.TestCase):
'active_filling_id', 'active_filling_id',
'warehouse_id', 'warehouse_id',
'site_id', 'site_id',
'tunnel_id',
'trace_no',
'current_line_id', 'current_line_id',
'last_event_id', 'last_event_id',
}, },
'biz_pallet_event': { 'biz_pallet_event': {
'before_warehouse_id', 'before_warehouse_id',
'before_site_id', 'before_site_id',
'before_tunnel_id',
'before_line_id', 'before_line_id',
'after_warehouse_id', 'after_warehouse_id',
'after_site_id', 'after_site_id',
'after_tunnel_id',
'after_line_id', 'after_line_id',
}, },
} }
@@ -0,0 +1,77 @@
"""在隔离内存库执行真实解析查询,不接触业务数据库。"""
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy import Column, MetaData, Table, create_engine
from sqlalchemy.orm import Session
from exceptions.exception import PermissionException, ServiceException
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizPallet
from module_business.service.access_service import AccessService
from module_business.service.filling_service import FillingService
from tests.module_business.test_batch_fixtures import make_test_scope
from tests.module_business.test_filling_service import make_inventory, make_pallet, make_user
class TestPalletScanIdentity(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.engine = create_engine('sqlite:///:memory:')
metadata = MetaData()
# 本测试只执行托盘查询,不复制业务数据库或外键依赖。
self.table = Table('biz_pallet', metadata, *[
Column(column.name, column.type, primary_key=column.primary_key)
for column in BizPallet.__table__.columns
])
metadata.create_all(self.engine)
self.session = Session(self.engine)
self.db = MagicMock()
self.db.execute = AsyncMock(side_effect=self.session.execute)
self.session.execute(self.table.insert(), [
{'pallet_id': 1, 'pallet_code': '123', 'qr_token': 'qr-one', 'test_batch_id': None},
{'pallet_id': 2, 'pallet_code': '456', 'qr_token': 'qr-two', 'test_batch_id': 7},
])
def tearDown(self):
self.session.close()
self.engine.dispose()
async def test_printed_number_and_qrcode_resolve_same_pallet(self):
by_code = await InventoryDao.get_pallet_by_qr_token(self.db, '123', TestBatchScope.formal())
by_qr = await InventoryDao.get_pallet_by_qr_token(self.db, 'qr-one', TestBatchScope.formal())
self.assertIsNotNone(by_code)
self.assertEqual(by_code.pallet_id, by_qr.pallet_id)
self.assertEqual(by_code.qr_token, 'qr-one')
async def test_resolver_never_crosses_formal_and_test_scopes(self):
self.assertIsNone(await InventoryDao.get_pallet_by_qr_token(self.db, '456', TestBatchScope.formal()))
self.assertIsNone(await InventoryDao.get_pallet_by_qr_token(self.db, '123', make_test_scope(7)))
pallet = await InventoryDao.get_pallet_by_qr_token(self.db, '456', make_test_scope(7))
self.assertIsNotNone(pallet)
self.assertEqual(pallet.pallet_id, 2)
async def test_ambiguous_number_and_token_are_rejected(self):
self.session.execute(self.table.insert(), {'pallet_id': 3, 'pallet_code': '789', 'qr_token': '123', 'test_batch_id': None})
with self.assertRaises(ServiceException):
await InventoryDao.get_pallet_by_qr_token(self.db, '123', TestBatchScope.formal())
async def test_scan_returns_canonical_token_after_warehouse_authorization(self):
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory())),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
):
result = await FillingService.scan_filling_pallet_services(self.db, 'P-002', make_user(), TestBatchScope.formal())
self.assertEqual(result.qr_token, 'qr-2')
async def test_printed_number_does_not_bypass_warehouse_access(self):
with (
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=make_inventory())),
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问'))),
self.assertRaises(PermissionException),
):
await FillingService.scan_filling_pallet_services(self.db, 'P-002', make_user(), TestBatchScope.formal())
@@ -60,7 +60,7 @@ def integrity_error() -> IntegrityError:
def make_pallet_record( def make_pallet_record(
pallet_id: int, pallet_id: int,
capacity_packages: int = 20, capacity_packages: int = 100,
test_batch_id: int | None = None, test_batch_id: int | None = None,
) -> SimpleNamespace: ) -> SimpleNamespace:
return SimpleNamespace( return SimpleNamespace(
@@ -113,7 +113,7 @@ class TestPalletVoContract(unittest.TestCase):
BusinessUserOptionModel.model_validate(value) BusinessUserOptionModel.model_validate(value)
def test_pallet_edit_rejects_code_and_qr_token(self) -> None: def test_pallet_edit_rejects_code_and_qr_token(self) -> None:
base = {'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'} base = {'palletId': 1, 'capacityPackages': 100, 'assetStatus': '0'}
for identity_field in ({'palletCode': 'P-001'}, {'qrToken': 'fixed-token'}): for identity_field in ({'palletCode': 'P-001'}, {'qrToken': 'fixed-token'}):
with self.subTest(identity_field=identity_field), self.assertRaises(ValidationError): with self.subTest(identity_field=identity_field), self.assertRaises(ValidationError):
PalletEditModel.model_validate({**base, **identity_field}) PalletEditModel.model_validate({**base, **identity_field})
@@ -147,7 +147,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
pallet = BizPallet( pallet = BizPallet(
pallet_code='P-001', pallet_code='P-001',
qr_token='fixed-token', qr_token='fixed-token',
capacity_packages=20, capacity_packages=100,
asset_status='0', asset_status='0',
) )
inventory = BizPalletInventory( inventory = BizPalletInventory(
@@ -179,7 +179,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('test_batch_id', compile_sql(statement.whereclause)) self.assertNotIn('test_batch_id', compile_sql(statement.whereclause))
async def test_update_pallet_sql_never_contains_identity_fields(self) -> None: async def test_update_pallet_sql_never_contains_identity_fields(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=18, assetStatus='1', remark='停用') edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='1', remark='停用')
updated_at = datetime(2026, 7, 26, 10, 30) updated_at = datetime(2026, 7, 26, 10, 30)
await MasterDao.update_pallet( await MasterDao.update_pallet(
@@ -193,7 +193,7 @@ class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
statement = self.db.execute.await_args.args[0] statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement) sql = compile_sql(statement)
self.assertIn('UPDATE biz_pallet', sql) self.assertIn('UPDATE biz_pallet', sql)
self.assertIn('capacity_packages=18', sql) self.assertIn('capacity_packages=100', sql)
self.assertIn("asset_status='1'", sql) self.assertIn("asset_status='1'", sql)
self.assertIn("remark='停用'", sql) self.assertIn("remark='停用'", sql)
self.assertIn("update_by='admin'", sql) self.assertIn("update_by='admin'", sql)
@@ -357,7 +357,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
self.db = MagicMock(spec=AsyncSession) self.db = MagicMock(spec=AsyncSession)
self.admin = make_admin() self.admin = make_admin()
self.scope = TestBatchScope.formal() self.scope = TestBatchScope.formal()
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=20, remark='测试托盘') self.create = PalletCreateModel(palletCode='P-001', capacityPackages=100, remark='测试托盘')
self.fixed_time = datetime(2026, 7, 26, 11, 30) self.fixed_time = datetime(2026, 7, 26, 11, 30)
async def test_add_generates_server_token_and_initial_inventory_in_one_transaction(self) -> None: async def test_add_generates_server_token_and_initial_inventory_in_one_transaction(self) -> None:
@@ -393,7 +393,7 @@ class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
pallet = captured['pallet'] pallet = captured['pallet']
self.assertEqual('P-001', pallet.pallet_code) self.assertEqual('P-001', pallet.pallet_code)
self.assertEqual('server-fixed-token', pallet.qr_token) self.assertEqual('server-fixed-token', pallet.qr_token)
self.assertEqual(20, pallet.capacity_packages) self.assertEqual(100, pallet.capacity_packages)
self.assertEqual('0', pallet.asset_status) self.assertEqual('0', pallet.asset_status)
self.assertEqual('admin', pallet.create_by) self.assertEqual('admin', pallet.create_by)
self.assertEqual(self.fixed_time, pallet.create_time) self.assertEqual(self.fixed_time, pallet.create_time)
@@ -549,7 +549,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
self.assertEqual('托盘不存在', raised.exception.message) self.assertEqual('托盘不存在', raised.exception.message)
async def test_edit_updates_only_mutable_fields_with_server_audit(self) -> None: async def test_edit_updates_only_mutable_fields_with_server_audit(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=12, assetStatus='1', remark='停用') edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='1', remark='停用')
updated_at = datetime(2026, 7, 26, 12, 30) updated_at = datetime(2026, 7, 26, 12, 30)
update_pallet = AsyncMock() update_pallet = AsyncMock()
lock_actions: list[str] = [] lock_actions: list[str] = []
@@ -591,7 +591,11 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
edit = PalletEditModel(palletId=3, capacityPackages=11, assetStatus='0') edit = PalletEditModel(palletId=3, capacityPackages=11, assetStatus='0')
update_pallet = AsyncMock() update_pallet = AsyncMock()
with ( with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))), patch.object(
MasterDao,
'get_pallet',
new=AsyncMock(return_value=make_pallet_record(3, capacity_packages=11)),
),
patch.object( patch.object(
MasterDao, MasterDao,
'get_pallet_inventory', 'get_pallet_inventory',
@@ -608,7 +612,7 @@ class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
self.db.rollback.assert_awaited_once() self.db.rollback.assert_awaited_once()
async def test_edit_rejects_missing_inventory(self) -> None: async def test_edit_rejects_missing_inventory(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=20, assetStatus='0') edit = PalletEditModel(palletId=3, capacityPackages=100, assetStatus='0')
with ( with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))), patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)),
@@ -975,7 +979,7 @@ class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
self.db = MagicMock(spec=AsyncSession) self.db = MagicMock(spec=AsyncSession)
self.admin = make_admin() self.admin = make_admin()
self.scope = make_test_scope(9) self.scope = make_test_scope(9)
self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=20) self.create = PalletCreateModel(palletCode='P-T09', capacityPackages=100)
async def test_list_detail_and_qrcode_forward_scope_to_scoped_dao_reads(self) -> None: async def test_list_detail_and_qrcode_forward_scope_to_scoped_dao_reads(self) -> None:
query = PalletPageQueryModel() query = PalletPageQueryModel()
@@ -1049,7 +1053,7 @@ class TestPalletBatchScopeService(unittest.IsolatedAsyncioTestCase):
events.append('get-pallet') events.append('get-pallet')
return make_pallet_record(7, test_batch_id=9) return make_pallet_record(7, test_batch_id=9)
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0') edit = PalletEditModel(palletId=7, capacityPackages=100, assetStatus='0')
update_pallet = AsyncMock() update_pallet = AsyncMock()
with ( with (
patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)), patch.object(TestBatchAccessService, 'lock_active_batch', new=AsyncMock(side_effect=lock_batch)),
@@ -1205,7 +1209,7 @@ class TestPalletDaoBatchScope(unittest.IsolatedAsyncioTestCase):
self.assertIn('FOR UPDATE', sql) self.assertIn('FOR UPDATE', sql)
async def test_pallet_update_is_scoped_and_never_updates_batch_id(self) -> None: async def test_pallet_update_is_scoped_and_never_updates_batch_id(self) -> None:
edit = PalletEditModel(palletId=7, capacityPackages=20, assetStatus='0') edit = PalletEditModel(palletId=7, capacityPackages=100, assetStatus='0')
await MasterDao.update_pallet( await MasterDao.update_pallet(
self.db, self.db,
edit, edit,
@@ -0,0 +1,116 @@
"""日期报表查询与响应回归,仅读取生成的查询,不连接数据库。"""
import unittest
from datetime import date
from unittest.mock import AsyncMock, patch
from common.vo import PageModel
from module_business.dao.report_dao import ReportDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportQueryModel,
PalletInventoryReportModel, PalletInventoryReportQueryModel,
PalletTraceReportModel, PalletTraceReportQueryModel,
ReturnReportModel, ReturnReportQueryModel,
)
from module_business.service.report_service import ReportService
from tests.module_business.test_report_service import make_user, report_row
from tests.module_business.test_report_tunnel import joins, sql
REPORTS = (
('pallet_inventory', PalletInventoryReportModel, PalletInventoryReportQueryModel, 'active_filling'),
('consumption_report', ConsumptionReportModel, ConsumptionReportQueryModel, 'biz_filling_record'),
('return_report', ReturnReportModel, ReturnReportQueryModel, 'return_filling'),
('pallet_trace', PalletTraceReportModel, PalletTraceReportQueryModel, 'trace_filling'),
)
class TestReportDates(unittest.IsolatedAsyncioTestCase):
async def statement(self, name, query, batch_id=None):
page = PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False)
with patch.object(ReportDao, '_paginate', AsyncMock(return_value=page)) as paginate:
await getattr(ReportDao, f'get_{name}_list')(
None, query, TestBatchScope(batch_id, 'TEST' if batch_id else 'FORMAL', None, False),
)
return paginate.await_args.args[1]
async def test_dates_are_selected_from_the_referenced_filling_not_operation_time(self):
for name, _, query_type, alias in REPORTS:
with self.subTest(report=name):
statement = await self.statement(name, query_type())
for field in ('production_date', 'expiry_date'):
self.assertIn(field, statement.selected_columns.keys())
self.assertEqual(f'{alias}.{field}', sql(statement.selected_columns[field].element))
if name != 'pallet_inventory':
self.assertNotIn('biz_pallet_inventory', sql(statement))
expected_join = {
'pallet_inventory': 'active_filling.filling_id = biz_pallet_inventory.active_filling_id',
'consumption_report': 'biz_filling_record.filling_id = biz_usage_record.active_filling_id',
'return_report': 'return_filling.filling_id = biz_return_item.active_filling_id_snapshot',
}.get(name)
if expected_join:
self.assertIn(expected_join, sql(statement))
async def test_trace_resolves_original_filling_by_event_source_and_same_pallet(self):
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel())
self.assertIn('production_date', statement.selected_columns.keys())
query = sql(statement)
for source, reference in (
('FILLING', 'biz_pallet_event.source_id'),
('USAGE', 'trace_usage.active_filling_id'),
('RETURN', 'trace_return_item.active_filling_id_snapshot'),
('SHIPMENT', 'trace_shipment_item.active_filling_id'),
):
self.assertIn(f"WHEN (biz_pallet_event.source_type = '{source}') THEN {reference}", query)
for alias, id_field in (('trace_usage', 'usage_id'), ('trace_shipment_item', 'shipment_id')):
self.assertIn(f'{alias}.{id_field} = biz_pallet_event.source_id', query)
self.assertIn(f'{alias}.pallet_id = biz_pallet_event.pallet_id', query)
self.assertIn('trace_filling.pallet_id = biz_pallet_event.pallet_id', query)
self.assertNotIn('biz_pallet_inventory', query)
async def test_tunnel_entry_resolves_prior_filling_event_not_later_refill(self):
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel())
query = sql(statement)
self.assertIn('SELECT prior_filling_event.source_id', query)
self.assertIn('prior_filling_event.pallet_id = biz_pallet_event.pallet_id', query)
self.assertIn('prior_filling_event.event_seq < biz_pallet_event.event_seq', query)
self.assertIn("prior_filling_event.event_type = 'FILLING_CONFIRMED'", query)
self.assertIn('ORDER BY prior_filling_event.event_seq DESC', query)
self.assertIn('LIMIT 1', query)
async def test_trace_date_joins_are_optional_and_batch_isolated(self):
for batch_id, predicate in ((None, 'IS NULL'), (41, '= 41')):
with self.subTest(batch_id=batch_id):
statement = await self.statement('pallet_trace', PalletTraceReportQueryModel(), batch_id)
all_joins = {join.right.name: join for join in joins(statement)}
for alias, key in (
('trace_usage', 'usage_id'), ('trace_shipment_item', 'shipment_item_id'),
('trace_filling', 'filling_id'),
):
self.assertIn(alias, all_joins)
self.assertTrue(all_joins[alias].isouter)
self.assertIn(f'({alias}.{key} IS NULL OR {alias}.test_batch_id {predicate})', sql(statement))
self.assertIn(f'prior_filling_event.test_batch_id {predicate}', sql(statement))
async def test_response_preserves_original_date_and_null_as_iso_calendar_dates(self):
for name, model_type, query_type, _ in REPORTS:
for production, expiry, expected in (
(date(2025, 12, 31), date(2026, 12, 31), ('2025-12-31', '2026-12-31')),
(None, None, (None, None)),
):
with self.subTest(report=name, production=production):
row = dict(report_row(model_type), production_date=production, expiry_date=expiry)
page = PageModel(rows=[row], total=1, pageNum=1, pageSize=10, hasNext=False)
with patch.object(ReportDao, f'get_{name}_list', AsyncMock(return_value=page)):
result = await getattr(ReportService, f'get_{name}_list_services')(
None, query_type(), make_user(1), TestBatchScope.formal(),
)
actual = result.rows[0].model_dump(mode='json', by_alias=True)
self.assertIn('productionDate', actual)
self.assertIn('expiryDate', actual)
self.assertEqual(expected, (actual['productionDate'], actual['expiryDate']))
if __name__ == '__main__':
unittest.main()
@@ -1026,6 +1026,13 @@ class TestReportControllerContract(unittest.TestCase):
'eventType', 'eventType',
}, },
} }
for path in (
'/report/pallet-inventory/list', '/report/consumption/list',
'/report/return/list', '/report/pallet-trace/list',
):
expected_parameters[path] |= {'tunnelId', 'tunnelCode', 'tunnelName', 'traceNo'}
for path in ('/report/return/list', '/report/pallet-trace/list'):
expected_parameters[path].add('batchNo')
for path, parameters in expected_parameters.items(): for path, parameters in expected_parameters.items():
with self.subTest(path=path): with self.subTest(path=path):
operation_parameters = schema['paths'][path]['get']['parameters'] operation_parameters = schema['paths'][path]['get']['parameters']
@@ -0,0 +1,164 @@
"""报表隧道回归:检查真实生成的查询与服务输出,全程不连接数据库。"""
import unittest
from unittest.mock import AsyncMock, patch
from common.vo import PageModel
from pydantic import ValidationError
from sqlalchemy.dialects import mysql
from sqlalchemy.sql.selectable import Join
from module_business.dao.report_dao import ReportDao
from module_business.domain.enums import PalletEventType
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportQueryModel,
PalletInventoryReportQueryModel, PalletTraceReportModel,
PalletTraceReportQueryModel, ReturnReportQueryModel,
)
from module_business.service.report_service import ReportService
from tests.module_business.test_report_service import make_user, report_row
def sql(expression):
return ' '.join(str(expression.compile(
dialect=mysql.dialect(), compile_kwargs={'literal_binds': True},
)).split())
def joins(statement):
def walk(node):
if isinstance(node, Join):
yield from walk(node.left)
yield node
return list(walk(statement.get_final_froms()[0]))
class TestReportTunnelQueries(unittest.IsolatedAsyncioTestCase):
async def statement(self, method, query, scope=None, **access):
# 只替换分页执行边界,保留 DAO 的真实 SQLAlchemy 查询生成。
paginate = AsyncMock(return_value=PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False))
with patch.object(ReportDao, '_paginate', paginate):
await method(None, query, scope or TestBatchScope.formal(), **access)
return paginate.await_args.args[1]
async def test_usage_without_line_is_not_removed_by_inner_join(self):
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel())
line_join = next(join for join in joins(statement) if join.right.name == 'biz_production_line')
self.assertTrue(line_join.isouter, 'line_id=NULL 的使用记录会被 INNER JOIN 丢弃')
async def test_optional_line_rejects_existing_cross_batch_association(self):
for batch_id, condition in ((None, 'IS NULL'), (41, '= 41')):
with self.subTest(batch_id=batch_id):
scope = TestBatchScope(batch_id=batch_id, mode='TEST' if batch_id else 'FORMAL', status=None, writable=False)
statement = await self.statement(
ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(), scope, site_ids=[9],
)
where = sql(statement.whereclause)
self.assertIn(f'(biz_production_line.line_id IS NULL OR biz_production_line.test_batch_id {condition})', where)
self.assertIn(f'biz_usage_record.test_batch_id {condition}', where)
self.assertIn('biz_usage_record.site_id IN (9)', where)
async def test_four_reports_filter_tunnel_and_trace_without_requiring_pallet(self):
for method, query_type, trace_column in (
(ReportDao.get_pallet_inventory_list, PalletInventoryReportQueryModel, 'biz_pallet_inventory.trace_no'),
(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel, 'biz_usage_record.trace_no'),
(ReportDao.get_return_report_list, ReturnReportQueryModel, 'biz_return_item.trace_no_snapshot'),
(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel, 'coalesce(biz_pallet_event.trace_no, trace_return_item.trace_no_snapshot)'),
):
with self.subTest(report=method.__name__):
query = query_type(tunnelId=17, tunnelCode='T-01', tunnelName='一号隧道', traceNo='TR-MIX')
statement = await self.statement(method, query)
where = sql(statement.whereclause)
self.assertIn(f"{trace_column} = 'TR-MIX'", where)
self.assertIn("tunnel_code LIKE '%%T-01%%'", where)
self.assertIn("tunnel_name LIKE '%%一号隧道%%'", where)
self.assertIn('tunnel_id', where)
self.assertIn('= 17', where)
self.assertNotIn('pallet_code LIKE', where)
self.assertTrue({'tunnel_id', 'tunnel_code', 'tunnel_name', 'trace_no'}.issubset(statement.selected_columns.keys()))
for join in joins(statement):
if 'tunnel' in join.right.name:
self.assertTrue(join.isouter)
self.assertIn(f'({join.right.name}.tunnel_id IS NULL OR {join.right.name}.test_batch_id IS NULL)', where)
async def test_usage_batch_filter_and_display_prefer_record_snapshot(self):
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(batchNo='B-OLD'))
self.assertEqual(
'coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no)',
sql(statement.selected_columns.batch_no.element),
)
self.assertIn("coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no) LIKE '%%B-OLD%%'", sql(statement.whereclause))
async def test_return_reads_frozen_tunnel_trace_and_batch_after_inventory_changes(self):
statement = await self.statement(ReportDao.get_return_report_list, ReturnReportQueryModel(batchNo='B-OLD'))
self.assertIn('tunnel_id', statement.selected_columns.keys())
self.assertEqual('biz_return_item.tunnel_id_snapshot', sql(statement.selected_columns.get('tunnel_id')).split(' AS ')[0])
self.assertEqual('biz_return_item.trace_no_snapshot', sql(statement.selected_columns.get('trace_no')).split(' AS ')[0])
self.assertIn('coalesce(biz_return_item.batch_no_snapshot, return_filling.batch_no)', sql(statement))
self.assertIn('return_filling.filling_id = biz_return_item.active_filling_id_snapshot', sql(statement))
self.assertIn('(return_filling.filling_id IS NULL OR return_filling.test_batch_id IS NULL)', sql(statement.whereclause))
self.assertNotIn('biz_pallet_inventory', sql(statement))
async def test_trace_filters_both_historical_tunnels_and_event_batch(self):
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel(tunnelId=17, batchNo='B-OLD'))
where = sql(statement.whereclause)
self.assertIn('(coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot) = 17 OR biz_pallet_event.after_tunnel_id = 17)', where)
self.assertIn("coalesce(biz_pallet_event.batch_no_snapshot, trace_return_item.batch_no_snapshot) LIKE '%%B-OLD%%'", where)
self.assertIn('biz_pallet_event.batch_no_snapshot', sql(statement))
self.assertNotIn('biz_pallet_inventory', sql(statement))
async def test_return_event_missing_tunnel_reads_same_pallet_return_snapshot(self):
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel())
self.assertEqual(
'coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot)',
sql(statement.selected_columns.before_tunnel_id.element),
)
query = sql(statement)
self.assertIn("biz_pallet_event.source_type = 'RETURN'", query)
self.assertIn('trace_return_item.return_id = biz_pallet_event.source_id', query)
self.assertIn('trace_return_item.pallet_id = biz_pallet_event.pallet_id', query)
self.assertIn('(trace_return_item.return_item_id IS NULL OR trace_return_item.test_batch_id IS NULL)', query)
class TestReportTunnelOutput(unittest.IsolatedAsyncioTestCase):
async def test_consumption_service_keeps_line_less_mixed_pallet_details(self):
rows = [dict(report_row(ConsumptionReportModel), usage_id=i, pallet_id=i,
pallet_code=f'P-{i}', line_id=None, line_name=None, tunnel_id=17,
tunnel_code='T-01', tunnel_name='一号隧道', trace_no='TR-MIX',
batch_no=batch, batch_no_snapshot=batch, used_packages=qty)
for i, batch, qty in ((1, 'B-A', 30), (2, 'B-B', 20))]
page = PageModel(rows=rows, total=2, pageNum=1, pageSize=10, hasNext=False)
with patch.object(ReportDao, 'get_consumption_report_list', AsyncMock(return_value=page)):
try:
result = await ReportService.get_consumption_report_list_services(
None, ConsumptionReportQueryModel(traceNo='TR-MIX'), make_user(1), TestBatchScope.formal(),
)
except ValidationError as exc:
self.fail(f'合法的无产线使用记录被响应模型拒绝:{exc}')
actual = [row.model_dump(by_alias=True) for row in result.rows]
self.assertEqual([30, 20], [row['usedPackages'] for row in actual])
self.assertEqual(['B-A', 'B-B'], [row['batchNoSnapshot'] for row in actual])
self.assertEqual(['TR-MIX', 'TR-MIX'], [row['traceNo'] for row in actual])
self.assertEqual([None, None], [row['lineId'] for row in actual])
def test_trace_quantities_separate_usage_and_confirmed_return(self):
for event, before, after, used, returned in (
('TUNNEL_ENTERED', 100, 100, 0, 0),
('USAGE_RECORDED', 100, 70, 30, 0),
('USAGE_RECORDED', 100, 80, 20, 0),
('RETURN_REQUESTED', 70, 70, 0, 0),
('RETURN_CONFIRMED', 70, 70, 0, 70),
('RETURN_CONFIRMED', 0, 0, 0, 0),
):
with self.subTest(event=event, before=before):
model = PalletTraceReportModel.model_validate(dict(
report_row(PalletTraceReportModel), event_type=PalletEventType(event),
before_remaining_packages=before, after_remaining_packages=after,
)).model_dump(by_alias=True)
self.assertEqual(used, model.get('usedPackages'))
self.assertEqual(returned, model.get('returnedPackages'))
if __name__ == '__main__':
unittest.main()
@@ -429,7 +429,7 @@ class ReturnControllerContractRedTest(unittest.TestCase):
@staticmethod @staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]: def _routes() -> dict[tuple[str, str], APIRoute]:
return { return {
(route.path, next(iter(route.methods))): route (route.path_format, next(iter(route.methods))): route
for route in return_controller_module.return_controller.routes for route in return_controller_module.return_controller.routes
if isinstance(route, APIRoute) if isinstance(route, APIRoute)
} }
@@ -468,7 +468,7 @@ class ReturnControllerContractRedTest(unittest.TestCase):
def test_static_routes_permissions_current_user_and_write_logs(self) -> None: def test_static_routes_permissions_current_user_and_write_logs(self) -> None:
routes = self._routes() routes = self._routes()
paths = [route.path for route in return_controller_module.return_controller.routes] paths = [route.path_format for route in return_controller_module.return_controller.routes]
detail_index = paths.index('/business/return/{return_id}') detail_index = paths.index('/business/return/{return_id}')
for static_path in ( for static_path in (
'/business/return/list', '/business/return/list',
@@ -1731,6 +1731,14 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
pallets = [make_pallet(2), make_pallet(5)] pallets = [make_pallet(2), make_pallet(5)]
filling = make_filling(2, warehouse_id=3) filling = make_filling(2, warehouse_id=3)
events: list[BizPalletEvent] = [] events: list[BizPalletEvent] = []
for inventory in inventories:
inventory.tunnel_id = 31
inventory.trace_no = 'TRACE-ORIGINAL'
inventory.site_arrived_at = datetime(2026, 7, 20)
for item in order.items:
item.tunnel_id_snapshot = 31
item.trace_no_snapshot = 'TRACE-ORIGINAL'
tunnel = SimpleNamespace(tunnel_id=31, site_id=9, current_trace_no='TRACE-ORIGINAL', test_batch_id=None)
async def add_event( async def add_event(
_db: AsyncSession, _db: AsyncSession,
@@ -1771,6 +1779,8 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
), ),
), ),
patch.object(OperationDao, 'update_return_order', new=AsyncMock()), patch.object(OperationDao, 'update_return_order', new=AsyncMock()),
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=tunnel)),
patch.object(MasterDao, 'count_tunnel_trace_members', new=AsyncMock(return_value=0)),
patch.object(OperationDao, 'update_filling_record', new=AsyncMock()) as update_filling, patch.object(OperationDao, 'update_filling_record', new=AsyncMock()) as update_filling,
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)), patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
patch.object(return_service_module, 'datetime') as service_datetime, patch.object(return_service_module, 'datetime') as service_datetime,
@@ -1804,6 +1814,9 @@ class ReturnConfirmTransactionRedTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([PalletEventType.RETURN_CONFIRMED.value] * 2, [event.event_type for event in events]) self.assertEqual([PalletEventType.RETURN_CONFIRMED.value] * 2, [event.event_type for event in events])
self.assertEqual([7, 7], [event.after_warehouse_id for event in events]) self.assertEqual([7, 7], [event.after_warehouse_id for event in events])
self.assertEqual([None, None], [event.after_site_id for event in events]) self.assertEqual([None, None], [event.after_site_id for event in events])
self.assertEqual([31, 31], [event.before_tunnel_id for event in events])
self.assertEqual(['TRACE-ORIGINAL', 'TRACE-ORIGINAL'], [event.trace_no for event in events])
self.assertTrue(all(inventory.site_arrived_at is None for inventory in inventories))
self.db.commit.assert_awaited_once() self.db.commit.assert_awaited_once()
async def test_confirm_resolves_authorized_return_route_before_key_lookup(self) -> None: async def test_confirm_resolves_authorized_return_route_before_key_lookup(self) -> None:
@@ -0,0 +1,27 @@
import unittest
from starlette.routing import Match
from module_business.controller.filling_controller import filling_controller
from module_business.controller.inbound_controller import inbound_controller
from module_business.controller.return_controller import return_controller
from module_business.controller.shipment_controller import shipment_controller
from module_business.controller.usage_controller import usage_controller
class TestScanPathIdentity(unittest.TestCase):
def test_scan_paths_preserve_printed_pallet_number_with_slashes(self) -> None:
for router in (
inbound_controller,
filling_controller,
shipment_controller,
usage_controller,
return_controller,
):
route = next(route for route in router.routes if '/scan/' in route.path)
# ASGI path 已由HTTP层解码,查询和片段字符不能再被当成URL语法。
path = route.path.split('/scan/')[0] + '/scan/托盘/2026?批#1'
with self.subTest(path=path):
match, child = route.matches({'type': 'http', 'method': 'GET', 'path': path, 'root_path': ''})
self.assertEqual(match, Match.FULL)
self.assertEqual(child['path_params']['qr_token'], '托盘/2026?批#1')
@@ -0,0 +1,244 @@
"""标签只读回归:执行真实服务/DAO,以记录型会话替代数据库连接。"""
import base64
import importlib
import importlib.util
import json
import unittest
from datetime import date
from typing import Any
from unittest.mock import MagicMock, patch
from fastapi import FastAPI, Request
from sqlalchemy.dialects import mysql
from common.aspect.interface_auth import CheckUserInterfaceAuth
from exceptions.exception import PermissionException, ServiceException
from module_business.controller import shipment_controller as shipment_controller_module
from module_business.dependency.test_batch_dependency import TestBatchScopeResolver
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizShipmentItem, BizShipmentOrder
from module_business.service.pallet_service import PalletService
from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope
LabelRecords = tuple[BizShipmentOrder, BizShipmentItem, BizPallet, BizFillingRecord, BizCustomerSite, BizCustomer]
def sql(statement: Any) -> str:
return ' '.join(str(statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})).split())
class ReadOnlySession:
"""仅支持 SELECT,不提供写入、提交或真实连接能力。"""
def __init__(self, *results: Any) -> None:
self.results = list(results)
self.statements = []
async def execute(self, statement: Any) -> MagicMock:
assert statement.is_select, '标签不得产生业务写入'
self.statements.append(sql(statement))
assert self.results, '出现非预期查询'
value = self.results.pop(0)
result = MagicMock()
result.scalar_one_or_none.return_value = value
result.scalars.return_value.all.return_value = value
result.all.return_value = value
return result
class ShipmentLabelsTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
spec = importlib.util.find_spec('module_business.service.shipment_label_service')
self.assertIsNotNone(spec, '缺少可执行的发货标签服务')
self.service = importlib.import_module('module_business.service.shipment_label_service').ShipmentLabelService
def records(self, status: str = 'CONFIRMED', batch_id: int | None = None, dates: bool = True) -> LabelRecords:
order = BizShipmentOrder(shipment_id=12, shipment_no='原发货-12', source_warehouse_id=7,
target_site_id=9, status=status, test_batch_id=batch_id)
item = BizShipmentItem(shipment_item_id=1, shipment_id=12, pallet_id=5,
active_filling_id=105, package_qty=37, test_batch_id=batch_id)
pallet = BizPallet(pallet_id=5, pallet_code='原托盘-005', qr_token='fixed-qr-005',
asset_status='0', capacity_packages=100, test_batch_id=batch_id)
filling = BizFillingRecord(filling_id=105, pallet_id=5, strain_id=3,
strain_name_snapshot='<img src=x onerror=alert(1)>',
strain_code_snapshot='S03', batch_no='原生产批号-03',
active_slot='ACTIVE' if status == 'DRAFT' else None,
production_date=date(2026, 9, 1) if dates else None,
expiry_date=date(2027, 9, 1) if dates else None,
test_batch_id=batch_id)
site = BizCustomerSite(site_id=9, customer_id=4, site_name='原现场', test_batch_id=batch_id)
customer = BizCustomer(customer_id=4, customer_name='客户 <script>攻击</script>', test_batch_id=batch_id)
order.items = [item]
return order, item, pallet, filling, site, customer
def session(self, records: LabelRecords, *, admin: bool = True, qty: int | None = None) -> ReadOnlySession:
order, item, pallet, filling, site, customer = records
current_qty = 62 if qty is None else qty
detail_items = [(item, current_qty)] if order.status == 'DRAFT' else [item]
rows = [(item, pallet, filling, current_qty if order.status == 'DRAFT' else item.package_qty)]
values = [order, detail_items, site, customer, rows]
return ReadOnlySession(*([] if admin else [[7], [9]]), *values)
async def test_confirmed_uses_original_filling_and_package_snapshot_without_inventory(self) -> None:
records = self.records()
db = self.session(records)
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
self.assertEqual(1, len(labels))
payload = labels[0].model_dump(mode='json', by_alias=True)
self.assertEqual('原托盘-005', payload['palletCode'])
self.assertEqual('原发货-12', payload['shipmentNo'])
self.assertEqual('原生产批号-03', payload['batchNo'])
self.assertEqual('客户 <script>攻击</script>', payload['customerName'])
self.assertEqual('原现场', payload['siteName'])
self.assertEqual('<img src=x onerror=alert(1)>', payload['strainName'])
self.assertEqual(37, payload['packageQty'])
self.assertEqual('2026-09-01', payload['productionDate'])
self.assertEqual('2027-09-01', payload['expiryDate'])
prefix, encoded = payload['qrCodeDataUrl'].split(',', 1)
self.assertEqual('data:image/png;base64', prefix)
self.assertEqual(PalletService.render_qr_png('fixed-qr-005'), base64.b64decode(encoded))
self.assertNotIn('biz_pallet_inventory', ' '.join(db.statements))
self.assertIn('biz_shipment_item.active_filling_id = biz_filling_record.filling_id', db.statements[-1])
self.assertNotIn("active_slot = 'ACTIVE'", db.statements[-1])
self.assertEqual([], db.results)
async def test_draft_uses_current_quantity_and_active_filling_with_null_dates(self) -> None:
records = self.records('DRAFT', dates=False)
records[1].active_filling_id = None
records[1].package_qty = None
db = self.session(records)
labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
self.assertEqual(62, labels[0].package_qty)
self.assertIsNone(labels[0].production_date)
self.assertIsNone(labels[0].expiry_date)
statement = db.statements[-1]
for fragment in (
'biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id',
'biz_pallet_inventory.warehouse_id = 7',
"biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'",
'biz_pallet_inventory.site_id IS NULL',
'biz_pallet_inventory.current_line_id IS NULL',
'biz_pallet_inventory.remaining_packages > 0',
"biz_filling_record.active_slot = 'ACTIVE'",
):
self.assertIn(fragment, statement)
async def test_both_business_scopes_and_each_related_batch_are_in_real_queries(self) -> None:
for scope in (TestBatchScope.formal(), make_test_scope(31)):
for status in ('DRAFT', 'CONFIRMED'):
with self.subTest(scope=scope, status=status):
db = self.session(self.records(status, scope.batch_id), admin=False)
await self.service.get_labels_services(db, 12, make_current_user(), scope)
self.assertIn('source_warehouse_id IN (7)', db.statements[2])
self.assertIn('target_site_id IN (9)', db.statements[2])
combined = ' '.join(db.statements)
tables = ['biz_shipment_order', 'biz_shipment_item', 'biz_pallet',
'biz_filling_record', 'biz_customer_site', 'biz_customer']
if status == 'DRAFT':
tables.append('biz_pallet_inventory')
for table in tables:
predicate = 'IS NULL' if scope.batch_id is None else '= 31'
self.assertIn(f'{table}.test_batch_id {predicate}', combined)
self.assertNotIn('FOR UPDATE', combined)
async def test_inaccessible_order_stops_before_related_queries_or_qr_rendering(self) -> None:
for warehouses, sites in (([], [9]), ([7], []), ([8], [10])):
with self.subTest(warehouses=warehouses, sites=sites):
db = ReadOnlySession(warehouses, sites, None)
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未授权生成二维码')),
self.assertRaises(PermissionException),
):
await self.service.get_labels_services(db, 12, make_current_user(), TestBatchScope.formal())
self.assertEqual(3, len(db.statements))
async def test_cross_batch_relations_are_rejected_before_qr_generation(self) -> None:
for index in range(6):
with self.subTest(record=index):
records = self.records()
records[index].test_batch_id = 31
db = self.session(records)
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('跨批次生成二维码')),
self.assertRaises(ServiceException),
):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_incomplete_label_does_not_print_partial_or_fall_back_to_current_inventory(self) -> None:
for status in ('DRAFT', 'CONFIRMED'):
for missing in ('pallet', 'filling', 'quantity', 'snapshot'):
with self.subTest(status=status, missing=missing):
records = self.records(status)
if missing == 'snapshot':
records[3].pallet_id = 99
db = self.session(records)
row = list(db.results[-1][0])
if missing != 'snapshot':
row[{'pallet': 1, 'filling': 2, 'quantity': 3}[missing]] = None
db.results[-1] = [tuple(row)]
with self.assertRaises(ServiceException):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_missing_later_pallet_prevents_any_qr_generation(self) -> None:
records = self.records()
db = self.session(records)
second = BizShipmentItem(shipment_item_id=2, shipment_id=12, pallet_id=6,
active_filling_id=106, package_qty=20, test_batch_id=None)
db.results[1].append(second)
db.results[-1].append((second, None, None, 20))
with (
patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未完成校验已生成二维码')),
self.assertRaises(ServiceException),
):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_empty_shipment_is_not_printable(self) -> None:
records = self.records()
db = self.session(records)
db.results[1] = []
db.results[-1] = []
with self.assertRaises(ServiceException):
await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal())
async def test_route_serializes_label_dates_and_prevents_cross_scope_browser_cache(self) -> None:
db = self.session(self.records())
response = await shipment_controller_module.get_business_shipment_labels(
Request({'type': 'http'}), 12, db, make_admin(), TestBatchScope.formal(),
)
payload = json.loads(response.body)
self.assertEqual(200, response.status_code)
self.assertEqual('2026-09-01', payload['data'][0]['productionDate'])
self.assertEqual('CONFIRMED', payload['data'][0]['shipmentStatus'])
self.assertTrue(payload['data'][0]['qrCodeDataUrl'].startswith('data:image/png;base64,'))
self.assertEqual('no-store', response.headers.get('Cache-Control'))
async def test_draft_rejects_disabled_closed_or_excess_quantity(self) -> None:
for field in ('asset_status', 'active_slot', 'capacity_packages'):
with self.subTest(field=field):
records = self.records('DRAFT')
target = records[3] if field == 'active_slot' else records[2]
setattr(target, field, {'asset_status': '1', 'active_slot': None, 'capacity_packages': 20}[field])
with self.assertRaises(ServiceException):
await self.service.get_labels_services(
self.session(records), 12, make_admin(), TestBatchScope.formal(),
)
class ShipmentLabelRouteTest(unittest.TestCase):
def test_label_route_reuses_query_permission_and_read_scope(self) -> None:
app = FastAPI()
app.include_router(shipment_controller_module.shipment_controller)
routes = [route for route in app.routes if route.path == '/business/shipment/{shipment_id}/labels']
self.assertEqual(1, len(routes), '缺少标签 GET 路由')
route = routes[0]
self.assertEqual({'GET'}, route.methods)
checks = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, CheckUserInterfaceAuth)]
self.assertEqual(['business:shipment:query'], [check.perm for check in checks])
scopes = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, TestBatchScopeResolver)]
self.assertEqual([False], [scope.write for scope in scopes])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,140 @@
import unittest
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import PermissionException, ServiceException
from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomerSite
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel
from module_business.service.shipment_receipt_service import ShipmentReceiptService
from tests.module_business.test_shipment_service import make_inventory, make_pallet, make_shipment, make_user
class TestShipmentReceipt(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.user = make_user()
self.scope = TestBatchScope.formal()
self.order = make_shipment(status='CONFIRMED', pallet_ids=(2, 5))
self.order.receipt_state = 'PENDING'
self.order.confirmed_at = datetime(2026, 10, 8, 8)
self.inventories = [
make_inventory(i, warehouse_id=None, site_id=9, flow_state='IN_TRANSIT', remaining_packages=100)
for i in (2, 5)
]
for item in self.order.items:
item.active_filling_id = 100 + item.pallet_id
item.package_qty = 100
self.events = []
self.site_access = AsyncMock(return_value=True)
self.patches = [
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(AccessDao, 'user_has_site', new=self.site_access),
patch.object(OperationDao, 'get_shipment_for_update', new=AsyncMock(return_value=self.order)),
patch.object(
MasterDao,
'get_site',
new=AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)),
),
patch.object(InventoryDao, 'get_inventories_for_update', new=AsyncMock(return_value=self.inventories)),
patch.object(
InventoryDao,
'get_pallets_by_ids',
new=AsyncMock(return_value=[make_pallet(i, capacity=100) for i in (2, 5)]),
),
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=self.capture_event)),
]
for patcher in self.patches:
patcher.start()
self.addCleanup(patcher.stop)
async def capture_event(self, _db, inventory, event, _scope):
self.events.append(event)
return event
def command(self, ids=None, key='receipt-key'):
return ShipmentReceiptCommandModel(scannedPalletIds=ids if ids is not None else [5, 2], idempotencyKey=key)
async def receive(self, command=None):
return await ShipmentReceiptService.confirm_services(
self.db, 12, command or self.command(), self.user, self.scope
)
async def test_receipt_keeps_original_pallet_filling_and_quantity(self) -> None:
await self.receive()
self.assertEqual(self.order.receipt_state, 'RECEIVED')
self.assertEqual(self.order.received_by, 7)
self.assertIsInstance(self.order.received_at, datetime)
self.assertEqual(
[(i.pallet_id, i.active_filling_id, i.remaining_packages, i.flow_state) for i in self.inventories],
[
(2, 102, 100, 'AT_SITE_UNUSED'),
(5, 105, 100, 'AT_SITE_UNUSED'),
],
)
self.assertTrue(all(i.site_arrived_at == self.order.received_at for i in self.inventories))
self.assertEqual(
[(e.event_type, e.before_state, e.after_state) for e in self.events],
[
('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'),
('SHIPMENT_RECEIVED', 'IN_TRANSIT', 'AT_SITE_UNUSED'),
],
)
self.db.commit.assert_awaited_once()
async def test_retry_after_later_consumption_does_not_restore_quantity(self) -> None:
await self.receive()
self.inventories[0].remaining_packages = 80
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
await self.receive(self.command(ids=[2, 5]))
self.assertEqual(self.inventories[0].remaining_packages, 80)
self.assertEqual(len(self.events), 2)
self.db.commit.assert_awaited_once()
async def test_received_order_rejects_different_confirmation_key(self) -> None:
await self.receive()
with self.assertRaises(ServiceException):
await self.receive(self.command(key='different'))
self.assertEqual(len(self.events), 2)
async def test_missing_or_unexpected_scans_do_not_change_inventory(self) -> None:
for ids in ([2], [2, 5, 6], [2, 6]):
with self.subTest(ids=ids), self.assertRaises(ServiceException):
await self.receive(self.command(ids=ids))
self.assertEqual(self.order.receipt_state, 'PENDING')
self.assertFalse(self.events)
self.db.commit.assert_not_awaited()
async def test_target_site_access_is_required_even_with_warehouse_permissions(self) -> None:
self.site_access.return_value = False
with self.assertRaises(PermissionException):
await self.receive()
self.assertFalse(self.events)
self.db.commit.assert_not_awaited()
async def test_draft_and_legacy_cannot_be_received_as_new_shipments(self) -> None:
for status, receipt_state in [('DRAFT', 'PENDING'), ('CONFIRMED', 'LEGACY')]:
self.order.status = status
self.order.receipt_state = receipt_state
with self.subTest(status=status, receipt_state=receipt_state), self.assertRaises(ServiceException):
await self.receive()
self.assertFalse(self.events)
async def test_changed_material_snapshot_is_rejected_before_any_mutation(self) -> None:
self.inventories[1].active_filling_id = 999
with self.assertRaises(ServiceException):
await self.receive()
self.assertEqual(self.inventories[0].flow_state, 'IN_TRANSIT')
self.assertFalse(self.events)
async def test_cross_test_batch_inventory_is_rejected(self) -> None:
self.inventories[1].test_batch_id = 3
with self.assertRaises(ServiceException):
await self.receive()
self.assertFalse(self.events)
@@ -0,0 +1,467 @@
"""收货独立回归:真实内存查询/序列化,以及确认服务的确定性并发边界。
从 backend 目录运行(不连接业务数据库、不生成日志文件或 pyc):
PYTHONDONTWRITEBYTECODE=1 LOG_FILE_ENABLED=false <python> -B -m unittest \
tests.module_business.test_shipment_receipt_reads -v
SQLite 仅验证读取;确认测试的锁替身不代表 MySQL 的实际锁调度。
"""
import asyncio
import json
import unittest
from datetime import date, datetime
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
from fastapi import FastAPI
from sqlalchemy import create_engine, event, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from sqlalchemy.schema import CreateTable
from common.annotation.log_annotation import Log
from common.aspect.pre_auth import PreAuth
from common.context import RequestContext
from config.database import async_engine as business_engine
from config.get_db import get_db
from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.log_service import LogQueueService
from module_admin.service.login_service import LoginService
from module_business.controller.shipment_receipt_controller import shipment_receipt_controller
from module_business.dao.access_dao import AccessDao
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.dependency.test_batch_dependency import TestBatchScopeResolver
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import (
BizCustomerSite, BizPallet, BizUserSite, BizUserWarehouse, BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord, BizPalletInventory, BizShipmentItem, BizShipmentOrder,
)
from module_business.entity.vo.shipment_receipt_vo import ShipmentReceiptCommandModel, ShipmentReceiptQueryModel
from module_business.service.shipment_receipt_service import ShipmentReceiptService
from module_business.service.test_batch_access_service import TestBatchAccessService
NOW = datetime(2026, 10, 8, 8)
def forbid_business_connection(*_args, **_kwargs):
raise AssertionError('收货测试禁止连接业务数据库')
def setUpModule():
event.listen(business_engine.sync_engine, 'do_connect', forbid_business_connection)
def tearDownModule():
event.remove(business_engine.sync_engine, 'do_connect', forbid_business_connection)
def user(user_id=7):
return CurrentUserModel(
permissions=['business:shipment:receive'], roles=[],
user=UserInfoModel(userId=user_id, userName=f'user-{user_id}'),
)
def batch_scope(batch_id=None):
if batch_id is None:
return TestBatchScope.formal()
return TestBatchScope(batch_id=batch_id, mode='TEST', status='ACTIVE', writable=True)
def order_values(shipment_id=12, site_id=9, batch_id=None, state='PENDING', status='CONFIRMED'):
return dict(
shipment_id=shipment_id, shipment_no=f'SH-{shipment_id}', source_warehouse_id=7,
target_site_id=site_id, test_batch_id=batch_id, status=status, receipt_state=state,
confirmed_at=NOW, confirmed_by=7,
)
def item_values(shipment_id, pallet_id, batch_id=None):
return dict(
shipment_item_id=shipment_id * 100 + pallet_id, shipment_id=shipment_id,
pallet_id=pallet_id, active_filling_id=100 + pallet_id, package_qty=100, test_batch_id=batch_id,
)
def pallet_values(pallet_id, batch_id=None):
return dict(
pallet_id=pallet_id, pallet_code=f'P-{pallet_id}', qr_token=f'QR-{pallet_id}',
capacity_packages=100, asset_status='0', test_batch_id=batch_id,
)
def filling_values(pallet_id, batch_id=None):
return dict(
filling_id=100 + pallet_id, filling_no=f'F-{pallet_id}', pallet_id=pallet_id,
warehouse_id=7, strain_id=1, strain_code_snapshot='S-1', strain_name_snapshot='原菌种',
batch_no=f'B-OLD-{pallet_id}', production_date=date(2026, 10, 1), expiry_date=date(2026, 11, 1),
package_spec='标准包', initial_packages=100, active_slot='ACTIVE',
operator_id=7, operated_at=NOW, idempotency_key=f'fill-{pallet_id}', request_hash='f' * 64,
test_batch_id=batch_id,
)
def inventory_values(pallet_id, site_id=9, batch_id=None):
return dict(
pallet_id=pallet_id, active_filling_id=100 + pallet_id, site_id=site_id,
warehouse_id=None, tunnel_id=None, trace_no=None, current_line_id=None,
flow_state='IN_TRANSIT', remaining_packages=100, version=3, test_batch_id=batch_id,
)
class MemorySession:
"""保留真实 SQLAlchemy ORM Row,仅适配 DAO 所需的异步 execute 接口。"""
def __init__(self, session):
self.session = session
async def execute(self, statement):
return self.session.execute(statement)
class TestShipmentReceiptReads(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.engine = create_engine('sqlite+pysqlite:///:memory:')
self.addCleanup(self.engine.dispose)
# 仅建读取涉及的表;外键/迁移契约由独立 SQL 测试负责。
models = (BizCustomerSite, BizUserSite, BizUserWarehouse, BizWarehouse, BizPallet,
BizFillingRecord, BizPalletInventory, BizShipmentOrder, BizShipmentItem)
with self.engine.begin() as connection:
for model in models:
connection.execute(CreateTable(model.__table__, include_foreign_key_constraints=[]))
for site_id, batch_id in ((9, None), (10, None), (19, 41), (29, 42)):
connection.execute(BizCustomerSite.__table__.insert(), dict(
site_id=site_id, customer_id=1, site_code=f'SITE-{site_id}',
site_name=f'现场{site_id}', status='0', test_batch_id=batch_id,
))
connection.execute(BizWarehouse.__table__.insert(), dict(
warehouse_id=7, warehouse_code='W-7', warehouse_name='原仓库', status='0',
))
for site_id in (9, 19, 29):
connection.execute(BizUserSite.__table__.insert(), dict(user_id=7, site_id=site_id))
connection.execute(BizUserWarehouse.__table__.insert(), dict(user_id=8, warehouse_id=7))
for pallet_id, site_id, batch_id in ((2, 9, None), (5, 9, None), (3, 10, None), (52, 19, 41), (62, 29, 42)):
connection.execute(BizPallet.__table__.insert(), pallet_values(pallet_id, batch_id))
connection.execute(BizFillingRecord.__table__.insert(), filling_values(pallet_id, batch_id))
connection.execute(BizPalletInventory.__table__.insert(), inventory_values(pallet_id, site_id, batch_id))
orders = [order_values(), order_values(13, 10), order_values(14, status='DRAFT'),
order_values(15, 19, 41), order_values(16, 29, 42),
order_values(17, state='RECEIVED'), order_values(18, state='LEGACY')]
for order in orders:
connection.execute(BizShipmentOrder.__table__.insert(), order)
for shipment_id, pallet_ids, batch_id in ((12, (2, 5), None), (13, (3,), None),
(15, (52,), 41), (16, (62,), 42),
(17, (2,), None), (18, (5,), None)):
for pallet_id in pallet_ids:
connection.execute(BizShipmentItem.__table__.insert(), item_values(shipment_id, pallet_id, batch_id))
self.session = Session(self.engine, autoflush=False)
self.addCleanup(self.session.close)
self.db = MemorySession(self.session)
self.user = user()
self.scope = batch_scope()
async def listing(self, **query):
return await ShipmentReceiptService.list_services(
self.db, ShipmentReceiptQueryModel(**query), self.user, self.scope,
)
async def detail(self, shipment_id=12):
return await ShipmentReceiptService.detail_services(self.db, shipment_id, self.user, self.scope)
async def scan(self, identifier='QR-2', shipment_id=12):
return await ShipmentReceiptService.scan_services(self.db, shipment_id, identifier, self.user, self.scope)
async def test_list_executes_real_query_and_pageutil_serialization(self):
page = await self.listing(pageSize=1)
payload = page.model_dump(by_alias=True, mode='json')
self.assertEqual(payload['total'], 3)
self.assertTrue(payload['hasNext'])
self.assertEqual(payload['rows'][0]['shipmentId'], 12)
self.assertEqual(payload['rows'][0]['receiptState'], 'PENDING')
self.assertNotIn('testBatchId', json.dumps(payload))
next_page = await self.listing(pageSize=1, pageNum=2)
self.assertEqual(next_page.rows[0].shipment_id, 17)
async def test_list_filters_original_route_receipt_state_and_confirmed_date(self):
page = await self.listing(shipmentNo='SH-12', sourceWarehouseId=7, targetSiteId=9,
receiptState='PENDING', beginTime='2026-10-08', endTime='2026-10-08')
self.assertEqual([row.shipment_id for row in page.rows], [12])
self.assertEqual((await self.listing(beginTime='2026-10-09')).total, 0)
async def test_list_keeps_formal_and_each_test_batch_separate(self):
for batch_id, expected in ((None, [12, 17, 18]), (41, [15]), (42, [16])):
with self.subTest(batch_id=batch_id):
self.scope = batch_scope(batch_id)
self.assertEqual([row.shipment_id for row in (await self.listing()).rows], expected)
async def test_warehouse_only_user_cannot_list_detail_or_scan_receipts(self):
self.user = user(8)
self.assertEqual((await self.listing()).total, 0)
for operation in (self.detail, self.scan):
with self.subTest(operation=operation.__name__), self.assertRaises(PermissionException):
await operation()
async def test_target_site_filter_cannot_expand_user_access(self):
with self.assertRaises(PermissionException):
await self.listing(targetSiteId=10)
with self.assertRaises(PermissionException):
await self.detail(13)
with self.assertRaises(PermissionException):
await self.scan('QR-3', 13)
async def test_detail_and_scan_cannot_read_other_batch_order(self):
with self.assertRaises(PermissionException):
await self.detail(15)
with self.assertRaises(PermissionException):
await self.scan('QR-52', 15)
self.scope = batch_scope(41)
self.assertEqual((await self.detail(15)).items[0].pallet_id, 52)
self.assertEqual((await self.scan('QR-52', 15)).pallet_id, 52)
with self.assertRaises(PermissionException):
await self.detail(16)
async def test_detail_reads_frozen_filling_after_pallet_refill(self):
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(
active_slot=None, closed_at=NOW,
))
replacement = filling_values(2)
replacement.update(filling_id=202, filling_no='F-NEW-2', batch_no='B-NEW', idempotency_key='new-fill')
self.session.execute(BizFillingRecord.__table__.insert(), replacement)
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(
active_filling_id=202, remaining_packages=20, flow_state='AT_SITE_PARTIAL',
))
item = (await self.detail(17)).items[0]
self.assertEqual((item.active_filling_id, item.package_qty, item.batch_no), (102, 100, 'B-OLD-2'))
self.assertEqual(item.production_date, date(2026, 10, 1))
self.assertEqual(item.expiry_date, date(2026, 11, 1))
async def test_legacy_detail_preserves_unknown_dates(self):
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 105).values(
production_date=None, expiry_date=None,
))
detail = await self.detail(18)
self.assertEqual(detail.receipt_state, 'LEGACY')
self.assertIsNone(detail.items[0].production_date)
self.assertIsNone(detail.items[0].expiry_date)
async def test_detail_rejects_original_filling_from_another_batch(self):
self.session.execute(update(BizFillingRecord).where(BizFillingRecord.filling_id == 102).values(test_batch_id=41))
with self.assertRaises(ServiceException):
await self.detail()
async def test_detail_rejects_filling_belonging_to_another_pallet(self):
self.session.execute(update(BizShipmentItem).where(BizShipmentItem.shipment_item_id == 1202).values(active_filling_id=105))
with self.assertRaises(ServiceException):
await self.detail()
async def test_scan_accepts_printed_pallet_and_returns_canonical_identity(self):
for identifier in ('QR-2', ' P-2 '):
with self.subTest(identifier=identifier):
item = await self.scan(identifier)
self.assertEqual((item.pallet_id, item.qr_token, item.package_qty), (2, 'QR-2', 100))
async def test_scan_rejects_wrong_order_and_cross_batch_pallet(self):
for identifier in ('QR-3', 'QR-52', 'MISSING'):
with self.subTest(identifier=identifier), self.assertRaises(ServiceException):
await self.scan(identifier)
async def test_scan_rejects_changed_quantity_before_receiving(self):
self.session.execute(update(BizPalletInventory).where(BizPalletInventory.pallet_id == 2).values(remaining_packages=90))
with self.assertRaises(ServiceException):
await self.scan()
async def test_scan_rejects_received_and_legacy_orders(self):
for shipment_id in (17, 18):
with self.subTest(shipment_id=shipment_id), self.assertRaises(ServiceException):
await self.scan('QR-2', shipment_id)
async def test_controller_list_detail_scan_run_through_asgi(self):
app = FastAPI()
app.include_router(shipment_receipt_controller)
async def current_user():
RequestContext.set_current_user(self.user)
RequestContext.set_current_exclude_patterns([])
return self.user
async def current_db():
return self.db
async def current_scope():
return self.scope
app.dependency_overrides[get_db] = current_db
app.dependency_overrides[LoginService.get_current_user] = current_user
for route in shipment_receipt_controller.routes:
for dependency in route.dependant.dependencies:
if isinstance(dependency.call, PreAuth):
app.dependency_overrides[dependency.call] = current_user
if isinstance(dependency.call, TestBatchScopeResolver):
app.dependency_overrides[dependency.call] = current_scope
# 只替换登录来源、批次解析、日志队列和 IP 归属;接口权限校验仍真实执行。
with patch.object(Log, '_get_oper_location', new=AsyncMock(return_value='内存测试')), \
patch.object(LogQueueService, 'enqueue_operation_log', new=AsyncMock()):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://receipt.test') as client:
listing = await client.get('/business/shipment-receipt/list', params={'receiptState': 'PENDING'})
self.assertEqual(listing.status_code, 200)
self.assertEqual([r['shipmentId'] for r in listing.json()['rows']], [12])
detail = await client.get('/business/shipment-receipt/12')
self.assertEqual(detail.status_code, 200)
self.assertEqual(detail.json()['data']['items'][0]['batchNo'], 'B-OLD-2')
scanned = await client.get('/business/shipment-receipt/12/scan/P-2')
self.assertEqual(scanned.status_code, 200)
self.assertEqual(scanned.json()['data']['qrToken'], 'QR-2')
class TestShipmentReceiptConfirmBoundaries(unittest.IsolatedAsyncioTestCase):
def setUp(self):
self.user = user()
self.scope = batch_scope()
self.db = MagicMock(spec=AsyncSession)
self.order = BizShipmentOrder(**order_values())
self.order.items = [BizShipmentItem(**item_values(12, pallet_id)) for pallet_id in (2, 5)]
self.inventories = [BizPalletInventory(**inventory_values(pallet_id)) for pallet_id in (2, 5)]
self.events = []
self.batch_lock = self.patch(TestBatchAccessService, 'lock_active_batch', AsyncMock())
self.site_ids = self.patch(AccessDao, 'list_user_site_ids', AsyncMock(return_value=[9]))
self.site_access = self.patch(AccessDao, 'user_has_site', AsyncMock(return_value=True))
self.order_lock = self.patch(OperationDao, 'get_shipment_for_update', AsyncMock(return_value=self.order))
self.inventory_lock = self.patch(InventoryDao, 'get_inventories_for_update', AsyncMock(return_value=self.inventories))
self.patch(InventoryDao, 'get_pallets_by_ids', AsyncMock(return_value=[BizPallet(**pallet_values(i)) for i in (2, 5)]))
self.patch(MasterDao, 'get_site', AsyncMock(return_value=BizCustomerSite(site_id=9, status='0', test_batch_id=None)))
self.patch(InventoryDao, 'add_event', AsyncMock(side_effect=self.capture_event))
def patch(self, owner, name, replacement):
patcher = patch.object(owner, name, new=replacement)
result = patcher.start()
self.addCleanup(patcher.stop)
return result
async def capture_event(self, _db, _inventory, receipt_event, _scope):
self.events.append(receipt_event)
await asyncio.sleep(0)
return receipt_event
def command(self, ids=(5, 2), key='receipt-key'):
return ShipmentReceiptCommandModel(scannedPalletIds=list(ids), idempotencyKey=key)
async def confirm(self, command=None, db=None):
return await ShipmentReceiptService.confirm_services(
db if db is not None else self.db, 12,
command if command is not None else self.command(), self.user, self.scope,
)
async def test_permission_revoked_after_order_lock_prevents_any_mutation(self):
async def revoked(_db, _user_id, _site_id, *, for_update=False):
self.order_lock.assert_awaited_once()
self.assertTrue(for_update)
return False
self.site_access.side_effect = revoked
with self.assertRaises(PermissionException):
await self.confirm()
self.inventory_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
self.assertEqual(self.order.receipt_state, 'PENDING')
async def test_idempotent_replay_also_requires_current_target_permission(self):
await self.confirm()
self.site_access.return_value = False
with self.assertRaises(PermissionException):
await self.confirm()
self.assertEqual(len(self.events), 2)
self.assertEqual(self.db.commit.await_count, 1)
async def test_same_key_different_payload_is_rejected(self):
await self.confirm()
with self.assertRaises(ServiceException):
await self.confirm(self.command(ids=(2,)))
self.assertEqual(len(self.events), 2)
self.assertEqual(self.db.commit.await_count, 1)
async def test_same_key_permuted_set_replays_without_restoring_used_inventory(self):
await self.confirm()
self.inventories[0].remaining_packages = 80
self.inventories[0].flow_state = 'AT_SITE_PARTIAL'
await self.confirm(self.command(ids=(2, 5)))
self.assertEqual(self.inventories[0].remaining_packages, 80)
self.assertEqual(len(self.events), 2)
self.assertEqual(self.db.commit.await_count, 1)
async def test_closed_test_batch_is_rejected_before_order_lock(self):
self.scope = batch_scope(41)
self.batch_lock.side_effect = PermissionException(data='', message='测试批次已关闭')
with self.assertRaises(PermissionException):
await self.confirm()
self.order_lock.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_cross_batch_order_is_rejected_even_if_dao_returns_it(self):
self.order.test_batch_id = 41
with self.assertRaises(ServiceException):
await self.confirm()
self.assertFalse(self.events)
self.db.commit.assert_not_awaited()
async def race(self, second_command):
"""模拟 DAO 的父单排他锁,第二个请求拿锁后必须重读已提交状态。"""
gate = asyncio.Lock()
attempted = []
async def lock_order(db, _shipment_id, _scope, **access):
self.assertEqual(access, {'warehouse_ids': None, 'site_ids': [9]})
attempted.append(db)
await gate.acquire()
db.owns_lock = True
return self.order
def connection():
db = MagicMock(spec=AsyncSession)
db.owns_lock = False
async def release():
if db.owns_lock:
db.owns_lock = False
gate.release()
db.commit = AsyncMock(side_effect=release)
db.rollback = AsyncMock(side_effect=release)
return db
first, second = connection(), connection()
self.order_lock.side_effect = lock_order
results = await asyncio.wait_for(asyncio.gather(
self.confirm(db=first), self.confirm(second_command, db=second), return_exceptions=True,
), timeout=2)
self.assertEqual(len(attempted), 2)
self.assertEqual(first.commit.await_count + second.commit.await_count, 1)
self.assertEqual(len(self.events), 2)
self.assertEqual([inventory.version for inventory in self.inventories], [4, 4])
self.assertTrue(all(inventory.remaining_packages == 100 for inventory in self.inventories))
return results
async def test_concurrent_identical_confirmations_append_events_once(self):
results = await self.race(self.command(ids=(2, 5)))
self.assertTrue(all(not isinstance(result, Exception) and result.is_success for result in results))
async def test_concurrent_same_key_different_payload_rejects_second_request(self):
results = await self.race(self.command(ids=(2,)))
self.assertTrue(results[0].is_success)
self.assertIsInstance(results[1], ServiceException)
async def test_concurrent_different_keys_do_not_confirm_twice(self):
results = await self.race(self.command(key='other-key'))
self.assertTrue(results[0].is_success)
self.assertIsInstance(results[1], ServiceException)
if __name__ == '__main__':
unittest.main()
@@ -52,6 +52,7 @@ from module_business.entity.vo.operation_vo import (
ShipmentScanQueryModel, ShipmentScanQueryModel,
ShipmentUpdateModel, ShipmentUpdateModel,
) )
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
from module_business.service import shipment_service as shipment_service_module from module_business.service import shipment_service as shipment_service_module
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
from module_business.service.shipment_service import ShipmentService from module_business.service.shipment_service import ShipmentService
@@ -395,12 +396,12 @@ class ShipmentControllerContractTest(unittest.TestCase):
@staticmethod @staticmethod
def _routes() -> dict[tuple[str, str], APIRoute]: def _routes() -> dict[tuple[str, str], APIRoute]:
return { return {
(route.path, next(iter(route.methods))): route (route.path_format, next(iter(route.methods))): route
for route in shipment_controller_module.shipment_controller.routes for route in shipment_controller_module.shipment_controller.routes
if isinstance(route, APIRoute) if isinstance(route, APIRoute)
} }
def test_openapi_has_nine_operations_aliases_and_response_models(self) -> None: def test_openapi_has_ten_operations_aliases_and_response_models(self) -> None:
app = FastAPI() app = FastAPI()
app.include_router(shipment_controller_module.shipment_controller) app.include_router(shipment_controller_module.shipment_controller)
schema = app.openapi() schema = app.openapi()
@@ -410,6 +411,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
('/business/shipment/site-options', 'get'), ('/business/shipment/site-options', 'get'),
('/business/shipment/scan/{qr_token}', 'get'), ('/business/shipment/scan/{qr_token}', 'get'),
('/business/shipment/{shipment_id}', 'get'), ('/business/shipment/{shipment_id}', 'get'),
('/business/shipment/{shipment_id}/labels', 'get'),
('/business/shipment', 'post'), ('/business/shipment', 'post'),
('/business/shipment', 'put'), ('/business/shipment', 'put'),
('/business/shipment/{shipment_ids}', 'delete'), ('/business/shipment/{shipment_ids}', 'delete'),
@@ -437,6 +439,10 @@ class ShipmentControllerContractTest(unittest.TestCase):
DataResponseModel[ShipmentModel], DataResponseModel[ShipmentModel],
routes[('/business/shipment/{shipment_id}', 'GET')].response_model, routes[('/business/shipment/{shipment_id}', 'GET')].response_model,
) )
self.assertEqual(
DataResponseModel[list[ShipmentLabelModel]],
routes[('/business/shipment/{shipment_id}/labels', 'GET')].response_model,
)
for key in ( for key in (
('/business/shipment', 'POST'), ('/business/shipment', 'POST'),
('/business/shipment', 'PUT'), ('/business/shipment', 'PUT'),
@@ -446,7 +452,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
self.assertEqual(ResponseBaseModel, routes[key].response_model) self.assertEqual(ResponseBaseModel, routes[key].response_model)
def test_static_options_and_scan_precede_dynamic_detail(self) -> None: def test_static_options_and_scan_precede_dynamic_detail(self) -> None:
paths = [route.path for route in shipment_controller_module.shipment_controller.routes] paths = [route.path_format for route in shipment_controller_module.shipment_controller.routes]
detail_index = paths.index('/business/shipment/{shipment_id}') detail_index = paths.index('/business/shipment/{shipment_id}')
for static_path in ( for static_path in (
'/business/shipment/warehouse-options', '/business/shipment/warehouse-options',
@@ -462,6 +468,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
('/business/shipment/site-options', 'GET'): 'business:shipment:query', ('/business/shipment/site-options', 'GET'): 'business:shipment:query',
('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query', ('/business/shipment/scan/{qr_token}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query', ('/business/shipment/{shipment_id}', 'GET'): 'business:shipment:query',
('/business/shipment/{shipment_id}/labels', 'GET'): 'business:shipment:query',
('/business/shipment', 'POST'): 'business:shipment:add', ('/business/shipment', 'POST'): 'business:shipment:add',
('/business/shipment', 'PUT'): 'business:shipment:edit', ('/business/shipment', 'PUT'): 'business:shipment:edit',
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list', ('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
@@ -1822,8 +1829,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
'warehouse-mapping-lock', 'warehouse-mapping-lock',
'site-lock', 'site-lock',
'site-mapping-lock', 'site-mapping-lock',
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED', 'transition-FILLED_IN_WAREHOUSE-IN_TRANSIT',
'transition-FILLED_IN_WAREHOUSE-AT_SITE_UNUSED', 'transition-FILLED_IN_WAREHOUSE-IN_TRANSIT',
'update-order', 'update-order',
'event-2', 'event-2',
'event-5', 'event-5',
@@ -1843,6 +1850,9 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.request_hash, shipment.confirm_request_hash) self.assertEqual(self.request_hash, shipment.confirm_request_hash)
self.assertEqual(7, shipment.confirmed_by) self.assertEqual(7, shipment.confirmed_by)
self.assertEqual(self.fixed_time, shipment.confirmed_at) self.assertEqual(self.fixed_time, shipment.confirmed_at)
self.assertEqual('PENDING', shipment.receipt_state)
self.assertIsNone(shipment.received_by)
self.assertIsNone(shipment.received_at)
snapshots = {item.pallet_id: (item.active_filling_id, item.package_qty) for item in shipment.items} snapshots = {item.pallet_id: (item.active_filling_id, item.package_qty) for item in shipment.items}
self.assertEqual({2: (102, 8), 5: (105, 13)}, snapshots) self.assertEqual({2: (102, 8), 5: (105, 13)}, snapshots)
for inventory, original_version, remaining in zip( for inventory, original_version, remaining in zip(
@@ -1851,7 +1861,8 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
(8, 13), (8, 13),
strict=True, strict=True,
): ):
self.assertEqual('AT_SITE_UNUSED', inventory.flow_state) self.assertEqual('IN_TRANSIT', inventory.flow_state)
self.assertIsNone(inventory.site_arrived_at)
self.assertIsNone(inventory.warehouse_id) self.assertIsNone(inventory.warehouse_id)
self.assertEqual(9, inventory.site_id) self.assertEqual(9, inventory.site_id)
self.assertIsNone(inventory.current_line_id) self.assertIsNone(inventory.current_line_id)
@@ -1865,7 +1876,7 @@ class ShipmentConfirmServiceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual('SHIPMENT', event.source_type) self.assertEqual('SHIPMENT', event.source_type)
self.assertEqual(12, event.source_id) self.assertEqual(12, event.source_id)
self.assertEqual('FILLED_IN_WAREHOUSE', event.before_state) self.assertEqual('FILLED_IN_WAREHOUSE', event.before_state)
self.assertEqual('AT_SITE_UNUSED', event.after_state) self.assertEqual('IN_TRANSIT', event.after_state)
self.assertEqual(7, event.before_warehouse_id) self.assertEqual(7, event.before_warehouse_id)
self.assertIsNone(event.before_site_id) self.assertIsNone(event.before_site_id)
self.assertIsNone(event.after_warehouse_id) self.assertIsNone(event.after_warehouse_id)
@@ -10,6 +10,7 @@ PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql' MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql' BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql'
TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql' TEST_BATCH_MYSQL_PATCH_PATH = SQL_ROOT / 'patches/20260922_test_data_batch_mysql.sql'
SHIPMENT_RECEIPT_PATCH_PATH = SQL_ROOT / 'patches/20261008_shipment_receipt_mysql.sql'
SYS_MENU_VALUE_COUNT = 20 SYS_MENU_VALUE_COUNT = 20
SYS_MENU_PERMS_INDEX = 13 SYS_MENU_PERMS_INDEX = 13
SYS_MENU_ICON_INDEX = 14 SYS_MENU_ICON_INDEX = 14
@@ -374,8 +375,79 @@ LEGACY_EXPECTED_UNIQUE_KEYS = {table: set(keys) for table, keys in EXPECTED_UNIQ
LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()} LEGACY_EXPECTED_FOREIGN_KEYS = {table: set(keys) for table, keys in EXPECTED_FOREIGN_KEYS.items()}
LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()} LEGACY_EXPECTED_CHECKS = {table: set(checks) for table, checks in EXPECTED_CHECKS.items()}
EXPECTED_COLUMNS['biz_filling_record'].update({'production_date', 'expiry_date'})
EXPECTED_CHECKS['biz_filling_record'].add(
'(production_dateisnullandexpiry_dateisnull)or(production_dateisnotnullandexpiry_dateisnotnullandexpiry_date>=production_date)'
)
CURRENT_TEST_BATCH_OWNED_TABLES = TEST_BATCH_OWNED_TABLES | {'biz_tunnel'}
EXPECTED_TABLES.add('biz_tunnel')
EXPECTED_COLUMNS.update(
{
'biz_tunnel': {
'tunnel_id',
'site_id',
'tunnel_code',
'tunnel_name',
'current_trace_no',
'current_trace_started_at',
'status',
}
| AUDIT_COLUMNS,
'biz_usage_record': EXPECTED_COLUMNS['biz_usage_record']
| {'tunnel_id', 'trace_no', 'batch_no_snapshot'},
'biz_return_item': EXPECTED_COLUMNS['biz_return_item']
| {'tunnel_id_snapshot', 'trace_no_snapshot', 'batch_no_snapshot'},
'biz_pallet_inventory': EXPECTED_COLUMNS['biz_pallet_inventory'] | {'tunnel_id', 'trace_no'},
'biz_pallet_event': EXPECTED_COLUMNS['biz_pallet_event']
| {'before_tunnel_id', 'after_tunnel_id', 'trace_no', 'batch_no_snapshot'},
}
)
EXPECTED_DATETIME_COLUMNS['biz_tunnel'] = {'current_trace_started_at', 'create_time', 'update_time'}
EXPECTED_PRIMARY_KEYS['biz_tunnel'] = ('tunnel_id',)
EXPECTED_UNIQUE_KEYS['biz_tunnel'] = {('site_id', 'tunnel_code')}
EXPECTED_FOREIGN_KEYS.update(
{
'biz_tunnel': {('site_id', 'biz_customer_site.site_id')},
'biz_usage_record': EXPECTED_FOREIGN_KEYS['biz_usage_record']
| {('tunnel_id', 'biz_tunnel.tunnel_id')},
'biz_return_item': EXPECTED_FOREIGN_KEYS['biz_return_item']
| {('tunnel_id_snapshot', 'biz_tunnel.tunnel_id')},
'biz_pallet_inventory': EXPECTED_FOREIGN_KEYS['biz_pallet_inventory']
| {('tunnel_id', 'biz_tunnel.tunnel_id')},
'biz_pallet_event': EXPECTED_FOREIGN_KEYS['biz_pallet_event']
| {
('before_tunnel_id', 'biz_tunnel.tunnel_id'),
('after_tunnel_id', 'biz_tunnel.tunnel_id'),
},
}
)
EXPECTED_CHECKS['biz_tunnel'] = {"statusin('0','1')"}
EXPECTED_CHECKS['biz_pallet_event'] = {
"event_typein('inbound_confirmed','filling_confirmed','shipment_confirmed','shipment_received','tunnel_entered','usage_recorded',"
"'return_requested','return_confirmed')"
}
# 新契约必须位于 LEGACY_EXPECTED_* 的独立副本之后,旧补丁保持原始结构。
for fifo_table in ('biz_usage_record', 'biz_pallet_event'):
EXPECTED_COLUMNS[fifo_table].update({'fifo_skip_reason', 'fifo_recommendation'})
EXPECTED_COLUMNS['biz_shipment_order'].update({
'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash',
})
EXPECTED_DATETIME_COLUMNS['biz_shipment_order'].add('received_at')
EXPECTED_UNIQUE_KEYS['biz_shipment_order'].add(('target_site_id', 'receipt_idempotency_key'))
EXPECTED_FOREIGN_KEYS['biz_shipment_order'].add(('received_by', 'sys_user.user_id'))
EXPECTED_CHECKS['biz_shipment_order'].add("receipt_statein('pending','received','legacy')")
EXPECTED_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
EXPECTED_DATETIME_COLUMNS['biz_pallet_inventory'].add('site_arrived_at')
EXPECTED_CHECKS['biz_pallet_inventory'] = {
check.replace("'filled_in_warehouse','at_site_unused'", "'filled_in_warehouse','in_transit','at_site_unused'")
for check in EXPECTED_CHECKS['biz_pallet_inventory']
}
EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES EXPECTED_TABLES |= EXPECTED_TEST_BATCH_TABLES
for table_name in TEST_BATCH_OWNED_TABLES: for table_name in CURRENT_TEST_BATCH_OWNED_TABLES:
EXPECTED_COLUMNS[table_name].add('test_batch_id') EXPECTED_COLUMNS[table_name].add('test_batch_id')
EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id')) EXPECTED_FOREIGN_KEYS[table_name].add(('test_batch_id', 'biz_test_batch.batch_id'))
@@ -452,6 +524,7 @@ EXPECTED_PAGES = {
505: ('产线资料', 121, 4, 'line', 'master/line/index', 'master:line:list'), 505: ('产线资料', 121, 4, 'line', 'master/line/index', 'master:line:list'),
506: ('仓库资料', 121, 5, 'warehouse', 'master/warehouse/index', 'master:warehouse:list'), 506: ('仓库资料', 121, 5, 'warehouse', 'master/warehouse/index', 'master:warehouse:list'),
507: ('托盘资料', 121, 6, 'pallet', 'master/pallet/index', 'master:pallet:list'), 507: ('托盘资料', 121, 6, 'pallet', 'master/pallet/index', 'master:pallet:list'),
520: ('隧道资料', 121, 7, 'tunnel', 'master/tunnel/index', 'master:tunnel:list'),
508: ('仓库入库', 122, 1, 'inbound', 'business/inbound/index', 'business:inbound:list'), 508: ('仓库入库', 122, 1, 'inbound', 'business/inbound/index', 'business:inbound:list'),
509: ('工厂装填', 122, 2, 'filling', 'business/filling/index', 'business:filling:list'), 509: ('工厂装填', 122, 2, 'filling', 'business/filling/index', 'business:filling:list'),
510: ('发货出库', 122, 3, 'shipment', 'business/shipment/index', 'business:shipment:list'), 510: ('发货出库', 122, 3, 'shipment', 'business/shipment/index', 'business:shipment:list'),
@@ -494,6 +567,9 @@ BUTTON_SPECS = """
1088 507 2 托盘新增 master:pallet:add 1088 507 2 托盘新增 master:pallet:add
1089 507 3 托盘修改 master:pallet:edit 1089 507 3 托盘修改 master:pallet:edit
1091 507 5 托盘打印 master:pallet:print 1091 507 5 托盘打印 master:pallet:print
1116 520 1 隧道查询 master:tunnel:query
1117 520 2 隧道新增 master:tunnel:add
1118 520 3 隧道修改 master:tunnel:edit
1092 508 1 入库查询 business:inbound:query 1092 508 1 入库查询 business:inbound:query
1093 508 2 入库新增 business:inbound:add 1093 508 2 入库新增 business:inbound:add
1094 508 3 入库修改 business:inbound:edit 1094 508 3 入库修改 business:inbound:edit
@@ -532,6 +608,7 @@ EXPECTED_PAGE_PERMISSIONS = frozenset(
'master:line:list', 'master:line:list',
'master:warehouse:list', 'master:warehouse:list',
'master:pallet:list', 'master:pallet:list',
'master:tunnel:list',
'business:inbound:list', 'business:inbound:list',
'business:filling:list', 'business:filling:list',
'business:shipment:list', 'business:shipment:list',
@@ -568,6 +645,9 @@ EXPECTED_BUTTON_PERMISSIONS = frozenset(
'master:pallet:add', 'master:pallet:add',
'master:pallet:edit', 'master:pallet:edit',
'master:pallet:print', 'master:pallet:print',
'master:tunnel:query',
'master:tunnel:add',
'master:tunnel:edit',
'business:inbound:query', 'business:inbound:query',
'business:inbound:add', 'business:inbound:add',
'business:inbound:edit', 'business:inbound:edit',
@@ -604,6 +684,7 @@ BUSINESS_MENU_SPECS = """
505|121|产线资料|line||4|master:line:list|master/line/index 505|121|产线资料|line||4|master:line:list|master/line/index
506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index 506|121|仓库资料|warehouse||5|master:warehouse:list|master/warehouse/index
507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index 507|121|托盘资料|pallet||6|master:pallet:list|master/pallet/index
520|121|隧道资料|tunnel||7|master:tunnel:list|master/tunnel/index
508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index 508|122|仓库入库|inbound||1|business:inbound:list|business/inbound/index
509|122|工厂装填|filling||2|business:filling:list|business/filling/index 509|122|工厂装填|filling||2|business:filling:list|business/filling/index
510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index 510|122|发货出库|shipment||3|business:shipment:list|business/shipment/index
@@ -636,6 +717,9 @@ BUSINESS_MENU_SPECS = """
1088|507|托盘新增|||2|master:pallet:add| 1088|507|托盘新增|||2|master:pallet:add|
1089|507|托盘修改|||3|master:pallet:edit| 1089|507|托盘修改|||3|master:pallet:edit|
1091|507|托盘打印|||5|master:pallet:print| 1091|507|托盘打印|||5|master:pallet:print|
1116|520|隧道查询|||1|master:tunnel:query|
1117|520|隧道新增|||2|master:tunnel:add|
1118|520|隧道修改|||3|master:tunnel:edit|
1092|508|入库查询|||1|business:inbound:query| 1092|508|入库查询|||1|business:inbound:query|
1093|508|入库新增|||2|business:inbound:add| 1093|508|入库新增|||2|business:inbound:add|
1094|508|入库修改|||3|business:inbound:edit| 1094|508|入库修改|||3|business:inbound:edit|
@@ -699,9 +783,11 @@ LEGACY_EXPECTED_BUSINESS_MENU_FIELDS.update(
for menu_id, (name, parent_id, order_num, permission) in LEGACY_REMOVED_BUTTON_SPECS.items() for menu_id, (name, parent_id, order_num, permission) in LEGACY_REMOVED_BUTTON_SPECS.items()
} }
) )
LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list'} LEGACY_EXPECTED_PAGE_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS - {'system:testbatch:list', 'master:tunnel:list'}
LEGACY_EXPECTED_BUTTON_PERMISSIONS = EXPECTED_BUTTON_PERMISSIONS - { LEGACY_EXPECTED_BUTTON_PERMISSIONS = EXPECTED_BUTTON_PERMISSIONS - {
permission for permission in EXPECTED_BUTTON_PERMISSIONS if permission.startswith('system:testbatch:') permission
for permission in EXPECTED_BUTTON_PERMISSIONS
if permission.startswith(('system:testbatch:', 'master:tunnel:'))
} | {spec[3] for spec in LEGACY_REMOVED_BUTTON_SPECS.values()} } | {spec[3] for spec in LEGACY_REMOVED_BUTTON_SPECS.values()}
LEGACY_EXPECTED_PERMISSIONS = LEGACY_EXPECTED_PAGE_PERMISSIONS | LEGACY_EXPECTED_BUTTON_PERMISSIONS LEGACY_EXPECTED_PERMISSIONS = LEGACY_EXPECTED_PAGE_PERMISSIONS | LEGACY_EXPECTED_BUTTON_PERMISSIONS
LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | { LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
@@ -709,6 +795,11 @@ LEGACY_PATCH_BUSINESS_MENU_FIELDS = LEGACY_EXPECTED_BUSINESS_MENU_FIELDS | {
122: (1, '业务管理', 'businessOperation', '', 11, '', None), 122: (1, '业务管理', 'businessOperation', '', 11, '', None),
123: (1, '报表查询', 'businessReport', '', 12, '', None), 123: (1, '报表查询', 'businessReport', '', 12, '', None),
} }
# 同样在菜单旧快照生成后加入收货权限,避免改变历史迁移的权限清单。
EXPECTED_BUTTONS[1119] = ('收货核对', 510, 6, 'business:shipment:receive')
EXPECTED_BUSINESS_MENU_FIELDS[1119] = (510, '收货核对', '', '', 6, 'business:shipment:receive', '')
EXPECTED_BUTTON_PERMISSIONS |= {'business:shipment:receive'}
EXPECTED_PERMISSIONS = EXPECTED_PAGE_PERMISSIONS | EXPECTED_BUTTON_PERMISSIONS
EXPECTED_TOP_LEVEL_MENU_POSITIONS = { EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
121: ('基础资料维护', 0, 1), 121: ('基础资料维护', 0, 1),
122: ('业务管理', 0, 2), 122: ('业务管理', 0, 2),
@@ -734,7 +825,7 @@ MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
BUSINESS_MENU_IDS = ( BUSINESS_MENU_IDS = (
set(range(121, 124)) set(range(121, 124))
| set(range(502, 519)) | set(range(502, 519))
| {519} | {519, 520, 1119}
| set(range(1065, 1068)) | set(range(1065, 1068))
| set(range(1069, 1072)) | set(range(1069, 1072))
| set(range(1073, 1076)) | set(range(1073, 1076))
@@ -750,7 +841,7 @@ BUSINESS_MENU_IDS = (
| set(range(1099, 1102)) | set(range(1099, 1102))
| {1103} | {1103}
| set(range(1104, 1109)) | set(range(1104, 1109))
| set(range(1109, 1116)) | set(range(1109, 1119))
) )
TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115} TEST_BATCH_MENU_IDS = {519, 1109, 1110, 1111, 1112, 1113, 1114, 1115}
MYSQL_MENU_STABLE_COLUMNS = ( MYSQL_MENU_STABLE_COLUMNS = (
@@ -1033,11 +1124,12 @@ def _assert_test_batch_sql_contract(
create_batch = normalized.index('create table biz_test_batch (') create_batch = normalized.index('create table biz_test_batch (')
create_batch_user = normalized.index('create table biz_test_batch_user (') create_batch_user = normalized.index('create table biz_test_batch_user (')
owned_marker = 'alter table' if is_patch else 'create table' owned_marker = 'alter table' if is_patch else 'create table'
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in TEST_BATCH_OWNED_TABLES) owned_tables = TEST_BATCH_OWNED_TABLES if is_patch else CURRENT_TEST_BATCH_OWNED_TABLES
first_owned = min(normalized.index(f'{owned_marker} {table_name} ') for table_name in owned_tables)
test_case.assertLess(create_batch, create_batch_user) test_case.assertLess(create_batch, create_batch_user)
test_case.assertLess(create_batch_user, first_owned) test_case.assertLess(create_batch_user, first_owned)
for table_name in TEST_BATCH_OWNED_TABLES: for table_name in owned_tables:
with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name): with test_case.subTest(dialect=dialect, patch=is_patch, table=table_name):
if is_patch: if is_patch:
test_case.assertRegex( test_case.assertRegex(
@@ -1319,6 +1411,103 @@ def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql
return pre_guard, post_guard return pre_guard, post_guard
class ShipmentReceiptSqlContractTest(unittest.TestCase):
def patch_sql(self) -> str:
self.assertTrue(SHIPMENT_RECEIPT_PATCH_PATH.is_file(), '缺少收货单次迁移脚本')
return SHIPMENT_RECEIPT_PATCH_PATH.read_text(encoding='utf-8')
def test_patch_additions_match_initializer_columns_and_constraints(self) -> None:
def normalize(value: str) -> str:
return re.sub(r'\b(bigint)\(20\)', r'\1', value).split(' comment ')[0]
raw = _strip_sql_comments(self.patch_sql())
initial = _create_table_bodies((SQL_ROOT / INITIALIZERS[0]).read_text(encoding='utf-8'))
alters = re.findall(r'\balter\s+table\s+(\w+)\s+(.*?);', raw, re.I | re.S)
self.assertEqual(['biz_shipment_order', 'biz_pallet_inventory', 'biz_pallet_event'], [table for table, _ in alters])
expected_columns = {
'biz_shipment_order': {'receipt_state', 'received_by', 'received_at', 'receipt_idempotency_key', 'receipt_request_hash'},
'biz_pallet_inventory': {'site_arrived_at'},
'biz_pallet_event': set(),
}
for table, body in alters:
with self.subTest(table=table):
additions = ','.join(
re.sub(r'^add\s+(?:column\s+)?', '', clause.strip(), flags=re.I)
for clause in _split_values(body) if clause.strip().lower().startswith('add ')
)
columns, _, uniques, foreign_keys, checks = _table_contract(additions)
self.assertEqual(expected_columns[table], columns)
self.assertEqual(
{('target_site_id', 'receipt_idempotency_key')} if table == 'biz_shipment_order' else set(), uniques,
)
self.assertEqual({('received_by', 'sys_user.user_id')} if table == 'biz_shipment_order' else set(), foreign_keys)
expected_checks = (
{"receipt_statein('pending','received','legacy')"} if table == 'biz_shipment_order'
else {check for check in EXPECTED_CHECKS[table] if check.startswith(('flow_statein', 'event_typein'))}
)
self.assertEqual(expected_checks, checks)
initializer_columns = _column_definitions(initial[table])
for column, definition in _column_definitions(additions).items():
normalized = _normalized_sql_statement(re.sub(r'\s+after\s+\w+\s*$', '', definition, flags=re.I))
# 类型、可空性和默认值均应与初始化定义一致,注释不参与比较。
self.assertEqual(normalize(_normalized_sql_statement(initializer_columns[column])), normalize(normalized))
def test_patch_only_marks_confirmed_legacy_and_does_not_touch_inventory_or_receipt_time(self) -> None:
raw = _strip_sql_comments(self.patch_sql())
updates = re.findall(r'\bupdate\s+([a-z_]+)\s+set\s+(.*?);', raw, re.I | re.S)
self.assertEqual(1, len(updates))
self.assertEqual('biz_shipment_order', updates[0][0].lower())
self.assertEqual("receipt_state = 'legacy' where status = 'confirmed'", _normalized_sql_statement(updates[0][1]))
inserts = re.findall(r'\binsert\s+into\s+(\w+)', raw, re.I)
self.assertEqual(['sys_menu'], inserts)
self.assertNotRegex(raw.lower(), r'\b(?:delete\s+from|replace\s+into|truncate\s+table)\b')
self.assertNotRegex(raw.lower(), r'\b(?:received_at|site_arrived_at)\s*=')
def test_patch_guards_conflicting_menu_ids_and_permissions_before_any_alter(self) -> None:
raw = _strip_sql_comments(self.patch_sql()).lower()
guard = raw[:raw.index('alter table')]
for expression in (
r'from\s+sys_menu\s+where\s+menu_id\s*=\s*1119\s+or\s+perms\s*=\s*\'business:shipment:receive\'',
r'from\s+sys_role_menu\s+where\s+menu_id\s*=\s*1119',
r'menu_id\s*=\s*510\s+and\s+perms\s*=\s*\'business:shipment:list\'',
):
self.assertRegex(guard, expression)
self.assertIn("signal sqlstate '45000'", guard)
self.assertIn('resignal', guard)
rows = _insert_rows(raw, 'sys_menu')
self.assertEqual(1, len(rows))
self.assertEqual(SYS_MENU_VALUE_COUNT, len(rows[0]))
self.assertEqual(['1119', '收货核对', '510', '6'], rows[0][:4])
self.assertEqual('business:shipment:receive', rows[0][SYS_MENU_PERMS_INDEX])
self.assertNotRegex(raw, r'\b(?:insert\s+into|update|delete\s+from)\s+sys_role_menu\b')
def test_patch_requires_prerequisites_and_rejects_repeated_or_partial_execution(self) -> None:
raw = self.patch_sql()
for phrase in ('MySQL 8', '停写', '备份', '单次', 'DDL', '隐式提交', '20261005_tunnel_material_flow_mysql.sql', '20261008_material_dates_mysql.sql'):
self.assertIn(phrase, raw)
guard = _strip_sql_comments(raw).lower().split('alter table', maxsplit=1)[0]
self.assertIn('version()', guard)
self.assertIn('information_schema.columns', guard)
for field in ('production_date', 'expiry_date', 'tunnel_id', 'trace_no', 'receipt_state', 'received_by',
'received_at', 'receipt_idempotency_key', 'receipt_request_hash', 'site_arrived_at'):
self.assertIn(f"'{field}'", guard)
self.assertIn('information_schema.check_constraints', guard)
self.assertIn("'ck_biz_filling_record_material_dates'", guard)
self.assertIn("'ck_biz_pallet_event_event_type'", guard)
self.assertIn('tunnel_entered', guard)
self.assertLess(raw.lower().rindex('alter table'), raw.lower().index('start transaction'))
def test_current_expectations_leave_legacy_schema_and_menu_snapshots_unchanged(self) -> None:
self.assertNotIn('receipt_state', LEGACY_EXPECTED_COLUMNS['biz_shipment_order'])
self.assertNotIn('site_arrived_at', LEGACY_EXPECTED_COLUMNS['biz_pallet_inventory'])
self.assertNotIn(('target_site_id', 'receipt_idempotency_key'), LEGACY_EXPECTED_UNIQUE_KEYS['biz_shipment_order'])
self.assertNotIn(('received_by', 'sys_user.user_id'), LEGACY_EXPECTED_FOREIGN_KEYS['biz_shipment_order'])
self.assertNotIn('in_transit', ' '.join(LEGACY_EXPECTED_CHECKS['biz_pallet_inventory']))
self.assertNotIn('shipment_received', ' '.join(LEGACY_EXPECTED_CHECKS['biz_pallet_event']))
self.assertNotIn('business:shipment:receive', LEGACY_EXPECTED_PERMISSIONS)
self.assertNotIn(1119, LEGACY_EXPECTED_BUSINESS_MENU_FIELDS)
class SqlContractTest(unittest.TestCase): class SqlContractTest(unittest.TestCase):
def test_test_batch_initializers_and_patches_match_contract(self) -> None: def test_test_batch_initializers_and_patches_match_contract(self) -> None:
scripts = ( scripts = (
@@ -1407,7 +1596,7 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual(EXPECTED_CHECKS[table], checks) self.assertEqual(EXPECTED_CHECKS[table], checks)
def test_mysql_business_datetimes_use_native_datetime_columns(self) -> None: def test_mysql_business_datetimes_use_native_datetime_columns(self) -> None:
self.assertEqual(40, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values())) self.assertEqual(45, sum(len(columns) for columns in EXPECTED_DATETIME_COLUMNS.values()))
tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8')) tables = _create_table_bodies((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
invalid_columns = [] invalid_columns = []
for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items(): for table, expected_columns in EXPECTED_DATETIME_COLUMNS.items():
@@ -1425,11 +1614,11 @@ class SqlContractTest(unittest.TestCase):
self.assertEqual([], sorted(invalid_columns)) self.assertEqual([], sorted(invalid_columns))
def test_initializers_define_fixed_business_menus_and_permissions(self) -> None: def test_initializers_define_fixed_business_menus_and_permissions(self) -> None:
self.assertEqual(18, len(EXPECTED_PAGE_PERMISSIONS)) self.assertEqual(19, len(EXPECTED_PAGE_PERMISSIONS))
self.assertEqual(43, len(EXPECTED_BUTTON_PERMISSIONS)) self.assertEqual(47, len(EXPECTED_BUTTON_PERMISSIONS))
self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS) self.assertFalse(EXPECTED_PAGE_PERMISSIONS & EXPECTED_BUTTON_PERMISSIONS)
self.assertEqual(61, len(EXPECTED_PERMISSIONS)) self.assertEqual(66, len(EXPECTED_PERMISSIONS))
self.assertEqual(64, len(EXPECTED_BUSINESS_MENU_FIELDS)) self.assertEqual(69, len(EXPECTED_BUSINESS_MENU_FIELDS))
for filename in INITIALIZERS: for filename in INITIALIZERS:
with self.subTest(filename=filename): with self.subTest(filename=filename):
sql = (SQL_ROOT / filename).read_text(encoding='utf-8') sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
@@ -13,10 +13,18 @@ from module_business.domain.pallet_state_machine import (
class PalletStateMachineTest(unittest.TestCase): class PalletStateMachineTest(unittest.TestCase):
def test_dispatch_cannot_skip_receiving_confirmation(self) -> None:
with self.assertRaises(InvalidPalletTransition):
PalletStateMachine.ensure_transition(
PalletFlowState.FILLED_IN_WAREHOUSE,
PalletFlowState.AT_SITE_UNUSED,
)
ALLOWED_TRANSITIONS = ( ALLOWED_TRANSITIONS = (
(PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE), (PalletFlowState.UNSTOCKED, PalletFlowState.EMPTY_IN_WAREHOUSE),
(PalletFlowState.EMPTY_IN_WAREHOUSE, PalletFlowState.FILLED_IN_WAREHOUSE), (PalletFlowState.EMPTY_IN_WAREHOUSE, PalletFlowState.FILLED_IN_WAREHOUSE),
(PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.AT_SITE_UNUSED), (PalletFlowState.FILLED_IN_WAREHOUSE, PalletFlowState.IN_TRANSIT),
(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL), (PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_PARTIAL),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY), (PalletFlowState.AT_SITE_UNUSED, PalletFlowState.AT_SITE_EMPTY),
(PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING), (PalletFlowState.AT_SITE_UNUSED, PalletFlowState.RETURNING),
@@ -75,6 +83,7 @@ class PalletEnumTest(unittest.TestCase):
'UNSTOCKED', 'UNSTOCKED',
'EMPTY_IN_WAREHOUSE', 'EMPTY_IN_WAREHOUSE',
'FILLED_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE',
'IN_TRANSIT',
'AT_SITE_UNUSED', 'AT_SITE_UNUSED',
'AT_SITE_PARTIAL', 'AT_SITE_PARTIAL',
'AT_SITE_EMPTY', 'AT_SITE_EMPTY',
@@ -92,6 +101,8 @@ class PalletEnumTest(unittest.TestCase):
'INBOUND_CONFIRMED', 'INBOUND_CONFIRMED',
'FILLING_CONFIRMED', 'FILLING_CONFIRMED',
'SHIPMENT_CONFIRMED', 'SHIPMENT_CONFIRMED',
'SHIPMENT_RECEIVED',
'TUNNEL_ENTERED',
'USAGE_RECORDED', 'USAGE_RECORDED',
'RETURN_REQUESTED', 'RETURN_REQUESTED',
'RETURN_CONFIRMED', 'RETURN_CONFIRMED',
@@ -35,6 +35,7 @@ TABLE_LABELS = {
'biz_customer': '客户', 'biz_customer': '客户',
'biz_customer_site': '客户现场', 'biz_customer_site': '客户现场',
'biz_production_line': '生产线', 'biz_production_line': '生产线',
'biz_tunnel': '隧道',
'biz_warehouse': '仓库', 'biz_warehouse': '仓库',
'biz_pallet': '托盘', 'biz_pallet': '托盘',
'biz_inbound_order': '入库单', 'biz_inbound_order': '入库单',
@@ -187,6 +188,8 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
active_filling_id=21, active_filling_id=21,
warehouse_id=None, warehouse_id=None,
site_id=3, site_id=3,
tunnel_id=None,
trace_no=None,
current_line_id=None, current_line_id=None,
last_event_id=41, last_event_id=41,
) )
@@ -198,9 +201,11 @@ class TestBatchGraphTest(unittest.IsolatedAsyncioTestCase):
source_id=30, source_id=30,
before_warehouse_id=2, before_warehouse_id=2,
before_site_id=None, before_site_id=None,
before_tunnel_id=None,
before_line_id=None, before_line_id=None,
after_warehouse_id=None, after_warehouse_id=None,
after_site_id=3, after_site_id=3,
after_tunnel_id=None,
after_line_id=None, after_line_id=None,
) )
self.db = GraphSession(self.rows) self.db = GraphSession(self.rows)
@@ -41,6 +41,7 @@ EXPECTED_DELETE_ORDER = [
'biz_pallet_event', 'biz_pallet_event',
'biz_filling_record', 'biz_filling_record',
'biz_production_line', 'biz_production_line',
'biz_tunnel',
'biz_customer_site', 'biz_customer_site',
'biz_customer', 'biz_customer',
'biz_warehouse', 'biz_warehouse',
@@ -0,0 +1,121 @@
import unittest
import hashlib
import json
from pydantic import ValidationError
from module_business.domain.enums import PalletEventType
from module_business.entity.do.master_do import BizTunnel
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory, BizReturnItem, BizUsageRecord
from module_business.entity.vo.master_vo import PalletCreateModel, TunnelModel, TunnelPageQueryModel
from module_business.entity.vo.operation_vo import FillingCreateModel, TunnelEntryCreateModel, UsageCreateModel
from module_business.service.usage_service import UsageService
class TestTunnelFlowContract(unittest.TestCase):
def test_tunnel_master_data_belongs_to_site_and_has_scoped_code(self) -> None:
self.assertEqual(BizTunnel.__tablename__, 'biz_tunnel')
self.assertIn('site_id', BizTunnel.__table__.c)
self.assertIn('tunnel_code', BizTunnel.__table__.c)
self.assertIn('current_trace_no', BizTunnel.__table__.c)
self.assertEqual(
{constraint.name for constraint in BizTunnel.__table__.constraints if constraint.name},
{
'uq_biz_tunnel_site_code',
'ck_biz_tunnel_status',
},
)
def test_tunnel_master_vo_supports_list_and_write_contract(self) -> None:
tunnel = TunnelModel(
siteId=9,
tunnelCode='T-01',
tunnelName='一号隧道',
)
self.assertEqual(tunnel.model_dump(by_alias=True)['tunnelCode'], 'T-01')
query = TunnelPageQueryModel(siteId=9, tunnelCode='T-01')
self.assertEqual(query.site_id, 9)
def test_usage_requires_tunnel_but_keeps_line_optional(self) -> None:
usage = UsageCreateModel(
qrToken='qr-1',
tunnelId=9,
usedPackages=20,
idempotencyKey='usage-key',
)
self.assertIsNone(usage.line_id)
with self.assertRaises(ValidationError):
UsageCreateModel(
qrToken='qr-1',
usedPackages=20,
idempotencyKey='usage-key',
)
def test_tunnel_entry_is_idempotent_command(self) -> None:
command = TunnelEntryCreateModel(
qrToken='qr-1',
tunnelId=9,
idempotencyKey='entry-key',
)
self.assertEqual(command.tunnel_id, 9)
def test_flow_records_keep_tunnel_and_trace_snapshots(self) -> None:
self.assertIn('tunnel_id', BizPalletInventory.__table__.c)
self.assertIn('trace_no', BizPalletInventory.__table__.c)
self.assertIn('tunnel_id', BizUsageRecord.__table__.c)
self.assertIn('trace_no', BizUsageRecord.__table__.c)
self.assertIn('batch_no_snapshot', BizUsageRecord.__table__.c)
self.assertIn('tunnel_id_snapshot', BizReturnItem.__table__.c)
self.assertIn('trace_no_snapshot', BizReturnItem.__table__.c)
self.assertIn('before_tunnel_id', BizPalletEvent.__table__.c)
self.assertIn('after_tunnel_id', BizPalletEvent.__table__.c)
self.assertIn('trace_no', BizPalletEvent.__table__.c)
self.assertEqual(PalletEventType.TUNNEL_ENTERED.value, 'TUNNEL_ENTERED')
def test_new_pallet_and_filling_are_fixed_to_one_hundred_packages(self) -> None:
PalletCreateModel(palletCode='P-001', capacityPackages=100)
FillingCreateModel(
qrToken='qr-1',
warehouseId=1,
strainId=1,
batchNo='B-1',
productionDate='2026-10-01',
expiryDate='2027-04-01',
packageSpec='标准包',
initialPackages=100,
idempotencyKey='fill-key',
)
with self.assertRaises(ValidationError):
PalletCreateModel(palletCode='P-002', capacityPackages=20)
with self.assertRaises(ValidationError):
FillingCreateModel(
qrToken='qr-1',
warehouseId=1,
strainId=1,
batchNo='B-1',
packageSpec='标准包',
initialPackages=20,
idempotencyKey='fill-key',
)
def test_usage_idempotency_hash_includes_tunnel(self) -> None:
command = UsageCreateModel(
qrToken=' qr-1 ',
tunnelId=9,
lineId=None,
usedPackages=20,
idempotencyKey='usage-key',
)
expected = hashlib.sha256(
json.dumps(
{
'qr_token': 'qr-1',
'tunnel_id': 9,
'line_id': None,
'used_packages': 20,
},
sort_keys=True,
separators=(',', ':'),
).encode()
).hexdigest()
self.assertEqual(expected, UsageService._build_request_hash(command))
@@ -0,0 +1,190 @@
"""隔离内存库验证真实 FIFO 查询,不依赖业务数据库。"""
import unittest
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy import Column, MetaData, Table, create_engine
from sqlalchemy.orm import Session
from exceptions.exception import PermissionException
from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
from module_business.service.usage_fifo_service import UsageFifoService
from module_business.service.usage_service import UsageService
from module_business.dao.master_dao import MasterDao
from types import SimpleNamespace
from tests.module_business.test_batch_fixtures import make_test_scope
class TestUsageFifo(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.engine = create_engine('sqlite:///:memory:')
metadata = MetaData()
self.tables = {}
for model in (BizPallet, BizFillingRecord, BizPalletInventory):
self.tables[model] = Table(
model.__tablename__,
metadata,
*[Column(c.name, c.type, primary_key=c.primary_key) for c in model.__table__.columns],
)
metadata.create_all(self.engine)
self.session = Session(self.engine)
self.db = MagicMock()
self.db.execute = AsyncMock(side_effect=self.session.execute)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def add(
self,
pallet_id,
arrived_at,
*,
site=9,
strain=4,
batch=None,
remaining=100,
state='AT_SITE_UNUSED',
tunnel=None,
asset='0',
filling_batch=None,
trace='TRACE-CURRENT',
) -> None:
self.session.execute(
self.tables[BizPallet].insert(),
{
'pallet_id': pallet_id,
'pallet_code': f'P-{pallet_id}',
'qr_token': f'qr-{pallet_id}',
'test_batch_id': batch,
'asset_status': asset,
'capacity_packages': 100,
},
)
self.session.execute(
self.tables[BizFillingRecord].insert(),
{
'filling_id': pallet_id,
'pallet_id': pallet_id,
'strain_id': strain,
'batch_no': f'B-{pallet_id}',
'test_batch_id': batch if filling_batch is None else filling_batch,
'active_slot': 'ACTIVE',
'closed_at': None,
},
)
self.session.execute(
self.tables[BizPalletInventory].insert(),
{
'pallet_id': pallet_id,
'active_filling_id': pallet_id,
'site_id': site,
'warehouse_id': None,
'test_batch_id': batch,
'tunnel_id': tunnel,
'trace_no': trace if tunnel is not None else None,
'flow_state': state,
'remaining_packages': remaining,
'site_arrived_at': arrived_at,
},
)
async def query(self, tunnel=None, scope=None):
return await UsageFifoDao.recommendation(
self.db,
site_id=9,
strain_id=4,
tunnel_id=tunnel,
trace_no='TRACE-CURRENT' if tunnel is not None else None,
scope=scope or TestBatchScope.formal(),
)
async def test_orders_by_real_receipt_time_not_pallet_id_and_supports_partial(self) -> None:
self.add(1, datetime(2026, 10, 8))
self.add(50, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL')
candidate, unknown = await self.query()
self.assertEqual(candidate['pallet_id'], 50)
self.assertEqual(candidate['remaining_packages'], 80)
self.assertEqual(candidate['batch_no'], 'B-50')
self.assertEqual(unknown, 0)
async def test_excludes_unreceived_empty_returning_wrong_site_strain_and_test_scope(self) -> None:
early = datetime(2026, 10, 1)
for i, change in enumerate(
[
{'state': 'IN_TRANSIT'},
{'remaining': 0},
{'state': 'RETURNING'},
{'site': 10},
{'strain': 8},
{'batch': 7},
{'asset': '1'},
{'filling_batch': 7},
],
1,
):
self.add(i, early, **change)
self.add(99, datetime(2026, 10, 8))
candidate, _ = await self.query()
self.assertEqual(candidate['pallet_id'], 99)
test_candidate, _ = await self.query(scope=make_test_scope(7))
self.assertEqual(test_candidate['pallet_id'], 6)
async def test_unknown_legacy_arrivals_are_reported_not_fabricated(self) -> None:
self.add(1, None)
self.add(2, datetime(2026, 10, 8))
candidate, unknown = await self.query()
self.assertEqual(candidate['pallet_id'], 2)
self.assertEqual(unknown, 1)
async def test_reserved_other_tunnels_are_not_recommended(self) -> None:
self.add(1, datetime(2026, 10, 1), tunnel=12)
self.add(2, datetime(2026, 10, 2), tunnel=13)
self.add(3, datetime(2026, 10, 3))
candidate, _ = await self.query()
self.assertEqual(candidate['pallet_id'], 3)
candidate, _ = await self.query(tunnel=13)
self.assertEqual(candidate['pallet_id'], 2)
async def test_no_stock_returns_no_recommendation(self) -> None:
self.assertEqual(await self.query(), (None, 0))
async def test_old_tunnel_trace_is_not_recommended_or_counted_as_unknown(self) -> None:
self.add(1, datetime(2026, 10, 1), tunnel=13, trace='TRACE-OLD')
self.add(2, None, tunnel=13, trace='TRACE-OLD')
self.add(3, datetime(2026, 10, 8), tunnel=13)
candidate, unknown = await self.query(tunnel=13)
self.assertEqual(candidate['pallet_id'], 3)
self.assertEqual(unknown, 0)
async def test_permission_failure_stops_before_candidate_query(self) -> None:
with (
patch.object(
UsageService,
'scan_usage_pallet_services',
new=AsyncMock(side_effect=PermissionException(data='', message='无权访问')),
),
self.assertRaises(PermissionException),
):
await UsageFifoService.recommendation(self.db, 'qr-other', MagicMock(), TestBatchScope.formal())
self.db.execute.assert_not_awaited()
async def test_advice_uses_selected_tunnel_and_requires_reason_for_strictly_earlier(self) -> None:
self.add(1, datetime(2026, 10, 1), tunnel=13)
self.add(2, datetime(2026, 10, 8))
scanned = SimpleNamespace(pallet_id=2, site_id=9, strain_id=4, tunnel_id=None, trace_no=None)
with (
patch.object(UsageService, 'scan_usage_pallet_services', new=AsyncMock(return_value=scanned)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=SimpleNamespace(
site_arrived_at=datetime(2026, 10, 8), test_batch_id=None))),
patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=SimpleNamespace(
site_id=9, status='0', current_trace_no='TRACE-CURRENT', test_batch_id=None))),
):
result = await UsageFifoService.recommendation(
self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13)
self.assertEqual(result.recommended.pallet_id, 1)
self.assertTrue(result.requires_skip_reason)

Some files were not shown because too many files have changed in this diff Show More