Compare commits

..

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

32 changed files with 255 additions and 1198 deletions

View File

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

@ -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("名称") 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,18 +18,10 @@ class Tid2Serializer(serializers.Serializer):
class CoderSendSerializer(serializers.Serializer): class CoderSendSerializer(serializers.Serializer):
tdata_list = serializers.ListField(child=serializers.JSONField(), label='模板数据列表', allow_empty=False) tdata = serializers.JSONField(label='模板数据', required=False, default=dict)
coder_ip = serializers.IPAddressField(label='喷码IP') coder_ip = serializers.IPAddressField(label='打码器IP(可覆盖模板)', required=False, allow_null=True)
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):
@ -51,7 +43,11 @@ 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, HanslaserSendSerializer from apps.cm.serializers import TidSerializer, LabelMatSerializer, LabelTemplateSerializer, Tid2Serializer, CoderSendSerializer
from apps.cm.coder import CoderClient, HanslaserClient from apps.cm.coder import CoderClient
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,84 +118,29 @@ 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 队列模式) 下发指令到打码器
body tdata_list (列表, 至少一条), 每条对应一个产品 按模板 commands tdata 格式化后, 通过模板上配置的 IP/端口下发到 1000 系列喷码机用户区
每次调用先 CQI 清空队列, 再按顺序 JDI 入队 N ; body 中的 coder_ip/coder_port/coder_field 可临时覆盖模板配置
喷码机每个外部触发(光电)消费一条, 喷一个产品
每条 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") ip = vdata.get("coder_ip") or lt.coder_ip
port = vdata.get("coder_port") or 3100 port = vdata.get("coder_port") or lt.coder_port
field = vdata.get("coder_field") or "1" field = vdata.get("coder_field") or "1"
if not ip:
raise ParseError("模板未配置打码器IP, 也未在请求中提供 coder_ip")
batched = [] commands = LabelTemplate.gen_commands(lt.id, None, vdata.get("tdata") or {})
for tdata in vdata["tdata_list"]: if not commands:
commands = LabelTemplate.gen_commands(lt.id, None, tdata) raise ParseError("模板未生成任何指令")
if not commands:
raise ParseError("模板未生成任何指令")
fields = {}
for item in commands:
text = str(item)
if "=" in text:
k, v = text.split("=", 1)
fields[k.strip()] = v
else:
fields[field] = text
batched.append(fields)
client = CoderClient(ip=ip, port=port) client = CoderClient(ip=ip, port=port)
coder_jobname = vdata.get("coder_jobname") results = []
client.clear_queue() for content in commands:
if coder_jobname: client.update_field(field, content)
client.select_job(coder_jobname) results.append(content)
client.clear_queue() return Response({"sent": results, "ip": ip, "port": port, "field": field})
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,7 +1,6 @@
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
@ -244,79 +243,10 @@ 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}")
res = r.json() res = r.json()
if "err_msg" in res: if "err_msg" in res:
raise ParseError(res["err_msg"]) raise ParseError(res["err_msg"])
return res return res

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 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
@ -128,26 +127,6 @@ 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

@ -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:劳动合同 TN:劳动合同
""" """
employee = models.ForeignKey(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True) employee = models.OneToOneField(Employee, verbose_name='人员信息', on_delete=models.SET_NULL, null=True, blank=True)
ticket = models.ForeignKey('wf.ticket', verbose_name='关联工单', ticket = models.OneToOneField('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,22 +425,6 @@ 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,9 +33,8 @@ 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, Workflow, State from apps.wf.models import Ticket
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
@ -290,70 +289,23 @@ class EmployeeViewSet(CustomModelViewSet):
"""导出excel """导出excel
导出excel 导出excel
""" """
# 导出列定义: (表头, 取值函数)。覆盖人员的全部业务字段。 field_data = ['人员类型', '人员', '手机号', '身份证号', '所属部门', '在职状态', '定位卡号']
def _cell(v): queryset = self.filter_queryset(self.get_queryset())
return '' if v is None else v if queryset.count() > 1000:
columns = [ raise ParseError('数据量超过1000,请筛选后导出')
('人员类型', 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([_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, '人员信息')}) 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'},
@ -408,8 +360,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 name: if not id_number or 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}")
# 处理人员类型 # 处理人员类型
@ -419,9 +371,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())}')
# 处理部门外键:填了就校验是否存在并赋值;为空时不动(新增场景的必填在下方创建处校验) # 处理部门外键
dept_name = data.pop('belong_dept', None) if 'belong_dept' in data and data['belong_dept']:
if dept_name: dept_name = data.pop('belong_dept')
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]
@ -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']): 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 = None
existing = name_matches[0] if name_matches else None if id_number:
# 同名但身份证号不一致,视为不同人员,新建 existing = Employee.objects.filter(id_number=id_number).first()
if existing and id_number and existing.id_number \ if not existing and name:
and existing.id_number != id_number: existing = Employee.objects.filter(name=name, id_number__isnull=True).first() or \
existing = None Employee.objects.filter(name=name, id_number='').first()
else:
# 存在重名,必须用身份证号区分
if not id_number:
raise ParseError(f'{row_num}行,姓名"{name}"存在重名,必须填写身份证号')
existing = next(
(e for e in name_matches if e.id_number == id_number), None)
if existing: if existing:
# 用 Excel 中填写了值的列覆盖数据库已有数据;空单元格保持原值不变 # 只用 Excel 非空值填补数据库中为空的字段
updated_fields = [] updated_fields = []
for field_name, value in data.items(): for field_name, value in data.items():
if value in [None, '']: if value in [None, '']:
continue continue
if getattr(existing, field_name, None) != value: current_value = getattr(existing, field_name, None)
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
@ -883,49 +825,3 @@ class EmpContractViewSet(TicketMixin, EuModelViewSet):
def gen_other_ticket_data(self, instance): def gen_other_ticket_data(self, instance):
return {"name": instance.employee.name if instance.employee.name else None} return {"name": instance.employee.name if instance.employee.name else None}
def perform_create(self, serializer):
result = serializer.save()
is_batch = isinstance(result, list)
instances = result if is_batch else [result]
handler = self.request.user
workflow_key = self.get_workflow_key(instances[0])
if not workflow_key:
raise ParseError('工作流异常:必须赋值workflow_key')
try:
wf = Workflow.objects.get(key=workflow_key)
except Exception as e:
raise ParseError(f'工作流{workflow_key}异常:{e}')
# 批量时创建一个工单,所有记录共享
if is_batch:
names = [ins.employee.name for ins in instances if ins.employee]
ticket_data = {
"t_model": "EmpContract",
"t_id": str(instances[0].id),
"batch": True,
"count": len(instances),
"name": f"批量合同变更({len(instances)}人)",
"employees": [
{"name": ins.employee.name, "id": str(ins.id)} for ins in instances if ins.employee
],
}
else:
ticket_data = self.gen_ticket_data(instances[0])
ticket = WfService.handle_ticket(ticket=None, transition=None, workflow=wf, new_ticket_data=ticket_data,
handler=handler, oinfo={})
for ins in instances:
ins.ticket = ticket
ins.save(update_fields=['ticket'])
if self.ticket_auto_submit_on_create:
source_state = WfService.get_workflow_start_state(wf)
transitions = WfService.get_state_transitions(source_state)
if transitions.count() == 1:
transition = transitions.first()
WfService.handle_ticket(ticket=ticket, transition=transition, new_ticket_data=ticket_data,
handler=handler, oinfo={})
else:
raise ParseError(f'工作流{workflow_key}异常:有多个或无后续状态;不可处理')

View File

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

View File

@ -13,6 +13,9 @@ def do_out(item: MIOItem, is_reverse: bool = False):
""" """
生产领料到车间 生产领料到车间
""" """
if item.mb and item.mb.defect is not None:
raise ParseError("生产领料不支持不合格品")
from apps.inm.models import MaterialBatch from apps.inm.models import MaterialBatch
mio:MIO = item.mio mio:MIO = item.mio
belong_dept = mio.belong_dept belong_dept = mio.belong_dept
@ -72,10 +75,6 @@ def do_out(item: MIOItem, is_reverse: bool = False):
xcount:str = al[2] xcount:str = al[2]
defect:Defect = al[3] defect:Defect = al[3]
state = WMaterial.WM_OK
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
state = WMaterial.WM_NOTOK
xbatches.append(xbatch) xbatches.append(xbatch)
if xcount <= 0: if xcount <= 0:
raise ParseError("存在非正数!") raise ParseError("存在非正数!")
@ -83,13 +82,13 @@ def do_out(item: MIOItem, is_reverse: bool = False):
if not is_zhj: if not is_zhj:
try: try:
mb = MaterialBatch.objects.get( mb = MaterialBatch.objects.get(
material=xmaterial, material=xmaterial,
warehouse=item.warehouse, warehouse=item.warehouse,
batch=xbatch, batch=xbatch,
state=state, state=WMaterial.WM_OK,
defect=defect defect=defect
) )
except (MaterialBatch.DoesNotExist, MaterialBatch.MultipleObjectsReturned) as e: except (MaterialBatch.DoesNotExist, MaterialBatch.MultipleObjectsReturned) as e:
raise ParseError(f"{str(xmaterial)}批次{xbatch}错误!{e}") raise ParseError(f"{str(xmaterial)}批次{xbatch}错误!{e}")
mb.count = mb.count - xcount mb.count = mb.count - xcount
if mb.count < 0: if mb.count < 0:
@ -101,8 +100,8 @@ def do_out(item: MIOItem, is_reverse: bool = False):
# 领到车间库存(或工段) # 领到车间库存(或工段)
wm, new_create = WMaterial.objects.get_or_create( wm, new_create = WMaterial.objects.get_or_create(
batch=xbatch, material=xmaterial, batch=xbatch, material=xmaterial,
belong_dept=belong_dept, mgroup=mgroup, belong_dept=belong_dept, mgroup=mgroup,
state=state, defect=defect) state=WMaterial.WM_OK, defect=defect)
if new_create: if new_create:
wm.create_by = do_user wm.create_by = do_user
wm.batch_ofrom = mb.batch if mb else None wm.batch_ofrom = mb.batch if mb else None
@ -176,23 +175,19 @@ def do_in(item: MIOItem):
xmaterial, xbatch, xcount, defect, xwm = al xmaterial, xbatch, xcount, defect, xwm = al
if xcount <= 0: if xcount <= 0:
raise ParseError("存在非正数!") raise ParseError("存在非正数!")
state = WMaterial.WM_OK
if defect and defect.okcate in [Defect.DEFECT_NOTOK]:
state = WMaterial.WM_NOTOK
xbatchs.append(xbatch) xbatchs.append(xbatch)
if xmaterial.into_wm: if xmaterial.into_wm:
if xwm: if xwm:
wm = xwm wm = xwm
else: else:
wm_qs = WMaterial.objects.filter( wm_qs = WMaterial.objects.filter(
batch=xbatch, batch=xbatch,
material=xmaterial, material=xmaterial,
belong_dept=belong_dept, belong_dept=belong_dept,
mgroup=mgroup, mgroup=mgroup,
defect=defect, defect=defect,
state=state) state=WMaterial.WM_OK)
count_x = wm_qs.count() count_x = wm_qs.count()
if count_x == 1: if count_x == 1:
wm = wm_qs.first() wm = wm_qs.first()
@ -221,10 +216,10 @@ def do_in(item: MIOItem):
# 增加mb # 增加mb
if not is_zhj: if not is_zhj:
mb, _ = MaterialBatch.objects.get_or_create( mb, _ = MaterialBatch.objects.get_or_create(
material=xmaterial, material=xmaterial,
warehouse=item.warehouse, warehouse=item.warehouse,
batch=xbatch, batch=xbatch,
state=state, state=WMaterial.WM_OK,
defect=defect, defect=defect,
defaults={ defaults={
"count": 0, "count": 0,
@ -463,8 +458,7 @@ class InmService:
@classmethod @classmethod
def revert_and_del(cls, mioitem: MIOItem): def revert_and_del(cls, mioitem: MIOItem):
# 锁定 MIO 行,防止多人同时撤销不同明细时并发漏删 MIO mio = mioitem.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:
@ -485,7 +479,4 @@ class InmService:
mioitem.delete() mioitem.delete()
else: else:
raise ParseError("不支持该出入库单明细撤销") raise ParseError("不支持该出入库单明细撤销")
# 若该出入库记录已无明细,自动删除
if not MIOItem.objects.filter(mio=mio).exists():
mio.delete()

View File

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

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) 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

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

View File

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

View File

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

@ -242,7 +242,6 @@ class Mlog(CommonADModel):
reminder_interval_list = models.JSONField('提醒间隔', default=list, blank=True) reminder_interval_list = models.JSONField('提醒间隔', default=list, blank=True)
stored_mgroup = models.BooleanField('入库到工段', default=False) stored_mgroup = models.BooleanField('入库到工段', default=False)
stored_notok = models.BooleanField('不合格品是否已入库', default=False) stored_notok = models.BooleanField('不合格品是否已入库', default=False)
clear_defect = models.BooleanField('合格B类缺陷不拆批', default=False)
route = models.ForeignKey(Route, verbose_name='生产路线', on_delete=models.SET_NULL, null=True, blank=True) route = models.ForeignKey(Route, verbose_name='生产路线', on_delete=models.SET_NULL, null=True, blank=True)
mtask = models.ForeignKey( mtask = models.ForeignKey(
Mtask, verbose_name='关联任务', on_delete=models.CASCADE, null=True, blank=True, related_name='mlog_mtask') Mtask, verbose_name='关联任务', on_delete=models.CASCADE, null=True, blank=True, related_name='mlog_mtask')
@ -614,8 +613,7 @@ 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, related_name='mlogbw_equip') equip = models.ForeignKey(Equipment, verbose_name='设备', on_delete=models.SET_NULL, null=True, blank=True)
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='关联检验',
@ -679,7 +677,6 @@ class Handover(CommonADModel):
H_DIV = 20 H_DIV = 20
new_batch = models.TextField('新批次号', null=True, blank=True, db_index=True) new_batch = models.TextField('新批次号', null=True, blank=True, db_index=True)
new_wm = models.ForeignKey(WMaterial, on_delete=models.SET_NULL, null=True, blank=True) new_wm = models.ForeignKey(WMaterial, on_delete=models.SET_NULL, null=True, blank=True)
clear_defect = models.BooleanField('合批清除缺陷', default=False)
mtype = models.PositiveSmallIntegerField("合并类型", default=H_NORMAL, choices= mtype = models.PositiveSmallIntegerField("合并类型", default=H_NORMAL, choices=
[(H_NORMAL, '正常'), (H_DIV, '分批'), (H_MERGE, '合批')]) [(H_NORMAL, '正常'), (H_DIV, '分批'), (H_MERGE, '合批')])
type = models.PositiveSmallIntegerField('交接类型', choices=[ type = models.PositiveSmallIntegerField('交接类型', choices=[

View File

@ -1,7 +1,7 @@
from apps.wpm.models import BatchSt from apps.wpm.models import BatchSt
import logging import logging
from apps.qm.models import Defect, FtestWork, FtestworkDefect from apps.qm.models import Defect, FtestWork, FtestworkDefect
from apps.wpm.models import Mlogb, MlogbDefect, Mlog from apps.wpm.models import Mlogb, MlogbDefect
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,44 +87,35 @@ 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}_班次"]])
# 按 mlog__submit_time, id 排序,每条 Mlogb 记录独立为一次返修 mlogb2_qs = Mlogb.objects.filter(mlog__submit_time__isnull=False,
# (同一 Mlog 下有多条同批次 Mlogb 时也能正确拆分为多次返修) material_out__isnull=False,
_all_fix_qs = Mlogb.objects.filter( mlog__mgroup__name="外观检验",
mlog__submit_time__isnull=False, mlog__is_fix=True, batch=batch, need_inout=True)
material_out__isnull=False, if mlogb2_qs.exists():
mlog__mgroup__name="外观检验", data["外观检验_返修_日期"] = []
mlog__is_fix=True, data["外观检验_返修_操作人"] = []
batch=batch, data["外观检验_返修_count_real"] = 0
need_inout=True, data["外观检验_返修_count_ok"] = 0
).order_by("mlog__submit_time", "id") data["外观检验_返修_count_ok_full"] = 0
_fix_prefixes = [] for item in mlogb2_qs:
for fix_idx, fix_mlogb in enumerate(_all_fix_qs): if item.mlog.handle_user:
suffix = "" if fix_idx == 0 else str(fix_idx + 1) data["外观检验_返修_操作人"].append(item.mlog.handle_user)
prefix = f"外观检验_返修{suffix}_" if item.mlog.handle_date:
_fix_prefixes.append(prefix) data["外观检验_返修_日期"].append(item.mlog.handle_date)
mlog = fix_mlogb.mlog data["外观检验_返修_count_real"] += item.count_real
handle_date = mlog.handle_date data["外观检验_返修_count_ok"] += item.count_ok
data[f"{prefix}count_real"] = fix_mlogb.count_real data["外观检验_返修_count_ok_full"] += item.count_ok_full if item.count_ok_full else 0
data[f"{prefix}count_ok"] = fix_mlogb.count_ok
data[f"{prefix}count_ok_full"] = fix_mlogb.count_ok_full or 0 data["外观检验_返修_日期"] = list(set(data["外观检验_返修_日期"]))
data[f"{prefix}count_notok"] = fix_mlogb.count_notok or 0 data["外观检验_返修_日期"] = ";".join([item.strftime("%Y-%m-%d") for item in data["外观检验_返修_日期"]])
try: data["外观检验_返修_操作人"] = list(set(data["外观检验_返修_操作人"]))
data[f"{prefix}合格率"] = round((fix_mlogb.count_ok / fix_mlogb.count_real) * 100, 2) data["外观检验_返修_操作人"] = ";".join([item.name for item in data["外观检验_返修_操作人"]])
except (decimal.InvalidOperation, ZeroDivisionError):
data[f"{prefix}合格率"] = 0 mlogbd2_qs = MlogbDefect.objects.filter(mlogb__in=mlogb2_qs, count__gt=0).values("defect__name").annotate(total=Sum("count"))
try:
data[f"{prefix}完全合格率"] = round(((fix_mlogb.count_ok_full or 0) / fix_mlogb.count_real) * 100, 2) for item in mlogbd2_qs:
except (decimal.InvalidOperation, ZeroDivisionError): data[f"外观检验_返修_缺陷_{item['defect__name']}"] = item["total"]
data[f"{prefix}完全合格率"] = 0 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}小日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"{prefix}大日期"] = handle_date.strftime("%Y-%m-%d") if handle_date else ""
data[f"{prefix}操作人"] = mlog.handle_user.name if mlog.handle_user else ""
data[f"{prefix}班次"] = mlog.shift.name if mlog.shift else ""
fix_defect_qs = MlogbDefect.objects.filter(mlogb=fix_mlogb, count__gt=0).values("defect__name").annotate(total=Sum("count"))
for item in fix_defect_qs:
data[f"{prefix}缺陷_{item['defect__name']}"] = item["total"]
data[f"{prefix}缺陷_{item['defect__name']}_比例"] = round((item["total"] / fix_mlogb.count_real) * 100, 2)
# 车间库存抽检 # 车间库存抽检
ft_qs = FtestWork.objects.filter(type2=FtestWork.TYPE2_SOME, wm__mgroup__name="外观检验", batch=batch, submit_time__isnull=False) ft_qs = FtestWork.objects.filter(type2=FtestWork.TYPE2_SOME, wm__mgroup__name="外观检验", batch=batch, submit_time__isnull=False)
@ -150,13 +141,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"] + sum(data.get(f"{p}count_ok", 0) for p in _fix_prefixes) data["外观检验_总合格数"] = data["外观检验_count_ok"] + data.get("外观检验_返修_count_ok", 0)
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"] + 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: 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,8 +680,6 @@ 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
@ -728,14 +726,12 @@ class MlogInitSerializer(CustomModelSerializer):
attrs["qct"] = Qct.get(attrs["material_out"], "process", "out") attrs["qct"] = Qct.get(attrs["material_out"], "process", "out")
attrs["handle_date"], attrs["shift"] = mgroup.get_shift(attrs['work_start_time']) attrs["handle_date"], attrs["shift"] = mgroup.get_shift(attrs['work_start_time'])
if mgroup.process:
attrs['clear_defect'] = mgroup.process.clear_defect
return attrs return attrs
class MlogChangeSerializer(CustomModelSerializer): class MlogChangeSerializer(CustomModelSerializer):
class Meta: class Meta:
model = Mlog model = Mlog
fields = ['id', 'work_start_time', 'work_end_time', 'handle_user', 'note', 'oinfo_json', 'test_file', 'test_user', 'test_time', 'equipment', "team", "clear_defect"] fields = ['id', 'work_start_time', 'work_end_time', 'handle_user', 'note', 'oinfo_json', 'test_file', 'test_user', 'test_time', 'equipment', "team"]
def update(self, instance, validated_data): def update(self, instance, validated_data):
work_start_time = validated_data.get('work_start_time', None) work_start_time = validated_data.get('work_start_time', None)
@ -878,17 +874,13 @@ 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", "tooling", "work_start_time", "mlogb", "ftest", "equip", "work_start_time",
"work_end_time", "mlogbw_from", "equip_name", "equip_number", "work_end_time", "mlogbw_from", "equip_name", "equip_number", "wpr_number_out", "mlogb__batch"]
"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):
@ -1312,21 +1304,16 @@ class HandoverSerializer(CustomModelSerializer):
elif deptOrmgroupId != current_mdept_id: elif deptOrmgroupId != current_mdept_id:
raise ParseError(f'{ind+1}行-交接物料所属工段/车间不一致') raise ParseError(f'{ind+1}行-交接物料所属工段/车间不一致')
if mtype == Handover.H_MERGE: if mtype == Handover.H_MERGE:
clear_defect = attrs.get('clear_defect', False)
if clear_defect and wm.state != WMaterial.WM_OK:
raise ParseError(f'{ind+1}行-清除缺陷合批仅支持合格品')
if clear_defect and new_wm is not None and new_wm.defect is not None:
raise ParseError('清除缺陷合批的目标批次不能带缺陷')
if next_mat is None: if next_mat is None:
next_mat = wm.material next_mat = wm.material
next_state = wm.state next_state = wm.state
next_defect = None if clear_defect else wm.defect next_defect = wm.defect
else: else:
if next_mat != wm.material: if next_mat != wm.material:
raise ParseError(f'{ind+1}行-合并的物料不一致') raise ParseError(f'{ind+1}行-合并的物料不一致')
if next_state != wm.state: if next_state != wm.state:
raise ParseError(f'{ind+1}行-合并的物料状态不一致') raise ParseError(f'{ind+1}行-合并的物料状态不一致')
if not clear_defect and next_defect != wm.defect: if next_defect != wm.defect:
raise ParseError(f'{ind+1}行-合并的物料缺陷不一致') raise ParseError(f'{ind+1}行-合并的物料缺陷不一致')
if tracking == Material.MA_TRACKING_SINGLE: if tracking == Material.MA_TRACKING_SINGLE:
handoverbw = item.get("handoverbw", []) handoverbw = item.get("handoverbw", [])
@ -1562,7 +1549,6 @@ class MlogUserSerializer(CustomModelSerializer):
handle_user_name = serializers.CharField(source='handle_user.name', read_only=True) handle_user_name = serializers.CharField(source='handle_user.name', read_only=True)
process_name = serializers.CharField(source='process.name', read_only=True) process_name = serializers.CharField(source='process.name', read_only=True)
shift_name = serializers.CharField(source='shift.name', read_only=True) shift_name = serializers.CharField(source='shift.name', read_only=True)
equipment_name = serializers.CharField(source='equipment.name', read_only=True, default=None)
class Meta: class Meta:
model = MlogUser model = MlogUser
fields = "__all__" fields = "__all__"

View File

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

View File

@ -1112,7 +1112,7 @@ class MlogbwViewSet(CustomModelViewSet):
list_serializer_class = MlogbwListSerializer list_serializer_class = MlogbwListSerializer
retrieve_serializer_class = MlogbwListSerializer retrieve_serializer_class = MlogbwListSerializer
filterset_class = MlogbwFilter filterset_class = MlogbwFilter
select_related_fields = ["ftest", "equip", "tooling", "wpr", "mlogb"] select_related_fields = ["ftest", "equip", "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", "equipment"] select_related_fields = ["handle_user", "shift", "process"]
filterset_fields = ["mlog"] filterset_fields = ["mlog"]
def get_queryset(self): 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) 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,7 +3,6 @@ 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,8 +54,4 @@ 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,10 +1,9 @@
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, BulkUpdateModelMixin from apps.utils.mixins import CustomListModelMixin, CustomRetrieveModelMixin, ComplexQueryMixin
from apps.wpmw.models import Wpr, WprDefect from apps.wpmw.models import Wpr, WprDefect
from apps.qm.models import FtestItem from apps.wpmw.serializers import WprSerializer, WprNewSerializer, WprDetailSerializer, WproutListSerializer, WprChangeNumberSerializer
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
@ -14,13 +13,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(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"] 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()
@ -149,18 +148,6 @@ class WprViewSet(BulkUpdateModelMixin, CustomListModelMixin, CustomRetrieveModel
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,24 +1,19 @@
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
import json import json
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
import socket import socket
import threading import traceback
import threading # 新增:引入线程锁
sc_all = {} # 全局 Socket 连接字典
sc_locks = {} sc_all = {
registry_lock = threading.Lock() "192.168.1.220_6000": None,
"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
@ -26,13 +21,13 @@ def get_checksum(body_msg):
def handle_bytes(arr): def handle_bytes(arr):
if len(arr) < 8: if len(arr) < 8:
return f"返回数据长度错误-{arr}" return f"返回数据长度错误-{arr}"
if arr[0] != 0xEB or arr[1] != 0x90: if arr[0] != 0xEB or arr[1] != 0x90:
return "数据头不正确" return "数据头不正确"
length_arr = arr[2:4][::-1] length_arr = arr[2:4][::-1]
length = int.from_bytes(length_arr, byteorder='little', signed=True) length = int.from_bytes(length_arr, byteorder='little', signed=True)
if len(arr) < length + 4: if len(arr) < length + 4:
return f"数据不完整,期望长度:{length+4},实际长度:{len(arr)}" return f"数据不完整,期望长度:{length+4},实际长度:{len(arr)}"
@ -51,45 +46,6 @@ 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)
@ -104,31 +60,48 @@ 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:
self.error(f'{addr} 未找到')
return
def request_once(): def connect_and_send():
with lock: 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
else: sc.settimeout(0.5)
drain_socket(sc) while True:
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:
@ -136,34 +109,34 @@ class JSONRequestHandler(BaseHTTPRequestHandler):
except Exception: except Exception:
pass pass
sc_all[addr] = None sc_all[addr] = None
raise e self.error(f'采集器通信失败: {e}')
return None, None
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) and res == "数据头不正确":
try:
resp = request_once()
except Exception as e:
self.error(f'采集器通信失败: {e}')
return
res = handle_bytes(resp)
if isinstance(res, str): if isinstance(res, str):
self.error(res) 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: else:
self.ok(res) self.ok(res)
def run(server_class=HTTPServer, handler_class=JSONRequestHandler, port=2300):
def run(server_class=ThreadingHTTPServer, handler_class=JSONRequestHandler, port=2300):
server_address = ('', port) server_address = ('', port)
httpd = server_class(server_address, handler_class) httpd = server_class(server_address, handler_class)
print(f'Starting httpd server on port {port}...') print(f'Starting httpd server on port {port}...')
httpd.serve_forever() httpd.serve_forever()
if __name__ == '__main__': if __name__ == '__main__':
run() run()