Compare commits

...

30 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
27 changed files with 1051 additions and 223 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 = []
for tdata in vdata["tdata_list"]:
commands = LabelTemplate.gen_commands(lt.id, None, tdata)
if not commands: if not commands:
raise ParseError("模板未生成任何指令") 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))
if len(name_matches) <= 1:
existing = name_matches[0] if name_matches else None
# 同名但身份证号不一致,视为不同人员,新建
if existing and id_number and existing.id_number \
and existing.id_number != id_number:
existing = None existing = None
if id_number: else:
existing = Employee.objects.filter(id_number=id_number).first() # 存在重名,必须用身份证号区分
if not existing and name: if not id_number:
existing = Employee.objects.filter(name=name, id_number__isnull=True).first() or \ raise ParseError(f'{row_num}行,姓名"{name}"存在重名,必须填写身份证号')
Employee.objects.filter(name=name, id_number='').first() 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

@ -463,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:
@ -484,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

@ -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,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

@ -614,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='关联检验',

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 记录独立为一次返修
# (同一 Mlog 下有多条同批次 Mlogb 时也能正确拆分为多次返修)
_all_fix_qs = Mlogb.objects.filter(
mlog__submit_time__isnull=False,
material_out__isnull=False, material_out__isnull=False,
mlog__mgroup__name="外观检验", mlog__mgroup__name="外观检验",
mlog__is_fix=True, batch=batch, need_inout=True) mlog__is_fix=True,
if mlogb2_qs.exists(): batch=batch,
data["外观检验_返修_日期"] = [] need_inout=True,
data["外观检验_返修_操作人"] = [] ).order_by("mlog__submit_time", "id")
data["外观检验_返修_count_real"] = 0 _fix_prefixes = []
data["外观检验_返修_count_ok"] = 0 for fix_idx, fix_mlogb in enumerate(_all_fix_qs):
data["外观检验_返修_count_ok_full"] = 0 suffix = "" if fix_idx == 0 else str(fix_idx + 1)
for item in mlogb2_qs: prefix = f"外观检验_返修{suffix}_"
if item.mlog.handle_user: _fix_prefixes.append(prefix)
data["外观检验_返修_操作人"].append(item.mlog.handle_user) mlog = fix_mlogb.mlog
if item.mlog.handle_date: handle_date = mlog.handle_date
data["外观检验_返修_日期"].append(item.mlog.handle_date) data[f"{prefix}count_real"] = fix_mlogb.count_real
data["外观检验_返修_count_real"] += item.count_real data[f"{prefix}count_ok"] = fix_mlogb.count_ok
data["外观检验_返修_count_ok"] += item.count_ok data[f"{prefix}count_ok_full"] = fix_mlogb.count_ok_full or 0
data["外观检验_返修_count_ok_full"] += item.count_ok_full if item.count_ok_full else 0 data[f"{prefix}count_notok"] = fix_mlogb.count_notok or 0
try:
data["外观检验_返修_日期"] = list(set(data["外观检验_返修_日期"])) data[f"{prefix}合格率"] = round((fix_mlogb.count_ok / fix_mlogb.count_real) * 100, 2)
data["外观检验_返修_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["外观检验_返修_日期"]]) except (decimal.InvalidOperation, ZeroDivisionError):
data["外观检验_返修_操作人"] = list(set(data["外观检验_返修_操作人"])) data[f"{prefix}合格率"] = 0
data["外观检验_返修_操作人"] = ";".join([item.name for item in data["外观检验_返修_操作人"]]) try:
data[f"{prefix}完全合格率"] = round(((fix_mlogb.count_ok_full or 0) / fix_mlogb.count_real) * 100, 2)
mlogbd2_qs = MlogbDefect.objects.filter(mlogb__in=mlogb2_qs, count__gt=0).values("defect__name").annotate(total=Sum("count")) except (decimal.InvalidOperation, ZeroDivisionError):
data[f"{prefix}完全合格率"] = 0
for item in mlogbd2_qs: data[f"{prefix}日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"外观检验_返修_缺陷_{item['defect__name']}"] = item["total"] data[f"{prefix}小日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"外观检验_返修_缺陷_{item['defect__name']}_比例"] = round((item["total"] / data["外观检验_返修_count_real"])*100, 2) 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,6 +728,8 @@ 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):
@ -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):
@ -1554,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

@ -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
raise e
try:
resp = request_once()
except Exception as e:
self.error(f'采集器通信失败: {e}') self.error(f'采集器通信失败: {e}')
return None, None
sc, resp = connect_and_send()
if sc is None or resp is None:
return return
res = handle_bytes(resp) res = handle_bytes(resp)
if isinstance(res, str): if isinstance(res, str) and res == "数据头不正确":
if res == "数据头不正确": try:
sc, resp = connect_and_send() resp = request_once()
if sc is None or resp is None: except Exception as e:
self.error(f'采集器通信失败: {e}')
return return
res = handle_bytes(resp) res = handle_bytes(resp)
if isinstance(res, str): if isinstance(res, str):
self.error(res) self.error(res)
else: else:
self.ok(res) self.ok(res)
else:
self.error(res)
else:
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}...')