From 797b61153090655a1630c723904e4d537fdfd1fa Mon Sep 17 00:00:00 2001 From: dxc Date: Wed, 4 Feb 2026 17:22:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=BA=E5=BA=93=E9=80=BB=E8=BE=91=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=EF=BC=8C=E6=89=AB=E7=A0=81=E8=AF=86=E5=88=AB=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E6=88=90=E5=8A=9F=EF=BC=8C=E5=90=8E=E7=BB=AD=E5=AF=B9?= =?UTF-8?q?=E5=BA=94=E9=80=BB=E8=BE=91=E6=B2=A1=E6=9C=89=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- inventory-backend/app/__init__.py | 20 +- inventory-backend/app/api/v1/outbound.py | 82 +++++ inventory-backend/app/models/outbound.py | 42 +++ .../app/services/outbound_service.py | 153 ++++++++++ inventory-web/package.json | 6 +- inventory-web/src/api/common/upload.ts | 19 ++ inventory-web/src/api/outbound.ts | 59 ++++ .../src/components/Signature/index.vue | 135 +++++++++ inventory-web/src/router/index.ts | 44 ++- inventory-web/src/views/outbound/create.vue | 286 ++++++++++++++++++ inventory-web/src/views/outbound/index.vue | 87 ++++++ inventory-web/vite.config.ts | 14 +- 12 files changed, 919 insertions(+), 28 deletions(-) create mode 100644 inventory-backend/app/api/v1/outbound.py create mode 100644 inventory-backend/app/models/outbound.py create mode 100644 inventory-backend/app/services/outbound_service.py create mode 100644 inventory-web/src/api/common/upload.ts create mode 100644 inventory-web/src/api/outbound.ts create mode 100644 inventory-web/src/components/Signature/index.vue create mode 100644 inventory-web/src/views/outbound/create.vue create mode 100644 inventory-web/src/views/outbound/index.vue diff --git a/inventory-backend/app/__init__.py b/inventory-backend/app/__init__.py index b5bb80a..5c9f409 100644 --- a/inventory-backend/app/__init__.py +++ b/inventory-backend/app/__init__.py @@ -43,7 +43,7 @@ def create_app(): print(f"❌ 错误: Auth 模块导入失败: {e}") # ----------------------------------------------------- - # 2.1 注册入库聚合模块 (Inbound) - 【核心修复点】 + # 2.1 注册入库聚合模块 (Inbound) # ----------------------------------------------------- try: from app.api.v1.inbound import inbound_bp @@ -79,7 +79,7 @@ def create_app(): print(f"❌ 错误: Upload 模块导入失败: {e}") # ----------------------------------------------------- - # 2.4 注册业务操作模块 (Transactions) + # 2.4 注册业务操作模块 (Transactions - 借还/维修/报废) # ----------------------------------------------------- try: from app.api.v1.transactions import trans_bp @@ -90,6 +90,19 @@ def create_app(): # 允许模块不存在时不崩溃 print(f"⚠️ 提示: Transaction 模块尚未创建或导入失败: {e}") + # ----------------------------------------------------- + # 2.5 ★ [新增] 注册出库模块 (Outbound) + # ----------------------------------------------------- + try: + from app.api.v1.outbound import outbound_bp + # 标准: /api/v1/outbound + app.register_blueprint(outbound_bp, url_prefix='/api/v1/outbound') + # 兼容: /api/outbound + app.register_blueprint(outbound_bp, url_prefix='/api/outbound', name='outbound_legacy') + print("✅ Outbound 模块注册成功") + except ImportError as e: + print(f"❌ 错误: Outbound 模块导入失败: {e}") + # ========================================================= # 3. 预加载数据模型 # ========================================================= @@ -101,6 +114,9 @@ def create_app(): from app.models.inbound.semi import StockSemi from app.models.inbound.product import StockProduct + # ★ [新增] 出库模型 (确保迁移工具能检测到 trans_outbound 表) + from app.models.outbound import TransOutbound + # 系统与业务模型 from app.models.system import SysUser, SysLog from app.models.transaction import TransBorrow, TransRepair, TransScrap diff --git a/inventory-backend/app/api/v1/outbound.py b/inventory-backend/app/api/v1/outbound.py new file mode 100644 index 0000000..079c150 --- /dev/null +++ b/inventory-backend/app/api/v1/outbound.py @@ -0,0 +1,82 @@ +from flask import Blueprint, request, jsonify +from app.services.outbound_service import OutboundService +from flask_jwt_extended import jwt_required, get_jwt_identity + +outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound') + + +# -------------------------------------------------------- +# 1. 扫码查询库存接口 +# GET /api/v1/outbound/scan?barcode=... +# -------------------------------------------------------- +@outbound_bp.route('/scan', methods=['GET']) +@jwt_required() +def scan_barcode(): + barcode = request.args.get('barcode') + if not barcode: + return jsonify({'code': 400, 'msg': '请提供条码'}), 400 + + try: + result = OutboundService.get_stock_by_barcode(barcode) + if result: + return jsonify({'code': 200, 'data': result, 'msg': '扫描成功'}) + else: + return jsonify({'code': 404, 'msg': '未找到对应的库存记录'}), 404 + except Exception as e: + return jsonify({'code': 500, 'msg': str(e)}), 500 + + +# -------------------------------------------------------- +# 2. 提交出库单接口 +# POST /api/v1/outbound +# -------------------------------------------------------- +@outbound_bp.route('', methods=['POST']) +@jwt_required() +def create_outbound(): + data = request.get_json() + if not data: + return jsonify({'code': 400, 'msg': '无有效数据'}), 400 + + current_user = get_jwt_identity() # 获取当前登录用户作为操作员 + + # 简单的必填校验 (更复杂的校验可放入 Schema) + required_fields = ['stock_id', 'source_table', 'quantity', 'consumer_name', 'signature_path'] + for field in required_fields: + if field not in data or not data[field]: + return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400 + + try: + outbound_record = OutboundService.create_outbound(data, operator_name=current_user) + return jsonify({ + 'code': 200, + 'msg': '出库成功', + 'data': outbound_record.to_dict() + }) + except ValueError as e: + return jsonify({'code': 400, 'msg': str(e)}), 400 + except Exception as e: + return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500 + + +# -------------------------------------------------------- +# 3. 获取出库记录列表 +# GET /api/v1/outbound +# -------------------------------------------------------- +@outbound_bp.route('', methods=['GET']) +@jwt_required() +def get_outbound_list(): + try: + page = int(request.args.get('page', 1)) + per_page = int(request.args.get('limit', 10)) + keyword = request.args.get('keyword', '') + # 日期范围处理可根据前端传参格式调整 + + result = OutboundService.get_list(page, per_page, keyword) + + return jsonify({ + 'code': 200, + 'msg': '获取成功', + 'data': result + }) + except Exception as e: + return jsonify({'code': 500, 'msg': str(e)}), 500 \ No newline at end of file diff --git a/inventory-backend/app/models/outbound.py b/inventory-backend/app/models/outbound.py new file mode 100644 index 0000000..e1b3cef --- /dev/null +++ b/inventory-backend/app/models/outbound.py @@ -0,0 +1,42 @@ +from app.extensions import db +from datetime import datetime + + +class TransOutbound(db.Model): + __tablename__ = 'trans_outbound' + + id = db.Column(db.Integer, primary_key=True) + outbound_no = db.Column(db.String(100), unique=True, nullable=False) # 出库单号 + + # 关联源库存信息 + sku = db.Column(db.String(100)) + source_table = db.Column(db.String(50)) # 'stock_buy', 'stock_product', etc. + stock_id = db.Column(db.Integer) # 对应源表的主键ID + barcode = db.Column(db.String(100)) # 实际扫码内容 + + # 业务信息 + outbound_type = db.Column(db.String(50), default='SALES') # SALES, USE, TRANSFER + quantity = db.Column(db.Numeric(19, 4), nullable=False) + + # 签字与追溯 + consumer_name = db.Column(db.String(100)) # 领用人/客户 + signature_path = db.Column(db.Text) # 签名图片路径 + outbound_time = db.Column(db.DateTime, default=datetime.now) + operator_name = db.Column(db.String(100)) # 操作员 + + remark = db.Column(db.Text) + + def to_dict(self): + return { + 'id': self.id, + 'outbound_no': self.outbound_no, + 'sku': self.sku, + 'source_table': self.source_table, + 'outbound_type': self.outbound_type, + 'quantity': float(self.quantity) if self.quantity else 0, + 'consumer_name': self.consumer_name, + 'signature_path': self.signature_path, + 'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None, + 'operator_name': self.operator_name, + 'remark': self.remark + } \ No newline at end of file diff --git a/inventory-backend/app/services/outbound_service.py b/inventory-backend/app/services/outbound_service.py new file mode 100644 index 0000000..44d1a92 --- /dev/null +++ b/inventory-backend/app/services/outbound_service.py @@ -0,0 +1,153 @@ +import uuid +from datetime import datetime +from sqlalchemy import or_ +from app.extensions import db +from app.models.outbound import TransOutbound + +# 导入所有库存实体模型,用于查找和扣减 +from app.models.inbound.buy import StockBuy +from app.models.inbound.semi import StockSemi +from app.models.inbound.product import StockProduct + + +class OutboundService: + + @staticmethod + def generate_outbound_no(): + """生成出库单号: OUT-yyyyMMdd-随机码""" + date_str = datetime.now().strftime('%Y%m%d') + short_uuid = uuid.uuid4().hex[:6].upper() + return f"OUT-{date_str}-{short_uuid}" + + @staticmethod + def get_stock_by_barcode(barcode): + """ + 根据条码在各个库存表中查找 + 优先级: 成品 -> 半成品 -> 采购件 + """ + if not barcode: + return None + + # 1. 查成品 + prod = StockProduct.query.filter_by(barcode=barcode).first() + if prod: + return OutboundService._format_scan_result(prod, 'stock_product', prod.sku) + + # 2. 查半成品 + semi = StockSemi.query.filter_by(barcode=barcode).first() + if semi: + return OutboundService._format_scan_result(semi, 'stock_semi', semi.sku) + + # 3. 查采购件 + buy = StockBuy.query.filter_by(barcode=barcode).first() + if buy: + # 采购件可能需要关联 material_base 获取名称,这里假设 base_id 关联已建立 + name = buy.base.name if buy.base else "未知采购件" + spec = buy.base.spec_model if buy.base else "" + return OutboundService._format_scan_result(buy, 'stock_buy', buy.sku, name, spec) + + return None + + @staticmethod + def _format_scan_result(item, table_name, sku, name=None, spec=None): + """格式化返回给前端的数据结构""" + # 如果没有传 name (例如成品/半成品),尝试通过关联获取,或者直接用 SKU 代替 + item_name = name + item_spec = spec + + if not item_name and hasattr(item, 'base') and item.base: + item_name = item.base.name + item_spec = item.base.spec_model + + return { + 'id': item.id, + 'sku': sku, + 'name': item_name or sku, + 'spec_model': item_spec or '', + 'source_table': table_name, + 'stock_quantity': float(item.stock_quantity), + 'available_quantity': float(item.available_quantity), + 'batch_number': getattr(item, 'batch_number', ''), + 'warehouse_location': getattr(item, 'warehouse_location', '') + } + + @staticmethod + def create_outbound(data, operator_name='System'): + """执行出库逻辑:扣减库存 + 记录日志""" + source_table = data.get('source_table') + stock_id = data.get('stock_id') + quantity = float(data.get('quantity', 0)) + + if quantity <= 0: + raise ValueError("出库数量必须大于0") + + # 1. 获取对应的库存记录模型 + model_map = { + 'stock_buy': StockBuy, + 'stock_semi': StockSemi, + 'stock_product': StockProduct + } + + ModelClass = model_map.get(source_table) + if not ModelClass: + raise ValueError(f"未知的库存来源表: {source_table}") + + # 2. 锁定并查询库存 (使用 with_for_update 防止并发扣减) + stock_item = ModelClass.query.with_for_update().get(stock_id) + if not stock_item: + raise ValueError("库存记录不存在") + + if stock_item.available_quantity < quantity: + raise ValueError(f"库存不足!当前可用: {stock_item.available_quantity}, 请求出库: {quantity}") + + try: + # 3. 扣减库存 + stock_item.stock_quantity -= quantity + stock_item.available_quantity -= quantity + + # 4. 创建出库记录 + new_outbound = TransOutbound( + outbound_no=OutboundService.generate_outbound_no(), + sku=data.get('sku'), + source_table=source_table, + stock_id=stock_id, + barcode=data.get('barcode'), + outbound_type=data.get('outbound_type', 'SALES'), + quantity=quantity, + consumer_name=data.get('consumer_name'), + signature_path=data.get('signature_path'), # 存储签名的 URL + operator_name=operator_name, + remark=data.get('remark') + ) + + db.session.add(new_outbound) + db.session.commit() + + return new_outbound + + except Exception as e: + db.session.rollback() + raise e + + @staticmethod + def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None): + query = TransOutbound.query.order_by(TransOutbound.outbound_time.desc()) + + if keyword: + query = query.filter(or_( + TransOutbound.outbound_no.ilike(f'%{keyword}%'), + TransOutbound.consumer_name.ilike(f'%{keyword}%'), + TransOutbound.sku.ilike(f'%{keyword}%') + )) + + if start_date and end_date: + # 假设传入的是 'YYYY-MM-DD',需要处理时间范围 + query = query.filter(TransOutbound.outbound_time.between(start_date, end_date)) + + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + return { + 'items': [item.to_dict() for item in pagination.items], + 'total': pagination.total, + 'pages': pagination.pages, + 'current_page': page + } \ No newline at end of file diff --git a/inventory-web/package.json b/inventory-web/package.json index 323e254..556813c 100644 --- a/inventory-web/package.json +++ b/inventory-web/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --host", + "dev": "vite", "build": "vue-tsc -b && vite build", "preview": "vite preview" }, @@ -12,6 +12,7 @@ "@element-plus/icons-vue": "^2.3.2", "axios": "^1.13.3", "element-plus": "^2.13.1", + "html5-qrcode": "^2.3.8", "pinia": "^3.0.4", "sass": "^1.97.3", "vue": "^3.5.24", @@ -19,6 +20,7 @@ }, "devDependencies": { "@types/node": "^24.10.1", + "@vitejs/plugin-basic-ssl": "^1.1.0", "@vitejs/plugin-vue": "^6.0.1", "@vue/tsconfig": "^0.8.1", "typescript": "~5.9.3", @@ -28,4 +30,4 @@ "overrides": { "vite": "npm:rolldown-vite@7.2.5" } -} +} \ No newline at end of file diff --git a/inventory-web/src/api/common/upload.ts b/inventory-web/src/api/common/upload.ts new file mode 100644 index 0000000..def1399 --- /dev/null +++ b/inventory-web/src/api/common/upload.ts @@ -0,0 +1,19 @@ +import request from '@/utils/request' + +/** + * 上传文件通用接口 + * @param file File 对象 + */ +export function uploadFile(file: File) { + const formData = new FormData() + formData.append('file', file) + + return request({ + url: '/api/v1/common/upload', + method: 'post', + data: formData, + headers: { + 'Content-Type': 'multipart/form-data' + } + }) +} \ No newline at end of file diff --git a/inventory-web/src/api/outbound.ts b/inventory-web/src/api/outbound.ts new file mode 100644 index 0000000..efb3271 --- /dev/null +++ b/inventory-web/src/api/outbound.ts @@ -0,0 +1,59 @@ +import request from '@/utils/request' + +export interface OutboundSubmitData { + sku: string + source_table: string + stock_id: number + barcode: string + outbound_type: string + quantity: number + consumer_name: string + signature_path: string // 上传后返回的图片路径 + remark?: string +} + +export interface ScanResult { + id: number + sku: string + name: string + spec_model: string + source_table: string // 'stock_buy' | 'stock_product' ... + stock_quantity: number + available_quantity: number + batch_number?: string + warehouse_location?: string +} + +/** + * 根据条码获取库存物品详情 + * @param barcode 扫描到的条码 + */ +export function getStockByBarcode(barcode: string) { + return request({ + url: '/api/v1/outbound/scan', + method: 'get', + params: { barcode } + }) +} + +/** + * 提交出库单 + */ +export function submitOutbound(data: OutboundSubmitData) { + return request({ + url: '/api/v1/outbound', + method: 'post', + data + }) +} + +/** + * 获取出库记录列表 + */ +export function getOutboundList(params: any) { + return request({ + url: '/api/v1/outbound', + method: 'get', + params + }) +} \ No newline at end of file diff --git a/inventory-web/src/components/Signature/index.vue b/inventory-web/src/components/Signature/index.vue new file mode 100644 index 0000000..e18bc2f --- /dev/null +++ b/inventory-web/src/components/Signature/index.vue @@ -0,0 +1,135 @@ + + + + + \ No newline at end of file diff --git a/inventory-web/src/router/index.ts b/inventory-web/src/router/index.ts index 38e986b..6a23fe8 100644 --- a/inventory-web/src/router/index.ts +++ b/inventory-web/src/router/index.ts @@ -42,11 +42,11 @@ const routes: Array = [ ] }, - // 4. 库存管理 + // 4. 库存管理 (入库) { path: '/inventory', component: Layout, - meta: { title: '库存管理', icon: 'Shop' }, + meta: { title: '入库管理', icon: 'Shop' }, // 修改标题以区分出库 redirect: '/inventory/buy', children: [ { @@ -76,11 +76,33 @@ const routes: Array = [ ] }, - // 5. 业务操作 + // 5. ★ [新增] 出库管理 + { + path: '/outbound', // 注意:这里使用了和你提供的文件路径一致的顶级路径 + component: Layout, + meta: { title: '出库管理', icon: 'Van' }, // 推荐使用 Van 图标 + redirect: '/outbound/index', + children: [ + { + path: 'create', + name: 'OutboundCreate', + component: () => import('@/views/outbound/create.vue'), + meta: { title: '扫码出库' } + }, + { + path: 'index', + name: 'OutboundList', + component: () => import('@/views/outbound/index.vue'), + meta: { title: '出库记录' } + } + ] + }, + + // 6. 业务操作 { path: '/operation', component: Layout, - meta: { title: '业务操作', icon: 'Operation' }, + meta: { title: '其他业务', icon: 'Operation' }, redirect: '/operation/borrow', children: [ { @@ -104,7 +126,7 @@ const routes: Array = [ ] }, - // 6. 系统管理 + // 7. 系统管理 { path: '/system', component: Layout, @@ -137,45 +159,35 @@ const router = createRouter({ }) // ========================================== -// [关键修改] 全局路由守卫 +// 全局路由守卫 // ========================================== router.beforeEach((to, from, next) => { const userStore = useUserStore() - // 1. 实时获取 Token (优先取 store,防止 store 未初始化取 localStorage) const token = userStore.token || localStorage.getItem('token') const userRole = userStore.role || localStorage.getItem('role') || 'user' - // 2. 如果要去的是登录页 if (to.path === '/login') { - // 如果有 Token,说明已登录,踢回首页 (防止重复登录) if (token) { next('/') } else { - // 没有 Token,允许访问登录页 next() } return } - // 3. 如果去的不是登录页,但没有 Token if (!token) { - // 强制重定向到登录页 - // 使用 replace 防止用户点击浏览器“返回”按钮时进入死循环 next({ path: '/login', replace: true }) return } - // 4. 权限判断 (已有 Token) if (to.meta.roles && Array.isArray(to.meta.roles)) { if (to.meta.roles.includes(userRole)) { next() } else { - // 权限不足,跳回首页 next('/dashboard') } } else { - // 无特殊权限要求,放行 next() } }) diff --git a/inventory-web/src/views/outbound/create.vue b/inventory-web/src/views/outbound/create.vue new file mode 100644 index 0000000..71fd2d2 --- /dev/null +++ b/inventory-web/src/views/outbound/create.vue @@ -0,0 +1,286 @@ + + + + + \ No newline at end of file diff --git a/inventory-web/src/views/outbound/index.vue b/inventory-web/src/views/outbound/index.vue new file mode 100644 index 0000000..51da8ea --- /dev/null +++ b/inventory-web/src/views/outbound/index.vue @@ -0,0 +1,87 @@ + + + \ No newline at end of file diff --git a/inventory-web/vite.config.ts b/inventory-web/vite.config.ts index 55b721c..76052f0 100644 --- a/inventory-web/vite.config.ts +++ b/inventory-web/vite.config.ts @@ -1,9 +1,13 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import basicSsl from '@vitejs/plugin-basic-ssl' // ★ [新增] 引入 SSL 插件 import { fileURLToPath, URL } from 'node:url' export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + basicSsl() // ★ [新增] 启用 HTTPS 证书生成 + ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) @@ -13,19 +17,13 @@ export default defineConfig({ // 允许局域网访问前端页面 host: '0.0.0.0', port: 5173, + https: true, // ★ [新增] 强制开启 HTTPS,否则浏览器会拦截摄像头 proxy: { // 拦截所有以 /api 开头的请求 '/api': { - // 【关键修改】 - // 你的截图显示后端容器名叫 inventory_api // 在 Docker 内部,直接用这个名字作为域名,就能找到它 target: 'http://inventory_api:8000', - changeOrigin: true, - - // 【保持注释】 - // 通常 Flask 后端都会把路由写全 (如 /api/v1/auth/login) - // 所以这里不需要 rewrite 去掉 /api,直接原样转发过去最稳妥 // rewrite: (path) => path.replace(/^\/api/, '') } }