添加条形码内容
This commit is contained in:
@ -20,7 +20,9 @@ def create_app():
|
|||||||
# 2. 注册蓝图 (Blueprints)
|
# 2. 注册蓝图 (Blueprints)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
|
|
||||||
# 注册入库聚合模块 (Inbound)
|
# -----------------------------------------------------
|
||||||
|
# 2.1 注册入库聚合模块 (Inbound)
|
||||||
|
# -----------------------------------------------------
|
||||||
try:
|
try:
|
||||||
# 指向聚合文件: app/api/v1/inbound/__init__.py
|
# 指向聚合文件: app/api/v1/inbound/__init__.py
|
||||||
# 该文件里应该包含了 buy, semi, base, product 的聚合逻辑
|
# 该文件里应该包含了 buy, semi, base, product 的聚合逻辑
|
||||||
@ -29,9 +31,6 @@ def create_app():
|
|||||||
# 注册父蓝图,路由前缀为 /api/v1/inbound
|
# 注册父蓝图,路由前缀为 /api/v1/inbound
|
||||||
# 最终路由效果:
|
# 最终路由效果:
|
||||||
# /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
|
# /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
|
||||||
# /api/v1/inbound + /semi/list -> /api/v1/inbound/semi/list
|
|
||||||
# /api/v1/inbound + /product/list -> /api/v1/inbound/product/list
|
|
||||||
# /api/v1/inbound + /base/search -> /api/v1/inbound/base/search
|
|
||||||
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
|
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
|
||||||
|
|
||||||
print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功")
|
print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功")
|
||||||
@ -39,6 +38,22 @@ def create_app():
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"❌ 错误: Inbound 模块导入失败: {e}")
|
print(f"❌ 错误: Inbound 模块导入失败: {e}")
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 2.2 注册通用打印模块 (Common Print) - [新增]
|
||||||
|
# -----------------------------------------------------
|
||||||
|
try:
|
||||||
|
from app.api.v1.common.print import print_bp
|
||||||
|
|
||||||
|
# 注册打印蓝图
|
||||||
|
# 前端请求地址: /common/print/preview
|
||||||
|
# 配合 baseURL=/api/v1,最终对应后端: /api/v1/common/print/preview
|
||||||
|
app.register_blueprint(print_bp, url_prefix='/api/v1/common/print')
|
||||||
|
|
||||||
|
print("✅ Print (Label Printing) 模块注册成功")
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ 错误: Print 模块导入失败: {e}")
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# 3. 预加载数据模型 (解决 relationship 找不到模型的问题)
|
# 3. 预加载数据模型 (解决 relationship 找不到模型的问题)
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@ -50,7 +65,7 @@ def create_app():
|
|||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
# 3. 半成品入库
|
# 3. 半成品入库
|
||||||
from app.models.inbound.semi import StockSemi
|
from app.models.inbound.semi import StockSemi
|
||||||
# 4. 成品入库 (新增)
|
# 4. 成品入库
|
||||||
from app.models.inbound.product import StockProduct
|
from app.models.inbound.product import StockProduct
|
||||||
|
|
||||||
# 开发环境如果需要自动建表,可以取消注释
|
# 开发环境如果需要自动建表,可以取消注释
|
||||||
|
|||||||
0
inventory-backend/app/api/v1/common/__init__.py
Normal file
0
inventory-backend/app/api/v1/common/__init__.py
Normal file
27
inventory-backend/app/api/v1/common/print.py
Normal file
27
inventory-backend/app/api/v1/common/print.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# app/api/v1/common/print.py
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from app.services.print.label_service import LabelPrintService
|
||||||
|
from app.models.inbound.buy import StockBuy
|
||||||
|
# 引入其他模型 StockSemi, StockProduct
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
print_bp = Blueprint('print', __name__)
|
||||||
|
|
||||||
|
@print_bp.route('/preview', methods=['POST'])
|
||||||
|
def preview_label():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
# 如果只传了ID和类型,可以在这里查库补全数据,也可以直接前端传全量数据
|
||||||
|
img_base64 = LabelPrintService.generate_preview_image(data)
|
||||||
|
return jsonify({"code": 200, "msg": "success", "data": img_base64})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
@print_bp.route('/execute', methods=['POST'])
|
||||||
|
def execute_print():
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
LabelPrintService.send_to_printer(data)
|
||||||
|
return jsonify({"code": 200, "msg": "指令已发送至打印机"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
@ -1,11 +1,13 @@
|
|||||||
|
# app/api/v1/inbound/buy.py
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from app.services.inbound.buy_service import BuyInboundService
|
from app.services.inbound.buy_service import BuyInboundService
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
inbound_buy_bp = Blueprint('inbound_buy', __name__)
|
inbound_buy_bp = Blueprint('inbound_buy', __name__)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 0. 基础物料搜索 (新增)
|
# 0. 基础物料搜索
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@inbound_buy_bp.route('/search-base', methods=['GET'])
|
@inbound_buy_bp.route('/search-base', methods=['GET'])
|
||||||
def search_base():
|
def search_base():
|
||||||
@ -25,6 +27,7 @@ def search_base():
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 1. 获取列表
|
# 1. 获取列表
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -38,8 +41,9 @@ def get_list():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 2. 新增入库
|
# 2. 新增入库 (修改:返回创建的对象数据)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@inbound_buy_bp.route('/submit', methods=['POST'])
|
@inbound_buy_bp.route('/submit', methods=['POST'])
|
||||||
def submit():
|
def submit():
|
||||||
@ -47,12 +51,21 @@ def submit():
|
|||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"code": 400, "msg": "No data"}), 400
|
return jsonify({"code": 400, "msg": "No data"}), 400
|
||||||
BuyInboundService.handle_inbound(data)
|
|
||||||
return jsonify({"code": 200, "msg": "入库成功"})
|
# 调用 Service 处理入库,获取新创建的对象
|
||||||
|
new_stock = BuyInboundService.handle_inbound(data)
|
||||||
|
|
||||||
|
# 返回成功信息以及新创建的数据(包含生成的ID和SKU),供前端打印使用
|
||||||
|
return jsonify({
|
||||||
|
"code": 200,
|
||||||
|
"msg": "入库成功",
|
||||||
|
"data": new_stock.to_dict()
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 3. 更新入库
|
# 3. 更新入库
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@ -65,6 +78,7 @@ def update_buy(id):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 4. 删除
|
# 4. 删除
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@ -42,7 +42,8 @@ class StockBuy(db.Model):
|
|||||||
detail_link = db.Column(db.Text)
|
detail_link = db.Column(db.Text)
|
||||||
arrival_photo = db.Column(db.Text)
|
arrival_photo = db.Column(db.Text)
|
||||||
|
|
||||||
# 注意:SQL 中没有 remark 字段,这里已移除
|
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq)
|
||||||
|
global_print_id = db.Column(db.Integer)
|
||||||
|
|
||||||
# 关系定义
|
# 关系定义
|
||||||
material = db.relationship('MaterialBase', back_populates='stock_buys')
|
material = db.relationship('MaterialBase', back_populates='stock_buys')
|
||||||
@ -83,5 +84,9 @@ class StockBuy(db.Model):
|
|||||||
'purchaser_email': self.buyer_email,
|
'purchaser_email': self.buyer_email,
|
||||||
'source_link': self.original_link,
|
'source_link': self.original_link,
|
||||||
'detail_link': self.detail_link,
|
'detail_link': self.detail_link,
|
||||||
'arrival_photo': self.arrival_photo
|
'arrival_photo': self.arrival_photo,
|
||||||
|
|
||||||
|
# [新增] 返回全局打印ID及其格式化字符串
|
||||||
|
'global_print_id': self.global_print_id,
|
||||||
|
'global_print_id_str': f"{self.global_print_id:08d}" if self.global_print_id else ""
|
||||||
}
|
}
|
||||||
@ -1,11 +1,9 @@
|
|||||||
|
# app/services/inbound/buy_service.py
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
# ==============================================================================
|
|
||||||
# ✅ 修复点:基础物料模型实际位于 app/models/base.py
|
|
||||||
# ==============================================================================
|
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy import or_, func
|
from sqlalchemy import or_, func, text
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
@ -71,13 +69,36 @@ class BuyInboundService:
|
|||||||
in_qty = float(data.get('in_quantity') or 0)
|
in_qty = float(data.get('in_quantity') or 0)
|
||||||
u_price = float(data.get('unit_price') or 0)
|
u_price = float(data.get('unit_price') or 0)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. 获取全局打印流水号 (跨表唯一)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||||
|
result = db.session.execute(seq_sql)
|
||||||
|
next_global_id = result.scalar()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. 自动生成 SKU (格式: 00000001)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
generated_sku = f"{next_global_id:08d}"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. 条码逻辑处理 (核心修改)
|
||||||
|
# 如果前端没传条码(barcode),则默认使用系统生成的 SKU 作为条码
|
||||||
|
# 这样保证了条码生成依据是 "自动填写的 SKU"
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
final_barcode = data.get('barcode')
|
||||||
|
if not final_barcode:
|
||||||
|
final_barcode = generated_sku
|
||||||
|
|
||||||
new_stock = StockBuy(
|
new_stock = StockBuy(
|
||||||
base_id=material.id,
|
base_id=material.id,
|
||||||
sku=data.get('sku'),
|
global_print_id=next_global_id,
|
||||||
|
sku=generated_sku, # 自动生成的SKU
|
||||||
|
barcode=final_barcode, # 如果未输入,则存入SKU值
|
||||||
|
|
||||||
in_date=in_date_val,
|
in_date=in_date_val,
|
||||||
serial_number=data.get('serial_number'),
|
serial_number=data.get('serial_number'),
|
||||||
batch_number=data.get('batch_number'),
|
batch_number=data.get('batch_number'),
|
||||||
barcode=data.get('barcode'),
|
|
||||||
status='在库',
|
status='在库',
|
||||||
in_quantity=in_qty,
|
in_quantity=in_qty,
|
||||||
stock_quantity=in_qty,
|
stock_quantity=in_qty,
|
||||||
@ -98,6 +119,8 @@ class BuyInboundService:
|
|||||||
|
|
||||||
db.session.add(new_stock)
|
db.session.add(new_stock)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
# 返回创建的对象实例
|
||||||
return new_stock
|
return new_stock
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -264,7 +287,10 @@ class BuyInboundService:
|
|||||||
'purchaser_email': item.buyer_email,
|
'purchaser_email': item.buyer_email,
|
||||||
'source_link': item.original_link,
|
'source_link': item.original_link,
|
||||||
'detail_link': item.detail_link,
|
'detail_link': item.detail_link,
|
||||||
'arrival_photo': item.arrival_photo
|
'arrival_photo': item.arrival_photo,
|
||||||
|
|
||||||
|
'global_print_id': item.global_print_id,
|
||||||
|
'global_print_id_str': f"{item.global_print_id:08d}" if item.global_print_id else ""
|
||||||
}
|
}
|
||||||
items.append(d)
|
items.append(d)
|
||||||
|
|
||||||
|
|||||||
198
inventory-backend/app/services/print/label_service.py
Normal file
198
inventory-backend/app/services/print/label_service.py
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import socket
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# 引入条形码生成库
|
||||||
|
try:
|
||||||
|
import barcode
|
||||||
|
from barcode.writer import ImageWriter
|
||||||
|
except ImportError:
|
||||||
|
print("❌ 警告: 未安装 python-barcode 库,无法生成真实条形码。请执行: pip install python-barcode")
|
||||||
|
|
||||||
|
|
||||||
|
class LabelPrintService:
|
||||||
|
PRINTER_IP = "192.168.9.205"
|
||||||
|
PRINTER_PORT = 9100
|
||||||
|
|
||||||
|
# ================= 1. 尺寸与分辨率配置 (300 DPI) =================
|
||||||
|
DOTS_PER_MM = 12 # 300 DPI
|
||||||
|
LABEL_WIDTH_MM = 40
|
||||||
|
LABEL_HEIGHT_MM = 30
|
||||||
|
|
||||||
|
# 画布像素: 40mm -> 480px, 30mm -> 360px
|
||||||
|
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
|
||||||
|
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
|
||||||
|
|
||||||
|
# 顶部留白: 10mm (120px) - 内容从这里开始
|
||||||
|
TOP_MARGIN_MM = 5
|
||||||
|
TOP_MARGIN_PX = int(TOP_MARGIN_MM * DOTS_PER_MM)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_font(size):
|
||||||
|
"""获取字体"""
|
||||||
|
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf"]
|
||||||
|
base_dirs = [os.getcwd(), os.path.dirname(__file__)]
|
||||||
|
|
||||||
|
for d in base_dirs:
|
||||||
|
for name in font_names:
|
||||||
|
path = os.path.join(d, name)
|
||||||
|
if os.path.exists(path):
|
||||||
|
return ImageFont.truetype(path, size)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _generate_barcode_image(content, width_px, height_px):
|
||||||
|
"""
|
||||||
|
生成真实的条形码图片
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 使用 Code128 编码(通用性最强)
|
||||||
|
code128 = barcode.get('code128', content, writer=ImageWriter())
|
||||||
|
|
||||||
|
# 渲染到内存
|
||||||
|
buffer = BytesIO()
|
||||||
|
# write_text=False: 不在条码下方生成自带的数字(我们自己画)
|
||||||
|
code128.write(buffer, options={"write_text": False, "module_height": 10.0, "quiet_zone": 1.0})
|
||||||
|
|
||||||
|
# 打开生成的条码图
|
||||||
|
buffer.seek(0)
|
||||||
|
bc_img = Image.open(buffer)
|
||||||
|
|
||||||
|
# 强制调整大小以适应我们的布局
|
||||||
|
return bc_img.resize((width_px, height_px), Image.Resampling.LANCZOS)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"条形码生成失败: {e}")
|
||||||
|
# 生成失败时返回一个黑色方块占位
|
||||||
|
return Image.new('RGB', (width_px, height_px), color='black')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_image_object(data):
|
||||||
|
"""
|
||||||
|
[绘图层] 生成标签图片
|
||||||
|
"""
|
||||||
|
# 1. 创建画布 (白底)
|
||||||
|
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# 2. 统一字体配置 (字号 30)
|
||||||
|
UNIFIED_FONT_SIZE = 30
|
||||||
|
font = LabelPrintService._get_font(UNIFIED_FONT_SIZE)
|
||||||
|
|
||||||
|
# 3. 提取条码内容 (优先使用 SKU)
|
||||||
|
# 逻辑:使用自动生成的 SKU (如 00000001) 作为条码内容
|
||||||
|
sku_code = data.get('sku')
|
||||||
|
if not sku_code:
|
||||||
|
# 兜底逻辑:如果没有 SKU,尝试用 serial_number 或 global_id
|
||||||
|
sku_code = data.get('serial_number') or str(data.get('global_print_id', '00000000')).zfill(8)
|
||||||
|
|
||||||
|
# ==================== 绘制布局 ====================
|
||||||
|
|
||||||
|
# X 轴偏移 (5mm)
|
||||||
|
GLOBAL_OFFSET_X = int(5 * LabelPrintService.DOTS_PER_MM)
|
||||||
|
|
||||||
|
# Y 轴起始: 10mm 处
|
||||||
|
CURRENT_Y = LabelPrintService.TOP_MARGIN_PX
|
||||||
|
|
||||||
|
# --- A. 绘制真实条形码 ---
|
||||||
|
bc_w = int(28 * LabelPrintService.DOTS_PER_MM) # 条码宽约 28mm
|
||||||
|
bc_h = int(6 * LabelPrintService.DOTS_PER_MM) # 条码高约 6mm
|
||||||
|
|
||||||
|
# 生成并粘贴条码
|
||||||
|
bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h)
|
||||||
|
# 粘贴位置: (X, Y)
|
||||||
|
img.paste(bc_img, (GLOBAL_OFFSET_X - 5, CURRENT_Y))
|
||||||
|
|
||||||
|
# 更新 Y 坐标 (条码高度 + 间距)
|
||||||
|
CURRENT_Y += bc_h + 5
|
||||||
|
|
||||||
|
# --- B. 绘制文字 (统一加粗) ---
|
||||||
|
|
||||||
|
# 准备数据
|
||||||
|
name = str(data.get('material_name', '') or '-')
|
||||||
|
if len(name) > 8: name = name[:7] + ".."
|
||||||
|
|
||||||
|
spec = str(data.get('spec_model', '') or '-')
|
||||||
|
if len(spec) > 11: spec = spec[:10] + ".."
|
||||||
|
|
||||||
|
loc = str(data.get('warehouse_loc', '') or '-')
|
||||||
|
|
||||||
|
cat = str(data.get('category', '') or '')
|
||||||
|
typ = str(data.get('material_type', '') or '')
|
||||||
|
attr = f"{cat}/{typ}"
|
||||||
|
if len(attr) > 11: attr = attr[:10] + ".."
|
||||||
|
|
||||||
|
# 底部显示的文字:SKU 编号
|
||||||
|
sku_str = f"NO.{sku_code}"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"名: {name}",
|
||||||
|
f"规: {spec}",
|
||||||
|
f"库: {loc}",
|
||||||
|
f"属: {attr}",
|
||||||
|
f"{sku_str}"
|
||||||
|
]
|
||||||
|
|
||||||
|
line_height = 36 # 行高 (字号30 + 6间距)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# 边界检查
|
||||||
|
if CURRENT_Y + UNIFIED_FONT_SIZE > LabelPrintService.LABEL_HEIGHT:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 绘制文字 (stroke_width=1 实现加粗效果)
|
||||||
|
d.text((GLOBAL_OFFSET_X, CURRENT_Y), line, font=font, fill='black', stroke_width=1, stroke_fill='black')
|
||||||
|
CURRENT_Y += line_height
|
||||||
|
|
||||||
|
return img
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_preview_image(data):
|
||||||
|
img = LabelPrintService._create_image_object(data)
|
||||||
|
output_buffer = BytesIO()
|
||||||
|
img.save(output_buffer, format='JPEG')
|
||||||
|
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
|
||||||
|
return f"data:image/jpeg;base64,{base64_str}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def send_to_printer(data):
|
||||||
|
ip = LabelPrintService.PRINTER_IP
|
||||||
|
port = LabelPrintService.PRINTER_PORT
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 获取图像 (RGB)
|
||||||
|
img_rgb = LabelPrintService._create_image_object(data)
|
||||||
|
|
||||||
|
# 2. 图像转二值化 (白=1, 黑=0 的逻辑,不反转)
|
||||||
|
img_gray = img_rgb.convert('L')
|
||||||
|
img_bw = img_gray.convert('1', dither=Image.Dither.NONE)
|
||||||
|
|
||||||
|
# 获取位图数据
|
||||||
|
bitmap_data = img_bw.tobytes()
|
||||||
|
width_bytes = (img_bw.width + 7) // 8
|
||||||
|
height_dots = img_bw.height
|
||||||
|
|
||||||
|
# 3. TSPL 指令
|
||||||
|
header = (
|
||||||
|
f"SIZE {LabelPrintService.LABEL_WIDTH_MM} mm, {LabelPrintService.LABEL_HEIGHT_MM} mm\r\n"
|
||||||
|
"GAP 2 mm, 0 mm\r\n"
|
||||||
|
"CLS\r\n"
|
||||||
|
"DIRECTION 1\r\n"
|
||||||
|
"REFERENCE 0, 0\r\n"
|
||||||
|
).encode('gbk')
|
||||||
|
|
||||||
|
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode('gbk')
|
||||||
|
footer = b"\r\nPRINT 1,1\r\n"
|
||||||
|
|
||||||
|
# 4. 发送
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.settimeout(5)
|
||||||
|
s.connect((ip, port))
|
||||||
|
s.sendall(header + bitmap_cmd + bitmap_data + footer)
|
||||||
|
s.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 打印异常: {e}")
|
||||||
|
raise Exception(f"打印机连接失败: {str(e)}")
|
||||||
@ -6,3 +6,5 @@ marshmallow-sqlalchemy==1.0.0
|
|||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
python-dotenv==1.0.0
|
python-dotenv==1.0.0
|
||||||
flask-cors==4.0.0
|
flask-cors==4.0.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
python-barcode>=0.14.0
|
||||||
BIN
inventory-backend/simhei.ttf
Normal file
BIN
inventory-backend/simhei.ttf
Normal file
Binary file not shown.
17
inventory-web/src/api/common/print.ts
Normal file
17
inventory-web/src/api/common/print.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function getLabelPreview(data: any) {
|
||||||
|
return request({
|
||||||
|
url: '/common/print/preview',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executePrint(data: any) {
|
||||||
|
return request({
|
||||||
|
url: '/common/print/execute',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -91,8 +91,11 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table-column label="操作" width="160" fixed="right" align="center">
|
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
|
<el-button link type="warning" size="default" @click="handlePrint(row)">
|
||||||
|
<el-icon><Printer /></el-icon> 打印
|
||||||
|
</el-button>
|
||||||
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
||||||
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
@ -194,7 +197,15 @@
|
|||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="选填" /></el-form-item></el-col>
|
<el-col :span="6">
|
||||||
|
<el-form-item label="编码/SKU" prop="sku">
|
||||||
|
<el-input
|
||||||
|
v-model="form.sku"
|
||||||
|
placeholder="系统自动生成 (0000000X)"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
<el-col :span="6">
|
<el-col :span="6">
|
||||||
<el-form-item label="入库日期" prop="in_date">
|
<el-form-item label="入库日期" prop="in_date">
|
||||||
<el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled />
|
<el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled />
|
||||||
@ -372,17 +383,46 @@
|
|||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<el-button @click="visible = false" size="large">取消</el-button>
|
<el-button @click="visible = false" size="large">取消</el-button>
|
||||||
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
|
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
|
||||||
{{ dialogStatus === 'create' ? '确认入库' : '保存修改' }}
|
{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="printVisible"
|
||||||
|
title="标签打印预览"
|
||||||
|
width="400px"
|
||||||
|
destroy-on-close
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<div style="text-align: center;">
|
||||||
|
<div v-loading="printLoading" class="preview-box">
|
||||||
|
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;" />
|
||||||
|
<div v-else class="empty-preview">正在生成预览...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 20px; font-size: 14px; color: #666;">
|
||||||
|
<p>打印机 IP: 192.168.9.205</p>
|
||||||
|
<p>尺寸: 40mm x 30mm</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="printVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="printing" @click="confirmPrint">
|
||||||
|
<el-icon><Printer /></el-icon> 确认打印
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted, watch } from 'vue'
|
import { ref, reactive, onMounted, watch } from 'vue'
|
||||||
import { Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link } from '@element-plus/icons-vue'
|
import { Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer } from '@element-plus/icons-vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import {
|
import {
|
||||||
@ -392,6 +432,7 @@ import {
|
|||||||
deleteBuyInbound,
|
deleteBuyInbound,
|
||||||
searchMaterialBase
|
searchMaterialBase
|
||||||
} from '@/api/inbound/buy'
|
} from '@/api/inbound/buy'
|
||||||
|
import { getLabelPreview, executePrint } from '@/api/common/print'
|
||||||
|
|
||||||
// ------------------------------------
|
// ------------------------------------
|
||||||
// 状态与变量
|
// 状态与变量
|
||||||
@ -407,6 +448,13 @@ const formRef = ref()
|
|||||||
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '' })
|
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '' })
|
||||||
const materialOptions = ref<any[]>([])
|
const materialOptions = ref<any[]>([])
|
||||||
|
|
||||||
|
// 打印相关变量
|
||||||
|
const printVisible = ref(false)
|
||||||
|
const printLoading = ref(false)
|
||||||
|
const printing = ref(false)
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const currentPrintData = ref<any>({})
|
||||||
|
|
||||||
const entryMode = ref('batch')
|
const entryMode = ref('batch')
|
||||||
const modeLocked = ref(false)
|
const modeLocked = ref(false)
|
||||||
|
|
||||||
@ -599,9 +647,6 @@ const querySearchCurrency = (queryString: string, cb: any) => {
|
|||||||
|
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
// 【修改点】: 展开时,如果列表为空,直接调用 API 加载前 20 条数据
|
|
||||||
// 同时也会把历史记录合并进去(在 handleSearchMaterial 内部处理或分开展示)
|
|
||||||
// 这里简单处理:直接调用 handleSearchMaterial('') 让后端返回默认数据
|
|
||||||
if (materialOptions.value.length === 0) {
|
if (materialOptions.value.length === 0) {
|
||||||
handleSearchMaterial('')
|
handleSearchMaterial('')
|
||||||
}
|
}
|
||||||
@ -611,14 +656,11 @@ const handleMaterialDropdownVisible = (visible: boolean) => {
|
|||||||
const handleSearchMaterial = async (query: string) => {
|
const handleSearchMaterial = async (query: string) => {
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
try {
|
try {
|
||||||
// 即使 query 为空,后端现在也会返回数据
|
|
||||||
const res: any = await searchMaterialBase(query)
|
const res: any = await searchMaterialBase(query)
|
||||||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||||
|
|
||||||
// 如果是空搜索,我们可以把本地历史记录插在前面,做个合并
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
const history = getMaterialHistory()
|
const history = getMaterialHistory()
|
||||||
// 简单去重:如果 API 返回的 ID 在历史记录里有,就跳过 API 的那个(优先显示历史标记)
|
|
||||||
const historyIds = new Set(history.map((h: any) => h.id))
|
const historyIds = new Set(history.map((h: any) => h.id))
|
||||||
const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))
|
const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))
|
||||||
materialOptions.value = [...history, ...filteredApi]
|
materialOptions.value = [...history, ...filteredApi]
|
||||||
@ -630,7 +672,6 @@ const handleSearchMaterial = async (query: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选中物料后 -> 存入历史 -> 填充表单
|
|
||||||
const onMaterialSelected = (val: number) => {
|
const onMaterialSelected = (val: number) => {
|
||||||
const item = materialOptions.value.find(i => i.id === val)
|
const item = materialOptions.value.find(i => i.id === val)
|
||||||
if (item) {
|
if (item) {
|
||||||
@ -758,7 +799,6 @@ const handleCreate = () => {
|
|||||||
entryMode.value = 'batch'
|
entryMode.value = 'batch'
|
||||||
form.batch_number = ''
|
form.batch_number = ''
|
||||||
visible.value = true
|
visible.value = true
|
||||||
// 每次打开弹窗时,先清空选项,让下拉时触发“历史加载”
|
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -806,7 +846,6 @@ const handleUpdate = (row: any) => {
|
|||||||
form.detail_link = row.detail_link
|
form.detail_link = row.detail_link
|
||||||
form.arrival_photo = row.arrival_photo
|
form.arrival_photo = row.arrival_photo
|
||||||
|
|
||||||
// 编辑模式下,把当前物料塞入选项,防止显示为 ID
|
|
||||||
materialOptions.value = [{
|
materialOptions.value = [{
|
||||||
id: row.base_id,
|
id: row.base_id,
|
||||||
name: row.material_name,
|
name: row.material_name,
|
||||||
@ -817,6 +856,9 @@ const handleUpdate = (row: any) => {
|
|||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 提交逻辑 (新增自动打印逻辑)
|
||||||
|
// ------------------------------------
|
||||||
const submitForm = async () => {
|
const submitForm = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
@ -824,8 +866,24 @@ const submitForm = async () => {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
if (dialogStatus.value === 'create') {
|
if (dialogStatus.value === 'create') {
|
||||||
await createBuyInbound(form)
|
// 1. 创建入库
|
||||||
|
const res: any = await createBuyInbound(form)
|
||||||
ElMessage.success('入库成功')
|
ElMessage.success('入库成功')
|
||||||
|
|
||||||
|
// 2. 自动打印 (使用返回的完整数据)
|
||||||
|
// res.data 包含了 newly created stock item, with generated ID and SKU
|
||||||
|
const newItem = res.data
|
||||||
|
if (newItem) {
|
||||||
|
ElMessage.info('正在发送打印指令...')
|
||||||
|
try {
|
||||||
|
await executePrint(newItem)
|
||||||
|
ElMessage.success('打印指令已发送')
|
||||||
|
} catch (printErr: any) {
|
||||||
|
console.error(printErr)
|
||||||
|
ElMessage.warning('入库成功,但自动打印失败:' + (printErr.msg || '未知错误'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const payload = {
|
const payload = {
|
||||||
...form,
|
...form,
|
||||||
@ -836,7 +894,6 @@ const submitForm = async () => {
|
|||||||
ElMessage.success('更新成功')
|
ElMessage.success('更新成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存供应商等信息到历史记录
|
|
||||||
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name)
|
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name)
|
||||||
saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser)
|
saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser)
|
||||||
saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
|
saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
|
||||||
@ -862,6 +919,50 @@ const handleDelete = async (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 打印逻辑 (手动 & 预览)
|
||||||
|
// ------------------------------------
|
||||||
|
const handlePrint = async (row: any) => {
|
||||||
|
printVisible.value = true
|
||||||
|
printLoading.value = true
|
||||||
|
previewUrl.value = ''
|
||||||
|
|
||||||
|
const printData = {
|
||||||
|
global_print_id: row.global_print_id,
|
||||||
|
material_name: row.material_name,
|
||||||
|
spec_model: row.spec_model,
|
||||||
|
category: row.category,
|
||||||
|
material_type: row.material_type,
|
||||||
|
warehouse_loc: row.warehouse_loc,
|
||||||
|
serial_number: row.serial_number,
|
||||||
|
batch_number: row.batch_number,
|
||||||
|
sku: row.sku // 【重要】显式增加 SKU 字段传递给后端
|
||||||
|
}
|
||||||
|
currentPrintData.value = printData
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res: any = await getLabelPreview(printData)
|
||||||
|
previewUrl.value = res.data
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('预览生成失败')
|
||||||
|
} finally {
|
||||||
|
printLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPrint = async () => {
|
||||||
|
printing.value = true
|
||||||
|
try {
|
||||||
|
await executePrint(currentPrintData.value)
|
||||||
|
ElMessage.success('指令已发送')
|
||||||
|
printVisible.value = false
|
||||||
|
} catch(e: any) {
|
||||||
|
ElMessage.error(e.msg || '打印失败')
|
||||||
|
} finally {
|
||||||
|
printing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
Object.assign(form, {
|
Object.assign(form, {
|
||||||
@ -1031,4 +1132,14 @@ onMounted(() => fetchData())
|
|||||||
.opt-name { font-weight: bold; }
|
.opt-name { font-weight: bold; }
|
||||||
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
|
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
|
||||||
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
|
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
|
||||||
|
|
||||||
|
.preview-box {
|
||||||
|
min-height: 150px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: #f5f7fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.empty-preview { color: #909399; }
|
||||||
</style>
|
</style>
|
||||||
Reference in New Issue
Block a user