From 7fa40115d904f7da4b6e87b85b16e44d6d1b30f7 Mon Sep 17 00:00:00 2001 From: dxc Date: Tue, 3 Feb 2026 11:16:12 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=87=E8=B4=AD=E4=BB=B6=E5=9B=BE=E5=83=8F?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=88=9D=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 12 +- inventory-backend/app/__init__.py | 31 +- inventory-backend/app/api/v1/common/upload.py | 132 +++++++ inventory-backend/app/models/inbound/buy.py | 15 +- .../app/services/inbound/buy_service.py | 25 +- inventory-web/src/api/inbound/buy.ts | 18 + inventory-web/src/views/stock/inbound/buy.vue | 368 +++++++++++++++--- 7 files changed, 510 insertions(+), 91 deletions(-) create mode 100644 inventory-backend/app/api/v1/common/upload.py diff --git a/docker-compose.yml b/docker-compose.yml index 6a5c747..80f4542 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,14 +19,15 @@ services: # --- 后端 Flask 服务 --- backend: build: - context: ./inventory-backend # 【修改】指向你的新后端目录 + context: ./inventory-backend # 指向你的新后端目录 container_name: inventory_api restart: always ports: - "8000:8000" volumes: - ./inventory-backend:/app # 挂载代码,实现热更新 - # 加上 --reload 参数,代码变了自动重启 + # 【核心修改】显式挂载 uploads 目录,确保图片持久化且宿主机可见 + - ./inventory-backend/uploads:/app/uploads command: gunicorn -c gunicorn.conf.py run:app --reload environment: # Host 必须写 'db' @@ -34,18 +35,17 @@ services: depends_on: - db - # --- 前端 Vue+Nginx 服务 --- -# --- 前端 Vue 开发服务 --- + # --- 前端 Vue 开发服务 --- frontend: build: context: ./inventory-web container_name: inventory_ui restart: always - # 【重点1】把本地代码挂载进去,实现“热更新” + # 把本地代码挂载进去,实现“热更新” volumes: - ./inventory-web:/app - /app/node_modules # 排除 node_modules,防止冲突 - # 【重点2】开发模式端口通常是 5173 + # 开发模式端口通常是 5173 ports: - "5173:5173" depends_on: diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index 5f0ac1d..c809185 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -3,7 +3,7 @@ from flask import Flask from config import Config from app.extensions import db, migrate, cors - +import os def create_app(): app = Flask(__name__) @@ -14,7 +14,8 @@ def create_app(): migrate.init_app(app, db) # 确保跨域配置 - cors.init_app(app, resources={r"/api/*": {"origins": "*"}}) + # 允许 /api/ 开头的请求跨域 + cors.init_app(app, resources={r"/*": {"origins": "*"}}) # 放宽跨域限制,防止图片访问被拦截 # ========================================================= # 2. 注册蓝图 (Blueprints) @@ -25,12 +26,9 @@ def create_app(): # ----------------------------------------------------- try: # 指向聚合文件: app/api/v1/inbound/__init__.py - # 该文件里应该包含了 buy, semi, base, product 的聚合逻辑 from app.api.v1.inbound import inbound_bp # 注册父蓝图,路由前缀为 /api/v1/inbound - # 最终路由效果: - # /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound') print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功") @@ -39,14 +37,12 @@ def create_app(): print(f"❌ 错误: Inbound 模块导入失败: {e}") # ----------------------------------------------------- - # 2.2 注册通用打印模块 (Common Print) - [新增] + # 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) 模块注册成功") @@ -54,6 +50,25 @@ def create_app(): except ImportError as e: print(f"❌ 错误: Print 模块导入失败: {e}") + # ----------------------------------------------------- + # 2.3 [新增] 注册通用上传模块 (Common Upload) + # ----------------------------------------------------- + try: + from app.api.v1.common.upload import upload_bp + + # 【核心修改】注册方式 1: 标准路径 (对应 /api/v1/common/files/xxx) + app.register_blueprint(upload_bp, url_prefix='/api/v1/common') + + # 【核心修改】注册方式 2: 兼容路径 (对应 /v1/common/files/xxx) + # 解决部分代理服务器剥离 /api 前缀导致的 404 问题 + # name='upload_fallback' 防止蓝图名称冲突 + app.register_blueprint(upload_bp, url_prefix='/v1/common', name='upload_fallback') + + print("✅ Upload (File Storage) 模块注册成功 (双路径兼容模式)") + + except ImportError as e: + print(f"❌ 错误: Upload 模块导入失败: {e}") + # ========================================================= # 3. 预加载数据模型 (解决 relationship 找不到模型的问题) # ========================================================= diff --git a/inventory-backend/app/api/v1/common/upload.py b/inventory-backend/app/api/v1/common/upload.py new file mode 100644 index 0000000..5fc6184 --- /dev/null +++ b/inventory-backend/app/api/v1/common/upload.py @@ -0,0 +1,132 @@ +# 文件路径: inventory-backend/app/api/v1/common/upload.py + +import os +import uuid +from flask import Blueprint, request, jsonify, send_from_directory + +# 定义蓝图 +upload_bp = Blueprint('upload', __name__) + + +# ========================================================= +# 配置上传路径 (核心修改:确保路径绝对准确) +# ========================================================= +# 向上寻找直到找到 inventory-backend 目录,或者默认为当前文件的上级目录的...上级 +# 这种方式比数 dirname 层级更稳健 + +def get_project_root(): + """获取项目根目录 inventory-backend""" + current_path = os.path.abspath(__file__) + # 循环向上查找,直到找到名为 inventory-backend 的目录 + # 如果你的根目录名字不是 inventory-backend,请修改这里的判断逻辑 + # 或者直接使用相对路径回退 5 层: api/v1/common -> app -> inventory-backend + base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_path))))) + return base + + +BASE_DIR = get_project_root() +UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads') + +# 允许上传的文件后缀 +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'} + + +def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + + +def ensure_upload_folder_exists(): + if not os.path.exists(UPLOAD_FOLDER): + try: + os.makedirs(UPLOAD_FOLDER) + print(f"✅ [Upload] 目录创建成功: {UPLOAD_FOLDER}") + except Exception as e: + print(f"❌ [Upload] 目录创建失败: {e}") + + +# ------------------------------------------------------------------ +# 1. 文件上传接口 +# URL: /api/v1/common/upload (POST) +# ------------------------------------------------------------------ +@upload_bp.route('/upload', methods=['POST']) +def upload_file(): + ensure_upload_folder_exists() + + if 'file' not in request.files: + return jsonify({"code": 400, "msg": "未找到文件部分"}), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({"code": 400, "msg": "未选择文件"}), 400 + + if file and allowed_file(file.filename): + try: + ext = file.filename.rsplit('.', 1)[1].lower() + new_filename = f"{uuid.uuid4().hex}.{ext}" + + save_path = os.path.join(UPLOAD_FOLDER, new_filename) + file.save(save_path) + + print(f"💾 [Upload] 文件已保存: {save_path}") + + # 生成访问 URL + # 这里的路径必须与 __init__.py 中注册的 url_prefix + 路由匹配 + file_url = f"/api/v1/common/files/{new_filename}" + + return jsonify({ + "code": 200, + "msg": "上传成功", + "data": { + "url": file_url, + "filename": new_filename + } + }) + except Exception as e: + print(f"❌ [Upload] 保存异常: {e}") + return jsonify({"code": 500, "msg": "文件保存失败"}), 500 + + return jsonify({"code": 400, "msg": "不支持的文件格式"}), 400 + + +# ------------------------------------------------------------------ +# 2. 静态文件访问接口 (回显) +# URL: /api/v1/common/files/ +# ------------------------------------------------------------------ +@upload_bp.route('/files/') +def uploaded_file(filename): + # 打印日志帮助调试 404 问题 + full_path = os.path.join(UPLOAD_FOLDER, filename) + if not os.path.exists(full_path): + print(f"❌ [File Access] 文件未找到: {full_path}") + return jsonify({"code": 404, "msg": "文件不存在"}), 404 + + return send_from_directory(UPLOAD_FOLDER, filename) + + +# ------------------------------------------------------------------ +# 3. 文件删除接口 (同步删除物理文件) +# URL: /api/v1/common/files/ (DELETE) +# ------------------------------------------------------------------ +@upload_bp.route('/files/', methods=['DELETE']) +def delete_file(filename): + try: + # 安全处理文件名 + safe_filename = os.path.basename(filename) + file_path = os.path.join(UPLOAD_FOLDER, safe_filename) + + print(f"🗑️ [Delete] 尝试删除文件: {file_path}") + + if os.path.exists(file_path): + os.remove(file_path) + print(f"✅ [Delete] 文件删除成功") + return jsonify({"code": 200, "msg": "文件已删除"}) + else: + print(f"⚠️ [Delete] 文件不存在,无需删除") + # 即使文件不存在也返回成功,保证前端流程继续 + return jsonify({"code": 200, "msg": "文件不存在或已删除"}) + + except Exception as e: + print(f"❌ [Delete] 删除异常: {e}") + return jsonify({"code": 500, "msg": f"删除失败: {str(e)}"}), 500 \ No newline at end of file diff --git a/inventory-backend/app/models/inbound/buy.py b/inventory-backend/app/models/inbound/buy.py index ee8fe0e..b9bf065 100644 --- a/inventory-backend/app/models/inbound/buy.py +++ b/inventory-backend/app/models/inbound/buy.py @@ -36,13 +36,16 @@ class StockBuy(db.Model): exchange_rate = db.Column(db.Numeric(15, 6), default=1.0) supplier_name = db.Column(db.String(255)) - buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name - buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email - original_link = db.Column(db.Text) # 对应 SQL: original_link + buyer_name = db.Column(db.String(100)) + buyer_email = db.Column(db.String(100)) + original_link = db.Column(db.Text) detail_link = db.Column(db.Text) arrival_photo = db.Column(db.Text) - # [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq) + # [新增] 检测报告图片路径 + inspection_report = db.Column(db.Text) + + # 全局打印流水号 global_print_id = db.Column(db.Integer) # 关系定义 @@ -86,7 +89,9 @@ class StockBuy(db.Model): 'detail_link': self.detail_link, 'arrival_photo': self.arrival_photo, - # [新增] 返回全局打印ID及其格式化字符串 + # [新增] 返回检测报告字段 + 'inspection_report': self.inspection_report, + 'global_print_id': self.global_print_id, 'global_print_id_str': f"{self.global_print_id:010d}" 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 4051013..2895687 100644 --- a/inventory-backend/app/services/inbound/buy_service.py +++ b/inventory-backend/app/services/inbound/buy_service.py @@ -82,9 +82,8 @@ class BuyInboundService: generated_sku = str(next_global_id).zfill(10) # ------------------------------------------------------------------ - # 3. 条码逻辑处理 (核心修改) + # 3. 条码逻辑处理 # 如果前端没传条码(barcode),则默认使用系统生成的 SKU 作为条码 - # 这样保证了条码生成依据是 "自动填写的 SKU" # ------------------------------------------------------------------ final_barcode = data.get('barcode') if not final_barcode: @@ -93,8 +92,8 @@ class BuyInboundService: new_stock = StockBuy( base_id=material.id, global_print_id=next_global_id, - sku=generated_sku, # 自动生成的SKU - barcode=final_barcode, # 如果未输入,则存入SKU值 + sku=generated_sku, + barcode=final_barcode, in_date=in_date_val, serial_number=data.get('serial_number'), @@ -114,13 +113,15 @@ class BuyInboundService: buyer_email=data.get('purchaser_email'), original_link=data.get('source_link'), detail_link=data.get('detail_link'), - arrival_photo=data.get('arrival_photo') + arrival_photo=data.get('arrival_photo'), + + # [新增] 保存检测报告字段 + inspection_report=data.get('inspection_report') ) db.session.add(new_stock) db.session.commit() - # 返回创建的对象实例 return new_stock except Exception as e: @@ -151,7 +152,10 @@ class BuyInboundService: 'exchange_rate': 'exchange_rate', 'purchaser': 'buyer_name', 'purchaser_email': 'buyer_email', - 'source_link': 'original_link' + 'source_link': 'original_link', + + # [新增] 允许更新检测报告 + 'inspection_report': 'inspection_report' } for frontend_key, db_attr in field_mapping.items(): @@ -207,7 +211,6 @@ class BuyInboundService: @staticmethod def get_list(page, limit, keyword=None): try: - # 1. 查询分页数据 query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id) if keyword: @@ -223,9 +226,6 @@ class BuyInboundService: pagination = query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit, error_out=False) - # --------------------------------------------------------------------- - # 计算总库存 (聚合) - # --------------------------------------------------------------------- current_items = pagination.items base_ids = list(set([item.base_id for item in current_items if item.base_id])) @@ -289,6 +289,9 @@ class BuyInboundService: 'detail_link': item.detail_link, 'arrival_photo': item.arrival_photo, + # [新增] 返回检测报告 + 'inspection_report': item.inspection_report, + 'global_print_id': item.global_print_id, 'global_print_id_str': f"{item.global_print_id:08d}" if item.global_print_id else "" } diff --git a/inventory-web/src/api/inbound/buy.ts b/inventory-web/src/api/inbound/buy.ts index f43146f..c4cd76d 100644 --- a/inventory-web/src/api/inbound/buy.ts +++ b/inventory-web/src/api/inbound/buy.ts @@ -42,4 +42,22 @@ export function searchMaterialBase(keyword: string) { method: 'get', params: { keyword } }) +} + +// 6. 文件上传 (用于图片/拍照) +export function uploadFile(data: FormData) { + return request({ + url: '/common/upload', // 对应后端 /api/v1/common/upload + method: 'post', + data, + headers: { 'Content-Type': 'multipart/form-data' } + }) +} + +// 7. [新增] 文件删除 +export function deleteFile(filename: string) { + return request({ + url: `/common/files/${filename}`, // 对应后端 /api/v1/common/files/ + method: 'delete' + }) } \ 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 e63e014..be0d46f 100644 --- a/inventory-web/src/views/stock/inbound/buy.vue +++ b/inventory-web/src/views/stock/inbound/buy.vue @@ -83,6 +83,25 @@ + +