Compare commits
87 Commits
master
...
18da3979a9
| Author | SHA1 | Date | |
|---|---|---|---|
| 18da3979a9 | |||
| 88d32067ae | |||
| b98f89bfe4 | |||
| c4d2e703f1 | |||
| e876505a1b | |||
| bccbeaadce | |||
| 1edd9a95c6 | |||
| 2d0593078b | |||
| a0ed92319c | |||
| d4b23790a1 | |||
| aee0fc4380 | |||
| 107c311391 | |||
| 5c8cefdb69 | |||
| 50361dba9a | |||
| eb771ec4f1 | |||
| 58f0ce48e2 | |||
| 4be42ae5f5 | |||
| 170e80e2a5 | |||
| e535a2d99c | |||
| 81c0e93d46 | |||
| c0463cb7dc | |||
| 20e4329a44 | |||
| b61072eea0 | |||
| 40abb53721 | |||
| fdf22b9973 | |||
| bdee5fb27a | |||
| 70f75cc72b | |||
| 94d3149bd9 | |||
| 6131b474a1 | |||
| 164988ab62 | |||
| 8138e8cf5f | |||
| b57e9f5bba | |||
| c06b96f149 | |||
| 03aea51e9a | |||
| 592830c213 | |||
| 89a29f0b65 | |||
| 49453d47f6 | |||
| 1b88171985 | |||
| f234ca6793 | |||
| 3f398b74e5 | |||
| 04ee938cd1 | |||
| 387c8973d6 | |||
| 489e62e55b | |||
| e027ebd4a9 | |||
| c1ddb8093f | |||
| 3f6ab3e607 | |||
| 374c4932f0 | |||
| cad5fd696c | |||
| 4f90e02dcf | |||
| 0bc47d306d | |||
| 10e53cab23 | |||
| aa40d4a6da | |||
| bf8cf37ff9 | |||
| 1e5627dc0a | |||
| 273f20f5c3 | |||
| f3b60dfc54 | |||
| 797b611530 | |||
| 596f366fc4 | |||
| c1c525b699 | |||
| ea17413bc1 | |||
| c1e08062f2 | |||
| fd5600b65b | |||
| 13590b1fac | |||
| 4aa43a0607 | |||
| 3257973820 | |||
| d084bd29dd | |||
| ba3085c1f2 | |||
| 7fa40115d9 | |||
| efcd2d923c | |||
| 98450d73f1 | |||
| 11a4e5f48a | |||
| cf6a4a8957 | |||
| a1133aac94 | |||
| 0009fe3121 | |||
| 30181fd21b | |||
| 482c5a2cb2 | |||
| 06ba2d7563 | |||
| b0df5c7458 | |||
| cd55a6aee1 | |||
| 6f4917f57e | |||
| e31ef59df0 | |||
| 87864a1c4f | |||
| 7a4ea8acfb | |||
| 9a04f65eb7 | |||
| 7a78975ce7 | |||
| 3afea217b7 | |||
| 2f8a5c55b1 |
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,3 +15,6 @@ inventory-web/*.local
|
||||
.vscode/
|
||||
.DS_Store
|
||||
*.log
|
||||
pgdata_docker/
|
||||
inventory-backend/uploads/
|
||||
.aider*
|
||||
|
||||
52
docker-compose.yml
Normal file
52
docker-compose.yml
Normal file
@ -0,0 +1,52 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# --- 数据库服务 ---
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: inventory_db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: 1234
|
||||
POSTGRES_DB: inventory_system
|
||||
volumes:
|
||||
# 数据持久化
|
||||
- ./pgdata_docker:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5434:5432"
|
||||
|
||||
# --- 后端 Flask 服务 ---
|
||||
backend:
|
||||
build:
|
||||
context: ./inventory-backend # 指向你的新后端目录
|
||||
container_name: inventory_api
|
||||
restart: always
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./inventory-backend:/app # 挂载代码,实现热更新
|
||||
# 【核心修改】显式挂载 uploads 目录,确保图片持久化且宿主机可见
|
||||
- ./inventory-backend/uploads:/app/uploads
|
||||
command: gunicorn -c gunicorn.conf.py run:app --reload
|
||||
environment:
|
||||
# Host 必须写 'db'
|
||||
DATABASE_URL: postgresql://test:1234@db:5432/inventory_system
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
# --- 前端 Vue 开发服务 ---
|
||||
frontend:
|
||||
build:
|
||||
context: ./inventory-web
|
||||
container_name: inventory_ui
|
||||
restart: always
|
||||
# 把本地代码挂载进去,实现“热更新”
|
||||
volumes:
|
||||
- ./inventory-web:/app
|
||||
- /app/node_modules # 排除 node_modules,防止冲突
|
||||
# 开发模式端口通常是 5173
|
||||
ports:
|
||||
- "5173:5173"
|
||||
depends_on:
|
||||
- backend
|
||||
6
inventory-backend/.dockerignore
Normal file
6
inventory-backend/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.env
|
||||
pgdata/
|
||||
17
inventory-backend/Dockerfile
Normal file
17
inventory-backend/Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
# 【修改】使用与你环境一致的 Python 3.8
|
||||
FROM python:3.8
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 1. 复制依赖并安装
|
||||
COPY requirements.txt .
|
||||
# 安装依赖 + gunicorn
|
||||
RUN pip install --no-cache-dir -r requirements.txt && \
|
||||
pip install --no-cache-dir gunicorn
|
||||
|
||||
# 2. 复制后端代码
|
||||
COPY . .
|
||||
|
||||
# 3. 启动命令
|
||||
# 假设你的入口文件是 run.py,实例叫 app
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:8000", "run:app"]
|
||||
0
inventory-backend/__init__.py
Normal file
0
inventory-backend/__init__.py
Normal file
@ -1,26 +1,149 @@
|
||||
# 文件路径: inventory-backend/app/__init__.py
|
||||
|
||||
from flask import Flask
|
||||
from config import Config
|
||||
from app.extensions import db, ma
|
||||
from app.extensions import db, migrate, cors, jwt
|
||||
import os
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 初始化插件
|
||||
# =========================================================
|
||||
# 1. 初始化插件
|
||||
# =========================================================
|
||||
db.init_app(app)
|
||||
ma.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
jwt.init_app(app) # 初始化 JWT
|
||||
|
||||
# 【新增关键步骤】: 显式导入 models,让 SQLAlchemy 认识所有的表
|
||||
# 必须放在 db.init_app 之后,create_all 或 蓝图注册 之前
|
||||
from app import models
|
||||
# 允许所有 /api/ 开头的请求跨域,支持 credentials
|
||||
cors.init_app(app, resources={r"/*": {"origins": "*"}}, supports_credentials=True)
|
||||
|
||||
# 注册路由蓝图
|
||||
from app.api.v1.stocks import stock_bp
|
||||
app.register_blueprint(stock_bp, url_prefix='/api/v1')
|
||||
# =========================================================
|
||||
# 2. 注册蓝图 (Blueprints)
|
||||
# ---------------------------------------------------------
|
||||
# 注意:为了解决前端请求不带 /v1 导致的 404 错误,
|
||||
# 下面的模块都采用了 "双重注册" 策略:
|
||||
# 1. 标准地址: /api/v1/...
|
||||
# 2. 兼容地址: /api/... (name 参数必须不同)
|
||||
# =========================================================
|
||||
|
||||
# 【可选】如果你没有用 Flask-Migrate,可以用下面这句话自动建表(开发阶段)
|
||||
# with app.app_context():
|
||||
# db.create_all()
|
||||
# -----------------------------------------------------
|
||||
# 2.0 注册权限与认证模块 (Auth)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.auth import auth_bp
|
||||
# 标准
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/v1/auth')
|
||||
# 兼容 (防止前端忘记写 v1)
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth', name='auth_legacy')
|
||||
print("✅ Auth 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Auth 模块导入失败: {e}")
|
||||
|
||||
return app
|
||||
# -----------------------------------------------------
|
||||
# 2.1 注册入库聚合模块 (Inbound)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.inbound import inbound_bp
|
||||
# 标准: /api/v1/inbound/base/list
|
||||
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
|
||||
|
||||
# 兼容: /api/inbound/base/list (修复前端 404)
|
||||
app.register_blueprint(inbound_bp, url_prefix='/api/inbound', name='inbound_legacy')
|
||||
print("✅ Inbound 模块注册成功 (已启用兼容模式: /api/inbound)")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Inbound 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.2 注册通用打印模块 (Common Print)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.common.print import print_bp
|
||||
app.register_blueprint(print_bp, url_prefix='/api/v1/common/print')
|
||||
app.register_blueprint(print_bp, url_prefix='/api/common/print', name='print_legacy')
|
||||
print("✅ Print 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Print 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.3 注册通用上传模块 (Common Upload)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.common.upload import upload_bp
|
||||
app.register_blueprint(upload_bp, url_prefix='/api/v1/common')
|
||||
app.register_blueprint(upload_bp, url_prefix='/api/common', name='upload_legacy')
|
||||
print("✅ Upload 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Upload 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
|
||||
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.transactions import trans_bp
|
||||
# 标准: /api/v1/transactions/borrow
|
||||
app.register_blueprint(trans_bp, url_prefix='/api/v1/transactions')
|
||||
# 兼容: /api/transactions/borrow
|
||||
app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
|
||||
print("✅ Transactions 模块注册成功")
|
||||
except ImportError as e:
|
||||
# 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题
|
||||
print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {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}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.6 注册 BOM 模块
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.bom import bom_bp
|
||||
# 标准: /api/v1/bom
|
||||
app.register_blueprint(bom_bp, url_prefix='/api/v1/bom')
|
||||
# 兼容: /api/bom
|
||||
app.register_blueprint(bom_bp, url_prefix='/api/bom', name='bom_legacy')
|
||||
print("✅ BOM 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: BOM 模块导入失败: {e}")
|
||||
|
||||
# =========================================================
|
||||
# 3. 预加载数据模型
|
||||
# =========================================================
|
||||
with app.app_context():
|
||||
try:
|
||||
# 基础与库存模型
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
# 出库模型
|
||||
from app.models.outbound import TransOutbound
|
||||
|
||||
# 系统与业务模型
|
||||
from app.models.system import SysUser, SysLog
|
||||
# 确保借还模型被加载
|
||||
from app.models.transaction import TransBorrow, TransRepair, TransScrap
|
||||
|
||||
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)
|
||||
# db.create_all()
|
||||
|
||||
except ImportError as e:
|
||||
print(f"⚠️ 模型预加载部分失败 (检查是否缺少文件): {e}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 模型预加载发生未知错误: {e}")
|
||||
|
||||
return app
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
from flask import Blueprint
|
||||
from .inbound import inbound_bp
|
||||
from .bom import bom_bp
|
||||
|
||||
v1_bp = Blueprint('v1', __name__)
|
||||
v1_bp.register_blueprint(inbound_bp, url_prefix='/inbound')
|
||||
v1_bp.register_blueprint(bom_bp, url_prefix='/bom')
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
# app/api/v1/auth.py
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required, get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login', methods=['POST'])
|
||||
def login():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'msg': '无效的请求数据'}), 400
|
||||
|
||||
if not data.get('username') or not data.get('password'):
|
||||
return jsonify({'msg': '请输入用户名和密码'}), 400
|
||||
|
||||
result = AuthService.login(data)
|
||||
|
||||
response_data = {
|
||||
'msg': '登录成功',
|
||||
'access_token': result.get('access_token'),
|
||||
'user': result.get('user')
|
||||
}
|
||||
return jsonify(response_data), 200
|
||||
|
||||
except ValueError as ve:
|
||||
return jsonify({'msg': str(ve)}), 401
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Login Failed Error: {str(e)}")
|
||||
return jsonify({'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/user/create', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_user():
|
||||
try:
|
||||
data = request.get_json()
|
||||
claims = get_jwt()
|
||||
operator_role = claims.get('role')
|
||||
|
||||
result = AuthService.create_user(data, operator_role)
|
||||
return jsonify({'msg': '用户创建成功', 'data': result}), 201
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"User Create Failed: {str(e)}")
|
||||
return jsonify({'msg': str(e)}), 400
|
||||
|
||||
|
||||
# [新增] 更新用户
|
||||
@auth_bp.route('/user/<int:user_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
def update_user(user_id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
claims = get_jwt()
|
||||
operator_role = claims.get('role')
|
||||
|
||||
result = AuthService.update_user(user_id, data, operator_role)
|
||||
return jsonify({'msg': '用户更新成功', 'data': result}), 200
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"User Update Failed: {str(e)}")
|
||||
return jsonify({'msg': str(e)}), 400
|
||||
|
||||
|
||||
@auth_bp.route('/users', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_users():
|
||||
try:
|
||||
users = AuthService.get_all_users()
|
||||
return jsonify({'msg': '获取成功', 'data': users}), 200
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Get Users Failed: {str(e)}")
|
||||
return jsonify({'msg': '获取用户列表失败'}), 500
|
||||
|
||||
|
||||
@auth_bp.route('/user/<int:user_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
def delete_user(user_id):
|
||||
try:
|
||||
claims = get_jwt()
|
||||
operator_role = claims.get('role')
|
||||
|
||||
AuthService.delete_user(user_id, operator_role)
|
||||
return jsonify({'msg': '删除成功'}), 200
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Delete User Failed: {str(e)}")
|
||||
return jsonify({'msg': str(e)}), 400
|
||||
76
inventory-backend/app/api/v1/bom.py
Normal file
76
inventory-backend/app/api/v1/bom.py
Normal file
@ -0,0 +1,76 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from app.services.bom_service import BomService
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.bom import BomTable
|
||||
from app.extensions import db
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy import distinct
|
||||
|
||||
bom_bp = Blueprint('bom', __name__)
|
||||
|
||||
@bom_bp.route('/<int:parent_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom(parent_id):
|
||||
try:
|
||||
data = BomService.get_bom_with_stock(parent_id)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
@bom_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
def save_bom():
|
||||
try:
|
||||
req_data = request.get_json()
|
||||
parent_id = req_data.get('parent_id')
|
||||
child_list = req_data.get('children', [])
|
||||
if not parent_id or not isinstance(child_list, list):
|
||||
return jsonify({'code': 400, 'msg': '参数错误'}), 400
|
||||
BomService.create_or_update_bom(parent_id, child_list)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '保存成功'
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'保存BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
@bom_bp.route('/base/list', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_material_base_list():
|
||||
"""获取所有基础物料列表,用于前端下拉框"""
|
||||
try:
|
||||
materials = MaterialBase.query.filter_by(is_enabled=True).order_by(MaterialBase.id.desc()).all()
|
||||
data = [item.to_dict() for item in materials]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取基础物料列表失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
@bom_bp.route('/parents', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_bom_parents():
|
||||
"""获取所有已定义BOM的父件物料列表"""
|
||||
try:
|
||||
subq = db.session.query(BomTable.parent_id).distinct().subquery()
|
||||
parents = MaterialBase.query.join(subq, MaterialBase.id == subq.c.parent_id).all()
|
||||
data = [item.to_dict() for item in parents]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取BOM父件列表失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
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
|
||||
131
inventory-backend/app/api/v1/common/upload.py
Normal file
131
inventory-backend/app/api/v1/common/upload.py
Normal file
@ -0,0 +1,131 @@
|
||||
# 文件路径: inventory-backend/app/api/v1/common/upload.py
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from flask import Blueprint, request, jsonify, send_from_directory
|
||||
|
||||
# 定义蓝图
|
||||
# 注意:在 app/__init__.py 或类似入口文件中,注册此蓝图时 url_prefix 通常应为 '/api/v1/common'
|
||||
upload_bp = Blueprint('upload', __name__)
|
||||
|
||||
|
||||
# =========================================================
|
||||
# 配置上传路径
|
||||
# =========================================================
|
||||
def get_project_root():
|
||||
"""获取项目根目录 inventory-backend"""
|
||||
current_path = os.path.abspath(__file__)
|
||||
# 向上回退直到找到根目录,根据你的目录结构可能需要调整层级
|
||||
# 假设结构: inventory-backend/app/api/v1/common/upload.py (回退5层)
|
||||
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', 'pdf', 'doc', 'docx', 'xls', 'xlsx'}
|
||||
|
||||
|
||||
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 (返回给前端的相对路径)
|
||||
# 前端展示时通常拼接 baseURL,或者直接使用此路径访问
|
||||
# 这里的 /api/v1/common 需与蓝图注册路径一致
|
||||
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/<filename> (GET)
|
||||
# ------------------------------------------------------------------
|
||||
@upload_bp.route('/files/<filename>', methods=['GET'])
|
||||
def uploaded_file(filename):
|
||||
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/<filename> (DELETE)
|
||||
# ------------------------------------------------------------------
|
||||
@upload_bp.route('/files/<filename>', 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
|
||||
25
inventory-backend/app/api/v1/inbound/__init__.py
Normal file
25
inventory-backend/app/api/v1/inbound/__init__.py
Normal file
@ -0,0 +1,25 @@
|
||||
from flask import Blueprint
|
||||
|
||||
# 首先创建主蓝图,避免子模块导入时出现循环依赖
|
||||
inbound_bp = Blueprint('inbound', __name__)
|
||||
|
||||
# 导入各子模块的蓝图(此时 inbound_bp 已定义,子模块可以安全导入它)
|
||||
from .buy import inbound_buy_bp
|
||||
from .semi import inbound_semi_bp
|
||||
from .base import inbound_base_bp
|
||||
from .product import inbound_product_bp
|
||||
from .inbound_summary import bp as inbound_summary_bp
|
||||
from .stock import bp as inbound_stock_bp
|
||||
|
||||
# 导入 service 模块,使其路由装饰器可以正常注册到 inbound_bp 上
|
||||
from . import service
|
||||
|
||||
# 注册子蓝图
|
||||
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
|
||||
inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi')
|
||||
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')
|
||||
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')
|
||||
inbound_bp.register_blueprint(inbound_summary_bp, url_prefix='/summary')
|
||||
inbound_bp.register_blueprint(inbound_stock_bp, url_prefix='/stock')
|
||||
|
||||
# service 模块的路由已经直接附加到 inbound_bp,无需再注册子蓝图
|
||||
93
inventory-backend/app/api/v1/inbound/base.py
Normal file
93
inventory-backend/app/api/v1/inbound/base.py
Normal file
@ -0,0 +1,93 @@
|
||||
# 文件路径: app/api/v1/inbound/base.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.base_service import MaterialBaseService
|
||||
import traceback
|
||||
|
||||
inbound_base_bp = Blueprint('stock_base', __name__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/search', methods=['GET'])
|
||||
def search_base():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = MaterialBaseService.search_material(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2. 列表接口 (GET /api/v1/inbound/base/list)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
try:
|
||||
page = request.args.get('pageNum', 1, type=int) # 前端传的是 pageNum
|
||||
limit = request.args.get('pageSize', 10, type=int)
|
||||
|
||||
# 构造筛选条件
|
||||
filters = {
|
||||
'keyword': request.args.get('keyword', ''),
|
||||
'category': request.args.get('category', ''),
|
||||
'type': request.args.get('type', ''),
|
||||
'isEnabled': request.args.get('isEnabled', None)
|
||||
}
|
||||
|
||||
result = MaterialBaseService.get_list(page, limit, filters)
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 新增接口 (POST /api/v1/inbound/base/)
|
||||
# 注意:前端 material_base.ts 可能会请求 / 或 /add,这里统一匹配
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/', methods=['POST'])
|
||||
def create():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data provided"}), 400
|
||||
|
||||
MaterialBaseService.create_material(data)
|
||||
return jsonify({"code": 200, "msg": "新增成功"})
|
||||
except ValueError as e:
|
||||
# 捕获业务逻辑验证错误 (如名称为空)
|
||||
return jsonify({"code": 400, "msg": str(e)}), 400
|
||||
except Exception as e:
|
||||
# 捕获系统错误
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": f"系统错误: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
MaterialBaseService.update_material(id, data)
|
||||
return jsonify({"code": 200, "msg": "修改成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete(id):
|
||||
try:
|
||||
MaterialBaseService.delete_material(id)
|
||||
return jsonify({"code": 200, "msg": "删除成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
113
inventory-backend/app/api/v1/inbound/buy.py
Normal file
113
inventory-backend/app/api/v1/inbound/buy.py
Normal file
@ -0,0 +1,113 @@
|
||||
# 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('stock_buy', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 基础物料搜索
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/search-base', methods=['GET'])
|
||||
def search_base():
|
||||
"""
|
||||
供前端下拉框远程搜索使用
|
||||
Query Param: keyword (名称或规格)
|
||||
"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = BuyInboundService.search_base_material(keyword)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取列表 (修改:支持状态筛选)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('pageSize', 15, type=int)
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
# 获取状态列表参数,前端传参格式: statuses=在库,借库
|
||||
statuses_str = request.args.get('statuses', '')
|
||||
statuses = statuses_str.split(',') if statuses_str else []
|
||||
|
||||
result = BuyInboundService.get_list(page, limit, keyword, statuses)
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. 新增入库
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/submit', methods=['POST'])
|
||||
def submit():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data"}), 400
|
||||
|
||||
new_stock = BuyInboundService.handle_inbound(data)
|
||||
|
||||
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. 更新入库
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update_buy(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
BuyInboundService.update_inbound(id, data)
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. 删除
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete_buy(id):
|
||||
try:
|
||||
BuyInboundService.delete_inbound(id)
|
||||
return jsonify({"code": 200, "msg": "删除成功"})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. 获取关联的出库历史
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
|
||||
def get_history(id):
|
||||
try:
|
||||
history = BuyInboundService.get_outbound_history(id)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": history
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
35
inventory-backend/app/api/v1/inbound/inbound_summary.py
Normal file
35
inventory-backend/app/api/v1/inbound/inbound_summary.py
Normal file
@ -0,0 +1,35 @@
|
||||
from flask import Blueprint, request, jsonify # .material -> .base refactor checked
|
||||
from app.services.inbound.inbound_summary_service import InboundSummaryService
|
||||
|
||||
# 定义蓝图
|
||||
bp = Blueprint('inbound_summary', __name__)
|
||||
|
||||
@bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
try:
|
||||
# 获取参数
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int) # 默认每页20
|
||||
keyword = request.args.get('keyword', '')
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
source_type = request.args.get('source_type') # 可选:筛选 specific table
|
||||
|
||||
result = InboundSummaryService.get_list(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
keyword=keyword,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
source_type=source_type
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
# 生产环境建议记录详细日志
|
||||
print(f"Inbound Summary Error: {str(e)}")
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
106
inventory-backend/app/api/v1/inbound/product.py
Normal file
106
inventory-backend/app/api/v1/inbound/product.py
Normal file
@ -0,0 +1,106 @@
|
||||
# inventory-backend/app/api/v1/inbound/product.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.product_service import ProductInboundService
|
||||
import traceback
|
||||
|
||||
inbound_product_bp = Blueprint('stock_product', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 基础物料搜索 (关键接口:配合 Service 实现自动回填)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/search-base', methods=['GET'])
|
||||
def search_base():
|
||||
"""
|
||||
对应前端 API: /inbound/product/search-base
|
||||
功能: 模糊搜索基础物料,返回 spec, unit, category, type 等详细信息
|
||||
"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
# 调用 Service 层已修复的 search_base_material 方法
|
||||
data = ProductInboundService.search_base_material(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
# 捕获异常并打印堆栈,方便调试
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取列表 (支持 status 多选筛选)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('pageSize', 15, type=int)
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
# 接收状态参数 (逗号分隔字符串 -> 列表)
|
||||
statuses_str = request.args.get('statuses', '')
|
||||
statuses = statuses_str.split(',') if statuses_str else []
|
||||
|
||||
result = ProductInboundService.get_list(page, limit, keyword, statuses)
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. 新增入库
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/submit', methods=['POST'])
|
||||
def submit():
|
||||
try:
|
||||
# 调用 Service 处理入库,获取新创建的对象
|
||||
new_stock = ProductInboundService.handle_inbound(request.get_json())
|
||||
|
||||
# 返回成功信息以及新创建的数据(包含生成的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. 更新入库
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update(id):
|
||||
try:
|
||||
ProductInboundService.update_inbound(id, request.get_json())
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. 删除
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete(id):
|
||||
try:
|
||||
ProductInboundService.delete_inbound(id)
|
||||
return jsonify({"code": 200, "msg": "删除成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. 获取出库历史
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/<int:id>/history', methods=['GET'])
|
||||
def get_history(id):
|
||||
try:
|
||||
data = ProductInboundService.get_outbound_history(id)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
120
inventory-backend/app/api/v1/inbound/semi.py
Normal file
120
inventory-backend/app/api/v1/inbound/semi.py
Normal file
@ -0,0 +1,120 @@
|
||||
# inventory-backend/app/api/v1/inbound/semi.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.semi_service import SemiInboundService
|
||||
import traceback
|
||||
|
||||
# 定义蓝图
|
||||
inbound_semi_bp = Blueprint('stock_semi', __name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 基础物料搜索 (复用逻辑)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/search-base', methods=['GET'])
|
||||
def search_base():
|
||||
"""
|
||||
供前端下拉框远程搜索使用 (搜索半成品类型的基础物料)
|
||||
Query Param: keyword (名称或规格)
|
||||
"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
# 这里复用 Service 中的搜索逻辑
|
||||
data = SemiInboundService.search_base_material(keyword)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取半成品列表
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/list', methods=['GET'])
|
||||
def get_list():
|
||||
try:
|
||||
page = request.args.get('page', 1, type=int)
|
||||
limit = request.args.get('pageSize', 15, type=int)
|
||||
# 支持按关键字搜索:BOM号、工单号、SN、批号等
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
# [修改] 获取状态列表参数
|
||||
statuses_str = request.args.get('statuses', '')
|
||||
statuses = statuses_str.split(',') if statuses_str else []
|
||||
|
||||
result = SemiInboundService.get_list(page, limit, keyword, statuses)
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. 新增半成品入库 (修改:返回创建的对象数据)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/submit', methods=['POST'])
|
||||
def submit():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data"}), 400
|
||||
|
||||
# 修改:调用 Service 处理入库,获取新创建的对象
|
||||
new_stock = SemiInboundService.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. 更新半成品入库信息
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
|
||||
def update_semi(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
SemiInboundService.update_inbound(id, data)
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. 删除半成品入库记录
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
|
||||
def delete_semi(id):
|
||||
try:
|
||||
SemiInboundService.delete_inbound(id)
|
||||
return jsonify({"code": 200, "msg": "删除成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. [新增] 获取关联出库历史
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
|
||||
def get_history(id):
|
||||
try:
|
||||
data = SemiInboundService.get_outbound_history(id)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": data
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
140
inventory-backend/app/api/v1/inbound/service.py
Normal file
140
inventory-backend/app/api/v1/inbound/service.py
Normal file
@ -0,0 +1,140 @@
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from . import inbound_bp
|
||||
from app.schemas.stock_schema import stock_service_schema
|
||||
from app.services.inbound.service_service import ServiceService
|
||||
from app.utils.decorators import role_required
|
||||
|
||||
|
||||
@inbound_bp.route('/service/search-base', methods=['GET'])
|
||||
@jwt_required()
|
||||
def search_base():
|
||||
"""搜索基础物料"""
|
||||
keyword = request.args.get('keyword', '')
|
||||
try:
|
||||
data = ServiceService.search_base_material(keyword)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'搜索基础物料失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
@inbound_bp.route('/service', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_service_list():
|
||||
"""获取服务权益列表"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
keyword = request.args.get('keyword', None)
|
||||
start_date = request.args.get('start_date', None)
|
||||
end_date = request.args.get('end_date', None)
|
||||
provider_name = request.args.get('provider_name', None)
|
||||
|
||||
try:
|
||||
result = ServiceService.get_service_list(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
keyword=keyword,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
provider_name=provider_name
|
||||
)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取服务列表失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service', methods=['POST'])
|
||||
@jwt_required()
|
||||
@role_required('admin,manager')
|
||||
def create_service():
|
||||
"""创建服务权益"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||
errors = stock_service_schema.validate(data)
|
||||
if errors:
|
||||
return jsonify({'code': 400, 'msg': '数据校验失败', 'errors': errors}), 400
|
||||
try:
|
||||
service = ServiceService.create_service(data)
|
||||
return jsonify({
|
||||
'code': 201,
|
||||
'msg': '创建成功',
|
||||
'data': stock_service_schema.dump(service)
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'创建服务权益失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_service(service_id):
|
||||
"""获取单个服务权益详情"""
|
||||
try:
|
||||
service = ServiceService.get_service(service_id)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': stock_service_schema.dump(service)
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取服务权益详情失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@role_required('admin,manager')
|
||||
def update_service(service_id):
|
||||
"""更新服务权益"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||
# 部分字段不允许更新,可在此过滤
|
||||
allowed_fields = {'sale_price', 'provider_name', 'description'}
|
||||
filtered_data = {k: v for k, v in data.items() if k in allowed_fields}
|
||||
if not filtered_data:
|
||||
return jsonify({'code': 400, 'msg': '无有效更新字段'}), 400
|
||||
try:
|
||||
service = ServiceService.update_service(service_id, filtered_data)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '更新成功',
|
||||
'data': stock_service_schema.dump(service)
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'更新服务权益失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@role_required('admin,manager')
|
||||
def delete_service(service_id):
|
||||
"""删除服务权益"""
|
||||
try:
|
||||
ServiceService.delete_service(service_id)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '删除成功'
|
||||
})
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 404, 'msg': str(e)}), 404
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'删除服务权益失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
136
inventory-backend/app/api/v1/inbound/stock.py
Normal file
136
inventory-backend/app/api/v1/inbound/stock.py
Normal file
@ -0,0 +1,136 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.extensions import db
|
||||
# ★★★ 修复点:必须引入 datetime,否则下方更新时间时会报错 500 ★★★
|
||||
from datetime import datetime
|
||||
|
||||
# 导入模型
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.stocktake import StocktakeDraft
|
||||
|
||||
# 尝试导入半成品和成品
|
||||
try:
|
||||
from app.models.inbound.semi import StockSemi
|
||||
except ImportError:
|
||||
StockSemi = None
|
||||
|
||||
try:
|
||||
from app.models.inbound.product import StockProduct
|
||||
except ImportError:
|
||||
StockProduct = None
|
||||
|
||||
from app.services.print.network_print_service import NetworkPrintService
|
||||
|
||||
bp = Blueprint('stock_ops', __name__)
|
||||
|
||||
|
||||
@bp.route('/all', methods=['GET'])
|
||||
def get_all_stock():
|
||||
"""
|
||||
获取所有库存 > 0 的物品
|
||||
"""
|
||||
try:
|
||||
# 1. 采购件
|
||||
materials = []
|
||||
if StockBuy:
|
||||
materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all()
|
||||
|
||||
# 2. 半成品
|
||||
semis = []
|
||||
if StockSemi:
|
||||
try:
|
||||
semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all()
|
||||
except Exception:
|
||||
semis = []
|
||||
|
||||
# 3. 成品
|
||||
products = []
|
||||
if StockProduct:
|
||||
try:
|
||||
products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all()
|
||||
except Exception:
|
||||
products = []
|
||||
|
||||
return jsonify({
|
||||
"materials": [item.to_dict() for item in materials],
|
||||
"semis": [item.to_dict() for item in semis],
|
||||
"products": [item.to_dict() for item in products]
|
||||
}), 200
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# --- 草稿箱接口 ---
|
||||
|
||||
@bp.route('/draft/list', methods=['GET'])
|
||||
def get_drafts():
|
||||
"""获取当前用户的盘点进度"""
|
||||
user_id = request.args.get('user_id', 'admin')
|
||||
drafts = StocktakeDraft.query.filter_by(user_id=user_id).all()
|
||||
return jsonify([d.to_dict() for d in drafts]), 200
|
||||
|
||||
|
||||
@bp.route('/draft/add', methods=['POST'])
|
||||
def add_draft():
|
||||
"""扫码同步 (支持更新数量)"""
|
||||
try:
|
||||
data = request.json
|
||||
user_id = data.get('user_id', 'admin')
|
||||
uuid = data.get('uuid')
|
||||
quantity = data.get('quantity', 1)
|
||||
|
||||
# 查找是否已存在
|
||||
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid).first()
|
||||
|
||||
if draft:
|
||||
# 如果已存在,更新数量和时间
|
||||
draft.quantity = quantity
|
||||
# ★ 修复点:这里需要 datetime 对象
|
||||
draft.scan_time = datetime.now()
|
||||
else:
|
||||
# 如果不存在,创建新的
|
||||
draft = StocktakeDraft(user_id=user_id, uuid=uuid, quantity=quantity)
|
||||
db.session.add(draft)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Saved"}), 200
|
||||
except Exception as e:
|
||||
print(f"Add Draft Error: {e}")
|
||||
return jsonify({"message": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/draft/clear', methods=['POST'])
|
||||
def clear_draft():
|
||||
"""清空进度"""
|
||||
data = request.json
|
||||
user_id = data.get('user_id', 'admin')
|
||||
|
||||
StocktakeDraft.query.filter_by(user_id=user_id).delete()
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Cleared"}), 200
|
||||
|
||||
|
||||
# --- 打印接口 ---
|
||||
|
||||
@bp.route('/print/selection', methods=['POST'])
|
||||
def print_selection():
|
||||
try:
|
||||
data = request.json
|
||||
items = data.get('items', [])
|
||||
if not items: return jsonify({"message": "未选择任何物品"}), 400
|
||||
printer = NetworkPrintService()
|
||||
success, msg = printer.print_outbound_selection(items)
|
||||
return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), 500
|
||||
|
||||
|
||||
@bp.route('/print/stocktake', methods=['POST'])
|
||||
def print_stocktake():
|
||||
try:
|
||||
data = request.json
|
||||
printer = NetworkPrintService()
|
||||
success, msg = printer.print_stocktake_report(data)
|
||||
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
|
||||
except Exception as e:
|
||||
return jsonify({"message": str(e)}), 500
|
||||
109
inventory-backend/app/api/v1/outbound.py
Normal file
109
inventory-backend/app/api/v1/outbound.py
Normal file
@ -0,0 +1,109 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.outbound_service import OutboundService
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
import traceback
|
||||
|
||||
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:
|
||||
# 调用 Service 层去三个表中查找 (Service已更新,会返回价格)
|
||||
result = OutboundService.get_stock_by_barcode(barcode)
|
||||
|
||||
if result:
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '扫描成功',
|
||||
'data': result
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'code': 404,
|
||||
'msg': '未找到对应的库存记录,请确认条码是否正确'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'扫描查询出错: {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
|
||||
|
||||
# 获取当前登录用户名 (JWT identity)
|
||||
current_user_name = get_jwt_identity()
|
||||
if not current_user_name:
|
||||
current_user_name = 'Unknown'
|
||||
|
||||
# 获取最终的操作员名称
|
||||
final_operator = data.get('operator_name')
|
||||
if not final_operator:
|
||||
final_operator = current_user_name
|
||||
|
||||
# 必填校验 (针对整个单据)
|
||||
# items 必须是列表且不为空,consumer_name 和 signature_path 必填
|
||||
if 'items' not in data or not data['items']:
|
||||
return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400
|
||||
|
||||
if not data.get('consumer_name') or not data.get('signature_path'):
|
||||
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
|
||||
|
||||
try:
|
||||
# ★ [修改] 调用批量创建服务
|
||||
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '出库成功',
|
||||
'data': {'outbound_no': outbound_no}
|
||||
})
|
||||
except ValueError as e:
|
||||
# 业务逻辑错误 (如库存不足)
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
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))
|
||||
limit = int(request.args.get('limit', 10))
|
||||
keyword = request.args.get('keyword', '')
|
||||
# 如果前端传了日期范围,可以解析处理,这里暂略
|
||||
|
||||
# ★ [修改] 调用分组查询服务
|
||||
result = OutboundService.get_grouped_list(page, limit, keyword)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
@ -1,34 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.stock_service import create_inbound_stock
|
||||
from app.schemas.stock_schema import StockBuySchema
|
||||
|
||||
stock_bp = Blueprint('stocks', __name__)
|
||||
|
||||
@stock_bp.route('/buy-inbound', methods=['POST'])
|
||||
def buy_inbound():
|
||||
"""
|
||||
采购入库接口
|
||||
POST /api/v1/buy-inbound
|
||||
Body: { "material_id": 1, "qty_inbound": 100, "price_unit": 10.5 ... }
|
||||
"""
|
||||
# 1. 接收 JSON 数据
|
||||
json_data = request.get_json()
|
||||
if not json_data:
|
||||
return jsonify({"message": "No input data provided"}), 400
|
||||
|
||||
# 2. 数据校验
|
||||
schema = StockBuySchema()
|
||||
try:
|
||||
# 这一步只做校验,不直接生成对象,因为我们要在 Service 里手动处理逻辑
|
||||
data = schema.load(json_data, partial=True)
|
||||
except Exception as e:
|
||||
return jsonify({"message": "Validation error", "errors": e.messages}), 422
|
||||
|
||||
# 3. 调用业务逻辑
|
||||
try:
|
||||
new_stock = create_inbound_stock(data)
|
||||
# 4. 返回成功结果
|
||||
result = schema.dump(new_stock)
|
||||
return jsonify({"message": "Inbound successful", "data": result}), 201
|
||||
except Exception as e:
|
||||
return jsonify({"message": "Internal Server Error", "error": str(e)}), 500
|
||||
@ -0,0 +1,58 @@
|
||||
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from app.services.trans_service import TransService
|
||||
import traceback
|
||||
|
||||
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
|
||||
|
||||
|
||||
# --- 借库接口 ---
|
||||
@trans_bp.route('/borrow', methods=['POST'])
|
||||
@jwt_required()
|
||||
def create_borrow():
|
||||
data = request.get_json()
|
||||
try:
|
||||
no = TransService.create_borrow(data)
|
||||
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --- 还库辅助:扫码查找借出记录 ---
|
||||
@trans_bp.route('/return/scan', methods=['GET'])
|
||||
@jwt_required()
|
||||
def scan_borrowed_item():
|
||||
barcode = request.args.get('barcode')
|
||||
if not barcode:
|
||||
return jsonify({'code': 400, 'msg': '无条码'}), 400
|
||||
|
||||
res = TransService.scan_for_return(barcode)
|
||||
if res:
|
||||
return jsonify({'code': 200, 'data': res})
|
||||
else:
|
||||
return jsonify({'code': 404, 'msg': '未找到该物品的未还记录'}), 404
|
||||
|
||||
|
||||
# --- 还库提交 ---
|
||||
@trans_bp.route('/return', methods=['POST'])
|
||||
@jwt_required()
|
||||
def submit_return():
|
||||
data = request.get_json()
|
||||
user = get_jwt_identity() # 库管
|
||||
try:
|
||||
TransService.process_return(data, operator_name=user)
|
||||
return jsonify({'code': 200, 'msg': '还库成功'})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --- 记录列表 ---
|
||||
@trans_bp.route('/records', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_records():
|
||||
status = request.args.get('status', 'all')
|
||||
page = int(request.args.get('page', 1))
|
||||
keyword = request.args.get('keyword', '')
|
||||
|
||||
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
|
||||
return jsonify({'code': 200, 'data': res})
|
||||
|
||||
@ -1,6 +1,28 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_marshmallow import Marshmallow
|
||||
from flask_migrate import Migrate
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager # 确保引入了 JWTManager
|
||||
|
||||
# 初始化数据库和序列化工具
|
||||
# 1. 创建扩展实例(此时未绑定具体的 App)
|
||||
db = SQLAlchemy()
|
||||
ma = Marshmallow()
|
||||
migrate = Migrate()
|
||||
cors = CORS()
|
||||
jwt = JWTManager() # 必须实例化
|
||||
|
||||
|
||||
# 2. 定义初始化函数 (供工厂函数 create_app 调用)
|
||||
def init_extensions(app):
|
||||
"""
|
||||
统一初始化所有 Flask 扩展
|
||||
"""
|
||||
# 初始化数据库
|
||||
db.init_app(app)
|
||||
|
||||
# 初始化迁移工具
|
||||
migrate.init_app(app, db)
|
||||
|
||||
# 初始化跨域设置 (允许 /api/* 路径被所有来源访问)
|
||||
cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
# 初始化 JWT (这一步至关重要,缺少它会导致 500 错误)
|
||||
jwt.init_app(app)
|
||||
@ -1,2 +1,19 @@
|
||||
from app.models.material import MaterialBase
|
||||
from app.models.stock import StockBuy
|
||||
# app/models/__init__.py
|
||||
|
||||
# 1. 基础物料 (必须先加载,因为 buy 依赖它)
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
# 2. 采购入库 (现在的类名是 StockBuy)
|
||||
from app.models.inbound.buy import StockBuy
|
||||
|
||||
# 3. 半成品入库 (如果有)
|
||||
try:
|
||||
from app.models.inbound.semi import StockSemi
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 4. 出库记录 (如果有,BuyService 用到了 TransOutbound)
|
||||
try:
|
||||
from app.models.outbound import TransOutbound
|
||||
except ImportError:
|
||||
pass
|
||||
@ -0,0 +1,73 @@
|
||||
# app/models/base.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
|
||||
class MaterialBase(db.Model):
|
||||
"""
|
||||
基础信息表模型
|
||||
对应数据库表: material_base
|
||||
"""
|
||||
__tablename__ = 'material_base'
|
||||
|
||||
# 1. 基础字段
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False, comment='名称')
|
||||
common_name = db.Column(db.String(255), comment='俗名')
|
||||
category = db.Column(db.String(100), comment='类别')
|
||||
material_type = db.Column(db.String(100), comment='类型')
|
||||
spec_model = db.Column(db.String(255), comment='规格型号')
|
||||
unit = db.Column(db.String(50), comment='计量单位')
|
||||
|
||||
# 可见等级
|
||||
visibility_level = db.Column(db.Integer, default=0, comment='信息可见等级')
|
||||
|
||||
# 链接与图片 (现在存储 JSON 字符串)
|
||||
manual_link = db.Column(db.Text, comment='通用说明书')
|
||||
product_image = db.Column(db.Text, comment='通用产品图')
|
||||
|
||||
# 启用状态
|
||||
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
|
||||
# ============================================================
|
||||
# 关联关系区域
|
||||
# ============================================================
|
||||
|
||||
# 1. 关联采购库存 (StockBuy) - 修改 back_populates 为 'base'
|
||||
stock_buys = db.relationship('StockBuy', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 2. 关联半成品库存 (StockSemi) - 修改 back_populates 为 'base'
|
||||
stock_semis = db.relationship('StockSemi', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 3. 关联成品库存 (StockProduct) - 修改 back_populates 为 'base'
|
||||
stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic')
|
||||
|
||||
def to_dict(self):
|
||||
"""
|
||||
序列化方法
|
||||
"""
|
||||
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
|
||||
def parse_list(json_str):
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL),则包装成 list
|
||||
if not json_str.startswith('['):
|
||||
return [json_str]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
return []
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'commonName': self.common_name,
|
||||
'category': self.category,
|
||||
'type': self.material_type,
|
||||
'spec': self.spec_model,
|
||||
'unit': self.unit,
|
||||
'visibilityLevel': self.visibility_level,
|
||||
# 修改:解析为列表返回
|
||||
'generalManual': parse_list(self.manual_link),
|
||||
'generalImage': parse_list(self.product_image),
|
||||
'isEnabled': 1 if self.is_enabled else 0,
|
||||
}
|
||||
17
inventory-backend/app/models/bom.py
Normal file
17
inventory-backend/app/models/bom.py
Normal file
@ -0,0 +1,17 @@
|
||||
from app.extensions import db
|
||||
|
||||
class BomTable(db.Model):
|
||||
__tablename__ = 'bom_table'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
child_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
bom_no = db.Column(db.String(100), comment='BOM编号')
|
||||
version = db.Column(db.String(50), comment='版本')
|
||||
dosage = db.Column(db.Numeric(19, 4), comment='个数')
|
||||
loss_rate = db.Column(db.Numeric(5, 2), comment='损耗率%(已废弃)', default=0, nullable=True)
|
||||
remark = db.Column(db.Text, comment='备注')
|
||||
|
||||
# relationships
|
||||
parent = db.relationship('MaterialBase', foreign_keys=[parent_id], backref='bom_parents')
|
||||
child = db.relationship('MaterialBase', foreign_keys=[child_id], backref='bom_children')
|
||||
0
inventory-backend/app/models/inbound/__init__.py
Normal file
0
inventory-backend/app/models/inbound/__init__.py
Normal file
114
inventory-backend/app/models/inbound/buy.py
Normal file
114
inventory-backend/app/models/inbound/buy.py
Normal file
@ -0,0 +1,114 @@
|
||||
# inventory-backend/app/models/inbound/buy.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
# 显式导入 MaterialBase 以防 relationship 找不到引用
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
class StockBuy(db.Model):
|
||||
"""
|
||||
采购入库库存表
|
||||
对应数据库表: stock_buy
|
||||
"""
|
||||
__tablename__ = 'stock_buy'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
|
||||
# 身份标识
|
||||
sku = db.Column(db.String(100))
|
||||
in_date = db.Column(db.DateTime)
|
||||
barcode = db.Column(db.String(100))
|
||||
serial_number = db.Column(db.String(100))
|
||||
batch_number = db.Column(db.String(100))
|
||||
|
||||
# 状态
|
||||
status = db.Column(db.String(50), default='在库')
|
||||
inspection_status = db.Column(db.String(50))
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
|
||||
# 数量
|
||||
in_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
stock_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
# 财务与商务
|
||||
unit_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
total_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
currency = db.Column(db.String(20), default='CNY')
|
||||
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
|
||||
detail_link = db.Column(db.Text)
|
||||
|
||||
# 图片字段 (存储 JSON 字符串)
|
||||
arrival_photo = db.Column(db.Text)
|
||||
# [新增] 检测报告图片路径 (存储 JSON 字符串)
|
||||
inspection_report = db.Column(db.Text)
|
||||
|
||||
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq)
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义 [已修改]
|
||||
base = db.relationship('MaterialBase', back_populates='stock_buys')
|
||||
|
||||
def to_dict(self):
|
||||
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
|
||||
def parse_img_list(json_str):
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL),则包装成 list
|
||||
if not json_str.startswith('['):
|
||||
return [json_str]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
return []
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
# [已修改] 使用 self.base
|
||||
'material_name': self.base.name if self.base else '',
|
||||
'spec_model': self.base.spec_model if self.base else '',
|
||||
'category': self.base.category if self.base else '',
|
||||
'unit': self.base.unit if self.base else '',
|
||||
'material_type': self.base.material_type if self.base else '',
|
||||
|
||||
'sku': self.sku,
|
||||
'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '',
|
||||
'barcode': self.barcode,
|
||||
'serial_number': self.serial_number,
|
||||
'batch_number': self.batch_number,
|
||||
'warehouse_loc': self.warehouse_location,
|
||||
'status': self.status,
|
||||
'inspection_status': self.inspection_status,
|
||||
|
||||
'in_quantity': float(self.in_quantity or 0),
|
||||
'qty_inbound': float(self.in_quantity or 0),
|
||||
'stock_quantity': float(self.stock_quantity or 0),
|
||||
'qty_stock': float(self.stock_quantity or 0),
|
||||
'available_quantity': float(self.available_quantity or 0),
|
||||
'qty_available': float(self.available_quantity or 0),
|
||||
|
||||
'unit_price': float(self.unit_price or 0),
|
||||
'total_price': float(self.total_price or 0),
|
||||
'currency': self.currency,
|
||||
'exchange_rate': float(self.exchange_rate or 1.0),
|
||||
|
||||
'supplier_name': self.supplier_name,
|
||||
'purchaser': self.buyer_name,
|
||||
'purchaser_email': self.buyer_email,
|
||||
'source_link': self.original_link,
|
||||
'detail_link': self.detail_link,
|
||||
|
||||
# [修改] 解析为数组返回给前端
|
||||
'arrival_photo': parse_img_list(self.arrival_photo),
|
||||
'inspection_report': parse_img_list(self.inspection_report),
|
||||
|
||||
# [新增] 返回全局打印ID及其格式化字符串
|
||||
'global_print_id': self.global_print_id,
|
||||
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
|
||||
}
|
||||
131
inventory-backend/app/models/inbound/product.py
Normal file
131
inventory-backend/app/models/inbound/product.py
Normal file
@ -0,0 +1,131 @@
|
||||
# app/models/inbound/product.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
class StockProduct(db.Model):
|
||||
"""
|
||||
成品入库库存表
|
||||
对应数据库表: stock_product
|
||||
"""
|
||||
__tablename__ = 'stock_product'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
|
||||
# 身份标识
|
||||
sku = db.Column(db.String(100))
|
||||
production_date = db.Column(db.DateTime)
|
||||
barcode = db.Column(db.String(100))
|
||||
serial_number = db.Column(db.String(100))
|
||||
|
||||
# 数量
|
||||
in_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
stock_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
# 状态与位置
|
||||
status = db.Column(db.String(50))
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
|
||||
# 生产与成本
|
||||
bom_code = db.Column('bom_id', db.String(100))
|
||||
bom_version = db.Column(db.String(50))
|
||||
work_order_code = db.Column('work_order_id', db.String(100))
|
||||
|
||||
raw_material_cost = db.Column(db.Numeric(19, 4), default=0)
|
||||
manual_cost = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
production_manager = db.Column('producer_name', db.String(100))
|
||||
production_time_range = db.Column(db.String(255))
|
||||
|
||||
# 质量与检测 (均为 JSON 存储)
|
||||
quality_status = db.Column(db.String(50))
|
||||
quality_report_link = db.Column(db.Text) # 质量报告
|
||||
inspection_report_link = db.Column(db.Text) # 检测报告(旧字段升级为JSON)
|
||||
|
||||
# [新增] 成品实拍图 (JSON 存储)
|
||||
product_photo = db.Column(db.Text)
|
||||
|
||||
detail_link = db.Column(db.Text)
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
# 销售相关
|
||||
sale_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
order_id = db.Column(db.String(100))
|
||||
|
||||
# 全局打印流水号
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义 [已修改]
|
||||
base = db.relationship('MaterialBase', back_populates='stock_products')
|
||||
|
||||
def to_dict(self):
|
||||
raw_val = float(self.raw_material_cost or 0)
|
||||
man_val = float(self.manual_cost or 0)
|
||||
unit_total = raw_val + man_val
|
||||
|
||||
# 辅助解析函数
|
||||
def parse_img_list(json_str):
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
if not json_str.startswith('['):
|
||||
return [json_str] # 兼容旧数据单链接
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
return []
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
# [已修改] 使用 self.base
|
||||
'material_name': self.base.name if self.base else '',
|
||||
'spec_model': self.base.spec_model if self.base else '',
|
||||
'category': self.base.category if self.base else '',
|
||||
'unit': self.base.unit if self.base else '',
|
||||
'material_type': self.base.material_type if self.base else '',
|
||||
|
||||
'sku': self.sku,
|
||||
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
|
||||
'barcode': self.barcode,
|
||||
'serial_number': self.serial_number,
|
||||
'warehouse_loc': self.warehouse_location,
|
||||
'status': self.status,
|
||||
|
||||
'in_quantity': float(self.in_quantity or 0),
|
||||
'qty_inbound': float(self.in_quantity or 0),
|
||||
'stock_quantity': float(self.stock_quantity or 0),
|
||||
'qty_stock': float(self.stock_quantity or 0),
|
||||
'available_quantity': float(self.available_quantity or 0),
|
||||
'qty_available': float(self.available_quantity or 0),
|
||||
|
||||
'bom_code': self.bom_code,
|
||||
'bom_version': self.bom_version,
|
||||
'work_order_code': self.work_order_code,
|
||||
'raw_material_cost': raw_val,
|
||||
'manual_cost': man_val,
|
||||
'unit_total_cost': unit_total,
|
||||
'production_manager': self.production_manager,
|
||||
'production_time_range': self.production_time_range,
|
||||
'production_start_time': self.production_time_range.split(' ~ ')[
|
||||
0] if self.production_time_range and ' ~ ' in self.production_time_range else '',
|
||||
'production_end_time': self.production_time_range.split(' ~ ')[
|
||||
1] if self.production_time_range and ' ~ ' in self.production_time_range else '',
|
||||
|
||||
'quality_status': self.quality_status,
|
||||
|
||||
# [核心修改] 三个图片/链接字段全部解析为数组
|
||||
'product_photo': parse_img_list(self.product_photo),
|
||||
'quality_report_link': parse_img_list(self.quality_report_link),
|
||||
'inspection_report_link': parse_img_list(self.inspection_report_link),
|
||||
|
||||
'detail_link': self.detail_link,
|
||||
'remark': self.remark,
|
||||
|
||||
'sale_price': float(self.sale_price or 0),
|
||||
'order_id': self.order_id,
|
||||
|
||||
'global_print_id': self.global_print_id,
|
||||
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
|
||||
}
|
||||
127
inventory-backend/app/models/inbound/semi.py
Normal file
127
inventory-backend/app/models/inbound/semi.py
Normal file
@ -0,0 +1,127 @@
|
||||
# app/models/inbound/semi.py
|
||||
from app.extensions import db
|
||||
import json
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
class StockSemi(db.Model):
|
||||
"""
|
||||
半成品入库库存表
|
||||
"""
|
||||
__tablename__ = 'stock_semi'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
|
||||
sku = db.Column(db.String(100))
|
||||
production_date = db.Column(db.DateTime)
|
||||
barcode = db.Column(db.String(100))
|
||||
serial_number = db.Column(db.String(100))
|
||||
batch_number = db.Column(db.String(100))
|
||||
|
||||
# 数量
|
||||
in_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
stock_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
available_quantity = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
# 状态与位置
|
||||
status = db.Column(db.String(50))
|
||||
warehouse_location = db.Column(db.String(100))
|
||||
|
||||
# 半成品特有字段
|
||||
bom_code = db.Column('bom_id', db.String(100))
|
||||
bom_version = db.Column(db.String(50))
|
||||
work_order_code = db.Column('work_order_id', db.String(100))
|
||||
|
||||
raw_material_cost = db.Column(db.Numeric(19, 4), default=0)
|
||||
manual_cost = db.Column(db.Numeric(19, 4), default=0)
|
||||
total_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
production_manager = db.Column('producer_name', db.String(100))
|
||||
production_start_time = db.Column(db.DateTime)
|
||||
production_end_time = db.Column(db.DateTime)
|
||||
production_time_range = db.Column(db.String(255))
|
||||
|
||||
quality_status = db.Column(db.String(50))
|
||||
|
||||
# [修改] 质量报告 (存储 JSON 字符串: 图片列表 + 链接)
|
||||
quality_report_link = db.Column(db.Text)
|
||||
|
||||
# [新增] 到货图片 (存储 JSON 字符串)
|
||||
arrival_photo = db.Column(db.Text)
|
||||
|
||||
detail_link = db.Column(db.Text)
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
# [新增] 全局打印流水号
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义 [已修改]
|
||||
base = db.relationship('MaterialBase', back_populates='stock_semis')
|
||||
|
||||
def to_dict(self):
|
||||
raw_val = float(self.raw_material_cost or 0)
|
||||
man_val = float(self.manual_cost or 0)
|
||||
unit_total = raw_val + man_val
|
||||
|
||||
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
|
||||
def parse_img_list(json_str):
|
||||
if not json_str:
|
||||
return []
|
||||
try:
|
||||
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL),则包装成 list
|
||||
if not json_str.startswith('['):
|
||||
return [json_str]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
return []
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
# [已修改] 使用 self.base
|
||||
'material_name': self.base.name if self.base else '',
|
||||
'spec_model': self.base.spec_model if self.base else '',
|
||||
'category': self.base.category if self.base else '',
|
||||
'unit': self.base.unit if self.base else '',
|
||||
'material_type': self.base.material_type if self.base else '',
|
||||
|
||||
'sku': self.sku,
|
||||
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
|
||||
'barcode': self.barcode,
|
||||
'serial_number': self.serial_number,
|
||||
'batch_number': self.batch_number,
|
||||
'warehouse_loc': self.warehouse_location,
|
||||
'status': self.status,
|
||||
|
||||
'in_quantity': float(self.in_quantity or 0),
|
||||
'qty_inbound': float(self.in_quantity or 0),
|
||||
'stock_quantity': float(self.stock_quantity or 0),
|
||||
'qty_stock': float(self.stock_quantity or 0),
|
||||
'available_quantity': float(self.available_quantity or 0),
|
||||
'qty_available': float(self.available_quantity or 0),
|
||||
|
||||
'bom_code': self.bom_code,
|
||||
'bom_version': self.bom_version,
|
||||
'work_order_code': self.work_order_code,
|
||||
'raw_material_cost': raw_val,
|
||||
'manual_cost': man_val,
|
||||
'unit_total_cost': unit_total,
|
||||
'total_price': float(self.total_price or 0),
|
||||
|
||||
'production_manager': self.production_manager,
|
||||
'production_time_range': self.production_time_range,
|
||||
'production_start_time': str(self.production_start_time) if self.production_start_time else '',
|
||||
'production_end_time': str(self.production_end_time) if self.production_end_time else '',
|
||||
|
||||
'quality_status': self.quality_status,
|
||||
|
||||
# [修改] 解析 JSON 字符串为数组返回给前端
|
||||
'quality_report_link': parse_img_list(self.quality_report_link),
|
||||
'arrival_photo': parse_img_list(self.arrival_photo),
|
||||
|
||||
'detail_link': self.detail_link,
|
||||
'remark': self.remark,
|
||||
|
||||
'global_print_id': self.global_print_id,
|
||||
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
|
||||
}
|
||||
46
inventory-backend/app/models/inbound/service.py
Normal file
46
inventory-backend/app/models/inbound/service.py
Normal file
@ -0,0 +1,46 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StockService(db.Model):
|
||||
"""
|
||||
服务权益库存表
|
||||
对应数据库表: stock_service
|
||||
"""
|
||||
__tablename__ = 'stock_service'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
# 关联基础物料信息
|
||||
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
# 系统生成的SKU,格式 SRV-YYYYMMDD-XXXX
|
||||
sku = db.Column(db.String(64), unique=True, nullable=False)
|
||||
# 售价
|
||||
sale_price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
# 服务商名称
|
||||
provider_name = db.Column(db.String(255), nullable=False, default='')
|
||||
# 服务详情/简介
|
||||
description = db.Column(db.Text, default='')
|
||||
# 创建时间与更新时间
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now, nullable=False)
|
||||
# 软删除标志
|
||||
is_deleted = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# 关系(可选)
|
||||
material_base = db.relationship('MaterialBase', backref='service_stocks', lazy='joined')
|
||||
|
||||
def to_dict(self):
|
||||
"""转为字典,用于 API 响应"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
'sku': self.sku,
|
||||
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
|
||||
'provider_name': self.provider_name,
|
||||
'description': self.description,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None,
|
||||
'material_name': self.material_base.name if self.material_base else None,
|
||||
'spec_model': self.material_base.spec_model if self.material_base else None,
|
||||
'unit': self.material_base.unit if self.material_base else None,
|
||||
}
|
||||
22
inventory-backend/app/models/inbound/stocktake.py
Normal file
22
inventory-backend/app/models/inbound/stocktake.py
Normal file
@ -0,0 +1,22 @@
|
||||
from app.extensions import db # .material -> .base refactor checked
|
||||
from datetime import datetime
|
||||
|
||||
class StocktakeDraft(db.Model):
|
||||
__tablename__ = 'stocktake_draft'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.String(100), default='admin')
|
||||
uuid = db.Column(db.String(100))
|
||||
# ★ 新增 quantity 字段
|
||||
quantity = db.Column(db.Numeric(19, 4), default=1)
|
||||
scan_time = db.Column(db.DateTime, default=datetime.now)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'uuid': self.uuid,
|
||||
# ★ 返回 quantity
|
||||
'quantity': float(self.quantity or 1),
|
||||
'scan_time': self.scan_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
from app.extensions import db
|
||||
|
||||
class MaterialBase(db.Model):
|
||||
__tablename__ = 'material_base'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
sku_code = db.Column(db.String(100), unique=True, nullable=False)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
spec_model = db.Column(db.String(255))
|
||||
unit = db.Column(db.String(50))
|
||||
# 其他字段按需添加,入库时主要是为了外键关联
|
||||
47
inventory-backend/app/models/outbound.py
Normal file
47
inventory-backend/app/models/outbound.py
Normal file
@ -0,0 +1,47 @@
|
||||
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), nullable=False)
|
||||
|
||||
# 关联源库存信息
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50)) # 'stock_buy', 'stock_product', 'stock_semi'
|
||||
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)
|
||||
|
||||
# [新增] 出库时的单价,用于计算金额
|
||||
unit_price = db.Column(db.Numeric(19, 2), default=0)
|
||||
|
||||
# 签字与追溯
|
||||
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,
|
||||
'unit_price': float(self.unit_price) if self.unit_price 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
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StockBuy(db.Model):
|
||||
__tablename__ = 'stock_buy'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
material_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
|
||||
inbound_date = db.Column(db.DateTime, default=datetime.now)
|
||||
barcode = db.Column(db.String(100))
|
||||
batch_no = db.Column(db.String(100))
|
||||
|
||||
# 数量相关 (使用 Numeric 对应数据库的 NUMERIC)
|
||||
qty_inbound = db.Column(db.Numeric(19, 4), default=0)
|
||||
qty_current = db.Column(db.Numeric(19, 4), default=0)
|
||||
qty_available = db.Column(db.Numeric(19, 4), default=0)
|
||||
|
||||
price_unit = db.Column(db.Numeric(19, 4), default=0)
|
||||
price_total = db.Column(db.Numeric(19, 4), default=0)
|
||||
supplier_name = db.Column(db.String(255))
|
||||
warehouse_loc = db.Column(db.String(100))
|
||||
|
||||
# 建立关联,方便查询物料详情
|
||||
material = db.relationship('MaterialBase', backref='buy_stocks')
|
||||
@ -0,0 +1,65 @@
|
||||
# app/models/system.py
|
||||
from app.extensions import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
class SysUser(db.Model):
|
||||
__tablename__ = 'sys_user'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), nullable=False)
|
||||
# 注意:如果允许邮箱为空,建议去掉 unique=True 或者在数据库层面处理空字符串
|
||||
email = db.Column(db.String(100), unique=True)
|
||||
department = db.Column(db.String(100))
|
||||
role = db.Column(db.String(50))
|
||||
status = db.Column(db.String(20), default='active')
|
||||
password_hash = db.Column(db.Text)
|
||||
created_at = db.Column(db.DateTime, default=datetime.now) # 新增创建时间
|
||||
|
||||
def set_password(self, password):
|
||||
"""生成加密密码"""
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
"""验证密码"""
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def to_dict(self):
|
||||
"""序列化为字典,供接口返回使用"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'department': self.department,
|
||||
'role': self.role,
|
||||
'status': self.status,
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else ''
|
||||
}
|
||||
|
||||
class SysLog(db.Model):
|
||||
"""
|
||||
系统操作日志表
|
||||
对应数据库表: sys_log
|
||||
"""
|
||||
__tablename__ = 'sys_log'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
op_time = db.Column(db.DateTime, default=datetime.now)
|
||||
op_user_name = db.Column(db.String(100))
|
||||
op_user_id = db.Column(db.String(50))
|
||||
module_name = db.Column(db.String(100))
|
||||
action_type = db.Column(db.String(50))
|
||||
target_table = db.Column(db.String(100))
|
||||
target_id = db.Column(db.Integer)
|
||||
description = db.Column(db.Text)
|
||||
ip_address = db.Column(db.String(50))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'op_time': self.op_time.isoformat() if self.op_time else None,
|
||||
'op_user_name': self.op_user_name,
|
||||
'module_name': self.module_name,
|
||||
'action_type': self.action_type,
|
||||
'description': self.description
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class TransBorrow(db.Model):
|
||||
__tablename__ = 'trans_borrow'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
borrow_no = db.Column(db.String(100))
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50))
|
||||
stock_id = db.Column(db.Integer)
|
||||
barcode = db.Column(db.String(100))
|
||||
quantity = db.Column(db.Numeric(19, 4))
|
||||
borrower_name = db.Column(db.String(100))
|
||||
borrow_time = db.Column(db.DateTime, default=datetime.now)
|
||||
borrow_signature = db.Column(db.Text)
|
||||
expected_return_time = db.Column(db.DateTime)
|
||||
is_returned = db.Column(db.Boolean, default=False)
|
||||
return_time = db.Column(db.DateTime)
|
||||
return_operator = db.Column(db.String(100))
|
||||
return_signature = db.Column(db.Text)
|
||||
return_location = db.Column(db.String(100))
|
||||
status = db.Column(db.String(20), default='borrowed')
|
||||
remark = db.Column(db.Text)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'borrow_no': self.borrow_no,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'barcode': self.barcode,
|
||||
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||
'borrower_name': self.borrower_name,
|
||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else None,
|
||||
'borrow_signature': self.borrow_signature,
|
||||
'expected_return_time': self.expected_return_time.strftime('%Y-%m-%d %H:%M') if self.expected_return_time else None,
|
||||
'is_returned': self.is_returned,
|
||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else None,
|
||||
'return_operator': self.return_operator,
|
||||
'return_signature': self.return_signature,
|
||||
'return_location': self.return_location,
|
||||
'status': self.status,
|
||||
'remark': self.remark,
|
||||
}
|
||||
|
||||
|
||||
class TransRepair(db.Model):
|
||||
__tablename__ = 'trans_repair'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50))
|
||||
stock_id = db.Column(db.Integer)
|
||||
arrival_date = db.Column(db.Date)
|
||||
expected_repair_time = db.Column(db.String(100))
|
||||
shipping_date = db.Column(db.Date)
|
||||
is_self_made = db.Column(db.Boolean, default=False)
|
||||
related_product_id = db.Column(db.Integer)
|
||||
related_contract_id = db.Column(db.String(100))
|
||||
repair_manager = db.Column(db.String(100))
|
||||
fault_description = db.Column(db.Text)
|
||||
repair_result = db.Column(db.Text)
|
||||
cost_price = db.Column(db.Numeric(19, 4))
|
||||
sale_price = db.Column(db.Numeric(19, 4))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'arrival_date': self.arrival_date.strftime('%Y-%m-%d') if self.arrival_date else None,
|
||||
'expected_repair_time': self.expected_repair_time,
|
||||
'shipping_date': self.shipping_date.strftime('%Y-%m-%d') if self.shipping_date else None,
|
||||
'is_self_made': self.is_self_made,
|
||||
'related_product_id': self.related_product_id,
|
||||
'related_contract_id': self.related_contract_id,
|
||||
'repair_manager': self.repair_manager,
|
||||
'fault_description': self.fault_description,
|
||||
'repair_result': self.repair_result,
|
||||
'cost_price': float(self.cost_price) if self.cost_price is not None else None,
|
||||
'sale_price': float(self.sale_price) if self.sale_price is not None else None,
|
||||
}
|
||||
|
||||
|
||||
class TransScrap(db.Model):
|
||||
__tablename__ = 'trans_scrap'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
sku = db.Column(db.String(100))
|
||||
source_table = db.Column(db.String(50))
|
||||
stock_id = db.Column(db.Integer)
|
||||
quantity = db.Column(db.Numeric(19, 4))
|
||||
reason = db.Column(db.Text)
|
||||
operator_name = db.Column(db.String(100))
|
||||
operation_time = db.Column(db.DateTime, default=datetime.now)
|
||||
approver_name = db.Column(db.String(100))
|
||||
approval_status = db.Column(db.String(20), default='pending')
|
||||
cost_at_scrap = db.Column(db.Numeric(19, 4))
|
||||
total_loss = db.Column(db.Numeric(19, 4))
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'sku': self.sku,
|
||||
'source_table': self.source_table,
|
||||
'stock_id': self.stock_id,
|
||||
'quantity': float(self.quantity) if self.quantity is not None else None,
|
||||
'reason': self.reason,
|
||||
'operator_name': self.operator_name,
|
||||
'operation_time': self.operation_time.strftime('%Y-%m-%d %H:%M:%S') if self.operation_time else None,
|
||||
'approver_name': self.approver_name,
|
||||
'approval_status': self.approval_status,
|
||||
'cost_at_scrap': float(self.cost_at_scrap) if self.cost_at_scrap is not None else None,
|
||||
'total_loss': float(self.total_loss) if self.total_loss is not None else None,
|
||||
}
|
||||
|
||||
@ -1,14 +1,65 @@
|
||||
from app.extensions import ma
|
||||
from app.models.stock import StockBuy
|
||||
from marshmallow import fields
|
||||
from marshmallow import Schema, fields, validate, validates_schema, ValidationError
|
||||
|
||||
class StockBuySchema(ma.SQLAlchemyAutoSchema):
|
||||
class Meta:
|
||||
model = StockBuy
|
||||
load_instance = True # 反序列化时自动创建模型实例
|
||||
include_fk = True # 包含外键 material_id
|
||||
|
||||
# 必须字段校验
|
||||
material_id = fields.Integer(required=True)
|
||||
qty_inbound = fields.Decimal(required=True, as_string=True)
|
||||
price_unit = fields.Decimal(missing=0, as_string=True)
|
||||
class StockBuySchema(Schema):
|
||||
# 只用于输出的字段
|
||||
id = fields.Int(dump_only=True)
|
||||
|
||||
# --- 输入字段 ---
|
||||
|
||||
# 1. 核心识别字段
|
||||
material_id = fields.Int(missing=None) # 如果是老物料,可能传ID
|
||||
sku_code = fields.Str(required=True, error_messages={"required": "SKU编码是必填项"}) # 必填
|
||||
|
||||
# 2. 新物料自动建档字段 (如果是新SKU,这些需要校验)
|
||||
material_name = fields.Str(missing=None)
|
||||
spec_model = fields.Str(missing=None)
|
||||
unit = fields.Str(missing=None)
|
||||
category = fields.Str(missing=None)
|
||||
|
||||
# 3. 入库业务字段
|
||||
qty_inbound = fields.Float(required=True, validate=validate.Range(min=0.0001, error="入库数量必须大于0"))
|
||||
price_unit = fields.Float(missing=0)
|
||||
|
||||
inbound_date = fields.DateTime(format='%Y-%m-%d %H:%M:%S')
|
||||
batch_no = fields.Str(missing='')
|
||||
warehouse_loc = fields.Str(missing='')
|
||||
supplier_name = fields.Str(missing='')
|
||||
|
||||
@validates_schema
|
||||
def validate_material_logic(self, data, **kwargs):
|
||||
"""
|
||||
自定义校验逻辑:
|
||||
如果用户没传 material_id,说明可能想新建物料。
|
||||
虽然最终是否新建由 Service 层判断数据库决定,
|
||||
但这里可以做一个弱校验:尽量让用户填上名字。
|
||||
"""
|
||||
pass
|
||||
# 这里暂时不强制抛出错误,交给 Service 层处理 "SKU不存在且无名字" 的情况
|
||||
|
||||
|
||||
class StockServiceSchema(Schema):
|
||||
# 只用于输出的字段
|
||||
id = fields.Int(dump_only=True)
|
||||
sku = fields.Str(dump_only=True)
|
||||
created_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
|
||||
updated_at = fields.DateTime(format='%Y-%m-%d %H:%M:%S', dump_only=True)
|
||||
material_name = fields.Str(dump_only=True)
|
||||
spec_model = fields.Str(dump_only=True)
|
||||
unit = fields.Str(dump_only=True)
|
||||
|
||||
# 输入字段
|
||||
base_id = fields.Int(required=True, error_messages={"required": "必须选择基础物料"})
|
||||
sale_price = fields.Float(required=True, validate=validate.Range(min=0, error="售价不能为负数"))
|
||||
provider_name = fields.Str(required=True, error_messages={"required": "服务商名称不能为空"})
|
||||
description = fields.Str(missing='')
|
||||
|
||||
@validates_schema
|
||||
def validate_base_id(self, data, **kwargs):
|
||||
# 可以在这里添加对 base_id 是否存在的检查,但更建议在 Service 层进行
|
||||
pass
|
||||
|
||||
|
||||
# 实例化 Schema
|
||||
stock_buy_schema = StockBuySchema()
|
||||
stock_service_schema = StockServiceSchema()
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
# app/services/auth_service.py
|
||||
from app.models.system import SysUser
|
||||
from app.extensions import db
|
||||
from flask_jwt_extended import create_access_token
|
||||
from app.utils.constants import UserRole
|
||||
|
||||
|
||||
class AuthService:
|
||||
# 硬编码的超级管理员凭证
|
||||
SUPER_ADMIN_USER = "IRIS"
|
||||
SUPER_ADMIN_PASS = "licahk"
|
||||
|
||||
@staticmethod
|
||||
def login(data):
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
user_role = None
|
||||
user_id = None
|
||||
user_info = {}
|
||||
|
||||
# 1. 优先检查硬编码的超级管理员
|
||||
if username == AuthService.SUPER_ADMIN_USER:
|
||||
if password == AuthService.SUPER_ADMIN_PASS:
|
||||
user_role = UserRole.SUPER_ADMIN
|
||||
user_id = 0 # 虚拟ID
|
||||
user_info = {
|
||||
'username': username,
|
||||
'role': user_role,
|
||||
'department': 'System'
|
||||
}
|
||||
else:
|
||||
raise ValueError("密码错误")
|
||||
|
||||
# 2. 如果不是 IRIS,检查数据库用户
|
||||
else:
|
||||
user = SysUser.query.filter_by(username=username).first()
|
||||
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
if not user.check_password(password):
|
||||
raise ValueError("密码错误")
|
||||
|
||||
if user.status != 'active':
|
||||
raise ValueError("账号已被禁用,请联系管理员")
|
||||
|
||||
user_role = user.role
|
||||
user_id = user.id
|
||||
user_info = user.to_dict()
|
||||
|
||||
# 3. 生成 Token
|
||||
access_token = create_access_token(
|
||||
identity=user_id,
|
||||
additional_claims={'role': user_role, 'username': username}
|
||||
)
|
||||
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'user': user_info
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_user(data, operator_role):
|
||||
"""
|
||||
创建新用户 (仅限管理员使用)
|
||||
"""
|
||||
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
|
||||
|
||||
if SysUser.query.filter_by(username=data.get('username')).first():
|
||||
raise Exception("用户名已存在")
|
||||
|
||||
role = data.get('role')
|
||||
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
|
||||
if role not in valid_roles:
|
||||
raise Exception(f"角色无效,可选角色: {valid_roles}")
|
||||
|
||||
email = data.get('email', '')
|
||||
if email and SysUser.query.filter_by(email=email).first():
|
||||
raise Exception("邮箱已被使用")
|
||||
|
||||
new_user = SysUser(
|
||||
username=data.get('username'),
|
||||
email=email,
|
||||
department=data.get('department', ''),
|
||||
role=role,
|
||||
status='active'
|
||||
)
|
||||
new_user.set_password(data.get('password'))
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
return new_user.to_dict()
|
||||
|
||||
@staticmethod
|
||||
def update_user(user_id, data, operator_role):
|
||||
"""
|
||||
[新增] 更新用户信息
|
||||
"""
|
||||
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足:只有超级管理员或主管可以修改用户信息")
|
||||
|
||||
user = SysUser.query.get(user_id)
|
||||
if not user:
|
||||
raise Exception("用户不存在")
|
||||
|
||||
# 1. 更新基本信息
|
||||
if 'role' in data:
|
||||
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
|
||||
if data['role'] not in valid_roles:
|
||||
raise Exception(f"角色无效")
|
||||
user.role = data['role']
|
||||
|
||||
if 'department' in data:
|
||||
user.department = data['department']
|
||||
|
||||
if 'email' in data:
|
||||
# 如果修改了邮箱,且新邮箱已被其他人使用
|
||||
email = data['email']
|
||||
if email and email != user.email:
|
||||
existing = SysUser.query.filter_by(email=email).first()
|
||||
if existing:
|
||||
raise Exception("该邮箱已被其他用户使用")
|
||||
user.email = email
|
||||
|
||||
# 2. 如果提供了密码,则重置密码;否则保持原密码
|
||||
new_password = data.get('password')
|
||||
if new_password and str(new_password).strip():
|
||||
if len(new_password) < 6:
|
||||
raise Exception("密码长度至少6位")
|
||||
user.set_password(new_password)
|
||||
|
||||
db.session.commit()
|
||||
return user.to_dict()
|
||||
|
||||
@staticmethod
|
||||
def get_all_users():
|
||||
"""获取所有系统用户"""
|
||||
users = SysUser.query.order_by(SysUser.id.desc()).all()
|
||||
return [user.to_dict() for user in users]
|
||||
|
||||
@staticmethod
|
||||
def delete_user(user_id, operator_role):
|
||||
"""删除用户"""
|
||||
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足")
|
||||
|
||||
user = SysUser.query.get(user_id)
|
||||
if not user:
|
||||
raise Exception("用户不存在")
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return True
|
||||
67
inventory-backend/app/services/bom_service.py
Normal file
67
inventory-backend/app/services/bom_service.py
Normal file
@ -0,0 +1,67 @@
|
||||
from app.extensions import db
|
||||
from app.models.bom import BomTable
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from sqlalchemy import func
|
||||
|
||||
class BomService:
|
||||
@staticmethod
|
||||
def create_or_update_bom(parent_id, child_list):
|
||||
"""
|
||||
保存/更新父件的BOM子件关系
|
||||
child_list: [{"child_id": int, "dosage": float, "remark": str}, ...]
|
||||
"""
|
||||
# 校验父件不能与子件相同
|
||||
for item in child_list:
|
||||
if item['child_id'] == parent_id:
|
||||
raise ValueError('父件与子件不能是同一物料')
|
||||
# 删除该父件原有的BOM记录
|
||||
BomTable.query.filter_by(parent_id=parent_id).delete()
|
||||
# 插入新的
|
||||
for item in child_list:
|
||||
bom = BomTable(
|
||||
parent_id=parent_id,
|
||||
child_id=item['child_id'],
|
||||
dosage=item.get('dosage', 0),
|
||||
remark=item.get('remark', '')
|
||||
)
|
||||
db.session.add(bom)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_bom_with_stock(parent_id):
|
||||
"""
|
||||
查询父件的BOM结构及库存信息
|
||||
"""
|
||||
bom_items = db.session.query(
|
||||
BomTable,
|
||||
MaterialBase.name.label('child_name')
|
||||
).join(
|
||||
MaterialBase, BomTable.child_id == MaterialBase.id
|
||||
).filter(
|
||||
BomTable.parent_id == parent_id
|
||||
).all()
|
||||
|
||||
result = []
|
||||
for bom, child_name in bom_items:
|
||||
# 查询该子件在 StockBuy 中的可用库存总量
|
||||
stock_qty = db.session.query(
|
||||
func.coalesce(func.sum(StockBuy.available_quantity), 0)
|
||||
).filter(
|
||||
StockBuy.base_id == bom.child_id
|
||||
).scalar() or 0
|
||||
|
||||
# 计算最大可生产数量
|
||||
dosage = float(bom.dosage) if bom.dosage else 0
|
||||
max_producible = int(stock_qty // dosage) if dosage > 0 else 0
|
||||
|
||||
result.append({
|
||||
'child_id': bom.child_id,
|
||||
'child_name': child_name,
|
||||
'dosage': dosage,
|
||||
'current_stock': float(stock_qty),
|
||||
'max_producible': max_producible,
|
||||
'remark': bom.remark or ''
|
||||
})
|
||||
return result
|
||||
0
inventory-backend/app/services/inbound/__init__.py
Normal file
0
inventory-backend/app/services/inbound/__init__.py
Normal file
195
inventory-backend/app/services/inbound/base_service.py
Normal file
195
inventory-backend/app/services/inbound/base_service.py
Normal file
@ -0,0 +1,195 @@
|
||||
# 文件路径: app/services/inbound/base_service.py
|
||||
|
||||
from app.extensions import db
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from sqlalchemy import or_
|
||||
import traceback
|
||||
import json
|
||||
|
||||
|
||||
class MaterialBaseService:
|
||||
"""
|
||||
基础物料服务层
|
||||
负责处理 MaterialBase 的增删改查及搜索逻辑
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def search_material(keyword):
|
||||
"""
|
||||
根据关键字搜索已启用的基础物料
|
||||
(供 /api/v1/inbound/base/search 接口调用)
|
||||
"""
|
||||
try:
|
||||
if not keyword:
|
||||
return []
|
||||
|
||||
query = MaterialBase.query.filter(
|
||||
MaterialBase.is_enabled == True,
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.common_name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
)
|
||||
).limit(20)
|
||||
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'commonName': item.common_name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'status': '启用'
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_list(page, limit, filters=None):
|
||||
"""
|
||||
获取基础信息列表 (带分页和筛选)
|
||||
"""
|
||||
try:
|
||||
query = MaterialBase.query
|
||||
|
||||
if filters:
|
||||
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号)
|
||||
if filters.get('keyword'):
|
||||
kw = f"%{filters['keyword']}%"
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.common_name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw)
|
||||
))
|
||||
|
||||
# 2. 精确筛选
|
||||
if filters.get('category'):
|
||||
query = query.filter_by(category=filters['category'])
|
||||
|
||||
if filters.get('type'):
|
||||
query = query.filter_by(material_type=filters['type'])
|
||||
|
||||
if filters.get('isEnabled') is not None:
|
||||
# 前端传 1/0,转为 Boolean
|
||||
is_active = bool(int(filters['isEnabled']))
|
||||
query = query.filter_by(is_enabled=is_active)
|
||||
|
||||
# 按 ID 倒序排列
|
||||
pagination = query.order_by(MaterialBase.id.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||
|
||||
items = [item.to_dict() for item in pagination.items]
|
||||
return {"total": pagination.total, "items": items}
|
||||
|
||||
except Exception as e:
|
||||
print(f"查询基础信息列表失败: {e}")
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def create_material(data):
|
||||
"""新增基础信息"""
|
||||
try:
|
||||
# 0. 基础校验
|
||||
if not data.get('name') or not data.get('spec'):
|
||||
raise ValueError("名称和规格型号不能为空")
|
||||
|
||||
# 1. 查重
|
||||
exist = MaterialBase.query.filter_by(
|
||||
name=data['name'],
|
||||
spec_model=data['spec']
|
||||
).first()
|
||||
if exist:
|
||||
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
|
||||
|
||||
# 2. 创建对象 (列表转JSON字符串)
|
||||
new_material = MaterialBase(
|
||||
name=data['name'],
|
||||
common_name=data.get('commonName'),
|
||||
spec_model=data['spec'],
|
||||
category=data.get('category'),
|
||||
material_type=data.get('type'),
|
||||
unit=data.get('unit'),
|
||||
visibility_level=data.get('visibilityLevel'),
|
||||
# 修改:将列表 dumps 为字符串
|
||||
manual_link=json.dumps(data.get('generalManual', [])),
|
||||
product_image=json.dumps(data.get('generalImage', [])),
|
||||
is_enabled=True if data.get('isEnabled', 1) == 1 else False
|
||||
)
|
||||
|
||||
db.session.add(new_material)
|
||||
db.session.commit()
|
||||
return new_material
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def update_material(m_id, data):
|
||||
"""修改基础信息"""
|
||||
try:
|
||||
material = MaterialBase.query.get(m_id)
|
||||
if not material:
|
||||
raise ValueError("数据不存在")
|
||||
|
||||
# 更新字段
|
||||
if 'name' in data: material.name = data['name']
|
||||
if 'commonName' in data: material.common_name = data['commonName']
|
||||
if 'spec' in data: material.spec_model = data['spec']
|
||||
if 'category' in data: material.category = data['category']
|
||||
if 'type' in data: material.material_type = data['type']
|
||||
if 'unit' in data: material.unit = data['unit']
|
||||
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
|
||||
|
||||
# 修改:将列表 dumps 为字符串
|
||||
if 'generalManual' in data:
|
||||
material.manual_link = json.dumps(data['generalManual'])
|
||||
if 'generalImage' in data:
|
||||
material.product_image = json.dumps(data['generalImage'])
|
||||
|
||||
if 'isEnabled' in data:
|
||||
material.is_enabled = bool(int(data['isEnabled']))
|
||||
|
||||
db.session.commit()
|
||||
return material
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def delete_material(m_id):
|
||||
"""
|
||||
删除基础信息 (带依赖检查)
|
||||
"""
|
||||
try:
|
||||
material = MaterialBase.query.get(m_id)
|
||||
if not material:
|
||||
raise ValueError("数据不存在")
|
||||
|
||||
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
|
||||
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
|
||||
total_usage = buy_usage_count + semi_usage_count
|
||||
|
||||
if total_usage > 0:
|
||||
raise ValueError(
|
||||
f"无法删除:该基础物料正被使用中。\n"
|
||||
f"- 采购库存记录: {buy_usage_count} 条\n"
|
||||
f"- 半成品库存记录: {semi_usage_count} 条\n"
|
||||
f"请先清理相关库存或仅‘禁用’此条目。"
|
||||
)
|
||||
|
||||
db.session.delete(material)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"删除基础信息失败: {e}")
|
||||
raise e
|
||||
348
inventory-backend/app/services/inbound/buy_service.py
Normal file
348
inventory-backend/app/services/inbound/buy_service.py
Normal file
@ -0,0 +1,348 @@
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
# 尝试导入出库模型,如果不存在则忽略
|
||||
try:
|
||||
from app.models.outbound import TransOutbound
|
||||
except ImportError:
|
||||
TransOutbound = None
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import or_, func, text, and_
|
||||
import traceback
|
||||
import json
|
||||
|
||||
|
||||
class BuyInboundService:
|
||||
|
||||
# ============================================================
|
||||
# 0. 辅助:唯一性校验 (核心修复)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||
"""
|
||||
校验序列号和批号的唯一性逻辑
|
||||
:param base_id: 当前物料的基础ID
|
||||
:param serial_number: 序列号
|
||||
:param batch_number: 批号
|
||||
:param exclude_id: 排除的ID (用于编辑模式)
|
||||
"""
|
||||
# 1. 序列号 (SN) 全局唯一校验
|
||||
# 解释: 不同规格的物料通常也不应该有相同的SN,防止扫码混淆
|
||||
if serial_number:
|
||||
query = StockBuy.query.filter(StockBuy.serial_number == serial_number)
|
||||
if exclude_id:
|
||||
query = query.filter(StockBuy.id != exclude_id)
|
||||
|
||||
exists = query.first()
|
||||
if exists:
|
||||
# [修改] 获取占用该SN的物料名称 (material -> base)
|
||||
occupied_name = exists.base.name if exists.base else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
# 2. 批号 (BN) 同物料唯一校验
|
||||
# 解释: 不同规格的物料可以有相同的批号(如都有 001 批次),但同一个物料不能重复建单
|
||||
if batch_number and base_id:
|
||||
query = StockBuy.query.filter(
|
||||
StockBuy.base_id == base_id,
|
||||
StockBuy.batch_number == batch_number
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(StockBuy.id != exclude_id)
|
||||
|
||||
if query.first():
|
||||
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复录入,可直接在该批次下追加库存。")
|
||||
|
||||
# ============================================================
|
||||
# 1. 基础物料搜索
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def search_base_material(keyword):
|
||||
try:
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
MaterialBase.pinyin.ilike(f'%{keyword}%') # 假设有拼音搜索
|
||||
)
|
||||
)
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model, # 确保这里字段对应正确
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'status': '启用'
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def handle_inbound(data):
|
||||
try:
|
||||
base_id = data.get('base_id')
|
||||
if not base_id:
|
||||
raise ValueError("必须选择基础物料")
|
||||
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material:
|
||||
raise ValueError("所选物料不存在")
|
||||
|
||||
# --- [修复点] 执行唯一性校验 ---
|
||||
BuyInboundService._check_unique(
|
||||
base_id=base_id,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number')
|
||||
)
|
||||
|
||||
# 时间处理 (强制北京时间)
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
in_date_val = current_time
|
||||
|
||||
if data.get('in_date'):
|
||||
try:
|
||||
date_str = str(data['in_date'])
|
||||
if len(date_str) > 10:
|
||||
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
|
||||
current_time.hour, current_time.minute, current_time.second)
|
||||
except:
|
||||
in_date_val = current_time
|
||||
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
u_price = float(data.get('unit_price') or 0)
|
||||
|
||||
# 获取全局打印ID
|
||||
try:
|
||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||
result = db.session.execute(seq_sql)
|
||||
next_global_id = result.scalar()
|
||||
except:
|
||||
next_global_id = None
|
||||
|
||||
# SKU 生成
|
||||
if next_global_id:
|
||||
generated_sku = str(next_global_id).zfill(10)
|
||||
else:
|
||||
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
|
||||
final_barcode = data.get('barcode') or generated_sku
|
||||
|
||||
arrival_list = data.get('arrival_photo', [])
|
||||
report_list = data.get('inspection_report', [])
|
||||
|
||||
new_stock = StockBuy(
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=generated_sku,
|
||||
barcode=final_barcode,
|
||||
in_date=in_date_val,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number'),
|
||||
status=data.get('status', '在库'),
|
||||
in_quantity=in_qty,
|
||||
stock_quantity=in_qty, # 初始库存等于入库数
|
||||
available_quantity=in_qty,
|
||||
inspection_status=data.get('inspection_status', '未检'),
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
unit_price=u_price,
|
||||
total_price=in_qty * u_price,
|
||||
currency=data.get('currency', 'CNY'),
|
||||
exchange_rate=data.get('exchange_rate', 1.0),
|
||||
supplier_name=data.get('supplier_name'),
|
||||
buyer_name=data.get('purchaser'),
|
||||
buyer_email=data.get('purchaser_email'),
|
||||
original_link=data.get('source_link'),
|
||||
detail_link=data.get('detail_link'),
|
||||
arrival_photo=json.dumps(arrival_list),
|
||||
inspection_report=json.dumps(report_list)
|
||||
)
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
return new_stock
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 3. 更新入库逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def update_inbound(stock_id, data):
|
||||
try:
|
||||
stock = StockBuy.query.get(stock_id)
|
||||
if not stock:
|
||||
raise ValueError("记录不存在")
|
||||
|
||||
# --- [修复点] 编辑时也要校验唯一性 (排除自身ID) ---
|
||||
# 如果修改了物料(base_id),或者修改了SN/BN,都需要校验
|
||||
new_base_id = data.get('base_id', stock.base_id)
|
||||
new_sn = data.get('serial_number', stock.serial_number)
|
||||
new_bn = data.get('batch_number', stock.batch_number)
|
||||
|
||||
BuyInboundService._check_unique(
|
||||
base_id=new_base_id,
|
||||
serial_number=new_sn,
|
||||
batch_number=new_bn,
|
||||
exclude_id=stock_id
|
||||
)
|
||||
|
||||
# 更新字段
|
||||
field_mapping = {
|
||||
'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
|
||||
'warehouse_location': 'warehouse_location',
|
||||
'serial_number': 'serial_number', 'batch_number': 'batch_number',
|
||||
'status': 'status', 'inspection_status': 'inspection_status',
|
||||
'supplier_name': 'supplier_name', 'detail_link': 'detail_link',
|
||||
'currency': 'currency', 'exchange_rate': 'exchange_rate',
|
||||
'purchaser': 'buyer_name', 'purchaser_email': 'buyer_email',
|
||||
'source_link': 'original_link'
|
||||
}
|
||||
for k, v in field_mapping.items():
|
||||
if k in data: setattr(stock, v, data[k])
|
||||
|
||||
if 'arrival_photo' in data and isinstance(data['arrival_photo'], list):
|
||||
stock.arrival_photo = json.dumps(data['arrival_photo'])
|
||||
if 'inspection_report' in data and isinstance(data['inspection_report'], list):
|
||||
stock.inspection_report = json.dumps(data['inspection_report'])
|
||||
|
||||
# 库存数量变更逻辑
|
||||
if 'in_quantity' in data:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
if diff != 0:
|
||||
stock.in_quantity = new_qty
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
if 'unit_price' in data:
|
||||
stock.unit_price = float(data['unit_price'])
|
||||
|
||||
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
|
||||
db.session.commit()
|
||||
return stock
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 4. 删除逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def delete_inbound(stock_id):
|
||||
try:
|
||||
stock = StockBuy.query.get(stock_id)
|
||||
if not stock: raise ValueError("记录不存在")
|
||||
db.session.delete(stock)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 5. 获取列表
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None):
|
||||
try:
|
||||
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
StockBuy.batch_number.ilike(kw),
|
||||
StockBuy.serial_number.ilike(kw),
|
||||
StockBuy.sku.ilike(kw),
|
||||
StockBuy.supplier_name.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
if not statuses:
|
||||
statuses = ['在库', '借库']
|
||||
|
||||
if '已出库' in statuses:
|
||||
query = query.filter(StockBuy.status.in_(statuses))
|
||||
else:
|
||||
query = query.filter(and_(StockBuy.status.in_(statuses), StockBuy.stock_quantity > 0))
|
||||
|
||||
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||
current_items = pagination.items
|
||||
|
||||
def parse_img(json_str):
|
||||
if not json_str: return []
|
||||
try:
|
||||
return json.loads(json_str) if json_str.startswith('[') else [json_str]
|
||||
except:
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in current_items:
|
||||
qty_stock = float(item.stock_quantity or 0)
|
||||
qty_avail = float(item.available_quantity or 0)
|
||||
|
||||
date_display = ''
|
||||
if item.in_date:
|
||||
try:
|
||||
date_display = item.in_date.strftime('%Y-%m-%d')
|
||||
except:
|
||||
date_display = str(item.in_date)[:10]
|
||||
|
||||
d = {
|
||||
'id': item.id,
|
||||
'base_id': item.base_id,
|
||||
# [核心修改] 确保这里从关联的 .base 获取信息
|
||||
'material_name': item.base.name if item.base else '',
|
||||
'spec_model': item.base.spec_model if item.base else '',
|
||||
'category': item.base.category if item.base else '',
|
||||
'unit': item.base.unit if item.base else '',
|
||||
'material_type': item.base.material_type if item.base else '',
|
||||
|
||||
'sku': item.sku,
|
||||
'inbound_date': date_display,
|
||||
'barcode': item.barcode,
|
||||
'serial_number': item.serial_number,
|
||||
'batch_number': item.batch_number,
|
||||
'status': item.status,
|
||||
'inspection_status': item.inspection_status,
|
||||
'qty_inbound': float(item.in_quantity or 0),
|
||||
'qty_stock': qty_stock,
|
||||
'qty_available': qty_avail,
|
||||
'warehouse_loc': item.warehouse_location,
|
||||
'unit_price': float(item.unit_price or 0),
|
||||
'total_price': float(item.total_price or 0),
|
||||
'currency': item.currency,
|
||||
'exchange_rate': float(item.exchange_rate or 1),
|
||||
'supplier_name': item.supplier_name,
|
||||
'purchaser': item.buyer_name,
|
||||
'purchaser_email': item.buyer_email,
|
||||
'source_link': item.original_link,
|
||||
'detail_link': item.detail_link,
|
||||
'arrival_photo': parse_img(item.arrival_photo),
|
||||
'inspection_report': parse_img(item.inspection_report),
|
||||
'global_print_id': item.global_print_id
|
||||
}
|
||||
items.append(d)
|
||||
|
||||
return {"total": pagination.total, "items": items}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
@ -0,0 +1,190 @@
|
||||
from sqlalchemy import select, literal, union_all, desc, asc, func, or_, cast, String, Numeric, Date # .material -> .base refactor checked
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from app.models.base import MaterialBase
|
||||
import traceback
|
||||
|
||||
|
||||
class InboundSummaryService:
|
||||
|
||||
@staticmethod
|
||||
def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None, source_type=None):
|
||||
"""
|
||||
聚合查询:
|
||||
1. 联合 StockBuy, StockSemi, StockProduct 三张表
|
||||
2. 关联 MaterialBase 获取名称规格
|
||||
3. 计算动态状态 (库存耗尽显示已出库)
|
||||
4. 排序:默认按入库日期倒序 (最近的在前)
|
||||
"""
|
||||
try:
|
||||
# =========================================================
|
||||
# 1. 构建三个子查询 (Subqueries)
|
||||
# =========================================================
|
||||
|
||||
# --- A. 采购件 (StockBuy) ---
|
||||
q_buy = db.session.query(
|
||||
StockBuy.id.label('id'),
|
||||
StockBuy.base_id.label('base_id'),
|
||||
StockBuy.sku.label('sku'),
|
||||
StockBuy.in_date.label('inbound_date'),
|
||||
StockBuy.in_quantity.label('in_qty'),
|
||||
StockBuy.stock_quantity.label('current_qty'),
|
||||
|
||||
cast(StockBuy.supplier_name, String).label('source_info'),
|
||||
StockBuy.status.label('orig_status'),
|
||||
cast(StockBuy.batch_number, String).label('batch_number'),
|
||||
cast(literal('buy'), String).label('source_type')
|
||||
)
|
||||
|
||||
# --- B. 半成品 (StockSemi) ---
|
||||
q_semi = db.session.query(
|
||||
StockSemi.id.label('id'),
|
||||
StockSemi.base_id.label('base_id'),
|
||||
StockSemi.sku.label('sku'),
|
||||
StockSemi.production_date.label('inbound_date'),
|
||||
StockSemi.in_quantity.label('in_qty'),
|
||||
StockSemi.stock_quantity.label('current_qty'),
|
||||
|
||||
cast(StockSemi.production_manager, String).label('source_info'),
|
||||
StockSemi.status.label('orig_status'),
|
||||
cast(StockSemi.batch_number, String).label('batch_number'),
|
||||
cast(literal('semi'), String).label('source_type')
|
||||
)
|
||||
|
||||
# --- C. 成品 (StockProduct) ---
|
||||
q_product = db.session.query(
|
||||
StockProduct.id.label('id'),
|
||||
StockProduct.base_id.label('base_id'),
|
||||
StockProduct.sku.label('sku'),
|
||||
StockProduct.production_date.label('inbound_date'),
|
||||
StockProduct.in_quantity.label('in_qty'),
|
||||
StockProduct.stock_quantity.label('current_qty'),
|
||||
|
||||
cast(StockProduct.production_manager, String).label('source_info'),
|
||||
StockProduct.status.label('orig_status'),
|
||||
cast(StockProduct.serial_number, String).label('batch_number'),
|
||||
cast(literal('product'), String).label('source_type')
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 2. 组合查询 (UNION ALL)
|
||||
# =========================================================
|
||||
combined_query = union_all(q_buy, q_semi, q_product)
|
||||
cte = combined_query.subquery()
|
||||
|
||||
# =========================================================
|
||||
# 3. 主查询:关联 MaterialBase
|
||||
# =========================================================
|
||||
query = db.session.query(
|
||||
cte,
|
||||
MaterialBase.name.label('material_name'),
|
||||
MaterialBase.spec_model.label('spec_model'),
|
||||
MaterialBase.category.label('category'),
|
||||
MaterialBase.material_type.label('material_type')
|
||||
).outerjoin(
|
||||
MaterialBase, cte.c.base_id == MaterialBase.id
|
||||
)
|
||||
|
||||
# =========================================================
|
||||
# 4. 过滤条件
|
||||
# =========================================================
|
||||
if keyword:
|
||||
rule = or_(
|
||||
cte.c.sku.ilike(f'%{keyword}%'),
|
||||
cte.c.source_info.ilike(f'%{keyword}%'),
|
||||
cte.c.batch_number.ilike(f'%{keyword}%'),
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
)
|
||||
query = query.filter(rule)
|
||||
|
||||
if start_date and end_date:
|
||||
query = query.filter(cte.c.inbound_date.between(start_date, end_date))
|
||||
|
||||
if source_type:
|
||||
query = query.filter(cte.c.source_type == source_type)
|
||||
|
||||
# =========================================================
|
||||
# 5. 获取总数
|
||||
# =========================================================
|
||||
count_query = db.session.query(func.count()) \
|
||||
.select_from(cte) \
|
||||
.outerjoin(MaterialBase, cte.c.base_id == MaterialBase.id)
|
||||
|
||||
if keyword:
|
||||
count_query = count_query.filter(rule)
|
||||
if start_date and end_date:
|
||||
count_query = count_query.filter(cte.c.inbound_date.between(start_date, end_date))
|
||||
if source_type:
|
||||
count_query = count_query.filter(cte.c.source_type == source_type)
|
||||
|
||||
total = count_query.scalar() or 0
|
||||
|
||||
# =========================================================
|
||||
# 6. 排序与分页
|
||||
# =========================================================
|
||||
# ★★★ 修改处:优先按入库日期倒序排列 (最近的在前) ★★★
|
||||
# 如果日期相同,再按 SKU 排序,保证分页稳定性
|
||||
query = query.order_by(desc(cte.c.inbound_date), asc(cte.c.sku))
|
||||
|
||||
pagination = query.limit(per_page).offset((page - 1) * per_page).all()
|
||||
|
||||
# =========================================================
|
||||
# 7. 数据格式化
|
||||
# =========================================================
|
||||
items = []
|
||||
type_map = {
|
||||
'buy': '采购入库',
|
||||
'semi': '半成品生产',
|
||||
'product': '成品完工'
|
||||
}
|
||||
|
||||
for row in pagination:
|
||||
date_str = ""
|
||||
if row.inbound_date:
|
||||
try:
|
||||
date_str = row.inbound_date.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
date_str = str(row.inbound_date)
|
||||
|
||||
in_qty = float(row.in_qty) if row.in_qty is not None else 0.0
|
||||
current_qty = float(row.current_qty) if row.current_qty is not None else 0.0
|
||||
|
||||
# 状态逻辑
|
||||
final_status = row.orig_status
|
||||
if current_qty <= 0:
|
||||
final_status = "已出库"
|
||||
elif current_qty < in_qty:
|
||||
final_status = "部分出库"
|
||||
|
||||
items.append({
|
||||
'id': row.id,
|
||||
'sku': row.sku or "",
|
||||
'name': row.material_name or "未知物品",
|
||||
'spec_model': row.spec_model or "",
|
||||
'category': row.category or "",
|
||||
'material_type': row.material_type or "",
|
||||
|
||||
'inbound_date': date_str,
|
||||
'quantity': in_qty,
|
||||
'current_qty': current_qty,
|
||||
'source_info': row.source_info or "",
|
||||
'status': final_status,
|
||||
'source_type': row.source_type,
|
||||
'type_label': type_map.get(row.source_type, "未知类型"),
|
||||
'batch_number': row.batch_number or ""
|
||||
})
|
||||
|
||||
return {
|
||||
'items': items,
|
||||
'total': total,
|
||||
'pages': (total + per_page - 1) // per_page if per_page > 0 else 0,
|
||||
'current_page': page
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print("【InboundSummaryService Error】:", str(e))
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
349
inventory-backend/app/services/inbound/product_service.py
Normal file
349
inventory-backend/app/services/inbound/product_service.py
Normal file
@ -0,0 +1,349 @@
|
||||
# app/services/inbound/product_service.py
|
||||
from app.extensions import db
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.outbound import TransOutbound
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import or_, func, text, and_
|
||||
import traceback
|
||||
import json
|
||||
|
||||
|
||||
class ProductInboundService:
|
||||
|
||||
# ============================================================
|
||||
# 0. 辅助:唯一性校验 (新增核心逻辑)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _check_unique(serial_number, exclude_id=None):
|
||||
"""
|
||||
校验成品的唯一性
|
||||
:param serial_number: 序列号
|
||||
:param exclude_id: 排除的ID (编辑模式用)
|
||||
"""
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
# 成品强校验序列号 (SN) - SN应该是全局唯一的
|
||||
if serial_number:
|
||||
query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
|
||||
if exclude_id:
|
||||
query = query.filter(StockProduct.id != exclude_id)
|
||||
|
||||
exists = query.first()
|
||||
if exists:
|
||||
# [修改] material -> base
|
||||
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
# ============================================================
|
||||
# 1. 基础物料搜索
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def search_base_material(keyword):
|
||||
try:
|
||||
# 1. 基础查询:必须是已启用的物料
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
# 2. 动态条件:如果传入了关键词,则增加模糊匹配条件
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 3. 排序与限制:按ID倒序,取最新20条
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
|
||||
# 4. 结果封装
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'status': '启用'
|
||||
})
|
||||
return results
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑 (强制北京时间 + 唯一性校验)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def handle_inbound(data):
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
try:
|
||||
base_id = data.get('base_id')
|
||||
if not base_id: raise ValueError("必须选择基础物料")
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material: raise ValueError("物料不存在")
|
||||
|
||||
# --- [核心修改] 执行唯一性校验 ---
|
||||
ProductInboundService._check_unique(
|
||||
serial_number=data.get('serial_number')
|
||||
)
|
||||
|
||||
# [核心修改] 强制北京时间
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
in_date_val = current_time
|
||||
|
||||
if data.get('in_date'):
|
||||
try:
|
||||
date_str = str(data['in_date'])
|
||||
if len(date_str) > 10:
|
||||
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
|
||||
current_time.hour, current_time.minute, current_time.second)
|
||||
except:
|
||||
in_date_val = current_time
|
||||
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
|
||||
p_start = data.get('production_start_time', '')
|
||||
p_end = data.get('production_end_time', '')
|
||||
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
|
||||
|
||||
# 全局流水号
|
||||
try:
|
||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||
result = db.session.execute(seq_sql)
|
||||
next_global_id = result.scalar()
|
||||
except:
|
||||
next_global_id = None
|
||||
|
||||
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
final_barcode = data.get('barcode') or generated_sku
|
||||
|
||||
photo_list = data.get('product_photo', [])
|
||||
quality_list = data.get('quality_report_link', [])
|
||||
inspection_list = data.get('inspection_report_link', [])
|
||||
|
||||
if not isinstance(photo_list, list): photo_list = []
|
||||
if not isinstance(quality_list, list): quality_list = []
|
||||
if not isinstance(inspection_list, list): inspection_list = []
|
||||
|
||||
new_stock = StockProduct(
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=generated_sku,
|
||||
production_date=in_date_val, # 存入 DateTime
|
||||
barcode=final_barcode,
|
||||
serial_number=data.get('serial_number'),
|
||||
|
||||
status=data.get('status', '在库'),
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
|
||||
in_quantity=in_qty,
|
||||
stock_quantity=in_qty,
|
||||
available_quantity=in_qty,
|
||||
|
||||
bom_code=data.get('bom_code'),
|
||||
bom_version=data.get('bom_version'),
|
||||
work_order_code=data.get('work_order_code'),
|
||||
production_manager=data.get('production_manager'),
|
||||
production_time_range=time_range,
|
||||
|
||||
raw_material_cost=float(data.get('raw_material_cost') or 0),
|
||||
manual_cost=float(data.get('manual_cost') or 0),
|
||||
|
||||
quality_status=data.get('quality_status', '合格'),
|
||||
|
||||
product_photo=json.dumps(photo_list),
|
||||
quality_report_link=json.dumps(quality_list),
|
||||
inspection_report_link=json.dumps(inspection_list),
|
||||
|
||||
detail_link=data.get('detail_link'),
|
||||
remark=data.get('remark'),
|
||||
|
||||
sale_price=float(data.get('sale_price') or 0),
|
||||
order_id=data.get('order_id')
|
||||
)
|
||||
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
return new_stock
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 3. 更新逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def update_inbound(stock_id, data):
|
||||
from app.models.inbound.product import StockProduct
|
||||
|
||||
try:
|
||||
stock = StockProduct.query.get(stock_id)
|
||||
if not stock: raise ValueError("记录不存在")
|
||||
|
||||
# --- [核心修改] 编辑时也要校验唯一性 ---
|
||||
if 'serial_number' in data:
|
||||
ProductInboundService._check_unique(
|
||||
serial_number=data['serial_number'],
|
||||
exclude_id=stock_id
|
||||
)
|
||||
|
||||
fields = [
|
||||
'barcode', 'serial_number', 'warehouse_location',
|
||||
'status', 'quality_status', 'bom_code', 'bom_version',
|
||||
'work_order_code', 'production_manager',
|
||||
'detail_link', 'order_id', 'remark'
|
||||
]
|
||||
for f in fields:
|
||||
if f in data: setattr(stock, f, data[f])
|
||||
|
||||
if 'product_photo' in data:
|
||||
imgs = data['product_photo']
|
||||
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
|
||||
|
||||
if 'quality_report_link' in data:
|
||||
imgs = data['quality_report_link']
|
||||
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
|
||||
|
||||
if 'inspection_report_link' in data:
|
||||
imgs = data['inspection_report_link']
|
||||
if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs)
|
||||
|
||||
if 'sale_price' in data: stock.sale_price = float(data['sale_price'])
|
||||
if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost'])
|
||||
if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost'])
|
||||
|
||||
if 'in_quantity' in data:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
stock.in_quantity = new_qty
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
if 'production_start_time' in data or 'production_end_time' in data:
|
||||
old_range = stock.production_time_range or " ~ "
|
||||
parts = old_range.split(' ~ ')
|
||||
old_start = parts[0] if len(parts) > 0 else ''
|
||||
old_end = parts[1] if len(parts) > 1 else ''
|
||||
start = data.get('production_start_time', old_start)
|
||||
end = data.get('production_end_time', old_end)
|
||||
stock.production_time_range = f"{start} ~ {end}"
|
||||
|
||||
db.session.commit()
|
||||
return stock
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 4. 删除逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def delete_inbound(stock_id):
|
||||
from app.models.inbound.product import StockProduct
|
||||
try:
|
||||
stock = StockProduct.query.get(stock_id)
|
||||
if stock:
|
||||
db.session.delete(stock)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 5. 出库历史
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_outbound_history(stock_id):
|
||||
"""获取出库历史"""
|
||||
try:
|
||||
records = TransOutbound.query.filter_by(
|
||||
source_table='stock_product', stock_id=stock_id
|
||||
).order_by(TransOutbound.outbound_time.desc()).all()
|
||||
return [r.to_dict() for r in records]
|
||||
except:
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 6. 获取列表
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None):
|
||||
from app.models.inbound.product import StockProduct
|
||||
try:
|
||||
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
StockProduct.serial_number.ilike(f'%{keyword}%'),
|
||||
StockProduct.work_order_code.ilike(f'%{keyword}%'),
|
||||
StockProduct.order_id.ilike(f'%{keyword}%'),
|
||||
StockProduct.sku.ilike(f'%{keyword}%')
|
||||
))
|
||||
|
||||
if not statuses:
|
||||
statuses = ['在库', '借库']
|
||||
|
||||
if '已出库' in statuses:
|
||||
query = query.filter(StockProduct.status.in_(statuses))
|
||||
else:
|
||||
query = query.filter(
|
||||
and_(
|
||||
StockProduct.status.in_(statuses),
|
||||
StockProduct.stock_quantity > 0
|
||||
)
|
||||
)
|
||||
|
||||
# 按照 production_date (入库日期) 倒序排序
|
||||
pagination = query.order_by(StockProduct.production_date.desc()).paginate(page=page, per_page=limit,
|
||||
error_out=False)
|
||||
|
||||
current_items = pagination.items
|
||||
|
||||
def parse_img(json_str):
|
||||
if not json_str: return []
|
||||
try:
|
||||
return json.loads(json_str) if json_str.startswith('[') else [json_str]
|
||||
except:
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in current_items:
|
||||
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base,所以这里直接调 to_dict 即可
|
||||
d = item.to_dict()
|
||||
|
||||
# 格式化日期
|
||||
date_display = ''
|
||||
if item.production_date:
|
||||
try:
|
||||
date_display = item.production_date.strftime('%Y-%m-%d')
|
||||
except:
|
||||
date_display = str(item.production_date)[:10]
|
||||
d['inbound_date'] = date_display
|
||||
|
||||
d['qty_stock'] = float(item.stock_quantity or 0)
|
||||
d['qty_available'] = float(item.available_quantity or 0)
|
||||
d['sum_stock'] = d['qty_stock']
|
||||
d['sum_available'] = d['qty_available']
|
||||
|
||||
d['product_photo'] = parse_img(item.product_photo)
|
||||
d['quality_report_link'] = parse_img(item.quality_report_link)
|
||||
d['inspection_report_link'] = parse_img(item.inspection_report_link)
|
||||
d['global_print_id'] = item.global_print_id
|
||||
|
||||
items.append(d)
|
||||
|
||||
return {"total": pagination.total, "items": items}
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
451
inventory-backend/app/services/inbound/semi_service.py
Normal file
451
inventory-backend/app/services/inbound/semi_service.py
Normal file
@ -0,0 +1,451 @@
|
||||
# app/services/inbound/semi_service.py
|
||||
from app.extensions import db
|
||||
from app.models.base import MaterialBase
|
||||
from app.models.outbound import TransOutbound
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from sqlalchemy import or_, func, text, and_
|
||||
import traceback
|
||||
import json
|
||||
|
||||
|
||||
class SemiInboundService:
|
||||
|
||||
# ============================================================
|
||||
# 0. 辅助:唯一性校验 (新增核心逻辑)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||
"""
|
||||
校验半成品的唯一性
|
||||
:param base_id: 基础物料ID
|
||||
:param serial_number: 序列号
|
||||
:param batch_number: 批号
|
||||
:param exclude_id: 排除的ID
|
||||
"""
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
# 1. 序列号 (SN) 校验 - 全局唯一
|
||||
if serial_number:
|
||||
query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
|
||||
if exclude_id:
|
||||
query = query.filter(StockSemi.id != exclude_id)
|
||||
|
||||
exists = query.first()
|
||||
if exists:
|
||||
# [修改] material -> base
|
||||
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
|
||||
if batch_number and base_id:
|
||||
query = StockSemi.query.filter(
|
||||
StockSemi.base_id == base_id,
|
||||
StockSemi.batch_number == batch_number
|
||||
)
|
||||
if exclude_id:
|
||||
query = query.filter(StockSemi.id != exclude_id)
|
||||
|
||||
if query.first():
|
||||
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
|
||||
|
||||
# ============================================================
|
||||
# 1. 基础物料搜索
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def search_base_material(keyword):
|
||||
try:
|
||||
# 基础查询:必须是已启用的物料
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
# 如果有关键词,进行模糊匹配
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 统一逻辑:按ID倒序,限制20条
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model, # 对应前端 item.spec
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type, # 对应前端 item.type
|
||||
'status': '启用'
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 2. 新增入库逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def handle_inbound(data):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
try:
|
||||
base_id = data.get('base_id')
|
||||
if not base_id:
|
||||
raise ValueError("必须选择基础物料 (缺少 base_id)")
|
||||
|
||||
material = MaterialBase.query.get(base_id)
|
||||
if not material:
|
||||
raise ValueError(f"ID为 {base_id} 的基础物料不存在")
|
||||
|
||||
# --- [核心修改] 执行唯一性校验 ---
|
||||
SemiInboundService._check_unique(
|
||||
base_id=base_id,
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number')
|
||||
)
|
||||
|
||||
# [核心修改] 强制北京时间
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
in_date_val = current_time
|
||||
|
||||
if data.get('in_date'):
|
||||
try:
|
||||
date_str = str(data['in_date'])
|
||||
if len(date_str) > 10:
|
||||
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
|
||||
current_time.hour, current_time.minute, current_time.second)
|
||||
except ValueError:
|
||||
in_date_val = current_time
|
||||
|
||||
# 2. 处理生产时间
|
||||
p_start = None
|
||||
p_end = None
|
||||
if data.get('production_start_time'):
|
||||
try:
|
||||
p_start = datetime.strptime(str(data['production_start_time']), '%Y-%m-%d %H:%M:%S')
|
||||
except:
|
||||
pass
|
||||
if data.get('production_end_time'):
|
||||
try:
|
||||
p_end = datetime.strptime(str(data['production_end_time']), '%Y-%m-%d %H:%M:%S')
|
||||
except:
|
||||
pass
|
||||
|
||||
time_range_str = None
|
||||
raw_range = data.get('production_time_range')
|
||||
if isinstance(raw_range, list):
|
||||
time_range_str = " ~ ".join([str(x) for x in raw_range])
|
||||
elif isinstance(raw_range, str):
|
||||
time_range_str = raw_range
|
||||
|
||||
# 3. 处理数值和成本
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
raw_cost = float(data.get('raw_material_cost') or 0)
|
||||
manual_cost = float(data.get('manual_cost') or 0)
|
||||
unit_total_cost = raw_cost + manual_cost
|
||||
total_value = unit_total_cost * in_qty
|
||||
|
||||
# 4. 获取全局打印流水号
|
||||
next_global_id = 0
|
||||
try:
|
||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||
result = db.session.execute(seq_sql)
|
||||
next_global_id = result.scalar()
|
||||
except Exception as e:
|
||||
print("❌ 数据库序列 global_print_seq 不存在,请执行SQL创建!")
|
||||
raise e
|
||||
|
||||
generated_sku = str(next_global_id).zfill(10)
|
||||
final_sku = data.get('sku')
|
||||
if not final_sku:
|
||||
final_sku = generated_sku
|
||||
|
||||
final_barcode = data.get('barcode')
|
||||
if not final_barcode:
|
||||
final_barcode = final_sku
|
||||
|
||||
arrival_list = data.get('arrival_photo', [])
|
||||
quality_report_list = data.get('quality_report_link', [])
|
||||
|
||||
if not isinstance(arrival_list, list): arrival_list = []
|
||||
if not isinstance(quality_report_list, list): quality_report_list = []
|
||||
|
||||
# 8. 创建记录
|
||||
new_stock = StockSemi(
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=final_sku,
|
||||
production_date=in_date_val, # 存入 DateTime
|
||||
|
||||
serial_number=data.get('serial_number'),
|
||||
batch_number=data.get('batch_number'),
|
||||
barcode=final_barcode,
|
||||
|
||||
status='在库',
|
||||
quality_status=data.get('quality_status', '合格'),
|
||||
in_quantity=in_qty,
|
||||
stock_quantity=in_qty,
|
||||
available_quantity=in_qty,
|
||||
warehouse_location=data.get('warehouse_location'),
|
||||
|
||||
bom_code=data.get('bom_code'),
|
||||
bom_version=data.get('bom_version'),
|
||||
work_order_code=data.get('work_order_code'),
|
||||
production_manager=data.get('production_manager'),
|
||||
|
||||
production_start_time=p_start,
|
||||
production_end_time=p_end,
|
||||
production_time_range=time_range_str,
|
||||
|
||||
raw_material_cost=raw_cost,
|
||||
manual_cost=manual_cost,
|
||||
total_price=total_value,
|
||||
|
||||
arrival_photo=json.dumps(arrival_list),
|
||||
quality_report_link=json.dumps(quality_report_list),
|
||||
|
||||
detail_link=data.get('detail_link'),
|
||||
remark=data.get('remark')
|
||||
)
|
||||
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
return new_stock
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print("----- SemiInboundService Error -----")
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 3. 更新逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def update_inbound(stock_id, data):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
|
||||
try:
|
||||
stock = StockSemi.query.get(stock_id)
|
||||
if not stock:
|
||||
raise ValueError("记录不存在")
|
||||
|
||||
# --- [核心修改] 编辑时也要校验唯一性 ---
|
||||
new_base_id = data.get('base_id', stock.base_id)
|
||||
new_sn = data.get('serial_number', stock.serial_number)
|
||||
new_bn = data.get('batch_number', stock.batch_number)
|
||||
|
||||
SemiInboundService._check_unique(
|
||||
base_id=new_base_id,
|
||||
serial_number=new_sn,
|
||||
batch_number=new_bn,
|
||||
exclude_id=stock_id
|
||||
)
|
||||
|
||||
field_mapping = {
|
||||
'sku': 'sku',
|
||||
'barcode': 'barcode',
|
||||
'warehouse_location': 'warehouse_location',
|
||||
'serial_number': 'serial_number',
|
||||
'batch_number': 'batch_number',
|
||||
'status': 'status',
|
||||
'quality_status': 'quality_status',
|
||||
'bom_code': 'bom_code',
|
||||
'bom_version': 'bom_version',
|
||||
'work_order_code': 'work_order_code',
|
||||
'production_manager': 'production_manager',
|
||||
'detail_link': 'detail_link',
|
||||
'remark': 'remark'
|
||||
}
|
||||
|
||||
for frontend_key, db_attr in field_mapping.items():
|
||||
if frontend_key in data:
|
||||
setattr(stock, db_attr, data[frontend_key])
|
||||
|
||||
if 'arrival_photo' in data:
|
||||
imgs = data['arrival_photo']
|
||||
if isinstance(imgs, list):
|
||||
stock.arrival_photo = json.dumps(imgs)
|
||||
|
||||
if 'quality_report_link' in data:
|
||||
imgs = data['quality_report_link']
|
||||
if isinstance(imgs, list):
|
||||
stock.quality_report_link = json.dumps(imgs)
|
||||
|
||||
if 'production_start_time' in data:
|
||||
try:
|
||||
if data['production_start_time']:
|
||||
stock.production_start_time = datetime.strptime(str(data['production_start_time']),
|
||||
'%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
stock.production_start_time = None
|
||||
except:
|
||||
pass
|
||||
|
||||
if 'production_end_time' in data:
|
||||
try:
|
||||
if data['production_end_time']:
|
||||
stock.production_end_time = datetime.strptime(str(data['production_end_time']),
|
||||
'%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
stock.production_end_time = None
|
||||
except:
|
||||
pass
|
||||
|
||||
if 'production_time_range' in data:
|
||||
raw_range = data['production_time_range']
|
||||
if isinstance(raw_range, list):
|
||||
stock.production_time_range = " ~ ".join([str(x) for x in raw_range])
|
||||
else:
|
||||
stock.production_time_range = raw_range
|
||||
|
||||
qty_changed = False
|
||||
cost_changed = False
|
||||
|
||||
if 'in_quantity' in data:
|
||||
new_qty = float(data['in_quantity'])
|
||||
diff = new_qty - float(stock.in_quantity)
|
||||
if diff != 0:
|
||||
stock.in_quantity = new_qty
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
qty_changed = True
|
||||
|
||||
if 'raw_material_cost' in data:
|
||||
stock.raw_material_cost = float(data['raw_material_cost'])
|
||||
cost_changed = True
|
||||
|
||||
if 'manual_cost' in data:
|
||||
stock.manual_cost = float(data['manual_cost'])
|
||||
cost_changed = True
|
||||
|
||||
if cost_changed or qty_changed:
|
||||
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost)
|
||||
stock.total_price = float(stock.in_quantity) * unit_total
|
||||
|
||||
db.session.commit()
|
||||
return stock
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 4. 删除逻辑
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def delete_inbound(stock_id):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
try:
|
||||
stock = StockSemi.query.get(stock_id)
|
||||
if not stock:
|
||||
raise ValueError("记录不存在")
|
||||
db.session.delete(stock)
|
||||
db.session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
# ============================================================
|
||||
# 5. 出库历史
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_outbound_history(stock_id):
|
||||
"""获取出库历史"""
|
||||
try:
|
||||
records = TransOutbound.query.filter_by(
|
||||
source_table='stock_semi', stock_id=stock_id
|
||||
).order_by(TransOutbound.outbound_time.desc()).all()
|
||||
return [r.to_dict() for r in records]
|
||||
except:
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 6. 获取列表
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
try:
|
||||
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
StockSemi.batch_number.ilike(kw),
|
||||
StockSemi.serial_number.ilike(kw),
|
||||
StockSemi.sku.ilike(kw),
|
||||
StockSemi.work_order_code.ilike(kw),
|
||||
StockSemi.bom_code.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
if not statuses:
|
||||
statuses = ['在库', '借库']
|
||||
|
||||
if '已出库' in statuses:
|
||||
query = query.filter(StockSemi.status.in_(statuses))
|
||||
else:
|
||||
query = query.filter(
|
||||
and_(
|
||||
StockSemi.status.in_(statuses),
|
||||
StockSemi.stock_quantity > 0
|
||||
)
|
||||
)
|
||||
|
||||
# 按照 production_date (入库日期) 倒序排序
|
||||
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
|
||||
error_out=False)
|
||||
|
||||
current_items = pagination.items
|
||||
|
||||
def parse_img(json_str):
|
||||
if not json_str: return []
|
||||
try:
|
||||
return json.loads(json_str) if json_str.startswith('[') else [json_str]
|
||||
except:
|
||||
return []
|
||||
|
||||
items = []
|
||||
for item in current_items:
|
||||
# [注意] 因为Model层已经修改了 to_dict 内部的 material -> base,所以这里直接调 to_dict 即可
|
||||
d = item.to_dict()
|
||||
|
||||
# 格式化展示日期
|
||||
date_display = ''
|
||||
if item.production_date:
|
||||
try:
|
||||
date_display = item.production_date.strftime('%Y-%m-%d')
|
||||
except:
|
||||
date_display = str(item.production_date)[:10]
|
||||
d['inbound_date'] = date_display
|
||||
|
||||
d['qty_stock'] = float(item.stock_quantity or 0)
|
||||
d['qty_available'] = float(item.available_quantity or 0)
|
||||
d['sum_stock'] = d['qty_stock']
|
||||
d['sum_available'] = d['qty_available']
|
||||
|
||||
d['arrival_photo'] = parse_img(item.arrival_photo)
|
||||
d['quality_report_link'] = parse_img(item.quality_report_link)
|
||||
d['global_print_id'] = item.global_print_id
|
||||
|
||||
items.append(d)
|
||||
|
||||
return {"total": pagination.total, "items": items}
|
||||
except Exception as e:
|
||||
print(f"List Error: {e}")
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
155
inventory-backend/app/services/inbound/service_service.py
Normal file
155
inventory-backend/app/services/inbound/service_service.py
Normal file
@ -0,0 +1,155 @@
|
||||
from app import db
|
||||
from app.models.inbound.service import StockService
|
||||
from app.models.base import MaterialBase
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
|
||||
|
||||
class ServiceService:
|
||||
"""服务权益库存业务逻辑"""
|
||||
|
||||
SKU_PREFIX = 'SRV'
|
||||
SKU_DATE_FORMAT = '%Y%m%d'
|
||||
SKU_SUFFIX_LEN = 4
|
||||
|
||||
@classmethod
|
||||
def _generate_sku(cls):
|
||||
"""生成唯一SKU,格式 SRV-YYYYMMDD-XXXX"""
|
||||
today_str = datetime.now().strftime(cls.SKU_DATE_FORMAT)
|
||||
prefix = f'{cls.SKU_PREFIX}-{today_str}-'
|
||||
# 查找今天已有的最大后缀
|
||||
max_sku = db.session.query(db.func.max(StockService.sku)).filter(
|
||||
StockService.sku.like(f'{prefix}%')
|
||||
).scalar()
|
||||
if not max_sku:
|
||||
suffix_num = 1
|
||||
else:
|
||||
# 提取后缀数字
|
||||
suffix_part = max_sku.replace(prefix, '')
|
||||
match = re.match(r'^(\d+)', suffix_part)
|
||||
suffix_num = int(match.group(1)) if match else 0
|
||||
suffix_num += 1
|
||||
# 格式化为4位数字,左侧补零
|
||||
suffix = str(suffix_num).zfill(cls.SKU_SUFFIX_LEN)
|
||||
return f'{prefix}{suffix}'
|
||||
|
||||
@classmethod
|
||||
def search_base_material(cls, keyword):
|
||||
"""搜索基础物料,供前端远程选择"""
|
||||
try:
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||
)
|
||||
)
|
||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||
results = []
|
||||
for item in query.all():
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def create_service(cls, data):
|
||||
"""创建服务权益记录"""
|
||||
# 检查基础物料是否存在
|
||||
base = MaterialBase.query.get(data.get('base_id'))
|
||||
if not base:
|
||||
raise ValueError('基础物料不存在')
|
||||
# 生成SKU
|
||||
sku = cls._generate_sku()
|
||||
service = StockService(
|
||||
base_id=data['base_id'],
|
||||
sku=sku,
|
||||
sale_price=data['sale_price'],
|
||||
provider_name=data['provider_name'],
|
||||
description=data.get('description', '')
|
||||
)
|
||||
db.session.add(service)
|
||||
db.session.commit()
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
def get_service(cls, service_id):
|
||||
"""获取单个服务权益"""
|
||||
service = StockService.query.filter_by(id=service_id, is_deleted=False).first()
|
||||
if not service:
|
||||
raise ValueError('服务权益记录不存在')
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
def update_service(cls, service_id, data):
|
||||
"""更新服务权益记录"""
|
||||
service = cls.get_service(service_id)
|
||||
# 不允许修改 base_id 和 sku(业务上不允许变更基础物料)
|
||||
if 'sale_price' in data:
|
||||
service.sale_price = data['sale_price']
|
||||
if 'provider_name' in data:
|
||||
service.provider_name = data['provider_name']
|
||||
if 'description' in data:
|
||||
service.description = data.get('description', '')
|
||||
service.updated_at = datetime.now()
|
||||
db.session.commit()
|
||||
return service
|
||||
|
||||
@classmethod
|
||||
def delete_service(cls, service_id):
|
||||
"""软删除服务权益"""
|
||||
service = cls.get_service(service_id)
|
||||
service.is_deleted = True
|
||||
service.updated_at = datetime.now()
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_service_list(cls, page=1, per_page=20, keyword=None,
|
||||
start_date=None, end_date=None, provider_name=None):
|
||||
"""分页查询服务权益列表"""
|
||||
query = StockService.query.filter_by(is_deleted=False)
|
||||
# 关键词搜索:可搜索 SKU 或 关联物料名称
|
||||
if keyword:
|
||||
# 子查询查找物料名称匹配的 base_id
|
||||
subquery = MaterialBase.query.filter(
|
||||
MaterialBase.name.ilike(f'%{keyword}%')
|
||||
).subquery()
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
StockService.sku.ilike(f'%{keyword}%'),
|
||||
StockService.base_id.in_([row.id for row in db.session.query(subquery.c.id)])
|
||||
)
|
||||
)
|
||||
if start_date:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
query = query.filter(StockService.created_at >= start)
|
||||
if end_date:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
# 包含当天
|
||||
end = end + timedelta(days=1) - timedelta(seconds=1)
|
||||
query = query.filter(StockService.created_at <= end)
|
||||
if provider_name:
|
||||
query = query.filter(StockService.provider_name.ilike(f'%{provider_name}%'))
|
||||
# 总数
|
||||
total = query.count()
|
||||
# 分页
|
||||
items = query.order_by(StockService.created_at.desc())\
|
||||
.offset((page - 1) * per_page)\
|
||||
.limit(per_page).all()
|
||||
return {
|
||||
'items': [item.to_dict() for item in items],
|
||||
'total': total,
|
||||
'page': page,
|
||||
'per_page': per_page
|
||||
}
|
||||
315
inventory-backend/app/services/outbound_service.py
Normal file
315
inventory-backend/app/services/outbound_service.py
Normal file
@ -0,0 +1,315 @@
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import or_, func, desc
|
||||
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
|
||||
# 引入基础信息表
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
|
||||
class OutboundService:
|
||||
|
||||
@staticmethod
|
||||
def generate_outbound_no():
|
||||
"""
|
||||
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
|
||||
例如: OUT-20260205-1558-0001
|
||||
"""
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
now = datetime.now(beijing_tz)
|
||||
|
||||
date_str = now.strftime('%Y%m%d')
|
||||
time_str = now.strftime('%H%M')
|
||||
|
||||
prefix = f"OUT-{date_str}-"
|
||||
|
||||
existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \
|
||||
.filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar()
|
||||
|
||||
sequence = existing_count + 1
|
||||
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
|
||||
|
||||
@staticmethod
|
||||
def get_stock_by_barcode(barcode):
|
||||
"""
|
||||
根据扫码内容查找对应的库存物品,并附带价格信息
|
||||
"""
|
||||
if not barcode:
|
||||
return None
|
||||
|
||||
clean_code = barcode.strip()
|
||||
|
||||
def get_price(item, table_type):
|
||||
if table_type == 'stock_product':
|
||||
return float(item.sale_price) if item.sale_price else 0
|
||||
elif table_type == 'stock_buy':
|
||||
return float(item.unit_price) if item.unit_price else 0
|
||||
return 0
|
||||
|
||||
prod = StockProduct.query.filter(
|
||||
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
|
||||
).first()
|
||||
if prod:
|
||||
res = OutboundService._format_scan_result(prod, 'stock_product')
|
||||
res['price'] = get_price(prod, 'stock_product')
|
||||
return res
|
||||
|
||||
semi = StockSemi.query.filter(
|
||||
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
|
||||
).first()
|
||||
if semi:
|
||||
res = OutboundService._format_scan_result(semi, 'stock_semi')
|
||||
res['price'] = 0
|
||||
return res
|
||||
|
||||
buy = StockBuy.query.filter(
|
||||
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
|
||||
).first()
|
||||
if buy:
|
||||
res = OutboundService._format_scan_result(buy, 'stock_buy')
|
||||
res['price'] = get_price(buy, 'stock_buy')
|
||||
return res
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_scan_result(item, table_name):
|
||||
base_name = ""
|
||||
base_spec = ""
|
||||
base_cat = ""
|
||||
base_type = ""
|
||||
|
||||
if hasattr(item, 'base') and item.base:
|
||||
base_name = item.base.name
|
||||
base_spec = item.base.spec_model
|
||||
base_cat = item.base.category
|
||||
base_type = item.base.material_type
|
||||
|
||||
if not base_name and hasattr(item, 'base_id') and item.base_id:
|
||||
try:
|
||||
base_info = MaterialBase.query.get(item.base_id)
|
||||
if base_info:
|
||||
base_name = base_info.name
|
||||
base_spec = base_info.spec_model
|
||||
base_cat = base_info.category
|
||||
base_type = base_info.material_type
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not base_name and hasattr(item, 'material_name'):
|
||||
base_name = item.material_name
|
||||
|
||||
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
|
||||
avail_qty = float(item.available_quantity) if item.available_quantity else 0
|
||||
|
||||
return {
|
||||
'id': item.id,
|
||||
'sku': item.sku,
|
||||
'name': base_name or "未知物品",
|
||||
'spec_model': base_spec or "",
|
||||
'category': base_cat or "",
|
||||
'material_type': base_type or "",
|
||||
'source_table': table_name,
|
||||
'stock_quantity': stock_qty,
|
||||
'available_quantity': avail_qty,
|
||||
'batch_number': getattr(item, 'batch_number', ''),
|
||||
'warehouse_location': getattr(item, 'warehouse_location', ''),
|
||||
'barcode': getattr(item, 'barcode', '')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_outbound_batch(data, operator_name='System'):
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
raise ValueError("出库商品列表不能为空")
|
||||
|
||||
outbound_no = OutboundService.generate_outbound_no()
|
||||
|
||||
common_data = {
|
||||
'outbound_no': outbound_no,
|
||||
'consumer_name': data.get('consumer_name'),
|
||||
'outbound_type': data.get('outbound_type', 'SALES'),
|
||||
'signature_path': data.get('signature_path'),
|
||||
'operator_name': operator_name,
|
||||
'remark': data.get('remark')
|
||||
}
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct
|
||||
}
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
source_table = item.get('source_table')
|
||||
stock_id = item.get('stock_id')
|
||||
quantity = float(item.get('quantity', 0))
|
||||
unit_price = float(item.get('price', 0))
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
|
||||
|
||||
ModelClass = model_map.get(source_table)
|
||||
if not ModelClass:
|
||||
continue
|
||||
|
||||
stock_record = ModelClass.query.with_for_update().get(stock_id)
|
||||
if not stock_record:
|
||||
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
||||
|
||||
if float(stock_record.available_quantity) < quantity:
|
||||
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
|
||||
|
||||
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
|
||||
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
|
||||
|
||||
new_record = TransOutbound(
|
||||
sku=item.get('sku'),
|
||||
source_table=source_table,
|
||||
stock_id=stock_id,
|
||||
barcode=item.get('barcode'),
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
outbound_time=current_time,
|
||||
**common_data
|
||||
)
|
||||
db.session.add(new_record)
|
||||
|
||||
db.session.commit()
|
||||
return outbound_no
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_grouped_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
|
||||
"""
|
||||
查询出库记录(按出库单号分组),包含详细物品信息
|
||||
"""
|
||||
# 1. 查询分页单号
|
||||
stmt = db.session.query(
|
||||
TransOutbound.outbound_no,
|
||||
func.max(TransOutbound.outbound_time).label('max_time')
|
||||
).group_by(TransOutbound.outbound_no)
|
||||
|
||||
if keyword:
|
||||
stmt = stmt.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:
|
||||
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
|
||||
|
||||
stmt = stmt.order_by(desc('max_time'))
|
||||
|
||||
pagination = stmt.paginate(page=page, per_page=per_page, error_out=False)
|
||||
outbound_nos = [row.outbound_no for row in pagination.items]
|
||||
|
||||
if not outbound_nos:
|
||||
return {
|
||||
'items': [],
|
||||
'total': 0,
|
||||
'pages': 0,
|
||||
'current_page': page
|
||||
}
|
||||
|
||||
# 2. 查询详细记录
|
||||
details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all()
|
||||
|
||||
# 3. 组装数据并查询物品详情
|
||||
grouped_map = {}
|
||||
|
||||
# 映射表模型以便查询
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct
|
||||
}
|
||||
|
||||
for d in details:
|
||||
ono = d.outbound_no
|
||||
if ono not in grouped_map:
|
||||
grouped_map[ono] = {
|
||||
'outbound_no': ono,
|
||||
'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'outbound_type': d.outbound_type,
|
||||
'consumer_name': d.consumer_name,
|
||||
'operator_name': d.operator_name,
|
||||
'signature_path': d.signature_path,
|
||||
'remark': d.remark,
|
||||
'total_amount': 0.0,
|
||||
'items': []
|
||||
}
|
||||
|
||||
# --- 查询物品详细信息 (名称, 规格, 类型, 类别) ---
|
||||
item_name = "未知物品"
|
||||
item_spec = ""
|
||||
item_cat = ""
|
||||
item_type = ""
|
||||
|
||||
ModelClass = model_map.get(d.source_table)
|
||||
if ModelClass and d.stock_id:
|
||||
# 注意:这里在循环中查询可能会有N+1问题,但考虑到单页数据量(通常每单条目不多),暂时可接受
|
||||
# 生产环境建议优化为预加载或批量查询
|
||||
try:
|
||||
stock_item = ModelClass.query.get(d.stock_id)
|
||||
if stock_item and stock_item.base:
|
||||
item_name = stock_item.base.name
|
||||
item_spec = stock_item.base.spec_model
|
||||
item_cat = stock_item.base.category
|
||||
item_type = stock_item.base.material_type
|
||||
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
|
||||
base_info = MaterialBase.query.get(stock_item.base_id)
|
||||
if base_info:
|
||||
item_name = base_info.name
|
||||
item_spec = base_info.spec_model
|
||||
item_cat = base_info.category
|
||||
item_type = base_info.material_type
|
||||
except Exception as e:
|
||||
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
|
||||
|
||||
# 计算金额
|
||||
price = float(d.unit_price) if d.unit_price else 0
|
||||
qty = float(d.quantity)
|
||||
subtotal = price * qty
|
||||
|
||||
grouped_map[ono]['total_amount'] += subtotal
|
||||
|
||||
grouped_map[ono]['items'].append({
|
||||
'sku': d.sku,
|
||||
'name': item_name,
|
||||
'spec_model': item_spec,
|
||||
'category': item_cat,
|
||||
'material_type': item_type,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'subtotal': subtotal
|
||||
})
|
||||
|
||||
# 4. 排序输出
|
||||
result_list = []
|
||||
for ono in outbound_nos:
|
||||
if ono in grouped_map:
|
||||
obj = grouped_map[ono]
|
||||
obj['items'].sort(key=lambda x: x['unit_price'], reverse=True)
|
||||
obj['total_amount'] = round(obj['total_amount'], 2)
|
||||
result_list.append(obj)
|
||||
|
||||
return {
|
||||
'items': result_list,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page
|
||||
}
|
||||
318
inventory-backend/app/services/print/label_service.py
Normal file
318
inventory-backend/app/services/print/label_service.py
Normal file
@ -0,0 +1,318 @@
|
||||
import socket # .material -> .base refactor checked
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# 引入二维码生成库
|
||||
try:
|
||||
import qrcode
|
||||
except ImportError:
|
||||
print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# ================= 2. 布局配置 =================
|
||||
MARGIN_LEFT = int(2 * DOTS_PER_MM) # 左边距 2mm
|
||||
MARGIN_RIGHT = int(1 * DOTS_PER_MM) # 右边距 1mm
|
||||
TOP_MARGIN = int(5 * DOTS_PER_MM) # 顶部边距 2mm
|
||||
|
||||
# 二维码尺寸 15mm * 15mm
|
||||
QR_SIZE_MM = 15
|
||||
QR_SIZE_PX = int(QR_SIZE_MM * DOTS_PER_MM) # 180px
|
||||
|
||||
# 左右分栏的间距
|
||||
GAP_COLUMNS = int(2 * DOTS_PER_MM) # 2mm 间距
|
||||
|
||||
@staticmethod
|
||||
def _get_font(size):
|
||||
"""获取字体 (优先使用黑体/微软雅黑)"""
|
||||
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf", "NotoSansCJK-Regular.ttc"]
|
||||
base_dirs = [os.getcwd(), os.path.dirname(__file__), "/usr/share/fonts", "C:\\Windows\\Fonts"]
|
||||
|
||||
for d in base_dirs:
|
||||
for name in font_names:
|
||||
path = os.path.join(d, name)
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
@staticmethod
|
||||
def _generate_qr_image(content, size_px):
|
||||
"""生成指定像素大小的二维码"""
|
||||
try:
|
||||
if not content: content = "000000"
|
||||
|
||||
# 创建二维码对象
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
# [重要] 必须转为 RGB 模式
|
||||
img = img.convert('RGB')
|
||||
|
||||
# 调整为指定像素大小
|
||||
return img.resize((size_px, size_px), Image.Resampling.LANCZOS)
|
||||
except Exception as e:
|
||||
print(f"二维码生成失败: {e}")
|
||||
return Image.new('RGB', (size_px, size_px), color='gray')
|
||||
|
||||
@staticmethod
|
||||
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0, stroke_width=1):
|
||||
"""
|
||||
[核心功能] 自动换行绘制文本
|
||||
"""
|
||||
if not text:
|
||||
return y
|
||||
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
# 计算折行
|
||||
for char in text:
|
||||
test_line = current_line + char
|
||||
width = font.getlength(test_line)
|
||||
|
||||
if width <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line: lines.append(current_line)
|
||||
current_line = char
|
||||
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
# 绘制
|
||||
current_y = y
|
||||
font_height = font.size
|
||||
|
||||
for line in lines:
|
||||
if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
|
||||
break
|
||||
|
||||
draw.text(
|
||||
(x, current_y),
|
||||
line,
|
||||
font=font,
|
||||
fill='black',
|
||||
stroke_width=stroke_width, # 支持动态调整粗细
|
||||
stroke_fill='black'
|
||||
)
|
||||
current_y += font_height + line_spacing
|
||||
|
||||
return current_y
|
||||
|
||||
@staticmethod
|
||||
def _create_image_object(data):
|
||||
"""
|
||||
[绘图层] 生成标签图片
|
||||
新布局逻辑:
|
||||
---------------------------------------
|
||||
| [QR Code] (15mm) | 名: XXXXXX |
|
||||
| | 规: XXXXXX |
|
||||
| SKU: XXXXX(大/粗)| 属: XXXXXX |
|
||||
| 库: XXXXX (中/粗)| SN: XXXXXX |
|
||||
---------------------------------------
|
||||
"""
|
||||
# 1. 创建画布
|
||||
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
|
||||
d = ImageDraw.Draw(img)
|
||||
|
||||
# 2. 字体配置 (字号再次加大)
|
||||
# [修改] 通用字体加大到 28
|
||||
font_text = LabelPrintService._get_font(28)
|
||||
# [修改] SKU字体加大到 34 (特大)
|
||||
font_sku = LabelPrintService._get_font(34)
|
||||
|
||||
# 3. 数据准备
|
||||
sku_code = str(data.get('sku') or data.get('serial_number') or '000000')
|
||||
|
||||
name = str(data.get('material_name', '') or '-')
|
||||
spec = str(data.get('spec_model', '') or '-')
|
||||
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 (cat or typ) else "-"
|
||||
|
||||
# 底部编号逻辑
|
||||
bottom_val = ""
|
||||
bottom_label = "NO"
|
||||
if data.get('print_no'):
|
||||
bottom_val = str(data.get('print_no'))
|
||||
l_type = data.get('print_label', '')
|
||||
bottom_label = 'SN' if l_type == '序' else 'BN' if l_type == '批' else 'NO'
|
||||
elif data.get('serial_number'):
|
||||
bottom_label = "SN"
|
||||
bottom_val = str(data.get('serial_number'))
|
||||
elif data.get('batch_number'):
|
||||
bottom_label = "BN"
|
||||
bottom_val = str(data.get('batch_number'))
|
||||
else:
|
||||
bottom_val = sku_code
|
||||
|
||||
bottom_text_full = f"{bottom_label}:{bottom_val}"
|
||||
|
||||
# ==================== 绘制区域划分 ====================
|
||||
|
||||
# --- A. 左侧区域 (二维码 + SKU + 库位) ---
|
||||
qr_x = LabelPrintService.MARGIN_LEFT
|
||||
qr_y = LabelPrintService.TOP_MARGIN
|
||||
|
||||
# 1. 绘制二维码
|
||||
qr_img = LabelPrintService._generate_qr_image(sku_code, LabelPrintService.QR_SIZE_PX)
|
||||
img.paste(qr_img, (qr_x, qr_y))
|
||||
|
||||
# 计算中心点,用于 SKU 和 库位 居中
|
||||
qr_center_x = qr_x + (LabelPrintService.QR_SIZE_PX // 2)
|
||||
|
||||
# 2. 绘制 SKU (特大 + 特粗)
|
||||
# 位于二维码下方,留 6px 间距
|
||||
current_left_y = qr_y + LabelPrintService.QR_SIZE_PX + 6
|
||||
|
||||
sku_w = font_sku.getlength(sku_code)
|
||||
sku_x = int(qr_center_x - (sku_w // 2))
|
||||
if sku_x < 2: sku_x = 2 # 边界保护
|
||||
|
||||
d.text(
|
||||
(sku_x, current_left_y),
|
||||
sku_code,
|
||||
font=font_sku,
|
||||
fill='black',
|
||||
stroke_width=2, # [修改] SKU 增加到 2px 描边,更粗
|
||||
stroke_fill='black'
|
||||
)
|
||||
|
||||
# 3. 绘制 库位 (放在 SKU 下方)
|
||||
# 位于 SKU 下方,留 6px 间距
|
||||
current_left_y += 34 + 6 # 34是字号大致高度
|
||||
|
||||
loc_text = f"库:{loc}"
|
||||
loc_w = font_text.getlength(loc_text)
|
||||
loc_x = int(qr_center_x - (loc_w // 2))
|
||||
if loc_x < 2: loc_x = 2
|
||||
|
||||
d.text(
|
||||
(loc_x, current_left_y),
|
||||
loc_text,
|
||||
font=font_text,
|
||||
fill='black',
|
||||
stroke_width=1, # 普通加粗
|
||||
stroke_fill='black'
|
||||
)
|
||||
|
||||
# --- B. 右侧区域 (名称、规格、属性、编号) ---
|
||||
|
||||
# 右侧起始 X
|
||||
right_start_x = LabelPrintService.MARGIN_LEFT + LabelPrintService.QR_SIZE_PX + LabelPrintService.GAP_COLUMNS
|
||||
# 右侧最大宽度
|
||||
right_max_width = LabelPrintService.LABEL_WIDTH - right_start_x - LabelPrintService.MARGIN_RIGHT
|
||||
|
||||
current_right_y = LabelPrintService.TOP_MARGIN
|
||||
|
||||
# [修改] 增大行间距 line_spacing=8
|
||||
LINE_SPACING = 8
|
||||
|
||||
# 1. 名称
|
||||
current_right_y = LabelPrintService.draw_text_wrap(
|
||||
d, f"名:{name}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
current_right_y += LINE_SPACING
|
||||
|
||||
# 2. 规格
|
||||
current_right_y = LabelPrintService.draw_text_wrap(
|
||||
d, f"规:{spec}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
current_right_y += LINE_SPACING
|
||||
|
||||
# 3. 属性
|
||||
current_right_y = LabelPrintService.draw_text_wrap(
|
||||
d, f"属:{attr}", right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
current_right_y += LINE_SPACING
|
||||
|
||||
# 4. 序列号/批号
|
||||
LabelPrintService.draw_text_wrap(
|
||||
d, bottom_text_full, right_start_x, current_right_y, font_text, right_max_width, line_spacing=LINE_SPACING
|
||||
)
|
||||
|
||||
return img
|
||||
|
||||
@staticmethod
|
||||
def generate_preview_image(data):
|
||||
"""生成 Base64 预览图"""
|
||||
img = LabelPrintService._create_image_object(data)
|
||||
output_buffer = BytesIO()
|
||||
img.save(output_buffer, format='JPEG', quality=95)
|
||||
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. 转换为灰度
|
||||
img_gray = img_rgb.convert('L')
|
||||
|
||||
# 3. 二值化处理
|
||||
img_bw = img_gray.point(lambda x: 0 if x < 128 else 255, '1')
|
||||
|
||||
# 4. 生成打印指令
|
||||
bitmap_data = img_bw.tobytes()
|
||||
width_bytes = (img_bw.width + 7) // 8
|
||||
height_dots = img_bw.height
|
||||
|
||||
# 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"
|
||||
|
||||
# 5. 发送 socket
|
||||
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)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pass
|
||||
114
inventory-backend/app/services/print/network_print_service.py
Normal file
114
inventory-backend/app/services/print/network_print_service.py
Normal file
@ -0,0 +1,114 @@
|
||||
import socket # .material -> .base refactor checked
|
||||
import datetime
|
||||
|
||||
|
||||
class NetworkPrintService:
|
||||
def __init__(self, ip='192.168.9.205', port=9100):
|
||||
"""
|
||||
初始化网络打印机服务
|
||||
:param ip: 打印机IP,默认 192.168.9.205
|
||||
:param port: 端口,默认 9100
|
||||
"""
|
||||
self.ip = ip
|
||||
self.port = port
|
||||
|
||||
def _send_to_printer(self, content):
|
||||
"""底层发送方法"""
|
||||
try:
|
||||
# 建立 Socket 连接
|
||||
with socket.socket(socket.socket.AF_INET, socket.socket.SOCK_STREAM) as s:
|
||||
s.settimeout(5) # 设置5秒超时
|
||||
s.connect((self.ip, self.port))
|
||||
|
||||
# 发送内容,使用 GB18030 编码以支持中文
|
||||
s.sendall(content.encode('gb18030'))
|
||||
|
||||
# 发送切纸指令 (ESC/POS: GS V m)
|
||||
# 十六进制: 1D 56 42 00
|
||||
s.sendall(b'\x1d\x56\x42\x00')
|
||||
|
||||
return True, "打印成功"
|
||||
except Exception as e:
|
||||
print(f"[NetworkPrint Error] {str(e)}")
|
||||
return False, f"打印失败: {str(e)}"
|
||||
|
||||
def print_outbound_selection(self, items):
|
||||
"""
|
||||
打印出库选单 (拣货单)
|
||||
:param items: 选中的物品列表
|
||||
"""
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
lines = []
|
||||
lines.append("\n")
|
||||
lines.append("********************************")
|
||||
lines.append(" 出库拣货确认单 ")
|
||||
lines.append("********************************")
|
||||
lines.append(f"打印时间: {timestamp}")
|
||||
lines.append(f"待出库总数: {len(items)} 件")
|
||||
lines.append("--------------------------------")
|
||||
lines.append(f"{'名称':<14}{'规格/批号':<10}")
|
||||
lines.append("--------------------------------")
|
||||
|
||||
for item in items:
|
||||
# 获取名称,优先取 material_name, 其次 product_name
|
||||
name = item.get('material_name') or item.get('product_name') or "未知物品"
|
||||
if len(name) > 14: name = name[:13] + "." # 名称过长截断
|
||||
|
||||
standard = item.get('standard', '')
|
||||
batch = item.get('batch_no', '')
|
||||
uuid = item.get('uuid', '')[-6:] # 只显示UUID后6位
|
||||
|
||||
lines.append(f"{name:<14} {standard}")
|
||||
lines.append(f"批号: {batch} | 尾号: {uuid}")
|
||||
lines.append("- - - - - - - - - - - - - - - -")
|
||||
|
||||
lines.append("\n")
|
||||
lines.append("库管员签字: ______________")
|
||||
lines.append("领料人签字: ______________")
|
||||
lines.append("\n\n\n") # 走纸
|
||||
|
||||
content = "\n".join(lines)
|
||||
return self._send_to_printer(content)
|
||||
|
||||
def print_stocktake_report(self, data):
|
||||
"""
|
||||
打印盘点统计报告
|
||||
:param data: 包含 total, scanned, missing, missing_items
|
||||
"""
|
||||
total = data.get('total', 0)
|
||||
scanned = data.get('scanned', 0)
|
||||
missing = data.get('missing', 0)
|
||||
missing_items = data.get('missing_items', [])
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
lines = []
|
||||
lines.append("\n")
|
||||
lines.append("================================")
|
||||
lines.append(" 库存盘点统计报告 ")
|
||||
lines.append("================================")
|
||||
lines.append(f"盘点时间: {timestamp}")
|
||||
lines.append(f"应盘总数: {total}")
|
||||
lines.append(f"实盘(已扫): {scanned}")
|
||||
lines.append(f"差异(未扫): {missing}")
|
||||
lines.append("--------------------------------")
|
||||
|
||||
if missing == 0:
|
||||
lines.append("【结果】: 账实相符,库存完美!")
|
||||
else:
|
||||
lines.append("【差异明细 (未扫码物品)】:")
|
||||
for item in missing_items:
|
||||
name = item.get('material_name') or item.get('product_name') or "未知"
|
||||
batch = item.get('batch_no', '-')
|
||||
# 兼容不同模型的字段
|
||||
code = item.get('uuid', item.get('bar_code', 'N/A'))[-6:]
|
||||
|
||||
lines.append(f"[ ] {name}")
|
||||
lines.append(f" 批:{batch} 码:{code}")
|
||||
|
||||
lines.append("\n")
|
||||
lines.append("监盘人: ______________")
|
||||
lines.append("\n\n\n")
|
||||
|
||||
content = "\n".join(lines)
|
||||
return self._send_to_printer(content)
|
||||
@ -1,39 +0,0 @@
|
||||
from app.extensions import db
|
||||
from app.models.stock import StockBuy
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
|
||||
def create_inbound_stock(data):
|
||||
"""
|
||||
处理采购入库逻辑
|
||||
"""
|
||||
try:
|
||||
# 1. 计算总价
|
||||
qty = data.get('qty_inbound')
|
||||
price = data.get('price_unit', 0)
|
||||
total = float(qty) * float(price)
|
||||
|
||||
# 2. 创建库存记录
|
||||
# 注意:入库时,当前库存(current)和可用库存(available)通常等于入库数量
|
||||
new_stock = StockBuy(
|
||||
material_id=data['material_id'],
|
||||
barcode=data.get('barcode'),
|
||||
batch_no=data.get('batch_no'),
|
||||
qty_inbound=qty,
|
||||
qty_current=qty, # 初始:当前=入库
|
||||
qty_available=qty, # 初始:可用=入库
|
||||
price_unit=price,
|
||||
price_total=total,
|
||||
supplier_name=data.get('supplier_name'),
|
||||
warehouse_loc=data.get('warehouse_loc'),
|
||||
inbound_date=data.get('inbound_date') # 如果前端没传,Model会默认用当前时间
|
||||
)
|
||||
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
|
||||
return new_stock
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
@ -0,0 +1,185 @@
|
||||
import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime
|
||||
from app.extensions import db
|
||||
from app.models.transaction import TransBorrow
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy import desc, func
|
||||
|
||||
|
||||
class TransService:
|
||||
|
||||
@staticmethod
|
||||
def generate_borrow_no():
|
||||
"""
|
||||
生成借用单号: BOR-yyyyMMdd-0001 (按日流水)
|
||||
逻辑:统计当天已存在的不同借用单号数量,+1 作为新序号
|
||||
"""
|
||||
now = datetime.now()
|
||||
date_str = now.strftime('%Y%m%d')
|
||||
prefix = f"BOR-{date_str}-"
|
||||
|
||||
# 使用 count distinct 来计算当天有多少个不同的借用单 (因为一单多货会占多行)
|
||||
count = db.session.query(func.count(func.distinct(TransBorrow.borrow_no))) \
|
||||
.filter(TransBorrow.borrow_no.like(f"{prefix}%")).scalar()
|
||||
|
||||
sequence = count + 1
|
||||
return f"{prefix}{sequence:04d}"
|
||||
|
||||
@staticmethod
|
||||
def create_borrow(data, operator_name='System'):
|
||||
"""
|
||||
借库逻辑:减少可用库存,不减总库存
|
||||
"""
|
||||
items = data.get('items', [])
|
||||
borrower_name = data.get('borrower_name')
|
||||
signature = data.get('signature_path') # 借用人签字
|
||||
|
||||
if not items: raise ValueError("物品列表为空")
|
||||
if not borrower_name: raise ValueError("请输入借用人")
|
||||
if not signature: raise ValueError("借用人必须签字")
|
||||
|
||||
borrow_no = TransService.generate_borrow_no()
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
source_table = item.get('source_table')
|
||||
stock_id = item.get('id')
|
||||
qty = float(item.get('out_quantity', 0))
|
||||
|
||||
ModelClass = model_map.get(source_table)
|
||||
if not ModelClass: continue
|
||||
|
||||
stock = ModelClass.query.with_for_update().get(stock_id)
|
||||
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
||||
|
||||
if float(stock.available_quantity) < qty:
|
||||
raise ValueError(f"SKU {stock.sku} 可用库存不足")
|
||||
|
||||
# 1. 冻结库存 (只减可用)
|
||||
stock.available_quantity = float(stock.available_quantity) - qty
|
||||
|
||||
# 2. 创建借用单
|
||||
record = TransBorrow(
|
||||
borrow_no=borrow_no,
|
||||
sku=stock.sku,
|
||||
source_table=source_table,
|
||||
stock_id=stock.id,
|
||||
barcode=stock.barcode,
|
||||
quantity=qty,
|
||||
borrower_name=borrower_name,
|
||||
borrow_signature=signature,
|
||||
remark=data.get('remark'),
|
||||
expected_return_time=data.get('expected_return_time'),
|
||||
status='borrowed',
|
||||
is_returned=False
|
||||
)
|
||||
db.session.add(record)
|
||||
|
||||
db.session.commit()
|
||||
return borrow_no
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def scan_for_return(barcode):
|
||||
"""
|
||||
扫码还库:查找未归还记录,并返回当前物品的库位
|
||||
"""
|
||||
records = TransBorrow.query.filter_by(barcode=barcode, is_returned=False).all()
|
||||
if not records:
|
||||
return None
|
||||
|
||||
# 取第一条未还记录
|
||||
record = records[0]
|
||||
|
||||
# 获取当前库存表中的实时库位
|
||||
current_location = ""
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.get(record.stock_id)
|
||||
if stock:
|
||||
current_location = stock.warehouse_location
|
||||
|
||||
res_dict = record.to_dict()
|
||||
res_dict['current_location'] = current_location # 用于前端对比和预填
|
||||
return res_dict
|
||||
|
||||
@staticmethod
|
||||
def process_return(data, operator_name):
|
||||
"""
|
||||
还库逻辑:
|
||||
1. 恢复可用库存
|
||||
2. 更新库位 (如果有变动)
|
||||
3. 记录库管签字
|
||||
"""
|
||||
items = data.get('items', [])
|
||||
signature = data.get('signature_path') # 库管签字
|
||||
|
||||
if not items: raise ValueError("还库列表为空")
|
||||
if not signature: raise ValueError("库管必须签字确认")
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||
|
||||
try:
|
||||
for item in items:
|
||||
borrow_id = item.get('id')
|
||||
# 前端如果没有填 return_location,应该在提交前处理好,或者这里做 fallback
|
||||
# 这里假设前端传来的 return_location 就是最终要保存的库位
|
||||
final_location = item.get('return_location')
|
||||
|
||||
record = TransBorrow.query.with_for_update().get(borrow_id)
|
||||
if not record or record.is_returned:
|
||||
continue
|
||||
|
||||
ModelClass = model_map.get(record.source_table)
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
# 1. 恢复可用库存
|
||||
stock.available_quantity = float(stock.available_quantity) + float(record.quantity)
|
||||
|
||||
# 2. 更新库位 (如果提供了有效值)
|
||||
if final_location:
|
||||
stock.warehouse_location = final_location
|
||||
|
||||
# 3. 更新借用单状态
|
||||
record.is_returned = True
|
||||
record.status = 'returned'
|
||||
record.return_time = datetime.now()
|
||||
record.return_operator = operator_name
|
||||
record.return_signature = signature
|
||||
record.return_location = final_location
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None):
|
||||
q = TransBorrow.query
|
||||
if status == 'borrowed':
|
||||
q = q.filter(TransBorrow.is_returned == False)
|
||||
elif status == 'returned':
|
||||
q = q.filter(TransBorrow.is_returned == True)
|
||||
|
||||
if keyword:
|
||||
q = q.filter(TransBorrow.borrower_name.ilike(f'%{keyword}%') |
|
||||
TransBorrow.sku.ilike(f'%{keyword}%') |
|
||||
TransBorrow.borrow_no.ilike(f'%{keyword}%'))
|
||||
|
||||
q = q.order_by(desc(TransBorrow.borrow_time))
|
||||
pagination = q.paginate(page=page, per_page=limit, error_out=False)
|
||||
|
||||
return {
|
||||
'items': [r.to_dict() for r in pagination.items],
|
||||
'total': pagination.total,
|
||||
'page': page,
|
||||
'limit': limit
|
||||
}
|
||||
|
||||
23
inventory-backend/app/utils/constants.py
Normal file
23
inventory-backend/app/utils/constants.py
Normal file
@ -0,0 +1,23 @@
|
||||
# app/utils/constants.py
|
||||
|
||||
class UserRole:
|
||||
SUPER_ADMIN = 'super_admin' # 超级管理员 (IRIS)
|
||||
SUPERVISOR = 'supervisor' # 主管
|
||||
FINANCE = 'finance' # 财务
|
||||
WAREHOUSE_MGR = 'warehouse_manager' # 库管
|
||||
INBOUND = 'inbound' # 入库员
|
||||
OUTBOUND = 'outbound' # 出库员
|
||||
PURCHASER = 'purchaser' # 采购员
|
||||
SALES = 'sales' # 销售
|
||||
|
||||
# 角色中文映射(用于前端展示或日志)
|
||||
ROLE_MAP = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
SUPERVISOR: '主管',
|
||||
FINANCE: '财务',
|
||||
WAREHOUSE_MGR: '库管',
|
||||
INBOUND: '入库员',
|
||||
OUTBOUND: '出库员',
|
||||
PURCHASER: '采购员',
|
||||
SALES: '销售'
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
# app/utils/decorators.py
|
||||
from functools import wraps
|
||||
from flask_jwt_extended import get_jwt
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def role_required(*roles):
|
||||
"""
|
||||
自定义装饰器:检查用户角色
|
||||
使用方法: @role_required('super_admin', 'finance')
|
||||
"""
|
||||
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
|
||||
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
|
||||
if user_role == 'super_admin':
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
if user_role not in roles:
|
||||
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return decorator
|
||||
|
||||
return wrapper
|
||||
@ -1,14 +1,43 @@
|
||||
import os
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
class Config:
|
||||
# 数据库连接配置
|
||||
# 请务必将 '你的密码' 替换为你 PostgreSQL 的真实密码
|
||||
# 如果数据库不在本地,请将 localhost 替换为 IP 地址
|
||||
SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:1234@localhost:5432/inventory_system'
|
||||
# =========================================================
|
||||
# 1. 基础路径与安全配置
|
||||
# =========================================================
|
||||
# 获取当前文件所在目录的绝对路径 (用于定位 uploads 文件夹等)
|
||||
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
# 关闭 SQLAlchemy 的事件追踪,减少内存消耗
|
||||
# Flask 的基础密钥 (用于 Session, Flash 消息等安全签名)
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-1234')
|
||||
|
||||
# =========================================================
|
||||
# 2. 数据库配置
|
||||
# =========================================================
|
||||
# 优先读取 .env 中的 'DATABASE_URL'。
|
||||
# 如果读不到,才回退使用默认的 localhost 连接字符串。
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
'DATABASE_URL',
|
||||
'postgresql://postgres:1234@localhost:5432/inventory_system'
|
||||
)
|
||||
|
||||
# 关闭 SQLAlchemy 的事件追踪,减少内存消耗 (推荐设为 False)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# Flask 的密钥,用于 Session 加密等,开发环境随便写一个即可
|
||||
SECRET_KEY = 'dev-secret-key-1234'
|
||||
# =========================================================
|
||||
# 3. JWT 配置 (修复 500 报错的核心区域)
|
||||
# =========================================================
|
||||
# 【核心】必须设置 JWT_SECRET_KEY,否则 create_access_token 会报错
|
||||
# 逻辑:优先读环境变量,读不到就用默认字符串
|
||||
JWT_SECRET_KEY = os.getenv('JWT_SECRET_KEY', 'default-jwt-secret-key-if-missing')
|
||||
|
||||
# 设置 Token 过期时间 (这里设为 1 天)
|
||||
JWT_ACCESS_TOKEN_EXPIRES = timedelta(days=1)
|
||||
|
||||
# =========================================================
|
||||
# 4. 文件上传配置
|
||||
# =========================================================
|
||||
# 上传文件存储路径
|
||||
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
|
||||
# 限制最大上传 16MB
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024
|
||||
@ -1,17 +0,0 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine # 使用轻量级的 Alpine 版本
|
||||
container_name: inventory_db
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: test # 自定义用户名
|
||||
POSTGRES_PASSWORD: 1234 # 自定义密码 (开发环境简单点没事)
|
||||
POSTGRES_DB: inventory_system # 默认创建的数据库名
|
||||
ports:
|
||||
- "5432:5432" # 将容器的5432端口映射到 WSL 的5432端口
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data # 【重要】数据持久化!防止重启容器数据丢失
|
||||
|
||||
# 这里以后可以加你的 pgadmin 或者 redis 等其他服务
|
||||
21
inventory-backend/gunicorn.conf.py
Normal file
21
inventory-backend/gunicorn.conf.py
Normal file
@ -0,0 +1,21 @@
|
||||
# inventory-backend/gunicorn.conf.py
|
||||
|
||||
import multiprocessing
|
||||
|
||||
# 原来的写法:根据 CPU 自动算,容易在强机上算太多
|
||||
# workers = multiprocessing.cpu_count() * 2 + 1
|
||||
|
||||
# --- 优化后的写法 ---
|
||||
# 我们设置一个上限:如果是开发环境或为了省资源,最多不超过 8 个
|
||||
# 这样既有并发能力(8个分身足够开发测试用了),又不会撑爆数据库
|
||||
cpu_calc = multiprocessing.cpu_count() * 2 + 1
|
||||
workers = min(cpu_calc, 8)
|
||||
|
||||
# 线程数保持不变
|
||||
threads = 2
|
||||
|
||||
bind = "0.0.0.0:8000"
|
||||
timeout = 120
|
||||
loglevel = 'info'
|
||||
accesslog = '-' # 输出到标准输出(Docker logs 能看到)
|
||||
errorlog = '-'
|
||||
@ -5,4 +5,12 @@ 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
|
||||
flask-cors==4.0.0
|
||||
# 图片处理核心库
|
||||
Pillow>=10.0.0
|
||||
# [旧] 条形码生成库 (建议保留,防止旧代码报错)
|
||||
python-barcode>=0.14.0
|
||||
# [新增] 二维码生成库 (标签打印必需,包含PIL支持)
|
||||
qrcode[pil]>=7.4.2
|
||||
# [新增] 必须添加,用于处理 token 登录
|
||||
Flask-JWT-Extended==4.6.0
|
||||
@ -1,6 +1,27 @@
|
||||
# inventory-backend/run.py
|
||||
from app import create_app
|
||||
|
||||
# Gunicorn 或 uWSGI 会寻找名为 'app' 的实例
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
# =================================================
|
||||
# 路由打印调试 (启动时会在控制台列出所有 URL)
|
||||
# 这一步能帮你确认 /api/inbound/base/list 是否存在
|
||||
# =================================================
|
||||
print("\n====== 当前生效的路由映射 ======")
|
||||
try:
|
||||
# 按 URL 排序打印,方便查找
|
||||
sorted_rules = sorted(app.url_map.iter_rules(), key=lambda x: str(x))
|
||||
for rule in sorted_rules:
|
||||
# 过滤掉一些系统自带的 static 路由,只显示 API
|
||||
if 'api' in str(rule):
|
||||
methods = ','.join(rule.methods - {'OPTIONS', 'HEAD'})
|
||||
print(f"{str(rule):<50} | {methods:<10} | {rule.endpoint}")
|
||||
except Exception:
|
||||
pass
|
||||
print("==============================\n")
|
||||
|
||||
# 启动开发服务器
|
||||
# 端口设置为 5000 (Flask 默认) 或 8000,请确保与前端 Vite 代理一致
|
||||
app.run(host='0.0.0.0', port=8000, debug=True)
|
||||
BIN
inventory-backend/simhei.ttf
Normal file
BIN
inventory-backend/simhei.ttf
Normal file
Binary file not shown.
3
inventory-web/.env.development
Normal file
3
inventory-web/.env.development
Normal file
@ -0,0 +1,3 @@
|
||||
# .env.development
|
||||
# 注意:这里必须写你电脑的局域网 IP
|
||||
VITE_API_BASE_URL=http://172.16.0.95:8000/api/v1
|
||||
31
inventory-web/Dockerfile
Normal file
31
inventory-web/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# ---------------------------------------
|
||||
# 这是开发模式 (Development Mode) 的配置
|
||||
# ---------------------------------------
|
||||
|
||||
# 1. 使用 Node 20 的 Alpine 版本 (轻量级)
|
||||
FROM node:20-alpine
|
||||
|
||||
# 【关键新增】安装 libc6 兼容库
|
||||
# 这一步能解决 90% 的 "Cannot find module ... musl.node" 或二进制文件缺失问题
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 2. 优先复制 package.json 和 lock 文件
|
||||
# 这样如果只改代码不改依赖,Docker 会利用缓存跳过安装步骤,构建更快
|
||||
COPY package*.json ./
|
||||
|
||||
# 3. 安装依赖
|
||||
# 这一步会在容器内部下载适合 Alpine Linux 的依赖包
|
||||
RUN npm install
|
||||
|
||||
# 4. 复制其余源代码
|
||||
COPY . .
|
||||
|
||||
# 5. 暴露端口 (仅作声明,方便查看)
|
||||
EXPOSE 5173
|
||||
|
||||
# 6. 启动开发服务器
|
||||
# 必须加 --host,否则只能在容器内部访问,无法通过浏览器 localhost 访问
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/jetbrains://idea/navigate/reference?project=inventory-web&path=public%2Firis.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>inventory-web</title>
|
||||
</head>
|
||||
|
||||
26
inventory-web/nginx.conf
Normal file
26
inventory-web/nginx.conf
Normal file
@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
gzip on;
|
||||
gzip_min_length 1k;
|
||||
gzip_comp_level 6;
|
||||
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript;
|
||||
|
||||
# 1. 前端页面
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 2. 后端接口代理
|
||||
location /api {
|
||||
# 'backend' 对应 docker-compose 里的服务名
|
||||
proxy_pass http://backend:8000;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
2289
inventory-web/package-lock.json
generated
2289
inventory-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -11,14 +11,20 @@
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.3",
|
||||
"cropperjs": "^1.6.2",
|
||||
"element-plus": "^2.13.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"pinia": "^3.0.4",
|
||||
"sass": "^1.97.3",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"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 +34,4 @@
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
inventory-web/public/iris.png
Normal file
BIN
inventory-web/public/iris.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@ -1,30 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||
import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute() // [新增] 获取当前路由对象
|
||||
const userStore = useUserStore()
|
||||
|
||||
// [新增] 计算属性:判断当前是否是登录页
|
||||
const isLoginPage = computed(() => {
|
||||
return route.path === '/login'
|
||||
})
|
||||
|
||||
// --- 退出登录逻辑 Start ---
|
||||
const handleLogout = () => {
|
||||
ElMessageBox.confirm(
|
||||
'确定要退出系统吗?',
|
||||
'提示',
|
||||
{
|
||||
confirmButtonText: '确定退出',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}
|
||||
)
|
||||
.then(async () => {
|
||||
// 1. 调用 Store 的 logout 清除状态
|
||||
userStore.logout()
|
||||
|
||||
// 2. 提示消息
|
||||
ElMessage({
|
||||
type: 'success',
|
||||
message: '已安全退出',
|
||||
})
|
||||
|
||||
// 3. 强制跳转回登录页
|
||||
await router.replace('/login')
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消操作
|
||||
})
|
||||
}
|
||||
// --- 退出登录逻辑 End ---
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src="/vite.svg" class="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
|
||||
</a>
|
||||
<div class="app-wrapper">
|
||||
<header v-if="!isLoginPage" class="app-header">
|
||||
<div class="logo-container">
|
||||
<router-link to="/" class="home-link">
|
||||
<img src="@/assets/iris.png" class="logo" alt="Logo" />
|
||||
<span class="system-title">IRIS 库存管理系统</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-profile">
|
||||
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
|
||||
<span class="user-name">{{ userStore.username || '管理员' }}</span>
|
||||
</div>
|
||||
|
||||
<el-divider direction="vertical" />
|
||||
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
@click="handleLogout"
|
||||
class="logout-btn"
|
||||
>
|
||||
<el-icon style="margin-right: 4px; font-size: 16px"><SwitchButton /></el-icon>
|
||||
退出
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<footer v-if="!isLoginPage" class="app-footer">
|
||||
<span class="version-tag">
|
||||
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
|
||||
当前版本: 1.0 Beta (测试版)
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
<HelloWorld msg="Vite + Vue" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
.app-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
height: 60px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #dcdfe6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
flex-shrink: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
height: 32px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
|
||||
.system-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.logo.vue:hover {
|
||||
filter: drop-shadow(0 0 2em #42b883aa);
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
font-weight: 400;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.logout-btn:hover {
|
||||
color: #f56c6c !important;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
height: 30px;
|
||||
background-color: #f0f2f5;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
52
inventory-web/src/api/auth.ts
Normal file
52
inventory-web/src/api/auth.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 登录
|
||||
export function login(data: any) {
|
||||
return request({
|
||||
url: '/v1/auth/login',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 创建用户 (管理员专用)
|
||||
export function createUser(data: any) {
|
||||
return request({
|
||||
url: '/v1/auth/user/create',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] 更新用户
|
||||
export function updateUser(id: number, data: any) {
|
||||
return request({
|
||||
url: `/v1/auth/user/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前登录用户信息
|
||||
export function getUserInfo() {
|
||||
return request({
|
||||
url: '/v1/auth/me',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取所有用户列表
|
||||
export function getUserList() {
|
||||
return request({
|
||||
url: '/v1/auth/users',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export function deleteUser(id: number) {
|
||||
return request({
|
||||
url: `/v1/auth/user/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
41
inventory-web/src/api/common/upload.ts
Normal file
41
inventory-web/src/api/common/upload.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 上传文件通用接口
|
||||
* @param data File 对象 或 FormData 对象
|
||||
* 适配说明:list.vue 中 customUpload 已经封装了 FormData,所以这里支持直接传 FormData
|
||||
*/
|
||||
export function uploadFile(data: File | FormData) {
|
||||
let formData: FormData
|
||||
|
||||
if (data instanceof FormData) {
|
||||
formData = data
|
||||
} else {
|
||||
// 如果传入的是原始 File 对象,则手动封装
|
||||
formData = new FormData()
|
||||
// @ts-ignore
|
||||
formData.append('file', data)
|
||||
}
|
||||
|
||||
return request({
|
||||
// 注意:这里 /v1/common/upload 需要与后端 BluePrint 注册的 url_prefix 对应
|
||||
url: '/v1/common/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件通用接口 (新增)
|
||||
* @param filename 文件名 (例如: a1b2c3d4.jpg)
|
||||
*/
|
||||
export function deleteFile(filename: string) {
|
||||
return request({
|
||||
// 对应后端路由: @upload_bp.route('/files/<filename>', methods=['DELETE'])
|
||||
url: `/v1/common/files/${filename}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
63
inventory-web/src/api/inbound/buy.ts
Normal file
63
inventory-web/src/api/inbound/buy.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 1. 获取列表
|
||||
export function getBuyList(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 新增入库
|
||||
export function createBuyInbound(data: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/submit',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 更新入库
|
||||
export function updateBuyInbound(id: number, data: any) {
|
||||
return request({
|
||||
url: `/inbound/buy/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 删除入库
|
||||
export function deleteBuyInbound(id: number) {
|
||||
return request({
|
||||
url: `/inbound/buy/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/buy/search-base',
|
||||
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/<filename>
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
18
inventory-web/src/api/inbound/inbound_summary.ts
Normal file
18
inventory-web/src/api/inbound/inbound_summary.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface InboundSummaryQuery {
|
||||
page: number
|
||||
per_page: number
|
||||
keyword?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
source_type?: string
|
||||
}
|
||||
|
||||
export function getInboundSummaryList(params: InboundSummaryQuery) {
|
||||
return request({
|
||||
url: '/v1/inbound/summary/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
42
inventory-web/src/api/inbound/product.ts
Normal file
42
inventory-web/src/api/inbound/product.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 注意 URL 已变为 /inbound/product/...
|
||||
|
||||
export function getProductList(params: any) {
|
||||
return request({
|
||||
url: '/inbound/product/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createProductInbound(data: any) {
|
||||
return request({
|
||||
url: '/inbound/product/submit',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateProductInbound(id: number, data: any) {
|
||||
return request({
|
||||
url: `/inbound/product/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteProductInbound(id: number) {
|
||||
return request({
|
||||
url: `/inbound/product/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function searchMaterialBase(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/product/search-base',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
45
inventory-web/src/api/inbound/semi.ts
Normal file
45
inventory-web/src/api/inbound/semi.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 1. 获取列表
|
||||
export function getSemiList(params: any) {
|
||||
return request({
|
||||
url: '/inbound/semi/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 新增入库
|
||||
export function createSemiInbound(data: any) {
|
||||
return request({
|
||||
url: '/inbound/semi/submit',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 更新入库
|
||||
export function updateSemiInbound(id: number, data: any) {
|
||||
return request({
|
||||
url: `/inbound/semi/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 删除入库
|
||||
export function deleteSemiInbound(id: number) {
|
||||
return request({
|
||||
url: `/inbound/semi/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/semi/search-base',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
113
inventory-web/src/api/inbound/service.ts
Normal file
113
inventory-web/src/api/inbound/service.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface ServiceItem {
|
||||
id: number
|
||||
base_id: number
|
||||
sku: string
|
||||
sale_price: number
|
||||
provider_name: string
|
||||
description: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
material_name?: string
|
||||
spec_model?: string
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export interface ServiceListResponse {
|
||||
code: number
|
||||
msg: string
|
||||
data: {
|
||||
items: ServiceItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServiceQueryParams {
|
||||
page?: number
|
||||
per_page?: number
|
||||
keyword?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
provider_name?: string
|
||||
}
|
||||
|
||||
export interface ServiceCreateRequest {
|
||||
base_id: number
|
||||
sale_price: number
|
||||
provider_name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ServiceUpdateRequest {
|
||||
sale_price?: number
|
||||
provider_name?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface MaterialBaseItem {
|
||||
id: number
|
||||
name: string
|
||||
spec: string
|
||||
category: string
|
||||
unit: string
|
||||
type: string
|
||||
}
|
||||
|
||||
// 获取服务权益列表
|
||||
export function getServiceList(params: ServiceQueryParams) {
|
||||
return request<ServiceListResponse>({
|
||||
url: '/v1/inbound/service',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 创建服务权益
|
||||
export function createService(data: ServiceCreateRequest) {
|
||||
return request({
|
||||
url: '/v1/inbound/service',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取服务权益详情
|
||||
export function getServiceDetail(id: number) {
|
||||
return request<ServiceListResponse>({
|
||||
url: `/v1/inbound/service/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新服务权益
|
||||
export function updateService(id: number, data: ServiceUpdateRequest) {
|
||||
return request({
|
||||
url: `/v1/inbound/service/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string) {
|
||||
return request<{
|
||||
code: number
|
||||
msg: string
|
||||
data: MaterialBaseItem[]
|
||||
}>({
|
||||
url: '/v1/inbound/service/search-base',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 删除服务权益
|
||||
export function deleteService(id: number) {
|
||||
return request({
|
||||
url: `/v1/inbound/service/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
65
inventory-web/src/api/inbound/stock.ts
Normal file
65
inventory-web/src/api/inbound/stock.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取全量库存
|
||||
// 修改前: url: '/api/v1/inbound/stock/all'
|
||||
// 修改后: url: '/v1/inbound/stock/all'
|
||||
export function getAllStock() {
|
||||
return request({
|
||||
url: '/v1/inbound/stock/all',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 打印出库选单
|
||||
// 修改后: 去掉开头的 /api
|
||||
export function printSelectionList(items: any[]) {
|
||||
return request({
|
||||
url: '/v1/inbound/stock/print/selection',
|
||||
method: 'post',
|
||||
data: { items }
|
||||
})
|
||||
}
|
||||
|
||||
// 打印盘点报告
|
||||
// 修改后: 去掉开头的 /api
|
||||
export function printStocktakeReport(data: any) {
|
||||
return request({
|
||||
url: '/v1/inbound/stock/print/stocktake',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 保存 BOM 结构
|
||||
export function saveBom(data: { parent_id: number; children: any[] }) {
|
||||
return request({
|
||||
url: '/v1/bom',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取基础物料列表
|
||||
export function getMaterialBaseList(params?: any) {
|
||||
return request({
|
||||
url: '/v1/bom/base/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 BOM 父件列表
|
||||
export function getBomParents() {
|
||||
return request({
|
||||
url: '/v1/bom/parents',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取指定BOM详情
|
||||
export function getBom(parentId: number) {
|
||||
return request({
|
||||
url: `/v1/bom/${parentId}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
45
inventory-web/src/api/material_base.ts
Normal file
45
inventory-web/src/api/material_base.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 1. 获取基础信息列表
|
||||
export function listMaterialBase(params: any) {
|
||||
return request({
|
||||
url: '/inbound/base/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 新增基础信息
|
||||
export function addMaterialBase(data: any) {
|
||||
return request({
|
||||
url: '/inbound/base/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 修改基础信息 (包含状态启用/禁用)
|
||||
// 【修复点】: 必须在 URL 中拼接 data.id,否则后端会报 405 Method Not Allowed
|
||||
export function updateMaterialBase(data: any) {
|
||||
return request({
|
||||
url: `/inbound/base/${data.id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 删除基础信息
|
||||
export function delMaterialBase(id: number) {
|
||||
return request({
|
||||
url: `/inbound/base/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 获取详情 (可选,用于编辑回显)
|
||||
export function getMaterialBase(id: number) {
|
||||
return request({
|
||||
url: `/inbound/base/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
80
inventory-web/src/api/outbound.ts
Normal file
80
inventory-web/src/api/outbound.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 购物车商品项接口
|
||||
export interface CartItem {
|
||||
id: number
|
||||
sku: string
|
||||
name: string
|
||||
spec_model: string
|
||||
source_table: string
|
||||
stock_quantity: number
|
||||
available_quantity: number
|
||||
barcode: string
|
||||
price: number // 单价
|
||||
out_quantity: number // 本次出库数量
|
||||
}
|
||||
|
||||
// 提交出库单的数据结构
|
||||
export interface OutboundSubmitData {
|
||||
items: Array<{
|
||||
sku: string
|
||||
source_table: string
|
||||
stock_id: number
|
||||
barcode: string
|
||||
quantity: number
|
||||
price: number
|
||||
}>
|
||||
outbound_type: string
|
||||
consumer_name: string
|
||||
operator_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
|
||||
barcode?: string
|
||||
price?: number // 扫描返回的价格
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条码获取库存物品详情
|
||||
* @param barcode 扫描到的条码
|
||||
*/
|
||||
export function getStockByBarcode(barcode: string) {
|
||||
return request<any, ScanResult>({
|
||||
url: '/v1/outbound/scan',
|
||||
method: 'get',
|
||||
params: { barcode }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交出库单 (批量)
|
||||
*/
|
||||
export function submitOutbound(data: OutboundSubmitData) {
|
||||
return request({
|
||||
url: '/v1/outbound',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取出库记录列表
|
||||
*/
|
||||
export function getOutboundList(params: any) {
|
||||
return request({
|
||||
url: '/v1/outbound',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
0
inventory-web/src/api/transaction.ts
Normal file
0
inventory-web/src/api/transaction.ts
Normal file
BIN
inventory-web/src/assets/iris.png
Normal file
BIN
inventory-web/src/assets/iris.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
553
inventory-web/src/components/Camera/WebRtcCamera.vue
Normal file
553
inventory-web/src/components/Camera/WebRtcCamera.vue
Normal file
@ -0,0 +1,553 @@
|
||||
<template>
|
||||
<div class="camera-container is-fullscreen">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
<el-button style="margin-top: 20px" @click="handleCancel">关闭</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="media-box">
|
||||
<video
|
||||
v-show="!imgSrc"
|
||||
ref="videoRef"
|
||||
autoplay
|
||||
playsinline
|
||||
class="media-content video-feed"
|
||||
:style="{ transform: `scale(${cameraZoom})` }"
|
||||
></video>
|
||||
|
||||
<div v-show="imgSrc" class="editor-container">
|
||||
<img
|
||||
ref="previewImgRef"
|
||||
:src="imgSrc"
|
||||
class="media-content preview-img"
|
||||
alt="Photo Preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<canvas ref="canvasRef" style="display: none;"></canvas>
|
||||
</div>
|
||||
|
||||
<div v-if="!imgSrc && isCameraReady" class="zoom-slider-container">
|
||||
<el-icon class="zoom-icon"><Remove /></el-icon>
|
||||
<el-slider
|
||||
v-model="cameraZoom"
|
||||
:min="1"
|
||||
:max="3"
|
||||
:step="0.1"
|
||||
:show-tooltip="false"
|
||||
class="custom-slider"
|
||||
/>
|
||||
<el-icon class="zoom-icon"><CirclePlus /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="camera-actions">
|
||||
|
||||
<template v-if="!imgSrc">
|
||||
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
circle
|
||||
type="danger"
|
||||
size="large"
|
||||
@click="capture"
|
||||
:disabled="!isCameraReady"
|
||||
class="shutter-btn"
|
||||
>
|
||||
<div class="shutter-inner"></div>
|
||||
</el-button>
|
||||
|
||||
<div class="placeholder-btn"></div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<div v-if="isEditing" class="edit-mode-bar">
|
||||
<div class="edit-tools">
|
||||
<el-tooltip content="切换移动图片/调整裁剪框" placement="top" :show-after="1000">
|
||||
<el-button
|
||||
circle
|
||||
@click="toggleDragMode"
|
||||
class="tool-btn"
|
||||
:class="{ 'is-active': isMoveMode }"
|
||||
>
|
||||
<el-icon><Rank /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-divider direction="vertical" border-style="dashed" />
|
||||
|
||||
<el-button circle @click="zoomCropper(0.1)" class="tool-btn"><el-icon><ZoomIn /></el-icon></el-button>
|
||||
<el-button circle @click="zoomCropper(-0.1)" class="tool-btn"><el-icon><ZoomOut /></el-icon></el-button>
|
||||
|
||||
<el-button circle @click="rotateLeft" class="tool-btn"><el-icon><RefreshLeft /></el-icon></el-button>
|
||||
<el-button circle @click="rotateRight" class="tool-btn"><el-icon><RefreshRight /></el-icon></el-button>
|
||||
<el-button circle @click="resetCrop" class="tool-btn"><el-icon><Refresh /></el-icon></el-button>
|
||||
</div>
|
||||
|
||||
<div class="edit-confirm">
|
||||
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button @click="stopEdit" class="text-btn">取消编辑</el-button>
|
||||
<el-button type="success" @click="confirmUse" class="confirm-btn">
|
||||
完成并上传
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="preview-mode-bar">
|
||||
<el-button circle size="large" @click="handleCancel" class="action-btn icon-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
|
||||
<el-button @click="retake" size="large" class="text-btn" style="min-width: 80px;">重拍</el-button>
|
||||
|
||||
<el-button @click="startEdit" size="large" class="text-btn" style="min-width: 80px;">
|
||||
<el-icon style="margin-right: 4px"><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
type="success"
|
||||
@click="confirmUse"
|
||||
size="large"
|
||||
class="confirm-btn"
|
||||
>
|
||||
确认使用 <el-icon class="el-icon--right"><Check /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Camera, RefreshLeft, RefreshRight, Check, Close, Refresh, Edit,
|
||||
ZoomIn, ZoomOut, Remove, CirclePlus, Rank
|
||||
} from '@element-plus/icons-vue'
|
||||
import Cropper from 'cropperjs'
|
||||
import 'cropperjs/dist/cropper.css'
|
||||
|
||||
const emit = defineEmits(['photo-submit', 'cancel'])
|
||||
|
||||
const videoRef = ref<HTMLVideoElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const previewImgRef = ref<HTMLImageElement>()
|
||||
const mediaStream = ref<MediaStream>()
|
||||
const error = ref('')
|
||||
const isCameraReady = ref(false)
|
||||
|
||||
const imgSrc = ref('')
|
||||
const currentFile = ref<File | null>(null)
|
||||
|
||||
const isEditing = ref(false)
|
||||
const isMoveMode = ref(false)
|
||||
const cameraZoom = ref(1) // 控制拍摄时的变焦倍数
|
||||
let cropper: Cropper | null = null
|
||||
|
||||
const startCamera = async () => {
|
||||
stopCamera()
|
||||
error.value = ''
|
||||
imgSrc.value = ''
|
||||
isEditing.value = false
|
||||
currentFile.value = null
|
||||
cameraZoom.value = 1
|
||||
|
||||
try {
|
||||
const constraints = {
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 }
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints)
|
||||
mediaStream.value = stream
|
||||
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = stream
|
||||
await videoRef.value.play()
|
||||
isCameraReady.value = true
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err)
|
||||
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
|
||||
error.value = '无法访问摄像头: 请使用 HTTPS 环境。'
|
||||
} else {
|
||||
error.value = '无法访问摄像头: ' + (err.message || '请检查权限')
|
||||
}
|
||||
ElMessage.error(error.value)
|
||||
}
|
||||
}
|
||||
|
||||
const stopCamera = () => {
|
||||
if (mediaStream.value) {
|
||||
mediaStream.value.getTracks().forEach(track => track.stop())
|
||||
mediaStream.value = undefined
|
||||
}
|
||||
if (videoRef.value) {
|
||||
videoRef.value.srcObject = null
|
||||
}
|
||||
isCameraReady.value = false
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 核心修复:拍照时应用数码变焦(裁剪+拉伸)
|
||||
// ----------------------------------------------------
|
||||
const capture = () => {
|
||||
const video = videoRef.value
|
||||
const canvas = canvasRef.value
|
||||
if (!video || !canvas) return
|
||||
|
||||
// 1. 获取视频原始尺寸
|
||||
const vW = video.videoWidth
|
||||
const vH = video.videoHeight
|
||||
if (vW === 0 || vH === 0) return
|
||||
|
||||
// 2. 设置画布为全尺寸(保持清晰度)
|
||||
canvas.width = vW
|
||||
canvas.height = vH
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
// 3. 计算基于 zoomLevel 的裁剪区域
|
||||
// zoom = 1: 裁剪宽 = vW
|
||||
// zoom = 2: 裁剪宽 = vW / 2
|
||||
const zoom = cameraZoom.value
|
||||
const cropW = vW / zoom
|
||||
const cropH = vH / zoom
|
||||
|
||||
// 4. 计算裁剪的起始点 (居中裁剪)
|
||||
const cropX = (vW - cropW) / 2
|
||||
const cropY = (vH - cropH) / 2
|
||||
|
||||
// 5. 将裁剪区域绘制到全尺寸画布上 (drawImage(source, sx, sy, sw, sh, dx, dy, dw, dh))
|
||||
ctx.drawImage(
|
||||
video,
|
||||
cropX, cropY, cropW, cropH, // 源:截取中心部分
|
||||
0, 0, vW, vH // 目标:铺满整个画布
|
||||
)
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('拍照失败,请重试')
|
||||
return
|
||||
}
|
||||
|
||||
const timestamp = new Date().getTime()
|
||||
const filename = `photo_${timestamp}.jpg`
|
||||
currentFile.value = new File([blob], filename, { type: 'image/jpeg' })
|
||||
|
||||
imgSrc.value = URL.createObjectURL(blob)
|
||||
stopCamera()
|
||||
}, 'image/jpeg', 0.95)
|
||||
}
|
||||
|
||||
const retake = () => {
|
||||
destroyCropper()
|
||||
isEditing.value = false
|
||||
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
|
||||
imgSrc.value = ''
|
||||
currentFile.value = null
|
||||
startCamera()
|
||||
}
|
||||
|
||||
const startEdit = () => {
|
||||
if (!imgSrc.value || !previewImgRef.value) return
|
||||
isEditing.value = true
|
||||
isMoveMode.value = false
|
||||
|
||||
nextTick(() => {
|
||||
if (cropper) cropper.destroy()
|
||||
|
||||
cropper = new Cropper(previewImgRef.value!, {
|
||||
viewMode: 1,
|
||||
dragMode: 'none',
|
||||
autoCropArea: 0.8,
|
||||
background: false,
|
||||
modal: true,
|
||||
guides: true,
|
||||
highlight: false,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: false,
|
||||
movable: true,
|
||||
zoomable: true,
|
||||
rotatable: true,
|
||||
scalable: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const toggleDragMode = () => {
|
||||
if (!cropper) return
|
||||
isMoveMode.value = !isMoveMode.value
|
||||
cropper.setDragMode(isMoveMode.value ? 'move' : 'none')
|
||||
}
|
||||
|
||||
const stopEdit = () => {
|
||||
destroyCropper()
|
||||
isEditing.value = false
|
||||
}
|
||||
|
||||
const destroyCropper = () => {
|
||||
if (cropper) {
|
||||
cropper.destroy()
|
||||
cropper = null
|
||||
}
|
||||
}
|
||||
|
||||
const rotateLeft = () => cropper?.rotate(-90)
|
||||
const rotateRight = () => cropper?.rotate(90)
|
||||
const resetCrop = () => {
|
||||
cropper?.reset()
|
||||
isMoveMode.value = false
|
||||
cropper?.setDragMode('none')
|
||||
}
|
||||
const zoomCropper = (ratio: number) => cropper?.zoom(ratio)
|
||||
|
||||
const confirmUse = () => {
|
||||
console.log('👆 确认使用')
|
||||
|
||||
if (isEditing.value && cropper) {
|
||||
const croppedCanvas = cropper.getCroppedCanvas({
|
||||
imageSmoothingQuality: 'high'
|
||||
})
|
||||
|
||||
if (!croppedCanvas) {
|
||||
ElMessage.error('图片处理失败')
|
||||
return
|
||||
}
|
||||
|
||||
croppedCanvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
ElMessage.error('文件生成失败')
|
||||
return
|
||||
}
|
||||
const timestamp = new Date().getTime()
|
||||
const filename = `photo_crop_${timestamp}.jpg`
|
||||
const file = new File([blob], filename, { type: 'image/jpeg' })
|
||||
|
||||
emitFile(file)
|
||||
}, 'image/jpeg', 0.9)
|
||||
}
|
||||
else if (currentFile.value) {
|
||||
emitFile(currentFile.value)
|
||||
}
|
||||
else {
|
||||
ElMessage.warning('没有可用的照片')
|
||||
}
|
||||
}
|
||||
|
||||
const emitFile = (file: File) => {
|
||||
try {
|
||||
console.log('📤 提交文件:', file.name, (file.size/1024).toFixed(1)+'KB')
|
||||
emit('photo-submit', file)
|
||||
} catch (err) {
|
||||
console.error('父组件处理事件失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
destroyCropper()
|
||||
stopCamera()
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
onMounted(() => startCamera())
|
||||
onBeforeUnmount(() => {
|
||||
destroyCropper()
|
||||
stopCamera()
|
||||
if (imgSrc.value) URL.revokeObjectURL(imgSrc.value)
|
||||
})
|
||||
|
||||
defineExpose({ startCamera, stopCamera })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-container.is-fullscreen {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background-color: #000;
|
||||
z-index: 9999;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #fff;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 媒体显示区 */
|
||||
.media-box {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.media-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.2s ease-out; /* 变焦平滑动画 */
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.editor-container { width: 100%; height: 100%; }
|
||||
.preview-img { display: block; max-width: 100%; max-height: 100%; }
|
||||
|
||||
/* 变焦滑块 */
|
||||
.zoom-slider-container {
|
||||
position: absolute;
|
||||
bottom: 150px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 70%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 20;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 5px 15px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.zoom-icon { color: #fff; font-size: 18px; }
|
||||
.custom-slider { flex: 1; }
|
||||
:deep(.el-slider__runway) { background-color: #555; }
|
||||
:deep(.el-slider__bar) { background-color: #fff; }
|
||||
:deep(.el-slider__button) { border-color: #fff; }
|
||||
|
||||
/* 底部操作栏 */
|
||||
.camera-actions {
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding-bottom: 10px;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* 拍照按钮布局 */
|
||||
.camera-actions:has(.shutter-btn) {
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.placeholder-btn { width: 40px; }
|
||||
|
||||
/* 按钮样式 */
|
||||
.action-btn { background: rgba(255, 255, 255, 0.15); border: none; color: #fff; }
|
||||
|
||||
.text-btn {
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
font-size: 14px;
|
||||
}
|
||||
.confirm-btn { min-width: 120px; font-weight: bold; }
|
||||
|
||||
/* 快门按钮 */
|
||||
.shutter-btn {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border: 4px solid #fff;
|
||||
background: transparent !important;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.shutter-inner {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.shutter-btn:active .shutter-inner {
|
||||
transform: scale(0.9);
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 预览模式操作栏 */
|
||||
.preview-mode-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* 编辑模式操作栏 */
|
||||
.edit-mode-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
.edit-tools {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.tool-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.tool-btn.is-active {
|
||||
background-color: #409EFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.edit-confirm {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Cropper 样式定制 */
|
||||
:deep(.cropper-view-box) {
|
||||
outline: 3px solid #409EFF;
|
||||
outline-color: #409EFF;
|
||||
}
|
||||
:deep(.cropper-point) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #409EFF;
|
||||
opacity: 0.9;
|
||||
}
|
||||
:deep(.cropper-line) {
|
||||
background-color: rgba(64, 158, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
357
inventory-web/src/components/QrScanner/index.vue
Normal file
357
inventory-web/src/components/QrScanner/index.vue
Normal file
@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div class="qr-scanner-container">
|
||||
<div id="qr-reader" class="scanner-box"></div>
|
||||
|
||||
<div v-if="errorMsg" class="error-msg">{{ errorMsg }}</div>
|
||||
|
||||
<div class="focus-tip" v-if="!errorMsg && !isPaused">
|
||||
<div class="scan-line"></div>
|
||||
<div class="scan-text">将条码置于镜头范围内即可</div>
|
||||
</div>
|
||||
|
||||
<div class="focus-tip success" v-if="isPaused">
|
||||
<div class="scan-text-success">
|
||||
<el-icon><CircleCheckFilled /></el-icon>
|
||||
扫描成功,2秒后继续...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasZoom" class="zoom-control">
|
||||
<span class="zoom-icon">-</span>
|
||||
<input
|
||||
type="range"
|
||||
:min="zoomMin"
|
||||
:max="zoomMax"
|
||||
step="0.1"
|
||||
v-model="currentZoom"
|
||||
@input="handleZoom"
|
||||
/>
|
||||
<span class="zoom-icon">+</span>
|
||||
<div class="zoom-value">{{ currentZoom }}x</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||
import { CircleCheckFilled } from '@element-plus/icons-vue'
|
||||
|
||||
const emit = defineEmits(['decode', 'error'])
|
||||
|
||||
const errorMsg = ref('')
|
||||
const isPaused = ref(false)
|
||||
let html5QrCode: Html5Qrcode | null = null
|
||||
const scannerElementId = "qr-reader"
|
||||
|
||||
// 变焦控制状态
|
||||
const hasZoom = ref(false)
|
||||
const zoomMin = ref(1)
|
||||
const zoomMax = ref(5)
|
||||
const currentZoom = ref(1)
|
||||
|
||||
// 音频上下文
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
// 提示音播放函数
|
||||
const playBeep = () => {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
|
||||
if (!audioCtx) {
|
||||
audioCtx = new AudioContext();
|
||||
}
|
||||
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
}
|
||||
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
// 三角波,清脆响亮
|
||||
oscillator.type = 'triangle';
|
||||
oscillator.frequency.value = 1500;
|
||||
|
||||
gainNode.gain.setValueAtTime(1.0, audioCtx.currentTime);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioCtx.currentTime + 0.1);
|
||||
|
||||
} catch (e) {
|
||||
console.error("播放提示音失败:", e);
|
||||
}
|
||||
};
|
||||
|
||||
const startScanning = async () => {
|
||||
try {
|
||||
html5QrCode = new Html5Qrcode(scannerElementId, {
|
||||
useBarCodeDetectorIfSupported: true,
|
||||
formatsToSupport: [
|
||||
Html5QrcodeSupportedFormats.CODE_128,
|
||||
Html5QrcodeSupportedFormats.QR_CODE
|
||||
],
|
||||
verbose: false
|
||||
})
|
||||
|
||||
const config = {
|
||||
fps: 20,
|
||||
disableFlip: false,
|
||||
videoConstraints: {
|
||||
facingMode: "environment",
|
||||
// ★★★ 核心修改:设置为 2K (QHD) 分辨率 ★★★
|
||||
// min: 1280x720 (保证低端机能启动)
|
||||
// ideal: 2560x1440 (2K QHD,清晰度与性能的平衡点)
|
||||
width: { min: 1280, ideal: 2560, max: 3840 },
|
||||
height: { min: 720, ideal: 1440, max: 2160 },
|
||||
// 16:9 的比例
|
||||
aspectRatio: { ideal: 1.7777777778 },
|
||||
focusMode: "continuous",
|
||||
advanced: [{ focusMode: "macro" }, { zoom: 2.0 }]
|
||||
}
|
||||
}
|
||||
|
||||
await html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
config,
|
||||
(decodedText) => {
|
||||
if (isPaused.value) return
|
||||
console.log(`Scan: ${decodedText}`)
|
||||
|
||||
isPaused.value = true
|
||||
|
||||
playBeep();
|
||||
|
||||
emit('decode', decodedText)
|
||||
|
||||
if (navigator.vibrate) navigator.vibrate(200);
|
||||
|
||||
setTimeout(() => {
|
||||
isPaused.value = false
|
||||
}, 2000)
|
||||
},
|
||||
(errorMessage) => {
|
||||
// ignore
|
||||
}
|
||||
)
|
||||
|
||||
checkZoomCapability()
|
||||
|
||||
} catch (err: any) {
|
||||
let msg = '无法启动摄像头'
|
||||
console.error("Scanner Error:", err)
|
||||
if (err.name === 'OverconstrainedError') {
|
||||
msg = '摄像头不支持 2K 分辨率,请尝试降低配置'
|
||||
}
|
||||
errorMsg.value = msg
|
||||
emit('error', msg)
|
||||
}
|
||||
}
|
||||
|
||||
const checkZoomCapability = () => {
|
||||
if (!html5QrCode) return
|
||||
|
||||
try {
|
||||
const videoTrack = html5QrCode.getRunningTrackCameraCapabilities() as MediaTrackCapabilities;
|
||||
|
||||
// @ts-ignore
|
||||
if (videoTrack && 'zoom' in videoTrack) {
|
||||
hasZoom.value = true
|
||||
// @ts-ignore
|
||||
zoomMin.value = videoTrack.zoom.min || 1
|
||||
// @ts-ignore
|
||||
zoomMax.value = videoTrack.zoom.max || 5
|
||||
// @ts-ignore
|
||||
currentZoom.value = videoTrack.zoom.min || 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("无法获取变焦能力", e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleZoom = () => {
|
||||
if (!html5QrCode) return
|
||||
|
||||
try {
|
||||
html5QrCode.applyVideoConstraints({
|
||||
advanced: [{ zoom: Number(currentZoom.value) }]
|
||||
})
|
||||
} catch (e) {
|
||||
console.error("变焦失败", e)
|
||||
}
|
||||
}
|
||||
|
||||
const stopScanning = async () => {
|
||||
if (html5QrCode) {
|
||||
try {
|
||||
if (html5QrCode.isScanning) {
|
||||
await html5QrCode.stop()
|
||||
}
|
||||
html5QrCode.clear()
|
||||
} catch (e) {
|
||||
console.error("Stop failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioCtx) {
|
||||
audioCtx.close();
|
||||
audioCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
if (AudioContext) audioCtx = new AudioContext();
|
||||
} catch(e) {}
|
||||
|
||||
setTimeout(() => {
|
||||
startScanning()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopScanning()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qr-scanner-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.scanner-box {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(#qr-reader) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:deep(#qr-reader video) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
background: rgba(245, 108, 108, 0.85);
|
||||
padding: 15px;
|
||||
transform: translateY(-50%);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.focus-tip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.focus-tip.success {
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
}
|
||||
|
||||
.scan-line {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgba(255, 0, 0, 0.5);
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.8);
|
||||
position: absolute;
|
||||
animation: scan-move 2.5s infinite linear;
|
||||
}
|
||||
|
||||
.scan-text {
|
||||
position: absolute;
|
||||
bottom: 150px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 14px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.scan-text-success {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.8);
|
||||
background: rgba(103, 194, 58, 0.9);
|
||||
padding: 15px 30px;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
@keyframes scan-move {
|
||||
0% { top: 0%; opacity: 0; }
|
||||
10% { opacity: 1; }
|
||||
90% { opacity: 1; }
|
||||
100% { top: 100%; opacity: 0; }
|
||||
}
|
||||
|
||||
.zoom-control {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80%;
|
||||
max-width: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 10px 20px;
|
||||
border-radius: 30px;
|
||||
z-index: 50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.zoom-control input[type=range] {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.zoom-icon {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.zoom-value {
|
||||
font-size: 14px;
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
135
inventory-web/src/components/Signature/index.vue
Normal file
135
inventory-web/src/components/Signature/index.vue
Normal file
@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<div class="signature-container">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
@mousedown="startDrawing"
|
||||
@mousemove="draw"
|
||||
@mouseup="stopDrawing"
|
||||
@mouseleave="stopDrawing"
|
||||
@touchstart.prevent="startDrawing"
|
||||
@touchmove.prevent="draw"
|
||||
@touchend.prevent="stopDrawing"
|
||||
></canvas>
|
||||
<div class="actions">
|
||||
<el-button size="small" @click="clear">重签</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const isDrawing = ref(false)
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||
|
||||
// 初始化 Canvas
|
||||
onMounted(() => {
|
||||
if (canvasRef.value) {
|
||||
const canvas = canvasRef.value
|
||||
// 设置画布大小 (可以根据父容器调整)
|
||||
canvas.width = canvas.offsetWidth
|
||||
canvas.height = 300 // 固定高度
|
||||
ctx.value = canvas.getContext('2d')
|
||||
if (ctx.value) {
|
||||
ctx.value.lineWidth = 3
|
||||
ctx.value.lineCap = 'round'
|
||||
ctx.value.strokeStyle = '#000'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 获取坐标 (兼容鼠标和触摸)
|
||||
const getPos = (e: MouseEvent | TouchEvent) => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return { x: 0, y: 0 }
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
let clientX, clientY
|
||||
|
||||
if ('touches' in e) {
|
||||
clientX = e.touches[0].clientX
|
||||
clientY = e.touches[0].clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
clientY = (e as MouseEvent).clientY
|
||||
}
|
||||
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
}
|
||||
}
|
||||
|
||||
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||
isDrawing.value = true
|
||||
const { x, y } = getPos(e)
|
||||
ctx.value?.beginPath()
|
||||
ctx.value?.moveTo(x, y)
|
||||
}
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
if (!isDrawing.value) return
|
||||
const { x, y } = getPos(e)
|
||||
ctx.value?.lineTo(x, y)
|
||||
ctx.value?.stroke()
|
||||
}
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing.value = false
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
if (canvasRef.value && ctx.value) {
|
||||
ctx.value.clearRect(0, 0, canvasRef.value.width, canvasRef.value.height)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出签名为 File 对象
|
||||
*/
|
||||
const generateFile = (): Promise<File | null> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!canvasRef.value) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
canvasRef.value.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
|
||||
resolve(file)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
clear,
|
||||
generateFile
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.signature-container {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #f5f7fa;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
canvas {
|
||||
display: block;
|
||||
width: 100%; /* 响应式宽度 */
|
||||
height: 300px;
|
||||
cursor: crosshair;
|
||||
background: #fff;
|
||||
}
|
||||
.actions {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
</style>
|
||||
36
inventory-web/src/layout/components/AppMain.vue
Normal file
36
inventory-web/src/layout/components/AppMain.vue
Normal file
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<section class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade-transform" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-main {
|
||||
/* 确保占满容器 */
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 简单的页面切换动画 */
|
||||
.fade-transform-leave-active,
|
||||
.fade-transform-enter-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.fade-transform-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
.fade-transform-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
</style>
|
||||
95
inventory-web/src/layout/components/Sidebar/index.vue
Normal file
95
inventory-web/src/layout/components/Sidebar/index.vue
Normal file
@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
:unique-opened="true"
|
||||
router
|
||||
class="el-menu-vertical"
|
||||
>
|
||||
<template v-for="route in menuRoutes" :key="route.path">
|
||||
|
||||
<el-menu-item
|
||||
v-if="!route.children || route.children.length === 1"
|
||||
:index="resolvePath(route)"
|
||||
>
|
||||
<el-icon v-if="getMeta(route).icon">
|
||||
<component :is="getMeta(route).icon" />
|
||||
</el-icon>
|
||||
<span>{{ getMeta(route).title }}</span>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu v-else :index="route.path">
|
||||
<template #title>
|
||||
<el-icon v-if="route.meta && route.meta.icon">
|
||||
<component :is="route.meta.icon" />
|
||||
</el-icon>
|
||||
<span>{{ route.meta?.title }}</span>
|
||||
</template>
|
||||
|
||||
<el-menu-item
|
||||
v-for="child in route.children"
|
||||
:key="child.path"
|
||||
:index="resolvePath(route, child)"
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ child.meta?.title }}</span>
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
</template>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 1. 获取当前激活的菜单路径
|
||||
const activeMenu = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
// 2. 获取需要在菜单中显示的路由(过滤掉 hidden 的路由)
|
||||
const menuRoutes = computed(() => {
|
||||
return router.options.routes.filter((r: any) => !r.meta?.hidden)
|
||||
})
|
||||
|
||||
// 3. 辅助函数:获取 meta 信息
|
||||
const getMeta = (route: any) => {
|
||||
if (route.meta) return route.meta
|
||||
// 如果是 layout 嵌套层(如首页),取第一个子路由的 meta
|
||||
if (route.children && route.children.length > 0) {
|
||||
return route.children[0].meta
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// 4. 辅助函数:拼接路径
|
||||
const resolvePath = (parent: any, child?: any) => {
|
||||
// 如果是首页这种 layout 嵌套结构
|
||||
if (!child && parent.children && parent.children.length === 1) {
|
||||
return parent.path === '/' ? '/dashboard' : parent.path + '/' + parent.children[0].path
|
||||
}
|
||||
// 如果是普通子菜单
|
||||
if (child) {
|
||||
return parent.path + '/' + child.path
|
||||
}
|
||||
return parent.path
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-menu-vertical {
|
||||
border-right: none; /* 去掉 Element Plus 菜单默认的右边框 */
|
||||
width: 100%;
|
||||
}
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background-color: #263445 !important;
|
||||
}
|
||||
</style>
|
||||
43
inventory-web/src/layout/index.vue
Normal file
43
inventory-web/src/layout/index.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="layout-wrapper">
|
||||
<Sidebar class="sidebar-container" />
|
||||
|
||||
<div class="main-container">
|
||||
<AppMain />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Sidebar from './components/Sidebar/index.vue'
|
||||
import AppMain from './components/AppMain.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%; /* 继承 App.vue 中 app-content 的高度 */
|
||||
overflow: hidden; /* 防止最外层出现滚动条 */
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
width: 180px; /* 固定侧边栏宽度 */
|
||||
height: 100%;
|
||||
background-color: #304156; /* 侧边栏背景色 */
|
||||
flex-shrink: 0; /* 防止被挤压 */
|
||||
overflow-y: auto; /* 侧边栏内容过多时允许滚动 */
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1; /* 自动占满右侧剩余空间 */
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto; /* 关键:页面内容过多时,只在右侧区域滚动 */
|
||||
background-color: #f0f2f5; /* 右侧灰色背景,让白色卡片更明显 */
|
||||
padding: 10px; /* 给内部页面留出边距 */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@ -1,5 +1,44 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import { createPinia } from 'pinia' // [新增] 引入 Pinia
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
// 1. 引入路由配置
|
||||
import router from './router'
|
||||
|
||||
// 2. 引入 Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
// 引入中文包
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
|
||||
// 3. 引入图标
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
// 4. 引入全局样式 (通常建议加上,如果没有可忽略)
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// =========================================================
|
||||
// [关键修复] 注册顺序非常重要!
|
||||
// 1. 必须先注册 Pinia,因为 Router 的守卫中会用到 Store
|
||||
// =========================================================
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// =========================================================
|
||||
// 2. 然后注册 Router
|
||||
// =========================================================
|
||||
app.use(router)
|
||||
|
||||
// 3. 注册 Element Plus
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn, // 设置为中文
|
||||
})
|
||||
|
||||
// 4. 注册所有图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
217
inventory-web/src/router/index.ts
Normal file
217
inventory-web/src/router/index.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import Layout from '@/layout/index.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
// 1. 登录页
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { hidden: true }
|
||||
},
|
||||
|
||||
// 2. 首页 Dashboard
|
||||
{
|
||||
path: '/',
|
||||
component: Layout,
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '首页', icon: 'HomeFilled' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 3. 基础信息
|
||||
{
|
||||
path: '/material',
|
||||
component: Layout,
|
||||
redirect: '/material/index',
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
name: 'MaterialBase',
|
||||
component: () => import('@/views/material/list.vue'),
|
||||
meta: { title: '基础信息', icon: 'Box' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 4. 库存管理 (入库)
|
||||
{
|
||||
path: '/inventory',
|
||||
component: Layout,
|
||||
meta: { title: '入库管理', icon: 'Shop' },
|
||||
redirect: '/inventory/buy',
|
||||
children: [
|
||||
{
|
||||
path: 'buy',
|
||||
name: 'InventoryBuy',
|
||||
component: () => import('@/views/stock/inbound/buy.vue'),
|
||||
meta: { title: '采购件' }
|
||||
},
|
||||
{
|
||||
path: 'semi',
|
||||
name: 'InventorySemi',
|
||||
component: () => import('@/views/stock/inbound/semi.vue'),
|
||||
meta: { title: '半成品' }
|
||||
},
|
||||
{
|
||||
path: 'product',
|
||||
name: 'InventoryProduct',
|
||||
component: () => import('@/views/stock/inbound/product.vue'),
|
||||
meta: { title: '成品' }
|
||||
},
|
||||
|
||||
{
|
||||
path: 'service',
|
||||
name: 'InventoryService',
|
||||
component: () => import('@/views/stock/inbound/service.vue'),
|
||||
meta: { title: '服务权益' }
|
||||
},
|
||||
// [原有] 入库记录整合
|
||||
{
|
||||
path: 'summary',
|
||||
name: 'InventorySummary',
|
||||
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
|
||||
meta: { title: '入库记录' }
|
||||
},
|
||||
// ★ [新增] 库存盘点页面 (查库/消除)
|
||||
{
|
||||
path: 'stocktake',
|
||||
name: 'InventoryStocktake',
|
||||
component: () => import('@/views/stock/stocktake/index.vue'),
|
||||
meta: { title: '库存盘点' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 5. 出库管理
|
||||
{
|
||||
path: '/outbound',
|
||||
component: Layout,
|
||||
meta: { title: '出库管理', icon: 'Van' },
|
||||
redirect: '/outbound/index',
|
||||
children: [
|
||||
// ★ [新增] 出库选单打印页面
|
||||
{
|
||||
path: 'selection',
|
||||
name: 'OutboundSelection',
|
||||
component: () => import('@/views/outbound/Selection.vue'),
|
||||
meta: { title: '出库选单' }
|
||||
},
|
||||
{
|
||||
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' },
|
||||
redirect: '/operation/borrow',
|
||||
children: [
|
||||
{
|
||||
path: 'borrow',
|
||||
name: 'OpBorrow',
|
||||
component: () => import('@/views/transaction/borrow.vue'),
|
||||
meta: { title: '借库' }
|
||||
},
|
||||
{
|
||||
path: 'repair',
|
||||
name: 'OpRepair',
|
||||
component: () => import('@/views/transaction/return.vue'),
|
||||
meta: { title: '返还' }
|
||||
},
|
||||
{
|
||||
path: 'records',
|
||||
name: 'OpRecords',
|
||||
component: () => import('@/views/transaction/records.vue'),
|
||||
meta: { title: '借还记录' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 7. 系统管理
|
||||
{
|
||||
path: '/system',
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: '系统管理',
|
||||
icon: 'Setting',
|
||||
roles: ['super_admin', 'supervisor']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'user-create',
|
||||
name: 'UserCreate',
|
||||
component: () => import('@/views/system/UserCreate.vue'),
|
||||
meta: { title: '账号开通', icon: 'User' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 404 路由
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/dashboard',
|
||||
meta: { hidden: true }
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
// ==========================================
|
||||
// 全局路由守卫
|
||||
// ==========================================
|
||||
router.beforeEach((to, from, next) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
const token = userStore.token || localStorage.getItem('token')
|
||||
const userRole = userStore.role || localStorage.getItem('role') || 'user'
|
||||
|
||||
if (to.path === '/login') {
|
||||
if (token) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
next({ path: '/login', replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
if (to.meta.roles && Array.isArray(to.meta.roles)) {
|
||||
if (to.meta.roles.includes(userRole)) {
|
||||
next()
|
||||
} else {
|
||||
next('/dashboard')
|
||||
}
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
0
inventory-web/src/router/permission.ts
Normal file
0
inventory-web/src/router/permission.ts
Normal file
0
inventory-web/src/stores/tagsView.ts
Normal file
0
inventory-web/src/stores/tagsView.ts
Normal file
77
inventory-web/src/stores/user.ts
Normal file
77
inventory-web/src/stores/user.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { login } from '@/api/auth'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 1. State: 初始化时优先从 localStorage 获取,防止刷新丢失
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const role = ref(localStorage.getItem('role') || '')
|
||||
const username = ref(localStorage.getItem('username') || '')
|
||||
|
||||
// 2. Actions
|
||||
// 登录逻辑
|
||||
const handleLogin = async (loginForm: any) => {
|
||||
const res = await login(loginForm)
|
||||
|
||||
// [调试日志] 查看实际返回的数据结构
|
||||
console.log('Login API Response:', res)
|
||||
|
||||
// ============================================================
|
||||
// [关键修复] 兼容 Axios 拦截器的不同处理方式
|
||||
// 如果拦截器已经返回了 response.data,那么 res 本身就是数据对象
|
||||
// ============================================================
|
||||
const data = res.data || res
|
||||
|
||||
// 安全检查:确保 data 存在且包含 access_token
|
||||
if (!data || !data.access_token) {
|
||||
console.error('Login Error: 响应数据中缺少 access_token', data)
|
||||
throw new Error('登录失败: 响应数据异常')
|
||||
}
|
||||
|
||||
// 更新 Pinia 状态 (内存)
|
||||
token.value = data.access_token
|
||||
|
||||
// 处理用户信息 (确保后端返回结构中有 user 字段)
|
||||
if (data.user) {
|
||||
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
|
||||
username.value = data.user.username || '用户'
|
||||
|
||||
// 持久化存储用户信息
|
||||
localStorage.setItem('role', role.value)
|
||||
localStorage.setItem('username', username.value)
|
||||
}
|
||||
|
||||
// 持久化存储 Token
|
||||
localStorage.setItem('token', data.access_token)
|
||||
|
||||
return true // 返回 true 表示登录成功
|
||||
}
|
||||
|
||||
// 退出逻辑
|
||||
const logout = () => {
|
||||
// 1. 清空 Pinia 状态 (内存)
|
||||
token.value = ''
|
||||
role.value = ''
|
||||
username.value = ''
|
||||
|
||||
// 2. 清空 LocalStorage (硬盘)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('role')
|
||||
localStorage.removeItem('username')
|
||||
}
|
||||
|
||||
// 3. Getters / Helpers
|
||||
// 判断当前用户是否拥有某些角色
|
||||
const hasRole = (roles: string[]) => {
|
||||
return roles.includes(role.value)
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
role,
|
||||
username,
|
||||
handleLogin,
|
||||
logout,
|
||||
hasRole
|
||||
}
|
||||
})
|
||||
@ -1,18 +1,31 @@
|
||||
/* inventory-web/src/style.css */
|
||||
|
||||
/* 1. 保留原有的字体定义,确保文字清晰好看 */
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
/* 颜色方案配置 */
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
/* 字体渲染优化 */
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 2. 针对亮色模式的颜色适配 (保留) */
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
/* 3. 链接的基本样式 (保留,但通常 RouterLink 会覆盖) */
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
@ -22,58 +35,44 @@ a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
/* -------------------------------------------------
|
||||
【重要修改区域】
|
||||
下面的代码是为了修复“无法铺满全屏”的问题
|
||||
-------------------------------------------------
|
||||
*/
|
||||
|
||||
/* 4. 全局盒模型修复:防止 padding 撑大元素 */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 5. 重置 body 和 html */
|
||||
html, body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%; /* 强制高度占满 */
|
||||
|
||||
/* !!! 删除了原有的 display: flex; place-items: center;
|
||||
这是导致你页面缩在中间的罪魁祸首
|
||||
*/
|
||||
display: block;
|
||||
|
||||
overflow: hidden; /* 防止最外层出现双滚动条 */
|
||||
}
|
||||
|
||||
/* 6. 重置 #app 挂载点 */
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
/* !!! 删除了 max-width: 1280px; padding: 2rem; text-align: center;
|
||||
这是导致你页面两边留白、无法全屏的原因
|
||||
*/
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
/* 注意:原文件中关于 button, .card 的样式已被删除,
|
||||
因为你的项目中引入了 Element Plus,
|
||||
保留原生 button 样式会和 Element Plus 组件产生冲突。
|
||||
*/
|
||||
0
inventory-web/src/utils/format.ts
Normal file
0
inventory-web/src/utils/format.ts
Normal file
89
inventory-web/src/utils/request.ts
Normal file
89
inventory-web/src/utils/request.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user' // 引入 Store 获取 Token
|
||||
|
||||
// 1. 创建 axios 实例
|
||||
const service = axios.create({
|
||||
// 【关键修改】
|
||||
// 设置为 '/api',请求会自动拼接成 http://localhost:5173/api/...
|
||||
// 然后被 Vite 代理转发到 http://127.0.0.1:8000/api/...
|
||||
baseURL: '/api',
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
// 2. 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
// 在发送请求之前做些什么
|
||||
// 注意:这里需要确保 Pinia 已经初始化,但在拦截器运行时组件早已加载,通常没问题
|
||||
// 为了安全起见,也可以直接读 localStorage,或者在函数内调用 store
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (token && config.headers) {
|
||||
// Flask-JWT-Extended 默认需要 'Bearer <token>' 格式
|
||||
config.headers['Authorization'] = 'Bearer ' + token
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 3. 响应拦截器
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// Axios 默认包了一层 data,所以这里取 response.data
|
||||
const res = response.data
|
||||
|
||||
// 如果后端返回的是标准 Flask jsonify 结果,通常没有 code 字段(除非你自己封装了)
|
||||
// 如果你使用了标准 HTTP 状态码(200, 201等),Axios 会直接进入这里
|
||||
|
||||
// 只有当业务逻辑明确返回错误码时才报错 (根据你的后端封装调整)
|
||||
if (res.code && res.code !== 200) {
|
||||
ElMessage.error(res.msg || 'Error')
|
||||
return Promise.reject(new Error(res.msg || 'Error'))
|
||||
} else {
|
||||
return res // 返回解包后的数据
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.log('err: ' + error) // for debug
|
||||
let message = error.message || '请求失败'
|
||||
|
||||
// 处理 HTTP 状态码错误
|
||||
const isLoginEndpoint = error.config && error.config.url.includes('/login')
|
||||
|
||||
if (error.response) {
|
||||
const status = error.response.status
|
||||
const data = error.response.data
|
||||
|
||||
if (status === 401) {
|
||||
// 对于登录接口的401错误,不执行登出重定向,仅提示错误
|
||||
if (!isLoginEndpoint) {
|
||||
message = '登录已过期,请重新登录'
|
||||
localStorage.clear()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// 如果是登录接口,message会被后面的data.msg覆盖
|
||||
} else if (status === 403) {
|
||||
message = '权限不足'
|
||||
} else if (status === 404) {
|
||||
message = '请求的资源不存在'
|
||||
} else if (status === 500) {
|
||||
message = '服务器内部错误'
|
||||
} else if (data && data.msg) {
|
||||
// 优先显示后端返回的错误信息
|
||||
message = data.msg
|
||||
}
|
||||
}
|
||||
|
||||
// 登录接口的错误由调用方单独处理,不再显示全局提示
|
||||
if (!isLoginEndpoint) {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default service
|
||||
0
inventory-web/src/utils/validate.ts
Normal file
0
inventory-web/src/utils/validate.ts
Normal file
112
inventory-web/src/views/dashboard/index.vue
Normal file
112
inventory-web/src/views/dashboard/index.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<el-card class="welcome-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">👋 欢迎回来,{{ userStore.username }}</span>
|
||||
<el-tag type="success">系统运行正常</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="card-body">
|
||||
<h2>IRIS 库存管理系统</h2>
|
||||
<p class="subtitle">请选择您要进行的业务操作:</p>
|
||||
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" size="large" @click="handleNav('/inventory/buy')">
|
||||
<el-icon style="margin-right: 5px"><ShoppingCart /></el-icon>
|
||||
采购入库
|
||||
</el-button>
|
||||
|
||||
<el-button type="success" size="large" @click="handleNav('/material/index')">
|
||||
<el-icon style="margin-right: 5px"><Box /></el-icon>
|
||||
基础信息
|
||||
</el-button>
|
||||
|
||||
<el-button type="warning" size="large" @click="handleNav('/operation/borrow')">
|
||||
<el-icon style="margin-right: 5px"><Operation /></el-icon>
|
||||
借库申请
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
// 1. 引入 User Store
|
||||
import { useUserStore } from '@/stores/user'
|
||||
// 引入需要的图标
|
||||
import { Box, TrendCharts, ShoppingCart, Operation } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
// 2. 实例化 store
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 统一跳转函数
|
||||
const handleNav = (path: string) => {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
/* 使用 100% 宽度和高度,利用 Flex 居中显示 */
|
||||
height: calc(100vh - 84px); /* 减去顶部导航栏的高度,防止出现双滚动条 */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #f0f2f5; /* 给背景加个淡灰色,突出卡片 */
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
width: 800px; /*稍微加宽一点 */
|
||||
text-align: center;
|
||||
border-radius: 8px; /* 圆角更好看 */
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.card-body h2 {
|
||||
font-size: 28px;
|
||||
color: #409EFF; /* 使用主题蓝 */
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #909399;
|
||||
margin-bottom: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap; /* 防止屏幕过窄时按钮挤压 */
|
||||
}
|
||||
|
||||
/* 给按钮加一点悬浮效果 */
|
||||
.el-button {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.el-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user