Compare commits
44 Commits
3.1.202607
...
master
| Author | SHA1 | Date |
|---|---|---|
|
|
6e9641f58a | |
|
|
0936687b1e | |
|
|
7e318d9cfd | |
|
|
db86a0fcf1 | |
|
|
a7a9a0aa6f | |
|
|
d1693799b9 | |
|
|
596f187d3d | |
|
|
fa16694f9f | |
|
|
94215df0b6 | |
|
|
c17552e0ea | |
|
|
24ce008d3a | |
|
|
d98b7fada2 | |
|
|
fd4de2bd4b | |
|
|
b365e06318 | |
|
|
ed952d2d3a | |
|
|
4bf5f1e585 | |
|
|
5fb179eb9e | |
|
|
69b5346031 | |
|
|
ddb3cc6f3f | |
|
|
19271e8880 | |
|
|
cfcd6c2ba3 | |
|
|
62f5aaef21 | |
|
|
d86d70641d | |
|
|
e23db9dc6c | |
|
|
cf2800daa0 | |
|
|
cee94f9f4a | |
|
|
1f98b81ce3 | |
|
|
074e759c29 | |
|
|
89cc999c42 | |
|
|
6068f2315d | |
|
|
298a32f744 | |
|
|
bcbb27374f | |
|
|
d5d940c5e4 | |
|
|
5f81ef9494 | |
|
|
8af19b8a9a | |
|
|
1700a58409 | |
|
|
dde5b0a6e8 | |
|
|
d5426c980f | |
|
|
c3f2c0f136 | |
|
|
919ac890a9 | |
|
|
534aa4224c | |
|
|
8a43ba7717 | |
|
|
b52a3fba4a | |
|
|
dd6b49e199 |
|
|
@ -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 的回退操作,都必须先取得用户明确授权。
|
||||||
|
|
@ -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 前执行。
|
||||||
|
|
||||||
|
这些文件记录的是长期约定或历史上下文。执行任务前应结合当前代码和数据重新验证,尤其不要把历史缺陷结论直接当成当前故障原因。
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
# 前端验证时机
|
||||||
|
|
||||||
|
- 修改配套前端 `../ehs_web` 时,日常开发和中间验证优先运行项目已有的 `check`,不要每次修改后都运行完整 `build`。
|
||||||
|
- 准备 push 前运行一次完整 `build`,用于发现生产构建阶段的问题。
|
||||||
|
- 若当前前端尚未配置 `check` 脚本,应先说明现状,不得把其他命令擅自当作 `check`。
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# ehs_web 路由由后端下发
|
||||||
|
|
||||||
|
`ehs_web` 使用后端下发的动态菜单和路由。新增管理页面时,不要向 `src/config/route.js` 添加条目;该文件中的既有静态路由属于历史或局部用法,不能作为新页面的默认范例。
|
||||||
|
|
||||||
|
通常只需:
|
||||||
|
|
||||||
|
- 创建 `src/views/<module>/<name>.vue`。
|
||||||
|
- 补充 `src/api/model/<module>.js` 中的接口。
|
||||||
|
- 在后端菜单与权限配置中设置组件相对路径和权限码。
|
||||||
|
|
||||||
|
若页面未出现在菜单中,应检查后端菜单、权限与组件路径配置,不要用新增静态路由规避问题。
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
# scripts 目录脚本不入库
|
||||||
|
|
||||||
|
仓库 `.gitignore` 中的 `scripts/*.py` 是有意设置的:`correct_*` 等一次性回刷、修数脚本只在本地或服务器按需运行,不进入版本库。
|
||||||
|
|
||||||
|
编写这类脚本后:
|
||||||
|
|
||||||
|
- 正常保留在本地工作区。
|
||||||
|
- 提交时不要执行 `git add -f`。
|
||||||
|
- 不因仓库中存在少量历史已跟踪脚本而推断新脚本也应提交。
|
||||||
|
|
||||||
|
只有用户明确要求某个脚本入库时,才可改变这一规则。
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# 发版只在用户明确要求时执行
|
||||||
|
|
||||||
|
不要在每轮代码修改完成后自动提升版本号、生成 release commit 或打 tag。版本对应实际部署,多轮改动可以合并到一次发版。
|
||||||
|
|
||||||
|
普通任务完成后可以正常提交代码,但只有用户明确说“发版”“走 release”或“bump 版本”时,才读取并执行 `.agents/skills/release/SKILL.md`。
|
||||||
|
|
||||||
|
后端与前端有各自独立的发版流程;后端 release skill 不适用于 `../ehs_web`。
|
||||||
|
|
@ -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 包含语义。
|
||||||
|
|
||||||
|
这些是现有实现惯例,不替代对当前页面和接口代码的检查。
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
# 生产数据只读验证
|
||||||
|
|
||||||
|
各工厂 PostgreSQL 连接参数保存在本机被 Git 忽略的配置中。涉及批次谱系、统计口径或真实数据形态时,可以在任务确有需要且用户允许的范围内做只读抽样查询。
|
||||||
|
|
||||||
|
安全要求:
|
||||||
|
|
||||||
|
- 从本地忽略配置读取连接参数,不在文档、脚本、命令记录或提交中复制凭据。
|
||||||
|
- 数据库会话显式设为只读。
|
||||||
|
- 禁止执行 INSERT、UPDATE、DELETE、DDL、修数或其他写操作,除非用户针对该操作另行明确授权。
|
||||||
|
- 临时查询脚本放在不会提交的本地位置。
|
||||||
|
- Windows 中文输出需要时设置 UTF-8 编码。
|
||||||
|
|
||||||
|
真实数据可能包含拆合批循环、自环边和期初断头批。修改统计或谱系算法前,优先用少量只读样本验证数据假设。
|
||||||
|
|
@ -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)。
|
||||||
|
|
@ -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` 字段不是当前权威版本。仍须遵守“只有用户明确要求才发版”的约束。
|
||||||
|
|
@ -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`,因为系统环境可能缺少项目依赖。
|
||||||
|
|
@ -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`。
|
||||||
|
|
@ -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 消息交互时,应检查这两个壳的约定。工程位置和加载地址可能随部署变化,操作前以当前本地代码为准。
|
||||||
|
|
@ -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 文件,避免把历史问题误当成当前事实。
|
||||||
|
|
@ -18,10 +18,35 @@ class DatasetCreateUpdateSerializer(CustomModelSerializer):
|
||||||
|
|
||||||
|
|
||||||
class DatasetSerializer(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:
|
class Meta:
|
||||||
model = Dataset
|
model = Dataset
|
||||||
fields = '__all__'
|
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 DatasetRecordSerializer(CustomModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DatasetRecord
|
model = DatasetRecord
|
||||||
|
|
@ -36,6 +61,14 @@ class DatasetRecordSerializer(CustomModelSerializer):
|
||||||
|
|
||||||
class DataExecSerializer(serializers.Serializer):
|
class DataExecSerializer(serializers.Serializer):
|
||||||
query = serializers.JSONField(
|
query = serializers.JSONField(
|
||||||
label="查询字典参数", required=False, allow_null=True)
|
label="查询字典参数",
|
||||||
is_test = serializers.BooleanField(label='是否测试', default=False)
|
help_text="按所选数据集 description/default_param 声明的业务参数填写",
|
||||||
raise_exception = serializers.BooleanField(label='是否直接报错', default=False)
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
from rest_framework.exceptions import ParseError
|
import concurrent.futures
|
||||||
import json
|
import json
|
||||||
from jinja2 import Template
|
import logging
|
||||||
|
|
||||||
|
from rest_framework.exceptions import ParseError
|
||||||
|
|
||||||
from apps.bi.models import Dataset
|
from apps.bi.models import Dataset
|
||||||
import concurrent
|
|
||||||
from apps.utils.sql import execute_raw_sql, format_sqldata
|
from apps.utils.sql import execute_raw_sql, format_sqldata
|
||||||
from apps.utils.tools import MyJSONEncoder
|
from apps.utils.tools import MyJSONEncoder
|
||||||
|
|
||||||
|
myLogger = logging.getLogger('log')
|
||||||
|
|
||||||
forbidden_keywords = ["UPDATE", "DELETE", "DROP", "TRUNCATE", "INSERT", "CREATE", "ALTER", "GRANT", "REVOKE", "EXEC", "EXECUTE"]
|
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
|
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})
|
返回 (sql语句, { rda})
|
||||||
"""
|
"""
|
||||||
rdata = {}
|
full_sql = render_dataset_sql(dt, xquery)
|
||||||
results = {}
|
response_data, _ = execute_rendered_dataset(dt, full_sql)
|
||||||
results2 = {}
|
return full_sql, response_data
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
130
apps/bi/views.py
130
apps/bi/views.py
|
|
@ -3,17 +3,21 @@ from apps.utils.viewsets import CustomModelViewSet, CustomGenericViewSet
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from apps.bi.models import Dataset, DatasetRecord
|
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
|
from django.apps import apps
|
||||||
import concurrent.futures
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from apps.utils.sql import execute_raw_sql, format_sqldata
|
from apps.bi.services import execute_rendered_dataset, render_dataset_sql
|
||||||
from apps.bi.services import check_sql_safe, format_json_with_placeholders
|
|
||||||
from rest_framework.exceptions import ParseError
|
from rest_framework.exceptions import ParseError
|
||||||
from rest_framework.generics import get_object_or_404
|
from rest_framework.generics import get_object_or_404
|
||||||
from apps.utils.mixins import ListModelMixin
|
from apps.utils.mixins import ListModelMixin
|
||||||
import logging
|
from drf_yasg import openapi
|
||||||
myLogger = logging.getLogger('log')
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -22,9 +26,54 @@ class DatasetViewSet(CustomModelViewSet):
|
||||||
serializer_class = DatasetSerializer
|
serializer_class = DatasetSerializer
|
||||||
create_serializer_class = DatasetCreateUpdateSerializer
|
create_serializer_class = DatasetCreateUpdateSerializer
|
||||||
update_serializer_class = DatasetCreateUpdateSerializer
|
update_serializer_class = DatasetCreateUpdateSerializer
|
||||||
search_fields = ['name', 'code']
|
search_fields = ['name', 'code', 'description']
|
||||||
ordering = ['name', 'code', 'id']
|
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):
|
def get_object(self):
|
||||||
"""
|
"""
|
||||||
Returns the object the view is displaying.
|
Returns the object the view is displaying.
|
||||||
|
|
@ -57,6 +106,18 @@ class DatasetViewSet(CustomModelViewSet):
|
||||||
|
|
||||||
return obj
|
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=[])
|
@action(methods=['post'], detail=True, perms_map={'post': 'dataset.exec'}, serializer_class=DataExecSerializer, cache_seconds=0, logging_methods=[])
|
||||||
def exec(self, request, pk=None):
|
def exec(self, request, pk=None):
|
||||||
"""执行sql查询
|
"""执行sql查询
|
||||||
|
|
@ -67,59 +128,24 @@ class DatasetViewSet(CustomModelViewSet):
|
||||||
if not dt.enabled:
|
if not dt.enabled:
|
||||||
raise ParseError(f'{dt.name}-该查询未启用')
|
raise ParseError(f'{dt.name}-该查询未启用')
|
||||||
rdata = DatasetSerializer(instance=dt).data
|
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)
|
is_test = request.data.get('is_test', False)
|
||||||
raise_exception = request.data.get('raise_exception', True)
|
raise_exception = request.data.get('raise_exception', True)
|
||||||
xquery['r_user'] = request.user.id
|
xquery['r_user'] = request.user.id
|
||||||
xquery['r_dept'] = request.user.belong_dept.id if request.user.belong_dept else ''
|
xquery['r_dept'] = request.user.belong_dept.id if request.user.belong_dept else ''
|
||||||
can_cache = True
|
full_sql = render_dataset_sql(dt, xquery, is_test=is_test)
|
||||||
results = {}
|
hash_k = None
|
||||||
results2 = {}
|
if full_sql:
|
||||||
query = dt.default_param
|
sql_f_strip = full_sql.strip(';')
|
||||||
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(';')
|
|
||||||
hash_k = hash(sql_f_strip)
|
hash_k = hash(sql_f_strip)
|
||||||
hash_v = cache.get(hash_k, None)
|
hash_v = cache.get(hash_k, None)
|
||||||
if hash_v:
|
if hash_v:
|
||||||
return Response(hash_v)
|
return Response(hash_v)
|
||||||
# 多线程运行并返回字典结果
|
response_data, can_cache = execute_rendered_dataset(
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
|
dt, full_sql, raise_exception=raise_exception
|
||||||
fun_ps = []
|
)
|
||||||
for ind, val in enumerate(sql_f_l):
|
rdata.update(response_data)
|
||||||
fun_ps.append((f'ds{ind}', execute_raw_sql, val))
|
if response_data['data'] and can_cache and hash_k is not None:
|
||||||
# 生成执行函数
|
|
||||||
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:
|
|
||||||
cache.set(hash_k, rdata, dt.cache_seconds)
|
cache.set(hash_k, rdata, dt.cache_seconds)
|
||||||
return Response(rdata)
|
return Response(rdata)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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,
|
from apps.develop.views import (BackupDatabase, BackupMedia, ReloadClientGit,
|
||||||
ReloadServerGit, ReloadServerOnly, TestViewSet, CorrectViewSet, testScanHtml, ServerTime)
|
ReloadServerGit, ReloadServerOnly, TestViewSet, CorrectViewSet, testScanHtml, ServerTime)
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
@ -6,9 +7,11 @@ from rest_framework.routers import DefaultRouter
|
||||||
API_BASE_URL = 'api/develop/'
|
API_BASE_URL = 'api/develop/'
|
||||||
HTML_BASE_URL = 'dhtml/develop/'
|
HTML_BASE_URL = 'dhtml/develop/'
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register('test', TestViewSet, basename='api_test')
|
|
||||||
router.register('correct', CorrectViewSet, basename='correct')
|
router.register('correct', CorrectViewSet, basename='correct')
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
router.register('test', TestViewSet, basename='api_test')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(API_BASE_URL + 'reload_server_git/', ReloadServerGit.as_view()),
|
path(API_BASE_URL + 'reload_server_git/', ReloadServerGit.as_view()),
|
||||||
# path(API_BASE_URL + 'reload_web_git/', ReloadClientGit.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 + 'backup_media/', BackupMedia.as_view()),
|
||||||
path(API_BASE_URL + 'server_time/', ServerTime.as_view()),
|
path(API_BASE_URL + 'server_time/', ServerTime.as_view()),
|
||||||
path(API_BASE_URL, include(router.urls)),
|
path(API_BASE_URL, include(router.urls)),
|
||||||
path(HTML_BASE_URL + "testscan/", testScanHtml)
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns.append(path(HTML_BASE_URL + "testscan/", testScanHtml))
|
||||||
|
|
|
||||||
|
|
@ -40,11 +40,12 @@ from datetime import datetime
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
class ServerTime(APIView):
|
class ServerTime(APIView):
|
||||||
|
permission_classes = [IsAdminUser]
|
||||||
|
|
||||||
def get_permissions(self):
|
def get_permissions(self):
|
||||||
if self.request.method == 'GET':
|
if self.request.method == "GET":
|
||||||
return [AllowAny()]
|
return [AllowAny()]
|
||||||
return [IsAdminUser()]
|
return super().get_permissions()
|
||||||
|
|
||||||
@swagger_auto_schema(responses={200: ServerTimeSerializer})
|
@swagger_auto_schema(responses={200: ServerTimeSerializer})
|
||||||
def get(self, request):
|
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(
|
completed = subprocess.run(
|
||||||
["sudo", "-S", "sh", "-c", command], # 添加 -S 参数
|
["sudo", "-S", "date", "-s", server_time],
|
||||||
input=SD_PWD + "\n", # 注意要在密码后加换行符
|
input=SD_PWD + "\n", # 注意要在密码后加换行符
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True
|
text=True
|
||||||
|
|
@ -269,10 +274,9 @@ class CorrectViewSet(CustomGenericViewSet):
|
||||||
|
|
||||||
class TestViewSet(CustomGenericViewSet):
|
class TestViewSet(CustomGenericViewSet):
|
||||||
perms_map = {}
|
perms_map = {}
|
||||||
authentication_classes = ()
|
permission_classes = [IsAdminUser]
|
||||||
permission_classes = ()
|
|
||||||
|
|
||||||
@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):
|
def send_sms(self, request, pk=None):
|
||||||
"""发送短信测试
|
"""发送短信测试
|
||||||
|
|
||||||
|
|
@ -565,7 +569,7 @@ class TestViewSet(CustomGenericViewSet):
|
||||||
# correct_card_time()
|
# correct_card_time()
|
||||||
# return Response()
|
# return Response()
|
||||||
|
|
||||||
@action(methods=['post'], detail=False, serializer_class=Serializer, permission_classes=[])
|
@action(methods=['post'], detail=False, serializer_class=Serializer)
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def correct_data(self, request, pk=None):
|
def correct_data(self, request, pk=None):
|
||||||
"""修正数据
|
"""修正数据
|
||||||
|
|
@ -674,7 +678,7 @@ class TestViewSet(CustomGenericViewSet):
|
||||||
Ticket.objects.get_queryset(all=True).delete()
|
Ticket.objects.get_queryset(all=True).delete()
|
||||||
return Response()
|
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):
|
def test_cal(self, request, pk=None):
|
||||||
from apps.wpm.tasks import cal_exp_duration_sec
|
from apps.wpm.tasks import cal_exp_duration_sec
|
||||||
cal_exp_duration_sec('3397169058570170368')
|
cal_exp_duration_sec('3397169058570170368')
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,8 @@ class ExamViewSet(CustomModelViewSet):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
|
if getattr(self, 'swagger_fake_view', False):
|
||||||
|
return qs
|
||||||
if has_perm(self.request.user, ["exam.view"]):
|
if has_perm(self.request.user, ["exam.view"]):
|
||||||
return qs
|
return qs
|
||||||
user:User = self.request.user
|
user:User = self.request.user
|
||||||
|
|
@ -142,6 +144,8 @@ class ExamRecordViewSet(ListModelMixin, DestroyModelMixin, RetrieveModelMixin, C
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
|
if getattr(self, 'swagger_fake_view', False):
|
||||||
|
return qs
|
||||||
if has_perm(self.request.user, ["examrecord.view"]):
|
if has_perm(self.request.user, ["examrecord.view"]):
|
||||||
return qs
|
return qs
|
||||||
return qs.filter(create_by=self.request.user)
|
return qs.filter(create_by=self.request.user)
|
||||||
|
|
@ -207,6 +211,8 @@ class TrainRecordViewSet(CustomModelViewSet):
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
|
if getattr(self, 'swagger_fake_view', False):
|
||||||
|
return qs
|
||||||
if has_perm(self.request.user, ["train.view"]):
|
if has_perm(self.request.user, ["train.view"]):
|
||||||
return qs
|
return qs
|
||||||
return qs.filter(create_by=self.request.user)
|
return qs.filter(create_by=self.request.user)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ from apps.utils.filters import MyJsonListFilter
|
||||||
|
|
||||||
class EquipFilterSet(filters.FilterSet):
|
class EquipFilterSet(filters.FilterSet):
|
||||||
tags = MyJsonListFilter(label='tags/json/list查询')
|
tags = MyJsonListFilter(label='tags/json/list查询')
|
||||||
|
exclude_cate_name = filters.CharFilter(
|
||||||
|
field_name='cate__name', exclude=True, label='排除设备分类名称')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Equipment
|
model = Equipment
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from apps.enm.views import (MpointViewSet, MpointStatViewSet,
|
from apps.enm.views import (MpointViewSet, MpointStatViewSet,
|
||||||
EnStatViewSet, EnStat2ViewSet, XscriptViewSet, MpLogxAPIView)
|
EnStatViewSet, EnStat2ViewSet, XscriptViewSet, MpLogxAPIView,
|
||||||
|
WengfuMpLogxAPIView, WengfuMpointListAPIView)
|
||||||
|
|
||||||
API_BASE_URL = 'api/enm/'
|
API_BASE_URL = 'api/enm/'
|
||||||
HTML_BASE_URL = 'dhtml/enm/'
|
HTML_BASE_URL = 'dhtml/enm/'
|
||||||
|
|
@ -16,4 +17,6 @@ router.register('xscript', XscriptViewSet, basename='xscript')
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(API_BASE_URL, include(router.urls)),
|
path(API_BASE_URL, include(router.urls)),
|
||||||
path(f'{API_BASE_URL}mplogx/', MpLogxAPIView.as_view(), name='mplogx_list'),
|
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'),
|
||||||
]
|
]
|
||||||
|
|
@ -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):
|
class MpLogxViewSet(CustomListModelMixin, CustomGenericViewSet):
|
||||||
"""
|
"""
|
||||||
list: 测点采集数据
|
list: 测点采集数据
|
||||||
|
|
|
||||||
|
|
@ -391,6 +391,23 @@ class EmployeeViewSet(CustomModelViewSet):
|
||||||
# 获取模型字段
|
# 获取模型字段
|
||||||
model_fields = {f.name: f for f in Employee._meta.fields}
|
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):
|
for row_num, row in enumerate(rows[1:], start=2):
|
||||||
try:
|
try:
|
||||||
data = {}
|
data = {}
|
||||||
|
|
@ -419,12 +436,15 @@ class EmployeeViewSet(CustomModelViewSet):
|
||||||
data['type'] = TYPE_MAPPING[excel_type]
|
data['type'] = TYPE_MAPPING[excel_type]
|
||||||
else:
|
else:
|
||||||
raise ParseError(f'第{row_num}行,人员类型"{excel_type}"无效,有效类型:{", ".join(TYPE_MAPPING.keys())}')
|
raise ParseError(f'第{row_num}行,人员类型"{excel_type}"无效,有效类型:{", ".join(TYPE_MAPPING.keys())}')
|
||||||
# 处理部门外键:填了就校验是否存在并赋值;为空时不动(新增场景的必填在下方创建处校验)
|
# 处理部门外键:填了校验并赋值;列存在但为空 -> 清空(新增场景的必填在创建处校验)
|
||||||
dept_name = data.pop('belong_dept', None)
|
dept_raw = data.pop('belong_dept', _ABSENT)
|
||||||
if dept_name:
|
if dept_raw is not _ABSENT:
|
||||||
if dept_name not in dept_map:
|
if dept_raw:
|
||||||
raise ParseError(f'第{row_num}行,部门"{dept_name}"不存在')
|
if dept_raw not in dept_map:
|
||||||
data['belong_dept_id'] = dept_map[dept_name]
|
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'):
|
if data.get('phone'):
|
||||||
|
|
@ -454,11 +474,14 @@ class EmployeeViewSet(CustomModelViewSet):
|
||||||
(e for e in name_matches if e.id_number == id_number), None)
|
(e for e in name_matches if e.id_number == id_number), None)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
# 用 Excel 中填写了值的列覆盖数据库已有数据;空单元格保持原值不变
|
# 全量覆盖:以本次 Excel 为准。填了值的列覆盖原值;
|
||||||
|
# 空单元格清空原值(不可为空的字段如布尔/性别/类别则保留原值)。
|
||||||
updated_fields = []
|
updated_fields = []
|
||||||
for field_name, value in data.items():
|
for field_name, value in data.items():
|
||||||
if value in [None, '']:
|
if value in [None, '']:
|
||||||
continue
|
value = empty_value(field_name)
|
||||||
|
if value is _KEEP:
|
||||||
|
continue
|
||||||
if getattr(existing, field_name, None) != value:
|
if getattr(existing, field_name, None) != value:
|
||||||
setattr(existing, field_name, value)
|
setattr(existing, field_name, value)
|
||||||
updated_fields.append(field_name)
|
updated_fields.append(field_name)
|
||||||
|
|
@ -470,9 +493,11 @@ class EmployeeViewSet(CustomModelViewSet):
|
||||||
created = False
|
created = False
|
||||||
else:
|
else:
|
||||||
# 新增人员时所属部门必填
|
# 新增人员时所属部门必填
|
||||||
if 'belong_dept_id' not in data:
|
if not data.get('belong_dept_id'):
|
||||||
raise ParseError(f'第{row_num}行,新增人员时所属部门不能为空')
|
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
|
created = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise
|
raise
|
||||||
|
|
@ -501,18 +526,23 @@ def convert_field_value(model_field, value, row_num):
|
||||||
|
|
||||||
# ===== 日期字段 =====
|
# ===== 日期字段 =====
|
||||||
if isinstance(model_field, CharField):
|
if isinstance(model_field, CharField):
|
||||||
|
sval = str(value)
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
if 'e' in value.lower():
|
if 'e' in value.lower():
|
||||||
try:
|
try:
|
||||||
return str(int(float(value)))
|
sval = str(int(float(value)))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
if '.' in value:
|
if '.' in value:
|
||||||
try:
|
try:
|
||||||
return str(int(float(value)))
|
sval = str(int(float(value)))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
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):
|
if isinstance(model_field, DateField):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,16 @@
|
||||||
from django_filters import rest_framework as filters
|
from django_filters import rest_framework as filters
|
||||||
from apps.inm.models import MaterialBatch, MIO
|
from apps.inm.models import MaterialBatch, MIO
|
||||||
from django.db.models import Q, Subquery, OuterRef, F
|
from django.db.models import Q, Subquery, OuterRef, F
|
||||||
|
from apps.qm.defect_grades import effective_defect_grade_q
|
||||||
|
|
||||||
class MaterialBatchFilter(filters.FilterSet):
|
class MaterialBatchFilter(filters.FilterSet):
|
||||||
count_canmio__gt = filters.NumberFilter(
|
count_canmio__gt = filters.NumberFilter(
|
||||||
method='filter_count_canmio__gt', label='可发数量大于')
|
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:
|
class Meta:
|
||||||
model = MaterialBatch
|
model = MaterialBatch
|
||||||
|
|
|
||||||
|
|
@ -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.utils.models import BaseModel, CommonBModel, CommonBDModel, CommonADModel
|
||||||
from apps.pum.models import Supplier, PuOrder
|
from apps.pum.models import Supplier, PuOrder
|
||||||
from apps.sam.models import Customer, Order
|
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')
|
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)
|
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
|
@property
|
||||||
def count_mioing(self):
|
def count_mioing(self):
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ from django.db.models import F, Sum, DecimalField
|
||||||
from server.settings import get_sysconfig
|
from server.settings import get_sysconfig
|
||||||
from apps.wpmw.models import Wpr
|
from apps.wpmw.models import Wpr
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from apps.qm.defect_grades import DEFECT_GRADE_NAMES, effective_defect_grade
|
||||||
|
|
||||||
|
|
||||||
class WareHourseSerializer(CustomModelSerializer):
|
class WareHourseSerializer(CustomModelSerializer):
|
||||||
|
|
@ -49,6 +50,8 @@ class MaterialBatchSerializer(CustomModelSerializer):
|
||||||
source='supplier', read_only=True)
|
source='supplier', read_only=True)
|
||||||
material_ = MaterialSerializer(source='material', read_only=True)
|
material_ = MaterialSerializer(source='material', read_only=True)
|
||||||
defect_name = serializers.CharField(source="defect.name", 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='正在出入库数量')
|
count_mioing = serializers.SerializerMethodField(label='正在出入库数量')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
@ -61,6 +64,12 @@ class MaterialBatchSerializer(CustomModelSerializer):
|
||||||
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可发量偏大)
|
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可发量偏大)
|
||||||
return instance.count_mioing_anno if hasattr(instance, 'count_mioing_anno') else instance.count_mioing
|
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):
|
def to_representation(self, instance):
|
||||||
ret = super().to_representation(instance)
|
ret = super().to_representation(instance)
|
||||||
if 'count' in ret:
|
if 'count' in ret:
|
||||||
|
|
@ -86,6 +95,15 @@ class MaterialBatchDetailSerializer(CustomModelSerializer):
|
||||||
source='a_mb', read_only=True, many=True)
|
source='a_mb', read_only=True, many=True)
|
||||||
supplier_name = serializers.StringRelatedField(
|
supplier_name = serializers.StringRelatedField(
|
||||||
source='supplier', read_only=True)
|
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:
|
class Meta:
|
||||||
model = MaterialBatch
|
model = MaterialBatch
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,10 @@ from apps.wpm.models import WMaterial, BatchSt, BatchLog
|
||||||
from apps.wpm.services_2 import ana_batch_thread
|
from apps.wpm.services_2 import ana_batch_thread
|
||||||
from apps.wpmw.models import Wpr
|
from apps.wpmw.models import Wpr
|
||||||
from apps.qm.models import Ftest, Defect
|
from apps.qm.models import Ftest, Defect
|
||||||
|
from django.db import transaction
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def do_out(item: MIOItem, is_reverse: bool = False):
|
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:
|
if is_zhj:
|
||||||
try:
|
try:
|
||||||
mb = MaterialBatch.objects.get(
|
mb = MaterialBatch.objects.select_for_update().get(
|
||||||
material=item.material,
|
material=item.material,
|
||||||
warehouse=item.warehouse,
|
warehouse=item.warehouse,
|
||||||
batch=item.batch,
|
batch=item.batch,
|
||||||
|
|
@ -82,7 +84,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
|
||||||
mb = None
|
mb = None
|
||||||
if not is_zhj:
|
if not is_zhj:
|
||||||
try:
|
try:
|
||||||
mb = MaterialBatch.objects.get(
|
mb = MaterialBatch.objects.select_for_update().get(
|
||||||
material=xmaterial,
|
material=xmaterial,
|
||||||
warehouse=item.warehouse,
|
warehouse=item.warehouse,
|
||||||
batch=xbatch,
|
batch=xbatch,
|
||||||
|
|
@ -99,7 +101,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
|
||||||
|
|
||||||
if xmaterial.into_wm:
|
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,
|
batch=xbatch, material=xmaterial,
|
||||||
belong_dept=belong_dept, mgroup=mgroup,
|
belong_dept=belong_dept, mgroup=mgroup,
|
||||||
state=state, defect=defect)
|
state=state, defect=defect)
|
||||||
|
|
@ -107,7 +109,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
|
||||||
wm.create_by = do_user
|
wm.create_by = do_user
|
||||||
wm.batch_ofrom = mb.batch if mb else None
|
wm.batch_ofrom = mb.batch if mb else None
|
||||||
wm.material_ofrom = mb.material 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.update_by = do_user
|
||||||
wm.save()
|
wm.save()
|
||||||
|
|
||||||
|
|
@ -130,6 +132,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
|
||||||
ana_batch_thread(xbatches)
|
ana_batch_thread(xbatches)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def do_in(item: MIOItem):
|
def do_in(item: MIOItem):
|
||||||
"""
|
"""
|
||||||
生产入库后更新车间物料
|
生产入库后更新车间物料
|
||||||
|
|
@ -184,9 +187,9 @@ def do_in(item: MIOItem):
|
||||||
xbatchs.append(xbatch)
|
xbatchs.append(xbatch)
|
||||||
if xmaterial.into_wm:
|
if xmaterial.into_wm:
|
||||||
if xwm:
|
if xwm:
|
||||||
wm = xwm
|
wm = WMaterial.objects.select_for_update().get(pk=xwm.pk)
|
||||||
else:
|
else:
|
||||||
wm_qs = WMaterial.objects.filter(
|
wm_qs = WMaterial.objects.select_for_update().filter(
|
||||||
batch=xbatch,
|
batch=xbatch,
|
||||||
material=xmaterial,
|
material=xmaterial,
|
||||||
belong_dept=belong_dept,
|
belong_dept=belong_dept,
|
||||||
|
|
@ -220,7 +223,7 @@ def do_in(item: MIOItem):
|
||||||
|
|
||||||
# 增加mb
|
# 增加mb
|
||||||
if not is_zhj:
|
if not is_zhj:
|
||||||
mb, _ = MaterialBatch.objects.get_or_create(
|
mb, _ = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=xmaterial,
|
material=xmaterial,
|
||||||
warehouse=item.warehouse,
|
warehouse=item.warehouse,
|
||||||
batch=xbatch,
|
batch=xbatch,
|
||||||
|
|
@ -255,7 +258,7 @@ def do_in(item: MIOItem):
|
||||||
|
|
||||||
|
|
||||||
if is_zhj: # 组合件单独处理并且不做追踪单个处理
|
if is_zhj: # 组合件单独处理并且不做追踪单个处理
|
||||||
mb, is_created = MaterialBatch.objects.get_or_create(
|
mb, is_created = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=item.material,
|
material=item.material,
|
||||||
warehouse=item.warehouse,
|
warehouse=item.warehouse,
|
||||||
batch=item.batch,
|
batch=item.batch,
|
||||||
|
|
@ -412,7 +415,7 @@ class InmService:
|
||||||
state = WMaterial.WM_OK
|
state = WMaterial.WM_OK
|
||||||
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
|
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
|
||||||
state = WMaterial.WM_NOTOK
|
state = WMaterial.WM_NOTOK
|
||||||
mb, _ = MaterialBatch.objects.get_or_create(
|
mb, _ = MaterialBatch.locked_get_or_create_inventory(
|
||||||
material=material,
|
material=material,
|
||||||
warehouse=warehouse,
|
warehouse=warehouse,
|
||||||
batch=batch,
|
batch=batch,
|
||||||
|
|
@ -433,11 +436,18 @@ class InmService:
|
||||||
raise ParseError("出入库与明细数量不一致,操作失败")
|
raise ParseError("出入库与明细数量不一致,操作失败")
|
||||||
for mioitemw in mioitemws:
|
for mioitemw in mioitemws:
|
||||||
if mioitemw.wpr:
|
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:
|
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.wpr = wpr
|
||||||
mioitemw.save()
|
mioitemw.save()
|
||||||
|
wpr.apply_ftest(mioitemw.ftest)
|
||||||
|
|
||||||
elif in_or_out == -1:
|
elif in_or_out == -1:
|
||||||
mb.count = mb.count - change_count
|
mb.count = mb.count - change_count
|
||||||
|
|
@ -488,4 +498,3 @@ class InmService:
|
||||||
# 若该出入库记录已无明细,自动删除
|
# 若该出入库记录已无明细,自动删除
|
||||||
if not MIOItem.objects.filter(mio=mio).exists():
|
if not MIOItem.objects.filter(mio=mio).exists():
|
||||||
mio.delete()
|
mio.delete()
|
||||||
|
|
||||||
|
|
@ -55,7 +55,7 @@ def daoru_mb(path: str):
|
||||||
process=process,
|
process=process,
|
||||||
defaults={"type": type, "name": name, "specification": specification, "model": model, "process": process, "number": ranstr(6), "id": idWorker.get_id()},
|
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()}
|
material=material, batch=batch, warehouse=warehouse, defaults={"material": material, "batch": batch, "warehouse": warehouse, "count": count, "id": idWorker.get_id()}
|
||||||
)
|
)
|
||||||
i = i + 1
|
i = i + 1
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ class MaterialBatchViewSet(ListModelMixin, CustomGenericViewSet):
|
||||||
queryset = MaterialBatch.objects.filter(count__gt=0)
|
queryset = MaterialBatch.objects.filter(count__gt=0)
|
||||||
serializer_class = MaterialBatchSerializer
|
serializer_class = MaterialBatchSerializer
|
||||||
retrieve_serializer_class = MaterialBatchDetailSerializer
|
retrieve_serializer_class = MaterialBatchDetailSerializer
|
||||||
select_related_fields = ['warehouse', 'material', 'supplier']
|
select_related_fields = ['warehouse', 'material', 'supplier', 'defect']
|
||||||
filterset_class = MaterialBatchFilter
|
filterset_class = MaterialBatchFilter
|
||||||
search_fields = ['material__name', 'material__number',
|
search_fields = ['material__name', 'material__number',
|
||||||
'material__model', 'material__specification', 'batch']
|
'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:
|
if ins.inout_date is None:
|
||||||
raise ParseError('出入库日期未填写')
|
raise ParseError('出入库日期未填写')
|
||||||
if ins.state != MIO.MIO_CREATE:
|
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
|
user = self.request.user
|
||||||
if ins.state != MIO.MIO_SUBMITED:
|
if ins.state != MIO.MIO_SUBMITED:
|
||||||
raise ParseError('记录状态异常')
|
raise ParseError('记录状态异常')
|
||||||
|
|
@ -586,4 +588,3 @@ class MIOItemwViewSet(CustomModelViewSet):
|
||||||
if ftest:
|
if ftest:
|
||||||
ftest.delete()
|
ftest.delete()
|
||||||
self.cal_mioitem_count(mioitem)
|
self.cal_mioitem_count(mioitem)
|
||||||
|
|
||||||
|
|
@ -5,7 +5,7 @@ from apps.mtm.models import Material, Shift, Mgroup, Process
|
||||||
|
|
||||||
@admin.register(Process)
|
@admin.register(Process)
|
||||||
class ProcessAdmin(admin.ModelAdmin):
|
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)
|
@admin.register(Material)
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -9,6 +9,28 @@ from django.utils import timezone
|
||||||
from django.db.models import Sum, Q, Value, F, ExpressionWrapper, DecimalField
|
from django.db.models import Sum, Q, Value, F, ExpressionWrapper, DecimalField
|
||||||
from django.db.models.functions import Coalesce
|
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):
|
class Process(CommonBModel):
|
||||||
"""
|
"""
|
||||||
TN:工序
|
TN:工序
|
||||||
|
|
@ -29,7 +51,8 @@ class Process(CommonBModel):
|
||||||
instruction = models.ForeignKey(
|
instruction = models.ForeignKey(
|
||||||
File, verbose_name='指导书', on_delete=models.SET_NULL, null=True, blank=True)
|
File, verbose_name='指导书', on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
instruction_content = models.TextField('指导书内容', 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)
|
store_notok = models.BooleanField('不合格品是否入库', default=False)
|
||||||
batch_append_equip = models.BooleanField('批号追加设备', default=False)
|
batch_append_equip = models.BooleanField('批号追加设备', default=False)
|
||||||
mlog_need_ticket = models.BooleanField('日志提交是否需要审批', default=False)
|
mlog_need_ticket = models.BooleanField('日志提交是否需要审批', default=False)
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,12 @@ from apps.mtm.models import (Shift, Material, Mgroup, Team, Goal,
|
||||||
Process, Route, TeamMember, RoutePack, Srule, RouteMat)
|
Process, Route, TeamMember, RoutePack, Srule, RouteMat)
|
||||||
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS_DEPT
|
from apps.utils.constants import EXCLUDE_FIELDS, EXCLUDE_FIELDS_BASE, EXCLUDE_FIELDS_DEPT
|
||||||
from rest_framework import serializers
|
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 apps.system.models import Dept, UserPost
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from apps.wf.serializers import TicketSimpleSerializer
|
from apps.wf.serializers import TicketSimpleSerializer
|
||||||
from apps.system.serializers import FileSerializer
|
from apps.system.serializers import FileSerializer
|
||||||
|
from apps.utils.permission import has_perm
|
||||||
|
|
||||||
|
|
||||||
class ShiftSerializer(CustomModelSerializer):
|
class ShiftSerializer(CustomModelSerializer):
|
||||||
|
|
@ -177,6 +178,23 @@ class RoutePackCopySerializer(serializers.Serializer):
|
||||||
material_in = serializers.CharField(label='原料ID')
|
material_in = serializers.CharField(label='原料ID')
|
||||||
material_out = 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):
|
class RouteSerializer(CustomModelSerializer):
|
||||||
name = serializers.CharField(source='__str__', read_only=True)
|
name = serializers.CharField(source='__str__', read_only=True)
|
||||||
material_ = MaterialSerializer(source='material', read_only=True)
|
material_ = MaterialSerializer(source='material', read_only=True)
|
||||||
|
|
@ -194,6 +212,7 @@ class RouteSerializer(CustomModelSerializer):
|
||||||
material_out_is_hidden = serializers.BooleanField(
|
material_out_is_hidden = serializers.BooleanField(
|
||||||
source='material_out.is_hidden', read_only=True)
|
source='material_out.is_hidden', read_only=True)
|
||||||
material_out_tracking = serializers.IntegerField(write_only=True, required=False, allow_null=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:
|
class Meta:
|
||||||
model = Route
|
model = Route
|
||||||
|
|
@ -201,6 +220,10 @@ class RouteSerializer(CustomModelSerializer):
|
||||||
read_only_fields = EXCLUDE_FIELDS
|
read_only_fields = EXCLUDE_FIELDS
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
if attrs.get('material_out') and attrs.get('material_out_create'):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
'选择已有输出物料和新建半成品不可同时提交'
|
||||||
|
)
|
||||||
if attrs.get('routepack', None):
|
if attrs.get('routepack', None):
|
||||||
attrs['material'] = attrs['routepack'].material
|
attrs['material'] = attrs['routepack'].material
|
||||||
if 'mgroup' in attrs and attrs['mgroup']:
|
if 'mgroup' in attrs and attrs['mgroup']:
|
||||||
|
|
@ -262,12 +285,30 @@ class RouteSerializer(CustomModelSerializer):
|
||||||
})
|
})
|
||||||
return material_out
|
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):
|
def create(self, validated_data):
|
||||||
process = validated_data['process']
|
process = validated_data['process']
|
||||||
routepack = validated_data.get('routepack', None)
|
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)
|
material_out_tracking = validated_data.pop("material_out_tracking", Material.MA_TRACKING_BATCH)
|
||||||
if material_out_tracking is None:
|
if material_out_tracking is None:
|
||||||
material_out_tracking = Material.MA_TRACKING_BATCH
|
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:
|
if routepack:
|
||||||
pass
|
pass
|
||||||
# if Route.objects.filter(routepack=routepack, process=process).exists():
|
# if Route.objects.filter(routepack=routepack, process=process).exists():
|
||||||
|
|
@ -308,12 +349,17 @@ class RouteSerializer(CustomModelSerializer):
|
||||||
# raise ParseError(f"该工艺步骤已存在-{msg}")
|
# raise ParseError(f"该工艺步骤已存在-{msg}")
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
validated_data.pop('material', None)
|
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)
|
material_out_tracking = validated_data.pop("material_out_tracking", Material.MA_TRACKING_BATCH)
|
||||||
if material_out_tracking is None:
|
if material_out_tracking is None:
|
||||||
material_out_tracking = Material.MA_TRACKING_BATCH
|
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:
|
if not validated_data.get('material_out', instance.material_out) and not instance.material:
|
||||||
raise ParseError('未指定输出物料,且工艺包未设置产品无法自动生成;请指定输出物料,或先在工艺包基本信息中选择产品')
|
raise ParseError('未指定输出物料,且工艺包未设置产品无法自动生成;请指定输出物料,或先在工艺包基本信息中选择产品')
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,69 @@
|
||||||
from django.test import TestCase
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -48,7 +48,7 @@ class FtestWorkFilter(filters.FilterSet):
|
||||||
"material": ["exact"],
|
"material": ["exact"],
|
||||||
"wm": ["exact", "isnull"],
|
"wm": ["exact", "isnull"],
|
||||||
"mb": ["exact", "isnull"],
|
"mb": ["exact", "isnull"],
|
||||||
"batch": ["exact"],
|
"batch": ["exact", "contains"],
|
||||||
"type": ["exact"],
|
"type": ["exact"],
|
||||||
"type2": ["exact"],
|
"type2": ["exact"],
|
||||||
"shift": ["exact"]
|
"shift": ["exact"]
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,25 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from rest_framework.exceptions import ParseError
|
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):
|
class Defect(CommonAModel):
|
||||||
"""TN:缺陷项"""
|
"""TN:缺陷项"""
|
||||||
DEFECT_OK = 10
|
DEFECT_OK = GRADE_OK
|
||||||
DEFECT_OK_B = 20
|
DEFECT_OK_B = GRADE_OK_B
|
||||||
DEFECT_NOTOK = 30
|
DEFECT_NOTOK = GRADE_NOTOK
|
||||||
cate_list = ["尺寸", "外观", "内质", "性能"]
|
cate_list = ["尺寸", "外观", "内质", "性能"]
|
||||||
name = models.CharField(max_length=50, verbose_name="名称")
|
name = models.CharField(max_length=50, verbose_name="名称")
|
||||||
code = models.CharField(max_length=50, verbose_name="标识", null=True, blank=True)
|
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))
|
cate = models.CharField(max_length=50, verbose_name="分类", help_text=str(cate_list))
|
||||||
okcate= models.PositiveSmallIntegerField(verbose_name="不合格分类",
|
okcate= models.PositiveSmallIntegerField(verbose_name="不合格分类",
|
||||||
choices=((DEFECT_OK, "合格"), (DEFECT_OK_B, "合格B类"), (DEFECT_NOTOK, "不合格")),
|
choices=DEFECT_GRADE_CHOICES,
|
||||||
default=DEFECT_NOTOK)
|
default=GRADE_NOTOK)
|
||||||
note = models.TextField('备注', null=True, blank=True)
|
note = models.TextField('备注', null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,7 @@ class FtestWorkSerializer(CustomModelSerializer):
|
||||||
material_cate = serializers.CharField(source='material.cate', read_only=True)
|
material_cate = serializers.CharField(source='material.cate', read_only=True)
|
||||||
mb_ = MaterialBatchDetailSerializer(source='mb', read_only=True)
|
mb_ = MaterialBatchDetailSerializer(source='mb', read_only=True)
|
||||||
test_user_name = serializers.CharField(source='test_user.name', 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:
|
class Meta:
|
||||||
model = FtestWork
|
model = FtestWork
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from apps.wf.models import Ticket
|
||||||
from apps.qm.models import NotOkOption, Defect
|
from apps.qm.models import NotOkOption, Defect
|
||||||
from apps.wpm.services_2 import ana_batch_thread
|
from apps.wpm.services_2 import ana_batch_thread
|
||||||
from apps.inm.models import MaterialBatch
|
from apps.inm.models import MaterialBatch
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
def ftestwork_submit_validate(ins: FtestWork):
|
def ftestwork_submit_validate(ins: FtestWork):
|
||||||
wm:WMaterial = ins.wm
|
wm:WMaterial = ins.wm
|
||||||
|
|
@ -21,8 +22,15 @@ def ftestwork_submit_validate(ins: FtestWork):
|
||||||
raise ParseError("不合格数不可大于批次数量")
|
raise ParseError("不合格数不可大于批次数量")
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def ftestwork_submit(ins:FtestWork, user: User):
|
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)
|
fwd_qs = FtestworkDefect.objects.filter(ftestwork=ins)
|
||||||
if wm and ins.need_update_wm:
|
if wm and ins.need_update_wm:
|
||||||
if ins.qct is None and not fwd_qs.exists():
|
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
|
need_move_count = need_move_count + v
|
||||||
count_ok = ins.count_ok - need_move_count
|
count_ok = ins.count_ok - need_move_count
|
||||||
if count_ok > 0:
|
if count_ok > 0:
|
||||||
wm, new_create = WMaterial.objects.get_or_create(
|
wm, new_create = WMaterial.locked_get_or_create_inventory(
|
||||||
material=wm.material,
|
material=wm.material,
|
||||||
batch=wm.batch,
|
batch=wm.batch,
|
||||||
mgroup=wm.mgroup,
|
mgroup=wm.mgroup,
|
||||||
|
|
@ -77,7 +85,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
|
||||||
astate = WMaterial.WM_NOTOK
|
astate = WMaterial.WM_NOTOK
|
||||||
if NotOkOption.get_extra_info(notok_sign)['cate'] == 'ok_b':
|
if NotOkOption.get_extra_info(notok_sign)['cate'] == 'ok_b':
|
||||||
astate = WMaterial.WM_OK
|
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,
|
material=wm.material,
|
||||||
batch=wm.batch,
|
batch=wm.batch,
|
||||||
mgroup=wm.mgroup,
|
mgroup=wm.mgroup,
|
||||||
|
|
@ -110,7 +118,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
|
||||||
wmstate = WMaterial.WM_OK
|
wmstate = WMaterial.WM_OK
|
||||||
if item.defect.okcate == Defect.DEFECT_NOTOK:
|
if item.defect.okcate == Defect.DEFECT_NOTOK:
|
||||||
wmstate = WMaterial.WM_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,
|
material=wm.material,
|
||||||
batch=wm.batch,
|
batch=wm.batch,
|
||||||
mgroup=wm.mgroup,
|
mgroup=wm.mgroup,
|
||||||
|
|
@ -127,7 +135,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
|
||||||
wmx.save()
|
wmx.save()
|
||||||
|
|
||||||
if ins.mb:
|
if ins.mb:
|
||||||
mb:MaterialBatch = ins.mb
|
mb = MaterialBatch.objects.select_for_update().get(pk=ins.mb_id)
|
||||||
for item in fwd_qs:
|
for item in fwd_qs:
|
||||||
item:FtestworkDefect = item
|
item:FtestworkDefect = item
|
||||||
if item.count > 0:
|
if item.count > 0:
|
||||||
|
|
@ -138,7 +146,7 @@ def ftestwork_submit(ins:FtestWork, user: User):
|
||||||
mbstate = WMaterial.WM_OK
|
mbstate = WMaterial.WM_OK
|
||||||
if item.defect.okcate == Defect.DEFECT_NOTOK:
|
if item.defect.okcate == Defect.DEFECT_NOTOK:
|
||||||
mbstate = WMaterial.WM_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,
|
material=mb.material,
|
||||||
warehouse=mb.warehouse,
|
warehouse=mb.warehouse,
|
||||||
batch=mb.batch,
|
batch=mb.batch,
|
||||||
|
|
@ -158,8 +166,15 @@ def ftestwork_submit(ins:FtestWork, user: User):
|
||||||
ana_batch_thread(xbatchs=[ins.batch])
|
ana_batch_thread(xbatchs=[ins.batch])
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def ftestwork_revert(ins: FtestWork):
|
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:
|
if wm and ins.need_update_wm:
|
||||||
fwd_qs = FtestworkDefect.objects.filter(ftestwork=ins)
|
fwd_qs = FtestworkDefect.objects.filter(ftestwork=ins)
|
||||||
for item in fwd_qs:
|
for item in fwd_qs:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from django.db import transaction
|
||||||
from apps.qm.services import ftestwork_submit, ftestwork_revert
|
from apps.qm.services import ftestwork_submit, ftestwork_revert
|
||||||
from apps.wpm.services_2 import ana_batch_thread
|
from apps.wpm.services_2 import ana_batch_thread
|
||||||
from apps.wf.models import State
|
from apps.wf.models import State
|
||||||
|
from django.db.models.functions import Length
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
||||||
class DefectViewSet(CustomModelViewSet):
|
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
|
serializer_class = QctSerializer
|
||||||
retrieve_serializer_class = QctDetailSerializer
|
retrieve_serializer_class = QctDetailSerializer
|
||||||
filterset_class = QctFilter
|
filterset_class = QctFilter
|
||||||
search_fields = ["name", "number"]
|
search_fields = ["name", "number"]
|
||||||
ordering = ["number", "-update_time"]
|
ordering = []
|
||||||
ordering_fields = ["number", "create_time", "update_time"]
|
ordering_fields = ["number", "create_time", "update_time"]
|
||||||
|
|
||||||
@action(methods=['post'], detail=False, perms_map={'post': '*'}, serializer_class=QctGetSerializer)
|
@action(methods=['post'], detail=False, perms_map={'post': '*'}, serializer_class=QctGetSerializer)
|
||||||
|
|
|
||||||
|
|
@ -651,6 +651,7 @@ class FileViewSet(BulkCreateModelMixin, RetrieveModelMixin, CustomListModelMixin
|
||||||
class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, GenericViewSet):
|
class ApkViewSet(MyLoggingMixin, CustomListModelMixin, BulkCreateModelMixin, GenericViewSet):
|
||||||
perms_map = {'get': '*', 'post': 'apk.upload'}
|
perms_map = {'get': '*', 'post': 'apk.upload'}
|
||||||
serializer_class = ApkSerializer
|
serializer_class = ApkSerializer
|
||||||
|
filter_backends = []
|
||||||
|
|
||||||
def get_authenticators(self):
|
def get_authenticators(self):
|
||||||
if self.request.method == 'GET':
|
if self.request.method == 'GET':
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ class SpeakerViewSet(CustomGenericViewSet):
|
||||||
"""
|
"""
|
||||||
perms_map = {}
|
perms_map = {}
|
||||||
serializer_class = serializers.Serializer
|
serializer_class = serializers.Serializer
|
||||||
|
filter_backends = []
|
||||||
|
|
||||||
@action(methods=['get'], detail=False,
|
@action(methods=['get'], detail=False,
|
||||||
permission_classes=[IsAuthenticated])
|
permission_classes=[IsAuthenticated])
|
||||||
|
|
@ -125,6 +126,7 @@ class XxTestView(APIView):
|
||||||
class XxCommonViewSet(CreateModelMixin, CustomGenericViewSet):
|
class XxCommonViewSet(CreateModelMixin, CustomGenericViewSet):
|
||||||
perms_map = {'post': '*'}
|
perms_map = {'post': '*'}
|
||||||
serializer_class = RequestCommonSerializer
|
serializer_class = RequestCommonSerializer
|
||||||
|
filter_backends = []
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
@ -258,6 +260,7 @@ class KingCommonViewSet(CreateModelMixin, CustomGenericViewSet):
|
||||||
class DhCommonViewSet(CreateModelMixin, CustomGenericViewSet):
|
class DhCommonViewSet(CreateModelMixin, CustomGenericViewSet):
|
||||||
perms_map = {'post': '*'}
|
perms_map = {'post': '*'}
|
||||||
serializer_class = RequestCommonSerializer
|
serializer_class = RequestCommonSerializer
|
||||||
|
filter_backends = []
|
||||||
|
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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}个操作)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -207,12 +207,28 @@ class CustomRetrieveModelMixin(RetrieveModelMixin):
|
||||||
|
|
||||||
class CustomListModelMixin(ListModelMixin):
|
class CustomListModelMixin(ListModelMixin):
|
||||||
|
|
||||||
@swagger_auto_schema(manual_parameters=[
|
@swagger_auto_schema(
|
||||||
openapi.Parameter(name="query", in_=openapi.IN_QUERY, description="定制返回数据",
|
operation_description=(
|
||||||
type=openapi.TYPE_STRING, required=False),
|
"通用列表接口用于记录或目录浏览以及逐条追溯。跨时间范围的产量、良率、缺陷、"
|
||||||
openapi.Parameter(name="with_children", in_=openapi.IN_QUERY, description="带有children(yes/no/count)",
|
"库存、绩效和趋势等统计聚合,优先查询 BI dataset 目录并执行匹配的数据集。"
|
||||||
type=openapi.TYPE_STRING, required=False),
|
),
|
||||||
])
|
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):
|
def list(self, request, *args, **kwargs):
|
||||||
|
|
||||||
queryset = self.filter_queryset(self.get_queryset())
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,27 @@ class SoftDeletableQuerySetMixin(object):
|
||||||
return super(SoftDeletableQuerySetMixin, self).delete()
|
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
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -111,6 +131,8 @@ class BaseModel(models.Model):
|
||||||
is_deleted = models.BooleanField(
|
is_deleted = models.BooleanField(
|
||||||
default=False, verbose_name='删除标记', help_text='删除标记')
|
default=False, verbose_name='删除标记', help_text='删除标记')
|
||||||
|
|
||||||
|
objects = SnowflakeManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
@ -154,8 +176,12 @@ class BaseModel(models.Model):
|
||||||
@classmethod
|
@classmethod
|
||||||
def locked_get_or_create(cls, defaults: dict, **kwargs):
|
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:
|
if not connection.in_atomic_block:
|
||||||
raise RuntimeError("locked_get_or_create 必须在事务中调用")
|
raise RuntimeError("locked_get_or_create 必须在事务中调用")
|
||||||
|
|
@ -185,7 +211,10 @@ class BaseModel(models.Model):
|
||||||
is_create = False
|
is_create = False
|
||||||
if not self.id:
|
if not self.id:
|
||||||
is_create = True
|
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
|
old_parent = None
|
||||||
need_handle_parent = False
|
need_handle_parent = False
|
||||||
|
|
@ -205,7 +234,7 @@ class BaseModel(models.Model):
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
if is_create:
|
if is_create:
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
self.id = idWorker.get_id()
|
self.id = str(idWorker.get_id())
|
||||||
ins = super().save(*args, **kwargs)
|
ins = super().save(*args, **kwargs)
|
||||||
raise e
|
raise e
|
||||||
# 处理父级
|
# 处理父级
|
||||||
|
|
|
||||||
|
|
@ -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__
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -154,6 +154,9 @@ class CustomGenericViewSet(MyLoggingMixin, GenericViewSet):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
queryset = self.get_queryset_custom(queryset)
|
queryset = self.get_queryset_custom(queryset)
|
||||||
|
# drf-yasg 生成文档时不应读取权限或业务数据。
|
||||||
|
if getattr(self, 'swagger_fake_view', False):
|
||||||
|
return queryset
|
||||||
if self.data_filter:
|
if self.data_filter:
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,38 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.hrm.models import Employee
|
from apps.hrm.models import Employee
|
||||||
from apps.hrm.services import HrmService
|
from apps.hrm.services import HrmService
|
||||||
|
|
||||||
from apps.vm.models import Visit
|
from apps.vm.models import Visit
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
|
|
||||||
|
|
||||||
class VisitTest(TestCase):
|
class VisitTest(TestCase):
|
||||||
def test_audit_end(self):
|
@patch.object(HrmService, 'sync_dahua_employee')
|
||||||
v = Visit.objects.get(id='1547401054033940480')
|
def test_sync_employee_uses_visit_time_window(self, sync_employee):
|
||||||
ep = Employee.objects.get(id='1547406632055607296')
|
visit_time = timezone.now()
|
||||||
HrmService.sync_dahua_employee(ep, '', v.visit_time, v.leave_time)
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -363,6 +363,34 @@ class TicketViewSet(CreateUpdateCustomMixin, CreateModelMixin, ListModelMixin, R
|
||||||
transitions = WfService.get_ticket_transitions(ticket)
|
transitions = WfService.get_ticket_transitions(ticket)
|
||||||
return Response(TransitionSerializer(instance=transitions.select_related('source_state', 'destination_state'), many=True).data)
|
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': '*'})
|
@action(methods=['post'], detail=True, perms_map={'post': '*'})
|
||||||
def accpet(self, request, pk=None):
|
def accpet(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from apps.mtm.models import Route, Material
|
||||||
from django.db.models import Q, Exists, OuterRef
|
from django.db.models import Q, Exists, OuterRef
|
||||||
from rest_framework.exceptions import ParseError
|
from rest_framework.exceptions import ParseError
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from apps.qm.defect_grades import effective_defect_grade_q
|
||||||
|
|
||||||
class SfLogFilter(filters.FilterSet):
|
class SfLogFilter(filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
@ -44,6 +45,10 @@ class WMaterialFilter(filters.FilterSet):
|
||||||
mlog_date_start = filters.DateFilter(label="产出开始", method="filter_mlog_date_start")
|
mlog_date_start = filters.DateFilter(label="产出开始", method="filter_mlog_date_start")
|
||||||
mlog_date_end = filters.DateFilter(label="产出结束", method="filter_mlog_date_end")
|
mlog_date_end = filters.DateFilter(label="产出结束", method="filter_mlog_date_end")
|
||||||
current_merged = filters.BooleanFilter(label="是否本工段新合成的批", method="filter_current_merged")
|
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):
|
def filter_mlog_date_start(self, queryset, name, value):
|
||||||
mgroupId = self.data.get("mgroup", None)
|
mgroupId = self.data.get("mgroup", None)
|
||||||
|
|
@ -123,7 +128,7 @@ class WMaterialFilter(filters.FilterSet):
|
||||||
"material__name": ["exact", "in", "contains"],
|
"material__name": ["exact", "in", "contains"],
|
||||||
"material__process": ["exact", "in"],
|
"material__process": ["exact", "in"],
|
||||||
"material__process__name": ["exact", "contains", "in"],
|
"material__process__name": ["exact", "contains", "in"],
|
||||||
"belong_dept": ["exact"],
|
"belong_dept": ["exact", "isnull"],
|
||||||
"belong_dept__name": ["exact", "in"],
|
"belong_dept__name": ["exact", "in"],
|
||||||
"batch": ["exact", "contains"],
|
"batch": ["exact", "contains"],
|
||||||
"mgroup": ["exact", "in", "isnull"],
|
"mgroup": ["exact", "in", "isnull"],
|
||||||
|
|
@ -132,7 +137,8 @@ class WMaterialFilter(filters.FilterSet):
|
||||||
"notok_sign": ["exact", "in", "isnull"],
|
"notok_sign": ["exact", "in", "isnull"],
|
||||||
"count_xtest": ["gte", "isnull"],
|
"count_xtest": ["gte", "isnull"],
|
||||||
"supplier": ["exact"],
|
"supplier": ["exact"],
|
||||||
"defect": ["exact", "isnull"]
|
"defect": ["exact", "isnull"],
|
||||||
|
"defect__okcate": ["exact", "in"],
|
||||||
}
|
}
|
||||||
|
|
||||||
class MlogFilter(filters.FilterSet):
|
class MlogFilter(filters.FilterSet):
|
||||||
|
|
@ -171,15 +177,23 @@ class HandoverFilter(filters.FilterSet):
|
||||||
dept = filters.CharFilter(label='DeptId', method='filter_dept')
|
dept = filters.CharFilter(label='DeptId', method='filter_dept')
|
||||||
|
|
||||||
def filter_mgroup(self, queryset, name, value):
|
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):
|
def filter_mgroupx(self, queryset, name, value):
|
||||||
dept = Mgroup.objects.get(id=value).belong_dept
|
dept = Mgroup.objects.get(id=value).belong_dept
|
||||||
return (queryset.filter(send_mgroup__id=value)|queryset.filter(recive_mgroup__id=value)|
|
return queryset.filter(
|
||||||
queryset.filter(send_dept=dept, send_mgroup__isnull=True)|queryset.filter(recive_dept=dept, recive_mgroup__isnull=True))
|
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):
|
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):
|
def filter_cbatch(self, queryset, name, value):
|
||||||
qs1 = queryset.filter(Q(batch=value)|Q(new_batch=value)|Q(wm__batch=value))
|
qs1 = queryset.filter(Q(batch=value)|Q(new_batch=value)|Q(wm__batch=value))
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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='接收部门',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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='单件缺陷提交前快照'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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="产出附件",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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='备注'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from apps.utils.models import CommonADModel, CommonBDModel, BaseModel
|
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.pm.models import Mtask, Mtaskb
|
||||||
from apps.system.models import User
|
from apps.system.models import User
|
||||||
from django.utils.timezone import localtime
|
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 django.utils.translation import gettext_lazy as _
|
||||||
from rest_framework.exceptions import ParseError
|
from rest_framework.exceptions import ParseError
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.db import transaction
|
from django.db import connection, transaction
|
||||||
from django.db.models import Max
|
from django.db.models import Max
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
from django.db.models import Q, F
|
from django.db.models import Q, F
|
||||||
import django.utils.timezone as timezone
|
import django.utils.timezone as timezone
|
||||||
|
|
@ -103,6 +104,9 @@ class WmStateOption(models.IntegerChoices):
|
||||||
class WMaterial(CommonBDModel):
|
class WMaterial(CommonBDModel):
|
||||||
"""
|
"""
|
||||||
TN: 车间库存
|
TN: 车间库存
|
||||||
|
|
||||||
|
归属范围分三级:mgroup 指定工段,只有 belong_dept 指定部门,
|
||||||
|
两者都为空表示全局库存。
|
||||||
"""
|
"""
|
||||||
WM_OK = 10
|
WM_OK = 10
|
||||||
WM_NOTOK = 20
|
WM_NOTOK = 20
|
||||||
|
|
@ -128,6 +132,122 @@ class WMaterial(CommonBDModel):
|
||||||
number_from = models.TextField("来源于个号", null=True, blank=True)
|
number_from = models.TextField("来源于个号", null=True, blank=True)
|
||||||
is_manual = models.BooleanField('手动创建', default=False)
|
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):
|
def delete(self, *args, **kwargs):
|
||||||
if not self.is_manual:
|
if not self.is_manual:
|
||||||
raise ParseError('只能删除手动创建的车间库存')
|
raise ParseError('只能删除手动创建的车间库存')
|
||||||
|
|
@ -144,7 +264,11 @@ class WMaterial(CommonBDModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def belong_dept_or_mgroup_id(self):
|
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
|
@property
|
||||||
def count_working(self):
|
def count_working(self):
|
||||||
|
|
@ -171,7 +295,6 @@ class WMaterial(CommonBDModel):
|
||||||
if qs is None:
|
if qs is None:
|
||||||
qs = cls.objects
|
qs = cls.objects
|
||||||
return WMaterial.ava_qs(mgroup=mtask.mgroup, qs=qs).filter(
|
return WMaterial.ava_qs(mgroup=mtask.mgroup, qs=qs).filter(
|
||||||
mgroup=mtask.mgroup,
|
|
||||||
material=mtask.material_in,
|
material=mtask.material_in,
|
||||||
batch__in=Subquery(
|
batch__in=Subquery(
|
||||||
Mlogb.objects.filter(
|
Mlogb.objects.filter(
|
||||||
|
|
@ -240,9 +363,16 @@ class Mlog(CommonADModel):
|
||||||
work_end_time = models.DateTimeField('生产结束时间', null=True, blank=True)
|
work_end_time = models.DateTimeField('生产结束时间', null=True, blank=True)
|
||||||
hour_work = models.FloatField('预计工时', null=True, blank=True)
|
hour_work = models.FloatField('预计工时', null=True, blank=True)
|
||||||
reminder_interval_list = models.JSONField('提醒间隔', default=list, 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)
|
stored_notok = models.BooleanField('不合格品是否已入库', default=False)
|
||||||
clear_defect = models.BooleanField('合格B类记为合格', 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)
|
route = models.ForeignKey(Route, verbose_name='生产路线', on_delete=models.SET_NULL, null=True, blank=True)
|
||||||
mtask = models.ForeignKey(
|
mtask = models.ForeignKey(
|
||||||
Mtask, verbose_name='关联任务', on_delete=models.CASCADE, null=True, blank=True, related_name='mlog_mtask')
|
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')
|
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)
|
shift = models.ForeignKey(Shift, verbose_name='关联班次', on_delete=models.CASCADE)
|
||||||
handle_date = models.DateField('操作日期')
|
handle_date = models.DateField('操作日期')
|
||||||
|
note = models.TextField('备注', default='', blank=True)
|
||||||
|
|
||||||
class Mlogb(BaseModel):
|
class Mlogb(BaseModel):
|
||||||
"""
|
"""
|
||||||
|
|
@ -610,6 +741,12 @@ class Mlogbw(BaseModel):
|
||||||
"""TN: 单个产品生产/检验日志
|
"""TN: 单个产品生产/检验日志
|
||||||
"""
|
"""
|
||||||
number = models.TextField('单个编号', db_index=True)
|
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")
|
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")
|
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
|
wpr = models.ForeignKey("wpmw.wpr", verbose_name='关联产品', on_delete=models.SET_NULL
|
||||||
|
|
@ -691,7 +828,8 @@ class Handover(CommonADModel):
|
||||||
send_mgroup = models.ForeignKey(
|
send_mgroup = models.ForeignKey(
|
||||||
Mgroup, verbose_name='送料工段', on_delete=models.CASCADE, null=True, blank=True)
|
Mgroup, verbose_name='送料工段', on_delete=models.CASCADE, null=True, blank=True)
|
||||||
send_dept = models.ForeignKey(
|
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)
|
batch = models.TextField('批次号', null=True, blank=True, db_index=True)
|
||||||
material = models.ForeignKey(
|
material = models.ForeignKey(
|
||||||
Material, verbose_name='物料', on_delete=models.CASCADE, related_name='h_ma')
|
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 = models.DecimalField('送料数', default=0, max_digits=11, decimal_places=1)
|
||||||
count_eweight = models.FloatField('单数重量', default=0)
|
count_eweight = models.FloatField('单数重量', default=0)
|
||||||
recive_dept = models.ForeignKey(
|
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_mgroup = models.ForeignKey(Mgroup, verbose_name='接收工段', on_delete=models.CASCADE, related_name='handover_recive_mgroup', null=True, blank=True)
|
||||||
recive_user = models.ForeignKey(
|
recive_user = models.ForeignKey(
|
||||||
User, verbose_name='接收人', on_delete=models.CASCADE, related_name='handover_recive_user', null=True, blank=True)
|
User, verbose_name='接收人', on_delete=models.CASCADE, related_name='handover_recive_user', null=True, blank=True)
|
||||||
|
|
@ -738,7 +877,7 @@ class Handoverb(BaseModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def handoverbw(self):
|
def handoverbw(self):
|
||||||
return Handoverbw.objects.filter(handoverb=self)
|
return self.w_handoverb.all()
|
||||||
|
|
||||||
class Handoverbw(BaseModel):
|
class Handoverbw(BaseModel):
|
||||||
"""TN: 单个产品交接记录
|
"""TN: 单个产品交接记录
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ from .models import (SfLog, StLog, SfLogExp, WMaterial, Mlog,
|
||||||
Handover, Handoverb, Mlogb, AttLog,
|
Handover, Handoverb, Mlogb, AttLog,
|
||||||
OtherLog, Fmlog, BatchSt, Mlogbw, Handoverbw, MlogbDefect, MlogUser, BatchLog)
|
OtherLog, Fmlog, BatchSt, Mlogbw, Handoverbw, MlogbDefect, MlogUser, BatchLog)
|
||||||
from apps.system.models import Dept, User
|
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.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.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.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 apps.mtm.serializers import MaterialSimpleSerializer
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
@ -24,12 +24,35 @@ from apps.wpmw.models import Wpr
|
||||||
from apps.qm.serializers import FtestProcessSerializer, FtestProcessListSerializer
|
from apps.qm.serializers import FtestProcessSerializer, FtestProcessListSerializer
|
||||||
import logging
|
import logging
|
||||||
from apps.qm.models import Defect
|
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 apps.utils.snowflake import idWorker
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from apps.em.models import Equipment
|
from apps.em.models import Equipment
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
mylogger = logging.getLogger("log")
|
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 OtherLogSerializer(CustomModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = OtherLog
|
model = OtherLog
|
||||||
|
|
@ -189,12 +212,21 @@ class WMaterialSerializer(CustomModelSerializer):
|
||||||
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
|
material_origin_name = serializers.StringRelatedField(source='material_origin', read_only=True)
|
||||||
notok_sign_name = serializers.SerializerMethodField()
|
notok_sign_name = serializers.SerializerMethodField()
|
||||||
defect_name = serializers.CharField(source="defect.name", read_only=True)
|
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_working = serializers.SerializerMethodField(label='在制数量')
|
||||||
count_handovering = serializers.SerializerMethodField(label='正在交送的数量')
|
count_handovering = serializers.SerializerMethodField(label='正在交送的数量')
|
||||||
|
|
||||||
def get_notok_sign_name(self, obj):
|
def get_notok_sign_name(self, obj):
|
||||||
return getattr(NotOkOption, obj.notok_sign, NotOkOption.qt).label if obj.notok_sign else None
|
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):
|
def get_count_working(self, obj):
|
||||||
# 列表接口 queryset 已注解(单次聚合); 嵌套等无注解场景回退模型属性
|
# 列表接口 queryset 已注解(单次聚合); 嵌套等无注解场景回退模型属性
|
||||||
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可用/可交接量偏大)
|
# 保留 decimal 精度(原 IntegerField 会截断在途量, 导致可用/可交接量偏大)
|
||||||
|
|
@ -232,18 +264,36 @@ class WMaterialSerializer(CustomModelSerializer):
|
||||||
class WMaterialCreateSerializer(CustomModelSerializer):
|
class WMaterialCreateSerializer(CustomModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = WMaterial
|
model = WMaterial
|
||||||
fields = ['material', 'count', 'batch', 'mgroup']
|
fields = ['material', 'count', 'batch', 'mgroup', 'belong_dept']
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
'material': {'required': True},
|
'material': {'required': True},
|
||||||
'count': {'required': True},
|
'count': {'required': True},
|
||||||
'batch': {'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):
|
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
|
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):
|
class MlogbDefectSerializer(CustomModelSerializer):
|
||||||
defect_name = serializers.CharField(source="defect.name", read_only=True)
|
defect_name = serializers.CharField(source="defect.name", read_only=True)
|
||||||
|
|
@ -329,8 +379,7 @@ class MlogListSerializer(CustomModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Mlog
|
model = Mlog
|
||||||
fields = '__all__'
|
exclude = ["wpr_defect_snapshot"]
|
||||||
# exclude = ["equipments","handle_users", "material_outs"]
|
|
||||||
|
|
||||||
|
|
||||||
class MlogbDetailSerializer(CustomModelSerializer):
|
class MlogbDetailSerializer(CustomModelSerializer):
|
||||||
|
|
@ -401,7 +450,7 @@ class MlogSerializer(CustomModelSerializer):
|
||||||
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
|
wm_in_state = serializers.IntegerField(source='wm_in.state', read_only=True)
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Mlog
|
model = Mlog
|
||||||
fields = '__all__'
|
exclude = ["wpr_defect_snapshot"]
|
||||||
read_only_fields = EXCLUDE_FIELDS + \
|
read_only_fields = EXCLUDE_FIELDS + \
|
||||||
['submit_time', 'submit_user', 'material_outs']
|
['submit_time', 'submit_user', 'material_outs']
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
|
|
@ -897,6 +946,8 @@ class MlogbInUpdateSerializer(CustomModelSerializer):
|
||||||
return ins
|
return ins
|
||||||
|
|
||||||
class MlogbwCreateUpdateSerializer(CustomModelSerializer):
|
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)
|
ftest = FtestProcessSerializer(required=False, allow_null=True)
|
||||||
equip_name = serializers.CharField(source='equip.name', read_only=True)
|
equip_name = serializers.CharField(source='equip.name', read_only=True)
|
||||||
equip_number = serializers.CharField(source='equip.number', 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",
|
fields = ["id", "number", "wpr", "note",
|
||||||
"mlogb", "ftest", "equip", "tooling", "work_start_time",
|
"mlogb", "ftest", "equip", "tooling", "work_start_time",
|
||||||
"work_end_time", "mlogbw_from", "equip_name", "equip_number",
|
"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"]
|
read_only_fields = ["mlogbw_from"]
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
|
|
@ -954,10 +1006,18 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
|
||||||
mlogbw = self.save_ftest(mlogbw, ftest_data)
|
mlogbw = self.save_ftest(mlogbw, ftest_data)
|
||||||
return mlogbw
|
return mlogbw
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
|
old_number = instance.number
|
||||||
validated_data.pop("mlogb")
|
validated_data.pop("mlogb")
|
||||||
ftest_data = validated_data.pop("ftest", None)
|
ftest_data = validated_data.pop("ftest", None)
|
||||||
mlogbw:Mlogbw = super().update(instance, validated_data)
|
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:
|
if ftest_data:
|
||||||
mlogbw = self.save_ftest(mlogbw, ftest_data)
|
mlogbw = self.save_ftest(mlogbw, ftest_data)
|
||||||
elif ftest_data is None:
|
elif ftest_data is None:
|
||||||
|
|
@ -972,6 +1032,15 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
|
||||||
class MlogbwListSerializer(MlogbwCreateUpdateSerializer):
|
class MlogbwListSerializer(MlogbwCreateUpdateSerializer):
|
||||||
ftest = FtestProcessListSerializer(read_only=True)
|
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):
|
class MlogbwStartTestSerializer(serializers.Serializer):
|
||||||
mlogbw_ids = serializers.ListField(child=serializers.CharField(), label="mlogbwId列表")
|
mlogbw_ids = serializers.ListField(child=serializers.CharField(), label="mlogbwId列表")
|
||||||
test_equip = serializers.CharField(label="测试设备", allow_null=True, required=False, allow_blank=True)
|
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"]
|
read_only_fields = EXCLUDE_FIELDS_BASE + ["handoverb", "number"]
|
||||||
extra_kwargs = {'wpr': {'required': True}}
|
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):
|
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 = 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_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)
|
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:
|
class Meta:
|
||||||
model = Handoverb
|
model = Handoverb
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
@ -1244,9 +1359,14 @@ class HandoverSerializer(CustomModelSerializer):
|
||||||
recive_user_name = serializers.CharField(
|
recive_user_name = serializers.CharField(
|
||||||
source='recive_user.name', read_only=True)
|
source='recive_user.name', read_only=True)
|
||||||
recive_dept_name = serializers.CharField(
|
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)
|
send_mgroup_name = serializers.CharField(source='send_mgroup.name', read_only=True)
|
||||||
recive_mgroup_name = serializers.CharField(source='recive_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_ = MaterialSimpleSerializer(source='material', read_only=True)
|
||||||
material_name = serializers.StringRelatedField(
|
material_name = serializers.StringRelatedField(
|
||||||
source='material', read_only=True)
|
source='material', read_only=True)
|
||||||
|
|
@ -1255,6 +1375,30 @@ class HandoverSerializer(CustomModelSerializer):
|
||||||
handoverb = HandoverbSerializer(many=True, required=False)
|
handoverb = HandoverbSerializer(many=True, required=False)
|
||||||
ticket_ = TicketSimpleSerializer(source='ticket', read_only=True)
|
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):
|
def validate(self, attrs):
|
||||||
if "mtype" not in attrs:
|
if "mtype" not in attrs:
|
||||||
attrs['mtype'] = Handover.H_NORMAL
|
attrs['mtype'] = Handover.H_NORMAL
|
||||||
|
|
@ -1262,6 +1406,9 @@ class HandoverSerializer(CustomModelSerializer):
|
||||||
attrs['type'] = Handover.H_NORMAL
|
attrs['type'] = Handover.H_NORMAL
|
||||||
|
|
||||||
mtype = attrs["mtype"]
|
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)
|
new_wm:WMaterial = attrs.get("new_wm", None)
|
||||||
if new_wm:
|
if new_wm:
|
||||||
|
|
@ -1297,16 +1444,68 @@ class HandoverSerializer(CustomModelSerializer):
|
||||||
else:
|
else:
|
||||||
raise ParseError('必须指定车间库存')
|
raise ParseError('必须指定车间库存')
|
||||||
attrs['material'] = wm.material
|
attrs['material'] = wm.material
|
||||||
attrs['send_dept'] = wm.belong_dept
|
is_global_source = wm.mgroup_id is None and wm.belong_dept_id is None
|
||||||
if wm.mgroup:
|
send_mgroup = attrs.get('send_mgroup')
|
||||||
|
if send_mgroup:
|
||||||
|
attrs['send_dept'] = send_mgroup.belong_dept
|
||||||
|
else:
|
||||||
|
attrs['send_dept'] = wm.belong_dept
|
||||||
|
if not send_mgroup and wm.mgroup:
|
||||||
attrs['send_mgroup'] = 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('必须指定交接工段')
|
raise ParseError('必须指定交接工段')
|
||||||
if 'recive_mgroup' in attrs and attrs['recive_mgroup']:
|
if 'recive_mgroup' in attrs and attrs['recive_mgroup']:
|
||||||
attrs['recive_dept'] = attrs['recive_mgroup'].belong_dept
|
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('必须指定收料车间或收料工段')
|
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('必须指定送料车间或送料工段')
|
raise ParseError('必须指定送料车间或送料工段')
|
||||||
|
|
||||||
# if attrs["mtype"] == Handover.H_NORMAL and attrs.get("recive_mgroup", None) == attrs.get("send_mgroup", None):
|
# 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:
|
elif deptOrmgroupId != current_mdept_id:
|
||||||
raise ParseError(f'第{ind+1}行-交接物料所属工段/车间不一致')
|
raise ParseError(f'第{ind+1}行-交接物料所属工段/车间不一致')
|
||||||
if mtype == Handover.H_MERGE:
|
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:
|
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:
|
if next_mat is None:
|
||||||
next_mat = wm.material
|
next_mat = wm.material
|
||||||
next_state = wm.state
|
next_state = wm.state
|
||||||
|
|
@ -1463,7 +1665,7 @@ class HandoverSerializer(CustomModelSerializer):
|
||||||
elif count == wm.count:
|
elif count == wm.count:
|
||||||
wpr_qs = Wpr.get_qs_by_wm(wm)
|
wpr_qs = Wpr.get_qs_by_wm(wm)
|
||||||
for item in wpr_qs:
|
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})
|
defaults={"number": item.number})
|
||||||
else:
|
else:
|
||||||
raise ParseError(f'第{ind+1}行-请提供交接物料明细')
|
raise ParseError(f'第{ind+1}行-请提供交接物料明细')
|
||||||
|
|
@ -1641,6 +1843,7 @@ class MlogQuickSerializer(serializers.Serializer):
|
||||||
route = serializers.CharField(label="工艺步骤ID", required=False)
|
route = serializers.CharField(label="工艺步骤ID", required=False)
|
||||||
mtask = serializers.CharField(label="任务ID", required=False)
|
mtask = serializers.CharField(label="任务ID", required=False)
|
||||||
handle_user = serializers.CharField(label="操作人员ID")
|
handle_user = serializers.CharField(label="操作人员ID")
|
||||||
|
supplier = serializers.CharField(label="外协单位ID", required=False)
|
||||||
|
|
||||||
|
|
||||||
class BatchChangeSerializer(serializers.Serializer):
|
class BatchChangeSerializer(serializers.Serializer):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
@ -21,11 +22,50 @@ from apps.wpmw.models import Wpr, WprDefect
|
||||||
from ..qm.models import Defect, Ftest
|
from ..qm.models import Defect, Ftest
|
||||||
from django.db.models import Count, Q
|
from django.db.models import Count, Q
|
||||||
from apps.utils.tasks import ctask_run
|
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
|
from django.db.models import F
|
||||||
|
|
||||||
myLogger = logging.getLogger('log')
|
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):
|
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
|
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]):
|
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
|
mgroup = mlog.mgroup
|
||||||
process = mgroup.process
|
process = mgroup.process
|
||||||
stored_mgroup = process.into_wm_mgroup
|
stored_scope = process.into_wm_scope
|
||||||
stored_notok = process.store_notok
|
stored_notok = process.store_notok
|
||||||
belong_dept = mgroup.belong_dept
|
belong_dept = mgroup.belong_dept
|
||||||
material_out: Material = mlog.material_out
|
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 # 外协
|
supplier = mlog.supplier # 外协
|
||||||
is_fix = mlog.is_fix
|
is_fix = mlog.is_fix
|
||||||
if 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_list = []
|
||||||
m_ins_bl_list = []
|
m_ins_bl_list = []
|
||||||
|
|
||||||
|
wpr_defect_snapshot = snapshot_mlog_wpr_defects(mlog)
|
||||||
|
|
||||||
if is_fix:
|
if is_fix:
|
||||||
can_matoutIds = process.get_canout_mat_ids()
|
can_matoutIds = process.get_canout_mat_ids()
|
||||||
# 建立关系链
|
# 建立关系链
|
||||||
|
|
@ -237,15 +348,23 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
continue
|
continue
|
||||||
if mi_count <= 0:
|
if mi_count <= 0:
|
||||||
raise ParseError('存在非正数!')
|
raise ParseError('存在非正数!')
|
||||||
# 需要判断领用数是否合理
|
# 优先使用日志已选库存;未选择时按工段、部门、全局依次匹配并记录实际来源
|
||||||
# 优先使用工段库存
|
wm_in = getattr(mlog_or_b, 'wm_in', None)
|
||||||
if isinstance(mlog_or_b, Mlogb) and mlog_or_b.wm_in:
|
if wm_in:
|
||||||
wm = WMaterial.objects.select_for_update().get(id=mlog_or_b.wm_in.id)
|
wm = WMaterial.objects.select_for_update().get(id=wm_in.id)
|
||||||
else:
|
else:
|
||||||
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma, mgroup=mgroup, state=WMaterial.WM_OK)
|
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma, mgroup=mgroup, state=WMaterial.WM_OK)
|
||||||
if not wm_qs.exists():
|
if not wm_qs.exists():
|
||||||
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma,
|
wm_qs = WMaterial.objects.filter(batch=mi_batch, material=mi_ma,
|
||||||
belong_dept=belong_dept, mgroup=None, state=WMaterial.WM_OK)
|
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()
|
count_x = wm_qs.count()
|
||||||
if count_x == 1:
|
if count_x == 1:
|
||||||
wm = WMaterial.objects.select_for_update().get(id=wm_qs.first().id)
|
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:
|
else:
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
f'{str(mi_ma)}-{mi_batch}-存在多个相同批次!')
|
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:
|
if mi_count > wm.count:
|
||||||
raise ParseError(
|
raise ParseError(
|
||||||
|
|
@ -268,7 +389,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
if mlogbws.count() != mi_count:
|
if mlogbws.count() != mi_count:
|
||||||
raise ParseError("日志与实际使用明细数量不一致,操作失败")
|
raise ParseError("日志与实际使用明细数量不一致,操作失败")
|
||||||
for item in mlogbws:
|
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类的需要当合格品
|
# 合格B类的需要当合格品
|
||||||
|
|
@ -280,8 +402,15 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
c_state = WMaterial.WM_NOTOK
|
c_state = WMaterial.WM_NOTOK
|
||||||
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
|
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
|
||||||
c_state = WMaterial.WM_OK
|
c_state = WMaterial.WM_OK
|
||||||
lookup = {'batch': batch, 'material': material, 'mgroup': mgroup, 'defect': defect, 'state': c_state}
|
lookup = {
|
||||||
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
|
'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
|
wm.count = wm.count + count
|
||||||
if is_create:
|
if is_create:
|
||||||
wm.create_by = user
|
wm.create_by = user
|
||||||
|
|
@ -294,7 +423,12 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
if mlogbws.count() != count:
|
if mlogbws.count() != count:
|
||||||
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
|
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
|
||||||
for item in mlogbws:
|
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)
|
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 (
|
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]
|
isinstance(notok_sign_or_defect, Defect) and notok_sign_or_defect.okcate in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]
|
||||||
) else WMaterial.WM_NOTOK
|
) else WMaterial.WM_NOTOK
|
||||||
lookup = {'batch': mo_batch, 'material': mo_ma, 'mgroup': None,
|
lookup = {
|
||||||
'notok_sign': None, 'defect': None, 'state': wm_state}
|
'batch': mo_batch,
|
||||||
|
'material': mo_ma,
|
||||||
|
'notok_sign': None,
|
||||||
|
'defect': None,
|
||||||
|
'state': wm_state,
|
||||||
|
**stored_location,
|
||||||
|
}
|
||||||
if isinstance(notok_sign_or_defect, Defect):
|
if isinstance(notok_sign_or_defect, Defect):
|
||||||
lookup['defect'] = notok_sign_or_defect
|
lookup['defect'] = notok_sign_or_defect
|
||||||
elif notok_sign_or_defect is not None:
|
elif notok_sign_or_defect is not None:
|
||||||
lookup['notok_sign'] = notok_sign_or_defect
|
lookup['notok_sign'] = notok_sign_or_defect
|
||||||
if stored_mgroup:
|
wm, is_create2 = WMaterial.locked_get_or_create_inventory(
|
||||||
lookup['mgroup'] = mgroup
|
**lookup, defaults={})
|
||||||
else:
|
|
||||||
lookup['belong_dept'] = belong_dept
|
|
||||||
|
|
||||||
wm, is_create2 = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
|
|
||||||
wm.count = wm.count + mo_count
|
wm.count = wm.count + mo_count
|
||||||
wm.count_eweight = mo_count_eweight
|
wm.count_eweight = mo_count_eweight
|
||||||
wm.update_by = user
|
wm.update_by = user
|
||||||
|
|
@ -399,25 +535,43 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
raise ParseError("日志与明细数量不一致,操作失败")
|
raise ParseError("日志与明细数量不一致,操作失败")
|
||||||
for item in mlogbws:
|
for item in mlogbws:
|
||||||
if item.wpr:
|
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:
|
else:
|
||||||
if not check_wpr_number(item.number):
|
if not check_wpr_number(item.number):
|
||||||
raise ParseError(f'{item.number} 该编号格式有误')
|
raise ParseError(f'{item.number} 该编号格式有误')
|
||||||
wpr_from = None
|
wpr_from = None
|
||||||
if item.mlogbw_from:
|
if item.mlogbw_from:
|
||||||
wpr_from = item.mlogbw_from.wpr
|
wpr_from = item.mlogbw_from.wpr
|
||||||
wpr = Wpr.change_or_new(number=item.number,
|
wpr = Wpr.change_or_new(
|
||||||
wm=wm, ftest=item.ftest,
|
number=item.number,
|
||||||
wpr_from=wpr_from)
|
wm=wm,
|
||||||
|
wpr_from=wpr_from,
|
||||||
|
)
|
||||||
item.wpr = wpr
|
item.wpr = wpr
|
||||||
item.save()
|
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_time = now
|
||||||
mlog.submit_user = user
|
mlog.submit_user = user
|
||||||
mlog.stored_notok = stored_notok
|
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:
|
if mlog.work_end_time is None and mlog.work_start_time is not None:
|
||||||
mlog.work_end_time = now
|
mlog.work_end_time = now
|
||||||
|
mlog.wpr_defect_snapshot = wpr_defect_snapshot
|
||||||
mlog.save()
|
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_out:Material = mlog.material_out
|
||||||
material_in:Material = mlog.material_in
|
material_in:Material = mlog.material_in
|
||||||
stored_notok = mlog.stored_notok
|
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
|
is_fix = mlog.is_fix
|
||||||
|
has_wpr_defect_snapshot = mlog.wpr_defect_snapshot is not None
|
||||||
|
|
||||||
if is_fix:
|
if is_fix:
|
||||||
can_matoutIds = process.get_canout_mat_ids()
|
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 (
|
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]
|
isinstance(notok_sign_or_defect, Defect) and notok_sign_or_defect.okcate in [Defect.DEFECT_OK, Defect.DEFECT_OK_B]
|
||||||
) else WMaterial.WM_NOTOK
|
) 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):
|
if isinstance(notok_sign_or_defect, Defect):
|
||||||
lookup['defect'] = notok_sign_or_defect
|
lookup['defect'] = notok_sign_or_defect
|
||||||
else:
|
else:
|
||||||
lookup['notok_sign'] = notok_sign_or_defect
|
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)
|
wm_qs = WMaterial.objects.filter(**lookup)
|
||||||
count_x = wm_qs.count()
|
count_x = wm_qs.count()
|
||||||
if count_x == 1:
|
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:
|
if mlogbws.count() != mo_count:
|
||||||
raise ParseError("日志与明细数量不一致,操作失败")
|
raise ParseError("日志与明细数量不一致,操作失败")
|
||||||
for item in mlogbws:
|
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 = []
|
m_ins_list = []
|
||||||
|
|
@ -584,18 +744,19 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
continue
|
continue
|
||||||
if mi_count <= 0:
|
if mi_count <= 0:
|
||||||
raise ParseError('存在非正数!')
|
raise ParseError('存在非正数!')
|
||||||
if isinstance(mlog_or_b, Mlogb) and mlog_or_b.wm_in:
|
wm_in = getattr(mlog_or_b, 'wm_in', None)
|
||||||
wm = WMaterial.objects.select_for_update().get(id=mlog_or_b.wm_in.id)
|
if wm_in:
|
||||||
|
wm = WMaterial.objects.select_for_update().get(id=wm_in.id)
|
||||||
else:
|
else:
|
||||||
# 针对光子的情况,实际上必须需要wm_in
|
# 针对光子的情况,实际上必须需要wm_in
|
||||||
lookup = {'batch': mi_batch, 'material': mi_ma, 'mgroup': None, 'state': WMaterial.WM_OK}
|
lookup = {
|
||||||
if stored_mgroup:
|
'batch': mi_batch,
|
||||||
# 退回到本工段
|
'material': mi_ma,
|
||||||
lookup['mgroup'] = mgroup
|
'state': WMaterial.WM_OK,
|
||||||
else:
|
**stored_location,
|
||||||
lookup['belong_dept'] = belong_dept
|
}
|
||||||
|
wm, _ = WMaterial.locked_get_or_create_inventory(
|
||||||
wm, _ = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
|
**lookup, defaults={})
|
||||||
wm.count = wm.count + mi_count
|
wm.count = wm.count + mi_count
|
||||||
wm.update_by = user
|
wm.update_by = user
|
||||||
wm.save()
|
wm.save()
|
||||||
|
|
@ -604,7 +765,9 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
if mlogbws.count() != mi_count:
|
if mlogbws.count() != mi_count:
|
||||||
raise ParseError("日志与实际使用明细数量不一致,操作失败")
|
raise ParseError("日志与实际使用明细数量不一致,操作失败")
|
||||||
for item in mlogbws:
|
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:
|
if stored_notok:
|
||||||
|
|
@ -615,12 +778,15 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
c_state = WMaterial.WM_NOTOK
|
c_state = WMaterial.WM_NOTOK
|
||||||
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
|
if isinstance(defect, Defect) and defect.okcate == Defect.DEFECT_OK_B:
|
||||||
c_state = WMaterial.WM_OK
|
c_state = WMaterial.WM_OK
|
||||||
lookup = {'batch': batch, 'material': material, 'mgroup': None, 'defect': defect, 'state': c_state}
|
lookup = {
|
||||||
if stored_mgroup:
|
'batch': batch,
|
||||||
lookup['mgroup'] = mgroup
|
'material': material,
|
||||||
else:
|
'defect': defect,
|
||||||
lookup['belong_dept'] = belong_dept
|
'state': c_state,
|
||||||
wm, is_create = WMaterial.locked_get_or_create(**lookup, defaults={"belong_dept": belong_dept})
|
**stored_location,
|
||||||
|
}
|
||||||
|
wm, is_create = WMaterial.locked_get_or_create_inventory(
|
||||||
|
**lookup, defaults={})
|
||||||
wm.count = wm.count - count
|
wm.count = wm.count - count
|
||||||
if wm.count < 0:
|
if wm.count < 0:
|
||||||
raise ParseError('加工前不良数量大于库存量')
|
raise ParseError('加工前不良数量大于库存量')
|
||||||
|
|
@ -634,7 +800,18 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
|
||||||
if mlogbws.count() != count:
|
if mlogbws.count() != count:
|
||||||
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
|
raise ParseError("日志与加工前不良明细数量不一致,操作失败")
|
||||||
for item in mlogbws:
|
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_time = None
|
||||||
mlog.submit_user = 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)
|
WMaterial.decrease(wm_id=wm_from.id, user=user, count=xcount)
|
||||||
|
|
||||||
if need_add:
|
if need_add:
|
||||||
|
target_defect = (
|
||||||
|
merge_target_defect
|
||||||
|
if mtype == Handover.H_MERGE
|
||||||
|
else wm_from.defect
|
||||||
|
)
|
||||||
# 开始变动
|
# 开始变动
|
||||||
if handover.type == Handover.H_NORMAL:
|
if handover.type == Handover.H_NORMAL:
|
||||||
if mtype == Handover.H_MERGE and handover.new_wm:
|
if mtype == Handover.H_MERGE and handover.new_wm:
|
||||||
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
|
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
|
||||||
defect_ok = wm_to.defect == wm_from.defect or (
|
if (
|
||||||
merge_clear_defect and wm_to.defect is None and wm_from.state == WMaterial.WM_OK
|
wm_to.state != wm_from.state
|
||||||
)
|
or wm_to.material != wm_from.material
|
||||||
if wm_to.state != wm_from.state or wm_to.material != wm_from.material or not defect_ok:
|
or wm_to.defect != target_defect
|
||||||
|
):
|
||||||
raise ParseError("正常合并到的车间库存状态或物料异常")
|
raise ParseError("正常合并到的车间库存状态或物料异常")
|
||||||
else:
|
else:
|
||||||
wm_to, _ = WMaterial.locked_get_or_create(
|
wm_to, _ = WMaterial.locked_get_or_create_inventory(
|
||||||
batch=batch,
|
batch=batch,
|
||||||
material=material,
|
material=material,
|
||||||
mgroup=recive_mgroup,
|
mgroup=recive_mgroup,
|
||||||
belong_dept=recive_dept,
|
belong_dept=recive_dept,
|
||||||
state=wm_from.state,
|
state=wm_from.state,
|
||||||
notok_sign=wm_from.notok_sign,
|
notok_sign=wm_from.notok_sign,
|
||||||
defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.defect,
|
defect=target_defect,
|
||||||
defaults={
|
defaults={
|
||||||
"batch_ofrom": wm_from.batch_ofrom,
|
"batch_ofrom": wm_from.batch_ofrom,
|
||||||
"material_ofrom": wm_from.material_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
|
wm_state = WMaterial.WM_REPAIR
|
||||||
if mtype == Handover.H_MERGE and handover.new_wm:
|
if mtype == Handover.H_MERGE and handover.new_wm:
|
||||||
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
|
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("返修合并到的车间库存状态或物料异常")
|
raise ParseError("返修合并到的车间库存状态或物料异常")
|
||||||
elif recive_mgroup:
|
elif recive_mgroup:
|
||||||
wm_to, _ = WMaterial.locked_get_or_create(
|
wm_to, _ = WMaterial.locked_get_or_create_inventory(
|
||||||
batch=batch,
|
batch=batch,
|
||||||
material=material,
|
material=material,
|
||||||
mgroup=recive_mgroup,
|
mgroup=recive_mgroup,
|
||||||
belong_dept=recive_dept,
|
belong_dept=recive_dept,
|
||||||
notok_sign=wm_from.notok_sign,
|
notok_sign=wm_from.notok_sign,
|
||||||
defect=wm_from.defect,
|
defect=target_defect,
|
||||||
material_origin=material,
|
material_origin=material,
|
||||||
state=wm_state,
|
state=wm_state,
|
||||||
defaults={
|
defaults={
|
||||||
|
|
@ -900,16 +1087,20 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
|
||||||
elif handover.type == Handover.H_SCRAP:
|
elif handover.type == Handover.H_SCRAP:
|
||||||
if mtype == Handover.H_MERGE and handover.new_wm:
|
if mtype == Handover.H_MERGE and handover.new_wm:
|
||||||
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
|
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("报废合并到的车间库存状态或物料异常")
|
raise ParseError("报废合并到的车间库存状态或物料异常")
|
||||||
elif recive_mgroup:
|
elif recive_mgroup:
|
||||||
wm_to, _ = WMaterial.locked_get_or_create(
|
wm_to, _ = WMaterial.locked_get_or_create_inventory(
|
||||||
batch=batch,
|
batch=batch,
|
||||||
material=material,
|
material=material,
|
||||||
mgroup=recive_mgroup,
|
mgroup=recive_mgroup,
|
||||||
belong_dept=recive_dept,
|
belong_dept=recive_dept,
|
||||||
notok_sign=wm_from.notok_sign,
|
notok_sign=wm_from.notok_sign,
|
||||||
defect=wm_from.defect,
|
defect=target_defect,
|
||||||
state=WMaterial.WM_SCRAP,
|
state=WMaterial.WM_SCRAP,
|
||||||
defaults={
|
defaults={
|
||||||
"batch_ofrom": wm_from.batch_ofrom,
|
"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:
|
elif handover.type == Handover.H_CHANGE:
|
||||||
if mtype == Handover.H_MERGE and handover.new_wm:
|
if mtype == Handover.H_MERGE and handover.new_wm:
|
||||||
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
|
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("改版合并到的车间库存状态或物料异常")
|
raise ParseError("改版合并到的车间库存状态或物料异常")
|
||||||
elif handover.recive_mgroup:
|
elif handover.recive_mgroup:
|
||||||
wm_to, _ = WMaterial.locked_get_or_create(
|
wm_to, _ = WMaterial.locked_get_or_create_inventory(
|
||||||
batch=batch,
|
batch=batch,
|
||||||
material=handover.material_changed,
|
material=handover.material_changed,
|
||||||
state=handover.state_changed,
|
state=handover.state_changed,
|
||||||
mgroup=recive_mgroup,
|
mgroup=recive_mgroup,
|
||||||
belong_dept=recive_dept,
|
belong_dept=recive_dept,
|
||||||
notok_sign=wm_from.notok_sign,
|
notok_sign=wm_from.notok_sign,
|
||||||
defect=wm_from.defect,
|
defect=target_defect,
|
||||||
material_origin=None if handover.material_changed == material else material,
|
material_origin=None if handover.material_changed == material else material,
|
||||||
state_origin=None if handover.state_changed == wm_from.state else wm_from.state,
|
state_origin=None if handover.state_changed == wm_from.state else wm_from.state,
|
||||||
defaults={
|
defaults={
|
||||||
|
|
@ -950,15 +1145,21 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
|
||||||
raise ParseError("仅合格品支持退回")
|
raise ParseError("仅合格品支持退回")
|
||||||
if mtype == Handover.H_MERGE and handover.new_wm:
|
if mtype == Handover.H_MERGE and handover.new_wm:
|
||||||
wm_to = WMaterial.objects.select_for_update().get(id=handover.new_wm.id)
|
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:
|
else:
|
||||||
wm_to, _ = WMaterial.locked_get_or_create(
|
wm_to, _ = WMaterial.locked_get_or_create_inventory(
|
||||||
batch=batch,
|
batch=batch,
|
||||||
material=material,
|
material=material,
|
||||||
mgroup=recive_mgroup,
|
mgroup=recive_mgroup,
|
||||||
belong_dept=recive_dept,
|
belong_dept=recive_dept,
|
||||||
state=wm_from.state,
|
state=wm_from.state,
|
||||||
notok_sign=wm_from.notok_sign,
|
notok_sign=wm_from.notok_sign,
|
||||||
defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.defect,
|
defect=target_defect,
|
||||||
defaults={
|
defaults={
|
||||||
"batch_ofrom": wm_from.batch_ofrom,
|
"batch_ofrom": wm_from.batch_ofrom,
|
||||||
"material_ofrom": wm_from.material_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)}个不同物料批次')
|
raise ParseError(f'{batch}物料明细中存在{len(wm_ids)}个不同物料批次')
|
||||||
for item in handoverbws:
|
for item in handoverbws:
|
||||||
wpr:Wpr = item.wpr
|
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()
|
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():
|
if db_count != Wpr.objects.filter(wm=wm_to).count():
|
||||||
raise ParseError("交接与明细数量不一致2,操作失败")
|
raise ParseError("交接与明细数量不一致2,操作失败")
|
||||||
|
|
@ -1038,7 +1244,13 @@ def handover_revert(handover:Handover, handler:User=None):
|
||||||
wm = WMaterial.objects.get(id=wm.id)
|
wm = WMaterial.objects.get(id=wm.id)
|
||||||
for item in handoverbws:
|
for item in handoverbws:
|
||||||
wpr:Wpr = item.wpr
|
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_time = None
|
||||||
handover.submit_user = None
|
handover.submit_user = None
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -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,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
@ -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, [])
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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/",
|
||||||
|
)
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
from string import Formatter
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ParseError
|
from rest_framework.exceptions import ParseError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import Serializer
|
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 django.utils import timezone
|
||||||
from apps.system.models import User
|
from apps.system.models import User
|
||||||
|
|
||||||
|
|
@ -46,19 +47,30 @@ from .serializers import (
|
||||||
BatchStSerializer,
|
BatchStSerializer,
|
||||||
MlogbwCreateUpdateSerializer,
|
MlogbwCreateUpdateSerializer,
|
||||||
MlogbwListSerializer,
|
MlogbwListSerializer,
|
||||||
|
MlogbwFilesSerializer,
|
||||||
HandoverMgroupSerializer,
|
HandoverMgroupSerializer,
|
||||||
MlogListSerializer,
|
MlogListSerializer,
|
||||||
MlogbSerializer,
|
MlogbSerializer,
|
||||||
MlogUserSerializer,
|
MlogUserSerializer,
|
||||||
BatchLogSerializer,
|
BatchLogSerializer,
|
||||||
MlogQuickSerializer,
|
MlogQuickSerializer,
|
||||||
|
MlogEquipmentOptionSerializer,
|
||||||
MlogbwStartTestSerializer,
|
MlogbwStartTestSerializer,
|
||||||
HandoverListSerializer,
|
HandoverListSerializer,
|
||||||
BatchChangeSerializer,
|
BatchChangeSerializer,
|
||||||
MlogbOutPatchUpdateSerializer
|
MlogbOutPatchUpdateSerializer
|
||||||
)
|
)
|
||||||
from .services import mlog_submit, handover_submit, mlog_revert, get_batch_dag, handover_revert
|
from .services import (
|
||||||
from apps.wpm.services import mlog_submit_validate, generate_new_batch
|
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.wf.models import State, Ticket
|
||||||
from apps.wpmw.models import Wpr
|
from apps.wpmw.models import Wpr
|
||||||
from apps.qm.models import Qct, Ftest, TestItem
|
from apps.qm.models import Qct, Ftest, TestItem
|
||||||
|
|
@ -72,7 +84,6 @@ from django.db.models import Prefetch
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.db.models.functions import Substr, Length
|
|
||||||
from apps.qm.models import FtestDefect, FtestItem
|
from apps.qm.models import FtestDefect, FtestItem
|
||||||
|
|
||||||
# Create your views here.
|
# Create your views here.
|
||||||
|
|
@ -331,6 +342,92 @@ class MlogViewSet(CustomModelViewSet):
|
||||||
]
|
]
|
||||||
ordering_fields = ["create_time", "update_time"]
|
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):
|
def add_info_for_item(self, data):
|
||||||
if data.get("oinfo_json", {}):
|
if data.get("oinfo_json", {}):
|
||||||
czx_dict = dict(TestItem.objects.filter(id__in=data.get("oinfo_json", {}).keys()).values_list("id", "name"))
|
czx_dict = dict(TestItem.objects.filter(id__in=data.get("oinfo_json", {}).keys()).values_list("id", "name"))
|
||||||
|
|
@ -352,6 +449,7 @@ class MlogViewSet(CustomModelViewSet):
|
||||||
return super().get_serializer_class()
|
return super().get_serializer_class()
|
||||||
|
|
||||||
@swagger_auto_schema(
|
@swagger_auto_schema(
|
||||||
|
operation_summary="查询生产日志明细(逐条追溯)",
|
||||||
manual_parameters=[
|
manual_parameters=[
|
||||||
openapi.Parameter(name="query", in_=openapi.IN_QUERY, description="定制返回数据", type=openapi.TYPE_STRING, required=False),
|
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),
|
openapi.Parameter(name="with_children", in_=openapi.IN_QUERY, description="带有children(yes/no/count)", type=openapi.TYPE_STRING, required=False),
|
||||||
|
|
@ -560,6 +658,8 @@ class MlogViewSet(CustomModelViewSet):
|
||||||
mloginit_data["equipment"] = vdata["equipment"]
|
mloginit_data["equipment"] = vdata["equipment"]
|
||||||
if "team" in vdata:
|
if "team" in vdata:
|
||||||
mloginit_data["team"] = vdata["team"]
|
mloginit_data["team"] = vdata["team"]
|
||||||
|
if "supplier" in vdata:
|
||||||
|
mloginit_data["supplier"] = vdata["supplier"]
|
||||||
sr_1 = MlogInitSerializer(data=mloginit_data, request=request)
|
sr_1 = MlogInitSerializer(data=mloginit_data, request=request)
|
||||||
sr_1.is_valid(raise_exception=True)
|
sr_1.is_valid(raise_exception=True)
|
||||||
mlog: Mlog = sr_1.save()
|
mlog: Mlog = sr_1.save()
|
||||||
|
|
@ -590,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"]
|
select_related_fields = ["send_user", "send_mgroup", "send_dept", "recive_user", "recive_mgroup", "recive_dept", "wm", "material_changed", "material", "material__process"]
|
||||||
filterset_class = HandoverFilter
|
filterset_class = HandoverFilter
|
||||||
search_fields = ["material__name", "material__number", "material__specification", "batch", "material__model", "b_handover__batch", "new_batch", "wm__batch"]
|
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):
|
def perform_destroy(self, instance: Handover):
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
|
@ -678,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)
|
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]:
|
elif type in [Handover.H_SCRAP]:
|
||||||
m_qs = m_qs.filter(process=None)
|
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)
|
@action(methods=["post"], detail=False, perms_map={"post": "handover.create"}, serializer_class=GenHandoverWmSerializer)
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
|
@ -1009,13 +1122,20 @@ class MlogbInViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, BulkDestroyMode
|
||||||
def gen_number_with_rule(cls, rule, material_out: Material, mlog: Mlog, gen_count=1):
|
def gen_number_with_rule(cls, rule, material_out: Material, mlog: Mlog, gen_count=1):
|
||||||
from apps.wpmw.models import Wpr
|
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
|
handle_date = mlog.handle_date
|
||||||
c_year = handle_date.year
|
c_year = handle_date.year
|
||||||
c_year2 = str(c_year)[-2:]
|
c_year2 = str(c_year)[-2:]
|
||||||
c_month = handle_date.month
|
c_month = handle_date.month
|
||||||
c_day = handle_date.day
|
c_day = handle_date.day
|
||||||
m_model = material_out.model
|
m_model = material_out.model
|
||||||
if 'm_model' in rule:
|
if "m_model" in rule_fields:
|
||||||
if m_model is None:
|
if m_model is None:
|
||||||
raise ParseError("生成编号出错:产品型号不能为空")
|
raise ParseError("生成编号出错:产品型号不能为空")
|
||||||
elif m_model and m_model.islower():
|
elif m_model and m_model.islower():
|
||||||
|
|
@ -1028,29 +1148,64 @@ class MlogbInViewSet(BulkCreateModelMixin, BulkUpdateModelMixin, BulkDestroyMode
|
||||||
if connection.vendor == "postgresql" and connection.in_atomic_block:
|
if connection.vendor == "postgresql" and connection.in_atomic_block:
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", [f"wpr_number_rule:{process.id}"])
|
cursor.execute("SELECT pg_advisory_xact_lock(hashtextextended(%s, 0))", [f"wpr_number_rule:{process.id}"])
|
||||||
# 按生产日志查询, 流水号归零周期跟随规则中最细的日期占位符
|
# 只按规则中实际使用的日期占位符筛选历史编号
|
||||||
wpr_filter = {
|
wpr_filter = {
|
||||||
"wpr_mlogbw__mlogb__material_out__isnull": False,
|
"wpr_mlogbw__mlogb__material_out__isnull": False,
|
||||||
"wpr_mlogbw__mlogb__mlog__mgroup__process": process,
|
"wpr_mlogbw__mlogb__mlog__mgroup__process": process,
|
||||||
"wpr_mlogbw__mlogb__mlog__is_fix": False,
|
"wpr_mlogbw__mlogb__mlog__is_fix": False,
|
||||||
"wpr_mlogbw__mlogb__mlog__submit_time__isnull": 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,
|
|
||||||
}
|
}
|
||||||
if "c_day" in rule:
|
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
|
wpr_filter["wpr_mlogbw__mlogb__mlog__handle_date__day"] = c_day
|
||||||
wpr = (
|
rule_values = {
|
||||||
Wpr.objects.filter(**wpr_filter)
|
"c_year": c_year,
|
||||||
.annotate(last_seq=Substr("number", Length("number") - (cq_w - 1)))
|
"c_year2": c_year2,
|
||||||
.order_by("last_seq")
|
"c_month": c_month,
|
||||||
.last()
|
"c_day": c_day,
|
||||||
)
|
"m_model": m_model,
|
||||||
n_count = 0
|
}
|
||||||
if wpr:
|
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:
|
try:
|
||||||
n_count = int(wpr.number[-cq_w:])
|
field_value = rule_values[field_name]
|
||||||
except Exception as e:
|
if conversion:
|
||||||
raise ParseError(f"获取该类产品最后编号错误: {str(e)}")
|
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:
|
if n_count + gen_count > 10 ** cq_w - 1:
|
||||||
raise ParseError(f"流水号超出{cq_w}位上限, 请调整编号规则")
|
raise ParseError(f"流水号超出{cq_w}位上限, 请调整编号规则")
|
||||||
try:
|
try:
|
||||||
|
|
@ -1147,6 +1302,7 @@ class MlogbwViewSet(CustomModelViewSet):
|
||||||
filterset_class = MlogbwFilter
|
filterset_class = MlogbwFilter
|
||||||
select_related_fields = ["ftest", "equip", "tooling", "wpr", "mlogb"]
|
select_related_fields = ["ftest", "equip", "tooling", "wpr", "mlogb"]
|
||||||
prefetch_related_fields = [
|
prefetch_related_fields = [
|
||||||
|
"files",
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"ftest__items_ftest",
|
"ftest__items_ftest",
|
||||||
queryset=FtestItem.objects.select_related(
|
queryset=FtestItem.objects.select_related(
|
||||||
|
|
@ -1166,6 +1322,21 @@ class MlogbwViewSet(CustomModelViewSet):
|
||||||
ordering = ["mlogb", "number", "create_time"]
|
ordering = ["mlogb", "number", "create_time"]
|
||||||
ordering_fields = ["number", "create_time", "mlogb"]
|
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):
|
def perform_create(self, serializer):
|
||||||
ins: Mlogbw = serializer.save()
|
ins: Mlogbw = serializer.save()
|
||||||
mlog: Mlog = None
|
mlog: Mlog = None
|
||||||
|
|
@ -1199,12 +1370,14 @@ class MlogbwViewSet(CustomModelViewSet):
|
||||||
material_out = material_in
|
material_out = material_in
|
||||||
else:
|
else:
|
||||||
raise ParseError("获取产出物料错误")
|
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:
|
if mlogb_qs.exists() and material_out.tracking == Material.MA_TRACKING_SINGLE:
|
||||||
for mlogb in mlogb_qs:
|
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})
|
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:
|
if route.div_number == 1:
|
||||||
Mlogbw.objects.get_or_create(wpr=ins.wpr, mlogb=mlogb, defaults={"number": ins.wpr.number, "mlogbw_from": ins})
|
Mlogbw.objects.get_or_create(wpr=ins.wpr, mlogb=mlogb, defaults={"number": ins.wpr.number, "mlogbw_from": ins})
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from apps.utils.models import BaseModel
|
||||||
from apps.mtm.models import Material
|
from apps.mtm.models import Material
|
||||||
from rest_framework.exceptions import ParseError
|
from rest_framework.exceptions import ParseError
|
||||||
from apps.wpm.models import WmStateOption, Mlogbw, Handoverbw
|
from apps.wpm.models import WmStateOption, Mlogbw, Handoverbw
|
||||||
|
from apps.utils.snowflake import idWorker
|
||||||
from apps.utils.tools import update_dict
|
from apps.utils.tools import update_dict
|
||||||
from apps.inm.models import MIOItemw
|
from apps.inm.models import MIOItemw
|
||||||
from django.db.models import F, Value
|
from django.db.models import F, Value
|
||||||
|
|
@ -32,8 +33,24 @@ class Wpr(BaseModel):
|
||||||
data = models.JSONField(verbose_name="数据", default=dict, blank=True)
|
data = models.JSONField(verbose_name="数据", default=dict, blank=True)
|
||||||
pre_info = models.JSONField(verbose_name="预处理信息", default=dict, blank=True, null=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
|
@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
|
is_create = False
|
||||||
if wpr is None and number is None:
|
if wpr is None and number is None:
|
||||||
raise ParseError("id和number不能同时为空")
|
raise ParseError("id和number不能同时为空")
|
||||||
|
|
@ -109,25 +126,9 @@ class Wpr(BaseModel):
|
||||||
ins.state = wm.state
|
ins.state = wm.state
|
||||||
if is_create:
|
if is_create:
|
||||||
ins.material_start = wm.material
|
ins.material_start = wm.material
|
||||||
WprDefect.objects.filter(wpr=ins).delete()
|
|
||||||
if add_version:
|
if add_version:
|
||||||
ins.version = ins.version + 1
|
ins.version = ins.version + 1
|
||||||
ins.save()
|
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:
|
# if ins.mb and ins.wm:
|
||||||
# raise ParseError("所属仓库批次和车间批次不可同时存在2")
|
# raise ParseError("所属仓库批次和车间批次不可同时存在2")
|
||||||
if number_out:
|
if number_out:
|
||||||
|
|
@ -138,6 +139,63 @@ class Wpr(BaseModel):
|
||||||
raise ParseError(f"{ins.number}-出库编号-{number_out}-已存在")
|
raise ParseError(f"{ins.number}-出库编号-{number_out}-已存在")
|
||||||
return ins
|
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
|
@classmethod
|
||||||
def clear(cls, number_list):
|
def clear(cls, number_list):
|
||||||
cls.objects.filter(number__in=number_list).update(mb=None, wm=None)
|
cls.objects.filter(number__in=number_list).update(mb=None, wm=None)
|
||||||
|
|
|
||||||
|
|
@ -63,15 +63,8 @@ class WprViewSet(BulkUpdateModelMixin, CustomListModelMixin, CustomRetrieveModel
|
||||||
vdata = sr.validated_data
|
vdata = sr.validated_data
|
||||||
new_number = vdata["new_number"]
|
new_number = vdata["new_number"]
|
||||||
old_number = vdata["old_number"]
|
old_number = vdata["old_number"]
|
||||||
if Wpr.objects.filter(number=new_number).exists():
|
|
||||||
raise ParseError("新编号已存在,不可使用")
|
|
||||||
wpr = Wpr.objects.get(number=old_number)
|
wpr = Wpr.objects.get(number=old_number)
|
||||||
from apps.wpm.models import Mlogbw, Handoverbw
|
wpr.change_number(new_number)
|
||||||
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)
|
|
||||||
return Response()
|
return Response()
|
||||||
|
|
||||||
@action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=WprNewSerializer)
|
@action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=WprNewSerializer)
|
||||||
|
|
|
||||||
10
changelog.md
10
changelog.md
|
|
@ -1,3 +1,13 @@
|
||||||
|
## 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
|
## 3.1.2026072316
|
||||||
|
|
||||||
- feat: 新增功能
|
- feat: 新增功能
|
||||||
|
|
|
||||||
|
|
@ -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。
|
||||||
|
|
@ -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"
|
||||||
|
```
|
||||||
|
|
@ -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 只包含本次迁移相关文件。
|
||||||
|
|
@ -5,7 +5,12 @@ import sys
|
||||||
|
|
||||||
|
|
||||||
def main():
|
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:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Factory MCP v2 integration."""
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Factory MCP 领域工具,按业务域拆分并在 server 中显式注册。"""
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 响应上限,请缩小查询范围或增加筛选参数"
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[pytest]
|
||||||
|
DJANGO_SETTINGS_MODULE = server.test_settings
|
||||||
|
python_files = tests.py test_*.py *_tests.py
|
||||||
|
|
@ -10,6 +10,11 @@ django-cors-headers==4.9.0
|
||||||
djangorestframework-simplejwt==5.5.1
|
djangorestframework-simplejwt==5.5.1
|
||||||
django-restql==0.15.2
|
django-restql==0.15.2
|
||||||
|
|
||||||
|
# =======================
|
||||||
|
# Agent Integration
|
||||||
|
# =======================
|
||||||
|
mcp==2.0.0
|
||||||
|
|
||||||
# =======================
|
# =======================
|
||||||
# Celery
|
# Celery
|
||||||
# =======================
|
# =======================
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue