Compare commits

..

51 Commits

Author SHA1 Message Date
shijing 6e9641f58a fix:外协快速报工 2026-08-13 08:59:58 +08:00
shijing 0936687b1e fix: /api/develop/server_time/ get接口全部放行 2026-08-12 16:12:42 +08:00
shijing 7e318d9cfd Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-08-11 16:02:57 +08:00
shijing db86a0fcf1 fix:(Length + 字符串)纯数字场景等价于按数字排 2026-08-11 15:55:38 +08:00
caoqianming a7a9a0aa6f feat(mcp): add factory domain tools 2026-08-10 13:47:56 +08:00
caoqianming d1693799b9 feat(wpm): recommend equipment for production logs 2026-08-07 16:24:45 +08:00
caoqianming 596f187d3d docs: record frontend validation timing 2026-08-07 16:17:23 +08:00
caoqianming fa16694f9f feat(wpm): enrich handover detail records 2026-08-07 16:17:12 +08:00
caoqianming 94215df0b6 feat(wpm): enrich sub-process operation records 2026-08-07 14:29:56 +08:00
caoqianming c17552e0ea test: isolate database settings from production 2026-08-07 11:15:39 +08:00
caoqianming 24ce008d3a perf(wpm): batch load handover inventory validation 2026-08-07 11:15:22 +08:00
caoqianming d98b7fada2 fix(wpm): allow qualified and grade b batch merges 2026-08-07 10:28:12 +08:00
caoqianming fd4de2bd4b fix(wpm): match historical numbers to active rule 2026-08-07 09:58:54 +08:00
caoqianming b365e06318 feat(inventory): add effective defect grade filtering 2026-08-06 10:31:37 +08:00
caoqianming ed952d2d3a feat(swagger): generate localized static API schema 2026-08-05 16:51:59 +08:00
caoqianming 4bf5f1e585 feat(bi): expose dataset query workflow 2026-08-05 15:01:44 +08:00
caoqianming 5fb179eb9e fix(develop): restrict unsafe debug endpoints 2026-08-04 16:33:27 +08:00
caoqianming 69b5346031 feat(wpm): sync wpr number from output edits 2026-08-04 14:32:24 +08:00
caoqianming ddb3cc6f3f fix(wpm): derive number date filters from rule 2026-08-04 13:45:29 +08:00
shijing 19271e8880 fix:ftestwork查询添加batch_contains 2026-08-04 11:12:36 +08:00
shijing cfcd6c2ba3 Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-08-04 09:55:18 +08:00
shijing 62f5aaef21 fix:api/qm/ftestwork接口返回submit_user_name 2026-08-04 09:55:16 +08:00
caoqianming d86d70641d feat(wpm): add files to individual outputs 2026-08-03 10:23:52 +08:00
zty e23db9dc6c feat(hrm): 员工Excel导入支持全量覆盖(空单元格清空,不可空字段保留原值)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 03:55:48 -04:00
zty cf2800daa0 feat(enm): 新增瓮福数据API(采集数据查询+测点列表分页)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-31 03:55:48 -04:00
caoqianming cee94f9f4a Centralize snowflake IDs for bulk create 2026-07-31 11:03:26 +08:00
caoqianming 1f98b81ce3 Fix bulk-created individual defect IDs 2026-07-31 11:00:01 +08:00
caoqianming 074e759c29 Allow clearing same-grade batch defects 2026-07-30 14:52:43 +08:00
caoqianming 89cc999c42 Prevent duplicate warehouse inventory creation 2026-07-30 13:22:28 +08:00
caoqianming 6068f2315d Expose independent material defect grades 2026-07-30 11:15:29 +08:00
caoqianming 298a32f744 Refine individual defect lifecycle 2026-07-30 09:40:29 +08:00
caoqianming bcbb27374f Support manual route output materials 2026-07-29 08:57:20 +08:00
caoqianming d5d940c5e4 Fix handover mgroup filtering 2026-07-28 16:11:11 +08:00
caoqianming 5f81ef9494 Prevent duplicate workshop inventory creation 2026-07-28 15:27:39 +08:00
caoqianming 8af19b8a9a Restrict global inventory handovers 2026-07-28 12:15:21 +08:00
caoqianming 1700a58409 统一车间库存归属策略 2026-07-28 09:26:25 +08:00
caoqianming dde5b0a6e8 Fix mlogbw creation for route-less rework 2026-07-27 17:36:24 +08:00
caoqianming d5426c980f release: 3.1.2026072713 2026-07-27 13:23:55 +08:00
caoqianming c3f2c0f136 fix: unify workflow action permissions 2026-07-27 13:20:58 +08:00
caoqianming 919ac890a9 docs: record Codex context migration plan 2026-07-27 10:30:10 +08:00
caoqianming 534aa4224c docs: migrate project memory for Codex 2026-07-27 10:29:31 +08:00
caoqianming 8a43ba7717 docs: add factory release skill 2026-07-27 10:29:07 +08:00
caoqianming b52a3fba4a docs: add Codex project instructions 2026-07-27 10:28:39 +08:00
caoqianming dd6b49e199 docs: design Claude to Codex context migration 2026-07-27 10:22:47 +08:00
caoqianming 599bb933b8 release: 3.1.2026072316
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-23 16:05:30 +08:00
caoqianming 1584763794 feat:单个编号规则支持c_day按天占位与按天归零;并发生成加工序级advisory锁防重号;流水号宽度按规则解析并对齐排序截取;超上限报错;工序保存时校验编号规则
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:05:05 +08:00
caoqianming 1c203325fc release: 3.1.2026072309
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:19:34 +08:00
caoqianming 3561a50647 feat:工艺路线Route创建/更新时未指定输出物料且工艺包无产品直接报错,不再静默生成残缺工序;补material_in_type字段供画布按物料真实类型着色
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 09:15:26 +08:00
caoqianming c08b220d17 feat:工艺路线新增validate接口,供拖拽画布实时校验DAG结构(不存盘,返回valid/error)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:29:53 +08:00
caoqianming ea0fe7c312 feat:物料新增入库检验方式test_mode_in(免检/必检,默认必检),生产领料检验卡点支持物料级免检,明细过滤器支持material__test_mode_in
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:20:18 +08:00
caoqianming b0263bae32 docs:新增优化算法切入点分析(排产/补货/合批/装箱等可优化环节盘点)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 11:05:11 +08:00
107 changed files with 6418 additions and 365 deletions

View File

@ -0,0 +1,23 @@
---
name: factory-release
description: Use when the user explicitly asks to release the factory backend, run its release workflow, or bump its deployed version.
---
# 发布 Factory 后端
仅在用户明确要求后端发版、release 或提升部署版本时执行本流程。普通代码修改、提交或推送不触发发版。
严格按以下顺序执行:
1. 确认目标是当前 `factory` 后端。若用户要求发布 `ehs_web` 前端,改读 `.codex/memory/reference_ehs_web_release.md`
2. 执行 `git status --short`,确认工作区状态。发布提交只能包含 `changelog.md``server/settings.py`;不得混入其他已修改或未跟踪文件。
3. 执行 `bash update_changelog.sh`,从脚本输出读取形如 `3.1.YYYYMMDDHH` 的版本号,记为 `<VER>`。不得自行编造或改写版本号。
4. 把 `server/settings.py` 中唯一权威版本字段 `SYS_VERSION` 更新为 `<VER>`
5. 读取 `changelog.md` 顶部约 20 行,确认标题是 `## <VER>`,且自上一个 tag 起的提交分类合理。出现重复段、明显漏项或无关内容时,先向用户确认,不得继续打 tag。
6. 再次检查 diff只暂存 `changelog.md``server/settings.py`
7. 提交信息使用 `release: <VER>`,不添加工具或模型专属署名。
8. 创建无 `v` 前缀的 tag`git tag <VER>`。
9. 推送当前分支到 `origin`,成功后再执行 `git push origin <VER>`。逐步检查结果,不用命令链掩盖中间失败。
10. 汇报 `<VER>`、changelog 包含的提交类别、commit hash、tag 名,以及分支和 tag 的推送结果。
若提交、tag 或推送中途失败,先检查实际 Git 状态。任何会重写历史或删除远端 tag 的回退操作,都必须先取得用户明确授权。

15
.codex/memory/MEMORY.md Normal file
View File

@ -0,0 +1,15 @@
# Factory 项目记忆索引
- [前端路由由后端下发](feedback_routing.md):新增 `ehs_web` 页面时不要修改静态路由表。
- [scripts 目录脚本不入库](feedback_scripts_gitignore.md):一次性回刷脚本不得强制加入 Git。
- [发版只在明确要求时执行](feedback_version_bump.md):普通改动不自动 bump 或 release。
- [后端配套前端](project_frontend_ehs_web.md):后端 API 改动时检查 `../ehs_web` 及统计页面惯例。
- [生产数据只读验证](project_gx_db_access.md):涉及真实数据形态时的安全查询原则。
- [合批原料字段历史问题](project_material_ofrom_merge_bug.md)`material_ofrom` 不一致的既有排查结论。
- [前端独立发版流程](reference_ehs_web_release.md):发布 `ehs_web` 时使用。
- [项目 Python 虚拟环境](reference_python_venv.md):运行 Django、pytest 和脚本时使用。
- [项目测试数据库](reference_test_database.md):测试与可切换的生产查询连接解耦,始终使用固定的 `test_ehs_develop`
- [两个 WebView 套壳 App](reference_wrapper_apps.md):修改 h5x 与扫码、返回键交互时使用。
- [前端验证时机](feedback_frontend_validation.md):日常修改先跑 check完整 build 留到 push 前执行。
这些文件记录的是长期约定或历史上下文。执行任务前应结合当前代码和数据重新验证,尤其不要把历史缺陷结论直接当成当前故障原因。

View File

@ -0,0 +1,5 @@
# 前端验证时机
- 修改配套前端 `../ehs_web` 时,日常开发和中间验证优先运行项目已有的 `check`,不要每次修改后都运行完整 `build`
- 准备 push 前运行一次完整 `build`,用于发现生产构建阶段的问题。
- 若当前前端尚未配置 `check` 脚本,应先说明现状,不得把其他命令擅自当作 `check`

View File

@ -0,0 +1,11 @@
# ehs_web 路由由后端下发
`ehs_web` 使用后端下发的动态菜单和路由。新增管理页面时,不要向 `src/config/route.js` 添加条目;该文件中的既有静态路由属于历史或局部用法,不能作为新页面的默认范例。
通常只需:
- 创建 `src/views/<module>/<name>.vue`
- 补充 `src/api/model/<module>.js` 中的接口。
- 在后端菜单与权限配置中设置组件相对路径和权限码。
若页面未出现在菜单中,应检查后端菜单、权限与组件路径配置,不要用新增静态路由规避问题。

View File

@ -0,0 +1,11 @@
# scripts 目录脚本不入库
仓库 `.gitignore` 中的 `scripts/*.py` 是有意设置的:`correct_*` 等一次性回刷、修数脚本只在本地或服务器按需运行,不进入版本库。
编写这类脚本后:
- 正常保留在本地工作区。
- 提交时不要执行 `git add -f`
- 不因仓库中存在少量历史已跟踪脚本而推断新脚本也应提交。
只有用户明确要求某个脚本入库时,才可改变这一规则。

View File

@ -0,0 +1,7 @@
# 发版只在用户明确要求时执行
不要在每轮代码修改完成后自动提升版本号、生成 release commit 或打 tag。版本对应实际部署多轮改动可以合并到一次发版。
普通任务完成后可以正常提交代码,但只有用户明确说“发版”“走 release”或“bump 版本”时,才读取并执行 `.agents/skills/release/SKILL.md`
后端与前端有各自独立的发版流程;后端 release skill 不适用于 `../ehs_web`

View File

@ -0,0 +1,21 @@
# Factory 配套前端 ehs_web
当前仓库是 Django 后端,配套 Vue 前端位于 `../ehs_web`(通常解析为 `D:\projects\ehs_web`,也可从 `factory.code-workspace` 核对)。
修改 serializer 字段、API 返回结构或接口行为后,应主动检查前端中消费该接口的:
- `src/views/` 页面。
- `src/api/model/` 接口定义。
路由和菜单规则见 [前端路由由后端下发](feedback_routing.md)。
统计页面现有惯例可参考 `src/views/statistics/good_check_gx2.vue``zt_batch_gx.vue`
- Options API 与 `el-container` 布局。
- 复杂查询通过 `$API.wpm.batchstquery` 调用后端 `ComplexQueryMixin`
- `querys` 使用外层 OR、内层 AND 的 `{field, compare, value}` 条件。
- JSONField 中文键常直接从 `scope.row.data` 读取。
- 表格导出使用项目现有 `$XLSX` 封装。
- JSONField 键的子串匹配使用 `icontains``contains` 表示 JSON 包含语义。
这些是现有实现惯例,不替代对当前页面和接口代码的检查。

View File

@ -0,0 +1,13 @@
# 生产数据只读验证
各工厂 PostgreSQL 连接参数保存在本机被 Git 忽略的配置中。涉及批次谱系、统计口径或真实数据形态时,可以在任务确有需要且用户允许的范围内做只读抽样查询。
安全要求:
- 从本地忽略配置读取连接参数,不在文档、脚本、命令记录或提交中复制凭据。
- 数据库会话显式设为只读。
- 禁止执行 INSERT、UPDATE、DELETE、DDL、修数或其他写操作除非用户针对该操作另行明确授权。
- 临时查询脚本放在不会提交的本地位置。
- Windows 中文输出需要时设置 UTF-8 编码。
真实数据可能包含拆合批循环、自环边和期初断头批。修改统计或谱系算法前,优先用少量只读样本验证数据假设。

View File

@ -0,0 +1,29 @@
# 合批原料 material_ofrom 历史排查
## 历史现象
曾出现合批或生产流转后,批次签带显示的原料类型与实物板段棒签不一致。同一工序可能存在多条并行原料输入,因此也可能发生真实混料。该问题当时决定暂缓,只有再次出现并取得可复现样本时再继续调查。
## 已确认的代码风险
`WMaterial.locked_get_or_create``apps/utils/models.py`)命中既有行时直接返回,不会应用 `defaults`。在 `apps/wpm/services.py` 的合批或流转逻辑中:
- `material_ofrom`、`batch_ofrom`、`number_from` 只放在 `defaults`
- 定位既有行的键不包含 `material_ofrom`
因此,一旦命中相同业务键的旧行,后续不同原料流入时,`material_ofrom` 可能继续保留首次建行时的值。该字段又是单个外键,结构上无法表达混料。签带展示值来自 serializer 中的 `material_ofrom_name`
## 历史样本结论
当时抽样数据中的相关 `WMaterial` 行已经归零,来源记录也指向原有材料;实物板段号在当前 WPM 相关表中没有找到。由此推测,实物棒签的材料源和板段号可能来自上游拉丝或滚圆子系统,单靠当前库无法闭环证明。
## 下次接续
再次出现时应先取得一个“实物棒签材料与批次签带材料不一致、且两者来源可在同一系统追踪”的批次样本,然后:
1. 确认实物棒签由哪个系统和表生成。
2. 用只读查询验证流转前后定位键是否命中旧行。
3. 区分真实混料、上游标签来源和 `defaults` 未更新这三种原因。
4. 在复现前不要直接修改字段结构或流转逻辑。
查询时遵守 [生产数据只读验证](project_gx_db_access.md)。

View File

@ -0,0 +1,14 @@
# ehs_web 前端独立发版流程
`../ehs_web` 有独立于后端的发版流程。`.agents/skills/release/SKILL.md` 只适用于当前 `factory` 后端。
前端发版要点:
1. 权威版本号位于 `src/config/index.js``APP_VER`
2. 在前端仓库运行 `update_changelog.sh`,使用脚本生成的 `3.1.YYYYMMDDHH` 版本号。若上一个 tag 过旧,可向脚本传入合适的起点 ref并检查提交分类。
3. 把 `APP_VER` 更新为相同版本。
4. 只暂存前端的 `changelog.md``src/config/index.js`
5. 提交信息使用 `release: <VER>`tag 使用无 `v` 前缀的 `<VER>`
6. 推送到前端仓库配置的 `coding` 远端,而不是默认假设使用 `origin`
前端 `package.json` 中的旧 `version` 字段不是当前权威版本。仍须遵守“只有用户明确要求才发版”的约束。

View File

@ -0,0 +1,15 @@
# Factory 项目 Python 解释器
项目根目录包含 Windows 虚拟环境 `.venv`。运行 `manage.py`、pytest 或项目 Python 脚本时使用:
```powershell
D:\projects\factory\.venv\Scripts\python.exe manage.py check
```
从 Git Bash 调用时可使用:
```bash
/d/projects/factory/.venv/Scripts/python.exe manage.py check
```
不要直接依赖系统 `python`,因为系统环境可能缺少项目依赖。

View File

@ -0,0 +1,15 @@
# 项目测试数据库
- 生产问题查询时,默认数据库连接可以根据工厂或环境切换到不同 IP 和业务库;这类连接只用于授权范围内的生产数据只读验证。
- 所有 Django、pytest 及其他自动化测试必须与当前生产查询连接解耦,始终使用固定的 `test_ehs_develop` 测试数据库连接。
- 不能仅依赖当前默认连接的 Django 自动 `test_<NAME>` 命名;即使当前业务库是 `bxerp` 或其他库,测试也不应转而使用 `test_bxerp` 或其他派生库。
- 不得在生产数据库上运行会建表、迁移、写入或清理数据的测试。
- 生产查询与固定测试库的连接参数均从本机已忽略配置中读取,不在项目记忆、源码或提交信息中记录凭据。
当前实现:
- 固定测试连接保存在已忽略的 `config/conf_test.py`
- `server/test_settings.py` 加载该连接,`manage.py test` 会自动选择测试 settings其他 `manage.py` 命令仍使用当前业务库连接。
- 测试 settings 中的基础 `NAME``TEST.NAME` 都必须是 `test_ehs_develop`,并在启动时校验,防止通过测试 settings 误操作 `ehs_develop` 或任何生产库。
- pytest-django 通过项目根目录 `pytest.ini` 固定使用 `server.test_settings`
- 本地重复运行测试时优先使用 `.venv\\Scripts\\python.exe manage.py test --keepdb --noinput`

View File

@ -0,0 +1,8 @@
# Web 前端的两个套壳 App
`ehs_web` 的 h5x 页面还由两个 WebView 壳工程加载:
- 原生 Android 壳:`C:\Users\caoqi\AndroidStudioProjects\BxErp`,包名 `com.example.bxerp`。核心入口是 `app/src/main/java/com/example/bxerp/MainActivity.kt`,通过 `window.Android` 暴露扫码能力,常用交互为 `Android.openScanner()` 和页面回调 `onScanResult(data)`
- uni-app 壳:`D:\projects\mp_shell`Vue 3/HBuilderX 工程应用名“光芯ERP”。`pages/index/index.vue` 使用 `<web-view>` 加载 h5x并处理返回键与状态栏适配。
修改 h5x 的扫码、返回键或 WebView 消息交互时,应检查这两个壳的约定。工程位置和加载地址可能随部署变化,操作前以当前本地代码为准。

28
AGENTS.md Normal file
View File

@ -0,0 +1,28 @@
# Factory 项目协作约定
## 项目结构
- 当前仓库是 Django 后端。
- 配套 Vue 前端位于 `../ehs_web`。修改 serializer 字段、API 返回结构或接口行为时,检查并同步对应的前端页面和 `src/api/model/` 接口定义。
- `ehs_web` 的菜单和路由由后端动态下发。新增页面时不要修改 `src/config/route.js`;页面组件、菜单和权限应按后端配置衔接。
## 本地执行
- 在 Windows 下运行 Django、pytest 或其他 Python 命令时,使用项目虚拟环境:`D:\projects\factory\.venv\Scripts\python.exe`。不要依赖系统 Python。
- `scripts/*.py` 是有意忽略的一次性回刷或修数脚本。未经用户明确要求,不得用 `git add -f` 强制提交。
## 数据安全
- 涉及生产数据验证时,只允许执行只读查询;写入、修数或结构变更必须取得用户针对该操作的明确授权。
- 数据库连接参数从本机已忽略的配置读取。不得把用户名、密码、连接串或可直接复用的带凭据命令写入文档、skill、提交信息或源码。
## 发版
- 不要在普通修改完成后自动提升版本号、打 tag 或发版。
- 只有用户明确要求“发版”“release”或“bump 版本”时,才使用 `.agents/skills/release/SKILL.md`
- 前端 `ehs_web` 有独立发版流程,不要套用后端 release 流程。
## 按需项目知识
- 详细项目背景、历史反馈和排障记录见 `.codex/memory/MEMORY.md`
- 只在任务相关时读取对应 memory 文件,避免把历史问题误当成当前事实。

View File

@ -18,10 +18,35 @@ class DatasetCreateUpdateSerializer(CustomModelSerializer):
class DatasetSerializer(CustomModelSerializer):
description = serializers.CharField(
label="适用场景与统计口径",
help_text="说明该数据集适合回答的问题、指标口径、参数格式和返回字段含义",
required=False,
allow_blank=True,
)
default_param = serializers.JSONField(
label="默认查询参数",
help_text="执行时可覆盖的参数及默认值;内部 SQL 片段参数应保留默认值",
required=False,
)
test_param = serializers.JSONField(
label="测试查询参数",
help_text="数据集维护时使用的示例参数,普通查询优先参考 description",
required=False,
)
class Meta:
model = Dataset
fields = '__all__'
class DatasetListResponseSerializer(serializers.Serializer):
count = serializers.IntegerField(label="数据集总数")
next = serializers.URLField(required=False, allow_null=True)
previous = serializers.URLField(required=False, allow_null=True)
results = DatasetSerializer(many=True)
class DatasetRecordSerializer(CustomModelSerializer):
class Meta:
model = DatasetRecord
@ -36,6 +61,14 @@ class DatasetRecordSerializer(CustomModelSerializer):
class DataExecSerializer(serializers.Serializer):
query = serializers.JSONField(
label="查询字典参数", required=False, allow_null=True)
is_test = serializers.BooleanField(label='是否测试', default=False)
raise_exception = serializers.BooleanField(label='是否直接报错', default=False)
label="查询字典参数",
help_text="按所选数据集 description/default_param 声明的业务参数填写",
required=False,
allow_null=True,
)
is_test = serializers.BooleanField(
label='是否测试', help_text="普通业务查询固定为 false", default=False
)
raise_exception = serializers.BooleanField(
label='是否直接报错', help_text="建议为 true便于修正缺失或非法参数", default=True
)

View File

@ -1,11 +1,15 @@
from rest_framework.exceptions import ParseError
import concurrent.futures
import json
from jinja2 import Template
import logging
from rest_framework.exceptions import ParseError
from apps.bi.models import Dataset
import concurrent
from apps.utils.sql import execute_raw_sql, format_sqldata
from apps.utils.tools import MyJSONEncoder
myLogger = logging.getLogger('log')
forbidden_keywords = ["UPDATE", "DELETE", "DROP", "TRUNCATE", "INSERT", "CREATE", "ALTER", "GRANT", "REVOKE", "EXEC", "EXECUTE"]
@ -32,31 +36,57 @@ def format_json_with_placeholders(json_str, **kwargs):
return formatted_json
def exec_dataset(dt: Dataset, xquery: dict = {}):
def render_dataset_sql(dt: Dataset, xquery=None, *, is_test=False):
"""根据数据集配置和调用参数生成经过安全检查的只读 SQL。"""
query = dict(dt.default_param or {})
query.update(dict(dt.test_param or {}) if is_test else dict(xquery or {}))
if not dt.sql_query:
return ''
try:
return check_sql_safe(dt.sql_query.format(**query))
except KeyError as exc:
raise ParseError(f'需指定查询参数_{str(exc)}') from exc
def execute_rendered_dataset(dt: Dataset, full_sql: str, *, raise_exception=True):
"""执行已经渲染和校验的 SQL返回可合并到数据集响应的结果。"""
results = {}
results2 = {}
can_cache = True
sql_list = [sql for sql in full_sql.strip(';').split(';') if sql.strip()]
if sql_list:
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
futures = {
executor.submit(execute_raw_sql, sql): (f'ds{index}', sql)
for index, sql in enumerate(sql_list)
}
for future in concurrent.futures.as_completed(futures):
name, sql = futures[future]
try:
res = future.result()
results[name], results2[name] = format_sqldata(res[0], res[1])
except Exception as exc:
myLogger.error(f'bi查询异常{str(exc)}-{dt.code}--{sql}')
if raise_exception:
raise ParseError(f'查询异常:{str(exc)}') from exc
results[name] = 'error: ' + str(exc)
can_cache = False
response_data = {'data': results, 'data2': results2}
if dt.echart_options and not dt.echart_options.startswith('function'):
for result in results.values():
if isinstance(result, str):
raise ParseError(result)
response_data['echart_options'] = format_json_with_placeholders(
dt.echart_options, **results
)
return response_data, can_cache
def exec_dataset(dt: Dataset, xquery=None):
"""执行数据集
返回 (sql语句, { rda})
"""
rdata = {}
results = {}
results2 = {}
query = dt.default_param
if dt.sql_query:
query.update(xquery)
sql_f_ = check_sql_safe(dt.sql_query.format(**query))
sql_f_strip = sql_f_.strip(';')
sql_f_l = sql_f_strip.split(';')
# 多线程运行并返回字典结果
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
fun_ps = []
for ind, val in enumerate(sql_f_l):
fun_ps.append((f'ds{ind}', execute_raw_sql, val))
# 生成执行函数
futures = {executor.submit(i[1], i[2]): i for i in fun_ps}
for future in concurrent.futures.as_completed(futures):
name, *_, sql_f = futures[future] # 获取对应的键
res = future.result()
results[name], results2[name] = format_sqldata(
res[0], res[1])
rdata['data'] = results
rdata['data2'] = results2
return sql_f_, rdata
full_sql = render_dataset_sql(dt, xquery)
response_data, _ = execute_rendered_dataset(dt, full_sql)
return full_sql, response_data

105
apps/bi/test_services.py Normal file
View File

@ -0,0 +1,105 @@
from types import SimpleNamespace
from unittest.mock import patch
from django.test import SimpleTestCase
from rest_framework.exceptions import ParseError
from apps.bi.services import (
exec_dataset,
execute_rendered_dataset,
render_dataset_sql,
)
from apps.bi.views import DatasetViewSet
def dataset(**overrides):
values = {
"code": "output_daily",
"sql_query": "select * from output where day = '{day}'",
"default_param": {"day": "2026-08-01"},
"test_param": {"day": "2026-08-02"},
"echart_options": "",
}
values.update(overrides)
return SimpleNamespace(**values)
class DatasetExecutionServiceTests(SimpleTestCase):
def test_render_does_not_mutate_default_parameters(self):
item = dataset()
sql = render_dataset_sql(item, {"day": "2026-08-10"})
self.assertIn("2026-08-10", sql)
self.assertEqual(item.default_param, {"day": "2026-08-01"})
def test_render_reports_missing_parameters(self):
item = dataset(default_param={}, sql_query="select '{required}'")
with self.assertRaises(ParseError):
render_dataset_sql(item)
def test_execute_formats_each_statement(self):
item = dataset(echart_options='{"series": {ds0}}')
with (
patch("apps.bi.services.execute_raw_sql", return_value=([], [])),
patch(
"apps.bi.services.format_sqldata",
return_value=([{"count": 1}], {"count": [1]}),
),
):
response, can_cache = execute_rendered_dataset(
item, "select 1;select 2"
)
self.assertTrue(can_cache)
self.assertEqual(set(response["data"]), {"ds0", "ds1"})
self.assertIn('"count": 1', response["echart_options"])
def test_empty_dataset_has_stable_empty_result(self):
full_sql, response = exec_dataset(dataset(sql_query=""))
self.assertEqual(full_sql, "")
self.assertEqual(response, {"data": {}, "data2": {}})
class DatasetViewExecutionTests(SimpleTestCase):
@patch("apps.bi.views.cache")
@patch("apps.bi.views.execute_rendered_dataset")
@patch("apps.bi.views.render_dataset_sql")
@patch("apps.bi.views.DatasetSerializer")
def test_api_reuses_shared_execution_service(
self,
serializer_mock,
render_mock,
execute_mock,
cache_mock,
):
item = dataset(
enabled=True,
name="日产量",
cache_seconds=10,
)
serializer_mock.return_value.data = {
"code": item.code,
"echart_options": "",
}
render_mock.return_value = "select 1"
execute_mock.return_value = (
{"data": {"ds0": [{"count": 1}]}, "data2": {}},
True,
)
cache_mock.get.return_value = None
view = DatasetViewSet(basename="dataset")
view.get_object = lambda: item
request = SimpleNamespace(
data={"query": {"day": "2026-08-10"}},
user=SimpleNamespace(id=42, belong_dept=SimpleNamespace(id=7)),
)
response = view.exec(request)
render_query = render_mock.call_args.args[1]
self.assertEqual(render_query["r_user"], 42)
self.assertEqual(render_query["r_dept"], 7)
self.assertEqual(response.data["data"]["ds0"][0]["count"], 1)

View File

@ -1,3 +1,25 @@
from django.test import TestCase
from django.test import SimpleTestCase
# Create your tests here.
from apps.bi.serializers import (
DataExecSerializer,
DatasetListResponseSerializer,
DatasetSerializer,
)
from apps.bi.views import DatasetViewSet
class DatasetAgentDiscoveryTests(SimpleTestCase):
def test_dataset_catalog_searches_description(self):
self.assertEqual(
DatasetViewSet.search_fields,
["name", "code", "description"],
)
def test_dataset_schema_explains_discovery_and_exec_parameters(self):
dataset = DatasetSerializer()
execute = DataExecSerializer()
self.assertIn("适合回答的问题", dataset.fields["description"].help_text)
self.assertIn("业务参数", execute.fields["query"].help_text)
self.assertTrue(execute.fields["raise_exception"].default)
self.assertIn("results", DatasetListResponseSerializer().fields)

View File

@ -3,17 +3,21 @@ from apps.utils.viewsets import CustomModelViewSet, CustomGenericViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from apps.bi.models import Dataset, DatasetRecord
from apps.bi.serializers import DatasetSerializer, DatasetCreateUpdateSerializer, DataExecSerializer, DatasetRecordSerializer
from apps.bi.serializers import (
DataExecSerializer,
DatasetCreateUpdateSerializer,
DatasetListResponseSerializer,
DatasetRecordSerializer,
DatasetSerializer,
)
from django.apps import apps
import concurrent.futures
from django.core.cache import cache
from apps.utils.sql import execute_raw_sql, format_sqldata
from apps.bi.services import check_sql_safe, format_json_with_placeholders
from apps.bi.services import execute_rendered_dataset, render_dataset_sql
from rest_framework.exceptions import ParseError
from rest_framework.generics import get_object_or_404
from apps.utils.mixins import ListModelMixin
import logging
myLogger = logging.getLogger('log')
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
# Create your views here.
@ -22,9 +26,54 @@ class DatasetViewSet(CustomModelViewSet):
serializer_class = DatasetSerializer
create_serializer_class = DatasetCreateUpdateSerializer
update_serializer_class = DatasetCreateUpdateSerializer
search_fields = ['name', 'code']
search_fields = ['name', 'code', 'description']
ordering = ['name', 'code', 'id']
@swagger_auto_schema(
operation_id="bi_dataset_list",
operation_summary="查询复杂统计报表的数据集目录",
operation_description=(
"产量、良率、缺陷、库存、绩效、趋势和按日/月汇总等统计聚合查询的统一入口。"
"先调用本接口,根据 name、description、default_param 和 test_param 选择数据集,"
"再调用 bi_dataset_exec。建议使用 query={id,name,code,description,default_param,"
"test_param,enabled} 裁剪字段,并设置 page_size=100 查看完整目录;"
"search 可按名称、code 或 description 检索。"
),
manual_parameters=[
openapi.Parameter(
"search",
openapi.IN_QUERY,
description="按数据集名称、code 或适用场景关键词检索",
type=openapi.TYPE_STRING,
),
openapi.Parameter(
"page",
openapi.IN_QUERY,
description="页码,从 1 开始",
type=openapi.TYPE_INTEGER,
),
openapi.Parameter(
"page_size",
openapi.IN_QUERY,
description="每页数量;当前目录建议传 100",
type=openapi.TYPE_INTEGER,
),
openapi.Parameter(
"query",
openapi.IN_QUERY,
description=(
"django-restql 字段裁剪表达式,例如 "
"{id,name,code,description,default_param,test_param,enabled}"
),
type=openapi.TYPE_STRING,
),
],
responses={200: DatasetListResponseSerializer},
tags=["BI 数据集与报表"],
)
def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs)
def get_object(self):
"""
Returns the object the view is displaying.
@ -57,6 +106,18 @@ class DatasetViewSet(CustomModelViewSet):
return obj
@swagger_auto_schema(
operation_id="bi_dataset_exec",
operation_summary="执行已配置的只读统计数据集",
operation_description=(
"使用 dataset list 返回的 id 或 code 执行数据集。body.query 只填写该数据集"
"description/default_param 声明的业务参数;正常查询设置 is_test=false。"
"统计聚合使用本接口,日志和业务明细列表用于逐条追溯。"
),
request_body=DataExecSerializer,
responses={200: DatasetSerializer},
tags=["BI 数据集与报表"],
)
@action(methods=['post'], detail=True, perms_map={'post': 'dataset.exec'}, serializer_class=DataExecSerializer, cache_seconds=0, logging_methods=[])
def exec(self, request, pk=None):
"""执行sql查询
@ -67,59 +128,24 @@ class DatasetViewSet(CustomModelViewSet):
if not dt.enabled:
raise ParseError(f'{dt.name}-该查询未启用')
rdata = DatasetSerializer(instance=dt).data
xquery = request.data.get('query', {})
xquery = dict(request.data.get('query') or {})
is_test = request.data.get('is_test', False)
raise_exception = request.data.get('raise_exception', True)
xquery['r_user'] = request.user.id
xquery['r_dept'] = request.user.belong_dept.id if request.user.belong_dept else ''
can_cache = True
results = {}
results2 = {}
query = dt.default_param
if dt.sql_query:
if is_test:
query.update(dt.test_param)
else:
query.update(xquery)
try:
sql_f_ = check_sql_safe(dt.sql_query.format(**query))
except KeyError as e:
raise ParseError(f'需指定查询参数_{str(e)}')
sql_f_strip = sql_f_.strip(';')
sql_f_l = sql_f_strip.split(';')
full_sql = render_dataset_sql(dt, xquery, is_test=is_test)
hash_k = None
if full_sql:
sql_f_strip = full_sql.strip(';')
hash_k = hash(sql_f_strip)
hash_v = cache.get(hash_k, None)
if hash_v:
return Response(hash_v)
# 多线程运行并返回字典结果
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
fun_ps = []
for ind, val in enumerate(sql_f_l):
fun_ps.append((f'ds{ind}', execute_raw_sql, val))
# 生成执行函数
futures = {executor.submit(i[1], i[2]): i for i in fun_ps}
for future in concurrent.futures.as_completed(futures):
name, *_, sql_f = futures[future] # 获取对应的键
try:
res = future.result()
results[name], results2[name] = format_sqldata(
res[0], res[1])
except Exception as e:
myLogger.error(f'bi查询异常{str(e)}-{dt.code}--{sql_f}')
if raise_exception:
raise ParseError(f'查询异常:{str(e)}')
else:
results[name] = 'error: ' + str(e)
can_cache = False
rdata['data'] = results
rdata['data2'] = results2
if rdata['echart_options'] and not rdata['echart_options'].startswith('function'):
for key in results:
if isinstance(results[key], str):
raise ParseError(results[key])
rdata['echart_options'] = format_json_with_placeholders(
rdata['echart_options'], **results)
if results and can_cache:
response_data, can_cache = execute_rendered_dataset(
dt, full_sql, raise_exception=raise_exception
)
rdata.update(response_data)
if response_data['data'] and can_cache and hash_k is not None:
cache.set(hash_k, rdata, dt.cache_seconds)
return Response(rdata)

View File

@ -1,3 +1,32 @@
from django.test import TestCase
from django.test import SimpleTestCase
from rest_framework.permissions import IsAdminUser
from rest_framework.test import APIRequestFactory
# Create your tests here.
from apps.develop.views import ServerTime, TestViewSet
class DevelopApiPermissionTests(SimpleTestCase):
def setUp(self):
self.factory = APIRequestFactory()
def test_test_endpoint_rejects_anonymous_requests(self):
request = self.factory.post(
'/api/develop/test/send_sms/',
{},
format='json',
)
response = TestViewSet.as_view({'post': 'send_sms'})(request)
self.assertIn(response.status_code, (401, 403))
def test_server_time_requires_admin(self):
request = self.factory.get('/api/develop/server_time/')
response = ServerTime.as_view()(request)
self.assertIn(response.status_code, (401, 403))
def test_develop_views_use_admin_permission(self):
self.assertEqual(TestViewSet.permission_classes, [IsAdminUser])
self.assertEqual(ServerTime.permission_classes, [IsAdminUser])

View File

@ -1,4 +1,5 @@
from django.urls import path, include
from django.conf import settings
from django.urls import include, path
from apps.develop.views import (BackupDatabase, BackupMedia, ReloadClientGit,
ReloadServerGit, ReloadServerOnly, TestViewSet, CorrectViewSet, testScanHtml, ServerTime)
from rest_framework.routers import DefaultRouter
@ -6,9 +7,11 @@ from rest_framework.routers import DefaultRouter
API_BASE_URL = 'api/develop/'
HTML_BASE_URL = 'dhtml/develop/'
router = DefaultRouter()
router.register('test', TestViewSet, basename='api_test')
router.register('correct', CorrectViewSet, basename='correct')
if settings.DEBUG:
router.register('test', TestViewSet, basename='api_test')
urlpatterns = [
path(API_BASE_URL + 'reload_server_git/', ReloadServerGit.as_view()),
# path(API_BASE_URL + 'reload_web_git/', ReloadClientGit.as_view()),
@ -17,5 +20,7 @@ urlpatterns = [
path(API_BASE_URL + 'backup_media/', BackupMedia.as_view()),
path(API_BASE_URL + 'server_time/', ServerTime.as_view()),
path(API_BASE_URL, include(router.urls)),
path(HTML_BASE_URL + "testscan/", testScanHtml)
]
if settings.DEBUG:
urlpatterns.append(path(HTML_BASE_URL + "testscan/", testScanHtml))

View File

@ -40,11 +40,12 @@ from datetime import datetime
# Create your views here.
class ServerTime(APIView):
permission_classes = [IsAdminUser]
def get_permissions(self):
if self.request.method == 'GET':
if self.request.method == "GET":
return [AllowAny()]
return [IsAdminUser()]
return super().get_permissions()
@swagger_auto_schema(responses={200: ServerTimeSerializer})
def get(self, request):
@ -62,9 +63,13 @@ class ServerTime(APIView):
修改服务器时间
"""
command = f'date -s "{request.data["server_time"]}"'
serializer = ServerTimeSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
server_time = serializer.validated_data['server_time'].strftime(
"%Y-%m-%d %H:%M:%S"
)
completed = subprocess.run(
["sudo", "-S", "sh", "-c", command], # 添加 -S 参数
["sudo", "-S", "date", "-s", server_time],
input=SD_PWD + "\n", # 注意要在密码后加换行符
capture_output=True,
text=True
@ -269,10 +274,9 @@ class CorrectViewSet(CustomGenericViewSet):
class TestViewSet(CustomGenericViewSet):
perms_map = {}
authentication_classes = ()
permission_classes = ()
permission_classes = [IsAdminUser]
@action(methods=['post'], detail=False, serializer_class=SendSmsSerializer, authentication_classes=())
@action(methods=['post'], detail=False, serializer_class=SendSmsSerializer)
def send_sms(self, request, pk=None):
"""发送短信测试
@ -565,7 +569,7 @@ class TestViewSet(CustomGenericViewSet):
# correct_card_time()
# return Response()
@action(methods=['post'], detail=False, serializer_class=Serializer, permission_classes=[])
@action(methods=['post'], detail=False, serializer_class=Serializer)
@transaction.atomic
def correct_data(self, request, pk=None):
"""修正数据
@ -674,7 +678,7 @@ class TestViewSet(CustomGenericViewSet):
Ticket.objects.get_queryset(all=True).delete()
return Response()
@action(methods=['post'], detail=False, serializer_class=Serializer, permission_classes=[])
@action(methods=['post'], detail=False, serializer_class=Serializer)
def test_cal(self, request, pk=None):
from apps.wpm.tasks import cal_exp_duration_sec
cal_exp_duration_sec('3397169058570170368')

View File

@ -71,6 +71,8 @@ class ExamViewSet(CustomModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
if getattr(self, 'swagger_fake_view', False):
return qs
if has_perm(self.request.user, ["exam.view"]):
return qs
user:User = self.request.user
@ -142,6 +144,8 @@ class ExamRecordViewSet(ListModelMixin, DestroyModelMixin, RetrieveModelMixin, C
def get_queryset(self):
qs = super().get_queryset()
if getattr(self, 'swagger_fake_view', False):
return qs
if has_perm(self.request.user, ["examrecord.view"]):
return qs
return qs.filter(create_by=self.request.user)
@ -207,6 +211,8 @@ class TrainRecordViewSet(CustomModelViewSet):
def get_queryset(self):
qs = super().get_queryset()
if getattr(self, 'swagger_fake_view', False):
return qs
if has_perm(self.request.user, ["train.view"]):
return qs
return qs.filter(create_by=self.request.user)

View File

@ -6,6 +6,8 @@ from apps.utils.filters import MyJsonListFilter
class EquipFilterSet(filters.FilterSet):
tags = MyJsonListFilter(label='tags/json/list查询')
exclude_cate_name = filters.CharFilter(
field_name='cate__name', exclude=True, label='排除设备分类名称')
class Meta:
model = Equipment

View File

@ -1,7 +1,8 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from apps.enm.views import (MpointViewSet, MpointStatViewSet,
EnStatViewSet, EnStat2ViewSet, XscriptViewSet, MpLogxAPIView)
EnStatViewSet, EnStat2ViewSet, XscriptViewSet, MpLogxAPIView,
WengfuMpLogxAPIView, WengfuMpointListAPIView)
API_BASE_URL = 'api/enm/'
HTML_BASE_URL = 'dhtml/enm/'
@ -16,4 +17,6 @@ router.register('xscript', XscriptViewSet, basename='xscript')
urlpatterns = [
path(API_BASE_URL, include(router.urls)),
path(f'{API_BASE_URL}mplogx/', MpLogxAPIView.as_view(), name='mplogx_list'),
path(f'{API_BASE_URL}wengfu_mplogx/', WengfuMpLogxAPIView.as_view(), name='wengfu_mplogx'),
path(f'{API_BASE_URL}wengfu_mpoints/', WengfuMpointListAPIView.as_view(), name='wengfu_mpoints'),
]

View File

@ -265,6 +265,134 @@ class MpLogxAPIView(APIView):
class WengfuMpLogxAPIView(APIView):
perms_map = {"get": "*", "post": "*"}
def _get_conn(self):
from apps.utils.sql import DbConnection
return DbConnection(
host='10.0.11.52', user='postgres',
password='zcDsj@2024', database='hfnf', dbtype='pg'
)
def get(self, request, *args, **kwargs):
mpoint_ids = request.query_params.get("mpoint_ids", None)
timex_gte = request.query_params.get("timex__gte", None)
timex_lte = request.query_params.get("timex__lte", None)
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", 20))
conditions = []
params = []
if mpoint_ids:
ids = [x.strip() for x in mpoint_ids.split(",") if x.strip()]
placeholders = ",".join(["%s"] * len(ids))
conditions.append(f"m.mpoint_id IN ({placeholders})")
params.extend(ids)
if timex_gte:
conditions.append("m.timex >= %s")
params.append(timex_gte)
if timex_lte:
conditions.append("m.timex <= %s")
params.append(timex_lte)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with self._get_conn() as cur:
count_sql = f"""SELECT COUNT(*) FROM mplogx_wengfu m {where}"""
cur.execute(count_sql, params)
total = cur.fetchone()[0]
data_sql = f"""
SELECT m.mpoint_id, n.point_name AS mpoint_name, m.timex,
m.val_float AS val, m.val_bool
FROM mplogx_wengfu m
LEFT JOIN cj_mpoint_name n ON n.mpoint_id = m.mpoint_id AND n.company = '瓮福'
{where}
ORDER BY m.timex DESC
LIMIT %s OFFSET %s
"""
cur.execute(data_sql, params + [page_size, (page - 1) * page_size])
columns = [desc[0] for desc in cur.description]
results = []
for row in cur.fetchall():
d = dict(zip(columns, row))
if d.get("timex"):
d["timex"] = d["timex"].strftime("%Y-%m-%d %H:%M:%S")
results.append(d)
return Response({"count": total, "results": results})
def post(self, request, *args, **kwargs):
"""导出或图表数据(不分页)"""
mpoint_ids = request.data.get("mpoint_ids", [])
timex_gte = request.data.get("timex__gte")
timex_lte = request.data.get("timex__lte")
if not mpoint_ids or not timex_gte or not timex_lte:
raise ParseError("mpoint_ids, timex__gte, timex__lte are required")
conditions = []
params = []
placeholders = ",".join(["%s"] * len(mpoint_ids))
conditions.append(f"m.mpoint_id IN ({placeholders})")
params.extend(mpoint_ids)
conditions.append("m.timex >= %s")
params.append(timex_gte)
conditions.append("m.timex <= %s")
params.append(timex_lte)
where = "WHERE " + " AND ".join(conditions)
with self._get_conn() as cur:
sql = f"""
SELECT m.mpoint_id, n.point_name AS mpoint_name, m.timex, m.val_float AS val, m.val_bool
FROM mplogx_wengfu m
LEFT JOIN cj_mpoint_name n ON n.mpoint_id = m.mpoint_id AND n.company = '瓮福'
{where}
ORDER BY m.timex ASC
"""
cur.execute(sql, params)
columns = [desc[0] for desc in cur.description]
results = []
for row in cur.fetchall():
d = dict(zip(columns, row))
if d.get("timex"):
d["timex"] = d["timex"].strftime("%Y-%m-%d %H:%M:%S")
results.append(d)
return Response(results)
class WengfuMpointListAPIView(APIView):
"""瓮福测点列表(供下拉选择)"""
perms_map = {"get": "*"}
def get(self, request, *args, **kwargs):
keyword = request.query_params.get("keyword", "")
page = int(request.query_params.get("page", 1))
page_size = int(request.query_params.get("page_size", 50))
from apps.utils.sql import DbConnection
with DbConnection(host='10.0.11.52', user='postgres',
password='zcDsj@2024', database='hfnf', dbtype='pg') as cur:
conditions = ["company = '瓮福'"]
params = []
if keyword:
conditions.append("(mpoint_id ILIKE %s OR point_name ILIKE %s)")
params.extend([f"%{keyword}%", f"%{keyword}%"])
where = "WHERE " + " AND ".join(conditions)
cur.execute(f"SELECT COUNT(*) FROM cj_mpoint_name {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT mpoint_id, point_name FROM cj_mpoint_name
{where} ORDER BY mpoint_id LIMIT %s OFFSET %s
""", params + [page_size, (page - 1) * page_size])
columns = [desc[0] for desc in cur.description]
results = [dict(zip(columns, row)) for row in cur.fetchall()]
return Response({"count": total, "results": results})
class MpLogxViewSet(CustomListModelMixin, CustomGenericViewSet):
"""
list: 测点采集数据

View File

@ -391,6 +391,23 @@ class EmployeeViewSet(CustomModelViewSet):
# 获取模型字段
model_fields = {f.name: f for f in Employee._meta.fields}
from django.db.models import CharField, TextField
_ABSENT = object() # 区分“列没出现”与“列为空”
_KEEP = object() # 该字段不可清空时,保留原值
def empty_value(field_name):
"""全量覆盖时,空单元格应写入的“清空值”;不可清空的字段返回 _KEEP。"""
if field_name == 'belong_dept_id':
return None # 部门 FK 可空
mf = model_fields.get(field_name)
if mf is None:
return _KEEP
if getattr(mf, 'null', False):
return None
if isinstance(mf, (CharField, TextField)) and getattr(mf, 'blank', False):
return ''
return _KEEP # 非空且不可为空(如布尔/性别/类别)-> 保留原值
for row_num, row in enumerate(rows[1:], start=2):
try:
data = {}
@ -419,12 +436,15 @@ class EmployeeViewSet(CustomModelViewSet):
data['type'] = TYPE_MAPPING[excel_type]
else:
raise ParseError(f'{row_num}行,人员类型"{excel_type}"无效,有效类型:{", ".join(TYPE_MAPPING.keys())}')
# 处理部门外键:填了就校验是否存在并赋值;为空时不动(新增场景的必填在下方创建处校验)
dept_name = data.pop('belong_dept', None)
if dept_name:
if dept_name not in dept_map:
raise ParseError(f'{row_num}行,部门"{dept_name}"不存在')
data['belong_dept_id'] = dept_map[dept_name]
# 处理部门外键:填了校验并赋值;列存在但为空 -> 清空(新增场景的必填在创建处校验)
dept_raw = data.pop('belong_dept', _ABSENT)
if dept_raw is not _ABSENT:
if dept_raw:
if dept_raw not in dept_map:
raise ParseError(f'{row_num}行,部门"{dept_raw}"不存在')
data['belong_dept_id'] = dept_map[dept_raw]
else:
data['belong_dept_id'] = None
# 数据验证
if data.get('phone'):
@ -454,10 +474,13 @@ class EmployeeViewSet(CustomModelViewSet):
(e for e in name_matches if e.id_number == id_number), None)
if existing:
# 用 Excel 中填写了值的列覆盖数据库已有数据;空单元格保持原值不变
# 全量覆盖:以本次 Excel 为准。填了值的列覆盖原值;
# 空单元格清空原值(不可为空的字段如布尔/性别/类别则保留原值)。
updated_fields = []
for field_name, value in data.items():
if value in [None, '']:
value = empty_value(field_name)
if value is _KEEP:
continue
if getattr(existing, field_name, None) != value:
setattr(existing, field_name, value)
@ -470,9 +493,11 @@ class EmployeeViewSet(CustomModelViewSet):
created = False
else:
# 新增人员时所属部门必填
if 'belong_dept_id' not in data:
if not data.get('belong_dept_id'):
raise ParseError(f'{row_num}行,新增人员时所属部门不能为空')
Employee.objects.create(**data)
# 新增时丢弃空值,交给模型默认值/允许为空
create_data = {k: v for k, v in data.items() if v is not None}
Employee.objects.create(**create_data)
created = True
except Exception as e:
raise
@ -501,18 +526,23 @@ def convert_field_value(model_field, value, row_num):
# ===== 日期字段 =====
if isinstance(model_field, CharField):
sval = str(value)
if isinstance(value, str):
if 'e' in value.lower():
try:
return str(int(float(value)))
sval = str(int(float(value)))
except ValueError:
pass
if '.' in value:
try:
return str(int(float(value)))
sval = str(int(float(value)))
except ValueError:
pass
return str(value)
# 超长字段给出明确提示,避免数据库报 value too long
if model_field.max_length and len(sval) > model_field.max_length:
raise ParseError(
f'{row_num}行,字段 {model_field.verbose_name} 长度超过 {model_field.max_length} 个字符(当前 {len(sval)}')
return sval
if isinstance(model_field, DateField):

View File

@ -1,10 +1,16 @@
from django_filters import rest_framework as filters
from apps.inm.models import MaterialBatch, MIO
from django.db.models import Q, Subquery, OuterRef, F
from apps.qm.defect_grades import effective_defect_grade_q
class MaterialBatchFilter(filters.FilterSet):
count_canmio__gt = filters.NumberFilter(
method='filter_count_canmio__gt', label='可发数量大于')
defect_grade = filters.NumberFilter(
method='filter_defect_grade', label='有效缺陷等级')
def filter_defect_grade(self, queryset, name, value):
return queryset.filter(effective_defect_grade_q(value))
class Meta:
model = MaterialBatch

View File

@ -1,4 +1,6 @@
from django.db import models
import json
from django.db import connection, models
from apps.utils.models import BaseModel, CommonBModel, CommonBDModel, CommonADModel
from apps.pum.models import Supplier, PuOrder
from apps.sam.models import Customer, Order
@ -38,6 +40,113 @@ class MaterialBatch(BaseModel):
material_ofrom = models.ForeignKey(Material, verbose_name='原料物料', on_delete=models.SET_NULL, null=True, blank=True, related_name='mb_mofrom')
defect = models.ForeignKey('qm.defect', verbose_name='缺陷', on_delete=models.PROTECT, null=True, blank=True)
INVENTORY_KEY_FIELDS = (
'material',
'batch',
'warehouse',
'state',
'defect',
)
@classmethod
def _normalize_inventory_lookup(cls, **kwargs):
"""生成唯一、完整的仓库库存业务键。"""
unknown_fields = set(kwargs) - set(cls.INVENTORY_KEY_FIELDS)
if unknown_fields:
fields = ', '.join(sorted(unknown_fields))
raise TypeError(f'不支持的仓库库存定位字段: {fields}')
required_fields = ('material', 'batch', 'warehouse')
missing_fields = [
field for field in required_fields
if kwargs.get(field) is None
]
if missing_fields:
fields = ', '.join(missing_fields)
raise ValueError(f'仓库库存业务键必须包含: {fields}')
lookup = {
field: kwargs.get(field)
for field in cls.INVENTORY_KEY_FIELDS
}
if lookup['state'] is None:
lookup['state'] = cls._meta.get_field('state').get_default()
return lookup
@classmethod
def _inventory_advisory_lock_payload(cls, lookup):
lock_values = {}
for name in cls.INVENTORY_KEY_FIELDS:
field = cls._meta.get_field(name)
value = lookup[name]
if field.is_relation and value is not None:
value = getattr(value, 'pk', value)
lock_values[field.attname] = value
return json.dumps(
{'model': cls._meta.label_lower, 'lookup': lock_values},
sort_keys=True,
ensure_ascii=False,
default=str,
separators=(',', ':'),
)
@classmethod
def locked_get_or_create_inventory(cls, defaults=None, **kwargs):
"""
在事务中按完整业务键获取或创建仓库库存
已存在记录使用行锁首次创建使用 PostgreSQL 事务级 advisory lock
并在取得锁后重新查询避免并发创建重复库存
"""
if not connection.in_atomic_block:
raise RuntimeError(
'locked_get_or_create_inventory 必须在事务中调用'
)
if connection.vendor != 'postgresql':
raise RuntimeError(
'locked_get_or_create_inventory 仅支持 PostgreSQL'
)
defaults = defaults or {}
lookup = cls._normalize_inventory_lookup(**kwargs)
create_defaults = {
key: value
for key, value in defaults.items()
if key not in cls.INVENTORY_KEY_FIELDS
}
rows = list(
cls.objects.select_for_update().filter(**lookup)[:2]
)
if len(rows) > 1:
raise RuntimeError(
f'{cls.__name__} 数据异常:库存业务键 {lookup} 命中多条'
)
if rows:
return rows[0], False
lock_payload = cls._inventory_advisory_lock_payload(lookup)
with connection.cursor() as cursor:
cursor.execute(
'SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))',
[lock_payload],
)
rows = list(
cls.objects.select_for_update().filter(**lookup)[:2]
)
if len(rows) > 1:
raise RuntimeError(
f'{cls.__name__} 数据异常:库存业务键 {lookup} 命中多条'
)
if rows:
return rows[0], False
return cls.objects.create(
**lookup,
**create_defaults,
), True
@property
def count_mioing(self):

View File

@ -14,6 +14,7 @@ from django.db.models import F, Sum, DecimalField
from server.settings import get_sysconfig
from apps.wpmw.models import Wpr
from decimal import Decimal
from apps.qm.defect_grades import DEFECT_GRADE_NAMES, effective_defect_grade
class WareHourseSerializer(CustomModelSerializer):
@ -49,6 +50,8 @@ class MaterialBatchSerializer(CustomModelSerializer):
source='supplier', read_only=True)
material_ = MaterialSerializer(source='material', read_only=True)
defect_name = serializers.CharField(source="defect.name", read_only=True)
defect_grade = serializers.SerializerMethodField()
defect_grade_name = serializers.SerializerMethodField()
count_mioing = serializers.SerializerMethodField(label='正在出入库数量')
class Meta:
@ -61,6 +64,12 @@ class MaterialBatchSerializer(CustomModelSerializer):
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可发量偏大)
return instance.count_mioing_anno if hasattr(instance, 'count_mioing_anno') else instance.count_mioing
def get_defect_grade(self, instance):
return effective_defect_grade(instance)
def get_defect_grade_name(self, instance):
return DEFECT_GRADE_NAMES[self.get_defect_grade(instance)]
def to_representation(self, instance):
ret = super().to_representation(instance)
if 'count' in ret:
@ -86,6 +95,15 @@ class MaterialBatchDetailSerializer(CustomModelSerializer):
source='a_mb', read_only=True, many=True)
supplier_name = serializers.StringRelatedField(
source='supplier', read_only=True)
defect_name = serializers.CharField(source="defect.name", read_only=True)
defect_grade = serializers.SerializerMethodField()
defect_grade_name = serializers.SerializerMethodField()
def get_defect_grade(self, instance):
return effective_defect_grade(instance)
def get_defect_grade_name(self, instance):
return DEFECT_GRADE_NAMES[self.get_defect_grade(instance)]
class Meta:
model = MaterialBatch
@ -188,9 +206,10 @@ class MIOItemCreateSerializer(CustomModelSerializer):
if mio.state != MIO.MIO_CREATE:
raise ParseError('出入库记录非创建中不可新增')
# 生产领料要校验是否进行检验
# 某些客户此处无需校验
# 某些客户此处无需校验(全局总开关); 免检物料单独放行
check_test_when_do_out = get_sysconfig('mes.check_test_when_do_out', True)
if check_test_when_do_out and mio.type == MIO.MIO_TYPE_DO_OUT:
if (check_test_when_do_out and mio.type == MIO.MIO_TYPE_DO_OUT
and material.test_mode_in != Material.MA_TEST_EXEMPT):
mis = MIOItem.objects.filter(batch=batch, material=material, mio__type__in=[MIO.MIO_TYPE_PUR_IN, MIO.MIO_TYPE_DO_IN, MIO.MIO_TYPE_OTHER_IN])
if mis.exists() and (not mis.exclude(test_date=None).exists()):
raise ParseError('该批次的物料未经检验')

View File

@ -7,8 +7,10 @@ from apps.wpm.models import WMaterial, BatchSt, BatchLog
from apps.wpm.services_2 import ana_batch_thread
from apps.wpmw.models import Wpr
from apps.qm.models import Ftest, Defect
from django.db import transaction
from django.db.models import Count, Q
@transaction.atomic
def do_out(item: MIOItem, is_reverse: bool = False):
"""
生产领料到车间
@ -45,7 +47,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
if is_zhj:
try:
mb = MaterialBatch.objects.get(
mb = MaterialBatch.objects.select_for_update().get(
material=item.material,
warehouse=item.warehouse,
batch=item.batch,
@ -82,7 +84,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
mb = None
if not is_zhj:
try:
mb = MaterialBatch.objects.get(
mb = MaterialBatch.objects.select_for_update().get(
material=xmaterial,
warehouse=item.warehouse,
batch=xbatch,
@ -99,7 +101,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
if xmaterial.into_wm:
# 领到车间库存(或工段)
wm, new_create = WMaterial.objects.get_or_create(
wm, new_create = WMaterial.locked_get_or_create_inventory(
batch=xbatch, material=xmaterial,
belong_dept=belong_dept, mgroup=mgroup,
state=state, defect=defect)
@ -107,7 +109,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
wm.create_by = do_user
wm.batch_ofrom = mb.batch if mb else None
wm.material_ofrom = mb.material if mb else None
wm.count = wm.count + item.count
wm.count = wm.count + xcount
wm.update_by = do_user
wm.save()
@ -130,6 +132,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
ana_batch_thread(xbatches)
@transaction.atomic
def do_in(item: MIOItem):
"""
生产入库后更新车间物料
@ -184,9 +187,9 @@ def do_in(item: MIOItem):
xbatchs.append(xbatch)
if xmaterial.into_wm:
if xwm:
wm = xwm
wm = WMaterial.objects.select_for_update().get(pk=xwm.pk)
else:
wm_qs = WMaterial.objects.filter(
wm_qs = WMaterial.objects.select_for_update().filter(
batch=xbatch,
material=xmaterial,
belong_dept=belong_dept,
@ -220,7 +223,7 @@ def do_in(item: MIOItem):
# 增加mb
if not is_zhj:
mb, _ = MaterialBatch.objects.get_or_create(
mb, _ = MaterialBatch.locked_get_or_create_inventory(
material=xmaterial,
warehouse=item.warehouse,
batch=xbatch,
@ -255,7 +258,7 @@ def do_in(item: MIOItem):
if is_zhj: # 组合件单独处理并且不做追踪单个处理
mb, is_created = MaterialBatch.objects.get_or_create(
mb, is_created = MaterialBatch.locked_get_or_create_inventory(
material=item.material,
warehouse=item.warehouse,
batch=item.batch,
@ -412,7 +415,7 @@ class InmService:
state = WMaterial.WM_OK
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
state = WMaterial.WM_NOTOK
mb, _ = MaterialBatch.objects.get_or_create(
mb, _ = MaterialBatch.locked_get_or_create_inventory(
material=material,
warehouse=warehouse,
batch=batch,
@ -433,11 +436,18 @@ class InmService:
raise ParseError("出入库与明细数量不一致,操作失败")
for mioitemw in mioitemws:
if mioitemw.wpr:
Wpr.change_or_new(wpr=mioitemw.wpr, mb=mb, ftest=mioitemw.ftest)
wpr = Wpr.change_or_new(
wpr=mioitemw.wpr,
mb=mb,
)
else:
wpr = Wpr.change_or_new(number=mioitemw.number, mb=mb, ftest=mioitemw.ftest)
wpr = Wpr.change_or_new(
number=mioitemw.number,
mb=mb,
)
mioitemw.wpr = wpr
mioitemw.save()
wpr.apply_ftest(mioitemw.ftest)
elif in_or_out == -1:
mb.count = mb.count - change_count
@ -488,4 +498,3 @@ class InmService:
# 若该出入库记录已无明细,自动删除
if not MIOItem.objects.filter(mio=mio).exists():
mio.delete()

View File

@ -55,7 +55,7 @@ def daoru_mb(path: str):
process=process,
defaults={"type": type, "name": name, "specification": specification, "model": model, "process": process, "number": ranstr(6), "id": idWorker.get_id()},
)
MaterialBatch.objects.get_or_create(
MaterialBatch.locked_get_or_create_inventory(
material=material, batch=batch, warehouse=warehouse, defaults={"material": material, "batch": batch, "warehouse": warehouse, "count": count, "id": idWorker.get_id()}
)
i = i + 1

View File

@ -1,3 +1,183 @@
from django.test import TestCase
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from threading import Barrier
from unittest import skipUnless
# Create your tests here.
from django.db import connection, connections, transaction
from django.test import SimpleTestCase, TestCase, TransactionTestCase
from apps.inm.filters import MaterialBatchFilter
from apps.inm.models import MaterialBatch, WareHouse
from apps.inm.serializers import MaterialBatchSerializer
from apps.mtm.models import Material
from apps.qm.models import Defect
class MaterialBatchDefectGradeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.material = Material.objects.create(name='仓库缺陷等级测试物料')
cls.warehouse = WareHouse.objects.create(
number='GRADE',
name='等级测试仓库',
place='测试地点',
)
cls.defect_b = Defect.objects.create(
name='仓库B类缺陷',
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_OK_B,
)
cls.notok_without_defect = MaterialBatch.objects.create(
material=cls.material,
warehouse=cls.warehouse,
batch='MB-NOTOK-NONE',
count=1,
state=20,
)
cls.normal_with_b_defect = MaterialBatch.objects.create(
material=cls.material,
warehouse=cls.warehouse,
batch='MB-NORMAL-B',
count=1,
state=10,
defect=cls.defect_b,
)
def test_serializer_uses_defect_or_defaults_to_ok_independent_of_state(self):
no_defect_data = MaterialBatchSerializer(
self.notok_without_defect
).data
b_defect_data = MaterialBatchSerializer(
self.normal_with_b_defect
).data
self.assertEqual(no_defect_data['defect_grade'], Defect.DEFECT_OK)
self.assertEqual(no_defect_data['defect_grade_name'], '合格')
self.assertEqual(b_defect_data['defect_grade'], Defect.DEFECT_OK_B)
self.assertEqual(b_defect_data['defect_grade_name'], '合格B类')
def test_effective_grade_filter_is_independent_of_state(self):
ok_items = MaterialBatchFilter(
{'defect_grade': Defect.DEFECT_OK},
queryset=MaterialBatch.objects.all(),
).qs
b_items = MaterialBatchFilter(
{'defect_grade': Defect.DEFECT_OK_B},
queryset=MaterialBatch.objects.all(),
).qs
self.assertQuerySetEqual(
ok_items,
[self.notok_without_defect],
transform=lambda item: item,
)
self.assertQuerySetEqual(
b_items,
[self.normal_with_b_defect],
transform=lambda item: item,
)
class MaterialBatchInventoryKeyTests(SimpleTestCase):
def setUp(self):
self.material = Material(id='100', name='测试物料')
self.warehouse = WareHouse(
id='200',
number='TEST',
name='测试仓库',
place='测试地点',
)
def test_inventory_key_normalizes_omitted_optional_fields(self):
omitted = MaterialBatch._normalize_inventory_lookup(
material=self.material,
batch='BATCH-001',
warehouse=self.warehouse,
)
explicit = MaterialBatch._normalize_inventory_lookup(
material=self.material,
batch='BATCH-001',
warehouse=self.warehouse,
state=10,
defect=None,
)
self.assertEqual(omitted, explicit)
self.assertEqual(
MaterialBatch._inventory_advisory_lock_payload(omitted),
MaterialBatch._inventory_advisory_lock_payload(explicit),
)
def test_inventory_key_requires_material_batch_and_warehouse(self):
required_values = {
'material': self.material,
'batch': 'BATCH-001',
'warehouse': self.warehouse,
}
for field in required_values:
with self.subTest(field=field):
lookup = required_values.copy()
lookup[field] = None
with self.assertRaisesRegex(ValueError, field):
MaterialBatch._normalize_inventory_lookup(**lookup)
def test_inventory_key_rejects_unknown_fields(self):
with self.assertRaisesRegex(TypeError, 'supplier'):
MaterialBatch._normalize_inventory_lookup(
material=self.material,
batch='BATCH-001',
warehouse=self.warehouse,
supplier=None,
)
@skipUnless(
connection.vendor == 'postgresql',
'advisory lock concurrency test requires PostgreSQL',
)
class MaterialBatchConcurrencyTests(TransactionTestCase):
def test_concurrent_first_create_uses_one_inventory_record(self):
material = Material.objects.create(name='仓库并发测试物料')
warehouse = WareHouse.objects.create(
number='CONCURRENT',
name='并发测试仓库',
place='测试地点',
)
barrier = Barrier(2)
def create_inventory():
connections.close_all()
try:
barrier.wait()
with transaction.atomic():
mb, created = (
MaterialBatch.locked_get_or_create_inventory(
material=material,
batch='CONCURRENT-001',
warehouse=warehouse,
defaults={'count': Decimal('0')},
)
)
mb.count += Decimal('1')
mb.save(update_fields=['count'])
return created
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as executor:
created_results = list(executor.map(
lambda _: create_inventory(),
range(2),
))
queryset = MaterialBatch.objects.filter(
material=material,
batch='CONCURRENT-001',
warehouse=warehouse,
state=10,
defect=None,
)
self.assertEqual(queryset.count(), 1)
self.assertEqual(queryset.get().count, Decimal('2'))
self.assertCountEqual(created_results, [True, False])

View File

@ -60,7 +60,7 @@ class MaterialBatchViewSet(ListModelMixin, CustomGenericViewSet):
queryset = MaterialBatch.objects.filter(count__gt=0)
serializer_class = MaterialBatchSerializer
retrieve_serializer_class = MaterialBatchDetailSerializer
select_related_fields = ['warehouse', 'material', 'supplier']
select_related_fields = ['warehouse', 'material', 'supplier', 'defect']
filterset_class = MaterialBatchFilter
search_fields = ['material__name', 'material__number',
'material__model', 'material__specification', 'batch']
@ -255,7 +255,8 @@ class MIOViewSet(CustomModelViewSet):
提交
"""
ins:MIO = self.get_object()
current = self.get_object()
ins = MIO.objects.select_for_update().get(pk=current.pk)
if ins.inout_date is None:
raise ParseError('出入库日期未填写')
if ins.state != MIO.MIO_CREATE:
@ -276,7 +277,8 @@ class MIOViewSet(CustomModelViewSet):
撤回
"""
ins = self.get_object()
current = self.get_object()
ins = MIO.objects.select_for_update().get(pk=current.pk)
user = self.request.user
if ins.state != MIO.MIO_SUBMITED:
raise ParseError('记录状态异常')
@ -379,6 +381,7 @@ class MIOItemViewSet(CustomListModelMixin, BulkCreateModelMixin, BulkDestroyMode
"mio__inout_date": ["gte", "lte", "exact"],
"material": ["exact"],
"material__type": ["exact"],
"material__test_mode_in": ["exact"],
"test_date": ["isnull", "exact"]
}
ordering = ['create_time']
@ -585,4 +588,3 @@ class MIOItemwViewSet(CustomModelViewSet):
if ftest:
ftest.delete()
self.cal_mioitem_count(mioitem)

View File

@ -5,7 +5,7 @@ from apps.mtm.models import Material, Shift, Mgroup, Process
@admin.register(Process)
class ProcessAdmin(admin.ModelAdmin):
list_display = ('id', 'name', 'cate', 'sort', 'into_wm_mgroup', 'store_notok', 'batch_append_equip', 'mlog_need_ticket')
list_display = ('id', 'name', 'cate', 'sort', 'into_wm_scope', 'store_notok', 'batch_append_equip', 'mlog_need_ticket')
@admin.register(Material)

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.27 on 2026-07-22 07:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mtm', '0066_alter_process_clear_defect'),
]
operations = [
migrations.AddField(
model_name='material',
name='test_mode_in',
field=models.PositiveSmallIntegerField(choices=[(10, '免检'), (20, '必检')], default=20, help_text='仓库入库后是否需要检验;免检物料生产领料时不校验', verbose_name='入库检验方式'),
),
]

View File

@ -0,0 +1,36 @@
from django.db import migrations, models
def migrate_scope_forward(apps, schema_editor):
Process = apps.get_model('mtm', 'Process')
Process.objects.filter(into_wm_mgroup=True).update(into_wm_scope=10)
def migrate_scope_backward(apps, schema_editor):
Process = apps.get_model('mtm', 'Process')
Process.objects.filter(into_wm_scope=10).update(into_wm_mgroup=True)
Process.objects.exclude(into_wm_scope=10).update(into_wm_mgroup=False)
class Migration(migrations.Migration):
dependencies = [
('mtm', '0067_material_test_mode_in'),
]
operations = [
migrations.AddField(
model_name='process',
name='into_wm_scope',
field=models.PositiveSmallIntegerField(
choices=[(10, '工段'), (20, '部门'), (30, '全局')],
default=20,
verbose_name='产出库存归属',
),
),
migrations.RunPython(migrate_scope_forward, migrate_scope_backward),
migrations.RemoveField(
model_name='process',
name='into_wm_mgroup',
),
]

View File

@ -9,6 +9,28 @@ from django.utils import timezone
from django.db.models import Sum, Q, Value, F, ExpressionWrapper, DecimalField
from django.db.models.functions import Coalesce
class WmScope(models.IntegerChoices):
MGROUP = 10, '工段'
DEPT = 20, '部门'
GLOBAL = 30, '全局'
@classmethod
def resolve_location(cls, scope, mgroup):
"""将库存归属策略统一解析为 WMaterial 的位置字段。"""
scope = cls(scope)
if scope == cls.GLOBAL:
return {'mgroup': None, 'belong_dept': None}
if mgroup is None:
raise ParseError('工段或部门库存必须提供工段')
if scope == cls.MGROUP:
return {'mgroup': mgroup, 'belong_dept': mgroup.belong_dept}
return {'mgroup': None, 'belong_dept': mgroup.belong_dept}
@classmethod
def requires_mgroup(cls, scope):
return cls(scope) == cls.MGROUP
class Process(CommonBModel):
"""
TN:工序
@ -29,7 +51,8 @@ class Process(CommonBModel):
instruction = models.ForeignKey(
File, verbose_name='指导书', on_delete=models.SET_NULL, null=True, blank=True)
instruction_content = models.TextField('指导书内容', null=True, blank=True)
into_wm_mgroup = models.BooleanField('交接到工段', default=False)
into_wm_scope = models.PositiveSmallIntegerField(
'产出库存归属', choices=WmScope.choices, default=WmScope.DEPT)
store_notok = models.BooleanField('不合格品是否入库', default=False)
batch_append_equip = models.BooleanField('批号追加设备', default=False)
mlog_need_ticket = models.BooleanField('日志提交是否需要审批', default=False)
@ -79,6 +102,9 @@ class Material(CommonAModel):
MA_TRACKING_BATCH = 10
MA_TRACKING_SINGLE = 20
MA_TEST_EXEMPT = 10
MA_TEST_REQUIRED = 20
name = models.CharField('名称', max_length=50)
cate = models.CharField('大类', max_length=20, default='', blank=True)
number = models.CharField('编号', max_length=100, null=True, blank=True)
@ -95,6 +121,10 @@ class Material(CommonAModel):
tracking = models.PositiveSmallIntegerField("追踪方式", default=10,
choices=((MA_TRACKING_BATCH, '批次'),
(MA_TRACKING_SINGLE, '单件')))
test_mode_in = models.PositiveSmallIntegerField('入库检验方式', default=20,
choices=((MA_TEST_EXEMPT, '免检'),
(MA_TEST_REQUIRED, '必检')),
help_text='仓库入库后是否需要检验;免检物料生产领料时不校验')
# count = models.DecimalField('总库存', max_digits=14, decimal_places=3, default=0)
# count_mb = models.DecimalField('仓库库存', max_digits=14, decimal_places=3, default=0)
# count_wm = models.DecimalField('车间库存', max_digits=14, decimal_places=3, default=0)

View File

@ -3,11 +3,12 @@ from apps.mtm.models import (Shift, Material, Mgroup, Team, Goal,
Process, Route, TeamMember, RoutePack, Srule, RouteMat)
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS_DEPT
from rest_framework import serializers
from rest_framework.exceptions import ValidationError, ParseError
from rest_framework.exceptions import ValidationError, ParseError, PermissionDenied
from apps.system.models import Dept, UserPost
from django.db import transaction
from apps.wf.serializers import TicketSimpleSerializer
from apps.system.serializers import FileSerializer
from apps.utils.permission import has_perm
class ShiftSerializer(CustomModelSerializer):
@ -24,7 +25,7 @@ class MaterialSimpleSerializer(CustomModelSerializer):
class Meta:
model = Material
fields = ['id', 'name', 'number', 'model',
'specification', 'type', 'cate', 'brothers', 'process_name', 'full_name', "tracking", "bin_number_main"]
'specification', 'type', 'cate', 'brothers', 'process_name', 'full_name', "tracking", "bin_number_main", "test_mode_in"]
def get_full_name(self, obj):
return f'{obj.name}|{obj.specification if obj.specification else ""}|{obj.model if obj.model else ""}|{obj.process.name if obj.process else ""}'
@ -146,6 +147,14 @@ class ProcessSerializer(CustomModelSerializer):
fields = '__all__'
read_only_fields = EXCLUDE_FIELDS
def validate_wpr_number_rule(self, value):
if value:
try:
value.format(c_year=2026, c_year2="26", c_month=1, c_day=1, m_model="MODEL", n_count=1)
except (KeyError, IndexError, ValueError) as e:
raise ValidationError(f"编号规则不合法({e}), 可用占位符: c_year, c_year2, c_month, c_day, m_model, n_count")
return value
class RoutePackSerializer(CustomModelSerializer):
material_name = serializers.StringRelatedField(
@ -169,6 +178,23 @@ class RoutePackCopySerializer(serializers.Serializer):
material_in = serializers.CharField(label='原料ID')
material_out = serializers.CharField(label='产品ID')
class RouteMaterialOutCreateSerializer(serializers.Serializer):
"""工艺路线内快捷新建半成品时允许录入的物料字段。"""
name = serializers.CharField(label='名称', max_length=50)
number = serializers.CharField(label='编号', max_length=100, required=False, allow_blank=True, allow_null=True)
cate = serializers.CharField(label='大类', max_length=20, required=False, allow_blank=True)
model = serializers.CharField(label='型号', max_length=100, required=False, allow_blank=True, allow_null=True)
specification = serializers.CharField(label='规格', max_length=100, required=False, allow_blank=True, allow_null=True)
unit = serializers.CharField(label='基准计量单位', max_length=10, required=False, default='')
test_mode_in = serializers.ChoiceField(
label='入库检验方式',
choices=(Material.MA_TEST_EXEMPT, Material.MA_TEST_REQUIRED),
required=False,
default=Material.MA_TEST_REQUIRED,
)
class RouteSerializer(CustomModelSerializer):
name = serializers.CharField(source='__str__', read_only=True)
material_ = MaterialSerializer(source='material', read_only=True)
@ -177,6 +203,8 @@ class RouteSerializer(CustomModelSerializer):
process_cate = serializers.CharField(source='process.cate', read_only=True)
material_in_name = serializers.StringRelatedField(
source='material_in', read_only=True)
material_in_type = serializers.CharField(
source='material_in.type', read_only=True)
material_out_name = serializers.StringRelatedField(
source='material_out', read_only=True)
material_out_type = serializers.CharField(
@ -184,6 +212,7 @@ class RouteSerializer(CustomModelSerializer):
material_out_is_hidden = serializers.BooleanField(
source='material_out.is_hidden', read_only=True)
material_out_tracking = serializers.IntegerField(write_only=True, required=False, allow_null=True)
material_out_create = RouteMaterialOutCreateSerializer(write_only=True, required=False)
class Meta:
model = Route
@ -191,6 +220,10 @@ class RouteSerializer(CustomModelSerializer):
read_only_fields = EXCLUDE_FIELDS
def validate(self, attrs):
if attrs.get('material_out') and attrs.get('material_out_create'):
raise serializers.ValidationError(
'选择已有输出物料和新建半成品不可同时提交'
)
if attrs.get('routepack', None):
attrs['material'] = attrs['routepack'].material
if 'mgroup' in attrs and attrs['mgroup']:
@ -252,12 +285,30 @@ class RouteSerializer(CustomModelSerializer):
})
return material_out
def create_material_out(self, material_data, process, tracking):
if self.request and not has_perm(self.request.user, ['material.create']):
raise PermissionDenied('无新建物料权限')
material_data.update({
'type': Material.MA_TYPE_HALFGOOD,
'process': process.id,
'tracking': tracking,
'is_hidden': False,
})
serializer = MaterialSerializer(data=material_data, context=self.context)
serializer.is_valid(raise_exception=True)
return serializer.save()
@transaction.atomic
def create(self, validated_data):
process = validated_data['process']
routepack = validated_data.get('routepack', None)
material_out_create = validated_data.pop('material_out_create', None)
material_out_tracking = validated_data.pop("material_out_tracking", Material.MA_TRACKING_BATCH)
if material_out_tracking is None:
material_out_tracking = Material.MA_TRACKING_BATCH
if material_out_create:
validated_data['material_out'] = self.create_material_out(
material_out_create, process, material_out_tracking)
if routepack:
pass
# if Route.objects.filter(routepack=routepack, process=process).exists():
@ -268,6 +319,8 @@ class RouteSerializer(CustomModelSerializer):
# if material and process and Route.objects.filter(material=material, process=process).exists():
# raise ValidationError('已选择该工序!!')
if not validated_data.get('material_out') and not validated_data.get('material'):
raise ParseError('未指定输出物料,且工艺包未设置产品无法自动生成;请指定输出物料,或先在工艺包基本信息中选择产品')
instance:Route = super().create(validated_data)
material_out = instance.material_out
if material_out:
@ -296,13 +349,20 @@ class RouteSerializer(CustomModelSerializer):
# raise ParseError(f"该工艺步骤已存在-{msg}")
return instance
@transaction.atomic
def update(self, instance, validated_data):
validated_data.pop('material', None)
process = validated_data.pop('process', None)
process = validated_data.pop('process', None) or instance.process
material_out_create = validated_data.pop('material_out_create', None)
material_out_tracking = validated_data.pop("material_out_tracking", Material.MA_TRACKING_BATCH)
if material_out_tracking is None:
material_out_tracking = Material.MA_TRACKING_BATCH
if material_out_create:
validated_data['material_out'] = self.create_material_out(
material_out_create, process, material_out_tracking)
if not validated_data.get('material_out', instance.material_out) and not instance.material:
raise ParseError('未指定输出物料,且工艺包未设置产品无法自动生成;请指定输出物料,或先在工艺包基本信息中选择产品')
instance = super().update(instance, validated_data)
material_out = instance.material_out
if material_out:

View File

@ -1,3 +1,69 @@
from django.test import TestCase
# Create your tests here.
from apps.mtm.models import Material, Process, Route, RoutePack
from apps.mtm.serializers import RouteSerializer
class RouteMaterialOutCreateSerializerTests(TestCase):
def setUp(self):
self.process = Process.objects.create(name='切割')
self.product = Material.objects.create(
name='产品A',
number='CP-A',
type=Material.MA_TYPE_GOOD,
process=self.process,
)
self.material_in = Material.objects.create(
name='原料A',
number='YL-A',
type=Material.MA_TYPE_MAINSO,
)
self.routepack = RoutePack.objects.create(name='产品A工艺', material=self.product)
def test_create_route_and_visible_halfgood_in_one_serializer_save(self):
serializer = RouteSerializer(data={
'routepack': self.routepack.id,
'process': self.process.id,
'material_in': self.material_in.id,
'material_out_tracking': Material.MA_TRACKING_SINGLE,
'material_out_create': {
'name': '分支半成品A',
'number': 'BCP-A',
'specification': 'S1',
'model': 'M1',
'unit': '',
},
})
serializer.is_valid(raise_exception=True)
route = serializer.save()
material_out = route.material_out
self.assertEqual(material_out.type, Material.MA_TYPE_HALFGOOD)
self.assertEqual(material_out.process, self.process)
self.assertEqual(material_out.tracking, Material.MA_TRACKING_SINGLE)
self.assertFalse(material_out.is_hidden)
self.assertEqual(material_out.name, '分支半成品A')
self.assertEqual(material_out.unit, '')
self.assertTrue(Route.objects.filter(pk=route.pk, material_out=material_out).exists())
def test_existing_and_new_material_out_are_mutually_exclusive(self):
existing = Material.objects.create(
name='已有半成品',
number='BCP-OLD',
type=Material.MA_TYPE_HALFGOOD,
process=self.process,
)
serializer = RouteSerializer(data={
'routepack': self.routepack.id,
'process': self.process.id,
'material_in': self.material_in.id,
'material_out': existing.id,
'material_out_create': {
'name': '新半成品',
'number': 'BCP-NEW',
},
})
self.assertFalse(serializer.is_valid())
self.assertIn('non_field_errors', serializer.errors)

View File

@ -369,6 +369,22 @@ class RoutePackViewSet(CustomModelViewSet):
qs = Material.objects.filter(id__in=matIds)
res = [{"id": x.id, "name": str(x)} for x in qs]
return Response(res)
@action(methods=['get'], detail=True, perms_map={'get': '*'})
def validate(self, request, *args, **kwargs):
"""校验工艺图是否合法(不存盘)
供拖拽画布实时校验DAG结构,返回 {valid, error},不修改gjson
"""
ins:RoutePack = self.get_object()
try:
ins.validate_and_return_gjson()
return Response({"valid": True, "error": ""})
except ParseError as e:
detail = e.detail
if isinstance(detail, (list, tuple)):
detail = detail[0] if detail else ""
return Response({"valid": False, "error": str(detail)})
class RouteViewSet(CustomModelViewSet):
queryset = Route.objects.all()
serializer_class = RouteSerializer

42
apps/qm/defect_grades.py Normal file
View File

@ -0,0 +1,42 @@
from django.db.models import Q
DEFECT_OK = 10
DEFECT_OK_B = 20
DEFECT_NOTOK = 30
DEFECT_GRADE_CHOICES = (
(DEFECT_OK, "合格"),
(DEFECT_OK_B, "合格B类"),
(DEFECT_NOTOK, "不合格"),
)
DEFECT_GRADE_NAMES = dict(DEFECT_GRADE_CHOICES)
def effective_defect_grade(instance, notok_sign_field=None):
"""Return the inventory grade without coupling it to inventory state."""
defect = getattr(instance, "defect", None)
if defect is not None:
return defect.okcate
if notok_sign_field and getattr(instance, notok_sign_field, None):
return DEFECT_NOTOK
return DEFECT_OK
def effective_defect_grade_q(value, notok_sign_field=None):
"""Build an index-friendly query matching ``effective_defect_grade``."""
explicit_grade = Q(defect__okcate=value)
without_defect = Q(defect__isnull=True)
if not notok_sign_field:
return explicit_grade | without_defect if value == DEFECT_OK else explicit_grade
has_legacy_sign = (
Q(**{f"{notok_sign_field}__isnull": False})
& ~Q(**{notok_sign_field: ""})
)
if value == DEFECT_OK:
return explicit_grade | (without_defect & ~has_legacy_sign)
if value == DEFECT_NOTOK:
return explicit_grade | (without_defect & has_legacy_sign)
return explicit_grade

View File

@ -48,7 +48,7 @@ class FtestWorkFilter(filters.FilterSet):
"material": ["exact"],
"wm": ["exact", "isnull"],
"mb": ["exact", "isnull"],
"batch": ["exact"],
"batch": ["exact", "contains"],
"type": ["exact"],
"type2": ["exact"],
"shift": ["exact"]

View File

@ -8,19 +8,25 @@ from django.utils.translation import gettext_lazy as _
from django.db import transaction
from django.db.models import Sum
from rest_framework.exceptions import ParseError
from apps.qm.defect_grades import (
DEFECT_GRADE_CHOICES,
DEFECT_NOTOK as GRADE_NOTOK,
DEFECT_OK as GRADE_OK,
DEFECT_OK_B as GRADE_OK_B,
)
class Defect(CommonAModel):
"""TN:缺陷项"""
DEFECT_OK = 10
DEFECT_OK_B = 20
DEFECT_NOTOK = 30
DEFECT_OK = GRADE_OK
DEFECT_OK_B = GRADE_OK_B
DEFECT_NOTOK = GRADE_NOTOK
cate_list = ["尺寸", "外观", "内质", "性能"]
name = models.CharField(max_length=50, verbose_name="名称")
code = models.CharField(max_length=50, verbose_name="标识", null=True, blank=True)
cate = models.CharField(max_length=50, verbose_name="分类", help_text=str(cate_list))
okcate= models.PositiveSmallIntegerField(verbose_name="不合格分类",
choices=((DEFECT_OK, "合格"), (DEFECT_OK_B, "合格B类"), (DEFECT_NOTOK, "不合格")),
default=DEFECT_NOTOK)
choices=DEFECT_GRADE_CHOICES,
default=GRADE_NOTOK)
note = models.TextField('备注', null=True, blank=True)
def __str__(self):

View File

@ -274,6 +274,7 @@ class FtestWorkSerializer(CustomModelSerializer):
material_cate = serializers.CharField(source='material.cate', read_only=True)
mb_ = MaterialBatchDetailSerializer(source='mb', read_only=True)
test_user_name = serializers.CharField(source='test_user.name', read_only=True)
submit_user_name = serializers.CharField(source='submit_user.name', read_only=True)
class Meta:
model = FtestWork

View File

@ -7,6 +7,7 @@ from apps.wf.models import Ticket
from apps.qm.models import NotOkOption, Defect
from apps.wpm.services_2 import ana_batch_thread
from apps.inm.models import MaterialBatch
from django.db import transaction
def ftestwork_submit_validate(ins: FtestWork):
wm:WMaterial = ins.wm
@ -21,8 +22,15 @@ def ftestwork_submit_validate(ins: FtestWork):
raise ParseError("不合格数不可大于批次数量")
@transaction.atomic
def ftestwork_submit(ins:FtestWork, user: User):
wm:WMaterial = ins.wm
ins = FtestWork.objects.select_for_update().get(pk=ins.pk)
if ins.submit_time is not None:
raise ParseError('该检验工作已提交')
wm = (
WMaterial.objects.select_for_update().get(pk=ins.wm_id)
if ins.wm_id else None
)
fwd_qs = FtestworkDefect.objects.filter(ftestwork=ins)
if wm and ins.need_update_wm:
if ins.qct is None and not fwd_qs.exists():
@ -46,7 +54,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
need_move_count = need_move_count + v
count_ok = ins.count_ok - need_move_count
if count_ok > 0:
wm, new_create = WMaterial.objects.get_or_create(
wm, new_create = WMaterial.locked_get_or_create_inventory(
material=wm.material,
batch=wm.batch,
mgroup=wm.mgroup,
@ -77,7 +85,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
astate = WMaterial.WM_NOTOK
if NotOkOption.get_extra_info(notok_sign)['cate'] == 'ok_b':
astate = WMaterial.WM_OK
wm_n, new_create = WMaterial.objects.get_or_create(
wm_n, new_create = WMaterial.locked_get_or_create_inventory(
material=wm.material,
batch=wm.batch,
mgroup=wm.mgroup,
@ -110,7 +118,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
wmstate = WMaterial.WM_OK
if item.defect.okcate == Defect.DEFECT_NOTOK:
wmstate = WMaterial.WM_NOTOK
wmx, new_create = WMaterial.objects.get_or_create(
wmx, new_create = WMaterial.locked_get_or_create_inventory(
material=wm.material,
batch=wm.batch,
mgroup=wm.mgroup,
@ -127,7 +135,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
wmx.save()
if ins.mb:
mb:MaterialBatch = ins.mb
mb = MaterialBatch.objects.select_for_update().get(pk=ins.mb_id)
for item in fwd_qs:
item:FtestworkDefect = item
if item.count > 0:
@ -138,7 +146,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
mbstate = WMaterial.WM_OK
if item.defect.okcate == Defect.DEFECT_NOTOK:
mbstate = WMaterial.WM_NOTOK
mbx, new_create = MaterialBatch.objects.get_or_create(
mbx, new_create = MaterialBatch.locked_get_or_create_inventory(
material=mb.material,
warehouse=mb.warehouse,
batch=mb.batch,
@ -158,8 +166,15 @@ def ftestwork_submit(ins:FtestWork, user: User):
ana_batch_thread(xbatchs=[ins.batch])
@transaction.atomic
def ftestwork_revert(ins: FtestWork):
wm:WMaterial = ins.wm
ins = FtestWork.objects.select_for_update().get(pk=ins.pk)
if ins.submit_time is None:
raise ParseError('该检验工作未提交')
wm = (
WMaterial.objects.select_for_update().get(pk=ins.wm_id)
if ins.wm_id else None
)
if wm and ins.need_update_wm:
fwd_qs = FtestworkDefect.objects.filter(ftestwork=ins)
for item in fwd_qs:

View File

@ -20,6 +20,7 @@ from django.db import transaction
from apps.qm.services import ftestwork_submit, ftestwork_revert
from apps.wpm.services_2 import ana_batch_thread
from apps.wf.models import State
from django.db.models.functions import Length
# Create your views here.
class DefectViewSet(CustomModelViewSet):
@ -43,12 +44,12 @@ class QctViewSet(CustomModelViewSet):
检测模板
"""
queryset = Qct.objects.all()
queryset = Qct.objects.annotate(_number_len=Length("number")).order_by("_number_len", "number")
serializer_class = QctSerializer
retrieve_serializer_class = QctDetailSerializer
filterset_class = QctFilter
search_fields = ["name", "number"]
ordering = ["number", "-update_time"]
ordering = []
ordering_fields = ["number", "create_time", "update_time"]
@action(methods=['post'], detail=False, perms_map={'post': '*'}, serializer_class=QctGetSerializer)

View File

@ -651,6 +651,7 @@ class FileViewSet(BulkCreateModelMixin, RetrieveModelMixin, CustomListModelMixin
class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, GenericViewSet):
perms_map = {'get': '*', 'post': 'apk.upload'}
serializer_class = ApkSerializer
filter_backends = []
def get_authenticators(self):
if self.request.method == 'GET':

View File

@ -69,6 +69,7 @@ class SpeakerViewSet(CustomGenericViewSet):
"""
perms_map = {}
serializer_class = serializers.Serializer
filter_backends = []
@action(methods=['get'], detail=False,
permission_classes=[IsAuthenticated])
@ -125,6 +126,7 @@ class XxTestView(APIView):
class XxCommonViewSet(CreateModelMixin, CustomGenericViewSet):
perms_map = {'post': '*'}
serializer_class = RequestCommonSerializer
filter_backends = []
def create(self, request, *args, **kwargs):
"""
@ -258,6 +260,7 @@ class KingCommonViewSet(CreateModelMixin, CustomGenericViewSet):
class DhCommonViewSet(CreateModelMixin, CustomGenericViewSet):
perms_map = {'post': '*'}
serializer_class = RequestCommonSerializer
filter_backends = []
def create(self, request, *args, **kwargs):
"""

View File

View File

@ -0,0 +1,71 @@
import json
import os
from io import StringIO
from pathlib import Path
from django.conf import settings
from django.core.management import BaseCommand, CommandError, call_command
class Command(BaseCommand):
help = "生成供 Swagger UI 和 ReDoc 使用的静态 Swagger JSON"
def add_arguments(self, parser):
parser.add_argument(
"--output",
help="输出路径,默认使用 settings.SWAGGER_SCHEMA_PATH",
)
parser.add_argument(
"--url",
help="文档中的 API 根地址,默认使用 settings.BASE_URL",
)
def handle(self, *args, **options):
target = Path(options["output"] or settings.SWAGGER_SCHEMA_PATH)
if not target.is_absolute():
target = Path(settings.BASE_DIR) / target
target = target.resolve()
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
try:
output = StringIO()
call_command(
"generate_swagger",
"-",
format="json",
api_url=options["url"] or settings.BASE_URL,
mock=True,
verbosity=0,
stdout=output,
)
content = output.getvalue()
schema = json.loads(content)
if schema.get("swagger") != "2.0" or not schema.get("paths"):
raise CommandError("生成的 Swagger 文档缺少版本或接口路径")
content = json.dumps(
schema,
ensure_ascii=False,
separators=(",", ":"),
)
temporary.write_text(content, encoding="utf-8")
os.replace(temporary, target)
except Exception as exc:
if isinstance(exc, CommandError):
raise
raise CommandError(f"生成 Swagger 文档失败:{exc}") from exc
finally:
temporary.unlink(missing_ok=True)
operation_count = sum(
method.lower() in {"get", "post", "put", "patch", "delete"}
for path in schema["paths"].values()
for method in path
)
self.stdout.write(
self.style.SUCCESS(
f"Swagger文档已生成{target} "
f"{len(schema['paths'])}个路径,{operation_count}个操作)"
)
)

View File

@ -207,12 +207,28 @@ class CustomRetrieveModelMixin(RetrieveModelMixin):
class CustomListModelMixin(ListModelMixin):
@swagger_auto_schema(manual_parameters=[
openapi.Parameter(name="query", in_=openapi.IN_QUERY, description="定制返回数据",
type=openapi.TYPE_STRING, required=False),
openapi.Parameter(name="with_children", in_=openapi.IN_QUERY, description="带有children(yes/no/count)",
type=openapi.TYPE_STRING, required=False),
])
@swagger_auto_schema(
operation_description=(
"通用列表接口用于记录或目录浏览以及逐条追溯。跨时间范围的产量、良率、缺陷、"
"库存、绩效和趋势等统计聚合,优先查询 BI dataset 目录并执行匹配的数据集。"
),
manual_parameters=[
openapi.Parameter(
name="query",
in_=openapi.IN_QUERY,
description="django-restql 返回字段裁剪表达式",
type=openapi.TYPE_STRING,
required=False,
),
openapi.Parameter(
name="with_children",
in_=openapi.IN_QUERY,
description="带有children(yes/no/count)",
type=openapi.TYPE_STRING,
required=False,
),
],
)
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())

View File

@ -31,7 +31,27 @@ class SoftDeletableQuerySetMixin(object):
return super(SoftDeletableQuerySetMixin, self).delete()
class SoftDeletableQuerySet(SoftDeletableQuerySetMixin, QuerySet):
class SnowflakeQuerySet(QuerySet):
"""为 BaseModel 的批量创建统一准备雪花主键。"""
def bulk_create(self, objs, *args, **kwargs):
objs = list(objs)
for obj in objs:
if not obj.pk:
obj.pk = str(idWorker.get_id())
else:
obj.pk = str(obj.pk)
return super().bulk_create(objs, *args, **kwargs)
class SnowflakeManager(models.Manager.from_queryset(SnowflakeQuerySet)):
pass
class SoftDeletableQuerySet(
SoftDeletableQuerySetMixin,
SnowflakeQuerySet,
):
pass
@ -111,6 +131,8 @@ class BaseModel(models.Model):
is_deleted = models.BooleanField(
default=False, verbose_name='删除标记', help_text='删除标记')
objects = SnowflakeManager()
class Meta:
abstract = True
@ -154,8 +176,12 @@ class BaseModel(models.Model):
@classmethod
def locked_get_or_create(cls, defaults: dict, **kwargs):
"""
仅用于事务内
并发安全的 get_or_create
仅用于事务内锁定已存在的记录
PostgreSQL 无法通过 select_for_update 锁定不存在的记录因此该方法
不保证首次创建并发安全需要防止首次重复创建的业务应提供稳定的业务
键并使用专用 advisory lock车间库存使用
WMaterial.locked_get_or_create_inventory
"""
if not connection.in_atomic_block:
raise RuntimeError("locked_get_or_create 必须在事务中调用")
@ -185,7 +211,10 @@ class BaseModel(models.Model):
is_create = False
if not self.id:
is_create = True
self.id = idWorker.get_id()
self.id = str(idWorker.get_id())
else:
# BaseModel 的主键是 CharField保证新建后内存值与数据库回读值类型一致。
self.id = str(self.id)
old_parent = None
need_handle_parent = False
@ -205,7 +234,7 @@ class BaseModel(models.Model):
except IntegrityError as e:
if is_create:
time.sleep(0.01)
self.id = idWorker.get_id()
self.id = str(idWorker.get_id())
ins = super().save(*args, **kwargs)
raise e
# 处理父级

367
apps/utils/swagger.py Normal file
View File

@ -0,0 +1,367 @@
import re
from pathlib import Path
from django.apps import apps
from django.conf import settings
from django.http import FileResponse, JsonResponse
from drf_yasg import openapi
from drf_yasg.inspectors import FieldInspector, SwaggerAutoSchema
from drf_yasg.inspectors.base import NotHandled
CRUD_SUMMARIES = {
"list": "查询{resource}列表",
"retrieve": "查询{resource}详情",
"create": "新增{resource}",
"update": "更新{resource}",
"partial_update": "部分更新{resource}",
"destroy": "删除{resource}",
}
TAG_NAMES = {
"am": "区域与准入管理",
"asm": "资产管理",
"cm": "标签管理",
"cms": "内容管理",
"develop": "开发工具",
"ecm": "事件管理",
"edu": "培训考试",
"em": "设备管理",
"enm": "能源管理",
"inm": "库存管理",
"mpr": "物资申购与领用",
"mtm": "物料与工艺管理",
"ofm": "办公管理",
"opm": "作业许可",
"pm": "生产任务管理",
"pum": "采购管理",
"qm": "质量管理",
"rem": "研发项目管理",
"rpm": "相关方管理",
"sam": "销售管理",
"third": "第三方集成",
"utils": "通用工具",
"wpm": "生产管理",
"wpmw": "动态产品管理",
"file": "文件管理",
}
FIELD_NAMES = {
"id": "主键ID",
"ids": "主键ID列表",
"access": "访问令牌",
"refresh": "刷新令牌",
"password_check": "密码确认",
"base64": "Base64数据",
"server_time": "服务器时间",
"timezone": "时区",
"next": "下一页",
"previous": "上一页",
"results": "结果列表",
"detail": "详情",
"items": "明细列表",
"files": "附件列表",
"echart_options": "图表配置",
"tdata_list": "数据列表",
"page": "页码",
"page_size": "每页数量",
"ordering": "排序字段",
"querys": "查询条件列表",
"annotate_field_list": "聚合字段列表",
}
FIELD_TOKENS = {
"name": "名称",
"code": "编码",
"number": "编号",
"description": "说明",
"note": "备注",
"employee": "人员",
"user": "用户",
"leader": "负责人",
"manager": "负责人",
"keeper": "保管人",
"participant": "参与人",
"post": "岗位",
"dept": "部门",
"belong": "所属",
"create": "创建",
"update": "更新",
"submit": "提交",
"handle": "处理",
"test": "检验",
"material": "物料",
"supplier": "供应商",
"defect": "缺陷",
"equipment": "设备",
"warehouse": "仓库",
"process": "工序",
"operation": "操作",
"state": "状态",
"cate": "分类",
"type": "类型",
"area": "区域",
"team": "班组",
"shift": "班次",
"ticket": "工单",
"file": "文件",
"photo": "照片",
"image": "图片",
"origin": "来源",
"in": "入库",
"out": "出库",
"list": "列表",
"count": "数量",
"total": "总计",
"enabled": "是否启用",
}
QUERY_PARAMETERS = {
"page": "页码",
"page_size": "每页数量",
"search": "搜索关键字",
"ordering": "排序字段,字段名前加“-”表示倒序",
"format": "响应格式",
}
LOOKUP_NAMES = {
"in": "属于列表",
"contains": "包含",
"icontains": "包含(忽略大小写)",
"gte": "大于或等于",
"gt": "大于",
"lte": "小于或等于",
"lt": "小于",
"isnull": "是否为空",
"exact": "等于",
}
def _contains_chinese(value):
return bool(re.search(r"[\u4e00-\u9fff]", str(value or "")))
def swagger_schema_file(request):
schema_path = Path(settings.SWAGGER_SCHEMA_PATH)
if not schema_path.is_file():
return JsonResponse(
{"detail": "Swagger文档尚未生成请先运行 manage.py build_swagger"},
status=503,
)
response = FileResponse(
schema_path.open("rb"),
content_type="application/json; charset=utf-8",
filename="swagger.json",
)
response["Content-Disposition"] = 'inline; filename="swagger.json"'
response["Cache-Control"] = "no-cache"
return response
def _serializer_model(field):
parent = getattr(field, "parent", None)
while parent is not None:
meta = getattr(parent, "Meta", None)
model = getattr(meta, "model", None)
if model is not None:
return model
parent = getattr(parent, "parent", None)
return None
def _model_path_label(model, parts):
labels = []
for part in parts:
if model is None:
break
try:
model_field = model._meta.get_field(part)
except Exception:
break
verbose_name = getattr(model_field, "verbose_name", "")
if _contains_chinese(verbose_name):
labels.append(str(verbose_name))
model = getattr(model_field, "related_model", None)
return " / ".join(labels)
def _field_name_label(field_name):
field_name = str(field_name or "").strip("_")
if field_name in FIELD_NAMES:
return FIELD_NAMES[field_name]
tokens = field_name.split("_")
if tokens and all(token in FIELD_TOKENS for token in tokens):
return "".join(FIELD_TOKENS[token] for token in tokens)
return ""
class ChineseFieldInspector(FieldInspector):
"""优先使用模型字段中文名称补全 serializer 字段标题。"""
def field_to_swagger_object(self, field, **kwargs):
return NotHandled
def process_result(self, result, method_name, obj, **kwargs):
if (
method_name != "field_to_swagger_object"
or not isinstance(result, openapi.SwaggerDict)
or "$ref" in result
or _contains_chinese(result.get("title"))
):
return result
source_attrs = getattr(obj, "source_attrs", None) or []
model_label = _model_path_label(_serializer_model(obj), source_attrs)
label = model_label or _field_name_label(getattr(obj, "field_name", ""))
field_name = getattr(obj, "field_name", "")
if label:
result["title"] = label
elif field_name:
result["title"] = f"{field_name}(字段)"
return result
class ChineseSwaggerAutoSchema(SwaggerAutoSchema):
"""为未显式编写文档的接口补充稳定、可读的中文展示信息。"""
field_inspectors = [ChineseFieldInspector] + SwaggerAutoSchema.field_inspectors
def get_operation(self, operation_keys=None):
operation = super().get_operation(operation_keys)
model = getattr(getattr(self.view, "queryset", None), "model", None)
for parameter in operation.get("parameters", []):
current = parameter.get("description", "")
if _contains_chinese(current):
continue
location = parameter.get("in")
if location == openapi.IN_BODY:
description = "请求数据"
elif location == openapi.IN_PATH:
description = f"路径参数:{parameter.get('name', '')}"
else:
description = self._get_parameter_description(
parameter.get("name", ""), model
)
if current:
description = f"{description}{current}"
parameter["description"] = description
return operation
def get_summary_and_description(self):
summary, description = super().get_summary_and_description()
if summary:
if description and not _contains_chinese(description):
description = f"{summary}\n\n{description}"
return summary, description or summary
resource = self._get_resource_name()
action = getattr(self.view, "action", None)
template = CRUD_SUMMARIES.get(action)
if template:
summary = template.format(resource=resource)
elif resource:
action_name = str(action or self.method).replace("_", " ")
display_resource = resource
if not _contains_chinese(display_resource):
display_resource = f"{display_resource}接口"
summary = f"{display_resource}{action_name}"
if description and not _contains_chinese(description):
description = f"{summary}\n\n{description}"
return summary, description or summary
def get_request_body_parameters(self, consumes):
parameters = super().get_request_body_parameters(consumes)
for parameter in parameters:
if parameter.get("in") == openapi.IN_BODY and not _contains_chinese(
parameter.get("description")
):
parameter["description"] = "请求数据"
return parameters
def get_query_parameters(self):
parameters = super().get_query_parameters()
model = getattr(getattr(self.view, "queryset", None), "model", None)
for parameter in parameters:
current = parameter.get("description", "")
if _contains_chinese(current):
continue
description = self._get_parameter_description(
parameter.get("name", ""), model
)
if current:
description = f"{description}{current}"
parameter["description"] = description
return parameters
def get_responses(self):
responses = super().get_responses()
descriptions = {
"200": "请求成功",
"201": "创建成功",
"202": "请求已接受",
"204": "操作成功,无响应内容",
"400": "请求参数错误",
"401": "未认证或认证已失效",
"403": "无权访问",
"404": "资源不存在",
}
for status, response in responses.items():
if not response.get("description"):
response["description"] = descriptions.get(str(status), "接口响应")
return responses
def _get_parameter_description(self, name, model):
if name in QUERY_PARAMETERS:
return QUERY_PARAMETERS[name]
parts = str(name).split("__")
lookup = LOOKUP_NAMES.get(parts[-1])
field_parts = parts[:-1] if lookup else parts
label = _model_path_label(model, field_parts)
if not label:
label = _field_name_label(field_parts[-1] if field_parts else name)
if not label:
label = f"查询参数:{name}"
if lookup:
label = f"{label}{lookup}"
return label
def get_tags(self, operation_keys=None):
tags = super().get_tags(operation_keys)
if self.overrides.get("tags") or not tags:
return tags
if tags[0] in TAG_NAMES:
return [TAG_NAMES[tags[0]]]
try:
app_config = apps.get_app_config(tags[0])
except LookupError:
return tags
if _contains_chinese(app_config.verbose_name):
return [str(app_config.verbose_name)]
return tags
def _get_resource_name(self):
queryset = getattr(self.view, "queryset", None)
model = getattr(queryset, "model", None)
if model is None:
serializer_class = getattr(self.view, "serializer_class", None)
meta = getattr(serializer_class, "Meta", None)
model = getattr(meta, "model", None)
if model is None:
return "接口"
match = re.search(r"TN\s*[:]\s*([^\n\r]+)", model.__doc__ or "")
if match:
return match.group(1).strip()
verbose_name = str(model._meta.verbose_name)
if _contains_chinese(verbose_name):
return verbose_name
return model.__name__

142
apps/utils/test_swagger.py Normal file
View File

@ -0,0 +1,142 @@
import json
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from unittest.mock import patch
from django.conf import settings
from django.core.management import call_command
from django.test import SimpleTestCase, override_settings
from apps.am.models import Area
from apps.am.views import AreaViewSet
from apps.utils.swagger import ChineseSwaggerAutoSchema, swagger_schema_file
class ChineseSwaggerAutoSchemaTests(SimpleTestCase):
def make_schema(self, view, method="GET"):
schema = ChineseSwaggerAutoSchema.__new__(ChineseSwaggerAutoSchema)
schema.view = view
schema.method = method
schema.path = "/am/area/"
schema.overrides = {}
schema.operation_keys = ("am", "area", "list")
schema._sch = SimpleNamespace(get_description=lambda path, method: "")
return schema
def test_crud_summary_uses_model_chinese_name(self):
view = SimpleNamespace(queryset=Area.objects.all(), action="list")
schema = self.make_schema(view)
summary, description = schema.get_summary_and_description()
self.assertEqual(summary, "查询地图区域列表")
self.assertEqual(description, "查询地图区域列表")
def test_explicit_summary_takes_priority(self):
view = SimpleNamespace(queryset=Area.objects.all(), action="list")
schema = self.make_schema(view)
schema.overrides = {
"operation_summary": "区域自定义查询",
"operation_description": "自定义说明",
}
summary, description = schema.get_summary_and_description()
self.assertEqual(summary, "区域自定义查询")
self.assertEqual(description, "自定义说明")
def test_custom_action_with_english_model_name_has_chinese_hint(self):
model = SimpleNamespace(
__doc__="",
__name__="Dataset",
_meta=SimpleNamespace(verbose_name="dataset"),
)
queryset = SimpleNamespace(model=model)
view = SimpleNamespace(queryset=queryset, action="base")
schema = self.make_schema(view)
summary, _ = schema.get_summary_and_description()
self.assertEqual(summary, "Dataset接口base")
def test_tag_uses_chinese_business_module_name(self):
view = SimpleNamespace(queryset=Area.objects.all(), action="list")
schema = self.make_schema(view)
self.assertEqual(schema.get_tags(("am", "area", "list")), ["区域与准入管理"])
def test_filter_parameter_uses_model_field_labels(self):
view = SimpleNamespace(queryset=Area.objects.all(), action="list")
schema = self.make_schema(view)
description = schema._get_parameter_description(
"manager__name__contains",
Area,
)
self.assertIn("区域负责人", description)
self.assertIn("包含", description)
def test_swagger_queryset_skips_permission_data_lookup(self):
view = AreaViewSet(basename="area")
view.action = "list"
view.swagger_fake_view = True
with patch("apps.utils.viewsets.get_user_perms_map") as permission_lookup:
queryset = view.get_queryset()
self.assertIs(queryset.model, Area)
permission_lookup.assert_not_called()
class SwaggerSettingsTests(SimpleTestCase):
def test_swagger_supports_jwt_authorization_header(self):
from django.conf import settings
bearer = settings.SWAGGER_SETTINGS["SECURITY_DEFINITIONS"]["Bearer"]
self.assertEqual(bearer["type"], "apiKey")
self.assertEqual(bearer["name"], "Authorization")
self.assertEqual(bearer["in"], "header")
def test_swagger_ui_uses_static_schema(self):
from django.conf import settings
self.assertEqual(settings.SWAGGER_SETTINGS["SPEC_URL"], "schema-swagger-json")
self.assertEqual(settings.REDOC_SETTINGS["SPEC_URL"], "schema-swagger-json")
class BuildSwaggerCommandTests(SimpleTestCase):
def test_command_writes_valid_utf8_schema(self):
schema = {
"swagger": "2.0",
"info": {"title": "中文文档"},
"paths": {"/demo/": {"get": {}}},
}
def generate_schema(command_name, output_file, **options):
self.assertEqual(command_name, "generate_swagger")
self.assertEqual(output_file, "-")
options["stdout"].write(json.dumps(schema, ensure_ascii=False))
with TemporaryDirectory(dir=settings.BASE_DIR) as directory:
target = Path(directory) / "swagger.json"
with override_settings(SWAGGER_SCHEMA_PATH=str(target)):
with patch(
"apps.utils.management.commands.build_swagger.call_command",
side_effect=generate_schema,
):
call_command("build_swagger", verbosity=0)
content = target.read_text(encoding="utf-8")
self.assertIn("中文文档", content)
self.assertEqual(json.loads(content), schema)
with override_settings(SWAGGER_SCHEMA_PATH=str(target)):
response = swagger_schema_file(SimpleNamespace())
body = b"".join(response.streaming_content)
response.close()
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(body), schema)

116
apps/utils/tests.py Normal file
View File

@ -0,0 +1,116 @@
from unittest.mock import patch
from django.db.models.query import QuerySet
from django.test import SimpleTestCase, TestCase
from apps.develop.models import Project
from apps.mtm.models import Material
from apps.system.models import User
from apps.wpmw.models import WprDefect
class SnowflakeBulkCreateTests(SimpleTestCase):
@patch.object(QuerySet, "bulk_create")
@patch(
"apps.utils.models.idWorker.get_id",
side_effect=[100, 101],
)
def test_base_model_bulk_create_assigns_missing_ids(
self,
get_id,
bulk_create,
):
objs = [
WprDefect(defect_id="10", wpr_id="20"),
WprDefect(defect_id="11", wpr_id="20"),
]
WprDefect.objects.bulk_create(objs)
self.assertEqual([obj.id for obj in objs], ["100", "101"])
self.assertEqual(get_id.call_count, 2)
bulk_create.assert_called_once()
@patch.object(QuerySet, "bulk_create")
@patch("apps.utils.models.idWorker.get_id")
def test_base_model_bulk_create_preserves_existing_ids(
self,
get_id,
bulk_create,
):
objs = [
WprDefect(id=100, defect_id="10", wpr_id="20"),
WprDefect(id="101", defect_id="11", wpr_id="20"),
]
WprDefect.objects.bulk_create(objs)
self.assertEqual([obj.id for obj in objs], ["100", "101"])
get_id.assert_not_called()
bulk_create.assert_called_once()
@patch.object(QuerySet, "bulk_create")
@patch(
"apps.utils.models.idWorker.get_id",
side_effect=[200, 201],
)
def test_soft_delete_manager_uses_snowflake_bulk_create(
self,
get_id,
bulk_create,
):
objs = [
Material(name="物料1"),
Material(name="物料2"),
]
Material.objects.bulk_create(objs)
self.assertEqual([obj.id for obj in objs], ["200", "201"])
self.assertEqual(get_id.call_count, 2)
bulk_create.assert_called_once()
@patch.object(QuerySet, "bulk_create")
@patch(
"apps.utils.models.idWorker.get_id",
side_effect=[300, 301],
)
def test_custom_user_manager_uses_snowflake_bulk_create(
self,
get_id,
bulk_create,
):
objs = [
User(username="bulk-user-1"),
User(username="bulk-user-2"),
]
User.objects.bulk_create(objs)
self.assertEqual([obj.id for obj in objs], ["300", "301"])
self.assertEqual(get_id.call_count, 2)
bulk_create.assert_called_once()
class SnowflakeBulkCreateDatabaseTests(TestCase):
@patch(
"apps.utils.models.idWorker.get_id",
side_effect=[900000000000000001, 900000000000000002],
)
def test_bulk_create_persists_generated_ids(self, get_id):
objs = [
Project(name="批量项目1", code="bulk-project-1"),
Project(name="批量项目2", code="bulk-project-2"),
]
Project.objects.bulk_create(objs)
self.assertEqual(
[obj.id for obj in objs],
["900000000000000001", "900000000000000002"],
)
self.assertEqual(
set(Project.objects.values_list("id", flat=True)),
{"900000000000000001", "900000000000000002"},
)
self.assertEqual(get_id.call_count, 2)

View File

@ -154,6 +154,9 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
def get_queryset(self):
queryset = super().get_queryset()
queryset = self.get_queryset_custom(queryset)
# drf-yasg 生成文档时不应读取权限或业务数据。
if getattr(self, 'swagger_fake_view', False):
return queryset
if self.data_filter:
user = self.request.user
if user.is_superuser:

View File

@ -1,14 +1,38 @@
from datetime import timedelta
from unittest.mock import patch
from django.test import TestCase
from django.utils import timezone
from apps.hrm.models import Employee
from apps.hrm.services import HrmService
from apps.vm.models import Visit
# Create your tests here.
class VisitTest(TestCase):
def test_audit_end(self):
v = Visit.objects.get(id='1547401054033940480')
ep = Employee.objects.get(id='1547406632055607296')
HrmService.sync_dahua_employee(ep, '', v.visit_time, v.leave_time)
@patch.object(HrmService, 'sync_dahua_employee')
def test_sync_employee_uses_visit_time_window(self, sync_employee):
visit_time = timezone.now()
leave_time = visit_time + timedelta(hours=2)
visit = Visit.objects.create(
purpose=Visit.V_PURPOSE_CHOICES[0][0],
level=Visit.V_LEVEL_CHOICES[0][0],
name='访客同步测试',
visit_time=visit_time,
leave_time=leave_time,
)
employee = Employee.objects.create(name='测试访客', type='visitor')
HrmService.sync_dahua_employee(
employee,
'',
visit.visit_time,
visit.leave_time,
)
sync_employee.assert_called_once_with(
employee,
'',
visit_time,
leave_time,
)

View File

@ -1,3 +1,60 @@
from django.test import TestCase
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
# Create your tests here.
from django.test import SimpleTestCase
from rest_framework.test import APIRequestFactory
from apps.wf.views import TicketViewSet
class TicketAvailableActionsTests(SimpleTestCase):
def setUp(self):
self.request = APIRequestFactory().get('/api/wf/ticket/1/available_actions/')
self.request.user = SimpleNamespace(id='user-1')
self.view = TicketViewSet()
self.ticket = SimpleNamespace(in_add_node=False)
self.view.get_object = MagicMock(return_value=self.ticket)
@patch('apps.wf.views.TransitionSerializer')
@patch('apps.wf.views.WfService.get_ticket_transitions')
@patch('apps.wf.views.WfService.ticket_handle_permission_check')
def test_returns_transitions_when_user_can_handle(
self, permission_check, get_transitions, serializer):
permission_check.return_value = {
'permission': True,
'need_accept': False,
'msg': '',
}
transitions = MagicMock()
transitions.select_related.return_value = transitions
get_transitions.return_value = transitions
serializer.return_value.data = [{'id': 'transition-1', 'name': '同意'}]
response = self.view.available_actions(self.request)
self.assertTrue(response.data['permission'])
self.assertFalse(response.data['need_accept'])
self.assertEqual(
response.data['transitions'],
[{'id': 'transition-1', 'name': '同意'}],
)
@patch('apps.wf.views.TransitionSerializer')
@patch('apps.wf.views.WfService.get_ticket_transitions')
@patch('apps.wf.views.WfService.ticket_handle_permission_check')
def test_hides_transitions_and_requests_accept_for_active_distribution(
self, permission_check, get_transitions, serializer):
permission_check.return_value = {
'permission': False,
'need_accept': True,
'msg': '需要先接单再处理',
}
response = self.view.available_actions(self.request)
self.assertFalse(response.data['permission'])
self.assertTrue(response.data['need_accept'])
self.assertEqual(response.data['message'], '需要先接单再处理')
self.assertEqual(response.data['transitions'], [])
get_transitions.assert_not_called()
serializer.assert_not_called()

View File

@ -363,6 +363,34 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
transitions = WfService.get_ticket_transitions(ticket)
return Response(TransitionSerializer(instance=transitions.select_related('source_state', 'destination_state'), many=True).data)
@action(methods=['get'], detail=True, perms_map={'get': '*'})
def available_actions(self, request, pk=None):
"""
获取当前用户对工单可执行的操作及接单状态
前端应使用该接口决定是否展示处理按钮避免自行解释 participant
后与后端实际处理权限不一致 transitions 接口保留用于兼容
"""
ticket = self.get_object()
permission_result = WfService.ticket_handle_permission_check(
ticket, request.user
)
transitions = []
if permission_result.get('permission', False):
transition_queryset = WfService.get_ticket_transitions(
ticket
).select_related('source_state', 'destination_state')
transitions = TransitionSerializer(
instance=transition_queryset, many=True
).data
return Response({
'permission': permission_result.get('permission', False),
'need_accept': permission_result.get('need_accept', False),
'message': permission_result.get('msg', ''),
'in_add_node': ticket.in_add_node,
'transitions': transitions,
})
@action(methods=['post'], detail=True, perms_map={'post': '*'})
def accpet(self, request, pk=None):
"""

View File

@ -5,6 +5,7 @@ from apps.mtm.models import Route, Material
from django.db.models import Q, Exists, OuterRef
from rest_framework.exceptions import ParseError
from datetime import datetime
from apps.qm.defect_grades import effective_defect_grade_q
class SfLogFilter(filters.FilterSet):
class Meta:
@ -44,6 +45,10 @@ class WMaterialFilter(filters.FilterSet):
mlog_date_start = filters.DateFilter(label="产出开始", method="filter_mlog_date_start")
mlog_date_end = filters.DateFilter(label="产出结束", method="filter_mlog_date_end")
current_merged = filters.BooleanFilter(label="是否本工段新合成的批", method="filter_current_merged")
defect_grade = filters.NumberFilter(label="有效缺陷等级", method="filter_defect_grade")
def filter_defect_grade(self, queryset, name, value):
return queryset.filter(effective_defect_grade_q(value, "notok_sign"))
def filter_mlog_date_start(self, queryset, name, value):
mgroupId = self.data.get("mgroup", None)
@ -123,7 +128,7 @@ class WMaterialFilter(filters.FilterSet):
"material__name": ["exact", "in", "contains"],
"material__process": ["exact", "in"],
"material__process__name": ["exact", "contains", "in"],
"belong_dept": ["exact"],
"belong_dept": ["exact", "isnull"],
"belong_dept__name": ["exact", "in"],
"batch": ["exact", "contains"],
"mgroup": ["exact", "in", "isnull"],
@ -132,7 +137,8 @@ class WMaterialFilter(filters.FilterSet):
"notok_sign": ["exact", "in", "isnull"],
"count_xtest": ["gte", "isnull"],
"supplier": ["exact"],
"defect": ["exact", "isnull"]
"defect": ["exact", "isnull"],
"defect__okcate": ["exact", "in"],
}
class MlogFilter(filters.FilterSet):
@ -171,15 +177,23 @@ class HandoverFilter(filters.FilterSet):
dept = filters.CharFilter(label='DeptId', method='filter_dept')
def filter_mgroup(self, queryset, name, value):
return queryset.filter(send_mgroup__id=value)|queryset.filter(recive_mgroup__id=value)
return queryset.filter(
Q(send_mgroup_id=value) | Q(recive_mgroup_id=value)
)
def filter_mgroupx(self, queryset, name, value):
dept = Mgroup.objects.get(id=value).belong_dept
return (queryset.filter(send_mgroup__id=value)|queryset.filter(recive_mgroup__id=value)|
queryset.filter(send_dept=dept, send_mgroup__isnull=True)|queryset.filter(recive_dept=dept, recive_mgroup__isnull=True))
return queryset.filter(
Q(send_mgroup_id=value)
| Q(recive_mgroup_id=value)
| Q(send_dept=dept, send_mgroup__isnull=True)
| Q(recive_dept=dept, recive_mgroup__isnull=True)
)
def filter_dept(self, queryset, name, value):
return queryset.filter(send_dept__id=value)|queryset.filter(recive_dept__id=value)
return queryset.filter(
Q(send_dept_id=value) | Q(recive_dept_id=value)
)
def filter_cbatch(self, queryset, name, value):
qs1 = queryset.filter(Q(batch=value)|Q(new_batch=value)|Q(wm__batch=value))

View File

@ -0,0 +1,37 @@
from django.db import migrations, models
def migrate_scope_forward(apps, schema_editor):
Mlog = apps.get_model('wpm', 'Mlog')
Mlog.objects.filter(stored_mgroup=True).update(stored_scope=10)
def migrate_scope_backward(apps, schema_editor):
Mlog = apps.get_model('wpm', 'Mlog')
Mlog.objects.filter(stored_scope=10).update(stored_mgroup=True)
Mlog.objects.exclude(stored_scope=10).update(stored_mgroup=False)
class Migration(migrations.Migration):
dependencies = [
('mtm', '0068_process_into_wm_scope'),
('wpm', '0134_alter_mlog_clear_defect'),
]
operations = [
migrations.AddField(
model_name='mlog',
name='stored_scope',
field=models.PositiveSmallIntegerField(
choices=[(10, '工段'), (20, '部门'), (30, '全局')],
default=20,
verbose_name='产出库存归属',
),
),
migrations.RunPython(migrate_scope_forward, migrate_scope_backward),
migrations.RemoveField(
model_name='mlog',
name='stored_mgroup',
),
]

View File

@ -0,0 +1,36 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wpm', '0135_mlog_stored_scope'),
]
operations = [
migrations.AlterField(
model_name='handover',
name='send_dept',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='handover_send_dept',
to='system.dept',
verbose_name='送料部门',
),
),
migrations.AlterField(
model_name='handover',
name='recive_dept',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='handover_recive_dept',
to='system.dept',
verbose_name='接收部门',
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.27 on 2026-07-30 01:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpm', '0136_alter_handover_global_scope_fields'),
]
operations = [
migrations.AddField(
model_name='mlog',
name='wpr_defect_snapshot',
field=models.JSONField(blank=True, editable=False, null=True, verbose_name='单件缺陷提交前快照'),
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.27 on 2026-08-03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("system", "0007_alter_dept_create_by_alter_dept_third_info_and_more"),
("wpm", "0137_mlog_wpr_defect_snapshot"),
]
operations = [
migrations.AddField(
model_name="mlogbw",
name="files",
field=models.ManyToManyField(
blank=True,
related_name="mlogbw_outputs",
to="system.file",
verbose_name="产出附件",
),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpm', '0138_mlogbw_files'),
]
operations = [
migrations.AddField(
model_name='mloguser',
name='note',
field=models.TextField(blank=True, default='', verbose_name='备注'),
),
]

View File

@ -1,6 +1,6 @@
from django.db import models
from apps.utils.models import CommonADModel, CommonBDModel, BaseModel
from apps.mtm.models import Mgroup, Team, Shift, Material, Route, Process
from apps.mtm.models import Mgroup, Team, Shift, Material, Route, Process, WmScope
from apps.pm.models import Mtask, Mtaskb
from apps.system.models import User
from django.utils.timezone import localtime
@ -11,8 +11,9 @@ from django.db.models import Sum, Subquery
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ParseError
from django.db.models import Count
from django.db import transaction
from django.db import connection, transaction
from django.db.models import Max
import json
import re
from django.db.models import Q, F
import django.utils.timezone as timezone
@ -103,6 +104,9 @@ class WmStateOption(models.IntegerChoices):
class WMaterial(CommonBDModel):
"""
TN: 车间库存
归属范围分三级mgroup 指定工段只有 belong_dept 指定部门
两者都为空表示全局库存
"""
WM_OK = 10
WM_NOTOK = 20
@ -128,6 +132,122 @@ class WMaterial(CommonBDModel):
number_from = models.TextField("来源于个号", null=True, blank=True)
is_manual = models.BooleanField('手动创建', default=False)
INVENTORY_KEY_FIELDS = (
'material',
'batch',
'mgroup',
'belong_dept',
'state',
'defect',
'notok_sign',
'material_origin',
'state_origin',
)
@classmethod
def _normalize_inventory_lookup(cls, **kwargs):
"""生成唯一、完整的库存业务键,避免省略 NULL 字段产生不同锁键。"""
unknown_fields = set(kwargs) - set(cls.INVENTORY_KEY_FIELDS)
if unknown_fields:
fields = ', '.join(sorted(unknown_fields))
raise TypeError(f'不支持的车间库存定位字段: {fields}')
if kwargs.get('material') is None or kwargs.get('batch') is None:
raise ValueError('车间库存业务键必须包含 material 和 batch')
lookup = {
field: kwargs.get(field)
for field in cls.INVENTORY_KEY_FIELDS
}
if lookup['state'] is None:
lookup['state'] = cls._meta.get_field('state').get_default()
mgroup = lookup['mgroup']
belong_dept = lookup['belong_dept']
if mgroup is not None:
mgroup_dept_id = getattr(mgroup, 'belong_dept_id', None)
belong_dept_id = getattr(belong_dept, 'pk', belong_dept)
if belong_dept is None:
lookup['belong_dept'] = mgroup.belong_dept
elif mgroup_dept_id != belong_dept_id:
raise ValueError('车间库存的工段与所属部门不匹配')
return lookup
@classmethod
def _inventory_advisory_lock_payload(cls, lookup):
lock_values = {}
for name in cls.INVENTORY_KEY_FIELDS:
field = cls._meta.get_field(name)
value = lookup[name]
if field.is_relation and value is not None:
value = getattr(value, 'pk', value)
lock_values[field.attname] = value
return json.dumps(
{'model': cls._meta.label_lower, 'lookup': lock_values},
sort_keys=True,
ensure_ascii=False,
default=str,
separators=(',', ':'),
)
@classmethod
def locked_get_or_create_inventory(cls, defaults=None, **kwargs):
"""
在事务中按完整库存业务键获取或创建记录
已存在记录使用行锁首次创建使用 PostgreSQL 事务级 advisory lock
并在取得锁后重新查询避免两个事务同时创建相同库存
"""
if not connection.in_atomic_block:
raise RuntimeError(
'locked_get_or_create_inventory 必须在事务中调用'
)
if connection.vendor != 'postgresql':
raise RuntimeError(
'locked_get_or_create_inventory 仅支持 PostgreSQL'
)
defaults = defaults or {}
lookup = cls._normalize_inventory_lookup(**kwargs)
create_defaults = {
key: value
for key, value in defaults.items()
if key not in cls.INVENTORY_KEY_FIELDS
}
rows = list(
cls.objects.select_for_update().filter(**lookup)[:2]
)
if len(rows) > 1:
raise RuntimeError(
f'{cls.__name__} 数据异常:库存业务键 {lookup} 命中多条'
)
if rows:
return rows[0], False
lock_payload = cls._inventory_advisory_lock_payload(lookup)
with connection.cursor() as cursor:
cursor.execute(
'SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))',
[lock_payload],
)
rows = list(
cls.objects.select_for_update().filter(**lookup)[:2]
)
if len(rows) > 1:
raise RuntimeError(
f'{cls.__name__} 数据异常:库存业务键 {lookup} 命中多条'
)
if rows:
return rows[0], False
return cls.objects.create(
**lookup,
**create_defaults,
), True
def delete(self, *args, **kwargs):
if not self.is_manual:
raise ParseError('只能删除手动创建的车间库存')
@ -144,7 +264,11 @@ class WMaterial(CommonBDModel):
@property
def belong_dept_or_mgroup_id(self):
return self.mgroup.id if self.mgroup else self.belong_dept.id
if self.mgroup_id:
return ("mgroup", self.mgroup_id)
if self.belong_dept_id:
return ("belong_dept", self.belong_dept_id)
return ("global", None)
@property
def count_working(self):
@ -171,7 +295,6 @@ class WMaterial(CommonBDModel):
if qs is None:
qs = cls.objects
return WMaterial.ava_qs(mgroup=mtask.mgroup, qs=qs).filter(
mgroup=mtask.mgroup,
material=mtask.material_in,
batch__in=Subquery(
Mlogb.objects.filter(
@ -240,9 +363,16 @@ class Mlog(CommonADModel):
work_end_time = models.DateTimeField('生产结束时间', null=True, blank=True)
hour_work = models.FloatField('预计工时', null=True, blank=True)
reminder_interval_list = models.JSONField('提醒间隔', default=list, blank=True)
stored_mgroup = models.BooleanField('入库到工段', default=False)
stored_scope = models.PositiveSmallIntegerField(
'产出库存归属', choices=WmScope.choices, default=WmScope.DEPT)
stored_notok = models.BooleanField('不合格品是否已入库', default=False)
clear_defect = models.BooleanField('合格B类记为合格', default=False)
wpr_defect_snapshot = models.JSONField(
'单件缺陷提交前快照',
null=True,
blank=True,
editable=False,
)
route = models.ForeignKey(Route, verbose_name='生产路线', on_delete=models.SET_NULL, null=True, blank=True)
mtask = models.ForeignKey(
Mtask, verbose_name='关联任务', on_delete=models.CASCADE, null=True, blank=True, related_name='mlog_mtask')
@ -420,6 +550,7 @@ class MlogUser(BaseModel):
Equipment, verbose_name='生产设备', on_delete=models.CASCADE, null=True, blank=True, related_name='mloguser_equipment')
shift = models.ForeignKey(Shift, verbose_name='关联班次', on_delete=models.CASCADE)
handle_date = models.DateField('操作日期')
note = models.TextField('备注', default='', blank=True)
class Mlogb(BaseModel):
"""
@ -610,6 +741,12 @@ class Mlogbw(BaseModel):
"""TN: 单个产品生产/检验日志
"""
number = models.TextField('单个编号', db_index=True)
files = models.ManyToManyField(
"system.File",
verbose_name="产出附件",
blank=True,
related_name="mlogbw_outputs",
)
mlogb = models.ForeignKey(Mlogb, verbose_name='生产记录', on_delete=models.CASCADE, related_name="w_mlogb")
mlogbw_from = models.ForeignKey("self", verbose_name='来源个', on_delete=models.CASCADE, null=True, blank=True, related_name="w_mlogbw_from")
wpr = models.ForeignKey("wpmw.wpr", verbose_name='关联产品', on_delete=models.SET_NULL
@ -691,7 +828,8 @@ class Handover(CommonADModel):
send_mgroup = models.ForeignKey(
Mgroup, verbose_name='送料工段', on_delete=models.CASCADE, null=True, blank=True)
send_dept = models.ForeignKey(
Dept, verbose_name='送料部门', on_delete=models.CASCADE, related_name='handover_send_dept')
Dept, verbose_name='送料部门', on_delete=models.CASCADE, related_name='handover_send_dept',
null=True, blank=True)
batch = models.TextField('批次号', null=True, blank=True, db_index=True)
material = models.ForeignKey(
Material, verbose_name='物料', on_delete=models.CASCADE, related_name='h_ma')
@ -700,7 +838,8 @@ class Handover(CommonADModel):
count = models.DecimalField('送料数', default=0, max_digits=11, decimal_places=1)
count_eweight = models.FloatField('单数重量', default=0)
recive_dept = models.ForeignKey(
Dept, verbose_name='接收部门', on_delete=models.CASCADE, related_name='handover_recive_dept')
Dept, verbose_name='接收部门', on_delete=models.CASCADE, related_name='handover_recive_dept',
null=True, blank=True)
recive_mgroup = models.ForeignKey(Mgroup, verbose_name='接收工段', on_delete=models.CASCADE, related_name='handover_recive_mgroup', null=True, blank=True)
recive_user = models.ForeignKey(
User, verbose_name='接收人', on_delete=models.CASCADE, related_name='handover_recive_user', null=True, blank=True)
@ -738,7 +877,7 @@ class Handoverb(BaseModel):
@property
def handoverbw(self):
return Handoverbw.objects.filter(handoverb=self)
return self.w_handoverb.all()
class Handoverbw(BaseModel):
"""TN: 单个产品交接记录

View File

@ -8,11 +8,11 @@ from .models import (SfLog, StLog, SfLogExp, WMaterial, Mlog,
Handover, Handoverb, Mlogb, AttLog,
OtherLog, Fmlog, BatchSt, Mlogbw, Handoverbw, MlogbDefect, MlogUser, BatchLog)
from apps.system.models import Dept, User
from apps.system.serializers import UserSimpleSerializer
from apps.system.serializers import FileSerializer, UserSimpleSerializer
from apps.pm.models import Mtask, Mtaskb
from apps.wpm.tasks import cal_enstat_when_pcoal_heat_change, cal_enstat_when_team_change, cal_exp_duration_sec
from apps.wpm.services import get_sflog, find_material_can_change, generate_new_batch
from apps.mtm.models import Mgroup, TeamMember, Shift, Material, Route, Process
from apps.mtm.models import Mgroup, TeamMember, Shift, Material, Route, Process, WmScope
from apps.mtm.serializers import MaterialSimpleSerializer
from django.db import transaction
from django.utils import timezone
@ -24,12 +24,35 @@ from apps.wpmw.models import Wpr
from apps.qm.serializers import FtestProcessSerializer, FtestProcessListSerializer
import logging
from apps.qm.models import Defect
from apps.qm.defect_grades import DEFECT_GRADE_NAMES, effective_defect_grade
from apps.utils.snowflake import idWorker
from decimal import Decimal
from apps.em.models import Equipment
from django.db.models import Q
mylogger = logging.getLogger("log")
WM_STATE_NAMES = {
WMaterial.WM_OK: "合格",
WMaterial.WM_NOTOK: "不合格",
WMaterial.WM_REPAIR: "返修",
WMaterial.WM_REPAIRED: "返修完成",
WMaterial.WM_TEST: "检验",
WMaterial.WM_SCRAP: "报废",
}
class MlogEquipmentOptionSerializer(serializers.ModelSerializer):
mgroup_name = serializers.CharField(source="mgroup.name", read_only=True)
full_name = serializers.SerializerMethodField()
def get_full_name(self, obj):
return f"{obj.number}|{obj.name}|{obj.model}"
class Meta:
model = Equipment
fields = ["id", "name", "number", "model", "mgroup_name", "full_name"]
class OtherLogSerializer(CustomModelSerializer):
class Meta:
model = OtherLog
@ -189,12 +212,21 @@ class WMaterialSerializer(CustomModelSerializer):
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
notok_sign_name = serializers.SerializerMethodField()
defect_name = serializers.CharField(source="defect.name", read_only=True)
defect_okcate = serializers.IntegerField(source="defect.okcate", read_only=True)
defect_grade = serializers.SerializerMethodField()
defect_grade_name = serializers.SerializerMethodField()
count_working = serializers.SerializerMethodField(label='在制数量')
count_handovering = serializers.SerializerMethodField(label='正在交送的数量')
def get_notok_sign_name(self, obj):
return getattr(NotOkOption, obj.notok_sign, NotOkOption.qt).label if obj.notok_sign else None
def get_defect_grade(self, obj):
return effective_defect_grade(obj, "notok_sign")
def get_defect_grade_name(self, obj):
return DEFECT_GRADE_NAMES[self.get_defect_grade(obj)]
def get_count_working(self, obj):
# 列表接口 queryset 已注解(单次聚合); 嵌套等无注解场景回退模型属性
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可用/可交接量偏大)
@ -232,18 +264,36 @@ class WMaterialSerializer(CustomModelSerializer):
class WMaterialCreateSerializer(CustomModelSerializer):
class Meta:
model = WMaterial
fields = ['material', 'count', 'batch', 'mgroup']
fields = ['material', 'count', 'batch', 'mgroup', 'belong_dept']
extra_kwargs = {
'material': {'required': True},
'count': {'required': True},
'batch': {'required': True},
'mgroup': {'required': True, 'allow_null': False},
'mgroup': {'required': False, 'allow_null': True},
'belong_dept': {'required': False, 'allow_null': True},
}
def validate(self, attrs):
attrs['belong_dept'] = attrs['mgroup'].belong_dept
mgroup = attrs.get('mgroup')
if mgroup:
attrs['belong_dept'] = mgroup.belong_dept
return attrs
@transaction.atomic
def create(self, validated_data):
lookup = {
field: validated_data.pop(field)
for field in WMaterial.INVENTORY_KEY_FIELDS
if field in validated_data
}
instance, created = WMaterial.locked_get_or_create_inventory(
**lookup,
defaults=validated_data,
)
if not created:
raise serializers.ValidationError('相同业务键的车间库存已存在')
return instance
class MlogbDefectSerializer(CustomModelSerializer):
defect_name = serializers.CharField(source="defect.name", read_only=True)
@ -329,8 +379,7 @@ class MlogListSerializer(CustomModelSerializer):
class Meta:
model = Mlog
fields = '__all__'
# exclude = ["equipments","handle_users", "material_outs"]
exclude = ["wpr_defect_snapshot"]
class MlogbDetailSerializer(CustomModelSerializer):
@ -401,7 +450,7 @@ class MlogSerializer(CustomModelSerializer):
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
class Meta:
model = Mlog
fields = '__all__'
exclude = ["wpr_defect_snapshot"]
read_only_fields = EXCLUDE_FIELDS + \
['submit_time', 'submit_user', 'material_outs']
extra_kwargs = {
@ -897,6 +946,8 @@ class MlogbInUpdateSerializer(CustomModelSerializer):
return ins
class MlogbwCreateUpdateSerializer(CustomModelSerializer):
files = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
files_ = FileSerializer(source="files", many=True, read_only=True)
ftest = FtestProcessSerializer(required=False, allow_null=True)
equip_name = serializers.CharField(source='equip.name', read_only=True)
equip_number = serializers.CharField(source='equip.number', read_only=True)
@ -910,7 +961,8 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
fields = ["id", "number", "wpr", "note",
"mlogb", "ftest", "equip", "tooling", "work_start_time",
"work_end_time", "mlogbw_from", "equip_name", "equip_number",
"tooling_name", "tooling_number", "wpr_number_out", "wpr_pre_info", "mlogb__batch"]
"tooling_name", "tooling_number", "wpr_number_out", "wpr_pre_info", "mlogb__batch",
"files", "files_"]
read_only_fields = ["mlogbw_from"]
def validate(self, attrs):
@ -954,10 +1006,18 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
mlogbw = self.save_ftest(mlogbw, ftest_data)
return mlogbw
@transaction.atomic
def update(self, instance, validated_data):
old_number = instance.number
validated_data.pop("mlogb")
ftest_data = validated_data.pop("ftest", None)
mlogbw:Mlogbw = super().update(instance, validated_data)
if (
mlogbw.number != old_number
and mlogbw.mlogb.material_out_id is not None
and mlogbw.wpr is not None
):
mlogbw.wpr.change_number(mlogbw.number)
if ftest_data:
mlogbw = self.save_ftest(mlogbw, ftest_data)
elif ftest_data is None:
@ -972,6 +1032,15 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
class MlogbwListSerializer(MlogbwCreateUpdateSerializer):
ftest = FtestProcessListSerializer(read_only=True)
class MlogbwFilesSerializer(CustomModelSerializer):
files_ = FileSerializer(source="files", many=True, read_only=True)
class Meta:
model = Mlogbw
fields = ["id", "files", "files_"]
read_only_fields = ["id", "files_"]
class MlogbwStartTestSerializer(serializers.Serializer):
mlogbw_ids = serializers.ListField(child=serializers.CharField(), label="mlogbwId列表")
test_equip = serializers.CharField(label="测试设备", allow_null=True, required=False, allow_blank=True)
@ -1218,10 +1287,56 @@ class Handoverbwserializer(CustomModelSerializer):
read_only_fields = EXCLUDE_FIELDS_BASE + ["handoverb", "number"]
extra_kwargs = {'wpr': {'required': True}}
class CachedWMaterialPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
def to_internal_value(self, data):
cache = getattr(self.root, "_handover_wmaterial_cache", None)
if cache is None:
return super().to_internal_value(data)
if not isinstance(data, (str, int)):
self.fail("incorrect_type", data_type=type(data).__name__)
try:
return cache[str(data)]
except KeyError:
self.fail("does_not_exist", pk_value=data)
class HandoverbSerializer(CustomModelSerializer):
wm = CachedWMaterialPrimaryKeyRelatedField(
queryset=WMaterial.objects.select_related(
"material", "defect", "mgroup", "belong_dept"
)
)
notok_sign = serializers.CharField(source='wm.notok_sign', read_only=True)
notok_sign_name = serializers.SerializerMethodField()
defect_name = serializers.CharField(source="wm.defect.name", read_only=True)
defect_grade = serializers.SerializerMethodField()
defect_grade_name = serializers.SerializerMethodField()
material_name = serializers.StringRelatedField(source="wm.material", read_only=True)
state_name = serializers.SerializerMethodField()
mgroup_name = serializers.CharField(source="wm.mgroup.name", read_only=True)
belong_dept_name = serializers.CharField(source="wm.belong_dept.name", read_only=True)
count_available = serializers.SerializerMethodField()
handoverbw = Handoverbwserializer(many=True, required=False)
def get_notok_sign_name(self, obj):
return getattr(NotOkOption, obj.wm.notok_sign, NotOkOption.qt).label if obj.wm.notok_sign else None
def get_defect_grade(self, obj):
return effective_defect_grade(obj.wm, "notok_sign")
def get_defect_grade_name(self, obj):
return DEFECT_GRADE_NAMES[self.get_defect_grade(obj)]
def get_state_name(self, obj):
return WM_STATE_NAMES.get(obj.wm.state, str(obj.wm.state))
def get_count_available(self, obj):
# 编辑未提交交接时,当前明细占用的数量仍应允许重新填写。
if obj.handover.submit_time is not None:
return obj.count
return obj.wm.count - obj.wm.count_handovering + obj.count
class Meta:
model = Handoverb
fields = "__all__"
@ -1244,9 +1359,14 @@ class HandoverSerializer(CustomModelSerializer):
recive_user_name = serializers.CharField(
source='recive_user.name', read_only=True)
recive_dept_name = serializers.CharField(
source='recive_dept', read_only=True)
source='recive_dept.name', read_only=True)
send_dept_name = serializers.CharField(source='send_dept.name', read_only=True)
send_mgroup_name = serializers.CharField(source='send_mgroup.name', read_only=True)
recive_mgroup_name = serializers.CharField(source='recive_mgroup.name', read_only=True)
submit_user_name = serializers.CharField(source='submit_user.name', read_only=True)
type_name = serializers.CharField(source='get_type_display', read_only=True)
mtype_name = serializers.CharField(source='get_mtype_display', read_only=True)
state_changed_name = serializers.SerializerMethodField()
material_ = MaterialSimpleSerializer(source='material', read_only=True)
material_name = serializers.StringRelatedField(
source='material', read_only=True)
@ -1255,6 +1375,30 @@ class HandoverSerializer(CustomModelSerializer):
handoverb = HandoverbSerializer(many=True, required=False)
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
def get_state_changed_name(self, obj):
return WM_STATE_NAMES.get(obj.state_changed) if obj.state_changed is not None else None
def to_internal_value(self, data):
handoverb = data.get("handoverb", []) if hasattr(data, "get") else []
wm_ids = {
str(item["wm"])
for item in handoverb
if isinstance(item, dict) and item.get("wm") is not None
}
if not wm_ids:
return super().to_internal_value(data)
queryset = WMaterial.objects.select_related(
"material", "defect", "mgroup", "belong_dept"
)
self._handover_wmaterial_cache = {
str(pk): instance for pk, instance in queryset.in_bulk(wm_ids).items()
}
try:
return super().to_internal_value(data)
finally:
del self._handover_wmaterial_cache
def validate(self, attrs):
if "mtype" not in attrs:
attrs['mtype'] = Handover.H_NORMAL
@ -1262,6 +1406,9 @@ class HandoverSerializer(CustomModelSerializer):
attrs['type'] = Handover.H_NORMAL
mtype = attrs["mtype"]
clear_defect = bool(attrs.get('clear_defect', False))
if clear_defect and mtype != Handover.H_MERGE:
raise ParseError('仅合批时可清除批次缺陷')
new_wm:WMaterial = attrs.get("new_wm", None)
if new_wm:
@ -1297,16 +1444,68 @@ class HandoverSerializer(CustomModelSerializer):
else:
raise ParseError('必须指定车间库存')
attrs['material'] = wm.material
is_global_source = wm.mgroup_id is None and wm.belong_dept_id is None
send_mgroup = attrs.get('send_mgroup')
if send_mgroup:
attrs['send_dept'] = send_mgroup.belong_dept
else:
attrs['send_dept'] = wm.belong_dept
if wm.mgroup:
if not send_mgroup and wm.mgroup:
attrs['send_mgroup'] = wm.mgroup
if attrs['material'].process and attrs['material'].process.into_wm_mgroup and 'recive_mgroup' not in attrs:
if (
is_global_source
and attrs['type'] == Handover.H_NORMAL
and mtype == Handover.H_NORMAL
):
raise ParseError('全局库存无需正常交接')
is_global_rebatch = (
is_global_source
and attrs['type'] == Handover.H_NORMAL
and mtype in [Handover.H_DIV, Handover.H_MERGE]
)
if is_global_rebatch:
# 全局库存的拆批、合批不改变库存归属。
attrs['recive_mgroup'] = None
attrs['recive_dept'] = None
if new_wm and (
new_wm.mgroup_id is not None
or new_wm.belong_dept_id is not None
):
raise ParseError('全局库存合批目标必须是全局库存')
elif (
is_global_source
and attrs['type'] in [
Handover.H_REPAIR,
Handover.H_SCRAP,
Handover.H_CHANGE,
]
and not attrs.get('recive_mgroup')
):
raise ParseError('全局库存返修、报废或改版必须指定接收工段')
material_process = attrs['material'].process
if (
not is_global_rebatch
and material_process
and WmScope.requires_mgroup(material_process.into_wm_scope)
and not attrs.get('recive_mgroup')
):
raise ParseError('必须指定交接工段')
if 'recive_mgroup' in attrs and attrs['recive_mgroup']:
attrs['recive_dept'] = attrs['recive_mgroup'].belong_dept
if not attrs.get('recive_dept', None) and not attrs.get('recive_mgroup', None):
if (
not is_global_rebatch
and not attrs.get('recive_dept', None)
and not attrs.get('recive_mgroup', None)
):
raise ParseError('必须指定收料车间或收料工段')
if not attrs.get('send_dept', None) and not attrs.get('send_mgroup', None):
if (
not is_global_source
and not attrs.get('send_dept', None)
and not attrs.get('send_mgroup', None)
):
raise ParseError('必须指定送料车间或送料工段')
# if attrs["mtype"] == Handover.H_NORMAL and attrs.get("recive_mgroup", None) == attrs.get("send_mgroup", None):
@ -1334,11 +1533,14 @@ class HandoverSerializer(CustomModelSerializer):
elif deptOrmgroupId != current_mdept_id:
raise ParseError(f'{ind+1}行-交接物料所属工段/车间不一致')
if mtype == Handover.H_MERGE:
clear_defect = attrs.get('clear_defect', False)
if clear_defect and wm.state != WMaterial.WM_OK:
raise ParseError(f'{ind+1}行-清除缺陷合批仅支持合格品')
if clear_defect and new_wm is not None and new_wm.defect is not None:
raise ParseError('清除缺陷合批的目标批次不能带缺陷')
raise ParseError('清除批次缺陷时目标批次不能带缺陷')
if clear_defect and tracking == Material.MA_TRACKING_BATCH:
defect_grade = effective_defect_grade(wm, "notok_sign")
if defect_grade not in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]:
raise ParseError(
f'{ind+1}行-批次追踪物料仅合格品和合格B类可清除批次缺陷'
)
if next_mat is None:
next_mat = wm.material
next_state = wm.state
@ -1463,7 +1665,7 @@ class HandoverSerializer(CustomModelSerializer):
elif count == wm.count:
wpr_qs = Wpr.get_qs_by_wm(wm)
for item in wpr_qs:
Handoverbw.objects.get_or_create(wpr=item, handoverb=handoverb,
Handoverbw.objects.get_or_create(wpr=item, handoverb=hb,
defaults={"number": item.number})
else:
raise ParseError(f'{ind+1}行-请提供交接物料明细')
@ -1641,6 +1843,7 @@ class MlogQuickSerializer(serializers.Serializer):
route = serializers.CharField(label="工艺步骤ID", required=False)
mtask = serializers.CharField(label="任务ID", required=False)
handle_user = serializers.CharField(label="操作人员ID")
supplier = serializers.CharField(label="外协单位ID", required=False)
class BatchChangeSerializer(serializers.Serializer):

View File

@ -1,4 +1,5 @@
import datetime
from collections import defaultdict
from django.core.cache import cache
from django.db.models import Sum
@ -21,11 +22,50 @@ from apps.wpmw.models import Wpr, WprDefect
from ..qm.models import Defect, Ftest
from django.db.models import Count, Q
from apps.utils.tasks import ctask_run
from apps.mtm.models import Process
from apps.utils.snowflake import idWorker
from apps.mtm.models import Process, WmScope
from django.db.models import F
myLogger = logging.getLogger('log')
RECENT_EQUIPMENT_LOG_LIMIT = 50
def get_recent_mgroup_equipment_ids(
mgroup_id, log_limit=RECENT_EQUIPMENT_LOG_LIMIT
):
"""按日志时间倒序返回工段最近使用过的设备 ID空值和重复值忽略。"""
recent_logs = list(
Mlog.objects.filter(mgroup_id=mgroup_id)
.order_by("-create_time", "-id")
.values_list("id", "equipment_id", "equipment_2_id")[:log_limit]
)
if not recent_logs:
return []
log_ids = [log_id for log_id, _, _ in recent_logs]
multiple_equipment_ids = defaultdict(list)
for log_id, equipment_id in (
Mlog.equipments.through.objects.filter(mlog_id__in=log_ids)
.order_by("id")
.values_list("mlog_id", "equipment_id")
):
multiple_equipment_ids[log_id].append(equipment_id)
result = []
seen = set()
for log_id, equipment_id, equipment_2_id in recent_logs:
candidate_ids = [
equipment_id,
equipment_2_id,
*multiple_equipment_ids[log_id],
]
for candidate_id in candidate_ids:
if candidate_id and candidate_id not in seen:
seen.add(candidate_id)
result.append(candidate_id)
return result
def inherit_zt_batch(source: BatchSt, target: BatchSt):
"""拆批/报工改号时目标批继承来源批的直通统计大批归属(纯继承, 不做判定)
@ -165,6 +205,74 @@ def get_pcoal_heat(year_s: int, month_s: int, day_s: int):
return 25000
def snapshot_mlog_wpr_defects(mlog: Mlog):
"""锁定并保存日志提交前已有单件的缺陷状态。"""
wpr_ids = list(Mlogbw.objects.filter(
mlogb__mlog=mlog,
wpr__isnull=False,
).values_list("wpr_id", flat=True).distinct())
list(Wpr.objects.select_for_update().filter(
id__in=wpr_ids,
).values_list("id", flat=True))
snapshot = {str(wpr_id): [] for wpr_id in wpr_ids}
for defect in WprDefect.objects.filter(wpr_id__in=wpr_ids).values(
"wpr_id",
"defect_id",
"is_main",
):
snapshot[str(defect["wpr_id"])].append({
"defect_id": str(defect["defect_id"]),
"is_main": defect["is_main"],
})
mlog.wpr_defect_snapshot = snapshot
mlog.save(update_fields=["wpr_defect_snapshot", "update_time"])
return snapshot
def restore_mlog_wpr_defects(mlog: Mlog):
"""按快照精确恢复日志提交前的单件缺陷。"""
snapshot = mlog.wpr_defect_snapshot
if snapshot is None:
return False
wpr_ids = list(snapshot)
wprs = {
str(wpr.id): wpr
for wpr in Wpr.objects.select_for_update().filter(id__in=wpr_ids)
}
WprDefect.objects.filter(wpr_id__in=wpr_ids).delete()
restored = []
for wpr_id, defects in snapshot.items():
wpr = wprs.get(str(wpr_id))
if wpr is None:
myLogger.warning(
"生产日志%s测回时未找到快照中的单件%s",
mlog.id,
wpr_id,
)
continue
restored.extend(
WprDefect(
id=idWorker.get_id(),
wpr=wpr,
defect_id=item["defect_id"],
is_main=item.get("is_main", False),
)
for item in defects
)
WprDefect.objects.bulk_create(restored)
return True
def restore_legacy_wpr_defects_from_wm(wpr: Wpr, wm: WMaterial):
"""兼容无快照历史日志的旧测回行为。"""
defects = []
if wm.defect_id:
defects.append({"defect_id": wm.defect_id, "is_main": True})
wpr.replace_defects(defects)
def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
"""
生产日志提交后需要执行的操作
@ -187,7 +295,7 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
mgroup = mlog.mgroup
process = mgroup.process
stored_mgroup = process.into_wm_mgroup
stored_scope = process.into_wm_scope
stored_notok = process.store_notok
belong_dept = mgroup.belong_dept
material_out: Material = mlog.material_out
@ -195,10 +303,13 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
supplier = mlog.supplier # 外协
is_fix = mlog.is_fix
if is_fix: # 如果是返工,直接放到工段下
stored_mgroup = True
stored_scope = WmScope.MGROUP
stored_location = WmScope.resolve_location(stored_scope, mgroup)
m_ins_list = []
m_ins_bl_list = []
wpr_defect_snapshot = snapshot_mlog_wpr_defects(mlog)
if is_fix:
can_matoutIds = process.get_canout_mat_ids()
# 建立关系链
@ -237,15 +348,23 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
continue
if mi_count <= 0:
raise ParseError('存在非正数!')
# 需要判断领用数是否合理
# 优先使用工段库存
if isinstance(mlog_or_b, Mlogb) and mlog_or_b.wm_in:
wm = WMaterial.objects.select_for_update().get(id=mlog_or_b.wm_in.id)
# 优先使用日志已选库存;未选择时按工段、部门、全局依次匹配并记录实际来源
wm_in = getattr(mlog_or_b, 'wm_in', None)
if wm_in:
wm = WMaterial.objects.select_for_update().get(id=wm_in.id)
else:
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma, mgroup=mgroup, state=WMaterial.WM_OK)
if not wm_qs.exists():
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma,
belong_dept=belong_dept, mgroup=None, state=WMaterial.WM_OK)
if not wm_qs.exists():
wm_qs = WMaterial.objects.filter(
batch=mi_batch,
material=mi_ma,
belong_dept=None,
mgroup=None,
state=WMaterial.WM_OK,
)
count_x = wm_qs.count()
if count_x == 1:
wm = WMaterial.objects.select_for_update().get(id=wm_qs.first().id)
@ -255,6 +374,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
else:
raise ParseError(
f'{str(mi_ma)}-{mi_batch}-存在多个相同批次!')
mlog_or_b.wm_in = wm
mlog_or_b.save(update_fields=['wm_in'])
if mi_count > wm.count:
raise ParseError(
@ -268,7 +389,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != mi_count:
raise ParseError("日志与实际使用明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, old_wm=wm, ftest=item.ftest)
wpr = Wpr.change_or_new(wpr=item.wpr, old_wm=wm)
wpr.apply_ftest(item.ftest)
# 针对加工前不良的暂时额外处理
# 合格B类的需要当合格品
@ -280,8 +402,15 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
c_state = WMaterial.WM_NOTOK
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
c_state = WMaterial.WM_OK
lookup = {'batch': batch, 'material': material, 'mgroup': mgroup, 'defect': defect, 'state': c_state}
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
lookup = {
'batch': batch,
'material': material,
'defect': defect,
'state': c_state,
**stored_location,
}
wm, is_create = WMaterial.locked_get_or_create_inventory(
**lookup, defaults={})
wm.count = wm.count + count
if is_create:
wm.create_by = user
@ -294,7 +423,12 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != count:
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, wm=wm, old_wm=wm, ftest=item.ftest)
wpr = Wpr.change_or_new(
wpr=item.wpr,
wm=wm,
old_wm=wm,
)
wpr.apply_ftest(item.ftest)
mlogb_out_qs = Mlogb.objects.filter(mlog=mlog, material_out__isnull=False)
@ -359,18 +493,20 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
wm_state = WMaterial.WM_OK if notok_sign_or_defect is None or (
isinstance(notok_sign_or_defect, Defect) and notok_sign_or_defect.okcate in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]
) else WMaterial.WM_NOTOK
lookup = {'batch': mo_batch, 'material': mo_ma, 'mgroup': None,
'notok_sign': None, 'defect': None, 'state': wm_state}
lookup = {
'batch': mo_batch,
'material': mo_ma,
'notok_sign': None,
'defect': None,
'state': wm_state,
**stored_location,
}
if isinstance(notok_sign_or_defect, Defect):
lookup['defect'] = notok_sign_or_defect
elif notok_sign_or_defect is not None:
lookup['notok_sign'] = notok_sign_or_defect
if stored_mgroup:
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm, is_create2 = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
wm, is_create2 = WMaterial.locked_get_or_create_inventory(
**lookup, defaults={})
wm.count = wm.count + mo_count
wm.count_eweight = mo_count_eweight
wm.update_by = user
@ -399,25 +535,43 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
raise ParseError("日志与明细数量不一致,操作失败")
for item in mlogbws:
if item.wpr:
Wpr.change_or_new(wpr=item.wpr, wm=wm, ftest=item.ftest)
wpr = Wpr.change_or_new(
wpr=item.wpr,
wm=wm,
)
else:
if not check_wpr_number(item.number):
raise ParseError(f'{item.number} 该编号格式有误')
wpr_from = None
if item.mlogbw_from:
wpr_from = item.mlogbw_from.wpr
wpr = Wpr.change_or_new(number=item.number,
wm=wm, ftest=item.ftest,
wpr_from=wpr_from)
wpr = Wpr.change_or_new(
number=item.number,
wm=wm,
wpr_from=wpr_from,
)
item.wpr = wpr
item.save()
wpr_id = str(wpr.id)
if wpr_id not in wpr_defect_snapshot:
wpr_defect_snapshot[wpr_id] = [
{
"defect_id": str(defect["defect_id"]),
"is_main": defect["is_main"],
}
for defect in WprDefect.objects.filter(
wpr=wpr,
).values("defect_id", "is_main")
]
wpr.apply_ftest(item.ftest)
mlog.submit_time = now
mlog.submit_user = user
mlog.stored_notok = stored_notok
mlog.stored_mgroup = stored_mgroup
mlog.stored_scope = stored_scope
if mlog.work_end_time is None and mlog.work_start_time is not None:
mlog.work_end_time = now
mlog.wpr_defect_snapshot = wpr_defect_snapshot
mlog.save()
# 更新任务进度
@ -448,8 +602,10 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
material_out:Material = mlog.material_out
material_in:Material = mlog.material_in
stored_notok = mlog.stored_notok
stored_mgroup = mlog.stored_mgroup
stored_scope = mlog.stored_scope
stored_location = WmScope.resolve_location(stored_scope, mgroup)
is_fix = mlog.is_fix
has_wpr_defect_snapshot = mlog.wpr_defect_snapshot is not None
if is_fix:
can_matoutIds = process.get_canout_mat_ids()
@ -523,15 +679,18 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
wm_state = WMaterial.WM_OK if notok_sign_or_defect is None or (
isinstance(notok_sign_or_defect, Defect) and notok_sign_or_defect.okcate in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]
) else WMaterial.WM_NOTOK
lookup = {'batch': mo_batch, 'material': mo_ma, 'mgroup': None, 'notok_sign': None, 'defect': None, 'state': wm_state}
lookup = {
'batch': mo_batch,
'material': mo_ma,
'notok_sign': None,
'defect': None,
'state': wm_state,
**stored_location,
}
if isinstance(notok_sign_or_defect, Defect):
lookup['defect'] = notok_sign_or_defect
else:
lookup['notok_sign'] = notok_sign_or_defect
if stored_mgroup:
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm_qs = WMaterial.objects.filter(**lookup)
count_x = wm_qs.count()
if count_x == 1:
@ -561,7 +720,8 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != mo_count:
raise ParseError("日志与明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, old_wm=wm)
wpr = Wpr.change_or_new(wpr=item.wpr, old_wm=wm)
wpr.revert_ftest_info(item.ftest)
# 再生成消耗
m_ins_list = []
@ -584,18 +744,19 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
continue
if mi_count <= 0:
raise ParseError('存在非正数!')
if isinstance(mlog_or_b, Mlogb) and mlog_or_b.wm_in:
wm = WMaterial.objects.select_for_update().get(id=mlog_or_b.wm_in.id)
wm_in = getattr(mlog_or_b, 'wm_in', None)
if wm_in:
wm = WMaterial.objects.select_for_update().get(id=wm_in.id)
else:
# 针对光子的情况实际上必须需要wm_in
lookup = {'batch': mi_batch, 'material': mi_ma, 'mgroup': None, 'state': WMaterial.WM_OK}
if stored_mgroup:
# 退回到本工段
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm, _ = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
lookup = {
'batch': mi_batch,
'material': mi_ma,
'state': WMaterial.WM_OK,
**stored_location,
}
wm, _ = WMaterial.locked_get_or_create_inventory(
**lookup, defaults={})
wm.count = wm.count + mi_count
wm.update_by = user
wm.save()
@ -604,7 +765,9 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != mi_count:
raise ParseError("日志与实际使用明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, wm=wm)
wpr = Wpr.change_or_new(wpr=item.wpr, wm=wm)
if not has_wpr_defect_snapshot:
restore_legacy_wpr_defects_from_wm(wpr, wm)
# 针对加工前不良的暂时额外处理
if stored_notok:
@ -615,12 +778,15 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
c_state = WMaterial.WM_NOTOK
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
c_state = WMaterial.WM_OK
lookup = {'batch': batch, 'material': material, 'mgroup': None, 'defect': defect, 'state': c_state}
if stored_mgroup:
lookup['mgroup'] = mgroup
else:
lookup['belong_dept'] = belong_dept
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
lookup = {
'batch': batch,
'material': material,
'defect': defect,
'state': c_state,
**stored_location,
}
wm, is_create = WMaterial.locked_get_or_create_inventory(
**lookup, defaults={})
wm.count = wm.count - count
if wm.count < 0:
raise ParseError('加工前不良数量大于库存量')
@ -634,7 +800,18 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
if mlogbws.count() != count:
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
for item in mlogbws:
Wpr.change_or_new(wpr=item.wpr, wm=wm)
wpr = Wpr.change_or_new(wpr=item.wpr, wm=wm)
if not has_wpr_defect_snapshot:
restore_legacy_wpr_defects_from_wm(wpr, wm)
if has_wpr_defect_snapshot:
restore_mlog_wpr_defects(mlog)
mlog.wpr_defect_snapshot = None
else:
myLogger.warning(
"生产日志%s无单件缺陷快照,测回按历史兼容逻辑处理,无法精确恢复提交前缺陷",
mlog.id,
)
mlog.submit_time = None
mlog.submit_user = None
@ -845,24 +1022,30 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
WMaterial.decrease(wm_id=wm_from.id, user=user, count=xcount)
if need_add:
target_defect = (
merge_target_defect
if mtype == Handover.H_MERGE
else wm_from.defect
)
# 开始变动
if handover.type == Handover.H_NORMAL:
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
defect_ok = wm_to.defect == wm_from.defect or (
merge_clear_defect and wm_to.defect is None and wm_from.state == WMaterial.WM_OK
)
if wm_to.state != wm_from.state or wm_to.material != wm_from.material or not defect_ok:
if (
wm_to.state != wm_from.state
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("正常合并到的车间库存状态或物料异常")
else:
wm_to, _ = WMaterial.locked_get_or_create(
wm_to, _ = WMaterial.locked_get_or_create_inventory(
batch=batch,
material=material,
mgroup=recive_mgroup,
belong_dept=recive_dept,
state=wm_from.state,
notok_sign=wm_from.notok_sign,
defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.defect,
defect=target_defect,
defaults={
"batch_ofrom": wm_from.batch_ofrom,
"material_ofrom": wm_from.material_ofrom,
@ -876,16 +1059,20 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
wm_state = WMaterial.WM_REPAIR
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if wm_to.state != WMaterial.WM_REPAIR or wm_to.material != wm_from.material or wm_to.defect != wm_from.defect:
if (
wm_to.state != WMaterial.WM_REPAIR
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("返修合并到的车间库存状态或物料异常")
elif recive_mgroup:
wm_to, _ = WMaterial.locked_get_or_create(
wm_to, _ = WMaterial.locked_get_or_create_inventory(
batch=batch,
material=material,
mgroup=recive_mgroup,
belong_dept=recive_dept,
notok_sign=wm_from.notok_sign,
defect=wm_from.defect,
defect=target_defect,
material_origin=material,
state=wm_state,
defaults={
@ -900,16 +1087,20 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
elif handover.type == Handover.H_SCRAP:
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if wm_to.state != WMaterial.WM_SCRAP or wm_to.material != wm_from.material or wm_to.defect != wm_from.defect:
if (
wm_to.state != WMaterial.WM_SCRAP
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("报废合并到的车间库存状态或物料异常")
elif recive_mgroup:
wm_to, _ = WMaterial.locked_get_or_create(
wm_to, _ = WMaterial.locked_get_or_create_inventory(
batch=batch,
material=material,
mgroup=recive_mgroup,
belong_dept=recive_dept,
notok_sign=wm_from.notok_sign,
defect=wm_from.defect,
defect=target_defect,
state=WMaterial.WM_SCRAP,
defaults={
"batch_ofrom": wm_from.batch_ofrom,
@ -923,17 +1114,21 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
elif handover.type == Handover.H_CHANGE:
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if wm_to.material != handover.material_changed or wm_to.state != handover.state_changed:
if (
wm_to.material != handover.material_changed
or wm_to.state != handover.state_changed
or wm_to.defect != target_defect
):
raise ParseError("改版合并到的车间库存状态或物料异常")
elif handover.recive_mgroup:
wm_to, _ = WMaterial.locked_get_or_create(
wm_to, _ = WMaterial.locked_get_or_create_inventory(
batch=batch,
material=handover.material_changed,
state=handover.state_changed,
mgroup=recive_mgroup,
belong_dept=recive_dept,
notok_sign=wm_from.notok_sign,
defect=wm_from.defect,
defect=target_defect,
material_origin=None if handover.material_changed == material else material,
state_origin=None if handover.state_changed == wm_from.state else wm_from.state,
defaults={
@ -950,15 +1145,21 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
raise ParseError("仅合格品支持退回")
if mtype == Handover.H_MERGE and handover.new_wm:
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
if (
wm_to.state != wm_from.state
or wm_to.material != wm_from.material
or wm_to.defect != target_defect
):
raise ParseError("退料合并到的车间库存状态或物料异常")
else:
wm_to, _ = WMaterial.locked_get_or_create(
wm_to, _ = WMaterial.locked_get_or_create_inventory(
batch=batch,
material=material,
mgroup=recive_mgroup,
belong_dept=recive_dept,
state=wm_from.state,
notok_sign=wm_from.notok_sign,
defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.defect,
defect=target_defect,
defaults={
"batch_ofrom": wm_from.batch_ofrom,
"material_ofrom": wm_from.material_ofrom,
@ -983,7 +1184,12 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
raise ParseError(f'{batch}物料明细中存在{len(wm_ids)}个不同物料批次')
for item in handoverbws:
wpr:Wpr = item.wpr
Wpr.change_or_new(wpr=wpr, wm=wm_to, old_wm=wpr.wm, old_mb=wpr.mb)
Wpr.change_or_new(
wpr=wpr,
wm=wm_to,
old_wm=wpr.wm,
old_mb=wpr.mb,
)
db_count = WMaterial.objects.filter(id=wm_to.id).values_list("count", flat=True).get()
if db_count != Wpr.objects.filter(wm=wm_to).count():
raise ParseError("交接与明细数量不一致2,操作失败")
@ -1038,7 +1244,13 @@ def handover_revert(handover:Handover, handler:User=None):
wm = WMaterial.objects.get(id=wm.id)
for item in handoverbws:
wpr:Wpr = item.wpr
Wpr.change_or_new(wpr=wpr, wm=wm, old_wm=wpr.wm, old_mb=wpr.mb, add_version=False)
Wpr.change_or_new(
wpr=wpr,
wm=wm,
old_wm=wpr.wm,
old_mb=wpr.mb,
add_version=False,
)
handover.submit_time = None
handover.submit_user = None

View File

@ -1,3 +1,854 @@
from django.test import TestCase
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal
from threading import Barrier
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
# Create your tests here.
from django.db import connection, connections, transaction
from django.test import SimpleTestCase, TestCase, TransactionTestCase
from unittest import skipUnless
from apps.mtm.models import Material, Mgroup, WmScope
from apps.qm.models import Defect
from apps.system.models import Dept, User
from apps.wpm.filters import HandoverFilter, WMaterialFilter
from apps.wpm.models import Handover, WMaterial
from apps.wpm.serializers import (
HandoverSerializer,
WMaterialCreateSerializer,
WMaterialSerializer,
)
from apps.wpm.services import restore_mlog_wpr_defects, snapshot_mlog_wpr_defects
from apps.wpm.views import MlogbwViewSet
from apps.wpmw.models import Wpr, WprDefect
from rest_framework.exceptions import ParseError
class HandoverFilterTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.dept = Dept.objects.create(name="交接筛选测试车间")
cls.other_dept = Dept.objects.create(name="其他测试车间")
cls.mgroup = Mgroup.objects.create(
name="交接筛选测试工段",
belong_dept=cls.dept,
)
cls.peer_mgroup = Mgroup.objects.create(
name="同车间其他工段",
belong_dept=cls.dept,
)
cls.other_mgroup = Mgroup.objects.create(
name="其他车间工段",
belong_dept=cls.other_dept,
)
cls.user = User.objects.create_user(username="handover-filter-user")
cls.material = Material.objects.create(name="交接筛选测试物料")
@classmethod
def create_handover(cls, **kwargs):
defaults = {
"send_date": "2026-07-28",
"send_user": cls.user,
"material": cls.material,
}
defaults.update(kwargs)
return Handover.objects.create(**defaults)
def test_mgroup_only_returns_records_involving_the_selected_mgroup(self):
sent = self.create_handover(
send_mgroup=self.mgroup,
recive_mgroup=self.peer_mgroup,
)
received = self.create_handover(
send_mgroup=self.peer_mgroup,
recive_mgroup=self.mgroup,
)
self.create_handover(
send_mgroup=self.peer_mgroup,
recive_mgroup=self.other_mgroup,
)
queryset = HandoverFilter(
{"mgroup": self.mgroup.id},
queryset=Handover.objects.all(),
).qs
self.assertCountEqual(queryset.values_list("id", flat=True), [
sent.id,
received.id,
])
def test_mgroupx_adds_only_department_level_records_for_same_dept(self):
direct = self.create_handover(send_mgroup=self.mgroup)
dept_level = self.create_handover(
send_dept=self.dept,
send_mgroup=None,
)
self.create_handover(
send_mgroup=self.peer_mgroup,
recive_mgroup=self.other_mgroup,
)
self.create_handover(
send_dept=self.other_dept,
send_mgroup=None,
)
queryset = HandoverFilter(
{"mgroupx": self.mgroup.id},
queryset=Handover.objects.all(),
).qs
self.assertCountEqual(queryset.values_list("id", flat=True), [
direct.id,
dept_level.id,
])
class WMaterialDefectGradeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.material = Material.objects.create(name="缺陷等级测试物料")
cls.defect_b = Defect.objects.create(
name="B类缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_OK_B,
)
cls.defect_notok = Defect.objects.create(
name="不合格缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_NOTOK,
)
cls.normal_with_notok_defect = WMaterial.objects.create(
material=cls.material,
batch="NORMAL-NOTOK",
count=1,
state=WMaterial.WM_OK,
defect=cls.defect_notok,
)
cls.notok_with_b_defect = WMaterial.objects.create(
material=cls.material,
batch="NOTOK-B",
count=1,
state=WMaterial.WM_NOTOK,
defect=cls.defect_b,
)
cls.normal_without_defect = WMaterial.objects.create(
material=cls.material,
batch="NORMAL-NONE",
count=1,
state=WMaterial.WM_OK,
)
cls.repair_without_defect = WMaterial.objects.create(
material=cls.material,
batch="REPAIR-NONE",
count=1,
state=WMaterial.WM_REPAIR,
)
cls.notok_with_legacy_sign = WMaterial.objects.create(
material=cls.material,
batch="NOTOK-LEGACY",
count=1,
state=WMaterial.WM_NOTOK,
notok_sign="zw",
)
def test_serializer_exposes_effective_defect_grade_without_using_state(self):
normal_notok_data = WMaterialSerializer(
self.normal_with_notok_defect
).data
notok_b_data = WMaterialSerializer(self.notok_with_b_defect).data
no_defect_data = WMaterialSerializer(self.normal_without_defect).data
repair_no_defect_data = WMaterialSerializer(
self.repair_without_defect
).data
legacy_data = WMaterialSerializer(self.notok_with_legacy_sign).data
self.assertEqual(
normal_notok_data["defect_grade"], Defect.DEFECT_NOTOK
)
self.assertEqual(normal_notok_data["defect_grade_name"], "不合格")
self.assertEqual(
notok_b_data["defect_grade"], Defect.DEFECT_OK_B
)
self.assertEqual(notok_b_data["defect_grade_name"], "合格B类")
self.assertEqual(no_defect_data["defect_grade"], Defect.DEFECT_OK)
self.assertEqual(no_defect_data["defect_grade_name"], "合格")
self.assertEqual(
repair_no_defect_data["defect_grade"], Defect.DEFECT_OK
)
self.assertEqual(repair_no_defect_data["defect_grade_name"], "合格")
self.assertEqual(legacy_data["defect_grade"], Defect.DEFECT_NOTOK)
self.assertEqual(legacy_data["defect_grade_name"], "不合格")
def test_filtering_state_and_defect_grade_are_independent(self):
normal_notok = WMaterialFilter(
{
"state": WMaterial.WM_OK,
"defect__okcate": Defect.DEFECT_NOTOK,
},
queryset=WMaterial.objects.all(),
).qs
notok_b = WMaterialFilter(
{
"state": WMaterial.WM_NOTOK,
"defect__okcate": Defect.DEFECT_OK_B,
},
queryset=WMaterial.objects.all(),
).qs
self.assertQuerySetEqual(
normal_notok,
[self.normal_with_notok_defect],
transform=lambda item: item,
)
self.assertQuerySetEqual(
notok_b,
[self.notok_with_b_defect],
transform=lambda item: item,
)
def test_effective_grade_filter_includes_defaults_and_legacy_signs(self):
ok_items = WMaterialFilter(
{"defect_grade": Defect.DEFECT_OK},
queryset=WMaterial.objects.all(),
).qs
notok_items = WMaterialFilter(
{"defect_grade": Defect.DEFECT_NOTOK},
queryset=WMaterial.objects.all(),
).qs
self.assertCountEqual(
ok_items.values_list("id", flat=True),
[self.normal_without_defect.id, self.repair_without_defect.id],
)
self.assertCountEqual(
notok_items.values_list("id", flat=True),
[
self.normal_with_notok_defect.id,
self.notok_with_legacy_sign.id,
],
)
class MlogbwViewSetTests(SimpleTestCase):
@patch("apps.wpm.views.MlogViewSet.lock_and_check_can_update")
@patch("apps.wpm.views.Mlogbw.cal_count_notok")
@patch("apps.wpm.views.Mlogbw.objects.get_or_create")
@patch("apps.wpm.views.Mlogb.objects.filter")
def test_perform_create_syncs_fix_output_without_route(
self,
mlogb_filter,
mlogbw_get_or_create,
cal_count_notok,
lock_and_check_can_update,
):
wm = object()
wpr = SimpleNamespace(wm=wm)
material = SimpleNamespace(tracking=Material.MA_TRACKING_SINGLE)
mlog = SimpleNamespace(
route=None,
is_fix=True,
cal_mlog_count_from_mlogb=MagicMock(),
)
mlogb_in = SimpleNamespace(
mlog=mlog,
route=None,
wm_in=wm,
material_in=material,
)
mlogb_out = object()
mlogb_qs = MagicMock()
mlogb_qs.exists.return_value = True
mlogb_qs.__iter__.return_value = iter([mlogb_out])
mlogb_filter.return_value = mlogb_qs
ins = SimpleNamespace(
mlogb=mlogb_in,
wpr=wpr,
number="2606P1095-3",
)
serializer = MagicMock()
serializer.save.return_value = ins
lock_and_check_can_update.return_value = mlog
MlogbwViewSet().perform_create(serializer)
mlogbw_get_or_create.assert_called_once_with(
mlogb=mlogb_out,
wpr=wpr,
defaults={
"number": "2606P1095-3",
"mlogbw_from": ins,
},
)
self.assertEqual(cal_count_notok.call_count, 2)
mlog.cal_mlog_count_from_mlogb.assert_called_once_with()
class WMaterialScopeTests(SimpleTestCase):
def test_inventory_key_normalizes_omitted_nullable_fields(self):
material = Material(id="100", name="测试物料")
omitted = WMaterial._normalize_inventory_lookup(
material=material,
batch="BATCH-001",
state=WMaterial.WM_OK,
)
explicit = WMaterial._normalize_inventory_lookup(
material=material,
batch="BATCH-001",
mgroup=None,
belong_dept=None,
state=WMaterial.WM_OK,
defect=None,
notok_sign=None,
material_origin=None,
state_origin=None,
)
self.assertEqual(omitted, explicit)
self.assertEqual(
WMaterial._inventory_advisory_lock_payload(omitted),
WMaterial._inventory_advisory_lock_payload(explicit),
)
def test_scope_resolver_returns_consistent_location_fields(self):
dept = object()
mgroup = SimpleNamespace(belong_dept=dept)
self.assertEqual(
WmScope.resolve_location(WmScope.MGROUP, mgroup),
{"mgroup": mgroup, "belong_dept": dept},
)
self.assertEqual(
WmScope.resolve_location(WmScope.DEPT, mgroup),
{"mgroup": None, "belong_dept": dept},
)
self.assertEqual(
WmScope.resolve_location(WmScope.GLOBAL, mgroup),
{"mgroup": None, "belong_dept": None},
)
def test_only_mgroup_scope_requires_handover_mgroup(self):
self.assertTrue(WmScope.requires_mgroup(WmScope.MGROUP))
self.assertFalse(WmScope.requires_mgroup(WmScope.DEPT))
self.assertFalse(WmScope.requires_mgroup(WmScope.GLOBAL))
def test_global_scope_key_does_not_access_missing_relations(self):
wm = WMaterial()
self.assertEqual(wm.belong_dept_or_mgroup_id, ("global", None))
def test_department_and_mgroup_scope_keys_cannot_collide(self):
dept_wm = WMaterial(belong_dept_id=10)
mgroup_wm = WMaterial(mgroup_id=10)
self.assertNotEqual(
dept_wm.belong_dept_or_mgroup_id,
mgroup_wm.belong_dept_or_mgroup_id,
)
def test_manual_create_allows_global_scope(self):
serializer = WMaterialCreateSerializer()
attrs = {
"material": Material(),
"count": 1,
"batch": "GLOBAL-001",
}
validated = serializer.validate(attrs)
self.assertFalse(serializer.fields["mgroup"].required)
self.assertFalse(serializer.fields["belong_dept"].required)
self.assertNotIn("mgroup", validated)
self.assertNotIn("belong_dept", validated)
def test_global_scope_can_be_filtered_explicitly(self):
self.assertIn(
"isnull",
WMaterialFilter.Meta.fields["belong_dept"],
)
self.assertIn(
"isnull",
WMaterialFilter.Meta.fields["mgroup"],
)
def test_manual_create_derives_department_from_mgroup(self):
dept = SimpleNamespace(id=20)
mgroup = SimpleNamespace(id=10, belong_dept=dept)
attrs = {
"material": Material(),
"count": 1,
"batch": "MGROUP-001",
"mgroup": mgroup,
}
validated = WMaterialCreateSerializer().validate(attrs)
self.assertIs(validated["belong_dept"], dept)
def test_global_inventory_cannot_enter_normal_handover(self):
wm = WMaterial(
material=Material(tracking=Material.MA_TRACKING_BATCH),
batch="GLOBAL-001",
count=1,
)
with self.assertRaisesMessage(ParseError, "全局库存无需正常交接"):
HandoverSerializer().validate({
"wm": wm,
"count": 1,
"type": Handover.H_NORMAL,
"mtype": Handover.H_NORMAL,
})
def test_global_inventory_split_stays_global(self):
wm = WMaterial(
material=Material(tracking=Material.MA_TRACKING_BATCH),
batch="GLOBAL-001",
count=1,
)
validated = HandoverSerializer().validate({
"wm": wm,
"count": 1,
"type": Handover.H_NORMAL,
"mtype": Handover.H_DIV,
})
self.assertIsNone(validated["send_dept"])
self.assertIsNone(validated["recive_dept"])
self.assertIsNone(validated["recive_mgroup"])
def test_global_inventory_cannot_merge_into_scoped_inventory(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
wm = WMaterial(material=material, batch="GLOBAL-001", count=1)
target = WMaterial(
material=material,
batch="GLOBAL-MERGED",
count=0,
belong_dept_id=20,
)
with self.assertRaisesMessage(ParseError, "全局库存合批目标必须是全局库存"):
HandoverSerializer().validate({
"wm": wm,
"count": 1,
"new_wm": target,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
def test_global_inventory_scrap_requires_receiving_mgroup(self):
wm = WMaterial(
material=Material(tracking=Material.MA_TRACKING_BATCH),
batch="GLOBAL-001",
count=1,
)
with self.assertRaisesMessage(
ParseError,
"全局库存返修、报废或改版必须指定接收工段",
):
HandoverSerializer().validate({
"wm": wm,
"count": 1,
"type": Handover.H_SCRAP,
"mtype": Handover.H_NORMAL,
})
def test_handover_keeps_explicit_sending_mgroup(self):
inventory_dept = Dept(id="20", name="库存车间")
sending_dept = Dept(id="30", name="送料车间")
mgroup = Mgroup(id="10", name="测试工段", belong_dept=sending_dept)
wm = WMaterial(
material=Material(tracking=Material.MA_TRACKING_BATCH),
batch="DEPT-001",
count=1,
belong_dept=inventory_dept,
)
validated = HandoverSerializer().validate({
"wm": wm,
"count": 1,
"send_mgroup": mgroup,
"recive_dept": object(),
"type": Handover.H_NORMAL,
"mtype": Handover.H_NORMAL,
})
self.assertIs(validated["send_mgroup"], mgroup)
self.assertIs(validated["send_dept"], sending_dept)
def test_batch_tracking_merge_can_clear_ok_b_batch_defects(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
defect_a = Defect(id="1", okcate=Defect.DEFECT_OK_B)
defect_b = Defect(id="2", okcate=Defect.DEFECT_OK_B)
wm_a = WMaterial(
id="10", material=material, batch="B-001", count=1,
state=WMaterial.WM_OK, defect=defect_a,
)
wm_b = WMaterial(
id="20", material=material, batch="B-002", count=1,
state=WMaterial.WM_OK, defect=defect_b,
)
validated = HandoverSerializer().validate({
"wm": wm_a,
"handoverb": [
{"wm": wm_a, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "B-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
self.assertTrue(validated["clear_defect"])
self.assertEqual(validated["count"], 2)
def test_batch_tracking_merge_can_clear_ok_and_ok_b_defects(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
defect_b = Defect(id="1", okcate=Defect.DEFECT_OK_B)
wm_ok = WMaterial(
id="10", material=material, batch="OK-001", count=1,
state=WMaterial.WM_OK, defect=None,
)
wm_b = WMaterial(
id="20", material=material, batch="B-001", count=1,
state=WMaterial.WM_OK, defect=defect_b,
)
validated = HandoverSerializer().validate({
"wm": wm_ok,
"handoverb": [
{"wm": wm_ok, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "OK-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
self.assertTrue(validated["clear_defect"])
self.assertEqual(validated["count"], 2)
def test_batch_tracking_merge_cannot_clear_same_grade_notok_defects(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
defect_a = Defect(id="1", okcate=Defect.DEFECT_NOTOK)
defect_b = Defect(id="2", okcate=Defect.DEFECT_NOTOK)
wm_a = WMaterial(
id="10", material=material, batch="N-001", count=1,
state=WMaterial.WM_NOTOK, defect=defect_a,
)
wm_b = WMaterial(
id="20", material=material, batch="N-002", count=1,
state=WMaterial.WM_NOTOK, defect=defect_b,
)
with self.assertRaisesMessage(
ParseError,
"批次追踪物料仅合格品和合格B类可清除批次缺陷",
):
HandoverSerializer().validate({
"wm": wm_a,
"handoverb": [
{"wm": wm_a, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "N-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
def test_batch_tracking_merge_cannot_clear_mixed_defect_grades(self):
material = Material(tracking=Material.MA_TRACKING_BATCH)
defect_b = Defect(id="1", okcate=Defect.DEFECT_OK_B)
defect_notok = Defect(id="2", okcate=Defect.DEFECT_NOTOK)
wm_a = WMaterial(
id="10", material=material, batch="B-001", count=1,
state=WMaterial.WM_OK, defect=defect_b,
)
wm_b = WMaterial(
id="20", material=material, batch="N-001", count=1,
state=WMaterial.WM_OK, defect=defect_notok,
)
with self.assertRaisesMessage(
ParseError,
"批次追踪物料仅合格品和合格B类可清除批次缺陷",
):
HandoverSerializer().validate({
"wm": wm_a,
"handoverb": [
{"wm": wm_a, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "MIXED-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
def test_single_tracking_merge_can_clear_notok_batch_defect(self):
material = Material(tracking=Material.MA_TRACKING_SINGLE)
defect = Defect(id="1", okcate=Defect.DEFECT_NOTOK)
wm = WMaterial(
id="10", material=material, batch="S-001", count=1,
state=WMaterial.WM_NOTOK, defect=defect,
)
wm_b = WMaterial(
id="20", material=material, batch="S-002", count=1,
state=WMaterial.WM_NOTOK, defect=defect,
)
validated = HandoverSerializer().validate({
"wm": wm,
"handoverb": [
{"wm": wm, "count": 1},
{"wm": wm_b, "count": 1},
],
"new_batch": "S-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
self.assertTrue(validated["clear_defect"])
self.assertEqual(validated["count"], 2)
class WprDefectLifecycleTests(SimpleTestCase):
@patch("apps.wpmw.models.WprDefect.objects.bulk_create")
@patch("apps.wpmw.models.WprDefect.objects.filter")
@patch(
"apps.wpmw.models.idWorker.get_id",
side_effect=["100", "101"],
)
def test_replace_defects_assigns_unique_ids_before_bulk_create(
self,
get_id,
defect_filter,
bulk_create,
):
wpr = Wpr(id="1")
wpr.replace_defects([
{"defect_id": "10", "is_main": True},
{"defect_id": "11", "is_main": False},
])
defect_filter.return_value.delete.assert_called_once_with()
created = bulk_create.call_args.args[0]
self.assertEqual([str(item.id) for item in created], ["100", "101"])
self.assertEqual(get_id.call_count, 2)
@patch("apps.wpmw.models.WprDefect.objects.filter")
def test_change_inventory_never_changes_individual_defects(self, defect_filter):
material = Material(id="1", tracking=Material.MA_TRACKING_SINGLE)
old_wm = WMaterial(
id="10", material=material, state=WMaterial.WM_OK,
)
target_wm = WMaterial(
id="20", material=material, state=WMaterial.WM_OK,
defect=None,
)
wpr = SimpleNamespace(
wm=old_wm,
mb=None,
material=material,
state=WMaterial.WM_OK,
version=1,
save=MagicMock(),
)
Wpr.change_or_new(
wpr=wpr,
wm=target_wm,
old_wm=old_wm,
)
defect_filter.assert_not_called()
self.assertIs(wpr.wm, target_wm)
@patch.object(Wpr, "replace_defects")
@patch("apps.wpmw.models.FtestDefect.objects.filter")
@patch("apps.wpmw.models.FtestItem.objects.filter")
def test_apply_ftest_replaces_defects_and_carries_info(
self,
ftest_item_filter,
ftest_defect_filter,
replace_defects,
):
ftest = object()
ftest_defect_filter.return_value.values.return_value = [
{"defect_id": "30", "is_main": False},
{"defect_id": "31", "is_main": True},
]
ftest_item_filter.return_value.select_related.return_value = [
SimpleNamespace(
id="40",
test_val_json={"value": 1},
testitem=SimpleNamespace(name="尺寸"),
),
]
wpr = Wpr(
oinfo={"existing": {"value": 2}},
)
wpr.save = MagicMock()
wpr.apply_ftest(ftest)
ftest_defect_filter.assert_called_once_with(ftest=ftest, has=True)
replace_defects.assert_called_once_with([
{"defect_id": "30", "is_main": False},
{"defect_id": "31", "is_main": True},
])
self.assertEqual(
wpr.oinfo["40"],
{"name": "尺寸", "val": {"value": 1}},
)
@patch.object(Wpr, "replace_defects")
def test_apply_no_ftest_clears_defects(self, replace_defects):
wpr = Wpr(oinfo={"existing": True})
wpr.apply_ftest(None)
replace_defects.assert_called_once_with([])
self.assertEqual(wpr.oinfo, {"existing": True})
@patch("apps.wpmw.models.FtestItem.objects.filter")
def test_revert_ftest_info_only_removes_matching_keys(self, item_filter):
item_filter.return_value.values_list.return_value = ["10", "11"]
wpr = Wpr(oinfo={"10": {"v": 1}, "other": {"v": 2}})
wpr.save = MagicMock()
wpr.revert_ftest_info(object())
self.assertEqual(wpr.oinfo, {"other": {"v": 2}})
wpr.save.assert_called_once()
class MlogWprDefectSnapshotTests(SimpleTestCase):
@patch("apps.wpm.services.WprDefect.objects.filter")
@patch("apps.wpm.services.Wpr.objects.select_for_update")
@patch("apps.wpm.services.Mlogbw.objects.filter")
def test_snapshot_keeps_multiple_defects_and_empty_wpr(
self,
mlogbw_filter,
select_for_update,
defect_filter,
):
mlogbw_filter.return_value.values_list.return_value.distinct.return_value = [
"1",
"2",
]
select_for_update.return_value.filter.return_value.values_list.return_value = [
"1",
"2",
]
defect_filter.return_value.values.return_value = [
{"wpr_id": "1", "defect_id": "10", "is_main": True},
{"wpr_id": "1", "defect_id": "11", "is_main": False},
]
mlog = SimpleNamespace(id="100", save=MagicMock())
snapshot = snapshot_mlog_wpr_defects(mlog)
self.assertEqual(snapshot["2"], [])
self.assertEqual(len(snapshot["1"]), 2)
select_for_update.return_value.filter.return_value.values_list.assert_called_once_with(
"id",
flat=True,
)
@patch("apps.wpm.services.WprDefect.objects.bulk_create")
@patch("apps.wpm.services.WprDefect.objects.filter")
@patch("apps.wpm.services.Wpr.objects.select_for_update")
@patch(
"apps.wpm.services.idWorker.get_id",
side_effect=["100", "101"],
)
def test_restore_replaces_current_defects_exactly(
self,
get_id,
select_for_update,
defect_filter,
bulk_create,
):
wpr = Wpr(id="1")
select_for_update.return_value.filter.return_value = [wpr]
mlog = SimpleNamespace(
id="100",
wpr_defect_snapshot={
"1": [
{"defect_id": "10", "is_main": True},
{"defect_id": "11", "is_main": False},
],
},
)
restored = restore_mlog_wpr_defects(mlog)
self.assertTrue(restored)
defect_filter.return_value.delete.assert_called_once_with()
created = bulk_create.call_args.args[0]
self.assertEqual(
[(str(item.defect_id), item.is_main) for item in created],
[("10", True), ("11", False)],
)
self.assertEqual([str(item.id) for item in created], ["100", "101"])
self.assertEqual(get_id.call_count, 2)
@skipUnless(
connection.vendor == "postgresql",
"advisory lock concurrency test requires PostgreSQL",
)
class WMaterialConcurrencyTests(TransactionTestCase):
def test_concurrent_first_create_uses_one_inventory_record(self):
material = Material.objects.create(name="并发库存测试物料")
barrier = Barrier(2)
def create_inventory():
connections.close_all()
try:
barrier.wait()
with transaction.atomic():
wm, created = (
WMaterial.locked_get_or_create_inventory(
material=material,
batch="CONCURRENT-001",
state=WMaterial.WM_OK,
defaults={"count": Decimal("0")},
)
)
wm.count += Decimal("1")
wm.save(update_fields=["count"])
return created
finally:
connections.close_all()
with ThreadPoolExecutor(max_workers=2) as executor:
created_results = list(executor.map(
lambda _: create_inventory(),
range(2),
))
queryset = WMaterial.objects.filter(
material=material,
batch="CONCURRENT-001",
mgroup=None,
belong_dept=None,
state=WMaterial.WM_OK,
defect=None,
notok_sign=None,
material_origin=None,
state_origin=None,
)
self.assertEqual(queryset.count(), 1)
self.assertEqual(queryset.get().count, Decimal("2"))
self.assertCountEqual(created_results, [True, False])

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,86 @@
from django.test import TestCase
from apps.mtm.models import Material
from apps.qm.models import Defect
from apps.wpm.filters import WMaterialFilter
from apps.wpm.models import WMaterial
from apps.wpm.serializers import WMaterialSerializer
class WMaterialDefectGradeTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.material = Material.objects.create(name="缺陷等级测试物料")
cls.defect_b = Defect.objects.create(
name="B类缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_OK_B,
)
cls.defect_notok = Defect.objects.create(
name="不合格缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_NOTOK,
)
cls.repair_without_defect = WMaterial.objects.create(
material=cls.material,
batch="REPAIR-NONE",
count=1,
state=WMaterial.WM_REPAIR,
)
cls.normal_with_notok_defect = WMaterial.objects.create(
material=cls.material,
batch="NORMAL-NOTOK",
count=1,
state=WMaterial.WM_OK,
defect=cls.defect_notok,
)
cls.notok_with_b_defect = WMaterial.objects.create(
material=cls.material,
batch="NOTOK-B",
count=1,
state=WMaterial.WM_NOTOK,
defect=cls.defect_b,
)
cls.notok_with_legacy_sign = WMaterial.objects.create(
material=cls.material,
batch="NOTOK-LEGACY",
count=1,
state=WMaterial.WM_NOTOK,
notok_sign="zw",
)
def test_serializer_uses_effective_grade_independent_of_state(self):
repair_data = WMaterialSerializer(self.repair_without_defect).data
notok_data = WMaterialSerializer(
self.normal_with_notok_defect
).data
b_data = WMaterialSerializer(self.notok_with_b_defect).data
legacy_data = WMaterialSerializer(self.notok_with_legacy_sign).data
self.assertEqual(repair_data["defect_grade"], Defect.DEFECT_OK)
self.assertEqual(repair_data["defect_grade_name"], "合格")
self.assertEqual(notok_data["defect_grade"], Defect.DEFECT_NOTOK)
self.assertEqual(b_data["defect_grade"], Defect.DEFECT_OK_B)
self.assertEqual(legacy_data["defect_grade"], Defect.DEFECT_NOTOK)
def test_effective_grade_filter_matches_serializer_rules(self):
ok_items = WMaterialFilter(
{"defect_grade": Defect.DEFECT_OK},
queryset=WMaterial.objects.all(),
).qs
notok_items = WMaterialFilter(
{"defect_grade": Defect.DEFECT_NOTOK},
queryset=WMaterial.objects.all(),
).qs
self.assertCountEqual(
ok_items.values_list("id", flat=True),
[self.repair_without_defect.id],
)
self.assertCountEqual(
notok_items.values_list("id", flat=True),
[
self.normal_with_notok_defect.id,
self.notok_with_legacy_sign.id,
],
)

View File

@ -0,0 +1,52 @@
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.wpm.services import get_recent_mgroup_equipment_ids
class RecentMgroupEquipmentTests(SimpleTestCase):
@patch("apps.wpm.services.Mlog")
def test_collects_all_equipment_fields_in_log_order_without_duplicates(
self, mlog
):
log_queryset = MagicMock()
mlog.objects.filter.return_value = log_queryset
log_queryset.order_by.return_value.values_list.return_value.__getitem__.return_value = [
("log-new", "equipment-a", None),
("log-middle", "equipment-b", "equipment-a"),
("log-empty", None, None),
]
through_queryset = MagicMock()
mlog.equipments.through.objects.filter.return_value = through_queryset
through_queryset.order_by.return_value.values_list.return_value = [
("log-new", "equipment-c"),
("log-middle", "equipment-c"),
("log-middle", "equipment-d"),
]
result = get_recent_mgroup_equipment_ids("mgroup-1", log_limit=50)
self.assertEqual(
result,
["equipment-a", "equipment-c", "equipment-b", "equipment-d"],
)
log_queryset.order_by.return_value.values_list.return_value.__getitem__.assert_called_once_with(
slice(None, 50, None)
)
@patch("apps.wpm.services.Mlog")
def test_returns_empty_when_recent_logs_have_no_equipment(self, mlog):
log_queryset = MagicMock()
mlog.objects.filter.return_value = log_queryset
log_queryset.order_by.return_value.values_list.return_value.__getitem__.return_value = [
("log-1", None, None),
("log-2", None, None),
]
through_queryset = MagicMock()
mlog.equipments.through.objects.filter.return_value = through_queryset
through_queryset.order_by.return_value.values_list.return_value = []
result = get_recent_mgroup_equipment_ids("mgroup-1")
self.assertEqual(result, [])

View File

@ -0,0 +1,87 @@
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from apps.mtm.models import Material
from apps.qm.models import Defect
from apps.system.models import Dept, User
from apps.wpm.models import Handover, Handoverb, WMaterial
from apps.wpm.serializers import HandoverSerializer
class HandoverSerializerQueryTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.dept = Dept.objects.create(name="合批查询测试车间")
cls.user = User.objects.create_user(username="handover-query-user")
cls.material = Material.objects.create(name="合批查询测试物料")
cls.defect_b = Defect.objects.create(
name="合批查询测试B类缺陷",
cate=Defect.cate_list[0],
okcate=Defect.DEFECT_OK_B,
)
cls.inventories = [
WMaterial.objects.create(
material=cls.material,
batch=f"QUERY-{index}",
count=1,
state=WMaterial.WM_OK,
defect=cls.defect_b if index == 2 else None,
belong_dept=cls.dept,
)
for index in range(3)
]
def test_handover_inventory_is_loaded_in_one_query(self):
serializer = HandoverSerializer(data={
"send_date": "2026-08-07",
"send_user": self.user.id,
"send_dept": self.dept.id,
"recive_dept": self.dept.id,
"handoverb": [
{"wm": inventory.id, "count": 1}
for inventory in self.inventories
],
"new_batch": "QUERY-MERGED",
"clear_defect": True,
"type": Handover.H_NORMAL,
"mtype": Handover.H_MERGE,
})
with CaptureQueriesContext(connection) as queries:
self.assertTrue(serializer.is_valid(), serializer.errors)
inventory_queries = [
query["sql"] for query in queries
if 'FROM "wpm_wmaterial"' in query["sql"]
]
self.assertEqual(len(inventory_queries), 1, inventory_queries)
def test_detail_contains_display_fields_for_handover_and_items(self):
handover = Handover.objects.create(
send_date="2026-08-07",
send_user=self.user,
send_dept=self.dept,
recive_dept=self.dept,
material=self.material,
wm=self.inventories[0],
count=1,
type=Handover.H_NORMAL,
mtype=Handover.H_NORMAL,
)
Handoverb.objects.create(
handover=handover,
wm=self.inventories[0],
batch=self.inventories[0].batch,
count=1,
)
data = HandoverSerializer(handover).data
self.assertEqual(data["send_dept_name"], self.dept.name)
self.assertEqual(data["recive_dept_name"], self.dept.name)
self.assertEqual(data["type_name"], "正常交接")
self.assertEqual(data["mtype_name"], "正常")
self.assertEqual(data["handoverb"][0]["material_name"], str(self.material))
self.assertEqual(data["handoverb"][0]["state_name"], "合格")
self.assertEqual(data["handoverb"][0]["count_available"], 1)

View File

@ -0,0 +1,36 @@
from django.test import SimpleTestCase
from django.urls import reverse
from apps.system.models import File
from apps.wpm.models import Mlogbw
from apps.wpm.serializers import MlogbwFilesSerializer, MlogbwListSerializer
from apps.wpm.views import MlogbwViewSet
class MlogbwFilesDefinitionTests(SimpleTestCase):
def test_files_field_links_system_file(self):
field = Mlogbw._meta.get_field("files")
self.assertTrue(field.blank)
self.assertIs(field.remote_field.model, File)
self.assertEqual(field.remote_field.related_name, "mlogbw_outputs")
def test_list_serializer_exposes_file_ids_and_details(self):
fields = MlogbwListSerializer().fields
self.assertTrue(fields["files"].read_only)
self.assertTrue(fields["files_"].read_only)
def test_files_serializer_only_writes_file_relations(self):
fields = MlogbwFilesSerializer().fields
self.assertEqual(set(fields), {"id", "files", "files_"})
self.assertFalse(fields["files"].read_only)
def test_files_action_supports_read_and_update(self):
self.assertEqual(MlogbwViewSet.files.mapping["get"], "files")
self.assertEqual(MlogbwViewSet.files.mapping["put"], "files")
self.assertEqual(
reverse("mlogbw-files", kwargs={"pk": "single-id"}),
"/api/wpm/mlogbw/single-id/files/",
)

View File

@ -0,0 +1,80 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.wpm.serializers import MlogbwCreateUpdateSerializer
from apps.wpmw.models import Wpr
class MlogbwNumberUpdateTests(SimpleTestCase):
@staticmethod
def _apply_update(instance, validated_data):
instance.number = validated_data["number"]
return instance
@patch("apps.wpm.serializers.CustomModelSerializer.update")
def test_output_number_update_changes_linked_wpr_number(self, base_update):
base_update.side_effect = self._apply_update
wpr = MagicMock()
instance = SimpleNamespace(
number="OLD-001",
mlogb=SimpleNamespace(material_out_id="material-out"),
wpr=wpr,
ftest=None,
)
MlogbwCreateUpdateSerializer.update.__wrapped__(
MlogbwCreateUpdateSerializer(),
instance,
{"mlogb": instance.mlogb, "number": "NEW-001"},
)
wpr.change_number.assert_called_once_with("NEW-001")
@patch("apps.wpm.serializers.CustomModelSerializer.update")
def test_input_number_update_does_not_change_wpr_number(self, base_update):
base_update.side_effect = self._apply_update
wpr = MagicMock()
instance = SimpleNamespace(
number="OLD-001",
mlogb=SimpleNamespace(material_out_id=None),
wpr=wpr,
ftest=None,
)
MlogbwCreateUpdateSerializer.update.__wrapped__(
MlogbwCreateUpdateSerializer(),
instance,
{"mlogb": instance.mlogb, "number": "NEW-001"},
)
wpr.change_number.assert_not_called()
@patch("apps.wpmw.models.MIOItemw.objects.filter")
@patch("apps.wpmw.models.Handoverbw.objects.filter")
@patch("apps.wpmw.models.Mlogbw.objects.filter")
@patch("apps.wpmw.models.Wpr.objects.filter")
def test_wpr_number_change_updates_all_number_copies(
self,
wpr_filter,
mlogbw_filter,
handoverbw_filter,
mioitemw_filter,
):
conflict_qs = MagicMock()
conflict_qs.exists.return_value = False
current_qs = MagicMock()
wpr_filter.side_effect = [conflict_qs, current_qs]
mlogbw_qs = mlogbw_filter.return_value
handoverbw_qs = handoverbw_filter.return_value
mioitemw_qs = mioitemw_filter.return_value
wpr = Wpr(id="wpr-id", number="OLD-001")
wpr.change_number("NEW-001")
current_qs.update.assert_called_once_with(number="NEW-001")
mlogbw_qs.update.assert_called_once_with(number="NEW-001")
handoverbw_qs.update.assert_called_once_with(number="NEW-001")
mioitemw_qs.update.assert_called_once_with(number="NEW-001")
self.assertEqual(wpr.number, "NEW-001")

View File

@ -0,0 +1,193 @@
from datetime import date
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.wpm.views import MlogbInViewSet
class GenNumberWithRuleFilterTests(SimpleTestCase):
def test_date_filters_follow_rule_placeholders(self):
cases = [
("SN-{n_count:04d}", {}),
("{c_year}-{n_count:04d}", {"year": 2026}),
("{c_year2}-{n_count:04d}", {"year": 2026}),
("{c_month:02d}-{n_count:04d}", {"month": 8}),
("{c_day:02d}-{n_count:04d}", {"day": 4}),
(
"{c_year}{c_month:02d}{c_day:02d}-{n_count:04d}",
{"year": 2026, "month": 8, "day": 4},
),
]
material = SimpleNamespace(model=None)
mlog = SimpleNamespace(
handle_date=date(2026, 8, 4),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
for rule, expected_dates in cases:
with self.subTest(rule=rule):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = []
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset) as mock_filter:
MlogbInViewSet.gen_number_with_rule(rule, material, mlog)
filters = mock_filter.call_args.kwargs
date_prefix = "wpr_mlogbw__mlogb__mlog__handle_date__"
actual_dates = {
key.removeprefix(date_prefix): value
for key, value in filters.items()
if key.startswith(date_prefix)
}
self.assertEqual(actual_dates, expected_dates)
def test_escaped_date_placeholder_text_does_not_add_filter(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = []
material = SimpleNamespace(model=None)
mlog = SimpleNamespace(
handle_date=date(2026, 8, 4),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset) as mock_filter:
MlogbInViewSet.gen_number_with_rule("{{c_year}}-{n_count:04d}", material, mlog)
self.assertFalse(
any("handle_date" in key for key in mock_filter.call_args.kwargs)
)
def test_previous_sequence_width_is_compatible(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = [
"2608P0001",
"2608P0003",
"2608P0002",
]
material = SimpleNamespace(model="P")
mlog = SimpleNamespace(
handle_date=date(2026, 8, 6),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset):
number = MlogbInViewSet.gen_number_with_rule(
"{c_year2}{c_month:02d}{m_model}{n_count:05d}",
material,
mlog,
)
self.assertEqual(number, "2608P00004")
def test_mixed_sequence_widths_use_numeric_maximum(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = [
"2608P9999",
"2608P10000",
"历史异常编号",
]
material = SimpleNamespace(model="P")
mlog = SimpleNamespace(
handle_date=date(2026, 8, 6),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset):
number = MlogbInViewSet.gen_number_with_rule(
"{c_year2}{c_month:02d}{m_model}{n_count:05d}",
material,
mlog,
)
self.assertEqual(number, "2608P10001")
def test_unrelated_historical_formats_do_not_participate(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = [
"202505508001",
"3p05013",
"05002",
"3pb003",
]
material = SimpleNamespace(model="P")
mlog = SimpleNamespace(
handle_date=date(2026, 8, 7),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset):
number = MlogbInViewSet.gen_number_with_rule(
"{m_model}{n_count:04d}",
material,
mlog,
)
self.assertEqual(number, "P0001")
def test_current_rule_uses_only_matching_model_numbers(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = [
"P0002",
"P0010",
"B0099",
"3pb100",
]
material = SimpleNamespace(model="P")
mlog = SimpleNamespace(
handle_date=date(2026, 8, 7),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset):
number = MlogbInViewSet.gen_number_with_rule(
"{m_model}{n_count:04d}",
material,
mlog,
)
self.assertEqual(number, "P0011")
def test_empty_historical_number_is_ignored(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = [
None,
"",
"P0002",
]
material = SimpleNamespace(model="P")
mlog = SimpleNamespace(
handle_date=date(2026, 8, 7),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset):
number = MlogbInViewSet.gen_number_with_rule(
"{m_model}{n_count:04d}",
material,
mlog,
)
self.assertEqual(number, "P0003")
def test_repeated_sequence_placeholder_does_not_break_matching(self):
queryset = MagicMock()
queryset.values_list.return_value.distinct.return_value.iterator.return_value = [
"P02-0002",
"P03-0004",
]
material = SimpleNamespace(model="P")
mlog = SimpleNamespace(
handle_date=date(2026, 8, 7),
mgroup=SimpleNamespace(process=SimpleNamespace(id=123)),
)
with patch("apps.wpmw.models.Wpr.objects.filter", return_value=queryset):
number = MlogbInViewSet.gen_number_with_rule(
"{m_model}{n_count:02d}-{n_count:04d}",
material,
mlog,
)
self.assertEqual(number, "P03-0003")

View File

@ -1,11 +1,13 @@
import math
import re
from string import Formatter
from django.db import transaction
from rest_framework.decorators import action
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from django.db.models import Sum
from django.db.models import Case, IntegerField, Sum, When
from django.utils import timezone
from apps.system.models import User
@ -45,19 +47,30 @@ from .serializers import (
BatchStSerializer,
MlogbwCreateUpdateSerializer,
MlogbwListSerializer,
MlogbwFilesSerializer,
HandoverMgroupSerializer,
MlogListSerializer,
MlogbSerializer,
MlogUserSerializer,
BatchLogSerializer,
MlogQuickSerializer,
MlogEquipmentOptionSerializer,
MlogbwStartTestSerializer,
HandoverListSerializer,
BatchChangeSerializer,
MlogbOutPatchUpdateSerializer
)
from .services import mlog_submit, handover_submit, mlog_revert, get_batch_dag, handover_revert
from apps.wpm.services import mlog_submit_validate, generate_new_batch
from .services import (
RECENT_EQUIPMENT_LOG_LIMIT,
generate_new_batch,
get_batch_dag,
get_recent_mgroup_equipment_ids,
handover_revert,
handover_submit,
mlog_revert,
mlog_submit,
mlog_submit_validate,
)
from apps.wf.models import State, Ticket
from apps.wpmw.models import Wpr
from apps.qm.models import Qct, Ftest, TestItem
@ -71,7 +84,6 @@ from django.db.models import Prefetch
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.db import connection
from django.db.models.functions import Substr, Length
from apps.qm.models import FtestDefect, FtestItem
# Create your views here.
@ -330,6 +342,92 @@ class MlogViewSet(CustomModelViewSet):
]
ordering_fields = ["create_time", "update_time"]
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
name="mgroup",
in_=openapi.IN_QUERY,
description="日志所属工段",
type=openapi.TYPE_STRING,
required=True,
),
openapi.Parameter(
name="search",
in_=openapi.IN_QUERY,
description="按设备名称或编号搜索全部生产设备",
type=openapi.TYPE_STRING,
required=False,
),
]
)
@action(
methods=["get"],
detail=False,
perms_map={"get": "*"},
serializer_class=MlogEquipmentOptionSerializer,
)
def equipment_options(self, request, *args, **kwargs):
"""返回本工段设备、最近 50 条日志用过的设备或搜索结果。"""
mgroup_id = request.query_params.get("mgroup")
if not mgroup_id:
raise ParseError("请传入mgroup参数")
search = request.query_params.get("search", "").strip()
owned_ids = list(
Equipment.objects.filter(
type=Equipment.EQUIP_TYPE_PRO,
mgroup_id=mgroup_id,
)
.order_by("name", "number")
.values_list("id", flat=True)
)
owned_id_set = set(owned_ids)
if search:
queryset = (
Equipment.objects.filter(type=Equipment.EQUIP_TYPE_PRO)
.filter(Q(name__icontains=search) | Q(number__icontains=search))
.order_by("name", "number")
)
option_group = "搜索结果"
else:
recent_ids = get_recent_mgroup_equipment_ids(
mgroup_id, RECENT_EQUIPMENT_LOG_LIMIT
)
option_ids = list(dict.fromkeys([*owned_ids, *recent_ids]))
if option_ids:
order = Case(
*[
When(id=equipment_id, then=position)
for position, equipment_id in enumerate(option_ids)
],
output_field=IntegerField(),
)
queryset = Equipment.objects.filter(
id__in=option_ids,
type=Equipment.EQUIP_TYPE_PRO,
).order_by(order)
else:
queryset = Equipment.objects.none()
option_group = None
queryset = queryset.select_related("mgroup")
page = self.paginate_queryset(queryset)
equipment_list = page if page is not None else queryset
data = MlogEquipmentOptionSerializer(
equipment_list,
many=True,
context=self.get_serializer_context(),
).data
for item in data:
item["option_group"] = option_group or (
"本工段设备" if item["id"] in owned_id_set else "近期使用"
)
if page is not None:
return self.get_paginated_response(data)
return Response(data)
def add_info_for_item(self, data):
if data.get("oinfo_json", {}):
czx_dict = dict(TestItem.objects.filter(id__in=data.get("oinfo_json", {}).keys()).values_list("id", "name"))
@ -351,6 +449,7 @@ class MlogViewSet(CustomModelViewSet):
return super().get_serializer_class()
@swagger_auto_schema(
operation_summary="查询生产日志明细(逐条追溯)",
manual_parameters=[
openapi.Parameter(name="query", in_=openapi.IN_QUERY, description="定制返回数据", type=openapi.TYPE_STRING, required=False),
openapi.Parameter(name="with_children", in_=openapi.IN_QUERY, description="带有children(yes/no/count)", type=openapi.TYPE_STRING, required=False),
@ -559,6 +658,8 @@ class MlogViewSet(CustomModelViewSet):
mloginit_data["equipment"] = vdata["equipment"]
if "team" in vdata:
mloginit_data["team"] = vdata["team"]
if "supplier" in vdata:
mloginit_data["supplier"] = vdata["supplier"]
sr_1 = MlogInitSerializer(data=mloginit_data, request=request)
sr_1.is_valid(raise_exception=True)
mlog: Mlog = sr_1.save()
@ -589,7 +690,20 @@ class HandoverViewSet(CustomModelViewSet):
select_related_fields = ["send_user", "send_mgroup", "send_dept", "recive_user", "recive_mgroup", "recive_dept", "wm", "material_changed", "material", "material__process"]
filterset_class = HandoverFilter
search_fields = ["material__name", "material__number", "material__specification", "batch", "material__model", "b_handover__batch", "new_batch", "wm__batch"]
prefetch_related_fields = [Prefetch("b_handover", queryset=Handoverb.objects.select_related("wm__defect")), "ticket__state"]
prefetch_related_fields = ["ticket__state"]
def get_queryset_custom(self, queryset):
if self.action not in ["list", "retrieve"]:
return queryset
detail_queryset = Handoverb.objects.select_related(
"handover", "wm__defect", "wm__material", "wm__mgroup", "wm__belong_dept"
)
if self.action == "retrieve":
detail_queryset = detail_queryset.prefetch_related("w_handoverb")
return queryset.prefetch_related(
Prefetch("b_handover", queryset=detail_queryset)
)
def perform_destroy(self, instance: Handover):
user = self.request.user
@ -677,7 +791,7 @@ class HandoverViewSet(CustomModelViewSet):
m_qs = m_qs.filter(process__route_p__material_in__id=materialInId) | m_qs.filter(process__route_p__routemat_route__material__id=materialInId)
elif type in [Handover.H_SCRAP]:
m_qs = m_qs.filter(process=None)
return Response(list(m_qs.values("id", "name").distinct()))
return Response(list(m_qs.values("id", "name", "belong_dept").distinct()))
@action(methods=["post"], detail=False, perms_map={"post": "handover.create"}, serializer_class=GenHandoverWmSerializer)
@transaction.atomic
@ -1008,44 +1122,97 @@ class MlogbInViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, BulkDestroyMode
def gen_number_with_rule(cls, rule, material_out: Material, mlog: Mlog, gen_count=1):
from apps.wpmw.models import Wpr
formatter = Formatter()
rule_parts = list(formatter.parse(rule))
rule_fields = {
field_name
for _, field_name, _, _ in rule_parts
if field_name
}
handle_date = mlog.handle_date
c_year = handle_date.year
c_year2 = str(c_year)[-2:]
c_month = handle_date.month
c_day = handle_date.day
m_model = material_out.model
if 'm_model' in rule:
if "m_model" in rule_fields:
if m_model is None:
raise ParseError("生成编号出错:产品型号不能为空")
elif m_model and m_model.islower():
raise ParseError("生成编号出错:产品型号不能为小写")
# 按生产日志查询
wpr = (
Wpr.objects.filter(
wpr_mlogbw__mlogb__material_out__isnull=False,
wpr_mlogbw__mlogb__mlog__mgroup__process=mlog.mgroup.process,
wpr_mlogbw__mlogb__mlog__is_fix=False,
wpr_mlogbw__mlogb__mlog__submit_time__isnull=False,
wpr_mlogbw__mlogb__mlog__handle_date__year=c_year,
wpr_mlogbw__mlogb__mlog__handle_date__month=c_month,
)
.annotate(last_four=Substr("number", Length("number")-3))
.order_by("last_four")
.last()
)
cq_w = 4
if "n_count:02d" in rule:
cq_w = 2
n_count = 0
if wpr:
# 流水号宽度跟随规则中n_count的0填充宽度, 未指定默认4位
w_match = re.search(r"\{n_count:0(\d+)d\}", rule)
cq_w = int(w_match.group(1)) if w_match else 4
process = mlog.mgroup.process
# 同一工序的编号生成串行化, 防止并发取到相同的最大号; 事务结束自动释放
if connection.vendor == "postgresql" and connection.in_atomic_block:
with connection.cursor() as cursor:
cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", [f"wpr_number_rule:{process.id}"])
# 只按规则中实际使用的日期占位符筛选历史编号
wpr_filter = {
"wpr_mlogbw__mlogb__material_out__isnull": False,
"wpr_mlogbw__mlogb__mlog__mgroup__process": process,
"wpr_mlogbw__mlogb__mlog__is_fix": False,
"wpr_mlogbw__mlogb__mlog__submit_time__isnull": False,
}
if rule_fields & {"c_year", "c_year2"}:
wpr_filter["wpr_mlogbw__mlogb__mlog__handle_date__year"] = c_year
if "c_month" in rule_fields:
wpr_filter["wpr_mlogbw__mlogb__mlog__handle_date__month"] = c_month
if "c_day" in rule_fields:
wpr_filter["wpr_mlogbw__mlogb__mlog__handle_date__day"] = c_day
rule_values = {
"c_year": c_year,
"c_year2": c_year2,
"c_month": c_month,
"c_day": c_day,
"m_model": m_model,
}
number_pattern_parts = ["^"]
sequence_group_names = []
for literal_text, field_name, format_spec, conversion in rule_parts:
number_pattern_parts.append(re.escape(literal_text))
if not field_name:
continue
if field_name == "n_count":
# 流水号宽度可以变化,规则中的其他部分必须与当前上下文一致。
group_name = f"n_count_{len(sequence_group_names)}"
sequence_group_names.append(group_name)
number_pattern_parts.append(fr"(?P<{group_name}>[0-9]+)")
continue
try:
n_count = int(wpr.number[-cq_w:].lstrip("0"))
except Exception as e:
raise ParseError(f"获取该类产品最后编号错误: {str(e)}")
field_value = rule_values[field_name]
if conversion:
field_value = formatter.convert_field(field_value, conversion)
formatted_value = formatter.format_field(field_value, format_spec)
except (KeyError, TypeError, ValueError) as e:
raise ParseError(f"个号生成错误: {e}")
number_pattern_parts.append(re.escape(formatted_value))
number_pattern_parts.append("$")
number_pattern = re.compile("".join(number_pattern_parts))
n_count = 0
# 只从符合当前规则固定部分的历史编号中提取流水号。例如当前规则为
# P{n_count:04d}时3pb003等同工序的旧格式编号不能参与续号同时
# 流水号使用数字匹配以兼容04d调整为05d后的历史编号。
numbers = Wpr.objects.filter(**wpr_filter).values_list("number", flat=True).distinct()
for number in numbers.iterator():
if not isinstance(number, str):
continue
sequence_match = number_pattern.fullmatch(number)
if sequence_match and sequence_group_names:
sequence_values = {
int(sequence_match.group(group_name))
for group_name in sequence_group_names
}
if len(sequence_values) == 1:
n_count = max(n_count, sequence_values.pop())
if n_count + gen_count > 10 ** cq_w - 1:
raise ParseError(f"流水号超出{cq_w}位上限, 请调整编号规则")
try:
if gen_count == 1:
return rule.format(c_year=c_year, c_month=c_month, m_model=m_model, n_count=n_count + 1, c_year2=c_year2)
return rule.format(c_year=c_year, c_month=c_month, c_day=c_day, m_model=m_model, n_count=n_count + 1, c_year2=c_year2)
else:
return [rule.format(c_year=c_year, c_month=c_month, m_model=m_model, n_count=n_count + i + 1, c_year2=c_year2) for i in range(gen_count)]
return [rule.format(c_year=c_year, c_month=c_month, c_day=c_day, m_model=m_model, n_count=n_count + i + 1, c_year2=c_year2) for i in range(gen_count)]
except Exception as e:
raise ParseError(f"个号生成错误: {e}")
@ -1135,6 +1302,7 @@ class MlogbwViewSet(CustomModelViewSet):
filterset_class = MlogbwFilter
select_related_fields = ["ftest", "equip", "tooling", "wpr", "mlogb"]
prefetch_related_fields = [
"files",
Prefetch(
"ftest__items_ftest",
queryset=FtestItem.objects.select_related(
@ -1154,6 +1322,21 @@ class MlogbwViewSet(CustomModelViewSet):
ordering = ["mlogb", "number", "create_time"]
ordering_fields = ["number", "create_time", "mlogb"]
@action(detail=True, methods=["get", "put"], url_path="files")
@transaction.atomic
def files(self, request, *args, **kwargs):
instance = self.get_object()
if instance.mlogb.material_out_id is None:
raise ParseError("只能给输出单件上传附件")
if request.method == "PUT":
MlogViewSet.lock_and_check_can_update(instance.mlogb.mlog)
serializer = MlogbwFilesSerializer(instance, data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
else:
serializer = MlogbwFilesSerializer(instance)
return Response(serializer.data)
def perform_create(self, serializer):
ins: Mlogbw = serializer.save()
mlog: Mlog = None
@ -1187,12 +1370,14 @@ class MlogbwViewSet(CustomModelViewSet):
material_out = material_in
else:
raise ParseError("获取产出物料错误")
mtype = route.process.mtype if route.process else None
process = route.process if route else None
# 返修不依赖工艺路线,按个到个的普通流转同步产出。
mtype = Process.PRO_NORMAL if mlog.is_fix else process.mtype if process else None
if mlogb_qs.exists() and material_out.tracking == Material.MA_TRACKING_SINGLE:
for mlogb in mlogb_qs:
if route.process.mtype == Process.PRO_NORMAL:
if mtype == Process.PRO_NORMAL:
Mlogbw.objects.get_or_create(mlogb=mlogb, wpr=ins.wpr, defaults={"number": ins.number, "mlogbw_from": ins})
elif route.process.mtype == Process.PRO_DIV:
elif mtype == Process.PRO_DIV:
if route.div_number == 1:
Mlogbw.objects.get_or_create(wpr=ins.wpr, mlogb=mlogb, defaults={"number": ins.wpr.number, "mlogbw_from": ins})
else:

View File

@ -5,6 +5,7 @@ from apps.utils.models import BaseModel
from apps.mtm.models import Material
from rest_framework.exceptions import ParseError
from apps.wpm.models import WmStateOption, Mlogbw, Handoverbw
from apps.utils.snowflake import idWorker
from apps.utils.tools import update_dict
from apps.inm.models import MIOItemw
from django.db.models import F, Value
@ -32,8 +33,24 @@ class Wpr(BaseModel):
data = models.JSONField(verbose_name="数据", default=dict, blank=True)
pre_info = models.JSONField(verbose_name="预处理信息", default=dict, blank=True, null=True)
def change_number(self, new_number):
"""修改产品编号,并同步所有保存了编号副本的关联明细。"""
if self.number == new_number:
return
if Wpr.objects.filter(number=new_number).exists():
raise ParseError("新编号已存在,不可使用")
Wpr.objects.filter(id=self.id).update(number=new_number)
Mlogbw.objects.filter(wpr=self).update(number=new_number)
Handoverbw.objects.filter(wpr=self).update(number=new_number)
MIOItemw.objects.filter(wpr=self).update(number=new_number)
self.number = new_number
@classmethod
def change_or_new(cls, wpr=None, number=None, mb=None, wm=None, old_mb=None, old_wm=None, ftest=None, wpr_from=None, add_version=True, number_out=None):
def change_or_new(
cls, wpr=None, number=None, mb=None, wm=None, old_mb=None,
old_wm=None, wpr_from=None, add_version=True, number_out=None,
):
is_create = False
if wpr is None and number is None:
raise ParseError("id和number不能同时为空")
@ -109,25 +126,9 @@ class Wpr(BaseModel):
ins.state = wm.state
if is_create:
ins.material_start = wm.material
WprDefect.objects.filter(wpr=ins).delete()
if add_version:
ins.version = ins.version + 1
ins.save()
if wm:
if wm.defect:
WprDefect.objects.create(wpr=ins, defect=wm.defect, is_main=True)
if ftest:
# 通过检验变更其缺陷项
WprDefect.objects.filter(wpr=ins).delete()
for ftestdefect in FtestDefect.objects.filter(ftest=ftest, has=True):
WprDefect.objects.create(wpr=ins, defect=ftestdefect.defect, is_main=ftestdefect.is_main)
# 携带某些检测信息
oinfo = {}
for ftestitem in FtestItem.objects.filter(ftest=ftest, addto_wpr=True):
if ftestitem.test_val_json:
oinfo[ftestitem.id] = {"name": ftestitem.testitem.name, "val": ftestitem.test_val_json}
ins.oinfo = update_dict(ins.oinfo, oinfo)
ins.save()
# if ins.mb and ins.wm:
# raise ParseError("所属仓库批次和车间批次不可同时存在2")
if number_out:
@ -138,6 +139,63 @@ class Wpr(BaseModel):
raise ParseError(f"{ins.number}-出库编号-{number_out}-已存在")
return ins
def replace_defects(self, defects):
"""用给定缺陷列表完整替换单件缺陷。"""
WprDefect.objects.filter(wpr=self).delete()
WprDefect.objects.bulk_create([
WprDefect(
id=idWorker.get_id(),
wpr=self,
defect_id=item["defect_id"],
is_main=item.get("is_main", False),
)
for item in defects
])
def apply_ftest(self, ftest):
"""应用一次明确的单件检验;无检验结果时缺陷为空。"""
defects = []
if ftest is not None:
defects = list(FtestDefect.objects.filter(
ftest=ftest,
has=True,
).values("defect_id", "is_main"))
self.replace_defects(defects)
if ftest is None:
return
oinfo = {}
for ftestitem in FtestItem.objects.filter(
ftest=ftest,
addto_wpr=True,
).select_related("testitem"):
if ftestitem.test_val_json:
oinfo[str(ftestitem.id)] = {
"name": ftestitem.testitem.name,
"val": ftestitem.test_val_json,
}
if oinfo:
self.oinfo = update_dict(self.oinfo, oinfo)
self.save(update_fields=["oinfo", "update_time"])
def revert_ftest_info(self, ftest):
"""仅撤销指定检验写入单件携带信息的字段。"""
if ftest is None or not self.oinfo:
return
item_ids = FtestItem.objects.filter(
ftest=ftest,
addto_wpr=True,
).values_list("id", flat=True)
changed = False
oinfo = dict(self.oinfo)
for item_id in item_ids:
if oinfo.pop(str(item_id), None) is not None:
changed = True
if changed:
self.oinfo = oinfo
self.save(update_fields=["oinfo", "update_time"])
@classmethod
def clear(cls, number_list):
cls.objects.filter(number__in=number_list).update(mb=None, wm=None)

View File

@ -63,15 +63,8 @@ class WprViewSet(BulkUpdateModelMixin, CustomListModelMixin, CustomRetrieveModel
vdata = sr.validated_data
new_number = vdata["new_number"]
old_number = vdata["old_number"]
if Wpr.objects.filter(number=new_number).exists():
raise ParseError("新编号已存在,不可使用")
wpr = Wpr.objects.get(number=old_number)
from apps.wpm.models import Mlogbw, Handoverbw
from apps.inm.models import MIOItemw
Wpr.objects.filter(id=wpr.id).update(number=new_number)
Mlogbw.objects.filter(wpr=wpr).update(number=new_number)
Handoverbw.objects.filter(wpr=wpr).update(number=new_number)
MIOItemw.objects.filter(wpr=wpr).update(number=new_number)
wpr.change_number(new_number)
return Response()
@action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=WprNewSerializer)

View File

@ -1,3 +1,25 @@
## 3.1.2026072713
- fix: 问题修复
- unify workflow action permissions [caoqianming]
- other: 其他变更
- docs: record Codex context migration plan [caoqianming]
- docs: migrate project memory for Codex [caoqianming]
- docs: add factory release skill [caoqianming]
- docs: add Codex project instructions [caoqianming]
- docs: design Claude to Codex context migration [caoqianming]
## 3.1.2026072316
- feat: 新增功能
- 单个编号规则支持c_day按天占位与按天归零;并发生成加工序级advisory锁防重号;流水号宽度按规则解析并对齐排序截取;超上限报错;工序保存时校验编号规则 [caoqianming]
## 3.1.2026072309
- feat: 新增功能
- 工艺路线Route创建/更新时未指定输出物料且工艺包无产品直接报错,不再静默生成残缺工序;补material_in_type字段供画布按物料真实类型着色 [caoqianming]
- 工艺路线新增validate接口,供拖拽画布实时校验DAG结构(不存盘,返回valid/error) [caoqianming]
- 物料新增入库检验方式test_mode_in(免检/必检,默认必检),生产领料检验卡点支持物料级免检,明细过滤器支持material__test_mode_in [caoqianming]
- other: 其他变更
- docs:新增优化算法切入点分析(排产/补货/合批/装箱等可优化环节盘点) [caoqianming]
## 3.1.2026071614
- feat: 新增功能

49
docs/mcp.md Normal file
View File

@ -0,0 +1,49 @@
# Factory MCP 服务
Factory MCP 是仓库顶层的独立服务,使用官方 Python SDK v2通过 Streamable HTTP 暴露 Agent 工具。当前已提供基础工具和第一批 Dataset 领域工具。
## 启动
在项目根目录使用项目虚拟环境启动独立进程:
```powershell
.venv\Scripts\python.exe -m mcp_server
```
默认监听 `127.0.0.1:2260`MCP 端点为 `/mcp`。客户端必须在每次请求中携带 Factory access token
```text
Authorization: Bearer <Factory access token>
```
## 配置
生产环境在本机已忽略的 `config/conf.py` 中覆盖以下配置:
- `MCP_HOST`:监听地址。
- `MCP_PORT`:监听端口。
- `MCP_PATH`Streamable HTTP 路径。
- `MCP_ALLOWED_HOSTS`:允许的 HTTP Host支持 `hostname:*` 端口通配形式。
- `MCP_ALLOWED_ORIGINS`:允许的浏览器 Origin非浏览器客户端通常不发送 Origin。
- `MCP_MAX_REQUEST_BODY_SIZE`:单个 MCP 请求体上限,默认 1 MiB。
- `MCP_MAX_RESULT_BYTES`:单次领域工具结果上限,默认 512 KiB。
生产部署必须明确配置实际域名的 Host 白名单,不应直接复用 Django 当前的宽泛 `ALLOWED_HOSTS`
## 基础工具
- `factory_server_info`返回系统版本、MCP 协议版本和认证方式。
- `factory_whoami`:返回当前 JWT 对应的 Factory 用户。
- `search_datasets`按名称、code 或描述搜索启用的数据集,不返回 SQL 配置。
- `execute_dataset`:按 code 执行数据集,需要当前用户具有 `dataset.exec` 权限。
- `search_wprs`:按编号、物料、批次、状态和当前位置搜索 WPR只返回摘要。
- `get_wpr`:按 ID、内部编号或对外编号读取 WPR 详情、缺陷和业务数据。
WPR 当前沿用既有 API 的读取边界:有效登录用户可读,且该 ViewSet 未启用部门数据过滤。MCP 不开放修改编号、分配对外编号或更新预处理信息等写操作。
- `search_batch_stats`:按批次、直通大批、起始物料和版本搜索批次统计摘要。
- `get_batch_stat`:读取指定批次版本的完整统计数据,可附带直接拆批/合批关系。
BatchSt 同样沿用既有 API 的 `get: *` 读取边界,不提供创建、重算或修改工具。完整统计结果仍受 `MCP_MAX_RESULT_BYTES` 限制。
新增领域工具时必须从 MCP 请求身份获取用户,并复用 Factory 的权限码和数据范围过滤;不得直接使用固定管理员身份查询 ORM。

View File

@ -0,0 +1,217 @@
# Claude to Codex Project Context Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a project-level Codex entry point, release skill, and sanitized memory set while leaving `.claude` unchanged.
**Architecture:** `AGENTS.md` contains short, always-on rules. `.agents/skills/release/SKILL.md` contains the opt-in backend release workflow. `.codex/memory/` contains detailed project knowledge linked from a single index so Codex can load it only when relevant.
**Tech Stack:** Markdown, Codex `AGENTS.md`, Codex project skills
---
### Task 1: Add the always-on Codex project instructions
**Files:**
- Create: `AGENTS.md`
- [x] **Step 1: Create the instruction entry point**
Add concise sections covering:
- Repository shape: Django backend here and Vue frontend at `../ehs_web`.
- Frontend route/menu behavior: routes are delivered by the backend; do not add new views to `src/config/route.js`.
- Python execution: use `D:\projects\factory\.venv\Scripts\python.exe`.
- Ignored scripts: never force-add `scripts/*.py` without explicit instruction.
- Release policy: never bump or release unless explicitly requested; then use `.agents/skills/release/SKILL.md`.
- Production data policy: read-only queries only when relevant, credentials from ignored local configuration, and no secrets in docs or commands.
- Pointers to `.codex/memory/MEMORY.md` for detailed context.
- [x] **Step 2: Verify the entry point**
Run:
```powershell
rg -n "ehs_web|route.js|.venv|scripts/\\*\\.py|release|只读|memory" AGENTS.md
```
Expected: every rule category appears at least once.
- [x] **Step 3: Commit**
```powershell
git add -- AGENTS.md
git commit -m "docs: add Codex project instructions"
```
### Task 2: Add the opt-in backend release skill
**Files:**
- Create: `.agents/skills/release/SKILL.md`
- [x] **Step 1: Create the skill**
Add valid skill frontmatter:
```yaml
---
name: factory-release
description: Use only when the user explicitly asks to release the factory backend, bump its version, or run the release workflow.
---
```
The body must require this exact order:
1. Confirm the user explicitly requested a backend release.
2. Check the working tree and prevent unrelated files from entering the release commit.
3. Run `bash update_changelog.sh` and use its emitted version.
4. Update `server/settings.py` `SYS_VERSION`.
5. Inspect the top of `changelog.md`.
6. Stage only `changelog.md` and `server/settings.py`.
7. Commit as `release: <VER>` without Claude-specific attribution.
8. Create tag `<VER>`, push the branch, then push the tag.
9. Report version, changelog categories, commit hash, tag, and push results.
The skill must state that frontend `ehs_web` releases use a separate workflow documented in `.codex/memory/reference_ehs_web_release.md`.
- [x] **Step 2: Verify the skill**
Run:
```powershell
rg -n "explicitly|update_changelog|SYS_VERSION|changelog.md|git tag|git push|ehs_web|Co-Authored-By" .agents/skills/release/SKILL.md
```
Expected: all required release steps appear and `Co-Authored-By` does not appear.
- [x] **Step 3: Commit**
```powershell
git add -- .agents/skills/release/SKILL.md
git commit -m "docs: add factory release skill"
```
### Task 3: Add the sanitized Codex project memory
**Files:**
- Create: `.codex/memory/MEMORY.md`
- Create: `.codex/memory/feedback_routing.md`
- Create: `.codex/memory/feedback_scripts_gitignore.md`
- Create: `.codex/memory/feedback_version_bump.md`
- Create: `.codex/memory/project_frontend_ehs_web.md`
- Create: `.codex/memory/project_gx_db_access.md`
- Create: `.codex/memory/project_material_ofrom_merge_bug.md`
- Create: `.codex/memory/reference_ehs_web_release.md`
- Create: `.codex/memory/reference_python_venv.md`
- Create: `.codex/memory/reference_wrapper_apps.md`
- [x] **Step 1: Create the memory index**
Create one relative Markdown link for each of the nine detailed memory files. Each link must summarize when the file is relevant.
- [x] **Step 2: Migrate stable feedback and references**
Preserve the operational meaning of:
- Backend-delivered frontend routes.
- The intentional `scripts/*.py` ignore rule.
- Explicit-only release/version bumps.
- The project virtual environment.
- The independent frontend release workflow.
- The two wrapper applications.
Remove Claude session IDs, Claude metadata, wiki-link syntax, and Claude-specific tool names.
- [x] **Step 3: Migrate project background and historical diagnosis**
Preserve:
- Backend/frontend coupling and statistics page conventions.
- Read-only production-data verification policy.
- The `material_ofrom` historical diagnosis, affected code locations, known evidence, and next investigation point.
Replace all database credentials with instructions to read connection parameters from ignored local configuration. Do not include usernames, passwords, `PGPASSWORD`, or ready-to-run production connection commands.
- [x] **Step 4: Verify index targets**
Run:
```powershell
$missing = @()
Get-Content .codex/memory/MEMORY.md |
Select-String -AllMatches '\]\(([^)]+\.md)\)' |
ForEach-Object { $_.Matches } |
ForEach-Object {
$target = Join-Path '.codex/memory' $_.Groups[1].Value
if (-not (Test-Path -LiteralPath $target)) { $missing += $target }
}
if ($missing.Count -gt 0) { $missing; exit 1 }
"All memory links resolve"
```
Expected: `All memory links resolve`.
- [x] **Step 5: Commit**
```powershell
git add -- .codex/memory
git commit -m "docs: migrate project memory for Codex"
```
### Task 4: Validate migration safety and coverage
**Files:**
- Inspect: `AGENTS.md`
- Inspect: `.agents/skills/release/SKILL.md`
- Inspect: `.codex/memory/*.md`
- Inspect unchanged: `.claude/settings.local.json`
- Inspect unchanged: `.claude/commands/release.md`
- [x] **Step 1: Scan for prohibited Claude artifacts and secrets**
Run:
```powershell
rg -n -i "originSessionId|scratchpad|PGPASSWORD|Co-Authored-By: Claude|password\s*=|postgresql://" AGENTS.md .agents/skills .codex/memory
```
Expected: no matches.
- [x] **Step 2: Check topic coverage**
Run:
```powershell
Get-ChildItem .codex/memory -File | Sort-Object Name | Select-Object -ExpandProperty Name
```
Expected: `MEMORY.md` plus all nine detailed memory files are listed.
- [x] **Step 3: Confirm Claude files are untouched**
Run:
```powershell
git status --short -- .claude
git diff -- .claude
```
Expected: no output.
- [x] **Step 4: Check formatting and scoped changes**
Run:
```powershell
git diff --check
git status --short
```
Expected: no whitespace errors; only pre-existing user files and this migration's plan file remain uncommitted.
- [x] **Step 5: Commit the implementation plan**
```powershell
git add -- docs/superpowers/plans/2026-07-27-claude-to-codex-project-context.md
git commit -m "docs: record Codex context migration plan"
```

View File

@ -0,0 +1,101 @@
# Claude 项目上下文迁移到 Codex设计说明
## 目标
在不删除或修改现有 `.claude` 配置的前提下,把 `factory` 项目中仍有效的 Claude 项目知识迁移成 Codex 可稳定使用的项目级上下文。
迁移后的配置应做到:
- Codex 每次进入仓库时都能读取关键项目约束。
- 较长的背景资料和历史排障记录不会挤占常规任务上下文。
- 发版流程只在用户明确要求时启用。
- 不复制数据库口令等敏感信息。
- Claude 与 Codex 可以继续并行使用。
## 现状
项目内现有 Claude 配置:
- `.claude/settings.local.json`Claude 工具授权历史,包含大量机器相关命令及明文数据库连接信息。
- `.claude/commands/release.md`:后端发版命令。
- Claude 用户目录中的项目 memory包含前后端关系、动态路由、Python 虚拟环境、生产库只读查询约束、脚本忽略规则、发版约束、套壳 App 背景及一次历史缺陷排查。
项目当前没有根目录 `CLAUDE.md`、`AGENTS.md` 或项目级 Codex memory。
## 采用方案
采用分层兼容结构:
1. 根目录 `AGENTS.md`
- 放置每次工作都应遵守的稳定规则。
- 内容保持简短,避免把一次性历史排障细节注入所有任务。
- 指向更详细的 memory 和按需 skill。
2. `.agents/skills/release/SKILL.md`
- 把 `.claude/commands/release.md` 转换为 Codex 项目 skill。
- 仅在用户明确要求“发版”“release”或“bump 版本”时使用。
- 保留版本生成、更新 `SYS_VERSION`、检查 changelog、提交、打 tag 和推送的顺序。
- 去掉 Claude 专属的 `Co-Authored-By` 署名。
- 保留远端写操作前的工作区检查和失败处理约束。
3. `.codex/memory/`
- `MEMORY.md` 作为主题索引。
- 按主题保存项目背景、用户反馈和历史排障资料。
- 去除 Claude 的 session ID、Claude 专属元数据和 wiki 链接语法。
- 使用普通 Markdown 相对链接,便于人工和 Codex 按需读取。
4. `.claude/`
- 原样保留,不删除、不重写。
## 内容映射
必须进入 `AGENTS.md` 的规则:
- 配套前端位于 `../ehs_web`;后端 API 变化时检查对应前端调用。
- `ehs_web` 菜单和路由由后端动态下发,新页面不要修改 `src/config/route.js`
- Django/Python 命令使用项目根目录 `.venv/Scripts/python.exe`
- `scripts/*.py` 是有意忽略的一次性脚本,未经明确要求不得 `git add -f`
- 不自动提升版本或发版,只有用户明确要求时才执行 release skill。
- 生产数据库只允许只读查询;任何写操作需要用户另行明确授权。
- 不在项目文档、skill 或命令中保存数据库口令。
保存在 `.codex/memory/` 的资料:
- 前后端工程关系与统计页面惯例。
- 生产数据库只读验证方法,但连接参数只指向本地忽略配置,不记录凭据。
- 前端独立发版流程。
- 两个 WebView 套壳 App 的位置与交互约定。
- `material_ofrom` 合批历史缺陷的排查结论和后续接续点。
- 上述关键反馈规则的详细原因。
不迁移的内容:
- `.claude/settings.local.json` 中的 Claude 权限语法。
- 临时 scratchpad 路径、历史会话 ID、一次性命令白名单。
- 数据库用户名、密码及可直接复用的带密码命令。
## 安全与错误处理
- 新文件中扫描常见密码片段和 `PGPASSWORD`,确认没有凭据泄漏。
- 不读取或修改被 `.gitignore` 排除的本地数据库配置。
- 不连接生产数据库验证迁移,因为本任务只迁移文档和工作约束。
- 不删除 `.claude`,迁移失败时现有 Claude 工作流不受影响。
- 不执行 release skill仅验证其结构与引用路径。
## 验证
完成迁移后执行:
1. 检查 `AGENTS.md`、`.agents/skills/release/SKILL.md` 和 `.codex/memory/*.md` 均存在。
2. 检查 `AGENTS.md` 中的索引链接均能解析到实际文件。
3. 搜索新文件中的密码、`PGPASSWORD`、Claude session ID 和临时 scratchpad 路径。
4. 对照原 memory 索引,确认所有仍有效主题均已覆盖。
5. 检查 Git diff确认 `.claude` 没有变化,且没有混入用户现有未跟踪文件。
## 完成标准
- Codex 项目级入口、按需发版 skill 和 memory 索引全部建立。
- 原 Claude 配置保持不变。
- 原有 9 个 memory 主题均被迁移或被更高层规则覆盖。
- 新配置不含明文凭据或 Claude 专属运行痕迹。
- 验证命令通过Git diff 只包含本次迁移相关文件。

View File

@ -5,7 +5,12 @@ import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')
settings_module = (
'server.test_settings'
if sys.argv[1:2] == ['test']
else 'server.settings'
)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', settings_module)
try:
from django.core.management import execute_from_command_line
except ImportError as exc:

1
mcp_server/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Factory MCP v2 integration."""

24
mcp_server/__main__.py Normal file
View File

@ -0,0 +1,24 @@
import os
import django
import uvicorn
def main() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "server.settings")
django.setup()
from mcp_server.server import application
from django.conf import settings
uvicorn.run(
application,
host=settings.MCP_HOST,
port=settings.MCP_PORT,
log_level="info",
)
if __name__ == "__main__":
main()

66
mcp_server/auth.py Normal file
View File

@ -0,0 +1,66 @@
from asgiref.sync import sync_to_async
from rest_framework.exceptions import APIException
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import TokenError
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware
from starlette.types import ASGIApp, Receive, Scope, Send
def verify_factory_jwt(token: str) -> AccessToken | None:
"""验证 Factory access token并生成 MCP 的逐请求身份信息。"""
authentication = JWTAuthentication()
try:
validated_token = authentication.get_validated_token(token)
user = authentication.get_user(validated_token)
except (APIException, TokenError):
return None
return AccessToken(
token=token,
client_id="factory-mcp",
scopes=["factory:user"],
expires_at=validated_token.get("exp"),
subject=str(user.pk),
claims={
"factory_user": {
"id": str(user.pk),
"username": user.get_username(),
"name": user.name,
"is_superuser": user.is_superuser,
},
},
)
class FactoryJWTVerifier:
"""让 MCP SDK 复用 Factory SimpleJWT 的验证规则。"""
async def verify_token(self, token: str) -> AccessToken | None:
return await sync_to_async(
verify_factory_jwt,
thread_sensitive=True,
)(token)
class RequireFactoryJWTMiddleware:
"""仅保护 HTTP 请求,并把 ASGI lifespan 原样交给 MCP SDK。"""
def __init__(self, app: ASGIApp):
self.app = app
self.protected_app = RequireAuthMiddleware(
app,
required_scopes=[],
)
async def __call__(
self,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
await self.protected_app(scope, receive, send)

32
mcp_server/context.py Normal file
View File

@ -0,0 +1,32 @@
from typing import Any
from django.contrib.auth import get_user_model
from mcp.server.auth.middleware.auth_context import get_access_token
from apps.utils.permission import has_perm
def authenticated_user_claims() -> dict[str, Any]:
"""返回当前请求中经过 Factory JWT 校验的用户摘要。"""
access_token = get_access_token()
claims = access_token.claims if access_token else None
user = claims.get("factory_user") if claims else None
if not isinstance(user, dict):
raise RuntimeError("当前 MCP 请求缺少有效的 Factory 用户身份")
return user
def authenticated_factory_user():
"""加载当前 JWT 对应的 Django 用户,供权限和数据范围逻辑复用。"""
claims = authenticated_user_claims()
try:
return get_user_model().objects.select_related("belong_dept").get(
pk=claims["id"]
)
except (KeyError, get_user_model().DoesNotExist) as exc:
raise RuntimeError("当前 JWT 对应的 Factory 用户不存在") from exc
def require_permission(user, permission_code: str) -> None:
if not has_perm(user, [permission_code]):
raise PermissionError(f"当前用户缺少权限:{permission_code}")

89
mcp_server/server.py Normal file
View File

@ -0,0 +1,89 @@
from collections.abc import Sequence
from typing import Any
from django.conf import settings
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
from mcp.server.auth.middleware.bearer_auth import (
BearerAuthBackend,
)
from mcp.server.auth.provider import TokenVerifier
from mcp.server.transport_security import TransportSecuritySettings
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.types import ASGIApp
from mcp_server.auth import (
FactoryJWTVerifier,
RequireFactoryJWTMiddleware,
)
from mcp_server.context import authenticated_user_claims
from mcp_server.tools.batch_stats import register_batch_stat_tools
from mcp_server.tools.datasets import register_dataset_tools
from mcp_server.tools.wprs import register_wpr_tools
PROTOCOL_REVISION = "2026-07-28"
mcp = MCPServer(
name="factory",
title="Factory MCP",
description="Factory 面向 Agent 的受控业务能力入口。",
instructions="所有工具均使用当前请求携带的 Factory JWT 身份执行。",
version=settings.SYS_VERSION,
)
@mcp.tool()
def factory_server_info() -> dict[str, Any]:
"""返回 Factory MCP 服务版本及协议基础信息。"""
return {
"name": "factory",
"system_version": settings.SYS_VERSION,
"protocol_revision": PROTOCOL_REVISION,
"authentication": "factory_jwt",
"domain_tools_ready": True,
}
@mcp.tool()
def factory_whoami() -> dict[str, Any]:
"""返回当前 Factory JWT 对应的用户身份。"""
return authenticated_user_claims()
register_dataset_tools(mcp)
register_wpr_tools(mcp)
register_batch_stat_tools(mcp)
def create_app(
*,
allowed_hosts: Sequence[str] | None = None,
allowed_origins: Sequence[str] | None = None,
token_verifier: TokenVerifier | None = None,
) -> ASGIApp:
"""创建仅接受 Factory JWT 的 MCP v2 Streamable HTTP 应用。"""
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=list(settings.MCP_ALLOWED_HOSTS if allowed_hosts is None else allowed_hosts),
allowed_origins=list(settings.MCP_ALLOWED_ORIGINS if allowed_origins is None else allowed_origins),
)
app: ASGIApp = mcp.streamable_http_app(
streamable_http_path=settings.MCP_PATH,
json_response=True,
max_request_body_size=settings.MCP_MAX_REQUEST_BODY_SIZE,
transport_security=transport_security,
host=settings.MCP_HOST,
)
# 包装顺序保证先解析 Bearer JWT再写入 MCP 请求上下文,最后强制认证。
app = RequireFactoryJWTMiddleware(app)
app = AuthContextMiddleware(app)
app = AuthenticationMiddleware(
app,
backend=BearerAuthBackend(token_verifier or FactoryJWTVerifier()),
)
return app
application = create_app()

View File

@ -0,0 +1,105 @@
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from apps.wpm.models import BatchSt
from mcp_server.tools.batch_stats import get_batch_stat, search_batch_stats
def batch_stat(**overrides):
values = {
"id": "500",
"batch": "BATCH-001",
"version": 1,
"zt_batch": "ZT-001",
"first_time": datetime(2026, 8, 1, 8, 0),
"last_time": datetime(2026, 8, 2, 8, 0),
"material_start": SimpleNamespace(
id="100",
name="原料",
model="M-1",
specification="S-1",
),
"data": {"output": {"count": 10}, "quality": {"ok": 9}},
"update_time": datetime(2026, 8, 10, 8, 0),
}
values.update(overrides)
return SimpleNamespace(**values)
class BatchStatToolTests(SimpleTestCase):
@patch("mcp_server.tools.batch_stats._base_queryset")
@patch("mcp_server.tools.batch_stats.authenticated_factory_user")
def test_search_returns_summary_without_full_data(
self,
_user_mock,
queryset_mock,
):
queryset = MagicMock()
queryset.filter.return_value = queryset
queryset.order_by.return_value = queryset
queryset.__getitem__.return_value = [batch_stat()]
queryset_mock.return_value = queryset
result = search_batch_stats(query="BATCH", limit=10)
self.assertEqual(result["items"][0]["batch"], "BATCH-001")
self.assertEqual(result["items"][0]["data_keys"], ["output", "quality"])
self.assertNotIn("data", result["items"][0])
@patch("mcp_server.tools.batch_stats.BatchLog.objects.filter")
@patch("mcp_server.tools.batch_stats._base_queryset")
@patch("mcp_server.tools.batch_stats.authenticated_factory_user")
def test_get_returns_data_and_direct_relations(
self,
_user_mock,
queryset_mock,
relation_filter_mock,
):
item = batch_stat()
queryset_mock.return_value.get.return_value = item
relation_filter_mock.return_value.select_related.return_value.values.return_value = [
{
"id": "600",
"relation_type": "split",
"source_id": "500",
"source__batch": "BATCH-001",
"source__version": 1,
"target_id": "501",
"target__batch": "BATCH-001-1",
"target__version": 1,
"handover_id": "700",
"mlog_id": None,
}
]
result = get_batch_stat("BATCH-001")
self.assertEqual(result["data"]["output"]["count"], 10)
self.assertEqual(result["relations"][0]["relation_type"], "split")
@patch("mcp_server.tools.batch_stats.BatchLog.objects.filter")
@patch("mcp_server.tools.batch_stats._base_queryset")
@patch("mcp_server.tools.batch_stats.authenticated_factory_user")
def test_get_can_omit_relations(
self,
_user_mock,
queryset_mock,
relation_filter_mock,
):
queryset_mock.return_value.get.return_value = batch_stat()
result = get_batch_stat("BATCH-001", include_relations=False)
self.assertNotIn("relations", result)
relation_filter_mock.assert_not_called()
@patch("mcp_server.tools.batch_stats._base_queryset")
@patch("mcp_server.tools.batch_stats.authenticated_factory_user")
def test_get_reports_missing_batch(self, _user_mock, queryset_mock):
queryset_mock.return_value.get.side_effect = BatchSt.DoesNotExist
with self.assertRaisesRegex(ValueError, "未找到批次统计"):
get_batch_stat("missing")

View File

@ -0,0 +1,96 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase, override_settings
from mcp_server.tools.datasets import execute_dataset, search_datasets
class DatasetToolTests(SimpleTestCase):
@patch("mcp_server.tools.datasets.authenticated_factory_user")
@patch("mcp_server.tools.datasets.Dataset.objects.filter")
def test_search_returns_safe_catalog_fields(self, filter_mock, _user_mock):
queryset = MagicMock()
filter_mock.return_value = queryset
queryset.order_by.return_value.values.return_value.__getitem__.return_value = [
{
"code": "daily_output",
"name": "日产量",
"description": "按日统计产量",
"default_param": {"day": "2026-08-10"},
"test_param": {},
}
]
result = search_datasets(limit=10)
self.assertEqual(result["items"][0]["code"], "daily_output")
self.assertNotIn("sql_query", result["items"][0])
@patch("mcp_server.tools.datasets.cache")
@patch("mcp_server.tools.datasets.execute_rendered_dataset")
@patch("mcp_server.tools.datasets.render_dataset_sql")
@patch("mcp_server.tools.datasets.require_permission")
@patch("mcp_server.tools.datasets.authenticated_factory_user")
@patch("mcp_server.tools.datasets.Dataset.objects.get")
def test_execute_reuses_identity_permission_and_service(
self,
get_mock,
user_mock,
permission_mock,
render_mock,
execute_mock,
cache_mock,
):
item = SimpleNamespace(
code="daily_output",
name="日产量",
description="按日统计产量",
cache_seconds=10,
)
user = SimpleNamespace(id=42, belong_dept_id=7)
get_mock.return_value = item
user_mock.return_value = user
render_mock.return_value = "select 1"
cache_mock.get.return_value = None
execute_mock.return_value = (
{"data": {"ds0": [{"count": 1}]}, "data2": {}},
True,
)
result = execute_dataset("daily_output", {"day": "2026-08-10"})
permission_mock.assert_called_once_with(user, "dataset.exec")
render_query = render_mock.call_args.args[1]
self.assertEqual(render_query["r_user"], 42)
self.assertEqual(render_query["r_dept"], 7)
self.assertEqual(result["data"]["ds0"][0]["count"], 1)
self.assertNotIn("sql_query", result)
@override_settings(MCP_MAX_RESULT_BYTES=1)
@patch("mcp_server.tools.datasets.cache")
@patch("mcp_server.tools.datasets.execute_rendered_dataset")
@patch("mcp_server.tools.datasets.render_dataset_sql", return_value="")
@patch("mcp_server.tools.datasets.require_permission")
@patch("mcp_server.tools.datasets.authenticated_factory_user")
@patch("mcp_server.tools.datasets.Dataset.objects.get")
def test_execute_rejects_oversized_results(
self,
get_mock,
user_mock,
_permission_mock,
_render_mock,
execute_mock,
_cache_mock,
):
get_mock.return_value = SimpleNamespace(
code="daily_output",
name="日产量",
description="",
cache_seconds=0,
)
user_mock.return_value = SimpleNamespace(id=42, belong_dept_id=None)
execute_mock.return_value = ({"data": {"ds0": [1]}, "data2": {}}, True)
with self.assertRaisesRegex(RuntimeError, "超过 MCP 响应上限"):
execute_dataset("daily_output")

103
mcp_server/test_wprs.py Normal file
View File

@ -0,0 +1,103 @@
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from django.test import SimpleTestCase
from mcp_server.tools.wprs import get_wpr, search_wprs
def material(material_id="100", name="成品"):
return SimpleNamespace(
id=material_id,
name=name,
model="M-1",
specification="S-1",
)
def wpr(**overrides):
values = {
"id": "200",
"number": "WPR-001",
"number_out": "OUT-001",
"version": 1,
"state": 10,
"get_state_display": lambda: "正常",
"material": material(),
"material_start": material("101", "原料"),
"wm_id": "300",
"wm": SimpleNamespace(batch="WP-001"),
"mb_id": None,
"mb": None,
"wpr_from_id": None,
"wpr_from": None,
"oinfo": {"test": "ok"},
"data": {"route": []},
"pre_info": {"tooling": "T-1"},
"create_time": datetime(2026, 8, 1, 8, 0),
"update_time": datetime(2026, 8, 10, 8, 0),
}
values.update(overrides)
return SimpleNamespace(**values)
class WprToolTests(SimpleTestCase):
@patch("mcp_server.tools.wprs._base_queryset")
@patch("mcp_server.tools.wprs.authenticated_factory_user")
def test_search_returns_read_only_summary(self, _user_mock, queryset_mock):
queryset = MagicMock()
queryset.filter.return_value = queryset
queryset.distinct.return_value = queryset
queryset.order_by.return_value = queryset
queryset.__getitem__.return_value = [wpr()]
queryset_mock.return_value = queryset
result = search_wprs(query="WPR", location="workshop", limit=10)
self.assertEqual(result["items"][0]["number"], "WPR-001")
self.assertEqual(result["items"][0]["workshop_batch"], "WP-001")
self.assertNotIn("data", result["items"][0])
self.assertNotIn("pre_info", result["items"][0])
@patch("mcp_server.tools.wprs.WprDefect.objects.filter")
@patch("mcp_server.tools.wprs._base_queryset")
@patch("mcp_server.tools.wprs.authenticated_factory_user")
def test_get_returns_business_detail(
self,
_user_mock,
queryset_mock,
defect_filter_mock,
):
item = wpr()
queryset = MagicMock()
queryset.filter.return_value.order_by.return_value.first.return_value = item
queryset_mock.return_value = queryset
defect_filter_mock.return_value.select_related.return_value.values.return_value = [
{
"defect_id": "400",
"defect__name": "划伤",
"is_main": True,
}
]
result = get_wpr("WPR-001")
self.assertEqual(result["material"]["name"], "成品")
self.assertEqual(result["defects"][0]["defect__name"], "划伤")
self.assertEqual(result["pre_info"]["tooling"], "T-1")
@patch("mcp_server.tools.wprs._base_queryset")
@patch("mcp_server.tools.wprs.authenticated_factory_user")
def test_get_reports_missing_wpr(self, _user_mock, queryset_mock):
queryset = MagicMock()
queryset.filter.return_value.order_by.return_value.first.return_value = None
queryset_mock.return_value = queryset
with self.assertRaisesRegex(ValueError, "未找到 WPR"):
get_wpr("missing")
@patch("mcp_server.tools.wprs.authenticated_factory_user")
def test_search_rejects_unknown_location(self, _user_mock):
with self.assertRaisesRegex(ValueError, "不支持的 WPR 位置"):
search_wprs(location="invalid")

164
mcp_server/tests.py Normal file
View File

@ -0,0 +1,164 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from django.test import SimpleTestCase
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from rest_framework.exceptions import AuthenticationFailed
from starlette.testclient import TestClient
from mcp_server.auth import verify_factory_jwt
from mcp_server.server import (
PROTOCOL_REVISION,
create_app,
factory_server_info,
factory_whoami,
mcp,
)
class FactoryJWTVerifierTests(SimpleTestCase):
def test_factory_access_token_is_accepted(self):
user = SimpleNamespace(
pk=42,
name="MCP用户",
is_superuser=False,
get_username=lambda: "mcp-user",
)
authentication = patch("mcp_server.auth.JWTAuthentication").start()
self.addCleanup(patch.stopall)
authentication.return_value.get_validated_token.return_value = {
"exp": 1234567890,
}
authentication.return_value.get_user.return_value = user
access_token = verify_factory_jwt("access-token")
self.assertIsNotNone(access_token)
self.assertEqual(access_token.subject, str(user.pk))
self.assertEqual(
access_token.claims["factory_user"]["username"],
user.get_username(),
)
def test_invalid_token_is_rejected(self):
with patch("mcp_server.auth.JWTAuthentication") as authentication:
authentication.return_value.get_validated_token.side_effect = AuthenticationFailed("invalid token")
self.assertIsNone(verify_factory_jwt("invalid-token"))
class FactoryMCPServerTests(SimpleTestCase):
def test_base_tools_are_registered(self):
tools = asyncio.run(mcp.list_tools())
names = {tool.name for tool in tools}
self.assertEqual(
names,
{
"execute_dataset",
"factory_server_info",
"factory_whoami",
"get_batch_stat",
"get_wpr",
"search_batch_stats",
"search_datasets",
"search_wprs",
},
)
def test_server_info_targets_mcp_v2(self):
result = factory_server_info()
self.assertEqual(result["protocol_revision"], PROTOCOL_REVISION)
self.assertTrue(result["domain_tools_ready"])
def test_whoami_uses_authenticated_request_context(self):
user = {
"id": "42",
"username": "agent-user",
"name": "Agent用户",
"is_superuser": False,
}
authenticated = AuthenticatedUser(
AccessToken(
token="test-token",
client_id="factory-mcp",
scopes=["factory:user"],
subject=user["id"],
claims={"factory_user": user},
)
)
context_token = auth_context_var.set(authenticated)
try:
self.assertEqual(factory_whoami(), user)
finally:
auth_context_var.reset(context_token)
def test_http_endpoint_requires_bearer_token(self):
app = create_app(allowed_hosts=["testserver"])
with TestClient(app) as client:
response = client.post("/mcp", json={})
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["error"], "invalid_token")
def test_mcp_v2_request_uses_bearer_identity(self):
user = {
"id": "42",
"username": "agent-user",
"name": "Agent用户",
"is_superuser": False,
}
class TestTokenVerifier:
async def verify_token(self, token):
if token != "valid-token":
return None
return AccessToken(
token=token,
client_id="test-client",
scopes=["factory:user"],
subject=user["id"],
claims={"factory_user": user},
)
app = create_app(
allowed_hosts=["testserver"],
token_verifier=TestTokenVerifier(),
)
request = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "factory_whoami",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": (PROTOCOL_REVISION),
"io.modelcontextprotocol/clientInfo": {
"name": "factory-tests",
"version": "1.0",
},
"io.modelcontextprotocol/clientCapabilities": {},
},
},
}
headers = {
"Authorization": "Bearer valid-token",
"MCP-Protocol-Version": PROTOCOL_REVISION,
"Mcp-Method": "tools/call",
"Mcp-Name": "factory_whoami",
}
with TestClient(app) as client:
response = client.post("/mcp", json=request, headers=headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.json()["result"]["structuredContent"],
user,
)

View File

@ -0,0 +1 @@
"""Factory MCP 领域工具,按业务域拆分并在 server 中显式注册。"""

View File

@ -0,0 +1,104 @@
from typing import Any
from django.db.models import Q
from apps.wpm.models import BatchLog, BatchSt
from mcp_server.context import authenticated_factory_user
from mcp_server.tools.common import json_safe_result, validate_result_size
def _base_queryset():
return BatchSt.objects.select_related("material_start")
def _batch_summary(batch_stat: BatchSt) -> dict[str, Any]:
material = batch_stat.material_start
return {
"id": str(batch_stat.id),
"batch": batch_stat.batch,
"version": batch_stat.version,
"zt_batch": batch_stat.zt_batch,
"first_time": batch_stat.first_time,
"last_time": batch_stat.last_time,
"material_start": (
{
"id": str(material.id),
"name": material.name,
"model": material.model,
"specification": material.specification,
}
if material
else None
),
"data_keys": sorted((batch_stat.data or {}).keys()),
"update_time": batch_stat.update_time,
}
def search_batch_stats(
query: str = "",
zt_batch: str = "",
material_id: str | None = None,
version: int | None = 1,
limit: int = 20,
) -> dict[str, Any]:
"""搜索批次统计;摘要仅返回数据分组名称,不返回完整统计数据。"""
authenticated_factory_user()
safe_limit = max(1, min(limit, 100))
queryset = _base_queryset()
if query.strip():
queryset = queryset.filter(batch__icontains=query.strip())
if zt_batch.strip():
queryset = queryset.filter(zt_batch=zt_batch.strip())
if material_id:
queryset = queryset.filter(material_start_id=material_id)
if version is not None:
queryset = queryset.filter(version=version)
items = [
_batch_summary(item)
for item in queryset.order_by("batch", "version")[:safe_limit]
]
result = json_safe_result({"items": items, "limit": safe_limit})
validate_result_size(result)
return result
def get_batch_stat(
batch: str,
version: int = 1,
include_relations: bool = True,
) -> dict[str, Any]:
"""读取指定批次版本的完整统计数据,并可附带直接拆合批关系。"""
authenticated_factory_user()
try:
batch_stat = _base_queryset().get(batch=batch, version=version)
except BatchSt.DoesNotExist as exc:
raise ValueError(f"未找到批次统计:{batch} v{version}") from exc
result = _batch_summary(batch_stat)
result["data"] = batch_stat.data
if include_relations:
result["relations"] = list(
BatchLog.objects.filter(Q(source=batch_stat) | Q(target=batch_stat))
.select_related("source", "target")
.values(
"id",
"relation_type",
"source_id",
"source__batch",
"source__version",
"target_id",
"target__batch",
"target__version",
"handover_id",
"mlog_id",
)
)
result = json_safe_result(result)
validate_result_size(result)
return result
def register_batch_stat_tools(server) -> None:
server.tool()(search_batch_stats)
server.tool()(get_batch_stat)

View File

@ -0,0 +1,18 @@
import json
from typing import Any
from django.conf import settings
from apps.utils.tools import MyJSONEncoder
def json_safe_result(value: Any) -> Any:
return json.loads(json.dumps(value, cls=MyJSONEncoder, ensure_ascii=False))
def validate_result_size(value: Any) -> None:
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
if len(encoded) > settings.MCP_MAX_RESULT_BYTES:
raise RuntimeError(
"工具结果超过 MCP 响应上限,请缩小查询范围或增加筛选参数"
)

View File

@ -0,0 +1,79 @@
import hashlib
from typing import Any
from django.core.cache import cache
from django.db.models import Q
from apps.bi.models import Dataset
from apps.bi.services import execute_rendered_dataset, render_dataset_sql
from mcp_server.context import authenticated_factory_user, require_permission
from mcp_server.tools.common import json_safe_result, validate_result_size
def search_datasets(query: str = "", limit: int = 20) -> dict[str, Any]:
"""搜索可执行的数据集目录,不返回 SQL 等敏感配置。"""
authenticated_factory_user()
safe_limit = max(1, min(limit, 100))
queryset = Dataset.objects.filter(enabled=True)
if query.strip():
queryset = queryset.filter(
Q(name__icontains=query.strip())
| Q(code__icontains=query.strip())
| Q(description__icontains=query.strip())
)
rows = queryset.order_by("name", "code", "id").values(
"code",
"name",
"description",
"default_param",
"test_param",
)[:safe_limit]
return {"items": list(rows), "limit": safe_limit}
def execute_dataset(
code: str,
parameters: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""以当前 Factory 用户身份执行启用的数据集。需要 dataset.exec 权限。"""
user = authenticated_factory_user()
require_permission(user, "dataset.exec")
try:
dataset = Dataset.objects.get(code=code, enabled=True)
except Dataset.DoesNotExist as exc:
raise ValueError(f"未找到已启用的数据集:{code}") from exc
except Dataset.MultipleObjectsReturned as exc:
raise RuntimeError(f"数据集 code 不唯一,无法执行:{code}") from exc
query = dict(parameters or {})
query["r_user"] = user.id
query["r_dept"] = user.belong_dept_id or ""
full_sql = render_dataset_sql(dataset, query)
cache_key = None
response_data = None
if full_sql and dataset.cache_seconds:
digest = hashlib.sha256(full_sql.strip(";").encode()).hexdigest()
cache_key = f"mcp:dataset:{digest}"
response_data = cache.get(cache_key)
if response_data is None:
response_data, can_cache = execute_rendered_dataset(dataset, full_sql)
if cache_key and can_cache and response_data["data"]:
cache.set(cache_key, response_data, dataset.cache_seconds)
result = json_safe_result(
{
"code": dataset.code,
"name": dataset.name,
"description": dataset.description,
**response_data,
}
)
validate_result_size(result)
return result
def register_dataset_tools(server) -> None:
server.tool()(search_datasets)
server.tool()(execute_dataset)

139
mcp_server/tools/wprs.py Normal file
View File

@ -0,0 +1,139 @@
from typing import Any, Literal
from django.db.models import Q
from apps.wpmw.models import Wpr, WprDefect
from mcp_server.context import authenticated_factory_user
from mcp_server.tools.common import json_safe_result, validate_result_size
WprLocation = Literal["all", "workshop", "warehouse", "unassigned"]
def _base_queryset():
return Wpr.objects.select_related(
"material",
"material_start",
"wm",
"mb",
"wpr_from",
)
def _wpr_summary(wpr: Wpr) -> dict[str, Any]:
material = wpr.material
return {
"id": str(wpr.id),
"number": wpr.number,
"number_out": wpr.number_out,
"version": wpr.version,
"state": wpr.state,
"state_name": wpr.get_state_display(),
"material": {
"id": str(material.id),
"name": material.name,
"model": material.model,
"specification": material.specification,
},
"workshop_batch": wpr.wm.batch if wpr.wm_id else None,
"warehouse_batch": wpr.mb.batch if wpr.mb_id else None,
"create_time": wpr.create_time,
"update_time": wpr.update_time,
}
def search_wprs(
query: str = "",
state: int | None = None,
material_id: str | None = None,
batch: str = "",
location: WprLocation = "all",
limit: int = 20,
) -> dict[str, Any]:
"""按编号、物料或批次搜索单件产品;仅提供只读摘要。"""
authenticated_factory_user()
if location not in {"all", "workshop", "warehouse", "unassigned"}:
raise ValueError(f"不支持的 WPR 位置:{location}")
safe_limit = max(1, min(limit, 100))
queryset = _base_queryset()
if query.strip():
keyword = query.strip()
queryset = queryset.filter(
Q(number__icontains=keyword)
| Q(number_out__icontains=keyword)
| Q(material__name__icontains=keyword)
| Q(material__model__icontains=keyword)
| Q(material__specification__icontains=keyword)
)
if state is not None:
queryset = queryset.filter(state=state)
if material_id:
queryset = queryset.filter(material_id=material_id)
if batch.strip():
queryset = queryset.filter(
Q(wm__batch__icontains=batch.strip())
| Q(mb__batch__icontains=batch.strip())
)
if location == "workshop":
queryset = queryset.filter(wm__isnull=False)
elif location == "warehouse":
queryset = queryset.filter(mb__isnull=False)
elif location == "unassigned":
queryset = queryset.filter(wm__isnull=True, mb__isnull=True)
items = [
_wpr_summary(wpr)
for wpr in queryset.distinct().order_by("number", "create_time")[:safe_limit]
]
result = json_safe_result({"items": items, "limit": safe_limit})
validate_result_size(result)
return result
def get_wpr(identifier: str) -> dict[str, Any]:
"""按 WPR ID、内部编号或对外编号读取单件详情。"""
authenticated_factory_user()
lookup = Q(number=identifier) | Q(number_out=identifier)
if identifier.isdigit():
lookup |= Q(pk=identifier)
wpr = _base_queryset().filter(lookup).order_by("-version", "-update_time").first()
if wpr is None:
raise ValueError(f"未找到 WPR{identifier}")
result = _wpr_summary(wpr)
material_start = wpr.material_start
result.update(
{
"material_start": (
{
"id": str(material_start.id),
"name": material_start.name,
"model": material_start.model,
"specification": material_start.specification,
}
if material_start
else None
),
"wpr_from": (
{"id": str(wpr.wpr_from.id), "number": wpr.wpr_from.number}
if wpr.wpr_from_id
else None
),
"oinfo": wpr.oinfo,
"data": wpr.data,
"pre_info": wpr.pre_info,
"defects": list(
WprDefect.objects.filter(wpr=wpr)
.select_related("defect")
.values("defect_id", "defect__name", "is_main")
),
}
)
result = json_safe_result(result)
validate_result_size(result)
return result
def register_wpr_tools(server) -> None:
server.tool()(search_wprs)
server.tool()(get_wpr)

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