diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index 22266b7..5f0ac1d 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -20,7 +20,9 @@ def create_app(): # 2. 注册蓝图 (Blueprints) # ========================================================= - # 注册入库聚合模块 (Inbound) + # ----------------------------------------------------- + # 2.1 注册入库聚合模块 (Inbound) + # ----------------------------------------------------- try: # 指向聚合文件: app/api/v1/inbound/__init__.py # 该文件里应该包含了 buy, semi, base, product 的聚合逻辑 @@ -29,9 +31,6 @@ def create_app(): # 注册父蓝图,路由前缀为 /api/v1/inbound # 最终路由效果: # /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') print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功") @@ -39,6 +38,22 @@ def create_app(): except ImportError as 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 找不到模型的问题) # ========================================================= @@ -50,7 +65,7 @@ def create_app(): from app.models.inbound.buy import StockBuy # 3. 半成品入库 from app.models.inbound.semi import StockSemi - # 4. 成品入库 (新增) + # 4. 成品入库 from app.models.inbound.product import StockProduct # 开发环境如果需要自动建表,可以取消注释 diff --git a/inventory-backend/app/api/v1/common/__init__.py b/inventory-backend/app/api/v1/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/inventory-backend/app/api/v1/common/print.py b/inventory-backend/app/api/v1/common/print.py new file mode 100644 index 0000000..efd20e9 --- /dev/null +++ b/inventory-backend/app/api/v1/common/print.py @@ -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 \ No newline at end of file diff --git a/inventory-backend/app/api/v1/inbound/buy.py b/inventory-backend/app/api/v1/inbound/buy.py index c1d6641..8f20fbd 100644 --- a/inventory-backend/app/api/v1/inbound/buy.py +++ b/inventory-backend/app/api/v1/inbound/buy.py @@ -1,11 +1,13 @@ +# app/api/v1/inbound/buy.py from flask import Blueprint, request, jsonify from app.services.inbound.buy_service import BuyInboundService import traceback inbound_buy_bp = Blueprint('inbound_buy', __name__) + # ------------------------------------------------------------------ -# 0. 基础物料搜索 (新增) +# 0. 基础物料搜索 # ------------------------------------------------------------------ @inbound_buy_bp.route('/search-base', methods=['GET']) def search_base(): @@ -25,6 +27,7 @@ def search_base(): traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 + # ------------------------------------------------------------------ # 1. 获取列表 # ------------------------------------------------------------------ @@ -38,8 +41,9 @@ def get_list(): except Exception as e: return jsonify({"code": 500, "msg": str(e)}), 500 + # ------------------------------------------------------------------ -# 2. 新增入库 +# 2. 新增入库 (修改:返回创建的对象数据) # ------------------------------------------------------------------ @inbound_buy_bp.route('/submit', methods=['POST']) def submit(): @@ -47,12 +51,21 @@ def submit(): data = request.get_json() if not data: 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: traceback.print_exc() return jsonify({"code": 500, "msg": str(e)}), 500 + # ------------------------------------------------------------------ # 3. 更新入库 # ------------------------------------------------------------------ @@ -65,6 +78,7 @@ def update_buy(id): except Exception as e: return jsonify({"code": 500, "msg": str(e)}), 500 + # ------------------------------------------------------------------ # 4. 删除 # ------------------------------------------------------------------ diff --git a/inventory-backend/app/models/inbound/buy.py b/inventory-backend/app/models/inbound/buy.py index bd5fc66..99833c0 100644 --- a/inventory-backend/app/models/inbound/buy.py +++ b/inventory-backend/app/models/inbound/buy.py @@ -42,7 +42,8 @@ class StockBuy(db.Model): detail_link = 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') @@ -83,5 +84,9 @@ class StockBuy(db.Model): 'purchaser_email': self.buyer_email, 'source_link': self.original_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 "" } \ No newline at end of file diff --git a/inventory-backend/app/services/inbound/buy_service.py b/inventory-backend/app/services/inbound/buy_service.py index 219b12f..ce85248 100644 --- a/inventory-backend/app/services/inbound/buy_service.py +++ b/inventory-backend/app/services/inbound/buy_service.py @@ -1,11 +1,9 @@ +# app/services/inbound/buy_service.py from app.extensions import db from app.models.inbound.buy import StockBuy -# ============================================================================== -# ✅ 修复点:基础物料模型实际位于 app/models/base.py -# ============================================================================== from app.models.base import MaterialBase from datetime import datetime -from sqlalchemy import or_, func +from sqlalchemy import or_, func, text import traceback @@ -71,13 +69,36 @@ class BuyInboundService: in_qty = float(data.get('in_quantity') 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( 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, serial_number=data.get('serial_number'), batch_number=data.get('batch_number'), - barcode=data.get('barcode'), status='在库', in_quantity=in_qty, stock_quantity=in_qty, @@ -98,6 +119,8 @@ class BuyInboundService: db.session.add(new_stock) db.session.commit() + + # 返回创建的对象实例 return new_stock except Exception as e: @@ -264,7 +287,10 @@ class BuyInboundService: 'purchaser_email': item.buyer_email, 'source_link': item.original_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) diff --git a/inventory-backend/app/services/print/label_service.py b/inventory-backend/app/services/print/label_service.py new file mode 100644 index 0000000..1ed8c27 --- /dev/null +++ b/inventory-backend/app/services/print/label_service.py @@ -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)}") \ No newline at end of file diff --git a/inventory-backend/requirements.txt b/inventory-backend/requirements.txt index 7e7294d..7a21306 100644 --- a/inventory-backend/requirements.txt +++ b/inventory-backend/requirements.txt @@ -5,4 +5,6 @@ Flask-Marshmallow==1.1.0 marshmallow-sqlalchemy==1.0.0 psycopg2-binary==2.9.9 python-dotenv==1.0.0 -flask-cors==4.0.0 \ No newline at end of file +flask-cors==4.0.0 +Pillow>=10.0.0 +python-barcode>=0.14.0 \ No newline at end of file diff --git a/inventory-backend/simhei.ttf b/inventory-backend/simhei.ttf new file mode 100644 index 0000000..3326815 Binary files /dev/null and b/inventory-backend/simhei.ttf differ diff --git a/inventory-web/src/api/common/print.ts b/inventory-web/src/api/common/print.ts new file mode 100644 index 0000000..00b74ee --- /dev/null +++ b/inventory-web/src/api/common/print.ts @@ -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 + }) +} \ No newline at end of file diff --git a/inventory-web/src/views/stock/inbound/buy.vue b/inventory-web/src/views/stock/inbound/buy.vue index 639435a..58e85f6 100644 --- a/inventory-web/src/views/stock/inbound/buy.vue +++ b/inventory-web/src/views/stock/inbound/buy.vue @@ -91,8 +91,11 @@ - +