Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4119c78c | ||
|
|
167df4adb8 | ||
|
|
3050ccdc4e |
+275
@@ -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_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
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.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
customer_controller = APIRouterPro(
|
||||
@@ -27,7 +29,7 @@ customer_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取客户分页列表接口',
|
||||
description='用于获取客户分页列表',
|
||||
response_model=PageResponseModel[CustomerModel],
|
||||
response_model=PageResponseModel[CustomerListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:list')],
|
||||
)
|
||||
async def get_master_customer_list(
|
||||
@@ -37,6 +39,9 @@ async def get_master_customer_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
@@ -45,7 +50,7 @@ async def get_master_customer_list(
|
||||
'/{customer_id}',
|
||||
summary='获取客户详情接口',
|
||||
description='用于获取指定客户的详细信息',
|
||||
response_model=DataResponseModel[CustomerModel],
|
||||
response_model=DataResponseModel[CustomerDetailModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:customer:query')],
|
||||
)
|
||||
async def query_detail_master_customer(
|
||||
@@ -55,6 +60,7 @@ async def query_detail_master_customer(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
@@ -34,7 +40,7 @@ filling_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取托盘装填记录分页列表接口',
|
||||
description='按当前用户仓库数据范围获取托盘装填记录分页列表',
|
||||
response_model=PageResponseModel[FillingModel],
|
||||
response_model=PageResponseModel[FillingListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('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)
|
||||
|
||||
|
||||
@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(
|
||||
'/strain-options',
|
||||
summary='获取装填菌种选项接口',
|
||||
|
||||
@@ -17,7 +17,9 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
@@ -37,7 +39,7 @@ inbound_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取首次入库单分页列表接口',
|
||||
description='按当前用户仓库数据范围获取首次入库单分页列表',
|
||||
response_model=PageResponseModel[InboundModel],
|
||||
response_model=PageResponseModel[InboundListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('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)
|
||||
|
||||
|
||||
@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(
|
||||
'/warehouse-options',
|
||||
summary='获取首次入库仓库选项接口',
|
||||
|
||||
@@ -14,18 +14,40 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import LineListItemModel, MasterParentOptionModel
|
||||
from module_business.entity.vo.master_vo import DeleteLineModel, LineModel, LinePageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
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(
|
||||
'/list',
|
||||
summary='获取生产线分页列表接口',
|
||||
description='用于获取生产线分页列表',
|
||||
response_model=PageResponseModel[LineModel],
|
||||
response_model=PageResponseModel[LineListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:line:list')],
|
||||
)
|
||||
async def get_master_line_list(
|
||||
@@ -35,6 +57,7 @@ async def get_master_line_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
|
||||
@@ -88,7 +88,14 @@ async def _import_excel(
|
||||
db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
scope: TestBatchScope,
|
||||
preview: bool = False,
|
||||
) -> 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(
|
||||
db, resource, file, update_support, current_user, scope
|
||||
)
|
||||
@@ -127,8 +134,9 @@ async def import_master_strain_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
@@ -176,8 +184,9 @@ async def import_master_customer_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
@@ -225,8 +234,9 @@ async def import_master_site_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
@@ -274,8 +284,9 @@ async def import_master_line_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
@@ -323,8 +334,9 @@ async def import_master_warehouse_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
@@ -372,8 +384,9 @@ async def import_master_pallet_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
@@ -421,8 +434,9 @@ async def import_master_tunnel_excel(
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency(write=True)],
|
||||
preview: Annotated[bool, Query(description='只校验不写入')] = False,
|
||||
) -> 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(
|
||||
|
||||
@@ -13,13 +13,14 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel
|
||||
from module_business.entity.vo.master_vo import (
|
||||
DeletePalletModel,
|
||||
PalletCreateModel,
|
||||
PalletEditModel,
|
||||
PalletModel,
|
||||
PalletPageQueryModel,
|
||||
)
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from module_business.service.pallet_service import PalletService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
@@ -32,7 +33,7 @@ pallet_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取托盘分页列表接口',
|
||||
description='用于获取托盘分页列表',
|
||||
response_model=PageResponseModel[PalletModel],
|
||||
response_model=PageResponseModel[PalletListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:pallet:list')],
|
||||
)
|
||||
async def get_master_pallet_list(
|
||||
@@ -42,6 +43,7 @@ async def get_master_pallet_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
|
||||
@@ -67,7 +69,7 @@ async def query_master_pallet_qrcode(
|
||||
'/{pallet_id}',
|
||||
summary='获取托盘详情接口',
|
||||
description='用于获取指定托盘的详细信息',
|
||||
response_model=DataResponseModel[PalletModel],
|
||||
response_model=DataResponseModel[PalletDetailModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:pallet:query')],
|
||||
)
|
||||
async def query_detail_master_pallet(
|
||||
@@ -77,6 +79,7 @@ async def query_detail_master_pallet(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
result = await PalletService.detail_pallet(query_db, pallet_id, scope)
|
||||
result = await MasterSummaryService.pallet_detail(query_db, result, scope)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
|
||||
@@ -19,10 +19,13 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.return_service import ReturnService
|
||||
from utils.response_util import ResponseUtil
|
||||
@@ -38,7 +41,7 @@ return_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取托盘退回单分页列表接口',
|
||||
description='按当前用户来源现场或目标仓库数据范围获取退回单',
|
||||
response_model=PageResponseModel[ReturnModel],
|
||||
response_model=PageResponseModel[ReturnListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('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_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(
|
||||
'/warehouse-options',
|
||||
summary='获取退回目标仓库选项接口',
|
||||
|
||||
@@ -18,6 +18,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
@@ -39,7 +40,7 @@ shipment_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取整托发货单分页列表接口',
|
||||
description='按当前用户仓库与现场双数据范围获取整托发货单',
|
||||
response_model=PageResponseModel[ShipmentModel],
|
||||
response_model=PageResponseModel[ShipmentListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('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_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
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 (
|
||||
BusinessUserOptionModel,
|
||||
DeleteSiteModel,
|
||||
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
|
||||
UserAssignModel,
|
||||
)
|
||||
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 utils.response_util import ResponseUtil
|
||||
|
||||
@@ -31,6 +33,26 @@ ASSIGN_CURRENT_USER = CurrentUserDependency
|
||||
site_controller = APIRouterPro(
|
||||
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(
|
||||
prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()]
|
||||
)
|
||||
@@ -77,7 +99,7 @@ async def assign_master_site_users(
|
||||
'/list',
|
||||
summary='获取客户现场分页列表接口',
|
||||
description='用于获取客户现场分页列表',
|
||||
response_model=PageResponseModel[SiteModel],
|
||||
response_model=PageResponseModel[SiteListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:site:list')],
|
||||
)
|
||||
async def get_master_site_list(
|
||||
@@ -87,6 +109,7 @@ async def get_master_site_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import StrainListItemModel
|
||||
from module_business.entity.vo.master_vo import DeleteStrainModel, StrainModel, StrainPageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
strain_controller = APIRouterPro(
|
||||
@@ -27,7 +29,7 @@ strain_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取菌种分页列表接口',
|
||||
description='用于获取菌种分页列表',
|
||||
response_model=PageResponseModel[StrainModel],
|
||||
response_model=PageResponseModel[StrainListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:strain:list')],
|
||||
)
|
||||
async def get_master_strain_list(
|
||||
@@ -37,6 +39,9 @@ async def get_master_strain_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import MasterParentOptionModel, TunnelListItemModel
|
||||
from module_business.entity.vo.master_vo import DeleteTunnelModel, TunnelModel, TunnelPageQueryModel
|
||||
from module_business.service.master_service import MasterService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
tunnel_controller = APIRouterPro(
|
||||
@@ -23,13 +25,33 @@ tunnel_controller = APIRouterPro(
|
||||
tags=['基础资料-隧道管理'],
|
||||
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(
|
||||
'/list',
|
||||
summary='获取隧道分页列表接口',
|
||||
description='用于获取客户现场隧道分页列表',
|
||||
response_model=PageResponseModel[TunnelModel],
|
||||
response_model=PageResponseModel[TunnelListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:tunnel:list')],
|
||||
)
|
||||
async def get_master_tunnel_list(
|
||||
@@ -39,6 +61,7 @@ async def get_master_tunnel_list(
|
||||
scope: Annotated[TestBatchScope, TestBatchScopeDependency()],
|
||||
) -> Response:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -21,8 +21,11 @@ from module_business.entity.vo.operation_vo import (
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
UsageSuccessResponseModel,
|
||||
)
|
||||
from module_business.service.usage_service import UsageService
|
||||
@@ -39,7 +42,7 @@ usage_controller = APIRouterPro(
|
||||
'/list',
|
||||
summary='获取客户现场使用记录分页列表接口',
|
||||
description='按当前用户客户现场数据范围获取使用记录',
|
||||
response_model=PageResponseModel[UsageModel],
|
||||
response_model=PageResponseModel[UsageListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('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)
|
||||
|
||||
|
||||
@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(
|
||||
'/enter-tunnel',
|
||||
summary='扫码入隧接口',
|
||||
|
||||
@@ -13,18 +13,22 @@ from common.vo import DataResponseModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel
|
||||
from module_business.service.usage_fifo_service import UsageFifoService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
usage_fifo_controller = APIRouterPro(
|
||||
prefix='/business/usage-fifo',
|
||||
tags=['业务管理-客户现场使用'],
|
||||
dependencies=[PreAuthDependency(), UserInterfaceAuthDependency('business:usage:query')],
|
||||
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(
|
||||
request: Request,
|
||||
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,
|
||||
)
|
||||
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_business.dependency.test_batch_dependency import TestBatchScopeDependency
|
||||
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 (
|
||||
BusinessUserOptionModel,
|
||||
DeleteWarehouseModel,
|
||||
@@ -22,6 +23,7 @@ from module_business.entity.vo.master_vo import (
|
||||
WarehousePageQueryModel,
|
||||
)
|
||||
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 utils.response_util import ResponseUtil
|
||||
|
||||
@@ -77,7 +79,7 @@ async def assign_master_warehouse_users(
|
||||
'/list',
|
||||
summary='获取仓库分页列表接口',
|
||||
description='用于获取仓库分页列表',
|
||||
response_model=PageResponseModel[WarehouseModel],
|
||||
response_model=PageResponseModel[WarehouseListItemModel],
|
||||
dependencies=[UserInterfaceAuthDependency('master:warehouse:list')],
|
||||
)
|
||||
async def get_master_warehouse_list(
|
||||
@@ -92,6 +94,9 @@ async def get_master_warehouse_list(
|
||||
scope,
|
||||
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)
|
||||
|
||||
|
||||
@@ -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 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.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from common.vo import PageModel
|
||||
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,
|
||||
@@ -39,6 +40,18 @@ from module_business.entity.vo.master_vo import (
|
||||
)
|
||||
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:
|
||||
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
|
||||
@@ -494,15 +507,44 @@ class MasterDao:
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)]
|
||||
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:
|
||||
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))
|
||||
if ids:
|
||||
conditions.append(BizPallet.pallet_id.in_(ids))
|
||||
statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id)
|
||||
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
|
||||
async def get_pallet(
|
||||
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.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_business.domain.enums import DocumentStatus, PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizWarehouse
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizInboundItem,
|
||||
@@ -608,6 +609,15 @@ class OperationDao:
|
||||
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
|
||||
if 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(
|
||||
cls._time_conditions(
|
||||
BizFillingRecord.operated_at,
|
||||
@@ -616,7 +626,7 @@ class OperationDao:
|
||||
)
|
||||
)
|
||||
statement = select(BizFillingRecord)
|
||||
if query_object.pallet_code:
|
||||
if query_object.pallet_code or query_object.keyword:
|
||||
statement = statement.join(
|
||||
BizPallet,
|
||||
BizPallet.pallet_id == BizFillingRecord.pallet_id,
|
||||
@@ -680,6 +690,10 @@ class OperationDao:
|
||||
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
|
||||
if query_object.status:
|
||||
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(
|
||||
cls._time_conditions(
|
||||
BizShipmentOrder.create_time,
|
||||
@@ -687,7 +701,45 @@ class OperationDao:
|
||||
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)
|
||||
|
||||
@classmethod
|
||||
@@ -818,6 +870,11 @@ class OperationDao:
|
||||
conditions.append(BizUsageRecord.line_id == query_object.line_id)
|
||||
if 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(
|
||||
cls._time_conditions(
|
||||
BizUsageRecord.occurred_at,
|
||||
@@ -826,7 +883,7 @@ class OperationDao:
|
||||
)
|
||||
)
|
||||
statement = select(BizUsageRecord)
|
||||
if query_object.pallet_code:
|
||||
if query_object.pallet_code or query_object.keyword:
|
||||
statement = statement.join(
|
||||
BizPallet,
|
||||
BizPallet.pallet_id == BizUsageRecord.pallet_id,
|
||||
@@ -884,7 +941,63 @@ class OperationDao:
|
||||
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)
|
||||
|
||||
@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,26 +2,28 @@
|
||||
|
||||
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 module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
|
||||
|
||||
# 待使用队列每个菌种最多返回的托盘数;总数仍完整统计
|
||||
QUEUE_LIMIT_PER_STRAIN = 20
|
||||
|
||||
|
||||
class UsageFifoDao:
|
||||
@staticmethod
|
||||
async def recommendation(
|
||||
db: AsyncSession,
|
||||
def _candidate_conditions(
|
||||
*,
|
||||
site_id: int,
|
||||
strain_id: int,
|
||||
strain_id: int | None,
|
||||
tunnel_id: int | None,
|
||||
trace_no: str | None,
|
||||
scope: TestBatchScope,
|
||||
for_update: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, int]:
|
||||
) -> list[ColumnElement[bool]]:
|
||||
"""建议与待使用队列共用的候选条件;strain_id 为空时不按菌种限制(仅队列使用)。"""
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
|
||||
conditions = [
|
||||
@@ -30,7 +32,11 @@ class UsageFifoDao:
|
||||
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,
|
||||
]
|
||||
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',
|
||||
@@ -43,7 +49,43 @@ class UsageFifoDao:
|
||||
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
|
||||
),
|
||||
]
|
||||
base = (
|
||||
)
|
||||
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(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
*,
|
||||
site_id: int,
|
||||
strain_id: int,
|
||||
tunnel_id: int | None,
|
||||
trace_no: str | None,
|
||||
scope: TestBatchScope,
|
||||
for_update: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, int]:
|
||||
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
|
||||
conditions = cls._candidate_conditions(
|
||||
site_id=site_id, strain_id=strain_id, tunnel_id=tunnel_id, trace_no=trace_no, scope=scope
|
||||
)
|
||||
base = cls._join_candidates(
|
||||
select(
|
||||
pallet.pallet_id,
|
||||
pallet.pallet_code,
|
||||
@@ -51,22 +93,8 @@ class UsageFifoDao:
|
||||
inventory.remaining_packages,
|
||||
inventory.site_arrived_at,
|
||||
)
|
||||
.select_from(inventory)
|
||||
.join(pallet, pallet.pallet_id == inventory.pallet_id)
|
||||
.join(
|
||||
filling,
|
||||
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
|
||||
)
|
||||
.where(*conditions)
|
||||
)
|
||||
statement = (
|
||||
base.where(inventory.site_arrived_at.is_not(None))
|
||||
.order_by(
|
||||
inventory.site_arrived_at,
|
||||
inventory.pallet_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).where(*conditions)
|
||||
statement = base.where(inventory.site_arrived_at.is_not(None)).order_by(*cls._fifo_order()).limit(1)
|
||||
if for_update:
|
||||
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
|
||||
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
|
||||
@@ -83,3 +111,88 @@ class UsageFifoDao:
|
||||
)
|
||||
)
|
||||
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='每页记录数')
|
||||
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='资产状态')
|
||||
flow_group: Literal['unstocked', 'warehouse', 'transit', 'site', 'returning'] | None = Field(
|
||||
default=None, description='当前流转环节'
|
||||
)
|
||||
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_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_time: datetime | None = Field(default=None, description='创建时间')
|
||||
|
||||
@@ -275,6 +277,44 @@ class InboundModel(BaseModel):
|
||||
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):
|
||||
"""托盘装填记录展示模型。"""
|
||||
|
||||
@@ -305,6 +345,68 @@ class FillingModel(BaseModel):
|
||||
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):
|
||||
"""整托发货明细模型。"""
|
||||
|
||||
@@ -321,6 +423,10 @@ class ShipmentItemModel(BaseModel):
|
||||
ge=0,
|
||||
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_time: datetime | None = Field(default=None, description='创建时间')
|
||||
|
||||
@@ -350,6 +456,61 @@ class ShipmentModel(BaseModel):
|
||||
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):
|
||||
"""客户现场使用记录展示模型。"""
|
||||
|
||||
@@ -373,6 +534,20 @@ class UsageModel(BaseModel):
|
||||
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):
|
||||
"""托盘退回明细模型。"""
|
||||
|
||||
@@ -386,6 +561,8 @@ class ReturnItemModel(BaseModel):
|
||||
trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照')
|
||||
batch_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时生产批次快照')
|
||||
remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照')
|
||||
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_time: datetime | None = Field(default=None, description='创建时间')
|
||||
|
||||
@@ -416,6 +593,37 @@ class ReturnModel(BaseModel):
|
||||
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):
|
||||
"""首次入库分页查询模型。"""
|
||||
|
||||
@@ -440,6 +648,13 @@ class FillingPageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, 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='装填单号')
|
||||
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')
|
||||
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='生产批次')
|
||||
@@ -450,6 +665,36 @@ class FillingPageQueryModel(BaseModel):
|
||||
_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):
|
||||
"""整托发货分页查询模型。"""
|
||||
|
||||
@@ -458,9 +703,17 @@ class ShipmentPageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, 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='发货单号')
|
||||
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')
|
||||
target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID')
|
||||
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='开始时间')
|
||||
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_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='使用记录号')
|
||||
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')
|
||||
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='物料溯源号')
|
||||
@@ -494,6 +755,13 @@ class ReturnPageQueryModel(BaseModel):
|
||||
page_num: int = Field(default=1, gt=0, 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='退回单号')
|
||||
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')
|
||||
target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID')
|
||||
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='业务名称')
|
||||
|
||||
|
||||
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):
|
||||
"""业务扫码托盘响应模型。"""
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic.alias_generators import to_camel
|
||||
@@ -21,3 +21,34 @@ class UsageFifoRecommendationModel(BaseModel):
|
||||
unknown_arrival_count: int = 0
|
||||
requires_skip_reason: bool = False
|
||||
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 json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.filling_summary_dao import FillingSummaryDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
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.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
@@ -34,6 +41,9 @@ from module_business.service.test_batch_access_service import TestBatchAccessSer
|
||||
class FillingService:
|
||||
"""仓库内单托盘即时装填服务层。"""
|
||||
|
||||
# 有效期剩余不超过该天数的在仓批次视为临期
|
||||
NEAR_EXPIRY_DAYS = 14
|
||||
|
||||
@staticmethod
|
||||
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
|
||||
user = current_user.user
|
||||
@@ -187,7 +197,7 @@ class FillingService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[FillingModel]:
|
||||
) -> PageModel | list[FillingListItemModel]:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
@@ -203,11 +213,81 @@ class FillingService:
|
||||
is_page=is_page,
|
||||
)
|
||||
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):
|
||||
return result.model_copy(update={'rows': 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
|
||||
async def filling_detail_services(
|
||||
cls,
|
||||
|
||||
@@ -11,6 +11,7 @@ from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException, ServiceWarning
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
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.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
@@ -29,7 +30,10 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundItemModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
@@ -245,7 +249,7 @@ class InboundService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[InboundModel]:
|
||||
) -> PageModel | list[InboundListItemModel]:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
@@ -262,7 +266,17 @@ class InboundService:
|
||||
is_page=is_page,
|
||||
)
|
||||
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):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
@@ -284,7 +298,31 @@ class InboundService:
|
||||
)
|
||||
if inbound is None:
|
||||
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
|
||||
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_business.dao.master_dao import MasterDao
|
||||
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 (
|
||||
CustomerModel,
|
||||
LineModel,
|
||||
@@ -33,6 +34,11 @@ _MAX_IMPORT_BYTES = 5 * 1024 * 1024
|
||||
_MAX_IMPORT_ROWS = 5000
|
||||
_STATUS_TO_VALUE = {'正常': '0', '停用': '1'}
|
||||
_VALUE_TO_STATUS = {'0': '正常', '1': '停用'}
|
||||
_PREVIEW_MESSAGES: dict[ImportOutcome, str] = {
|
||||
'add': '校验通过,将新增',
|
||||
'update': '{code}已存在,将按文件内容更新',
|
||||
'skip': '{code}已存在,保持原资料不变',
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -245,6 +251,65 @@ class MasterExcelService:
|
||||
raise ServiceException(message='导入失败,数据已全部回滚') from exc
|
||||
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
|
||||
async def _read_upload(cls, resource: MasterExcelResource, file: UploadFile) -> list[ParsedRow]:
|
||||
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.master_dao import MasterDao
|
||||
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.pallet_state_machine import PalletStateMachine
|
||||
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,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnItemModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
@@ -211,7 +216,7 @@ class ReturnService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[ReturnModel]:
|
||||
) -> PageModel | list[ReturnListItemModel]:
|
||||
site_ids, warehouse_ids = await cls._scopes(query_db, current_user)
|
||||
if site_ids == [] and warehouse_ids == []:
|
||||
if is_page:
|
||||
@@ -233,7 +238,17 @@ class ReturnService:
|
||||
is_page=is_page,
|
||||
)
|
||||
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):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
@@ -257,7 +272,42 @@ class ReturnService:
|
||||
if return_order is None:
|
||||
cls._raise_return_not_accessible(current_user)
|
||||
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
|
||||
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.master_dao import MasterDao
|
||||
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.pallet_state_machine import PalletStateMachine
|
||||
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,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
@@ -254,7 +257,7 @@ class ShipmentService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[ShipmentModel]:
|
||||
) -> PageModel | list[ShipmentListItemModel]:
|
||||
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
@@ -288,7 +291,17 @@ class ShipmentService:
|
||||
is_page=is_page,
|
||||
)
|
||||
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):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
@@ -311,7 +324,35 @@ class ShipmentService:
|
||||
)
|
||||
if shipment is None:
|
||||
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
|
||||
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.domain.fifo_policy import requires_fifo_skip_reason
|
||||
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
|
||||
from module_business.entity.vo.usage_fifo_vo import UsageFifoQueueModel, UsageFifoRecommendationModel
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.usage_service import UsageService
|
||||
|
||||
|
||||
@@ -52,3 +53,19 @@ class UsageFifoService:
|
||||
recommended=candidate, unknown_arrival_count=unknown,
|
||||
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 json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, NoReturn
|
||||
|
||||
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.operation_dao import OperationDao
|
||||
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.fifo_policy import requires_fifo_skip_reason
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
@@ -29,8 +30,11 @@ from module_business.entity.vo.operation_vo import (
|
||||
TunnelEntryCreateModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
UsageSuccessResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
@@ -279,7 +283,7 @@ class UsageService:
|
||||
current_user: CurrentUserModel,
|
||||
test_scope: TestBatchScope,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[UsageModel]:
|
||||
) -> PageModel | list[UsageListItemModel]:
|
||||
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:
|
||||
raise PermissionException(data='', message='无权访问该客户现场')
|
||||
@@ -301,11 +305,56 @@ class UsageService:
|
||||
is_page=is_page,
|
||||
)
|
||||
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):
|
||||
return result.model_copy(update={'rows': 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
|
||||
async def usage_detail_services(
|
||||
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', 'PUT'): 'master:warehouse:assign',
|
||||
('/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/summary', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
|
||||
('/business/inbound/scan/{qr_token}', '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_id}/confirm', 'PUT'): 'business:inbound:confirm',
|
||||
('/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/scan/{qr_token}', '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}/confirm', 'PUT'): 'business:shipment:receive',
|
||||
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
|
||||
('/business/usage-fifo/queue', 'GET'): 'business:usage:query',
|
||||
('/business/shipment', 'POST'): 'business:shipment:add',
|
||||
('/business/shipment', 'PUT'): 'business:shipment:edit',
|
||||
('/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/line-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/scan/{qr_token}', 'GET'): 'business:usage:query',
|
||||
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
|
||||
('/business/usage', 'POST'): 'business:usage:add',
|
||||
('/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/scan/{qr_token}', '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()
|
||||
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/summary',
|
||||
'/business/inbound/warehouse-options',
|
||||
'/business/inbound/scan/{qr_token}',
|
||||
),
|
||||
'/business/filling/{filling_id}': (
|
||||
'/business/filling/summary',
|
||||
'/business/filling/batches',
|
||||
'/business/filling/batches/detail',
|
||||
'/business/filling/strain-options',
|
||||
'/business/filling/scan/{qr_token}',
|
||||
),
|
||||
@@ -311,9 +330,13 @@ STATIC_READS_BEFORE_DETAIL = {
|
||||
'/business/usage/{usage_id}': (
|
||||
'/business/usage/line-options',
|
||||
'/business/usage/tunnel-options',
|
||||
'/business/usage/site-options',
|
||||
'/business/usage/site-board',
|
||||
'/business/usage/scan/{qr_token}',
|
||||
),
|
||||
'/business/return/{return_id}': (
|
||||
'/business/return/summary',
|
||||
'/business/return/site-options',
|
||||
'/business/return/warehouse-options',
|
||||
'/business/return/scan/{qr_token}',
|
||||
),
|
||||
@@ -327,6 +350,8 @@ EXACT_QUERY_PARAMETERS = {
|
||||
('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'},
|
||||
('/business/usage/line-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'},
|
||||
}
|
||||
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:
|
||||
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_PERMISSIONS, frozenset(expected))
|
||||
|
||||
@@ -501,7 +526,7 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
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/'))
|
||||
app_ai_paths = sorted(
|
||||
@@ -582,7 +607,7 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
|
||||
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
|
||||
literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||
self.assertEqual(119, len(literals))
|
||||
self.assertEqual(131, len(literals))
|
||||
literals.append(literals[0])
|
||||
|
||||
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_business.controller import filling_controller as filling_controller_module
|
||||
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.master_dao import MasterDao
|
||||
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.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
@@ -242,6 +247,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
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(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
):
|
||||
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()
|
||||
expected = {
|
||||
('/business/filling/list', 'get'),
|
||||
('/business/filling/summary', 'get'),
|
||||
('/business/filling/batches', 'get'),
|
||||
('/business/filling/batches/detail', 'get'),
|
||||
('/business/filling/strain-options', 'get'),
|
||||
('/business/filling/scan/{qr_token}', '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})
|
||||
|
||||
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(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
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:
|
||||
paths = [route.path_format for route in filling_controller_module.filling_controller.routes]
|
||||
detail_index = paths.index('/business/filling/{filling_id}')
|
||||
self.assertLess(paths.index('/business/filling/strain-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index)
|
||||
for static_path in (
|
||||
'/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:
|
||||
expected = {
|
||||
('/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/scan/{qr_token}', '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_business.controller import inbound_controller as inbound_controller_module
|
||||
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.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
@@ -45,7 +46,9 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
@@ -741,6 +744,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
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(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
):
|
||||
actual = await InboundService.get_inbound_list_services(
|
||||
self.db, query, self.user, TestBatchScope.formal(), is_page=True
|
||||
@@ -827,6 +831,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
|
||||
) as scope,
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
||||
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())
|
||||
|
||||
@@ -1900,6 +1905,7 @@ class TestInboundController(unittest.TestCase):
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/inbound/list', 'get'),
|
||||
('/business/inbound/summary', 'get'),
|
||||
('/business/inbound/warehouse-options', 'get'),
|
||||
('/business/inbound/scan/{qr_token}', 'get'),
|
||||
('/business/inbound/{inbound_id}', 'get'),
|
||||
@@ -1913,7 +1919,12 @@ class TestInboundController(unittest.TestCase):
|
||||
self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters})
|
||||
|
||||
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(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
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:
|
||||
paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes]
|
||||
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/scan/{qr_token}'), detail_index)
|
||||
|
||||
def test_each_route_has_exact_permission_and_five_unique_permission_values(self) -> None:
|
||||
expected = {
|
||||
('/business/inbound/list', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/summary', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
|
||||
('/business/inbound/scan/{qr_token}', '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,
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.vo.master_summary_vo import (
|
||||
CustomerDetailModel,
|
||||
CustomerListItemModel,
|
||||
LineListItemModel,
|
||||
SiteListItemModel,
|
||||
StrainListItemModel,
|
||||
WarehouseListItemModel,
|
||||
)
|
||||
from module_business.entity.vo.master_vo import (
|
||||
CustomerModel,
|
||||
CustomerPageQueryModel,
|
||||
@@ -1060,17 +1068,24 @@ class TestMasterControllers(unittest.TestCase):
|
||||
def test_route_response_model_contracts(self) -> None:
|
||||
model_by_resource = {
|
||||
'strain': StrainModel,
|
||||
'customer': CustomerModel,
|
||||
'customer': CustomerDetailModel,
|
||||
'site': SiteModel,
|
||||
'line': LineModel,
|
||||
'warehouse': WarehouseModel,
|
||||
}
|
||||
list_model_by_resource = {
|
||||
'strain': StrainListItemModel,
|
||||
'customer': CustomerListItemModel,
|
||||
'site': SiteListItemModel,
|
||||
'line': LineListItemModel,
|
||||
'warehouse': WarehouseListItemModel,
|
||||
}
|
||||
for resource, router in self.ROUTERS.items():
|
||||
for route in router.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
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'}:
|
||||
self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model)
|
||||
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,
|
||||
)
|
||||
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 (
|
||||
BusinessUserOptionModel,
|
||||
DeletePalletModel,
|
||||
@@ -1285,11 +1286,11 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase):
|
||||
def test_route_response_models_are_exact(self) -> None:
|
||||
routes = self._routes()
|
||||
self.assertEqual(
|
||||
PageResponseModel[PalletModel],
|
||||
PageResponseModel[PalletListItemModel],
|
||||
routes[('/master/pallet/list', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[PalletModel],
|
||||
DataResponseModel[PalletDetailModel],
|
||||
routes[('/master/pallet/{pallet_id}', 'GET')].response_model,
|
||||
)
|
||||
for path, method in (
|
||||
|
||||
@@ -38,10 +38,13 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
@@ -440,6 +443,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/return/list', 'get'),
|
||||
('/business/return/summary', 'get'),
|
||||
('/business/return/site-options', 'get'),
|
||||
('/business/return/warehouse-options', 'get'),
|
||||
('/business/return/scan/{qr_token}', 'get'),
|
||||
('/business/return/{return_id}', 'get'),
|
||||
@@ -450,7 +455,17 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters']
|
||||
self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters})
|
||||
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(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/return/warehouse-options', 'GET')].response_model,
|
||||
@@ -472,6 +487,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
detail_index = paths.index('/business/return/{return_id}')
|
||||
for static_path in (
|
||||
'/business/return/list',
|
||||
'/business/return/summary',
|
||||
'/business/return/site-options',
|
||||
'/business/return/warehouse-options',
|
||||
'/business/return/scan/{qr_token}',
|
||||
'/business/return/request',
|
||||
@@ -480,6 +497,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
|
||||
expected = {
|
||||
('/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/scan/{qr_token}', '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.master_dao import MasterDao
|
||||
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.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
@@ -47,6 +49,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
@@ -425,7 +428,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
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'):
|
||||
self.assertEqual(
|
||||
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(OperationDao, 'get_shipment_list', new=get_list),
|
||||
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(
|
||||
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.entity.do.master_do import BizPallet
|
||||
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_service import UsageService
|
||||
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.assertEqual(result.recommended.pallet_id, 1)
|
||||
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.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.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
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,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
UsageSuccessResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
@@ -381,6 +385,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
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(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
):
|
||||
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/line-options', 'get'),
|
||||
('/business/usage/tunnel-options', 'get'),
|
||||
('/business/usage/site-options', 'get'),
|
||||
('/business/usage/site-board', 'get'),
|
||||
('/business/usage/enter-tunnel', 'post'),
|
||||
('/business/usage/scan/{qr_token}', 'get'),
|
||||
('/business/usage/{usage_id}', 'get'),
|
||||
@@ -674,7 +681,15 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
)
|
||||
|
||||
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(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/usage/line-options', 'GET')].response_model,
|
||||
@@ -717,6 +732,8 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
detail_index = paths.index('/business/usage/{usage_id}')
|
||||
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/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/scan/{qr_token}'), detail_index)
|
||||
|
||||
@@ -725,6 +742,8 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
('/business/usage/list', 'GET'): 'business:usage:list',
|
||||
('/business/usage/line-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/scan/{qr_token}', '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 (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingBatchRecordModel,
|
||||
FillingBatchStateCountModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
InboundCreateModel,
|
||||
InboundItemModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
InboundWarehouseCountModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnItemModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentItemModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentStrainSummaryModel,
|
||||
ShipmentUpdateModel,
|
||||
UsageBoardTunnelModel,
|
||||
UsageCreateModel,
|
||||
UsageLinePackagesModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
)
|
||||
from module_business.entity.vo.report_vo import (
|
||||
ConsumptionReportModel,
|
||||
@@ -170,7 +191,7 @@ MASTER_MODEL_FIELDS = {
|
||||
},
|
||||
PalletCreateModel: {'pallet_code', 'capacity_packages', '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'},
|
||||
UserAssignModel: {'user_ids'},
|
||||
}
|
||||
@@ -195,7 +216,15 @@ OPERATION_MODEL_FIELDS = {
|
||||
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'},
|
||||
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: {
|
||||
'inbound_id',
|
||||
'inbound_no',
|
||||
@@ -212,6 +241,38 @@ OPERATION_MODEL_FIELDS = {
|
||||
'remark',
|
||||
'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: {
|
||||
'filling_id',
|
||||
'filling_no',
|
||||
@@ -237,6 +298,88 @@ OPERATION_MODEL_FIELDS = {
|
||||
'update_time',
|
||||
'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: {
|
||||
'shipment_item_id',
|
||||
'shipment_id',
|
||||
@@ -244,6 +387,10 @@ OPERATION_MODEL_FIELDS = {
|
||||
'active_filling_id',
|
||||
'package_qty',
|
||||
'remaining_packages',
|
||||
'pallet_code',
|
||||
'strain_code',
|
||||
'strain_name',
|
||||
'batch_no',
|
||||
'create_by',
|
||||
'create_time',
|
||||
},
|
||||
@@ -267,6 +414,37 @@ OPERATION_MODEL_FIELDS = {
|
||||
'remark',
|
||||
'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: {
|
||||
'usage_id',
|
||||
'fifo_skip_reason',
|
||||
@@ -285,6 +463,63 @@ OPERATION_MODEL_FIELDS = {
|
||||
'operator_id',
|
||||
'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: {
|
||||
'return_item_id',
|
||||
'return_id',
|
||||
@@ -294,6 +529,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
'trace_no_snapshot',
|
||||
'batch_no_snapshot',
|
||||
'remaining_packages_snapshot',
|
||||
'pallet_code',
|
||||
'tunnel_name',
|
||||
'create_by',
|
||||
'create_time',
|
||||
},
|
||||
@@ -318,6 +555,48 @@ OPERATION_MODEL_FIELDS = {
|
||||
'remark',
|
||||
'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: {
|
||||
'page_num',
|
||||
'page_size',
|
||||
@@ -331,6 +610,7 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'filling_no',
|
||||
'keyword',
|
||||
'warehouse_id',
|
||||
'strain_id',
|
||||
'batch_no',
|
||||
@@ -338,13 +618,24 @@ OPERATION_MODEL_FIELDS = {
|
||||
'begin_time',
|
||||
'end_time',
|
||||
},
|
||||
FillingBatchPageQueryModel: {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'keyword',
|
||||
'strain_id',
|
||||
'begin_time',
|
||||
'end_time',
|
||||
},
|
||||
FillingBatchDetailQueryModel: {'strain_id', 'batch_no'},
|
||||
ShipmentPageQueryModel: {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'shipment_no',
|
||||
'keyword',
|
||||
'source_warehouse_id',
|
||||
'target_site_id',
|
||||
'status',
|
||||
'receipt_state',
|
||||
'begin_time',
|
||||
'end_time',
|
||||
},
|
||||
@@ -352,6 +643,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'usage_no',
|
||||
'keyword',
|
||||
'fifo_skipped',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
@@ -364,6 +657,7 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'return_no',
|
||||
'keyword',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
'status',
|
||||
@@ -374,6 +668,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'},
|
||||
ReturnScanQueryModel: {'mode', 'return_id'},
|
||||
BusinessOptionModel: {'id', 'code', 'name'},
|
||||
ReturnSiteOptionModel: {'id', 'code', 'name', 'customer_name'},
|
||||
UsageSiteOptionModel: {'id', 'code', 'name', 'customer_name', 'tunnel_count', 'line_count'},
|
||||
PalletScanResponseModel: {
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
@@ -726,6 +1022,7 @@ WRITE_MODELS = (
|
||||
OPERATION_PAGE_QUERY_MODELS = (
|
||||
InboundPageQueryModel,
|
||||
FillingPageQueryModel,
|
||||
FillingBatchPageQueryModel,
|
||||
ShipmentPageQueryModel,
|
||||
UsagePageQueryModel,
|
||||
ReturnPageQueryModel,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:docker": "vite build --mode docker",
|
||||
"build:stage": "vite build --mode staging",
|
||||
"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": {
|
||||
"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 }),
|
||||
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
|
||||
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('网络暂不可用') },
|
||||
})
|
||||
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, [])
|
||||
const apiSource = await read('api/business/usage.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 ${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() }
|
||||
function deferred() {
|
||||
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('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } })
|
||||
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 === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } })
|
||||
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) }
|
||||
}, 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 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。
|
||||
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]))
|
||||
@@ -95,7 +103,8 @@ async function retry(page) {
|
||||
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
|
||||
const page = createPage()
|
||||
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('印刷托盘号')
|
||||
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
|
||||
page.close()
|
||||
@@ -753,3 +762,121 @@ test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它',
|
||||
assert.equal(page.state.tunnelLocked.value, true)
|
||||
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: 'put' },
|
||||
{ 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': [
|
||||
{ url: "'/master/line/list'", method: 'get' },
|
||||
{ url: "'/master/line/' + lineId", method: 'get' },
|
||||
{ url: "'/master/line'", method: 'post' },
|
||||
{ url: "'/master/line'", method: 'put' },
|
||||
{ url: "'/master/line/site-options'", method: 'get' }
|
||||
],
|
||||
'src/api/master/warehouse.js': [
|
||||
{ url: "'/master/warehouse/list'", method: 'get' },
|
||||
@@ -119,9 +121,11 @@ const expectedRequests = {
|
||||
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
|
||||
{ url: "'/master/tunnel'", method: 'post' },
|
||||
{ url: "'/master/tunnel'", method: 'put' },
|
||||
{ url: "'/master/tunnel/site-options'", method: 'get' }
|
||||
],
|
||||
'src/api/business/inbound.js': [
|
||||
{ url: "'/business/inbound/list'", method: 'get' },
|
||||
{ url: "'/business/inbound/summary'", method: 'get' },
|
||||
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
|
||||
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/inbound/' + inboundId", method: 'get' },
|
||||
@@ -131,6 +135,9 @@ const expectedRequests = {
|
||||
],
|
||||
'src/api/business/filling.js': [
|
||||
{ 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/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/filling/' + fillingId", method: 'get' },
|
||||
@@ -151,6 +158,9 @@ const expectedRequests = {
|
||||
{ url: "'/business/usage/list'", method: 'get' },
|
||||
{ url: "'/business/usage/line-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/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
|
||||
@@ -159,6 +169,8 @@ const expectedRequests = {
|
||||
],
|
||||
'src/api/business/return.js': [
|
||||
{ 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/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||
{ url: "'/business/return/request'", method: 'post' },
|
||||
@@ -211,14 +223,14 @@ const masterPageContracts = {
|
||||
},
|
||||
site: {
|
||||
relativePath: 'src/views/master/site/index.vue',
|
||||
apiNames: ['addSite', 'getSite', 'listSite', 'updateSite'],
|
||||
apiNames: ['addSite', 'getSite', 'getSiteCustomerOptions', 'listSite', 'updateSite'],
|
||||
permissions: ['add', 'assign', 'edit', 'list', 'query'],
|
||||
addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'],
|
||||
editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status']
|
||||
},
|
||||
line: {
|
||||
relativePath: 'src/views/master/line/index.vue',
|
||||
apiNames: ['addLine', 'getLine', 'listLine', 'updateLine'],
|
||||
apiNames: ['addLine', 'getLine', 'getLineSiteOptions', 'listLine', 'updateLine'],
|
||||
permissions: ['add', 'edit', 'list', 'query'],
|
||||
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
|
||||
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
|
||||
@@ -246,6 +258,7 @@ const businessPageContracts = {
|
||||
'addInbound',
|
||||
'confirmInbound',
|
||||
'getInbound',
|
||||
'getInboundSummary',
|
||||
'getInboundWarehouseOptions',
|
||||
'listInbound',
|
||||
'scanInboundPallet',
|
||||
@@ -260,7 +273,15 @@ const businessPageContracts = {
|
||||
},
|
||||
filling: {
|
||||
relativePath: 'src/views/business/filling/index.vue',
|
||||
apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'],
|
||||
apiNames: [
|
||||
'addFilling',
|
||||
'getFillingBatchDetail',
|
||||
'getFillingStrainOptions',
|
||||
'getFillingSummary',
|
||||
'listFilling',
|
||||
'listFillingBatches',
|
||||
'scanFillingPallet'
|
||||
],
|
||||
permissions: ['add', 'list', 'query'],
|
||||
payloads: {
|
||||
addFilling: [
|
||||
@@ -298,7 +319,18 @@ const businessPageContracts = {
|
||||
},
|
||||
usage: {
|
||||
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'],
|
||||
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
|
||||
payloads: {}
|
||||
@@ -308,6 +340,8 @@ const businessPageContracts = {
|
||||
apiNames: [
|
||||
'confirmReturn',
|
||||
'getReturn',
|
||||
'getReturnSiteOptions',
|
||||
'getReturnSummary',
|
||||
'getReturnWarehouseOptions',
|
||||
'listReturn',
|
||||
'requestReturn',
|
||||
@@ -1538,7 +1572,7 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
|
||||
|
||||
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
||||
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)) {
|
||||
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*#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 dashboardSource = fs.readFileSync(dashboardPath, 'utf8')
|
||||
|
||||
test('首页包含仓储运营总览的核心模块', () => {
|
||||
test('首页工作台包含扫码入口、流转看板与核心运营模块', () => {
|
||||
for (const label of [
|
||||
'仓储运营总览',
|
||||
'当前库存托盘',
|
||||
'剩余包数',
|
||||
'今日入库单',
|
||||
'今日出库单',
|
||||
'待确认单据',
|
||||
'新增入库',
|
||||
'托盘填充',
|
||||
'扫描托盘码',
|
||||
'快捷作业',
|
||||
'新建作业',
|
||||
'在库托盘',
|
||||
'客户现场在用',
|
||||
'今日发货',
|
||||
'待处理',
|
||||
'托盘流转',
|
||||
'装填与发货',
|
||||
'临期预警',
|
||||
'仓库库存',
|
||||
'最近动态',
|
||||
'仓库入库',
|
||||
'工厂装填',
|
||||
'发货出库',
|
||||
'托盘使用',
|
||||
'托盘退回',
|
||||
'托盘追踪',
|
||||
'近 7 天入库 / 出库趋势',
|
||||
'托盘流转状态分布',
|
||||
'仓库库存排行',
|
||||
'最近入库单',
|
||||
'最近出库单',
|
||||
'扫码异常',
|
||||
'待确认业务'
|
||||
'现场使用',
|
||||
'退回仓库',
|
||||
'托盘追溯',
|
||||
'扫码异常'
|
||||
]) {
|
||||
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() {
|
||||
return request({
|
||||
|
||||
@@ -9,6 +9,14 @@ export function listInbound(query) {
|
||||
})
|
||||
}
|
||||
|
||||
// 查询首次入库概况
|
||||
export function getInboundSummary() {
|
||||
return request({
|
||||
url: '/business/inbound/summary',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 查询首次入库仓库选项
|
||||
export function getInboundWarehouseOptions() {
|
||||
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() {
|
||||
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) {
|
||||
return request({
|
||||
|
||||
@@ -34,3 +34,11 @@ export function updateLine(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
|
||||
})
|
||||
}
|
||||
|
||||
// 查询新增或修改现场时可选的客户
|
||||
export function getSiteCustomerOptions() {
|
||||
return request({
|
||||
url: '/master/site/customer-options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,3 +34,11 @@ export function updateTunnel(data) {
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 查询新增或修改隧道时可选的现场
|
||||
export function getTunnelSiteOptions() {
|
||||
return request({
|
||||
url: '/master/tunnel/site-options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
@use './shierfeng.scss';
|
||||
|
||||
@use './data-pages.scss';
|
||||
@use './sf-shell.scss';
|
||||
@use './sf-master.scss';
|
||||
|
||||
body {
|
||||
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;
|
||||
|
||||
// ==========================================
|
||||
// 侧边导航:位于底色之上,内容区为右侧内嵌面板
|
||||
// ==========================================
|
||||
#app {
|
||||
|
||||
.main-container {
|
||||
@@ -14,20 +17,50 @@
|
||||
}
|
||||
|
||||
.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;
|
||||
width: vars.$base-sidebar-width !important;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
font-size: 0px;
|
||||
font-size: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--el-border-color-lighter, #e4e7ed);
|
||||
box-shadow: 2px 0 10px 0 rgba(15, 34, 58, 0.05);
|
||||
background: var(--sf-ground, #f5f6f7);
|
||||
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 {
|
||||
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
|
||||
}
|
||||
@@ -37,175 +70,168 @@
|
||||
}
|
||||
|
||||
.el-scrollbar__bar.is-vertical {
|
||||
right: 0px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.el-scrollbar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.has-logo {
|
||||
.el-scrollbar {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.is-horizontal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
.el-menu a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.svg-icon {
|
||||
margin-right: 10px !important;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border: none;
|
||||
height: 100%;
|
||||
width: 100% !important;
|
||||
padding: 4px 0 16px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:not(.el-menu--collapse) {
|
||||
// 菜单项:圆角胶囊
|
||||
.el-menu-item,
|
||||
.el-sub-menu__title {
|
||||
height: 46px;
|
||||
line-height: 46px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
margin: 1px 10px;
|
||||
padding-right: 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--sf-menu-text);
|
||||
font-size: 13.5px;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sf-menu-hover) !important;
|
||||
color: var(--sf-menu-active-text);
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-weight: 600;
|
||||
background-color: rgba(0, 133, 150, 0.12) !important;
|
||||
color: var(--current-color, #008596) !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item, .menu-title {
|
||||
.menu-title,
|
||||
.el-menu-item {
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.el-menu-item .el-menu-tooltip__trigger {
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
.el-sub-menu__icon-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--el-menu-base-level-padding, 20px);
|
||||
margin-top: -6px;
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
// menu hover
|
||||
.sub-menu-title-noDropdown,
|
||||
.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;
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--el-menu-base-level-padding, 20px);
|
||||
margin-top: -6px;
|
||||
width: auto !important;
|
||||
}
|
||||
right: 10px;
|
||||
width: 12px !important;
|
||||
font-size: 12px;
|
||||
color: var(--sf-menu-group);
|
||||
}
|
||||
|
||||
& .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;
|
||||
// 一级目录展开时作为分组标题:小字号、无图标
|
||||
.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: vars.$base-sub-menu-hover !important;
|
||||
background-color: transparent !important;
|
||||
color: var(--sf-menu-active-text);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: rgba(0, 133, 150, 0.28) !important;
|
||||
color: #38bdf8 !important;
|
||||
& > .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;
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu.is-active > .el-sub-menu__title {
|
||||
color: var(--sf-menu-active-text);
|
||||
}
|
||||
}
|
||||
|
||||
.hideSidebar {
|
||||
.sidebar-container {
|
||||
width: 54px !important;
|
||||
width: vars.$collapsed-sidebar-width !important;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
margin-left: 54px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
}
|
||||
margin-left: vars.$collapsed-sidebar-width;
|
||||
}
|
||||
|
||||
.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__title {
|
||||
&>span {
|
||||
height: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
visibility: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
& > span,
|
||||
& > i {
|
||||
height: 0;
|
||||
width: 0;
|
||||
@@ -222,15 +248,16 @@
|
||||
min-width: vars.$base-sidebar-width !important;
|
||||
}
|
||||
|
||||
// mobile responsive
|
||||
// 移动端:抽屉式导航
|
||||
.mobile {
|
||||
.main-container {
|
||||
margin-left: 0px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
transition: transform .28s;
|
||||
width: vars.$base-sidebar-width !important;
|
||||
box-shadow: 8px 0 32px rgba(16, 24, 40, 0.12);
|
||||
}
|
||||
|
||||
&.hideSidebar {
|
||||
@@ -251,47 +278,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
// when menu collapsed
|
||||
// 折叠态弹出的子菜单
|
||||
.el-menu--vertical {
|
||||
.el-sub-menu {
|
||||
.el-sub-menu__icon-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: var(--el-menu-base-level-padding, 20px);
|
||||
margin-top: -6px;
|
||||
width: auto !important;
|
||||
--el-menu-item-height: 36px;
|
||||
--el-menu-sub-item-height: 36px;
|
||||
|
||||
.el-menu--popup {
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.el-menu-item,
|
||||
.el-sub-menu__title {
|
||||
border-radius: 6px;
|
||||
font-size: 13.5px;
|
||||
|
||||
.sf-menu-icon,
|
||||
.svg-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&>.el-menu {
|
||||
.svg-icon {
|
||||
margin-right: 16px;
|
||||
.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 {
|
||||
&:hover {
|
||||
// you can use $sub-menuHover
|
||||
background-color: rgba(0, 0, 0, 0.06) !important;
|
||||
background-color: var(--sf-hover, rgba(0, 0, 0, 0.05)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// the scroll bar appears when the sub-menu is too long
|
||||
> .el-menu--popup {
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background: #d3dce6;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #99a9bf;
|
||||
background: var(--sf-border-strong, #c0c4cc);
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ $menuLightText: #303133;
|
||||
$menuLightActiveText: #409EFF;
|
||||
|
||||
// 基础变量
|
||||
$base-sidebar-width: 200px;
|
||||
$sideBarWidth: 200px;
|
||||
$base-sidebar-width: 232px;
|
||||
$collapsed-sidebar-width: 64px;
|
||||
$sideBarWidth: 232px;
|
||||
|
||||
// 菜单暗色变量
|
||||
$base-menu-color: #bfcbd9;
|
||||
|
||||
@@ -34,7 +34,7 @@ function getBreadcrumb() {
|
||||
}
|
||||
// 判断是否为首页
|
||||
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)
|
||||
}
|
||||
@@ -86,22 +86,27 @@ getBreadcrumb()
|
||||
<style lang='scss' scoped>
|
||||
.app-breadcrumb.el-breadcrumb {
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
line-height: 50px;
|
||||
margin-left: 8px;
|
||||
font-size: 13.5px;
|
||||
line-height: 56px;
|
||||
margin-left: 4px;
|
||||
|
||||
:deep(.el-breadcrumb__separator) {
|
||||
margin: 0 8px;
|
||||
color: var(--sf-border-strong, #c0c4cc);
|
||||
}
|
||||
|
||||
.no-redirect {
|
||||
color: var(--el-text-color-regular, #606266);
|
||||
color: var(--sf-text-1, var(--el-text-color-regular, #606266));
|
||||
cursor: text;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
transition: color 0.2s ease;
|
||||
color: var(--sf-text-4, var(--el-text-color-secondary, #909399));
|
||||
transition: color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary, #008596);
|
||||
color: var(--sf-text-1, var(--el-color-primary, #008596));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,32 @@
|
||||
top="6vh"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
class="sf-assign-dialog"
|
||||
:close-on-click-modal="!submitLoading"
|
||||
:close-on-press-escape="!submitLoading"
|
||||
:show-close="!submitLoading"
|
||||
:before-close="handleDialogBeforeClose"
|
||||
@closed="resetDialogState"
|
||||
>
|
||||
<p class="assign-intro">{{ dialogIntro }}</p>
|
||||
<el-form :inline="true" class="assign-search" @submit.prevent>
|
||||
<el-form-item label="用户搜索">
|
||||
<el-form-item>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
class="assign-search__input"
|
||||
placeholder="请输入账号或昵称"
|
||||
placeholder="搜索账号或昵称"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
:disabled="loading || submitLoading || loadFailed"
|
||||
/>
|
||||
</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-alert
|
||||
@@ -40,44 +50,51 @@
|
||||
row-key="userId"
|
||||
height="360"
|
||||
empty-text="暂无可分配用户"
|
||||
class="assign-table"
|
||||
@row-click="handleRowClick"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="55" :reserve-selection="true" />
|
||||
<el-table-column label="用户账号" prop="userName" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="用户昵称" prop="nickName" :show-overflow-tooltip="true">
|
||||
<el-table-column type="selection" width="48" :reserve-selection="true" />
|
||||
<el-table-column label="用户" min-width="220">
|
||||
<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>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前状态" width="100" align="center">
|
||||
<el-table-column label="授权变化" width="120" align="right">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.assigned ? 'success' : 'info'">
|
||||
{{ scope.row.assigned ? "已分配" : "未分配" }}
|
||||
</el-tag>
|
||||
<span class="assign-change" :class="`is-${changeOf(scope.row)}`">{{ CHANGE_LABELS[changeOf(scope.row)] }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="dialog-footer assign-footer">
|
||||
<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
|
||||
v-if="resourceType === 'site'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="loading || submitLoading || loadFailed"
|
||||
:disabled="loading || submitLoading || loadFailed || !assignmentChanged"
|
||||
@click="submitAssignment"
|
||||
v-hasPermi="['master:site:assign']"
|
||||
>确 定</el-button>
|
||||
>保存授权</el-button>
|
||||
<el-button
|
||||
v-else-if="resourceType === 'warehouse'"
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
:disabled="loading || submitLoading || loadFailed"
|
||||
:disabled="loading || submitLoading || loadFailed || !assignmentChanged"
|
||||
@click="submitAssignment"
|
||||
v-hasPermi="['master:warehouse:assign']"
|
||||
>确 定</el-button>
|
||||
>保存授权</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -108,6 +125,9 @@ const users = ref([]);
|
||||
const selectedUserIds = ref([]);
|
||||
const userTableRef = ref();
|
||||
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 submitGeneration = 0;
|
||||
|
||||
@@ -116,16 +136,44 @@ const dialogTitle = computed(() => {
|
||||
return `分配${label}用户${resourceName.value ? ` - ${resourceName.value}` : ""}`;
|
||||
});
|
||||
|
||||
const dialogIntro = computed(() => (resourceType.value === "site"
|
||||
? "被授权的用户才能在该现场收货核对、登记使用和发起退回,并在报表中看到它的数据。"
|
||||
: "被授权的用户才能在入库、装填、发货和退回中选择该仓库,并在报表中看到它的数据。"));
|
||||
|
||||
// 筛选按打开时的授权状态,勾选变化不会让行从列表中消失
|
||||
const filteredUsers = computed(() => {
|
||||
const normalizedKeyword = keyword.value.trim().toLowerCase();
|
||||
const byState = users.value.filter((user) => assignFilter.value === "all"
|
||||
|| (assignFilter.value === "assigned" ? user.assigned : !user.assigned));
|
||||
if (!normalizedKeyword) {
|
||||
return users.value;
|
||||
return byState;
|
||||
}
|
||||
return users.value.filter((user) =>
|
||||
return byState.filter((user) =>
|
||||
user.userName.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) {
|
||||
return rows
|
||||
@@ -226,6 +274,7 @@ function resetDialogState() {
|
||||
submitLoading.value = false;
|
||||
loadError.value = "";
|
||||
keyword.value = "";
|
||||
assignFilter.value = "all";
|
||||
resourceType.value = "";
|
||||
resourceId.value = undefined;
|
||||
resourceName.value = "";
|
||||
@@ -301,17 +350,122 @@ function isCurrentSubmit(submitContext) {
|
||||
&& submitContext.resourceId === resourceId.value;
|
||||
}
|
||||
|
||||
watch(keyword, restoreSelection);
|
||||
watch([keyword, assignFilter], restoreSelection);
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<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 {
|
||||
width: 260px;
|
||||
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) {
|
||||
.assign-search :deep(.el-form-item),
|
||||
.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>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 内容面板负责滚动,app-main 只占据页头以下的空间 */
|
||||
.app-main {
|
||||
/* 50= navbar 50 */
|
||||
min-height: calc(100vh - 50px);
|
||||
min-height: calc(100% - 56px);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
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;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.app-main:has(.copyright) {
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.fixed-header + .app-main {
|
||||
margin-top: 50px;
|
||||
.hasTagsView .app-main {
|
||||
min-height: calc(100% - 92px);
|
||||
}
|
||||
|
||||
.hasTagsView {
|
||||
@media screen and (max-width: 991px) {
|
||||
.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) {
|
||||
.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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
padding-bottom: max(24px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
||||
<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="测试数据模式" />
|
||||
<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>
|
||||
|
||||
<el-tooltip content="主题模式" effect="dark" placement="bottom">
|
||||
<el-tooltip :content="settingsStore.isDark ? '切换浅色模式' : '切换深色模式'" effect="dark" placement="bottom">
|
||||
<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="moon" />
|
||||
@@ -27,9 +32,9 @@
|
||||
</el-tooltip>
|
||||
</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">
|
||||
<img :src="userStore.avatar" class="user-avatar" />
|
||||
<img :src="userStore.avatar" class="user-avatar" alt="" />
|
||||
<span class="user-nickname"> {{ userStore.nickName }} </span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
@@ -59,7 +64,7 @@ import Logo from './Sidebar/Logo'
|
||||
import Hamburger from '@/components/Hamburger'
|
||||
import Screenfull from '@/components/Screenfull'
|
||||
import SizeSelect from '@/components/SizeSelect'
|
||||
import HeaderSearch from '@/components/HeaderSearch'
|
||||
import SfIcon from '@/components/SfIcon'
|
||||
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
|
||||
import useAppStore from '@/store/modules/app'
|
||||
import useUserStore from '@/store/modules/user'
|
||||
@@ -68,6 +73,8 @@ import useSettingsStore from '@/store/modules/settings'
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
// 左侧导航底部已展示当前用户时,顶部不再重复显示头像
|
||||
const sidebarShowsUser = computed(() => appStore.device !== 'mobile' && !appStore.sidebar.hide && settingsStore.navType == 1)
|
||||
|
||||
function toggleSideBar() {
|
||||
appStore.toggleSideBar()
|
||||
@@ -98,11 +105,15 @@ function logout() {
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
const emits = defineEmits(['setLayout'])
|
||||
const emits = defineEmits(['setLayout', 'openSearch'])
|
||||
function setLayout() {
|
||||
emits('setLayout')
|
||||
}
|
||||
|
||||
function openSearch() {
|
||||
emits('openSearch')
|
||||
}
|
||||
|
||||
async function toggleTheme(event) {
|
||||
const x = event?.clientX || window.innerWidth / 2
|
||||
const y = event?.clientY || window.innerHeight / 2
|
||||
@@ -152,35 +163,46 @@ async function toggleTheme(event) {
|
||||
}
|
||||
|
||||
.navbar {
|
||||
height: 50px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: var(--navbar-bg);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed);
|
||||
box-shadow: 0 1px 4px rgba(15, 34, 58, 0.04);
|
||||
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
// padding: 0 8px;
|
||||
gap: 4px;
|
||||
padding: 0 12px 0 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.hamburger-container {
|
||||
line-height: 46px;
|
||||
height: 100%;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
color: var(--sf-text-3, #5b616e);
|
||||
transition: background 0.15s;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
margin-right: 4px;
|
||||
|
||||
&: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 {
|
||||
flex-shrink: 0;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.topmenu-container {
|
||||
@@ -197,16 +219,11 @@ async function toggleTheme(event) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.errLog-container {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.right-menu {
|
||||
height: 100%;
|
||||
line-height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
@@ -220,75 +237,94 @@ async function toggleTheme(event) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
height: 100%;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-regular, #5a5e66);
|
||||
border-radius: 8px;
|
||||
font-size: 17px;
|
||||
color: var(--sf-text-3, #5b616e);
|
||||
vertical-align: middle;
|
||||
|
||||
&.hover-effect {
|
||||
cursor: pointer;
|
||||
transition: background 0.3s, color 0.2s;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.035);
|
||||
color: var(--el-color-primary, #008596);
|
||||
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
|
||||
color: var(--sf-text-1, #0f1115);
|
||||
}
|
||||
}
|
||||
|
||||
&.theme-switch-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: transform 0.3s;
|
||||
.search-trigger {
|
||||
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 {
|
||||
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 {
|
||||
margin-right: 4px;
|
||||
padding: 0 4px;
|
||||
margin-left: 4px;
|
||||
padding: 0;
|
||||
|
||||
.avatar-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 20px;
|
||||
transition: background 0.25s ease;
|
||||
padding: 2px 8px 2px 2px;
|
||||
border-radius: 18px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1.5px solid var(--el-color-primary-light-7, #b2e0e6);
|
||||
box-shadow: 0 2px 6px rgba(0, 133, 150, 0.12);
|
||||
border: 1px solid var(--sf-border, #e7e9ec);
|
||||
}
|
||||
|
||||
.user-nickname {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary, #303133);
|
||||
}
|
||||
|
||||
i {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: -20px;
|
||||
top: 25px;
|
||||
font-size: 12px;
|
||||
color: var(--sf-text-1, #0f1115);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +333,20 @@ async function toggleTheme(event) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
margin-right: 6px;
|
||||
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;
|
||||
}
|
||||
|
||||
.search-trigger {
|
||||
margin-right: 2px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.avatar-container .avatar-wrapper .user-nickname {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
<img :src="logoMark" :alt="title" class="sidebar-logo sidebar-logo-mark" />
|
||||
</router-link>
|
||||
<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>
|
||||
</transition>
|
||||
</div>
|
||||
@@ -13,8 +15,9 @@
|
||||
|
||||
<script setup>
|
||||
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 variables from '@/assets/styles/variables.module.scss'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineProps({
|
||||
collapse: {
|
||||
@@ -23,8 +26,11 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const title = import.meta.env.VITE_APP_TITLE;
|
||||
const logoBackground = variables.menuBg;
|
||||
const settingsStore = useSettingsStore()
|
||||
const title = import.meta.env.VITE_APP_TITLE
|
||||
const shortTitle = (title || '').replace(/管理系统$/, '') || title
|
||||
// 原版横版标识文字为白色,仅用于深色侧栏;浅色侧栏使用品牌深蓝文字版本
|
||||
const onDarkSurface = computed(() => settingsStore.isDark || settingsStore.sideTheme === 'theme-dark')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -39,21 +45,18 @@ const logoBackground = variables.menuBg;
|
||||
|
||||
.sidebar-logo-container {
|
||||
position: relative;
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
background: v-bind(logoBackground);
|
||||
text-align: center;
|
||||
flex: none;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
& .sidebar-logo-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 12px;
|
||||
padding: 0 20px;
|
||||
|
||||
& .sidebar-logo {
|
||||
display: block;
|
||||
@@ -61,20 +64,46 @@ const logoBackground = variables.menuBg;
|
||||
}
|
||||
|
||||
& .sidebar-logo-full {
|
||||
width: 132px;
|
||||
height: 42px;
|
||||
width: auto;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
& .sidebar-logo-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&.collapse {
|
||||
.sidebar-logo-link {
|
||||
padding: 4px;
|
||||
.sidebar-logo-divider {
|
||||
flex: none;
|
||||
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>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<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)">
|
||||
<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>
|
||||
</el-menu-item>
|
||||
</app-link>
|
||||
@@ -11,7 +12,8 @@
|
||||
|
||||
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +32,8 @@
|
||||
<script setup>
|
||||
import { isExternal } from '@/utils/validate'
|
||||
import AppLink from './Link'
|
||||
import SfIcon from '@/components/SfIcon'
|
||||
import { resolveMenuIcon } from '@/components/SfIcon/icons'
|
||||
import { getNormalPath } from '@/utils/shierfeng'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -90,6 +94,10 @@ function resolvePath(routePath, routeQuery) {
|
||||
return getNormalPath(props.basePath + '/' + routePath)
|
||||
}
|
||||
|
||||
function menuIcon(target) {
|
||||
return resolveMenuIcon(typeof target === 'string' ? target : target?.path)
|
||||
}
|
||||
|
||||
function hasTitle(title){
|
||||
if (title.length > 5) {
|
||||
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>
|
||||
<div :class="{ 'has-logo': showLogo }" class="sidebar-container">
|
||||
<div :class="['sidebar-container', sideThemeClass, { 'has-logo': showLogo }]">
|
||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:default-openeds="defaultOpeneds"
|
||||
:collapse="isCollapse"
|
||||
:background-color="getMenuBackground"
|
||||
:text-color="getMenuTextColor"
|
||||
:unique-opened="true"
|
||||
:active-text-color="theme"
|
||||
:unique-opened="false"
|
||||
:collapse-transition="false"
|
||||
mode="vertical"
|
||||
:class="sideTheme"
|
||||
class="sf-menu"
|
||||
>
|
||||
<sidebar-item
|
||||
v-for="(route, index) in sidebarRouters"
|
||||
@@ -21,84 +19,45 @@
|
||||
/>
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
<user-card :collapse="isCollapse" @setLayout="emit('setLayout')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Logo from './Logo'
|
||||
import SidebarItem from './SidebarItem'
|
||||
import variables from '@/assets/styles/variables.module.scss'
|
||||
import UserCard from './UserCard'
|
||||
import useAppStore from '@/store/modules/app'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
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 settingsStore = useSettingsStore()
|
||||
const permissionStore = usePermissionStore()
|
||||
|
||||
const sidebarRouters = computed(() => permissionStore.sidebarRouters);
|
||||
const showLogo = computed(() => settingsStore.sidebarLogo);
|
||||
const sideTheme = computed(() => settingsStore.sideTheme);
|
||||
const theme = computed(() => settingsStore.theme);
|
||||
const isCollapse = computed(() => !appStore.sidebar.opened);
|
||||
const sidebarRouters = computed(() => permissionStore.sidebarRouters)
|
||||
const showLogo = computed(() => settingsStore.sidebarLogo)
|
||||
const isCollapse = computed(() => !appStore.sidebar.opened)
|
||||
const sideThemeClass = computed(() => (settingsStore.sideTheme === 'theme-dark' && !settingsStore.isDark ? 'theme-dark' : 'theme-light'))
|
||||
|
||||
// 获取菜单背景色
|
||||
const getMenuBackground = computed(() => {
|
||||
if (settingsStore.isDark) {
|
||||
return 'var(--sidebar-bg)';
|
||||
}
|
||||
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;
|
||||
});
|
||||
// 与 SidebarItem.resolvePath 对一级目录生成的 index 保持一致
|
||||
const defaultOpeneds = computed(() =>
|
||||
sidebarRouters.value
|
||||
.filter((item) => !item.hidden && DEFAULT_OPEN_GROUPS.includes(item.path))
|
||||
.map((item) => getNormalPath(`${item.path}/${item.path}`))
|
||||
)
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route;
|
||||
const { meta, path } = route
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu;
|
||||
return meta.activeMenu
|
||||
}
|
||||
return path;
|
||||
});
|
||||
return path
|
||||
})
|
||||
</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) {
|
||||
if (!isActive(tag)) return {};
|
||||
return {
|
||||
"background-color": theme.value || '#008596',
|
||||
"border-color": theme.value || '#008596'
|
||||
"border-color": 'var(--sf-border-strong, #dde0e4)'
|
||||
};
|
||||
}
|
||||
function isAffix(tag) {
|
||||
@@ -241,11 +240,10 @@ function handleScroll() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
height: 36px;
|
||||
width: 100%;
|
||||
background: var(--tags-bg, #fff);
|
||||
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed);
|
||||
box-shadow: 0 1px 4px 0 rgba(15, 34, 58, 0.04);
|
||||
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
|
||||
.tags-view-wrapper {
|
||||
.tags-view-item {
|
||||
display: inline-block;
|
||||
@@ -272,26 +270,25 @@ function handleScroll() {
|
||||
}
|
||||
|
||||
&:not(.active):hover {
|
||||
background-color: var(--el-fill-color-light, #f5f7fa);
|
||||
border-color: var(--el-color-primary-light-7, #b2e0e6);
|
||||
color: var(--el-color-primary, #008596);
|
||||
background-color: var(--sf-surface-2, #f7f8f9);
|
||||
color: var(--sf-text-1, #0f1115);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #008596;
|
||||
color: #fff;
|
||||
border-color: #008596;
|
||||
box-shadow: 0 2px 6px rgba(0, 133, 150, 0.22);
|
||||
background-color: var(--sf-surface-3, #f1f2f4);
|
||||
color: var(--sf-text-1, #0f1115);
|
||||
font-weight: 500;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
background: #fff;
|
||||
background: var(--sf-accent, #007787);
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
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>
|
||||
@@ -1,32 +1,42 @@
|
||||
<template>
|
||||
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
|
||||
<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="{ 'fixed-header': fixedHeader }">
|
||||
<navbar @setLayout="setLayout" />
|
||||
<div ref="panelRef" class="sf-panel">
|
||||
<div :class="{ 'fixed-header': fixedHeader }" class="sf-panel__header">
|
||||
<test-mode-banner />
|
||||
<navbar @setLayout="setLayout" @openSearch="openCommandPalette" />
|
||||
<tags-view v-if="needTagsView" />
|
||||
</div>
|
||||
<app-main />
|
||||
</div>
|
||||
<settings ref="settingRef" />
|
||||
</div>
|
||||
<command-palette ref="commandPaletteRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
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 useAppStore from '@/store/modules/app'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
const SHELL_CLASS = 'sf-shell'
|
||||
|
||||
const route = useRoute()
|
||||
const settingsStore = useSettingsStore()
|
||||
const theme = computed(() => settingsStore.theme);
|
||||
const sideTheme = computed(() => settingsStore.sideTheme);
|
||||
const sidebar = computed(() => useAppStore().sidebar);
|
||||
const device = computed(() => useAppStore().device);
|
||||
const needTagsView = computed(() => settingsStore.tagsView);
|
||||
const fixedHeader = computed(() => settingsStore.fixedHeader);
|
||||
const panelRef = ref(null)
|
||||
const commandPaletteRef = ref(null)
|
||||
|
||||
const classObj = computed(() => ({
|
||||
hideSidebar: !sidebar.value.opened,
|
||||
@@ -53,14 +63,32 @@ watchEffect(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// 内容面板是滚动容器,切换页面时回到顶部
|
||||
watch(() => route.path, () => {
|
||||
if (panelRef.value) panelRef.value.scrollTop = 0
|
||||
})
|
||||
|
||||
function handleClickOutside() {
|
||||
useAppStore().closeSideBar({ withoutAnimation: false })
|
||||
}
|
||||
|
||||
function openCommandPalette() {
|
||||
commandPaletteRef.value?.open()
|
||||
}
|
||||
|
||||
const settingRef = ref(null);
|
||||
function setLayout() {
|
||||
settingRef.value.openSetting();
|
||||
}
|
||||
|
||||
// 工作区设计令牌只作用于登录后的布局,登录页等独立页面不受影响
|
||||
onMounted(() => {
|
||||
document.documentElement.classList.add(SHELL_CLASS)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.documentElement.classList.remove(SHELL_CLASS)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -72,6 +100,7 @@ function setLayout() {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--sf-ground, #f5f6f7);
|
||||
|
||||
&.mobile.openSidebar {
|
||||
position: fixed;
|
||||
@@ -79,14 +108,42 @@ function setLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
.main-container:has(.fixed-header) {
|
||||
.main-container {
|
||||
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 {
|
||||
background: #000;
|
||||
opacity: 0.3;
|
||||
background: #0f1115;
|
||||
opacity: 0.32;
|
||||
width: 100%;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
@@ -94,24 +151,14 @@ function setLayout() {
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 9;
|
||||
width: calc(100% - #{vars.$base-sidebar-width});
|
||||
transition: width 0.28s;
|
||||
.mobile {
|
||||
.main-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.hideSidebar .fixed-header {
|
||||
width: calc(100% - 54px);
|
||||
.sf-panel {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sidebarHide .fixed-header {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile .fixed-header {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -44,6 +44,10 @@ import ImagePreview from "@/components/ImagePreview"
|
||||
// 字典标签组件
|
||||
import DictTag from '@/components/DictTag'
|
||||
import MasterExcelActions from '@/components/MasterExcelActions'
|
||||
// 页面标题与托盘档案链接
|
||||
import PageHeader from '@/components/PageHeader'
|
||||
import PalletCodeLink from '@/components/PalletCodeLink'
|
||||
import DocNo from '@/components/DocNo'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
@@ -67,6 +71,9 @@ app.component('ImagePreview', ImagePreview)
|
||||
app.component('RightToolbar', RightToolbar)
|
||||
app.component('Editor', Editor)
|
||||
app.component('MasterExcelActions', MasterExcelActions)
|
||||
app.component('PageHeader', PageHeader)
|
||||
app.component('PalletCodeLink', PalletCodeLink)
|
||||
app.component('DocNo', DocNo)
|
||||
|
||||
app.use(router)
|
||||
app.use(store)
|
||||
|
||||
@@ -72,7 +72,7 @@ export const constantRoutes = [
|
||||
path: '/index',
|
||||
component: () => import('@/views/dashboard/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 = [
|
||||
{
|
||||
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',
|
||||
component: Layout,
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
/**
|
||||
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
|
||||
*/
|
||||
sideTheme: 'theme-dark',
|
||||
sideTheme: 'theme-light',
|
||||
|
||||
/**
|
||||
* 是否系统布局配置
|
||||
@@ -22,7 +22,7 @@ export default {
|
||||
/**
|
||||
* 是否显示 tagsView
|
||||
*/
|
||||
tagsView: true,
|
||||
tagsView: false,
|
||||
|
||||
/**
|
||||
* 显示页签图标
|
||||
|
||||
@@ -15,6 +15,8 @@ const useUserStore = defineStore(
|
||||
name: '',
|
||||
nickName: '',
|
||||
avatar: '',
|
||||
deptName: '',
|
||||
roleNames: [],
|
||||
admin: false,
|
||||
roles: [],
|
||||
permissions: []
|
||||
@@ -55,6 +57,8 @@ const useUserStore = defineStore(
|
||||
this.name = user.userName
|
||||
this.nickName = user.nickName
|
||||
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
|
||||
/* 初始密码提示 */
|
||||
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
Reference in New Issue
Block a user