更新登录页及优化相关功能
This commit is contained in:
@@ -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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
```
|
||||||
@@ -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 运行现有契约测试及构建。最终单独列出已验证代码、待执行迁移和待实机验收,不把它们混为完成。
|
||||||
@@ -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
Reference in New Issue
Block a user