Compare commits
229 Commits
master
...
80ee0fe88e
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ee0fe88e | |||
| f49f8dba04 | |||
| cf75b80e13 | |||
| 16350842f8 | |||
| d7dff943fc | |||
| 2f140e112f | |||
| 8264867b1c | |||
| d993e6796e | |||
| 4e05734865 | |||
| 7f19867139 | |||
| bcd39729f8 | |||
| 9cfbdc7d13 | |||
| d3510b0261 | |||
| 7b0082c6e0 | |||
| b08196c479 | |||
| 68ea351c99 | |||
| f001be9eef | |||
| 545cd86632 | |||
| b688480892 | |||
| 646804bb98 | |||
| 3daf7e4500 | |||
| e61c179d77 | |||
| f7cfb5a346 | |||
| 29fd397e4f | |||
| 54d83803c4 | |||
| 05fbb4e3b3 | |||
| fb56359f41 | |||
| 00ebffb9fd | |||
| 4b29912f6f | |||
| cc33108e88 | |||
| d78ef22251 | |||
| c3e2494b3e | |||
| fed85e51c5 | |||
| d2082c712b | |||
| b85f28fc72 | |||
| 8f6d0cd40b | |||
| 281a41c549 | |||
| dda54e829b | |||
| 5beb373677 | |||
| c1e4acc1d8 | |||
| a0993767fe | |||
| ad8bb5a75d | |||
| c414efc7a4 | |||
| 09a2af0b55 | |||
| 89620b2445 | |||
| a1df62238e | |||
| 3a056335bb | |||
| fbff519ac9 | |||
| 657c916703 | |||
| 3c1c822f88 | |||
| 4324e5a688 | |||
| 1fe00a8ba3 | |||
| afcf90a859 | |||
| 5bc3dab31c | |||
| 079987e7f3 | |||
| 00c45c72fb | |||
| 6fa5233ea6 | |||
| 3f83e8742b | |||
| 348e4dd024 | |||
| 42b0cddd3e | |||
| a2b1a62132 | |||
| 5065410662 | |||
| 3714dd180b | |||
| af41eb1803 | |||
| f79fb53b17 | |||
| 38f0bbe41d | |||
| 1ad477eda8 | |||
| 1d2e8feced | |||
| 246fb45cde | |||
| 6e914f1e96 | |||
| b5b1efdc4e | |||
| 56bb6a1c84 | |||
| 379bc5786f | |||
| a96597da33 | |||
| 4c1c61065e | |||
| 25487dbede | |||
| a547d6b164 | |||
| 661ce4e5a0 | |||
| d6d9621bf3 | |||
| f178b9cd00 | |||
| 11fafde5e3 | |||
| 1f9a363545 | |||
| b3e1ac6245 | |||
| 73ee163352 | |||
| c86e67b793 | |||
| 57c2c532ca | |||
| dad7ffdc66 | |||
| b798c42abf | |||
| 8698b2582c | |||
| 220f50dba6 | |||
| 7431f1f41e | |||
| 47fb8912a9 | |||
| 82a9a4c2ba | |||
| 1c3f116c50 | |||
| 948149cd44 | |||
| 63a3cf269d | |||
| 447b1890ab | |||
| 8a7e367d00 | |||
| 31ddb1aafd | |||
| 42171ed612 | |||
| 7e2fa8db8e | |||
| d1ab5f1100 | |||
| 853374de5d | |||
| b682d4b02f | |||
| d61668bc4b | |||
| b93a565c82 | |||
| 6e5df70ee6 | |||
| fb536dad7f | |||
| d479b750d7 | |||
| 05a108e96d | |||
| ec7f20869a | |||
| fcebe70848 | |||
| bb8c07a465 | |||
| 5245ee2da3 | |||
| 04dd6fb3fa | |||
| 32f031b047 | |||
| 6d5d8a6aad | |||
| e900326571 | |||
| 5513e4cd81 | |||
| 83f040728f | |||
| d3d35e03cd | |||
| 9f0134b2e4 | |||
| b3fdc65d33 | |||
| 9c70d78d9f | |||
| cfb36ebf0b | |||
| c6fd0aca90 | |||
| 5f3ceef3fd | |||
| 5532c87684 | |||
| d0a237625c | |||
| b1e2836e4b | |||
| 706476d421 | |||
| 64efbb97d6 | |||
| ec16ef8d20 | |||
| d594ed7ef1 | |||
| 8ee2a9a45b | |||
| b5b0677b01 | |||
| 8d00e6783c | |||
| 695c78090a | |||
| af1a95017b | |||
| 8cae6ee7f6 | |||
| 94ff7cecdc | |||
| 17a61b489c | |||
| 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,160 @@
|
||||
# 文件路径: 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():
|
||||
# -----------------------------------------------------
|
||||
# 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}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 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 - 借还/维修/报废)
|
||||
# -----------------------------------------------------
|
||||
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:
|
||||
print(f"⚠️ 提示: Transaction 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.5 注册出库模块 (Outbound)
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.outbound import outbound_bp
|
||||
# 标准: /api/v1/outbound
|
||||
app.register_blueprint(outbound_bp, url_prefix='/api/v1/outbound')
|
||||
# 兼容: /api/outbound
|
||||
app.register_blueprint(outbound_bp, url_prefix='/api/outbound', name='outbound_legacy')
|
||||
print("✅ Outbound 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Outbound 模块导入失败: {e}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 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}")
|
||||
|
||||
# -----------------------------------------------------
|
||||
# 2.7 注册权限管理模块 (Permission) - [新增]
|
||||
# -----------------------------------------------------
|
||||
try:
|
||||
from app.api.v1.permission import permission_bp
|
||||
# 标准: /api/v1/permissions/tree
|
||||
app.register_blueprint(permission_bp, url_prefix='/api/v1/permissions')
|
||||
# 兼容: /api/permissions/tree
|
||||
app.register_blueprint(permission_bp, url_prefix='/api/permissions', name='permission_legacy')
|
||||
print("✅ Permission 模块注册成功")
|
||||
except ImportError as e:
|
||||
print(f"❌ 错误: Permission 模块导入失败 (请检查 app/api/v1/permission.py 是否存在): {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
|
||||
|
||||
# 系统与业务模型 (SysRolePermission 等在 models.system 中)
|
||||
from app.models.system import SysUser, SysLog, SysMenu, SysElement, SysRolePermission
|
||||
# 确保借还模型被加载
|
||||
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,210 @@
|
||||
# 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
|
||||
from app.utils.decorators import permission_required
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
return ['system_user:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'system_user:id',
|
||||
'username': 'system_user:username',
|
||||
'account_id': 'system_user:account_id',
|
||||
'email': 'system_user:email',
|
||||
'department': 'system_user:department',
|
||||
'role': 'system_user:role',
|
||||
'status': 'system_user:status',
|
||||
'created_at': 'system_user:created_at',
|
||||
}
|
||||
# 如果用户是超级管理员且有 'system_user:*',则不过滤
|
||||
if 'system_user:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
|
||||
@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()
|
||||
@permission_required('system_user:operation')
|
||||
def create_user():
|
||||
try:
|
||||
data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'system_user:*' not in user_permissions:
|
||||
# 字段名到权限码的映射
|
||||
field_to_perm = {
|
||||
'cn_name': 'system_user:username',
|
||||
'username': 'system_user:username',
|
||||
'password': 'system_user:password',
|
||||
'department': 'system_user:department',
|
||||
'role': 'system_user:role',
|
||||
'email': 'system_user:email',
|
||||
}
|
||||
# 对于 password 字段,如果没有对应权限但用户有操作权限,可以保留(由装饰器保证)
|
||||
# 但如果连操作权限都没有,则不会进入此接口。
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
# 密码字段特殊处理:如果没有 password 权限但用户有操作权限,仍允许(不删除)
|
||||
if field == 'password':
|
||||
# 检查用户是否有操作权限,如果有则保留
|
||||
if 'system_user:operation' not in user_permissions:
|
||||
data.pop(field, None)
|
||||
continue
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
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()
|
||||
@permission_required('system_user:operation')
|
||||
def update_user(user_id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'system_user:*' not in user_permissions:
|
||||
# 字段名到权限码的映射
|
||||
field_to_perm = {
|
||||
'cn_name': 'system_user:username',
|
||||
'username': 'system_user:username',
|
||||
'password': 'system_user:password',
|
||||
'department': 'system_user:department',
|
||||
'role': 'system_user:role',
|
||||
'email': 'system_user:email',
|
||||
}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
# 密码字段特殊处理:如果没有 password 权限但用户有操作权限,仍允许(不删除)
|
||||
if field == 'password':
|
||||
# 检查用户是否有操作权限,如果有则保留
|
||||
if 'system_user:operation' not in user_permissions:
|
||||
data.pop(field, None)
|
||||
continue
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
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()
|
||||
@permission_required('system_user')
|
||||
def get_users():
|
||||
try:
|
||||
users = AuthService.get_all_users()
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
filtered_users = [filter_item_by_permissions(user, user_permissions) for user in users]
|
||||
return jsonify({'msg': '获取成功', 'data': filtered_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()
|
||||
@permission_required('system_user:operation')
|
||||
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
|
||||
|
||||
|
||||
@auth_bp.route('/my-permissions', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_my_permissions():
|
||||
"""获取当前登录用户的权限列表"""
|
||||
try:
|
||||
claims = get_jwt()
|
||||
role = claims.get('role')
|
||||
|
||||
# 调用 Service 获取权限
|
||||
permissions = AuthService.get_user_permissions(role)
|
||||
|
||||
return jsonify({'msg': '获取成功', 'data': permissions}), 200
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Get Permissions Failed: {str(e)}")
|
||||
return jsonify({'msg': '获取权限失败'}), 500
|
||||
|
||||
334
inventory-backend/app/api/v1/bom.py
Normal file
334
inventory-backend/app/api/v1/bom.py
Normal file
@ -0,0 +1,334 @@
|
||||
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, get_jwt
|
||||
from app.utils.decorators import permission_required
|
||||
from app.services.auth_service import AuthService
|
||||
|
||||
bom_bp = Blueprint('bom', __name__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
return ['bom_manage:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'bom_no': 'bom_manage:bom_no',
|
||||
'parent_name': 'bom_manage:parent_name',
|
||||
'parent_spec': 'bom_manage:parent_spec',
|
||||
'version': 'bom_manage:version',
|
||||
'is_enabled': 'bom_manage:status',
|
||||
'child_count': 'bom_manage:child_count',
|
||||
}
|
||||
# 如果用户是超级管理员且有 'bom_manage:*',则不过滤
|
||||
if 'bom_manage:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
|
||||
# ==================== 新版 BOM 接口(基于 bom_no) ====================
|
||||
|
||||
@bom_bp.route('/list', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage')
|
||||
def get_bom_list():
|
||||
"""获取所有 BOM 配方列表,支持 keyword 搜索和 active_only 过滤"""
|
||||
try:
|
||||
keyword = request.args.get('keyword', '').strip()
|
||||
# 将字符串 'true' 转为布尔值
|
||||
active_only = request.args.get('active_only', 'false').lower() == 'true'
|
||||
|
||||
data = BomService.get_bom_list(keyword=keyword, active_only=active_only)
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
if isinstance(data, list):
|
||||
data = [filter_item_by_permissions(item, user_permissions) for item in data]
|
||||
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('/detail/<path:bom_no>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage')
|
||||
def get_bom_detail(bom_no):
|
||||
"""
|
||||
根据 BOM 编号获取配方详情
|
||||
Query参数: ?version=V1.0 (如果不传则取最新)
|
||||
"""
|
||||
try:
|
||||
version = request.args.get('version')
|
||||
data = BomService.get_bom_detail(bom_no, version=version)
|
||||
if not data:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
data = filter_item_by_permissions(data, user_permissions)
|
||||
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('/save', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage:operation')
|
||||
def save_bom():
|
||||
"""保存或更新 BOM 配方(支持自定义 bom_no 和 多版本)"""
|
||||
try:
|
||||
req_data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'bom_manage:*' not in user_permissions:
|
||||
# 字段名到权限码的映射
|
||||
field_to_perm = {
|
||||
'parent_id': 'bom_manage:parent_id',
|
||||
'version': 'bom_manage:version',
|
||||
'is_enabled': 'bom_manage:status',
|
||||
'bom_no': 'bom_manage:bom_no',
|
||||
}
|
||||
# 清洗顶级字段
|
||||
for field in list(req_data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
req_data.pop(field, None)
|
||||
# 清洗 children 中的字段
|
||||
if 'children' in req_data and isinstance(req_data['children'], list):
|
||||
for child in req_data['children']:
|
||||
# 子件字段映射
|
||||
child_field_to_perm = {
|
||||
'child_id': 'bom_manage:child_id',
|
||||
'dosage': 'bom_manage:dosage',
|
||||
'remark': 'bom_manage:remark',
|
||||
}
|
||||
for field in list(child.keys()):
|
||||
perm_code = child_field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
child.pop(field, None)
|
||||
|
||||
# 必需字段校验
|
||||
if 'parent_id' not in req_data or 'children' not in req_data:
|
||||
return jsonify({'code': 400, 'msg': '缺少 parent_id 或 children 字段'}), 400
|
||||
|
||||
# 校验 bom_no 不能为空
|
||||
if 'bom_no' in req_data and not req_data['bom_no']:
|
||||
return jsonify({'code': 400, 'msg': 'BOM编号不能为空'}), 400
|
||||
|
||||
bom_no = BomService.save_bom(req_data)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '保存成功',
|
||||
'data': {'bom_no': bom_no}
|
||||
})
|
||||
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('/stock/<path:bom_no>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage')
|
||||
def get_bom_with_stock_by_no(bom_no):
|
||||
"""根据 BOM 编号获取配方详情及库存信息"""
|
||||
try:
|
||||
data = BomService.get_bom_with_stock_by_bom_no(bom_no)
|
||||
if not data:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
data = filter_item_by_permissions(data, user_permissions)
|
||||
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接口 ====================
|
||||
|
||||
@bom_bp.route('/<path:bom_no>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage:operation')
|
||||
def delete_bom(bom_no):
|
||||
"""
|
||||
根据 BOM 编号删除
|
||||
Query参数: ?version=V1.0 (如果不传,删除该编号下所有版本)
|
||||
"""
|
||||
try:
|
||||
version = request.args.get('version')
|
||||
query = BomTable.query.filter_by(bom_no=bom_no)
|
||||
|
||||
if version:
|
||||
query = query.filter_by(version=version)
|
||||
|
||||
exist = query.first()
|
||||
if not exist:
|
||||
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
|
||||
|
||||
# 删除
|
||||
query.delete()
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '删除成功'
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'删除BOM失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
# ==================== 兼容旧接口 ====================
|
||||
|
||||
@bom_bp.route('/<int:parent_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('bom_manage')
|
||||
def get_bom(parent_id):
|
||||
try:
|
||||
data = BomService.get_bom_with_stock(parent_id)
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
data = filter_item_by_permissions(data, user_permissions)
|
||||
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()
|
||||
@permission_required('bom_manage:operation')
|
||||
def save_bom_legacy():
|
||||
try:
|
||||
req_data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'bom_manage:*' not in user_permissions:
|
||||
# 字段名到权限码的映射
|
||||
field_to_perm = {
|
||||
'parent_id': 'bom_manage:parent_id',
|
||||
'version': 'bom_manage:version',
|
||||
'is_enabled': 'bom_manage:status',
|
||||
'bom_no': 'bom_manage:bom_no',
|
||||
}
|
||||
# 清洗顶级字段
|
||||
for field in list(req_data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
req_data.pop(field, None)
|
||||
# 清洗 children 中的字段
|
||||
if 'children' in req_data and isinstance(req_data['children'], list):
|
||||
for child in req_data['children']:
|
||||
# 子件字段映射
|
||||
child_field_to_perm = {
|
||||
'child_id': 'bom_manage:child_id',
|
||||
'dosage': 'bom_manage:dosage',
|
||||
'remark': 'bom_manage:remark',
|
||||
}
|
||||
for field in list(child.keys()):
|
||||
perm_code = child_field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
child.pop(field, None)
|
||||
|
||||
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()
|
||||
@permission_required('bom_manage')
|
||||
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()
|
||||
@permission_required('bom_manage')
|
||||
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]
|
||||
# 字段级脱敏 (如果需要)
|
||||
user_permissions = get_current_user_permissions()
|
||||
data = [filter_item_by_permissions(item, user_permissions) for item in data]
|
||||
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
49
inventory-backend/app/api/v1/common/print.py
Normal file
49
inventory-backend/app/api/v1/common/print.py
Normal file
@ -0,0 +1,49 @@
|
||||
# app/api/v1/common/print.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.print.label_service import LabelPrintService
|
||||
from app.services.print.print_config import PrintConfigManager
|
||||
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
|
||||
|
||||
|
||||
@print_bp.route('/config', methods=['GET'])
|
||||
def get_printer_config():
|
||||
try:
|
||||
label = PrintConfigManager.get_config('label_printer')
|
||||
network = PrintConfigManager.get_config('network_printer')
|
||||
config = {'label_printer': label, 'network_printer': network}
|
||||
return jsonify({"code": 200, "msg": "success", "data": config})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
@print_bp.route('/config', methods=['POST'])
|
||||
def update_printer_config():
|
||||
try:
|
||||
data = request.get_json()
|
||||
PrintConfigManager.save_config(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,无需再注册子蓝图
|
||||
301
inventory-backend/app/api/v1/inbound/base.py
Normal file
301
inventory-backend/app/api/v1/inbound/base.py
Normal file
@ -0,0 +1,301 @@
|
||||
# 文件路径: app/api/v1/inbound/base.py
|
||||
|
||||
from flask import Blueprint, request, jsonify, send_file, g
|
||||
from app.services.inbound.base_service import MaterialBaseService
|
||||
from app.utils.decorators import login_required, permission_required
|
||||
import traceback
|
||||
import datetime
|
||||
|
||||
inbound_base_bp = Blueprint('stock_base', __name__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
# 返回通配符权限(供列表脱敏使用)以及所有具体权限(供导出脱敏使用)
|
||||
return [
|
||||
'material_list:*',
|
||||
'material_list:id',
|
||||
'material_list:companyName',
|
||||
'material_list:name',
|
||||
'material_list:commonName',
|
||||
'material_list:category',
|
||||
'material_list:type',
|
||||
'material_list:spec',
|
||||
'material_list:unit',
|
||||
'material_list:inventoryCount',
|
||||
'material_list:availableCount',
|
||||
'material_list:files',
|
||||
'material_list:isEnabled',
|
||||
'material_list:operation'
|
||||
]
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 如果用户拥有通配符权限,则不过滤
|
||||
if 'material_list:*' in user_permissions:
|
||||
return item_dict
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'material_list:id',
|
||||
'companyName': 'material_list:companyName',
|
||||
'name': 'material_list:name',
|
||||
'commonName': 'material_list:commonName',
|
||||
'category': 'material_list:category',
|
||||
'type': 'material_list:type',
|
||||
'spec': 'material_list:spec',
|
||||
'unit': 'material_list:unit',
|
||||
'inventoryCount': 'material_list:inventoryCount',
|
||||
'availableCount': 'material_list:availableCount',
|
||||
'generalManual': 'material_list:files',
|
||||
'generalImage': 'material_list:files',
|
||||
'isEnabled': 'material_list:isEnabled'
|
||||
}
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/search', methods=['GET'])
|
||||
@permission_required('material_list')
|
||||
def search_base():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = MaterialBaseService.search_material(keyword)
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
filtered_data = [filter_item_by_permissions(item, user_permissions) for item in data]
|
||||
return jsonify({"code": 200, "msg": "success", "data": filtered_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'])
|
||||
@permission_required('material_list')
|
||||
def get_list():
|
||||
try:
|
||||
page = request.args.get('pageNum', 1, type=int)
|
||||
limit = request.args.get('pageSize', 10, type=int)
|
||||
|
||||
# 构造筛选条件
|
||||
filters = {
|
||||
'keyword': request.args.get('keyword', ''),
|
||||
'company': request.args.get('company', ''),
|
||||
'category': request.args.get('category', ''),
|
||||
'type': request.args.get('type', ''),
|
||||
'isEnabled': request.args.get('isEnabled', None),
|
||||
'orderByColumn': request.args.get('orderByColumn', ''),
|
||||
'isAsc': request.args.get('isAsc', None)
|
||||
}
|
||||
|
||||
result = MaterialBaseService.get_list(page, limit, filters)
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.1 选项接口 (GET /api/v1/inbound/base/options)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/options', methods=['GET'])
|
||||
@permission_required('material_list')
|
||||
def get_options():
|
||||
try:
|
||||
data = MaterialBaseService.get_distinct_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 2.2 导出接口 (GET /api/v1/inbound/base/export)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/export', methods=['GET'])
|
||||
@permission_required('material_list')
|
||||
def export_data():
|
||||
try:
|
||||
# 获取筛选条件
|
||||
filters = {
|
||||
'keyword': request.args.get('keyword', ''),
|
||||
'company': request.args.get('company', ''),
|
||||
'category': request.args.get('category', ''),
|
||||
'type': request.args.get('type', ''),
|
||||
'isEnabled': request.args.get('isEnabled', None)
|
||||
}
|
||||
|
||||
# 获取当前用户权限
|
||||
user_permissions = get_current_user_permissions()
|
||||
|
||||
# 生成 Excel 文件流(传入用户权限进行脱敏)
|
||||
file_stream = MaterialBaseService.export_excel(filters, user_permissions)
|
||||
|
||||
# 生成文件名:库存统计+年月日+时分秒 (北京时间 UTC+8)
|
||||
# 简单处理:UTC时间 + 8小时
|
||||
beijing_time = datetime.datetime.utcnow() + datetime.timedelta(hours=8)
|
||||
filename = f"库存统计_{beijing_time.strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
|
||||
# 发送文件
|
||||
# 注意:download_name 仅在较新 Flask 版本有效,旧版本可能需要手动 header,
|
||||
# 但通常浏览器下载名由前端 Blob 处理或 Content-Disposition 决定。
|
||||
return send_file(
|
||||
file_stream,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": f"导出失败: {str(e)}"}), 500
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 3. 新增接口 (POST /api/v1/inbound/base/)
|
||||
# ==============================================================================
|
||||
@inbound_base_bp.route('/', methods=['POST'])
|
||||
@permission_required('material_list:operation')
|
||||
def create():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data provided"}), 400
|
||||
|
||||
# 获取当前用户权限
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 字段到权限码的映射(与 filter_item_by_permissions 一致)
|
||||
field_to_perm = {
|
||||
'id': 'material_list:id',
|
||||
'companyName': 'material_list:companyName',
|
||||
'name': 'material_list:name',
|
||||
'commonName': 'material_list:commonName',
|
||||
'category': 'material_list:category',
|
||||
'type': 'material_list:type',
|
||||
'spec': 'material_list:spec',
|
||||
'unit': 'material_list:unit',
|
||||
'inventoryCount': 'material_list:inventoryCount',
|
||||
'availableCount': 'material_list:availableCount',
|
||||
'generalManual': 'material_list:files',
|
||||
'generalImage': 'material_list:files',
|
||||
'isEnabled': 'material_list:isEnabled'
|
||||
}
|
||||
# 过滤用户没有权限的字段
|
||||
filtered_data = {}
|
||||
# 如果拥有通配符权限,则不过滤
|
||||
if 'material_list:*' in user_permissions:
|
||||
filtered_data = data
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if key in field_to_perm:
|
||||
perm_code = field_to_perm[key]
|
||||
if perm_code in user_permissions:
|
||||
filtered_data[key] = value
|
||||
# 没有权限则跳过,不包含在 filtered_data 中
|
||||
else:
|
||||
# 不在映射中的字段,默认允许(例如 visibilityLevel)
|
||||
filtered_data[key] = value
|
||||
|
||||
MaterialBaseService.create_material(filtered_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'])
|
||||
@permission_required('material_list:operation')
|
||||
def update(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
# 获取当前用户权限
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 字段到权限码的映射(与 filter_item_by_permissions 一致)
|
||||
field_to_perm = {
|
||||
'id': 'material_list:id',
|
||||
'companyName': 'material_list:companyName',
|
||||
'name': 'material_list:name',
|
||||
'commonName': 'material_list:commonName',
|
||||
'category': 'material_list:category',
|
||||
'type': 'material_list:type',
|
||||
'spec': 'material_list:spec',
|
||||
'unit': 'material_list:unit',
|
||||
'inventoryCount': 'material_list:inventoryCount',
|
||||
'availableCount': 'material_list:availableCount',
|
||||
'generalManual': 'material_list:files',
|
||||
'generalImage': 'material_list:files',
|
||||
'isEnabled': 'material_list:isEnabled'
|
||||
}
|
||||
# 过滤用户没有权限的字段
|
||||
filtered_data = {}
|
||||
# 如果拥有通配符权限,则不过滤
|
||||
if 'material_list:*' in user_permissions:
|
||||
filtered_data = data
|
||||
else:
|
||||
for key, value in data.items():
|
||||
if key in field_to_perm:
|
||||
perm_code = field_to_perm[key]
|
||||
if perm_code in user_permissions:
|
||||
filtered_data[key] = value
|
||||
# 没有权限则跳过,不包含在 filtered_data 中
|
||||
else:
|
||||
# 不在映射中的字段,默认允许(例如 visibilityLevel)
|
||||
filtered_data[key] = value
|
||||
# 使用过滤后的数据调用服务
|
||||
MaterialBaseService.update_material(id, filtered_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'])
|
||||
@permission_required('material_list:operation')
|
||||
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
|
||||
351
inventory-backend/app/api/v1/inbound/buy.py
Normal file
351
inventory-backend/app/api/v1/inbound/buy.py
Normal file
@ -0,0 +1,351 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.buy_service import BuyInboundService
|
||||
from app.utils.decorators import permission_required
|
||||
import traceback
|
||||
|
||||
inbound_buy_bp = Blueprint('stock_buy', __name__)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
# 返回所有以 inbound_buy: 开头的权限码(这里我们返回一个特殊标记,表示全部)
|
||||
# 为了简单,我们返回 ['inbound_buy:*'],在过滤函数中特殊处理
|
||||
return ['inbound_buy:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'inbound_buy:id',
|
||||
'base_id': 'inbound_buy:base_id',
|
||||
'global_print_id': 'inbound_buy:global_print_id',
|
||||
'sku': 'inbound_buy:sku',
|
||||
'barcode': 'inbound_buy:barcode',
|
||||
'in_date': 'inbound_buy:in_date',
|
||||
'serial_number': 'inbound_buy:serial_number',
|
||||
'batch_number': 'inbound_buy:batch_number',
|
||||
'status': 'inbound_buy:status',
|
||||
'in_quantity': 'inbound_buy:in_quantity',
|
||||
'stock_quantity': 'inbound_buy:stock_quantity',
|
||||
'available_quantity': 'inbound_buy:available_quantity',
|
||||
'inspection_status': 'inbound_buy:inspection_status',
|
||||
'warehouse_location': 'inbound_buy:warehouse_location',
|
||||
'unit_price': 'inbound_buy:unit_price',
|
||||
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
|
||||
'tax_rate': 'inbound_buy:tax_rate',
|
||||
'total_price': 'inbound_buy:total_price',
|
||||
'currency': 'inbound_buy:currency',
|
||||
'exchange_rate': 'inbound_buy:exchange_rate',
|
||||
'supplier_name': 'inbound_buy:supplier_name',
|
||||
'buyer_name': 'inbound_buy:buyer_name',
|
||||
'buyer_email': 'inbound_buy:buyer_email',
|
||||
'original_link': 'inbound_buy:original_link',
|
||||
'detail_link': 'inbound_buy:detail_link',
|
||||
'arrival_photo': 'inbound_buy:arrival_photo',
|
||||
'inspection_report': 'inbound_buy:inspection_report',
|
||||
'material_name': 'inbound_buy:material_name',
|
||||
'spec_model': 'inbound_buy:spec_model',
|
||||
'category': 'inbound_buy:category',
|
||||
'unit': 'inbound_buy:unit',
|
||||
'material_type': 'inbound_buy:material_type',
|
||||
'company_name': 'inbound_buy:company_name',
|
||||
}
|
||||
# 如果用户是超级管理员且有 'inbound_buy:*',则不过滤
|
||||
if 'inbound_buy:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 0. 基础物料搜索
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/search-base', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def search_base():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
# 固定每次加载50条
|
||||
limit = 50
|
||||
|
||||
result = BuyInboundService.search_base_material(keyword, page, limit)
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": result['items'],
|
||||
"total": result['total'],
|
||||
"has_next": result['has_next']
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. 获取列表 (修改:接收 category 和 material_type)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/list', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
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', '')
|
||||
|
||||
# 新增筛选参数
|
||||
category = request.args.get('category', '')
|
||||
material_type = request.args.get('material_type', '')
|
||||
company = request.args.get('company', '')
|
||||
|
||||
# 状态参数处理
|
||||
statuses_str = request.args.get('statuses', '')
|
||||
statuses = statuses_str.split(',') if statuses_str else []
|
||||
|
||||
result = BuyInboundService.get_list(page, limit, keyword, statuses, category, material_type, company)
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
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'])
|
||||
@permission_required('inbound_buy:operation')
|
||||
def submit():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"code": 400, "msg": "No data"}), 400
|
||||
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'inbound_buy:*' not in user_permissions:
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'inbound_buy:id',
|
||||
'base_id': 'inbound_buy:base_id',
|
||||
'global_print_id': 'inbound_buy:global_print_id',
|
||||
'sku': 'inbound_buy:sku',
|
||||
'barcode': 'inbound_buy:barcode',
|
||||
'in_date': 'inbound_buy:in_date',
|
||||
'serial_number': 'inbound_buy:serial_number',
|
||||
'batch_number': 'inbound_buy:batch_number',
|
||||
'status': 'inbound_buy:status',
|
||||
'in_quantity': 'inbound_buy:in_quantity',
|
||||
'stock_quantity': 'inbound_buy:stock_quantity',
|
||||
'available_quantity': 'inbound_buy:available_quantity',
|
||||
'inspection_status': 'inbound_buy:inspection_status',
|
||||
'warehouse_location': 'inbound_buy:warehouse_location',
|
||||
'unit_price': 'inbound_buy:unit_price',
|
||||
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
|
||||
'tax_rate': 'inbound_buy:tax_rate',
|
||||
'total_price': 'inbound_buy:total_price',
|
||||
'currency': 'inbound_buy:currency',
|
||||
'exchange_rate': 'inbound_buy:exchange_rate',
|
||||
'supplier_name': 'inbound_buy:supplier_name',
|
||||
'buyer_name': 'inbound_buy:buyer_name',
|
||||
'buyer_email': 'inbound_buy:buyer_email',
|
||||
'original_link': 'inbound_buy:original_link',
|
||||
'detail_link': 'inbound_buy:detail_link',
|
||||
'arrival_photo': 'inbound_buy:arrival_photo',
|
||||
'inspection_report': 'inbound_buy:inspection_report',
|
||||
'material_name': 'inbound_buy:material_name',
|
||||
'spec_model': 'inbound_buy:spec_model',
|
||||
'category': 'inbound_buy:category',
|
||||
'unit': 'inbound_buy:unit',
|
||||
'material_type': 'inbound_buy:material_type',
|
||||
'company_name': 'inbound_buy:company_name',
|
||||
}
|
||||
# 复制一份,避免遍历时修改字典
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
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'])
|
||||
@permission_required('inbound_buy:operation')
|
||||
def update_buy(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'inbound_buy:*' not in user_permissions:
|
||||
field_to_perm = {
|
||||
'id': 'inbound_buy:id',
|
||||
'base_id': 'inbound_buy:base_id',
|
||||
'global_print_id': 'inbound_buy:global_print_id',
|
||||
'sku': 'inbound_buy:sku',
|
||||
'barcode': 'inbound_buy:barcode',
|
||||
'in_date': 'inbound_buy:in_date',
|
||||
'serial_number': 'inbound_buy:serial_number',
|
||||
'batch_number': 'inbound_buy:batch_number',
|
||||
'status': 'inbound_buy:status',
|
||||
'in_quantity': 'inbound_buy:in_quantity',
|
||||
'stock_quantity': 'inbound_buy:stock_quantity',
|
||||
'available_quantity': 'inbound_buy:available_quantity',
|
||||
'inspection_status': 'inbound_buy:inspection_status',
|
||||
'warehouse_location': 'inbound_buy:warehouse_location',
|
||||
'unit_price': 'inbound_buy:unit_price',
|
||||
'post_tax_unit_price': 'inbound_buy:post_tax_unit_price',
|
||||
'tax_rate': 'inbound_buy:tax_rate',
|
||||
'total_price': 'inbound_buy:total_price',
|
||||
'currency': 'inbound_buy:currency',
|
||||
'exchange_rate': 'inbound_buy:exchange_rate',
|
||||
'supplier_name': 'inbound_buy:supplier_name',
|
||||
'buyer_name': 'inbound_buy:buyer_name',
|
||||
'buyer_email': 'inbound_buy:buyer_email',
|
||||
'original_link': 'inbound_buy:original_link',
|
||||
'detail_link': 'inbound_buy:detail_link',
|
||||
'arrival_photo': 'inbound_buy:arrival_photo',
|
||||
'inspection_report': 'inbound_buy:inspection_report',
|
||||
'material_name': 'inbound_buy:material_name',
|
||||
'spec_model': 'inbound_buy:spec_model',
|
||||
'category': 'inbound_buy:category',
|
||||
'unit': 'inbound_buy:unit',
|
||||
'material_type': 'inbound_buy:material_type',
|
||||
'company_name': 'inbound_buy:company_name',
|
||||
}
|
||||
# 复制一份,避免遍历时修改字典
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
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'])
|
||||
@permission_required('inbound_buy:operation')
|
||||
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. [新增] 获取筛选下拉选项 (修复404的关键)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/options', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def get_options():
|
||||
try:
|
||||
data = BuyInboundService.get_filter_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. 获取关联的出库历史 (如果有)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def get_history(id):
|
||||
# 如果没有出库模块,这个接口可能为空,但为保持兼容性保留
|
||||
return jsonify({"code": 200, "msg": "success", "data": []})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. 供应商建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/suppliers', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def get_supplier_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||||
data = BuyInboundService.get_history_suppliers(base_id)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. 采购人建议 (全局)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/users', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = BuyInboundService.get_history_purchasers(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. 链接建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/links', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def get_link_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
link_type = request.args.get('type', 'original') # original or detail
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||||
data = BuyInboundService.get_history_links(base_id, link_type)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. 库位建议
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_buy_bp.route('/suggestions/locations', methods=['GET'])
|
||||
@permission_required('inbound_buy')
|
||||
def get_location_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
if not base_id:
|
||||
return jsonify({"code": 400, "msg": "base_id required"}), 400
|
||||
data = BuyInboundService.get_history_locations(base_id)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
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
|
||||
187
inventory-backend/app/api/v1/inbound/product.py
Normal file
187
inventory-backend/app/api/v1/inbound/product.py
Normal file
@ -0,0 +1,187 @@
|
||||
# inventory-backend/app/api/v1/inbound/product.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.product_service import ProductInboundService
|
||||
from app.utils.decorators import permission_required
|
||||
import traceback
|
||||
|
||||
# === 这一行非常关键,绝对不能丢!===
|
||||
inbound_product_bp = Blueprint('stock_product', __name__)
|
||||
|
||||
def get_current_user_permissions():
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role: return []
|
||||
if user_role.upper() == 'SUPER_ADMIN': return ['inbound_product:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
field_to_perm = {
|
||||
'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id', 'company_name': 'inbound_product:company_name',
|
||||
'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category',
|
||||
'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model',
|
||||
'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date',
|
||||
'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number',
|
||||
'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status',
|
||||
'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity',
|
||||
'available_quantity': 'inbound_product:available_quantity', 'warehouse_location': 'inbound_product:warehouse_location',
|
||||
'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version',
|
||||
'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id',
|
||||
'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time',
|
||||
'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost',
|
||||
'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price',
|
||||
'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link',
|
||||
'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link',
|
||||
}
|
||||
if 'inbound_product:*' in user_permissions: return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions: item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
@inbound_product_bp.route('/search-base', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def search_base():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
result = ProductInboundService.search_base_material(keyword, page)
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/search-bom', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def search_bom():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = ProductInboundService.search_bom_options(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/list', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
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 []
|
||||
category = request.args.get('category', '')
|
||||
material_type = request.args.get('material_type', '')
|
||||
company = request.args.get('company', '')
|
||||
result = ProductInboundService.get_list(page, limit, keyword, statuses, category, material_type, company)
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/submit', methods=['POST'])
|
||||
@permission_required('inbound_product:operation')
|
||||
def submit():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data: return jsonify({"code": 400, "msg": "No data"}), 400
|
||||
user_permissions = get_current_user_permissions()
|
||||
if 'inbound_product:*' not in user_permissions:
|
||||
field_to_perm = {'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id', 'company_name': 'inbound_product:company_name', 'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category', 'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model', 'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date', 'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number', 'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status', 'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity', 'available_quantity': 'inbound_product:available_quantity', 'warehouse_location': 'inbound_product:warehouse_location', 'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version', 'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id', 'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time', 'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost', 'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price', 'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link', 'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link'}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions: data.pop(field, None)
|
||||
new_stock = ProductInboundService.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
|
||||
|
||||
@inbound_product_bp.route('/<int:id>', methods=['PUT'])
|
||||
@permission_required('inbound_product:operation')
|
||||
def update(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
user_permissions = get_current_user_permissions()
|
||||
if 'inbound_product:*' not in user_permissions:
|
||||
field_to_perm = {'id': 'inbound_product:id', 'base_id': 'inbound_product:base_id', 'company_name': 'inbound_product:company_name', 'material_name': 'inbound_product:material_name', 'category': 'inbound_product:category', 'material_type': 'inbound_product:material_type', 'spec_model': 'inbound_product:spec_model', 'unit': 'inbound_product:unit', 'sku': 'inbound_product:sku', 'inbound_date': 'inbound_product:inbound_date', 'barcode': 'inbound_product:barcode', 'serial_number': 'inbound_product:serial_number', 'status': 'inbound_product:status', 'quality_status': 'inbound_product:quality_status', 'in_quantity': 'inbound_product:in_quantity', 'stock_quantity': 'inbound_product:stock_quantity', 'available_quantity': 'inbound_product:available_quantity', 'warehouse_location': 'inbound_product:warehouse_location', 'bom_code': 'inbound_product:bom_code', 'bom_version': 'inbound_product:bom_version', 'work_order_code': 'inbound_product:work_order_code', 'order_id': 'inbound_product:order_id', 'production_manager': 'inbound_product:production_manager', 'production_start_time': 'inbound_product:production_start_time', 'production_end_time': 'inbound_product:production_end_time', 'raw_material_cost': 'inbound_product:raw_material_cost', 'manual_cost': 'inbound_product:manual_cost', 'sale_price': 'inbound_product:sale_price', 'product_photo': 'inbound_product:product_photo', 'quality_report_link': 'inbound_product:quality_report_link', 'inspection_report_link': 'inbound_product:inspection_report_link', 'detail_link': 'inbound_product:detail_link'}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions: data.pop(field, None)
|
||||
ProductInboundService.update_inbound(id, data)
|
||||
return jsonify({"code": 200, "msg": "更新成功"})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/<int:id>', methods=['DELETE'])
|
||||
@permission_required('inbound_product:operation')
|
||||
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
|
||||
|
||||
@inbound_product_bp.route('/<int:id>/history', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
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
|
||||
|
||||
@inbound_product_bp.route('/suggestions/users', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = ProductInboundService.search_system_users(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
@inbound_product_bp.route('/options', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def get_options():
|
||||
try:
|
||||
data = ProductInboundService.get_filter_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_product_bp.route('/suggestions/managers', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def get_manager_history():
|
||||
keyword = request.args.get('keyword', '')
|
||||
try:
|
||||
data = ProductInboundService.get_history_managers(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. BOM 原材料成本自动核算 (新增)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_product_bp.route('/calculate-bom-cost', methods=['GET'])
|
||||
@permission_required('inbound_product')
|
||||
def calculate_bom_cost():
|
||||
try:
|
||||
bom_code = request.args.get('bom_code')
|
||||
bom_version = request.args.get('bom_version')
|
||||
if not bom_code or not bom_version:
|
||||
return jsonify({"code": 400, "msg": "bom_code和bom_version不能为空"}), 400
|
||||
cost = ProductInboundService.calculate_bom_cost(bom_code, bom_version)
|
||||
return jsonify({"code": 200, "msg": "success", "data": cost})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
184
inventory-backend/app/api/v1/inbound/semi.py
Normal file
184
inventory-backend/app/api/v1/inbound/semi.py
Normal file
@ -0,0 +1,184 @@
|
||||
# inventory-backend/app/api/v1/inbound/semi.py
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.inbound.semi_service import SemiInboundService
|
||||
from app.utils.decorators import permission_required
|
||||
import traceback
|
||||
|
||||
# === 这一行非常关键,绝对不能丢!===
|
||||
inbound_semi_bp = Blueprint('stock_semi', __name__)
|
||||
|
||||
def get_current_user_permissions():
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role: return []
|
||||
if user_role.upper() == 'SUPER_ADMIN': return ['inbound_semi:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
return perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
field_to_perm = {
|
||||
'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name',
|
||||
'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category',
|
||||
'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model',
|
||||
'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date',
|
||||
'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number',
|
||||
'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status',
|
||||
'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity',
|
||||
'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity',
|
||||
'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code',
|
||||
'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code',
|
||||
'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost',
|
||||
'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager',
|
||||
'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time',
|
||||
'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link',
|
||||
'detail_link': 'inbound_semi:detail_link',
|
||||
}
|
||||
if 'inbound_semi:*' in user_permissions: return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions: item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
@inbound_semi_bp.route('/search-base', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def search_base():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
page = request.args.get('page', 1, type=int)
|
||||
result = SemiInboundService.search_base_material(keyword, page)
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_semi_bp.route('/search-bom', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def search_bom():
|
||||
try:
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = SemiInboundService.search_bom_options(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_semi_bp.route('/list', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
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 = SemiInboundService.get_list(page, limit, keyword, statuses)
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
return jsonify({"code": 200, "msg": "success", "data": result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_semi_bp.route('/submit', methods=['POST'])
|
||||
@permission_required('inbound_semi:operation')
|
||||
def submit():
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data: return jsonify({"code": 400, "msg": "No data"}), 400
|
||||
user_permissions = get_current_user_permissions()
|
||||
if 'inbound_semi:*' not in user_permissions:
|
||||
field_to_perm = {'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name', 'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category', 'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model', 'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date', 'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number', 'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status', 'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity', 'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity', 'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code', 'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code', 'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost', 'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager', 'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time', 'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link', 'detail_link': 'inbound_semi:detail_link'}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions: data.pop(field, None)
|
||||
new_stock = SemiInboundService.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
|
||||
|
||||
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
|
||||
@permission_required('inbound_semi:operation')
|
||||
def update_semi(id):
|
||||
try:
|
||||
data = request.get_json()
|
||||
user_permissions = get_current_user_permissions()
|
||||
if 'inbound_semi:*' not in user_permissions:
|
||||
field_to_perm = {'id': 'inbound_semi:id', 'base_id': 'inbound_semi:base_id', 'company_name': 'inbound_semi:company_name', 'material_name': 'inbound_semi:material_name', 'category': 'inbound_semi:category', 'material_type': 'inbound_semi:material_type', 'spec_model': 'inbound_semi:spec_model', 'unit': 'inbound_semi:unit', 'sku': 'inbound_semi:sku', 'inbound_date': 'inbound_semi:inbound_date', 'barcode': 'inbound_semi:barcode', 'serial_number': 'inbound_semi:serial_number', 'batch_number': 'inbound_semi:batch_number', 'status': 'inbound_semi:status', 'quality_status': 'inbound_semi:quality_status', 'in_quantity': 'inbound_semi:in_quantity', 'stock_quantity': 'inbound_semi:stock_quantity', 'available_quantity': 'inbound_semi:available_quantity', 'warehouse_location': 'inbound_semi:warehouse_location', 'bom_code': 'inbound_semi:bom_code', 'bom_version': 'inbound_semi:bom_version', 'work_order_code': 'inbound_semi:work_order_code', 'raw_material_cost': 'inbound_semi:raw_material_cost', 'manual_cost': 'inbound_semi:manual_cost', 'unit_total_cost': 'inbound_semi:unit_total_cost', 'production_manager': 'inbound_semi:production_manager', 'production_start_time': 'inbound_semi:production_start_time', 'production_end_time': 'inbound_semi:production_end_time', 'arrival_photo': 'inbound_semi:arrival_photo', 'quality_report_link': 'inbound_semi:quality_report_link', 'detail_link': 'inbound_semi:detail_link'}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions: data.pop(field, None)
|
||||
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
|
||||
|
||||
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
|
||||
@permission_required('inbound_semi:operation')
|
||||
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
|
||||
|
||||
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
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
|
||||
|
||||
@inbound_semi_bp.route('/suggestions/users', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = SemiInboundService.search_system_users(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
|
||||
@inbound_semi_bp.route('/options', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def get_options():
|
||||
try:
|
||||
data = SemiInboundService.get_filter_options()
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
@inbound_semi_bp.route('/suggestions/managers', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def get_manager_history():
|
||||
keyword = request.args.get('keyword', '')
|
||||
try:
|
||||
data = SemiInboundService.get_history_managers(keyword)
|
||||
return jsonify({"code": 200, "msg": "success", "data": data})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. BOM 原材料成本自动核算 (新增)
|
||||
# ------------------------------------------------------------------
|
||||
@inbound_semi_bp.route('/calculate-bom-cost', methods=['GET'])
|
||||
@permission_required('inbound_semi')
|
||||
def calculate_bom_cost():
|
||||
try:
|
||||
bom_code = request.args.get('bom_code')
|
||||
bom_version = request.args.get('bom_version')
|
||||
if not bom_code or not bom_version:
|
||||
return jsonify({"code": 400, "msg": "bom_code和bom_version不能为空"}), 400
|
||||
cost = SemiInboundService.calculate_bom_cost(bom_code, bom_version)
|
||||
return jsonify({"code": 200, "msg": "success", "data": cost})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({"code": 500, "msg": str(e)}), 500
|
||||
290
inventory-backend/app/api/v1/inbound/service.py
Normal file
290
inventory-backend/app/api/v1/inbound/service.py
Normal file
@ -0,0 +1,290 @@
|
||||
# inventory-backend/app/api/v1/inbound/service.py
|
||||
from flask import request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from . import inbound_bp
|
||||
from app.services.inbound.service_service import ServiceService
|
||||
from app.utils.decorators import role_required, permission_required
|
||||
import traceback
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
return ['inbound_service:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'inbound_service:id',
|
||||
'base_id': 'inbound_service:base_id',
|
||||
'sku': 'inbound_service:sku',
|
||||
'material_name': 'inbound_service:material_name',
|
||||
'provider_name': 'inbound_service:provider_name',
|
||||
'sale_price': 'inbound_service:sale_price',
|
||||
'description': 'inbound_service:description',
|
||||
'created_at': 'inbound_service:created_at',
|
||||
'material_type': 'inbound_service:material_type',
|
||||
'category': 'inbound_service:category',
|
||||
'spec_model': 'inbound_service:spec_model',
|
||||
'unit': 'inbound_service:unit',
|
||||
}
|
||||
if 'inbound_service:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
|
||||
@inbound_bp.route('/service/search-base', methods=['GET'])
|
||||
@permission_required('inbound_service')
|
||||
def search_base():
|
||||
"""搜索基础物料"""
|
||||
keyword = request.args.get('keyword', '')
|
||||
try:
|
||||
data = ServiceService.search_base_material(keyword)
|
||||
user_permissions = get_current_user_permissions()
|
||||
filtered_data = [filter_item_by_permissions(item, user_permissions) for item in data]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': filtered_data
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'搜索基础物料失败: {str(e)}')
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service', methods=['GET'])
|
||||
@permission_required('inbound_service')
|
||||
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
|
||||
)
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'获取服务列表失败: {str(e)}')
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service', methods=['POST'])
|
||||
@permission_required('inbound_service:operation')
|
||||
def create_service():
|
||||
"""创建服务权益"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'inbound_service:*' not in user_permissions:
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'inbound_service:id',
|
||||
'base_id': 'inbound_service:base_id',
|
||||
'sku': 'inbound_service:sku',
|
||||
'material_name': 'inbound_service:material_name',
|
||||
'provider_name': 'inbound_service:provider_name',
|
||||
'sale_price': 'inbound_service:sale_price',
|
||||
'description': 'inbound_service:description',
|
||||
'created_at': 'inbound_service:created_at',
|
||||
'material_type': 'inbound_service:material_type',
|
||||
'category': 'inbound_service:category',
|
||||
'spec_model': 'inbound_service:spec_model',
|
||||
'unit': 'inbound_service:unit',
|
||||
}
|
||||
# 复制一份,避免遍历时修改字典
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
# 基础校验
|
||||
if not data.get('base_id'):
|
||||
return jsonify({'code': 400, 'msg': '请选择基础物料'}), 400
|
||||
if data.get('sale_price') is None:
|
||||
return jsonify({'code': 400, 'msg': '请输入售价'}), 400
|
||||
|
||||
try:
|
||||
service = ServiceService.create_service(data)
|
||||
user_permissions = get_current_user_permissions()
|
||||
filtered_data = filter_item_by_permissions(service.to_dict(), user_permissions)
|
||||
return jsonify({
|
||||
'code': 201,
|
||||
'msg': '创建成功',
|
||||
'data': filtered_data
|
||||
}), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'创建服务权益失败: {str(e)}')
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': '内部服务器错误'}), 500
|
||||
|
||||
|
||||
@inbound_bp.route('/service/<int:service_id>', methods=['GET'])
|
||||
@permission_required('inbound_service')
|
||||
def get_service(service_id):
|
||||
"""获取单个服务权益详情"""
|
||||
try:
|
||||
service = ServiceService.get_service(service_id)
|
||||
user_permissions = get_current_user_permissions()
|
||||
filtered_data = filter_item_by_permissions(service.to_dict(), user_permissions)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': filtered_data
|
||||
})
|
||||
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'])
|
||||
@permission_required('inbound_service:operation')
|
||||
def update_service(service_id):
|
||||
"""更新服务权益"""
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'code': 400, 'msg': '请求数据为空'}), 400
|
||||
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'inbound_service:*' not in user_permissions:
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'id': 'inbound_service:id',
|
||||
'base_id': 'inbound_service:base_id',
|
||||
'sku': 'inbound_service:sku',
|
||||
'material_name': 'inbound_service:material_name',
|
||||
'provider_name': 'inbound_service:provider_name',
|
||||
'sale_price': 'inbound_service:sale_price',
|
||||
'description': 'inbound_service:description',
|
||||
'created_at': 'inbound_service:created_at',
|
||||
'material_type': 'inbound_service:material_type',
|
||||
'category': 'inbound_service:category',
|
||||
'spec_model': 'inbound_service:spec_model',
|
||||
'unit': 'inbound_service:unit',
|
||||
}
|
||||
# 复制一份,避免遍历时修改字典
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
|
||||
# 允许更新的字段
|
||||
allowed_fields = {
|
||||
'sale_price', 'provider_name', 'description',
|
||||
'cost_price', 'contract_id', 'contact_person', 'valid_period'
|
||||
}
|
||||
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)
|
||||
user_permissions = get_current_user_permissions()
|
||||
filtered_service = filter_item_by_permissions(service.to_dict(), user_permissions)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '更新成功',
|
||||
'data': filtered_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'])
|
||||
@permission_required('inbound_service:operation')
|
||||
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
|
||||
|
||||
|
||||
@inbound_bp.route('/service/suggestions/providers', methods=['GET'])
|
||||
@permission_required('inbound_service')
|
||||
def get_provider_suggestions():
|
||||
base_id = request.args.get('base_id', type=int)
|
||||
if not base_id:
|
||||
return jsonify({'code': 400, 'msg': 'base_id required'}), 400
|
||||
data = ServiceService.get_history_providers(base_id)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
@inbound_bp.route('/service/suggestions/users', methods=['GET'])
|
||||
@permission_required('inbound_service')
|
||||
def get_user_suggestions():
|
||||
keyword = request.args.get('keyword', '')
|
||||
data = ServiceService.search_system_users(keyword)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
|
||||
|
||||
@inbound_bp.route('/service/options', methods=['GET'])
|
||||
@permission_required('inbound_service')
|
||||
def get_options():
|
||||
try:
|
||||
data = ServiceService.get_filter_options()
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': data})
|
||||
except Exception as e:
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
159
inventory-backend/app/api/v1/inbound/stock.py
Normal file
159
inventory-backend/app/api/v1/inbound/stock.py
Normal file
@ -0,0 +1,159 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from app.extensions import db
|
||||
# ★★★ 修复点:必须引入 datetime,否则下方更新时间时会报错 500 ★★★
|
||||
from datetime import datetime
|
||||
from app.utils.decorators import permission_required
|
||||
|
||||
# 导入模型
|
||||
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'])
|
||||
@permission_required('inventory_stocktake')
|
||||
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'])
|
||||
@permission_required('inventory_stocktake')
|
||||
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'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
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'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
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('/borrowed-quantities', methods=['POST'])
|
||||
@permission_required('inventory_stocktake')
|
||||
def get_borrowed_quantities():
|
||||
"""批量获取借出未还数量"""
|
||||
from app.models.transaction import TransBorrow
|
||||
data = request.json.get('items', [])
|
||||
result = {}
|
||||
for item in data:
|
||||
source = item.get('source_table')
|
||||
stock_id = item.get('stock_id')
|
||||
if source and stock_id is not None:
|
||||
qty = TransBorrow.get_borrowed_quantity(source, stock_id)
|
||||
result[f"{source}_{stock_id}"] = qty
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
# --- 打印接口 ---
|
||||
|
||||
@bp.route('/print/selection', methods=['POST'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
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'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
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
|
||||
229
inventory-backend/app/api/v1/outbound.py
Normal file
229
inventory-backend/app/api/v1/outbound.py
Normal file
@ -0,0 +1,229 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app.services.outbound_service import OutboundService
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required
|
||||
from app.services.auth_service import AuthService
|
||||
import traceback
|
||||
|
||||
outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound')
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
from flask_jwt_extended import get_jwt
|
||||
from app.services.auth_service import AuthService
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
return ['outbound_list:*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'outbound_no': 'outbound_list:outbound_no',
|
||||
'outbound_time': 'outbound_list:outbound_time',
|
||||
'outbound_type': 'outbound_list:outbound_type',
|
||||
'total_amount': 'outbound_list:total_amount',
|
||||
'consumer_name': 'outbound_list:consumer_name',
|
||||
'operator_name': 'outbound_list:operator_name',
|
||||
'remark': 'outbound_list:remark',
|
||||
'signature_path': 'outbound_list:signature_path',
|
||||
# 明细字段
|
||||
'sku': 'outbound_list:sku',
|
||||
'name': 'outbound_list:name',
|
||||
'material_type': 'outbound_list:material_type',
|
||||
'category': 'outbound_list:category',
|
||||
'spec_model': 'outbound_list:spec_model',
|
||||
'quantity': 'outbound_list:quantity',
|
||||
'unit_price': 'outbound_list:unit_price',
|
||||
'subtotal': 'outbound_list:subtotal',
|
||||
}
|
||||
# 如果用户是超级管理员且有 'outbound_list:*',则不过滤
|
||||
if 'outbound_list:*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
# 如果 item_dict 中包含 items 列表,递归处理每个子项
|
||||
if 'items' in item_dict and isinstance(item_dict['items'], list):
|
||||
for sub_item in item_dict['items']:
|
||||
filter_item_by_permissions(sub_item, user_permissions)
|
||||
return item_dict
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 1. 扫码查询库存接口 (关联三个库存表)
|
||||
# GET /api/v1/outbound/scan?barcode=...
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('/scan', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('outbound_selection')
|
||||
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():
|
||||
# 权限检查:需要 outbound_create:operation 或 outbound_selection:operation 之一
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return jsonify({'code': 403, 'msg': '未授权'}), 403
|
||||
|
||||
# 超级管理员直接放行
|
||||
if user_role.upper() != 'SUPER_ADMIN':
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
if ('outbound_create:operation' not in perms) and ('outbound_selection:operation' not in perms):
|
||||
return jsonify({'code': 403, 'msg': '权限不足'}), 403
|
||||
|
||||
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
|
||||
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if 'outbound_list:*' not in user_permissions:
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'outbound_no': 'outbound_list:outbound_no',
|
||||
'outbound_time': 'outbound_list:outbound_time',
|
||||
'outbound_type': 'outbound_list:outbound_type',
|
||||
'total_amount': 'outbound_list:total_amount',
|
||||
'consumer_name': 'outbound_list:consumer_name',
|
||||
'operator_name': 'outbound_list:operator_name',
|
||||
'remark': 'outbound_list:remark',
|
||||
'signature_path': 'outbound_list:signature_path',
|
||||
# 明细字段
|
||||
'sku': 'outbound_list:sku',
|
||||
'name': 'outbound_list:name',
|
||||
'material_type': 'outbound_list:material_type',
|
||||
'category': 'outbound_list:category',
|
||||
'spec_model': 'outbound_list:spec_model',
|
||||
'quantity': 'outbound_list:quantity',
|
||||
'unit_price': 'outbound_list:unit_price',
|
||||
'price': 'outbound_list:unit_price', # 兼容 price 字段
|
||||
'subtotal': 'outbound_list:subtotal',
|
||||
}
|
||||
# 清洗顶层字段
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
# 清洗 items 中的每个商品字段
|
||||
if 'items' in data and isinstance(data['items'], list):
|
||||
for item in data['items']:
|
||||
for field in list(item.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
item.pop(field, None)
|
||||
|
||||
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()
|
||||
@permission_required('outbound_list')
|
||||
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)
|
||||
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
if result.get('items'):
|
||||
result['items'] = [filter_item_by_permissions(item, user_permissions) for item in result['items']]
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
'data': result
|
||||
})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
48
inventory-backend/app/api/v1/permission.py
Normal file
48
inventory-backend/app/api/v1/permission.py
Normal file
@ -0,0 +1,48 @@
|
||||
# inventory-backend/app/api/v1/permission.py
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from flask_jwt_extended import jwt_required
|
||||
from app.services.permission_service import PermissionService
|
||||
|
||||
permission_bp = Blueprint('permission', __name__)
|
||||
|
||||
|
||||
@permission_bp.route('/tree', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_tree():
|
||||
"""获取权限树"""
|
||||
try:
|
||||
data = PermissionService.get_permission_tree()
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
|
||||
except Exception as e:
|
||||
# 打印详细错误到控制台,方便调试
|
||||
current_app.logger.error(f"Get Tree Failed: {str(e)}")
|
||||
# 返回 500 时带上错误信息
|
||||
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||
|
||||
|
||||
@permission_bp.route('/role/<string:role_code>', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_role_perms(role_code):
|
||||
"""获取某个角色的权限列表"""
|
||||
try:
|
||||
data = PermissionService.get_role_permissions(role_code)
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Get Role Perms Failed: {str(e)}")
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
@permission_bp.route('/assign', methods=['POST'])
|
||||
@jwt_required()
|
||||
def assign_perms():
|
||||
"""保存权限分配"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
role_code = data.get('role_code')
|
||||
permissions = data.get('permissions', []) # list of codes
|
||||
|
||||
PermissionService.assign_permissions(role_code, permissions)
|
||||
return jsonify({'code': 200, 'msg': '保存成功'}), 200
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Assign Perms Failed: {str(e)}")
|
||||
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,155 @@
|
||||
from flask import Blueprint, jsonify, request # .material -> .base refactor checked
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
|
||||
from app.utils.decorators import permission_required
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.trans_service import TransService
|
||||
import traceback
|
||||
|
||||
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
def get_current_user_permissions():
|
||||
"""
|
||||
返回当前用户拥有的所有权限码列表(包括菜单和元素)
|
||||
此函数根据角色查询数据库得到权限。
|
||||
"""
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
if not user_role:
|
||||
return []
|
||||
# 超级管理员返回所有字段权限 (忽略大小写)
|
||||
if user_role.upper() == 'SUPER_ADMIN':
|
||||
return ['*']
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
# 合并菜单和元素权限
|
||||
perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
return perms
|
||||
|
||||
|
||||
def filter_item_by_permissions(item_dict, user_permissions, prefix='op_records'):
|
||||
"""
|
||||
根据用户权限过滤 item 字典,无权限的字段值置为 None
|
||||
"""
|
||||
# 字段名到权限码的映射(与前端 permissionMap 保持一致)
|
||||
field_to_perm = {
|
||||
'borrow_no': f'{prefix}:borrow_no',
|
||||
'borrower_name': f'{prefix}:borrower_name',
|
||||
'sku': f'{prefix}:sku',
|
||||
'borrow_time': f'{prefix}:borrow_time',
|
||||
'return_time': f'{prefix}:return_time',
|
||||
'status': f'{prefix}:status',
|
||||
'expected_return_time': f'{prefix}:expected_return_time',
|
||||
'return_location': f'{prefix}:return_location',
|
||||
'borrow_signature': f'{prefix}:borrow_signature',
|
||||
'return_signature': f'{prefix}:return_signature',
|
||||
}
|
||||
# 如果用户是超级管理员且有 '*',则不过滤
|
||||
if '*' in user_permissions:
|
||||
return item_dict
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item_dict and perm_code not in user_permissions:
|
||||
item_dict[field] = None
|
||||
return item_dict
|
||||
|
||||
|
||||
# --- 借库接口 ---
|
||||
@trans_bp.route('/borrow', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('op_borrow:operation')
|
||||
def create_borrow():
|
||||
data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if '*' not in user_permissions:
|
||||
field_to_perm = {
|
||||
'borrow_no': 'op_records:borrow_no',
|
||||
'borrower_name': 'op_records:borrower_name',
|
||||
'sku': 'op_records:sku',
|
||||
'borrow_time': 'op_records:borrow_time',
|
||||
'return_time': 'op_records:return_time',
|
||||
'status': 'op_records:status',
|
||||
'expected_return_time': 'op_records:expected_return_time',
|
||||
'return_location': 'op_records:return_location',
|
||||
'borrow_signature': 'op_records:borrow_signature',
|
||||
'return_signature': 'op_records:return_signature',
|
||||
}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
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()
|
||||
@permission_required('op_return')
|
||||
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()
|
||||
@permission_required('op_return:operation')
|
||||
def submit_return():
|
||||
data = request.get_json()
|
||||
# 数据清洗:移除用户没有权限的字段
|
||||
user_permissions = get_current_user_permissions()
|
||||
# 超级管理员不过滤
|
||||
if '*' not in user_permissions:
|
||||
field_to_perm = {
|
||||
'borrow_no': 'op_records:borrow_no',
|
||||
'borrower_name': 'op_records:borrower_name',
|
||||
'sku': 'op_records:sku',
|
||||
'borrow_time': 'op_records:borrow_time',
|
||||
'return_time': 'op_records:return_time',
|
||||
'status': 'op_records:status',
|
||||
'expected_return_time': 'op_records:expected_return_time',
|
||||
'return_location': 'op_records:return_location',
|
||||
'borrow_signature': 'op_records:borrow_signature',
|
||||
'return_signature': 'op_records:return_signature',
|
||||
}
|
||||
for field in list(data.keys()):
|
||||
perm_code = field_to_perm.get(field)
|
||||
if perm_code and perm_code not in user_permissions:
|
||||
data.pop(field, None)
|
||||
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()
|
||||
@permission_required('op_records')
|
||||
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)
|
||||
# 字段级脱敏
|
||||
user_permissions = get_current_user_permissions()
|
||||
if res.get('items'):
|
||||
res['items'] = [filter_item_by_permissions(item, user_permissions, 'op_records') for item in res['items']]
|
||||
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,81 @@
|
||||
# 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)
|
||||
# [修改] 所属公司,去除了 default='IRIS'
|
||||
company_name = db.Column(db.String(255), comment='所属公司')
|
||||
|
||||
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)
|
||||
stock_buys = db.relationship('StockBuy', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 2. 关联半成品库存 (StockSemi)
|
||||
stock_semis = db.relationship('StockSemi', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 3. 关联成品库存 (StockProduct)
|
||||
stock_products = db.relationship('StockProduct', back_populates='base', lazy='dynamic')
|
||||
|
||||
# 4. 关联服务库存 (StockService)
|
||||
stock_services = db.relationship('StockService', 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,
|
||||
'companyName': self.company_name,
|
||||
'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,
|
||||
}
|
||||
28
inventory-backend/app/models/bom.py
Normal file
28
inventory-backend/app/models/bom.py
Normal file
@ -0,0 +1,28 @@
|
||||
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), nullable=False, comment='BOM编号')
|
||||
version = db.Column(db.String(50), nullable=False, default='V1.0', 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='备注')
|
||||
|
||||
# ★ 新增:启用状态
|
||||
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
|
||||
# 约束: 保证同一版本下的父子对唯一,允许不同版本存在
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('bom_no', 'version', 'parent_id', 'child_id', name='uniq_bom_pair_in_version'),
|
||||
)
|
||||
|
||||
# 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
121
inventory-backend/app/models/inbound/buy.py
Normal file
121
inventory-backend/app/models/inbound/buy.py
Normal file
@ -0,0 +1,121 @@
|
||||
# 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)
|
||||
|
||||
# 财务与商务
|
||||
pre_tax_unit_price = db.Column(db.Numeric(19, 4), default=0) # 现意为:不含税单价
|
||||
post_tax_unit_price = db.Column(db.Numeric(19, 4), default=0) # 税后单价
|
||||
total_price = db.Column(db.Numeric(19, 4), default=0) # 总价
|
||||
# [新增] 税率
|
||||
tax_rate = db.Column(db.Numeric(5, 2), 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))
|
||||
buyer_email = db.Column(db.String(100))
|
||||
original_link = db.Column(db.Text)
|
||||
detail_link = db.Column(db.Text)
|
||||
|
||||
# 图片字段 (存储 JSON 字符串)
|
||||
arrival_photo = db.Column(db.Text)
|
||||
inspection_report = db.Column(db.Text)
|
||||
|
||||
# 全局打印流水号
|
||||
global_print_id = db.Column(db.Integer)
|
||||
|
||||
# 关系定义
|
||||
base = db.relationship('MaterialBase', back_populates='stock_buys')
|
||||
|
||||
def to_dict(self):
|
||||
# 辅助解析函数
|
||||
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,
|
||||
|
||||
# [修改] 增加公司名称
|
||||
'company_name': self.base.company_name if self.base else '',
|
||||
'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.pre_tax_unit_price or 0),
|
||||
'post_tax_unit_price': float(self.post_tax_unit_price or 0),
|
||||
'total_price': float(self.total_price or 0),
|
||||
# [新增] 税率
|
||||
'tax_rate': float(self.tax_rate 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),
|
||||
|
||||
'global_print_id': self.global_print_id,
|
||||
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
|
||||
}
|
||||
133
inventory-backend/app/models/inbound/product.py
Normal file
133
inventory-backend/app/models/inbound/product.py
Normal file
@ -0,0 +1,133 @@
|
||||
# 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,
|
||||
|
||||
# [新增] 公司名称
|
||||
'company_name': self.base.company_name if self.base else '',
|
||||
'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 ""
|
||||
}
|
||||
129
inventory-backend/app/models/inbound/semi.py
Normal file
129
inventory-backend/app/models/inbound/semi.py
Normal file
@ -0,0 +1,129 @@
|
||||
# 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,
|
||||
|
||||
# [新增] 公司名称
|
||||
'company_name': self.base.company_name if self.base else '',
|
||||
'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,
|
||||
|
||||
'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 ""
|
||||
}
|
||||
73
inventory-backend/app/models/inbound/service.py
Normal file
73
inventory-backend/app/models/inbound/service.py
Normal file
@ -0,0 +1,73 @@
|
||||
# inventory-backend/app/models/inbound/service.py
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class StockService(db.Model):
|
||||
"""
|
||||
服务权益库存表
|
||||
对应数据库表: stock_service
|
||||
说明:服务权益通常为虚拟资产,不进行具体的库存数量(actual_quantity)管理
|
||||
"""
|
||||
__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 = db.Column(db.String(100), unique=True, nullable=False)
|
||||
|
||||
# 扩展字段 (对应您的数据库建表脚本)
|
||||
service_category = db.Column(db.String(100), comment='服务类别')
|
||||
provider_name = db.Column(db.String(255), nullable=False, default='')
|
||||
contract_id = db.Column(db.String(100), comment='合同号')
|
||||
contact_person = db.Column(db.String(100), comment='联系人')
|
||||
|
||||
# 价格相关
|
||||
cost_price = db.Column(db.Numeric(19, 4), default=0)
|
||||
sale_price = db.Column(db.Numeric(19, 4), nullable=False, default=0)
|
||||
|
||||
# 描述与状态
|
||||
description = db.Column(db.Text, default='')
|
||||
valid_period = db.Column(db.String(100), comment='有效期')
|
||||
status = db.Column(db.String(20), default='active')
|
||||
|
||||
# 时间与系统字段
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
is_deleted = db.Column(db.Boolean, default=False)
|
||||
|
||||
# ==========================================================================
|
||||
# 关联关系设置
|
||||
# MaterialBase 中定义了 back_populates='stock_services'
|
||||
# 因此这里必须定义 base 属性指向 'stock_services'
|
||||
# ==========================================================================
|
||||
base = db.relationship('MaterialBase', back_populates='stock_services')
|
||||
|
||||
def to_dict(self):
|
||||
"""序列化为字典"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'base_id': self.base_id,
|
||||
'sku': self.sku,
|
||||
'service_category': self.service_category,
|
||||
'provider_name': self.provider_name,
|
||||
'contract_id': self.contract_id,
|
||||
'contact_person': self.contact_person,
|
||||
'sale_price': float(self.sale_price) if self.sale_price is not None else 0,
|
||||
'cost_price': float(self.cost_price) if self.cost_price is not None else 0,
|
||||
'description': self.description,
|
||||
'valid_period': self.valid_period,
|
||||
'status': self.status,
|
||||
'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,
|
||||
|
||||
# 关联的基础信息 (Flattened)
|
||||
'material_name': self.base.name if self.base else None,
|
||||
'spec_model': self.base.spec_model if self.base else None,
|
||||
'unit': self.base.unit if self.base else None,
|
||||
'category': self.base.category if self.base else None,
|
||||
'material_type': self.base.material_type if self.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,151 @@
|
||||
# inventory-backend/app/models/system.py
|
||||
from app.extensions import db
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 1. 系统用户表
|
||||
# ==========================================
|
||||
class SysUser(db.Model):
|
||||
"""
|
||||
系统用户表
|
||||
对应数据库: sys_user
|
||||
username 字段存储格式约定: "真实姓名/登录账号" (例如: 张三/zhangsan01)
|
||||
"""
|
||||
__tablename__ = 'sys_user'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), nullable=False) # 存储 "张三/zhangsan"
|
||||
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):
|
||||
"""
|
||||
序列化为字典
|
||||
数据库存的是 '张三/zhangsan'
|
||||
前端需要的是 '张三(zhangsan)'
|
||||
"""
|
||||
raw_name = self.username
|
||||
display_name = raw_name
|
||||
account_id = raw_name
|
||||
|
||||
# 解析存储格式: Name/ID
|
||||
if '/' in raw_name:
|
||||
parts = raw_name.split('/')
|
||||
real_name = parts[0]
|
||||
acc_id = parts[1]
|
||||
# 格式化为前端展示格式: 张三(zhangsan01)
|
||||
display_name = f"{real_name}({acc_id})"
|
||||
# 单独提取账号ID (如果前端需要单独用)
|
||||
account_id = acc_id
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': display_name, # 列表显示: 张三(zhangsan01)
|
||||
'raw_username': self.username, # 原始数据
|
||||
'account_id': account_id, # 纯账号ID: zhangsan01
|
||||
'email': self.email,
|
||||
'department': self.department,
|
||||
'role': self.role,
|
||||
'status': self.status,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 2. 系统日志表
|
||||
# ==========================================
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 3. 权限管理模型 (RBAC) - [新增]
|
||||
# ==========================================
|
||||
|
||||
class SysMenu(db.Model):
|
||||
"""系统菜单/页面表"""
|
||||
__tablename__ = 'sys_menu'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
parent_id = db.Column(db.Integer, default=0)
|
||||
name = db.Column(db.String(50), nullable=False)
|
||||
code = db.Column(db.String(100), unique=True, nullable=False)
|
||||
path = db.Column(db.String(200))
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
is_visible = db.Column(db.Boolean, default=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'path': self.path,
|
||||
'type': 'menu' # 前端树形控件图标判断用
|
||||
}
|
||||
|
||||
|
||||
class SysElement(db.Model):
|
||||
"""页面元素/列定义表"""
|
||||
__tablename__ = 'sys_element'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
menu_code = db.Column(db.String(100), db.ForeignKey('sys_menu.code'))
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
code = db.Column(db.String(100), nullable=False) # 如: unit_price
|
||||
element_type = db.Column(db.String(20), default='column')
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'code': self.code,
|
||||
'menu_code': self.menu_code,
|
||||
'type': 'element',
|
||||
'element_type': self.element_type
|
||||
}
|
||||
|
||||
|
||||
class SysRolePermission(db.Model):
|
||||
"""角色权限关联表"""
|
||||
__tablename__ = 'sys_role_permission'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
role_code = db.Column(db.String(50), nullable=False)
|
||||
target_code = db.Column(db.String(100), nullable=False) # menu_code 或 element_code
|
||||
type = db.Column(db.String(20), nullable=False) # 'menu' 或 'element'
|
||||
@ -0,0 +1,133 @@
|
||||
from app.extensions import db
|
||||
from datetime import datetime
|
||||
from sqlalchemy import func
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_borrowed_quantity(cls, source_table, stock_id):
|
||||
"""
|
||||
获取指定库存记录(source_table 和 stock_id)的借出未还数量总和。
|
||||
返回浮点数,若无借出记录则返回 0.0。
|
||||
"""
|
||||
result = db.session.query(func.sum(cls.quantity)).filter(
|
||||
cls.source_table == source_table,
|
||||
cls.stock_id == stock_id,
|
||||
cls.is_returned == False
|
||||
).scalar()
|
||||
return float(result) if result is not None else 0.0
|
||||
|
||||
|
||||
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,265 @@
|
||||
# app/services/auth_service.py
|
||||
from app.models.system import SysUser, SysRolePermission # <== 引入 SysRolePermission
|
||||
from app.extensions import db
|
||||
from sqlalchemy import func
|
||||
from flask_jwt_extended import create_access_token
|
||||
from app.utils.constants import UserRole
|
||||
from datetime import timedelta
|
||||
|
||||
class AuthService:
|
||||
# 硬编码的超级管理员凭证
|
||||
SUPER_ADMIN_USER = "IRIS"
|
||||
SUPER_ADMIN_PASS = "licahk"
|
||||
|
||||
@staticmethod
|
||||
def login(data):
|
||||
# 用户登录时输入的只是账号ID (例如: zhangsan)
|
||||
login_input = data.get('username', '').strip()
|
||||
password = data.get('password')
|
||||
|
||||
user_role = None
|
||||
user_id = None
|
||||
user_info = {}
|
||||
|
||||
# 1. 优先检查硬编码的超级管理员 (IRIS)
|
||||
if login_input == AuthService.SUPER_ADMIN_USER:
|
||||
if password == AuthService.SUPER_ADMIN_PASS:
|
||||
user_role = UserRole.SUPER_ADMIN
|
||||
user_id = 0
|
||||
user_info = {
|
||||
'username': '超级管理员(IRIS)',
|
||||
'account_id': 'IRIS',
|
||||
'role': user_role,
|
||||
'department': 'System',
|
||||
'status': 'active'
|
||||
}
|
||||
else:
|
||||
raise ValueError("密码错误")
|
||||
|
||||
# 2. 检查数据库用户
|
||||
# 数据库存的是 "张三/zhangsan"
|
||||
# 登录匹配逻辑: 查找以 "/login_input" 结尾的记录
|
||||
else:
|
||||
# 使用 like 进行后缀匹配: '%/zhangsan'
|
||||
user = SysUser.query.filter(SysUser.username.like(f"%/{login_input}")).first()
|
||||
|
||||
if not user:
|
||||
raise ValueError("用户不存在")
|
||||
|
||||
if not user.check_password(password):
|
||||
raise ValueError("密码错误")
|
||||
|
||||
if user.status != 'active':
|
||||
raise ValueError("账号已被禁用,请联系管理员")
|
||||
|
||||
user_role = user.role.upper() if user.role else None
|
||||
user_id = user.id
|
||||
user_info = user.to_dict()
|
||||
user_info['role'] = user_role
|
||||
|
||||
# 3. 生成 Token
|
||||
# Token 中 identity 存数据库ID,claims 存登录账号ID
|
||||
account_id = user_info.get('account_id', login_input)
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=user_id,
|
||||
additional_claims={
|
||||
'role': user_role,
|
||||
'username': account_id, # 存纯账号ID
|
||||
'display_name': user_info.get('username') # 存显示名
|
||||
},
|
||||
expires_delta=timedelta(days=7)
|
||||
)
|
||||
|
||||
return {
|
||||
'access_token': access_token,
|
||||
'user': user_info
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_user(data, operator_role):
|
||||
"""
|
||||
创建新用户
|
||||
data 包含: cn_name(张三), username(zhangsan), ...
|
||||
"""
|
||||
# 标准化操作者角色为全大写
|
||||
operator_role_upper = operator_role.upper() if operator_role else None
|
||||
if operator_role_upper not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
|
||||
raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
|
||||
|
||||
cn_name = data.get('cn_name')
|
||||
pinyin_base = data.get('username') # 前端传来的基础拼音,如 zhangsan
|
||||
|
||||
if not cn_name or not pinyin_base:
|
||||
raise Exception("姓名和账号不能为空")
|
||||
|
||||
role_raw = data.get('role')
|
||||
role = role_raw.upper() if role_raw else None
|
||||
|
||||
# 验证角色合法性
|
||||
valid_roles = [
|
||||
v for k, v in UserRole.__dict__.items()
|
||||
if not k.startswith('__') and isinstance(v, str)
|
||||
]
|
||||
|
||||
if role not in valid_roles:
|
||||
raise Exception(f"角色无效")
|
||||
|
||||
if operator_role_upper == UserRole.SUPERVISOR and role == UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足:主管无法创建超级管理员")
|
||||
|
||||
email = data.get('email', '')
|
||||
if email and SysUser.query.filter_by(email=email).first():
|
||||
raise Exception("邮箱已被使用")
|
||||
|
||||
# === 核心逻辑: 自动处理账号重复 (zhangsan -> zhangsan1 -> zhangsan2) ===
|
||||
final_account_id = pinyin_base
|
||||
counter = 1 # 如果重复,从1开始累加
|
||||
|
||||
while True:
|
||||
# 检查数据库是否存在以 "/final_account_id" 结尾的记录
|
||||
existing = SysUser.query.filter(
|
||||
(SysUser.username.like(f"%/{final_account_id}")) |
|
||||
(SysUser.username == final_account_id)
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
break # 找到了可用的ID,跳出循环
|
||||
|
||||
# 如果存在,使用 base + counter
|
||||
final_account_id = f"{pinyin_base}{counter}"
|
||||
counter += 1
|
||||
|
||||
# 拼接最终存储格式: 张三/zhangsan1
|
||||
full_username_storage = f"{cn_name}/{final_account_id}"
|
||||
|
||||
new_user = SysUser(
|
||||
username=full_username_storage, # 存组合串
|
||||
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()
|
||||
|
||||
# 返回时,最好把生成的ID告诉前端
|
||||
return new_user.to_dict()
|
||||
|
||||
@staticmethod
|
||||
def update_user(user_id, data, operator_role):
|
||||
"""
|
||||
更新用户信息
|
||||
注意: 这里暂时不允许修改用户名/账号,因为涉及 split 逻辑较复杂,且通常账号不开通后不改
|
||||
"""
|
||||
# 标准化操作者角色为全大写
|
||||
operator_role_upper = operator_role.upper() if operator_role else None
|
||||
if operator_role_upper 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('__') and isinstance(v, str)
|
||||
]
|
||||
new_role_raw = data['role']
|
||||
new_role = new_role_raw.upper() if new_role_raw else None
|
||||
if new_role not in valid_roles:
|
||||
raise Exception(f"角色无效")
|
||||
if operator_role_upper == UserRole.SUPERVISOR and new_role == UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足")
|
||||
user.role = new_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
|
||||
|
||||
if 'status' in data:
|
||||
user.status = data['status']
|
||||
|
||||
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):
|
||||
"""删除用户"""
|
||||
# 标准化操作者角色为全大写
|
||||
operator_role_upper = operator_role.upper() if operator_role else None
|
||||
if operator_role_upper != UserRole.SUPER_ADMIN:
|
||||
raise Exception("权限不足:只有超级管理员可以删除用户")
|
||||
|
||||
user = SysUser.query.get(user_id)
|
||||
if not user:
|
||||
raise Exception("用户不存在")
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_user_permissions(role_code):
|
||||
"""
|
||||
获取指定角色的所有权限代码列表
|
||||
返回格式: {
|
||||
'menus': ['inbound_buy', 'system_user'],
|
||||
'elements': ['inbound_buy:unit_price', ...]
|
||||
}
|
||||
"""
|
||||
# 超级管理员返回所有权限(通配符)
|
||||
from app.utils.constants import UserRole
|
||||
if role_code and role_code.upper() == UserRole.SUPER_ADMIN:
|
||||
# 返回通配符,表示拥有所有菜单和元素权限
|
||||
return {
|
||||
'menus': ['*'],
|
||||
'elements': ['*']
|
||||
}
|
||||
|
||||
# 1. 查菜单权限
|
||||
menu_perms = SysRolePermission.query.filter(
|
||||
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
||||
SysRolePermission.type == 'menu'
|
||||
).all()
|
||||
menu_codes = [p.target_code for p in menu_perms]
|
||||
|
||||
# 2. 查元素(列)权限
|
||||
# 注意:这里我们只返回用户拥有的。前端逻辑是:"如果列配置了Key且用户没这个Key,则隐藏"
|
||||
element_perms = SysRolePermission.query.filter(
|
||||
func.upper(SysRolePermission.role_code) == role_code.upper(),
|
||||
SysRolePermission.type == 'element'
|
||||
).all()
|
||||
|
||||
# 这里的 target_code 就是列的 code (如 unit_price)
|
||||
# 为了防止不同页面有相同列名导致的混淆,我们之前数据库设计是做了隔离的
|
||||
# 但为了前端处理方便,我们直接返回列的 code 集合
|
||||
element_codes = [p.target_code for p in element_perms]
|
||||
|
||||
return {
|
||||
'menus': menu_codes,
|
||||
'elements': element_codes
|
||||
}
|
||||
|
||||
246
inventory-backend/app/services/bom_service.py
Normal file
246
inventory-backend/app/services/bom_service.py
Normal file
@ -0,0 +1,246 @@
|
||||
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, distinct, or_, case
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BomService:
|
||||
# ====================== 新版 BOM 逻辑(基于 bom_no) ======================
|
||||
|
||||
@staticmethod
|
||||
def generate_bom_no():
|
||||
"""生成唯一的 BOM 编号 (作为默认备选)"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
return f'BOM-{timestamp}-{unique}'
|
||||
|
||||
@staticmethod
|
||||
def get_bom_list(keyword=None, active_only=False):
|
||||
"""
|
||||
获取所有 BOM 配方(按 bom_no + version 分组)
|
||||
支持模糊搜索:BOM编号、父件名称/规格、子件名称/规格
|
||||
"""
|
||||
# 1. 关键词过滤:先找出符合条件的 (bom_no, version) 组合
|
||||
query_base = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
)
|
||||
|
||||
# ★ 过滤禁用状态
|
||||
if active_only:
|
||||
query_base = query_base.filter(BomTable.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
# 关联子件表以支持子件搜索
|
||||
child_alias = db.aliased(MaterialBase)
|
||||
query_base = query_base.outerjoin(
|
||||
child_alias, BomTable.child_id == child_alias.id
|
||||
).filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
child_alias.name.ilike(kw),
|
||||
child_alias.spec_model.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
# 获取符合条件的唯一组合
|
||||
target_pairs = query_base.distinct().all()
|
||||
|
||||
if not target_pairs:
|
||||
return []
|
||||
|
||||
# 2. 聚合查询详情
|
||||
results = []
|
||||
for bom_no, version in target_pairs:
|
||||
summary = db.session.query(
|
||||
BomTable.parent_id,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec'),
|
||||
BomTable.is_enabled,
|
||||
func.count(BomTable.child_id).label('child_count')
|
||||
).join(
|
||||
MaterialBase, BomTable.parent_id == MaterialBase.id
|
||||
).filter(
|
||||
BomTable.bom_no == bom_no,
|
||||
BomTable.version == version
|
||||
).group_by(
|
||||
BomTable.parent_id, MaterialBase.name, MaterialBase.spec_model, BomTable.is_enabled
|
||||
).first()
|
||||
|
||||
if summary:
|
||||
results.append({
|
||||
'bom_no': bom_no,
|
||||
'version': version,
|
||||
'parent_id': summary.parent_id,
|
||||
'parent_name': summary.parent_name,
|
||||
'parent_spec': summary.parent_spec or '',
|
||||
'is_enabled': summary.is_enabled,
|
||||
'child_count': summary.child_count
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def get_bom_detail(bom_no, version=None):
|
||||
"""
|
||||
根据 bom_no (和 version) 获取配方详情
|
||||
"""
|
||||
query = db.session.query(
|
||||
BomTable,
|
||||
MaterialBase.name.label('child_name'),
|
||||
MaterialBase.spec_model.label('child_spec')
|
||||
).join(
|
||||
MaterialBase, BomTable.child_id == MaterialBase.id
|
||||
).filter(
|
||||
BomTable.bom_no == bom_no
|
||||
)
|
||||
|
||||
if version:
|
||||
query = query.filter(BomTable.version == version)
|
||||
else:
|
||||
latest_ver = db.session.query(BomTable.version).filter_by(bom_no=bom_no) \
|
||||
.order_by(BomTable.version.desc()).limit(1).scalar()
|
||||
if not latest_ver:
|
||||
return None
|
||||
query = query.filter(BomTable.version == latest_ver)
|
||||
|
||||
rows = query.all()
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
first = rows[0]
|
||||
parent_id = first.BomTable.parent_id
|
||||
parent_material = MaterialBase.query.get(parent_id)
|
||||
|
||||
children = []
|
||||
for bom, child_name, child_spec in rows:
|
||||
children.append({
|
||||
'child_id': bom.child_id,
|
||||
'child_name': child_name,
|
||||
'child_spec': child_spec or '',
|
||||
'dosage': float(bom.dosage) if bom.dosage else 0.0,
|
||||
'remark': bom.remark or ''
|
||||
})
|
||||
|
||||
return {
|
||||
'bom_no': bom_no,
|
||||
'version': first.BomTable.version,
|
||||
'parent_id': parent_id,
|
||||
'parent_name': parent_material.name if parent_material else '',
|
||||
'parent_spec': parent_material.spec_model if parent_material else '',
|
||||
'is_enabled': first.BomTable.is_enabled,
|
||||
'children': children
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def save_bom(data):
|
||||
"""保存 BOM (支持多版本)"""
|
||||
bom_no = data.get('bom_no')
|
||||
version = data.get('version', 'V1.0')
|
||||
parent_id = data['parent_id']
|
||||
children = data['children']
|
||||
is_enabled = data.get('is_enabled', True)
|
||||
|
||||
if not bom_no:
|
||||
raise ValueError('BOM编号不能为空')
|
||||
|
||||
for child in children:
|
||||
if child['child_id'] == parent_id:
|
||||
raise ValueError('父件与子件不能是同一物料')
|
||||
|
||||
# 仅删除当前版本的旧记录
|
||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
||||
|
||||
for child in children:
|
||||
bom = BomTable(
|
||||
bom_no=bom_no,
|
||||
version=version,
|
||||
parent_id=parent_id,
|
||||
child_id=child['child_id'],
|
||||
dosage=child.get('dosage', 0),
|
||||
remark=child.get('remark', ''),
|
||||
is_enabled=is_enabled
|
||||
)
|
||||
db.session.add(bom)
|
||||
|
||||
db.session.commit()
|
||||
return bom_no
|
||||
|
||||
@staticmethod
|
||||
def get_bom_with_stock_by_bom_no(bom_no):
|
||||
"""
|
||||
根据 bom_no 获取配方详情,并计算:
|
||||
1. 总可用库存
|
||||
2. 最大可生产套数
|
||||
3. ★ 聚合库位信息 (warehouse_locations)
|
||||
"""
|
||||
detail = BomService.get_bom_detail(bom_no)
|
||||
if not detail:
|
||||
return None
|
||||
|
||||
for child in detail['children']:
|
||||
# 1. 查询该子件的总库存
|
||||
stock_qty = db.session.query(
|
||||
func.coalesce(func.sum(StockBuy.available_quantity), 0)
|
||||
).filter(
|
||||
StockBuy.base_id == child['child_id']
|
||||
).scalar() or 0
|
||||
|
||||
# 2. ★ 查询该子件涉及的所有库位,并去重拼接 (PostgreSQL 使用 string_agg)
|
||||
# 注意:这里假设主要是 stock_buy 表,如果是成品或半成品也需要做类似 Union 查询
|
||||
# 为简化,这里演示只查 stock_buy 的库位
|
||||
locations = db.session.query(
|
||||
# 去除空值和重复值
|
||||
func.string_agg(distinct(StockBuy.warehouse_location), ', ')
|
||||
).filter(
|
||||
StockBuy.base_id == child['child_id'],
|
||||
StockBuy.available_quantity > 0, # 只看有货的库位
|
||||
StockBuy.warehouse_location != None,
|
||||
StockBuy.warehouse_location != ''
|
||||
).scalar()
|
||||
|
||||
child['current_stock'] = float(stock_qty)
|
||||
child['warehouse_location'] = locations or '' # 返回给前端
|
||||
|
||||
dosage = child['dosage']
|
||||
child['max_producible'] = int(stock_qty // dosage) if dosage > 0 else 0
|
||||
|
||||
return detail
|
||||
|
||||
# ====================== 兼容旧接口 ======================
|
||||
@staticmethod
|
||||
def get_bom_no_by_parent(parent_id):
|
||||
row = BomTable.query.filter_by(parent_id=parent_id).order_by(BomTable.version.desc()).first()
|
||||
return row.bom_no if row else None
|
||||
|
||||
@staticmethod
|
||||
def create_or_update_bom(parent_id, child_list, bom_no=None, version='V1.0'):
|
||||
if not bom_no:
|
||||
existing = BomTable.query.filter_by(parent_id=parent_id).first()
|
||||
bom_no = existing.bom_no if existing else BomService.generate_bom_no()
|
||||
|
||||
BomTable.query.filter_by(bom_no=bom_no, version=version).delete()
|
||||
for item in child_list:
|
||||
bom = BomTable(
|
||||
bom_no=bom_no, version=version, 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_no = BomService.get_bom_no_by_parent(parent_id)
|
||||
if not bom_no: return []
|
||||
detail = BomService.get_bom_with_stock_by_bom_no(bom_no)
|
||||
return detail['children'] if detail else []
|
||||
0
inventory-backend/app/services/inbound/__init__.py
Normal file
0
inventory-backend/app/services/inbound/__init__.py
Normal file
707
inventory-backend/app/services/inbound/base_service.py
Normal file
707
inventory-backend/app/services/inbound/base_service.py
Normal file
@ -0,0 +1,707 @@
|
||||
# 文件路径: 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 app.models.inbound.product import StockProduct
|
||||
# from app.models.inbound.service import StockService
|
||||
from sqlalchemy import or_, and_, func
|
||||
import traceback
|
||||
import json
|
||||
import io
|
||||
import datetime
|
||||
# 需要 pip install openpyxl
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, Border, Side, PatternFill
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
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}%'),
|
||||
# 支持搜索公司名
|
||||
MaterialBase.company_name.ilike(f'%{keyword}%')
|
||||
)
|
||||
)
|
||||
|
||||
# [修改1] 增加返回数量限制
|
||||
# 原为 limit(20),现改为 1000,确保前端能获取所有(或足够多)的数据
|
||||
query = query.limit(1000)
|
||||
|
||||
# 获取查询结果对象列表
|
||||
db_items = query.all()
|
||||
|
||||
# [修改2] 规格型号排序逻辑
|
||||
# 要求:只考虑 '/' 前面的内容进行排序
|
||||
# 使用 Python 的 sort 方法,提取 spec_model 中 '/' 前的部分
|
||||
def get_sort_key(item):
|
||||
if not item.spec_model:
|
||||
return ""
|
||||
# 如果包含 '/',取前半部分;否则取整个字符串
|
||||
parts = item.spec_model.split('/')
|
||||
return parts[0] if len(parts) > 0 else item.spec_model
|
||||
|
||||
# 执行排序
|
||||
db_items.sort(key=get_sort_key)
|
||||
|
||||
results = []
|
||||
for item in db_items:
|
||||
results.append({
|
||||
'id': item.id, # 必须保留ID供前端逻辑使用,视觉上的隐藏请在前端处理
|
||||
'companyName': item.company_name,
|
||||
'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_stock_counts(stock_query):
|
||||
"""
|
||||
辅助函数:安全计算库存列表的总数量
|
||||
"""
|
||||
total_inv = 0
|
||||
total_avail = 0
|
||||
|
||||
try:
|
||||
items = list(stock_query) # 触发查询
|
||||
except:
|
||||
items = []
|
||||
|
||||
for x in items:
|
||||
# 1. 获取库存数 (兼容不同字段名)
|
||||
q = getattr(x, 'stock_quantity', getattr(x, 'in_quantity', 0)) # 优先取库存,其次入库
|
||||
# 2. 获取可用数
|
||||
a = getattr(x, 'available_quantity', q)
|
||||
|
||||
try:
|
||||
total_inv += float(q if q is not None else 0)
|
||||
total_avail += float(a if a is not None else 0)
|
||||
except:
|
||||
pass
|
||||
|
||||
return total_inv, total_avail
|
||||
|
||||
@staticmethod
|
||||
def get_list(page, limit, filters=None):
|
||||
"""
|
||||
获取基础信息列表 (带分页和筛选)
|
||||
"""
|
||||
try:
|
||||
# 构建聚合子查询
|
||||
buy_sub = db.session.query(
|
||||
StockBuy.base_id,
|
||||
func.sum(StockBuy.stock_quantity).label('buy_inv'),
|
||||
func.sum(StockBuy.available_quantity).label('buy_avail')
|
||||
).group_by(StockBuy.base_id).subquery()
|
||||
|
||||
semi_sub = db.session.query(
|
||||
StockSemi.base_id,
|
||||
func.sum(StockSemi.stock_quantity).label('semi_inv'),
|
||||
func.sum(StockSemi.available_quantity).label('semi_avail')
|
||||
).group_by(StockSemi.base_id).subquery()
|
||||
|
||||
prod_sub = db.session.query(
|
||||
StockProduct.base_id,
|
||||
func.sum(StockProduct.stock_quantity).label('prod_inv'),
|
||||
func.sum(StockProduct.available_quantity).label('prod_avail')
|
||||
).group_by(StockProduct.base_id).subquery()
|
||||
|
||||
# 总库存和可用数的 SQL 表达式
|
||||
total_inv = func.coalesce(buy_sub.c.buy_inv, 0) + \
|
||||
func.coalesce(semi_sub.c.semi_inv, 0) + \
|
||||
func.coalesce(prod_sub.c.prod_inv, 0)
|
||||
total_avail = func.coalesce(buy_sub.c.buy_avail, 0) + \
|
||||
func.coalesce(semi_sub.c.semi_avail, 0) + \
|
||||
func.coalesce(prod_sub.c.prod_avail, 0)
|
||||
|
||||
# 主查询,关联聚合子查询
|
||||
query = db.session.query(
|
||||
MaterialBase,
|
||||
total_inv.label('total_inv'),
|
||||
total_avail.label('total_avail')
|
||||
).outerjoin(buy_sub, MaterialBase.id == buy_sub.c.base_id) \
|
||||
.outerjoin(semi_sub, MaterialBase.id == semi_sub.c.base_id) \
|
||||
.outerjoin(prod_sub, MaterialBase.id == prod_sub.c.base_id)
|
||||
|
||||
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. 精确筛选
|
||||
company = filters.get('company')
|
||||
if company is not None and company != '':
|
||||
query = query.filter(MaterialBase.company_name.ilike(company.strip()))
|
||||
|
||||
category = filters.get('category')
|
||||
if category is not None and category != '':
|
||||
query = query.filter(MaterialBase.category.ilike(category.strip()))
|
||||
|
||||
type_val = filters.get('type')
|
||||
if type_val is not None and type_val != '':
|
||||
query = query.filter(MaterialBase.material_type.ilike(type_val.strip()))
|
||||
|
||||
if filters.get('isEnabled') is not None:
|
||||
is_active = bool(int(filters['isEnabled']))
|
||||
query = query.filter_by(is_enabled=is_active)
|
||||
|
||||
# 排序处理
|
||||
order_by_column = filters.get('orderByColumn', '')
|
||||
is_asc = filters.get('isAsc', None)
|
||||
if order_by_column == 'inventoryCount':
|
||||
if is_asc == 'asc':
|
||||
query = query.order_by(total_inv.asc())
|
||||
else:
|
||||
query = query.order_by(total_inv.desc())
|
||||
elif order_by_column == 'availableCount':
|
||||
if is_asc == 'asc':
|
||||
query = query.order_by(total_avail.asc())
|
||||
else:
|
||||
query = query.order_by(total_avail.desc())
|
||||
else:
|
||||
# 默认排序:优先按总库存数降序,当库存相同时,再按规格型号升序
|
||||
query = query.order_by(total_inv.desc(), MaterialBase.spec_model.asc())
|
||||
|
||||
# 分页
|
||||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
|
||||
items_list = []
|
||||
for item, inv, avail in pagination.items:
|
||||
item_dict = item.to_dict()
|
||||
item_dict['inventoryCount'] = float(inv) if inv is not None else 0.0
|
||||
item_dict['availableCount'] = float(avail) if avail is not None else 0.0
|
||||
items_list.append(item_dict)
|
||||
|
||||
return {"total": pagination.total, "items": items_list}
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
print(f"查询基础信息列表失败: {e}")
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def get_distinct_options():
|
||||
"""
|
||||
获取所有已存在的类别、类型、公司 (去重且排序)
|
||||
"""
|
||||
try:
|
||||
# 1. 类别 (获取后在内存或前端做层级处理,这里先按字母序返回扁平列表)
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
|
||||
# 对类别进行排序
|
||||
sorted_categories = sorted([c[0] for c in categories])
|
||||
|
||||
# 2. 类型
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
sorted_types = sorted([t[0] for t in types])
|
||||
|
||||
# 3. 公司
|
||||
companies = db.session.query(MaterialBase.company_name) \
|
||||
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
|
||||
.distinct().all()
|
||||
sorted_companies = sorted([c[0] for c in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
|
||||
@staticmethod
|
||||
def create_material(data):
|
||||
"""新增基础信息"""
|
||||
try:
|
||||
if not data.get('name') or not data.get('spec'):
|
||||
raise ValueError("名称和规格型号不能为空")
|
||||
|
||||
exist = MaterialBase.query.filter_by(
|
||||
name=data['name'],
|
||||
spec_model=data['spec']
|
||||
).first()
|
||||
if exist:
|
||||
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
|
||||
|
||||
new_material = MaterialBase(
|
||||
company_name=data.get('companyName'),
|
||||
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'),
|
||||
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 'companyName' in data: material.company_name = data['companyName']
|
||||
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']
|
||||
|
||||
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()
|
||||
prod_usage_count = StockProduct.query.filter_by(base_id=m_id).count()
|
||||
|
||||
total_usage = buy_usage_count + semi_usage_count + prod_usage_count
|
||||
|
||||
if total_usage > 0:
|
||||
raise ValueError(
|
||||
f"无法删除:该基础物料正被使用中。\n"
|
||||
f"- 采购库存记录: {buy_usage_count} 条\n"
|
||||
f"- 半成品库存记录: {semi_usage_count} 条\n"
|
||||
f"- 成品库存记录: {prod_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
|
||||
|
||||
# ==============================================================================
|
||||
# [核心修改] 统一资产统计导出(增加最高单价计算逻辑)
|
||||
# ==============================================================================
|
||||
@staticmethod
|
||||
def export_excel(filters=None, user_permissions=None):
|
||||
"""
|
||||
全口径资产统计报表:
|
||||
根据基础信息列表和库存表,计算出每个物料的最高历史单价并进行导出
|
||||
"""
|
||||
try:
|
||||
# 1. 构造基础信息的筛选条件 (用于过滤库存)
|
||||
filter_conditions = []
|
||||
if filters:
|
||||
if filters.get('keyword'):
|
||||
kw = f"%{filters['keyword']}%"
|
||||
filter_conditions.append(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.common_name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw)
|
||||
))
|
||||
company = filters.get('company')
|
||||
if company is not None and company != '':
|
||||
filter_conditions.append(MaterialBase.company_name.ilike(company.strip()))
|
||||
category = filters.get('category')
|
||||
if category is not None and category != '':
|
||||
filter_conditions.append(MaterialBase.category.ilike(category.strip()))
|
||||
type_val = filters.get('type')
|
||||
if type_val is not None and type_val != '':
|
||||
filter_conditions.append(MaterialBase.material_type.ilike(type_val.strip()))
|
||||
if filters.get('isEnabled') is not None:
|
||||
is_active = bool(int(filters['isEnabled']))
|
||||
filter_conditions.append(MaterialBase.is_enabled == is_active)
|
||||
|
||||
# 2. 分别查询三个库存表,并 Join MaterialBase 进行筛选
|
||||
# 2.1 采购库存 (StockBuy)
|
||||
query_buy = db.session.query(StockBuy, MaterialBase).join(
|
||||
MaterialBase, StockBuy.base_id == MaterialBase.id
|
||||
)
|
||||
for cond in filter_conditions:
|
||||
query_buy = query_buy.filter(cond)
|
||||
list_buy = query_buy.all()
|
||||
|
||||
# 2.2 半成品库存 (StockSemi)
|
||||
query_semi = db.session.query(StockSemi, MaterialBase).join(
|
||||
MaterialBase, StockSemi.base_id == MaterialBase.id
|
||||
)
|
||||
for cond in filter_conditions:
|
||||
query_semi = query_semi.filter(cond)
|
||||
list_semi = query_semi.all()
|
||||
|
||||
# 2.3 成品库存 (StockProduct)
|
||||
query_product = db.session.query(StockProduct, MaterialBase).join(
|
||||
MaterialBase, StockProduct.base_id == MaterialBase.id
|
||||
)
|
||||
for cond in filter_conditions:
|
||||
query_product = query_product.filter(cond)
|
||||
list_product = query_product.all()
|
||||
|
||||
# ====================================================
|
||||
# [核心新增] 预先计算每个 base_id 的全局最高历史单价
|
||||
# 优先级:采购件 > 半成品 > 成品
|
||||
# ====================================================
|
||||
buy_max_prices = {}
|
||||
for stock, base in list_buy:
|
||||
price = float(stock.pre_tax_unit_price or 0)
|
||||
if price > buy_max_prices.get(base.id, 0):
|
||||
buy_max_prices[base.id] = price
|
||||
|
||||
semi_max_prices = {}
|
||||
for stock, base in list_semi:
|
||||
# 半成品的单价直接取自 manual_cost 字段(单件总成本)
|
||||
price = float(stock.manual_cost or 0)
|
||||
|
||||
if price > semi_max_prices.get(base.id, 0):
|
||||
semi_max_prices[base.id] = price
|
||||
|
||||
product_max_prices = {}
|
||||
for stock, base in list_product:
|
||||
# 成品的单价直接取自 manual_cost 字段(单件总成本)
|
||||
price = float(stock.manual_cost or 0)
|
||||
|
||||
if price > product_max_prices.get(base.id, 0):
|
||||
product_max_prices[base.id] = price
|
||||
|
||||
# 构造获取某个物料最高价的闭包函数
|
||||
def get_highest_price(base_id):
|
||||
if base_id in buy_max_prices and buy_max_prices[base_id] > 0:
|
||||
return buy_max_prices[base_id]
|
||||
if base_id in semi_max_prices and semi_max_prices[base_id] > 0:
|
||||
return semi_max_prices[base_id]
|
||||
if base_id in product_max_prices and product_max_prices[base_id] > 0:
|
||||
return product_max_prices[base_id]
|
||||
return 0.0
|
||||
|
||||
# 3. 数据整合
|
||||
all_rows = []
|
||||
|
||||
# 处理采购件
|
||||
for stock, base in list_buy:
|
||||
qty = float(stock.stock_quantity or 0)
|
||||
# 使用该物料的全局最高单价作为不含税单价
|
||||
highest_excl_price = get_highest_price(base.id)
|
||||
tax_rate = float(stock.tax_rate or 0)
|
||||
|
||||
# 计算含税单价和总额
|
||||
highest_incl_price = highest_excl_price * (1 + tax_rate / 100.0)
|
||||
total_val_excl = qty * highest_excl_price
|
||||
total_val_incl = qty * highest_incl_price
|
||||
|
||||
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
|
||||
|
||||
all_rows.append({
|
||||
"base": base,
|
||||
"type_name": "采购件",
|
||||
"ident": ident,
|
||||
"loc": stock.warehouse_location,
|
||||
"source": stock.supplier_name,
|
||||
"date": stock.in_date,
|
||||
"qty": qty,
|
||||
"avail": float(stock.available_quantity or 0),
|
||||
"price_excl": highest_excl_price,
|
||||
"total_val_excl": total_val_excl,
|
||||
"tax": tax_rate,
|
||||
"price_incl": highest_incl_price,
|
||||
"total_val": total_val_incl
|
||||
})
|
||||
|
||||
# 处理半成品
|
||||
for stock, base in list_semi:
|
||||
qty = float(stock.stock_quantity or 0)
|
||||
# 半成品的单价直接取自 manual_cost 字段(单件总成本)
|
||||
unit_cost = float(stock.manual_cost or 0)
|
||||
|
||||
total_val_excl = qty * unit_cost
|
||||
total_val_incl = qty * unit_cost # 半成品无税
|
||||
|
||||
ident = stock.batch_number or stock.serial_number or stock.barcode or stock.sku
|
||||
|
||||
all_rows.append({
|
||||
"base": base,
|
||||
"type_name": "半成品",
|
||||
"ident": ident,
|
||||
"loc": stock.warehouse_location,
|
||||
"source": stock.production_manager,
|
||||
"date": stock.production_date,
|
||||
"qty": qty,
|
||||
"avail": float(stock.available_quantity or 0),
|
||||
"price_excl": unit_cost,
|
||||
"total_val_excl": total_val_excl,
|
||||
"tax": 0.0,
|
||||
"price_incl": unit_cost,
|
||||
"total_val": total_val_incl
|
||||
})
|
||||
|
||||
# 处理成品
|
||||
for stock, base in list_product:
|
||||
qty = float(stock.stock_quantity or 0)
|
||||
# 成品的单价直接取自 manual_cost 字段(单件总成本)
|
||||
unit_cost = float(stock.manual_cost or 0)
|
||||
|
||||
total_val_excl = qty * unit_cost
|
||||
total_val_incl = qty * unit_cost
|
||||
|
||||
ident = stock.serial_number or stock.barcode or stock.sku
|
||||
|
||||
all_rows.append({
|
||||
"base": base,
|
||||
"type_name": "成品",
|
||||
"ident": ident,
|
||||
"loc": stock.warehouse_location,
|
||||
"source": stock.production_manager,
|
||||
"date": stock.production_date,
|
||||
"qty": qty,
|
||||
"avail": float(stock.available_quantity or 0),
|
||||
"price_excl": unit_cost,
|
||||
"total_val_excl": total_val_excl,
|
||||
"tax": 0.0,
|
||||
"price_incl": unit_cost,
|
||||
"total_val": total_val_incl
|
||||
})
|
||||
|
||||
# 4. 排序:按公司 -> 规格型号 -> 基础ID -> 批号 排序
|
||||
all_rows.sort(key=lambda x: (
|
||||
x['base'].company_name or "",
|
||||
x['base'].spec_model or "",
|
||||
x['base'].id,
|
||||
x['ident'] or ""
|
||||
))
|
||||
|
||||
# 5. 生成 Excel
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "库存统计"
|
||||
|
||||
# 表头 (严格对应你的图 5)
|
||||
headers = [
|
||||
"所属公司", "资产名称", "规格型号", "物料类型",
|
||||
"类别一级", "类别二级", "类别三级", "类别四级", "类别五级",
|
||||
"计量单位",
|
||||
"库存性质", "唯一标识码 (批号/SN)", "仓库位置",
|
||||
"资产来源", "入库/生产日期",
|
||||
"库存数量", "可用数量",
|
||||
"单价/成本 (不含税)", "资产总额 (不含税)", "税率 (%)", "单价/成本 (含税)", "资产总额 (含税)"
|
||||
]
|
||||
ws.append(headers)
|
||||
|
||||
# 确定各字段在表头中的列索引
|
||||
col_idx = {}
|
||||
for idx, header in enumerate(headers):
|
||||
if header == "所属公司":
|
||||
col_idx['companyName'] = idx
|
||||
elif header == "资产名称":
|
||||
col_idx['name'] = idx
|
||||
elif header == "规格型号":
|
||||
col_idx['spec'] = idx
|
||||
elif header == "物料类型":
|
||||
col_idx['type'] = idx
|
||||
elif header in ("类别一级", "类别二级", "类别三级", "类别四级", "类别五级"):
|
||||
col_idx.setdefault('category_cols', []).append(idx)
|
||||
elif header == "计量单位":
|
||||
col_idx['unit'] = idx
|
||||
elif header == "库存数量":
|
||||
col_idx['inventoryCount'] = idx
|
||||
elif header == "可用数量":
|
||||
col_idx['availableCount'] = idx
|
||||
elif header == "单价/成本 (不含税)":
|
||||
col_idx['price_excl'] = idx
|
||||
elif header == "资产总额 (不含税)":
|
||||
col_idx['total_val_excl'] = idx
|
||||
elif header == "税率 (%)":
|
||||
col_idx['tax'] = idx
|
||||
elif header == "单价/成本 (含税)":
|
||||
col_idx['price_incl'] = idx
|
||||
elif header == "资产总额 (含税)":
|
||||
col_idx['total_val'] = idx
|
||||
|
||||
# 样式
|
||||
header_fill = PatternFill(start_color="D7E4BC", end_color="D7E4BC", fill_type="solid")
|
||||
border_style = Border(left=Side(style='thin'), right=Side(style='thin'), top=Side(style='thin'),
|
||||
bottom=Side(style='thin'))
|
||||
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True, name='微软雅黑')
|
||||
cell.alignment = Alignment(horizontal='center', vertical='center')
|
||||
cell.fill = header_fill
|
||||
cell.border = border_style
|
||||
|
||||
# 字段到权限码的映射
|
||||
field_to_perm = {
|
||||
'companyName': 'material_list:companyName',
|
||||
'name': 'material_list:name',
|
||||
'spec': 'material_list:spec',
|
||||
'type': 'material_list:type',
|
||||
'unit': 'material_list:unit',
|
||||
'category': 'material_list:category',
|
||||
'inventoryCount': 'material_list:inventoryCount',
|
||||
'availableCount': 'material_list:availableCount'
|
||||
}
|
||||
|
||||
# 写入数据,并脱敏
|
||||
for r in all_rows:
|
||||
base = r['base']
|
||||
# 类别拆分
|
||||
cat_parts = (base.category or "").split('/')
|
||||
while len(cat_parts) < 5:
|
||||
cat_parts.append("")
|
||||
|
||||
# 日期格式化
|
||||
date_str = r['date'].strftime('%Y-%m-%d') if isinstance(r['date'], datetime.date) else ""
|
||||
|
||||
row_val = [
|
||||
base.company_name,
|
||||
base.name,
|
||||
base.spec_model,
|
||||
base.material_type,
|
||||
cat_parts[0], cat_parts[1], cat_parts[2], cat_parts[3], cat_parts[4],
|
||||
base.unit,
|
||||
r['type_name'],
|
||||
r['ident'],
|
||||
r['loc'],
|
||||
r['source'],
|
||||
date_str,
|
||||
r['qty'],
|
||||
r['avail'],
|
||||
r['price_excl'],
|
||||
r['total_val_excl'],
|
||||
r['tax'],
|
||||
r['price_incl'],
|
||||
r['total_val']
|
||||
]
|
||||
|
||||
# 根据用户权限脱敏
|
||||
if user_permissions is not None:
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if perm_code not in user_permissions:
|
||||
if field == 'category':
|
||||
for cat_idx in col_idx.get('category_cols', []):
|
||||
row_val[cat_idx] = ''
|
||||
elif field in col_idx:
|
||||
row_val[col_idx[field]] = ''
|
||||
|
||||
# 联动脱敏:根据数据来源,校验对应模块的价格/成本权限
|
||||
if user_permissions is not None:
|
||||
# 超级管理员拥有所有权限,跳过价格脱敏
|
||||
if 'material_list:*' in user_permissions:
|
||||
# 拥有通配符权限,不隐藏价格列
|
||||
pass
|
||||
else:
|
||||
has_price_perm = True
|
||||
row_type = r['type_name']
|
||||
|
||||
# 根据数据来源检查对应模块的权限
|
||||
if row_type == '采购件':
|
||||
# 校验采购模块的价格权限
|
||||
has_price_perm = any(p in user_permissions for p in
|
||||
['inbound_buy:postTaxUnitPrice', 'inbound_buy:preTaxUnitPrice',
|
||||
'inbound_buy:totalAmount'])
|
||||
elif row_type == '半成品':
|
||||
# 校验半成品模块的成本权限
|
||||
has_price_perm = any(p in user_permissions for p in
|
||||
['inbound_semi:rawMaterialCost', 'inbound_semi:manualCost'])
|
||||
elif row_type == '成品':
|
||||
# 校验成品模块的成本权限
|
||||
has_price_perm = any(p in user_permissions for p in
|
||||
['inbound_product:rawMaterialCost', 'inbound_product:manualCost'])
|
||||
else:
|
||||
# 未知类型,默认隐藏价格列
|
||||
has_price_perm = False
|
||||
|
||||
# 如果没有对应模块的价格查看权限,则清空涉密的5个列
|
||||
if not has_price_perm:
|
||||
for p_col in ['price_excl', 'total_val_excl', 'tax', 'price_incl', 'total_val']:
|
||||
if p_col in col_idx:
|
||||
row_val[col_idx[p_col]] = ''
|
||||
|
||||
ws.append(row_val)
|
||||
|
||||
# 列宽调整
|
||||
dims = {}
|
||||
for row in ws.rows:
|
||||
for cell in row:
|
||||
if cell.value:
|
||||
dims[cell.column_letter] = max((dims.get(cell.column_letter, 0), len(str(cell.value))))
|
||||
for col, value in dims.items():
|
||||
ws.column_dimensions[col].width = min(value + 2, 30)
|
||||
|
||||
output = io.BytesIO()
|
||||
wb.save(output)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
345
inventory-backend/app/services/inbound/buy_service.py
Normal file
345
inventory-backend/app/services/inbound/buy_service.py
Normal file
@ -0,0 +1,345 @@
|
||||
# inventory-backend/app/services/inbound/buy_service.py
|
||||
from app.extensions import db
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.base import MaterialBase
|
||||
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):
|
||||
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:
|
||||
occupied_name = exists.base.name if exists.base else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
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, page=1, limit=50):
|
||||
try:
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
k = keyword.strip()
|
||||
k_str = f'%{k}%'
|
||||
query = query.filter(and_(
|
||||
or_(
|
||||
MaterialBase.name.ilike(k_str),
|
||||
MaterialBase.spec_model.ilike(k_str),
|
||||
MaterialBase.company_name.ilike(k_str) # 支持搜公司
|
||||
)
|
||||
))
|
||||
|
||||
query = query.order_by(MaterialBase.id.desc())
|
||||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
|
||||
items = []
|
||||
for item in pagination.items:
|
||||
items.append({
|
||||
'id': item.id,
|
||||
'company_name': item.company_name, # [新增]
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'brand': getattr(item, 'brand', ''),
|
||||
'manufacturer': getattr(item, 'manufacturer', ''),
|
||||
'pinyin': getattr(item, 'pinyin', ''),
|
||||
'status': '启用'
|
||||
})
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": pagination.total,
|
||||
"page": page,
|
||||
"has_next": pagination.has_next
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||
|
||||
# ============================================================
|
||||
# 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("所选物料不存在")
|
||||
if not material.is_enabled: raise ValueError(f"物料【{material.name}】已停用")
|
||||
|
||||
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)
|
||||
tax_rate = float(data.get('tax_rate') or 0)
|
||||
|
||||
# 计算税后单价
|
||||
post_tax_price = float(data.get('post_tax_unit_price') or 0)
|
||||
if post_tax_price == 0 and u_price > 0:
|
||||
tax_multiplier = 1 + (tax_rate / 100)
|
||||
post_tax_price = u_price * tax_multiplier
|
||||
|
||||
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
|
||||
|
||||
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'),
|
||||
|
||||
# 价格信息
|
||||
pre_tax_unit_price=u_price,
|
||||
post_tax_unit_price=post_tax_price,
|
||||
tax_rate=tax_rate,
|
||||
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(data.get('arrival_photo', [])),
|
||||
inspection_report=json.dumps(data.get('inspection_report', []))
|
||||
)
|
||||
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("记录不存在")
|
||||
BuyInboundService._check_unique(base_id=data.get('base_id', stock.base_id),
|
||||
serial_number=data.get('serial_number', stock.serial_number),
|
||||
batch_number=data.get('batch_number', stock.batch_number),
|
||||
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: stock.arrival_photo = json.dumps(data['arrival_photo'])
|
||||
if 'inspection_report' in data: stock.inspection_report = json.dumps(data['inspection_report'])
|
||||
|
||||
# 更新税率
|
||||
if 'tax_rate' in data:
|
||||
stock.tax_rate = float(data['tax_rate'])
|
||||
|
||||
# 更新税前单价
|
||||
if 'unit_price' in data:
|
||||
stock.pre_tax_unit_price = float(data['unit_price'])
|
||||
|
||||
# 更新税后单价
|
||||
if 'post_tax_unit_price' in data:
|
||||
stock.post_tax_unit_price = float(data['post_tax_unit_price'])
|
||||
else:
|
||||
# 如果税后单价没有提供,根据税前单价和税率计算
|
||||
if 'unit_price' in data or 'tax_rate' in data:
|
||||
tax_multiplier = 1 + (float(data.get('tax_rate', stock.tax_rate or 0)) / 100)
|
||||
stock.post_tax_unit_price = float(stock.pre_tax_unit_price) * tax_multiplier
|
||||
|
||||
if 'in_quantity' in data:
|
||||
diff = float(data['in_quantity']) - float(stock.in_quantity)
|
||||
if diff != 0:
|
||||
stock.in_quantity = float(data['in_quantity'])
|
||||
stock.stock_quantity = float(stock.stock_quantity) + diff
|
||||
stock.available_quantity = float(stock.available_quantity) + diff
|
||||
|
||||
# 重新计算总价
|
||||
stock.total_price = float(stock.in_quantity) * float(stock.pre_tax_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, category=None, material_type=None, company=None):
|
||||
try:
|
||||
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||
|
||||
# 1. 通用关键词搜索
|
||||
if keyword:
|
||||
k_str = f'%{keyword.strip()}%'
|
||||
conditions = [
|
||||
StockBuy.sku.ilike(k_str),
|
||||
StockBuy.barcode.ilike(k_str),
|
||||
StockBuy.batch_number.ilike(k_str),
|
||||
StockBuy.serial_number.ilike(k_str),
|
||||
StockBuy.supplier_name.ilike(k_str),
|
||||
StockBuy.buyer_name.ilike(k_str),
|
||||
MaterialBase.name.ilike(k_str),
|
||||
MaterialBase.spec_model.ilike(k_str),
|
||||
MaterialBase.company_name.ilike(k_str), # 关键词也支持搜公司
|
||||
]
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
# 2. 类别独立搜索
|
||||
if category and category.strip():
|
||||
query = query.filter(MaterialBase.category == category.strip())
|
||||
|
||||
# 3. 类型独立搜索
|
||||
if material_type and material_type.strip():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
# 3.1 公司独立搜索 [新增]
|
||||
if company and company.strip():
|
||||
query = query.filter(MaterialBase.company_name == company.strip())
|
||||
|
||||
# 4. 状态筛选
|
||||
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)
|
||||
items = []
|
||||
for item in pagination.items:
|
||||
items.append(item.to_dict()) # 直接使用 model 的 to_dict
|
||||
return {"total": pagination.total, "items": items}
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
# ============================================================
|
||||
# 6. 获取筛选选项(类别、类型、公司)并排序
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def get_filter_options():
|
||||
try:
|
||||
# 类别
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
sorted_categories = sorted([r[0] for r in categories])
|
||||
|
||||
# 类型
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
sorted_types = sorted([r[0] for r in types])
|
||||
|
||||
# [新增] 公司
|
||||
companies = db.session.query(MaterialBase.company_name) \
|
||||
.filter(MaterialBase.company_name != None, MaterialBase.company_name != '') \
|
||||
.distinct().all()
|
||||
sorted_companies = sorted([r[0] for r in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
|
||||
# 7-10 建议类接口保持不变
|
||||
@staticmethod
|
||||
def get_history_suppliers(base_id):
|
||||
return [r[0] for r in db.session.query(StockBuy.supplier_name).filter(StockBuy.base_id == base_id,
|
||||
StockBuy.supplier_name != '').distinct().all()]
|
||||
|
||||
@staticmethod
|
||||
def get_history_purchasers(keyword):
|
||||
return [{'value': r.buyer_name, 'email': r.buyer_email} for r in
|
||||
db.session.query(StockBuy.buyer_name, StockBuy.buyer_email).filter(
|
||||
StockBuy.buyer_name != '').distinct().all()]
|
||||
|
||||
@staticmethod
|
||||
def get_history_links(base_id, type):
|
||||
return [r[0] for r in
|
||||
db.session.query(StockBuy.original_link if type == 'original' else StockBuy.detail_link).filter(
|
||||
StockBuy.base_id == base_id).distinct().all()]
|
||||
|
||||
@staticmethod
|
||||
def get_history_locations(base_id):
|
||||
return [r[0] for r in
|
||||
db.session.query(StockBuy.warehouse_location).filter(StockBuy.base_id == base_id).distinct().all()]
|
||||
@ -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
|
||||
441
inventory-backend/app/services/inbound/product_service.py
Normal file
441
inventory-backend/app/services/inbound/product_service.py
Normal file
@ -0,0 +1,441 @@
|
||||
# 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:
|
||||
|
||||
@staticmethod
|
||||
def _check_unique(serial_number, exclude_id=None):
|
||||
from app.models.inbound.product import StockProduct
|
||||
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:
|
||||
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
@staticmethod
|
||||
def search_base_material(keyword, page=1, limit=50):
|
||||
try:
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw)
|
||||
)
|
||||
)
|
||||
query = query.order_by(MaterialBase.id.desc())
|
||||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
results = []
|
||||
for item in pagination.items:
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'company_name': item.company_name,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'status': '启用'
|
||||
})
|
||||
return {
|
||||
"items": results,
|
||||
"total": pagination.total,
|
||||
"page": page,
|
||||
"has_next": pagination.has_next
|
||||
}
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||
|
||||
@staticmethod
|
||||
def search_bom_options(keyword):
|
||||
from app.models.bom import BomTable
|
||||
try:
|
||||
query = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec')
|
||||
).join(MaterialBase, BomTable.parent_id == MaterialBase.id)
|
||||
|
||||
if hasattr(BomTable, 'is_enabled'):
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.distinct().limit(20).all()
|
||||
|
||||
return [{
|
||||
'bom_no': r.bom_no,
|
||||
'version': r.version,
|
||||
'parent_name': r.parent_name,
|
||||
'parent_spec': r.parent_spec or ''
|
||||
} for r in results]
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@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("物料不存在")
|
||||
if not material.is_enabled:
|
||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||
|
||||
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)
|
||||
raw_cost = float(data.get('raw_material_cost') or 0)
|
||||
manual_cost = 0.0 # 字段已弃用,保持向后兼容
|
||||
unit_total_cost = float(data.get('unit_total_cost') or raw_cost or 0)
|
||||
total_price = unit_total_cost * in_qty
|
||||
|
||||
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,
|
||||
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=raw_cost,
|
||||
manual_cost=unit_total_cost,
|
||||
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
|
||||
|
||||
@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 'unit_total_cost' in data: stock.manual_cost = float(data['unit_total_cost']) # 映射到 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
|
||||
|
||||
@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
|
||||
|
||||
@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 []
|
||||
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=None):
|
||||
from app.models.inbound.product import StockProduct
|
||||
try:
|
||||
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw),
|
||||
StockProduct.serial_number.ilike(kw),
|
||||
StockProduct.work_order_code.ilike(kw),
|
||||
StockProduct.order_id.ilike(kw),
|
||||
StockProduct.sku.ilike(kw)
|
||||
))
|
||||
if category and category.strip():
|
||||
query = query.filter(MaterialBase.category == category.strip())
|
||||
if material_type and material_type.strip():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
if company and company.strip():
|
||||
query = query.filter(MaterialBase.company_name == company.strip())
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
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:
|
||||
item_dict = item.to_dict()
|
||||
item_dict['unit_total_cost'] = float(item.manual_cost or 0)
|
||||
items.append(item_dict)
|
||||
return {"total": pagination.total, "items": items}
|
||||
except:
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def search_system_users(keyword):
|
||||
from app.models.system import SysUser
|
||||
try:
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(db.or_(
|
||||
SysUser.username.ilike(kw),
|
||||
SysUser.email.ilike(kw)
|
||||
))
|
||||
query = query.order_by(SysUser.username)
|
||||
users = []
|
||||
for u in query.limit(20).all():
|
||||
users.append({
|
||||
'value': u.username,
|
||||
'email': u.email
|
||||
})
|
||||
return users
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_filter_options():
|
||||
try:
|
||||
from app.models.base import MaterialBase
|
||||
categories = db.session.query(MaterialBase.category).filter(MaterialBase.category != None,
|
||||
MaterialBase.category != '').distinct().all()
|
||||
sorted_categories = sorted([r[0] for r in categories])
|
||||
|
||||
types = db.session.query(MaterialBase.material_type).filter(MaterialBase.material_type != None,
|
||||
MaterialBase.material_type != '').distinct().all()
|
||||
sorted_types = sorted([r[0] for r in types])
|
||||
|
||||
companies = db.session.query(MaterialBase.company_name).filter(MaterialBase.company_name != None,
|
||||
MaterialBase.company_name != '').distinct().all()
|
||||
sorted_companies = sorted([r[0] for r in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
|
||||
@staticmethod
|
||||
def get_history_managers(keyword=None):
|
||||
from app.models.inbound.product import StockProduct
|
||||
try:
|
||||
query = db.session.query(StockProduct.production_manager).filter(
|
||||
StockProduct.production_manager.isnot(None),
|
||||
StockProduct.production_manager != ''
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(StockProduct.production_manager.ilike(f'%{keyword}%'))
|
||||
records = query.distinct().all()
|
||||
return [r[0] for r in records if r[0]]
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
# ============================================================
|
||||
# 9. BOM 原材料成本自动核算 (新增)
|
||||
# ============================================================
|
||||
@staticmethod
|
||||
def calculate_bom_cost(bom_no, bom_version):
|
||||
"""
|
||||
根据 BOM 编号和版本计算原材料总成本
|
||||
遍历 BOM 子件,使用原生 SQL 查物理表 bom_table,取每个子件在采购、半成品、成品三个表中的最高单价,乘以用量后累加
|
||||
"""
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy import func, text
|
||||
try:
|
||||
# 使用原生 SQL 精准查询 bom_table,避免模型映射错误
|
||||
sql = text("""
|
||||
SELECT child_id, dosage
|
||||
FROM bom_table
|
||||
WHERE bom_no = :bom_no AND version = :version
|
||||
""")
|
||||
bom_lines = db.session.execute(sql, {'bom_no': bom_no, 'version': bom_version}).fetchall()
|
||||
|
||||
total_cost = 0.0
|
||||
for line in bom_lines:
|
||||
component_base_id = line[0] # child_id
|
||||
usage_qty = float(line[1] or 1.0) # dosage
|
||||
|
||||
# 1. 查采购表最高价 (不含税)
|
||||
buy_price = db.session.query(func.max(StockBuy.pre_tax_unit_price)).filter(
|
||||
StockBuy.base_id == component_base_id
|
||||
).scalar() or 0.0
|
||||
|
||||
# 2. 查半成品表最高价 (单件成本映射存在 manual_cost 里了)
|
||||
semi_price = db.session.query(func.max(StockSemi.manual_cost)).filter(
|
||||
StockSemi.base_id == component_base_id
|
||||
).scalar() or 0.0
|
||||
|
||||
# 3. 查成品表最高价 (同样存储在 manual_cost 字段里)
|
||||
product_price = db.session.query(func.max(StockProduct.manual_cost)).filter(
|
||||
StockProduct.base_id == component_base_id
|
||||
).scalar() or 0.0
|
||||
|
||||
# 4. 取三个表中的最大值,乘以用量 (dosage)
|
||||
max_price = max(float(buy_price), float(semi_price), float(product_price))
|
||||
total_cost += max_price * usage_qty
|
||||
|
||||
return round(total_cost, 2)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
504
inventory-backend/app/services/inbound/semi_service.py
Normal file
504
inventory-backend/app/services/inbound/semi_service.py
Normal file
@ -0,0 +1,504 @@
|
||||
# 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:
|
||||
|
||||
@staticmethod
|
||||
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
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:
|
||||
occupied_name = exists.base.name if (hasattr(exists, 'base') and exists.base) else "未知物料"
|
||||
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
|
||||
|
||||
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}】,请勿重复建单,建议在原批次上追加库存。")
|
||||
|
||||
@staticmethod
|
||||
def search_base_material(keyword, page=1, limit=50):
|
||||
try:
|
||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw),
|
||||
MaterialBase.company_name.ilike(kw)
|
||||
)
|
||||
)
|
||||
query = query.order_by(MaterialBase.id.desc())
|
||||
pagination = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
results = []
|
||||
for item in pagination.items:
|
||||
results.append({
|
||||
'id': item.id,
|
||||
'company_name': item.company_name,
|
||||
'name': item.name,
|
||||
'spec': item.spec_model,
|
||||
'category': item.category,
|
||||
'unit': item.unit,
|
||||
'type': item.material_type,
|
||||
'status': '启用'
|
||||
})
|
||||
return {"items": results, "total": pagination.total, "page": page, "has_next": pagination.has_next}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return {"items": [], "total": 0, "page": 1, "has_next": False}
|
||||
|
||||
@staticmethod
|
||||
def search_bom_options(keyword):
|
||||
from app.models.bom import BomTable
|
||||
try:
|
||||
query = db.session.query(
|
||||
BomTable.bom_no,
|
||||
BomTable.version,
|
||||
MaterialBase.name.label('parent_name'),
|
||||
MaterialBase.spec_model.label('parent_spec')
|
||||
).join(MaterialBase, BomTable.parent_id == MaterialBase.id)
|
||||
|
||||
if hasattr(BomTable, 'is_enabled'):
|
||||
query = query.filter(BomTable.is_enabled == True)
|
||||
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(
|
||||
or_(
|
||||
BomTable.bom_no.ilike(kw),
|
||||
MaterialBase.name.ilike(kw),
|
||||
MaterialBase.spec_model.ilike(kw)
|
||||
)
|
||||
)
|
||||
|
||||
results = query.distinct().limit(20).all()
|
||||
|
||||
return [{
|
||||
'bom_no': r.bom_no,
|
||||
'version': r.version,
|
||||
'parent_name': r.parent_name,
|
||||
'parent_spec': r.parent_spec or ''
|
||||
} for r in results]
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@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} 的基础物料不存在")
|
||||
if not material.is_enabled:
|
||||
raise ValueError(f"物料【{material.name}】已停用,无法办理新入库。")
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
in_qty = float(data.get('in_quantity') or 0)
|
||||
raw_cost = float(data.get('raw_material_cost') or 0)
|
||||
# 【重要修改】:把前端的 unit_total_cost(单件成本)存入原数据库的 manual_cost 字段中
|
||||
unit_cost = float(data.get('unit_total_cost') or raw_cost)
|
||||
total_value = unit_cost * in_qty
|
||||
|
||||
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 = []
|
||||
|
||||
new_stock = StockSemi(
|
||||
base_id=material.id,
|
||||
global_print_id=next_global_id,
|
||||
sku=final_sku,
|
||||
production_date=in_date_val,
|
||||
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=unit_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
|
||||
|
||||
@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
|
||||
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'])
|
||||
if 'unit_total_cost' in data:
|
||||
stock.manual_cost = float(data['unit_total_cost']) # 映射到 manual_cost 物理字段
|
||||
|
||||
if 'unit_total_cost' in data or qty_changed:
|
||||
qty = float(stock.in_quantity or 1)
|
||||
# 使用存入 manual_cost 的单价计算总价
|
||||
stock.total_price = float(stock.manual_cost or 0) * qty
|
||||
|
||||
db.session.commit()
|
||||
return stock
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@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
|
||||
|
||||
@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 []
|
||||
|
||||
@staticmethod
|
||||
def get_list(page, limit, keyword=None, statuses=None, category=None, material_type=None, company=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),
|
||||
MaterialBase.company_name.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 category and category.strip():
|
||||
query = query.filter(MaterialBase.category == category.strip())
|
||||
if material_type and material_type.strip():
|
||||
query = query.filter(MaterialBase.material_type == material_type.strip())
|
||||
|
||||
if company and company.strip():
|
||||
query = query.filter(MaterialBase.company_name == company.strip())
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
|
||||
error_out=False)
|
||||
items = []
|
||||
for item in pagination.items:
|
||||
# 把 manual_cost 伪装成 unit_total_cost 返回给前端
|
||||
item_dict = item.to_dict()
|
||||
item_dict['unit_total_cost'] = float(item.manual_cost or 0)
|
||||
items.append(item_dict)
|
||||
return {"total": pagination.total, "items": items}
|
||||
except Exception as e:
|
||||
print(f"List Error: {e}")
|
||||
traceback.print_exc()
|
||||
return {"total": 0, "items": []}
|
||||
|
||||
@staticmethod
|
||||
def search_system_users(keyword):
|
||||
from app.models.system import SysUser
|
||||
try:
|
||||
query = SysUser.query.filter(SysUser.status == 'active')
|
||||
if keyword:
|
||||
kw = f'%{keyword}%'
|
||||
query = query.filter(db.or_(
|
||||
SysUser.username.ilike(kw),
|
||||
SysUser.email.ilike(kw)
|
||||
))
|
||||
query = query.order_by(SysUser.username)
|
||||
users = []
|
||||
for u in query.limit(20).all():
|
||||
users.append({
|
||||
'value': u.username,
|
||||
'email': u.email
|
||||
})
|
||||
return users
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_filter_options():
|
||||
try:
|
||||
from app.models.base import MaterialBase
|
||||
categories = db.session.query(MaterialBase.category).filter(MaterialBase.category != None,
|
||||
MaterialBase.category != '').distinct().all()
|
||||
sorted_categories = sorted([r[0] for r in categories])
|
||||
types = db.session.query(MaterialBase.material_type).filter(MaterialBase.material_type != None,
|
||||
MaterialBase.material_type != '').distinct().all()
|
||||
sorted_types = sorted([r[0] for r in types])
|
||||
companies = db.session.query(MaterialBase.company_name).filter(MaterialBase.company_name != None,
|
||||
MaterialBase.company_name != '').distinct().all()
|
||||
sorted_companies = sorted([r[0] for r in companies])
|
||||
|
||||
return {
|
||||
"categories": sorted_categories,
|
||||
"types": sorted_types,
|
||||
"companies": sorted_companies
|
||||
}
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"categories": [], "types": [], "companies": []}
|
||||
|
||||
@staticmethod
|
||||
def get_history_managers(keyword=None):
|
||||
from app.models.inbound.semi import StockSemi
|
||||
try:
|
||||
query = db.session.query(StockSemi.production_manager).filter(
|
||||
StockSemi.production_manager.isnot(None),
|
||||
StockSemi.production_manager != ''
|
||||
)
|
||||
if keyword:
|
||||
query = query.filter(StockSemi.production_manager.ilike(f'%{keyword}%'))
|
||||
records = query.distinct().all()
|
||||
return [r[0] for r in records if r[0]]
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def calculate_bom_cost(bom_no, bom_version):
|
||||
"""
|
||||
根据 BOM 编号和版本计算原材料总成本
|
||||
遍历 BOM 子件,使用原生 SQL 查物理表 bom_table,取每个子件在采购、半成品、成品三个表中的最高单价,乘以用量后累加
|
||||
"""
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy import func, text
|
||||
try:
|
||||
# 使用原生 SQL 精准查询 bom_table,避免模型映射错误
|
||||
sql = text("""
|
||||
SELECT child_id, dosage
|
||||
FROM bom_table
|
||||
WHERE bom_no = :bom_no AND version = :version
|
||||
""")
|
||||
bom_lines = db.session.execute(sql, {'bom_no': bom_no, 'version': bom_version}).fetchall()
|
||||
|
||||
total_cost = 0.0
|
||||
for line in bom_lines:
|
||||
component_base_id = line[0] # child_id
|
||||
usage_qty = float(line[1] or 1.0) # dosage
|
||||
|
||||
# 1. 查采购表最高价 (不含税)
|
||||
buy_price = db.session.query(func.max(StockBuy.pre_tax_unit_price)).filter(
|
||||
StockBuy.base_id == component_base_id
|
||||
).scalar() or 0.0
|
||||
|
||||
# 2. 查半成品表最高价 (单件成本映射存在 manual_cost 里了)
|
||||
semi_price = db.session.query(func.max(StockSemi.manual_cost)).filter(
|
||||
StockSemi.base_id == component_base_id
|
||||
).scalar() or 0.0
|
||||
|
||||
# 3. 查成品表最高价 (同样存储在 manual_cost 字段里)
|
||||
product_price = db.session.query(func.max(StockProduct.manual_cost)).filter(
|
||||
StockProduct.base_id == component_base_id
|
||||
).scalar() or 0.0
|
||||
|
||||
# 4. 取三个表中的最大值,乘以用量 (dosage)
|
||||
max_price = max(float(buy_price), float(semi_price), float(product_price))
|
||||
total_cost += max_price * usage_qty
|
||||
|
||||
return round(total_cost, 2)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
239
inventory-backend/app/services/inbound/service_service.py
Normal file
239
inventory-backend/app/services/inbound/service_service.py
Normal file
@ -0,0 +1,239 @@
|
||||
# inventory-backend/app/services/inbound/service_service.py
|
||||
from app.extensions import db
|
||||
from app.models.inbound.service import StockService
|
||||
from app.models.base import MaterialBase
|
||||
from datetime import datetime, timedelta
|
||||
import re
|
||||
import traceback
|
||||
|
||||
|
||||
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, '')
|
||||
try:
|
||||
match = re.search(r'(\d+)$', suffix_part)
|
||||
suffix_num = int(match.group(1)) if match else 0
|
||||
except:
|
||||
suffix_num = 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:
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def create_service(cls, data):
|
||||
"""创建服务权益记录"""
|
||||
# 1. 检查基础物料
|
||||
base_id = data.get('base_id')
|
||||
base = MaterialBase.query.get(base_id)
|
||||
if not base:
|
||||
raise ValueError('基础物料不存在')
|
||||
|
||||
if not base.is_enabled:
|
||||
raise ValueError(f"物料【{base.name}】已停用,无法创建新的服务权益。")
|
||||
|
||||
# 2. 生成SKU
|
||||
sku = cls._generate_sku()
|
||||
|
||||
# 3. 创建对象 (不包含库存数量字段)
|
||||
service = StockService(
|
||||
base_id=data['base_id'],
|
||||
sku=sku,
|
||||
sale_price=data.get('sale_price', 0),
|
||||
provider_name=data.get('provider_name', ''),
|
||||
description=data.get('description', ''),
|
||||
|
||||
# 可选字段映射
|
||||
service_category=data.get('service_category', ''),
|
||||
contract_id=data.get('contract_id', ''),
|
||||
contact_person=data.get('contact_person', ''),
|
||||
valid_period=data.get('valid_period', ''),
|
||||
cost_price=data.get('cost_price', 0)
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# 允许更新的字段
|
||||
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', '')
|
||||
if 'cost_price' in data:
|
||||
service.cost_price = data.get('cost_price', 0)
|
||||
if 'contract_id' in data:
|
||||
service.contract_id = data.get('contract_id', '')
|
||||
if 'contact_person' in data:
|
||||
service.contact_person = data.get('contact_person', '')
|
||||
if 'valid_period' in data:
|
||||
service.valid_period = data.get('valid_period', '')
|
||||
|
||||
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):
|
||||
"""分页查询列表"""
|
||||
try:
|
||||
query = StockService.query.filter_by(is_deleted=False)
|
||||
|
||||
# 关键词联表搜索
|
||||
if keyword:
|
||||
query = query.join(StockService.base).filter(
|
||||
db.or_(
|
||||
StockService.sku.ilike(f'%{keyword}%'),
|
||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
||||
)
|
||||
)
|
||||
|
||||
# 日期过滤
|
||||
if start_date:
|
||||
try:
|
||||
start = datetime.strptime(start_date, '%Y-%m-%d')
|
||||
query = query.filter(StockService.created_at >= start)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end = datetime.strptime(end_date, '%Y-%m-%d')
|
||||
# 包含当天结束
|
||||
end = end + timedelta(days=1) - timedelta(seconds=1)
|
||||
query = query.filter(StockService.created_at <= end)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# 服务商过滤
|
||||
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
|
||||
}
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
|
||||
@classmethod
|
||||
def get_history_providers(cls, base_id):
|
||||
"""获取历史供应商"""
|
||||
try:
|
||||
query = db.session.query(StockService.provider_name).filter(
|
||||
StockService.base_id == base_id,
|
||||
StockService.provider_name.isnot(None),
|
||||
StockService.provider_name != ''
|
||||
).distinct().order_by(StockService.provider_name)
|
||||
return [row[0] for row in query.all()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def search_system_users(cls, keyword):
|
||||
"""搜索系统用户(占位)"""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def get_filter_options(cls):
|
||||
"""获取筛选下拉选项"""
|
||||
try:
|
||||
categories = db.session.query(MaterialBase.category) \
|
||||
.filter(MaterialBase.category != None, MaterialBase.category != '') \
|
||||
.distinct().all()
|
||||
types = db.session.query(MaterialBase.material_type) \
|
||||
.filter(MaterialBase.material_type != None, MaterialBase.material_type != '') \
|
||||
.distinct().all()
|
||||
return {
|
||||
"categories": [r[0] for r in categories],
|
||||
"types": [r[0] for r in types]
|
||||
}
|
||||
except Exception:
|
||||
return {"categories": [], "types": []}
|
||||
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.pre_tax_unit_price) if item.pre_tax_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
|
||||
}
|
||||
149
inventory-backend/app/services/permission_service.py
Normal file
149
inventory-backend/app/services/permission_service.py
Normal file
@ -0,0 +1,149 @@
|
||||
from app.models.system import SysMenu, SysElement, SysRolePermission
|
||||
from app.extensions import db
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
|
||||
class PermissionService:
|
||||
|
||||
@staticmethod
|
||||
def get_permission_tree():
|
||||
"""
|
||||
获取完整的权限树(菜单嵌套菜单 + 菜单包含元素)
|
||||
供前端权限配置页面展示
|
||||
"""
|
||||
# 1. 获取所有菜单 (按 parent_id 和 sort_order 排序,保证父子处理顺序)
|
||||
menus = SysMenu.query.order_by(SysMenu.parent_id, SysMenu.sort_order).all()
|
||||
# 2. 获取所有元素
|
||||
elements = SysElement.query.all()
|
||||
|
||||
# --- 核心逻辑:构建树形结构 ---
|
||||
|
||||
# 3. 创建一个 lookup 字典,方便通过 ID 查找菜单节点
|
||||
# 同时将 SQLAlchemy 对象转为字典,方便后续操作
|
||||
menu_map = {}
|
||||
for m in menus:
|
||||
m_dict = m.to_dict()
|
||||
m_dict['children'] = [] # 初始化 children
|
||||
menu_map[m.id] = m_dict
|
||||
|
||||
# 4. 创建 code 到 id 的映射,用于把 element 挂载到 menu 上
|
||||
# 因为 SysElement 关联的是 menu_code,而不是 menu_id
|
||||
code_to_id = {m.code: m.id for m in menus}
|
||||
|
||||
# 5. 将元素 (Elements) 挂载到对应的菜单 (Menu) 下
|
||||
for el in elements:
|
||||
# 找到该元素所属菜单的 ID
|
||||
parent_menu_id = code_to_id.get(el.menu_code)
|
||||
if parent_menu_id and parent_menu_id in menu_map:
|
||||
el_dict = el.to_dict()
|
||||
# 标记类型为 element,前端 transformData 需要用到
|
||||
el_dict['type'] = 'element'
|
||||
menu_map[parent_menu_id]['children'].append(el_dict)
|
||||
|
||||
# 6. 将子菜单挂载到父菜单下,并构建最终的树
|
||||
tree_data = []
|
||||
for m in menus:
|
||||
current_node = menu_map[m.id]
|
||||
|
||||
if m.parent_id == 0 or m.parent_id is None:
|
||||
# 如果是顶级菜单,直接放入结果集
|
||||
tree_data.append(current_node)
|
||||
else:
|
||||
# 如果是子菜单,找到它的父级,把它塞进父级的 children 里
|
||||
if m.parent_id in menu_map:
|
||||
menu_map[m.parent_id]['children'].append(current_node)
|
||||
else:
|
||||
# 如果找不到父级(比如父级被删了),为了防止数据丢失,暂时作为顶级显示
|
||||
tree_data.append(current_node)
|
||||
|
||||
return tree_data
|
||||
|
||||
@staticmethod
|
||||
def get_role_permissions(role_code):
|
||||
"""获取指定角色拥有的所有权限Code"""
|
||||
try:
|
||||
# === 新增逻辑:超级管理员上帝模式 ===
|
||||
if role_code == 'SUPER_ADMIN':
|
||||
# 直接获取所有菜单和元素,无视配置表
|
||||
all_menus = [m.code for m in SysMenu.query.all()]
|
||||
all_elements = [e.code for e in SysElement.query.all()]
|
||||
return {
|
||||
'menus': all_menus,
|
||||
'elements': all_elements
|
||||
}
|
||||
# =================================
|
||||
|
||||
perms = SysRolePermission.query.filter_by(role_code=role_code).all()
|
||||
|
||||
menu_codes = []
|
||||
element_codes = []
|
||||
|
||||
for p in perms:
|
||||
# 这里假设你的数据库存的是 target_code
|
||||
if p.type == 'menu':
|
||||
menu_codes.append(p.target_code)
|
||||
else:
|
||||
element_codes.append(p.target_code)
|
||||
|
||||
# 前端 handleRoleSelect 会合并这两个数组,所以分开返回没问题
|
||||
return {
|
||||
'menus': menu_codes,
|
||||
'elements': element_codes
|
||||
}
|
||||
except Exception as e:
|
||||
# 记录日志或处理错误
|
||||
print(f"Error fetching role permissions: {e}")
|
||||
return {'menus': [], 'elements': []}
|
||||
|
||||
@staticmethod
|
||||
def assign_permissions(role_code, permissions):
|
||||
"""
|
||||
保存角色的权限
|
||||
permissions: 前端传来的 list,混合了 menu_code 和 element_code
|
||||
"""
|
||||
if not role_code:
|
||||
raise ValueError("角色代码不能为空")
|
||||
|
||||
session = db.session
|
||||
try:
|
||||
# 1. 开启事务 (Flask-SQLAlchemy 自动管理,但明确逻辑更好)
|
||||
|
||||
# 2. 删除该角色旧的所有权限
|
||||
SysRolePermission.query.filter_by(role_code=role_code).delete()
|
||||
|
||||
# 3. 准备新数据
|
||||
if permissions:
|
||||
# 3.1 去重
|
||||
unique_codes = set(permissions)
|
||||
|
||||
# 3.2 预加载所有 Menu Code,用于区分是 Menu 还是 Element
|
||||
# 这一步很重要,因为 SysRolePermission 表需要 type 字段
|
||||
all_menu_codes = {res[0] for res in session.query(SysMenu.code).all()}
|
||||
|
||||
new_records = []
|
||||
for code in unique_codes:
|
||||
if not code: continue
|
||||
|
||||
# 判断类型:如果 code 存在于菜单表中,就是 menu,否则就是 element
|
||||
p_type = 'menu' if code in all_menu_codes else 'element'
|
||||
|
||||
new_records.append(SysRolePermission(
|
||||
role_code=role_code,
|
||||
target_code=code,
|
||||
type=p_type
|
||||
))
|
||||
|
||||
# 3.3 批量插入
|
||||
if new_records:
|
||||
session.add_all(new_records)
|
||||
|
||||
# 4. 提交
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
session.rollback()
|
||||
raise e
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
raise e
|
||||
319
inventory-backend/app/services/print/label_service.py
Normal file
319
inventory-backend/app/services/print/label_service.py
Normal file
@ -0,0 +1,319 @@
|
||||
import socket # .material -> .base refactor checked
|
||||
import base64
|
||||
import os
|
||||
from io import BytesIO
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from .print_config import PrintConfigManager
|
||||
|
||||
# 引入二维码生成库
|
||||
try:
|
||||
import qrcode
|
||||
except ImportError:
|
||||
print("❌ 警告: 未安装 qrcode 库,无法生成二维码。请执行: pip install qrcode[pil]")
|
||||
|
||||
|
||||
class LabelPrintService:
|
||||
# Printer IP and port now managed by PrintConfigManager
|
||||
|
||||
# ================= 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):
|
||||
config = PrintConfigManager.get_config('label_printer')
|
||||
ip = config['ip']
|
||||
port = config['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
|
||||
@ -0,0 +1,42 @@
|
||||
import socket
|
||||
import datetime
|
||||
from .print_config import PrintConfigManager
|
||||
|
||||
|
||||
class NetworkPrintService:
|
||||
def __init__(self, ip=None, port=None):
|
||||
config = PrintConfigManager.get_config('network_printer')
|
||||
self.ip = ip if ip is not None else config['ip']
|
||||
self.port = port if port is not None else config['port']
|
||||
|
||||
def _send_to_printer(self, content):
|
||||
"""
|
||||
对于 A4 打印机,后端直接发送 Socket 指令通常无效或导致乱码。
|
||||
因此这里只做日志记录,实际打印由前端浏览器完成。
|
||||
"""
|
||||
print(f"--- [后端日志] 收到打印请求 (实际由前端处理) ---\n{content}\n----------------")
|
||||
return True, "记录成功"
|
||||
|
||||
def print_outbound_selection(self, items):
|
||||
"""
|
||||
仅记录出库日志,不发送物理指令
|
||||
"""
|
||||
try:
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
total_qty = sum([float(i.get('quantity', 0)) for i in items])
|
||||
|
||||
# 简单构造一个日志字符串
|
||||
log_content = f"出库时间: {timestamp}, 总数: {int(total_qty)}\n"
|
||||
for item in items:
|
||||
log_content += f"- {item.get('name')} (规格:{item.get('standard')}) x {item.get('quantity')}\n"
|
||||
|
||||
# 调用虚拟发送
|
||||
return self._send_to_printer(log_content)
|
||||
|
||||
except Exception as e:
|
||||
print(f"日志记录失败: {e}")
|
||||
return True, "记录忽略" # 即使失败也不要在前端报错
|
||||
|
||||
def print_stocktake_report(self, data):
|
||||
# 同样处理
|
||||
return self._send_to_printer(f"盘点报告: 应盘{data.get('total')}, 实盘{data.get('scanned')}")
|
||||
61
inventory-backend/app/services/print/print_config.py
Normal file
61
inventory-backend/app/services/print/print_config.py
Normal file
@ -0,0 +1,61 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
class PrintConfigManager:
|
||||
CONFIG_FILENAME = 'printer_config.json'
|
||||
DEFAULT_CONFIG = {
|
||||
'label_printer': {'ip': '192.168.9.221', 'port': 9100},
|
||||
'network_printer': {'ip': '192.168.9.250', 'port': 9100}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _get_config_path(cls):
|
||||
# Determine the path relative to this file's directory
|
||||
current_dir = Path(__file__).parent
|
||||
return current_dir / cls.CONFIG_FILENAME
|
||||
|
||||
@classmethod
|
||||
def get_config(cls, printer_type='label_printer'):
|
||||
"""
|
||||
Retrieve configuration for a given printer type.
|
||||
Returns a dict with 'ip' and 'port'.
|
||||
"""
|
||||
config_path = cls._get_config_path()
|
||||
if not config_path.exists():
|
||||
# Write default config if not exists
|
||||
cls.save_config(cls.DEFAULT_CONFIG)
|
||||
config = cls.DEFAULT_CONFIG
|
||||
else:
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"Error reading printer config: {e}")
|
||||
config = cls.DEFAULT_CONFIG
|
||||
# Return specific printer config, falling back to default for that type
|
||||
printer_config = config.get(printer_type, cls.DEFAULT_CONFIG.get(printer_type))
|
||||
# Ensure it's a dict with ip and port
|
||||
if not printer_config or 'ip' not in printer_config:
|
||||
printer_config = cls.DEFAULT_CONFIG.get(printer_type, {'ip': '127.0.0.1', 'port': 9100})
|
||||
return printer_config
|
||||
|
||||
@classmethod
|
||||
def save_config(cls, new_config):
|
||||
"""
|
||||
Save entire config dictionary to file.
|
||||
new_config should be a dict with keys 'label_printer' and/or 'network_printer'.
|
||||
"""
|
||||
config_path = cls._get_config_path()
|
||||
try:
|
||||
# If file exists, merge existing with new
|
||||
existing = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
existing = json.load(f)
|
||||
existing.update(new_config)
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"Error saving printer config: {e}")
|
||||
raise
|
||||
@ -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
|
||||
}
|
||||
|
||||
27
inventory-backend/app/utils/constants.py
Normal file
27
inventory-backend/app/utils/constants.py
Normal file
@ -0,0 +1,27 @@
|
||||
# app/utils/constants.py
|
||||
|
||||
class UserRole:
|
||||
"""
|
||||
用户角色定义
|
||||
"""
|
||||
SUPER_ADMIN = 'SUPER_ADMIN' # 超级管理员 (IRIS)
|
||||
SUPERVISOR = 'SUPERVISOR' # 主管
|
||||
FINANCE = 'FINANCE' # 财务
|
||||
WAREHOUSE_MGR = 'WAREHOUSE_MGR' # 库管
|
||||
INBOUND = 'INBOUND' # 入库员
|
||||
OUTBOUND = 'OUTBOUND' # 出库员
|
||||
PURCHASER = 'PURCHASER' # 采购员
|
||||
SALES = 'SALES' # 销售
|
||||
|
||||
# 角色中文映射(用于前端展示或日志)
|
||||
# 注意:这个字典在 auth_service 遍历时需要被过滤掉
|
||||
ROLE_MAP = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
SUPERVISOR: '主管',
|
||||
FINANCE: '财务',
|
||||
WAREHOUSE_MGR: '库管',
|
||||
INBOUND: '入库员',
|
||||
OUTBOUND: '出库员',
|
||||
PURCHASER: '采购员',
|
||||
SALES: '销售'
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
# app/utils/decorators.py
|
||||
from functools import wraps
|
||||
from flask_jwt_extended import get_jwt, verify_jwt_in_request
|
||||
from flask import jsonify, g
|
||||
import logging
|
||||
|
||||
|
||||
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')
|
||||
user_role_upper = user_role.upper() if user_role else None
|
||||
|
||||
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
|
||||
if user_role_upper == 'SUPER_ADMIN':
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
if user_role_upper not in [r.upper() for r in roles]:
|
||||
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return decorator
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def login_required(fn):
|
||||
"""
|
||||
验证 JWT 令牌是否存在且有效
|
||||
"""
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
except Exception as e:
|
||||
logging.warning(f"JWT verification failed: {e}")
|
||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||
return fn(*args, **kwargs)
|
||||
return decorator
|
||||
|
||||
|
||||
def permission_required(permission_code):
|
||||
"""
|
||||
检查当前用户是否拥有指定权限码
|
||||
使用方法: @permission_required('material:base:read')
|
||||
"""
|
||||
def wrapper(fn):
|
||||
@wraps(fn)
|
||||
def decorator(*args, **kwargs):
|
||||
# 首先验证 JWT
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
except Exception as e:
|
||||
logging.warning(f"JWT verification failed: {e}")
|
||||
return jsonify(msg='登录已过期,请重新登录'), 401
|
||||
|
||||
claims = get_jwt()
|
||||
user_role = claims.get('role')
|
||||
# 超级管理员放行 (忽略大小写)
|
||||
if user_role and user_role.upper() == 'SUPER_ADMIN':
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
# 根据角色查询数据库中的权限
|
||||
try:
|
||||
from app.services.auth_service import AuthService
|
||||
perm_dict = AuthService.get_user_permissions(user_role)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to fetch permissions for role {user_role}: {e}")
|
||||
return jsonify(msg='权限查询失败'), 403
|
||||
|
||||
# 合并菜单和元素权限
|
||||
all_perms = perm_dict.get('menus', []) + perm_dict.get('elements', [])
|
||||
if permission_code not in all_perms:
|
||||
# 详细的调试日志
|
||||
print(f"🔴 [权限拦截] 角色 '{user_role}' 访问被拒!需要权限码: '{permission_code}', 但该角色实际拥有: {all_perms}")
|
||||
logging.warning(
|
||||
f"权限检查失败: 角色={user_role}, 所需权限={permission_code}, 实际权限列表={all_perms}")
|
||||
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 = '-'
|
||||
@ -6,3 +6,13 @@ marshmallow-sqlalchemy==1.0.0
|
||||
psycopg2-binary==2.9.9
|
||||
python-dotenv==1.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
|
||||
# [新增] Excel 处理库 (解决 No module named 'openpyxl' 报错)
|
||||
openpyxl>=3.1.2
|
||||
@ -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>
|
||||
|
||||
58
inventory-web/nginx.conf
Normal file
58
inventory-web/nginx.conf
Normal file
@ -0,0 +1,58 @@
|
||||
# --- HTTP 重定向到 HTTPS ---
|
||||
server {
|
||||
listen 80;
|
||||
server_name _; # 匹配所有域名/IP
|
||||
|
||||
# 将所有 HTTP 请求强制跳转到 HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
# --- HTTPS 服务器配置 ---
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name _;
|
||||
|
||||
# 1. SSL 证书配置
|
||||
ssl_certificate /etc/nginx/ssl/nginx.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/nginx.key;
|
||||
|
||||
# SSL 优化配置 (可选)
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 允许上传大文件
|
||||
client_max_body_size 20M;
|
||||
|
||||
# 开启 Gzip
|
||||
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 image/jpeg image/gif image/png;
|
||||
|
||||
# 2. 前端 Vue 页面
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 3. 后端 API 接口代理
|
||||
location /api/ {
|
||||
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;
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 告诉后端这是 HTTPS 请求
|
||||
|
||||
proxy_connect_timeout 300s;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
}
|
||||
|
||||
# 4. 图片资源托管
|
||||
location /uploads/ {
|
||||
alias /usr/share/nginx/html/uploads/;
|
||||
expires 30d;
|
||||
}
|
||||
}
|
||||
2289
inventory-web/package-lock.json
generated
2289
inventory-web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -5,20 +5,27 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"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",
|
||||
"pinyin-pro": "^3.19.0",
|
||||
"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",
|
||||
|
||||
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,207 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { computed, onMounted } 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'
|
||||
})
|
||||
|
||||
// 页面加载时刷新权限
|
||||
onMounted(() => {
|
||||
if (userStore.token) {
|
||||
userStore.refreshUserPermissions()
|
||||
}
|
||||
})
|
||||
|
||||
// --- 退出登录逻辑 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>
|
||||
当前版本: 2.4录入测试版
|
||||
</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;
|
||||
}
|
||||
|
||||
.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'
|
||||
})
|
||||
}
|
||||
41
inventory-web/src/api/bom.ts
Normal file
41
inventory-web/src/api/bom.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取BOM列表
|
||||
export function getBomList(params?: any) {
|
||||
return request({
|
||||
url: '/v1/bom/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取BOM详情
|
||||
export function getBomDetail(bomNo: string) {
|
||||
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
|
||||
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
|
||||
const encoded = encodeURIComponent(trimmed);
|
||||
return request({
|
||||
url: `/v1/bom/detail/${encoded}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 保存BOM
|
||||
export function saveBom(data: any) {
|
||||
return request({
|
||||
url: '/v1/bom/save',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除BOM(暂未实现,预留)
|
||||
export function deleteBom(bomNo: string) {
|
||||
// 去除首尾斜杠,保留中间斜杠并进行 URL 编码
|
||||
const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
|
||||
const encoded = encodeURIComponent(trimmed);
|
||||
return request({
|
||||
url: `/v1/bom/${encoded}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
32
inventory-web/src/api/common/print.ts
Normal file
32
inventory-web/src/api/common/print.ts
Normal file
@ -0,0 +1,32 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
export function getPrinterConfig() {
|
||||
return request({
|
||||
url: '/common/print/config',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function updatePrinterConfig(data: any) {
|
||||
return request({
|
||||
url: '/common/print/config',
|
||||
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'
|
||||
})
|
||||
}
|
||||
107
inventory-web/src/api/inbound/buy.ts
Normal file
107
inventory-web/src/api/inbound/buy.ts
Normal file
@ -0,0 +1,107 @@
|
||||
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 getFilterOptions() {
|
||||
return request({
|
||||
url: '/inbound/buy/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 6. 搜索基础物料
|
||||
export function searchMaterialBase(keyword: string, page: number = 1) {
|
||||
return request({
|
||||
url: '/inbound/buy/search-base',
|
||||
method: 'get',
|
||||
params: { keyword, page }
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 文件上传
|
||||
export function uploadFile(data: FormData) {
|
||||
return request({
|
||||
url: '/common/upload',
|
||||
method: 'post',
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 文件删除
|
||||
export function deleteFile(filename: string) {
|
||||
return request({
|
||||
url: `/common/files/${filename}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 供应商建议
|
||||
export function getSupplierSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/suppliers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 10. 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 11. 链接建议
|
||||
export function getLinkSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/links',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 12. 库位建议
|
||||
export function getLocationSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/buy/suggestions/locations',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
87
inventory-web/src/api/inbound/product.ts
Normal file
87
inventory-web/src/api/inbound/product.ts
Normal file
@ -0,0 +1,87 @@
|
||||
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'
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索基础物料 (已增加 page 参数)
|
||||
export function searchMaterialBase(keyword: string, page: number = 1) {
|
||||
return request({
|
||||
url: '/inbound/product/search-base',
|
||||
method: 'get',
|
||||
params: { keyword, page }
|
||||
})
|
||||
}
|
||||
|
||||
// 搜索BOM
|
||||
export function searchBom(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/product/search-bom',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/product/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 筛选选项
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/inbound/product/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] 负责人历史记录全局查询
|
||||
export function getManagerHistory(params: any) {
|
||||
return request({
|
||||
url: '/inbound/product/suggestions/managers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] BOM 成本自动计算
|
||||
export function calculateBomCost(params: {bom_code: string, bom_version: string}) {
|
||||
return request({
|
||||
url: '/inbound/product/calculate-bom-cost',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
89
inventory-web/src/api/inbound/semi.ts
Normal file
89
inventory-web/src/api/inbound/semi.ts
Normal file
@ -0,0 +1,89 @@
|
||||
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. 搜索基础物料 (已增加 page 参数)
|
||||
export function searchMaterialBase(keyword: string, page: number = 1) {
|
||||
return request({
|
||||
url: '/inbound/semi/search-base',
|
||||
method: 'get',
|
||||
params: { keyword, page }
|
||||
})
|
||||
}
|
||||
|
||||
// 5.5 搜索BOM (新增)
|
||||
export function searchBom(keyword: string) {
|
||||
return request({
|
||||
url: '/inbound/semi/search-bom',
|
||||
method: 'get',
|
||||
params: { keyword }
|
||||
})
|
||||
}
|
||||
|
||||
// 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/inbound/semi/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 筛选选项
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/inbound/semi/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] 生产负责人历史记录全局查询
|
||||
export function getManagerHistory(params: any) {
|
||||
return request({
|
||||
url: '/inbound/semi/suggestions/managers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// [新增] BOM 成本自动计算
|
||||
export function calculateBomCost(params: {bom_code: string, bom_version: string}) {
|
||||
return request({
|
||||
url: '/inbound/semi/calculate-bom-cost',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
139
inventory-web/src/api/inbound/service.ts
Normal file
139
inventory-web/src/api/inbound/service.ts
Normal file
@ -0,0 +1,139 @@
|
||||
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 getProviderSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/v1/inbound/service/suggestions/providers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 用户建议
|
||||
export function getUserSuggestions(params: any) {
|
||||
return request({
|
||||
url: '/v1/inbound/service/suggestions/users',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 筛选选项
|
||||
export function getFilterOptions() {
|
||||
return request({
|
||||
url: '/v1/inbound/service/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除服务权益
|
||||
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'
|
||||
})
|
||||
}
|
||||
54
inventory-web/src/api/material_base.ts
Normal file
54
inventory-web/src/api/material_base.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 1. 获取基础信息列表
|
||||
export function listMaterialBase(params: any) {
|
||||
return request({
|
||||
url: '/inbound/base/list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 1.1 获取选项
|
||||
export function getMaterialBaseOptions() {
|
||||
return request({
|
||||
url: '/inbound/base/options',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 1.2 [新增] 导出全口径资产统计表
|
||||
export function exportAssetStatistics(params: any) {
|
||||
return request({
|
||||
url: '/inbound/base/export',
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob' // 关键:必须声明为 blob 处理文件流
|
||||
})
|
||||
}
|
||||
|
||||
// 2. 新增
|
||||
export function addMaterialBase(data: any) {
|
||||
return request({
|
||||
url: '/inbound/base/',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 3. 修改
|
||||
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'
|
||||
})
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
26
inventory-web/src/api/system/permission.ts
Normal file
26
inventory-web/src/api/system/permission.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取所有可用的权限树(菜单+列)
|
||||
export function getAllPermissionTree() {
|
||||
return request({
|
||||
url: '/v1/permissions/tree',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取某个角色已拥有的权限列表
|
||||
export function getRolePermissions(roleCode: string) {
|
||||
return request({
|
||||
url: '/v1/permissions/role/' + roleCode,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 保存角色的权限配置
|
||||
export function saveRolePermissions(data: any) {
|
||||
return request({
|
||||
url: '/v1/permissions/assign',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
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 |
134
inventory-web/src/components/BaseTable/index.vue
Normal file
134
inventory-web/src/components/BaseTable/index.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div class="base-table">
|
||||
<el-table
|
||||
v-bind="$attrs"
|
||||
:data="data"
|
||||
border
|
||||
stripe
|
||||
v-loading="loading"
|
||||
header-cell-class-name="table-header-gray"
|
||||
>
|
||||
<template v-for="col in visibleColumns" :key="col.prop">
|
||||
|
||||
<el-table-column
|
||||
v-if="!col.slot"
|
||||
v-bind="col"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:min-width="col.minWidth || '120'"
|
||||
:width="col.width"
|
||||
:fixed="col.fixed"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
|
||||
<el-table-column
|
||||
v-else
|
||||
v-bind="col"
|
||||
:prop="col.prop"
|
||||
:label="col.label"
|
||||
:min-width="col.minWidth || '120'"
|
||||
:width="col.width"
|
||||
:fixed="col.fixed"
|
||||
>
|
||||
<template #default="scope">
|
||||
<slot :name="col.prop" :row="scope.row" :index="scope.$index"></slot>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<el-empty description="暂无数据" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="showPagination" class="pagination-container">
|
||||
<el-pagination
|
||||
v-bind="paginationConfig"
|
||||
v-model:current-page="localPage"
|
||||
v-model:page-size="localLimit"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
|
||||
// --- Props 定义 ---
|
||||
const props = defineProps({
|
||||
// 数据源
|
||||
data: { type: Array, default: () => [] },
|
||||
// 列配置 (核心)
|
||||
columns: { type: Array as () => any[], required: true },
|
||||
// 页面编码 (用于权限隔离,如果列名全局唯一可不传,但建议传)
|
||||
pageCode: { type: String, default: '' },
|
||||
|
||||
loading: { type: Boolean, default: false },
|
||||
total: { type: Number, default: 0 },
|
||||
showPagination: { type: Boolean, default: true },
|
||||
page: { type: Number, default: 1 },
|
||||
limit: { type: Number, default: 10 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:page', 'update:limit', 'pagination'])
|
||||
|
||||
const permStore = usePermissionStore()
|
||||
|
||||
// --- 核心逻辑:计算当前可见的列 ---
|
||||
const visibleColumns = computed(() => {
|
||||
return props.columns.filter(col => {
|
||||
// 1. 获取该列在数据库中对应的 code
|
||||
// 如果列配置里显式写了 code,就用写的;如果没有,默认认为 prop 就是 code
|
||||
const permissionKey = col.code || col.prop
|
||||
|
||||
// 2. 如果这个列不需要权限控制 (比如序号 index),可以在配置里加个 ignoreAuth: true
|
||||
if (col.ignoreAuth) return true
|
||||
|
||||
// 3. 问 Store:我有这个权限吗?
|
||||
// 注意:我们在 PermissionStore 里存的是全局唯一的 code
|
||||
return permStore.hasColumnPermission(props.pageCode, permissionKey)
|
||||
})
|
||||
})
|
||||
|
||||
// --- 分页逻辑处理 ---
|
||||
const localPage = ref(props.page)
|
||||
const localLimit = ref(props.limit)
|
||||
|
||||
watch(() => props.page, (val) => localPage.value = val)
|
||||
watch(() => props.limit, (val) => localLimit.value = val)
|
||||
|
||||
const handleSizeChange = (val: number) => {
|
||||
emit('update:limit', val)
|
||||
emit('pagination', { page: localPage.value, limit: val })
|
||||
}
|
||||
|
||||
const handleCurrentChange = (val: number) => {
|
||||
emit('update:page', val)
|
||||
emit('pagination', { page: val, limit: localLimit.value })
|
||||
}
|
||||
|
||||
const paginationConfig = {
|
||||
pageSizes: [10, 20, 50, 100],
|
||||
background: true
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pagination-container {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
:deep(.table-header-gray th) {
|
||||
background-color: #f8f9fb !important;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
height: 45px;
|
||||
}
|
||||
</style>
|
||||
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')
|
||||
272
inventory-web/src/router/index.ts
Normal file
272
inventory-web/src/router/index.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import Layout from '@/layout/index.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import BomManage from '@/views/bom/BomManage.vue'
|
||||
|
||||
// [新增] 扩展 RouteMeta 类型定义,防止 TS 报错
|
||||
declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
icon?: string
|
||||
hidden?: boolean
|
||||
roles?: string[] // 允许的角色列表
|
||||
}
|
||||
}
|
||||
|
||||
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: '出库记录' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 5. BOM 管理
|
||||
{
|
||||
path: '/bom',
|
||||
component: Layout,
|
||||
meta: { title: 'BOM管理', icon: 'Document' },
|
||||
redirect: '/bom/manage',
|
||||
children: [
|
||||
{
|
||||
path: 'manage',
|
||||
name: 'BomManage',
|
||||
component: BomManage,
|
||||
meta: { title: 'BOM配方管理', icon: 'list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 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,
|
||||
// [修复] 添加 redirect,点击父菜单时跳转到子页面
|
||||
redirect: '/system/user-create',
|
||||
meta: {
|
||||
title: '系统管理',
|
||||
icon: 'Setting',
|
||||
// [修复] 使用大写角色名,匹配后端常量
|
||||
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'user-create',
|
||||
name: 'UserCreate',
|
||||
component: () => import('@/views/system/UserCreate.vue'),
|
||||
meta: {
|
||||
title: '账号开通',
|
||||
icon: 'User',
|
||||
roles: ['SUPER_ADMIN', 'SUPERVISOR']
|
||||
}
|
||||
},
|
||||
// [新增] 权限分配页面,只有超级管理员可进
|
||||
{
|
||||
path: 'permission',
|
||||
name: 'PermissionConfig',
|
||||
component: () => import('@/views/system/PermissionConfig.vue'),
|
||||
meta: {
|
||||
title: '权限分配',
|
||||
icon: 'Lock',
|
||||
roles: ['SUPER_ADMIN']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// 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')
|
||||
|
||||
// [修复] 优先从 user 对象获取,并统一转大写,防止大小写不一致导致权限失效
|
||||
const rawRole = userStore.user?.role || userStore.role || localStorage.getItem('role') || 'user'
|
||||
const userRole = String(rawRole).toUpperCase()
|
||||
|
||||
// 调试日志
|
||||
if (to.path.includes('/system')) {
|
||||
console.log(`路由守卫检查: Path=${to.path}, UserRole=${userRole}, Required=${to.meta.roles}`)
|
||||
}
|
||||
|
||||
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 {
|
||||
console.warn(`权限不足: 用户角色 ${userRole} 不在允许列表 ${to.meta.roles} 中`)
|
||||
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
49
inventory-web/src/stores/permission.ts
Normal file
49
inventory-web/src/stores/permission.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
export const usePermissionStore = defineStore('permission', () => {
|
||||
// 存储我能看到的页面代码 (如 ['inbound_buy', ...])
|
||||
const menuPermissions = ref<string[]>([])
|
||||
|
||||
// 存储我能看到的列代码 (如 ['unit_price', 'sale_price'])
|
||||
const elementPermissions = ref<string[]>([])
|
||||
|
||||
// 初始化加载权限 (登录后调用)
|
||||
const loadPermissions = async () => {
|
||||
try {
|
||||
const res: any = await request({
|
||||
url: '/v1/auth/my-permissions',
|
||||
method: 'get'
|
||||
})
|
||||
if (res.code === 200 && res.data) {
|
||||
menuPermissions.value = res.data.menus || []
|
||||
elementPermissions.value = res.data.elements || []
|
||||
console.log('权限字典加载完成:', elementPermissions.value.length, '个列权限')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载权限失败', e)
|
||||
// 失败时清空,防止残留
|
||||
menuPermissions.value = []
|
||||
elementPermissions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 核心判断函数:判断当前用户是否拥有某个列/按钮的权限
|
||||
// page: 页面代码 (预留字段,目前全局唯一code,暂不使用page隔离)
|
||||
// code: 权限标识 (如 'unit_price')
|
||||
const hasColumnPermission = (page: string, code: string) => {
|
||||
// 1. 如果列没有配置 permissionKey,说明是公开列,直接放行
|
||||
if (!code) return true
|
||||
|
||||
// 2. 检查权限池里是否有这个 code
|
||||
return elementPermissions.value.includes(code)
|
||||
}
|
||||
|
||||
return {
|
||||
menuPermissions,
|
||||
elementPermissions,
|
||||
loadPermissions,
|
||||
hasColumnPermission
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user