Author SHA1 Message Date
yujiangandClaude Opus 5.5 2a4119c78c Docs: 新增 Web 端改版修改记录 readmeupdate.md
分前端、后端说明本次改版的全部改动与目的,并列出与设计稿的差异和待确认事项。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00
yujiangandClaude Opus 5.5 3050ccdc4e Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:10 -07:00
123 changed files with 25216 additions and 2531 deletions
+275
View File
@@ -0,0 +1,275 @@
# 智链云枢 Web 端改版 · 修改记录
本文记录在提交 `80a71b4`(添加前后端筛选导出excel按钮权限)之后所做、**尚未提交**的全部改动,说明每项改了什么、为什么改。
- **改动范围**:后端 `shierfeng-fastapi-backend`、前端 `shierfeng-fastapi-frontend`。
- **未改动**:小程序 `shierfeng-fastapi-app`、登录页 `src/views/login.vue` 及 Logo 资源、`shierfeng-fastapi-backend/.env.dev`(该文件的改动是原有的,不属于本次工作)。
- **总原则**:后端只新增只读查询、扩展列表和详情的返回字段;**所有写接口、托盘状态机和幂等键规则都没有改**。新增的关联查询都沿用原接口的数据范围(仓库 / 现场授权),并受同一测试批次范围约束。没有新增权限标识,也不需要改菜单。
- **规模**:已跟踪文件 85 个改动(约 +14,400 / −2,500 行),新增文件 37 个(不含本文件)。
改动分几轮完成,顺序如下:
| 轮次 | 内容 |
|---|---|
| 1 | 全局外壳与导航、工作台、托盘档案、各页统一页头 |
| 2 | 发货出库页、侧边栏用户卡片、托盘追溯入口 |
| 3 | 基础资料维护:托盘、客户、仓库、导入、分配用户,以及菌种、现场、产线、隧道 |
| 4 | 客户、仓库指标条;7 个新增 / 编辑对话框 |
| 5 | 业务管理:共用部分(第 0 步)、仓库入库 |
| 6 | 退回仓库 |
| 7 | 工厂装填(同时修复入库页扫码反馈不显示的问题) |
| 8 | 客户现场使用 |
---
## 一、后端(shierfeng-fastapi-backend)
### 1. 新增接口一览
全部为 GET、只读。
| 接口 | 权限 | 用途 |
|---|---|---|
| `/master/site/customer-options` | `master:site:list` | 新增 / 编辑现场时选择所属客户 |
| `/master/line/site-options` | `master:line:list` | 新增 / 编辑产线时选择所属现场 |
| `/master/tunnel/site-options` | `master:tunnel:list` | 新增 / 编辑隧道时选择所属现场 |
| `/business/inbound/summary` | `business:inbound:list` | 入库页指标:草稿单数与托数、本月入库、待首次入库托盘、各仓库空托盘 |
| `/business/return/summary` | `business:return:list` | 退回页指标:待核对单数与托数、退回中带料托数与包数、本月确认与回仓去向、平均确认耗时 |
| `/business/return/site-options` | `business:return:list` | 退回页的来源现场筛选与名称显示 |
| `/business/filling/summary` | `business:filling:list` | 装填页指标:今日与本月装填、可装填空托盘、14 天内到期仍在仓的托盘 |
| `/business/filling/batches` | `business:filling:list` | 按「菌种 + 生产批次」汇总装填(分页,最近装填的在前) |
| `/business/filling/batches/detail` | `business:filling:query` | 批次详情:托盘当前去向分布与最近 10 条装填 |
| `/business/usage/site-options` | `business:usage:list` | 现场使用页的现场选择器(附隧道数、产线数) |
| `/business/usage/site-board?siteId=` | `business:usage:query` | 现场看板:库存、今日用量、隧道在用情况、跳过先到次数(不返回内部溯源号) |
| `/business/usage-fifo/queue?siteId=` | `business:usage:query` | 现场待使用托盘按菌种分组,顺序与先到先用建议一致 |
另外,基础资料的各个导入接口新增参数 `preview=true`,表示「只校验、不写入」,权限不变。
### 2. 扩展的已有接口
返回结构只增加字段,不删除、不改名。
| 接口 | 新增内容 | 目的 |
|---|---|---|
| 基础资料各列表(菌种 / 客户 / 现场 / 产线 / 隧道 / 仓库 / 托盘) | 名称、统计数、分配用户等汇总字段 | 页面显示名称而不是 ID,并提供指标数字 |
| 客户详情 | 现场树:产线、隧道、各状态托盘数、分配用户 | 客户页右侧详情面板 |
| 托盘列表 | `flowGroup` 参数(未入库 / 仓库 / 在途 / 现场 / 退回中);托盘号参数同时精确匹配二维码标识 | 托盘页的状态页签,以及扫码枪搜索 |
| 托盘详情 | 最近流转记录 | 托盘侧边面板 |
| 发货列表 | `keyword`、`receiptState` 参数;仓库、现场、客户、创建人名称,托盘数、包数、菌种构成 | 发货页的统一搜索、状态页签和路线显示 |
| 发货详情 | 明细补托盘号、菌种、批次 | 详情面板;也修复了编辑对话框只显示 `#123` 的问题 |
| 入库列表 / 详情 | 仓库、创建人、确认人名称,托盘数;明细补托盘号和当前状态 | 入库页列表与详情面板 |
| 退回列表 / 详情 | `keyword` 参数(单号、托盘号、现场、客户、仓库);来源现场、客户、目标仓库、申请人、确认人名称,托盘数、余量包数、带料托数;明细补托盘号和隧道名称 | 退回页列表、详情和到仓核对 |
| 装填列表 | `keyword` 参数(装填单号、托盘号、批次);托盘号、仓库、操作人名称 | 装填明细视图 |
| 使用记录列表 | `keyword`(记录号、托盘号)、`fifoSkipped` 参数;托盘、现场、隧道、产线、操作人名称,以及**被跳过的先到托盘号**(取自写入时已保存的先到先用快照) | 使用记录表和「只看跳过先到」开关 |
### 3. 按文件说明
**新增文件**
| 文件 | 说明 |
|---|---|
| `dao/master_summary_dao.py`、`service/master_summary_service.py`、`entity/vo/master_summary_vo.py` | 基础资料列表汇总、客户现场树、托盘最近流转、上级资料选项、导入预览模型 |
| `dao/shipment_summary_dao.py` | 发货列表名称、托盘数、包数与菌种构成 |
| `dao/inbound_summary_dao.py` | 入库列表名称与托盘数、明细托盘号与状态、页面概况 |
| `dao/return_summary_dao.py` | 退回列表汇总、明细托盘号与隧道、来源现场选项、页面概况 |
| `dao/filling_summary_dao.py` | 装填列表名称、批次汇总与详情、页面概况 |
| `dao/usage_summary_dao.py` | 使用记录名称、现场选项、现场看板 |
**修改文件**
| 文件 | 修改内容 |
|---|---|
| `controller/*_controller.py`(菌种、客户、现场、产线、隧道、仓库、托盘) | 列表和详情改用带汇总的返回模型;现场、产线、隧道新增上级选项路由,单独注册并排在 `/{id}` 详情路由之前,避免被详情路由误匹配 |
| `controller/master_excel_controller.py`、`service/master_excel_service.py` | 导入支持 `preview`:执行与真实导入相同的校验后整体回滚,返回逐行的新增 / 更新 / 跳过 / 错误结果 |
| `dao/master_dao.py`、`entity/vo/master_vo.py` | 托盘列表的 `flowGroup` 过滤与二维码精确匹配 |
| `controller/shipment_controller.py`、`service/shipment_service.py` | 发货列表汇总与筛选、详情明细补充字段 |
| `controller/inbound_controller.py`、`service/inbound_service.py` | 入库列表与详情补充字段,新增 `/summary` |
| `controller/return_controller.py`、`service/return_service.py` | 退回列表与详情补充字段,新增 `/summary`、`/site-options` |
| `controller/filling_controller.py`、`service/filling_service.py` | 装填列表补充字段,新增 `/summary`、`/batches`、`/batches/detail` |
| `controller/usage_controller.py`、`service/usage_service.py` | 使用记录列表补充字段,新增 `/site-options`、`/site-board` |
| `controller/usage_fifo_controller.py`、`service/usage_fifo_service.py` | 新增 `/queue`。原来权限写在路由组上,现改为每个接口各自声明(同为 `business:usage:query`),以符合「每个接口一个权限声明」的契约测试,实际效果不变 |
| `dao/usage_fifo_dao.py` | 把先到先用的候选条件、关联和排序抽成公共方法,供建议和队列共用,保证两边顺序一致。已逐条比对:改动前后建议查询(含写入时的加锁复核)在 12 种参数组合下生成的 SQL 完全相同 |
| `dao/operation_dao.py` | 发货、退回、装填、使用记录列表的 `keyword` 等筛选条件。原排序(按 ID 从旧到新)未改 |
| `entity/vo/operation_vo.py`、`entity/vo/usage_fifo_vo.py` | 上述列表行、概况、看板、队列等新模型;查询模型新增 `keyword`、`fifoSkipped` 等字段 |
### 4. 测试(`tests/module_business`)
- **新增**:`test_master_summary.py`、`test_shipment_list_summary.py`、`test_inbound_summary.py`、`test_return_summary.py`、`test_filling_summary.py`、`test_usage_summary.py`;另在 `test_usage_fifo.py` 增加 3 条待使用队列测试(内存数据库,实际执行查询)。覆盖内容为数据范围、测试批次隔离、SQL 条件,以及返回字段的驼峰命名。
- **同步更新**(接口变更是有意为之):
- `test_api_permission_contract.py`:登记新路由、权限映射、静态路由在详情路由之前、查询参数,路由权限条目从 119 条增至 131 条;
- `test_vo_contract.py`:新模型和新字段;
- 各 `test_*_service.py`:路由列表、返回模型,以及对新汇总查询的模拟。
- **结果**:`PYTHONPATH=. python -m unittest` 运行 `tests/module_business` 下全部用例,共 843 条,全部通过(2 条跳过)。
---
## 二、前端(shierfeng-fastapi-frontend)
### 1. 全局外壳与导航
| 文件 | 修改内容与目的 |
|---|---|
| `layout/index.vue`、`layout/components/AppMain.vue`、`Navbar.vue`、`TagsView/index.vue` | 新版外壳:内容区为白色内嵌面板,顶栏更简洁;手机宽度可用 |
| `layout/components/Sidebar/index.vue`、`SidebarItem.vue`、`Logo.vue` | 浅色侧边栏、分组菜单、统一图标,使用官方 Sylvan Logo |
| `layout/components/Sidebar/UserCard.vue`(新增) | 侧边栏左下角显示当前用户的头像、姓名、部门 · 角色,点击打开 个人中心 / 布局设置 / 退出登录;侧边栏折叠时只显示头像 |
| `layout/components/CommandPalette/index.vue`(新增) | Ctrl / ⌘K 搜索面板:输入页面名称直接跳转,输入托盘号打开托盘档案 |
| `layout/components/TestModeBanner/index.vue`(新增) | 测试数据模式下页面顶部显示琥珀色提示条,可直接退出测试批次 |
| `assets/styles/sf-shell.scss`(新增)、`index.scss`、`sidebar.scss`、`variables.module.scss` | 设计令牌(颜色、圆角、阴影),深色模式有单独配色;全屏作业区、分段切换、批量操作栏等共用样式 |
| `settings.js` | 默认浅色侧边栏、默认关闭页签栏。已保存过「布局设置」的用户仍沿用自己的设置 |
| `store/modules/user.js` | 读取部门名和角色名,供用户卡片使用 |
| `router/index.js`、`components/Breadcrumb/index.vue` | 「首页」改称「工作台」;新增托盘档案路由 `/pallet/passport`,需要托盘追溯权限 |
| `main.js` | 全局注册 `PageHeader`、`PalletCodeLink`、`DocNo` |
### 2. 新增的共用组件与工具
| 文件 | 用途 |
|---|---|
| `components/PageHeader`(含 `descriptions.js`) | 统一页头:标题、页面说明、右侧操作区;业务页用 `flow` 属性显示托盘流程条 |
| `components/FlowStepper` | 托盘流程 1 入库 → 2 装填 → 3 发货 → 4 现场使用 → 5 退回,高亮当前环节;没有菜单权限的环节只显示、不能点 |
| `components/ScanFeedback` | 扫码结果条,分成功、提醒、拒绝、等待等状态,屏幕阅读器会播报 |
| `components/DocNo`、`utils/docNo.js` | 长单号缩短显示(如 `SH-9F2C…E41B`),鼠标悬停显示完整单号 |
| `components/PalletCodeLink` | 托盘号链接到托盘档案;没有权限时显示为纯文本 |
| `components/SfIcon`(含 `icons.js`) | 统一线性图标,路径只取自本地常量表 |
| `components/MasterRefSelect` | 可搜索的上级资料选择框(所属客户 / 所属现场) |
| `assets/styles/sf-master.scss` | 指标条、状态页签、筛选行、列表加详情面板、时间线、状态胶囊等样式,基础资料页和业务页共用 |
| `utils/masterDisplay.js` | 数字格式化、天数计算、状态胶囊样式、按路由末段查找菜单路径 |
| `utils/masterRouteIntent.js` | 基础资料页之间的跳转意图(如从客户详情跳到现场页并带上所属客户);一次性意图处理后从地址栏移除,刷新不会重复触发 |
| `utils/palletPassport.js` | 托盘流转状态的中文名称与阶段分组 |
| `utils/palletRanges.js` | 把连续托盘号合并成编号段;解析批量粘贴的内容,支持 `10512-10518` 这类区间写法 |
### 3. 工作台、托盘档案、托盘追溯
- **`views/dashboard/index.vue` 工作台**:扫码 / 搜索栏、4 个关键指标、托盘 8 种状态的流转看板(点击某个状态可查看托盘)、装填与发货对比图、待办、临期托盘、仓库库存、最近动态。全部数据来自已有接口。同时修复了「最近出库单」实际显示的是最早单据的问题(后端列表按从旧到新排序)。
- **`views/pallet/passport/index.vue` 托盘档案**(新增):当前状态、位置、余量、有效期,本轮流转进度条,所有变动的时间线(往轮折叠),以及同一溯源号的其他托盘。
- **`views/report/palletTrace/index.vue` 托盘追溯**:顶部新增「查看单个托盘档案」入口,下方保留原来的跨托盘流转明细查询。
- **其余 5 个报表页**:加统一页头,托盘号可点击打开托盘档案;查询逻辑没变。
### 4. 基础资料维护页
| 页面 | 修改内容 |
|---|---|
| 托盘资料 `master/pallet` | 指标条;状态页签带数量;搜索框也支持扫码;每行显示当前位置、余量和周转次数;侧边面板含二维码标签、最近流转、打印 / 编辑 / 停用;勾选多行后可批量打印标签或停用 |
| 客户资料 `master/customer` | 客户列表加详情面板,每个现场展开后显示产线、隧道、各状态托盘数和分配用户;「新增现场 / 分配」等按钮跳到对应页面并带好上级 |
| 仓库资料 `master/warehouse` | 卡片 / 列表切换;显示库存、今日装填与发货、待处理单据、7 天内到期托盘、分配用户 |
| 菌种、现场、产线、隧道 | 显示名称而不是 ID,加指标条(多数指标只统计当前页,会标「本页」)和近期使用情况 |
| 7 个新增 / 编辑对话框 | 统一布局;状态做成两张说明卡,写明选择后的影响(已与后端校验逐条核对);所属客户 / 现场改为可搜索列表,只列启用的上级,加载失败时退回原来的 ID 输入;托盘容量固定显示 100 包;编辑托盘时托盘号和二维码标识只读 |
| `components/MasterExcelActions` 批量导入 | 改为三步:选择文件 → 校验预览(逐行显示新增 / 更新 / 跳过 / 错误,错误行可下载 CSV)→ 正式导入。导入请求现在会带上测试批次请求头(原来的上传绕开了统一请求层,不会带) |
| `components/BusinessUserAssignDialog` 分配用户 | 支持筛选和头像,用户旁标注「将新增 / 将移除」,并汇总本次变化 |
| `api/master/site.js`、`line.js`、`tunnel.js` | 新增上级选项接口的调用 |
### 5. 业务管理页
各页都只改界面和读取方式。**写入流程原样保留**:提交前的复核、幂等键、防串单的请求代次、确认前重新读取详情等。
**发货出库 `business/shipment`**
- 状态页签带数量:全部 / 草稿 / 待收货 / 已收货 / 历史单据。
- 一个搜索框覆盖单号、仓库、客户和现场,另加创建时间筛选;原来的仓库ID、现场ID 输入框去掉。
- 列表显示路线名称(`成品一仓 → 金禾菌业 · 南厂`)、托盘数与包数、菌种构成,超过 48 小时未收货会标出。
- 点一行打开详情面板:打印标签、编辑、确认发货,以及 创建 → 确认 → 收货核对 的进度线。
- 「发货单 / 收货核对」改为页头切换;扫码在全屏作业区进行。
**仓库入库 `business/inbound`**
- 指标条,全部 / 草稿 / 已确认 页签,按仓库和创建时间筛选,草稿等待 2 天以上会标出。
- 详情面板:时间线、编号段、托盘当前状态。
- 批量确认:逐张确认,每张用自己的幂等键,中途失败就停下。
- 作业区可切换「逐个扫码 / 批量粘贴」,粘贴支持区间写法,按编号段汇总便于核对实物,被拒托盘写明原因;确认后显示结果和「去工厂装填」链接。
- 修复:作业区扫码反馈条之前一直不显示(见下方「测试脚本」中的说明)。
**工厂装填 `business/filling`**
- 指标条;「按生产批次 / 装填明细」两种视图。
- 批次视图显示有效期剩余天数;批次面板显示托盘去向分布、最近装填,可「沿用此批参数继续装填」,有效期剩余不超过 14 天时禁用。
- 全屏装填工位:
- 先填写并锁定本批参数,锁定后才能扫码;有效期提供 +30 / +45 / +60 天快捷按钮,每托固定 100 包;
- 「扫码后确认 / 扫码即装填」两种模式;
- 「本班装填」列表和未装填的扫码记录;结束本班后显示结果和「去发货出库」链接。
- 每次扫码都先清空上一托的提交内容和幂等键,锁定的批次参数另外保存,扫码成功后再带入。
**客户现场使用 `business/usage`**
- 页头:现场选择器(按账号记住上次选择,只在主动切换时保存)和「开始现场作业」按钮。
- 看板:4 个指标;隧道卡片(点击按隧道筛选记录,再点取消);按菌种分页签的待使用队列,第一托标「建议优先」,并注明未登记到场时间的托数。
- 记录表:按关键字、隧道、产线、时间筛选,有「只看跳过先到」开关;显示被跳过的托盘号和原因;原来的现场ID、隧道ID、产线ID 输入框去掉。
- 全屏作业区分 4 步:扫码 → 确认托盘 · 入隧 → 登记用量 → 完成。
- 第 2 步:有更早到场的托盘时可一键「改用」它,或选择跳过原因快捷项再补充说明;隧道用卡片选择。
- 第 3 步:快捷包数 10 / 20 / 50 / 全部,实时显示提交后余量;托盘已绑定产线时产线锁定。
- 完成后可扫描下一托或继续使用这一托;托盘用完时给出退回仓库的链接。
- 有未确认的操作时,第 1 步显示恢复卡片并锁住扫码,页顶横幅也保留。
- 步骤只是从现有状态推导出来的。原有 34 个函数中 33 个一字未改,只有 `resetQuery` 增加了清空日期筛选。待确认记录的本机存储键和格式、恢复重试、人工解除、先到先用拒绝的处理都保持原样。
- 页面不显示内部溯源号:原有测试规定作业页不能出现溯源号,溯源号只在托盘追溯报表里查看。
**退回仓库 `business/return`**
- 去掉页面顶部的两张操作卡。页头放「发起退回申请」,待确认的行和详情面板放「到仓核对」。
- 指标条,全部 / 待确认 / 已确认 页签,按关键字、来源现场、目标仓库、申请时间筛选。
- 列表显示「现场 → 仓库」和「托数 · 余量」;详情面板显示确认后去向(几托带料回有料库存、几托成为空托)。
- 到仓核对作业区:进度 n/N、方块网格(扫到一托亮一格,可撤销),非本单托盘单独列出;全部对上之前「确认退回」按钮不能点。
- 发起申请作业区:来源现场由第一个托盘锁定并显示名称;被拒托盘写明后端原因。
**业务 API**:`api/business/inbound.js`、`return.js`、`filling.js`、`usage.js` 增加上述新接口的调用。
### 6. 测试脚本(`scripts/`)
- **新增**:`pallet-ranges.test.mjs`(8 条)、`return-behavior.test.mjs`(7 条)、`filling-behavior.test.mjs`(7 条)。后两个在 Node 中加载真实页面,只替换网络层。
- **`usage-behavior.test.mjs`**:
- 补充新组件的替身和新只读接口的默认响应。看板默认不带隧道,避免它的隧道名称让摘要断言白白通过。
- 新增 6 条测试:现场偏好、看板权限、隧道筛选、作业步骤、快捷包数、先到先用快捷原因。
- 有一条原断言用「最后一个请求」去找扫码请求;页面启动后还会读看板,所以改为按路径取最近一次扫码请求,对编码后地址的精确比对没变。
- **`filling-dates.test.mjs`**:只为页面启动时新增的读取接口补了替身,断言没动。
- **`verify-business-contract.mjs`**:
- 登记新接口,请求总数 74 → 86,并更新各页的 API 名称清单;
- 新增检查「导入的组件不被同名状态变量遮蔽」。原因:入库页曾有变量 `scanFeedback` 与组件 `ScanFeedback` 同名,模板编译时选中了变量,导致扫码反馈条一直不显示。这个问题已修复,此检查防止再出现。
- **`verify-dashboard-contract.mjs`**:工作台文案变了,更新了必需文案列表;接口和路由检查不变。
- **`package.json`**:`test:business` 加入 `pallet-ranges.test.mjs`。
- **结果**:`npm run test:business` 共 278 条,通过 260 条。失败的 18 条在本次改动之前就存在,与本次无关:
- 17 条是小程序测试,`shierfeng-fastapi-app` 未安装依赖,报找不到 `vue`;
- 1 条版权检查要求公司名为 "Shierfeng",而已提交的 `settings.js` 中是 "Sylvan"。
- `npm run build:prod`(即 `vite build`)通过。
---
## 三、其他
### 开发库演示数据(不在代码仓库内)
为便于查看页面效果,在开发库中写入了一批演示数据:
- **数据性质**:正式数据(`test_batch_id` 为空),切换模式后也能直接看到。
- **生成方式**:调用真实业务服务生成,模拟时间为 2026-09-01 至 2026-10-17。
- **主数据**:
- 菌种 6 个(T09 停用);
- 仓库 3 个:成品一仓、成品二仓、周转仓;
- 客户 4 个,现场 4 个:金禾菌业 南厂、北厂,绿源农业 一号基地,丰登食用菌合作社 东区;
- 产线 9 条,隧道 12 条;
- 托盘 `TP-10401`–`TP-10720`(`TP-10720` 停用)。
- **业务单据**:
- 入库单 8 张(6 张已确认、2 张草稿);
- 装填记录 309 条,分属 15 个批次;
- 发货单 14 张:11 张已收货、2 张在途、1 张草稿;
- 入隧 152 次,使用记录 496 条,其中 13 条跳过先到;
- 退回单 8 张:5 张已确认、3 张待确认。
- **账号授权调整**:
- niangao(id 2):成品一仓、成品二仓、南厂、北厂;
- test(id 102):成品二仓、周转仓、一号基地、东区。
- **注意**:生成脚本放在临时目录,不在仓库中,**重复运行会重复生成业务单据**;如需清除,只能手工执行 SQL。
### 设计稿
各页面的设计稿在:https://claude.ai/artifact/2aJi4pKkw9vMnepsNLFax9
### 与设计稿或计划不同之处
- **导出按钮**:业务页都没有「导出 Excel」,因为契约测试不允许业务页出现导入 / 导出按钮。
- **现场使用页的溯源号**:不显示,原因见上文。
- **新建的入库草稿不能在作业区一步确认**:新增接口不返回单据 ID。目前是保存后在草稿页签里确认;编辑已有草稿时有「保存并确认」。
- **装填**:本班列表不显示装填单号(新增接口不返回);沿用批次只带入参数,不自动锁定。
- **待使用队列**:只按 `siteId` 查询,一次返回该现场所有菌种的分组。
- **字体**:使用系统字体。Geist 字体包未安装(当时无法访问 npm 源),以后安装 `@fontsource-variable/geist` 即可生效。
### 待确认事项
1. **入库新增接口是否返回新单据 ID**:返回后新建草稿就能一步确认,但这需要改写接口。
2. **列表排序是否改为新的在前**:入库、退回、装填明细、使用记录等列表目前从旧到新排列,新单据会落在最后一页。
3. **手工验收**:页面只用自动化测试和本地模拟接口验证过,尚未用真实账号在浏览器里逐页走查。建议走查:
- 草稿到确认;
- 扫码被拒;
- 断网后重试不重复扣减(装填、现场使用、退回确认);
- 测试批次数据隔离;
- 无权限账号看不到对应按钮。
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import CustomerDetailModel, CustomerListItemModel
from module_business.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel from module_business.entity.vo.master_vo import CustomerModel, CustomerPageQueryModel, DeleteCustomerModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
customer_controller = APIRouterPro( customer_controller = APIRouterPro(
@@ -27,7 +29,7 @@ customer_controller = APIRouterPro(
'/list', '/list',
summary='获取客户分页列表接口', summary='获取客户分页列表接口',
description='用于获取客户分页列表', description='用于获取客户分页列表',
response_model=PageResponseModel[CustomerModel], response_model=PageResponseModel[CustomerListItemModel],
dependencies=[UserInterfaceAuthDependency('master:customer:list')], dependencies=[UserInterfaceAuthDependency('master:customer:list')],
) )
async def get_master_customer_list( async def get_master_customer_list(
@@ -37,6 +39,9 @@ async def get_master_customer_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True) customer_page_query_result = await MasterService.list_customer(query_db, customer_page_query, scope, is_page=True)
customer_page_query_result = await MasterSummaryService.enrich_page(
query_db, 'customer', customer_page_query_result, scope
)
return ResponseUtil.success(model_content=customer_page_query_result) return ResponseUtil.success(model_content=customer_page_query_result)
@@ -45,7 +50,7 @@ async def get_master_customer_list(
'/{customer_id}', '/{customer_id}',
summary='获取客户详情接口', summary='获取客户详情接口',
description='用于获取指定客户的详细信息', description='用于获取指定客户的详细信息',
response_model=DataResponseModel[CustomerModel], response_model=DataResponseModel[CustomerDetailModel],
dependencies=[UserInterfaceAuthDependency('master:customer:query')], dependencies=[UserInterfaceAuthDependency('master:customer:query')],
) )
async def query_detail_master_customer( async def query_detail_master_customer(
@@ -55,6 +60,7 @@ async def query_detail_master_customer(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope) customer_detail_result = await MasterService.detail_customer(query_db, customer_id, scope)
customer_detail_result = await MasterSummaryService.customer_detail(query_db, customer_detail_result, scope)
return ResponseUtil.success(data=customer_detail_result) return ResponseUtil.success(data=customer_detail_result)
@@ -15,8 +15,14 @@ from module_business.dependency.test_batch_dependency import TestBatchScopeDepen
from module_business.domain.test_batch_scope import TestBatchScope 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,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
PalletScanResponseModel, PalletScanResponseModel,
) )
@@ -34,7 +40,7 @@ filling_controller = APIRouterPro(
'/list', '/list',
summary='获取托盘装填记录分页列表接口', summary='获取托盘装填记录分页列表接口',
description='按当前用户仓库数据范围获取托盘装填记录分页列表', description='按当前用户仓库数据范围获取托盘装填记录分页列表',
response_model=PageResponseModel[FillingModel], response_model=PageResponseModel[FillingListItemModel],
dependencies=[UserInterfaceAuthDependency('business:filling:list')], dependencies=[UserInterfaceAuthDependency('business:filling:list')],
) )
async def get_business_filling_list( async def get_business_filling_list(
@@ -54,6 +60,64 @@ async def get_business_filling_list(
return ResponseUtil.success(model_content=result) return ResponseUtil.success(model_content=result)
@filling_controller.get(
'/summary',
summary='获取工厂装填概况接口',
description='按当前用户仓库数据范围统计今日与本月装填、可装填空托盘和临期仍在仓的托盘',
response_model=DataResponseModel[FillingOverviewModel],
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
)
async def get_business_filling_summary(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.get_filling_overview_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@filling_controller.get(
'/batches',
summary='获取生产批次装填汇总分页列表接口',
description='按当前用户仓库数据范围,将装填记录按菌种与生产批次聚合,最近装填的批次在前',
response_model=PageResponseModel[FillingBatchModel],
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
)
async def get_business_filling_batches(
request: Request,
batch_page_query: Annotated[FillingBatchPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.get_filling_batches_services(query_db, batch_page_query, current_user, test_scope)
return ResponseUtil.success(model_content=result)
@filling_controller.get(
'/batches/detail',
summary='获取生产批次详情接口',
description='按当前用户仓库数据范围返回批次汇总、托盘当前去向与最近装填记录',
response_model=DataResponseModel[FillingBatchDetailModel],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
async def get_business_filling_batch_detail(
request: Request,
batch_detail_query: Annotated[FillingBatchDetailQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await FillingService.get_filling_batch_detail_services(
query_db,
batch_detail_query,
current_user,
test_scope,
)
return ResponseUtil.success(data=result)
@filling_controller.get( @filling_controller.get(
'/strain-options', '/strain-options',
summary='获取装填菌种选项接口', summary='获取装填菌种选项接口',
@@ -17,7 +17,9 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
InboundCreateModel, InboundCreateModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
@@ -37,7 +39,7 @@ inbound_controller = APIRouterPro(
'/list', '/list',
summary='获取首次入库单分页列表接口', summary='获取首次入库单分页列表接口',
description='按当前用户仓库数据范围获取首次入库单分页列表', description='按当前用户仓库数据范围获取首次入库单分页列表',
response_model=PageResponseModel[InboundModel], response_model=PageResponseModel[InboundListItemModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:list')], dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
) )
async def get_business_inbound_list( async def get_business_inbound_list(
@@ -57,6 +59,23 @@ async def get_business_inbound_list(
return ResponseUtil.success(model_content=result) return ResponseUtil.success(model_content=result)
@inbound_controller.get(
'/summary',
summary='获取首次入库概况接口',
description='按当前用户仓库数据范围统计待确认草稿、本月入库、待首次入库托盘与仓库空托盘',
response_model=DataResponseModel[InboundOverviewModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
)
async def get_business_inbound_summary(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await InboundService.get_inbound_overview_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@inbound_controller.get( @inbound_controller.get(
'/warehouse-options', '/warehouse-options',
summary='获取首次入库仓库选项接口', summary='获取首次入库仓库选项接口',
@@ -14,18 +14,40 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import LineListItemModel, MasterParentOptionModel
from module_business.entity.vo.master_vo import DeleteLineModel, LineModel, LinePageQueryModel from module_business.entity.vo.master_vo import DeleteLineModel, LineModel, LinePageQueryModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
line_controller = APIRouterPro(prefix='/master/line', tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()]) line_controller = APIRouterPro(prefix='/master/line', tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()])
# 上级资料选项单独注册且排在详情路由之前,避免被 /{line_id} 捕获
line_option_controller = APIRouterPro(
prefix='/master/line', order_num=99, tags=['基础资料-生产线管理'], dependencies=[PreAuthDependency()]
)
@line_option_controller.get(
'/site-options',
summary='获取产线可选现场接口',
description='新增或修改产线时选择所属现场,只返回状态正常的现场',
response_model=DataResponseModel[list[MasterParentOptionModel]],
dependencies=[UserInterfaceAuthDependency('master:line:list')],
)
async def get_master_line_site_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
options = await MasterSummaryService.site_options(query_db, scope)
return ResponseUtil.success(data=options)
@line_controller.get( @line_controller.get(
'/list', '/list',
summary='获取生产线分页列表接口', summary='获取生产线分页列表接口',
description='用于获取生产线分页列表', description='用于获取生产线分页列表',
response_model=PageResponseModel[LineModel], response_model=PageResponseModel[LineListItemModel],
dependencies=[UserInterfaceAuthDependency('master:line:list')], dependencies=[UserInterfaceAuthDependency('master:line:list')],
) )
async def get_master_line_list( async def get_master_line_list(
@@ -35,6 +57,7 @@ async def get_master_line_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
line_page_query_result = await MasterService.list_line(query_db, line_page_query, scope, is_page=True) line_page_query_result = await MasterService.list_line(query_db, line_page_query, scope, is_page=True)
line_page_query_result = await MasterSummaryService.enrich_page(query_db, 'line', line_page_query_result, scope)
return ResponseUtil.success(model_content=line_page_query_result) return ResponseUtil.success(model_content=line_page_query_result)
@@ -88,7 +88,14 @@ async def _import_excel(
db: AsyncSession, db: AsyncSession,
current_user: CurrentUserModel, current_user: CurrentUserModel,
scope: TestBatchScope, scope: TestBatchScope,
preview: bool = False,
) -> Response: ) -> Response:
if preview:
preview_result = await MasterExcelService.preview_import(
db, resource, file, update_support, current_user, scope
)
logger.info('导入校验完成')
return ResponseUtil.success(msg='校验完成,尚未写入', data=preview_result)
result = await MasterExcelService.import_resource( result = await MasterExcelService.import_resource(
db, resource, file, update_support, current_user, scope db, resource, file, update_support, current_user, scope
) )
@@ -127,8 +134,9 @@ async def import_master_strain_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('strain', file, update_support, query_db, current_user, scope) return await _import_excel('strain', file, update_support, query_db, current_user, scope, preview)
@strain_excel_controller.post( @strain_excel_controller.post(
@@ -176,8 +184,9 @@ async def import_master_customer_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('customer', file, update_support, query_db, current_user, scope) return await _import_excel('customer', file, update_support, query_db, current_user, scope, preview)
@customer_excel_controller.post( @customer_excel_controller.post(
@@ -225,8 +234,9 @@ async def import_master_site_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('site', file, update_support, query_db, current_user, scope) return await _import_excel('site', file, update_support, query_db, current_user, scope, preview)
@site_excel_controller.post( @site_excel_controller.post(
@@ -274,8 +284,9 @@ async def import_master_line_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('line', file, update_support, query_db, current_user, scope) return await _import_excel('line', file, update_support, query_db, current_user, scope, preview)
@line_excel_controller.post( @line_excel_controller.post(
@@ -323,8 +334,9 @@ async def import_master_warehouse_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('warehouse', file, update_support, query_db, current_user, scope) return await _import_excel('warehouse', file, update_support, query_db, current_user, scope, preview)
@warehouse_excel_controller.post( @warehouse_excel_controller.post(
@@ -372,8 +384,9 @@ async def import_master_pallet_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('pallet', file, update_support, query_db, current_user, scope) return await _import_excel('pallet', file, update_support, query_db, current_user, scope, preview)
@pallet_excel_controller.post( @pallet_excel_controller.post(
@@ -421,8 +434,9 @@ async def import_master_tunnel_excel(
query_db: Annotated[AsyncSession, DBSessionDependency()], query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()], current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)], scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
preview: Annotated[bool, Query(description='只校验不写入')] = False,
) -> Response: ) -> Response:
return await _import_excel('tunnel', file, update_support, query_db, current_user, scope) return await _import_excel('tunnel', file, update_support, query_db, current_user, scope, preview)
@tunnel_excel_controller.post( @tunnel_excel_controller.post(
@@ -13,13 +13,14 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
DeletePalletModel, DeletePalletModel,
PalletCreateModel, PalletCreateModel,
PalletEditModel, PalletEditModel,
PalletModel,
PalletPageQueryModel, PalletPageQueryModel,
) )
from module_business.service.master_summary_service import MasterSummaryService
from module_business.service.pallet_service import PalletService from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -32,7 +33,7 @@ pallet_controller = APIRouterPro(
'/list', '/list',
summary='获取托盘分页列表接口', summary='获取托盘分页列表接口',
description='用于获取托盘分页列表', description='用于获取托盘分页列表',
response_model=PageResponseModel[PalletModel], response_model=PageResponseModel[PalletListItemModel],
dependencies=[UserInterfaceAuthDependency('master:pallet:list')], dependencies=[UserInterfaceAuthDependency('master:pallet:list')],
) )
async def get_master_pallet_list( async def get_master_pallet_list(
@@ -42,6 +43,7 @@ async def get_master_pallet_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
result = await PalletService.list_pallet(query_db, pallet_page_query, scope, is_page=True) result = await PalletService.list_pallet(query_db, pallet_page_query, scope, is_page=True)
result = await MasterSummaryService.enrich_page(query_db, 'pallet', result, scope)
return ResponseUtil.success(model_content=result) return ResponseUtil.success(model_content=result)
@@ -67,7 +69,7 @@ async def query_master_pallet_qrcode(
'/{pallet_id}', '/{pallet_id}',
summary='获取托盘详情接口', summary='获取托盘详情接口',
description='用于获取指定托盘的详细信息', description='用于获取指定托盘的详细信息',
response_model=DataResponseModel[PalletModel], response_model=DataResponseModel[PalletDetailModel],
dependencies=[UserInterfaceAuthDependency('master:pallet:query')], dependencies=[UserInterfaceAuthDependency('master:pallet:query')],
) )
async def query_detail_master_pallet( async def query_detail_master_pallet(
@@ -77,6 +79,7 @@ async def query_detail_master_pallet(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
result = await PalletService.detail_pallet(query_db, pallet_id, scope) result = await PalletService.detail_pallet(query_db, pallet_id, scope)
result = await MasterSummaryService.pallet_detail(query_db, result, scope)
return ResponseUtil.success(data=result) return ResponseUtil.success(data=result)
@@ -19,10 +19,13 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
) )
from module_business.service.return_service import ReturnService from module_business.service.return_service import ReturnService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -38,7 +41,7 @@ return_controller = APIRouterPro(
'/list', '/list',
summary='获取托盘退回单分页列表接口', summary='获取托盘退回单分页列表接口',
description='按当前用户来源现场或目标仓库数据范围获取退回单', description='按当前用户来源现场或目标仓库数据范围获取退回单',
response_model=PageResponseModel[ReturnModel], response_model=PageResponseModel[ReturnListItemModel],
dependencies=[UserInterfaceAuthDependency('business:return:list')], dependencies=[UserInterfaceAuthDependency('business:return:list')],
) )
async def get_business_return_list( async def get_business_return_list(
@@ -58,6 +61,40 @@ async def get_business_return_list(
return ResponseUtil.success(model_content=result) return ResponseUtil.success(model_content=result)
@return_controller.get(
'/summary',
summary='获取托盘退回概况接口',
description='按当前用户来源现场或目标仓库数据范围统计待核对退回、退回中托盘与本月回仓去向',
response_model=DataResponseModel[ReturnOverviewModel],
dependencies=[UserInterfaceAuthDependency('business:return:list')],
)
async def get_business_return_summary(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await ReturnService.get_return_overview_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@return_controller.get(
'/site-options',
summary='获取退回来源现场选项接口',
description='返回当前用户授权的现场及其可见退回单涉及的来源现场,用于筛选与显示名称',
response_model=DataResponseModel[list[ReturnSiteOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:return:list')],
)
async def get_business_return_site_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await ReturnService.get_site_options_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@return_controller.get( @return_controller.get(
'/warehouse-options', '/warehouse-options',
summary='获取退回目标仓库选项接口', summary='获取退回目标仓库选项接口',
@@ -18,6 +18,7 @@ from module_business.entity.vo.operation_vo import (
ConfirmCommandModel, ConfirmCommandModel,
PalletScanResponseModel, PalletScanResponseModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
@@ -39,7 +40,7 @@ shipment_controller = APIRouterPro(
'/list', '/list',
summary='获取整托发货单分页列表接口', summary='获取整托发货单分页列表接口',
description='按当前用户仓库与现场双数据范围获取整托发货单', description='按当前用户仓库与现场双数据范围获取整托发货单',
response_model=PageResponseModel[ShipmentModel], response_model=PageResponseModel[ShipmentListItemModel],
dependencies=[UserInterfaceAuthDependency('business:shipment:list')], dependencies=[UserInterfaceAuthDependency('business:shipment:list')],
) )
async def get_business_shipment_list( async def get_business_shipment_list(
@@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, SiteListItemModel
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
BusinessUserOptionModel, BusinessUserOptionModel,
DeleteSiteModel, DeleteSiteModel,
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
UserAssignModel, UserAssignModel,
) )
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from module_business.service.pallet_service import PalletService from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -31,6 +33,26 @@ ASSIGN_CURRENT_USER = CurrentUserDependency
site_controller = APIRouterPro( site_controller = APIRouterPro(
prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()] prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()]
) )
# 上级资料选项单独注册且排在详情路由之前,避免被 /{site_id} 捕获
site_option_controller = APIRouterPro(
prefix='/master/site', order_num=99, tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()]
)
@site_option_controller.get(
'/customer-options',
summary='获取客户现场可选客户接口',
description='新增或修改客户现场时选择所属客户,只返回状态正常的客户',
response_model=DataResponseModel[list[MasterParentOptionModel]],
dependencies=[UserInterfaceAuthDependency('master:site:list')],
)
async def get_master_site_customer_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
options = await MasterSummaryService.customer_options(query_db, scope)
return ResponseUtil.success(data=options)
site_user_controller = APIRouterPro( site_user_controller = APIRouterPro(
prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()] prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()]
) )
@@ -77,7 +99,7 @@ async def assign_master_site_users(
'/list', '/list',
summary='获取客户现场分页列表接口', summary='获取客户现场分页列表接口',
description='用于获取客户现场分页列表', description='用于获取客户现场分页列表',
response_model=PageResponseModel[SiteModel], response_model=PageResponseModel[SiteListItemModel],
dependencies=[UserInterfaceAuthDependency('master:site:list')], dependencies=[UserInterfaceAuthDependency('master:site:list')],
) )
async def get_master_site_list( async def get_master_site_list(
@@ -87,6 +109,7 @@ async def get_master_site_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
site_page_query_result = await MasterService.list_site(query_db, site_page_query, scope, is_page=True) site_page_query_result = await MasterService.list_site(query_db, site_page_query, scope, is_page=True)
site_page_query_result = await MasterSummaryService.enrich_page(query_db, 'site', site_page_query_result, scope)
return ResponseUtil.success(model_content=site_page_query_result) return ResponseUtil.success(model_content=site_page_query_result)
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import StrainListItemModel
from module_business.entity.vo.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel from module_business.entity.vo.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
strain_controller = APIRouterPro( strain_controller = APIRouterPro(
@@ -27,7 +29,7 @@ strain_controller = APIRouterPro(
'/list', '/list',
summary='获取菌种分页列表接口', summary='获取菌种分页列表接口',
description='用于获取菌种分页列表', description='用于获取菌种分页列表',
response_model=PageResponseModel[StrainModel], response_model=PageResponseModel[StrainListItemModel],
dependencies=[UserInterfaceAuthDependency('master:strain:list')], dependencies=[UserInterfaceAuthDependency('master:strain:list')],
) )
async def get_master_strain_list( async def get_master_strain_list(
@@ -37,6 +39,9 @@ async def get_master_strain_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, scope, is_page=True) strain_page_query_result = await MasterService.list_strain(query_db, strain_page_query, scope, is_page=True)
strain_page_query_result = await MasterSummaryService.enrich_page(
query_db, 'strain', strain_page_query_result, scope
)
return ResponseUtil.success(model_content=strain_page_query_result) return ResponseUtil.success(model_content=strain_page_query_result)
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, TunnelListItemModel
from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
tunnel_controller = APIRouterPro( tunnel_controller = APIRouterPro(
@@ -23,13 +25,33 @@ tunnel_controller = APIRouterPro(
tags=['基础资料-隧道管理'], tags=['基础资料-隧道管理'],
dependencies=[PreAuthDependency()], dependencies=[PreAuthDependency()],
) )
# 上级资料选项单独注册且排在详情路由之前,避免被 /{tunnel_id} 捕获
tunnel_option_controller = APIRouterPro(
prefix='/master/tunnel', order_num=99, tags=['基础资料-隧道管理'], dependencies=[PreAuthDependency()]
)
@tunnel_option_controller.get(
'/site-options',
summary='获取隧道可选现场接口',
description='新增或修改隧道时选择所属现场,只返回状态正常的现场',
response_model=DataResponseModel[list[MasterParentOptionModel]],
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
)
async def get_master_tunnel_site_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
options = await MasterSummaryService.site_options(query_db, scope)
return ResponseUtil.success(data=options)
@tunnel_controller.get( @tunnel_controller.get(
'/list', '/list',
summary='获取隧道分页列表接口', summary='获取隧道分页列表接口',
description='用于获取客户现场隧道分页列表', description='用于获取客户现场隧道分页列表',
response_model=PageResponseModel[TunnelModel], response_model=PageResponseModel[TunnelListItemModel],
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')], dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
) )
async def get_master_tunnel_list( async def get_master_tunnel_list(
@@ -39,6 +61,7 @@ async def get_master_tunnel_list(
scope: Annotated[TestBatchScope, TestBatchScopeDependency()], scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response: ) -> Response:
result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True) result = await MasterService.list_tunnel(query_db, tunnel_page_query, scope, is_page=True)
result = await MasterSummaryService.enrich_page(query_db, 'tunnel', result, scope)
return ResponseUtil.success(model_content=result) return ResponseUtil.success(model_content=result)
@@ -21,8 +21,11 @@ from module_business.entity.vo.operation_vo import (
TunnelEntryCreateModel, TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel, TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
UsageSuccessResponseModel, UsageSuccessResponseModel,
) )
from module_business.service.usage_service import UsageService from module_business.service.usage_service import UsageService
@@ -39,7 +42,7 @@ usage_controller = APIRouterPro(
'/list', '/list',
summary='获取客户现场使用记录分页列表接口', summary='获取客户现场使用记录分页列表接口',
description='按当前用户客户现场数据范围获取使用记录', description='按当前用户客户现场数据范围获取使用记录',
response_model=PageResponseModel[UsageModel], response_model=PageResponseModel[UsageListItemModel],
dependencies=[UserInterfaceAuthDependency('business:usage:list')], dependencies=[UserInterfaceAuthDependency('business:usage:list')],
) )
async def get_business_usage_list( async def get_business_usage_list(
@@ -95,6 +98,41 @@ async def get_business_usage_tunnel_options(
return ResponseUtil.success(data=result) return ResponseUtil.success(data=result)
@usage_controller.get(
'/site-options',
summary='获取客户现场使用的现场选项接口',
description='返回当前用户有权访问且已启用的客户现场,附带启用隧道与产线数',
response_model=DataResponseModel[list[UsageSiteOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:usage:list')],
)
async def get_business_usage_site_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
test_scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
) -> Response:
result = await UsageService.get_site_options_services(query_db, current_user, test_scope)
return ResponseUtil.success(data=result)
@usage_controller.get(
'/site-board',
summary='获取客户现场看板接口',
description='统计指定现场的托盘库存、今日用量、隧道使用与跳过先到托盘次数;不返回内部溯源号',
response_model=DataResponseModel[UsageSiteBoardModel],
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
)
async def get_business_usage_site_board(
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_site_board_services(query_db, site_id, current_user, test_scope)
return ResponseUtil.success(data=result)
@usage_controller.post( @usage_controller.post(
'/enter-tunnel', '/enter-tunnel',
summary='扫码入隧接口', summary='扫码入隧接口',
@@ -13,18 +13,22 @@ from common.vo import DataResponseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel
from module_business.service.usage_fifo_service import UsageFifoService from module_business.service.usage_fifo_service import UsageFifoService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
usage_fifo_controller = APIRouterPro( usage_fifo_controller = APIRouterPro(
prefix='/business/usage-fifo', prefix='/business/usage-fifo',
tags=['业务管理-客户现场使用'], tags=['业务管理-客户现场使用'],
dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('business:usage:query')], dependencies=[PreAuthDependency()],
) )
@usage_fifo_controller.get('/recommendation', response_model=DataResponseModel[UsageFifoRecommendationModel]) @usage_fifo_controller.get(
'/recommendation',
response_model=DataResponseModel[UsageFifoRecommendationModel],
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
)
async def get_usage_fifo_recommendation( async def get_usage_fifo_recommendation(
request: Request, request: Request,
qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')], qr_token: Annotated[str, Query(alias='qrToken', min_length=1, max_length=128, pattern=r'\S')],
@@ -37,3 +41,19 @@ async def get_usage_fifo_recommendation(
query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id, query_db, qr_token, current_user, test_scope, tunnel_id=tunnel_id,
) )
return ResponseUtil.success(data=result) return ResponseUtil.success(data=result)
@usage_fifo_controller.get(
'/queue',
response_model=DataResponseModel[UsageFifoQueueModel],
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
)
async def get_usage_fifo_queue(
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 UsageFifoService.queue(query_db, site_id, current_user, test_scope)
return ResponseUtil.success(data=result)
@@ -14,6 +14,7 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import WarehouseListItemModel
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
BusinessUserOptionModel, BusinessUserOptionModel,
DeleteWarehouseModel, DeleteWarehouseModel,
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
WarehousePageQueryModel, WarehousePageQueryModel,
) )
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.master_summary_service import MasterSummaryService
from module_business.service.pallet_service import PalletService from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
@@ -77,7 +79,7 @@ async def assign_master_warehouse_users(
'/list', '/list',
summary='获取仓库分页列表接口', summary='获取仓库分页列表接口',
description='用于获取仓库分页列表', description='用于获取仓库分页列表',
response_model=PageResponseModel[WarehouseModel], response_model=PageResponseModel[WarehouseListItemModel],
dependencies=[UserInterfaceAuthDependency('master:warehouse:list')], dependencies=[UserInterfaceAuthDependency('master:warehouse:list')],
) )
async def get_master_warehouse_list( async def get_master_warehouse_list(
@@ -92,6 +94,9 @@ async def get_master_warehouse_list(
scope, scope,
is_page=True, is_page=True,
) )
warehouse_page_query_result = await MasterSummaryService.enrich_page(
query_db, 'warehouse', warehouse_page_query_result, scope
)
return ResponseUtil.success(model_content=warehouse_page_query_result) return ResponseUtil.success(model_content=warehouse_page_query_result)
@@ -0,0 +1,381 @@
from datetime import date, datetime
from typing import Any
from sqlalchemy import ColumnElement, and_, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.user_do import SysUser
from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizPallet, BizWarehouse
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
from module_business.entity.vo.operation_vo import FillingBatchPageQueryModel
# 装填周期已结束(物料用完、托盘回到空托)的托盘在批次去向中归为一类
CONSUMED_STATE = 'CONSUMED'
RECENT_RECORD_LIMIT = 10
TOP_STRAIN_LIMIT = 3
class FillingSummaryDao:
"""工厂装填的只读汇总:列表名称、按生产批次聚合、批次去向与页面概况。
装填记录按仓库数据范围筛选;所有关联表均受同一测试批次范围约束。
"""
@staticmethod
def _batch_columns() -> list[Any]:
return [
BizFillingRecord.strain_id,
BizFillingRecord.batch_no,
func.max(BizFillingRecord.strain_code_snapshot).label('strain_code'),
func.max(BizFillingRecord.strain_name_snapshot).label('strain_name'),
func.min(BizFillingRecord.production_date).label('production_date'),
func.min(BizFillingRecord.expiry_date).label('expiry_date'),
func.count(BizFillingRecord.filling_id).label('pallet_count'),
func.sum(BizFillingRecord.initial_packages).label('package_sum'),
func.max(BizFillingRecord.operated_at).label('last_operated_at'),
func.max(BizFillingRecord.filling_id).label('last_filling_id'),
]
@staticmethod
def _batch_row(row: Any) -> dict[str, Any]:
return {
'strain_id': row.strain_id,
'strain_code': row.strain_code,
'strain_name': row.strain_name,
'batch_no': row.batch_no,
'production_date': row.production_date,
'expiry_date': row.expiry_date,
'package_spec': None,
'pallet_count': int(row.pallet_count or 0),
'package_sum': int(row.package_sum or 0),
'last_operated_at': row.last_operated_at,
'last_operator_name': None,
'last_filling_id': row.last_filling_id,
}
@staticmethod
async def _attach_latest_record(
db: AsyncSession,
batches: list[dict[str, Any]],
scope: TestBatchScope,
) -> None:
"""用批次内最新一条记录补充包装规格与操作人。"""
latest_ids = sorted({batch['last_filling_id'] for batch in batches if batch['last_filling_id']})
if not latest_ids:
return
statement = (
select(BizFillingRecord.filling_id, BizFillingRecord.package_spec, SysUser.nick_name)
.select_from(BizFillingRecord)
.outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0'))
.where(
BizFillingRecord.filling_id.in_(latest_ids),
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
)
latest = {filling_id: (spec, name) for filling_id, spec, name in (await db.execute(statement)).all()}
for batch in batches:
spec, name = latest.get(batch['last_filling_id'], (None, None))
batch['package_spec'] = spec
batch['last_operator_name'] = name
@staticmethod
async def get_list_summaries(
db: AsyncSession,
filling_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(filling_ids))
if not ids:
return {}
statement = (
select(
BizFillingRecord.filling_id,
BizPallet.pallet_code,
BizWarehouse.warehouse_name,
SysUser.nick_name,
)
.select_from(BizFillingRecord)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizFillingRecord.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0'))
.where(
BizFillingRecord.filling_id.in_(ids),
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
)
summaries: dict[int, dict[str, Any]] = {
filling_id: {'pallet_code': None, 'warehouse_name': None, 'operator_name': None} for filling_id in ids
}
for filling_id, pallet_code, warehouse_name, operator_name in (await db.execute(statement)).all():
summary = summaries.get(filling_id)
if summary is not None:
summary.update(pallet_code=pallet_code, warehouse_name=warehouse_name, operator_name=operator_name)
return summaries
@classmethod
async def get_batch_page(
cls,
db: AsyncSession,
query_object: FillingBatchPageQueryModel,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
) -> tuple[list[dict[str, Any]], int]:
"""按菌种 + 生产批次聚合,最近装填的批次在前。"""
conditions: list[ColumnElement[bool]] = OperationDao._filling_scope_conditions(warehouse_ids, scope)
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
conditions.append(
or_(
BizFillingRecord.batch_no.like(keyword),
BizFillingRecord.strain_code_snapshot.like(keyword),
BizFillingRecord.strain_name_snapshot.like(keyword),
)
)
if query_object.strain_id:
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
conditions.extend(
OperationDao._time_conditions(
BizFillingRecord.operated_at,
query_object.begin_time,
query_object.end_time,
)
)
grouped = (
select(*cls._batch_columns())
.where(*conditions)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
total = (await db.execute(select(func.count()).select_from(grouped.subquery()))).scalar_one()
page_statement = (
grouped.order_by(
func.max(BizFillingRecord.operated_at).desc(),
func.max(BizFillingRecord.filling_id).desc(),
)
.offset((query_object.page_num - 1) * query_object.page_size)
.limit(query_object.page_size)
)
batches = [cls._batch_row(row) for row in (await db.execute(page_statement)).all()]
await cls._attach_latest_record(db, batches, scope)
return batches, int(total or 0)
@classmethod
async def get_batch_detail(
cls,
db: AsyncSession,
strain_id: int,
batch_no: str,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
) -> dict[str, Any] | None:
conditions = [
*OperationDao._filling_scope_conditions(warehouse_ids, scope),
BizFillingRecord.strain_id == strain_id,
BizFillingRecord.batch_no == batch_no,
]
header_statement = (
select(*cls._batch_columns())
.where(*conditions)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
header_row = (await db.execute(header_statement)).first()
if header_row is None:
return None
batch = cls._batch_row(header_row)
# 托盘仍在本次装填周期内时取其当前状态;周期已结束则物料已用完
distribution_statement = (
select(BizPalletInventory.flow_state, func.count(BizFillingRecord.filling_id))
.select_from(BizFillingRecord)
.outerjoin(
BizPalletInventory,
and_(
BizPalletInventory.pallet_id == BizFillingRecord.pallet_id,
BizPalletInventory.active_filling_id == BizFillingRecord.filling_id,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
),
)
.where(*conditions)
.group_by(BizPalletInventory.flow_state)
)
recent_statement = (
select(
BizFillingRecord.filling_id,
BizFillingRecord.filling_no,
BizFillingRecord.pallet_id,
BizPallet.pallet_code,
BizWarehouse.warehouse_name,
SysUser.nick_name,
BizFillingRecord.operated_at,
BizFillingRecord.package_spec,
)
.select_from(BizFillingRecord)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizFillingRecord.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0'))
.where(*conditions)
.order_by(BizFillingRecord.operated_at.desc(), BizFillingRecord.filling_id.desc())
.limit(RECENT_RECORD_LIMIT)
)
counts: dict[str, int] = {}
for flow_state, count in (await db.execute(distribution_statement)).all():
state = flow_state or CONSUMED_STATE
counts[state] = counts.get(state, 0) + int(count or 0)
recent_rows = (await db.execute(recent_statement)).all()
batch['distribution'] = [
{'state': state, 'pallet_count': count}
for state, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
]
batch['recent'] = [
{
'filling_id': filling_id,
'filling_no': filling_no,
'pallet_id': pallet_id,
'pallet_code': pallet_code,
'warehouse_name': warehouse_name,
'operator_name': operator_name,
'operated_at': operated_at,
}
for filling_id, filling_no, pallet_id, pallet_code, warehouse_name, operator_name, operated_at, _ in recent_rows
]
if recent_rows:
batch['package_spec'] = recent_rows[0][7]
batch['last_operator_name'] = recent_rows[0][5]
return batch
@staticmethod
async def get_overview(
db: AsyncSession,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
today_start: datetime,
month_start: datetime,
near_expiry_until: date,
near_expiry_days: int,
) -> dict[str, Any]:
"""装填页概况:今日与本月装填、可装填空托盘、临期仍在仓。"""
filling_conditions = OperationDao._filling_scope_conditions(warehouse_ids, scope)
inventory_conditions = [batch_scope_condition(BizPalletInventory.test_batch_id, scope)]
if warehouse_ids is not None:
scoped_ids = sorted(set(warehouse_ids))
inventory_conditions.append(BizPalletInventory.warehouse_id.in_(scoped_ids) if scoped_ids else false())
def batches_since(since: datetime) -> Any:
return (
select(
BizFillingRecord.strain_id,
BizFillingRecord.batch_no,
func.max(BizFillingRecord.strain_name_snapshot),
func.max(BizFillingRecord.strain_code_snapshot),
func.count(BizFillingRecord.filling_id),
func.sum(BizFillingRecord.initial_packages),
)
.where(*filling_conditions, BizFillingRecord.operated_at >= since)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
empty_statement = (
select(
BizPalletInventory.warehouse_id,
BizWarehouse.warehouse_name,
func.count(BizPalletInventory.pallet_id),
)
.select_from(BizPalletInventory)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.where(
*inventory_conditions,
BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value,
BizPalletInventory.warehouse_id.is_not(None),
)
.group_by(BizPalletInventory.warehouse_id, BizWarehouse.warehouse_name)
)
near_expiry_statement = (
select(
BizFillingRecord.batch_no,
func.min(BizFillingRecord.expiry_date),
func.count(BizPalletInventory.pallet_id),
)
.select_from(BizPalletInventory)
.join(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
*inventory_conditions,
BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value,
BizFillingRecord.expiry_date <= near_expiry_until,
)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
today_rows = (await db.execute(batches_since(today_start))).all()
month_rows = (await db.execute(batches_since(month_start))).all()
empty_rows = [
{'warehouse_id': warehouse_id, 'warehouse_name': warehouse_name, 'pallet_count': int(count or 0)}
for warehouse_id, warehouse_name, count in (await db.execute(empty_statement)).all()
]
empty_rows.sort(key=lambda row: (-row['pallet_count'], row['warehouse_id']))
near_rows = sorted(
(await db.execute(near_expiry_statement)).all(),
key=lambda row: (row[1] or date.max, row[0]),
)
strain_counts: dict[str, int] = {}
for _, _, strain_name, strain_code, count, _ in month_rows:
label = strain_name or strain_code
if label:
strain_counts[label] = strain_counts.get(label, 0) + int(count or 0)
top_strains = sorted(strain_counts.items(), key=lambda entry: (-entry[1], entry[0]))[:TOP_STRAIN_LIMIT]
nearest = near_rows[0] if near_rows else None
return {
'today_pallet_count': sum(int(row[4] or 0) for row in today_rows),
'today_package_sum': sum(int(row[5] or 0) for row in today_rows),
'today_batch_count': len(today_rows),
'month_start': month_start.date(),
'month_pallet_count': sum(int(row[4] or 0) for row in month_rows),
'month_batch_count': len(month_rows),
'month_top_strains': [{'strain_name': name, 'pallet_count': count} for name, count in top_strains],
'empty_pallet_count': sum(row['pallet_count'] for row in empty_rows),
'empty_by_warehouse': empty_rows,
'near_expiry_days': near_expiry_days,
'near_expiry_pallet_count': sum(int(row[2] or 0) for row in near_rows),
'near_expiry_batch_count': len(near_rows),
'nearest_expiry_batch_no': nearest[0] if nearest else None,
'nearest_expiry_date': nearest[1] if nearest else None,
}
@@ -0,0 +1,220 @@
from datetime import datetime
from typing import Any
from sqlalchemy import and_, false, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from module_admin.entity.do.user_do import SysUser
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, BizWarehouse
from module_business.entity.do.operation_do import BizInboundItem, BizInboundOrder, BizPalletInventory
class InboundSummaryDao:
"""首次入库的只读汇总:列表名称与托盘数、详情托盘编号与当前状态、页面概况。
列表与详情只汇总调用方已按数据范围取得的单据,所有关联表均受同一测试批次范围约束。
"""
@staticmethod
async def get_list_summaries(
db: AsyncSession,
inbound_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(inbound_ids))
if not ids:
return {}
creator = aliased(SysUser, name='creator')
confirmer = aliased(SysUser, name='confirmer')
name_statement = (
select(
BizInboundOrder.inbound_id,
BizWarehouse.warehouse_name,
creator.nick_name,
confirmer.nick_name,
)
.select_from(BizInboundOrder)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizInboundOrder.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(creator, and_(creator.user_name == BizInboundOrder.create_by, creator.del_flag == '0'))
.outerjoin(confirmer, and_(confirmer.user_id == BizInboundOrder.confirmed_by, confirmer.del_flag == '0'))
.where(
BizInboundOrder.inbound_id.in_(ids),
batch_scope_condition(BizInboundOrder.test_batch_id, scope),
)
)
count_statement = (
select(BizInboundItem.inbound_id, func.count(BizInboundItem.inbound_item_id))
.where(
BizInboundItem.inbound_id.in_(ids),
batch_scope_condition(BizInboundItem.test_batch_id, scope),
)
.group_by(BizInboundItem.inbound_id)
)
summaries: dict[int, dict[str, Any]] = {
inbound_id: {
'warehouse_name': None,
'creator_name': None,
'confirmer_name': None,
'pallet_count': 0,
}
for inbound_id in ids
}
for inbound_id, warehouse_name, creator_name, confirmer_name in (await db.execute(name_statement)).all():
summary = summaries.get(inbound_id)
if summary is None:
continue
summary.update(
warehouse_name=warehouse_name,
creator_name=creator_name,
confirmer_name=confirmer_name,
)
for inbound_id, pallet_count in (await db.execute(count_statement)).all():
summary = summaries.get(inbound_id)
if summary is not None:
summary['pallet_count'] = int(pallet_count or 0)
return summaries
@staticmethod
async def get_item_identities(
db: AsyncSession,
inbound_id: int,
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
"""为详情明细补充托盘编号与当前流转状态,只读展示,不参与任何写入。"""
statement = (
select(BizInboundItem.inbound_item_id, BizPallet.pallet_code, BizPalletInventory.flow_state)
.select_from(BizInboundItem)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizInboundItem.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizPalletInventory,
and_(
BizPalletInventory.pallet_id == BizInboundItem.pallet_id,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
),
)
.where(
BizInboundItem.inbound_id == inbound_id,
batch_scope_condition(BizInboundItem.test_batch_id, scope),
)
)
return {
inbound_item_id: {'pallet_code': pallet_code, 'flow_state': flow_state}
for inbound_item_id, pallet_code, flow_state in (await db.execute(statement)).all()
}
@staticmethod
async def get_overview(
db: AsyncSession,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
month_start: datetime,
) -> dict[str, Any]:
"""入库页概况。单据与空托盘按仓库数据范围统计;未入库托盘尚无仓库归属,按测试批次范围统计。"""
order_conditions = [batch_scope_condition(BizInboundOrder.test_batch_id, scope)]
inventory_conditions = [batch_scope_condition(BizPalletInventory.test_batch_id, scope)]
if warehouse_ids is not None:
scoped_ids = sorted(set(warehouse_ids))
order_conditions.append(BizInboundOrder.warehouse_id.in_(scoped_ids) if scoped_ids else false())
inventory_conditions.append(BizPalletInventory.warehouse_id.in_(scoped_ids) if scoped_ids else false())
item_join = and_(
BizInboundItem.inbound_id == BizInboundOrder.inbound_id,
batch_scope_condition(BizInboundItem.test_batch_id, scope),
)
draft_statement = (
select(
func.count(func.distinct(BizInboundOrder.inbound_id)),
func.count(BizInboundItem.inbound_item_id),
func.min(BizInboundOrder.create_time),
)
.select_from(BizInboundOrder)
.outerjoin(BizInboundItem, item_join)
.where(*order_conditions, BizInboundOrder.status == DocumentStatus.DRAFT.value)
)
month_statement = (
select(
func.count(func.distinct(BizInboundOrder.inbound_id)),
func.count(BizInboundItem.inbound_item_id),
)
.select_from(BizInboundOrder)
.outerjoin(BizInboundItem, item_join)
.where(
*order_conditions,
BizInboundOrder.status == DocumentStatus.CONFIRMED.value,
BizInboundOrder.confirmed_at >= month_start,
)
)
unstocked_statement = (
select(func.count(BizPalletInventory.pallet_id))
.select_from(BizPalletInventory)
.join(
BizPallet,
and_(
BizPallet.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.where(
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
BizPalletInventory.flow_state == PalletFlowState.UNSTOCKED.value,
BizPallet.asset_status == '0',
)
)
empty_statement = (
select(
BizPalletInventory.warehouse_id,
BizWarehouse.warehouse_name,
func.count(BizPalletInventory.pallet_id),
)
.select_from(BizPalletInventory)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.where(
*inventory_conditions,
BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value,
BizPalletInventory.warehouse_id.is_not(None),
)
.group_by(BizPalletInventory.warehouse_id, BizWarehouse.warehouse_name)
)
draft_count, draft_pallets, oldest_draft_at = (await db.execute(draft_statement)).one()
month_count, month_pallets = (await db.execute(month_statement)).one()
unstocked_count = (await db.execute(unstocked_statement)).scalar_one()
empty_rows = [
{'warehouse_id': warehouse_id, 'warehouse_name': warehouse_name, 'pallet_count': int(count or 0)}
for warehouse_id, warehouse_name, count in (await db.execute(empty_statement)).all()
]
empty_rows.sort(key=lambda row: (-row['pallet_count'], row['warehouse_id']))
return {
'draft_count': int(draft_count or 0),
'draft_pallet_count': int(draft_pallets or 0),
'oldest_draft_at': oldest_draft_at,
'month_start': month_start.date(),
'month_confirmed_count': int(month_count or 0),
'month_confirmed_pallet_count': int(month_pallets or 0),
'unstocked_pallet_count': int(unstocked_count or 0),
'empty_pallet_count': sum(row['pallet_count'] for row in empty_rows),
'empty_by_warehouse': empty_rows,
}
@@ -2,12 +2,13 @@ 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, func, select, update from sqlalchemy import ColumnElement, Select, func, or_, 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
from common.vo import PageModel from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import PalletFlowState
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 ( from module_business.entity.do.master_do import (
BizCustomer, BizCustomer,
@@ -39,6 +40,18 @@ from module_business.entity.vo.master_vo import (
) )
from utils.page_util import PageUtil from utils.page_util import PageUtil
_PALLET_FLOW_GROUP_STATES = {
'unstocked': (PalletFlowState.UNSTOCKED.value,),
'warehouse': (PalletFlowState.EMPTY_IN_WAREHOUSE.value, PalletFlowState.FILLED_IN_WAREHOUSE.value),
'transit': (PalletFlowState.IN_TRANSIT.value,),
'site': (
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
PalletFlowState.AT_SITE_EMPTY.value,
),
'returning': (PalletFlowState.RETURNING.value,),
}
class MasterDao: class MasterDao:
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。""" """菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
@@ -494,15 +507,44 @@ class MasterDao:
) -> PageModel | list[dict[str, Any]]: ) -> PageModel | list[dict[str, Any]]:
conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)] conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)]
if query_object.pallet_code: if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) # 扫码枪输入的是二维码固定标识,按编号模糊或标识精确匹配
conditions.append(
or_(
BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'),
BizPallet.qr_token == query_object.pallet_code,
)
)
if query_object.asset_status: if query_object.asset_status:
conditions.append(BizPallet.asset_status == query_object.asset_status) conditions.append(BizPallet.asset_status == query_object.asset_status)
if query_object.flow_group:
conditions.append(cls._pallet_flow_group_condition(query_object.flow_group, scope))
ids = cls._parse_id_list(getattr(query_object, 'ids', None)) ids = cls._parse_id_list(getattr(query_object, 'ids', None))
if ids: if ids:
conditions.append(BizPallet.pallet_id.in_(ids)) conditions.append(BizPallet.pallet_id.in_(ids))
statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id) statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id)
return await cls._paginate(db, statement, query_object, is_page) return await cls._paginate(db, statement, query_object, is_page)
@staticmethod
def _pallet_flow_group_condition(flow_group: str, scope: TestBatchScope) -> ColumnElement[bool]:
states = _PALLET_FLOW_GROUP_STATES[flow_group]
in_states = BizPallet.pallet_id.in_(
select(BizPalletInventory.pallet_id).where(
BizPalletInventory.flow_state.in_(states),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
)
if flow_group != 'unstocked':
return in_states
# 历史托盘可能缺少库存行,同样视为未入库
return or_(
in_states,
BizPallet.pallet_id.not_in(
select(BizPalletInventory.pallet_id).where(
batch_scope_condition(BizPalletInventory.test_batch_id, scope)
)
),
)
@classmethod @classmethod
async def get_pallet( async def get_pallet(
cls, cls,
@@ -0,0 +1,997 @@
from datetime import date, datetime, timedelta
from typing import Any
from sqlalchemy import and_, case, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
BizProductionLine,
BizTunnel,
BizUserSite,
BizUserWarehouse,
BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
SITE_STATES = (
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
PalletFlowState.AT_SITE_EMPTY.value,
)
FIELD_STOCK_STATES = (
PalletFlowState.IN_TRANSIT.value,
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
)
EXPIRY_WARNING_DAYS = 7
RECENT_DAYS = 30
USER_PREVIEW_LIMIT = 5
RECENT_EVENT_LIMIT = 6
PARENT_OPTION_LIMIT = 1000
def _count_when(condition: Any) -> Any:
return func.coalesce(func.sum(case((condition, 1), else_=0)), 0)
def _sum_when(condition: Any, column: Any) -> Any:
return func.coalesce(func.sum(case((condition, column), else_=0)), 0)
def _active_user() -> Any:
return and_(SysUser.status == '0', SysUser.del_flag == '0')
class MasterSummaryDao:
"""基础资料列表与详情的只读汇总。
只汇总调用方已分页得到的主数据 ID;每张关联表都受同一测试批次范围约束,
结果只用于展示,不参与任何写入校验。
"""
@staticmethod
def _ids(resource_ids: list[int]) -> list[int]:
return sorted(set(resource_ids))
@classmethod
async def get_strain_summaries(
cls,
db: AsyncSession,
strain_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(strain_ids)
if not ids:
return {}
in_warehouse = BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value
in_field = BizPalletInventory.flow_state.in_(FIELD_STOCK_STATES)
stock_statement = (
select(
BizFillingRecord.strain_id,
_count_when(in_warehouse),
_sum_when(in_warehouse, BizPalletInventory.remaining_packages),
_count_when(in_field),
_sum_when(in_field, BizPalletInventory.remaining_packages),
func.min(case((in_warehouse, BizFillingRecord.expiry_date), else_=None)),
)
.select_from(BizPalletInventory)
.join(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
BizFillingRecord.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizFillingRecord.strain_id.in_(ids),
BizPalletInventory.remaining_packages > 0,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizFillingRecord.strain_id)
)
recent_from = datetime.now() - timedelta(days=RECENT_DAYS)
filling_statement = (
select(
BizFillingRecord.strain_id,
func.max(BizFillingRecord.operated_at),
_count_when(BizFillingRecord.operated_at >= recent_from),
)
.where(
BizFillingRecord.strain_id.in_(ids),
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
.group_by(BizFillingRecord.strain_id)
)
summaries: dict[int, dict[str, Any]] = {strain_id: {} for strain_id in ids}
for strain_id, wh_pallets, wh_packages, field_pallets, field_packages, nearest in (
await db.execute(stock_statement)
).all():
summaries[strain_id].update(
warehouse_pallet_count=int(wh_pallets),
warehouse_package_qty=int(wh_packages),
field_pallet_count=int(field_pallets),
field_package_qty=int(field_packages),
nearest_expiry_date=nearest,
)
for strain_id, last_filled_at, recent_count in (await db.execute(filling_statement)).all():
summaries[strain_id].update(last_filled_at=last_filled_at, filling_count_30d=int(recent_count))
return summaries
@classmethod
async def _site_pallet_counts(
cls,
db: AsyncSession,
group_column: Any,
condition: Any,
scope: TestBatchScope,
) -> dict[int, dict[str, int]]:
state = BizPalletInventory.flow_state
statement = (
select(
group_column,
_count_when(state == PalletFlowState.AT_SITE_UNUSED.value),
_count_when(state == PalletFlowState.AT_SITE_PARTIAL.value),
_count_when(state == PalletFlowState.AT_SITE_EMPTY.value),
_count_when(state == PalletFlowState.IN_TRANSIT.value),
_sum_when(state.in_(SITE_STATES), BizPalletInventory.remaining_packages),
)
.select_from(BizPalletInventory)
.join(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizPalletInventory.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.where(
condition,
state.in_((*SITE_STATES, PalletFlowState.IN_TRANSIT.value)),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(group_column)
)
return {
key: {
'unused_pallet_count': int(unused),
'partial_pallet_count': int(partial),
'empty_pallet_count': int(empty),
'transit_pallet_count': int(transit),
'remaining_package_qty': int(remaining),
}
for key, unused, partial, empty, transit, remaining in (await db.execute(statement)).all()
}
@classmethod
async def _site_users(
cls,
db: AsyncSession,
site_ids: list[int],
scope: TestBatchScope,
) -> dict[int, list[dict[str, Any]]]:
if not site_ids:
return {}
statement = (
select(BizUserSite.site_id, SysUser.user_id, SysUser.user_name, SysUser.nick_name)
.select_from(BizUserSite)
.join(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizUserSite.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.join(SysUser, and_(SysUser.user_id == BizUserSite.user_id, _active_user()))
.where(BizUserSite.site_id.in_(site_ids))
.order_by(BizUserSite.site_id, SysUser.user_id)
)
users: dict[int, list[dict[str, Any]]] = {}
for site_id, user_id, user_name, nick_name in (await db.execute(statement)).all():
users.setdefault(site_id, []).append({'user_id': user_id, 'user_name': user_name, 'nick_name': nick_name})
return users
@classmethod
async def _count_by(
cls,
db: AsyncSession,
group_column: Any,
count_column: Any,
*conditions: Any,
join: tuple[Any, Any] | None = None,
) -> dict[int, int]:
statement = select(group_column, func.count(count_column))
if join is not None:
statement = statement.select_from(count_column.class_).join(*join)
statement = statement.where(*conditions).group_by(group_column)
return {key: int(count) for key, count in (await db.execute(statement)).all()}
@classmethod
async def get_customer_summaries(
cls,
db: AsyncSession,
customer_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(customer_ids)
if not ids:
return {}
site_scope = batch_scope_condition(BizCustomerSite.test_batch_id, scope)
site_join = (
BizCustomerSite,
and_(BizCustomerSite.site_id == BizProductionLine.site_id, site_scope),
)
site_counts = await cls._count_by(
db,
BizCustomerSite.customer_id,
BizCustomerSite.site_id,
BizCustomerSite.customer_id.in_(ids),
site_scope,
)
line_counts = await cls._count_by(
db,
BizCustomerSite.customer_id,
BizProductionLine.line_id,
BizCustomerSite.customer_id.in_(ids),
batch_scope_condition(BizProductionLine.test_batch_id, scope),
join=site_join,
)
tunnel_counts = await cls._count_by(
db,
BizCustomerSite.customer_id,
BizTunnel.tunnel_id,
BizCustomerSite.customer_id.in_(ids),
batch_scope_condition(BizTunnel.test_batch_id, scope),
join=(BizCustomerSite, and_(BizCustomerSite.site_id == BizTunnel.site_id, site_scope)),
)
pallet_counts = await cls._site_pallet_counts(
db, BizCustomerSite.customer_id, BizCustomerSite.customer_id.in_(ids), scope
)
user_statement = (
select(BizCustomerSite.customer_id, func.count(func.distinct(BizUserSite.user_id)))
.select_from(BizUserSite)
.join(BizCustomerSite, and_(BizCustomerSite.site_id == BizUserSite.site_id, site_scope))
.join(SysUser, and_(SysUser.user_id == BizUserSite.user_id, _active_user()))
.where(BizCustomerSite.customer_id.in_(ids))
.group_by(BizCustomerSite.customer_id)
)
user_counts = {key: int(count) for key, count in (await db.execute(user_statement)).all()}
summaries: dict[int, dict[str, Any]] = {}
for customer_id in ids:
pallets = pallet_counts.get(customer_id, {})
summaries[customer_id] = {
'site_count': site_counts.get(customer_id, 0),
'line_count': line_counts.get(customer_id, 0),
'tunnel_count': tunnel_counts.get(customer_id, 0),
'site_pallet_count': pallets.get('unused_pallet_count', 0)
+ pallets.get('partial_pallet_count', 0)
+ pallets.get('empty_pallet_count', 0),
'transit_pallet_count': pallets.get('transit_pallet_count', 0),
'user_count': user_counts.get(customer_id, 0),
}
return summaries
@classmethod
async def get_customer_site_tree(
cls,
db: AsyncSession,
customer_id: int,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
site_rows = (
await db.execute(
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomerSite.address,
BizCustomerSite.status,
)
.where(
BizCustomerSite.customer_id == customer_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
)
.order_by(BizCustomerSite.site_code, BizCustomerSite.site_id)
)
).all()
sites = {
row.site_id: {
'site_id': row.site_id,
'site_code': row.site_code,
'site_name': row.site_name,
'address': row.address,
'status': row.status,
'lines': [],
'tunnels': [],
'users': [],
}
for row in site_rows
}
if not sites:
return []
site_ids = list(sites)
line_pallets = (
select(
BizPalletInventory.current_line_id,
func.count(BizPalletInventory.pallet_id).label('pallet_count'),
)
.where(
BizPalletInventory.site_id.in_(site_ids),
BizPalletInventory.current_line_id.is_not(None),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.current_line_id)
.subquery()
)
line_rows = (
await db.execute(
select(
BizProductionLine.site_id,
BizProductionLine.line_id,
BizProductionLine.line_code,
BizProductionLine.line_name,
BizProductionLine.status,
func.coalesce(line_pallets.c.pallet_count, 0),
)
.outerjoin(line_pallets, line_pallets.c.current_line_id == BizProductionLine.line_id)
.where(
BizProductionLine.site_id.in_(site_ids),
batch_scope_condition(BizProductionLine.test_batch_id, scope),
)
.order_by(BizProductionLine.line_code, BizProductionLine.line_id)
)
).all()
for site_id, line_id, line_code, line_name, status, pallet_count in line_rows:
sites[site_id]['lines'].append(
{
'line_id': line_id,
'line_code': line_code,
'line_name': line_name,
'status': status,
'active_pallet_count': int(pallet_count),
}
)
tunnel_pallets = (
select(
BizPalletInventory.tunnel_id,
func.count(BizPalletInventory.pallet_id).label('pallet_count'),
)
.where(
BizPalletInventory.site_id.in_(site_ids),
BizPalletInventory.tunnel_id.is_not(None),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.tunnel_id)
.subquery()
)
tunnel_rows = (
await db.execute(
select(
BizTunnel.site_id,
BizTunnel.tunnel_id,
BizTunnel.tunnel_code,
BizTunnel.tunnel_name,
BizTunnel.status,
BizTunnel.current_trace_no,
BizTunnel.current_trace_started_at,
func.coalesce(tunnel_pallets.c.pallet_count, 0),
)
.outerjoin(tunnel_pallets, tunnel_pallets.c.tunnel_id == BizTunnel.tunnel_id)
.where(
BizTunnel.site_id.in_(site_ids),
batch_scope_condition(BizTunnel.test_batch_id, scope),
)
.order_by(BizTunnel.tunnel_code, BizTunnel.tunnel_id)
)
).all()
for site_id, tunnel_id, tunnel_code, tunnel_name, status, trace_no, trace_started_at, pallet_count in (
tunnel_rows
):
sites[site_id]['tunnels'].append(
{
'tunnel_id': tunnel_id,
'tunnel_code': tunnel_code,
'tunnel_name': tunnel_name,
'status': status,
'current_trace_no': trace_no,
'current_trace_started_at': trace_started_at,
'active_pallet_count': int(pallet_count),
}
)
pallet_counts = await cls._site_pallet_counts(
db, BizPalletInventory.site_id, BizPalletInventory.site_id.in_(site_ids), scope
)
for site_id, counts in pallet_counts.items():
sites[site_id].update(counts)
for site_id, users in (await cls._site_users(db, site_ids, scope)).items():
sites[site_id]['users'] = users
return list(sites.values())
@classmethod
async def get_site_summaries(
cls,
db: AsyncSession,
site_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(site_ids)
if not ids:
return {}
name_rows = (
await db.execute(
select(BizCustomerSite.site_id, BizCustomer.customer_code, BizCustomer.customer_name)
.select_from(BizCustomerSite)
.join(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(BizCustomerSite.site_id.in_(ids), batch_scope_condition(BizCustomerSite.test_batch_id, scope))
)
).all()
line_counts = await cls._count_by(
db,
BizProductionLine.site_id,
BizProductionLine.line_id,
BizProductionLine.site_id.in_(ids),
batch_scope_condition(BizProductionLine.test_batch_id, scope),
)
tunnel_counts = await cls._count_by(
db,
BizTunnel.site_id,
BizTunnel.tunnel_id,
BizTunnel.site_id.in_(ids),
batch_scope_condition(BizTunnel.test_batch_id, scope),
)
pallet_counts = await cls._site_pallet_counts(
db, BizPalletInventory.site_id, BizPalletInventory.site_id.in_(ids), scope
)
users = await cls._site_users(db, ids, scope)
summaries: dict[int, dict[str, Any]] = {
site_id: {
'line_count': line_counts.get(site_id, 0),
'tunnel_count': tunnel_counts.get(site_id, 0),
**pallet_counts.get(site_id, {}),
'user_count': len(users.get(site_id, [])),
'users': users.get(site_id, [])[:USER_PREVIEW_LIMIT],
}
for site_id in ids
}
for site_id, customer_code, customer_name in name_rows:
summaries[site_id].update(customer_code=customer_code, customer_name=customer_name)
return summaries
@classmethod
async def _child_site_names(
cls,
db: AsyncSession,
model: Any,
id_column: Any,
ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
statement = (
select(id_column, BizCustomerSite.site_code, BizCustomerSite.site_name, BizCustomer.customer_name)
.select_from(model)
.join(
BizCustomerSite,
and_(
BizCustomerSite.site_id == model.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(id_column.in_(ids), batch_scope_condition(model.test_batch_id, scope))
)
return {
resource_id: {'site_code': site_code, 'site_name': site_name, 'customer_name': customer_name}
for resource_id, site_code, site_name, customer_name in (await db.execute(statement)).all()
}
@classmethod
async def get_line_summaries(
cls,
db: AsyncSession,
line_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(line_ids)
if not ids:
return {}
names = await cls._child_site_names(db, BizProductionLine, BizProductionLine.line_id, ids, scope)
pallet_counts = await cls._count_by(
db,
BizPalletInventory.current_line_id,
BizPalletInventory.pallet_id,
BizPalletInventory.current_line_id.in_(ids),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
recent_from = datetime.now() - timedelta(days=RECENT_DAYS)
recent = BizUsageRecord.occurred_at >= recent_from
usage_rows = (
await db.execute(
select(
BizUsageRecord.line_id,
_count_when(recent),
_sum_when(recent, BizUsageRecord.used_packages),
func.max(BizUsageRecord.occurred_at),
)
.where(BizUsageRecord.line_id.in_(ids), batch_scope_condition(BizUsageRecord.test_batch_id, scope))
.group_by(BizUsageRecord.line_id)
)
).all()
summaries = {
line_id: {**names.get(line_id, {}), 'active_pallet_count': pallet_counts.get(line_id, 0)} for line_id in ids
}
for line_id, usage_count, used_packages, last_used_at in usage_rows:
summaries[line_id].update(
usage_count_30d=int(usage_count),
used_package_qty_30d=int(used_packages),
last_used_at=last_used_at,
)
return summaries
@classmethod
async def get_tunnel_summaries(
cls,
db: AsyncSession,
tunnel_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(tunnel_ids)
if not ids:
return {}
names = await cls._child_site_names(db, BizTunnel, BizTunnel.tunnel_id, ids, scope)
pallet_rows = (
await db.execute(
select(
BizPalletInventory.tunnel_id,
func.count(BizPalletInventory.pallet_id),
func.coalesce(func.sum(BizPalletInventory.remaining_packages), 0),
)
.where(
BizPalletInventory.tunnel_id.in_(ids),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.tunnel_id)
)
).all()
recent_from = datetime.now() - timedelta(days=RECENT_DAYS)
usage_rows = (
await db.execute(
select(
BizUsageRecord.tunnel_id,
_count_when(BizUsageRecord.occurred_at >= recent_from),
func.max(BizUsageRecord.occurred_at),
)
.where(
BizUsageRecord.tunnel_id.in_(ids),
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
)
.group_by(BizUsageRecord.tunnel_id)
)
).all()
summaries: dict[int, dict[str, Any]] = {tunnel_id: dict(names.get(tunnel_id, {})) for tunnel_id in ids}
for tunnel_id, pallet_count, package_qty in pallet_rows:
summaries[tunnel_id].update(active_pallet_count=int(pallet_count), active_package_qty=int(package_qty))
for tunnel_id, usage_count, last_used_at in usage_rows:
summaries[tunnel_id].update(usage_count_30d=int(usage_count), last_used_at=last_used_at)
return summaries
@classmethod
async def get_warehouse_summaries(
cls,
db: AsyncSession,
warehouse_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(warehouse_ids)
if not ids:
return {}
today_start = datetime.combine(date.today(), datetime.min.time())
expiry_limit = date.today() + timedelta(days=EXPIRY_WARNING_DAYS)
filled = BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value
stock_rows = (
await db.execute(
select(
BizPalletInventory.warehouse_id,
_count_when(filled),
_sum_when(filled, BizPalletInventory.remaining_packages),
_count_when(BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value),
_count_when(and_(filled, BizFillingRecord.expiry_date <= expiry_limit)),
)
.select_from(BizPalletInventory)
.outerjoin(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
BizFillingRecord.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizPalletInventory.warehouse_id.in_(ids),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.warehouse_id)
)
).all()
filling_counts = await cls._count_by(
db,
BizFillingRecord.warehouse_id,
BizFillingRecord.filling_id,
BizFillingRecord.warehouse_id.in_(ids),
BizFillingRecord.operated_at >= today_start,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
shipment_counts = await cls._count_by(
db,
BizShipmentOrder.source_warehouse_id,
BizShipmentItem.shipment_item_id,
BizShipmentOrder.source_warehouse_id.in_(ids),
BizShipmentOrder.status == DocumentStatus.CONFIRMED.value,
BizShipmentOrder.confirmed_at >= today_start,
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
join=(
BizShipmentOrder,
and_(
BizShipmentOrder.shipment_id == BizShipmentItem.shipment_id,
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
),
),
)
inbound_drafts = await cls._count_by(
db,
BizInboundOrder.warehouse_id,
BizInboundOrder.inbound_id,
BizInboundOrder.warehouse_id.in_(ids),
BizInboundOrder.status == DocumentStatus.DRAFT.value,
batch_scope_condition(BizInboundOrder.test_batch_id, scope),
)
shipment_drafts = await cls._count_by(
db,
BizShipmentOrder.source_warehouse_id,
BizShipmentOrder.shipment_id,
BizShipmentOrder.source_warehouse_id.in_(ids),
BizShipmentOrder.status == DocumentStatus.DRAFT.value,
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
)
incoming_returns = await cls._count_by(
db,
BizReturnOrder.target_warehouse_id,
BizReturnOrder.return_id,
BizReturnOrder.target_warehouse_id.in_(ids),
BizReturnOrder.status == ReturnStatus.REQUESTED.value,
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
)
user_rows = (
await db.execute(
select(BizUserWarehouse.warehouse_id, SysUser.user_id, SysUser.user_name, SysUser.nick_name)
.select_from(BizUserWarehouse)
.join(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizUserWarehouse.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.join(SysUser, and_(SysUser.user_id == BizUserWarehouse.user_id, _active_user()))
.where(BizUserWarehouse.warehouse_id.in_(ids))
.order_by(BizUserWarehouse.warehouse_id, SysUser.user_id)
)
).all()
users: dict[int, list[dict[str, Any]]] = {}
for warehouse_id, user_id, user_name, nick_name in user_rows:
users.setdefault(warehouse_id, []).append(
{'user_id': user_id, 'user_name': user_name, 'nick_name': nick_name}
)
summaries: dict[int, dict[str, Any]] = {
warehouse_id: {
'today_filling_count': filling_counts.get(warehouse_id, 0),
'today_shipment_pallet_count': shipment_counts.get(warehouse_id, 0),
'draft_count': inbound_drafts.get(warehouse_id, 0) + shipment_drafts.get(warehouse_id, 0),
'incoming_return_count': incoming_returns.get(warehouse_id, 0),
'user_count': len(users.get(warehouse_id, [])),
'users': users.get(warehouse_id, [])[:USER_PREVIEW_LIMIT],
}
for warehouse_id in ids
}
for warehouse_id, filled_count, package_qty, empty_count, expiring_count in stock_rows:
summaries[warehouse_id].update(
filled_pallet_count=int(filled_count),
filled_package_qty=int(package_qty),
empty_pallet_count=int(empty_count),
expiring_pallet_count=int(expiring_count),
)
return summaries
@classmethod
async def get_pallet_summaries(
cls,
db: AsyncSession,
pallet_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(pallet_ids)
if not ids:
return {}
state_rows = (
await db.execute(
select(
BizPalletInventory.pallet_id,
BizPalletInventory.flow_state,
BizPalletInventory.remaining_packages,
BizWarehouse.warehouse_name,
BizCustomer.customer_name,
BizCustomerSite.site_name,
BizProductionLine.line_name,
BizTunnel.tunnel_name,
BizFillingRecord.strain_name_snapshot,
BizFillingRecord.batch_no,
BizFillingRecord.expiry_date,
)
.select_from(BizPalletInventory)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizPalletInventory.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizPalletInventory.current_line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
.outerjoin(
BizTunnel,
and_(
BizTunnel.tunnel_id == BizPalletInventory.tunnel_id,
batch_scope_condition(BizTunnel.test_batch_id, scope),
),
)
.outerjoin(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
BizFillingRecord.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizPalletInventory.pallet_id.in_(ids),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
)
).all()
shipped = BizPalletEvent.event_type == PalletEventType.SHIPMENT_CONFIRMED.value
event_rows = (
await db.execute(
select(
BizPalletEvent.pallet_id,
_count_when(shipped),
func.max(case((shipped, BizPalletEvent.occurred_at), else_=None)),
func.max(BizPalletEvent.occurred_at),
)
.where(BizPalletEvent.pallet_id.in_(ids), batch_scope_condition(BizPalletEvent.test_batch_id, scope))
.group_by(BizPalletEvent.pallet_id)
)
).all()
return_rows = (
await db.execute(
select(BizReturnItem.pallet_id, BizWarehouse.warehouse_name)
.select_from(BizReturnItem)
.join(
BizReturnOrder,
and_(
BizReturnOrder.return_id == BizReturnItem.return_id,
BizReturnOrder.status == ReturnStatus.REQUESTED.value,
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
),
)
.join(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.where(BizReturnItem.pallet_id.in_(ids), batch_scope_condition(BizReturnItem.test_batch_id, scope))
)
).all()
summaries: dict[int, dict[str, Any]] = {pallet_id: {} for pallet_id in ids}
for row in state_rows:
summaries[row[0]].update(
flow_state=row[1],
remaining_packages=row[2] or 0,
warehouse_name=row[3],
customer_name=row[4],
site_name=row[5],
line_name=row[6],
tunnel_name=row[7],
strain_name=row[8],
batch_no=row[9],
expiry_date=row[10],
)
for pallet_id, cycle_count, last_shipped_at, last_event_at in event_rows:
summaries[pallet_id].update(
cycle_count=int(cycle_count),
last_shipped_at=last_shipped_at,
last_event_at=last_event_at,
)
for pallet_id, warehouse_name in return_rows:
summaries[pallet_id]['return_warehouse_name'] = warehouse_name
return summaries
@classmethod
async def get_pallet_recent_events(
cls,
db: AsyncSession,
pallet_id: int,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
warehouse = aliased(BizWarehouse, name='event_warehouse')
site = aliased(BizCustomerSite, name='event_site')
customer = aliased(BizCustomer, name='event_customer')
line = aliased(BizProductionLine, name='event_line')
tunnel = aliased(BizTunnel, name='event_tunnel')
operator = aliased(SysUser, name='event_operator')
statement = (
select(
BizPalletEvent.event_id,
BizPalletEvent.event_type,
BizPalletEvent.occurred_at,
BizPalletEvent.after_state,
BizPalletEvent.before_remaining_packages,
BizPalletEvent.after_remaining_packages,
BizPalletEvent.trace_no,
warehouse.warehouse_name,
customer.customer_name,
site.site_name,
line.line_name,
tunnel.tunnel_name,
operator.nick_name,
)
.select_from(BizPalletEvent)
.outerjoin(
warehouse,
and_(
warehouse.warehouse_id
== func.coalesce(BizPalletEvent.after_warehouse_id, BizPalletEvent.before_warehouse_id),
batch_scope_condition(warehouse.test_batch_id, scope),
),
)
.outerjoin(
site,
and_(
site.site_id == func.coalesce(BizPalletEvent.after_site_id, BizPalletEvent.before_site_id),
batch_scope_condition(site.test_batch_id, scope),
),
)
.outerjoin(
customer,
and_(customer.customer_id == site.customer_id, batch_scope_condition(customer.test_batch_id, scope)),
)
.outerjoin(
line,
and_(line.line_id == BizPalletEvent.after_line_id, batch_scope_condition(line.test_batch_id, scope)),
)
.outerjoin(
tunnel,
and_(
tunnel.tunnel_id == BizPalletEvent.after_tunnel_id,
batch_scope_condition(tunnel.test_batch_id, scope),
),
)
.outerjoin(operator, operator.user_id == BizPalletEvent.operator_id)
.where(BizPalletEvent.pallet_id == pallet_id, batch_scope_condition(BizPalletEvent.test_batch_id, scope))
.order_by(BizPalletEvent.event_seq.desc())
.limit(RECENT_EVENT_LIMIT)
)
keys = (
'event_id',
'event_type',
'occurred_at',
'after_state',
'before_remaining_packages',
'after_remaining_packages',
'trace_no',
'warehouse_name',
'customer_name',
'site_name',
'line_name',
'tunnel_name',
'operator_name',
)
return [dict(zip(keys, row, strict=True)) for row in (await db.execute(statement)).all()]
@classmethod
async def list_customer_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[dict[str, Any]]:
"""新增或修改现场时可选的客户:当前数据范围内状态正常的客户。"""
rows = (
await db.execute(
select(BizCustomer.customer_id, BizCustomer.customer_code, BizCustomer.customer_name)
.where(BizCustomer.status == '0', batch_scope_condition(BizCustomer.test_batch_id, scope))
.order_by(BizCustomer.customer_code, BizCustomer.customer_id)
.limit(PARENT_OPTION_LIMIT)
)
).all()
return [{'id': row[0], 'code': row[1], 'name': row[2]} for row in rows]
@classmethod
async def list_site_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[dict[str, Any]]:
"""新增或修改产线、隧道时可选的现场:状态正常且所属客户也正常的现场。"""
rows = (
await db.execute(
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomer.customer_name,
)
.select_from(BizCustomerSite)
.join(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
BizCustomer.status == '0',
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(BizCustomerSite.status == '0', batch_scope_condition(BizCustomerSite.test_batch_id, scope))
.order_by(BizCustomer.customer_code, BizCustomerSite.site_code, BizCustomerSite.site_id)
.limit(PARENT_OPTION_LIMIT)
)
).all()
return [{'id': row[0], 'code': row[1], 'name': row[2], 'parent_name': row[3]} for row in rows]
@@ -3,12 +3,13 @@ from typing import Any
from sqlalchemy import ColumnElement, Select, and_, delete, false, or_, select, update from sqlalchemy import ColumnElement, Select, and_, delete, false, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_business.domain.enums import DocumentStatus, PalletFlowState from module_business.domain.enums import DocumentStatus, PalletFlowState
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 BizCustomer, BizCustomerSite, BizPallet, BizWarehouse
from module_business.entity.do.operation_do import ( from module_business.entity.do.operation_do import (
BizFillingRecord, BizFillingRecord,
BizInboundItem, BizInboundItem,
@@ -608,6 +609,15 @@ class OperationDao:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%')) conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
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}%'))
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
conditions.append(
or_(
BizFillingRecord.filling_no.like(keyword),
BizPallet.pallet_code.like(keyword),
BizFillingRecord.batch_no.like(keyword),
)
)
conditions.extend( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizFillingRecord.operated_at, BizFillingRecord.operated_at,
@@ -616,7 +626,7 @@ class OperationDao:
) )
) )
statement = select(BizFillingRecord) statement = select(BizFillingRecord)
if query_object.pallet_code: if query_object.pallet_code or query_object.keyword:
statement = statement.join( statement = statement.join(
BizPallet, BizPallet,
BizPallet.pallet_id == BizFillingRecord.pallet_id, BizPallet.pallet_id == BizFillingRecord.pallet_id,
@@ -680,6 +690,10 @@ class OperationDao:
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id) conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
if query_object.status: if query_object.status:
conditions.append(BizShipmentOrder.status == query_object.status.value) conditions.append(BizShipmentOrder.status == query_object.status.value)
if query_object.receipt_state:
# 收货状态只在确认后有意义,草稿的默认值不参与筛选
conditions.append(BizShipmentOrder.status == DocumentStatus.CONFIRMED.value)
conditions.append(BizShipmentOrder.receipt_state == query_object.receipt_state)
conditions.extend( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizShipmentOrder.create_time, BizShipmentOrder.create_time,
@@ -687,7 +701,45 @@ class OperationDao:
query_object.end_time, query_object.end_time,
) )
) )
statement = select(BizShipmentOrder).where(*conditions).order_by(BizShipmentOrder.shipment_id) statement = select(BizShipmentOrder)
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
source_warehouse = aliased(BizWarehouse, name='source_warehouse')
target_site = aliased(BizCustomerSite, name='target_site')
target_customer = aliased(BizCustomer, name='target_customer')
# 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配
statement = (
statement.outerjoin(
source_warehouse,
and_(
source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id,
batch_scope_condition(source_warehouse.test_batch_id, scope),
),
)
.outerjoin(
target_site,
and_(
target_site.site_id == BizShipmentOrder.target_site_id,
batch_scope_condition(target_site.test_batch_id, scope),
),
)
.outerjoin(
target_customer,
and_(
target_customer.customer_id == target_site.customer_id,
batch_scope_condition(target_customer.test_batch_id, scope),
),
)
)
conditions.append(
or_(
BizShipmentOrder.shipment_no.like(keyword),
source_warehouse.warehouse_name.like(keyword),
target_site.site_name.like(keyword),
target_customer.customer_name.like(keyword),
)
)
statement = statement.where(*conditions).order_by(BizShipmentOrder.shipment_id)
return await cls._paginate(db, statement, query_object, is_page) return await cls._paginate(db, statement, query_object, is_page)
@classmethod @classmethod
@@ -818,6 +870,11 @@ class OperationDao:
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:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%')) conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
conditions.append(or_(BizUsageRecord.usage_no.like(keyword), BizPallet.pallet_code.like(keyword)))
if query_object.fifo_skipped:
conditions.append(BizUsageRecord.fifo_skip_reason.is_not(None))
conditions.extend( conditions.extend(
cls._time_conditions( cls._time_conditions(
BizUsageRecord.occurred_at, BizUsageRecord.occurred_at,
@@ -826,7 +883,7 @@ class OperationDao:
) )
) )
statement = select(BizUsageRecord) statement = select(BizUsageRecord)
if query_object.pallet_code: if query_object.pallet_code or query_object.keyword:
statement = statement.join( statement = statement.join(
BizPallet, BizPallet,
BizPallet.pallet_id == BizUsageRecord.pallet_id, BizPallet.pallet_id == BizUsageRecord.pallet_id,
@@ -884,7 +941,63 @@ class OperationDao:
query_object.end_time, query_object.end_time,
) )
) )
statement = select(BizReturnOrder).where(*conditions).order_by(BizReturnOrder.return_id) statement = select(BizReturnOrder)
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
source_site = aliased(BizCustomerSite, name='source_site')
source_customer = aliased(BizCustomer, name='source_customer')
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
# 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配
statement = (
statement.outerjoin(
source_site,
and_(
source_site.site_id == BizReturnOrder.source_site_id,
batch_scope_condition(source_site.test_batch_id, scope),
),
)
.outerjoin(
source_customer,
and_(
source_customer.customer_id == source_site.customer_id,
batch_scope_condition(source_customer.test_batch_id, scope),
),
)
.outerjoin(
target_warehouse,
and_(
target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
batch_scope_condition(target_warehouse.test_batch_id, scope),
),
)
)
# 托盘编号按明细存在性匹配,不改变每张单据只出现一行
pallet_match = (
select(BizReturnItem.return_item_id)
.join(
BizPallet,
and_(
BizPallet.pallet_id == BizReturnItem.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.where(
BizReturnItem.return_id == BizReturnOrder.return_id,
batch_scope_condition(BizReturnItem.test_batch_id, scope),
BizPallet.pallet_code.like(keyword),
)
.exists()
)
conditions.append(
or_(
BizReturnOrder.return_no.like(keyword),
source_site.site_name.like(keyword),
source_customer.customer_name.like(keyword),
target_warehouse.warehouse_name.like(keyword),
pallet_match,
)
)
statement = statement.where(*conditions).order_by(BizReturnOrder.return_id)
return await cls._paginate(db, statement, query_object, is_page) return await cls._paginate(db, statement, query_object, is_page)
@classmethod @classmethod
@@ -0,0 +1,280 @@
from datetime import datetime
from typing import Any
from sqlalchemy import and_, case, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from module_admin.entity.do.user_do import SysUser
from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import ReturnStatus
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizTunnel, BizWarehouse
from module_business.entity.do.operation_do import BizReturnItem, BizReturnOrder
class ReturnSummaryDao:
"""托盘退回的只读汇总:列表名称与托盘汇总、详情托盘编号与隧道名称、来源现场选项、页面概况。
列表与详情只汇总调用方已按数据范围取得的单据;概况与选项沿用列表的「来源现场或目标仓库」可见范围。
所有关联表均受同一测试批次范围约束。
"""
@staticmethod
def _filled_flag() -> Any:
# 申请时仍有余量的托盘,确认后回到有料库存;其余成为空托盘
return case((BizReturnItem.remaining_packages_snapshot > 0, 1), else_=0)
@staticmethod
async def get_list_summaries(
db: AsyncSession,
return_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(return_ids))
if not ids:
return {}
source_site = aliased(BizCustomerSite, name='source_site')
source_customer = aliased(BizCustomer, name='source_customer')
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
requester = aliased(SysUser, name='requester')
confirmer = aliased(SysUser, name='confirmer')
name_statement = (
select(
BizReturnOrder.return_id,
source_site.site_name,
source_customer.customer_name,
target_warehouse.warehouse_name,
requester.nick_name,
confirmer.nick_name,
)
.select_from(BizReturnOrder)
.outerjoin(
source_site,
and_(
source_site.site_id == BizReturnOrder.source_site_id,
batch_scope_condition(source_site.test_batch_id, scope),
),
)
.outerjoin(
source_customer,
and_(
source_customer.customer_id == source_site.customer_id,
batch_scope_condition(source_customer.test_batch_id, scope),
),
)
.outerjoin(
target_warehouse,
and_(
target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
batch_scope_condition(target_warehouse.test_batch_id, scope),
),
)
.outerjoin(requester, and_(requester.user_id == BizReturnOrder.requested_by, requester.del_flag == '0'))
.outerjoin(confirmer, and_(confirmer.user_id == BizReturnOrder.confirmed_by, confirmer.del_flag == '0'))
.where(
BizReturnOrder.return_id.in_(ids),
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
)
)
item_statement = (
select(
BizReturnItem.return_id,
func.count(BizReturnItem.return_item_id),
func.sum(BizReturnItem.remaining_packages_snapshot),
func.sum(ReturnSummaryDao._filled_flag()),
)
.where(
BizReturnItem.return_id.in_(ids),
batch_scope_condition(BizReturnItem.test_batch_id, scope),
)
.group_by(BizReturnItem.return_id)
)
summaries: dict[int, dict[str, Any]] = {
return_id: {
'source_site_name': None,
'customer_name': None,
'target_warehouse_name': None,
'requester_name': None,
'confirmer_name': None,
'pallet_count': 0,
'package_sum': 0,
'filled_count': 0,
}
for return_id in ids
}
for return_id, site_name, customer_name, warehouse_name, requester_name, confirmer_name in (
await db.execute(name_statement)
).all():
summary = summaries.get(return_id)
if summary is None:
continue
summary.update(
source_site_name=site_name,
customer_name=customer_name,
target_warehouse_name=warehouse_name,
requester_name=requester_name,
confirmer_name=confirmer_name,
)
for return_id, pallet_count, package_sum, filled_count in (await db.execute(item_statement)).all():
summary = summaries.get(return_id)
if summary is not None:
summary.update(
pallet_count=int(pallet_count or 0),
package_sum=int(package_sum or 0),
filled_count=int(filled_count or 0),
)
return summaries
@staticmethod
async def get_item_identities(
db: AsyncSession,
return_id: int,
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
"""为详情明细补充托盘编号与申请时所在隧道名称,只读展示,不参与任何写入或核对。"""
statement = (
select(BizReturnItem.return_item_id, BizPallet.pallet_code, BizTunnel.tunnel_name)
.select_from(BizReturnItem)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizReturnItem.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizTunnel,
and_(
BizTunnel.tunnel_id == BizReturnItem.tunnel_id_snapshot,
batch_scope_condition(BizTunnel.test_batch_id, scope),
),
)
.where(
BizReturnItem.return_id == return_id,
batch_scope_condition(BizReturnItem.test_batch_id, scope),
)
)
return {
return_item_id: {'pallet_code': pallet_code, 'tunnel_name': tunnel_name}
for return_item_id, pallet_code, tunnel_name in (await db.execute(statement)).all()
}
@staticmethod
async def get_site_options(
db: AsyncSession,
site_ids: list[int] | None,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
"""来源现场选项:用户授权的现场(可发起退回),加上其可见退回单涉及的来源现场(用于筛选)。
停用的现场仍保留,以便筛选历史单据。
"""
conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope)]
if site_ids is not None or warehouse_ids is not None:
scoped_site_ids = sorted(set(site_ids or []))
visible_sources = select(BizReturnOrder.source_site_id).where(
# 与列表接口使用同一可见范围
*OperationDao._return_scope_conditions(site_ids, warehouse_ids, scope)
)
conditions.append(
or_(
BizCustomerSite.site_id.in_(scoped_site_ids) if scoped_site_ids else false(),
BizCustomerSite.site_id.in_(visible_sources),
)
)
statement = (
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomer.customer_name,
)
.select_from(BizCustomerSite)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(*conditions)
.order_by(BizCustomerSite.site_id)
)
return [
{'id': site_id, 'code': site_code, 'name': site_name, 'customer_name': customer_name}
for site_id, site_code, site_name, customer_name in (await db.execute(statement)).all()
]
@staticmethod
async def get_overview(
db: AsyncSession,
site_ids: list[int] | None,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
month_start: datetime,
) -> dict[str, Any]:
"""退回页概况。待核对托盘即当前处于「退回中」的托盘,余量取申请时快照(确认前不会变化)。"""
visibility = OperationDao._return_scope_conditions(site_ids, warehouse_ids, scope)
item_join = and_(
BizReturnItem.return_id == BizReturnOrder.return_id,
batch_scope_condition(BizReturnItem.test_batch_id, scope),
)
filled = ReturnSummaryDao._filled_flag()
pending_statement = (
select(
func.count(func.distinct(BizReturnOrder.return_id)),
func.count(BizReturnItem.return_item_id),
func.sum(filled),
func.sum(BizReturnItem.remaining_packages_snapshot),
func.min(BizReturnOrder.requested_at),
)
.select_from(BizReturnOrder)
.outerjoin(BizReturnItem, item_join)
.where(*visibility, BizReturnOrder.status == ReturnStatus.REQUESTED.value)
)
# 按单据分组取回申请与确认时间,平均耗时在应用侧计算,避免依赖数据库的日期差函数
month_statement = (
select(
BizReturnOrder.return_id,
BizReturnOrder.requested_at,
BizReturnOrder.confirmed_at,
func.count(BizReturnItem.return_item_id),
func.sum(filled),
func.sum(BizReturnItem.remaining_packages_snapshot),
)
.select_from(BizReturnOrder)
.outerjoin(BizReturnItem, item_join)
.where(
*visibility,
BizReturnOrder.status == ReturnStatus.CONFIRMED.value,
BizReturnOrder.confirmed_at >= month_start,
)
.group_by(BizReturnOrder.return_id, BizReturnOrder.requested_at, BizReturnOrder.confirmed_at)
)
pending_count, pending_pallets, pending_filled, pending_packages, oldest_pending_at = (
await db.execute(pending_statement)
).one()
month_rows = (await db.execute(month_statement)).all()
durations = [
(confirmed_at - requested_at).total_seconds() / 3600
for _, requested_at, confirmed_at, *_ in month_rows
if requested_at is not None and confirmed_at is not None and confirmed_at >= requested_at
]
return {
'pending_count': int(pending_count or 0),
'pending_pallet_count': int(pending_pallets or 0),
'pending_filled_count': int(pending_filled or 0),
'pending_package_sum': int(pending_packages or 0),
'oldest_pending_at': oldest_pending_at,
'month_start': month_start.date(),
'month_confirmed_count': len(month_rows),
'month_confirmed_pallet_count': sum(int(row[3] or 0) for row in month_rows),
'month_filled_pallet_count': sum(int(row[4] or 0) for row in month_rows),
'month_package_sum': sum(int(row[5] or 0) for row in month_rows),
'month_avg_confirm_hours': round(sum(durations) / len(durations), 1) if durations else None,
}
@@ -0,0 +1,167 @@
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from module_admin.entity.do.user_do import SysUser
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 BizCustomer, BizCustomerSite, BizWarehouse
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletInventory,
BizShipmentItem,
BizShipmentOrder,
)
class ShipmentSummaryDao:
"""发货单列表的只读汇总:名称、托盘数、包数与菌种构成。
只汇总调用方已按数据范围分页得到的单据 ID,所有关联表均受同一测试批次范围约束。
"""
@staticmethod
async def get_list_summaries(
db: AsyncSession,
shipment_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(shipment_ids))
if not ids:
return {}
source_warehouse = aliased(BizWarehouse, name='source_warehouse')
target_site = aliased(BizCustomerSite, name='target_site')
target_customer = aliased(BizCustomer, name='target_customer')
creator = aliased(SysUser, name='creator')
name_statement = (
select(
BizShipmentOrder.shipment_id,
source_warehouse.warehouse_name,
target_site.site_name,
target_customer.customer_name,
creator.nick_name,
)
.select_from(BizShipmentOrder)
.outerjoin(
source_warehouse,
and_(
source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id,
batch_scope_condition(source_warehouse.test_batch_id, scope),
),
)
.outerjoin(
target_site,
and_(
target_site.site_id == BizShipmentOrder.target_site_id,
batch_scope_condition(target_site.test_batch_id, scope),
),
)
.outerjoin(
target_customer,
and_(
target_customer.customer_id == target_site.customer_id,
batch_scope_condition(target_customer.test_batch_id, scope),
),
)
.outerjoin(creator, and_(creator.user_name == BizShipmentOrder.create_by, creator.del_flag == '0'))
.where(
BizShipmentOrder.shipment_id.in_(ids),
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
)
)
# 已确认单使用确认快照;草稿与详情一致,只取来源仓库内仍可发货的当前余量
draft_inventory = and_(
BizShipmentOrder.status == DocumentStatus.DRAFT.value,
BizPalletInventory.pallet_id == BizShipmentItem.pallet_id,
BizPalletInventory.warehouse_id == BizShipmentOrder.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),
)
item_statement = (
select(
BizShipmentItem.shipment_id,
BizShipmentItem.package_qty,
BizPalletInventory.remaining_packages,
BizFillingRecord.strain_code_snapshot,
BizFillingRecord.strain_name_snapshot,
)
.select_from(BizShipmentItem)
.join(
BizShipmentOrder,
and_(
BizShipmentOrder.shipment_id == BizShipmentItem.shipment_id,
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
),
)
.outerjoin(BizPalletInventory, draft_inventory)
.outerjoin(
BizFillingRecord,
and_(
BizFillingRecord.filling_id
== func.coalesce(BizShipmentItem.active_filling_id, BizPalletInventory.active_filling_id),
BizFillingRecord.pallet_id == BizShipmentItem.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizShipmentItem.shipment_id.in_(ids),
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
)
.order_by(BizShipmentItem.shipment_id, BizShipmentItem.shipment_item_id)
)
summaries: dict[int, dict[str, Any]] = {
shipment_id: {
'source_warehouse_name': None,
'target_site_name': None,
'customer_name': None,
'creator_name': None,
'pallet_count': 0,
'package_qty': None,
'strains': [],
}
for shipment_id in ids
}
for shipment_id, warehouse_name, site_name, customer_name, creator_name in (
await db.execute(name_statement)
).all():
summary = summaries.get(shipment_id)
if summary is None:
continue
summary.update(
source_warehouse_name=warehouse_name,
target_site_name=site_name,
customer_name=customer_name,
creator_name=creator_name,
)
strain_counts: dict[int, dict[str, int]] = {}
for shipment_id, package_qty, remaining_packages, strain_code, strain_name in (
await db.execute(item_statement)
).all():
summary = summaries.get(shipment_id)
if summary is None:
continue
summary['pallet_count'] += 1
quantity = package_qty if package_qty is not None else remaining_packages
if quantity is not None:
summary['package_qty'] = (summary['package_qty'] or 0) + quantity
label = strain_name or strain_code
if label:
counts = strain_counts.setdefault(shipment_id, {})
counts[label] = counts.get(label, 0) + 1
for shipment_id, counts in strain_counts.items():
summaries[shipment_id]['strains'] = [
{'strain_name': name, 'pallet_count': count}
for name, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
]
return summaries
@@ -2,17 +2,76 @@
from typing import Any from typing import Any
from sqlalchemy import and_, func, or_, select from sqlalchemy import ColumnElement, Select, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
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 BizFillingRecord, BizPalletInventory from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
# 待使用队列每个菌种最多返回的托盘数;总数仍完整统计
QUEUE_LIMIT_PER_STRAIN = 20
class UsageFifoDao: class UsageFifoDao:
@staticmethod @staticmethod
def _candidate_conditions(
*,
site_id: int,
strain_id: int | None,
tunnel_id: int | None,
trace_no: str | None,
scope: TestBatchScope,
) -> list[ColumnElement[bool]]:
"""建议与待使用队列共用的候选条件;strain_id 为空时不按菌种限制(仅队列使用)。"""
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,
]
if strain_id is not None:
conditions.append(filling.strain_id == strain_id)
conditions.extend(
[
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))
),
]
)
return conditions
@staticmethod
def _join_candidates(statement: Select) -> Select:
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
return (
statement.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),
)
)
@staticmethod
def _fifo_order() -> tuple[Any, ...]:
# 先到先用:按实际收货时间,同一时间按托盘ID稳定排序
return (BizPalletInventory.site_arrived_at, BizPalletInventory.pallet_id)
@classmethod
async def recommendation( async def recommendation(
cls,
db: AsyncSession, db: AsyncSession,
*, *,
site_id: int, site_id: int,
@@ -23,27 +82,10 @@ class UsageFifoDao:
for_update: bool = False, for_update: bool = False,
) -> tuple[dict[str, Any] | None, int]: ) -> tuple[dict[str, Any] | None, int]:
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None)) conditions = cls._candidate_conditions(
conditions = [ site_id=site_id, strain_id=strain_id, tunnel_id=tunnel_id, trace_no=trace_no, scope=scope
inventory.site_id == site_id, )
inventory.warehouse_id.is_(None), base = cls._join_candidates(
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( select(
pallet.pallet_id, pallet.pallet_id,
pallet.pallet_code, pallet.pallet_code,
@@ -51,22 +93,8 @@ class UsageFifoDao:
inventory.remaining_packages, inventory.remaining_packages,
inventory.site_arrived_at, inventory.site_arrived_at,
) )
.select_from(inventory) ).where(*conditions)
.join(pallet, pallet.pallet_id == inventory.pallet_id) statement = base.where(inventory.site_arrived_at.is_not(None)).order_by(*cls._fifo_order()).limit(1)
.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: if for_update:
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。 # 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。 # 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
@@ -83,3 +111,88 @@ class UsageFifoDao:
) )
) )
return (dict(first) if first is not None else None, unknown.scalar_one()) return (dict(first) if first is not None else None, unknown.scalar_one())
@classmethod
async def queue(
cls,
db: AsyncSession,
*,
site_id: int,
scope: TestBatchScope,
limit_per_strain: int = QUEUE_LIMIT_PER_STRAIN,
) -> list[dict[str, Any]]:
"""现场尚未入隧的待使用托盘,按菌种分组;组内顺序与先到先用建议一致(只读)。"""
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
conditions = cls._candidate_conditions(
site_id=site_id, strain_id=None, tunnel_id=None, trace_no=None, scope=scope
)
known_statement = (
cls._join_candidates(
select(
filling.strain_id,
filling.strain_code_snapshot,
filling.strain_name_snapshot,
pallet.pallet_id,
pallet.pallet_code,
filling.batch_no,
filling.expiry_date,
inventory.remaining_packages,
inventory.site_arrived_at,
)
)
.where(*conditions, inventory.site_arrived_at.is_not(None))
.order_by(*cls._fifo_order())
)
unknown_statement = (
cls._join_candidates(
select(
filling.strain_id,
func.max(filling.strain_code_snapshot),
func.max(filling.strain_name_snapshot),
func.count(inventory.pallet_id),
)
)
.where(*conditions, inventory.site_arrived_at.is_(None))
.group_by(filling.strain_id)
)
groups: dict[int, dict[str, Any]] = {}
def group_for(strain_id: int, strain_code: str | None, strain_name: str | None) -> dict[str, Any]:
group = groups.get(strain_id)
if group is None:
group = {
'strain_id': strain_id,
'strain_code': strain_code,
'strain_name': strain_name,
'pallet_count': 0,
'unknown_arrival_count': 0,
'pallets': [],
}
groups[strain_id] = group
return group
for row in (await db.execute(known_statement)).mappings().all():
group = group_for(row['strain_id'], row['strain_code_snapshot'], row['strain_name_snapshot'])
group['pallet_count'] += 1
if len(group['pallets']) < limit_per_strain:
group['pallets'].append(
{
'pallet_id': row['pallet_id'],
'pallet_code': row['pallet_code'],
'batch_no': row['batch_no'],
'expiry_date': row['expiry_date'],
'remaining_packages': row['remaining_packages'],
'site_arrived_at': row['site_arrived_at'],
}
)
for strain_id, strain_code, strain_name, count in (await db.execute(unknown_statement)).all():
group_for(strain_id, strain_code, strain_name)['unknown_arrival_count'] = int(count or 0)
return sorted(
groups.values(),
key=lambda group: (
-(group['pallet_count'] + group['unknown_arrival_count']),
group['strain_name'] or '',
group['strain_id'],
),
)
@@ -0,0 +1,358 @@
from datetime import datetime
from typing import Any
from sqlalchemy import and_, case, false, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizProductionLine, BizTunnel
from module_business.entity.do.operation_do import BizPalletInventory, BizUsageRecord
_IN_USE_STATES = (PalletFlowState.AT_SITE_UNUSED.value, PalletFlowState.AT_SITE_PARTIAL.value)
class UsageSummaryDao:
"""客户现场使用的只读汇总:列表名称、现场选项与现场看板。
不返回隧道内部溯源号;所有关联表均受同一测试批次范围约束。
"""
@staticmethod
def _skipped_pallet_code(snapshot: Any) -> str | None:
# 跳过先到托盘时,写入路径保存了当时的先到托盘快照
if isinstance(snapshot, dict):
code = snapshot.get('palletCode')
return code if isinstance(code, str) and code.strip() else None
return None
@classmethod
async def get_list_summaries(
cls,
db: AsyncSession,
usage_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(usage_ids))
if not ids:
return {}
statement = (
select(
BizUsageRecord.usage_id,
BizPallet.pallet_code,
BizCustomerSite.site_name,
BizTunnel.tunnel_code,
BizTunnel.tunnel_name,
BizProductionLine.line_name,
SysUser.nick_name,
BizUsageRecord.fifo_recommendation,
)
.select_from(BizUsageRecord)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizUsageRecord.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizUsageRecord.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.outerjoin(
BizTunnel,
and_(
BizTunnel.tunnel_id == BizUsageRecord.tunnel_id,
batch_scope_condition(BizTunnel.test_batch_id, scope),
),
)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizUsageRecord.line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
.outerjoin(SysUser, and_(SysUser.user_id == BizUsageRecord.operator_id, SysUser.del_flag == '0'))
.where(
BizUsageRecord.usage_id.in_(ids),
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
)
)
summaries: dict[int, dict[str, Any]] = {
usage_id: {
'pallet_code': None,
'site_name': None,
'tunnel_code': None,
'tunnel_name': None,
'line_name': None,
'operator_name': None,
'skipped_pallet_code': None,
}
for usage_id in ids
}
for usage_id, pallet_code, site_name, tunnel_code, tunnel_name, line_name, operator_name, snapshot in (
await db.execute(statement)
).all():
summary = summaries.get(usage_id)
if summary is None:
continue
summary.update(
pallet_code=pallet_code,
site_name=site_name,
tunnel_code=tunnel_code,
tunnel_name=tunnel_name,
line_name=line_name,
operator_name=operator_name,
skipped_pallet_code=cls._skipped_pallet_code(snapshot),
)
return summaries
@staticmethod
async def get_site_options(
db: AsyncSession,
site_ids: list[int] | None,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
"""当前用户有权访问且已启用的现场,附带启用隧道与产线数。"""
conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope), BizCustomerSite.status == '0']
if site_ids is not None:
scoped_ids = sorted(set(site_ids))
conditions.append(BizCustomerSite.site_id.in_(scoped_ids) if scoped_ids else false())
tunnel_counts = (
select(BizTunnel.site_id, func.count(BizTunnel.tunnel_id).label('tunnel_count'))
.where(BizTunnel.status == '0', batch_scope_condition(BizTunnel.test_batch_id, scope))
.group_by(BizTunnel.site_id)
.subquery()
)
line_counts = (
select(BizProductionLine.site_id, func.count(BizProductionLine.line_id).label('line_count'))
.where(BizProductionLine.status == '0', batch_scope_condition(BizProductionLine.test_batch_id, scope))
.group_by(BizProductionLine.site_id)
.subquery()
)
statement = (
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomer.customer_name,
tunnel_counts.c.tunnel_count,
line_counts.c.line_count,
)
.select_from(BizCustomerSite)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.outerjoin(tunnel_counts, tunnel_counts.c.site_id == BizCustomerSite.site_id)
.outerjoin(line_counts, line_counts.c.site_id == BizCustomerSite.site_id)
.where(*conditions)
.order_by(BizCustomerSite.site_id)
)
return [
{
'id': site_id,
'code': site_code,
'name': site_name,
'customer_name': customer_name,
'tunnel_count': int(tunnel_count or 0),
'line_count': int(line_count or 0),
}
for site_id, site_code, site_name, customer_name, tunnel_count, line_count in (
await db.execute(statement)
).all()
]
@staticmethod
async def get_site_board(
db: AsyncSession,
site: Any,
scope: TestBatchScope,
today_start: datetime,
week_start: datetime,
) -> dict[str, Any]:
"""调用方已校验现场权限与批次范围;这里只做只读统计。"""
site_id = site.site_id
customer_statement = select(BizCustomer.customer_name).where(
BizCustomer.customer_id == site.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
)
inventory_statement = (
select(
BizPalletInventory.flow_state,
func.count(BizPalletInventory.pallet_id),
func.sum(BizPalletInventory.remaining_packages),
)
.where(
BizPalletInventory.site_id == site_id,
BizPalletInventory.warehouse_id.is_(None),
BizPalletInventory.flow_state.in_(
[*_IN_USE_STATES, PalletFlowState.AT_SITE_EMPTY.value],
),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.flow_state)
)
usage_conditions = [
BizUsageRecord.site_id == site_id,
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
]
today_statement = (
select(
BizUsageRecord.line_id,
BizProductionLine.line_name,
func.count(BizUsageRecord.usage_id),
func.sum(BizUsageRecord.used_packages),
)
.select_from(BizUsageRecord)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizUsageRecord.line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
.where(*usage_conditions, BizUsageRecord.occurred_at >= today_start)
.group_by(BizUsageRecord.line_id, BizProductionLine.line_name)
)
skip_statement = select(
func.sum(case((BizUsageRecord.occurred_at >= today_start, 1), else_=0)),
func.count(BizUsageRecord.usage_id),
).where(
*usage_conditions,
BizUsageRecord.fifo_skip_reason.is_not(None),
BizUsageRecord.occurred_at >= week_start,
)
tunnel_statement = (
select(
BizTunnel.tunnel_id,
BizTunnel.tunnel_code,
BizTunnel.tunnel_name,
BizTunnel.current_trace_no.is_not(None).label('in_use'),
BizTunnel.current_trace_started_at,
)
.where(
BizTunnel.site_id == site_id,
BizTunnel.status == '0',
batch_scope_condition(BizTunnel.test_batch_id, scope),
)
.order_by(BizTunnel.tunnel_code, BizTunnel.tunnel_id)
)
tunnel_load_statement = (
select(
BizPalletInventory.tunnel_id,
func.count(BizPalletInventory.pallet_id),
func.sum(BizPalletInventory.remaining_packages),
)
.where(
BizPalletInventory.site_id == site_id,
BizPalletInventory.tunnel_id.is_not(None),
BizPalletInventory.flow_state.in_(_IN_USE_STATES),
BizPalletInventory.remaining_packages > 0,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.tunnel_id)
)
latest_usage = (
select(BizUsageRecord.tunnel_id, func.max(BizUsageRecord.usage_id).label('usage_id'))
.where(*usage_conditions, BizUsageRecord.tunnel_id.is_not(None))
.group_by(BizUsageRecord.tunnel_id)
.subquery()
)
last_use_statement = (
select(BizUsageRecord.tunnel_id, BizUsageRecord.occurred_at, BizProductionLine.line_name)
.select_from(latest_usage)
.join(BizUsageRecord, BizUsageRecord.usage_id == latest_usage.c.usage_id)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizUsageRecord.line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
)
line_statement = (
select(BizProductionLine.line_id, BizProductionLine.line_code, BizProductionLine.line_name)
.where(
BizProductionLine.site_id == site_id,
BizProductionLine.status == '0',
batch_scope_condition(BizProductionLine.test_batch_id, scope),
)
.order_by(BizProductionLine.line_id)
)
customer_name = (await db.execute(customer_statement)).scalar_one_or_none()
stock = {
state: (int(count or 0), int(packages or 0))
for state, count, packages in (await db.execute(inventory_statement)).all()
}
today_rows = (await db.execute(today_statement)).all()
today_skips, week_skips = (await db.execute(skip_statement)).one()
tunnel_rows = (await db.execute(tunnel_statement)).all()
loads = {
tunnel_id: (int(count or 0), int(packages or 0))
for tunnel_id, count, packages in (await db.execute(tunnel_load_statement)).all()
}
last_uses = {
tunnel_id: (occurred_at, line_name)
for tunnel_id, occurred_at, line_name in (await db.execute(last_use_statement)).all()
}
lines = [
{'id': line_id, 'code': line_code, 'name': line_name}
for line_id, line_code, line_name in (await db.execute(line_statement)).all()
]
unused = stock.get(PalletFlowState.AT_SITE_UNUSED.value, (0, 0))
partial = stock.get(PalletFlowState.AT_SITE_PARTIAL.value, (0, 0))
by_line = sorted(
(
{'line_id': line_id, 'line_name': line_name, 'used_packages': int(packages or 0)}
for line_id, line_name, _, packages in today_rows
),
key=lambda row: (-row['used_packages'], row['line_id'] or 0),
)
tunnels = []
for tunnel_id, tunnel_code, tunnel_name, in_use, started_at in tunnel_rows:
pallet_count, remaining = loads.get(tunnel_id, (0, 0))
last_used_at, last_line_name = last_uses.get(tunnel_id, (None, None))
tunnels.append(
{
'tunnel_id': tunnel_id,
'tunnel_code': tunnel_code,
'tunnel_name': tunnel_name,
'in_use': bool(in_use),
'pallet_count': pallet_count,
'remaining_packages': remaining,
'started_at': started_at if in_use else None,
'last_used_at': last_used_at,
'last_line_name': last_line_name,
}
)
return {
'site_id': site_id,
'site_code': site.site_code,
'site_name': site.site_name,
'customer_name': customer_name,
'pallet_count': unused[0] + partial[0],
'package_sum': unused[1] + partial[1],
'unused_pallet_count': unused[0],
'partial_pallet_count': partial[0],
'empty_pallet_count': stock.get(PalletFlowState.AT_SITE_EMPTY.value, (0, 0))[0],
'today_usage_count': sum(int(row[2] or 0) for row in today_rows),
'today_used_packages': sum(row['used_packages'] for row in by_line),
'today_by_line': by_line,
'tunnel_count': len(tunnels),
'active_tunnel_count': sum(1 for tunnel in tunnels if tunnel['in_use']),
'today_fifo_skip_count': int(today_skips or 0),
'week_fifo_skip_count': int(week_skips or 0),
'tunnels': tunnels,
'lines': lines,
}
@@ -0,0 +1,217 @@
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from pydantic.alias_generators import to_camel
from module_business.entity.vo.master_vo import (
CustomerModel,
LineModel,
PalletModel,
SiteModel,
StrainModel,
TunnelModel,
WarehouseModel,
)
class _ReadModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
class MasterUserBriefModel(_ReadModel):
"""已分配到仓库或客户现场的用户摘要。"""
user_id: int = Field(description='用户ID')
user_name: str = Field(description='用户账号')
nick_name: str | None = Field(default=None, description='用户昵称')
class StrainListItemModel(StrainModel):
"""菌种列表行:主数据加当前在库与在外的物料量。"""
warehouse_pallet_count: int = Field(default=0, description='仓库有料托盘数')
warehouse_package_qty: int = Field(default=0, description='仓库有料包数')
field_pallet_count: int = Field(default=0, description='在途与客户现场有料托盘数')
field_package_qty: int = Field(default=0, description='在途与客户现场剩余包数')
nearest_expiry_date: date | None = Field(default=None, description='仓库在库物料最近有效期')
last_filled_at: datetime | None = Field(default=None, description='最近装填时间')
filling_count_30d: int = Field(default=0, description='近30天装填次数')
class CustomerListItemModel(CustomerModel):
"""客户列表行:主数据加现场层级与在用托盘规模。"""
site_count: int = Field(default=0, description='现场数')
line_count: int = Field(default=0, description='产线数')
tunnel_count: int = Field(default=0, description='隧道数')
site_pallet_count: int = Field(default=0, description='客户现场托盘数')
transit_pallet_count: int = Field(default=0, description='在途托盘数')
user_count: int = Field(default=0, description='已分配现场用户数')
class CustomerSiteLineModel(_ReadModel):
line_id: int = Field(description='产线ID')
line_code: str = Field(description='产线编码')
line_name: str = Field(description='产线名称')
status: str = Field(description='状态')
active_pallet_count: int = Field(default=0, description='产线上的托盘数')
class CustomerSiteTunnelModel(_ReadModel):
tunnel_id: int = Field(description='隧道ID')
tunnel_code: str = Field(description='隧道编号')
tunnel_name: str = Field(description='隧道名称')
status: str = Field(description='状态')
current_trace_no: str | None = Field(default=None, description='当前物料溯源号')
current_trace_started_at: datetime | None = Field(default=None, description='当前溯源批次开始时间')
active_pallet_count: int = Field(default=0, description='隧道内托盘数')
class CustomerSiteNodeModel(_ReadModel):
"""客户详情中的一个现场节点。"""
site_id: int = Field(description='现场ID')
site_code: str = Field(description='现场编码')
site_name: str = Field(description='现场名称')
address: str | None = Field(default=None, description='现场地址')
status: str = Field(description='状态')
unused_pallet_count: int = Field(default=0, description='现场未使用托盘数')
partial_pallet_count: int = Field(default=0, description='现场部分使用托盘数')
empty_pallet_count: int = Field(default=0, description='现场空托盘数')
transit_pallet_count: int = Field(default=0, description='在途托盘数')
remaining_package_qty: int = Field(default=0, description='现场剩余包数')
lines: list[CustomerSiteLineModel] = Field(default_factory=list, description='产线')
tunnels: list[CustomerSiteTunnelModel] = Field(default_factory=list, description='隧道')
users: list[MasterUserBriefModel] = Field(default_factory=list, description='已分配用户')
class CustomerDetailModel(CustomerModel):
"""客户详情:主数据加现场、产线、隧道、托盘与用户层级。"""
sites: list[CustomerSiteNodeModel] = Field(default_factory=list, description='现场层级')
class SiteListItemModel(SiteModel):
"""客户现场列表行:主数据加所属客户、层级规模、托盘与授权用户。"""
customer_code: str | None = Field(default=None, description='客户编码')
customer_name: str | None = Field(default=None, description='客户名称')
line_count: int = Field(default=0, description='产线数')
tunnel_count: int = Field(default=0, description='隧道数')
unused_pallet_count: int = Field(default=0, description='现场未使用托盘数')
partial_pallet_count: int = Field(default=0, description='现场部分使用托盘数')
empty_pallet_count: int = Field(default=0, description='现场空托盘数')
transit_pallet_count: int = Field(default=0, description='在途托盘数')
remaining_package_qty: int = Field(default=0, description='现场剩余包数')
user_count: int = Field(default=0, description='已分配用户数')
users: list[MasterUserBriefModel] = Field(default_factory=list, description='已分配用户(前5位)')
class LineListItemModel(LineModel):
"""产线列表行:主数据加所属现场与近期使用。"""
site_code: str | None = Field(default=None, description='现场编码')
site_name: str | None = Field(default=None, description='现场名称')
customer_name: str | None = Field(default=None, description='客户名称')
active_pallet_count: int = Field(default=0, description='产线上的托盘数')
usage_count_30d: int = Field(default=0, description='近30天使用次数')
used_package_qty_30d: int = Field(default=0, description='近30天使用包数')
last_used_at: datetime | None = Field(default=None, description='最近使用时间')
class TunnelListItemModel(TunnelModel):
"""隧道列表行:主数据加所属现场、隧道内托盘与近期使用。"""
site_code: str | None = Field(default=None, description='现场编码')
site_name: str | None = Field(default=None, description='现场名称')
customer_name: str | None = Field(default=None, description='客户名称')
active_pallet_count: int = Field(default=0, description='隧道内托盘数')
active_package_qty: int = Field(default=0, description='隧道内剩余包数')
usage_count_30d: int = Field(default=0, description='近30天使用次数')
last_used_at: datetime | None = Field(default=None, description='最近使用时间')
class WarehouseListItemModel(WarehouseModel):
"""仓库列表行:主数据加库存、今日作业、待处理与授权用户。"""
filled_pallet_count: int = Field(default=0, description='有料托盘数')
filled_package_qty: int = Field(default=0, description='库存包数')
empty_pallet_count: int = Field(default=0, description='空托盘数')
expiring_pallet_count: int = Field(default=0, description='7天内到期托盘数')
today_filling_count: int = Field(default=0, description='今日装填托盘数')
today_shipment_pallet_count: int = Field(default=0, description='今日发货托盘数')
draft_count: int = Field(default=0, description='待确认入库与发货单数')
incoming_return_count: int = Field(default=0, description='待确认退回单数')
user_count: int = Field(default=0, description='已分配用户数')
users: list[MasterUserBriefModel] = Field(default_factory=list, description='已分配用户(前5位)')
class PalletListItemModel(PalletModel):
"""托盘列表行:资产主数据加当前流转状态与位置。"""
flow_state: str | None = Field(default=None, description='当前流转状态')
remaining_packages: int = Field(default=0, description='当前剩余包数')
warehouse_name: str | None = Field(default=None, description='所在仓库')
customer_name: str | None = Field(default=None, description='所在或目标客户')
site_name: str | None = Field(default=None, description='所在或目标现场')
line_name: str | None = Field(default=None, description='当前产线')
tunnel_name: str | None = Field(default=None, description='当前隧道')
return_warehouse_name: str | None = Field(default=None, description='退回目标仓库')
strain_name: str | None = Field(default=None, description='当前菌种')
batch_no: str | None = Field(default=None, description='当前生产批次')
expiry_date: date | None = Field(default=None, description='当前物料有效期')
cycle_count: int = Field(default=0, description='累计发货周转次数')
last_shipped_at: datetime | None = Field(default=None, description='最近一次发货时间')
last_event_at: datetime | None = Field(default=None, description='最近一次状态变化时间')
class PalletEventBriefModel(_ReadModel):
event_id: int = Field(description='事件ID')
event_type: str = Field(description='事件类型')
occurred_at: datetime = Field(description='发生时间')
after_state: str = Field(description='变更后状态')
before_remaining_packages: int = Field(description='变更前剩余包数')
after_remaining_packages: int = Field(description='变更后剩余包数')
trace_no: str | None = Field(default=None, description='物料溯源号')
warehouse_name: str | None = Field(default=None, description='仓库')
customer_name: str | None = Field(default=None, description='客户')
site_name: str | None = Field(default=None, description='现场')
line_name: str | None = Field(default=None, description='产线')
tunnel_name: str | None = Field(default=None, description='隧道')
operator_name: str | None = Field(default=None, description='操作人')
class PalletDetailModel(PalletListItemModel):
"""托盘详情:列表行信息加最近流转事件。"""
recent_events: list[PalletEventBriefModel] = Field(default_factory=list, description='最近流转事件')
class MasterImportPreviewRowModel(_ReadModel):
excel_row: int = Field(description='Excel 行号')
outcome: Literal['add', 'update', 'skip', 'error'] = Field(description='导入时将发生的结果')
message: str = Field(description='校验说明')
values: list[str] = Field(description='按表头顺序的单元格文本')
class MasterImportPreviewModel(_ReadModel):
"""导入校验预览:按真实导入流程逐行校验后整体回滚,不写入任何数据。"""
headers: list[str] = Field(description='模板表头')
update_support: bool = Field(description='编码已存在时是否更新')
total_rows: int = Field(description='数据行数')
add_count: int = Field(description='将新增行数')
update_count: int = Field(description='将更新行数')
skip_count: int = Field(description='将跳过行数')
error_count: int = Field(description='有错误行数')
rows: list[MasterImportPreviewRowModel] = Field(description='逐行结果')
class MasterParentOptionModel(_ReadModel):
"""基础资料表单中可选的上级资料(客户或客户现场)。"""
id: int = Field(description='上级资料ID')
code: str = Field(description='上级资料编码')
name: str = Field(description='上级资料名称')
parent_name: str | None = Field(default=None, description='所属客户名称(现场选项)')
@@ -422,6 +422,9 @@ class PalletPageQueryModel(BaseModel):
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') page_size: int = Field(default=10, gt=0, le=100, 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='托盘编号')
asset_status: Literal['0', '1'] | None = Field(default=None, description='资产状态') asset_status: Literal['0', '1'] | None = Field(default=None, description='资产状态')
flow_group: Literal['unstocked', 'warehouse', 'transit', 'site', 'returning'] | None = Field(
default=None, description='当前流转环节'
)
ids: str | None = Field(default=None, description='主键ID列表(英文逗号分隔)') ids: str | None = Field(default=None, description='主键ID列表(英文逗号分隔)')
@@ -250,6 +250,8 @@ class InboundItemModel(BaseModel):
inbound_item_id: int = Field(gt=0, description='入库明细ID') inbound_item_id: int = Field(gt=0, description='入库明细ID')
inbound_id: int = Field(gt=0, description='入库单ID') inbound_id: int = Field(gt=0, description='入库单ID')
pallet_id: int = Field(gt=0, description='托盘ID') pallet_id: int = Field(gt=0, description='托盘ID')
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)')
flow_state: PalletFlowState | 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='创建时间')
@@ -275,6 +277,44 @@ class InboundModel(BaseModel):
items: list[InboundItemModel] = Field(default_factory=list, description='入库托盘明细') items: list[InboundItemModel] = Field(default_factory=list, description='入库托盘明细')
class InboundListItemModel(InboundModel):
"""首次入库单列表行:在单据字段外附带只读的名称与托盘数。"""
warehouse_name: str | None = Field(default=None, max_length=100, description='入库仓库名称')
creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称')
confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称')
pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数')
class InboundWarehouseCountModel(BaseModel):
"""按仓库统计的托盘数。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
warehouse_id: int = Field(gt=0, description='仓库ID')
warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称')
pallet_count: int = Field(strict=True, ge=0, description='托盘数')
class InboundOverviewModel(BaseModel):
"""首次入库页面概况(只读)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
draft_count: int = Field(strict=True, ge=0, description='待确认草稿单数')
draft_pallet_count: int = Field(strict=True, ge=0, description='待确认草稿托盘数')
oldest_draft_at: datetime | None = Field(default=None, description='最早草稿创建时间')
month_start: date = Field(description='本月统计起始日期')
month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数')
month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认入库托盘数')
unstocked_pallet_count: int = Field(strict=True, ge=0, description='待首次入库托盘数(资产正常且未入库)')
empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库空托盘总数')
empty_by_warehouse: list[InboundWarehouseCountModel] = Field(
default_factory=list,
description='授权仓库空托盘按仓库分布,托盘数从多到少',
)
class FillingModel(BaseModel): class FillingModel(BaseModel):
"""托盘装填记录展示模型。""" """托盘装填记录展示模型。"""
@@ -305,6 +345,68 @@ class FillingModel(BaseModel):
remark: str | None = Field(default=None, max_length=500, description='备注') remark: str | None = Field(default=None, max_length=500, description='备注')
class FillingListItemModel(FillingModel):
"""托盘装填记录列表行:在记录字段外附带只读的托盘编号、仓库与操作人名称。"""
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号')
warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称')
operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称')
class FillingBatchModel(BaseModel):
"""按菌种 + 生产批次聚合的装填汇总(只读)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
strain_id: int = Field(gt=0, description='菌种ID')
strain_code: str | None = Field(default=None, max_length=64, description='菌种编码快照')
strain_name: str | None = Field(default=None, max_length=100, description='菌种名称快照')
batch_no: str = Field(min_length=1, 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='最近一次装填的包装规格')
pallet_count: int = Field(strict=True, ge=0, description='已装填托盘数')
package_sum: int = Field(strict=True, ge=0, description='已装填包数')
last_operated_at: datetime | None = Field(default=None, description='最近装填时间')
last_operator_name: str | None = Field(default=None, max_length=30, description='最近装填操作人昵称')
class FillingBatchStateCountModel(BaseModel):
"""批次托盘按当前去向统计。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
state: str = Field(
min_length=1,
max_length=32,
description='托盘当前流转状态;装填周期已结束(物料用完)时为 CONSUMED',
)
pallet_count: int = Field(strict=True, ge=0, description='托盘数')
class FillingBatchRecordModel(BaseModel):
"""批次内的一条装填记录(只读展示)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
filling_id: int = Field(gt=0, description='装填记录ID')
filling_no: str = Field(min_length=1, max_length=64, description='装填单号')
pallet_id: int = Field(gt=0, description='托盘ID')
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号')
warehouse_name: str | None = Field(default=None, max_length=100, description='装填仓库名称')
operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称')
operated_at: datetime = Field(description='装填时间')
class FillingBatchDetailModel(FillingBatchModel):
"""生产批次详情:批次汇总、托盘当前去向与最近装填记录。"""
distribution: list[FillingBatchStateCountModel] = Field(
default_factory=list, description='托盘当前去向,托盘数从多到少'
)
recent: list[FillingBatchRecordModel] = Field(default_factory=list, description='最近 10 条装填记录')
class ShipmentItemModel(BaseModel): class ShipmentItemModel(BaseModel):
"""整托发货明细模型。""" """整托发货明细模型。"""
@@ -321,6 +423,10 @@ class ShipmentItemModel(BaseModel):
ge=0, ge=0,
description='当前库存剩余包数(只读,非确认快照)', description='当前库存剩余包数(只读,非确认快照)',
) )
pallet_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='菌种名称快照(只读展示)')
batch_no: str | None = Field(default=None, max_length=64, 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='创建时间')
@@ -350,6 +456,61 @@ class ShipmentModel(BaseModel):
items: list[ShipmentItemModel] = Field(default_factory=list, description='发货托盘明细') items: list[ShipmentItemModel] = Field(default_factory=list, description='发货托盘明细')
class ShipmentStrainSummaryModel(BaseModel):
"""发货单内菌种构成。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
strain_name: str = Field(min_length=1, max_length=100, description='菌种名称或编码')
pallet_count: int = Field(strict=True, gt=0, description='托盘数')
class FillingOverviewModel(BaseModel):
"""工厂装填页面概况(只读)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
today_pallet_count: int = Field(strict=True, ge=0, description='今日装填托盘数')
today_package_sum: int = Field(strict=True, ge=0, description='今日装填包数')
today_batch_count: int = Field(strict=True, ge=0, description='今日涉及的生产批次数')
month_start: date = Field(description='本月统计起始日期')
month_pallet_count: int = Field(strict=True, ge=0, description='本月装填托盘数')
month_batch_count: int = Field(strict=True, ge=0, description='本月涉及的生产批次数')
month_top_strains: list[ShipmentStrainSummaryModel] = Field(
default_factory=list,
description='本月装填最多的菌种(最多 3 个)',
)
empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库可装填空托盘总数')
empty_by_warehouse: list[InboundWarehouseCountModel] = Field(
default_factory=list,
description='授权仓库空托盘按仓库分布,托盘数从多到少',
)
near_expiry_days: int = Field(strict=True, gt=0, description='临期判定天数')
near_expiry_pallet_count: int = Field(
strict=True, ge=0, description='有效期在临期天数内(含已过期)且仍在仓的有料托盘数'
)
near_expiry_batch_count: int = Field(strict=True, ge=0, description='上述托盘涉及的生产批次数')
nearest_expiry_batch_no: str | None = Field(default=None, max_length=64, description='最早到期的在仓批次')
nearest_expiry_date: date | None = Field(default=None, description='最早到期的在仓批次有效期')
class ShipmentListItemModel(ShipmentModel):
"""整托发货单列表行:在单据字段外附带只读的名称与汇总。"""
source_warehouse_name: str | None = Field(default=None, max_length=100, description='来源仓库名称')
target_site_name: str | None = Field(default=None, max_length=100, description='目标现场名称')
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称')
pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数')
package_qty: int | None = Field(
default=None,
strict=True,
ge=0,
description='包数:已确认为确认快照之和,草稿为当前可发余量之和',
)
strains: list[ShipmentStrainSummaryModel] = Field(default_factory=list, description='菌种构成')
class UsageModel(BaseModel): class UsageModel(BaseModel):
"""客户现场使用记录展示模型。""" """客户现场使用记录展示模型。"""
@@ -373,6 +534,20 @@ class UsageModel(BaseModel):
fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因') fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因')
class UsageListItemModel(UsageModel):
"""客户现场使用记录列表行:附带只读的托盘、现场、隧道、产线与操作人名称。"""
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号')
site_name: str | None = Field(default=None, max_length=100, description='现场名称')
tunnel_code: str | None = Field(default=None, max_length=64, description='隧道编号')
tunnel_name: str | None = Field(default=None, max_length=100, description='隧道名称')
line_name: str | None = Field(default=None, max_length=100, description='产线名称')
operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称')
skipped_pallet_code: str | None = Field(
default=None, max_length=64, description='被跳过的先到托盘编号(取自使用时的先到先用快照)'
)
class ReturnItemModel(BaseModel): class ReturnItemModel(BaseModel):
"""托盘退回明细模型。""" """托盘退回明细模型。"""
@@ -386,6 +561,8 @@ class ReturnItemModel(BaseModel):
trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照') trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照')
batch_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='申请时剩余包数快照')
pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)')
tunnel_name: str | None = Field(default=None, max_length=100, 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='创建时间')
@@ -416,6 +593,37 @@ class ReturnModel(BaseModel):
items: list[ReturnItemModel] = Field(default_factory=list, description='退回托盘明细') items: list[ReturnItemModel] = Field(default_factory=list, description='退回托盘明细')
class ReturnListItemModel(ReturnModel):
"""托盘退回单列表行:在单据字段外附带只读的名称与托盘汇总。"""
source_site_name: str | None = Field(default=None, max_length=100, description='来源现场名称')
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
target_warehouse_name: str | None = Field(default=None, max_length=100, description='目标仓库名称')
requester_name: str | None = Field(default=None, max_length=30, description='申请人昵称')
confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称')
pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数')
package_sum: int = Field(default=0, strict=True, ge=0, description='申请时剩余包数快照之和')
filled_count: int = Field(default=0, strict=True, ge=0, description='申请时仍有余量的托盘数')
class ReturnOverviewModel(BaseModel):
"""托盘退回页面概况(只读)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
pending_count: int = Field(strict=True, ge=0, description='待到仓核对单数')
pending_pallet_count: int = Field(strict=True, ge=0, description='待到仓核对托盘数(即退回中托盘)')
pending_filled_count: int = Field(strict=True, ge=0, description='待核对托盘中仍有余量的托盘数')
pending_package_sum: int = Field(strict=True, ge=0, description='待核对托盘剩余包数之和')
oldest_pending_at: datetime | None = Field(default=None, description='最早一张待核对单的申请时间')
month_start: date = Field(description='本月统计起始日期')
month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数')
month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认托盘数')
month_filled_pallet_count: int = Field(strict=True, ge=0, description='本月确认后回到有料库存的托盘数')
month_package_sum: int = Field(strict=True, ge=0, description='本月确认带回的剩余包数')
month_avg_confirm_hours: float | None = Field(default=None, ge=0, description='本月申请到确认的平均小时数')
class InboundPageQueryModel(BaseModel): class InboundPageQueryModel(BaseModel):
"""首次入库分页查询模型。""" """首次入库分页查询模型。"""
@@ -440,6 +648,13 @@ class FillingPageQueryModel(BaseModel):
page_num: int = Field(default=1, gt=0, description='当前页码') page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号') filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号')
keyword: str | None = Field(
default=None,
min_length=1,
max_length=64,
pattern=r'\S',
description='关键字:匹配装填单号、托盘编号或生产批次',
)
warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID') warehouse_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')
batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次') batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次')
@@ -450,6 +665,36 @@ class FillingPageQueryModel(BaseModel):
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class FillingBatchPageQueryModel(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='每页记录数')
keyword: str | None = Field(
default=None,
min_length=1,
max_length=64,
pattern=r'\S',
description='关键字:匹配生产批次、菌种编码或名称',
)
strain_id: int | None = Field(default=None, gt=0, description='菌种ID')
begin_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='装填结束日期')
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class FillingBatchDetailQueryModel(BaseModel):
"""生产批次详情查询模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
strain_id: int = Field(gt=0, description='菌种ID')
batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次')
class ShipmentPageQueryModel(BaseModel): class ShipmentPageQueryModel(BaseModel):
"""整托发货分页查询模型。""" """整托发货分页查询模型。"""
@@ -458,9 +703,17 @@ class ShipmentPageQueryModel(BaseModel):
page_num: int = Field(default=1, gt=0, description='当前页码') page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号') shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号')
keyword: str | None = Field(
default=None,
min_length=1,
max_length=64,
pattern=r'\S',
description='关键字:匹配发货单号、来源仓库、目标现场或客户名称',
)
source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID') source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID')
target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID') target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID')
status: DocumentStatus | None = Field(default=None, description='单据状态') status: DocumentStatus | None = Field(default=None, description='单据状态')
receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, 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='开始时间')
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='结束时间')
@@ -475,6 +728,14 @@ class UsagePageQueryModel(BaseModel):
page_num: int = Field(default=1, gt=0, description='当前页码') page_num: int = Field(default=1, gt=0, description='当前页码')
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='使用记录号')
keyword: str | None = Field(
default=None,
min_length=1,
max_length=64,
pattern=r'\S',
description='关键字:匹配使用记录号或托盘编号',
)
fifo_skipped: bool | None = Field(default=None, description='为 true 时只看跳过先到托盘的记录')
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') 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='物料溯源号') trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='物料溯源号')
@@ -494,6 +755,13 @@ class ReturnPageQueryModel(BaseModel):
page_num: int = Field(default=1, gt=0, description='当前页码') page_num: int = Field(default=1, gt=0, description='当前页码')
page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') page_size: int = Field(default=10, gt=0, le=100, description='每页记录数')
return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号') return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号')
keyword: str | None = Field(
default=None,
min_length=1,
max_length=64,
pattern=r'\S',
description='关键字:匹配退回单号、托盘编号、来源现场、客户或目标仓库名称',
)
source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID') source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID')
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID') target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
status: ReturnStatus | None = Field(default=None, description='退回状态') status: ReturnStatus | None = Field(default=None, description='退回状态')
@@ -547,6 +815,71 @@ class BusinessOptionModel(BaseModel):
name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='业务名称') name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='业务名称')
class ReturnSiteOptionModel(BusinessOptionModel):
"""退回来源现场选项,附带所属客户名称。"""
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
class UsageSiteOptionModel(BusinessOptionModel):
"""现场使用页的现场选项,附带客户名称与启用隧道、产线数。"""
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
tunnel_count: int = Field(default=0, strict=True, ge=0, description='启用隧道数')
line_count: int = Field(default=0, strict=True, ge=0, description='启用产线数')
class UsageBoardTunnelModel(BaseModel):
"""隧道看板中的一条隧道(不含内部溯源号)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
tunnel_id: int = Field(gt=0, description='隧道ID')
tunnel_code: str = Field(min_length=1, max_length=64, description='隧道编号')
tunnel_name: str = Field(min_length=1, max_length=100, description='隧道名称')
in_use: bool = Field(description='是否有正在使用的物料批')
pallet_count: int = Field(strict=True, ge=0, description='在隧道中仍有余量的托盘数')
remaining_packages: int = Field(strict=True, ge=0, description='在隧道中托盘的剩余包数')
started_at: datetime | None = Field(default=None, description='本轮物料批开始时间')
last_used_at: datetime | None = Field(default=None, description='最近一次登记使用时间')
last_line_name: str | None = Field(default=None, max_length=100, description='最近一次使用的产线名称')
class UsageLinePackagesModel(BaseModel):
"""按产线统计的用量。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
line_id: int | None = Field(default=None, gt=0, description='产线ID;未指定产线时为空')
line_name: str | None = Field(default=None, max_length=100, description='产线名称')
used_packages: int = Field(strict=True, ge=0, description='使用包数')
class UsageSiteBoardModel(BaseModel):
"""客户现场看板:库存、今日用量、隧道与先到先用概况(只读)。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
site_id: int = Field(gt=0, description='现场ID')
site_code: str | None = Field(default=None, max_length=64, description='现场编码')
site_name: str | None = Field(default=None, max_length=100, description='现场名称')
customer_name: str | None = Field(default=None, max_length=100, description='客户名称')
pallet_count: int = Field(strict=True, ge=0, description='现场未使用与使用中的托盘数')
package_sum: int = Field(strict=True, ge=0, description='上述托盘的剩余包数')
unused_pallet_count: int = Field(strict=True, ge=0, description='现场未使用托盘数')
partial_pallet_count: int = Field(strict=True, ge=0, description='现场使用中托盘数')
empty_pallet_count: int = Field(strict=True, ge=0, description='现场已用完、待退回的托盘数')
today_usage_count: int = Field(strict=True, ge=0, description='今日登记次数')
today_used_packages: int = Field(strict=True, ge=0, description='今日使用包数')
today_by_line: list[UsageLinePackagesModel] = Field(default_factory=list, description='今日用量按产线,从多到少')
tunnel_count: int = Field(strict=True, ge=0, description='启用隧道数')
active_tunnel_count: int = Field(strict=True, ge=0, description='正在使用的隧道数')
today_fifo_skip_count: int = Field(strict=True, ge=0, description='今日跳过先到托盘的使用次数')
week_fifo_skip_count: int = Field(strict=True, ge=0, description='近 7 天跳过先到托盘的使用次数')
tunnels: list[UsageBoardTunnelModel] = Field(default_factory=list, description='启用隧道,按编号排序')
lines: list[BusinessOptionModel] = Field(default_factory=list, description='启用产线,供筛选使用')
class PalletScanResponseModel(BaseModel): class PalletScanResponseModel(BaseModel):
"""业务扫码托盘响应模型。""" """业务扫码托盘响应模型。"""
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from pydantic.alias_generators import to_camel from pydantic.alias_generators import to_camel
@@ -21,3 +21,34 @@ class UsageFifoRecommendationModel(BaseModel):
unknown_arrival_count: int = 0 unknown_arrival_count: int = 0
requires_skip_reason: bool = False requires_skip_reason: bool = False
message: str message: str
class UsageFifoQueueItemModel(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
expiry_date: date | None = None
class UsageFifoStrainQueueModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
strain_id: int
strain_code: str | None = None
strain_name: str | None = None
pallet_count: int = 0
unknown_arrival_count: int = 0
pallets: list[UsageFifoQueueItemModel] = []
class UsageFifoQueueModel(BaseModel):
"""现场待使用托盘按菌种分组,组内与先到先用建议同序。"""
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
site_id: int
strains: list[UsageFifoStrainQueueModel] = []
@@ -1,7 +1,7 @@
import hashlib import hashlib
import json import json
import secrets import secrets
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, NoReturn from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
from exceptions.exception import PermissionException, ServiceException from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.filling_summary_dao import FillingSummaryDao
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
@@ -22,8 +23,14 @@ from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEv
from module_business.entity.vo.master_vo import StrainPageQueryModel from module_business.entity.vo.master_vo import StrainPageQueryModel
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
PalletScanResponseModel, PalletScanResponseModel,
) )
@@ -34,6 +41,9 @@ from module_business.service.test_batch_access_service import TestBatchAccessSer
class FillingService: class FillingService:
"""仓库内单托盘即时装填服务层。""" """仓库内单托盘即时装填服务层。"""
# 有效期剩余不超过该天数的在仓批次视为临期
NEAR_EXPIRY_DAYS = 14
@staticmethod @staticmethod
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]: def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
user = current_user.user user = current_user.user
@@ -187,7 +197,7 @@ class FillingService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[FillingModel]: ) -> PageModel | list[FillingListItemModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if ( if (
warehouse_ids is not None warehouse_ids is not None
@@ -203,11 +213,81 @@ class FillingService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result rows = result.rows if isinstance(result, PageModel) else result
models = [FillingModel.model_validate(record) for record in rows] records = [FillingModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的记录补充托盘编号、仓库与操作人名称
summaries = await FillingSummaryDao.get_list_summaries(
query_db,
[record.filling_id for record in records],
test_scope,
)
models = [
FillingListItemModel.model_validate({**record.model_dump(), **summaries.get(record.filling_id, {})})
for record in records
]
if isinstance(result, PageModel): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@classmethod
async def get_filling_batches_services(
cls,
query_db: AsyncSession,
query_object: FillingBatchPageQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
batches, total = await FillingSummaryDao.get_batch_page(query_db, query_object, warehouse_ids, test_scope)
return PageModel(
rows=[FillingBatchModel.model_validate(batch) for batch in batches],
pageNum=query_object.page_num,
pageSize=query_object.page_size,
total=total,
hasNext=query_object.page_num * query_object.page_size < total,
)
@classmethod
async def get_filling_batch_detail_services(
cls,
query_db: AsyncSession,
query_object: FillingBatchDetailQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> FillingBatchDetailModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
batch = await FillingSummaryDao.get_batch_detail(
query_db,
query_object.strain_id,
query_object.batch_no,
warehouse_ids,
test_scope,
)
if batch is None:
if cls._is_admin(current_user):
raise ServiceException(message='生产批次不存在')
raise PermissionException(data='', message='无权访问该生产批次')
return FillingBatchDetailModel.model_validate(batch)
@classmethod
async def get_filling_overview_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> FillingOverviewModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
overview = await FillingSummaryDao.get_overview(
query_db,
warehouse_ids,
test_scope,
today_start,
today_start.replace(day=1),
(today_start + timedelta(days=cls.NEAR_EXPIRY_DAYS)).date(),
cls.NEAR_EXPIRY_DAYS,
)
return FillingOverviewModel.model_validate(overview)
@classmethod @classmethod
async def filling_detail_services( async def filling_detail_services(
cls, cls,
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
from exceptions.exception import PermissionException, ServiceException, ServiceWarning from exceptions.exception import PermissionException, ServiceException, ServiceWarning
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.inbound_summary_dao import InboundSummaryDao
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
@@ -29,7 +30,10 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
InboundCreateModel, InboundCreateModel,
InboundItemModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
@@ -245,7 +249,7 @@ class InboundService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[InboundModel]: ) -> PageModel | list[InboundListItemModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user) warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if ( if (
warehouse_ids is not None warehouse_ids is not None
@@ -262,7 +266,17 @@ class InboundService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result rows = result.rows if isinstance(result, PageModel) else result
models = [InboundModel.model_validate(record) for record in rows] records = [InboundModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的单据补充名称与托盘数
summaries = await InboundSummaryDao.get_list_summaries(
query_db,
[record.inbound_id for record in records],
test_scope,
)
models = [
InboundListItemModel.model_validate({**record.model_dump(), **summaries.get(record.inbound_id, {})})
for record in records
]
if isinstance(result, PageModel): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@@ -284,7 +298,31 @@ class InboundService:
) )
if inbound is None: if inbound is None:
cls._raise_inbound_not_accessible(current_user) cls._raise_inbound_not_accessible(current_user)
return InboundModel.model_validate(inbound) model = InboundModel.model_validate(inbound)
if not model.items:
return model
# 托盘编号与当前状态只读展示,不参与任何写入或校验
identities = await InboundSummaryDao.get_item_identities(query_db, model.inbound_id, test_scope)
return model.model_copy(
update={
'items': [
InboundItemModel.model_validate({**item.model_dump(), **identities.get(item.inbound_item_id, {})})
for item in model.items
]
}
)
@classmethod
async def get_inbound_overview_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> InboundOverviewModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
overview = await InboundSummaryDao.get_overview(query_db, warehouse_ids, test_scope, month_start)
return InboundOverviewModel.model_validate(overview)
@classmethod @classmethod
async def get_warehouse_options_services( async def get_warehouse_options_services(
@@ -15,6 +15,7 @@ from exceptions.exception import PermissionException, ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import MasterImportPreviewModel, MasterImportPreviewRowModel
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
CustomerModel, CustomerModel,
LineModel, LineModel,
@@ -33,6 +34,11 @@ _MAX_IMPORT_BYTES = 5 * 1024 * 1024
_MAX_IMPORT_ROWS = 5000 _MAX_IMPORT_ROWS = 5000
_STATUS_TO_VALUE = {'正常': '0', '停用': '1'} _STATUS_TO_VALUE = {'正常': '0', '停用': '1'}
_VALUE_TO_STATUS = {'0': '正常', '1': '停用'} _VALUE_TO_STATUS = {'0': '正常', '1': '停用'}
_PREVIEW_MESSAGES: dict[ImportOutcome, str] = {
'add': '校验通过,将新增',
'update': '{code}已存在,将按文件内容更新',
'skip': '{code}已存在,保持原资料不变',
}
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -245,6 +251,65 @@ class MasterExcelService:
raise ServiceException(message='导入失败,数据已全部回滚') from exc raise ServiceException(message='导入失败,数据已全部回滚') from exc
return CrudResponseModel(is_success=True, message=cls._summary(added, updated, skipped, details)) return CrudResponseModel(is_success=True, message=cls._summary(added, updated, skipped, details))
@classmethod
async def preview_import(
cls,
db: AsyncSession,
resource: MasterExcelResource,
file: UploadFile,
update_support: bool,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> MasterImportPreviewModel:
"""走与正式导入完全相同的逐行校验,最后整体回滚,返回每行将发生的结果。"""
spec = _SPECS[resource]
parsed_rows = await cls._read_upload(resource, file)
rows: list[MasterImportPreviewRowModel] = []
try:
if resource == 'pallet':
await PalletService._prepare_test_write(db, current_user, scope)
else:
await MasterService._prepare_test_write(db, current_user, scope)
for row in parsed_rows:
values = [row.values.get(header, '') for header in spec.headers]
try:
outcome, _ = await cls._apply_row(db, resource, row, update_support, current_user, scope)
except ServiceException as exc:
rows.append(
MasterImportPreviewRowModel(
excel_row=row.excel_row,
outcome='error',
message=exc.message or '校验失败',
values=values,
)
)
continue
rows.append(
MasterImportPreviewRowModel(
excel_row=row.excel_row,
outcome=outcome,
message=_PREVIEW_MESSAGES[outcome].format(code=spec.code_header),
values=values,
)
)
except Exception as exc:
if isinstance(exc, (ServiceException, PermissionException)):
raise
raise ServiceException(message='导入校验失败,请稍后重试') from exc
finally:
await db.rollback()
counts = {outcome: sum(1 for row in rows if row.outcome == outcome) for outcome in _PREVIEW_MESSAGES}
return MasterImportPreviewModel(
headers=list(spec.headers),
update_support=update_support,
total_rows=len(rows),
add_count=counts['add'],
update_count=counts['update'],
skip_count=counts['skip'],
error_count=sum(1 for row in rows if row.outcome == 'error'),
rows=rows,
)
@classmethod @classmethod
async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]: async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]:
spec = _SPECS[resource] spec = _SPECS[resource]
@@ -0,0 +1,89 @@
from collections.abc import Awaitable, Callable
from typing import Any, Literal
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_business.dao.master_summary_dao import MasterSummaryDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import (
CustomerDetailModel,
CustomerListItemModel,
LineListItemModel,
MasterParentOptionModel,
PalletDetailModel,
PalletListItemModel,
SiteListItemModel,
StrainListItemModel,
TunnelListItemModel,
WarehouseListItemModel,
)
from module_business.entity.vo.master_vo import CustomerModel, PalletModel
SummaryResource = Literal['strain', 'customer', 'site', 'line', 'tunnel', 'warehouse', 'pallet']
_SummaryLoader = Callable[[AsyncSession, list[int], TestBatchScope], Awaitable[dict[int, dict[str, Any]]]]
_ENRICHERS: dict[SummaryResource, tuple[str, _SummaryLoader, type[BaseModel]]] = {
'strain': ('strain_id', MasterSummaryDao.get_strain_summaries, StrainListItemModel),
'customer': ('customer_id', MasterSummaryDao.get_customer_summaries, CustomerListItemModel),
'site': ('site_id', MasterSummaryDao.get_site_summaries, SiteListItemModel),
'line': ('line_id', MasterSummaryDao.get_line_summaries, LineListItemModel),
'tunnel': ('tunnel_id', MasterSummaryDao.get_tunnel_summaries, TunnelListItemModel),
'warehouse': ('warehouse_id', MasterSummaryDao.get_warehouse_summaries, WarehouseListItemModel),
'pallet': ('pallet_id', MasterSummaryDao.get_pallet_summaries, PalletListItemModel),
}
class MasterSummaryService:
"""为基础资料页面补充只读汇总。导出与写入仍使用原主数据模型。"""
@classmethod
async def enrich_page(
cls,
db: AsyncSession,
resource: SummaryResource,
page: PageModel,
scope: TestBatchScope,
) -> PageModel:
id_field, loader, item_model = _ENRICHERS[resource]
rows = list(page.rows)
summaries = await loader(db, [getattr(row, id_field) for row in rows], scope)
enriched = [
item_model.model_validate({**row.model_dump(), **summaries.get(getattr(row, id_field), {})})
for row in rows
]
return page.model_copy(update={'rows': enriched})
@classmethod
async def customer_detail(
cls,
db: AsyncSession,
customer: CustomerModel,
scope: TestBatchScope,
) -> CustomerDetailModel:
sites = await MasterSummaryDao.get_customer_site_tree(db, customer.customer_id, scope)
return CustomerDetailModel.model_validate({**customer.model_dump(), 'sites': sites})
@classmethod
async def pallet_detail(
cls,
db: AsyncSession,
pallet: PalletModel,
scope: TestBatchScope,
) -> PalletDetailModel:
summaries = await MasterSummaryDao.get_pallet_summaries(db, [pallet.pallet_id], scope)
events = await MasterSummaryDao.get_pallet_recent_events(db, pallet.pallet_id, scope)
return PalletDetailModel.model_validate(
{**pallet.model_dump(), **summaries.get(pallet.pallet_id, {}), 'recent_events': events}
)
@classmethod
async def customer_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[MasterParentOptionModel]:
rows = await MasterSummaryDao.list_customer_options(db, scope)
return [MasterParentOptionModel.model_validate(row) for row in rows]
@classmethod
async def site_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[MasterParentOptionModel]:
rows = await MasterSummaryDao.list_site_options(db, scope)
return [MasterParentOptionModel.model_validate(row) for row in rows]
@@ -16,6 +16,7 @@ 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.return_summary_dao import ReturnSummaryDao
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
@@ -32,10 +33,14 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnItemModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
) )
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
@@ -211,7 +216,7 @@ class ReturnService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[ReturnModel]: ) -> PageModel | list[ReturnListItemModel]:
site_ids, warehouse_ids = await cls._scopes(query_db, current_user) site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
if site_ids == [] and warehouse_ids == []: if site_ids == [] and warehouse_ids == []:
if is_page: if is_page:
@@ -233,7 +238,17 @@ class ReturnService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result rows = result.rows if isinstance(result, PageModel) else result
models = [ReturnModel.model_validate(record) for record in rows] records = [ReturnModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的单据补充名称与托盘汇总
summaries = await ReturnSummaryDao.get_list_summaries(
query_db,
[record.return_id for record in records],
test_scope,
)
models = [
ReturnListItemModel.model_validate({**record.model_dump(), **summaries.get(record.return_id, {})})
for record in records
]
if isinstance(result, PageModel): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@@ -257,7 +272,42 @@ class ReturnService:
if return_order is None: if return_order is None:
cls._raise_return_not_accessible(current_user) cls._raise_return_not_accessible(current_user)
assert_record_in_scope(return_order, test_scope, '退回单') assert_record_in_scope(return_order, test_scope, '退回单')
return ReturnModel.model_validate(return_order) model = ReturnModel.model_validate(return_order)
if not model.items:
return model
# 托盘编号与隧道名称只读展示,不参与确认核对;核对仍只使用托盘ID
identities = await ReturnSummaryDao.get_item_identities(query_db, model.return_id, test_scope)
return model.model_copy(
update={
'items': [
ReturnItemModel.model_validate({**item.model_dump(), **identities.get(item.return_item_id, {})})
for item in model.items
]
}
)
@classmethod
async def get_return_overview_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> ReturnOverviewModel:
site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
month_start = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
overview = await ReturnSummaryDao.get_overview(query_db, site_ids, warehouse_ids, test_scope, month_start)
return ReturnOverviewModel.model_validate(overview)
@classmethod
async def get_site_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[ReturnSiteOptionModel]:
site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
options = await ReturnSummaryDao.get_site_options(query_db, site_ids, warehouse_ids, test_scope)
return [ReturnSiteOptionModel.model_validate(option) for option in options]
@classmethod @classmethod
async def get_warehouse_options_services( async def get_warehouse_options_services(
@@ -14,6 +14,8 @@ 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.shipment_label_dao import ShipmentLabelDao
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState
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
@@ -31,6 +33,7 @@ from module_business.entity.vo.operation_vo import (
ConfirmCommandModel, ConfirmCommandModel,
PalletScanResponseModel, PalletScanResponseModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
@@ -254,7 +257,7 @@ class ShipmentService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[ShipmentModel]: ) -> PageModel | list[ShipmentListItemModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user) warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if ( if (
warehouse_ids is not None warehouse_ids is not None
@@ -288,7 +291,17 @@ class ShipmentService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result rows = result.rows if isinstance(result, PageModel) else result
models = [ShipmentModel.model_validate(record) for record in rows] records = [ShipmentModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的单据补充名称与汇总
summaries = await ShipmentSummaryDao.get_list_summaries(
query_db,
[record.shipment_id for record in records],
test_scope,
)
models = [
ShipmentListItemModel.model_validate({**record.model_dump(), **summaries.get(record.shipment_id, {})})
for record in records
]
if isinstance(result, PageModel): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@@ -311,7 +324,35 @@ class ShipmentService:
) )
if shipment is None: if shipment is None:
cls._raise_shipment_not_accessible(current_user) cls._raise_shipment_not_accessible(current_user)
return ShipmentModel.model_validate(shipment) model = ShipmentModel.model_validate(shipment)
identities = await cls._item_identities(query_db, shipment, test_scope)
if not identities:
return model
return model.model_copy(
update={
'items': [
item.model_copy(update=identities.get(item.shipment_item_id, {}))
for item in model.items
]
}
)
@staticmethod
async def _item_identities(
query_db: AsyncSession,
shipment: BizShipmentOrder,
test_scope: TestBatchScope,
) -> dict[int, dict[str, str | None]]:
"""为详情明细补充托盘编号与菌种批次,只读展示,不参与任何写入或数量计算。"""
identities: dict[int, dict[str, str | None]] = {}
for item, pallet, filling, _quantity in await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope):
identities[item.shipment_item_id] = {
'pallet_code': getattr(pallet, 'pallet_code', None),
'strain_code': getattr(filling, 'strain_code_snapshot', None),
'strain_name': getattr(filling, 'strain_name_snapshot', None),
'batch_no': getattr(filling, 'batch_no', None),
}
return identities
@classmethod @classmethod
async def get_warehouse_options_services( async def get_warehouse_options_services(
@@ -6,7 +6,8 @@ from module_business.dao.master_dao import MasterDao
from module_business.dao.usage_fifo_dao import UsageFifoDao from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.fifo_policy import requires_fifo_skip_reason 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.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel
from module_business.service.access_service import AccessService
from module_business.service.usage_service import UsageService from module_business.service.usage_service import UsageService
@@ -52,3 +53,19 @@ class UsageFifoService:
recommended=candidate, unknown_arrival_count=unknown, recommended=candidate, unknown_arrival_count=unknown,
requires_skip_reason=needs_reason, message=message, requires_skip_reason=needs_reason, message=message,
) )
@staticmethod
async def queue(
db: AsyncSession,
site_id: int,
user: CurrentUserModel,
scope: TestBatchScope,
) -> UsageFifoQueueModel:
"""现场待使用队列(只读):与建议共用候选条件和排序,仅包含尚未入隧的托盘。"""
await AccessService.assert_site_access(db, user, site_id)
site = await MasterDao.get_site(db, site_id, scope)
if site is None:
raise ServiceException(message='客户现场不存在')
assert_record_in_scope(site, scope, '现场')
strains = await UsageFifoDao.queue(db, site_id=site_id, scope=scope)
return UsageFifoQueueModel.model_validate({'site_id': site_id, 'strains': strains})
@@ -3,7 +3,7 @@
import hashlib import hashlib
import json import json
import secrets import secrets
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, NoReturn from typing import Any, NoReturn
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -17,6 +17,7 @@ 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.dao.usage_fifo_dao import UsageFifoDao
from module_business.dao.usage_summary_dao import UsageSummaryDao
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.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
@@ -29,8 +30,11 @@ from module_business.entity.vo.operation_vo import (
TunnelEntryCreateModel, TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel, TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
UsageSuccessResponseModel, UsageSuccessResponseModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
@@ -279,7 +283,7 @@ class UsageService:
current_user: CurrentUserModel, current_user: CurrentUserModel,
test_scope: TestBatchScope, test_scope: TestBatchScope,
is_page: bool = True, is_page: bool = True,
) -> PageModel | list[UsageModel]: ) -> PageModel | list[UsageListItemModel]:
site_ids = await cls._site_scope(query_db, current_user) site_ids = await cls._site_scope(query_db, current_user)
if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids: if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids:
raise PermissionException(data='', message='无权访问该客户现场') raise PermissionException(data='', message='无权访问该客户现场')
@@ -301,11 +305,56 @@ class UsageService:
is_page=is_page, is_page=is_page,
) )
rows = result.rows if isinstance(result, PageModel) else result rows = result.rows if isinstance(result, PageModel) else result
models = [UsageModel.model_validate(record) for record in rows] records = [UsageModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的记录补充名称;不改变记录本身
summaries = await UsageSummaryDao.get_list_summaries(
query_db,
[record.usage_id for record in records],
test_scope,
)
models = [
UsageListItemModel.model_validate({**record.model_dump(), **summaries.get(record.usage_id, {})})
for record in records
]
if isinstance(result, PageModel): if isinstance(result, PageModel):
return result.model_copy(update={'rows': models}) return result.model_copy(update={'rows': models})
return models return models
@classmethod
async def get_site_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[UsageSiteOptionModel]:
site_ids = await cls._site_scope(query_db, current_user)
options = await UsageSummaryDao.get_site_options(query_db, site_ids, test_scope)
return [UsageSiteOptionModel.model_validate(option) for option in options]
@classmethod
async def get_site_board_services(
cls,
query_db: AsyncSession,
site_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> UsageSiteBoardModel:
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, '现场')
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
board = await UsageSummaryDao.get_site_board(
query_db,
site,
test_scope,
today_start,
today_start - timedelta(days=6),
)
return UsageSiteBoardModel.model_validate(board)
@classmethod @classmethod
async def usage_detail_services( async def usage_detail_services(
cls, cls,
@@ -230,7 +230,11 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/master/warehouse/{warehouse_id}/users', 'GET'): 'master:warehouse:assign', ('/master/warehouse/{warehouse_id}/users', 'GET'): 'master:warehouse:assign',
('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign', ('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign',
('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print', ('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print',
('/master/site/customer-options', 'GET'): 'master:site:list',
('/master/line/site-options', 'GET'): 'master:line:list',
('/master/tunnel/site-options', 'GET'): 'master:tunnel:list',
('/business/inbound/list', 'GET'): 'business:inbound:list', ('/business/inbound/list', 'GET'): 'business:inbound:list',
('/business/inbound/summary', 'GET'): 'business:inbound:list',
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query', ('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query', ('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
@@ -239,6 +243,9 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:list', ('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:list',
('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm', ('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm',
('/business/filling/list', 'GET'): 'business:filling:list', ('/business/filling/list', 'GET'): 'business:filling:list',
('/business/filling/summary', 'GET'): 'business:filling:list',
('/business/filling/batches', 'GET'): 'business:filling:list',
('/business/filling/batches/detail', 'GET'): 'business:filling:query',
('/business/filling/strain-options', 'GET'): 'business:filling:query', ('/business/filling/strain-options', 'GET'): 'business:filling:query',
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query', ('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
('/business/filling/{filling_id}', 'GET'): 'business:filling:query', ('/business/filling/{filling_id}', 'GET'): 'business:filling:query',
@@ -254,6 +261,7 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/business/shipment-receipt/{shipment_id}/scan/{identifier}', '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/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive',
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query', ('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
('/business/usage-fifo/queue', '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',
@@ -261,11 +269,15 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
('/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/tunnel-options', 'GET'): 'business:usage:query',
('/business/usage/site-options', 'GET'): 'business:usage:list',
('/business/usage/site-board', 'GET'): 'business:usage:query',
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add', ('/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',
('/business/return/list', 'GET'): 'business:return:list', ('/business/return/list', 'GET'): 'business:return:list',
('/business/return/summary', 'GET'): 'business:return:list',
('/business/return/site-options', 'GET'): 'business:return:list',
('/business/return/warehouse-options', 'GET'): 'business:return:query', ('/business/return/warehouse-options', 'GET'): 'business:return:query',
('/business/return/scan/{qr_token}', 'GET'): 'business:return:query', ('/business/return/scan/{qr_token}', 'GET'): 'business:return:query',
('/business/return/{return_id}', 'GET'): 'business:return:query', ('/business/return/{return_id}', 'GET'): 'business:return:query',
@@ -295,11 +307,18 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
EXPECTED_ROUTE_PERMISSIONS = _build_expected_route_permissions() EXPECTED_ROUTE_PERMISSIONS = _build_expected_route_permissions()
STATIC_READS_BEFORE_DETAIL = { STATIC_READS_BEFORE_DETAIL = {
'/master/site/{site_id}': ('/master/site/customer-options',),
'/master/line/{line_id}': ('/master/line/site-options',),
'/master/tunnel/{tunnel_id}': ('/master/tunnel/site-options',),
'/business/inbound/{inbound_id}': ( '/business/inbound/{inbound_id}': (
'/business/inbound/summary',
'/business/inbound/warehouse-options', '/business/inbound/warehouse-options',
'/business/inbound/scan/{qr_token}', '/business/inbound/scan/{qr_token}',
), ),
'/business/filling/{filling_id}': ( '/business/filling/{filling_id}': (
'/business/filling/summary',
'/business/filling/batches',
'/business/filling/batches/detail',
'/business/filling/strain-options', '/business/filling/strain-options',
'/business/filling/scan/{qr_token}', '/business/filling/scan/{qr_token}',
), ),
@@ -311,9 +330,13 @@ 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/tunnel-options',
'/business/usage/site-options',
'/business/usage/site-board',
'/business/usage/scan/{qr_token}', '/business/usage/scan/{qr_token}',
), ),
'/business/return/{return_id}': ( '/business/return/{return_id}': (
'/business/return/summary',
'/business/return/site-options',
'/business/return/warehouse-options', '/business/return/warehouse-options',
'/business/return/scan/{qr_token}', '/business/return/scan/{qr_token}',
), ),
@@ -327,6 +350,8 @@ EXACT_QUERY_PARAMETERS = {
('/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/usage/tunnel-options', 'GET'): {'siteId'},
('/business/usage/site-board', 'GET'): {'siteId'},
('/business/usage-fifo/queue', 'GET'): {'siteId'},
('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'}, ('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'},
} }
REQUIRED_QUERY_PARAMETERS = { REQUIRED_QUERY_PARAMETERS = {
@@ -349,7 +374,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(119, expected.total()) test_case.assertEqual(131, 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))
@@ -501,7 +526,7 @@ 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(121, len(actual_operations)) self.assertEqual(133, 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(
@@ -582,7 +607,7 @@ class ApiPermissionContractTest(unittest.TestCase):
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(119, len(literals)) self.assertEqual(131, len(literals))
literals.append(literals[0]) literals.append(literals[0])
with self.assertRaises(AssertionError): with self.assertRaises(AssertionError):
@@ -21,6 +21,7 @@ from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
from module_admin.service.login_service import LoginService from module_admin.service.login_service import LoginService
from module_business.controller import filling_controller as filling_controller_module from module_business.controller import filling_controller as filling_controller_module
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.filling_summary_dao import FillingSummaryDao
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
@@ -30,8 +31,12 @@ from module_business.entity.do.master_do import BizPallet, BizStrain
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
FillingBatchDetailModel,
FillingBatchModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
PalletScanResponseModel, PalletScanResponseModel,
) )
@@ -242,6 +247,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])), patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])),
patch.object(OperationDao, 'get_filling_list', new=get_list), patch.object(OperationDao, 'get_filling_list', new=get_list),
patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
): ):
result = await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal()) result = await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal())
@@ -1169,6 +1175,9 @@ class TestFillingController(unittest.TestCase):
schema = app.openapi() schema = app.openapi()
expected = { expected = {
('/business/filling/list', 'get'), ('/business/filling/list', 'get'),
('/business/filling/summary', 'get'),
('/business/filling/batches', 'get'),
('/business/filling/batches/detail', 'get'),
('/business/filling/strain-options', 'get'), ('/business/filling/strain-options', 'get'),
('/business/filling/scan/{qr_token}', 'get'), ('/business/filling/scan/{qr_token}', 'get'),
('/business/filling/{filling_id}', 'get'), ('/business/filling/{filling_id}', 'get'),
@@ -1179,7 +1188,21 @@ class TestFillingController(unittest.TestCase):
self.assertEqual({'qr_token'}, {parameter['name'] for parameter in scan_parameters}) self.assertEqual({'qr_token'}, {parameter['name'] for parameter in scan_parameters})
routes = self._routes() routes = self._routes()
self.assertEqual(PageResponseModel[FillingModel], routes[('/business/filling/list', 'GET')].response_model) self.assertEqual(
PageResponseModel[FillingListItemModel], routes[('/business/filling/list', 'GET')].response_model
)
self.assertEqual(
DataResponseModel[FillingOverviewModel],
routes[('/business/filling/summary', 'GET')].response_model,
)
self.assertEqual(
PageResponseModel[FillingBatchModel],
routes[('/business/filling/batches', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[FillingBatchDetailModel],
routes[('/business/filling/batches/detail', 'GET')].response_model,
)
self.assertEqual( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/filling/strain-options', 'GET')].response_model, routes[('/business/filling/strain-options', 'GET')].response_model,
@@ -1197,12 +1220,21 @@ class TestFillingController(unittest.TestCase):
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_format 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) for static_path in (
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index) '/business/filling/summary',
'/business/filling/batches',
'/business/filling/batches/detail',
'/business/filling/strain-options',
'/business/filling/scan/{qr_token}',
):
self.assertLess(paths.index(static_path), detail_index)
def test_each_route_has_exact_permission_and_three_unique_values(self) -> None: def test_each_route_has_exact_permission_and_three_unique_values(self) -> None:
expected = { expected = {
('/business/filling/list', 'GET'): 'business:filling:list', ('/business/filling/list', 'GET'): 'business:filling:list',
('/business/filling/summary', 'GET'): 'business:filling:list',
('/business/filling/batches', 'GET'): 'business:filling:list',
('/business/filling/batches/detail', 'GET'): 'business:filling:query',
('/business/filling/strain-options', 'GET'): 'business:filling:query', ('/business/filling/strain-options', 'GET'): 'business:filling:query',
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query', ('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
('/business/filling/{filling_id}', 'GET'): 'business:filling:query', ('/business/filling/{filling_id}', 'GET'): 'business:filling:query',
@@ -0,0 +1,431 @@
import unittest
from datetime import date, datetime, timedelta
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import 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.filling_summary_dao import FillingSummaryDao
from module_business.dao.operation_dao import OperationDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import (
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingListItemModel,
FillingOverviewModel,
FillingPageQueryModel,
)
from module_business.service.filling_service import FillingService
from utils.page_util import PageUtil
def compile_sql(statement: Any) -> str:
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
return ' '.join(str(compiled).split()).replace('%%', '%')
def make_test_scope(batch_id: int) -> TestBatchScope:
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
def result_rows(rows: list[Any]) -> MagicMock:
result = MagicMock()
result.all.return_value = rows
result.first.return_value = rows[0] if rows else None
return result
def result_scalar(value: Any) -> MagicMock:
result = MagicMock()
result.scalar_one.return_value = value
return result
def batch_row(**overrides: Any) -> SimpleNamespace:
values = {
'strain_id': 3,
'batch_no': 'B261017-01',
'strain_code': 'A15',
'strain_name': '平菇 A15',
'production_date': date(2026, 10, 16),
'expiry_date': date(2026, 11, 30),
'pallet_count': 6,
'package_sum': 600,
'last_operated_at': datetime(2026, 10, 17, 9, 10),
'last_filling_id': 88,
}
values.update(overrides)
return SimpleNamespace(**values)
def admin_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
)
def normal_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['business:filling:list'],
roles=['common'],
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
)
class FillingListFilterTest(unittest.IsolatedAsyncioTestCase):
async def _capture(self, query: FillingPageQueryModel) -> str:
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await OperationDao.get_filling_list(
MagicMock(spec=AsyncSession),
query,
warehouse_ids=[7],
scope=make_test_scope(4),
)
return compile_sql(paginate.await_args.args[1])
async def test_keyword_matches_filling_no_pallet_code_or_batch_within_scope(self) -> None:
sql = await self._capture(FillingPageQueryModel(keyword='B2610'))
for fragment in (
'JOIN biz_pallet ON biz_pallet.pallet_id = biz_filling_record.pallet_id',
"biz_filling_record.filling_no LIKE '%B2610%'",
"biz_pallet.pallet_code LIKE '%B2610%'",
"biz_filling_record.batch_no LIKE '%B2610%'",
'biz_filling_record.test_batch_id = 4',
'biz_filling_record.warehouse_id IN (7)',
):
self.assertIn(fragment, sql)
async def test_without_keyword_or_pallet_code_list_does_not_join_pallets(self) -> None:
sql = await self._capture(FillingPageQueryModel(batchNo='B1'))
self.assertNotIn('JOIN', sql)
def test_keyword_rejects_blank_value(self) -> None:
for model in (FillingPageQueryModel, FillingBatchPageQueryModel):
with self.subTest(model=model.__name__), self.assertRaises(ValueError):
model(keyword=' ')
class FillingListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
db = MagicMock(spec=AsyncSession)
self.assertEqual({}, await FillingSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_names_are_joined_in_same_batch_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10401', '成品一仓', '年糕')]))
summaries = await FillingSummaryDao.get_list_summaries(db, [32, 31, 31], make_test_scope(4))
self.assertEqual(
{'pallet_code': 'TP-10401', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}, summaries[31]
)
self.assertEqual({'pallet_code': None, 'warehouse_name': None, 'operator_name': None}, summaries[32])
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_filling_record.filling_id IN (31, 32)',
'biz_filling_record.test_batch_id = 4',
'biz_pallet.test_batch_id = 4',
'biz_warehouse.test_batch_id = 4',
'sys_user.user_id = biz_filling_record.operator_id',
"sys_user.del_flag = '0'",
):
self.assertIn(fragment, sql)
class FillingBatchPageDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_groups_by_strain_and_batch_newest_first_within_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_scalar(18),
result_rows([batch_row(), batch_row(batch_no='B261016-01', last_filling_id=80)]),
result_rows([(88, '1kg/包', '年糕'), (80, '1.2kg/包', None)]),
]
)
query = FillingBatchPageQueryModel(
pageNum=2, pageSize=5, keyword='A15', strainId=3, beginTime='2026-10-01', endTime='2026-10-17'
)
batches, total = await FillingSummaryDao.get_batch_page(db, query, [7, 6], make_test_scope(4))
self.assertEqual(18, total)
self.assertEqual(['B261017-01', 'B261016-01'], [batch['batch_no'] for batch in batches])
self.assertEqual(('1kg/包', '年糕'), (batches[0]['package_spec'], batches[0]['last_operator_name']))
self.assertEqual(('1.2kg/包', None), (batches[1]['package_spec'], batches[1]['last_operator_name']))
FillingBatchModel.model_validate(batches[0])
count_sql, page_sql, latest_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
for sql in (count_sql, page_sql):
for fragment in (
'GROUP BY biz_filling_record.strain_id, biz_filling_record.batch_no',
'biz_filling_record.test_batch_id = 4',
'biz_filling_record.warehouse_id IN (6, 7)',
"biz_filling_record.batch_no LIKE '%A15%'",
"biz_filling_record.strain_name_snapshot LIKE '%A15%'",
'biz_filling_record.strain_id = 3',
"biz_filling_record.operated_at >= '2026-10-01 00:00:00'",
"biz_filling_record.operated_at < '2026-10-18 00:00:00'",
):
self.assertIn(fragment, sql)
self.assertIn(
'ORDER BY max(biz_filling_record.operated_at) DESC, max(biz_filling_record.filling_id) DESC', page_sql
)
self.assertIn('LIMIT 5, 5', page_sql)
self.assertIn('biz_filling_record.filling_id IN (80, 88)', latest_sql)
async def test_empty_page_skips_latest_record_lookup(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(side_effect=[result_scalar(0), result_rows([])])
batches, total = await FillingSummaryDao.get_batch_page(
db, FillingBatchPageQueryModel(), [], TestBatchScope.formal()
)
self.assertEqual(([], 0), (batches, total))
self.assertEqual(2, db.execute.await_count)
self.assertIn('false', compile_sql(db.execute.await_args_list[0].args[0]).lower())
class FillingBatchDetailDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_missing_batch_returns_none_without_further_queries(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
self.assertIsNone(await FillingSummaryDao.get_batch_detail(db, 3, 'B1', [7], TestBatchScope.formal()))
self.assertEqual(1, db.execute.await_count)
async def test_distribution_uses_active_cycle_and_closed_cycles_count_as_consumed(self) -> None:
db = MagicMock(spec=AsyncSession)
recent = [
(90, 'FL-90', 501, 'TP-10501', '成品一仓', '李娜', datetime(2026, 10, 17, 9, 10), '1kg/包'),
(89, 'FL-89', 500, None, None, None, datetime(2026, 10, 17, 9, 5), '0.8kg/包'),
]
db.execute = AsyncMock(
side_effect=[
result_rows([batch_row(pallet_count=30)]),
result_rows([('AT_SITE_UNUSED', 15), (None, 2), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5)]),
result_rows(recent),
]
)
detail = await FillingSummaryDao.get_batch_detail(db, 3, 'B261017-01', [7], make_test_scope(4))
self.assertEqual(
[('AT_SITE_UNUSED', 15), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5), ('CONSUMED', 2)],
[(item['state'], item['pallet_count']) for item in detail['distribution']],
)
self.assertEqual(['TP-10501', None], [item['pallet_code'] for item in detail['recent']])
# 包装规格与操作人取批次内最新一条记录
self.assertEqual(('1kg/包', '李娜'), (detail['package_spec'], detail['last_operator_name']))
FillingBatchDetailModel.model_validate(detail)
header_sql, distribution_sql, recent_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
for sql in (header_sql, distribution_sql, recent_sql):
for fragment in (
'biz_filling_record.strain_id = 3',
"biz_filling_record.batch_no = 'B261017-01'",
'biz_filling_record.warehouse_id IN (7)',
'biz_filling_record.test_batch_id = 4',
):
self.assertIn(fragment, sql)
self.assertIn('biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', distribution_sql)
self.assertIn('biz_pallet_inventory.test_batch_id = 4', distribution_sql)
self.assertIn('ORDER BY biz_filling_record.operated_at DESC, biz_filling_record.filling_id DESC', recent_sql)
self.assertIn('LIMIT 10', recent_sql)
class FillingOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
def _db(self) -> MagicMock:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_rows([(3, 'B261017-01', '平菇 A15', 'A15', 6, 600)]),
result_rows(
[
(3, 'B261017-01', '平菇 A15', 'A15', 6, 600),
(3, 'B261016-01', '平菇 A15', 'A15', 10, 1000),
(4, 'B261015-01', '白灵菇 B22', 'B22', 27, 2700),
(5, 'B261010-01', None, 'S130', 18, 1800),
(6, 'B261008-01', '金针菇 J61', 'J61', 8, 800),
]
),
result_rows([(6, '周转仓', 10), (4, '成品一仓', 13)]),
result_rows([('B260930-01', date(2026, 10, 28), 3), ('B260923-01', date(2026, 10, 25), 4)]),
]
)
return db
async def test_overview_totals_top_strains_empty_pallets_and_nearest_expiry(self) -> None:
db = self._db()
overview = await FillingSummaryDao.get_overview(
db,
[4, 6],
make_test_scope(4),
datetime(2026, 10, 17),
datetime(2026, 10, 1),
date(2026, 10, 31),
14,
)
self.assertEqual(
(6, 600, 1), (overview['today_pallet_count'], overview['today_package_sum'], overview['today_batch_count'])
)
self.assertEqual((69, 5), (overview['month_pallet_count'], overview['month_batch_count']))
self.assertEqual(
[('白灵菇 B22', 27), ('S130', 18), ('平菇 A15', 16)],
[(item['strain_name'], item['pallet_count']) for item in overview['month_top_strains']],
)
self.assertEqual(23, overview['empty_pallet_count'])
self.assertEqual([4, 6], [row['warehouse_id'] for row in overview['empty_by_warehouse']])
self.assertEqual((7, 2), (overview['near_expiry_pallet_count'], overview['near_expiry_batch_count']))
self.assertEqual(
('B260923-01', date(2026, 10, 25)), (overview['nearest_expiry_batch_no'], overview['nearest_expiry_date'])
)
FillingOverviewModel.model_validate(overview)
today_sql, month_sql, empty_sql, near_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
self.assertIn("biz_filling_record.operated_at >= '2026-10-17 00:00:00'", today_sql)
self.assertIn("biz_filling_record.operated_at >= '2026-10-01 00:00:00'", month_sql)
for sql in (today_sql, month_sql):
self.assertIn('biz_filling_record.warehouse_id IN (4, 6)', sql)
self.assertIn('biz_filling_record.test_batch_id = 4', sql)
self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql)
self.assertIn("biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", near_sql)
self.assertIn("biz_filling_record.expiry_date <= '2026-10-31'", near_sql)
self.assertIn('biz_filling_record.filling_id = biz_pallet_inventory.active_filling_id', near_sql)
for sql in (empty_sql, near_sql):
self.assertIn('biz_pallet_inventory.warehouse_id IN (4, 6)', sql)
self.assertIn('biz_pallet_inventory.test_batch_id = 4', sql)
async def test_user_without_warehouses_sees_nothing(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(side_effect=[result_rows([]), result_rows([]), result_rows([]), result_rows([])])
overview = await FillingSummaryDao.get_overview(
db, [], TestBatchScope.formal(), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14
)
self.assertEqual(0, overview['month_pallet_count'])
self.assertIsNone(overview['nearest_expiry_date'])
FillingOverviewModel.model_validate(overview)
for call in db.execute.await_args_list:
self.assertIn('false', compile_sql(call.args[0]).lower())
class FillingServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
async def test_list_rows_carry_names_in_camel_case(self) -> None:
record = {
'filling_id': 31,
'filling_no': 'FL-1',
'pallet_id': 501,
'warehouse_id': 4,
'strain_id': 3,
'strain_code_snapshot': 'A15',
'strain_name_snapshot': '平菇 A15',
'batch_no': 'B1',
'package_spec': '1kg/包',
'initial_packages': 100,
'idempotency_key': 'k',
'request_hash': 'h',
'operator_id': 2,
'operated_at': datetime(2026, 10, 17, 9, 10),
}
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
summary = {31: {'pallet_code': 'TP-10501', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}}
with (
patch.object(OperationDao, 'get_filling_list', new=AsyncMock(return_value=page)),
patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
):
result = await FillingService.get_filling_list_services(
MagicMock(spec=AsyncSession), FillingPageQueryModel(), admin_user(), TestBatchScope.formal()
)
self.assertEqual([31], summaries.await_args.args[1])
self.assertIsInstance(result.rows[0], FillingListItemModel)
dumped = result.rows[0].model_dump(by_alias=True)
self.assertEqual(
('TP-10501', '成品一仓', '年糕'), (dumped['palletCode'], dumped['warehouseName'], dumped['operatorName'])
)
async def test_batches_page_uses_warehouse_scope_and_reports_has_next(self) -> None:
batch = {**vars(batch_row()), 'package_spec': '1kg/包', 'last_operator_name': '年糕'}
query = FillingBatchPageQueryModel(pageNum=1, pageSize=1)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 4, 7])),
patch.object(FillingSummaryDao, 'get_batch_page', new=AsyncMock(return_value=([batch], 3))) as get_page,
):
result = await FillingService.get_filling_batches_services(
MagicMock(spec=AsyncSession), query, normal_user(), TestBatchScope.formal()
)
self.assertEqual([4, 7], get_page.await_args.args[2])
self.assertEqual((3, True), (result.total, result.has_next))
row = result.rows[0].model_dump(by_alias=True)
self.assertEqual(('B261017-01', 600, '年糕'), (row['batchNo'], row['packageSum'], row['lastOperatorName']))
self.assertNotIn('lastFillingId', row)
async def test_missing_batch_is_hidden_for_normal_user_and_precise_for_admin(self) -> None:
query = FillingBatchDetailQueryModel(strainId=3, batchNo='B1')
cases = (
(normal_user(), PermissionException, '无权访问该生产批次'),
(admin_user(), ServiceException, '生产批次不存在'),
)
for user, error, message in cases:
with (
self.subTest(admin=user.user.admin),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(FillingSummaryDao, 'get_batch_detail', new=AsyncMock(return_value=None)) as get_detail,
self.assertRaises(error) as raised,
):
await FillingService.get_filling_batch_detail_services(
MagicMock(spec=AsyncSession), query, user, TestBatchScope.formal()
)
self.assertEqual(message, raised.exception.message)
self.assertEqual((3, 'B1'), get_detail.await_args.args[1:3])
async def test_overview_passes_today_month_and_near_expiry_bounds(self) -> None:
overview = {
'today_pallet_count': 0,
'today_package_sum': 0,
'today_batch_count': 0,
'month_start': date(2026, 10, 1),
'month_pallet_count': 0,
'month_batch_count': 0,
'month_top_strains': [],
'empty_pallet_count': 0,
'empty_by_warehouse': [],
'near_expiry_days': 14,
'near_expiry_pallet_count': 0,
'near_expiry_batch_count': 0,
'nearest_expiry_batch_no': None,
'nearest_expiry_date': None,
}
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(FillingSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
):
result = await FillingService.get_filling_overview_services(
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
)
self.assertIsInstance(result, FillingOverviewModel)
_, warehouse_ids, _, today_start, month_start, near_until, days = get_overview.await_args.args
self.assertEqual([7], warehouse_ids)
self.assertEqual((0, 0), (today_start.hour, today_start.minute))
self.assertEqual((1, today_start.month), (month_start.day, month_start.month))
self.assertEqual(today_start.date() + timedelta(days=14), near_until)
self.assertEqual(14, days)
if __name__ == '__main__':
unittest.main()
@@ -28,6 +28,7 @@ from module_admin.service.log_service import LogQueueService
from module_admin.service.login_service import LoginService from module_admin.service.login_service import LoginService
from module_business.controller import inbound_controller as inbound_controller_module from module_business.controller import inbound_controller as inbound_controller_module
from module_business.dao.access_dao import AccessDao from module_business.dao.access_dao import AccessDao
from module_business.dao.inbound_summary_dao import InboundSummaryDao
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
@@ -45,7 +46,9 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
InboundCreateModel, InboundCreateModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
@@ -741,6 +744,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope, patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope,
patch.object(OperationDao, 'get_inbound_list', new=get_list), patch.object(OperationDao, 'get_inbound_list', new=get_list),
patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
): ):
actual = await InboundService.get_inbound_list_services( actual = await InboundService.get_inbound_list_services(
self.db, query, self.user, TestBatchScope.formal(), is_page=True self.db, query, self.user, TestBatchScope.formal(), is_page=True
@@ -827,6 +831,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
) as scope, ) as scope,
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail), patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
patch.object(AccessService, 'assert_warehouse_access', new=access), patch.object(AccessService, 'assert_warehouse_access', new=access),
patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value={})),
): ):
actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal()) actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal())
@@ -1900,6 +1905,7 @@ class TestInboundController(unittest.TestCase):
schema = app.openapi() schema = app.openapi()
expected = { expected = {
('/business/inbound/list', 'get'), ('/business/inbound/list', 'get'),
('/business/inbound/summary', 'get'),
('/business/inbound/warehouse-options', 'get'), ('/business/inbound/warehouse-options', 'get'),
('/business/inbound/scan/{qr_token}', 'get'), ('/business/inbound/scan/{qr_token}', 'get'),
('/business/inbound/{inbound_id}', 'get'), ('/business/inbound/{inbound_id}', 'get'),
@@ -1913,7 +1919,12 @@ class TestInboundController(unittest.TestCase):
self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters}) self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters})
routes = self._routes() routes = self._routes()
self.assertEqual(PageResponseModel[InboundModel], routes[('/business/inbound/list', 'GET')].response_model) self.assertEqual(
PageResponseModel[InboundListItemModel], routes[('/business/inbound/list', 'GET')].response_model
)
self.assertEqual(
DataResponseModel[InboundOverviewModel], routes[('/business/inbound/summary', 'GET')].response_model
)
self.assertEqual( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/inbound/warehouse-options', 'GET')].response_model, routes[('/business/inbound/warehouse-options', 'GET')].response_model,
@@ -1937,12 +1948,14 @@ class TestInboundController(unittest.TestCase):
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_format 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/summary'), detail_index)
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)
def test_each_route_has_exact_permission_and_five_unique_permission_values(self) -> None: def test_each_route_has_exact_permission_and_five_unique_permission_values(self) -> None:
expected = { expected = {
('/business/inbound/list', 'GET'): 'business:inbound:list', ('/business/inbound/list', 'GET'): 'business:inbound:list',
('/business/inbound/summary', 'GET'): 'business:inbound:list',
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query', ('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query', ('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query', ('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
@@ -0,0 +1,256 @@
import unittest
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.inbound_summary_dao import InboundSummaryDao
from module_business.dao.operation_dao import OperationDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import (
InboundListItemModel,
InboundOverviewModel,
InboundPageQueryModel,
)
from module_business.service.inbound_service import InboundService
def compile_sql(statement: Any) -> str:
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
return ' '.join(str(compiled).split()).replace('%%', '%')
def make_test_scope(batch_id: int) -> TestBatchScope:
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
result = MagicMock()
result.all.return_value = rows
return result
def result_one(row: tuple[Any, ...]) -> MagicMock:
result = MagicMock()
result.one.return_value = row
return result
def result_scalar(value: Any) -> MagicMock:
result = MagicMock()
result.scalar_one.return_value = value
return result
def admin_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
)
class InboundListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
db = MagicMock(spec=AsyncSession)
self.assertEqual({}, await InboundSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_aggregates_names_and_pallet_counts_within_batch_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_rows([(12, '成品一仓', '李娜', '王强'), (13, None, None, None)]),
result_rows([(12, 18)]),
]
)
summaries = await InboundSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7))
self.assertEqual(
{'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': '王强', 'pallet_count': 18},
summaries[12],
)
# 没有明细的单据托盘数为 0,名称缺失保持为空
self.assertEqual(
{'warehouse_name': None, 'creator_name': None, 'confirmer_name': None, 'pallet_count': 0},
summaries[13],
)
name_sql = compile_sql(db.execute.await_args_list[0].args[0])
count_sql = compile_sql(db.execute.await_args_list[1].args[0])
for fragment in (
'biz_inbound_order.inbound_id IN (12, 13)',
'biz_inbound_order.test_batch_id = 7',
'biz_warehouse.test_batch_id = 7',
"creator.del_flag = '0'",
'confirmer.user_id = biz_inbound_order.confirmed_by',
):
self.assertIn(fragment, name_sql)
self.assertIn('biz_inbound_item.inbound_id IN (12, 13)', count_sql)
self.assertIn('biz_inbound_item.test_batch_id = 7', count_sql)
async def test_item_identities_join_pallet_and_inventory_in_same_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10501', 'EMPTY_IN_WAREHOUSE'), (32, None, None)]))
identities = await InboundSummaryDao.get_item_identities(db, 12, make_test_scope(7))
self.assertEqual({'pallet_code': 'TP-10501', 'flow_state': 'EMPTY_IN_WAREHOUSE'}, identities[31])
self.assertEqual({'pallet_code': None, 'flow_state': None}, identities[32])
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_inbound_item.inbound_id = 12',
'biz_inbound_item.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'biz_pallet_inventory.test_batch_id = 7',
):
self.assertIn(fragment, sql)
class InboundOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
def _db(self) -> MagicMock:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_one((3, 26, datetime(2026, 10, 14, 9, 30))),
result_one((28, 1240)),
result_scalar(54),
result_rows([(2, '成品二仓', 126), (1, '成品一仓', 168), (3, None, 78)]),
]
)
return db
async def test_overview_counts_and_sorts_empty_pallets_by_warehouse(self) -> None:
db = self._db()
overview = await InboundSummaryDao.get_overview(db, [1, 2, 3], make_test_scope(7), datetime(2026, 10, 1))
self.assertEqual(3, overview['draft_count'])
self.assertEqual(26, overview['draft_pallet_count'])
self.assertEqual(datetime(2026, 10, 14, 9, 30), overview['oldest_draft_at'])
self.assertEqual(28, overview['month_confirmed_count'])
self.assertEqual(1240, overview['month_confirmed_pallet_count'])
self.assertEqual(54, overview['unstocked_pallet_count'])
self.assertEqual(372, overview['empty_pallet_count'])
self.assertEqual([1, 2, 3], [row['warehouse_id'] for row in overview['empty_by_warehouse']])
InboundOverviewModel.model_validate(overview)
draft_sql, month_sql, unstocked_sql, empty_sql = (
compile_sql(call.args[0]) for call in db.execute.await_args_list
)
for sql in (draft_sql, month_sql):
self.assertIn('biz_inbound_order.test_batch_id = 7', sql)
self.assertIn('biz_inbound_order.warehouse_id IN (1, 2, 3)', sql)
self.assertIn('biz_inbound_item.test_batch_id = 7', sql)
self.assertIn("biz_inbound_order.status = 'DRAFT'", draft_sql)
self.assertIn("biz_inbound_order.status = 'CONFIRMED'", month_sql)
self.assertIn("biz_inbound_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql)
# 未入库托盘没有仓库归属,只受测试批次范围与资产状态约束
self.assertIn("biz_pallet_inventory.flow_state = 'UNSTOCKED'", unstocked_sql)
self.assertIn("biz_pallet.asset_status = '0'", unstocked_sql)
self.assertIn('biz_pallet_inventory.test_batch_id = 7', unstocked_sql)
self.assertNotIn('warehouse_id IN', unstocked_sql)
self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql)
self.assertIn('biz_pallet_inventory.warehouse_id IN (1, 2, 3)', empty_sql)
self.assertIn('biz_warehouse.test_batch_id = 7', empty_sql)
async def test_user_without_warehouses_sees_no_orders_or_empty_pallets(self) -> None:
db = self._db()
await InboundSummaryDao.get_overview(db, [], TestBatchScope.formal(), datetime(2026, 10, 1))
draft_sql, month_sql, _unstocked_sql, empty_sql = (
compile_sql(call.args[0]) for call in db.execute.await_args_list
)
for sql in (draft_sql, month_sql, empty_sql):
self.assertIn('false', sql.lower())
async def test_admin_overview_has_no_warehouse_filter(self) -> None:
db = self._db()
await InboundSummaryDao.get_overview(db, None, TestBatchScope.formal(), datetime(2026, 10, 1))
for call in db.execute.await_args_list:
self.assertNotIn('warehouse_id IN', compile_sql(call.args[0]))
class InboundServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None:
record = {'inbound_id': 12, 'inbound_no': 'IN-1', 'warehouse_id': 7, 'status': 'DRAFT'}
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
summary = {
12: {'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': None, 'pallet_count': 18}
}
with (
patch.object(OperationDao, 'get_inbound_list', new=AsyncMock(return_value=page)),
patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
):
result = await InboundService.get_inbound_list_services(
MagicMock(spec=AsyncSession), InboundPageQueryModel(), admin_user(), TestBatchScope.formal()
)
self.assertEqual([12], summaries.await_args.args[1])
row = result.rows[0]
self.assertIsInstance(row, InboundListItemModel)
dumped = row.model_dump(by_alias=True)
self.assertEqual('成品一仓', dumped['warehouseName'])
self.assertEqual('李娜', dumped['creatorName'])
self.assertEqual(18, dumped['palletCount'])
async def test_detail_items_carry_pallet_code_and_current_state(self) -> None:
inbound = {
'inbound_id': 12,
'inbound_no': 'IN-1',
'warehouse_id': 7,
'status': 'CONFIRMED',
'items': [{'inbound_item_id': 31, 'inbound_id': 12, 'pallet_id': 501}],
}
identities = {31: {'pallet_code': 'TP-10501', 'flow_state': 'FILLED_IN_WAREHOUSE'}}
with (
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)),
):
result = await InboundService.inbound_detail_services(
MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal()
)
item = result.model_dump(by_alias=True)['items'][0]
self.assertEqual('TP-10501', item['palletCode'])
self.assertEqual('FILLED_IN_WAREHOUSE', item['flowState'])
async def test_overview_uses_warehouse_scope_and_month_start(self) -> None:
user = CurrentUserModel.model_construct(
permissions=['business:inbound:list'],
roles=['common'],
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
)
overview = {
'draft_count': 0,
'draft_pallet_count': 0,
'oldest_draft_at': None,
'month_start': datetime(2026, 10, 1).date(),
'month_confirmed_count': 0,
'month_confirmed_pallet_count': 0,
'unstocked_pallet_count': 0,
'empty_pallet_count': 0,
'empty_by_warehouse': [],
}
with (
patch.object(InboundService, '_warehouse_scope', new=AsyncMock(return_value=[7])),
patch.object(InboundSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
):
result = await InboundService.get_inbound_overview_services(
MagicMock(spec=AsyncSession), user, TestBatchScope.formal()
)
self.assertIsInstance(result, InboundOverviewModel)
self.assertEqual([7], get_overview.await_args.args[1])
month_start = get_overview.await_args.args[3]
self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute))
if __name__ == '__main__':
unittest.main()
@@ -33,6 +33,14 @@ from module_business.entity.do.master_do import (
BizStrain, BizStrain,
BizWarehouse, BizWarehouse,
) )
from module_business.entity.vo.master_summary_vo import (
CustomerDetailModel,
CustomerListItemModel,
LineListItemModel,
SiteListItemModel,
StrainListItemModel,
WarehouseListItemModel,
)
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
CustomerModel, CustomerModel,
CustomerPageQueryModel, CustomerPageQueryModel,
@@ -1060,17 +1068,24 @@ class TestMasterControllers(unittest.TestCase):
def test_route_response_model_contracts(self) -> None: def test_route_response_model_contracts(self) -> None:
model_by_resource = { model_by_resource = {
'strain': StrainModel, 'strain': StrainModel,
'customer': CustomerModel, 'customer': CustomerDetailModel,
'site': SiteModel, 'site': SiteModel,
'line': LineModel, 'line': LineModel,
'warehouse': WarehouseModel, 'warehouse': WarehouseModel,
} }
list_model_by_resource = {
'strain': StrainListItemModel,
'customer': CustomerListItemModel,
'site': SiteListItemModel,
'line': LineListItemModel,
'warehouse': WarehouseListItemModel,
}
for resource, router in self.ROUTERS.items(): for resource, router in self.ROUTERS.items():
for route in router.routes: for route in router.routes:
if not isinstance(route, APIRoute): if not isinstance(route, APIRoute):
continue continue
if route.path.endswith('/list'): if route.path.endswith('/list'):
self.assertEqual(PageResponseModel[model_by_resource[resource]], route.response_model) self.assertEqual(PageResponseModel[list_model_by_resource[resource]], route.response_model)
elif route.methods == {'GET'}: elif route.methods == {'GET'}:
self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model) self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model)
else: else:
@@ -0,0 +1,351 @@
import io
import unittest
from datetime import date, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from openpyxl import Workbook
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from exceptions.exception import ServiceException
from module_business.controller import line_controller, site_controller, tunnel_controller
from module_business.dao.master_dao import MasterDao
from module_business.dao.master_summary_dao import MasterSummaryDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.master_summary_vo import (
CustomerDetailModel,
MasterImportPreviewModel,
PalletDetailModel,
PalletListItemModel,
WarehouseListItemModel,
)
from module_business.entity.vo.master_vo import (
CustomerModel,
PalletModel,
PalletPageQueryModel,
WarehouseModel,
)
from module_business.service.master_excel_service import MasterExcelService
from module_business.service.master_summary_service import MasterSummaryService
from tests.module_business.test_batch_fixtures import make_admin, make_test_scope
from utils.page_util import PageUtil
def compile_sql(statement: Any) -> str:
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
return ' '.join(str(compiled).split()).replace('%%', '%')
def result_rows(rows: list[Any]) -> MagicMock:
result = MagicMock()
result.all.return_value = rows
return result
class _Upload:
def __init__(self, content: bytes) -> None:
self.content = content
async def read(self) -> bytes:
return self.content
async def close(self) -> None:
return None
def workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes:
workbook = Workbook()
worksheet = workbook.active
worksheet.append(headers)
for row in rows:
worksheet.append(row)
buffer = io.BytesIO()
workbook.save(buffer)
return buffer.getvalue()
SUMMARY_GETTERS = (
'get_strain_summaries',
'get_customer_summaries',
'get_site_summaries',
'get_line_summaries',
'get_tunnel_summaries',
'get_warehouse_summaries',
'get_pallet_summaries',
)
class MasterSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
for getter in SUMMARY_GETTERS:
db = MagicMock(spec=AsyncSession)
with self.subTest(getter=getter):
self.assertEqual({}, await getattr(MasterSummaryDao, getter)(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_every_summary_query_is_bound_to_current_batch(self) -> None:
for getter in SUMMARY_GETTERS:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
with self.subTest(getter=getter):
summaries = await getattr(MasterSummaryDao, getter)(db, [3, 3, 1], make_test_scope(7))
self.assertEqual([1, 3], sorted(summaries))
self.assertGreater(db.execute.await_count, 0)
for call in db.execute.await_args_list:
sql = compile_sql(call.args[0])
self.assertIn('test_batch_id = 7', sql)
self.assertNotIn('test_batch_id IS NULL', sql)
async def test_customer_site_tree_queries_are_bound_to_current_batch(self) -> None:
site = SimpleNamespace(site_id=5, site_code='S-01', site_name='南厂', address=None, status='0')
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(side_effect=[result_rows([site])] + [result_rows([])] * 4)
tree = await MasterSummaryDao.get_customer_site_tree(db, 2, make_test_scope(7))
self.assertEqual(5, db.execute.await_count)
self.assertEqual([{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'address': None, 'status': '0',
'lines': [], 'tunnels': [], 'users': []}], tree)
for call in db.execute.await_args_list:
self.assertIn('test_batch_id = 7', compile_sql(call.args[0]))
async def test_customer_without_sites_stops_after_first_query(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
self.assertEqual([], await MasterSummaryDao.get_customer_site_tree(db, 2, TestBatchScope.formal()))
self.assertEqual(1, db.execute.await_count)
async def test_pallet_summary_merges_state_cycles_and_return_target(self) -> None:
shipped_at = datetime(2026, 10, 1, 9, 0)
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_rows(
[
(11, 'RETURNING', 0, None, '金禾菌业', '南厂', None, None, None, None, None),
(12, 'FILLED_IN_WAREHOUSE', 100, '成品一仓', None, None, None, None, '香菇', 'B-1',
date(2026, 10, 30)),
]
),
result_rows([(11, 3, shipped_at, shipped_at), (12, 0, None, shipped_at)]),
result_rows([(11, '周转仓')]),
]
)
summaries = await MasterSummaryDao.get_pallet_summaries(db, [12, 11], TestBatchScope.formal())
self.assertEqual('RETURNING', summaries[11]['flow_state'])
self.assertEqual(3, summaries[11]['cycle_count'])
self.assertEqual('周转仓', summaries[11]['return_warehouse_name'])
self.assertEqual('成品一仓', summaries[12]['warehouse_name'])
self.assertEqual('香菇', summaries[12]['strain_name'])
self.assertNotIn('return_warehouse_name', summaries[12])
sql = compile_sql(db.execute.await_args_list[2].args[0])
self.assertIn("biz_return_order.status = 'REQUESTED'", sql)
class ParentOptionTest(unittest.IsolatedAsyncioTestCase):
async def test_customer_options_only_list_active_customers_in_batch(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(2, 'C-01', '金禾菌业')]))
options = await MasterSummaryDao.list_customer_options(db, make_test_scope(7))
self.assertEqual([{'id': 2, 'code': 'C-01', 'name': '金禾菌业'}], options)
sql = compile_sql(db.execute.await_args.args[0])
self.assertIn("biz_customer.status = '0'", sql)
self.assertIn('biz_customer.test_batch_id = 7', sql)
self.assertIn('ORDER BY biz_customer.customer_code', sql)
async def test_site_options_require_active_site_and_active_customer(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(5, 'S-01', '南厂', '金禾菌业')]))
options = await MasterSummaryDao.list_site_options(db, TestBatchScope.formal())
self.assertEqual([{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}], options)
sql = compile_sql(db.execute.await_args.args[0])
self.assertIn("biz_customer_site.status = '0'", sql)
self.assertIn("biz_customer.status = '0'", sql)
self.assertIn('biz_customer_site.test_batch_id IS NULL', sql)
self.assertIn('biz_customer.test_batch_id IS NULL', sql)
async def test_service_wraps_options_in_camel_case_models(self) -> None:
rows = [{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}]
with patch.object(MasterSummaryDao, 'list_site_options', new=AsyncMock(return_value=rows)):
options = await MasterSummaryService.site_options(MagicMock(spec=AsyncSession), TestBatchScope.formal())
self.assertEqual({'id': 5, 'code': 'S-01', 'name': '南厂', 'parentName': '金禾菌业'}, options[0].model_dump(by_alias=True))
def test_option_routes_are_registered_before_detail_routes(self) -> None:
for module, path, permission in (
(site_controller, '/master/site/customer-options', 'master:site:list'),
(line_controller, '/master/line/site-options', 'master:line:list'),
(tunnel_controller, '/master/tunnel/site-options', 'master:tunnel:list'),
):
resource = path.split('/')[2]
option_router = getattr(module, f'{resource}_option_controller')
main_router = getattr(module, f'{resource}_controller')
with self.subTest(path=path):
self.assertLess(option_router.order_num, main_router.order_num)
(route,) = [route for route in option_router.routes if getattr(route, 'path', '') == path]
self.assertEqual({'GET'}, route.methods)
self.assertEqual(
[permission],
[dep.dependency.perm for dep in route.dependencies if hasattr(dep.dependency, 'perm')],
)
class PalletListFilterTest(unittest.IsolatedAsyncioTestCase):
async def _capture(self, query: PalletPageQueryModel, scope: TestBatchScope) -> str:
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await MasterDao.list_pallet(MagicMock(spec=AsyncSession), query, scope, is_page=True)
return compile_sql(paginate.await_args.args[1])
async def test_flow_group_filters_by_inventory_state_in_same_batch(self) -> None:
sql = await self._capture(PalletPageQueryModel(flowGroup='site'), make_test_scope(7))
self.assertIn('biz_pallet.pallet_id IN (SELECT biz_pallet_inventory.pallet_id', sql)
self.assertIn("'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY'", sql)
self.assertIn('biz_pallet_inventory.test_batch_id = 7', sql)
self.assertNotIn('NOT IN', sql)
async def test_unstocked_group_includes_pallets_without_inventory(self) -> None:
sql = await self._capture(PalletPageQueryModel(flowGroup='unstocked'), TestBatchScope.formal())
self.assertIn("biz_pallet_inventory.flow_state IN ('UNSTOCKED')", sql)
self.assertIn('biz_pallet.pallet_id NOT IN (SELECT biz_pallet_inventory.pallet_id', sql)
async def test_code_search_also_matches_scanned_qr_token(self) -> None:
sql = await self._capture(PalletPageQueryModel(palletCode='tok-1'), TestBatchScope.formal())
self.assertIn("biz_pallet.pallet_code LIKE '%tok-1%' OR biz_pallet.qr_token = 'tok-1'", sql)
def test_flow_group_rejects_unknown_value(self) -> None:
with self.assertRaises(ValueError):
PalletPageQueryModel(flowGroup='lost')
class MasterSummaryServiceTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.scope = TestBatchScope.formal()
async def test_enrich_page_merges_summaries_into_list_rows(self) -> None:
page = PageModel[Any](
rows=[
WarehouseModel(warehouseId=1, warehouseCode='W-01', warehouseName='成品一仓'),
WarehouseModel(warehouseId=2, warehouseCode='W-02', warehouseName='周转仓'),
],
pageNum=1,
pageSize=10,
total=2,
hasNext=False,
)
loader = AsyncMock(return_value={1: {'filled_pallet_count': 4, 'users': [{'user_id': 3, 'user_name': 'li'}]}})
with patch.object(MasterSummaryDao, 'get_warehouse_summaries', new=loader), patch.dict(
'module_business.service.master_summary_service._ENRICHERS',
{'warehouse': ('warehouse_id', loader, WarehouseListItemModel)},
):
result = await MasterSummaryService.enrich_page(self.db, 'warehouse', page, self.scope)
loader.assert_awaited_once_with(self.db, [1, 2], self.scope)
self.assertEqual(2, result.total)
self.assertIsInstance(result.rows[0], WarehouseListItemModel)
self.assertEqual(4, result.rows[0].filled_pallet_count)
self.assertEqual('li', result.rows[0].users[0].user_name)
self.assertEqual(0, result.rows[1].filled_pallet_count)
dumped = result.model_dump(by_alias=True)['rows'][0]
self.assertEqual(4, dumped['filledPalletCount'])
self.assertEqual('成品一仓', dumped['warehouseName'])
async def test_customer_detail_carries_site_tree(self) -> None:
tree = [{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'status': '0', 'unused_pallet_count': 2}]
with patch.object(MasterSummaryDao, 'get_customer_site_tree', new=AsyncMock(return_value=tree)) as loader:
detail = await MasterSummaryService.customer_detail(
self.db,
CustomerModel(customerId=2, customerCode='C-01', customerName='金禾菌业'),
self.scope,
)
loader.assert_awaited_once_with(self.db, 2, self.scope)
self.assertIsInstance(detail, CustomerDetailModel)
self.assertEqual('C-01', detail.customer_code)
self.assertEqual(2, detail.sites[0].unused_pallet_count)
async def test_pallet_detail_adds_state_and_recent_events(self) -> None:
pallet = PalletModel(palletId=9, palletCode='P-9', qrToken='tok', capacityPackages=100, assetStatus='0')
event = {
'event_id': 1,
'event_type': 'USAGE_RECORDED',
'occurred_at': datetime(2026, 10, 16, 9, 12),
'after_state': 'AT_SITE_PARTIAL',
'before_remaining_packages': 50,
'after_remaining_packages': 38,
}
with patch.object(
MasterSummaryDao,
'get_pallet_summaries',
new=AsyncMock(return_value={9: {'flow_state': 'AT_SITE_PARTIAL', 'remaining_packages': 38}}),
), patch.object(MasterSummaryDao, 'get_pallet_recent_events', new=AsyncMock(return_value=[event])):
detail = await MasterSummaryService.pallet_detail(self.db, pallet, self.scope)
self.assertIsInstance(detail, PalletDetailModel)
self.assertIsInstance(detail, PalletListItemModel)
self.assertEqual('tok', detail.qr_token)
self.assertEqual(38, detail.remaining_packages)
self.assertEqual('USAGE_RECORDED', detail.recent_events[0].event_type)
class MasterImportPreviewTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = AsyncMock(spec=AsyncSession)
self.db.execute = AsyncMock()
self.headers = ['菌种编码', '菌种名称', '状态', '备注']
async def _preview(self, rows: list[list[object]], *, update_support: bool = False) -> MasterImportPreviewModel:
return await MasterExcelService.preview_import(
self.db,
'strain',
_Upload(workbook_bytes(self.headers, rows)), # type: ignore[arg-type]
update_support,
make_admin(),
TestBatchScope.formal(),
)
async def test_preview_reports_each_row_and_never_commits(self) -> None:
existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None)
async def find(_db: object, code: str) -> object:
return existing if code == 'S001' else None
with patch.object(MasterDao, 'find_strain_by_code', new=find), patch.object(
MasterDao, 'get_strain', new=AsyncMock(return_value=existing)
), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain, patch.object(
MasterDao, 'update_strain', new=AsyncMock()
):
preview = await self._preview(
[['S001', '香菇', '正常', ''], ['S002', '平菇', '正常', ''], ['S003', '金针菇', '启用', '']]
)
self.assertEqual(['菌种编码', '菌种名称', '状态', '备注'], preview.headers)
self.assertEqual(3, preview.total_rows)
self.assertEqual((1, 0, 1, 1), (preview.add_count, preview.update_count, preview.skip_count, preview.error_count))
self.assertEqual(['skip', 'add', 'error'], [row.outcome for row in preview.rows])
self.assertEqual([2, 3, 4], [row.excel_row for row in preview.rows])
self.assertIn('状态只能填写正常或停用', preview.rows[2].message)
self.assertEqual(['S002', '平菇', '正常', ''], preview.rows[1].values)
add_strain.assert_awaited_once()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_preview_with_update_support_marks_existing_rows_as_update(self) -> None:
existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None)
with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object(
MasterDao, 'get_strain', new=AsyncMock(return_value=existing)
), patch.object(MasterDao, 'update_strain', new=AsyncMock()):
preview = await self._preview([['S001', '新香菇', '停用', '']], update_support=True)
self.assertEqual('update', preview.rows[0].outcome)
self.assertTrue(preview.update_support)
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_whole_file_errors_are_raised_before_any_row_is_checked(self) -> None:
with self.assertRaises(ServiceException) as raised:
await self._preview([['S001', '香菇', '正常', ''], ['S001', '香菇', '正常', '']])
self.assertIn('重复编码', raised.exception.message)
self.db.execute.assert_not_awaited()
self.db.commit.assert_not_awaited()
if __name__ == '__main__':
unittest.main()
@@ -34,6 +34,7 @@ from module_business.entity.do.master_do import (
BizWarehouse, BizWarehouse,
) )
from module_business.entity.do.operation_do import BizPalletInventory from module_business.entity.do.operation_do import BizPalletInventory
from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel
from module_business.entity.vo.master_vo import ( from module_business.entity.vo.master_vo import (
BusinessUserOptionModel, BusinessUserOptionModel,
DeletePalletModel, DeletePalletModel,
@@ -1285,11 +1286,11 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase):
def test_route_response_models_are_exact(self) -> None: def test_route_response_models_are_exact(self) -> None:
routes = self._routes() routes = self._routes()
self.assertEqual( self.assertEqual(
PageResponseModel[PalletModel], PageResponseModel[PalletListItemModel],
routes[('/master/pallet/list', 'GET')].response_model, routes[('/master/pallet/list', 'GET')].response_model,
) )
self.assertEqual( self.assertEqual(
DataResponseModel[PalletModel], DataResponseModel[PalletDetailModel],
routes[('/master/pallet/{pallet_id}', 'GET')].response_model, routes[('/master/pallet/{pallet_id}', 'GET')].response_model,
) )
for path, method in ( for path, method in (
@@ -38,10 +38,13 @@ from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
) )
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
@@ -440,6 +443,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
schema = app.openapi() schema = app.openapi()
expected = { expected = {
('/business/return/list', 'get'), ('/business/return/list', 'get'),
('/business/return/summary', 'get'),
('/business/return/site-options', 'get'),
('/business/return/warehouse-options', 'get'), ('/business/return/warehouse-options', 'get'),
('/business/return/scan/{qr_token}', 'get'), ('/business/return/scan/{qr_token}', 'get'),
('/business/return/{return_id}', 'get'), ('/business/return/{return_id}', 'get'),
@@ -450,7 +455,17 @@ class ReturnControllerContractRedTest(unittest.TestCase):
parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters'] parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters']
self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters}) self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters})
routes = self._routes() routes = self._routes()
self.assertEqual(PageResponseModel[ReturnModel], routes[('/business/return/list', 'GET')].response_model) self.assertEqual(
PageResponseModel[ReturnListItemModel], routes[('/business/return/list', 'GET')].response_model
)
self.assertEqual(
DataResponseModel[ReturnOverviewModel],
routes[('/business/return/summary', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[list[ReturnSiteOptionModel]],
routes[('/business/return/site-options', 'GET')].response_model,
)
self.assertEqual( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/return/warehouse-options', 'GET')].response_model, routes[('/business/return/warehouse-options', 'GET')].response_model,
@@ -472,6 +487,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
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',
'/business/return/summary',
'/business/return/site-options',
'/business/return/warehouse-options', '/business/return/warehouse-options',
'/business/return/scan/{qr_token}', '/business/return/scan/{qr_token}',
'/business/return/request', '/business/return/request',
@@ -480,6 +497,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
expected = { expected = {
('/business/return/list', 'GET'): 'business:return:list', ('/business/return/list', 'GET'): 'business:return:list',
('/business/return/summary', 'GET'): 'business:return:list',
('/business/return/site-options', 'GET'): 'business:return:list',
('/business/return/warehouse-options', 'GET'): 'business:return:query', ('/business/return/warehouse-options', 'GET'): 'business:return:query',
('/business/return/scan/{qr_token}', 'GET'): 'business:return:query', ('/business/return/scan/{qr_token}', 'GET'): 'business:return:query',
('/business/return/{return_id}', 'GET'): 'business:return:query', ('/business/return/{return_id}', 'GET'): 'business:return:query',
@@ -0,0 +1,408 @@
import unittest
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao
from module_business.dao.operation_dao import OperationDao
from module_business.dao.return_summary_dao import ReturnSummaryDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.operation_do import BizReturnItem, BizReturnOrder
from module_business.entity.vo.operation_vo import (
ReturnListItemModel,
ReturnOverviewModel,
ReturnPageQueryModel,
ReturnSiteOptionModel,
)
from module_business.service.return_service import ReturnService
from utils.page_util import PageUtil
def compile_sql(statement: Any) -> str:
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
return ' '.join(str(compiled).split()).replace('%%', '%')
def make_test_scope(batch_id: int) -> TestBatchScope:
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
result = MagicMock()
result.all.return_value = rows
return result
def result_one(row: tuple[Any, ...]) -> MagicMock:
result = MagicMock()
result.one.return_value = row
return result
def admin_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
)
def normal_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['business:return:list'],
roles=['common'],
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
)
class ReturnListFilterTest(unittest.IsolatedAsyncioTestCase):
async def _capture(self, query: ReturnPageQueryModel, scope: TestBatchScope) -> str:
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await OperationDao.get_return_list(
MagicMock(spec=AsyncSession),
query,
site_ids=[9],
warehouse_ids=[7],
scope=scope,
)
return compile_sql(paginate.await_args.args[1])
async def test_keyword_matches_number_names_and_pallet_code_within_same_batch_scope(self) -> None:
sql = await self._capture(ReturnPageQueryModel(keyword='TP-104'), make_test_scope(7))
for fragment in (
"biz_return_order.return_no LIKE '%TP-104%'",
"source_site.site_name LIKE '%TP-104%'",
"source_customer.customer_name LIKE '%TP-104%'",
"target_warehouse.warehouse_name LIKE '%TP-104%'",
"biz_pallet.pallet_code LIKE '%TP-104%'",
'EXISTS (SELECT biz_return_item.return_item_id',
'biz_return_item.return_id = biz_return_order.return_id',
'biz_return_item.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'source_site.test_batch_id = 7',
'source_customer.test_batch_id = 7',
'target_warehouse.test_batch_id = 7',
# 关键字只在原可见范围内匹配
'biz_return_order.source_site_id IN (9)',
'biz_return_order.target_warehouse_id IN (7)',
):
self.assertIn(fragment, sql)
async def test_without_keyword_list_does_not_join_master_tables(self) -> None:
sql = await self._capture(ReturnPageQueryModel(), TestBatchScope.formal())
self.assertNotIn('JOIN', sql)
self.assertNotIn('EXISTS', sql)
def test_keyword_rejects_blank_value(self) -> None:
with self.assertRaises(ValueError):
ReturnPageQueryModel(keyword=' ')
class ReturnListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
db = MagicMock(spec=AsyncSession)
self.assertEqual({}, await ReturnSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_aggregates_names_and_snapshot_totals_within_batch_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_rows([(12, '南厂', '金禾菌业', '成品一仓', '赵强', None), (13, None, None, None, None, None)]),
result_rows([(12, 12, 520, 7)]),
]
)
summaries = await ReturnSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7))
self.assertEqual(
{
'source_site_name': '南厂',
'customer_name': '金禾菌业',
'target_warehouse_name': '成品一仓',
'requester_name': '赵强',
'confirmer_name': None,
'pallet_count': 12,
'package_sum': 520,
'filled_count': 7,
},
summaries[12],
)
# 没有明细的单据汇总为 0,名称缺失保持为空
self.assertEqual(0, summaries[13]['pallet_count'])
self.assertEqual(0, summaries[13]['package_sum'])
self.assertIsNone(summaries[13]['source_site_name'])
name_sql = compile_sql(db.execute.await_args_list[0].args[0])
item_sql = compile_sql(db.execute.await_args_list[1].args[0])
for fragment in (
'biz_return_order.return_id IN (12, 13)',
'biz_return_order.test_batch_id = 7',
'source_site.test_batch_id = 7',
'source_customer.test_batch_id = 7',
'target_warehouse.test_batch_id = 7',
'requester.user_id = biz_return_order.requested_by',
'confirmer.user_id = biz_return_order.confirmed_by',
"requester.del_flag = '0'",
):
self.assertIn(fragment, name_sql)
self.assertIn('biz_return_item.return_id IN (12, 13)', item_sql)
self.assertIn('biz_return_item.test_batch_id = 7', item_sql)
self.assertIn('WHEN (biz_return_item.remaining_packages_snapshot > 0) THEN 1 ELSE 0', item_sql)
async def test_item_identities_join_pallet_and_snapshot_tunnel_in_same_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10466', '2 号隧道'), (32, 'TP-10468', None)]))
identities = await ReturnSummaryDao.get_item_identities(db, 12, make_test_scope(7))
self.assertEqual({'pallet_code': 'TP-10466', 'tunnel_name': '2 号隧道'}, identities[31])
self.assertEqual({'pallet_code': 'TP-10468', 'tunnel_name': None}, identities[32])
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_return_item.return_id = 12',
'biz_return_item.test_batch_id = 7',
'biz_pallet.test_batch_id = 7',
'biz_tunnel.tunnel_id = biz_return_item.tunnel_id_snapshot',
'biz_tunnel.test_batch_id = 7',
):
self.assertIn(fragment, sql)
class ReturnSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_user_sees_assigned_sites_and_sources_of_visible_returns(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(9, 'JH-S', '南厂', '金禾菌业')]))
options = await ReturnSummaryDao.get_site_options(db, [9, 9], [7], make_test_scope(4))
self.assertEqual([{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业'}], options)
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_customer_site.test_batch_id = 4',
'biz_customer.test_batch_id = 4',
'biz_customer_site.site_id IN (9)',
'biz_customer_site.site_id IN (SELECT biz_return_order.source_site_id',
'biz_return_order.test_batch_id = 4',
'biz_return_order.target_warehouse_id IN (7)',
):
self.assertIn(fragment, sql)
async def test_user_without_mappings_gets_no_sites(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
await ReturnSummaryDao.get_site_options(db, [], [], TestBatchScope.formal())
sql = compile_sql(db.execute.await_args.args[0]).lower()
self.assertIn('false', sql)
self.assertNotIn('site_id in (9)', sql)
async def test_admin_sees_all_sites_in_batch_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
await ReturnSummaryDao.get_site_options(db, None, None, TestBatchScope.formal())
sql = compile_sql(db.execute.await_args.args[0])
self.assertIn('biz_customer_site.test_batch_id IS NULL', sql)
self.assertNotIn('biz_return_order', sql)
class ReturnOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
def _db(self) -> MagicMock:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_one((3, 26, 9, 610, datetime(2026, 10, 15, 9, 5))),
result_rows(
[
(4, datetime(2026, 10, 14, 16, 0), datetime(2026, 10, 15, 8, 0), 16, 4, 210),
(5, datetime(2026, 10, 12, 11, 0), datetime(2026, 10, 13, 11, 0), 10, 0, None),
]
),
]
)
return db
async def test_overview_totals_pending_and_month_outcome(self) -> None:
db = self._db()
overview = await ReturnSummaryDao.get_overview(db, [9], [7], make_test_scope(7), datetime(2026, 10, 1))
self.assertEqual(3, overview['pending_count'])
self.assertEqual(26, overview['pending_pallet_count'])
self.assertEqual(9, overview['pending_filled_count'])
self.assertEqual(610, overview['pending_package_sum'])
self.assertEqual(datetime(2026, 10, 15, 9, 5), overview['oldest_pending_at'])
self.assertEqual(2, overview['month_confirmed_count'])
self.assertEqual(26, overview['month_confirmed_pallet_count'])
self.assertEqual(4, overview['month_filled_pallet_count'])
self.assertEqual(210, overview['month_package_sum'])
# (16 + 24) / 2 小时
self.assertEqual(20.0, overview['month_avg_confirm_hours'])
ReturnOverviewModel.model_validate(overview)
pending_sql, month_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
for sql in (pending_sql, month_sql):
self.assertIn('biz_return_order.test_batch_id = 7', sql)
self.assertIn('biz_return_item.test_batch_id = 7', sql)
self.assertIn(
'(biz_return_order.source_site_id IN (9) OR biz_return_order.target_warehouse_id IN (7))',
sql,
)
self.assertIn("biz_return_order.status = 'REQUESTED'", pending_sql)
self.assertIn("biz_return_order.status = 'CONFIRMED'", month_sql)
self.assertIn("biz_return_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql)
async def test_empty_month_has_no_average(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(side_effect=[result_one((0, 0, None, None, None)), result_rows([])])
overview = await ReturnSummaryDao.get_overview(db, [], [], TestBatchScope.formal(), datetime(2026, 10, 1))
self.assertEqual(0, overview['pending_filled_count'])
self.assertEqual(0, overview['month_confirmed_count'])
self.assertIsNone(overview['month_avg_confirm_hours'])
ReturnOverviewModel.model_validate(overview)
for call in db.execute.await_args_list:
self.assertIn('false', compile_sql(call.args[0]).lower())
async def test_admin_overview_has_no_visibility_filter(self) -> None:
db = self._db()
await ReturnSummaryDao.get_overview(db, None, None, TestBatchScope.formal(), datetime(2026, 10, 1))
for call in db.execute.await_args_list:
sql = compile_sql(call.args[0])
self.assertNotIn('source_site_id IN', sql)
self.assertNotIn('target_warehouse_id IN', sql)
class ReturnServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None:
record = {
'return_id': 12,
'return_no': 'RE-1',
'source_site_id': 9,
'target_warehouse_id': 7,
'status': 'REQUESTED',
'request_idempotency_key': 'k',
'request_hash': 'h' * 64,
'requested_by': 8,
'requested_at': datetime(2026, 10, 17, 10, 20),
}
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
summary = {
12: {
'source_site_name': '南厂',
'customer_name': '金禾菌业',
'target_warehouse_name': '成品一仓',
'requester_name': '赵强',
'confirmer_name': None,
'pallet_count': 12,
'package_sum': 520,
'filled_count': 7,
}
}
with (
patch.object(OperationDao, 'get_return_list', new=AsyncMock(return_value=page)),
patch.object(ReturnSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
):
result = await ReturnService.get_return_list_services(
MagicMock(spec=AsyncSession), ReturnPageQueryModel(), admin_user(), TestBatchScope.formal()
)
self.assertEqual([12], summaries.await_args.args[1])
row = result.rows[0]
self.assertIsInstance(row, ReturnListItemModel)
dumped = row.model_dump(by_alias=True)
self.assertEqual('南厂', dumped['sourceSiteName'])
self.assertEqual('金禾菌业', dumped['customerName'])
self.assertEqual('成品一仓', dumped['targetWarehouseName'])
self.assertEqual(520, dumped['packageSum'])
self.assertEqual(7, dumped['filledCount'])
async def test_detail_items_carry_pallet_code_and_tunnel_name(self) -> None:
order = BizReturnOrder(
return_id=12,
return_no='RE-1',
source_site_id=9,
target_warehouse_id=7,
status='REQUESTED',
request_idempotency_key='k',
request_hash='h' * 64,
requested_by=8,
requested_at=datetime(2026, 10, 17, 10, 20),
test_batch_id=None,
)
order.items = [
BizReturnItem(return_item_id=31, return_id=12, pallet_id=501, remaining_packages_snapshot=60),
]
identities = {31: {'pallet_code': 'TP-10466', 'tunnel_name': '2 号隧道'}}
with (
patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(return_value=order)),
patch.object(ReturnSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)),
):
result = await ReturnService.return_detail_services(
MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal()
)
item = result.model_dump(by_alias=True)['items'][0]
self.assertEqual(501, item['palletId'])
self.assertEqual('TP-10466', item['palletCode'])
self.assertEqual('2 号隧道', item['tunnelName'])
async def test_overview_uses_site_and_warehouse_scope_and_month_start(self) -> None:
overview = {
'pending_count': 0,
'pending_pallet_count': 0,
'pending_filled_count': 0,
'pending_package_sum': 0,
'oldest_pending_at': None,
'month_start': datetime(2026, 10, 1).date(),
'month_confirmed_count': 0,
'month_confirmed_pallet_count': 0,
'month_filled_pallet_count': 0,
'month_package_sum': 0,
'month_avg_confirm_hours': None,
}
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(ReturnSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
):
result = await ReturnService.get_return_overview_services(
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
)
self.assertIsInstance(result, ReturnOverviewModel)
self.assertEqual([9], get_overview.await_args.args[1])
self.assertEqual([7], get_overview.await_args.args[2])
month_start = get_overview.await_args.args[4]
self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute))
async def test_site_options_return_models_with_customer_name(self) -> None:
rows = [{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业'}]
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])),
patch.object(ReturnSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options,
):
result = await ReturnService.get_site_options_services(
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
)
self.assertEqual(([9], []), get_options.await_args.args[1:3])
self.assertIsInstance(result[0], ReturnSiteOptionModel)
self.assertEqual(
{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customerName': '金禾菌业'}, result[0].model_dump(by_alias=True)
)
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,186 @@
import unittest
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import PageModel
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.access_dao import AccessDao
from module_business.dao.operation_dao import OperationDao
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import ShipmentListItemModel, ShipmentPageQueryModel
from module_business.service.shipment_service import ShipmentService
from utils.page_util import PageUtil
def compile_sql(statement: Any) -> str:
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
return ' '.join(str(compiled).split()).replace('%%', '%')
def make_test_scope(batch_id: int) -> TestBatchScope:
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
result = MagicMock()
result.all.return_value = rows
return result
class ShipmentListFilterTest(unittest.IsolatedAsyncioTestCase):
async def _capture(self, query: ShipmentPageQueryModel, scope: TestBatchScope) -> str:
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await OperationDao.get_shipment_list(
MagicMock(spec=AsyncSession),
query,
warehouse_ids=[7],
site_ids=[9],
scope=scope,
)
return compile_sql(paginate.await_args.args[1])
async def test_receipt_state_filter_only_matches_confirmed_shipments(self) -> None:
sql = await self._capture(ShipmentPageQueryModel(receipt_state='PENDING'), TestBatchScope.formal())
self.assertIn("biz_shipment_order.status = 'CONFIRMED'", sql)
self.assertIn("biz_shipment_order.receipt_state = 'PENDING'", sql)
async def test_keyword_matches_number_and_names_within_same_batch_scope(self) -> None:
sql = await self._capture(ShipmentPageQueryModel(keyword='金禾'), make_test_scope(7))
for fragment in (
"biz_shipment_order.shipment_no LIKE '%金禾%'",
"source_warehouse.warehouse_name LIKE '%金禾%'",
"target_site.site_name LIKE '%金禾%'",
"target_customer.customer_name LIKE '%金禾%'",
'source_warehouse.test_batch_id = 7',
'target_site.test_batch_id = 7',
'target_customer.test_batch_id = 7',
'biz_shipment_order.source_warehouse_id IN (7)',
'biz_shipment_order.target_site_id IN (9)',
):
self.assertIn(fragment, sql)
async def test_without_keyword_list_does_not_join_master_tables(self) -> None:
sql = await self._capture(ShipmentPageQueryModel(), TestBatchScope.formal())
self.assertNotIn('JOIN', sql)
def test_keyword_rejects_blank_value(self) -> None:
with self.assertRaises(ValueError):
ShipmentPageQueryModel(keyword=' ')
class ShipmentSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
db = MagicMock(spec=AsyncSession)
self.assertEqual({}, await ShipmentSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_aggregates_names_packages_and_strains_per_shipment(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_rows([(12, '成品一仓', '南厂', '金禾菌业', '李娜'), (13, '成品二仓', None, None, None)]),
result_rows(
[
(12, 100, None, 'A15', 'A15'),
(12, 100, None, 'S130', None),
(12, 100, None, 'A15', 'A15'),
(13, None, 40, 'B22', 'B22'),
(13, None, None, None, None),
]
),
]
)
summaries = await ShipmentSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7))
self.assertEqual(
{
'source_warehouse_name': '成品一仓',
'target_site_name': '南厂',
'customer_name': '金禾菌业',
'creator_name': '李娜',
'pallet_count': 3,
'package_qty': 300,
'strains': [{'strain_name': 'A15', 'pallet_count': 2}, {'strain_name': 'S130', 'pallet_count': 1}],
},
summaries[12],
)
# 草稿中已不可发货的托盘计入托盘数,但不计入包数
self.assertEqual(2, summaries[13]['pallet_count'])
self.assertEqual(40, summaries[13]['package_qty'])
self.assertEqual([{'strain_name': 'B22', 'pallet_count': 1}], summaries[13]['strains'])
name_sql = compile_sql(db.execute.await_args_list[0].args[0])
item_sql = compile_sql(db.execute.await_args_list[1].args[0])
self.assertIn('biz_shipment_order.shipment_id IN (12, 13)', name_sql)
for fragment in (
'biz_shipment_order.test_batch_id = 7',
'source_warehouse.test_batch_id = 7',
'target_site.test_batch_id = 7',
'target_customer.test_batch_id = 7',
):
self.assertIn(fragment, name_sql)
for fragment in (
'biz_shipment_item.shipment_id IN (12, 13)',
'biz_shipment_item.test_batch_id = 7',
'biz_pallet_inventory.test_batch_id = 7',
'biz_filling_record.test_batch_id = 7',
"biz_shipment_order.status = 'DRAFT'",
"biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'",
):
self.assertIn(fragment, item_sql)
class ShipmentListServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
async def test_rows_carry_summary_fields_in_camel_case(self) -> None:
record = {
'shipment_id': 12,
'shipment_no': 'SH-1',
'source_warehouse_id': 7,
'target_site_id': 9,
'status': 'CONFIRMED',
'receipt_state': 'PENDING',
}
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
summary = {
12: {
'source_warehouse_name': '成品一仓',
'target_site_name': '南厂',
'customer_name': '金禾菌业',
'creator_name': '李娜',
'pallet_count': 2,
'package_qty': 200,
'strains': [{'strain_name': 'A15', 'pallet_count': 2}],
}
}
user = CurrentUserModel.model_construct(
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, admin=True)
)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
patch.object(OperationDao, 'get_shipment_list', new=AsyncMock(return_value=page)),
patch.object(ShipmentSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
patch.object(ShipmentService, '_scopes', new=AsyncMock(return_value=([7], [9]))),
):
result = await ShipmentService.get_shipment_list_services(
MagicMock(spec=AsyncSession), ShipmentPageQueryModel(), user, TestBatchScope.formal()
)
summaries.assert_awaited_once()
self.assertEqual([12], summaries.await_args.args[1])
row = result.rows[0]
self.assertIsInstance(row, ShipmentListItemModel)
dumped = row.model_dump(by_alias=True)
self.assertEqual('成品一仓', dumped['sourceWarehouseName'])
self.assertEqual(200, dumped['packageQty'])
self.assertEqual([{'strainName': 'A15', 'palletCount': 2}], dumped['strains'])
if __name__ == '__main__':
unittest.main()
@@ -31,6 +31,8 @@ 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.shipment_label_dao import ShipmentLabelDao
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.enums import PalletEventType, PalletFlowState
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 from module_business.domain.test_batch_scope import TestBatchScope
@@ -47,6 +49,7 @@ from module_business.entity.vo.operation_vo import (
ConfirmCommandModel, ConfirmCommandModel,
PalletScanResponseModel, PalletScanResponseModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
@@ -425,7 +428,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
) )
routes = self._routes() routes = self._routes()
self.assertEqual(PageResponseModel[ShipmentModel], routes[('/business/shipment/list', 'GET')].response_model) self.assertEqual(PageResponseModel[ShipmentListItemModel], routes[('/business/shipment/list', 'GET')].response_model)
for path in ('warehouse-options', 'site-options'): for path in ('warehouse-options', 'site-options'):
self.assertEqual( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
@@ -545,6 +548,8 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])), patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])),
patch.object(OperationDao, 'get_shipment_list', new=get_list), patch.object(OperationDao, 'get_shipment_list', new=get_list),
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail), patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
patch.object(ShipmentSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
patch.object(ShipmentLabelDao, 'get_label_sources', new=AsyncMock(return_value=[])),
): ):
listed = await ShipmentService.get_shipment_list_services( listed = await ShipmentService.get_shipment_list_services(
self.db, self.db,
@@ -12,6 +12,7 @@ from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
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 BizFillingRecord, BizPalletInventory from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
from module_business.service.access_service import AccessService
from module_business.service.usage_fifo_service import UsageFifoService from module_business.service.usage_fifo_service import UsageFifoService
from module_business.service.usage_service import UsageService from module_business.service.usage_service import UsageService
from module_business.dao.master_dao import MasterDao from module_business.dao.master_dao import MasterDao
@@ -188,3 +189,53 @@ class TestUsageFifo(unittest.IsolatedAsyncioTestCase):
self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13) self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13)
self.assertEqual(result.recommended.pallet_id, 1) self.assertEqual(result.recommended.pallet_id, 1)
self.assertTrue(result.requires_skip_reason) self.assertTrue(result.requires_skip_reason)
async def test_queue_groups_by_strain_in_recommendation_order(self) -> None:
# 同菌种按实际收货时间排序;已入隧和不可用的托盘不进入待使用队列
self.add(1, datetime(2026, 10, 8))
self.add(2, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL')
self.add(3, datetime(2026, 10, 1), tunnel=13)
self.add(4, None)
self.add(5, datetime(2026, 10, 2), strain=8)
self.add(6, datetime(2026, 10, 1), state='RETURNING')
self.add(7, datetime(2026, 10, 1), site=10)
groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal())
self.assertEqual([4, 8], [group['strain_id'] for group in groups])
main = groups[0]
self.assertEqual([2, 1], [pallet['pallet_id'] for pallet in main['pallets']])
self.assertEqual((2, 1), (main['pallet_count'], main['unknown_arrival_count']))
self.assertEqual(80, main['pallets'][0]['remaining_packages'])
self.assertEqual([5], [pallet['pallet_id'] for pallet in groups[1]['pallets']])
candidate, unknown = await self.query()
self.assertEqual((main['pallets'][0]['pallet_id'], main['unknown_arrival_count']), (candidate['pallet_id'], unknown))
async def test_queue_limits_listed_pallets_but_counts_all_and_respects_test_scope(self) -> None:
for pallet_id in range(1, 6):
self.add(pallet_id, datetime(2026, 10, pallet_id))
self.add(9, datetime(2026, 9, 1), batch=7)
groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal(), limit_per_strain=2)
self.assertEqual(5, groups[0]['pallet_count'])
self.assertEqual([1, 2], [pallet['pallet_id'] for pallet in groups[0]['pallets']])
test_groups = await UsageFifoDao.queue(self.db, site_id=9, scope=make_test_scope(7))
self.assertEqual([9], [pallet['pallet_id'] for pallet in test_groups[0]['pallets']])
async def test_queue_service_checks_site_access_before_query(self) -> None:
with (
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))),
self.assertRaises(PermissionException),
):
await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal())
self.db.execute.assert_not_awaited()
self.add(1, datetime(2026, 10, 1))
with (
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(site_id=9, test_batch_id=None))),
):
result = await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal())
dumped = result.model_dump(by_alias=True)
self.assertEqual(9, dumped['siteId'])
self.assertEqual('P-1', dumped['strains'][0]['pallets'][0]['palletCode'])
@@ -30,6 +30,7 @@ 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_summary_dao import UsageSummaryDao
from module_business.domain.enums import PalletEventType, PalletFlowState from module_business.domain.enums import PalletEventType, PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel
@@ -45,8 +46,11 @@ from module_business.entity.vo.operation_vo import (
PalletScanResponseModel, PalletScanResponseModel,
TunnelEntrySuccessResponseModel, TunnelEntrySuccessResponseModel,
UsageCreateModel, UsageCreateModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
UsageSuccessResponseModel, UsageSuccessResponseModel,
) )
from module_business.service.access_service import AccessService from module_business.service.access_service import AccessService
@@ -381,6 +385,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
with ( with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])), patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])),
patch.object(OperationDao, 'get_usage_list', new=get_list), patch.object(OperationDao, 'get_usage_list', new=get_list),
patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
): ):
result = await UsageService.get_usage_list_services(self.db, query, self.user, TestBatchScope.formal()) result = await UsageService.get_usage_list_services(self.db, query, self.user, TestBatchScope.formal())
@@ -648,6 +653,8 @@ class TestUsageControllerContract(unittest.TestCase):
('/business/usage/list', 'get'), ('/business/usage/list', 'get'),
('/business/usage/line-options', 'get'), ('/business/usage/line-options', 'get'),
('/business/usage/tunnel-options', 'get'), ('/business/usage/tunnel-options', 'get'),
('/business/usage/site-options', 'get'),
('/business/usage/site-board', 'get'),
('/business/usage/enter-tunnel', 'post'), ('/business/usage/enter-tunnel', 'post'),
('/business/usage/scan/{qr_token}', 'get'), ('/business/usage/scan/{qr_token}', 'get'),
('/business/usage/{usage_id}', 'get'), ('/business/usage/{usage_id}', 'get'),
@@ -674,7 +681,15 @@ class TestUsageControllerContract(unittest.TestCase):
) )
routes = self._routes() routes = self._routes()
self.assertEqual(PageResponseModel[UsageModel], routes[('/business/usage/list', 'GET')].response_model) self.assertEqual(PageResponseModel[UsageListItemModel], routes[('/business/usage/list', 'GET')].response_model)
self.assertEqual(
DataResponseModel[list[UsageSiteOptionModel]],
routes[('/business/usage/site-options', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[UsageSiteBoardModel],
routes[('/business/usage/site-board', 'GET')].response_model,
)
self.assertEqual( self.assertEqual(
DataResponseModel[list[BusinessOptionModel]], DataResponseModel[list[BusinessOptionModel]],
routes[('/business/usage/line-options', 'GET')].response_model, routes[('/business/usage/line-options', 'GET')].response_model,
@@ -717,6 +732,8 @@ class TestUsageControllerContract(unittest.TestCase):
detail_index = paths.index('/business/usage/{usage_id}') detail_index = paths.index('/business/usage/{usage_id}')
self.assertLess(paths.index('/business/usage/line-options'), detail_index) self.assertLess(paths.index('/business/usage/line-options'), detail_index)
self.assertLess(paths.index('/business/usage/tunnel-options'), detail_index) self.assertLess(paths.index('/business/usage/tunnel-options'), detail_index)
self.assertLess(paths.index('/business/usage/site-options'), detail_index)
self.assertLess(paths.index('/business/usage/site-board'), detail_index)
self.assertLess(paths.index('/business/usage/enter-tunnel'), detail_index) self.assertLess(paths.index('/business/usage/enter-tunnel'), detail_index)
self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index) self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index)
@@ -725,6 +742,8 @@ class TestUsageControllerContract(unittest.TestCase):
('/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/tunnel-options', 'GET'): 'business:usage:query',
('/business/usage/site-options', 'GET'): 'business:usage:list',
('/business/usage/site-board', 'GET'): 'business:usage:query',
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add', ('/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',
@@ -0,0 +1,368 @@
import unittest
from datetime import datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import 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.master_dao import MasterDao
from module_business.dao.operation_dao import OperationDao
from module_business.dao.usage_summary_dao import UsageSummaryDao
from module_business.domain.test_batch_scope import TestBatchScope
from module_business.entity.vo.operation_vo import (
UsageListItemModel,
UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
)
from module_business.service.usage_service import UsageService
from utils.page_util import PageUtil
def compile_sql(statement: Any) -> str:
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
return ' '.join(str(compiled).split()).replace('%%', '%')
def make_test_scope(batch_id: int) -> TestBatchScope:
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
def result_rows(rows: list[Any]) -> MagicMock:
result = MagicMock()
result.all.return_value = rows
return result
def result_one(row: tuple[Any, ...]) -> MagicMock:
result = MagicMock()
result.one.return_value = row
return result
def result_scalar(value: Any) -> MagicMock:
result = MagicMock()
result.scalar_one_or_none.return_value = value
return result
def admin_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
)
def normal_user() -> CurrentUserModel:
return CurrentUserModel.model_construct(
permissions=['business:usage:list'],
roles=['common'],
user=SimpleNamespace(user_id=5, user_name='zq', admin=False),
)
class UsageListFilterTest(unittest.IsolatedAsyncioTestCase):
async def _capture(self, query: UsagePageQueryModel) -> str:
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await OperationDao.get_usage_list(
MagicMock(spec=AsyncSession), query, site_ids=[9], scope=make_test_scope(4)
)
return compile_sql(paginate.await_args.args[1])
async def test_keyword_matches_usage_no_or_pallet_code_and_skip_filter_requires_reason(self) -> None:
sql = await self._capture(UsagePageQueryModel(keyword='TP-104', fifoSkipped=True))
for fragment in (
'JOIN biz_pallet ON biz_pallet.pallet_id = biz_usage_record.pallet_id',
"biz_usage_record.usage_no LIKE '%TP-104%'",
"biz_pallet.pallet_code LIKE '%TP-104%'",
'biz_usage_record.fifo_skip_reason IS NOT NULL',
'biz_usage_record.site_id IN (9)',
'biz_usage_record.test_batch_id = 4',
):
self.assertIn(fragment, sql)
async def test_plain_list_does_not_join_or_filter_skips(self) -> None:
sql = await self._capture(UsagePageQueryModel(fifoSkipped=False))
self.assertNotIn('JOIN', sql)
self.assertNotIn('fifo_skip_reason IS NOT NULL', sql)
def test_keyword_rejects_blank_value(self) -> None:
with self.assertRaises(ValueError):
UsagePageQueryModel(keyword=' ')
class UsageListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
db = MagicMock(spec=AsyncSession)
self.assertEqual({}, await UsageSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_names_and_skipped_pallet_come_from_same_scope_and_snapshot(self) -> None:
db = MagicMock(spec=AsyncSession)
snapshot = {'palletId': 100, 'palletCode': 'TP-10495', 'siteArrivedAt': '2026-09-10T08:30:00'}
db.execute = AsyncMock(
return_value=result_rows(
[
(71, 'TP-10501', '南厂', 'JH-S-T03', '3 号隧道', '一号线', '赵强', snapshot),
(72, 'TP-10502', '南厂', None, None, None, None, None),
(73, 'TP-10503', '南厂', None, None, None, None, {'palletCode': ' '}),
]
)
)
summaries = await UsageSummaryDao.get_list_summaries(db, [73, 72, 71], make_test_scope(4))
self.assertEqual(
{
'pallet_code': 'TP-10501',
'site_name': '南厂',
'tunnel_code': 'JH-S-T03',
'tunnel_name': '3 号隧道',
'line_name': '一号线',
'operator_name': '赵强',
'skipped_pallet_code': 'TP-10495',
},
summaries[71],
)
self.assertIsNone(summaries[72]['skipped_pallet_code'])
self.assertIsNone(summaries[73]['skipped_pallet_code'])
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_usage_record.usage_id IN (71, 72, 73)',
'biz_usage_record.test_batch_id = 4',
'biz_pallet.test_batch_id = 4',
'biz_customer_site.test_batch_id = 4',
'biz_tunnel.test_batch_id = 4',
'biz_production_line.test_batch_id = 4',
"sys_user.del_flag = '0'",
):
self.assertIn(fragment, sql)
self.assertNotIn('trace_no', sql)
class UsageSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_user_sites_are_enabled_and_carry_counts(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(4, 'JH-S', '南厂', '金禾菌业', 4, None)]))
options = await UsageSummaryDao.get_site_options(db, [5, 4, 4], make_test_scope(4))
self.assertEqual(
[
{
'id': 4,
'code': 'JH-S',
'name': '南厂',
'customer_name': '金禾菌业',
'tunnel_count': 4,
'line_count': 0,
}
],
options,
)
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_customer_site.site_id IN (4, 5)',
"biz_customer_site.status = '0'",
'biz_customer_site.test_batch_id = 4',
"biz_tunnel.status = '0'",
'biz_tunnel.test_batch_id = 4',
"biz_production_line.status = '0'",
):
self.assertIn(fragment, sql)
async def test_without_sites_matches_nothing_and_admin_is_unfiltered(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
await UsageSummaryDao.get_site_options(db, [], TestBatchScope.formal())
self.assertIn('false', compile_sql(db.execute.await_args.args[0]).lower())
await UsageSummaryDao.get_site_options(db, None, TestBatchScope.formal())
self.assertNotIn('biz_customer_site.site_id IN', compile_sql(db.execute.await_args.args[0]))
class UsageSiteBoardDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_board_combines_stock_today_usage_tunnels_and_skips(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_scalar('金禾菌业'),
result_rows([('AT_SITE_UNUSED', 22, 2200), ('AT_SITE_PARTIAL', 4, 205), ('AT_SITE_EMPTY', 8, 0)]),
result_rows([(4, '一号线', 3, 120), (None, None, 1, 20), (5, '二号线', 2, 200)]),
result_one((1, 3)),
result_rows(
[
(2, 'JH-S-T01', '1 号隧道', True, datetime(2026, 9, 7, 7, 59)),
(3, 'JH-S-T02', '2 号隧道', False, datetime(2026, 9, 1)),
]
),
result_rows([(2, 1, 75)]),
result_rows([(2, datetime(2026, 10, 17, 10, 55), '一号线')]),
result_rows([(4, 'JH-S-L1', '一号线'), (5, 'JH-S-L2', '二号线')]),
]
)
site = SimpleNamespace(site_id=4, site_code='JH-S', site_name='南厂', customer_id=3)
board = await UsageSummaryDao.get_site_board(
db, site, make_test_scope(4), datetime(2026, 10, 18), datetime(2026, 10, 12)
)
self.assertEqual(
(26, 2405, 22, 4, 8),
(
board['pallet_count'],
board['package_sum'],
board['unused_pallet_count'],
board['partial_pallet_count'],
board['empty_pallet_count'],
),
)
self.assertEqual((6, 340), (board['today_usage_count'], board['today_used_packages']))
self.assertEqual([200, 120, 20], [row['used_packages'] for row in board['today_by_line']])
self.assertEqual((2, 1), (board['tunnel_count'], board['active_tunnel_count']))
self.assertEqual((1, 3), (board['today_fifo_skip_count'], board['week_fifo_skip_count']))
first, idle = board['tunnels']
self.assertEqual(
(1, 75, '一号线'), (first['pallet_count'], first['remaining_packages'], first['last_line_name'])
)
self.assertEqual(
(False, 0, None, None), (idle['in_use'], idle['pallet_count'], idle['started_at'], idle['last_used_at'])
)
self.assertNotIn('trace_no', board['tunnels'][0])
UsageSiteBoardModel.model_validate(board)
statements = [compile_sql(call.args[0]) for call in db.execute.await_args_list]
_, stock_sql, today_sql, skip_sql, tunnel_sql, load_sql, last_sql, line_sql = statements
self.assertIn('biz_pallet_inventory.site_id = 4', stock_sql)
self.assertIn('biz_pallet_inventory.warehouse_id IS NULL', stock_sql)
self.assertIn("biz_usage_record.occurred_at >= '2026-10-18 00:00:00'", today_sql)
self.assertIn("biz_usage_record.occurred_at >= '2026-10-12 00:00:00'", skip_sql)
self.assertIn('biz_usage_record.fifo_skip_reason IS NOT NULL', skip_sql)
# 只暴露隧道是否在用,不返回内部溯源号的值
self.assertIn('biz_tunnel.current_trace_no IS NOT NULL AS in_use', tunnel_sql)
self.assertIn("biz_tunnel.status = '0'", tunnel_sql)
self.assertIn('biz_pallet_inventory.remaining_packages > 0', load_sql)
self.assertIn('max(biz_usage_record.usage_id)', last_sql)
for sql in statements[1:]:
self.assertRegex(sql, r'test_batch_id = 4')
self.assertIn("biz_production_line.status = '0'", line_sql)
class UsageServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
async def test_list_rows_carry_names_in_camel_case(self) -> None:
record = {
'usage_id': 71,
'usage_no': 'US-1',
'pallet_id': 501,
'active_filling_id': 9,
'site_id': 4,
'tunnel_id': 2,
'used_packages': 20,
'remaining_packages_after': 80,
'idempotency_key': 'k',
'request_hash': 'h',
'operator_id': 2,
'occurred_at': datetime(2026, 10, 17, 10, 55),
'fifo_skip_reason': '建议托盘外包装破损',
}
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
summary = {
71: {
'pallet_code': 'TP-10501',
'site_name': '南厂',
'tunnel_code': 'JH-S-T01',
'tunnel_name': '1 号隧道',
'line_name': '一号线',
'operator_name': '赵强',
'skipped_pallet_code': 'TP-10495',
}
}
with (
patch.object(OperationDao, 'get_usage_list', new=AsyncMock(return_value=page)),
patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
):
result = await UsageService.get_usage_list_services(
MagicMock(spec=AsyncSession), UsagePageQueryModel(), admin_user(), TestBatchScope.formal()
)
self.assertEqual([71], summaries.await_args.args[1])
self.assertIsInstance(result.rows[0], UsageListItemModel)
dumped = result.rows[0].model_dump(by_alias=True)
self.assertEqual(
('TP-10501', '1 号隧道', '一号线', 'TP-10495'),
(dumped['palletCode'], dumped['tunnelName'], dumped['lineName'], dumped['skippedPalletCode']),
)
async def test_site_options_use_site_scope(self) -> None:
rows = [
{'id': 4, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业', 'tunnel_count': 4, 'line_count': 3}
]
with (
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[5, 4, 4])),
patch.object(UsageSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options,
):
result = await UsageService.get_site_options_services(
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
)
self.assertEqual([4, 5], get_options.await_args.args[1])
self.assertIsInstance(result[0], UsageSiteOptionModel)
self.assertEqual(4, result[0].model_dump(by_alias=True)['tunnelCount'])
async def test_board_requires_site_access_before_reading(self) -> None:
get_board = AsyncMock()
with (
patch.object(AccessDao, 'user_has_site', new=AsyncMock(return_value=False)),
patch.object(UsageSummaryDao, 'get_site_board', new=get_board),
self.assertRaises(PermissionException),
):
await UsageService.get_site_board_services(
MagicMock(spec=AsyncSession), 7, normal_user(), TestBatchScope.formal()
)
get_board.assert_not_awaited()
async def test_board_rejects_missing_site_and_passes_today_and_week_bounds(self) -> None:
with (
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as raised,
):
await UsageService.get_site_board_services(
MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal()
)
self.assertEqual('客户现场不存在', raised.exception.message)
site = SimpleNamespace(site_id=4, test_batch_id=None)
board = {
'site_id': 4,
'pallet_count': 0,
'package_sum': 0,
'unused_pallet_count': 0,
'partial_pallet_count': 0,
'empty_pallet_count': 0,
'today_usage_count': 0,
'today_used_packages': 0,
'tunnel_count': 0,
'active_tunnel_count': 0,
'today_fifo_skip_count': 0,
'week_fifo_skip_count': 0,
}
with (
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=site)),
patch.object(UsageSummaryDao, 'get_site_board', new=AsyncMock(return_value=board)) as get_board,
):
result = await UsageService.get_site_board_services(
MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal()
)
self.assertIsInstance(result, UsageSiteBoardModel)
today_start, week_start = get_board.await_args.args[3:5]
self.assertEqual((0, 0), (today_start.hour, today_start.minute))
self.assertEqual(6, (today_start - week_start).days)
if __name__ == '__main__':
unittest.main()
@@ -30,31 +30,52 @@ from module_business.entity.vo.master_vo import (
from module_business.entity.vo.operation_vo import ( from module_business.entity.vo.operation_vo import (
BusinessOptionModel, BusinessOptionModel,
ConfirmCommandModel, ConfirmCommandModel,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingBatchRecordModel,
FillingBatchStateCountModel,
FillingCreateModel, FillingCreateModel,
FillingListItemModel,
FillingModel, FillingModel,
FillingOverviewModel,
FillingPageQueryModel, FillingPageQueryModel,
InboundCreateModel, InboundCreateModel,
InboundItemModel, InboundItemModel,
InboundListItemModel,
InboundModel, InboundModel,
InboundOverviewModel,
InboundPageQueryModel, InboundPageQueryModel,
InboundScanQueryModel, InboundScanQueryModel,
InboundUpdateModel, InboundUpdateModel,
InboundWarehouseCountModel,
PalletScanResponseModel, PalletScanResponseModel,
ReturnConfirmModel, ReturnConfirmModel,
ReturnItemModel, ReturnItemModel,
ReturnListItemModel,
ReturnModel, ReturnModel,
ReturnOverviewModel,
ReturnPageQueryModel, ReturnPageQueryModel,
ReturnRequestModel, ReturnRequestModel,
ReturnScanQueryModel, ReturnScanQueryModel,
ReturnSiteOptionModel,
ShipmentCreateModel, ShipmentCreateModel,
ShipmentItemModel, ShipmentItemModel,
ShipmentListItemModel,
ShipmentModel, ShipmentModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
ShipmentScanQueryModel, ShipmentScanQueryModel,
ShipmentStrainSummaryModel,
ShipmentUpdateModel, ShipmentUpdateModel,
UsageBoardTunnelModel,
UsageCreateModel, UsageCreateModel,
UsageLinePackagesModel,
UsageListItemModel,
UsageModel, UsageModel,
UsagePageQueryModel, UsagePageQueryModel,
UsageSiteBoardModel,
UsageSiteOptionModel,
) )
from module_business.entity.vo.report_vo import ( from module_business.entity.vo.report_vo import (
ConsumptionReportModel, ConsumptionReportModel,
@@ -170,7 +191,7 @@ MASTER_MODEL_FIELDS = {
}, },
PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'}, PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'},
PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', 'remark'}, PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', 'remark'},
PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status', 'ids'}, PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status', 'flow_group', 'ids'},
DeletePalletModel: {'pallet_ids'}, DeletePalletModel: {'pallet_ids'},
UserAssignModel: {'user_ids'}, UserAssignModel: {'user_ids'},
} }
@@ -195,7 +216,15 @@ OPERATION_MODEL_FIELDS = {
UsageCreateModel: {'qr_token', 'tunnel_id', 'line_id', 'used_packages', 'idempotency_key', 'fifo_skip_reason'}, UsageCreateModel: {'qr_token', 'tunnel_id', 'line_id', 'used_packages', 'idempotency_key', 'fifo_skip_reason'},
ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'}, ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'},
ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'}, ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'},
InboundItemModel: {'inbound_item_id', 'inbound_id', 'pallet_id', 'create_by', 'create_time'}, InboundItemModel: {
'inbound_item_id',
'inbound_id',
'pallet_id',
'pallet_code',
'flow_state',
'create_by',
'create_time',
},
InboundModel: { InboundModel: {
'inbound_id', 'inbound_id',
'inbound_no', 'inbound_no',
@@ -212,6 +241,38 @@ OPERATION_MODEL_FIELDS = {
'remark', 'remark',
'items', 'items',
}, },
InboundListItemModel: {
'inbound_id',
'inbound_no',
'warehouse_id',
'status',
'confirm_idempotency_key',
'confirm_request_hash',
'confirmed_by',
'confirmed_at',
'create_by',
'create_time',
'update_by',
'update_time',
'remark',
'items',
'warehouse_name',
'creator_name',
'confirmer_name',
'pallet_count',
},
InboundWarehouseCountModel: {'warehouse_id', 'warehouse_name', 'pallet_count'},
InboundOverviewModel: {
'draft_count',
'draft_pallet_count',
'oldest_draft_at',
'month_start',
'month_confirmed_count',
'month_confirmed_pallet_count',
'unstocked_pallet_count',
'empty_pallet_count',
'empty_by_warehouse',
},
FillingModel: { FillingModel: {
'filling_id', 'filling_id',
'filling_no', 'filling_no',
@@ -237,6 +298,88 @@ OPERATION_MODEL_FIELDS = {
'update_time', 'update_time',
'remark', 'remark',
}, },
FillingListItemModel: {
'filling_id',
'filling_no',
'pallet_id',
'warehouse_id',
'strain_id',
'strain_code_snapshot',
'strain_name_snapshot',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'initial_packages',
'active_slot',
'closed_at',
'idempotency_key',
'request_hash',
'operator_id',
'operated_at',
'create_by',
'create_time',
'update_by',
'update_time',
'remark',
'pallet_code',
'warehouse_name',
'operator_name',
},
FillingBatchModel: {
'strain_id',
'strain_code',
'strain_name',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'pallet_count',
'package_sum',
'last_operated_at',
'last_operator_name',
},
FillingBatchStateCountModel: {'state', 'pallet_count'},
FillingBatchRecordModel: {
'filling_id',
'filling_no',
'pallet_id',
'pallet_code',
'warehouse_name',
'operator_name',
'operated_at',
},
FillingBatchDetailModel: {
'strain_id',
'strain_code',
'strain_name',
'batch_no',
'production_date',
'expiry_date',
'package_spec',
'pallet_count',
'package_sum',
'last_operated_at',
'last_operator_name',
'distribution',
'recent',
},
FillingOverviewModel: {
'today_pallet_count',
'today_package_sum',
'today_batch_count',
'month_start',
'month_pallet_count',
'month_batch_count',
'month_top_strains',
'empty_pallet_count',
'empty_by_warehouse',
'near_expiry_days',
'near_expiry_pallet_count',
'near_expiry_batch_count',
'nearest_expiry_batch_no',
'nearest_expiry_date',
},
ShipmentItemModel: { ShipmentItemModel: {
'shipment_item_id', 'shipment_item_id',
'shipment_id', 'shipment_id',
@@ -244,6 +387,10 @@ OPERATION_MODEL_FIELDS = {
'active_filling_id', 'active_filling_id',
'package_qty', 'package_qty',
'remaining_packages', 'remaining_packages',
'pallet_code',
'strain_code',
'strain_name',
'batch_no',
'create_by', 'create_by',
'create_time', 'create_time',
}, },
@@ -267,6 +414,37 @@ OPERATION_MODEL_FIELDS = {
'remark', 'remark',
'items', 'items',
}, },
ShipmentStrainSummaryModel: {
'strain_name',
'pallet_count',
},
ShipmentListItemModel: {
'shipment_id',
'shipment_no',
'source_warehouse_id',
'target_site_id',
'status',
'confirm_idempotency_key',
'confirm_request_hash',
'confirmed_by',
'confirmed_at',
'receipt_state',
'received_by',
'received_at',
'create_by',
'create_time',
'update_by',
'update_time',
'remark',
'items',
'source_warehouse_name',
'target_site_name',
'customer_name',
'creator_name',
'pallet_count',
'package_qty',
'strains',
},
UsageModel: { UsageModel: {
'usage_id', 'usage_id',
'fifo_skip_reason', 'fifo_skip_reason',
@@ -285,6 +463,63 @@ OPERATION_MODEL_FIELDS = {
'operator_id', 'operator_id',
'occurred_at', 'occurred_at',
}, },
UsageListItemModel: {
'usage_id',
'fifo_skip_reason',
'usage_no',
'pallet_id',
'active_filling_id',
'site_id',
'tunnel_id',
'trace_no',
'batch_no_snapshot',
'line_id',
'used_packages',
'remaining_packages_after',
'idempotency_key',
'request_hash',
'operator_id',
'occurred_at',
'pallet_code',
'site_name',
'tunnel_code',
'tunnel_name',
'line_name',
'operator_name',
'skipped_pallet_code',
},
UsageBoardTunnelModel: {
'tunnel_id',
'tunnel_code',
'tunnel_name',
'in_use',
'pallet_count',
'remaining_packages',
'started_at',
'last_used_at',
'last_line_name',
},
UsageLinePackagesModel: {'line_id', 'line_name', 'used_packages'},
UsageSiteBoardModel: {
'site_id',
'site_code',
'site_name',
'customer_name',
'pallet_count',
'package_sum',
'unused_pallet_count',
'partial_pallet_count',
'empty_pallet_count',
'today_usage_count',
'today_used_packages',
'today_by_line',
'tunnel_count',
'active_tunnel_count',
'today_fifo_skip_count',
'week_fifo_skip_count',
'tunnels',
'lines',
},
ReturnItemModel: { ReturnItemModel: {
'return_item_id', 'return_item_id',
'return_id', 'return_id',
@@ -294,6 +529,8 @@ OPERATION_MODEL_FIELDS = {
'trace_no_snapshot', 'trace_no_snapshot',
'batch_no_snapshot', 'batch_no_snapshot',
'remaining_packages_snapshot', 'remaining_packages_snapshot',
'pallet_code',
'tunnel_name',
'create_by', 'create_by',
'create_time', 'create_time',
}, },
@@ -318,6 +555,48 @@ OPERATION_MODEL_FIELDS = {
'remark', 'remark',
'items', 'items',
}, },
ReturnListItemModel: {
'return_id',
'return_no',
'source_site_id',
'target_warehouse_id',
'status',
'request_idempotency_key',
'request_hash',
'confirm_idempotency_key',
'confirm_request_hash',
'requested_by',
'requested_at',
'confirmed_by',
'confirmed_at',
'create_by',
'create_time',
'update_by',
'update_time',
'remark',
'items',
'source_site_name',
'customer_name',
'target_warehouse_name',
'requester_name',
'confirmer_name',
'pallet_count',
'package_sum',
'filled_count',
},
ReturnOverviewModel: {
'pending_count',
'pending_pallet_count',
'pending_filled_count',
'pending_package_sum',
'oldest_pending_at',
'month_start',
'month_confirmed_count',
'month_confirmed_pallet_count',
'month_filled_pallet_count',
'month_package_sum',
'month_avg_confirm_hours',
},
InboundPageQueryModel: { InboundPageQueryModel: {
'page_num', 'page_num',
'page_size', 'page_size',
@@ -331,6 +610,7 @@ OPERATION_MODEL_FIELDS = {
'page_num', 'page_num',
'page_size', 'page_size',
'filling_no', 'filling_no',
'keyword',
'warehouse_id', 'warehouse_id',
'strain_id', 'strain_id',
'batch_no', 'batch_no',
@@ -338,13 +618,24 @@ OPERATION_MODEL_FIELDS = {
'begin_time', 'begin_time',
'end_time', 'end_time',
}, },
FillingBatchPageQueryModel: {
'page_num',
'page_size',
'keyword',
'strain_id',
'begin_time',
'end_time',
},
FillingBatchDetailQueryModel: {'strain_id', 'batch_no'},
ShipmentPageQueryModel: { ShipmentPageQueryModel: {
'page_num', 'page_num',
'page_size', 'page_size',
'shipment_no', 'shipment_no',
'keyword',
'source_warehouse_id', 'source_warehouse_id',
'target_site_id', 'target_site_id',
'status', 'status',
'receipt_state',
'begin_time', 'begin_time',
'end_time', 'end_time',
}, },
@@ -352,6 +643,8 @@ OPERATION_MODEL_FIELDS = {
'page_num', 'page_num',
'page_size', 'page_size',
'usage_no', 'usage_no',
'keyword',
'fifo_skipped',
'site_id', 'site_id',
'tunnel_id', 'tunnel_id',
'trace_no', 'trace_no',
@@ -364,6 +657,7 @@ OPERATION_MODEL_FIELDS = {
'page_num', 'page_num',
'page_size', 'page_size',
'return_no', 'return_no',
'keyword',
'source_site_id', 'source_site_id',
'target_warehouse_id', 'target_warehouse_id',
'status', 'status',
@@ -374,6 +668,8 @@ OPERATION_MODEL_FIELDS = {
ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'}, ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'},
ReturnScanQueryModel: {'mode', 'return_id'}, ReturnScanQueryModel: {'mode', 'return_id'},
BusinessOptionModel: {'id', 'code', 'name'}, BusinessOptionModel: {'id', 'code', 'name'},
ReturnSiteOptionModel: {'id', 'code', 'name', 'customer_name'},
UsageSiteOptionModel: {'id', 'code', 'name', 'customer_name', 'tunnel_count', 'line_count'},
PalletScanResponseModel: { PalletScanResponseModel: {
'pallet_id', 'pallet_id',
'pallet_code', 'pallet_code',
@@ -726,6 +1022,7 @@ WRITE_MODELS = (
OPERATION_PAGE_QUERY_MODELS = ( OPERATION_PAGE_QUERY_MODELS = (
InboundPageQueryModel, InboundPageQueryModel,
FillingPageQueryModel, FillingPageQueryModel,
FillingBatchPageQueryModel,
ShipmentPageQueryModel, ShipmentPageQueryModel,
UsagePageQueryModel, UsagePageQueryModel,
ReturnPageQueryModel, ReturnPageQueryModel,
+1 -1
View File
@@ -11,7 +11,7 @@
"build:docker": "vite build --mode docker", "build:docker": "vite build --mode docker",
"build:stage": "vite build --mode staging", "build:stage": "vite build --mode staging",
"preview": "vite preview", "preview": "vite preview",
"test:business": "node --test scripts/verify-business-contract.mjs scripts/verify-report-*.mjs scripts/*-behavior.test.mjs scripts/filling-dates.test.mjs scripts/pallet-identity.test.mjs" "test:business": "node --test scripts/verify-business-contract.mjs scripts/verify-report-*.mjs scripts/*-behavior.test.mjs scripts/filling-dates.test.mjs scripts/pallet-identity.test.mjs scripts/pallet-ranges.test.mjs"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -0,0 +1,350 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { webcrypto } from 'node:crypto'
import { test } from 'node:test'
import * as Vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
// 执行真实 setup、模板和 API,仅替换网络与组件宿主边界。
const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8')
const { descriptor } = parse(await read('views/business/filling/index.vue'))
const script = compileScript(descriptor, { id: 'pc-filling-test', genDefaultAs: '__component' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'filling/index.vue', id: 'pc-filling-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const apiSource = await read('api/business/filling.js')
const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`)
const scanUtils = await importText('utils/palletScan.js')
const passport = await importText('utils/palletPassport.js')
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
function loadModule(source, modules) {
const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1])
return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules)
}
const masterDisplay = loadModule(await read('utils/masterDisplay.js'), {
'@/router': { default: { getRoutes: () => [{ path: '/businessOperation/shipment' }] } },
'@/utils/palletPassport': passport
})
const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
const isoDate = offset => {
const now = new Date()
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset)
return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
}
const STRAINS = [{ id: 3, code: 'A15', name: '平菇 A15' }]
const SUMMARY = {
todayPalletCount: 6, todayPackageSum: 600, todayBatchCount: 1, monthStart: '2026-10-01', monthPalletCount: 99, monthBatchCount: 8,
monthTopStrains: [{ strainName: '平菇 A15', palletCount: 31 }, { strainName: '白灵菇 B22', palletCount: 27 }],
emptyPalletCount: 13, emptyByWarehouse: [{ warehouseId: 4, warehouseName: '成品一仓', palletCount: 13 }],
nearExpiryDays: 14, nearExpiryPalletCount: 4, nearExpiryBatchCount: 1, nearestExpiryBatchNo: 'B260923-01', nearestExpiryDate: isoDate(7)
}
const BATCH = {
strainId: 3, strainCode: 'A15', strainName: '平菇 A15', batchNo: 'B261017-01', productionDate: isoDate(-1), expiryDate: isoDate(44),
packageSpec: '1kg/包', palletCount: 6, packageSum: 600, lastOperatedAt: '2026-10-17T09:10:00', lastOperatorName: '年糕'
}
const NEAR_BATCH = { ...BATCH, batchNo: 'B260923-01', expiryDate: isoDate(7) }
const DETAIL = {
...BATCH,
distribution: [{ state: 'FILLED_IN_WAREHOUSE', palletCount: 4 }, { state: 'IN_TRANSIT', palletCount: 1 }, { state: 'CONSUMED', palletCount: 1 }],
recent: [{ fillingId: 90, fillingNo: 'FL-90', palletId: 501, palletCode: 'TP-10501', warehouseName: '成品一仓', operatorName: '年糕', operatedAt: '2026-10-17T09:10:00' }]
}
const EMPTY_PALLETS = {
'TP-10501': { palletId: 501, palletCode: 'TP-10501', qrToken: 'QR-501', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 },
'TP-10502': { palletId: 502, palletCode: 'TP-10502', qrToken: 'QR-502', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 }
}
function defaultScan(token) {
const pallet = EMPTY_PALLETS[token]
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('托盘不符合仓库空托盘装填条件'))
}
const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null })
function flatten(vnode) {
if (Array.isArray(vnode)) return vnode.flatMap(flatten)
if (!vnode || typeof vnode !== 'object') return []
const children = vnode.children
const slots = children && !Array.isArray(children) && typeof children === 'object'
? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : []
return [vnode, ...flatten(children), ...slots]
}
function createPage({ request: override, permissions = () => true, batches = [BATCH] } = {}) {
const requests = []
const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false })
const request = config => {
requests.push(structuredClone(config))
const custom = override?.(config)
if (custom !== undefined) return custom
if (config.url === '/business/filling/summary') return Promise.resolve({ data: SUMMARY })
if (config.url === '/business/filling/batches') return Promise.resolve({ rows: structuredClone(batches), total: batches.length })
if (config.url === '/business/filling/batches/detail') return Promise.resolve({ data: structuredClone({ ...DETAIL, ...batches.find(item => item.batchNo === config.params.batchNo) }) })
if (config.url === '/business/filling/list') return Promise.resolve({ rows: [], total: 0 })
if (config.url === '/business/filling/strain-options') return Promise.resolve({ data: STRAINS })
if (config.url.startsWith('/business/filling/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]))
if (config.url === '/business/filling' && config.method === 'post') return Promise.resolve({ msg: '装填成功' })
throw new Error(`未处理请求 ${config.url}`)
}
const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } })
const proxy = {
$auth: { hasPermiAnd: perms => permissions(perms) },
$modal: { msgSuccess() {}, msgWarning() {} },
$refs: { fillingRef: { validate: callback => callback(true), clearValidate() {} } },
resetForm() {}
}
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
const stub = name => ({ default: { name } })
const modules = {
vue,
'@/components/PalletScanInput': stub('PalletScanInput'),
'@/components/ScanFeedback': stub('ScanFeedback'),
'@/components/SfIcon': stub('SfIcon'),
'@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'),
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
'@/utils/masterDisplay': masterDisplay,
'@/utils/palletPassport': passport,
'@/store/modules/user': { default: () => user },
'@/store/modules/testBatch': { default: () => batch },
'@/api/business/filling': api
}
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance']
const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...globals.map(name => vue[name]))
const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} }))
const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules)
return {
state, requests,
posts: () => requests.filter(item => item.method === 'post'),
async scan(value) { state.handleFillingScan(value); await flush() },
nodes() {
let nodes
const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
app.mount({}); app.unmount()
return nodes
},
close() { scope.stop() }
}
}
const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('')
const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text))
const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput')
async function openStation(page, batch) {
await flush()
page.state.handleOpenFilling(batch)
await flush()
}
async function lockParams(page, overrides = {}) {
Object.assign(page.state.form.value, {
strainId: 3, batchNo: ' B261018-01 ', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: ' 1kg/包 ', ...overrides
})
await flush()
page.state.lockFillingParams()
await flush()
}
test('装填工位:未锁定参数不能扫码,锁定前校验必填与日期顺序', async () => {
const page = createPage()
await openStation(page)
assert.equal(page.state.open.value, true)
assert.equal(scanInput(page).props.disabled, true)
await page.scan('TP-10501')
assert.equal(page.requests.some(item => item.url.includes('/scan/')), false)
assert.equal(page.state.scanNotice.value.type, 'warn')
page.state.lockFillingParams()
assert.equal(page.state.paramError.value, '请选择菌种')
await lockParams(page, { expiryDate: isoDate(-1) })
assert.equal(page.state.paramError.value, '有效期不能早于生产日期')
assert.equal(page.state.paramsLocked.value, false)
await lockParams(page)
assert.equal(page.state.paramsLocked.value, true)
assert.deepEqual(
{ ...page.state.lockedParams.value },
{ strainId: 3, strainLabel: '平菇 A15', batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: '1kg/包', initialPackages: 100 }
)
assert.equal(scanInput(page).props.disabled, false)
page.close()
})
test('扫码后确认:载荷由扫码结果与锁定参数组成,成功后进入本班记录且参数保持锁定', async () => {
const page = createPage()
await openStation(page)
await lockParams(page)
await page.scan('TP-10501')
assert.equal(page.posts().length, 0, '默认模式扫码后等待确认')
assert.equal(page.state.scannedPallet.value.palletCode, 'TP-10501')
assert.equal(page.state.form.value.qrToken, 'QR-501')
assert.equal(page.state.form.value.warehouseId, 4)
assert.equal(button(page, '确认装填').props.disabled, false)
page.state.submitFilling()
await flush()
const [post] = page.posts()
assert.deepEqual(post.data, {
qrToken: 'QR-501', warehouseId: 4, strainId: 3, batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45),
packageSpec: '1kg/包', initialPackages: 100, idempotencyKey: post.data.idempotencyKey
})
assert.ok(post.data.idempotencyKey)
assert.equal(page.state.shiftRecords.value.length, 1)
assert.deepEqual(
[page.state.shiftRecords.value[0].palletCode, page.state.shiftRecords.value[0].warehouseName, page.state.shiftRecords.value[0].batchNo],
['TP-10501', '成品一仓', 'B261018-01']
)
assert.equal(page.state.scannedPallet.value, null)
assert.equal(page.state.paramsLocked.value, true)
assert.equal(page.state.scanNotice.value.type, 'ok')
await page.scan('TP-10502')
page.state.submitFilling()
await flush()
const keys = page.posts().map(item => item.data.idempotencyKey)
assert.equal(keys.length, 2)
assert.notEqual(keys[0], keys[1], '不同托盘使用不同幂等键')
page.close()
})
test('扫码即装填:校验通过立即提交,失败后重试复用同一幂等键', async () => {
let attempts = 0
const page = createPage({
request: config => {
if (config.method !== 'post') return undefined
attempts += 1
return attempts === 1 ? Promise.reject(new Error('历史非 100 包托盘不允许新增装填')) : Promise.resolve({ msg: '装填成功' })
}
})
await openStation(page)
await lockParams(page)
page.state.fillMode.value = 'auto'
await page.scan('TP-10501')
assert.equal(page.posts().length, 1)
assert.match(page.state.formError.value, /直接重试将复用原键/)
assert.equal(page.state.scanNotice.value.message, '历史非 100 包托盘不允许新增装填')
assert.ok(page.state.scannedPallet.value, '失败后保留待装填托盘以便重试')
assert.ok(button(page, '重试装填'))
page.state.submitFilling()
await flush()
const keys = page.posts().map(item => item.data.idempotencyKey)
assert.equal(keys[0], keys[1])
assert.equal(page.state.shiftRecords.value.length, 1)
page.close()
})
test('后端拒绝与非空托盘进入未装填记录,本班已装填的托盘提示无需重复', async () => {
const filled = new Set()
const page = createPage({
request: config => {
if (config.method === 'post') {
filled.add(config.data.qrToken)
return Promise.resolve({ msg: '装填成功' })
}
if (config.url.endsWith('/scan/TP-10288')) {
return Promise.resolve({ data: { palletId: 288, palletCode: 'TP-10288', flowState: 'FILLED_IN_WAREHOUSE', warehouseId: 4 } })
}
// 已装填的托盘不再是空托盘,服务端扫码会拒绝
const token = decodeURIComponent(config.url.split('/scan/')[1] || '')
if (filled.has(EMPTY_PALLETS[token]?.qrToken)) return Promise.reject(new Error('托盘不符合仓库空托盘装填条件'))
return undefined
}
})
await openStation(page)
await lockParams(page)
await page.scan('TP-10999')
assert.deepEqual([page.state.scanRejects.value[0].code, page.state.scanRejects.value[0].message], ['TP-10999', '托盘不符合仓库空托盘装填条件'])
await page.scan('TP-10288')
assert.equal(page.state.scanRejects.value[0].message, '该托盘为「仓库有料」,只有仓库空托盘可以装填')
assert.equal(page.state.scannedPallet.value, null)
await page.scan('TP-10501')
page.state.submitFilling()
await flush()
await page.scan('TP-10501')
assert.equal(page.state.scanNotice.value.type, 'info')
assert.match(page.state.scanNotice.value.message, /^本班 \d{2}:\d{2} 已装填,无需重复$/)
assert.equal(page.state.scanRejects.value.length, 2, '重复扫本班托盘不计入未装填记录')
page.close()
})
test('修改参数会放弃待装填托盘,沿用批次只带入参数仍需锁定', async () => {
const page = createPage()
await openStation(page, BATCH)
assert.equal(page.state.paramsLocked.value, false)
assert.deepEqual(
[page.state.form.value.strainId, page.state.form.value.batchNo, page.state.form.value.packageSpec],
[3, 'B261017-01', '1kg/包']
)
page.state.lockFillingParams()
await flush()
await page.scan('TP-10501')
assert.ok(page.state.scannedPallet.value)
page.state.unlockFillingParams()
await flush()
assert.equal(page.state.paramsLocked.value, false)
assert.equal(page.state.scannedPallet.value, null)
assert.equal(page.state.form.value.batchNo, 'B261017-01', '解锁后参数回到表单供修改')
assert.equal(page.state.form.value.qrToken, '')
page.state.applyShelfLife(30)
assert.equal(page.state.form.value.expiryDate, isoDate(29))
page.close()
})
test('结束本班后显示装填结果并刷新列表与概况,再次打开工位从空白开始', async () => {
const page = createPage()
await openStation(page, BATCH)
page.state.lockFillingParams()
await flush()
await page.scan('TP-10502')
page.state.submitFilling()
await flush()
const before = page.requests.length
page.state.cancelFilling()
await flush()
assert.equal(page.state.open.value, false)
assert.match(page.state.shiftResultText.value, /^本班已装填 1 托 · 100 包(B261017-01)/)
const refreshed = page.requests.slice(before).map(item => item.url)
assert.ok(refreshed.includes('/business/filling/batches'))
assert.ok(refreshed.includes('/business/filling/summary'))
await openStation(page)
assert.equal(page.state.shiftRecords.value.length, 0)
assert.equal(page.state.form.value.batchNo, '')
assert.equal(page.state.lockedParams.value, null)
page.close()
})
test('列表:默认按生产批次,切到装填明细使用同一筛选;临期批次不能沿用', async () => {
const page = createPage({ batches: [BATCH, NEAR_BATCH] })
await flush()
const urls = page.requests.map(item => item.url)
assert.deepEqual(urls.slice(0, 2), ['/business/filling/summary', '/business/filling/batches'])
assert.equal(urls.includes('/business/filling/strain-options'), false, '启动阶段不请求 query 权限接口')
assert.equal(page.state.todayHint.value, '600 包 · 1 个批次')
assert.equal(page.state.monthHint.value, '8 个批次 · 平菇 A15 / 白灵菇 B22')
assert.equal(page.state.nearExpiryHint.value, 'B260923-01 · 7 天后到期,请优先发货')
assert.deepEqual(page.state.specPresets.value, ['1kg/包'])
page.state.openBatchPanel(page.state.batchList.value[0])
await flush()
assert.deepEqual(page.state.distributionRows.value.map(item => [item.label, item.palletCount]), [['仓库有料', 4], ['在途待收货', 1], ['已用完', 1]])
assert.equal(page.state.reuseBlocked.value, false)
assert.equal(button(page, '沿用此批参数').props.disabled, false)
page.state.openBatchPanel(page.state.batchList.value[1])
await flush()
assert.equal(page.state.reuseBlocked.value, true)
assert.equal(button(page, '沿用此批参数').props.disabled, true)
page.state.queryParams.value.keyword = 'TP-105'
page.state.viewMode.value = 'record'
page.state.handleViewChange()
await flush()
const last = page.requests.filter(item => item.url === '/business/filling/list').at(-1)
assert.equal(last.params.keyword, 'TP-105')
assert.equal(last.params.pageNum, 1)
assert.equal(page.state.panelBatch.value, null)
page.close()
})
@@ -25,6 +25,9 @@ async function fillingPage(platform) {
useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }), useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }),
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`, onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
listFilling: async () => ({ rows: [], total: 0 }), listFilling: async () => ({ rows: [], total: 0 }),
listFillingBatches: async () => ({ rows: [], total: 0 }),
getFillingSummary: async () => ({ data: null }),
getFillingBatchDetail: async () => ({ data: null }),
addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') }, addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') },
}) })
vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context) vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context)
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { groupCodeRanges, parsePasteTokens, splitPalletCode } from '../src/utils/palletRanges.js'
test('托盘号拆分为前缀与末尾数字,无数字结尾时返回空', () => {
assert.deepEqual(splitPalletCode(' TP-10501 '), { prefix: 'TP-', digits: '10501', number: 10501 })
assert.deepEqual(splitPalletCode('PLT-007'), { prefix: 'PLT-', digits: '007', number: 7 })
assert.equal(splitPalletCode('TP-A'), null)
assert.equal(splitPalletCode(''), null)
})
test('连续编号合并成段,乱序与重复不影响结果', () => {
const ranges = groupCodeRanges(['TP-10503', 'TP-10501', 'TP-10502', 'TP-10502', 'TP-10520', 'TP-10521'])
assert.deepEqual(
ranges.map(({ label, count }) => [label, count]),
[['TP-10501 – 10503', 3], ['TP-10520 – 10521', 2]]
)
})
test('补零位数保持连续,不同前缀与无数字托盘号单独成段', () => {
const ranges = groupCodeRanges(['PLT-0999', 'PLT-1000', 'A-9', 'A-10', 'B-001', 'TP-X'])
assert.deepEqual(
ranges.map(({ label, count }) => [label, count]),
[['A-9 – 10', 2], ['B-001', 1], ['PLT-0999 – 1000', 2], ['TP-X', 1]]
)
// 位数不同且带补零的编号不是同一段
assert.equal(groupCodeRanges(['C-09', 'C-010']).length, 2)
})
test('粘贴内容按换行、空格与中英文标点切分并去重', () => {
const result = parsePasteTokens('TP-10501\nTP-10502,TP-10503、TP-10501;TP-10504 TP-10505')
assert.deepEqual(result.codes, ['TP-10501', 'TP-10502', 'TP-10503', 'TP-10504', 'TP-10505'])
assert.equal(result.duplicates, 1)
assert.deepEqual(result.errors, [])
})
test('区间按起始托盘号的前缀与位数展开', () => {
assert.deepEqual(parsePasteTokens('TP-10512-10514').codes, ['TP-10512', 'TP-10513', 'TP-10514'])
assert.deepEqual(parsePasteTokens('10512-10513').codes, ['10512', '10513'])
assert.deepEqual(parsePasteTokens('PLT-008 ~ PLT-010').codes, ['PLT-008', 'PLT-009', 'PLT-010'])
assert.deepEqual(parsePasteTokens('PLT-008–010').codes, ['PLT-008', 'PLT-009', 'PLT-010'])
})
test('普通带连字符的托盘号不会被误当作区间', () => {
assert.deepEqual(parsePasteTokens('TP-10501').codes, ['TP-10501'])
// 右侧不大于左侧时不构成区间
assert.deepEqual(parsePasteTokens('A1-0001').codes, ['A1-0001'])
})
test('超长区间与无法识别的波浪号区间给出错误且不展开', () => {
const result = parsePasteTokens('TP-1-300 TP-5~TP-3 TP-9', { rangeLimit: 200 })
assert.deepEqual(result.codes, ['TP-9'])
assert.equal(result.errors.length, 2)
assert.match(result.errors[0].message, /最多 200 个/)
assert.match(result.errors[1].message, /无法识别/)
})
test('总数超过上限时截断并标记', () => {
const result = parsePasteTokens('T-1-10', { totalLimit: 4 })
assert.deepEqual(result.codes, ['T-1', 'T-2', 'T-3', 'T-4'])
assert.equal(result.truncated, true)
})
@@ -0,0 +1,361 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { webcrypto } from 'node:crypto'
import { test } from 'node:test'
import * as Vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
// 执行真实 setup、模板和 API,仅替换网络与组件宿主边界。
const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8')
const { descriptor } = parse(await read('views/business/return/index.vue'))
const script = compileScript(descriptor, { id: 'pc-return-test', genDefaultAs: '__component' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'return/index.vue', id: 'pc-return-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const apiSource = await read('api/business/return.js')
const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`)
const scanUtils = await importText('utils/palletScan.js')
const passport = await importText('utils/palletPassport.js')
const docNo = await importText('utils/docNo.js')
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
function loadModule(source, modules) {
const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1])
return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules)
}
const masterDisplay = loadModule(await read('utils/masterDisplay.js'), {
'@/router': { default: { getRoutes: () => [{ path: '/businessOperation/filling' }] } },
'@/utils/palletPassport': passport
})
const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
const ROW = {
returnId: 13,
returnNo: 'RE-3f8a6d1c0b9e42f7a5d3c8e1b06fc21d',
status: 'REQUESTED',
sourceSiteId: 9,
targetWarehouseId: 7,
sourceSiteName: '南厂',
customerName: '金禾菌业',
targetWarehouseName: '成品一仓',
palletCount: 3,
packageSum: 60,
filledCount: 1,
requestedAt: '2026-10-17T10:20:00',
requesterName: '赵强'
}
const DETAIL = {
...ROW,
items: [
{ returnItemId: 1, palletId: 101, palletCode: 'TP-10466', remainingPackagesSnapshot: 60, tunnelIdSnapshot: 31, tunnelName: '2 号隧道' },
{ returnItemId: 2, palletId: 102, palletCode: 'TP-10468', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null },
{ returnItemId: 3, palletId: 103, palletCode: 'TP-10471', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null }
]
}
const SITES = [
{ id: 9, code: 'JH-S', name: '南厂', customerName: '金禾菌业' },
{ id: 10, code: 'LY-1', name: '一号基地', customerName: '绿源农业' }
]
const WAREHOUSES = [{ id: 7, code: 'WH-NB01', name: '成品一仓' }, { id: 8, code: 'WH-NB02', name: '成品二仓' }]
const SUMMARY = {
pendingCount: 1, pendingPalletCount: 3, pendingFilledCount: 1, pendingPackageSum: 60, oldestPendingAt: '2026-10-17T10:20:00',
monthStart: '2026-10-01', monthConfirmedCount: 2, monthConfirmedPalletCount: 26, monthFilledPalletCount: 4, monthPackageSum: 210, monthAvgConfirmHours: 20
}
const CONFIRM_PALLETS = Object.fromEntries(DETAIL.items.map(item => [item.palletCode, { palletId: item.palletId, palletCode: item.palletCode, flowState: 'RETURNING', siteId: 9, remainingPackages: item.remainingPackagesSnapshot }]))
const REQUEST_PALLETS = {
'TP-10475': { palletId: 201, palletCode: 'TP-10475', flowState: 'AT_SITE_PARTIAL', siteId: 9, remainingPackages: 30, tunnelId: 31, traceNo: 'TR-0042' },
'TP-10474': { palletId: 202, palletCode: 'TP-10474', flowState: 'AT_SITE_EMPTY', siteId: 9, remainingPackages: 0, tunnelId: null, traceNo: null },
'TP-10520': { palletId: 203, palletCode: 'TP-10520', flowState: 'AT_SITE_EMPTY', siteId: 10, remainingPackages: 0, tunnelId: null, traceNo: null }
}
function defaultScan(token, params) {
if (params.mode === 'confirm') {
const pallet = CONFIRM_PALLETS[token]
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('扫码托盘不属于该退回单'))
}
if (token === 'TP-10288') return Promise.reject(new Error('托盘当前状态不允许发起退回'))
const pallet = REQUEST_PALLETS[token]
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('无权访问该托盘'))
}
const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null })
function flatten(vnode) {
if (Array.isArray(vnode)) return vnode.flatMap(flatten)
if (!vnode || typeof vnode !== 'object') return []
const children = vnode.children
const slots = children && !Array.isArray(children) && typeof children === 'object'
? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : []
return [vnode, ...flatten(children), ...slots]
}
function createPage({ request: override, permissions = () => true } = {}) {
const requests = [], messages = []
const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false })
const request = config => {
requests.push(structuredClone(config))
const custom = override?.(config)
if (custom !== undefined) return custom
if (config.url === '/business/return/list') return Promise.resolve({ rows: [structuredClone(ROW)], total: 1 })
if (config.url === '/business/return/summary') return Promise.resolve({ data: SUMMARY })
if (config.url === '/business/return/site-options') return Promise.resolve({ data: SITES })
if (config.url === '/business/return/warehouse-options') return Promise.resolve({ data: WAREHOUSES })
if (config.url === '/business/return/13') return Promise.resolve({ data: structuredClone(DETAIL) })
if (config.url.startsWith('/business/return/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]), config.params)
if (config.url === '/business/return/13/confirm') return Promise.resolve({ msg: '退回确认成功' })
if (config.url === '/business/return/request') return Promise.resolve({ msg: '退回申请提交成功' })
throw new Error(`未处理请求 ${config.url}`)
}
const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } })
const proxy = {
$auth: { hasPermiAnd: perms => permissions(perms) },
$modal: { msgSuccess: msg => messages.push(msg), msgWarning: msg => messages.push(msg) },
resetForm() {}
}
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
const stub = name => ({ default: { name } })
const modules = {
vue,
'@/components/PalletScanInput': stub('PalletScanInput'),
'@/components/ScanFeedback': stub('ScanFeedback'),
'@/components/SfIcon': stub('SfIcon'),
'@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'),
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
'@/utils/docNo': docNo,
'@/utils/masterDisplay': masterDisplay,
'@/utils/palletPassport': passport,
'@/store/modules/user': { default: () => user },
'@/store/modules/testBatch': { default: () => batch },
'@/api/business/return': api
}
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
const globals = ['ref', 'reactive', 'computed', 'watch', 'getCurrentInstance']
const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...globals.map(name => vue[name]))
const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} }))
const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules)
return {
state, requests, messages,
listCalls: () => requests.filter(item => item.url === '/business/return/list' && item.params.pageSize !== 1),
async confirmScan(value) { state.handleConfirmScan(value); await flush() },
async requestScan(value) { state.handleRequestScan(value); await flush() },
nodes() {
let nodes
const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
app.mount({}); app.unmount()
return nodes
},
close() { scope.stop() }
}
}
const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('')
const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text))
const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput')
async function openConfirm(page) {
await flush()
page.state.startConfirmFromRow(page.state.returnList.value[0])
await flush()
}
async function scanAll(page) {
for (const code of ['TP-10466', 'TP-10468', 'TP-10471']) await page.confirmScan(code)
}
test('到仓核对:全部对上前禁止确认,非本单托盘不计入,提交前重新读取详情', async () => {
const page = createPage()
await openConfirm(page)
assert.equal(page.state.jobOpen.value, true)
assert.equal(page.state.jobKind.value, 'confirm')
assert.deepEqual(page.state.confirmExpectedPalletIds.value, [101, 102, 103])
assert.deepEqual(page.state.confirmOutcome.value, { filled: 1, packages: 60, empty: 2, inTunnel: 1 })
const confirmButton = () => button(page, '确认退回 ·')
assert.equal(confirmButton().props.disabled, true)
await page.confirmScan('TP-10466')
await page.confirmScan('TP-10468')
assert.equal(confirmButton().props.disabled, true)
assert.equal(page.state.confirmTodoCount.value, 1)
assert.equal(page.state.confirmFeedback.value.message, '核对通过 · 还差 1 托')
await page.confirmScan('TP-10999')
assert.equal(page.state.confirmScannedPallets.value.length, 2, '非本单托盘不能计入')
assert.deepEqual(page.state.confirmStrays.value.map(item => [item.code, item.message]), [['TP-10999', '扫码托盘不属于该退回单']])
await page.confirmScan('TP-10466')
assert.equal(page.state.confirmFeedback.value.type, 'info')
assert.equal(page.state.confirmScannedPallets.value.length, 2, '重复扫码不追加')
await page.confirmScan('TP-10471')
assert.equal(page.state.confirmComplete.value, true)
assert.equal(confirmButton().props.disabled, false)
const before = page.requests.length
page.state.submitReturnConfirm()
await flush()
const writes = page.requests.slice(before, before + 2)
assert.deepEqual(writes.map(item => `${item.method} ${item.url}`), ['get /business/return/13', 'put /business/return/13/confirm'])
assert.deepEqual(Object.keys(writes[1].data).sort(), ['idempotencyKey', 'scannedPalletIds'])
assert.deepEqual([...writes[1].data.scannedPalletIds].sort(), [101, 102, 103])
assert.equal(page.state.jobOpen.value, false)
assert.match(page.state.jobResultText.value, /RE-3F8A…C21D 已确认 · 1 托带料(60 包)已回到成品一仓有料库存,2 托空托盘回到成品一仓,可再次装填。/)
page.close()
})
test('到仓核对:确认失败后重试复用幂等键,撤销一托后重新锁定确认', async () => {
let attempts = 0
const page = createPage({
request: config => {
if (config.url !== '/business/return/13/confirm') return undefined
attempts += 1
return attempts === 1 ? Promise.reject(new Error('网络异常')) : Promise.resolve({ msg: '退回确认成功' })
}
})
await openConfirm(page)
await scanAll(page)
page.state.removeConfirmPallet(102)
await flush()
assert.equal(page.state.confirmComplete.value, false)
assert.equal(button(page, '确认退回 ·').props.disabled, true)
assert.equal(page.state.confirmFeedback.value.message, '已撤销核对,需要重新扫码')
await page.confirmScan('TP-10468')
page.state.submitReturnConfirm()
await flush()
assert.match(page.state.confirmError.value, /复用本次幂等键/)
assert.equal(page.state.jobOpen.value, true, '失败时保留作业区与已核对集合')
assert.equal(page.state.confirmScannedPallets.value.length, 3)
page.state.submitReturnConfirm()
await flush()
const keys = page.requests.filter(item => item.method === 'put').map(item => item.data.idempotencyKey)
assert.equal(keys.length, 2)
assert.equal(keys[0], keys[1])
assert.equal(page.state.jobOpen.value, false)
page.close()
})
test('到仓核对:详情已变化时不提交确认', async () => {
let reads = 0
const page = createPage({
request: config => {
if (config.url !== '/business/return/13') return undefined
reads += 1
const detail = structuredClone(DETAIL)
if (reads > 1) detail.items = detail.items.slice(0, 2)
return Promise.resolve({ data: detail })
}
})
await openConfirm(page)
await scanAll(page)
page.state.submitReturnConfirm()
await flush()
assert.equal(page.requests.filter(item => item.method === 'put').length, 0)
assert.match(page.state.confirmError.value, /明细已变化/)
page.close()
})
test('发起申请:先选仓库再扫码,首托锁定来源现场,跨现场与后端拒绝原因进入被拒记录', async () => {
const page = createPage()
await flush()
page.state.startRequestJob()
await flush()
assert.equal(page.state.jobKind.value, 'request')
assert.deepEqual(page.state.warehouseOptions.value, WAREHOUSES)
assert.equal(scanInput(page).props.disabled, true, '未选目标仓库时扫码框禁用')
await page.requestScan('TP-10475')
assert.equal(page.requests.filter(item => item.url.includes('/scan/')).length, 0)
assert.equal(page.state.requestFeedback.value.type, 'warn')
page.state.requestForm.value.targetWarehouseId = 7
await flush()
assert.equal(scanInput(page).props.disabled, false)
await page.requestScan('TP-10475')
assert.deepEqual(page.requests.filter(item => item.url.includes('/scan/')).at(-1).params, { mode: 'request' })
assert.equal(page.state.requestForm.value.sourceSiteId, 9)
assert.equal(page.state.requestSiteText.value, '金禾菌业 · 南厂')
assert.equal(page.state.requestFeedback.value.message, '已添加 · 现场部分使用 · 余 30 包')
await page.requestScan('TP-10520')
assert.equal(page.state.requestRejects.value[0].message, '同一退回申请只能包含同一客户现场的托盘:该托盘在绿源农业 · 一号基地')
await page.requestScan('TP-10288')
assert.deepEqual([page.state.requestRejects.value[0].code, page.state.requestRejects.value[0].message], ['TP-10288', '托盘当前状态不允许发起退回'])
assert.equal(page.state.requestRejectCount.value, 2)
await page.requestScan('TP-10474')
await page.requestScan('TP-10474')
assert.equal(page.state.requestFeedback.value.message, '已在本单中,未重复添加')
assert.deepEqual(page.state.requestScannedPallets.value.map(item => item.palletCode), ['TP-10474', 'TP-10475'])
assert.deepEqual(page.state.requestSummary.value, { count: 2, packages: 30, filled: 1, empty: 1 })
page.state.submitReturnRequest()
await flush()
const post = page.requests.find(item => item.method === 'post')
assert.deepEqual(Object.keys(post.data).sort(), ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId'])
assert.deepEqual([...post.data.palletIds].sort(), [201, 202])
assert.equal(post.data.sourceSiteId, 9)
assert.equal(post.data.targetWarehouseId, 7)
assert.equal(page.state.jobOpen.value, false)
assert.equal(page.state.jobResultText.value, '退回申请已提交 · 2 托已变为「退回中」,现场不能再使用。成品一仓收到实物后逐托核对确认。')
assert.equal(page.state.activeStatusTab.value, 'requested')
assert.equal(page.listCalls().at(-1).params.status, 'REQUESTED')
page.close()
})
test('移除最后一托后释放来源现场,可改扫其他现场', async () => {
const page = createPage()
await flush()
page.state.startRequestJob()
await flush()
page.state.requestForm.value.targetWarehouseId = 7
await flush()
await page.requestScan('TP-10475')
page.state.removeRequestPallet(201)
await flush()
assert.equal(page.state.requestForm.value.sourceSiteId, undefined)
await page.requestScan('TP-10520')
assert.equal(page.state.requestForm.value.sourceSiteId, 10)
page.close()
})
test('列表:关键字、页签与概况来自真实接口,名称与余量按行展示', async () => {
const page = createPage()
await flush()
assert.deepEqual(page.requests.slice(0, 3).map(item => item.url), ['/business/return/summary', '/business/return/site-options', '/business/return/list'])
assert.equal(page.requests.some(item => item.url === '/business/return/warehouse-options'), false, '启动阶段不请求 query 权限接口')
assert.equal(page.state.sourceLabel(ROW), '金禾菌业 · 南厂')
assert.equal(page.state.targetLabel(ROW), '成品一仓')
assert.equal(page.state.packagesText(ROW), '余 60 包')
assert.equal(page.state.packagesText({ packageSum: 0 }), '均已用完')
assert.equal(page.state.returningHint.value, '其中带料 1 托 · 共 60 包')
assert.equal(page.state.monthEmptyCount.value, 22)
assert.equal(page.state.monthHint.value, '平均申请到确认 20 小时')
assert.equal(page.state.statusCounts.all, 1)
page.state.queryParams.value.keyword = 'TP-104'
page.state.selectStatusTab('confirmed')
await flush()
const last = page.listCalls().at(-1)
assert.equal(last.params.keyword, 'TP-104')
assert.equal(last.params.status, 'CONFIRMED')
assert.equal(last.params.pageNum, 1)
page.close()
})
test('无确认权限时详情面板与行内都不提供到仓核对入口', async () => {
const page = createPage({ permissions: perms => !perms.includes('business:return:confirm') })
await flush()
page.state.openReturnPanel(page.state.returnList.value[0])
await flush()
assert.equal(page.state.panelItems.value.length, 3)
assert.equal(button(page, '开始到仓核对'), undefined)
page.state.startConfirmFromRow(page.state.returnList.value[0])
await flush()
assert.equal(page.state.jobOpen.value, false)
assert.equal(page.requests.some(item => item.url.includes('/scan/')), false)
page.close()
const allowed = createPage()
await flush()
allowed.state.openReturnPanel(allowed.state.returnList.value[0])
await flush()
assert.ok(button(allowed, '开始到仓核对'))
allowed.close()
})
@@ -13,8 +13,13 @@ const template = compileTemplate({ source: descriptor.template.content, filename
assert.deepEqual(template.errors, []) assert.deepEqual(template.errors, [])
const apiSource = await read('api/business/usage.js') const apiSource = await read('api/business/usage.js')
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`) const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`)
const passport = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletPassport.js'))}`)
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) => const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`) `const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
const masterDisplaySource = await read('utils/masterDisplay.js')
const masterDisplay = new Function('__modules', `${bindImports(masterDisplaySource).replace(/export (function|const) /g, '$1 ')}\nreturn { ${[...masterDisplaySource.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]).join(',')} };`)({ '@/router': { default: { getRoutes: () => [] } }, '@/utils/palletPassport': passport })
// 看板默认不含隧道和产线,避免看板名称掩盖托盘摘要中的隧道断言
const siteBoard = { siteId: 21, siteCode: 'XC-21', siteName: '一号现场', customerName: '客户A', palletCount: 0, packageSum: 0, unusedPalletCount: 0, partialPalletCount: 0, emptyPalletCount: 0, todayUsageCount: 0, todayUsedPackages: 0, todayByLine: [], tunnelCount: 0, activeTunnelCount: 0, todayFifoSkipCount: 0, weekFifoSkipCount: 0, tunnels: [], lines: [] }
const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() } const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
function deferred() { function deferred() {
let resolve, reject let resolve, reject
@@ -46,6 +51,9 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines }) if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines })
if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } }) if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } })
if (config.url.endsWith('/list')) return Promise.resolve({ rows: [], total: 0 }) if (config.url.endsWith('/list')) return Promise.resolve({ rows: [], total: 0 })
if (config.url === '/business/usage/site-options') return Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }] })
if (config.url === '/business/usage/site-board') return Promise.resolve({ data: siteBoard })
if (config.url === '/business/usage-fifo/queue') return Promise.resolve({ data: { siteId: 21, strains: [] } })
if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } }) if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } })
if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } }) if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } })
throw new Error(`未处理请求 ${config.url}`) throw new Error(`未处理请求 ${config.url}`)
@@ -63,7 +71,7 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
confirm(message) { confirmations.push(message); return confirm(message) } confirm(message) { confirmations.push(message); return confirm(message) }
}, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } } }, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } }
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) } const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } } const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/ScanFeedback': { default: { name: 'ScanFeedback' } }, '@/components/SfIcon': { default: { name: 'SfIcon' } }, '@/utils/masterDisplay': masterDisplay, '@/utils/palletPassport': passport, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。 // PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount'] const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount']
const component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name])) const component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name]))
@@ -95,7 +103,8 @@ async function retry(page) {
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => { test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
const page = createPage() const page = createPage()
await page.api.scanUsagePallet('托盘/A?#') await page.api.scanUsagePallet('托盘/A?#')
assert.equal(page.requests.at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23') // 页面启动后还会读取现场看板,按路径取最近一次扫码请求再核对编码
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
await page.scan('印刷托盘号') await page.scan('印刷托盘号')
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } }) assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
page.close() page.close()
@@ -753,3 +762,121 @@ test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它',
assert.equal(page.state.tunnelLocked.value, true) assert.equal(page.state.tunnelLocked.value, true)
page.close() page.close()
}) })
const twoSites = config => config.url === '/business/usage/site-options'
? Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }, { id: 22, code: 'XC-22', name: '二号现场', customerName: '客户A', tunnelCount: 0, lineCount: 0 }] })
: undefined
test('现场看板默认选第一个可访问现场,只有主动切换才记住,记住的现场用于首次查询', async () => {
const storage = new Map()
const page = createPage({ storage, request: twoSites })
await flush()
assert.deepEqual(page.requests.find(item => item.url === '/business/usage/site-board').params, { siteId: 21 })
assert.deepEqual(page.requests.find(item => item.url === '/business/usage-fifo/queue').params, { siteId: 21 })
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
assert.equal(storage.size, 0, '自动选择不写入本机偏好')
page.state.handleSiteChange(22); await flush()
assert.equal(storage.get('business:usage:site:v1:7'), '22')
assert.deepEqual(page.requests.filter(item => item.url === '/business/usage/site-board').at(-1).params, { siteId: 22 })
page.close()
const again = createPage({ storage, request: twoSites })
assert.equal(again.requests.find(item => item.url === '/business/usage/list').params.siteId, 22, '首次查询直接使用记住的现场')
await flush()
assert.equal(again.state.selectedSiteId.value, 22)
again.close()
const otherUser = createPage({ storage, userId: 8, request: twoSites })
await flush()
assert.equal(otherUser.state.selectedSiteId.value, 21, '偏好按账号隔离')
otherUser.close()
})
test('偏好写入失败不影响切换,读取到已无权访问的现场时回到第一个现场', async () => {
const storage = new Map([['business:usage:site:v1:7', '99']])
const page = createPage({ storage, storageWriteError: true })
assert.equal(page.requests.find(item => item.url === '/business/usage/list').params.siteId, 99)
await flush()
assert.equal(page.state.selectedSiteId.value, 21)
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
page.state.handleSiteChange(21); await flush()
assert.equal(storage.get('business:usage:site:v1:7'), '99')
assert.equal(page.state.storageError.value, '')
page.close()
})
test('隧道卡片切换记录筛选,看板只展示隧道在用情况不展示溯源号', async () => {
const board = { ...siteBoard, tunnelCount: 1, activeTunnelCount: 1, tunnels: [{ tunnelId: 51, tunnelCode: 'KB-01', tunnelName: '看板隧道', inUse: true, palletCount: 2, remainingPackages: 150, startedAt: '2026-10-17T08:00:00', lastUsedAt: '2026-10-17T10:00:00', lastLineName: '看板产线' }] }
const page = createPage({ request: config => config.url === '/business/usage/site-board' ? Promise.resolve({ data: board }) : undefined })
await flush()
assert.match(text(page), /看板隧道/)
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
assert.equal(page.state.tunnelFill(board.tunnels[0]), '75%')
page.state.toggleTunnelFilter(51); await flush()
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, 51)
page.state.toggleTunnelFilter(51); await flush()
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, undefined)
page.close()
})
test('无查询权限不读取看板,无列表权限不读取现场选项', async () => {
const page = createPage({ canOperate: false })
await flush()
assert.equal(page.requests.some(item => ['/business/usage/site-options', '/business/usage/site-board', '/business/usage-fifo/queue'].includes(item.url)), false)
page.state.openWork()
assert.equal(page.state.workOpen.value, false)
page.close()
})
test('作业步骤由扫码、入隧和登记结果推导,快捷包数与预览按当前余量计算', async () => {
const page = createPage()
await flush()
page.state.openWork()
assert.equal(page.state.workOpen.value, true)
assert.equal(page.state.workStep.value, 1)
await page.scan()
assert.equal(page.state.workStep.value, 2)
page.state.selectTunnel(31)
assert.equal(page.state.form.value.tunnelId, 31)
assert.equal(page.state.selectedTunnelName.value, '一号隧道')
await page.act('enterTunnel')
assert.equal(page.state.workStep.value, 3)
page.state.selectTunnel(32)
assert.equal(page.state.form.value.tunnelId, 31, '入隧后不能再切换隧道')
assert.deepEqual(page.state.quickAmounts.value.map(item => item.label), ['10', '20', '50', '全部 100'])
page.state.form.value.usedPackages = 30
assert.deepEqual({ ...page.state.usagePreview.value }, { text: '提交后余 70 包', bad: false })
page.state.form.value.usedPackages = 120
assert.equal(page.state.usagePreview.value.bad, true)
page.state.form.value.usedPackages = 17
const boards = page.requests.filter(item => item.url === '/business/usage/site-board').length
await page.act('submitUsage')
assert.equal(page.state.workStep.value, 4)
assert.equal(page.requests.filter(item => item.url === '/business/usage/site-board').length, boards + 1, '登记成功后刷新看板')
page.state.continueSamePallet(); await flush()
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/QR-001')
page.state.closeWork(); await flush()
assert.equal(page.state.workOpen.value, false)
assert.equal(page.state.scannedPallet.value, null)
page.close()
})
test('先到先用:可一键改用建议托盘,快捷原因写入同一原因字段', async () => {
const page = createPage({ request: config => config.url.endsWith('/recommendation')
? Promise.resolve({ data: { recommended: { palletId: 9, palletCode: '较早托盘', batchNo: '较早批次', remainingPackages: 25, siteArrivedAt: '2026-10-01 09:00:00' }, unknownArrivalCount: 0, requiresSkipReason: true, message: '请填写跳过原因' } })
: undefined })
await page.scan()
assert.equal(page.state.recommendedOther.value, true)
assert.ok(button(page, '改用 较早托盘'))
page.state.pickFifoReason('建议托盘取不出')
assert.equal(page.state.form.value.fifoSkipReason, '建议托盘取不出')
assert.equal(page.state.isReasonActive('建议托盘取不出'), true)
page.state.pickFifoReason('其他')
assert.equal(page.state.form.value.fifoSkipReason, '')
page.state.form.value.fifoSkipReason = '叉车暂时取不出'
assert.equal(page.state.isReasonActive('其他'), true)
page.state.pickFifoReason('其他')
assert.equal(page.state.form.value.fifoSkipReason, '叉车暂时取不出', '自填原因不被「其他」清空')
page.state.useRecommendedPallet(); await flush()
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, `/business/usage/scan/${encodeURIComponent('较早托盘')}`)
page.close()
})
@@ -91,13 +91,15 @@ const expectedRequests = {
{ url: "'/master/site'", method: 'post' }, { url: "'/master/site'", method: 'post' },
{ url: "'/master/site'", method: 'put' }, { url: "'/master/site'", method: 'put' },
{ url: "'/master/site/' + siteId + '/users'", method: 'get' }, { url: "'/master/site/' + siteId + '/users'", method: 'get' },
{ url: "'/master/site/' + siteId + '/users'", method: 'put' } { url: "'/master/site/' + siteId + '/users'", method: 'put' },
{ url: "'/master/site/customer-options'", method: 'get' }
], ],
'src/api/master/line.js': [ 'src/api/master/line.js': [
{ url: "'/master/line/list'", method: 'get' }, { url: "'/master/line/list'", method: 'get' },
{ url: "'/master/line/' + lineId", method: 'get' }, { url: "'/master/line/' + lineId", method: 'get' },
{ url: "'/master/line'", method: 'post' }, { url: "'/master/line'", method: 'post' },
{ url: "'/master/line'", method: 'put' }, { url: "'/master/line'", method: 'put' },
{ url: "'/master/line/site-options'", method: 'get' }
], ],
'src/api/master/warehouse.js': [ 'src/api/master/warehouse.js': [
{ url: "'/master/warehouse/list'", method: 'get' }, { url: "'/master/warehouse/list'", method: 'get' },
@@ -119,9 +121,11 @@ const expectedRequests = {
{ url: "'/master/tunnel/' + tunnelId", method: 'get' }, { url: "'/master/tunnel/' + tunnelId", method: 'get' },
{ url: "'/master/tunnel'", method: 'post' }, { url: "'/master/tunnel'", method: 'post' },
{ url: "'/master/tunnel'", method: 'put' }, { url: "'/master/tunnel'", method: 'put' },
{ url: "'/master/tunnel/site-options'", method: 'get' }
], ],
'src/api/business/inbound.js': [ 'src/api/business/inbound.js': [
{ url: "'/business/inbound/list'", method: 'get' }, { url: "'/business/inbound/list'", method: 'get' },
{ url: "'/business/inbound/summary'", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' }, { url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/inbound/' + inboundId", method: 'get' }, { url: "'/business/inbound/' + inboundId", method: 'get' },
@@ -131,6 +135,9 @@ const expectedRequests = {
], ],
'src/api/business/filling.js': [ 'src/api/business/filling.js': [
{ url: "'/business/filling/list'", method: 'get' }, { url: "'/business/filling/list'", method: 'get' },
{ url: "'/business/filling/summary'", method: 'get' },
{ url: "'/business/filling/batches'", method: 'get' },
{ url: "'/business/filling/batches/detail'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' }, { url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/filling/' + fillingId", method: 'get' }, { url: "'/business/filling/' + fillingId", method: 'get' },
@@ -151,6 +158,9 @@ const expectedRequests = {
{ url: "'/business/usage/list'", method: 'get' }, { url: "'/business/usage/list'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' }, { url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/tunnel-options'", method: 'get' }, { url: "'/business/usage/tunnel-options'", method: 'get' },
{ url: "'/business/usage/site-options'", method: 'get' },
{ url: "'/business/usage/site-board'", method: 'get' },
{ url: "'/business/usage-fifo/queue'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' }, { url: "'/business/usage/enter-tunnel'", method: 'post' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage-fifo/recommendation'", method: 'get' }, { url: "'/business/usage-fifo/recommendation'", method: 'get' },
@@ -159,6 +169,8 @@ const expectedRequests = {
], ],
'src/api/business/return.js': [ 'src/api/business/return.js': [
{ url: "'/business/return/list'", method: 'get' }, { url: "'/business/return/list'", method: 'get' },
{ url: "'/business/return/summary'", method: 'get' },
{ url: "'/business/return/site-options'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' }, { url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' }, { url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/return/request'", method: 'post' }, { url: "'/business/return/request'", method: 'post' },
@@ -211,14 +223,14 @@ const masterPageContracts = {
}, },
site: { site: {
relativePath: 'src/views/master/site/index.vue', relativePath: 'src/views/master/site/index.vue',
apiNames: ['addSite', 'getSite', 'listSite', 'updateSite'], apiNames: ['addSite', 'getSite', 'getSiteCustomerOptions', 'listSite', 'updateSite'],
permissions: ['add', 'assign', 'edit', 'list', 'query'], permissions: ['add', 'assign', 'edit', 'list', 'query'],
addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'], addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'],
editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status'] editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status']
}, },
line: { line: {
relativePath: 'src/views/master/line/index.vue', relativePath: 'src/views/master/line/index.vue',
apiNames: ['addLine', 'getLine', 'listLine', 'updateLine'], apiNames: ['addLine', 'getLine', 'getLineSiteOptions', 'listLine', 'updateLine'],
permissions: ['add', 'edit', 'list', 'query'], permissions: ['add', 'edit', 'list', 'query'],
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'], addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status'] editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
@@ -246,6 +258,7 @@ const businessPageContracts = {
'addInbound', 'addInbound',
'confirmInbound', 'confirmInbound',
'getInbound', 'getInbound',
'getInboundSummary',
'getInboundWarehouseOptions', 'getInboundWarehouseOptions',
'listInbound', 'listInbound',
'scanInboundPallet', 'scanInboundPallet',
@@ -260,7 +273,15 @@ const businessPageContracts = {
}, },
filling: { filling: {
relativePath: 'src/views/business/filling/index.vue', relativePath: 'src/views/business/filling/index.vue',
apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'], apiNames: [
'addFilling',
'getFillingBatchDetail',
'getFillingStrainOptions',
'getFillingSummary',
'listFilling',
'listFillingBatches',
'scanFillingPallet'
],
permissions: ['add', 'list', 'query'], permissions: ['add', 'list', 'query'],
payloads: { payloads: {
addFilling: [ addFilling: [
@@ -298,7 +319,18 @@ const businessPageContracts = {
}, },
usage: { usage: {
relativePath: 'src/views/business/usage/index.vue', relativePath: 'src/views/business/usage/index.vue',
apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'], apiNames: [
'addUsage',
'enterUsageTunnel',
'getUsageFifoQueue',
'getUsageFifoRecommendation',
'getUsageLineOptions',
'getUsageSiteBoard',
'getUsageSiteOptions',
'getUsageTunnelOptions',
'listUsage',
'scanUsagePallet'
],
permissions: ['add', 'list', 'query'], permissions: ['add', 'list', 'query'],
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。 // 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
payloads: {} payloads: {}
@@ -308,6 +340,8 @@ const businessPageContracts = {
apiNames: [ apiNames: [
'confirmReturn', 'confirmReturn',
'getReturn', 'getReturn',
'getReturnSiteOptions',
'getReturnSummary',
'getReturnWarehouseOptions', 'getReturnWarehouseOptions',
'listReturn', 'listReturn',
'requestReturn', 'requestReturn',
@@ -1538,7 +1572,7 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => { test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 18) assert.equal(Object.keys(expectedRequests).length, 18)
assert.equal(Object.values(expectedRequests).flat().length, 74) assert.equal(Object.values(expectedRequests).flat().length, 86)
for (const [relativePath, expected] of Object.entries(expectedRequests)) { for (const [relativePath, expected] of Object.entries(expectedRequests)) {
await t.test(relativePath, async () => { await t.test(relativePath, async () => {
@@ -2923,3 +2957,20 @@ test('三个业务菜单公共样式统一使用 Element Plus 主题变量', asy
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/) assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/) assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
}) })
test('业务页面导入的组件不被同名响应式状态遮蔽', async (t) => {
// 非 .vue 路径导入的组件按 maybe-ref 绑定,模板标签会优先解析为同名 camelCase 状态(如 scanFeedback 遮蔽 ScanFeedback)
const { compileScript, parse } = await import('@vue/compiler-sfc')
for (const [resource, contract] of Object.entries(businessPageContracts)) {
await t.test(resource, async () => {
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
const { descriptor } = parse(source)
const compiled = compileScript(descriptor, { id: `contract-${resource}`, inlineTemplate: true }).content
const components = [...source.matchAll(/^import\s+([A-Z]\w*)\s+from\s+['"]@\/components\/[^'"]+['"]/gm)].map((match) => match[1])
for (const component of components) {
const shadow = component[0].toLowerCase() + component.slice(1)
assert.doesNotMatch(compiled, new RegExp(`_create(?:VNode|Block)\\(_unref\\(${shadow}\\)`), `${component} 被同名状态 ${shadow} 遮蔽`)
}
})
}
})
@@ -8,27 +8,27 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const dashboardPath = path.resolve(scriptDir, '../src/views/dashboard/index.vue') const dashboardPath = path.resolve(scriptDir, '../src/views/dashboard/index.vue')
const dashboardSource = fs.readFileSync(dashboardPath, 'utf8') const dashboardSource = fs.readFileSync(dashboardPath, 'utf8')
test('首页包含仓储运营总览的核心模块', () => { test('首页工作台包含扫码入口、流转看板与核心运营模块', () => {
for (const label of [ for (const label of [
'仓储运营总览', '扫描托盘码',
'当前库存托盘', '快捷作业',
'剩余包数', '新建作业',
'今日入库单', '在库托盘',
'今日出库单', '客户现场在用',
'待确认单据', '今日发货',
'新增入库', '待处理',
'托盘填充', '托盘流转',
'装填与发货',
'临期预警',
'仓库库存',
'最近动态',
'仓库入库',
'工厂装填',
'发货出库', '发货出库',
'托盘使用', '现场使用',
'托盘退回', '退回仓库',
'托盘追踪', '托盘追溯',
'近 7 天入库 / 出库趋势', '扫码异常'
'托盘流转状态分布',
'仓库库存排行',
'最近入库单',
'最近出库单',
'扫码异常',
'待确认业务'
]) { ]) {
assert.match(dashboardSource, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) assert.match(dashboardSource, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
} }
@@ -9,6 +9,32 @@ export function listFilling(query) {
}) })
} }
// 查询工厂装填概况
export function getFillingSummary() {
return request({
url: '/business/filling/summary',
method: 'get'
})
}
// 查询按生产批次汇总的装填列表
export function listFillingBatches(query) {
return request({
url: '/business/filling/batches',
method: 'get',
params: query
})
}
// 查询生产批次详情
export function getFillingBatchDetail(query) {
return request({
url: '/business/filling/batches/detail',
method: 'get',
params: query
})
}
// 查询装填菌种选项 // 查询装填菌种选项
export function getFillingStrainOptions() { export function getFillingStrainOptions() {
return request({ return request({
@@ -9,6 +9,14 @@ export function listInbound(query) {
}) })
} }
// 查询首次入库概况
export function getInboundSummary() {
return request({
url: '/business/inbound/summary',
method: 'get'
})
}
// 查询首次入库仓库选项 // 查询首次入库仓库选项
export function getInboundWarehouseOptions() { export function getInboundWarehouseOptions() {
return request({ return request({
@@ -9,6 +9,22 @@ export function listReturn(query) {
}) })
} }
// 查询托盘退回概况
export function getReturnSummary() {
return request({
url: '/business/return/summary',
method: 'get'
})
}
// 查询退回来源现场选项
export function getReturnSiteOptions() {
return request({
url: '/business/return/site-options',
method: 'get'
})
}
// 查询退回目标仓库选项 // 查询退回目标仓库选项
export function getReturnWarehouseOptions() { export function getReturnWarehouseOptions() {
return request({ return request({
@@ -27,6 +27,32 @@ export function getUsageTunnelOptions(siteId) {
}) })
} }
// 查询当前用户可访问的启用现场
export function getUsageSiteOptions() {
return request({
url: '/business/usage/site-options',
method: 'get'
})
}
// 查询现场看板(库存、今日用量、隧道)
export function getUsageSiteBoard(siteId) {
return request({
url: '/business/usage/site-board',
method: 'get',
params: { siteId }
})
}
// 查询现场待使用托盘队列,与先到先用建议同序
export function getUsageFifoQueue(siteId) {
return request({
url: '/business/usage-fifo/queue',
method: 'get',
params: { siteId }
})
}
// 扫码入隧 // 扫码入隧
export function enterUsageTunnel(data) { export function enterUsageTunnel(data) {
return request({ return request({
@@ -34,3 +34,11 @@ export function updateLine(data) {
data: data data: data
}) })
} }
// 查询新增或修改产线时可选的现场
export function getLineSiteOptions() {
return request({
url: '/master/line/site-options',
method: 'get'
})
}
@@ -51,3 +51,11 @@ export function assignSiteUsers(siteId, data) {
data: data data: data
}) })
} }
// 查询新增或修改现场时可选的客户
export function getSiteCustomerOptions() {
return request({
url: '/master/site/customer-options',
method: 'get'
})
}
@@ -34,3 +34,11 @@ export function updateTunnel(data) {
data: data data: data
}) })
} }
// 查询新增或修改隧道时可选的现场
export function getTunnelSiteOptions() {
return request({
url: '/master/tunnel/site-options',
method: 'get'
})
}
@@ -6,6 +6,8 @@
@use './shierfeng.scss'; @use './shierfeng.scss';
@use './data-pages.scss'; @use './data-pages.scss';
@use './sf-shell.scss';
@use './sf-master.scss';
body { body {
height: 100%; height: 100%;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,8 @@
@use './variables.module.scss' as vars; @use './variables.module.scss' as vars;
// ==========================================
// 侧边导航:位于底色之上,内容区为右侧内嵌面板
// ==========================================
#app { #app {
.main-container { .main-container {
@@ -10,24 +13,54 @@
} }
.sidebarHide { .sidebarHide {
margin-left: 0!important; margin-left: 0 !important;
} }
.sidebar-container { .sidebar-container {
--sf-menu-text: var(--sf-text-2, #3d4350);
--sf-menu-icon: var(--sf-text-4, #6b717d);
--sf-menu-group: var(--sf-text-4, #6b717d);
--sf-menu-hover: var(--sf-hover, rgba(15, 17, 21, 0.045));
--sf-menu-active-bg: var(--sf-surface, #fff);
--sf-menu-active-text: var(--sf-text-1, #0f1115);
--sf-menu-active-icon: var(--sf-accent, #007787);
--sf-menu-active-ring: var(--sf-ring, 0 0 0 1px #e7e9ec);
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: var(--sf-menu-hover);
--el-menu-text-color: var(--sf-menu-text);
--el-menu-active-color: var(--sf-menu-active-text);
--el-menu-item-height: 36px;
--el-menu-sub-item-height: 36px;
--el-menu-base-level-padding: 12px;
--el-menu-level-padding: 14px;
--el-menu-item-font-size: 13.5px;
transition: width 0.28s; transition: width 0.28s;
width: vars.$base-sidebar-width !important; width: vars.$base-sidebar-width !important;
height: 100%; height: 100%;
position: fixed; position: fixed;
font-size: 0px; font-size: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
z-index: 1001; z-index: 1001;
overflow: hidden; overflow: hidden;
border-right: 1px solid var(--el-border-color-lighter, #e4e7ed); background: var(--sf-ground, #f5f6f7);
box-shadow: 2px 0 10px 0 rgba(15, 34, 58, 0.05); display: flex;
flex-direction: column;
&.theme-dark {
--sf-menu-text: #c9cdd4;
--sf-menu-icon: #8d939d;
--sf-menu-group: #8d939d;
--sf-menu-hover: rgba(255, 255, 255, 0.06);
--sf-menu-active-bg: rgba(255, 255, 255, 0.1);
--sf-menu-active-text: #ffffff;
--sf-menu-active-icon: var(--el-color-primary-light-3, #4daab6);
--sf-menu-active-ring: none;
background: #15171c;
}
// reset element-ui css
.horizontal-collapse-transition { .horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
} }
@@ -37,176 +70,169 @@
} }
.el-scrollbar__bar.is-vertical { .el-scrollbar__bar.is-vertical {
right: 0px; right: 0;
} }
.el-scrollbar { .el-scrollbar {
height: 100%; flex: 1;
} min-height: 0;
&.has-logo {
.el-scrollbar {
height: calc(100% - 50px);
}
} }
.is-horizontal { .is-horizontal {
display: none; display: none;
} }
a { .el-menu a {
display: inline-block; display: block;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
} }
.svg-icon {
margin-right: 10px !important;
}
.el-menu { .el-menu {
border: none; border: none;
height: 100%; height: 100%;
width: 100% !important; width: 100% !important;
padding: 4px 0 16px;
background: transparent;
}
&:not(.el-menu--collapse) { // 菜单项:圆角胶囊
.el-menu-item, .el-menu-item,
.el-sub-menu__title { .el-sub-menu__title {
height: 46px; height: 36px;
line-height: 46px; line-height: 36px;
margin: 0; margin: 1px 10px;
border-radius: 0; padding-right: 10px;
transition: background-color 0.2s ease, color 0.2s ease; border-radius: 8px;
} color: var(--sf-menu-text);
font-size: 13.5px;
transition: background-color 0.15s ease, color 0.15s ease;
.el-menu-item.is-active { &:hover {
font-weight: 600; background-color: var(--sf-menu-hover) !important;
background-color: rgba(0, 133, 150, 0.12) !important; color: var(--sf-menu-active-text);
color: var(--current-color, #008596) !important;
border-radius: 0;
}
} }
} }
.el-menu-item, .menu-title { .sf-menu-icon,
.svg-icon {
flex: none;
width: 16px !important;
height: 16px !important;
margin-right: 10px !important;
color: var(--sf-menu-icon);
font-size: 16px;
}
.el-menu-item.is-active {
background-color: var(--sf-menu-active-bg) !important;
box-shadow: var(--sf-menu-active-ring);
color: var(--sf-menu-active-text) !important;
font-weight: 500;
.sf-menu-icon,
.svg-icon {
color: var(--sf-menu-active-icon);
}
}
.menu-title,
.el-menu-item {
overflow: hidden !important; overflow: hidden !important;
text-overflow: ellipsis !important; text-overflow: ellipsis !important;
white-space: nowrap !important; white-space: nowrap !important;
} }
.el-menu-item .el-menu-tooltip__trigger { .el-menu-item .el-menu-tooltip__trigger {
display: inline-block !important; display: inline-flex !important;
align-items: center;
} }
.el-sub-menu { .el-sub-menu__icon-arrow {
.el-sub-menu__icon-arrow { right: 10px;
position: absolute; width: 12px !important;
top: 50%; font-size: 12px;
right: var(--el-menu-base-level-padding, 20px); color: var(--sf-menu-group);
margin-top: -6px; }
width: auto !important;
// 一级目录展开时作为分组标题:小字号、无图标
.el-menu:not(.el-menu--collapse) {
& > div > .el-sub-menu {
margin-top: 10px;
& > .el-sub-menu__title {
height: 28px;
line-height: 28px;
font-size: 12px;
font-weight: 500;
color: var(--sf-menu-group);
letter-spacing: 0.02em;
.sf-menu-icon,
.svg-icon {
display: none;
}
&:hover {
background-color: transparent !important;
color: var(--sf-menu-active-text);
}
}
& > .el-menu {
padding: 2px 0 0;
}
// 分组内一级子项与顶层菜单项左对齐
& > .el-menu > div > a > .el-menu-item,
& > .el-menu > div > .el-sub-menu > .el-sub-menu__title {
padding-left: 12px !important;
}
}
.nest-menu .el-sub-menu .el-menu-item {
padding-left: 38px !important;
} }
} }
// menu hover .el-sub-menu.is-active > .el-sub-menu__title {
.sub-menu-title-noDropdown, color: var(--sf-menu-active-text);
.el-sub-menu__title {
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
& .theme-dark .is-active > .el-sub-menu__title {
color: vars.$base-menu-color-active !important;
}
& .theme-dark .el-menu:not(.el-menu--collapse) .el-menu-item.is-active {
background-color: rgba(0, 133, 150, 0.26) !important;
color: #38bdf8 !important;
border-radius: 0;
}
& .nest-menu .el-sub-menu>.el-sub-menu__title,
& .el-sub-menu .el-menu-item {
min-width: vars.$base-sidebar-width !important;
width: 100% !important;
margin: 0 !important;
border-radius: 0 !important;
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important;
}
.el-sub-menu__icon-arrow {
position: absolute;
top: 50%;
right: var(--el-menu-base-level-padding, 20px);
margin-top: -6px;
width: auto !important;
}
}
& .theme-dark .nest-menu .el-sub-menu>.el-sub-menu__title,
& .theme-dark .el-sub-menu .el-menu-item {
background-color: vars.$base-sub-menu-background;
&:hover {
background-color: vars.$base-sub-menu-hover !important;
}
&.is-active {
background-color: rgba(0, 133, 150, 0.28) !important;
color: #38bdf8 !important;
}
} }
} }
.hideSidebar { .hideSidebar {
.sidebar-container { .sidebar-container {
width: 54px !important; width: vars.$collapsed-sidebar-width !important;
} }
.main-container { .main-container {
margin-left: 54px; margin-left: vars.$collapsed-sidebar-width;
}
.sub-menu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-sub-menu {
overflow: hidden;
&>.el-sub-menu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
} }
.el-menu--collapse { .el-menu--collapse {
width: 100% !important;
.el-menu-item,
.el-sub-menu__title {
justify-content: center;
margin: 1px 10px;
padding: 0 !important;
}
.el-tooltip__trigger {
justify-content: center;
padding: 0 !important;
}
.sf-menu-icon,
.svg-icon {
margin-right: 0 !important;
}
.el-sub-menu { .el-sub-menu {
&>.el-sub-menu__title { & > .el-sub-menu__title {
&>span { & > span,
height: 0; & > i {
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
&>i {
height: 0; height: 0;
width: 0; width: 0;
overflow: hidden; overflow: hidden;
@@ -222,15 +248,16 @@
min-width: vars.$base-sidebar-width !important; min-width: vars.$base-sidebar-width !important;
} }
// mobile responsive // 移动端:抽屉式导航
.mobile { .mobile {
.main-container { .main-container {
margin-left: 0px; margin-left: 0;
} }
.sidebar-container { .sidebar-container {
transition: transform .28s; transition: transform .28s;
width: vars.$base-sidebar-width !important; width: vars.$base-sidebar-width !important;
box-shadow: 8px 0 32px rgba(16, 24, 40, 0.12);
} }
&.hideSidebar { &.hideSidebar {
@@ -251,47 +278,60 @@
} }
} }
// when menu collapsed // 折叠态弹出的子菜单
.el-menu--vertical { .el-menu--vertical {
.el-sub-menu { --el-menu-item-height: 36px;
.el-sub-menu__icon-arrow { --el-menu-sub-item-height: 36px;
position: absolute;
top: 50%; .el-menu--popup {
right: var(--el-menu-base-level-padding, 20px); padding: 6px;
margin-top: -6px; border-radius: 10px;
width: auto !important; min-width: 200px;
}
} }
&>.el-menu { .el-menu-item,
.el-sub-menu__title {
border-radius: 6px;
font-size: 13.5px;
.sf-menu-icon,
.svg-icon { .svg-icon {
margin-right: 16px; width: 16px;
height: 16px;
margin-right: 10px;
} }
} }
.nest-menu .el-sub-menu>.el-sub-menu__title, .el-menu-item.is-active {
color: var(--sf-accent, #007787);
font-weight: 500;
}
.el-sub-menu .el-sub-menu__icon-arrow {
position: absolute;
top: 50%;
right: 10px;
margin-top: -6px;
width: auto !important;
}
.nest-menu .el-sub-menu > .el-sub-menu__title,
.el-menu-item { .el-menu-item {
&:hover { &:hover {
// you can use $sub-menuHover background-color: var(--sf-hover, rgba(0, 0, 0, 0.05)) !important;
background-color: rgba(0, 0, 0, 0.06) !important;
} }
} }
// the scroll bar appears when the sub-menu is too long > .el-menu--popup {
>.el-menu--popup {
max-height: 100vh; max-height: 100vh;
overflow-y: auto; overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; width: 6px;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
background: #99a9bf; background: var(--sf-border-strong, #c0c4cc);
border-radius: 20px; border-radius: 20px;
} }
} }
@@ -21,8 +21,9 @@ $menuLightText: #303133;
$menuLightActiveText: #409EFF; $menuLightActiveText: #409EFF;
// 基础变量 // 基础变量
$base-sidebar-width: 200px; $base-sidebar-width: 232px;
$sideBarWidth: 200px; $collapsed-sidebar-width: 64px;
$sideBarWidth: 232px;
// 菜单暗色变量 // 菜单暗色变量
$base-menu-color: #bfcbd9; $base-menu-color: #bfcbd9;
@@ -34,7 +34,7 @@ function getBreadcrumb() {
} }
// 判断是否为首页 // 判断是否为首页
if (!isDashboard(matched[0])) { if (!isDashboard(matched[0])) {
matched = [{ path: "/index", meta: { title: "首页" } }].concat(matched) matched = [{ path: "/index", meta: { title: "工作台" } }].concat(matched)
} }
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false) levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
} }
@@ -86,22 +86,27 @@ getBreadcrumb()
<style lang='scss' scoped> <style lang='scss' scoped>
.app-breadcrumb.el-breadcrumb { .app-breadcrumb.el-breadcrumb {
display: inline-block; display: inline-block;
font-size: 14px; font-size: 13.5px;
line-height: 50px; line-height: 56px;
margin-left: 8px; margin-left: 4px;
:deep(.el-breadcrumb__separator) {
margin: 0 8px;
color: var(--sf-border-strong, #c0c4cc);
}
.no-redirect { .no-redirect {
color: var(--el-text-color-regular, #606266); color: var(--sf-text-1, var(--el-text-color-regular, #606266));
cursor: text; cursor: text;
font-weight: 500; font-weight: 500;
} }
a { a {
color: var(--el-text-color-secondary, #909399); color: var(--sf-text-4, var(--el-text-color-secondary, #909399));
transition: color 0.2s ease; transition: color 0.15s ease;
&:hover { &:hover {
color: var(--el-color-primary, #008596); color: var(--sf-text-1, var(--el-color-primary, #008596));
} }
} }
} }
@@ -6,22 +6,32 @@
top="6vh" top="6vh"
append-to-body append-to-body
destroy-on-close destroy-on-close
class="sf-assign-dialog"
:close-on-click-modal="!submitLoading" :close-on-click-modal="!submitLoading"
:close-on-press-escape="!submitLoading" :close-on-press-escape="!submitLoading"
:show-close="!submitLoading" :show-close="!submitLoading"
:before-close="handleDialogBeforeClose" :before-close="handleDialogBeforeClose"
@closed="resetDialogState" @closed="resetDialogState"
> >
<p class="assign-intro">{{ dialogIntro }}</p>
<el-form :inline="true" class="assign-search" @submit.prevent> <el-form :inline="true" class="assign-search" @submit.prevent>
<el-form-item label="用户搜索"> <el-form-item>
<el-input <el-input
v-model="keyword" v-model="keyword"
class="assign-search__input" class="assign-search__input"
placeholder="请输入账号或昵称" placeholder="搜索账号或昵称"
prefix-icon="Search"
clearable clearable
:disabled="loading || submitLoading || loadFailed" :disabled="loading || submitLoading || loadFailed"
/> />
</el-form-item> </el-form-item>
<el-form-item>
<el-radio-group v-model="assignFilter" class="sf-segmented" aria-label="用户筛选" :disabled="loading || submitLoading || loadFailed">
<el-radio-button value="all">全部 {{ users.length }}</el-radio-button>
<el-radio-button value="assigned">已授权 {{ originalAssignedIds.length }}</el-radio-button>
<el-radio-button value="unassigned">未授权 {{ users.length - originalAssignedIds.length }}</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form> </el-form>
<el-alert <el-alert
@@ -40,44 +50,51 @@
row-key="userId" row-key="userId"
height="360" height="360"
empty-text="暂无可分配用户" empty-text="暂无可分配用户"
class="assign-table"
@row-click="handleRowClick" @row-click="handleRowClick"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
> >
<el-table-column type="selection" width="55" :reserve-selection="true" /> <el-table-column type="selection" width="48" :reserve-selection="true" />
<el-table-column label="用户账号" prop="userName" :show-overflow-tooltip="true" /> <el-table-column label="用户" min-width="220">
<el-table-column label="用户昵称" prop="nickName" :show-overflow-tooltip="true">
<template #default="scope"> <template #default="scope">
<span>{{ scope.row.nickName || "-" }}</span> <span class="assign-user">
<span class="assign-user__avatar" :style="{ background: avatarColor(scope.row.userId) }" aria-hidden="true">{{ userInitial(scope.row) }}</span>
<span class="assign-user__text">
<strong>{{ scope.row.nickName || scope.row.userName }}</strong>
<small>{{ scope.row.userName }}</small>
</span>
</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="当前状态" width="100" align="center"> <el-table-column label="授权变化" width="120" align="right">
<template #default="scope"> <template #default="scope">
<el-tag :type="scope.row.assigned ? 'success' : 'info'"> <span class="assign-change" :class="`is-${changeOf(scope.row)}`">{{ CHANGE_LABELS[changeOf(scope.row)] }}</span>
{{ scope.row.assigned ? "已分配" : "未分配" }}
</el-tag>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="dialog-footer assign-footer">
<el-button :disabled="submitLoading" @click="closeDialog">取 消</el-button> <span class="assign-footer__summary">
已授权 {{ selectedUserIds.length }} 人<span v-if="assignmentChanged" class="assign-footer__diff">({{ diffText }})</span>
</span>
<el-button :disabled="submitLoading" @click="closeDialog">取消</el-button>
<el-button <el-button
v-if="resourceType === 'site'" v-if="resourceType === 'site'"
type="primary" type="primary"
:loading="submitLoading" :loading="submitLoading"
:disabled="loading || submitLoading || loadFailed" :disabled="loading || submitLoading || loadFailed || !assignmentChanged"
@click="submitAssignment" @click="submitAssignment"
v-hasPermi="['master:site:assign']" v-hasPermi="['master:site:assign']"
>确 定</el-button> >保存授权</el-button>
<el-button <el-button
v-else-if="resourceType === 'warehouse'" v-else-if="resourceType === 'warehouse'"
type="primary" type="primary"
:loading="submitLoading" :loading="submitLoading"
:disabled="loading || submitLoading || loadFailed" :disabled="loading || submitLoading || loadFailed || !assignmentChanged"
@click="submitAssignment" @click="submitAssignment"
v-hasPermi="['master:warehouse:assign']" v-hasPermi="['master:warehouse:assign']"
>确 定</el-button> >保存授权</el-button>
</div> </div>
</template> </template>
</el-dialog> </el-dialog>
@@ -108,6 +125,9 @@ const users = ref([]);
const selectedUserIds = ref([]); const selectedUserIds = ref([]);
const userTableRef = ref(); const userTableRef = ref();
const restoringSelection = ref(false); const restoringSelection = ref(false);
const assignFilter = ref("all");
const CHANGE_LABELS = { add: "将新增", remove: "将移除", kept: "已授权", none: "—" };
const AVATAR_COLORS = ["#00909F", "#6A7FE8", "#C2410C", "#8E6BD9", "#0F766E", "#3549A8", "#9A3A05", "#5B616E"];
let loadGeneration = 0; let loadGeneration = 0;
let submitGeneration = 0; let submitGeneration = 0;
@@ -116,16 +136,44 @@ const dialogTitle = computed(() => {
return `分配${label}用户${resourceName.value ? ` - ${resourceName.value}` : ""}`; return `分配${label}用户${resourceName.value ? ` - ${resourceName.value}` : ""}`;
}); });
const dialogIntro = computed(() => (resourceType.value === "site"
? "被授权的用户才能在该现场收货核对、登记使用和发起退回,并在报表中看到它的数据。"
: "被授权的用户才能在入库、装填、发货和退回中选择该仓库,并在报表中看到它的数据。"));
// 筛选按打开时的授权状态,勾选变化不会让行从列表中消失
const filteredUsers = computed(() => { const filteredUsers = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase(); const normalizedKeyword = keyword.value.trim().toLowerCase();
const byState = users.value.filter((user) => assignFilter.value === "all"
|| (assignFilter.value === "assigned" ? user.assigned : !user.assigned));
if (!normalizedKeyword) { if (!normalizedKeyword) {
return users.value; return byState;
} }
return users.value.filter((user) => return byState.filter((user) =>
user.userName.toLowerCase().includes(normalizedKeyword) user.userName.toLowerCase().includes(normalizedKeyword)
|| (user.nickName || "").toLowerCase().includes(normalizedKeyword) || (user.nickName || "").toLowerCase().includes(normalizedKeyword)
); );
}); });
const originalAssignedIds = computed(() => users.value.filter((user) => user.assigned).map((user) => user.userId));
const addedCount = computed(() => selectedUserIds.value.filter((userId) => !originalAssignedIds.value.includes(userId)).length);
const removedCount = computed(() => originalAssignedIds.value.filter((userId) => !selectedUserIds.value.includes(userId)).length);
const assignmentChanged = computed(() => addedCount.value > 0 || removedCount.value > 0);
const diffText = computed(() => [
addedCount.value ? `新增 ${addedCount.value}` : "",
removedCount.value ? `移除 ${removedCount.value}` : ""
].filter(Boolean).join(","));
function changeOf(user) {
const selected = selectedUserIds.value.includes(user.userId);
if (user.assigned) return selected ? "kept" : "remove";
return selected ? "add" : "none";
}
function userInitial(user) {
const name = String(user.nickName || user.userName || "").trim();
return name ? name.slice(-1).toUpperCase() : "?";
}
function avatarColor(userId) {
return AVATAR_COLORS[Math.abs(Number(userId) || 0) % AVATAR_COLORS.length];
}
function normalizeUserRows(rows) { function normalizeUserRows(rows) {
return rows return rows
@@ -226,6 +274,7 @@ function resetDialogState() {
submitLoading.value = false; submitLoading.value = false;
loadError.value = ""; loadError.value = "";
keyword.value = ""; keyword.value = "";
assignFilter.value = "all";
resourceType.value = ""; resourceType.value = "";
resourceId.value = undefined; resourceId.value = undefined;
resourceName.value = ""; resourceName.value = "";
@@ -301,17 +350,122 @@ function isCurrentSubmit(submitContext) {
&& submitContext.resourceId === resourceId.value; && submitContext.resourceId === resourceId.value;
} }
watch(keyword, restoreSelection); watch([keyword, assignFilter], restoreSelection);
defineExpose({ show }); defineExpose({ show });
</script> </script>
<style scoped> <style scoped>
.assign-intro {
margin: -4px 0 14px;
color: var(--sf-text-3, #5b616e);
font-size: 13px;
line-height: 1.6;
}
.assign-search {
display: flex;
flex-wrap: wrap;
gap: 0 10px;
}
.assign-search :deep(.el-form-item) {
margin-right: 0;
margin-bottom: 12px;
}
.assign-search__input { .assign-search__input {
width: 260px; width: 260px;
max-width: 100%; max-width: 100%;
} }
.assign-table :deep(.el-table__row) {
cursor: pointer;
}
.assign-user {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.assign-user__avatar {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: #ffffff;
font-size: 12px;
font-weight: 600;
}
.assign-user__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.35;
}
.assign-user__text strong {
overflow: hidden;
color: var(--sf-text-1, #0f1115);
font-size: 13.5px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.assign-user__text small {
color: var(--sf-text-4, #6b717d);
font-family: var(--sf-font-mono, monospace);
font-size: 12px;
}
.assign-change {
color: var(--sf-text-4, #6b717d);
font-size: 12px;
font-weight: 500;
}
.assign-change.is-add {
color: var(--sf-accent-strong, #006a78);
}
.assign-change.is-remove {
color: var(--sf-danger-fg, #b42318);
}
.assign-change.is-kept {
color: var(--sf-text-2, #3d4350);
}
.assign-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.assign-footer .el-button + .el-button {
margin-left: 0;
}
.assign-footer__summary {
margin-right: auto;
color: var(--sf-text-2, #3d4350);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.assign-footer__diff {
color: var(--sf-accent-strong, #006a78);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.assign-search :deep(.el-form-item), .assign-search :deep(.el-form-item),
.assign-search__input { .assign-search__input {
@@ -0,0 +1,19 @@
<template>
<span class="sf-doc-no sf-mono" :title="value">{{ shortDocNo(value) }}</span>
</template>
<script setup>
import { shortDocNo } from '@/utils/docNo'
defineProps({
value: { type: String, default: '' }
})
</script>
<style scoped>
.sf-doc-no {
color: var(--sf-text-1, #0f1115);
font-weight: 500;
white-space: nowrap;
}
</style>
@@ -0,0 +1,92 @@
<template>
<nav class="sf-flow" aria-label="托盘流程">
<template v-for="(step, index) in steps" :key="step.key">
<svg v-if="index" class="sf-flow__sep" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
<span v-if="step.key === current" class="sf-flow__step is-current" aria-current="step">
<span class="sf-flow__no">{{ index + 1 }}</span>{{ step.label }}
</span>
<router-link v-else-if="step.path" class="sf-flow__step" :to="step.path">
<span class="sf-flow__no">{{ index + 1 }}</span>{{ step.label }}
</router-link>
<!-- 没有该菜单权限时只显示环节,不提供跳转 -->
<span v-else class="sf-flow__step is-plain">
<span class="sf-flow__no">{{ index + 1 }}</span>{{ step.label }}
</span>
</template>
</nav>
</template>
<script setup>
import { masterRoutePath } from '@/utils/masterDisplay'
defineProps({
// 当前所在环节:inbound / filling / shipment / usage / return
current: { type: String, default: '' }
})
const FLOW_STEPS = [
{ key: 'inbound', label: '入库' },
{ key: 'filling', label: '装填' },
{ key: 'shipment', label: '发货' },
{ key: 'usage', label: '现场使用' },
{ key: 'return', label: '退回' }
]
// 业务菜单由后端动态下发,按路由末段查找实际路径
const steps = computed(() => FLOW_STEPS.map((step) => ({ ...step, path: masterRoutePath(step.key) })))
</script>
<style lang="scss" scoped>
.sf-flow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 2px;
margin-top: 8px;
font-size: 12.5px;
}
.sf-flow__sep {
flex: none;
color: var(--sf-border-strong, #c3c8cf);
}
.sf-flow__step {
display: inline-flex;
align-items: center;
gap: 6px;
height: 26px;
padding: 0 9px;
border-radius: 7px;
color: var(--sf-text-3, #5b616e);
text-decoration: none;
white-space: nowrap;
&:hover:not(.is-current):not(.is-plain) {
background: var(--sf-surface-3, #f1f2f4);
color: var(--sf-text-1, #0f1115);
}
&.is-current {
background: var(--sf-accent-soft, #e6f2f3);
color: var(--sf-accent-strong, #005a66);
font-weight: 600;
.sf-flow__no {
color: inherit;
}
}
}
.sf-flow__no {
color: var(--sf-text-4, #9aa1ac);
font-size: 11px;
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px) {
.sf-flow {
display: none;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,132 @@
<template>
<el-select
v-if="!failed"
:model-value="modelValue"
class="sf-ref-select"
popper-class="sf-ref-select__popper"
filterable
clearable
:filter-method="setQuery"
:loading="loading"
:placeholder="placeholder"
:no-match-text="`没有匹配的${kind}`"
:no-data-text="loading ? '加载中…' : `暂无可选${kind}`"
@update:model-value="emit('update:modelValue', $event)"
@visible-change="(visible) => { if (!visible) setQuery('') }"
>
<el-option v-for="option in shownOptions" :key="option.id" :value="option.id" :label="labelOf(option)">
<span class="sf-ref-option">
<span class="sf-ref-option__name">{{ option.name }}<em v-if="option.unavailable">已停用或不在当前数据范围</em></span>
<span class="sf-ref-option__meta">{{ [option.parentName, option.code].filter(Boolean).join(' · ') }}</span>
</span>
</el-option>
</el-select>
<span v-else class="sf-ref-fallback">
<el-input-number
:model-value="modelValue"
:min="1"
:step="1"
step-strictly
:controls="false"
:placeholder="`${kind}ID`"
@update:model-value="emit('update:modelValue', $event ?? undefined)"
/>
<small>{{ kind }}列表加载失败,可直接输入{{ kind }}ID,保存时校验</small>
</span>
</template>
<script setup>
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: { type: Number, default: undefined },
options: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
failed: { type: Boolean, default: false },
kind: { type: String, default: '资料' },
placeholder: { type: String, default: '搜索名称或编码' },
// 当前值不在可选列表时(例如上级已停用)用于回显的名称
currentLabel: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])
const query = ref('')
const mergedOptions = computed(() => {
const value = props.modelValue
if (!value || props.options.some((option) => option.id === value)) return props.options
return [{ id: value, name: props.currentLabel || `${props.kind} #${value}`, code: '', unavailable: true }, ...props.options]
})
const shownOptions = computed(() => {
const text = query.value.trim().toLowerCase()
if (!text) return mergedOptions.value
return mergedOptions.value.filter((option) =>
[option.name, option.code, option.parentName].some((field) => String(field || '').toLowerCase().includes(text))
)
})
function setQuery(value) {
query.value = value || ''
}
function labelOf(option) {
return [option.parentName, option.name].filter(Boolean).join(' · ')
}
</script>
<style lang="scss">
.sf-ref-select {
width: 100%;
}
.sf-ref-select__popper {
.el-select-dropdown__item {
height: auto;
padding-top: 6px;
padding-bottom: 6px;
line-height: 1.4;
}
}
.sf-ref-option {
display: flex;
flex-direction: column;
min-width: 0;
}
.sf-ref-option__name {
overflow: hidden;
color: var(--sf-text-1, #0f1115);
font-size: 13.5px;
white-space: nowrap;
text-overflow: ellipsis;
em {
margin-left: 6px;
color: var(--sf-warn-fg, #b54708);
font-size: 12px;
font-style: normal;
}
}
.sf-ref-option__meta {
color: var(--sf-text-4, #6b717d);
font-family: var(--sf-font-mono, monospace);
font-size: 12px;
}
.sf-ref-fallback {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
.el-input-number {
width: 160px;
}
small {
color: var(--sf-warn-fg, #b54708);
font-size: 12px;
line-height: 1.4;
}
}
</style>
@@ -0,0 +1,24 @@
// 页面说明:按菜单路由末段匹配,用于页面标题下方的一句话说明
export const PAGE_DESCRIPTIONS = {
// 基础资料
strain: '菌种是装填时选择的物料品类。这里能看到每个菌种此刻在仓库与在外的量、最近有效期和装填情况。',
customer: '客户 → 现场 → 产线与隧道的完整层级。现场是发货的目的地,产线和隧道用于现场使用登记和同批溯源。',
site: '现场是发货的目的地,也是使用登记与退回的发起地。授权用户只能在自己负责的现场收货、使用和退回。',
line: '产线隶属于客户现场。现场使用登记时选择产线,用于按产线统计用量。',
warehouse: '仓库是入库、装填、发货与退回的归属地。授权用户只能在自己负责的仓库中作业,也只能看到这些仓库的数据。',
pallet: '托盘是循环使用的资产。在这里登记编号、二维码与容量,并随时看到每个托盘此刻在哪、处于哪个环节。',
tunnel: '隧道隶属于客户现场。托盘入隧后生成内部溯源号,同一隧道同一批次的托盘共享溯源号,用于同批追溯。',
// 业务作业
inbound: '空托盘首次入库登记。草稿可继续扫码修改,确认后托盘成为仓库空托。',
filling: '扫描仓库空托盘,登记菌种、生产批次与包数,完成后托盘变为仓库有料。',
shipment: '从仓库发往客户现场的托盘单据。确认后托盘进入在途,由现场扫码完成收货核对。',
usage: '客户现场扫码入隧并登记使用包数,系统按到货先后提示优先使用的托盘。',
return: '客户现场发起空托或余料退回申请,仓库扫码核对后确认退回。',
// 报表查询
palletInventory: '每个托盘当前所处的流转状态、位置与剩余包数。',
reportFilling: '按时间、仓库、菌种与批次查询工厂装填记录。',
reportShipment: '已确认发货的托盘明细,包含发货时的包数快照与客户现场。',
consumption: '客户现场按产线、隧道统计的实际使用包数。',
reportReturn: '退回申请与确认的托盘明细,可按溯源号与批次追溯。',
palletTrace: '托盘的每一次状态、位置与数量变化。点击托盘编号查看完整的托盘档案。'
}
@@ -0,0 +1,101 @@
<template>
<header class="sf-page-header">
<div class="sf-page-header__main">
<h1 class="sf-page-header__title">{{ resolvedTitle }}</h1>
<p v-if="resolvedDescription" class="sf-page-header__desc">{{ resolvedDescription }}</p>
<flow-stepper v-if="flow" :current="flow" />
<slot />
</div>
<div v-if="$slots.actions" class="sf-page-header__actions">
<slot name="actions" />
</div>
</header>
</template>
<script setup>
import FlowStepper from '@/components/FlowStepper'
import { PAGE_DESCRIPTIONS } from './descriptions'
const props = defineProps({
title: { type: String, default: '' },
description: { type: String, default: undefined },
// 业务作业页传入当前环节,标题下方显示托盘流程
flow: { type: String, default: '' }
})
const route = useRoute()
const resolvedTitle = computed(() => props.title || route.meta?.title || '')
// 未显式传入说明时,按页面路由末段读取统一的页面说明
const resolvedDescription = computed(() => {
if (props.description !== undefined) return props.description
const segment = route.path.split('/').filter(Boolean).pop()
return PAGE_DESCRIPTIONS[segment] || ''
})
</script>
<style lang="scss" scoped>
.sf-page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px 16px;
margin: 0 0 20px;
}
.sf-page-header__main {
display: flex;
flex: 1 1 420px;
flex-direction: column;
gap: 4px;
min-width: 0;
max-width: 720px;
}
.sf-page-header__title {
margin: 0;
color: var(--sf-text-1, #0f1115);
font-size: 22px;
font-weight: 600;
line-height: 1.3;
}
.sf-page-header__desc {
margin: 0;
color: var(--sf-text-3, #5b616e);
font-size: 13.5px;
line-height: 1.6;
}
.sf-page-header__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
@media (max-width: 767px) {
.sf-page-header {
margin-bottom: 16px;
}
.sf-page-header__title {
font-size: 19px;
}
.sf-page-header__actions {
width: 100%;
:deep(.el-button) {
flex: 1 1 auto;
min-height: 40px;
}
}
}
</style>
@@ -0,0 +1,33 @@
<template>
<router-link v-if="available && code" class="sf-pallet-link sf-mono" :to="palletPassportLocation(code)" :title="`查看 ${code} 的托盘档案`" @click.stop>
<slot>{{ code }}</slot>
</router-link>
<span v-else class="sf-mono"><slot>{{ code }}</slot></span>
</template>
<script setup>
import { PALLET_PASSPORT_PATH, palletPassportLocation } from '@/utils/palletPassport'
defineProps({
code: { type: String, default: '' }
})
const router = useRouter()
// 无托盘追溯权限时档案路由未注册,退化为纯文本
const available = computed(() => router.resolve(PALLET_PASSPORT_PATH).matched.some((record) => !record.path.includes(':pathMatch')))
</script>
<style scoped>
.sf-pallet-link {
color: var(--sf-text-1, #0f1115);
font-weight: 500;
text-decoration: underline;
text-decoration-color: var(--sf-border-strong, #dde0e4);
text-underline-offset: 3px;
}
.sf-pallet-link:hover {
color: var(--sf-accent, #007787);
text-decoration-color: currentColor;
}
</style>
@@ -0,0 +1,68 @@
<template>
<div class="sf-scan-feedback" :class="`is-${type}`" role="status" aria-live="polite">
<sf-icon :name="ICONS[type] || 'info'" :size="18" :stroke-width="2" />
<span class="sf-scan-feedback__text">
<strong v-if="code" class="sf-mono">{{ code }}</strong>
{{ message }}
</span>
<span v-if="$slots.default" class="sf-scan-feedback__extra"><slot /></span>
</div>
</template>
<script setup>
import SfIcon from '@/components/SfIcon'
defineProps({
// ok:已加入;warn:需要注意但未失败;error:被拒绝;idle:等待扫码
type: { type: String, default: 'idle' },
code: { type: String, default: '' },
message: { type: String, default: '' }
})
const ICONS = { ok: 'check', warn: 'alert', error: 'alert', idle: 'scan', info: 'info' }
</script>
<style lang="scss" scoped>
.sf-scan-feedback {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 10px 14px;
border-radius: var(--sf-radius-md, 10px);
background: var(--sf-surface-3, #f1f2f4);
color: var(--sf-text-2, #3d4350);
font-size: 13.5px;
line-height: 1.5;
&.is-ok {
background: var(--sf-accent-soft, #e6f2f3);
color: var(--sf-accent-strong, #005a66);
}
&.is-warn {
background: var(--sf-warn-bg, #fff3e8);
color: var(--sf-warn-fg, #b54708);
}
&.is-error {
background: var(--sf-danger-bg, #fef3f2);
color: var(--sf-danger-fg, #9f1a10);
}
}
.sf-scan-feedback__text {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
strong {
margin-right: 4px;
font-weight: 600;
}
}
.sf-scan-feedback__extra {
flex: none;
}
</style>
@@ -0,0 +1,116 @@
// 线性图标路径(24×24 视窗,描边绘制),供菜单与工作区页面统一使用
export const SF_ICONS = {
layout: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
search: '<circle cx="11" cy="11" r="7.5"/><path d="m20.5 20.5-4-4"/>',
scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/>',
route: '<circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="5" r="2.5"/><path d="M8.5 19h8a3.5 3.5 0 0 0 0-7h-9a3.5 3.5 0 0 1 0-7h8"/>',
inbound: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
layers: '<path d="m12 3 9 4.5-9 4.5-9-4.5L12 3Z"/><path d="m3 12 9 4.5 9-4.5"/><path d="m3 16.5 9 4.5 9-4.5"/>',
truck: '<path d="M14 17V6a1 1 0 0 0-1-1H3v12h2"/><path d="M14 9h4l3 4v4h-2"/><path d="M9 17h6"/><circle cx="7" cy="17.5" r="2"/><circle cx="17" cy="17.5" r="2"/>',
clipboard: '<rect x="8" y="2.5" width="8" height="4" rx="1"/><path d="M16 4.5h2a2 2 0 0 1 2 2V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6.5a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/>',
sprout: '<path d="M7 20h10"/><path d="M12 20v-8"/><path d="M12 12c0-4 2.5-6.5 7-7 0 4.5-2.5 7-7 7Z"/><path d="M12 14c0-3-2-5-6-5.5 0 3.5 2 5.5 6 5.5Z"/>',
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
warehouse: '<path d="M21 8.5V20H3V8.5L12 4l9 4.5Z"/><path d="M7 20v-7h10v7"/><path d="M7 16.5h10"/>',
chart: '<path d="M3 3v18h18"/><path d="M8 17v-4"/><path d="M13 17V7"/><path d="M18 17v-7"/>',
gauge: '<path d="M12 14 15.5 9.5"/><path d="M3.5 18a9 9 0 1 1 17 0"/><circle cx="12" cy="14" r="1.2"/>',
database: '<ellipse cx="12" cy="5.5" rx="8" ry="2.5"/><path d="M4 5.5v13c0 1.4 3.6 2.5 8 2.5s8-1.1 8-2.5v-13"/><path d="M4 12c0 1.4 3.6 2.5 8 2.5s8-1.1 8-2.5"/>',
flask: '<path d="M9.5 3v6.5L4.6 18a2 2 0 0 0 1.7 3h11.4a2 2 0 0 0 1.7-3l-4.9-8.5V3"/><path d="M8 3h8"/><path d="M7 15h10"/>',
building: '<rect x="4" y="3" width="16" height="18" rx="1.5"/><path d="M9 7h1M14 7h1M9 11h1M14 11h1M9 15h1M14 15h1"/><path d="M10 21v-3h4v3"/>',
pin: '<path d="M20 10c0 5-5.5 10.2-7.4 11.8a1 1 0 0 1-1.2 0C9.5 20.2 4 15 4 10a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>',
line: '<path d="M3 20h18"/><path d="M5 20v-8l4 3v-3l4 3v-3l4 3V6h2v14"/>',
package: '<path d="M21 8 12 3 3 8v8l9 5 9-5V8Z"/><path d="m3 8 9 5 9-5"/><path d="M12 13v8"/>',
tunnel: '<path d="M3 20v-7a9 9 0 0 1 18 0v7"/><path d="M7 20v-6a5 5 0 0 1 10 0v6"/><path d="M2 20h20"/>',
settings: '<path d="M20 7h-9"/><path d="M14 17H4"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>',
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7"/><path d="M18 14.2a6.5 6.5 0 0 1 3.5 5.8"/>',
shield: '<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3Z"/><path d="m9 12 2 2 4-4"/>',
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/>',
tree: '<rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/>',
badge: '<rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="12" cy="10" r="2.5"/><path d="M8 17a4 4 0 0 1 8 0"/>',
book: '<path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H20v15H6.5A1.5 1.5 0 0 0 5 19.5v-15Z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H20"/>',
sliders: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
megaphone: '<path d="M3 10v4a1 1 0 0 0 1 1h2l5 4V5L6 9H4a1 1 0 0 0-1 1Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>',
file: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9l-6-6Z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h5"/>',
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
activity: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
zap: '<path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/>',
lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
wrench: '<path d="M14.7 6.3a4 4 0 0 0 5 5L21 13l-8 8-3-3 6.7-6.7Z"/><path d="M14.7 6.3 9 12l-6 6 3 3 6-6"/>',
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
code: '<path d="m8 8-5 4 5 4"/><path d="m16 8 5 4-5 4"/><path d="m14 4-4 16"/>',
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
check: '<path d="M20 6 9 17l-5-5"/>',
x: '<path d="M18 6 6 18M6 6l12 12"/>',
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
'arrow-right': '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
'arrow-left': '<path d="m15 18-6-6 6-6"/>',
alert: '<path d="M10.3 3.9 2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
'check-circle': '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
refresh: '<path d="M20 11a8 8 0 0 0-14.7-4.3L3 9"/><path d="M3 4v5h5"/><path d="M4 13a8 8 0 0 0 14.7 4.3L21 15"/><path d="M21 20v-5h-5"/>',
printer: '<path d="M6 17H4a1 1 0 0 1-1-1v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5a1 1 0 0 1-1 1h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="13" width="12" height="8" rx="1"/>',
copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
calendar: '<rect x="3" y="4.5" width="18" height="16.5" rx="2"/><path d="M8 2.5v4M16 2.5v4M3 10h18"/>',
enter: '<path d="M20 5v7a3 3 0 0 1-3 3H5"/><path d="m9 11-4 4 4 4"/>'
}
// 菜单路由名(路径末段)到图标的映射;未命中时回退到菜单配置的 svg 图标
export const MENU_ICON_MAP = {
index: 'layout',
businessMaster: 'database',
strain: 'flask',
customer: 'building',
site: 'pin',
line: 'line',
warehouse: 'warehouse',
pallet: 'package',
tunnel: 'tunnel',
businessOperation: 'layers',
inbound: 'inbound',
filling: 'layers',
shipment: 'truck',
usage: 'sprout',
return: 'undo',
businessReport: 'chart',
palletInventory: 'warehouse',
reportFilling: 'layers',
reportShipment: 'truck',
consumption: 'gauge',
reportReturn: 'undo',
palletTrace: 'route',
system: 'settings',
user: 'user',
role: 'shield',
menu: 'menu',
dept: 'tree',
post: 'badge',
dict: 'book',
config: 'sliders',
notice: 'megaphone',
log: 'file',
operlog: 'file',
logininfor: 'login',
testBatch: 'flask',
monitor: 'activity',
online: 'users',
job: 'clock',
druid: 'database',
server: 'server',
cache: 'zap',
cacheList: 'zap',
transportCrypto: 'lock',
tool: 'wrench',
build: 'grid',
gen: 'code',
swagger: 'book'
}
export function resolveMenuIcon(path) {
if (!path || typeof path !== 'string') return ''
const segment = path.split('/').filter(Boolean).pop() || 'index'
return MENU_ICON_MAP[segment] || ''
}
@@ -0,0 +1,37 @@
<template>
<svg
class="sf-icon"
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
focusable="false"
v-html="paths"
/>
</template>
<script setup>
import { SF_ICONS } from './icons'
const props = defineProps({
name: { type: String, required: true },
size: { type: [Number, String], default: 16 },
strokeWidth: { type: [Number, String], default: 1.8 }
})
// 路径仅来自本地常量表,不接收外部输入
const paths = computed(() => SF_ICONS[props.name] || '')
</script>
<style scoped>
.sf-icon {
display: inline-block;
flex: none;
vertical-align: middle;
}
</style>
@@ -46,73 +46,25 @@ async function syncTestBatchScope() {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 内容面板负责滚动,app-main 只占据页头以下的空间 */
.app-main { .app-main {
/* 50= navbar 50 */ min-height: calc(100% - 56px);
min-height: calc(100vh - 50px);
width: 100%; width: 100%;
position: relative; position: relative;
overflow: hidden; background-color: var(--el-bg-color-page);
background-color: var(--el-bg-color-page, #f5f7fb);
}
.fixed-header + .app-main {
overflow-y: auto;
scrollbar-gutter: auto;
height: calc(100vh - 50px);
min-height: 0px;
} }
.app-main:has(.copyright) { .app-main:has(.copyright) {
padding-bottom: 36px; padding-bottom: 36px;
} }
.fixed-header + .app-main { .hasTagsView .app-main {
margin-top: 50px; min-height: calc(100% - 92px);
} }
.hasTagsView {
.app-main {
/* 84 = navbar + tags-view = 50 + 34 */
min-height: calc(100vh - 84px);
}
.fixed-header + .app-main {
margin-top: 84px;
height: calc(100vh - 84px);
min-height: 0px;
}
}
/* 移动端fixed-header优化 */
@media screen and (max-width: 991px) { @media screen and (max-width: 991px) {
.fixed-header + .app-main { .app-main {
padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px)); padding-bottom: max(24px, env(safe-area-inset-bottom));
padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
overscroll-behavior-y: none;
}
.hasTagsView .fixed-header + .app-main {
padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
overscroll-behavior-y: none;
}
}
@supports (-webkit-touch-callout: none) {
@media screen and (max-width: 991px) {
.fixed-header + .app-main {
padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
height: calc(100svh - 50px);
height: calc(100dvh - 50px);
}
.hasTagsView .fixed-header + .app-main {
padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
height: calc(100svh - 84px);
height: calc(100dvh - 84px);
}
} }
} }
</style> </style>
@@ -0,0 +1,376 @@
<template>
<teleport to="body">
<transition name="sf-palette-fade">
<div v-if="visible" class="sf-palette-overlay" @mousedown.self="close">
<div class="sf-palette" role="dialog" aria-modal="true" aria-label="搜索或扫码">
<div class="sf-palette__input">
<sf-icon name="scan" :size="18" />
<label for="sf-palette-input" class="sf-visually-hidden">搜索页面或扫描托盘码</label>
<input
id="sf-palette-input"
ref="inputRef"
v-model="keyword"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="扫描托盘码,或搜索页面名称"
role="combobox"
aria-controls="sf-palette-list"
:aria-expanded="results.length > 0"
:aria-activedescendant="results.length ? `sf-palette-item-${activeIndex}` : undefined"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="choose(results[activeIndex])"
@keydown.esc.prevent="close"
/>
<kbd class="sf-palette__kbd">Esc</kbd>
</div>
<ul v-if="results.length" id="sf-palette-list" class="sf-palette__list" role="listbox">
<li
v-for="(item, index) in results"
:id="`sf-palette-item-${index}`"
:key="item.key"
role="option"
:aria-selected="index === activeIndex"
:class="['sf-palette__item', { 'is-active': index === activeIndex }]"
@mousemove="activeIndex = index"
@click="choose(item)"
>
<span class="sf-palette__item-icon"><sf-icon :name="item.icon" :size="16" /></span>
<span class="sf-palette__item-main">
<span class="sf-palette__item-title" :class="{ 'sf-mono': item.mono }">{{ item.title }}</span>
<span v-if="item.description" class="sf-palette__item-desc">{{ item.description }}</span>
</span>
<span class="sf-palette__item-kind">{{ item.kind }}</span>
</li>
</ul>
<div v-else class="sf-palette__empty">
<template v-if="keyword.trim()">未找到与「{{ keyword.trim() }}」相关的页面</template>
<template v-else>输入页面名称快速跳转,或直接扫描托盘码查看托盘档案</template>
</div>
<div class="sf-palette__footer">
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
<span><kbd>Enter</kbd> 打开</span>
<span class="sf-palette__footer-tip">随时按 <kbd>Ctrl</kbd><kbd>K</kbd> 唤起</span>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup>
import SfIcon from '@/components/SfIcon'
import { resolveMenuIcon } from '@/components/SfIcon/icons'
import auth from '@/plugins/auth'
import usePermissionStore from '@/store/modules/permission'
import { getNormalPath } from '@/utils/shierfeng'
import { isHttp } from '@/utils/validate'
import { palletPassportLocation } from '@/utils/palletPassport'
const MAX_PAGE_RESULTS = 8
const router = useRouter()
const permissionStore = usePermissionStore()
const visible = ref(false)
const keyword = ref('')
const activeIndex = ref(0)
const inputRef = ref(null)
let restoreFocus = null
function flattenRoutes(routes, basePath = '', group = '') {
const pages = []
for (const item of routes || []) {
if (item.hidden) continue
const segment = item.path && item.path[0] === '/' ? item.path : `/${item.path || ''}`
const path = isHttp(item.path) ? item.path : getNormalPath(basePath + segment)
const title = item.meta?.title
const visibleChildren = (item.children || []).filter((child) => !child.hidden)
if (visibleChildren.length) {
pages.push(...flattenRoutes(visibleChildren, path, title || group))
} else if (title) {
pages.push({ path, title, group, query: item.query })
}
}
return pages
}
const pages = computed(() => flattenRoutes(permissionStore.sidebarRouters))
const results = computed(() => {
const term = keyword.value.trim()
const lowered = term.toLowerCase()
const items = []
// 含中文的输入视为页面搜索,只有像托盘码的输入才提供托盘档案入口
const looksLikeCode = term && !/[\u4e00-\u9fa5]/.test(term)
if (looksLikeCode && auth.hasPermi('report:pallettrace:list')) {
items.push({
key: `pallet:${term}`,
kind: '托盘档案',
icon: 'route',
title: term,
mono: true,
description: '查看该托盘的当前状态与完整流转记录',
action: () => router.push(palletPassportLocation(term))
})
}
const matchedPages = term
? pages.value.filter((page) => `${page.title}${page.group}${page.path}`.toLowerCase().includes(lowered))
: pages.value
matchedPages.slice(0, MAX_PAGE_RESULTS).forEach((page) => {
items.push({
key: `page:${page.path}`,
kind: '页面',
icon: resolveMenuIcon(page.path) || 'arrow-right',
title: page.title,
description: page.group,
action: () => openPage(page)
})
})
return items
})
watch(results, () => {
activeIndex.value = 0
})
function openPage(page) {
if (isHttp(page.path)) {
window.open(page.path, '_blank', 'noopener')
return
}
if (page.query) {
router.push({ path: page.path, query: JSON.parse(page.query) })
} else {
router.push(page.path)
}
}
function move(step) {
const count = results.value.length
if (!count) return
activeIndex.value = (activeIndex.value + step + count) % count
}
function choose(item) {
if (!item) return
close()
item.action()
}
function open() {
if (visible.value) return
restoreFocus = document.activeElement
keyword.value = ''
visible.value = true
nextTick(() => inputRef.value?.focus())
}
function close() {
if (!visible.value) return
visible.value = false
if (restoreFocus && typeof restoreFocus.focus === 'function') {
restoreFocus.focus()
}
restoreFocus = null
}
function handleShortcut(event) {
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
visible.value ? close() : open()
}
}
onMounted(() => window.addEventListener('keydown', handleShortcut))
onBeforeUnmount(() => window.removeEventListener('keydown', handleShortcut))
defineExpose({ open, close })
</script>
<style lang="scss" scoped>
.sf-palette-overlay {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 12vh 16px 16px;
background: rgba(15, 17, 21, 0.28);
}
.sf-palette {
display: flex;
flex-direction: column;
width: 100%;
max-width: 640px;
max-height: 70vh;
overflow: hidden;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 14px;
background: var(--sf-surface, #fff);
box-shadow: 0 24px 64px rgba(16, 24, 40, 0.24);
color: var(--sf-text-1, #0f1115);
font-family: var(--sf-font-sans);
}
.sf-palette__input {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
color: var(--sf-accent, #007787);
input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--sf-text-1, #0f1115);
font: inherit;
font-size: 15px;
&::placeholder {
color: var(--el-text-color-placeholder, #8a909b);
}
}
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 5px;
background: var(--sf-surface-2, #f7f8f9);
color: var(--sf-text-3, #5b616e);
font-family: var(--sf-font-mono);
font-size: 11px;
line-height: 1;
}
.sf-palette__list {
flex: 1;
margin: 0;
padding: 6px;
overflow-y: auto;
list-style: none;
}
.sf-palette__item {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 10px;
border-radius: 9px;
cursor: pointer;
&.is-active {
background: var(--sf-surface-2, #f7f8f9);
}
}
.sf-palette__item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--sf-surface-3, #f1f2f4);
color: var(--sf-text-3, #5b616e);
.is-active & {
background: var(--sf-accent-soft, #e6f3f5);
color: var(--sf-accent, #007787);
}
}
.sf-palette__item-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
line-height: 1.4;
}
.sf-palette__item-title {
overflow: hidden;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.sf-palette__item-desc {
overflow: hidden;
color: var(--sf-text-4, #6b717d);
font-size: 12.5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.sf-palette__item-kind {
flex: none;
color: var(--sf-text-4, #6b717d);
font-size: 12px;
}
.sf-palette__empty {
padding: 28px 20px;
color: var(--sf-text-3, #5b616e);
font-size: 13.5px;
text-align: center;
}
.sf-palette__footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
border-top: 1px solid var(--sf-border-soft, #eef0f2);
color: var(--sf-text-4, #6b717d);
font-size: 12px;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.sf-palette__footer-tip {
margin-left: auto;
}
.sf-palette-fade-enter-active,
.sf-palette-fade-leave-active {
transition: opacity 0.15s ease;
}
.sf-palette-fade-enter-from,
.sf-palette-fade-leave-to {
opacity: 0;
}
@media (max-width: 767px) {
.sf-palette-overlay {
padding-top: 8vh;
}
.sf-palette__footer {
display: none;
}
}
</style>
@@ -9,13 +9,18 @@
</template> </template>
<div class="right-menu"> <div class="right-menu">
<button type="button" class="right-menu-item search-trigger" aria-label="搜索或扫码(Ctrl K)" @click="openSearch">
<sf-icon name="search" :size="15" />
<span class="search-trigger__text">搜索或扫码</span>
<kbd class="search-trigger__kbd">Ctrl K</kbd>
</button>
<test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" /> <test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" />
<template v-if="appStore.device !== 'mobile'"> <template v-if="appStore.device !== 'mobile'">
<header-search id="header-search" class="right-menu-item" /> <el-tooltip content="全屏" effect="dark" placement="bottom">
<screenfull id="screenfull" class="right-menu-item hover-effect" />
</el-tooltip>
<screenfull id="screenfull" class="right-menu-item hover-effect" /> <el-tooltip :content="settingsStore.isDark ? '切换浅色模式' : '切换深色模式'" effect="dark" placement="bottom">
<el-tooltip content="主题模式" effect="dark" placement="bottom">
<div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme"> <div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
<svg-icon v-if="settingsStore.isDark" icon-class="sunny" /> <svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
<svg-icon v-if="!settingsStore.isDark" icon-class="moon" /> <svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
@@ -27,9 +32,9 @@
</el-tooltip> </el-tooltip>
</template> </template>
<el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover"> <el-dropdown v-if="!sidebarShowsUser" @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
<div class="avatar-wrapper"> <div class="avatar-wrapper">
<img :src="userStore.avatar" class="user-avatar" /> <img :src="userStore.avatar" class="user-avatar" alt="" />
<span class="user-nickname"> {{ userStore.nickName }} </span> <span class="user-nickname"> {{ userStore.nickName }} </span>
</div> </div>
<template #dropdown> <template #dropdown>
@@ -59,7 +64,7 @@ import Logo from './Sidebar/Logo'
import Hamburger from '@/components/Hamburger' import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull' import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect' import SizeSelect from '@/components/SizeSelect'
import HeaderSearch from '@/components/HeaderSearch' import SfIcon from '@/components/SfIcon'
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch' import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
import useAppStore from '@/store/modules/app' import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user' import useUserStore from '@/store/modules/user'
@@ -68,6 +73,8 @@ import useSettingsStore from '@/store/modules/settings'
const appStore = useAppStore() const appStore = useAppStore()
const userStore = useUserStore() const userStore = useUserStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
// 左侧导航底部已展示当前用户时,顶部不再重复显示头像
const sidebarShowsUser = computed(() => appStore.device !== 'mobile' && !appStore.sidebar.hide && settingsStore.navType == 1)
function toggleSideBar() { function toggleSideBar() {
appStore.toggleSideBar() appStore.toggleSideBar()
@@ -98,11 +105,15 @@ function logout() {
}).catch(() => { }) }).catch(() => { })
} }
const emits = defineEmits(['setLayout']) const emits = defineEmits(['setLayout', 'openSearch'])
function setLayout() { function setLayout() {
emits('setLayout') emits('setLayout')
} }
function openSearch() {
emits('openSearch')
}
async function toggleTheme(event) { async function toggleTheme(event) {
const x = event?.clientX || window.innerWidth / 2 const x = event?.clientX || window.innerWidth / 2
const y = event?.clientY || window.innerHeight / 2 const y = event?.clientY || window.innerHeight / 2
@@ -152,35 +163,46 @@ async function toggleTheme(event) {
} }
.navbar { .navbar {
height: 50px; height: 56px;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: var(--navbar-bg); background: var(--navbar-bg);
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed); border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
box-shadow: 0 1px 4px rgba(15, 34, 58, 0.04);
display: flex; display: flex;
align-items: center; align-items: center;
// padding: 0 8px; gap: 4px;
padding: 0 12px 0 8px;
box-sizing: border-box; box-sizing: border-box;
.hamburger-container { .hamburger-container {
line-height: 46px; height: 34px;
height: 100%; border-radius: 8px;
cursor: pointer; cursor: pointer;
transition: background 0.3s; color: var(--sf-text-3, #5b616e);
transition: background 0.15s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
margin-right: 8px; margin-right: 4px;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.025); background: var(--sf-hover, rgba(15, 17, 21, 0.045));
}
:deep(div) {
padding: 0 8px !important;
}
:deep(.hamburger) {
width: 18px;
height: 18px;
} }
} }
.breadcrumb-container { .breadcrumb-container {
flex-shrink: 0; flex-shrink: 0;
margin-left: 4px;
} }
.topmenu-container { .topmenu-container {
@@ -197,16 +219,11 @@ async function toggleTheme(event) {
margin-left: 8px; margin-left: 8px;
} }
.errLog-container {
display: inline-block;
vertical-align: top;
}
.right-menu { .right-menu {
height: 100%; height: 100%;
line-height: 50px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 2px;
margin-left: auto; margin-left: auto;
min-width: 0; min-width: 0;
flex-shrink: 1; flex-shrink: 1;
@@ -220,75 +237,94 @@ async function toggleTheme(event) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 34px;
height: 34px;
padding: 0 8px; padding: 0 8px;
height: 100%; border-radius: 8px;
font-size: 18px; font-size: 17px;
color: var(--el-text-color-regular, #5a5e66); color: var(--sf-text-3, #5b616e);
vertical-align: middle; vertical-align: middle;
&.hover-effect { &.hover-effect {
cursor: pointer; cursor: pointer;
transition: background 0.3s, color 0.2s; transition: background 0.15s, color 0.15s;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.035); background: var(--sf-hover, rgba(15, 17, 21, 0.045));
color: var(--el-color-primary, #008596); color: var(--sf-text-1, #0f1115);
} }
} }
&.theme-switch-wrapper { &.theme-switch-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
}
}
svg { .search-trigger {
transition: transform 0.3s; gap: 8px;
width: 248px;
height: 34px;
margin-right: 8px;
padding: 0 8px 0 10px;
border: 1px solid var(--sf-border-strong, #dde0e4);
background: var(--sf-surface, #fff);
color: var(--sf-text-4, #6b717d);
font: inherit;
font-size: 13px;
cursor: pointer;
justify-content: flex-start;
transition: border-color 0.15s;
&:hover { &:hover {
transform: scale(1.15); border-color: #c3c8cf;
} }
}
.search-trigger__text {
flex: 1;
text-align: left;
}
.search-trigger__kbd {
padding: 1px 6px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 5px;
font-family: var(--sf-font-mono);
font-size: 11px;
color: var(--sf-text-4, #6b717d);
} }
} }
.avatar-container { .avatar-container {
margin-right: 4px; margin-left: 4px;
padding: 0 4px; padding: 0;
.avatar-wrapper { .avatar-wrapper {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 4px 8px; padding: 2px 8px 2px 2px;
border-radius: 20px; border-radius: 18px;
transition: background 0.25s ease; transition: background 0.15s ease;
&:hover { &:hover {
background: rgba(0, 0, 0, 0.04); background: var(--sf-hover, rgba(15, 17, 21, 0.045));
} }
.user-avatar { .user-avatar {
cursor: pointer; cursor: pointer;
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 1.5px solid var(--el-color-primary-light-7, #b2e0e6); border: 1px solid var(--sf-border, #e7e9ec);
box-shadow: 0 2px 6px rgba(0, 133, 150, 0.12);
} }
.user-nickname { .user-nickname {
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--el-text-color-primary, #303133); color: var(--sf-text-1, #0f1115);
}
i {
cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
} }
} }
} }
@@ -297,7 +333,20 @@ async function toggleTheme(event) {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
margin-right: 6px;
font-size: 14px; font-size: 14px;
color: inherit;
}
}
}
@media screen and (max-width: 1200px) {
.navbar .right-menu .search-trigger {
width: auto;
.search-trigger__text,
.search-trigger__kbd {
display: none;
} }
} }
} }
@@ -315,6 +364,11 @@ async function toggleTheme(event) {
padding: 0; padding: 0;
} }
.search-trigger {
margin-right: 2px;
border: 0;
}
.avatar-container .avatar-wrapper .user-nickname { .avatar-container .avatar-wrapper .user-nickname {
display: none; display: none;
} }
@@ -5,7 +5,9 @@
<img :src="logoMark" :alt="title" class="sidebar-logo sidebar-logo-mark" /> <img :src="logoMark" :alt="title" class="sidebar-logo sidebar-logo-mark" />
</router-link> </router-link>
<router-link v-else key="expand" class="sidebar-logo-link" :aria-label="title" to="/"> <router-link v-else key="expand" class="sidebar-logo-link" :aria-label="title" to="/">
<img :src="logo" :alt="title" class="sidebar-logo sidebar-logo-full" /> <img :src="onDarkSurface ? logo : logoLight" alt="Sylvan" class="sidebar-logo sidebar-logo-full" />
<span class="sidebar-logo-divider" aria-hidden="true" />
<span class="sidebar-logo-title">{{ shortTitle }}</span>
</router-link> </router-link>
</transition> </transition>
</div> </div>
@@ -13,8 +15,9 @@
<script setup> <script setup>
import logo from '@/assets/logo/sylvan-logo.svg' import logo from '@/assets/logo/sylvan-logo.svg'
import logoLight from '@/assets/logo/sylvan-logo-light.svg'
import logoMark from '@/assets/logo/sylvan-mark.svg' import logoMark from '@/assets/logo/sylvan-mark.svg'
import variables from '@/assets/styles/variables.module.scss' import useSettingsStore from '@/store/modules/settings'
defineProps({ defineProps({
collapse: { collapse: {
@@ -23,8 +26,11 @@ defineProps({
} }
}) })
const title = import.meta.env.VITE_APP_TITLE; const settingsStore = useSettingsStore()
const logoBackground = variables.menuBg; const title = import.meta.env.VITE_APP_TITLE
const shortTitle = (title || '').replace(/管理系统$/, '') || title
// 原版横版标识文字为白色,仅用于深色侧栏;浅色侧栏使用品牌深蓝文字版本
const onDarkSurface = computed(() => settingsStore.isDark || settingsStore.sideTheme === 'theme-dark')
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -39,21 +45,18 @@ const logoBackground = variables.menuBg;
.sidebar-logo-container { .sidebar-logo-container {
position: relative; position: relative;
height: 50px; flex: none;
line-height: 50px; height: 60px;
background: v-bind(logoBackground);
text-align: center;
overflow: hidden; overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
& .sidebar-logo-link { & .sidebar-logo-link {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 10px;
height: 100%; height: 100%;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 4px 12px; padding: 0 20px;
& .sidebar-logo { & .sidebar-logo {
display: block; display: block;
@@ -61,20 +64,46 @@ const logoBackground = variables.menuBg;
} }
& .sidebar-logo-full { & .sidebar-logo-full {
width: 132px; width: auto;
height: 42px; height: 30px;
} }
& .sidebar-logo-mark { & .sidebar-logo-mark {
width: 32px; width: 30px;
height: 32px; height: 30px;
} }
} }
&.collapse { .sidebar-logo-divider {
.sidebar-logo-link { flex: none;
padding: 4px; width: 1px;
} height: 18px;
background: var(--sf-border-strong, #dde0e4);
}
.sidebar-logo-title {
overflow: hidden;
color: var(--sf-text-1, #0f1115);
font-size: 14px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
&.collapse .sidebar-logo-link {
justify-content: center;
padding: 0;
}
}
.theme-dark .sidebar-logo-container {
.sidebar-logo-divider {
background: rgba(255, 255, 255, 0.16);
}
.sidebar-logo-title {
color: #ffffff;
} }
} }
</style> </style>
@@ -3,7 +3,8 @@
<template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow"> <template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)"> <app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }"> <el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
<svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"/> <sf-icon v-if="menuIcon(resolvePath(onlyOneChild.path))" class="sf-menu-icon" :name="menuIcon(resolvePath(onlyOneChild.path))" />
<svg-icon v-else :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"/>
<template #title><span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span></template> <template #title><span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span></template>
</el-menu-item> </el-menu-item>
</app-link> </app-link>
@@ -11,7 +12,8 @@
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported> <el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
<template v-if="item.meta" #title> <template v-if="item.meta" #title>
<svg-icon :icon-class="item.meta && item.meta.icon" /> <sf-icon v-if="menuIcon(resolvePath(item.path))" class="sf-menu-icon" :name="menuIcon(resolvePath(item.path))" />
<svg-icon v-else :icon-class="item.meta && item.meta.icon" />
<span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span> <span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
</template> </template>
@@ -30,6 +32,8 @@
<script setup> <script setup>
import { isExternal } from '@/utils/validate' import { isExternal } from '@/utils/validate'
import AppLink from './Link' import AppLink from './Link'
import SfIcon from '@/components/SfIcon'
import { resolveMenuIcon } from '@/components/SfIcon/icons'
import { getNormalPath } from '@/utils/shierfeng' import { getNormalPath } from '@/utils/shierfeng'
const props = defineProps({ const props = defineProps({
@@ -90,6 +94,10 @@ function resolvePath(routePath, routeQuery) {
return getNormalPath(props.basePath + '/' + routePath) return getNormalPath(props.basePath + '/' + routePath)
} }
function menuIcon(target) {
return resolveMenuIcon(typeof target === 'string' ? target : target?.path)
}
function hasTitle(title){ function hasTitle(title){
if (title.length > 5) { if (title.length > 5) {
return title; return title;
@@ -0,0 +1,186 @@
<template>
<div class="sidebar-user" :class="{ 'is-collapse': collapse }">
<el-dropdown trigger="click" placement="top-start" popper-class="sidebar-user-menu" @command="handleCommand">
<button type="button" class="sidebar-user__trigger" :aria-label="`当前用户 ${displayName},打开账户菜单`">
<img :src="userStore.avatar" class="sidebar-user__avatar" alt="" />
<span v-if="!collapse" class="sidebar-user__text">
<span class="sidebar-user__name">{{ displayName }}</span>
<span class="sidebar-user__meta">{{ subtitle }}</span>
</span>
<svg v-if="!collapse" class="sidebar-user__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg>
</button>
<template #dropdown>
<el-dropdown-menu>
<div class="sidebar-user-menu__head">
<strong>{{ displayName }}</strong>
<span>{{ userStore.name }}</span>
</div>
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
<el-dropdown-item v-if="settingsStore.showSettings" command="setLayout">布局设置</el-dropdown-item>
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { ElMessageBox } from 'element-plus'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'
defineProps({
collapse: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['setLayout'])
const router = useRouter()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
const displayName = computed(() => userStore.nickName || userStore.name || '未命名用户')
// 优先展示部门与角色,缺失时回退到登录账号
const subtitle = computed(() => [userStore.deptName, userStore.roleNames[0]].filter(Boolean).join(' · ') || userStore.name)
function handleCommand(command) {
if (command === 'profile') {
router.push('/user/profile')
} else if (command === 'setLayout') {
emit('setLayout')
} else if (command === 'logout') {
logout()
}
}
function logout() {
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logOut().then(() => {
location.href = '/index'
})
}).catch(() => { })
}
</script>
<style lang="scss" scoped>
.sidebar-user {
flex: none;
padding: 8px 10px 12px;
border-top: 1px solid var(--sf-border-soft, #eef0f2);
:deep(.el-dropdown) {
display: block;
width: 100%;
}
}
.sidebar-user__trigger {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--sf-menu-active-text, #0f1115);
font: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
&:hover,
&[aria-expanded='true'] {
background: var(--sf-menu-hover, rgba(15, 17, 21, 0.045));
}
}
.sidebar-user__avatar {
flex: none;
width: 32px;
height: 32px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 50%;
object-fit: cover;
}
.sidebar-user__text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
line-height: 1.35;
}
.sidebar-user__name {
overflow: hidden;
font-size: 13.5px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.sidebar-user__meta {
overflow: hidden;
color: var(--sf-menu-group, #6b717d);
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.sidebar-user__chevron {
flex: none;
color: var(--sf-menu-icon, #6b717d);
}
.is-collapse {
padding: 8px 0 12px;
.sidebar-user__trigger {
justify-content: center;
width: 44px;
margin: 0 auto;
padding: 6px;
}
}
.theme-dark .sidebar-user {
border-top-color: rgba(255, 255, 255, 0.08);
}
</style>
<style lang="scss">
.sidebar-user-menu {
min-width: 200px;
.sidebar-user-menu__head {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 12px 10px;
margin-bottom: 4px;
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
line-height: 1.4;
strong {
color: var(--sf-text-1, #303133);
font-size: 13.5px;
font-weight: 600;
}
span {
color: var(--sf-text-4, #909399);
font-size: 12px;
}
}
}
</style>
@@ -1,17 +1,15 @@
<template> <template>
<div :class="{ 'has-logo': showLogo }" class="sidebar-container"> <div :class="['sidebar-container', sideThemeClass, { 'has-logo': showLogo }]">
<logo v-if="showLogo" :collapse="isCollapse" /> <logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper"> <el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu <el-menu
:default-active="activeMenu" :default-active="activeMenu"
:default-openeds="defaultOpeneds"
:collapse="isCollapse" :collapse="isCollapse"
:background-color="getMenuBackground" :unique-opened="false"
:text-color="getMenuTextColor"
:unique-opened="true"
:active-text-color="theme"
:collapse-transition="false" :collapse-transition="false"
mode="vertical" mode="vertical"
:class="sideTheme" class="sf-menu"
> >
<sidebar-item <sidebar-item
v-for="(route, index) in sidebarRouters" v-for="(route, index) in sidebarRouters"
@@ -21,84 +19,45 @@
/> />
</el-menu> </el-menu>
</el-scrollbar> </el-scrollbar>
<user-card :collapse="isCollapse" @setLayout="emit('setLayout')" />
</div> </div>
</template> </template>
<script setup> <script setup>
import Logo from './Logo' import Logo from './Logo'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
import variables from '@/assets/styles/variables.module.scss' import UserCard from './UserCard'
import useAppStore from '@/store/modules/app' import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings' import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission' import usePermissionStore from '@/store/modules/permission'
import { getNormalPath } from '@/utils/shierfeng'
const route = useRoute(); // 业务作业与查询分组默认展开,其余分组按需展开
const DEFAULT_OPEN_GROUPS = ['/businessOperation', '/businessReport']
const emit = defineEmits(['setLayout'])
const route = useRoute()
const appStore = useAppStore() const appStore = useAppStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore() const permissionStore = usePermissionStore()
const sidebarRouters = computed(() => permissionStore.sidebarRouters); const sidebarRouters = computed(() => permissionStore.sidebarRouters)
const showLogo = computed(() => settingsStore.sidebarLogo); const showLogo = computed(() => settingsStore.sidebarLogo)
const sideTheme = computed(() => settingsStore.sideTheme); const isCollapse = computed(() => !appStore.sidebar.opened)
const theme = computed(() => settingsStore.theme); const sideThemeClass = computed(() => (settingsStore.sideTheme === 'theme-dark' && !settingsStore.isDark ? 'theme-dark' : 'theme-light'))
const isCollapse = computed(() => !appStore.sidebar.opened);
// 获取菜单背景色 // 与 SidebarItem.resolvePath 对一级目录生成的 index 保持一致
const getMenuBackground = computed(() => { const defaultOpeneds = computed(() =>
if (settingsStore.isDark) { sidebarRouters.value
return 'var(--sidebar-bg)'; .filter((item) => !item.hidden && DEFAULT_OPEN_GROUPS.includes(item.path))
} .map((item) => getNormalPath(`${item.path}/${item.path}`))
return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg; )
});
// 获取菜单文字颜色
const getMenuTextColor = computed(() => {
if (settingsStore.isDark) {
return 'var(--sidebar-text)';
}
return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText;
});
const activeMenu = computed(() => { const activeMenu = computed(() => {
const { meta, path } = route; const { meta, path } = route
if (meta.activeMenu) { if (meta.activeMenu) {
return meta.activeMenu; return meta.activeMenu
} }
return path; return path
}); })
</script> </script>
<style lang="scss" scoped>
.sidebar-container {
background-color: v-bind(getMenuBackground);
.scrollbar-wrapper {
background-color: v-bind(getMenuBackground);
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
.el-menu-item, .el-sub-menu__title {
&:hover {
background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
}
}
.el-menu-item {
color: v-bind(getMenuTextColor);
&.is-active {
color: var(--menu-active-text, #409eff);
background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
}
}
.el-sub-menu__title {
color: v-bind(getMenuTextColor);
}
}
}
</style>
@@ -87,8 +87,7 @@ function isActive(r) {
function activeStyle(tag) { function activeStyle(tag) {
if (!isActive(tag)) return {}; if (!isActive(tag)) return {};
return { return {
"background-color": theme.value || '#008596', "border-color": 'var(--sf-border-strong, #dde0e4)'
"border-color": theme.value || '#008596'
}; };
} }
function isAffix(tag) { function isAffix(tag) {
@@ -241,11 +240,10 @@ function handleScroll() {
<style lang="scss" scoped> <style lang="scss" scoped>
.tags-view-container { .tags-view-container {
height: 34px; height: 36px;
width: 100%; width: 100%;
background: var(--tags-bg, #fff); background: var(--tags-bg, #fff);
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed); border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
box-shadow: 0 1px 4px 0 rgba(15, 34, 58, 0.04);
.tags-view-wrapper { .tags-view-wrapper {
.tags-view-item { .tags-view-item {
display: inline-block; display: inline-block;
@@ -272,26 +270,25 @@ function handleScroll() {
} }
&:not(.active):hover { &:not(.active):hover {
background-color: var(--el-fill-color-light, #f5f7fa); background-color: var(--sf-surface-2, #f7f8f9);
border-color: var(--el-color-primary-light-7, #b2e0e6); color: var(--sf-text-1, #0f1115);
color: var(--el-color-primary, #008596);
} }
&.active { &.active {
background-color: #008596; background-color: var(--sf-surface-3, #f1f2f4);
color: #fff; color: var(--sf-text-1, #0f1115);
border-color: #008596; font-weight: 500;
box-shadow: 0 2px 6px rgba(0, 133, 150, 0.22);
&::before { &::before {
content: ''; content: '';
background: #fff; background: var(--sf-accent, #007787);
display: inline-block; display: inline-block;
width: 7px; width: 6px;
height: 7px; height: 6px;
border-radius: 50%; border-radius: 50%;
position: relative; position: relative;
margin-right: 6px; margin-right: 6px;
vertical-align: 1px;
} }
} }
} }
@@ -0,0 +1,92 @@
<template>
<div v-if="testBatchStore.isTestMode" class="test-mode-banner" role="status">
<sf-icon name="flask" :size="16" />
<strong>测试数据模式</strong>
<span class="test-mode-banner__text">
当前批次 {{ testBatchStore.currentBatchName || '已选批次' }} · 本模式下的业务操作只写入测试批次,不影响正式库存
</span>
<button type="button" class="test-mode-banner__exit" :disabled="leaving" @click="leaveTestMode">退出测试模式</button>
</div>
</template>
<script setup>
import { ElMessageBox } from 'element-plus'
import SfIcon from '@/components/SfIcon'
import useTestBatchStore from '@/store/modules/testBatch'
const testBatchStore = useTestBatchStore()
const leaving = ref(false)
// 与顶部模式切换保持一致:先确认会清空未提交内容,再退出测试批次
async function leaveTestMode() {
if (leaving.value) return
leaving.value = true
try {
await ElMessageBox.confirm('切换数据模式将清空当前页面未提交内容', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
testBatchStore.leaveTestMode()
} catch {
// 用户取消,保持测试模式
} finally {
leaving.value = false
}
}
</script>
<style lang="scss" scoped>
.test-mode-banner {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 10px;
padding: 8px 24px;
border-bottom: 1px solid var(--sf-warn-border, #f9ddba);
background: var(--sf-warn-bg, #fff3e8);
color: var(--sf-warn-fg, #b54708);
font-size: 13px;
line-height: 1.5;
strong {
font-weight: 600;
}
}
.test-mode-banner__text {
min-width: 0;
}
.test-mode-banner__exit {
margin-left: auto;
height: 28px;
padding: 0 10px;
border: 1px solid var(--sf-warn-border, #f9ddba);
border-radius: 7px;
background: var(--sf-surface, #fff);
color: inherit;
font: inherit;
font-size: 12.5px;
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--sf-warn-dot, #f79009);
}
&:disabled {
cursor: progress;
opacity: 0.7;
}
}
@media (max-width: 767px) {
.test-mode-banner {
padding: 8px 16px;
}
.test-mode-banner__text {
display: none;
}
}
</style>
+75 -28
View File
@@ -1,32 +1,42 @@
<template> <template>
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }"> <div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/> <div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
<sidebar v-if="!sidebar.hide" class="sidebar-container" /> <sidebar v-if="!sidebar.hide" class="sidebar-container" @setLayout="setLayout" />
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container"> <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
<div :class="{ 'fixed-header': fixedHeader }"> <div ref="panelRef" class="sf-panel">
<navbar @setLayout="setLayout" /> <div :class="{ 'fixed-header': fixedHeader }" class="sf-panel__header">
<tags-view v-if="needTagsView" /> <test-mode-banner />
<navbar @setLayout="setLayout" @openSearch="openCommandPalette" />
<tags-view v-if="needTagsView" />
</div>
<app-main />
</div> </div>
<app-main />
<settings ref="settingRef" /> <settings ref="settingRef" />
</div> </div>
<command-palette ref="commandPaletteRef" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import Sidebar from './components/Sidebar/index.vue' import Sidebar from './components/Sidebar/index.vue'
import TestModeBanner from './components/TestModeBanner/index.vue'
import CommandPalette from './components/CommandPalette/index.vue'
import { AppMain, Navbar, Settings, TagsView } from './components' import { AppMain, Navbar, Settings, TagsView } from './components'
import useAppStore from '@/store/modules/app' import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings' import useSettingsStore from '@/store/modules/settings'
const SHELL_CLASS = 'sf-shell'
const route = useRoute()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const theme = computed(() => settingsStore.theme); const theme = computed(() => settingsStore.theme);
const sideTheme = computed(() => settingsStore.sideTheme);
const sidebar = computed(() => useAppStore().sidebar); const sidebar = computed(() => useAppStore().sidebar);
const device = computed(() => useAppStore().device); const device = computed(() => useAppStore().device);
const needTagsView = computed(() => settingsStore.tagsView); const needTagsView = computed(() => settingsStore.tagsView);
const fixedHeader = computed(() => settingsStore.fixedHeader); const fixedHeader = computed(() => settingsStore.fixedHeader);
const panelRef = ref(null)
const commandPaletteRef = ref(null)
const classObj = computed(() => ({ const classObj = computed(() => ({
hideSidebar: !sidebar.value.opened, hideSidebar: !sidebar.value.opened,
@@ -53,14 +63,32 @@ watchEffect(() => {
} }
}) })
// 内容面板是滚动容器,切换页面时回到顶部
watch(() => route.path, () => {
if (panelRef.value) panelRef.value.scrollTop = 0
})
function handleClickOutside() { function handleClickOutside() {
useAppStore().closeSideBar({ withoutAnimation: false }) useAppStore().closeSideBar({ withoutAnimation: false })
} }
function openCommandPalette() {
commandPaletteRef.value?.open()
}
const settingRef = ref(null); const settingRef = ref(null);
function setLayout() { function setLayout() {
settingRef.value.openSetting(); settingRef.value.openSetting();
} }
// 工作区设计令牌只作用于登录后的布局,登录页等独立页面不受影响
onMounted(() => {
document.documentElement.classList.add(SHELL_CLASS)
})
onBeforeUnmount(() => {
document.documentElement.classList.remove(SHELL_CLASS)
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -72,6 +100,7 @@ function setLayout() {
position: relative; position: relative;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: var(--sf-ground, #f5f6f7);
&.mobile.openSidebar { &.mobile.openSidebar {
position: fixed; position: fixed;
@@ -79,14 +108,42 @@ function setLayout() {
} }
} }
.main-container:has(.fixed-header) { .main-container {
height: 100vh; height: 100vh;
overflow: hidden; height: 100dvh;
padding: 8px 8px 8px 0;
box-sizing: border-box;
}
.sidebarHide.main-container {
padding-left: 8px;
}
.sf-panel {
position: relative;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: var(--sf-radius-xl, 14px);
background: var(--sf-surface, #fff);
// 面板内的页面底色即白色表面
--el-bg-color-page: var(--sf-surface, #fff);
}
.sf-panel__header {
background: var(--sf-surface, #fff);
}
.fixed-header {
position: sticky;
top: 0;
z-index: 9;
} }
.drawer-bg { .drawer-bg {
background: #000; background: #0f1115;
opacity: 0.3; opacity: 0.32;
width: 100%; width: 100%;
top: 0; top: 0;
height: 100%; height: 100%;
@@ -94,24 +151,14 @@ function setLayout() {
z-index: 999; z-index: 999;
} }
.fixed-header { .mobile {
position: fixed; .main-container {
top: 0; padding: 0;
right: 0; }
z-index: 9;
width: calc(100% - #{vars.$base-sidebar-width});
transition: width 0.28s;
}
.hideSidebar .fixed-header { .sf-panel {
width: calc(100% - 54px); border: 0;
} border-radius: 0;
}
.sidebarHide .fixed-header {
width: 100%;
}
.mobile .fixed-header {
width: 100%;
} }
</style> </style>
+7
View File
@@ -44,6 +44,10 @@ import ImagePreview from "@/components/ImagePreview"
// 字典标签组件 // 字典标签组件
import DictTag from '@/components/DictTag' import DictTag from '@/components/DictTag'
import MasterExcelActions from '@/components/MasterExcelActions' import MasterExcelActions from '@/components/MasterExcelActions'
// 页面标题与托盘档案链接
import PageHeader from '@/components/PageHeader'
import PalletCodeLink from '@/components/PalletCodeLink'
import DocNo from '@/components/DocNo'
const app = createApp(App) const app = createApp(App)
@@ -67,6 +71,9 @@ app.component('ImagePreview', ImagePreview)
app.component('RightToolbar', RightToolbar) app.component('RightToolbar', RightToolbar)
app.component('Editor', Editor) app.component('Editor', Editor)
app.component('MasterExcelActions', MasterExcelActions) app.component('MasterExcelActions', MasterExcelActions)
app.component('PageHeader', PageHeader)
app.component('PalletCodeLink', PalletCodeLink)
app.component('DocNo', DocNo)
app.use(router) app.use(router)
app.use(store) app.use(store)
+18 -1
View File
@@ -72,7 +72,7 @@ export const constantRoutes = [
path: '/index', path: '/index',
component: () => import('@/views/dashboard/index'), component: () => import('@/views/dashboard/index'),
name: 'Index', name: 'Index',
meta: { title: '首页', icon: 'dashboard', affix: true } meta: { title: '工作台', icon: 'dashboard', affix: true }
} }
] ]
}, },
@@ -94,6 +94,23 @@ export const constantRoutes = [
// 动态路由,基于用户权限动态去加载 // 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [ export const dynamicRoutes = [
{
path: '/pallet',
component: Layout,
hidden: true,
// 面包屑显示“托盘追溯 / 托盘档案”,点击返回追溯页
redirect: '/businessReport/palletTrace',
meta: { title: '托盘追溯' },
permissions: ['report:pallettrace:list'],
children: [
{
path: 'passport',
component: () => import('@/views/pallet/passport/index'),
name: 'PalletPassport',
meta: { title: '托盘档案', activeMenu: '/businessReport/palletTrace' }
}
]
},
{ {
path: '/system/user-auth', path: '/system/user-auth',
component: Layout, component: Layout,
+2 -2
View File
@@ -7,7 +7,7 @@ export default {
/** /**
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light * 侧边栏主题 深色主题theme-dark,浅色主题theme-light
*/ */
sideTheme: 'theme-dark', sideTheme: 'theme-light',
/** /**
* 是否系统布局配置 * 是否系统布局配置
@@ -22,7 +22,7 @@ export default {
/** /**
* 是否显示 tagsView * 是否显示 tagsView
*/ */
tagsView: true, tagsView: false,
/** /**
* 显示页签图标 * 显示页签图标
@@ -15,6 +15,8 @@ const useUserStore = defineStore(
name: '', name: '',
nickName: '', nickName: '',
avatar: '', avatar: '',
deptName: '',
roleNames: [],
admin: false, admin: false,
roles: [], roles: [],
permissions: [] permissions: []
@@ -55,6 +57,8 @@ const useUserStore = defineStore(
this.name = user.userName this.name = user.userName
this.nickName = user.nickName this.nickName = user.nickName
this.avatar = avatar this.avatar = avatar
this.deptName = user.dept?.deptName || ''
this.roleNames = (Array.isArray(user.role) ? user.role : []).map(role => role?.roleName).filter(Boolean)
this.admin = user.admin === true this.admin = user.admin === true
/* 初始密码提示 */ /* 初始密码提示 */
if(res.isDefaultModifyPwd) { if(res.isDefaultModifyPwd) {
@@ -0,0 +1,8 @@
// 后端单号与溯源号为“前缀-32 位随机串”,列表中保留首尾 4 位便于人工核对,完整值放在 title 中
export function shortDocNo(value) {
const text = String(value ?? '')
const match = text.match(/^([A-Z]{2,4}-)([0-9a-f]{20,})$/i)
if (!match) return text
const body = match[2].toUpperCase()
return `${match[1]}${body.slice(0, 4)}…${body.slice(-4)}`
}
@@ -0,0 +1,51 @@
import router from '@/router'
import { flowStateMeta } from '@/utils/palletPassport'
// 流转阶段对应的胶囊样式,与工作台和托盘档案保持同一套颜色
const PHASE_CLASS = { warehouse: 'is-wh', transit: 'is-transit', site: 'is-site', return: 'is-return' }
export function flowPillClass(flowState) {
return PHASE_CLASS[flowStateMeta(flowState).phase] || ''
}
export function formatCount(value) {
return Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN') : '—'
}
export function daysSince(value, now = new Date()) {
if (!value) return null
const time = new Date(value).getTime()
if (Number.isNaN(time)) return null
return Math.max(0, Math.floor((now.getTime() - time) / 86400000))
}
// 人名取末字、机构名取首字,作为头像占位
export function personInitial(user) {
const name = String(user?.nickName || user?.userName || '').trim()
return name ? name.slice(-1).toUpperCase() : '?'
}
export function orgInitial(name) {
const text = String(name || '').trim()
return text ? text.slice(0, 1).toUpperCase() : '?'
}
const AVATAR_TINTS = ['#00909F', '#6A7FE8', '#C2410C', '#8E6BD9', '#0F766E', '#3549A8', '#9A3A05', '#5B616E']
export function avatarTint(seed) {
const key = Number(seed) || String(seed || '').split('').reduce((sum, char) => sum + char.charCodeAt(0), 0)
return AVATAR_TINTS[Math.abs(key) % AVATAR_TINTS.length]
}
export function userDisplayName(user) {
return user?.nickName || user?.userName || ''
}
// 基础资料菜单由后端动态下发,按路由末段查找实际路径;当前用户没有该菜单时返回空串
export function masterRoutePath(segment) {
const route = router.getRoutes().find((item) => {
const parts = item.path.split('/').filter(Boolean)
return parts.length >= 2 && parts[parts.length - 1] === segment && !item.redirect
})
return route?.path || ''
}

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