Compare commits

...

40 Commits

Author SHA1 Message Date
caoqianming 652555ef01 feat(cm): 新增大族激光打标机(Hanslaser)单件打标接口
新增 HanslaserClient(STX/ETX 帧, gbk 编码, 每条命令校验回包)与
send_to_hanslaser action, 走 $Initialize_/$Data_/$MarkStart_ 单件流程。
该协议无队列能力, 与 1880 喷码机协议独立, 各用各的 serializer 与接口。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 10:43:30 +08:00
zty 301e886a9e fix(hrm): 导入部门改为仅新增必填,修复更新被拦截
之前“所属部门必填”会拦掉部门列为空的更新行,导致已有员工无法被覆盖。
改为:填了才校验是否存在;为空时——更新保留原部门、新增才报错。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:45:01 +08:00
zty b395db7a28 feat(hrm): 完善员工导入导出
导出(export_excel):
- 去掉 1000 条上限,导出筛选后的全部记录
- 由 7 列扩展为全部业务字段;select_related 避免 N+1

导入(import_excel):
- 改为按姓名 upsert:姓名唯一直接更新(身份证号可空);
  重名必须用身份证号区分,否则报错
- 已存在记录用 Excel 非空列覆盖,空单元格保持原值
- 所属部门改为必填:为空或系统不存在均报错
- 修复新增分支重复关键字参数导致的报错

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:18:47 +08:00
shijing 1e920296e0 fix:get_tyy_data返回数据 2026-06-03 13:34:24 +08:00
shijing b21c48b90c fix: get_tyy_data方法调整 2026-06-03 10:50:57 +08:00
shijing beddfe98ba feat: get_tyy_data单独处理 2026-06-03 09:22:32 +08:00
caoqianming 2f8beec37f feat(cd): get_tyy_data 支持并行获取维视智造SVS识别字符
新增 get_svs_char/_parse_vtfp_line,通过 VTFP 文本协议触发拍照并解析
识别字符。get_tyy_data 增加可选 svs_host/svs_port/svs_flow,采集器与
SVS 并行触发后合并为一个 dict 返回,任一失败整体抛错。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 10:13:46 +08:00
shijing 804746fde8 fix:喷码时偶尔出现不属于队列项的调整 2026-05-29 09:32:47 +08:00
shijing 39ae839692 fix: 移除mlogbw pre_info 2026-05-28 11:22:28 +08:00
shijing c7515052cf Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-05-28 11:20:45 +08:00
shijing 95605be52f fix: pre_info可为空 2026-05-28 11:20:43 +08:00
caoqianming 7ef9763a50 feat(cd): out_service/cd.py 支持任意 host:port, 去掉 IP 白名单
socket 槽位和 per-host 锁改为首次访问时懒注册, 用 double-checked locking
保证热路径无竞争。不再硬编码 4 个采集器地址。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:14:12 +08:00
caoqianming 86a8110a5e perf(cd): out_service/cd.py 改 ThreadingHTTPServer + per-host 锁, drain 改非阻塞
- HTTPServer -> ThreadingHTTPServer, HTTP 层放开并发
- 全局 sc_lock 拆为 per-host 锁, 不同采集器互不阻塞
- drain 改 setblocking(False), 缓冲区为空时立即返回, 省掉 0.5s 阻塞等待
- recv 改读满 4+length 字节, 避免分包时误判"数据不完整"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:47:44 +08:00
shijing 8de4d1197b fix:工装调整 2026-05-27 10:18:07 +08:00
shijing 53f6082071 fix:wpr更新添加pre_info字段 2026-05-26 09:27:45 +08:00
caoqianming dab1da0b05 feat: send_to_coder 支持传入 coder_jobname 切换喷码机信息模板
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 14:30:22 +08:00
shijing 0dd78f09b9 fix:锁定 MIO 行,防止多人同时撤销不同明细时并发漏删 MIO,若该出入库记录已无明细,自动删除 2026-05-22 09:43:43 +08:00
shijing b108b48714 feat:wpm/mloguser返回设备名称 2026-05-22 09:42:03 +08:00
shijing 7d91682e8a fix:禅道413 2026-05-21 16:18:01 +08:00
caoqianming 99e2909514 feat: send_to_coder 统一走 CQI+JDI 队列, 单/批共用 tdata_list
- 入参只接 tdata_list (列表, 至少一条), 单条就是长度=1
- 每次先 CQI 清队列, 再按顺序 JDI 入 N 条
- 一次光电触发消费一条, 与"一行=一个产品"的业务语义对齐
- 不再保留单条 tdata, 开发期清理掉

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:57:21 +08:00
caoqianming 2b3aaf8879 feat: 写指令也短读0.3s捕获机器的ERR回包
之前 expect_reply=False 直接关 socket, 机器若回 ERR 完全感知不到,
表现就是"接口200但实际没动作"。改为写完后短读最多一行, 拿到
ERR/'!' 开头直接报错; 超时则照旧视为成功放行。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:16:27 +08:00
caoqianming 3304371560 fix: 1880 无参查询指令(GST/GFT/GJD)去掉多余的 '|'
PDF 标题里的 'GST|<CR>' 是排版分隔, 实际帧是 'GST<CR>'。
之前多发了一个 '|' 导致机器回 'ERR\r'。
CQI 之前已是正确写法, 有参指令(JDA/SLA/JDI/SST)不变。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:15:04 +08:00
caoqianming f8b6f084d4 feat: 重写喷码通讯为伟迪捷1880 ASCII协议, 配置改由前端传入
- 原 CoderClient 用的是 Domino 1000 系列 STX/ETX 二进制帧, 不适用于 1880
- 改为 ASCII '|' 分隔 + <CR> 结尾, 实现 SLA/JDA/CQI/JDI/SST/GST 等指令
- send_to_coder 把多条 commands 合并成单帧 JDA 一次下发
- 配合模型字段移除, IP/端口/用户区由请求 body 提供, 不再走模板回退

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 13:48:52 +08:00
caoqianming 43b2a4c7f7 fix: 移除LabelTemplate不必要的字段 2026-05-18 10:49:49 +08:00
caoqianming 96f5133152 Merge branch 'master' of http://gitea.xxhhcty.xyz:8080/zcdsj/factory 2026-05-18 09:16:28 +08:00
caoqianming 2b77a469dd feat: 简化打码器 2026-05-18 09:16:16 +08:00
TianyangZhang d480595415 feat: 修改光芯bug 2026-05-15 17:07:58 +08:00
caoqianming dadfd9669a feat: 标签模板增加打码器用户区配置, 支持同一信息内切换码型
LabelTemplate 新增 coder_field 字段(默认"1"), 喷码机一条信息里插入多个不同
码型的用户区时, 不同模板填不同用户区名即可打不同码型(条码/二维码/文本),
无需走 T 指令。coder.py 通讯编码放宽到 latin-1 并校验帧内控制字符。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:08:54 +08:00
shijing 1299bddc6a fix:工序添加clear_defect 2026-05-15 13:39:22 +08:00
caoqianming 92c3fd60ca Merge pull request 'dev_cqm' (#7) from dev_cqm into master
Reviewed-on: http://gitea.xxhhcty.xyz:8080/zcdsj/factory/pulls/7
2026-05-14 09:48:39 +08:00
caoqianming 8327a5d125 Merge pull request 'master' (#6) from master into dev_cqm
Reviewed-on: http://gitea.xxhhcty.xyz:8080/zcdsj/factory/pulls/6
2026-05-14 09:45:01 +08:00
caoqianming 2c1b0f7b83 feat: mlog 提交支持 clear_defect,合格 B 类与合格品合并入库
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 09:22:40 +08:00
caoqianming b03b42ad23 Merge pull request 'feat: 合批支持 clear_defect,合格品多 defect 合批后置空' (#5) from dev_cqm into master
Reviewed-on: http://gitea.xxhhcty.xyz:8080/zcdsj/factory/pulls/5
2026-05-13 08:09:01 +08:00
caoqianming 651366257c feat: 合批支持 clear_defect,合格品多 defect 合批后置空
新增 Handover.clear_defect 字段:合批时若源批次 state 均为 WM_OK 且
defect 不一致,可由前端显式勾选开启,落库后目标 defect 为 None;默认
false 保持原有严格校验。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 18:03:36 +08:00
caoqianming 4d60c2c7bd Merge pull request 'feat: mioitem 返回 defect/defect_name/okcate 并优化 mio 详情 items 序列化' (#4) from dev_cqm into master
Reviewed-on: http://gitea.xxhhcty.xyz:8080/zcdsj/factory/pulls/4
2026-05-12 09:28:25 +08:00
caoqianming a0739a9351 Merge branch 'master' into dev_cqm 2026-05-12 09:28:06 +08:00
caoqianming bb64c89d76 feat: mioitem 返回 defect/defect_name/okcate 并优化 mio 详情 items 序列化
- MIOItemSerializer 新增 defect/defect_name/okcate,取数优先级 wm.defect>mb.defect,
  与 services.py 中 do_out/do_in 的取数逻辑保持一致
- MIOItemViewSet.select_related_fields 加 wm__defect/mb__defect 消除 N+1
- MIODetailSerializer.items 改用 MIOItemForMioDetailSerializer 轻量序列化器
  (仅含前端实际消费的 id/mio/material/material_/batch/count/pack_index),
  避开 MIOItemSerializer 中 assemb/mioitemw 的 N+1
- MIOViewSet.retrieve 时挂 Prefetch('item_mio') 进一步减少查询

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 09:23:22 +08:00
caoqianming 08d9b7c7a7 Merge pull request 'feat: inm 生产领料/入库支持带 defect 物料并按 okcate 推导 state' (#3) from fix/inm-defect-do-out into master
Reviewed-on: http://gitea.xxhhcty.xyz:8080/zcdsj/factory/pulls/3
2026-05-12 08:59:57 +08:00
caoqianming 70f2d0d2dd feat: inm 生产领料/入库支持带 defect 物料并按 okcate 推导 state
去除 do_out 对 defect 非空的硬拦截,do_out/do_in 中查询与创建
MaterialBatch、WMaterial 时按 defect.okcate==DEFECT_NOTOK 推导
state=WM_NOTOK,与 update_mb_item 保持一致,撤销路径同步打通。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:57:38 +08:00
caoqianming 5354557f4a Merge pull request 'fix:分支提交测试' (#2) from dev_sj into master
Reviewed-on: http://gitea.xxhhcty.xyz:8080/zcdsj/factory/pulls/2
2026-05-11 15:27:37 +08:00
32 changed files with 1198 additions and 255 deletions

View File

@ -1,14 +1,14 @@
""" """
1000 系列喷码机网口通讯封装 伟迪捷 1880 喷码机 ASCII 协议封装
协议: STX(0x02) + 指令 + 数据 + ETX(0x03)
反馈: $XX 成功 / !XX 失败 (XX 为两位校验) 帧格式: ASCII, 字段以 '|' 分隔, 整帧以 <CR>(0x0D) 结尾
- 写指令(SLA/JDA/CQI/JDI/SST/SNO) 通常无回包
- 查询指令(GST/GFT/GJD) 回包同样以 <CR> 结尾
""" """
import socket import socket
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
STX = b"\x02" CR = b"\x0d"
ETX = b"\x03"
LF = b"\x0a"
class CoderError(ParseError): class CoderError(ParseError):
@ -18,44 +18,245 @@ class CoderError(ParseError):
class CoderClient: class CoderClient:
def __init__(self, ip: str, port: int = 3100, timeout: float = 2.0): def __init__(self, ip: str, port: int = 3100, timeout: float = 2.0):
if not ip: if not ip:
raise CoderError("打码器IP未配置") raise CoderError("喷码IP未配置")
self.ip = ip self.ip = ip
self.port = port self.port = port
self.timeout = timeout self.timeout = timeout
def _send(self, frame: bytes) -> bytes: def _send(self, frame: bytes, expect_reply: bool = False) -> bytes:
try: try:
with socket.create_connection((self.ip, self.port), timeout=self.timeout) as s: with socket.create_connection((self.ip, self.port), timeout=self.timeout) as s:
s.sendall(frame) s.sendall(frame)
return s.recv(64) if expect_reply:
buf = b""
while CR not in buf:
chunk = s.recv(256)
if not chunk:
break
buf += chunk
return buf
# 写指令: 短暂等待, 捕获机器拒绝(ERR/!); 超时则视为成功
s.settimeout(0.3)
try:
buf = b""
while CR not in buf:
chunk = s.recv(256)
if not chunk:
break
buf += chunk
except socket.timeout:
buf = b""
if buf:
text = buf.rstrip(b"\r\n").decode("latin-1", errors="replace")
if text.startswith("ERR") or text.startswith("!"):
raise CoderError(f"喷码机拒绝指令 {frame!r}: {text}")
return b""
except CoderError:
raise
except (socket.timeout, OSError) as e: except (socket.timeout, OSError) as e:
raise CoderError(f"打码器通讯失败 {self.ip}:{self.port} - {e}") raise CoderError(f"喷码通讯失败 {self.ip}:{self.port} - {e}")
@staticmethod @staticmethod
def _check_ack(resp: bytes): def _encode(text: str, label: str) -> bytes:
if not resp: if "\r" in text or "\n" in text:
raise CoderError("打码器无响应") raise CoderError(f"{label}含 CR/LF, 会破坏帧结构")
head = resp[:1] if "|" in text:
if head == b"$": raise CoderError(f"{label}'|', 与 1880 分隔符冲突")
return True try:
if head == b"!": return text.encode("latin-1")
raise CoderError(f"打码器返回失败: {resp!r}") except UnicodeEncodeError as e:
raise CoderError(f"打码器响应不识别: {resp!r}") raise CoderError(f"{label}含喷码机不支持的字符: {e}")
def update_field(self, field_name: str, content: str) -> bool: def send_command(self, command: str, expect_reply: bool = False) -> bytes:
"""更新用户区: 02 55 <field> 0A <content> 03""" """直接下发一条已拼装好的 1880 指令(不含末尾 CR), 由本方法统一补 CR。"""
frame = STX + b"\x55" + field_name.encode("ascii") + LF + content.encode("ascii") + ETX if "\r" in command or "\n" in command:
return self._check_ack(self._send(frame)) raise CoderError("指令含 CR/LF, 由 send_command 统一补 CR, 不要手动拼")
return self._send(command.encode("latin-1") + CR, expect_reply=expect_reply)
def select_message(self, message_name: str) -> bool: def select_job(self, job_name: str):
"""选择信息: 02 4D <name> 03""" """SLA|<jobname>|<CR> 选择信息模板"""
frame = STX + b"\x4d" + message_name.encode("ascii") + ETX self._send(b"SLA|" + self._encode(job_name, "模板名") + b"|" + CR)
return self._check_ack(self._send(frame))
def get_status(self) -> bytes: def update_fields(self, fields: dict):
"""获取喷码机状态: 02 45 03""" """JDA|<field>=<value>|...|<CR> 一帧更新多个用户区"""
return self._send(STX + b"\x45" + ETX) if not fields:
return
parts = [b"JDA"]
for k, v in fields.items():
parts.append(self._encode(str(k), "用户区名") + b"=" + self._encode(str(v), "喷印内容"))
self._send(b"|".join(parts) + b"|" + CR)
def send_raw(self, frame_str: str) -> bytes: def clear_queue(self):
"""发送任意已拼好的帧字符串(不含 STX/ETX), 自动补头尾""" """CQI<CR> 清空喷码机内部缓存队列"""
return self._send(STX + frame_str.encode("ascii") + ETX) self._send(b"CQI" + CR)
def push_queue(self, fields: dict):
"""JDI|1|<field>=<value>|...|<CR> 入队列数据"""
if not fields:
return
parts = [b"JDI", b"1"]
for k, v in fields.items():
parts.append(self._encode(str(k), "用户区名") + b"=" + self._encode(str(v), "喷印内容"))
self._send(b"|".join(parts) + b"|" + CR)
def set_state(self, state: int):
"""SST|<State>|<CR> 0关机 1开机 3运行 4离线"""
self._send(b"SST|" + str(int(state)).encode("ascii") + b"|" + CR)
def register_print_callback(self):
"""SNO|PRC|1|<CR> 注册打印完成反馈, 之后喷码机每打印一次会回 PRC<CR>"""
self._send(b"SNO|PRC|1|" + CR)
def get_status(self) -> dict:
"""GST<CR> -> STS|<overall>|<error>|<currentjob>|<batchcount>|<totalcount>|<CR>"""
resp = self._send(b"GST" + CR, expect_reply=True)
parts = resp.rstrip(b"\r\n").decode("latin-1").split("|")
if not parts or parts[0] != "STS" or len(parts) < 6:
raise CoderError(f"GST 响应不识别: {resp!r}")
try:
return {
"overall": int(parts[1]),
"error": int(parts[2]),
"current_job": parts[3],
"batch_count": int(parts[4]),
"total_count": int(parts[5]),
}
except ValueError as e:
raise CoderError(f"GST 响应解析失败: {resp!r} - {e}")
def get_faults(self) -> list:
"""GFT<CR> -> FLT|<count>|[<code>|<clearable>|<title>|]*<CR>"""
resp = self._send(b"GFT" + CR, expect_reply=True)
parts = resp.rstrip(b"\r\n").decode("latin-1").split("|")
if not parts or parts[0] != "FLT" or len(parts) < 2:
raise CoderError(f"GFT 响应不识别: {resp!r}")
try:
count = int(parts[1])
except ValueError as e:
raise CoderError(f"GFT 响应解析失败: {resp!r} - {e}")
faults = []
for i in range(count):
base = 2 + i * 3
if base + 2 >= len(parts):
break
faults.append({
"code": parts[base],
"clearable": parts[base + 1] == "1",
"title": parts[base + 2],
})
return faults
def get_current_job(self) -> dict:
"""GJD<CR> -> JDL|<count>|<field>=<value>|...|<CR>"""
resp = self._send(b"GJD" + CR, expect_reply=True)
parts = resp.rstrip(b"\r\n").decode("latin-1").split("|")
if not parts or parts[0] != "JDL" or len(parts) < 2:
raise CoderError(f"GJD 响应不识别: {resp!r}")
try:
count = int(parts[1])
except ValueError as e:
raise CoderError(f"GJD 响应解析失败: {resp!r} - {e}")
fields = {}
for i in range(count):
idx = 2 + i
if idx >= len(parts):
break
pair = parts[idx]
if "=" not in pair:
continue
k, v = pair.split("=", 1)
fields[k] = v
return fields
# ============================================================================
# Hanslaser(大族激光) 老款打标机 标准通讯协议(Winsocket/TCP)
# 帧格式: STX(0x02) + 内容 + ETX(0x03), 每条命令下位机都回一帧
# 单件打标流程: $Initialize_<文件> -> $Data_<v1,v2,...> -> $MarkStart_
# 该协议无队列/批量能力, 只能逐件: 一组 Data 对应一次 MarkStart
# ============================================================================
STX = b"\x02"
ETX = b"\x03"
class HanslaserClient:
encoding = "gbk" # 大族激光为国产机, 文档 code page 936
def __init__(self, ip: str, port: int, timeout: float = 5.0):
if not ip:
raise CoderError("打标机IP未配置")
if not port:
raise CoderError("打标机端口未配置")
self.ip = ip
self.port = port
self.timeout = timeout
def _encode(self, text: str, label: str) -> bytes:
if "\x02" in text or "\x03" in text:
raise CoderError(f"{label}含 STX/ETX, 会破坏帧结构")
if "," in text:
raise CoderError(f"{label}',', 与 Hanslaser $Data 分隔符冲突")
if "\r" in text or "\n" in text:
raise CoderError(f"{label}含 CR/LF")
try:
return text.encode(self.encoding)
except UnicodeEncodeError as e:
raise CoderError(f"{label}含打标机不支持的字符: {e}")
def _send_recv(self, body: bytes) -> str:
"""发 STX+body+ETX, 读回一整帧, 返回去掉 STX/ETX 后的内容字符串。"""
frame = STX + body + ETX
try:
with socket.create_connection((self.ip, self.port), timeout=self.timeout) as s:
s.sendall(frame)
buf = b""
while ETX not in buf:
chunk = s.recv(256)
if not chunk:
break
buf += chunk
except (socket.timeout, OSError) as e:
raise CoderError(f"打标通讯失败 {self.ip}:{self.port} - {e}")
if ETX not in buf:
raise CoderError(f"打标机无完整回包(缺 ETX): {buf!r}")
start = buf.find(STX)
end = buf.find(ETX, start + 1 if start >= 0 else 0)
content = buf[(start + 1 if start >= 0 else 0):end]
return content.decode(self.encoding, errors="replace")
def initialize(self, filename: str):
"""$Initialize_<文件名> -> $Initialize_OK / $Initialize_FALSE"""
resp = self._send_recv(b"$Initialize_" + self._encode(filename, "打标文件名"))
if resp != "$Initialize_OK":
raise CoderError(f"打标文件初始化失败: {resp}")
def send_data(self, values: list):
"""$Data_<v1,v2,...> -> $Receive_OK / $Receive_ERROR / $SysNoReady"""
if not values:
raise CoderError("打标数据为空")
payload = b",".join(self._encode(str(v), "打标内容") for v in values)
resp = self._send_recv(b"$Data_" + payload)
if resp == "$Receive_OK":
return
if resp == "$SysNoReady":
raise CoderError("打标机未准备好($SysNoReady)")
raise CoderError(f"打标数据被拒绝: {resp}")
def mark_start(self):
"""$MarkStart_ -> $MarkStart_OK / $MarkStart_OK_<次数> / $MarkStart_ERROR
下位机开始打标即回 $MarkStart_OK; 打标完成会再回 $MarkStart_OK_<次数>
单件场景只确认开始成功, 不阻塞等待打标完成
"""
resp = self._send_recv(b"$MarkStart_")
if not resp.startswith("$MarkStart_OK"):
raise CoderError(f"启动打标失败: {resp}")
return resp
def mark_stop(self):
"""$MarkStop_ -> $MarkStop_OK / $MarkStop_ERROR"""
resp = self._send_recv(b"$MarkStop_")
if not resp.startswith("$MarkStop_OK"):
raise CoderError(f"停止打标失败: {resp}")
return resp

View File

@ -1,23 +0,0 @@
# Generated for coder fields on LabelTemplate
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cm', '0006_alter_lablemat_batch'),
]
operations = [
migrations.AddField(
model_name='labeltemplate',
name='coder_ip',
field=models.GenericIPAddressField(blank=True, null=True, verbose_name='打码器IP'),
),
migrations.AddField(
model_name='labeltemplate',
name='coder_port',
field=models.PositiveIntegerField(default=3100, verbose_name='打码器端口'),
),
]

View File

@ -22,8 +22,6 @@ class LabelTemplate(BaseModel):
name = models.TextField("名称") name = models.TextField("名称")
commands = models.JSONField("指令模板", default=list, blank=True) commands = models.JSONField("指令模板", default=list, blank=True)
process_json = models.JSONField("工序", default=list, blank=True) process_json = models.JSONField("工序", default=list, blank=True)
coder_ip = models.GenericIPAddressField("打码器IP", null=True, blank=True)
coder_port = models.PositiveIntegerField("打码器端口", default=3100)
@classmethod @classmethod
def gen_commands(cls, label_template, label_template_name, tdata): def gen_commands(cls, label_template, label_template_name, tdata):

View File

@ -18,10 +18,18 @@ class Tid2Serializer(serializers.Serializer):
class CoderSendSerializer(serializers.Serializer): class CoderSendSerializer(serializers.Serializer):
tdata = serializers.JSONField(label='模板数据', required=False, default=dict) tdata_list = serializers.ListField(child=serializers.JSONField(), label='模板数据列表', allow_empty=False)
coder_ip = serializers.IPAddressField(label='打码器IP(可覆盖模板)', required=False, allow_null=True) coder_ip = serializers.IPAddressField(label='喷码IP')
coder_port = serializers.IntegerField(label='打码器端口(可覆盖模板)', required=False, allow_null=True) coder_port = serializers.IntegerField(label='喷码端口', required=False, allow_null=True)
coder_field = serializers.CharField(label='用户区名(可覆盖模板)', required=False, allow_null=True) coder_field = serializers.CharField(label='默认用户区名', required=False, allow_null=True)
coder_jobname = serializers.CharField(label='信息模板名', required=False, allow_null=True)
class HanslaserSendSerializer(serializers.Serializer):
tdata_list = serializers.ListField(child=serializers.JSONField(), label='模板数据列表', allow_empty=False)
coder_ip = serializers.IPAddressField(label='打标机IP')
coder_port = serializers.IntegerField(label='打标机端口')
coder_jobname = serializers.CharField(label='打标文件名', required=False, allow_null=True)
class LabelMatSerializer(serializers.ModelSerializer): class LabelMatSerializer(serializers.ModelSerializer):
@ -43,11 +51,7 @@ class LabelMatSerializer(serializers.ModelSerializer):
class LabelTemplateSerializer(CustomModelSerializer): class LabelTemplateSerializer(CustomModelSerializer):
coder_ip = serializers.IPAddressField(required=False, allow_null=True, allow_blank=True)
class Meta: class Meta:
model = LabelTemplate model = LabelTemplate
fields = '__all__' fields = '__all__'
def validate_coder_ip(self, value):
return value or None

View File

@ -1,7 +1,7 @@
from apps.cm.models import LableMat, LabelTemplate from apps.cm.models import LableMat, LabelTemplate
from rest_framework.decorators import action from rest_framework.decorators import action
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer, HanslaserSendSerializer
from apps.cm.coder import CoderClient from apps.cm.coder import CoderClient, HanslaserClient
from apps.inm.models import MaterialBatch, MIOItem from apps.inm.models import MaterialBatch, MIOItem
from apps.wpm.models import WMaterial from apps.wpm.models import WMaterial
from rest_framework.exceptions import ParseError, NotFound from rest_framework.exceptions import ParseError, NotFound
@ -118,29 +118,84 @@ class LabelTemplateViewSet(CustomModelViewSet):
@action(methods=["post"], detail=True, serializer_class=CoderSendSerializer, perms_map={"post": "*"}) @action(methods=["post"], detail=True, serializer_class=CoderSendSerializer, perms_map={"post": "*"})
def send_to_coder(self, request, pk=None, *args, **kwargs): def send_to_coder(self, request, pk=None, *args, **kwargs):
""" """
下发指令到打码器 下发指令到伟迪捷 1880 喷码机 (统一走 CQI + JDI 队列模式)
按模板 commands tdata 格式化后, 通过模板上配置的 IP/端口下发到 1000 系列喷码机用户区 body tdata_list (列表, 至少一条), 每条对应一个产品
body 中的 coder_ip/coder_port/coder_field 可临时覆盖模板配置 每次调用先 CQI 清空队列, 再按顺序 JDI 入队 N ;
喷码机每个外部触发(光电)消费一条, 喷一个产品
每条 tdata 走模板 commands, 解析为 <字段>=<内容> 合并成一帧 JDI;
commands 中不含 '=' 的元素, 会绑定到默认 coder_field
""" """
sr = CoderSendSerializer(data=request.data) sr = CoderSendSerializer(data=request.data)
sr.is_valid(raise_exception=True) sr.is_valid(raise_exception=True)
vdata = sr.validated_data vdata = sr.validated_data
lt: LabelTemplate = self.get_object() lt: LabelTemplate = self.get_object()
ip = vdata.get("coder_ip") or lt.coder_ip ip = vdata.get("coder_ip")
port = vdata.get("coder_port") or lt.coder_port port = vdata.get("coder_port") or 3100
field = vdata.get("coder_field") or "1" field = vdata.get("coder_field") or "1"
if not ip:
raise ParseError("模板未配置打码器IP, 也未在请求中提供 coder_ip")
commands = LabelTemplate.gen_commands(lt.id, None, vdata.get("tdata") or {}) batched = []
if not commands: for tdata in vdata["tdata_list"]:
raise ParseError("模板未生成任何指令") commands = LabelTemplate.gen_commands(lt.id, None, tdata)
if not commands:
raise ParseError("模板未生成任何指令")
fields = {}
for item in commands:
text = str(item)
if "=" in text:
k, v = text.split("=", 1)
fields[k.strip()] = v
else:
fields[field] = text
batched.append(fields)
client = CoderClient(ip=ip, port=port) client = CoderClient(ip=ip, port=port)
results = [] coder_jobname = vdata.get("coder_jobname")
for content in commands: client.clear_queue()
client.update_field(field, content) if coder_jobname:
results.append(content) client.select_job(coder_jobname)
return Response({"sent": results, "ip": ip, "port": port, "field": field}) client.clear_queue()
for fields in batched:
client.push_queue(fields)
return Response({"queued": len(batched), "fields": batched, "ip": ip, "port": port, "jobname": coder_jobname})
@action(methods=["post"], detail=True, serializer_class=HanslaserSendSerializer, perms_map={"post": "*"})
def send_to_hanslaser(self, request, pk=None, *args, **kwargs):
"""
下发指令到大族激光(Hanslaser)老款打标机 (单件打标, 协议无队列能力)
body tdata_list, 可选 coder_jobname 先选打标文件
tdata_list 逐件执行 $Data_<逗号值> -> $MarkStart_, 每步等下位机回包;
模板 commands 取每个元素作为位置值( '=' 时取等号右侧), 按顺序逗号拼接
传多条会同步逐件打完, 单件场景传 1 条即可
"""
sr = HanslaserSendSerializer(data=request.data)
sr.is_valid(raise_exception=True)
vdata = sr.validated_data
lt: LabelTemplate = self.get_object()
ip = vdata.get("coder_ip")
port = vdata.get("coder_port")
coder_jobname = vdata.get("coder_jobname")
batched = []
for tdata in vdata["tdata_list"]:
commands = LabelTemplate.gen_commands(lt.id, None, tdata)
if not commands:
raise ParseError("模板未生成任何指令")
values = []
for item in commands:
text = str(item)
values.append(text.split("=", 1)[1] if "=" in text else text)
batched.append(values)
client = HanslaserClient(ip=ip, port=port)
if coder_jobname:
client.initialize(coder_jobname)
marked = 0
for values in batched:
client.send_data(values)
client.mark_start()
marked += 1
return Response({"marked": marked, "values": batched, "ip": ip, "port": port, "jobname": coder_jobname})

View File

@ -1,6 +1,7 @@
import socket import socket
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
import json import json
import re
import time import time
from django.core.cache import cache from django.core.cache import cache
from apps.utils.thread import MyThread from apps.utils.thread import MyThread
@ -243,6 +244,75 @@ def get_tyy_data_3(*args, retry=2):
raise ParseError(f"未知错误: {str(e)}") raise ParseError(f"未知错误: {str(e)}")
def _parse_vtfp_line(line):
"""解析 SVS 的 VTFP 应答行,返回 (识别字符, 字段列表)。
返回格式形如 ``VTFP 0 <用户自定义数据...>``
- 1 个字段固定为 ``VTFP``
- 2 个字段为状态码0 成功 0 失败失败时后面无其他数据
- 其余字段为用户在程序中自定义输出的数据即识别出的字符
分隔符按协议同时兼容空格和逗号
"""
parts = [p for p in re.split(r"[,\s]+", line.strip()) if p != ""]
if not parts or parts[0].upper() != "VTFP":
raise ParseError(f"SVS返回格式错误-{line}")
if len(parts) < 2:
raise ParseError(f"SVS返回缺少状态码-{line}")
status = parts[1]
if status != "0":
raise ParseError(f"SVS触发失败,状态码-{status}")
fields = parts[2:] # 用户自定义数据(识别字符),可能含可选的流程单元号回显
char = " ".join(fields)
return char, fields
def get_svs_char(host, port, flow_unit=0, timeout=10):
"""连接 SVS维视智造 VisionBank用 VTFP 触发拍照并取回识别字符。
返回 ``{"char": 识别字符, "fields": [...], "raw": 原始应答行}``
不同流程的输出配置可能不一样有的回 ``VTFP 0 <数据>`` 完整应答有的只回
用户自定义的识别内容( ``1``)这里读到第一行完整数据就返回 ``VTFP``
前缀的按协议解析否则整行当作识别内容
"""
cmd = f"VTFP {flow_unit}\r".encode("ascii") # 该设备只认单 CR 结尾, 带 \n 会无响应
try:
with socket.create_connection((host, int(port)), timeout=timeout) as sc:
buf = bytearray()
sc.sendall(cmd)
# 第一次用完整超时等设备响应; 设备返回的识别内容(如 b'1')末尾可能没有
# 换行符, 因此拿到数据后只用很短的超时把剩余字节收干净(兼容多位数分包),
# 不再死等换行, 否则会卡到超时
chunk = sc.recv(1024)
if chunk:
buf.extend(chunk)
sc.settimeout(0.3)
while b"\n" not in buf and b"\r" not in buf:
try:
more = sc.recv(1024)
except socket.timeout:
break
if not more:
break
buf.extend(more)
text = buf.decode("utf-8", errors="replace")
for ln in re.split(r"[\r\n]+", text):
ln = ln.strip()
if not ln:
continue
if ln.upper().startswith("VTFP"):
char, fields = _parse_vtfp_line(ln)
return {"char": char, "fields": fields, "raw": ln}
# 无 VTFP 前缀,设备直接回识别内容
return {"char": ln, "fields": ln.split(), "raw": ln}
except ParseError:
raise
except (socket.timeout, ConnectionError, OSError) as e:
raise ParseError(f"SVS连接失败-{str(e)}")
raise ParseError("SVS未返回有效数据")
def get_tyy_data(*args): def get_tyy_data(*args):
host, port = args[0], int(args[1]) host, port = args[0], int(args[1])
r = requests.get(f"http://127.0.0.1:2300?host={host}&port={port}") r = requests.get(f"http://127.0.0.1:2300?host={host}&port={port}")

View File

@ -0,0 +1,72 @@
# Generated by Django 4.2.27 on 2026-05-26 07:52
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('em', '0023_repair'),
]
operations = [
migrations.AlterField(
model_name='ecate',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='ecate',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='echeckrecord',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='echeckrecord',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='einspect',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='einspect',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='equipment',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='equipment',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='equipment',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='repair',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='repair',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
]

View File

@ -14,6 +14,7 @@ from rest_framework.decorators import action
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from django.db import transaction from django.db import transaction
from apps.em.services import daoru_equipment from apps.em.services import daoru_equipment
from apps.em import cd
from rest_framework.response import Response from rest_framework.response import Response
from django.conf import settings from django.conf import settings
from django.db.models import Count, Case, When, IntegerField from django.db.models import Count, Case, When, IntegerField
@ -127,6 +128,26 @@ class EquipmentViewSet(CustomModelViewSet):
return Response(json_result) return Response(json_result)
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(name="host", in_=openapi.IN_QUERY, description="SVS IP", type=openapi.TYPE_STRING, required=True),
openapi.Parameter(name="port", in_=openapi.IN_QUERY, description="SVS 端口", type=openapi.TYPE_STRING, required=True),
openapi.Parameter(name="flow_unit", in_=openapi.IN_QUERY, description="识别码(流程单元号), 多台 SVS 共用 IP 时用于区分设备", type=openapi.TYPE_STRING, required=True),
]
)
@action(methods=["get"], detail=False, perms_map={"get": "*"})
def get_svs_char(self, request, *args, **kwargs):
"""获取 SVS 识别字符
触发 SVS 拍照并取回识别字符, flow_unit 为识别码用于区分共用 IP 的不同设备
"""
host = request.query_params.get("host", None)
port = request.query_params.get("port", None)
if not host or not port:
raise ParseError("请传入host和port参数")
flow_unit = request.query_params.get("flow_unit", 0)
return Response(cd.get_svs_char(host, port, flow_unit))
class EcheckRecordViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, CustomGenericViewSet): class EcheckRecordViewSet(ListModelMixin, CreateModelMixin, DestroyModelMixin, CustomGenericViewSet):
""" """

View File

@ -0,0 +1,27 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wf', '0001_initial'),
('hrm', '0035_rename_band_card_employee_bank_card'),
]
operations = [
migrations.AlterField(
model_name='empcontract',
name='employee',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
to='hrm.employee', verbose_name='人员信息'),
),
migrations.AlterField(
model_name='empcontract',
name='ticket',
field=models.ForeignKey(
blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='contract_ticket', to='wf.ticket', verbose_name='关联工单'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2026-05-13 03:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('hrm', '0036_empcontract_batch'),
]
operations = [
migrations.AlterField(
model_name='empcontract',
name='counts',
field=models.PositiveSmallIntegerField(default=0, verbose_name='合同变更次数'),
),
]

View File

@ -354,8 +354,8 @@ class EmpContract(CommonAModel):
""" """
TN:劳动合同 TN:劳动合同
""" """
employee = models.OneToOneField(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True) employee = models.ForeignKey(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True)
ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单', ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单',
on_delete=models.CASCADE, related_name='contract_ticket', null=True, blank=True) on_delete=models.CASCADE, related_name='contract_ticket', null=True, blank=True)
counts = models.PositiveSmallIntegerField('合同变更次数', default=0) counts = models.PositiveSmallIntegerField('合同变更次数', default=0)
plan_renewal = models.DateField('应续签', null=True, blank=True) plan_renewal = models.DateField('应续签', null=True, blank=True)

View File

@ -425,6 +425,22 @@ class EmpContractSerializer(CustomModelSerializer):
gender = serializers.CharField(source="employee.gender", read_only=True) gender = serializers.CharField(source="employee.gender", read_only=True)
join_date = serializers.CharField(source="employee.start_date", read_only=True) join_date = serializers.CharField(source="employee.start_date", read_only=True)
end_contract = serializers.CharField(source="employee.contract_end_date", read_only=True) end_contract = serializers.CharField(source="employee.contract_end_date", read_only=True)
batch_employees = serializers.SerializerMethodField()
def get_batch_employees(self, obj):
if obj.ticket_id:
siblings = EmpContract.objects.filter(ticket_id=obj.ticket_id).select_related(
'employee', 'employee__post', 'employee__belong_dept')
if siblings.count() > 1:
return [{
'employee_name': s.employee.name if s.employee else '',
'post_name': s.employee.post.name if s.employee and s.employee.post else '',
'dept_name': s.employee.belong_dept.name if s.employee and s.employee.belong_dept else '',
'join_date': str(s.employee.start_date or '') if s.employee else '',
'end_contract': str(s.employee.contract_end_date or '') if s.employee else '',
} for s in siblings]
return []
class Meta: class Meta:
model = EmpContract model = EmpContract
fields = '__all__' fields = '__all__'

View File

@ -33,8 +33,9 @@ from datetime import datetime
from apps.utils.export import export_excel from apps.utils.export import export_excel
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet
from apps.utils.mixins import BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, RetrieveModelMixin from apps.utils.mixins import BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, RetrieveModelMixin
from apps.wf.models import Ticket from apps.wf.models import Ticket, Workflow, State
from apps.wf.mixins import TicketMixin from apps.wf.mixins import TicketMixin
from apps.wf.services import WfService
from apps.system.models import Post, Dept from apps.system.models import Post, Dept
from django.db.models import DateField from django.db.models import DateField
from datetime import datetime, date from datetime import datetime, date
@ -289,23 +290,70 @@ class EmployeeViewSet(CustomModelViewSet):
"""导出excel """导出excel
导出excel 导出excel
""" """
field_data = ['人员类型', '人员', '手机号', '身份证号', '所属部门', '在职状态', '定位卡号'] # 导出列定义: (表头, 取值函数)。覆盖人员的全部业务字段。
queryset = self.filter_queryset(self.get_queryset()) def _cell(v):
if queryset.count() > 1000: return '' if v is None else v
raise ParseError('数据量超过1000,请筛选后导出') columns = [
('人员类型', lambda i: epTypeOptions.get(i['type'], i['type'])),
('姓名', lambda i: i['name']),
('性别', lambda i: i['gender']),
('人员编号', lambda i: i['number']),
('手机号', lambda i: i['phone']),
('个人邮箱', lambda i: i['email']),
('身份证号', lambda i: i['id_number']),
('所属部门', lambda i: i.get('belong_dept_name', '')),
('所属岗位', lambda i: i.get('post_name', '')),
('车间', lambda i: i['workshop']),
('职务', lambda i: i['position']),
('职务聘任日期', lambda i: i['office_date']),
('在职状态', lambda i: epStateOptions.get(i['job_state'], i['job_state'])),
('入职日期', lambda i: i['start_date']),
('转正日期', lambda i: i['regular_date']),
('合同到期日', lambda i: i['contract_end_date']),
('离职日期', lambda i: i['end_date']),
('银行卡号', lambda i: i['bank_card']),
('出生年月日', lambda i: i['birthday']),
('年龄', lambda i: i['age']),
('工龄', lambda i: i['work_years']),
('学历', lambda i: i['qualification']),
('政治面貌', lambda i: i['partisan']),
('入党时间', lambda i: i['join_partisan_date']),
('民族', lambda i: i['nation']),
('婚姻状况', lambda i: i['marriage']),
('户口性质', lambda i: i['hukou_type']),
('籍贯', lambda i: i['birthplace']),
('户籍地址', lambda i: i['hukou_address']),
('现住地址', lambda i: i['address']),
('枣庄市职称', lambda i: i['zhuanzhi']),
('获得枣庄市职称日期', lambda i: i['zhuanzhi_date']),
('总院/集团职称', lambda i: i['zyjt_zhuanzhi']),
('获得职称日期', lambda i: i['zyjt_zhuanzhi_date']),
('职业技能等级', lambda i: i['skill_rank']),
('获得技能等级证书日期', lambda i: i['skill_rank_date']),
('全日制最高学历', lambda i: i['full_edu']),
('全日制最高学历学校名称', lambda i: i['full_edu_school']),
('全日制最高学历专业', lambda i: i['full_edu_major']),
('全日制最高学历毕业时间', lambda i: i['full_edu_time']),
('非全日制最高学历', lambda i: i['part_edu']),
('非全日制最高学历学校名称', lambda i: i['part_edu_school']),
('非全日制最高学历专业', lambda i: i['part_edu_major']),
('非全日制最高学历毕业时间', lambda i: i['part_edu_time']),
('紧急联系人姓名', lambda i: i['emergency_contact']),
('紧急联系人电话', lambda i: i['emergency_phone']),
('所获荣誉', lambda i: i['honor']),
('首次缴纳社保日期', lambda i: i['first_social_security_date']),
('是否为退役军人', lambda i: '' if i['is_veteran'] else ''),
('定位卡号', lambda i: i['blt_'].get('code', '') if i.get('blt_') else ''),
('创建时间', lambda i: (i.get('create_time') or '')[:19].replace('T', ' ')),
]
field_data = [c[0] for c in columns]
queryset = self.filter_queryset(self.get_queryset()).select_related(
'belong_dept', 'post', 'blt')
odata = EmployeeSerializer(queryset, many=True).data odata = EmployeeSerializer(queryset, many=True).data
# 处理数据 # 处理数据
data = [] data = []
for i in odata: for i in odata:
data.append( data.append([_cell(getter(i)) for _, getter in columns])
[epTypeOptions[i['type']],
i['name'],
i['phone'],
i['id_number'],
i.get('belong_dept_name', ''),
epStateOptions[i['job_state']],
i['blt_'].get('code', '') if 'blt_' in i and i['blt_'] else '']
)
return Response({'path': export_excel(field_data, data, '人员信息')}) return Response({'path': export_excel(field_data, data, '人员信息')})
@action(methods=['post'], detail=False, perms_map={'post': 'employee.import_excel'}, @action(methods=['post'], detail=False, perms_map={'post': 'employee.import_excel'},
@ -360,8 +408,8 @@ class EmployeeViewSet(CustomModelViewSet):
id_number = data.get("id_number") id_number = data.get("id_number")
name = data.get("name") name = data.get("name")
if not id_number or not name: if not name:
raise ParseError(f'{row_num}行,身份证号或姓名为空') raise ParseError(f'{row_num}行,姓名为空')
myLogger.info(f"处理第{row_num}行:{name} - {id_number}") myLogger.info(f"处理第{row_num}行:{name} - {id_number}")
# 处理人员类型 # 处理人员类型
@ -371,9 +419,9 @@ 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())}')
# 处理部门外键 # 处理部门外键:填了就校验是否存在并赋值;为空时不动(新增场景的必填在下方创建处校验)
if 'belong_dept' in data and data['belong_dept']: dept_name = data.pop('belong_dept', None)
dept_name = data.pop('belong_dept') if dept_name:
if dept_name not in dept_map: if dept_name not in dept_map:
raise ParseError(f'{row_num}行,部门"{dept_name}"不存在') raise ParseError(f'{row_num}行,部门"{dept_name}"不存在')
data['belong_dept_id'] = dept_map[dept_name] data['belong_dept_id'] = dept_map[dept_name]
@ -387,34 +435,44 @@ class EmployeeViewSet(CustomModelViewSet):
if not re.match(r'^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$', data['id_number']): if not re.match(r'^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(10|11|12))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$', data['id_number']):
raise ParseError(f'{row_num}行,身份证号格式不正确') raise ParseError(f'{row_num}行,身份证号格式不正确')
# 查找或创建/补全 # 按姓名匹配并更新:姓名唯一则直接更新(身份证号可为空);
# 存在重名则必须用身份证号定位具体记录,否则报错。
try: try:
with transaction.atomic(): with transaction.atomic():
# 优先按身份证号匹配,匹配不到再按姓名匹配 name_matches = list(Employee.objects.filter(name=name))
existing = None if len(name_matches) <= 1:
if id_number: existing = name_matches[0] if name_matches else None
existing = Employee.objects.filter(id_number=id_number).first() # 同名但身份证号不一致,视为不同人员,新建
if not existing and name: if existing and id_number and existing.id_number \
existing = Employee.objects.filter(name=name, id_number__isnull=True).first() or \ and existing.id_number != id_number:
Employee.objects.filter(name=name, id_number='').first() existing = None
else:
# 存在重名,必须用身份证号区分
if not id_number:
raise ParseError(f'{row_num}行,姓名"{name}"存在重名,必须填写身份证号')
existing = next(
(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 continue
current_value = getattr(existing, field_name, None) if getattr(existing, field_name, None) != value:
if current_value in [None, '']:
setattr(existing, field_name, value) setattr(existing, field_name, value)
updated_fields.append(field_name) updated_fields.append(field_name)
if updated_fields: if updated_fields:
existing.save(update_fields=updated_fields + ['update_time']) existing.save(update_fields=updated_fields + ['update_time'])
myLogger.info(f"✅ 第{row_num}补全成功:{name},更新字段:{updated_fields}") myLogger.info(f"✅ 第{row_num}更新成功:{name},更新字段:{updated_fields}")
else: else:
myLogger.info(f"⏭️ 第{row_num}行无需补全{name}") myLogger.info(f"⏭️ 第{row_num}行无变化{name}")
created = False created = False
else: else:
Employee.objects.create(id_number=id_number, name=name, **data) # 新增人员时所属部门必填
if 'belong_dept_id' not in data:
raise ParseError(f'{row_num}行,新增人员时所属部门不能为空')
Employee.objects.create(**data)
created = True created = True
except Exception as e: except Exception as e:
raise raise
@ -825,3 +883,49 @@ class EmpContractViewSet(TicketMixin, EuModelViewSet):
def gen_other_ticket_data(self, instance): def gen_other_ticket_data(self, instance):
return {"name": instance.employee.name if instance.employee.name else None} return {"name": instance.employee.name if instance.employee.name else None}
def perform_create(self, serializer):
result = serializer.save()
is_batch = isinstance(result, list)
instances = result if is_batch else [result]
handler = self.request.user
workflow_key = self.get_workflow_key(instances[0])
if not workflow_key:
raise ParseError('工作流异常:必须赋值workflow_key')
try:
wf = Workflow.objects.get(key=workflow_key)
except Exception as e:
raise ParseError(f'工作流{workflow_key}异常:{e}')
# 批量时创建一个工单,所有记录共享
if is_batch:
names = [ins.employee.name for ins in instances if ins.employee]
ticket_data = {
"t_model": "EmpContract",
"t_id": str(instances[0].id),
"batch": True,
"count": len(instances),
"name": f"批量合同变更({len(instances)}人)",
"employees": [
{"name": ins.employee.name, "id": str(ins.id)} for ins in instances if ins.employee
],
}
else:
ticket_data = self.gen_ticket_data(instances[0])
ticket = WfService.handle_ticket(ticket=None, transition=None, workflow=wf, new_ticket_data=ticket_data,
handler=handler, oinfo={})
for ins in instances:
ins.ticket = ticket
ins.save(update_fields=['ticket'])
if self.ticket_auto_submit_on_create:
source_state = WfService.get_workflow_start_state(wf)
transitions = WfService.get_state_transitions(source_state)
if transitions.count() == 1:
transition = transitions.first()
WfService.handle_ticket(ticket=ticket, transition=transition, new_ticket_data=ticket_data,
handler=handler, oinfo={})
else:
raise ParseError(f'工作流{workflow_key}异常:有多个或无后续状态;不可处理')

View File

@ -264,6 +264,21 @@ class MIOItemListSimpleSerializer(CustomModelSerializer):
model = MIOItem model = MIOItem
fields = ["id", "mio", "material", "warehouse", "material_name", "warehouse_name", "batch", "count", "test_date", "count_notok"] fields = ["id", "mio", "material", "warehouse", "material_name", "warehouse_name", "batch", "count", "test_date", "count_notok"]
class _MioDetailItemMaterialSerializer(CustomModelSerializer):
class Meta:
model = Material
fields = ["id", "name", "model"]
class MIOItemForMioDetailSerializer(CustomModelSerializer):
"""MIO 详情下挂的 items 用,仅含前端实际消费的字段"""
material_ = _MioDetailItemMaterialSerializer(source='material', read_only=True)
class Meta:
model = MIOItem
fields = ["id", "mio", "material", "material_", "batch", "count", "pack_index"]
class MIOItemSerializer(CustomModelSerializer): class MIOItemSerializer(CustomModelSerializer):
warehouse_name = serializers.CharField(source='warehouse.name', read_only=True) warehouse_name = serializers.CharField(source='warehouse.name', read_only=True)
material_ = MaterialSerializer(source='material', read_only=True) material_ = MaterialSerializer(source='material', read_only=True)
@ -273,11 +288,33 @@ class MIOItemSerializer(CustomModelSerializer):
inout_date = serializers.DateField(source='mio.inout_date', read_only=True) inout_date = serializers.DateField(source='mio.inout_date', 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)
mioitemw = MIOItemwSerializer(many=True, required=False) mioitemw = MIOItemwSerializer(many=True, required=False)
defect = serializers.SerializerMethodField(label="缺陷")
defect_name = serializers.SerializerMethodField(label="缺陷名称")
okcate = serializers.SerializerMethodField(label="缺陷分类")
class Meta: class Meta:
model = MIOItem model = MIOItem
fields = '__all__' fields = '__all__'
def _resolve_defect(self, obj):
if obj.wm_id and obj.wm.defect_id:
return obj.wm.defect
if obj.mb_id and obj.mb.defect_id:
return obj.mb.defect
return None
def get_defect(self, obj):
d = self._resolve_defect(obj)
return d.id if d else None
def get_defect_name(self, obj):
d = self._resolve_defect(obj)
return d.name if d else None
def get_okcate(self, obj):
d = self._resolve_defect(obj)
return d.okcate if d else None
def to_representation(self, instance): def to_representation(self, instance):
ret = super().to_representation(instance) ret = super().to_representation(instance)
ret["price"] = None ret["price"] = None
@ -424,7 +461,7 @@ class MIOOtherSerializer(CustomModelSerializer):
class MIODetailSerializer(MIOListSerializer): class MIODetailSerializer(MIOListSerializer):
items = MIOItemSerializer(source='item_mio', many=True, read_only=True) items = MIOItemForMioDetailSerializer(source='item_mio', many=True, read_only=True)
class MIOItemTestSerializer(CustomModelSerializer): class MIOItemTestSerializer(CustomModelSerializer):

View File

@ -13,9 +13,6 @@ def do_out(item: MIOItem, is_reverse: bool = False):
""" """
生产领料到车间 生产领料到车间
""" """
if item.mb and item.mb.defect is not None:
raise ParseError("生产领料不支持不合格品")
from apps.inm.models import MaterialBatch from apps.inm.models import MaterialBatch
mio:MIO = item.mio mio:MIO = item.mio
belong_dept = mio.belong_dept belong_dept = mio.belong_dept
@ -75,6 +72,10 @@ def do_out(item: MIOItem, is_reverse: bool = False):
xcount:str = al[2] xcount:str = al[2]
defect:Defect = al[3] defect:Defect = al[3]
state = WMaterial.WM_OK
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
state = WMaterial.WM_NOTOK
xbatches.append(xbatch) xbatches.append(xbatch)
if xcount <= 0: if xcount <= 0:
raise ParseError("存在非正数!") raise ParseError("存在非正数!")
@ -85,7 +86,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
material=xmaterial, material=xmaterial,
warehouse=item.warehouse, warehouse=item.warehouse,
batch=xbatch, batch=xbatch,
state=WMaterial.WM_OK, state=state,
defect=defect defect=defect
) )
except (MaterialBatch.DoesNotExist, MaterialBatch.MultipleObjectsReturned) as e: except (MaterialBatch.DoesNotExist, MaterialBatch.MultipleObjectsReturned) as e:
@ -101,7 +102,7 @@ def do_out(item: MIOItem, is_reverse: bool = False):
wm, new_create = WMaterial.objects.get_or_create( wm, new_create = WMaterial.objects.get_or_create(
batch=xbatch, material=xmaterial, batch=xbatch, material=xmaterial,
belong_dept=belong_dept, mgroup=mgroup, belong_dept=belong_dept, mgroup=mgroup,
state=WMaterial.WM_OK, defect=defect) state=state, defect=defect)
if new_create: if new_create:
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
@ -176,6 +177,10 @@ def do_in(item: MIOItem):
if xcount <= 0: if xcount <= 0:
raise ParseError("存在非正数!") raise ParseError("存在非正数!")
state = WMaterial.WM_OK
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
state = WMaterial.WM_NOTOK
xbatchs.append(xbatch) xbatchs.append(xbatch)
if xmaterial.into_wm: if xmaterial.into_wm:
if xwm: if xwm:
@ -187,7 +192,7 @@ def do_in(item: MIOItem):
belong_dept=belong_dept, belong_dept=belong_dept,
mgroup=mgroup, mgroup=mgroup,
defect=defect, defect=defect,
state=WMaterial.WM_OK) state=state)
count_x = wm_qs.count() count_x = wm_qs.count()
if count_x == 1: if count_x == 1:
wm = wm_qs.first() wm = wm_qs.first()
@ -219,7 +224,7 @@ def do_in(item: MIOItem):
material=xmaterial, material=xmaterial,
warehouse=item.warehouse, warehouse=item.warehouse,
batch=xbatch, batch=xbatch,
state=WMaterial.WM_OK, state=state,
defect=defect, defect=defect,
defaults={ defaults={
"count": 0, "count": 0,
@ -458,7 +463,8 @@ class InmService:
@classmethod @classmethod
def revert_and_del(cls, mioitem: MIOItem): def revert_and_del(cls, mioitem: MIOItem):
mio = mioitem.mio # 锁定 MIO 行,防止多人同时撤销不同明细时并发漏删 MIO
mio = MIO.objects.select_for_update().get(id=mioitem.mio_id)
if mio.submit_time is None: if mio.submit_time is None:
raise ParseError("未提交的出入库明细不允许撤销") raise ParseError("未提交的出入库明细不允许撤销")
if mioitem.test_date is not None: if mioitem.test_date is not None:
@ -479,4 +485,7 @@ class InmService:
mioitem.delete() mioitem.delete()
else: else:
raise ParseError("不支持该出入库单明细撤销") raise ParseError("不支持该出入库单明细撤销")
# 若该出入库记录已无明细,自动删除
if not MIOItem.objects.filter(mio=mio).exists():
mio.delete()

View File

@ -7,7 +7,7 @@ from django.conf import settings
from rest_framework import serializers from rest_framework import serializers
from django.utils import timezone from django.utils import timezone
from rest_framework.response import Response from rest_framework.response import Response
from django.db.models import Sum from django.db.models import Sum, Prefetch
from apps.inm.models import WareHouse, MaterialBatch, MIO, MIOItem, MIOItemw, Pack from apps.inm.models import WareHouse, MaterialBatch, MIO, MIOItem, MIOItemw, Pack
from apps.inm.serializers import ( from apps.inm.serializers import (
@ -153,6 +153,15 @@ class MIOViewSet(CustomModelViewSet):
'item_mio__a_mioitem__batch'] 'item_mio__a_mioitem__batch']
data_filter = True data_filter = True
def get_queryset(self):
queryset = super().get_queryset()
if self.action == 'retrieve':
queryset = queryset.prefetch_related(Prefetch(
'item_mio',
queryset=MIOItem.objects.select_related('material'),
))
return queryset
@classmethod @classmethod
def lock_and_check_can_update(cls, mio:MIO): def lock_and_check_can_update(cls, mio:MIO):
if not connection.in_atomic_block: if not connection.in_atomic_block:
@ -346,7 +355,7 @@ class MIOItemViewSet(CustomListModelMixin, BulkCreateModelMixin, BulkDestroyMode
serializer_class = MIOItemSerializer serializer_class = MIOItemSerializer
retrieve_serializer_class = MioItemDetailSerializer retrieve_serializer_class = MioItemDetailSerializer
create_serializer_class = MIOItemCreateSerializer create_serializer_class = MIOItemCreateSerializer
select_related_fields = ['warehouse', 'mio', 'material', 'test_user'] select_related_fields = ['warehouse', 'mio', 'material', 'test_user', 'wm__defect', 'mb__defect']
filterset_fields = { filterset_fields = {
"warehouse": ["exact"], "warehouse": ["exact"],
"mio": ["exact"], "mio": ["exact"],

View File

@ -0,0 +1,137 @@
# Generated by Django 4.2.27 on 2026-05-15 03:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('system', '0007_alter_dept_create_by_alter_dept_third_info_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('mtm', '0063_auto_20251106_2339'),
]
operations = [
migrations.AddField(
model_name='process',
name='clear_defect',
field=models.BooleanField(default=False, verbose_name='合批清除缺陷'),
),
migrations.AlterField(
model_name='goal',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='goal',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='material',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='material',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='mgroup',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='mgroup',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='mgroup',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='process',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='process',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='process',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='route',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='route',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='routepack',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='routepack',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='shift',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='shift',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='shift',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='srule',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='srule',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='srule',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
migrations.AlterField(
model_name='team',
name='belong_dept',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_belong_dept', to='system.dept', verbose_name='所属部门'),
),
migrations.AlterField(
model_name='team',
name='create_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_create_by', to=settings.AUTH_USER_MODEL, verbose_name='创建人'),
),
migrations.AlterField(
model_name='team',
name='update_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_update_by', to=settings.AUTH_USER_MODEL, verbose_name='最后编辑人'),
),
]

View File

@ -37,7 +37,7 @@ class Process(CommonBModel):
parent = models.ForeignKey('self', verbose_name='父工序', on_delete=models.CASCADE, null=True, blank=True) parent = models.ForeignKey('self', verbose_name='父工序', on_delete=models.CASCADE, null=True, blank=True)
number_to_batch = models.BooleanField('个号转批号', default=False) number_to_batch = models.BooleanField('个号转批号', default=False)
wpr_number_rule = models.TextField("单个编号规则", null=True, blank=True) wpr_number_rule = models.TextField("单个编号规则", null=True, blank=True)
clear_defect = models.BooleanField('合批清除缺陷', default=False)
class Meta: class Meta:
verbose_name = '工序' verbose_name = '工序'
ordering = ['sort', 'create_time'] ordering = ['sort', 'create_time']

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpm', '0129_mlogbdefect_is_inherited'),
]
operations = [
migrations.AddField(
model_name='handover',
name='clear_defect',
field=models.BooleanField(default=False, verbose_name='合批清除缺陷'),
),
]

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpm', '0130_handover_clear_defect'),
]
operations = [
migrations.AddField(
model_name='mlog',
name='clear_defect',
field=models.BooleanField(default=False, verbose_name='合格B类缺陷不拆批'),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.27 on 2026-05-26 07:52
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('em', '0024_alter_ecate_create_by_alter_ecate_update_by_and_more'),
('wpm', '0131_mlog_clear_defect'),
]
operations = [
migrations.AddField(
model_name='mlogbw',
name='tooling',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mlogbw_tooling', to='em.equipment', verbose_name='工装'),
),
migrations.AlterField(
model_name='mlogbw',
name='equip',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='mlogbw_equip', to='em.equipment', verbose_name='设备'),
),
]

View File

@ -242,6 +242,7 @@ class Mlog(CommonADModel):
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_mgroup = models.BooleanField('入库到工段', default=False)
stored_notok = models.BooleanField('不合格品是否已入库', default=False) stored_notok = models.BooleanField('不合格品是否已入库', default=False)
clear_defect = models.BooleanField('合格B类缺陷不拆批', default=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')
@ -613,7 +614,8 @@ class Mlogbw(BaseModel):
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
, related_name='wpr_mlogbw', null=True, blank=True) , related_name='wpr_mlogbw', null=True, blank=True)
equip = models.ForeignKey(Equipment, verbose_name='设备', on_delete=models.SET_NULL, null=True, blank=True) equip = models.ForeignKey(Equipment, verbose_name='设备', on_delete=models.SET_NULL, null=True, blank=True, related_name='mlogbw_equip')
tooling = models.ForeignKey(Equipment, verbose_name='工装', on_delete=models.SET_NULL, null=True, blank=True, related_name='mlogbw_tooling')
work_start_time = models.DateTimeField('开始加工时间', null=True, blank=True) work_start_time = models.DateTimeField('开始加工时间', null=True, blank=True)
work_end_time = models.DateTimeField('结束加工时间', null=True, blank=True) work_end_time = models.DateTimeField('结束加工时间', null=True, blank=True)
ftest = models.OneToOneField("qm.ftest", verbose_name='关联检验', ftest = models.OneToOneField("qm.ftest", verbose_name='关联检验',
@ -677,6 +679,7 @@ class Handover(CommonADModel):
H_DIV = 20 H_DIV = 20
new_batch = models.TextField('新批次号', null=True, blank=True, db_index=True) new_batch = models.TextField('新批次号', null=True, blank=True, db_index=True)
new_wm = models.ForeignKey(WMaterial, on_delete=models.SET_NULL, null=True, blank=True) new_wm = models.ForeignKey(WMaterial, on_delete=models.SET_NULL, null=True, blank=True)
clear_defect = models.BooleanField('合批清除缺陷', default=False)
mtype = models.PositiveSmallIntegerField("合并类型", default=H_NORMAL, choices= mtype = models.PositiveSmallIntegerField("合并类型", default=H_NORMAL, choices=
[(H_NORMAL, '正常'), (H_DIV, '分批'), (H_MERGE, '合批')]) [(H_NORMAL, '正常'), (H_DIV, '分批'), (H_MERGE, '合批')])
type = models.PositiveSmallIntegerField('交接类型', choices=[ type = models.PositiveSmallIntegerField('交接类型', choices=[

View File

@ -1,7 +1,7 @@
from apps.wpm.models import BatchSt from apps.wpm.models import BatchSt
import logging import logging
from apps.qm.models import Defect, FtestWork, FtestworkDefect from apps.qm.models import Defect, FtestWork, FtestworkDefect
from apps.wpm.models import Mlogb, MlogbDefect from apps.wpm.models import Mlogb, MlogbDefect, Mlog
from apps.mtm.models import Mgroup from apps.mtm.models import Mgroup
import decimal import decimal
from django.db.models import Sum from django.db.models import Sum
@ -87,35 +87,44 @@ def main(batch: str, mgroup_obj):
data[f"{mgroup_name}_班次"] = ";".join([item for item in data[f"{mgroup_name}_班次"]]) data[f"{mgroup_name}_班次"] = ";".join([item for item in data[f"{mgroup_name}_班次"]])
mlogb2_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False, # 按 mlog__submit_time, id 排序,每条 Mlogb 记录独立为一次返修
material_out__isnull=False, # (同一 Mlog 下有多条同批次 Mlogb 时也能正确拆分为多次返修)
mlog__mgroup__name="外观检验", _all_fix_qs = Mlogb.objects.filter(
mlog__is_fix=True, batch=batch, need_inout=True) mlog__submit_time__isnull=False,
if mlogb2_qs.exists(): material_out__isnull=False,
data["外观检验_返修_日期"] = [] mlog__mgroup__name="外观检验",
data["外观检验_返修_操作人"] = [] mlog__is_fix=True,
data["外观检验_返修_count_real"] = 0 batch=batch,
data["外观检验_返修_count_ok"] = 0 need_inout=True,
data["外观检验_返修_count_ok_full"] = 0 ).order_by("mlog__submit_time", "id")
for item in mlogb2_qs: _fix_prefixes = []
if item.mlog.handle_user: for fix_idx, fix_mlogb in enumerate(_all_fix_qs):
data["外观检验_返修_操作人"].append(item.mlog.handle_user) suffix = "" if fix_idx == 0 else str(fix_idx + 1)
if item.mlog.handle_date: prefix = f"外观检验_返修{suffix}_"
data["外观检验_返修_日期"].append(item.mlog.handle_date) _fix_prefixes.append(prefix)
data["外观检验_返修_count_real"] += item.count_real mlog = fix_mlogb.mlog
data["外观检验_返修_count_ok"] += item.count_ok handle_date = mlog.handle_date
data["外观检验_返修_count_ok_full"] += item.count_ok_full if item.count_ok_full else 0 data[f"{prefix}count_real"] = fix_mlogb.count_real
data[f"{prefix}count_ok"] = fix_mlogb.count_ok
data["外观检验_返修_日期"] = list(set(data["外观检验_返修_日期"])) data[f"{prefix}count_ok_full"] = fix_mlogb.count_ok_full or 0
data["外观检验_返修_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["外观检验_返修_日期"]]) data[f"{prefix}count_notok"] = fix_mlogb.count_notok or 0
data["外观检验_返修_操作人"] = list(set(data["外观检验_返修_操作人"])) try:
data["外观检验_返修_操作人"] = ";".join([item.name for item in data["外观检验_返修_操作人"]]) data[f"{prefix}合格率"] = round((fix_mlogb.count_ok / fix_mlogb.count_real) * 100, 2)
except (decimal.InvalidOperation, ZeroDivisionError):
mlogbd2_qs = MlogbDefect.objects.filter(mlogb__in=mlogb2_qs, count__gt=0).values("defect__name").annotate(total=Sum("count")) data[f"{prefix}合格率"] = 0
try:
for item in mlogbd2_qs: data[f"{prefix}完全合格率"] = round(((fix_mlogb.count_ok_full or 0) / fix_mlogb.count_real) * 100, 2)
data[f"外观检验_返修_缺陷_{item['defect__name']}"] = item["total"] except (decimal.InvalidOperation, ZeroDivisionError):
data[f"外观检验_返修_缺陷_{item['defect__name']}_比例"] = round((item["total"] / data["外观检验_返修_count_real"])*100, 2) data[f"{prefix}完全合格率"] = 0
data[f"{prefix}日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"{prefix}小日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"{prefix}大日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"{prefix}操作人"] = mlog.handle_user.name if mlog.handle_user else ""
data[f"{prefix}班次"] = mlog.shift.name if mlog.shift else ""
fix_defect_qs = MlogbDefect.objects.filter(mlogb=fix_mlogb, count__gt=0).values("defect__name").annotate(total=Sum("count"))
for item in fix_defect_qs:
data[f"{prefix}缺陷_{item['defect__name']}"] = item["total"]
data[f"{prefix}缺陷_{item['defect__name']}_比例"] = round((item["total"] / fix_mlogb.count_real) * 100, 2)
# 车间库存抽检 # 车间库存抽检
ft_qs = FtestWork.objects.filter(type2=FtestWork.TYPE2_SOME, wm__mgroup__name="外观检验", batch=batch, submit_time__isnull=False) ft_qs = FtestWork.objects.filter(type2=FtestWork.TYPE2_SOME, wm__mgroup__name="外观检验", batch=batch, submit_time__isnull=False)
@ -141,13 +150,13 @@ def main(batch: str, mgroup_obj):
data[f"外观检验_车间库存抽检_缺陷_{item['defect__name']}"] = item["total"] data[f"外观检验_车间库存抽检_缺陷_{item['defect__name']}"] = item["total"]
if "外观检验_count_ok" in data: if "外观检验_count_ok" in data:
data["外观检验_总合格数"] = data["外观检验_count_ok"] + data.get("外观检验_返修_count_ok", 0) data["外观检验_总合格数"] = data["外观检验_count_ok"] + sum(data.get(f"{p}count_ok", 0) for p in _fix_prefixes)
try: try:
data["外观检验_总合格率"] = round((data["外观检验_总合格数"] / data["外观检验_count_real"])*100, 2) data["外观检验_总合格率"] = round((data["外观检验_总合格数"] / data["外观检验_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError): except (decimal.InvalidOperation, ZeroDivisionError):
data["外观检验_总合格率"] = 0 data["外观检验_总合格率"] = 0
data["外观检验_完全总合格数"] = data["外观检验_count_ok_full"] + data.get("外观检验_返修_count_ok_full", 0) data["外观检验_完全总合格数"] = data["外观检验_count_ok_full"] + sum(data.get(f"{p}count_ok_full", 0) for p in _fix_prefixes)
try: try:
data["外观检验_完全总合格率"] = round((data["外观检验_完全总合格数"] / data["外观检验_count_real"])*100, 2) data["外观检验_完全总合格率"] = round((data["外观检验_完全总合格数"] / data["外观检验_count_real"])*100, 2)
except (decimal.InvalidOperation, ZeroDivisionError): except (decimal.InvalidOperation, ZeroDivisionError):

View File

@ -680,6 +680,8 @@ class MlogSerializer(CustomModelSerializer):
handle_user = attrs.get('handle_user', None) handle_user = attrs.get('handle_user', None)
if handle_user is None and hasattr(self, "request"): if handle_user is None and hasattr(self, "request"):
handle_user = self.request.user handle_user = self.request.user
if self.instance is None and mgroup.process:
attrs['clear_defect'] = mgroup.process.clear_defect
return attrs return attrs
@ -726,12 +728,14 @@ class MlogInitSerializer(CustomModelSerializer):
attrs["qct"] = Qct.get(attrs["material_out"], "process", "out") attrs["qct"] = Qct.get(attrs["material_out"], "process", "out")
attrs["handle_date"], attrs["shift"] = mgroup.get_shift(attrs['work_start_time']) attrs["handle_date"], attrs["shift"] = mgroup.get_shift(attrs['work_start_time'])
if mgroup.process:
attrs['clear_defect'] = mgroup.process.clear_defect
return attrs return attrs
class MlogChangeSerializer(CustomModelSerializer): class MlogChangeSerializer(CustomModelSerializer):
class Meta: class Meta:
model = Mlog model = Mlog
fields = ['id', 'work_start_time', 'work_end_time', 'handle_user', 'note', 'oinfo_json', 'test_file', 'test_user', 'test_time', 'equipment', "team"] fields = ['id', 'work_start_time', 'work_end_time', 'handle_user', 'note', 'oinfo_json', 'test_file', 'test_user', 'test_time', 'equipment', "team", "clear_defect"]
def update(self, instance, validated_data): def update(self, instance, validated_data):
work_start_time = validated_data.get('work_start_time', None) work_start_time = validated_data.get('work_start_time', None)
@ -874,13 +878,17 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
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)
tooling_name = serializers.CharField(source='tooling.name', read_only=True)
tooling_number = serializers.CharField(source='tooling.number', read_only=True)
wpr_number_out = serializers.CharField(source='wpr.number_out', read_only=True) wpr_number_out = serializers.CharField(source='wpr.number_out', read_only=True)
wpr_pre_info = serializers.JSONField(source='wpr.pre_info', read_only=True)
mlogb__batch = serializers.CharField(source='mlogb.batch', read_only=True) mlogb__batch = serializers.CharField(source='mlogb.batch', read_only=True)
class Meta: class Meta:
model = Mlogbw model = Mlogbw
fields = ["id", "number", "wpr", "note", fields = ["id", "number", "wpr", "note",
"mlogb", "ftest", "equip", "work_start_time", "mlogb", "ftest", "equip", "tooling", "work_start_time",
"work_end_time", "mlogbw_from", "equip_name", "equip_number", "wpr_number_out", "mlogb__batch"] "work_end_time", "mlogbw_from", "equip_name", "equip_number",
"tooling_name", "tooling_number", "wpr_number_out", "wpr_pre_info", "mlogb__batch"]
read_only_fields = ["mlogbw_from"] read_only_fields = ["mlogbw_from"]
def validate(self, attrs): def validate(self, attrs):
@ -1304,16 +1312,21 @@ 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:
raise ParseError('清除缺陷合批的目标批次不能带缺陷')
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
next_defect = wm.defect next_defect = None if clear_defect else wm.defect
else: else:
if next_mat != wm.material: if next_mat != wm.material:
raise ParseError(f'{ind+1}行-合并的物料不一致') raise ParseError(f'{ind+1}行-合并的物料不一致')
if next_state != wm.state: if next_state != wm.state:
raise ParseError(f'{ind+1}行-合并的物料状态不一致') raise ParseError(f'{ind+1}行-合并的物料状态不一致')
if next_defect != wm.defect: if not clear_defect and next_defect != wm.defect:
raise ParseError(f'{ind+1}行-合并的物料缺陷不一致') raise ParseError(f'{ind+1}行-合并的物料缺陷不一致')
if tracking == Material.MA_TRACKING_SINGLE: if tracking == Material.MA_TRACKING_SINGLE:
handoverbw = item.get("handoverbw", []) handoverbw = item.get("handoverbw", [])
@ -1549,6 +1562,7 @@ class MlogUserSerializer(CustomModelSerializer):
handle_user_name = serializers.CharField(source='handle_user.name', read_only=True) handle_user_name = serializers.CharField(source='handle_user.name', read_only=True)
process_name = serializers.CharField(source='process.name', read_only=True) process_name = serializers.CharField(source='process.name', read_only=True)
shift_name = serializers.CharField(source='shift.name', read_only=True) shift_name = serializers.CharField(source='shift.name', read_only=True)
equipment_name = serializers.CharField(source='equipment.name', read_only=True, default=None)
class Meta: class Meta:
model = MlogUser model = MlogUser
fields = "__all__" fields = "__all__"

View File

@ -282,9 +282,13 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
mlogb_out_qs = Mlogb.objects.filter(mlog=mlog, material_out__isnull=False) mlogb_out_qs = Mlogb.objects.filter(mlog=mlog, material_out__isnull=False)
clear_defect = bool(mlog.clear_defect)
if mlogb_out_qs.exists(): if mlogb_out_qs.exists():
mlogb_out_qs = mlogb_out_qs.filter(need_inout=True) mlogb_out_qs = mlogb_out_qs.filter(need_inout=True)
m_outs_list = [(mo.material_out, mo.batch if mo.batch else mlog.batch, mo.count_ok_full if mo.count_ok_full is not None else mo.count_ok, mlog.count_real_eweight, None, mo) for mo in mlogb_out_qs.all()] if clear_defect:
m_outs_list = [(mo.material_out, mo.batch if mo.batch else mlog.batch, mo.count_ok, mlog.count_real_eweight, None, mo) for mo in mlogb_out_qs.all()]
else:
m_outs_list = [(mo.material_out, mo.batch if mo.batch else mlog.batch, mo.count_ok_full if mo.count_ok_full is not None else mo.count_ok, mlog.count_real_eweight, None, mo) for mo in mlogb_out_qs.all()]
if stored_notok: if stored_notok:
for item in mlogb_out_qs: for item in mlogb_out_qs:
mbd_qs = MlogbDefect.get_defect_qs_from_mlogb(item) mbd_qs = MlogbDefect.get_defect_qs_from_mlogb(item)
@ -293,6 +297,8 @@ def mlog_submit(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
# Mlogbw.cal_count_notok(item) # Mlogbw.cal_count_notok(item)
for itemx in mbd_qs: for itemx in mbd_qs:
if itemx.count > 0: if itemx.count > 0:
if clear_defect and itemx.defect and itemx.defect.okcate == Defect.DEFECT_OK_B:
continue
m_outs_list.append((item.material_out, item.batch, itemx.count, 0, itemx.defect, item)) m_outs_list.append((item.material_out, item.batch, itemx.count, 0, itemx.defect, item))
# # 获取所有主要的不合格项/先暂时保留 # # 获取所有主要的不合格项/先暂时保留
# bw_qs = Mlogbw.objects.filter(mlogb=item) # bw_qs = Mlogbw.objects.filter(mlogb=item)
@ -435,11 +441,17 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
# 有多个产物的情况 # 有多个产物的情况
# 需要考虑不合格品退回的情况 # 需要考虑不合格品退回的情况
mlogb_out_qs = Mlogb.objects.filter(mlog=mlog, material_out__isnull=False) mlogb_out_qs = Mlogb.objects.filter(mlog=mlog, material_out__isnull=False)
clear_defect = bool(mlog.clear_defect)
if mlogb_out_qs.exists(): if mlogb_out_qs.exists():
mlogb_out_qs = mlogb_out_qs.filter(need_inout=True) mlogb_out_qs = mlogb_out_qs.filter(need_inout=True)
m_outs_list = [ if clear_defect:
(mo.material_out, mo.batch if mo.batch else mlog.batch, mo.count_ok_full if mo.count_ok_full is not None else mo.count_ok, mlog.count_real_eweight, None, mo) m_outs_list = [
for mo in mlogb_out_qs.all()] (mo.material_out, mo.batch if mo.batch else mlog.batch, mo.count_ok, mlog.count_real_eweight, None, mo)
for mo in mlogb_out_qs.all()]
else:
m_outs_list = [
(mo.material_out, mo.batch if mo.batch else mlog.batch, mo.count_ok_full if mo.count_ok_full is not None else mo.count_ok, mlog.count_real_eweight, None, mo)
for mo in mlogb_out_qs.all()]
if stored_notok: if stored_notok:
for item in mlogb_out_qs: for item in mlogb_out_qs:
mbd_qs = MlogbDefect.get_defect_qs_from_mlogb(item) mbd_qs = MlogbDefect.get_defect_qs_from_mlogb(item)
@ -448,6 +460,8 @@ def mlog_revert(mlog: Mlog, user: User, now: Union[datetime.datetime, None]):
# Mlogbw.cal_count_notok(item) # Mlogbw.cal_count_notok(item)
for itemx in mbd_qs: for itemx in mbd_qs:
if itemx.count > 0: if itemx.count > 0:
if clear_defect and itemx.defect and itemx.defect.okcate == Defect.DEFECT_OK_B:
continue
m_outs_list.append((item.material_out, item.batch, itemx.count, 0, itemx.defect, item)) m_outs_list.append((item.material_out, item.batch, itemx.count, 0, itemx.defect, item))
# if item.material_out.tracking == Material.MA_TRACKING_SINGLE: # if item.material_out.tracking == Material.MA_TRACKING_SINGLE:
# # 获取所有主要的不合格项 # # 获取所有主要的不合格项
@ -757,6 +771,8 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
mids = [] mids = []
exclude_batchst_ids = [] exclude_batchst_ids = []
check_batch_exist = False check_batch_exist = False
merge_target_defect = None
merge_clear_defect = False
if mtype == Handover.H_MERGE: if mtype == Handover.H_MERGE:
if new_batch: if new_batch:
batches = [new_batch] batches = [new_batch]
@ -766,6 +782,12 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
check_batch_exist = True check_batch_exist = True
target_b, _ = BatchSt.g_create(batch=new_batch, handover=handover, material_start=material, check_batch_exist=check_batch_exist) target_b, _ = BatchSt.g_create(batch=new_batch, handover=handover, material_start=material, check_batch_exist=check_batch_exist)
exclude_batchst_ids.append(target_b.id) exclude_batchst_ids.append(target_b.id)
merge_clear_defect = bool(handover.clear_defect)
if merge_clear_defect:
merge_target_defect = None
else:
source_wm_ids = [hb[0] for hb in handoverb_list]
merge_target_defect = WMaterial.objects.filter(id__in=source_wm_ids).first().defect
elif mtype == Handover.H_DIV: elif mtype == Handover.H_DIV:
if handover.wm is None: if handover.wm is None:
raise ParseError('拆批请选择车间库存') raise ParseError('拆批请选择车间库存')
@ -810,7 +832,10 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
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)
if wm_to.state != wm_from.state or wm_to.material != wm_from.material or wm_to.defect != wm_from.defect: defect_ok = wm_to.defect == wm_from.defect or (
merge_clear_defect and wm_to.defect is None and wm_from.state == WMaterial.WM_OK
)
if wm_to.state != wm_from.state or wm_to.material != wm_from.material or not defect_ok:
raise ParseError("正常合并到的车间库存状态或物料异常") raise ParseError("正常合并到的车间库存状态或物料异常")
else: else:
wm_to, _ = WMaterial.locked_get_or_create( wm_to, _ = WMaterial.locked_get_or_create(
@ -820,7 +845,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
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=wm_from.defect, defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.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,
@ -916,7 +941,7 @@ def handover_submit(handover:Handover, user: User, now: Union[datetime.datetime,
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=wm_from.defect, defect=merge_target_defect if mtype == Handover.H_MERGE else wm_from.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,

View File

@ -1112,7 +1112,7 @@ class MlogbwViewSet(CustomModelViewSet):
list_serializer_class = MlogbwListSerializer list_serializer_class = MlogbwListSerializer
retrieve_serializer_class = MlogbwListSerializer retrieve_serializer_class = MlogbwListSerializer
filterset_class = MlogbwFilter filterset_class = MlogbwFilter
select_related_fields = ["ftest", "equip", "wpr", "mlogb"] select_related_fields = ["ftest", "equip", "tooling", "wpr", "mlogb"]
prefetch_related_fields = [ prefetch_related_fields = [
Prefetch( Prefetch(
"ftest__items_ftest", "ftest__items_ftest",
@ -1273,7 +1273,7 @@ class MlogUserViewSet(BulkCreateModelMixin, CustomListModelMixin, BulkDestroyMod
perms_map = {"get": "*", "post": "mlog.update", "delete": "mlog.update"} perms_map = {"get": "*", "post": "mlog.update", "delete": "mlog.update"}
queryset = MlogUser.objects.all() queryset = MlogUser.objects.all()
serializer_class = MlogUserSerializer serializer_class = MlogUserSerializer
select_related_fields = ["handle_user", "shift", "process"] select_related_fields = ["handle_user", "shift", "process", "equipment"]
filterset_fields = ["mlog"] filterset_fields = ["mlog"]
def get_queryset(self): def get_queryset(self):

View File

@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpmw', '0009_alter_wpr_number'),
]
operations = [
migrations.AddField(
model_name='wpr',
name='pre_info',
field=models.JSONField(blank=True, default=dict, verbose_name='预处理信息'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.27 on 2026-05-28 03:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('wpmw', '0010_wpr_pre_info'),
]
operations = [
migrations.AlterField(
model_name='wpr',
name='pre_info',
field=models.JSONField(blank=True, default=dict, null=True, verbose_name='预处理信息'),
),
]

View File

@ -30,6 +30,7 @@ class Wpr(BaseModel):
oinfo = models.JSONField(verbose_name="其他信息", default=dict, blank=True) oinfo = models.JSONField(verbose_name="其他信息", default=dict, blank=True)
wpr_from = models.ForeignKey("self", verbose_name="来源于", on_delete=models.CASCADE, null=True, blank=True) wpr_from = models.ForeignKey("self", verbose_name="来源于", on_delete=models.CASCADE, null=True, blank=True)
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)
@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, ftest=None, wpr_from=None, add_version=True, number_out=None):

View File

@ -3,6 +3,7 @@ from apps.utils.serializers import CustomModelSerializer
from rest_framework import serializers from rest_framework import serializers
from apps.inm.serializers import MaterialBatchSerializer from apps.inm.serializers import MaterialBatchSerializer
from apps.wpm.serializers import WMaterialSerializer from apps.wpm.serializers import WMaterialSerializer
from apps.qm.models import FtestItem
class WprDefectSerializer(CustomModelSerializer): class WprDefectSerializer(CustomModelSerializer):
defect_name = serializers.CharField(source="defect.name", read_only=True) defect_name = serializers.CharField(source="defect.name", read_only=True)
@ -55,3 +56,7 @@ class WproutListSerializer(serializers.Serializer):
class WprChangeNumberSerializer(serializers.Serializer): class WprChangeNumberSerializer(serializers.Serializer):
old_number = serializers.CharField(label="原编号") old_number = serializers.CharField(label="原编号")
new_number = serializers.CharField(label="新编号") new_number = serializers.CharField(label="新编号")
class WprPreInfoSerializer(serializers.Serializer):
tooling = serializers.CharField(label="工装ID")

View File

@ -1,9 +1,10 @@
from rest_framework.decorators import action from rest_framework.decorators import action
from apps.utils.viewsets import CustomModelViewSet, CustomGenericViewSet from apps.utils.viewsets import CustomModelViewSet, CustomGenericViewSet
from apps.utils.mixins import CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin from apps.utils.mixins import CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin, BulkUpdateModelMixin
from apps.wpmw.models import Wpr, WprDefect from apps.wpmw.models import Wpr, WprDefect
from apps.wpmw.serializers import WprSerializer, WprNewSerializer, WprDetailSerializer, WproutListSerializer, WprChangeNumberSerializer from apps.qm.models import FtestItem
from apps.wpmw.serializers import WprSerializer, WprNewSerializer, WprDetailSerializer, WproutListSerializer, WprChangeNumberSerializer, WprPreInfoSerializer
from rest_framework.response import Response from rest_framework.response import Response
from apps.mtm.models import Material from apps.mtm.models import Material
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
@ -13,13 +14,13 @@ from apps.utils.sql import query_one_dict
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
class WprViewSet(CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin, CustomGenericViewSet): class WprViewSet(BulkUpdateModelMixin, CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin, CustomGenericViewSet):
"""动态产品 """动态产品
动态产品 动态产品
""" """
perms_map = {"get": "*"} perms_map = {"get": "*", "put": "*", "patch": "*"}
select_related_fields = ["wm", "mb", "material", "wm__material_ofrom"] select_related_fields = ["wm", "mb", "material", "wm__material_ofrom"]
prefetch_related_fields = ["defects"] prefetch_related_fields = ["defects"]
queryset = Wpr.objects.all() queryset = Wpr.objects.all()
@ -148,6 +149,18 @@ class WprViewSet(CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMix
else: else:
return Response({"number_out_last": None}) return Response({"number_out_last": None})
@action(methods=["patch"], detail=True, perms_map={"patch": "*"}, serializer_class=WprPreInfoSerializer)
def update_pre_info(self, request, *args, **kwargs):
"""更新预处理信息"""
wpr = self.get_object()
sr = WprPreInfoSerializer(data=request.data)
sr.is_valid(raise_exception=True)
raw_info = sr.validated_data
from apps.utils.tools import update_dict
wpr.pre_info = update_dict(wpr.pre_info or {}, raw_info)
wpr.save(update_fields=["pre_info"])
return Response({"pre_info": wpr.pre_info})
@action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=WproutListSerializer) @action(methods=["post"], detail=False, perms_map={"post": "*"}, serializer_class=WproutListSerializer)
@transaction.atomic @transaction.atomic
def assgin_number_out(self, request, *args, **kwargs): def assgin_number_out(self, request, *args, **kwargs):

View File

@ -1,19 +1,24 @@
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json import json
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import socket import socket
import traceback import threading
import threading # 新增:引入线程锁
# 全局 Socket 连接字典 sc_all = {}
sc_all = { sc_locks = {}
"192.168.1.220_6000": None, registry_lock = threading.Lock()
"192.168.1.235_6000": None,
"192.168.1.225_6000": None,
"192.168.1.230_6000": None,
}
sc_lock = threading.Lock() # 全局锁,保护 sc_all
def get_lock(addr):
lock = sc_locks.get(addr)
if lock is None:
with registry_lock:
lock = sc_locks.get(addr)
if lock is None:
lock = threading.Lock()
sc_locks[addr] = lock
sc_all.setdefault(addr, None)
return lock
def get_checksum(body_msg): def get_checksum(body_msg):
return sum(body_msg) & 0xFF return sum(body_msg) & 0xFF
@ -46,6 +51,45 @@ def handle_bytes(arr):
except Exception as e: except Exception as e:
return f"数据解析错误: {str(e)}" return f"数据解析错误: {str(e)}"
def drain_socket(sc):
sc.setblocking(False)
try:
while True:
data = sc.recv(65536)
if not data:
break
except BlockingIOError:
pass
finally:
sc.settimeout(5)
def recv_full_message(sc):
data = bytearray()
while len(data) < 4:
chunk = sc.recv(1024)
if not chunk:
raise ConnectionError("连接中断")
data.extend(chunk)
if data[0] != 0xEB or data[1] != 0x90:
return bytes(data)
length = int.from_bytes(bytes(data[2:4])[::-1], byteorder='little', signed=True)
if length <= 0 or length > 65536:
return bytes(data)
total_needed = 4 + length
while len(data) < total_needed:
chunk = sc.recv(1024)
if not chunk:
raise ConnectionError("连接中断")
data.extend(chunk)
return bytes(data)
class JSONRequestHandler(BaseHTTPRequestHandler): class JSONRequestHandler(BaseHTTPRequestHandler):
def ok(self, data): def ok(self, data):
self.send_response(200) self.send_response(200)
@ -60,48 +104,31 @@ class JSONRequestHandler(BaseHTTPRequestHandler):
data = {"err_msg": err_msg} data = {"err_msg": err_msg}
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8')) self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
def log_message(self, format, *args):
return
def do_GET(self): def do_GET(self):
parsed_url = urlparse(self.path) parsed_url = urlparse(self.path)
query_params = parse_qs(parsed_url.query) query_params = parse_qs(parsed_url.query)
host = query_params.get('host', ['127.0.0.1'])[0] host = query_params.get('host', ['127.0.0.1'])[0]
port = query_params.get('port', ['6000'])[0] port = query_params.get('port', ['6000'])[0]
addr = f'{host}_{port}' addr = f'{host}_{port}'
lock = get_lock(addr)
if addr not in sc_all: def request_once():
self.error(f'{addr} 未找到') with lock:
return
def connect_and_send():
with sc_lock: # 加锁,防止竞争
sc = sc_all[addr] sc = sc_all[addr]
try: try:
if sc is None: if sc is None:
sc = socket.socket() sc = socket.socket()
sc.settimeout(5) sc.settimeout(5)
sc.connect((host, int(port))) sc.connect((host, int(port)))
sc_all[addr] = sc sc_all[addr] = sc
sc.settimeout(0.5) else:
while True: drain_socket(sc)
try:
data = sc.recv(65536)
if not data:
break
except (socket.timeout, BlockingIOError):
break
sc.settimeout(5)
sc.sendall(b"R") sc.sendall(b"R")
return recv_full_message(sc)
data = bytearray()
while len(data) < 8:
chunk = sc.recv(1024)
if not chunk:
raise ConnectionError("连接中断")
data.extend(chunk)
return sc, bytes(data)
except Exception as e: except Exception as e:
if sc is not None: if sc is not None:
try: try:
@ -109,30 +136,30 @@ class JSONRequestHandler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass pass
sc_all[addr] = None sc_all[addr] = None
self.error(f'采集器通信失败: {e}') raise e
return None, None
sc, resp = connect_and_send() try:
if sc is None or resp is None: resp = request_once()
except Exception as e:
self.error(f'采集器通信失败: {e}')
return return
res = handle_bytes(resp) res = handle_bytes(resp)
if isinstance(res, str) and res == "数据头不正确":
try:
resp = request_once()
except Exception as e:
self.error(f'采集器通信失败: {e}')
return
res = handle_bytes(resp)
if isinstance(res, str): if isinstance(res, str):
if res == "数据头不正确": self.error(res)
sc, resp = connect_and_send()
if sc is None or resp is None:
return
res = handle_bytes(resp)
if isinstance(res, str):
self.error(res)
else:
self.ok(res)
else:
self.error(res)
else: else:
self.ok(res) self.ok(res)
def run(server_class=HTTPServer, handler_class=JSONRequestHandler, port=2300):
def run(server_class=ThreadingHTTPServer, handler_class=JSONRequestHandler, port=2300):
server_address = ('', port) server_address = ('', port)
httpd = server_class(server_address, handler_class) httpd = server_class(server_address, handler_class)
print(f'Starting httpd server on port {port}...') print(f'Starting httpd server on port {port}...')