chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
208
backend/app/services/label_service.py
Normal file
208
backend/app/services/label_service.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""标签打印服务 — 工业级精确定位标签 (480×360) + TSPL 发送"""
|
||||
import base64
|
||||
import socket
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import qrcode
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.services.print_config import PrintConfigManager
|
||||
|
||||
# ============================================================
|
||||
# 字体 — 项目内 simhei.ttf
|
||||
# ============================================================
|
||||
|
||||
_FONT_PATH = str(Path(__file__).resolve().parent.parent.parent / "simhei.ttf")
|
||||
|
||||
FONT_NORMAL = ImageFont.truetype(_FONT_PATH, 28) # 右侧:名/规/单
|
||||
FONT_LARGE = ImageFont.truetype(_FONT_PATH, 34) # 底部:序列号
|
||||
|
||||
# ============================================================
|
||||
# 画布 & 坐标常量
|
||||
# ============================================================
|
||||
|
||||
WIDTH, HEIGHT = 480, 360
|
||||
QR_X, QR_Y = 24, 60
|
||||
QR_SIZE = 180
|
||||
TEXT_X = 228
|
||||
TEXT_MAX_W = WIDTH - TEXT_X - 12 # ~240px
|
||||
BOTTOM_Y = 260 # 序列号 y
|
||||
LINE_H = 44 # 28px 行高
|
||||
|
||||
# ============================================================
|
||||
# QR 码
|
||||
# ============================================================
|
||||
|
||||
def _generate_qr(content: str) -> Image.Image:
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
return img.resize((QR_SIZE, QR_SIZE), Image.NEAREST)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 文字绘制 — 带描边 + 自动换行
|
||||
# ============================================================
|
||||
|
||||
def _draw(
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
x: int,
|
||||
y: int,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
max_width: int,
|
||||
stroke_width: int = 1,
|
||||
) -> int:
|
||||
"""
|
||||
绘制文字,超出 max_width 自动折行。
|
||||
返回下一行可用的 y 坐标。
|
||||
"""
|
||||
if not text:
|
||||
return y + LINE_H
|
||||
|
||||
# 单行不超宽 → 直接画(带描边)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
draw.text((x, y), text, font=font, fill="black",
|
||||
stroke_width=stroke_width, stroke_fill="black")
|
||||
return y + LINE_H
|
||||
|
||||
# 逐字符折行
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for char in text:
|
||||
test = current + char
|
||||
w = draw.textbbox((0, 0), test, font=font)[2]
|
||||
if w <= max_width:
|
||||
current = test
|
||||
else:
|
||||
lines.append(current)
|
||||
current = char
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
cy = y
|
||||
for line in lines:
|
||||
draw.text((x, cy), line, font=font, fill="black",
|
||||
stroke_width=stroke_width, stroke_fill="black")
|
||||
cy += LINE_H
|
||||
return cy
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 标签绑制 — 工业级精确坐标
|
||||
# ============================================================
|
||||
|
||||
def _create_label(data: dict) -> Image.Image:
|
||||
"""
|
||||
480×360 工业级排版:
|
||||
|
||||
┌───────────────┬──────────────────────────────┐
|
||||
│ │ 名: 样品升降台V1J y=60 │ ← 28px sw=1
|
||||
│ [QR Code] │ 规: PH-B4V1J/类A y=104 │
|
||||
│ 180×180 │ 单: ORD-2024-001 y=148 │
|
||||
│ (24, 60) │ │
|
||||
│ │ │
|
||||
│ 码: 0000000000000001 y=260 │ ← 34px sw=2
|
||||
└───────────────┴──────────────────────────────┘
|
||||
"""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
serial = data.get("serial_number", "")
|
||||
|
||||
# ── QR 码 (24, 60) ──
|
||||
if serial:
|
||||
qr_img = _generate_qr(serial)
|
||||
img.paste(qr_img, (QR_X, QR_Y))
|
||||
|
||||
# ── 右侧文字 x=228 — 28px, stroke_width=1 ──
|
||||
y = 60
|
||||
|
||||
name = data.get("material_name", "") or "未命名"
|
||||
y = _draw(draw, f"名: {name}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1)
|
||||
|
||||
spec = data.get("spec_model", "") or "-"
|
||||
y = _draw(draw, f"规: {spec}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1)
|
||||
|
||||
order_no = data.get("order_no", "")
|
||||
if order_no and str(order_no).strip():
|
||||
y = _draw(draw, f"单: {str(order_no).strip()}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1)
|
||||
|
||||
# ── 底部通栏 (24, 260) — 34px, stroke_width=2 ──
|
||||
code = serial or "-"
|
||||
draw.text((24, BOTTOM_Y), f"码: {code}", font=FONT_LARGE, fill="black",
|
||||
stroke_width=2, stroke_fill="black")
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API
|
||||
# ============================================================
|
||||
|
||||
def generate_preview_image(**data) -> str:
|
||||
"""返回 Base64 JPEG data URL"""
|
||||
img = _create_label(data)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=92)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def send_to_printer(
|
||||
copies: int = 1,
|
||||
printer_ip: Optional[str] = None,
|
||||
printer_port: Optional[int] = None,
|
||||
**data,
|
||||
) -> dict:
|
||||
"""二值化 → TSPL → Socket 发送"""
|
||||
printer = PrintConfigManager.get_printer("label_printer")
|
||||
ip = printer_ip or printer.get("ip", "192.168.9.221")
|
||||
port = printer_port or printer.get("port", 9100)
|
||||
|
||||
img_rgb = _create_label(data)
|
||||
img_gray = img_rgb.convert("L")
|
||||
img_bw = img_gray.point(lambda px: 0 if px < 128 else 255, "1")
|
||||
|
||||
width_bytes = (img_bw.width + 7) // 8
|
||||
height_dots = img_bw.height
|
||||
|
||||
tspl = (
|
||||
"SIZE 40 mm, 30 mm\r\n"
|
||||
"GAP 2 mm, 0 mm\r\n"
|
||||
"CLS\r\n"
|
||||
"DIRECTION 1\r\n"
|
||||
).encode("gbk", errors="replace")
|
||||
|
||||
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode("gbk", errors="replace")
|
||||
bitmap_data = img_bw.tobytes()
|
||||
footer = f"\r\nPRINT 1,{copies}\r\n".encode("gbk", errors="replace")
|
||||
|
||||
payload = tspl + bitmap_cmd + bitmap_data + footer
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect((ip, port))
|
||||
s.sendall(payload)
|
||||
s.close()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"打印指令已发送 → {ip}:{port},份数: {copies}",
|
||||
"printer": f"{ip}:{port}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"打印机连接失败 ({ip}:{port}): {str(e)}",
|
||||
"printer": f"{ip}:{port}",
|
||||
}
|
||||
Reference in New Issue
Block a user