Compare commits

..

No commits in common. "master" and "dev_cqm" have entirely different histories.

27 changed files with 223 additions and 1051 deletions

View File

@ -1,14 +1,14 @@
"""
伟迪捷 1880 喷码机 ASCII 协议封装
帧格式: ASCII, 字段以 '|' 分隔, 整帧以 <CR>(0x0D) 结尾
- 写指令(SLA/JDA/CQI/JDI/SST/SNO) 通常无回包
- 查询指令(GST/GFT/GJD) 回包同样以 <CR> 结尾
1000 系列喷码机网口通讯封装
协议: STX(0x02) + 指令 + 数据 + ETX(0x03)
反馈: $XX 成功 / !XX 失败 (XX 为两位校验)
"""
import socket
from rest_framework.exceptions import ParseError
CR = b"\x0d"
STX = b"\x02"
ETX = b"\x03"
LF = b"\x0a"
class CoderError(ParseError):
@ -18,245 +18,44 @@ class CoderError(ParseError):
class CoderClient:
def __init__(self, ip: str, port: int = 3100, timeout: float = 2.0):
if not ip:
raise CoderError("喷码IP未配置")
raise CoderError("打码器IP未配置")
self.ip = ip
self.port = port
self.timeout = timeout
def _send(self, frame: bytes, expect_reply: bool = False) -> bytes:
def _send(self, frame: bytes) -> bytes:
try:
with socket.create_connection((self.ip, self.port), timeout=self.timeout) as s:
s.sendall(frame)
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
return s.recv(64)
except (socket.timeout, OSError) as e:
raise CoderError(f"喷码通讯失败 {self.ip}:{self.port} - {e}")
raise CoderError(f"打码器通讯失败 {self.ip}:{self.port} - {e}")
@staticmethod
def _encode(text: str, label: str) -> bytes:
if "\r" in text or "\n" in text:
raise CoderError(f"{label}含 CR/LF, 会破坏帧结构")
if "|" in text:
raise CoderError(f"{label}'|', 与 1880 分隔符冲突")
try:
return text.encode("latin-1")
except UnicodeEncodeError as e:
raise CoderError(f"{label}含喷码机不支持的字符: {e}")
def _check_ack(resp: bytes):
if not resp:
raise CoderError("打码器无响应")
head = resp[:1]
if head == b"$":
return True
if head == b"!":
raise CoderError(f"打码器返回失败: {resp!r}")
raise CoderError(f"打码器响应不识别: {resp!r}")
def send_command(self, command: str, expect_reply: bool = False) -> bytes:
"""直接下发一条已拼装好的 1880 指令(不含末尾 CR), 由本方法统一补 CR。"""
if "\r" in command or "\n" in command:
raise CoderError("指令含 CR/LF, 由 send_command 统一补 CR, 不要手动拼")
return self._send(command.encode("latin-1") + CR, expect_reply=expect_reply)
def update_field(self, field_name: str, content: str) -> bool:
"""更新用户区: 02 55 <field> 0A <content> 03"""
frame = STX + b"\x55" + field_name.encode("ascii") + LF + content.encode("ascii") + ETX
return self._check_ack(self._send(frame))
def select_job(self, job_name: str):
"""SLA|<jobname>|<CR> 选择信息模板"""
self._send(b"SLA|" + self._encode(job_name, "模板名") + b"|" + CR)
def select_message(self, message_name: str) -> bool:
"""选择信息: 02 4D <name> 03"""
frame = STX + b"\x4d" + message_name.encode("ascii") + ETX
return self._check_ack(self._send(frame))
def update_fields(self, fields: dict):
"""JDA|<field>=<value>|...|<CR> 一帧更新多个用户区"""
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 get_status(self) -> bytes:
"""获取喷码机状态: 02 45 03"""
return self._send(STX + b"\x45" + ETX)
def clear_queue(self):
"""CQI<CR> 清空喷码机内部缓存队列"""
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
def send_raw(self, frame_str: str) -> bytes:
"""发送任意已拼好的帧字符串(不含 STX/ETX), 自动补头尾"""
return self._send(STX + frame_str.encode("ascii") + ETX)

View File

@ -0,0 +1,23 @@
# 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,6 +22,8 @@ class LabelTemplate(BaseModel):
name = models.TextField("名称")
commands = 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
def gen_commands(cls, label_template, label_template_name, tdata):

View File

@ -18,18 +18,10 @@ class Tid2Serializer(serializers.Serializer):
class CoderSendSerializer(serializers.Serializer):
tdata_list = serializers.ListField(child=serializers.JSONField(), label='模板数据列表', allow_empty=False)
coder_ip = serializers.IPAddressField(label='喷码IP')
coder_port = serializers.IntegerField(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)
tdata = serializers.JSONField(label='模板数据', required=False, default=dict)
coder_ip = serializers.IPAddressField(label='打码器IP(可覆盖模板)', 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)
class LabelMatSerializer(serializers.ModelSerializer):
@ -51,7 +43,11 @@ class LabelMatSerializer(serializers.ModelSerializer):
class LabelTemplateSerializer(CustomModelSerializer):
coder_ip = serializers.IPAddressField(required=False, allow_null=True, allow_blank=True)
class Meta:
model = LabelTemplate
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 rest_framework.decorators import action
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer, HanslaserSendSerializer
from apps.cm.coder import CoderClient, HanslaserClient
from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer
from apps.cm.coder import CoderClient
from apps.inm.models import MaterialBatch, MIOItem
from apps.wpm.models import WMaterial
from rest_framework.exceptions import ParseError, NotFound
@ -118,84 +118,29 @@ class LabelTemplateViewSet(CustomModelViewSet):
@action(methods=["post"], detail=True, serializer_class=CoderSendSerializer, perms_map={"post": "*"})
def send_to_coder(self, request, pk=None, *args, **kwargs):
"""
下发指令到伟迪捷 1880 喷码机 (统一走 CQI + JDI 队列模式)
下发指令到打码器
body tdata_list (列表, 至少一条), 每条对应一个产品
每次调用先 CQI 清空队列, 再按顺序 JDI 入队 N ;
喷码机每个外部触发(光电)消费一条, 喷一个产品
每条 tdata 走模板 commands, 解析为 <字段>=<内容> 合并成一帧 JDI;
commands 中不含 '=' 的元素, 会绑定到默认 coder_field
按模板 commands tdata 格式化后, 通过模板上配置的 IP/端口下发到 1000 系列喷码机用户区
body 中的 coder_ip/coder_port/coder_field 可临时覆盖模板配置
"""
sr = CoderSendSerializer(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") or 3100
ip = vdata.get("coder_ip") or lt.coder_ip
port = vdata.get("coder_port") or lt.coder_port
field = vdata.get("coder_field") or "1"
if not ip:
raise ParseError("模板未配置打码器IP, 也未在请求中提供 coder_ip")
batched = []
for tdata in vdata["tdata_list"]:
commands = LabelTemplate.gen_commands(lt.id, None, tdata)
commands = LabelTemplate.gen_commands(lt.id, None, vdata.get("tdata") or {})
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)
coder_jobname = vdata.get("coder_jobname")
client.clear_queue()
if coder_jobname:
client.select_job(coder_jobname)
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})
results = []
for content in commands:
client.update_field(field, content)
results.append(content)
return Response({"sent": results, "ip": ip, "port": port, "field": field})

View File

@ -1,7 +1,6 @@
import socket
from rest_framework.exceptions import ParseError
import json
import re
import time
from django.core.cache import cache
from apps.utils.thread import MyThread
@ -244,75 +243,6 @@ def get_tyy_data_3(*args, retry=2):
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):
host, port = args[0], int(args[1])
r = requests.get(f"http://127.0.0.1:2300?host={host}&port={port}")

View File

@ -1,72 +0,0 @@
# 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,7 +14,6 @@ from rest_framework.decorators import action
from rest_framework.serializers import Serializer
from django.db import transaction
from apps.em.services import daoru_equipment
from apps.em import cd
from rest_framework.response import Response
from django.conf import settings
from django.db.models import Count, Case, When, IntegerField
@ -128,26 +127,6 @@ class EquipmentViewSet(CustomModelViewSet):
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):
"""

View File

@ -1,27 +0,0 @@
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

@ -1,18 +0,0 @@
# 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:劳动合同
"""
employee = models.ForeignKey(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True)
ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单',
employee = models.OneToOneField(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True)
ticket = models.OneToOneField('wf.ticket', verbose_name='关联工单',
on_delete=models.CASCADE, related_name='contract_ticket', null=True, blank=True)
counts = models.PositiveSmallIntegerField('合同变更次数', default=0)
plan_renewal = models.DateField('应续签', null=True, blank=True)

View File

@ -425,22 +425,6 @@ class EmpContractSerializer(CustomModelSerializer):
gender = serializers.CharField(source="employee.gender", 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)
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:
model = EmpContract
fields = '__all__'

View File

@ -33,9 +33,8 @@ from datetime import datetime
from apps.utils.export import export_excel
from apps.utils.viewsets import CustomGenericViewSet, CustomModelViewSet, EuModelViewSet
from apps.utils.mixins import BulkCreateModelMixin, BulkDestroyModelMixin, CustomListModelMixin, RetrieveModelMixin
from apps.wf.models import Ticket, Workflow, State
from apps.wf.models import Ticket
from apps.wf.mixins import TicketMixin
from apps.wf.services import WfService
from apps.system.models import Post, Dept
from django.db.models import DateField
from datetime import datetime, date
@ -290,70 +289,23 @@ class EmployeeViewSet(CustomModelViewSet):
"""导出excel
导出excel
"""
# 导出列定义: (表头, 取值函数)。覆盖人员的全部业务字段。
def _cell(v):
return '' if v is None else v
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')
field_data = ['人员类型', '人员', '手机号', '身份证号', '所属部门', '在职状态', '定位卡号']
queryset = self.filter_queryset(self.get_queryset())
if queryset.count() > 1000:
raise ParseError('数据量超过1000,请筛选后导出')
odata = EmployeeSerializer(queryset, many=True).data
# 处理数据
data = []
for i in odata:
data.append([_cell(getter(i)) for _, getter in columns])
data.append(
[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, '人员信息')})
@action(methods=['post'], detail=False, perms_map={'post': 'employee.import_excel'},
@ -408,8 +360,8 @@ class EmployeeViewSet(CustomModelViewSet):
id_number = data.get("id_number")
name = data.get("name")
if not name:
raise ParseError(f'{row_num}行,姓名为空')
if not id_number or not name:
raise ParseError(f'{row_num}行,身份证号或姓名为空')
myLogger.info(f"处理第{row_num}行:{name} - {id_number}")
# 处理人员类型
@ -419,9 +371,9 @@ class EmployeeViewSet(CustomModelViewSet):
data['type'] = TYPE_MAPPING[excel_type]
else:
raise ParseError(f'{row_num}行,人员类型"{excel_type}"无效,有效类型:{", ".join(TYPE_MAPPING.keys())}')
# 处理部门外键:填了就校验是否存在并赋值;为空时不动(新增场景的必填在下方创建处校验)
dept_name = data.pop('belong_dept', None)
if dept_name:
# 处理部门外键
if 'belong_dept' in data and data['belong_dept']:
dept_name = data.pop('belong_dept')
if dept_name not in dept_map:
raise ParseError(f'{row_num}行,部门"{dept_name}"不存在')
data['belong_dept_id'] = dept_map[dept_name]
@ -435,44 +387,34 @@ 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']):
raise ParseError(f'{row_num}行,身份证号格式不正确')
# 按姓名匹配并更新:姓名唯一则直接更新(身份证号可为空);
# 存在重名则必须用身份证号定位具体记录,否则报错。
# 查找或创建/补全
try:
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
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 id_number:
existing = Employee.objects.filter(id_number=id_number).first()
if not existing and name:
existing = Employee.objects.filter(name=name, id_number__isnull=True).first() or \
Employee.objects.filter(name=name, id_number='').first()
if existing:
# 用 Excel 中填写了值的列覆盖数据库已有数据;空单元格保持原值不变
# 只用 Excel 非空值填补数据库中为空的字段
updated_fields = []
for field_name, value in data.items():
if value in [None, '']:
continue
if getattr(existing, field_name, None) != value:
current_value = getattr(existing, field_name, None)
if current_value in [None, '']:
setattr(existing, field_name, value)
updated_fields.append(field_name)
if updated_fields:
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:
myLogger.info(f"⏭️ 第{row_num}行无变化{name}")
myLogger.info(f"⏭️ 第{row_num}行无需补全{name}")
created = False
else:
# 新增人员时所属部门必填
if 'belong_dept_id' not in data:
raise ParseError(f'{row_num}行,新增人员时所属部门不能为空')
Employee.objects.create(**data)
Employee.objects.create(id_number=id_number, name=name, **data)
created = True
except Exception as e:
raise
@ -883,49 +825,3 @@ class EmpContractViewSet(TicketMixin, EuModelViewSet):
def gen_other_ticket_data(self, instance):
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,8 +463,7 @@ class InmService:
@classmethod
def revert_and_del(cls, mioitem: MIOItem):
# 锁定 MIO 行,防止多人同时撤销不同明细时并发漏删 MIO
mio = MIO.objects.select_for_update().get(id=mioitem.mio_id)
mio = mioitem.mio
if mio.submit_time is None:
raise ParseError("未提交的出入库明细不允许撤销")
if mioitem.test_date is not None:
@ -485,7 +484,4 @@ class InmService:
mioitem.delete()
else:
raise ParseError("不支持该出入库单明细撤销")
# 若该出入库记录已无明细,自动删除
if not MIOItem.objects.filter(mio=mio).exists():
mio.delete()

View File

@ -1,137 +0,0 @@
# 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)
number_to_batch = models.BooleanField('个号转批号', default=False)
wpr_number_rule = models.TextField("单个编号规则", null=True, blank=True)
clear_defect = models.BooleanField('合批清除缺陷', default=False)
class Meta:
verbose_name = '工序'
ordering = ['sort', 'create_time']

View File

@ -1,25 +0,0 @@
# 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,8 +614,7 @@ class Mlogbw(BaseModel):
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
, related_name='wpr_mlogbw', 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')
equip = models.ForeignKey(Equipment, verbose_name='设备', on_delete=models.SET_NULL, null=True, blank=True)
work_start_time = models.DateTimeField('开始加工时间', null=True, blank=True)
work_end_time = models.DateTimeField('结束加工时间', null=True, blank=True)
ftest = models.OneToOneField("qm.ftest", verbose_name='关联检验',

View File

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

View File

@ -680,8 +680,6 @@ class MlogSerializer(CustomModelSerializer):
handle_user = attrs.get('handle_user', None)
if handle_user is None and hasattr(self, "request"):
handle_user = self.request.user
if self.instance is None and mgroup.process:
attrs['clear_defect'] = mgroup.process.clear_defect
return attrs
@ -728,8 +726,6 @@ class MlogInitSerializer(CustomModelSerializer):
attrs["qct"] = Qct.get(attrs["material_out"], "process", "out")
attrs["handle_date"], attrs["shift"] = mgroup.get_shift(attrs['work_start_time'])
if mgroup.process:
attrs['clear_defect'] = mgroup.process.clear_defect
return attrs
class MlogChangeSerializer(CustomModelSerializer):
@ -878,17 +874,13 @@ class MlogbwCreateUpdateSerializer(CustomModelSerializer):
ftest = FtestProcessSerializer(required=False, allow_null=True)
equip_name = serializers.CharField(source='equip.name', read_only=True)
equip_number = serializers.CharField(source='equip.number', read_only=True)
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_pre_info = serializers.JSONField(source='wpr.pre_info', read_only=True)
mlogb__batch = serializers.CharField(source='mlogb.batch', read_only=True)
class Meta:
model = Mlogbw
fields = ["id", "number", "wpr", "note",
"mlogb", "ftest", "equip", "tooling", "work_start_time",
"work_end_time", "mlogbw_from", "equip_name", "equip_number",
"tooling_name", "tooling_number", "wpr_number_out", "wpr_pre_info", "mlogb__batch"]
"mlogb", "ftest", "equip", "work_start_time",
"work_end_time", "mlogbw_from", "equip_name", "equip_number", "wpr_number_out", "mlogb__batch"]
read_only_fields = ["mlogbw_from"]
def validate(self, attrs):
@ -1562,7 +1554,6 @@ class MlogUserSerializer(CustomModelSerializer):
handle_user_name = serializers.CharField(source='handle_user.name', read_only=True)
process_name = serializers.CharField(source='process.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:
model = MlogUser
fields = "__all__"

View File

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

View File

@ -1,16 +0,0 @@
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

@ -1,18 +0,0 @@
# 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,7 +30,6 @@ class Wpr(BaseModel):
oinfo = models.JSONField(verbose_name="其他信息", default=dict, 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)
pre_info = models.JSONField(verbose_name="预处理信息", default=dict, blank=True, null=True)
@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):

View File

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

View File

@ -1,10 +1,9 @@
from rest_framework.decorators import action
from apps.utils.viewsets import CustomModelViewSet, CustomGenericViewSet
from apps.utils.mixins import CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin, BulkUpdateModelMixin
from apps.utils.mixins import CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin
from apps.wpmw.models import Wpr, WprDefect
from apps.qm.models import FtestItem
from apps.wpmw.serializers import WprSerializer, WprNewSerializer, WprDetailSerializer, WproutListSerializer, WprChangeNumberSerializer, WprPreInfoSerializer
from apps.wpmw.serializers import WprSerializer, WprNewSerializer, WprDetailSerializer, WproutListSerializer, WprChangeNumberSerializer
from rest_framework.response import Response
from apps.mtm.models import Material
from rest_framework.exceptions import ParseError
@ -14,13 +13,13 @@ from apps.utils.sql import query_one_dict
from django.db.models.expressions import RawSQL
class WprViewSet(BulkUpdateModelMixin, CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin, CustomGenericViewSet):
class WprViewSet(CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin, CustomGenericViewSet):
"""动态产品
动态产品
"""
perms_map = {"get": "*", "put": "*", "patch": "*"}
perms_map = {"get": "*"}
select_related_fields = ["wm", "mb", "material", "wm__material_ofrom"]
prefetch_related_fields = ["defects"]
queryset = Wpr.objects.all()
@ -149,18 +148,6 @@ class WprViewSet(BulkUpdateModelMixin, CustomListModelMixin, CustomRetrieveModel
else:
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)
@transaction.atomic
def assgin_number_out(self, request, *args, **kwargs):

View File

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