47 Commits

Author SHA1 Message Date
dxc
04ee938cd1 借库逻辑实现 2026-02-06 17:11:47 +08:00
dxc
387c8973d6 盘库盲盘以及导出excel实现 2026-02-06 14:30:14 +08:00
dxc
489e62e55b 摄像头逻辑进行修改,更改分辨率进行快速的读取识别 2026-02-06 11:28:48 +08:00
dxc
e027ebd4a9 盘库操作初设计 2026-02-06 10:16:37 +08:00
dxc
c1ddb8093f 出库进行修改,确保可以进行多个样例的出库以及出库的记录展示 2026-02-05 16:54:11 +08:00
dxc
3f6ab3e607 修改出库签名逻辑,对签字进行单独屏幕 2026-02-05 15:51:19 +08:00
dxc
374c4932f0 修改semi页面的弹窗滚动条的适配内容 2026-02-05 15:09:37 +08:00
dxc
cad5fd696c 修正新增入库时3个组件的名称筛选逻辑 2026-02-05 15:04:06 +08:00
dxc
4f90e02dcf 修改时间时区问题 2026-02-05 14:36:36 +08:00
dxc
0bc47d306d 增加入库记录页面,同时修正三组入库的时间问题 2026-02-05 14:30:11 +08:00
dxc
10e53cab23 修改半成品与成品出入库相关联逻辑 2026-02-05 13:17:39 +08:00
dxc
aa40d4a6da 成品入库与出库相关联 2026-02-05 11:58:35 +08:00
dxc
bf8cf37ff9 半成品入库与出库相关联 2026-02-05 11:51:25 +08:00
dxc
1e5627dc0a 修改采购件入库逻辑 2026-02-05 11:37:06 +08:00
dxc
273f20f5c3 采购件入库与出库相关联 2026-02-05 11:08:29 +08:00
dxc
f3b60dfc54 出库操作逻辑上面实现,成功跑通 2026-02-05 10:20:52 +08:00
dxc
797b611530 出库逻辑添加,扫码识别编码成功,后续对应逻辑没有完成 2026-02-04 17:22:20 +08:00
dxc
596f366fc4 进入界面的调整 2026-02-04 15:55:20 +08:00
dxc
c1c525b699 登录界面调整 2026-02-04 15:41:51 +08:00
dxc
ea17413bc1 新增用户页面更新以及调整 2026-02-04 15:16:14 +08:00
dxc
c1e08062f2 修正名称与俗名关系 2026-02-04 14:37:18 +08:00
dxc
fd5600b65b 修改登录退出逻辑 2026-02-04 14:29:59 +08:00
dxc
13590b1fac 超级管理员登录设置 2026-02-04 13:30:07 +08:00
dxc
4aa43a0607 打印标签内容以及尺寸确定 2026-02-04 10:35:13 +08:00
dxc
3257973820 成品图像上传初实现,支持多图,检测报告的图片以及链接上传 2026-02-03 13:20:17 +08:00
dxc
d084bd29dd 半成品图像上传初实现,支持多图,检测报告的图片以及链接上传 2026-02-03 13:06:18 +08:00
dxc
ba3085c1f2 采购件图像上传初实现,支持多图,检测报告的图片以及链接上传 2026-02-03 11:55:33 +08:00
dxc
7fa40115d9 采购件图像上传初实现 2026-02-03 11:16:12 +08:00
dxc
efcd2d923c 对于成品的条形码进行功能实现 2026-02-03 09:17:28 +08:00
dxc
98450d73f1 对于半成品的条形码进行更改 2026-02-03 09:01:03 +08:00
dxc
11a4e5f48a 针对于条形码生成进行修改 2026-02-02 16:43:35 +08:00
dxc
cf6a4a8957 添加条形码内容 2026-02-02 15:06:20 +08:00
dxc
a1133aac94 三个基础入库页面修改新增弹窗内容展示,下拉框以及弹窗屏幕大小自适应性 2026-01-30 12:58:19 +08:00
dxc
0009fe3121 维护三个基础物件入库时候与数据库不匹配问题 2026-01-30 11:51:16 +08:00
dxc
30181fd21b 维护三个基础物件入库时候与数据库不匹配问题 2026-01-30 11:50:35 +08:00
dxc
482c5a2cb2 修改基础信息启用停用内容,进行修复 2026-01-30 11:21:10 +08:00
dxc
06ba2d7563 采购件,半成品,产品页面初步完成 2026-01-29 09:27:56 +08:00
dxc
b0df5c7458 添加半成品页面进行数据 2026-01-28 17:44:39 +08:00
dxc
cd55a6aee1 Merge remote-tracking branch 'origin/1.0入库操作' into 1.0入库操作
# Conflicts:
#	inventory-backend/app/services/inbound/buy_service.py
#	inventory-web/src/views/stock/inbound/buy.vue
2026-01-28 11:50:55 +08:00
dxc
6f4917f57e 针对于采购页面进行优化逻辑 2026-01-28 11:49:59 +08:00
dxc
e31ef59df0 针对于采购页面进行优化逻辑 2026-01-28 11:22:08 +08:00
dxc
87864a1c4f 基础信息和采购件页面返回值读取正确 2026-01-28 09:13:20 +08:00
dxc
7a4ea8acfb 基础信息读取错误,未修改完成 2026-01-28 08:54:11 +08:00
dxc
9a04f65eb7 基础信息读取错误,未修改完成 2026-01-27 18:10:09 +08:00
dxc
7a78975ce7 采购件管理修改页面文字大小以及调整文字栏间距 2026-01-27 16:43:44 +08:00
dxc
3afea217b7 物料-采购件入库页面功能实现 2026-01-27 15:50:23 +08:00
dxc
2f8a5c55b1 python-flask和Vue两种模式初模板 2026-01-26 17:00:12 +08:00
107 changed files with 13004 additions and 2534 deletions

52
docker-compose.yml Normal file
View 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

View File

@ -0,0 +1,6 @@
venv/
__pycache__/
*.pyc
.git/
.env
pgdata/

View 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"]

View File

View File

@ -1,26 +1,136 @@
# 文件路径: 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 - 借还/维修/报废)
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
# -----------------------------------------------------
try:
from app.api.v1.transactions import trans_bp
# 标准: /api/v1/transactions/borrow
app.register_blueprint(trans_bp, url_prefix='/api/v1/transactions')
# 兼容: /api/transactions/borrow
app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
print("✅ Transactions 模块注册成功")
except ImportError as e:
# 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题
print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}")
# -----------------------------------------------------
# 2.5 注册出库模块 (Outbound)
# -----------------------------------------------------
try:
from app.api.v1.outbound import outbound_bp
# 标准: /api/v1/outbound
app.register_blueprint(outbound_bp, url_prefix='/api/v1/outbound')
# 兼容: /api/outbound
app.register_blueprint(outbound_bp, url_prefix='/api/outbound', name='outbound_legacy')
print("✅ Outbound 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Outbound 模块导入失败: {e}")
# =========================================================
# 3. 预加载数据模型
# =========================================================
with app.app_context():
try:
# 基础与库存模型
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# 出库模型
from app.models.outbound import TransOutbound
# 系统与业务模型
from app.models.system import SysUser, SysLog
# 确保借还模型被加载
from app.models.transaction import TransBorrow, TransRepair, TransScrap
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)
# db.create_all()
except ImportError as e:
print(f"⚠️ 模型预加载部分失败 (检查是否缺少文件): {e}")
except Exception as e:
print(f"⚠️ 模型预加载发生未知错误: {e}")
return app

View File

@ -0,0 +1,90 @@
# app/api/v1/auth.py
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt
from app.services.auth_service import AuthService
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['POST'])
def login():
try:
data = request.get_json()
if not data:
return jsonify({'msg': '无效的请求数据'}), 400
if not data.get('username') or not data.get('password'):
return jsonify({'msg': '请输入用户名和密码'}), 400
result = AuthService.login(data)
response_data = {
'msg': '登录成功',
'access_token': result.get('access_token'),
'user': result.get('user')
}
return jsonify(response_data), 200
except ValueError as ve:
return jsonify({'msg': str(ve)}), 401
except Exception as e:
current_app.logger.error(f"Login Failed Error: {str(e)}")
return jsonify({'msg': f'服务器内部错误: {str(e)}'}), 500
@auth_bp.route('/user/create', methods=['POST'])
@jwt_required()
def create_user():
try:
data = request.get_json()
claims = get_jwt()
operator_role = claims.get('role')
result = AuthService.create_user(data, operator_role)
return jsonify({'msg': '用户创建成功', 'data': result}), 201
except Exception as e:
current_app.logger.error(f"User Create Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400
# [新增] 更新用户
@auth_bp.route('/user/<int:user_id>', methods=['PUT'])
@jwt_required()
def update_user(user_id):
try:
data = request.get_json()
claims = get_jwt()
operator_role = claims.get('role')
result = AuthService.update_user(user_id, data, operator_role)
return jsonify({'msg': '用户更新成功', 'data': result}), 200
except Exception as e:
current_app.logger.error(f"User Update Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400
@auth_bp.route('/users', methods=['GET'])
@jwt_required()
def get_users():
try:
users = AuthService.get_all_users()
return jsonify({'msg': '获取成功', 'data': users}), 200
except Exception as e:
current_app.logger.error(f"Get Users Failed: {str(e)}")
return jsonify({'msg': '获取用户列表失败'}), 500
@auth_bp.route('/user/<int:user_id>', methods=['DELETE'])
@jwt_required()
def delete_user(user_id):
try:
claims = get_jwt()
operator_role = claims.get('role')
AuthService.delete_user(user_id, operator_role)
return jsonify({'msg': '删除成功'}), 200
except Exception as e:
current_app.logger.error(f"Delete User Failed: {str(e)}")
return jsonify({'msg': str(e)}), 400

View File

@ -0,0 +1,27 @@
# app/api/v1/common/print.py
from flask import Blueprint, request, jsonify
from app.services.print.label_service import LabelPrintService
from app.models.inbound.buy import StockBuy
# 引入其他模型 StockSemi, StockProduct
import traceback
print_bp = Blueprint('print', __name__)
@print_bp.route('/preview', methods=['POST'])
def preview_label():
try:
data = request.get_json()
# 如果只传了ID和类型可以在这里查库补全数据也可以直接前端传全量数据
img_base64 = LabelPrintService.generate_preview_image(data)
return jsonify({"code": 200, "msg": "success", "data": img_base64})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@print_bp.route('/execute', methods=['POST'])
def execute_print():
try:
data = request.get_json()
LabelPrintService.send_to_printer(data)
return jsonify({"code": 200, "msg": "指令已发送至打印机"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,132 @@
# 文件路径: inventory-backend/app/api/v1/common/upload.py
import os
import uuid
from flask import Blueprint, request, jsonify, send_from_directory
# 定义蓝图
upload_bp = Blueprint('upload', __name__)
# =========================================================
# 配置上传路径 (核心修改:确保路径绝对准确)
# =========================================================
# 向上寻找直到找到 inventory-backend 目录,或者默认为当前文件的上级目录的...上级
# 这种方式比数 dirname 层级更稳健
def get_project_root():
"""获取项目根目录 inventory-backend"""
current_path = os.path.abspath(__file__)
# 循环向上查找,直到找到名为 inventory-backend 的目录
# 如果你的根目录名字不是 inventory-backend请修改这里的判断逻辑
# 或者直接使用相对路径回退 5 层: api/v1/common -> app -> inventory-backend
base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(current_path)))))
return base
BASE_DIR = get_project_root()
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'uploads')
# 允许上传的文件后缀
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def ensure_upload_folder_exists():
if not os.path.exists(UPLOAD_FOLDER):
try:
os.makedirs(UPLOAD_FOLDER)
print(f"✅ [Upload] 目录创建成功: {UPLOAD_FOLDER}")
except Exception as e:
print(f"❌ [Upload] 目录创建失败: {e}")
# ------------------------------------------------------------------
# 1. 文件上传接口
# URL: /api/v1/common/upload (POST)
# ------------------------------------------------------------------
@upload_bp.route('/upload', methods=['POST'])
def upload_file():
ensure_upload_folder_exists()
if 'file' not in request.files:
return jsonify({"code": 400, "msg": "未找到文件部分"}), 400
file = request.files['file']
if file.filename == '':
return jsonify({"code": 400, "msg": "未选择文件"}), 400
if file and allowed_file(file.filename):
try:
ext = file.filename.rsplit('.', 1)[1].lower()
new_filename = f"{uuid.uuid4().hex}.{ext}"
save_path = os.path.join(UPLOAD_FOLDER, new_filename)
file.save(save_path)
print(f"💾 [Upload] 文件已保存: {save_path}")
# 生成访问 URL
# 这里的路径必须与 __init__.py 中注册的 url_prefix + 路由匹配
file_url = f"/api/v1/common/files/{new_filename}"
return jsonify({
"code": 200,
"msg": "上传成功",
"data": {
"url": file_url,
"filename": new_filename
}
})
except Exception as e:
print(f"❌ [Upload] 保存异常: {e}")
return jsonify({"code": 500, "msg": "文件保存失败"}), 500
return jsonify({"code": 400, "msg": "不支持的文件格式"}), 400
# ------------------------------------------------------------------
# 2. 静态文件访问接口 (回显)
# URL: /api/v1/common/files/<filename>
# ------------------------------------------------------------------
@upload_bp.route('/files/<filename>')
def uploaded_file(filename):
# 打印日志帮助调试 404 问题
full_path = os.path.join(UPLOAD_FOLDER, filename)
if not os.path.exists(full_path):
print(f"❌ [File Access] 文件未找到: {full_path}")
return jsonify({"code": 404, "msg": "文件不存在"}), 404
return send_from_directory(UPLOAD_FOLDER, filename)
# ------------------------------------------------------------------
# 3. 文件删除接口 (同步删除物理文件)
# URL: /api/v1/common/files/<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

View File

@ -0,0 +1,20 @@
from flask import Blueprint
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
# ★ [新增] 导入 stock 模块
from .stock import bp as stock_bp
inbound_bp = Blueprint('inbound', __name__)
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')
# ★ [新增] 挂载 stock 模块,路径前缀为 /stock
# 最终访问路径例:/api/v1/inbound/stock/all
inbound_bp.register_blueprint(stock_bp, url_prefix='/stock')

View File

@ -0,0 +1,93 @@
# 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify
from app.services.inbound.base_service import MaterialBaseService
import traceback
inbound_base_bp = Blueprint('stock_base', __name__)
# ==============================================================================
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
# ==============================================================================
@inbound_base_bp.route('/search', methods=['GET'])
def search_base():
try:
keyword = request.args.get('keyword', '')
data = MaterialBaseService.search_material(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 2. 列表接口 (GET /api/v1/inbound/base/list)
# ==============================================================================
@inbound_base_bp.route('/list', methods=['GET'])
def get_list():
try:
page = request.args.get('pageNum', 1, type=int) # 前端传的是 pageNum
limit = request.args.get('pageSize', 10, type=int)
# 构造筛选条件
filters = {
'keyword': request.args.get('keyword', ''),
'category': request.args.get('category', ''),
'type': request.args.get('type', ''),
'isEnabled': request.args.get('isEnabled', None)
}
result = MaterialBaseService.get_list(page, limit, filters)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 3. 新增接口 (POST /api/v1/inbound/base/)
# 注意:前端 material_base.ts 可能会请求 / 或 /add这里统一匹配
# ==============================================================================
@inbound_base_bp.route('/', methods=['POST'])
def create():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
MaterialBaseService.create_material(data)
return jsonify({"code": 200, "msg": "新增成功"})
except ValueError as e:
# 捕获业务逻辑验证错误 (如名称为空)
return jsonify({"code": 400, "msg": str(e)}), 400
except Exception as e:
# 捕获系统错误
traceback.print_exc()
return jsonify({"code": 500, "msg": f"系统错误: {str(e)}"}), 500
# ==============================================================================
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT'])
def update(id):
try:
data = request.get_json()
MaterialBaseService.update_material(id, data)
return jsonify({"code": 200, "msg": "修改成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ==============================================================================
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
def delete(id):
try:
MaterialBaseService.delete_material(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,113 @@
# app/api/v1/inbound/buy.py
from flask import Blueprint, request, jsonify
from app.services.inbound.buy_service import BuyInboundService
import traceback
inbound_buy_bp = Blueprint('stock_buy', __name__)
# ------------------------------------------------------------------
# 0. 基础物料搜索
# ------------------------------------------------------------------
@inbound_buy_bp.route('/search-base', methods=['GET'])
def search_base():
"""
供前端下拉框远程搜索使用
Query Param: keyword (名称或规格)
"""
try:
keyword = request.args.get('keyword', '')
data = BuyInboundService.search_base_material(keyword)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 1. 获取列表 (修改:支持状态筛选)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/list', methods=['GET'])
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
keyword = request.args.get('keyword', '')
# 获取状态列表参数,前端传参格式: statuses=在库,借库
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = BuyInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增入库
# ------------------------------------------------------------------
@inbound_buy_bp.route('/submit', methods=['POST'])
def submit():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data"}), 400
new_stock = BuyInboundService.handle_inbound(data)
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新入库
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['PUT'])
def update_buy(id):
try:
data = request.get_json()
BuyInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
def delete_buy(id):
try:
BuyInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. 获取关联的出库历史
# ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>/history', methods=['GET'])
def get_history(id):
try:
history = BuyInboundService.get_outbound_history(id)
return jsonify({
"code": 200,
"msg": "success",
"data": history
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,35 @@
from flask import Blueprint, request, jsonify
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

View File

@ -0,0 +1,106 @@
# inventory-backend/app/api/v1/inbound/product.py
from flask import Blueprint, request, jsonify
from app.services.inbound.product_service import ProductInboundService
import traceback
inbound_product_bp = Blueprint('stock_product', __name__)
# ------------------------------------------------------------------
# 0. 基础物料搜索 (关键接口:配合 Service 实现自动回填)
# ------------------------------------------------------------------
@inbound_product_bp.route('/search-base', methods=['GET'])
def search_base():
"""
对应前端 API: /inbound/product/search-base
功能: 模糊搜索基础物料,返回 spec, unit, category, type 等详细信息
"""
try:
keyword = request.args.get('keyword', '')
# 调用 Service 层已修复的 search_base_material 方法
data = ProductInboundService.search_base_material(keyword)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
# 捕获异常并打印堆栈,方便调试
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 1. 获取列表 (支持 status 多选筛选)
# ------------------------------------------------------------------
@inbound_product_bp.route('/list', methods=['GET'])
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
keyword = request.args.get('keyword', '')
# 接收状态参数 (逗号分隔字符串 -> 列表)
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = ProductInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增入库
# ------------------------------------------------------------------
@inbound_product_bp.route('/submit', methods=['POST'])
def submit():
try:
# 调用 Service 处理入库,获取新创建的对象
new_stock = ProductInboundService.handle_inbound(request.get_json())
# 返回成功信息以及新创建的数据包含生成的ID和SKU供前端自动打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新入库
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['PUT'])
def update(id):
try:
ProductInboundService.update_inbound(id, request.get_json())
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>', methods=['DELETE'])
def delete(id):
try:
ProductInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. 获取出库历史
# ------------------------------------------------------------------
@inbound_product_bp.route('/<int:id>/history', methods=['GET'])
def get_history(id):
try:
data = ProductInboundService.get_outbound_history(id)
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,120 @@
# inventory-backend/app/api/v1/inbound/semi.py
from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService
import traceback
# 定义蓝图
inbound_semi_bp = Blueprint('stock_semi', __name__)
# ------------------------------------------------------------------
# 0. 基础物料搜索 (复用逻辑)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/search-base', methods=['GET'])
def search_base():
"""
供前端下拉框远程搜索使用 (搜索半成品类型的基础物料)
Query Param: keyword (名称或规格)
"""
try:
keyword = request.args.get('keyword', '')
# 这里复用 Service 中的搜索逻辑
data = SemiInboundService.search_base_material(keyword)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 1. 获取半成品列表
# ------------------------------------------------------------------
@inbound_semi_bp.route('/list', methods=['GET'])
def get_list():
try:
page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int)
# 支持按关键字搜索BOM号、工单号、SN、批号等
keyword = request.args.get('keyword', '')
# [修改] 获取状态列表参数
statuses_str = request.args.get('statuses', '')
statuses = statuses_str.split(',') if statuses_str else []
result = SemiInboundService.get_list(page, limit, keyword, statuses)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 2. 新增半成品入库 (修改:返回创建的对象数据)
# ------------------------------------------------------------------
@inbound_semi_bp.route('/submit', methods=['POST'])
def submit():
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data"}), 400
# 修改:调用 Service 处理入库,获取新创建的对象
new_stock = SemiInboundService.handle_inbound(data)
# 修改返回成功信息以及新创建的数据包含生成的ID和SKU供前端打印使用
return jsonify({
"code": 200,
"msg": "入库成功",
"data": new_stock.to_dict()
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 3. 更新半成品入库信息
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['PUT'])
def update_semi(id):
try:
data = request.get_json()
SemiInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 4. 删除半成品入库记录
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>', methods=['DELETE'])
def delete_semi(id):
try:
SemiInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------
# 5. [新增] 获取关联出库历史
# ------------------------------------------------------------------
@inbound_semi_bp.route('/<int:id>/history', methods=['GET'])
def get_history(id):
try:
data = SemiInboundService.get_outbound_history(id)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,136 @@
from flask import Blueprint, jsonify, request
from app.extensions import db
# ★★★ 修复点:必须引入 datetime否则下方更新时间时会报错 500 ★★★
from datetime import datetime
# 导入模型
from app.models.inbound.buy import StockBuy
from app.models.inbound.stocktake import StocktakeDraft
# 尝试导入半成品和成品
try:
from app.models.inbound.semi import StockSemi
except ImportError:
StockSemi = None
try:
from app.models.inbound.product import StockProduct
except ImportError:
StockProduct = None
from app.services.print.network_print_service import NetworkPrintService
bp = Blueprint('stock_ops', __name__)
@bp.route('/all', methods=['GET'])
def get_all_stock():
"""
获取所有库存 > 0 的物品
"""
try:
# 1. 采购件
materials = []
if StockBuy:
materials = StockBuy.query.filter(StockBuy.stock_quantity > 0).all()
# 2. 半成品
semis = []
if StockSemi:
try:
semis = StockSemi.query.filter(StockSemi.stock_quantity > 0).all()
except Exception:
semis = []
# 3. 成品
products = []
if StockProduct:
try:
products = StockProduct.query.filter(StockProduct.stock_quantity > 0).all()
except Exception:
products = []
return jsonify({
"materials": [item.to_dict() for item in materials],
"semis": [item.to_dict() for item in semis],
"products": [item.to_dict() for item in products]
}), 200
except Exception as e:
print(f"Error: {e}")
return jsonify({"message": f"查询库存失败: {str(e)}"}), 500
# --- 草稿箱接口 ---
@bp.route('/draft/list', methods=['GET'])
def get_drafts():
"""获取当前用户的盘点进度"""
user_id = request.args.get('user_id', 'admin')
drafts = StocktakeDraft.query.filter_by(user_id=user_id).all()
return jsonify([d.to_dict() for d in drafts]), 200
@bp.route('/draft/add', methods=['POST'])
def add_draft():
"""扫码同步 (支持更新数量)"""
try:
data = request.json
user_id = data.get('user_id', 'admin')
uuid = data.get('uuid')
quantity = data.get('quantity', 1)
# 查找是否已存在
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid).first()
if draft:
# 如果已存在,更新数量和时间
draft.quantity = quantity
# ★ 修复点:这里需要 datetime 对象
draft.scan_time = datetime.now()
else:
# 如果不存在,创建新的
draft = StocktakeDraft(user_id=user_id, uuid=uuid, quantity=quantity)
db.session.add(draft)
db.session.commit()
return jsonify({"message": "Saved"}), 200
except Exception as e:
print(f"Add Draft Error: {e}")
return jsonify({"message": str(e)}), 500
@bp.route('/draft/clear', methods=['POST'])
def clear_draft():
"""清空进度"""
data = request.json
user_id = data.get('user_id', 'admin')
StocktakeDraft.query.filter_by(user_id=user_id).delete()
db.session.commit()
return jsonify({"message": "Cleared"}), 200
# --- 打印接口 ---
@bp.route('/print/selection', methods=['POST'])
def print_selection():
try:
data = request.json
items = data.get('items', [])
if not items: return jsonify({"message": "未选择任何物品"}), 400
printer = NetworkPrintService()
success, msg = printer.print_outbound_selection(items)
return jsonify({"message": "打印指令已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500
@bp.route('/print/stocktake', methods=['POST'])
def print_stocktake():
try:
data = request.json
printer = NetworkPrintService()
success, msg = printer.print_stocktake_report(data)
return jsonify({"message": "盘点报告已发送" if success else msg}), 200 if success else 500
except Exception as e:
return jsonify({"message": str(e)}), 500

View File

@ -0,0 +1,109 @@
from flask import Blueprint, request, jsonify
from app.services.outbound_service import OutboundService
from flask_jwt_extended import jwt_required, get_jwt_identity
import traceback
outbound_bp = Blueprint('outbound', __name__, url_prefix='/outbound')
# --------------------------------------------------------
# 1. 扫码查询库存接口 (关联三个库存表)
# GET /api/v1/outbound/scan?barcode=...
# --------------------------------------------------------
@outbound_bp.route('/scan', methods=['GET'])
@jwt_required()
def scan_barcode():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
try:
# 调用 Service 层去三个表中查找 (Service已更新会返回价格)
result = OutboundService.get_stock_by_barcode(barcode)
if result:
return jsonify({
'code': 200,
'msg': '扫描成功',
'data': result
})
else:
return jsonify({
'code': 404,
'msg': '未找到对应的库存记录,请确认条码是否正确'
}), 404
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'扫描查询出错: {str(e)}'}), 500
# --------------------------------------------------------
# 2. 提交出库单接口 (批量)
# POST /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['POST'])
@jwt_required()
def create_outbound():
data = request.get_json()
if not data:
return jsonify({'code': 400, 'msg': '无有效数据'}), 400
# 获取当前登录用户名 (JWT identity)
current_user_name = get_jwt_identity()
if not current_user_name:
current_user_name = 'Unknown'
# 获取最终的操作员名称
final_operator = data.get('operator_name')
if not final_operator:
final_operator = current_user_name
# 必填校验 (针对整个单据)
# items 必须是列表且不为空consumer_name 和 signature_path 必填
if 'items' not in data or not data['items']:
return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400
if not data.get('consumer_name') or not data.get('signature_path'):
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
try:
# ★ [修改] 调用批量创建服务
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
return jsonify({
'code': 200,
'msg': '出库成功',
'data': {'outbound_no': outbound_no}
})
except ValueError as e:
# 业务逻辑错误 (如库存不足)
return jsonify({'code': 400, 'msg': str(e)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
# --------------------------------------------------------
# 3. 获取出库记录列表 (分组展示)
# GET /api/v1/outbound
# --------------------------------------------------------
@outbound_bp.route('', methods=['GET'])
@jwt_required()
def get_outbound_list():
try:
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
keyword = request.args.get('keyword', '')
# 如果前端传了日期范围,可以解析处理,这里暂略
# ★ [修改] 调用分组查询服务
result = OutboundService.get_grouped_list(page, limit, keyword)
return jsonify({
'code': 200,
'msg': '获取成功',
'data': result
})
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': str(e)}), 500

View File

@ -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

View File

@ -0,0 +1,58 @@
from flask import Blueprint, jsonify, request
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.services.trans_service import TransService
import traceback
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
# --- 借库接口 ---
@trans_bp.route('/borrow', methods=['POST'])
@jwt_required()
def create_borrow():
data = request.get_json()
try:
no = TransService.create_borrow(data)
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 还库辅助:扫码查找借出记录 ---
@trans_bp.route('/return/scan', methods=['GET'])
@jwt_required()
def scan_borrowed_item():
barcode = request.args.get('barcode')
if not barcode:
return jsonify({'code': 400, 'msg': '无条码'}), 400
res = TransService.scan_for_return(barcode)
if res:
return jsonify({'code': 200, 'data': res})
else:
return jsonify({'code': 404, 'msg': '未找到该物品的未还记录'}), 404
# --- 还库提交 ---
@trans_bp.route('/return', methods=['POST'])
@jwt_required()
def submit_return():
data = request.get_json()
user = get_jwt_identity() # 库管
try:
TransService.process_return(data, operator_name=user)
return jsonify({'code': 200, 'msg': '还库成功'})
except Exception as e:
return jsonify({'code': 400, 'msg': str(e)}), 400
# --- 记录列表 ---
@trans_bp.route('/records', methods=['GET'])
@jwt_required()
def get_records():
status = request.args.get('status', 'all')
page = int(request.args.get('page', 1))
keyword = request.args.get('keyword', '')
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
return jsonify({'code': 200, 'data': res})

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,59 @@
# app/models/base.py
from app.extensions import db
class MaterialBase(db.Model):
"""
基础信息表模型
对应数据库表: material_base
"""
__tablename__ = 'material_base'
# 1. 基础字段
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, comment='名称')
common_name = db.Column(db.String(255), comment='俗名') # ✅ 新增字段
category = db.Column(db.String(100), comment='类别')
material_type = db.Column(db.String(100), comment='类型')
spec_model = db.Column(db.String(255), comment='规格型号')
unit = db.Column(db.String(50), comment='计量单位')
# 可见等级
visibility_level = db.Column(db.Integer, default=0, comment='信息可见等级')
# 链接与图片
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='material', lazy='dynamic')
# 2. 关联半成品库存 (StockSemi)
stock_semis = db.relationship('StockSemi', back_populates='material', lazy='dynamic')
# 3. 关联成品库存 (StockProduct)
stock_products = db.relationship('StockProduct', back_populates='material', lazy='dynamic')
def to_dict(self):
"""
序列化方法
"""
return {
'id': self.id,
'name': self.name,
'commonName': self.common_name, # ✅ 序列化新增字段
'category': self.category,
'type': self.material_type, # 前端字段映射
'spec': self.spec_model, # 前端字段映射
'unit': self.unit,
'visibilityLevel': self.visibility_level,
'generalManual': self.manual_link,
'generalImage': self.product_image,
'isEnabled': 1 if self.is_enabled else 0,
}

View File

@ -0,0 +1,111 @@
from app.extensions import db
import json
class StockBuy(db.Model):
"""
采购入库库存表
对应数据库表: stock_buy
"""
__tablename__ = 'stock_buy'
id = db.Column(db.Integer, primary_key=True)
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# 身份标识
sku = db.Column(db.String(100))
in_date = db.Column(db.DateTime)
barcode = db.Column(db.String(100))
serial_number = db.Column(db.String(100))
batch_number = db.Column(db.String(100))
# 状态
status = db.Column(db.String(50), default='在库')
inspection_status = db.Column(db.String(50))
warehouse_location = db.Column(db.String(100))
# 数量
in_quantity = db.Column(db.Numeric(19, 4), default=0)
stock_quantity = db.Column(db.Numeric(19, 4), default=0)
available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 财务与商务
unit_price = db.Column(db.Numeric(19, 4), default=0)
total_price = db.Column(db.Numeric(19, 4), default=0)
currency = db.Column(db.String(20), default='CNY')
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
supplier_name = db.Column(db.String(255))
buyer_name = db.Column(db.String(100)) # 对应 SQL: buyer_name
buyer_email = db.Column(db.String(100)) # 对应 SQL: buyer_email
original_link = db.Column(db.Text) # 对应 SQL: original_link
detail_link = db.Column(db.Text)
# 图片字段 (存储 JSON 字符串)
arrival_photo = db.Column(db.Text)
# [新增] 检测报告图片路径 (存储 JSON 字符串)
inspection_report = db.Column(db.Text)
# [新增] 全局打印流水号 (用于跨表连续编号,对应 Sequence: global_print_seq)
global_print_id = db.Column(db.Integer)
# 关系定义
# 注意:这里使用字符串 'MaterialBase' 引用,避免了直接 import 导致的潜在循环依赖
material = db.relationship('MaterialBase', back_populates='stock_buys')
def to_dict(self):
# 辅助解析函数:将数据库存储的 JSON 字符串转为 List
def parse_img_list(json_str):
if not json_str:
return []
try:
# 兼容旧数据:如果不是 JSON 格式(比如是单个 URL则包装成 list
if not json_str.startswith('['):
return [json_str]
return json.loads(json_str)
except:
return []
return {
'id': self.id,
'base_id': self.base_id,
'material_name': self.material.name if self.material else '',
'spec_model': self.material.spec_model if self.material else '',
'category': self.material.category if self.material else '',
'unit': self.material.unit if self.material else '',
'material_type': self.material.material_type if self.material else '',
'sku': self.sku,
'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '',
'barcode': self.barcode,
'serial_number': self.serial_number,
'batch_number': self.batch_number,
'warehouse_loc': self.warehouse_location,
'status': self.status,
'inspection_status': self.inspection_status,
'in_quantity': float(self.in_quantity or 0),
'qty_inbound': float(self.in_quantity or 0),
'stock_quantity': float(self.stock_quantity or 0),
'qty_stock': float(self.stock_quantity or 0),
'available_quantity': float(self.available_quantity or 0),
'qty_available': float(self.available_quantity or 0),
'unit_price': float(self.unit_price or 0),
'total_price': float(self.total_price or 0),
'currency': self.currency,
'exchange_rate': float(self.exchange_rate or 1.0),
'supplier_name': self.supplier_name,
'purchaser': self.buyer_name,
'purchaser_email': self.buyer_email,
'source_link': self.original_link,
'detail_link': self.detail_link,
# [修改] 解析为数组返回给前端
'arrival_photo': parse_img_list(self.arrival_photo),
'inspection_report': parse_img_list(self.inspection_report),
# [新增] 返回全局打印ID及其格式化字符串
'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
}

View File

@ -0,0 +1,130 @@
# app/models/inbound/product.py
from app.extensions import db
import json
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)
# 关系定义
material = 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,
'material_name': self.material.name if self.material else '',
'spec_model': self.material.spec_model if self.material else '',
'category': self.material.category if self.material else '',
'unit': self.material.unit if self.material else '',
'material_type': self.material.material_type if self.material 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 ""
}

View File

@ -0,0 +1,126 @@
# app/models/inbound/semi.py
from app.extensions import db
import json
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)
# 关系定义
material = 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,
'material_name': self.material.name if self.material else '',
'spec_model': self.material.spec_model if self.material else '',
'category': self.material.category if self.material else '',
'unit': self.material.unit if self.material else '',
'material_type': self.material.material_type if self.material else '',
'sku': self.sku,
'inbound_date': self.production_date.strftime('%Y-%m-%d') if self.production_date else '',
'barcode': self.barcode,
'serial_number': self.serial_number,
'batch_number': self.batch_number,
'warehouse_loc': self.warehouse_location,
'status': self.status,
'in_quantity': float(self.in_quantity or 0),
'qty_inbound': float(self.in_quantity or 0),
'stock_quantity': float(self.stock_quantity or 0),
'qty_stock': float(self.stock_quantity or 0),
'available_quantity': float(self.available_quantity or 0),
'qty_available': float(self.available_quantity or 0),
'bom_code': self.bom_code,
'bom_version': self.bom_version,
'work_order_code': self.work_order_code,
'raw_material_cost': raw_val,
'manual_cost': man_val,
'unit_total_cost': unit_total,
'total_price': float(self.total_price or 0),
'production_manager': self.production_manager,
'production_time_range': self.production_time_range,
'production_start_time': str(self.production_start_time) if self.production_start_time else '',
'production_end_time': str(self.production_end_time) if self.production_end_time else '',
'quality_status': self.quality_status,
# [修改] 解析 JSON 字符串为数组返回给前端
'quality_report_link': parse_img_list(self.quality_report_link),
'arrival_photo': parse_img_list(self.arrival_photo),
'detail_link': self.detail_link,
'remark': self.remark,
'global_print_id': self.global_print_id,
'global_print_id_str': f"{self.global_print_id:010d}" if self.global_print_id else ""
}

View File

@ -0,0 +1,22 @@
from app.extensions import db
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')
}

View File

@ -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))
# 其他字段按需添加,入库时主要是为了外键关联

View 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
}

View File

@ -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')

View File

@ -0,0 +1,65 @@
# app/models/system.py
from app.extensions import db
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
class SysUser(db.Model):
__tablename__ = 'sys_user'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(100), nullable=False)
# 注意:如果允许邮箱为空,建议去掉 unique=True 或者在数据库层面处理空字符串
email = db.Column(db.String(100), unique=True)
department = db.Column(db.String(100))
role = db.Column(db.String(50))
status = db.Column(db.String(20), default='active')
password_hash = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now) # 新增创建时间
def set_password(self, password):
"""生成加密密码"""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""验证密码"""
return check_password_hash(self.password_hash, password)
def to_dict(self):
"""序列化为字典,供接口返回使用"""
return {
'id': self.id,
'username': self.username,
'email': self.email,
'department': self.department,
'role': self.role,
'status': self.status,
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else ''
}
class SysLog(db.Model):
"""
系统操作日志表
对应数据库表: sys_log
"""
__tablename__ = 'sys_log'
id = db.Column(db.Integer, primary_key=True)
op_time = db.Column(db.DateTime, default=datetime.now)
op_user_name = db.Column(db.String(100))
op_user_id = db.Column(db.String(50))
module_name = db.Column(db.String(100))
action_type = db.Column(db.String(50))
target_table = db.Column(db.String(100))
target_id = db.Column(db.Integer)
description = db.Column(db.Text)
ip_address = db.Column(db.String(50))
def to_dict(self):
return {
'id': self.id,
'op_time': self.op_time.isoformat() if self.op_time else None,
'op_user_name': self.op_user_name,
'module_name': self.module_name,
'action_type': self.action_type,
'description': self.description
}

View File

@ -0,0 +1,53 @@
from app.extensions import db
from datetime import datetime
class TransBorrow(db.Model):
__tablename__ = 'trans_borrow'
id = db.Column(db.Integer, primary_key=True)
borrow_no = db.Column(db.String(100))
sku = db.Column(db.String(100))
source_table = db.Column(db.String(50))
stock_id = db.Column(db.Integer)
barcode = db.Column(db.String(100))
quantity = db.Column(db.Numeric(19, 4))
# 借出信息
borrower_name = db.Column(db.String(100))
borrow_time = db.Column(db.DateTime, default=datetime.now)
borrow_signature = db.Column(db.Text) # 借用人签字
expected_return_time = db.Column(db.DateTime)
# 归还信息
is_returned = db.Column(db.Boolean, default=False)
return_time = db.Column(db.DateTime)
return_operator = db.Column(db.String(100)) # 库管
return_signature = db.Column(db.Text) # 库管签字
return_location = db.Column(db.String(100)) # 归还库位
status = db.Column(db.String(20), default='borrowed')
remark = db.Column(db.Text)
def to_dict(self):
return {
'id': self.id,
'borrow_no': self.borrow_no,
'sku': self.sku,
'barcode': self.barcode,
'quantity': float(self.quantity) if self.quantity else 0,
'borrower_name': self.borrower_name,
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else '',
'borrow_signature': self.borrow_signature,
'expected_return_time': self.expected_return_time.strftime(
'%Y-%m-%d %H:%M') if self.expected_return_time else '',
'is_returned': self.is_returned,
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else '',
'return_operator': self.return_operator,
'return_signature': self.return_signature,
'return_location': self.return_location,
'status': self.status,
'remark': self.remark
}

View File

@ -1,14 +1,42 @@
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不存在且无名字" 的情况
# 实例化 Schema
stock_buy_schema = StockBuySchema()

View File

@ -0,0 +1,156 @@
# app/services/auth_service.py
from app.models.system import SysUser
from app.extensions import db
from flask_jwt_extended import create_access_token
from app.utils.constants import UserRole
class AuthService:
# 硬编码的超级管理员凭证
SUPER_ADMIN_USER = "IRIS"
SUPER_ADMIN_PASS = "licahk"
@staticmethod
def login(data):
username = data.get('username')
password = data.get('password')
user_role = None
user_id = None
user_info = {}
# 1. 优先检查硬编码的超级管理员
if username == AuthService.SUPER_ADMIN_USER:
if password == AuthService.SUPER_ADMIN_PASS:
user_role = UserRole.SUPER_ADMIN
user_id = 0 # 虚拟ID
user_info = {
'username': username,
'role': user_role,
'department': 'System'
}
else:
raise ValueError("密码错误")
# 2. 如果不是 IRIS检查数据库用户
else:
user = SysUser.query.filter_by(username=username).first()
if not user:
raise ValueError("用户不存在")
if not user.check_password(password):
raise ValueError("密码错误")
if user.status != 'active':
raise ValueError("账号已被禁用,请联系管理员")
user_role = user.role
user_id = user.id
user_info = user.to_dict()
# 3. 生成 Token
access_token = create_access_token(
identity=user_id,
additional_claims={'role': user_role, 'username': username}
)
return {
'access_token': access_token,
'user': user_info
}
@staticmethod
def create_user(data, operator_role):
"""
创建新用户 (仅限管理员使用)
"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以创建新用户")
if SysUser.query.filter_by(username=data.get('username')).first():
raise Exception("用户名已存在")
role = data.get('role')
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
if role not in valid_roles:
raise Exception(f"角色无效,可选角色: {valid_roles}")
email = data.get('email', '')
if email and SysUser.query.filter_by(email=email).first():
raise Exception("邮箱已被使用")
new_user = SysUser(
username=data.get('username'),
email=email,
department=data.get('department', ''),
role=role,
status='active'
)
new_user.set_password(data.get('password'))
db.session.add(new_user)
db.session.commit()
return new_user.to_dict()
@staticmethod
def update_user(user_id, data, operator_role):
"""
[新增] 更新用户信息
"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足:只有超级管理员或主管可以修改用户信息")
user = SysUser.query.get(user_id)
if not user:
raise Exception("用户不存在")
# 1. 更新基本信息
if 'role' in data:
valid_roles = [v for k, v in UserRole.__dict__.items() if not k.startswith('__')]
if data['role'] not in valid_roles:
raise Exception(f"角色无效")
user.role = data['role']
if 'department' in data:
user.department = data['department']
if 'email' in data:
# 如果修改了邮箱,且新邮箱已被其他人使用
email = data['email']
if email and email != user.email:
existing = SysUser.query.filter_by(email=email).first()
if existing:
raise Exception("该邮箱已被其他用户使用")
user.email = email
# 2. 如果提供了密码,则重置密码;否则保持原密码
new_password = data.get('password')
if new_password and str(new_password).strip():
if len(new_password) < 6:
raise Exception("密码长度至少6位")
user.set_password(new_password)
db.session.commit()
return user.to_dict()
@staticmethod
def get_all_users():
"""获取所有系统用户"""
users = SysUser.query.order_by(SysUser.id.desc()).all()
return [user.to_dict() for user in users]
@staticmethod
def delete_user(user_id, operator_role):
"""删除用户"""
if operator_role not in [UserRole.SUPER_ADMIN, UserRole.SUPERVISOR]:
raise Exception("权限不足")
user = SysUser.query.get(user_id)
if not user:
raise Exception("用户不存在")
db.session.delete(user)
db.session.commit()
return True

View File

@ -0,0 +1,197 @@
# 文件路径: app/services/inbound/base_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from sqlalchemy import or_
import traceback
class MaterialBaseService:
"""
基础物料服务层
负责处理 MaterialBase 的增删改查及搜索逻辑
"""
@staticmethod
def search_material(keyword):
"""
根据关键字搜索已启用的基础物料
(供 /api/v1/inbound/base/search 接口调用)
"""
try:
if not keyword:
return []
# ✅ 搜索范围增加 common_name (俗名)
query = MaterialBase.query.filter(
MaterialBase.is_enabled == True,
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.common_name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).limit(20)
results = []
for item in query.all():
results.append({
'id': item.id,
'name': item.name,
'commonName': item.common_name, # ✅ 返回俗名
'spec': item.spec_model,
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'status': '启用'
})
return results
except Exception as e:
traceback.print_exc()
return []
@staticmethod
def get_list(page, limit, filters=None):
"""
获取基础信息列表 (带分页和筛选)
"""
try:
query = MaterialBase.query
if filters:
# 1. 关键词模糊搜索 (名称 或 俗名 或 规格型号)
if filters.get('keyword'):
kw = f"%{filters['keyword']}%"
# ✅ 增加俗名搜索
query = query.filter(or_(
MaterialBase.name.ilike(kw),
MaterialBase.common_name.ilike(kw),
MaterialBase.spec_model.ilike(kw)
))
# 2. 精确筛选
if filters.get('category'):
query = query.filter_by(category=filters['category'])
if filters.get('type'):
query = query.filter_by(material_type=filters['type'])
if filters.get('isEnabled') is not None:
# 前端传 1/0转为 Boolean
is_active = bool(int(filters['isEnabled']))
query = query.filter_by(is_enabled=is_active)
# 按 ID 倒序排列
pagination = query.order_by(MaterialBase.id.desc()).paginate(page=page, per_page=limit, error_out=False)
items = [item.to_dict() for item in pagination.items]
return {"total": pagination.total, "items": items}
except Exception as e:
print(f"查询基础信息列表失败: {e}")
return {"total": 0, "items": []}
@staticmethod
def create_material(data):
"""新增基础信息"""
try:
# 0. 基础校验
if not data.get('name') or not data.get('spec'):
raise ValueError("名称和规格型号不能为空")
# 1. 查重 (名称+规格型号 唯一)
# 注意:俗名不参与唯一性校验,允许重复或为空
exist = MaterialBase.query.filter_by(
name=data['name'],
spec_model=data['spec']
).first()
if exist:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
# 2. 创建对象
new_material = MaterialBase(
name=data['name'],
common_name=data.get('commonName'), # ✅ 读取俗名
spec_model=data['spec'],
category=data.get('category'),
material_type=data.get('type'),
unit=data.get('unit'),
visibility_level=data.get('visibilityLevel'),
manual_link=data.get('generalManual'),
product_image=data.get('generalImage'),
is_enabled=True if data.get('isEnabled', 1) == 1 else False
)
db.session.add(new_material)
db.session.commit()
return new_material
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def update_material(m_id, data):
"""修改基础信息"""
try:
material = MaterialBase.query.get(m_id)
if not material:
raise ValueError("数据不存在")
# 更新字段
if 'name' in data: material.name = data['name']
if 'commonName' in data: material.common_name = data['commonName'] # ✅ 更新俗名
if 'spec' in data: material.spec_model = data['spec']
if 'category' in data: material.category = data['category']
if 'type' in data: material.material_type = data['type']
if 'unit' in data: material.unit = data['unit']
if 'visibilityLevel' in data: material.visibility_level = data['visibilityLevel']
if 'generalManual' in data: material.manual_link = data['generalManual']
if 'generalImage' in data: material.product_image = 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("数据不存在")
# 1. 依赖检查:采购入库引用
buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
# 2. 依赖检查:半成品入库引用
semi_usage_count = StockSemi.query.filter_by(base_id=m_id).count()
total_usage = buy_usage_count + semi_usage_count
if total_usage > 0:
raise ValueError(
f"无法删除:该基础物料正被使用中。\n"
f"- 采购库存记录: {buy_usage_count}\n"
f"- 半成品库存记录: {semi_usage_count}\n"
f"请先清理相关库存或仅‘禁用’此条目。"
)
# 3. 执行删除
db.session.delete(material)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
print(f"删除基础信息失败: {e}")
raise e

View File

@ -0,0 +1,348 @@
from app.extensions import db
from app.models.inbound.buy import StockBuy
from app.models.base import MaterialBase
# 尝试导入出库模型,如果不存在则忽略
try:
from app.models.outbound import TransOutbound
except ImportError:
TransOutbound = None
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
import traceback
import json
class BuyInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (核心修复)
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
"""
校验序列号和批号的唯一性逻辑
:param base_id: 当前物料的基础ID
:param serial_number: 序列号
:param batch_number: 批号
:param exclude_id: 排除的ID (用于编辑模式)
"""
# 1. 序列号 (SN) 全局唯一校验
# 解释: 不同规格的物料通常也不应该有相同的SN防止扫码混淆
if serial_number:
query = StockBuy.query.filter(StockBuy.serial_number == serial_number)
if exclude_id:
query = query.filter(StockBuy.id != exclude_id)
exists = query.first()
if exists:
# 获取占用该SN的物料名称提示更友好
occupied_name = exists.material.name if exists.material else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 同物料唯一校验
# 解释: 不同规格的物料可以有相同的批号(如都有 001 批次),但同一个物料不能重复建单
if batch_number and base_id:
query = StockBuy.query.filter(
StockBuy.base_id == base_id,
StockBuy.batch_number == batch_number
)
if exclude_id:
query = query.filter(StockBuy.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复录入,可直接在该批次下追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
try:
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
MaterialBase.pinyin.ilike(f'%{keyword}%') # 假设有拼音搜索
)
)
query = query.order_by(MaterialBase.id.desc()).limit(20)
results = []
for item in query.all():
results.append({
'id': item.id,
'name': item.name,
'spec': item.spec_model, # 确保这里字段对应正确
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'status': '启用'
})
return results
except Exception as e:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod
def handle_inbound(data):
try:
base_id = data.get('base_id')
if not base_id:
raise ValueError("必须选择基础物料")
material = MaterialBase.query.get(base_id)
if not material:
raise ValueError("所选物料不存在")
# --- [修复点] 执行唯一性校验 ---
BuyInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number')
)
# 时间处理 (强制北京时间)
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time
if data.get('in_date'):
try:
date_str = str(data['in_date'])
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else:
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second)
except:
in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or 0)
# 获取全局打印ID
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except:
next_global_id = None
# SKU 生成
if next_global_id:
generated_sku = str(next_global_id).zfill(10)
else:
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku
arrival_list = data.get('arrival_photo', [])
report_list = data.get('inspection_report', [])
new_stock = StockBuy(
base_id=material.id,
global_print_id=next_global_id,
sku=generated_sku,
barcode=final_barcode,
in_date=in_date_val,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'),
status=data.get('status', '在库'),
in_quantity=in_qty,
stock_quantity=in_qty, # 初始库存等于入库数
available_quantity=in_qty,
inspection_status=data.get('inspection_status', '未检'),
warehouse_location=data.get('warehouse_location'),
unit_price=u_price,
total_price=in_qty * u_price,
currency=data.get('currency', 'CNY'),
exchange_rate=data.get('exchange_rate', 1.0),
supplier_name=data.get('supplier_name'),
buyer_name=data.get('purchaser'),
buyer_email=data.get('purchaser_email'),
original_link=data.get('source_link'),
detail_link=data.get('detail_link'),
arrival_photo=json.dumps(arrival_list),
inspection_report=json.dumps(report_list)
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 3. 更新入库逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
try:
stock = StockBuy.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# --- [修复点] 编辑时也要校验唯一性 (排除自身ID) ---
# 如果修改了物料(base_id)或者修改了SN/BN都需要校验
new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number)
BuyInboundService._check_unique(
base_id=new_base_id,
serial_number=new_sn,
batch_number=new_bn,
exclude_id=stock_id
)
# 更新字段
field_mapping = {
'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
'warehouse_location': 'warehouse_location',
'serial_number': 'serial_number', 'batch_number': 'batch_number',
'status': 'status', 'inspection_status': 'inspection_status',
'supplier_name': 'supplier_name', 'detail_link': 'detail_link',
'currency': 'currency', 'exchange_rate': 'exchange_rate',
'purchaser': 'buyer_name', 'purchaser_email': 'buyer_email',
'source_link': 'original_link'
}
for k, v in field_mapping.items():
if k in data: setattr(stock, v, data[k])
if 'arrival_photo' in data and isinstance(data['arrival_photo'], list):
stock.arrival_photo = json.dumps(data['arrival_photo'])
if 'inspection_report' in data and isinstance(data['inspection_report'], list):
stock.inspection_report = json.dumps(data['inspection_report'])
# 库存数量变更逻辑
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
if diff != 0:
stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
if 'unit_price' in data:
stock.unit_price = float(data['unit_price'])
stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
try:
stock = StockBuy.query.get(stock_id)
if not stock: raise ValueError("记录不存在")
db.session.delete(stock)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 5. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
try:
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
if keyword:
kw = f'%{keyword}%'
query = query.filter(
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
StockBuy.batch_number.ilike(kw),
StockBuy.serial_number.ilike(kw),
StockBuy.sku.ilike(kw),
StockBuy.supplier_name.ilike(kw)
)
)
if not statuses:
statuses = ['在库', '借库']
if '已出库' in statuses:
query = query.filter(StockBuy.status.in_(statuses))
else:
query = query.filter(and_(StockBuy.status.in_(statuses), StockBuy.stock_quantity > 0))
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
qty_stock = float(item.stock_quantity or 0)
qty_avail = float(item.available_quantity or 0)
date_display = ''
if item.in_date:
try:
date_display = item.in_date.strftime('%Y-%m-%d')
except:
date_display = str(item.in_date)[:10]
d = {
'id': item.id,
'base_id': item.base_id,
# 确保这里从关联的 MaterialBase 获取规格型号
'material_name': item.material.name if item.material else '',
'spec_model': item.material.spec_model if item.material else '',
'category': item.material.category if item.material else '',
'unit': item.material.unit if item.material else '',
'material_type': item.material.material_type if item.material else '',
'sku': item.sku,
'inbound_date': date_display,
'barcode': item.barcode,
'serial_number': item.serial_number,
'batch_number': item.batch_number,
'status': item.status,
'inspection_status': item.inspection_status,
'qty_inbound': float(item.in_quantity or 0),
'qty_stock': qty_stock,
'qty_available': qty_avail,
'warehouse_loc': item.warehouse_location,
'unit_price': float(item.unit_price or 0),
'total_price': float(item.total_price or 0),
'currency': item.currency,
'exchange_rate': float(item.exchange_rate or 1),
'supplier_name': item.supplier_name,
'purchaser': item.buyer_name,
'purchaser_email': item.buyer_email,
'source_link': item.original_link,
'detail_link': item.detail_link,
'arrival_photo': parse_img(item.arrival_photo),
'inspection_report': parse_img(item.inspection_report),
'global_print_id': item.global_print_id
}
items.append(d)
return {"total": pagination.total, "items": items}
except Exception as e:
traceback.print_exc()
return {"total": 0, "items": []}

View File

@ -0,0 +1,190 @@
from sqlalchemy import select, literal, union_all, desc, asc, func, or_, cast, String, Numeric, Date
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

View File

@ -0,0 +1,347 @@
# app/services/inbound/product_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.outbound import TransOutbound
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
import traceback
import json
class ProductInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (新增核心逻辑)
# ============================================================
@staticmethod
def _check_unique(serial_number, exclude_id=None):
"""
校验成品的唯一性
:param serial_number: 序列号
:param exclude_id: 排除的ID (编辑模式用)
"""
from app.models.inbound.product import StockProduct
# 成品强校验序列号 (SN) - SN应该是全局唯一的
if serial_number:
query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
if exclude_id:
query = query.filter(StockProduct.id != exclude_id)
exists = query.first()
if exists:
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
try:
# 1. 基础查询:必须是已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
# 2. 动态条件:如果传入了关键词,则增加模糊匹配条件
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
# 3. 排序与限制按ID倒序取最新20条
query = query.order_by(MaterialBase.id.desc()).limit(20)
# 4. 结果封装
results = []
for item in query.all():
results.append({
'id': item.id,
'name': item.name,
'spec': item.spec_model,
'category': item.category,
'unit': item.unit,
'type': item.material_type,
'status': '启用'
})
return results
except Exception:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑 (强制北京时间 + 唯一性校验)
# ============================================================
@staticmethod
def handle_inbound(data):
from app.models.inbound.product import StockProduct
try:
base_id = data.get('base_id')
if not base_id: raise ValueError("必须选择基础物料")
material = MaterialBase.query.get(base_id)
if not material: raise ValueError("物料不存在")
# --- [核心修改] 执行唯一性校验 ---
ProductInboundService._check_unique(
serial_number=data.get('serial_number')
)
# [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time
if data.get('in_date'):
try:
date_str = str(data['in_date'])
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else:
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second)
except:
in_date_val = current_time
in_qty = float(data.get('in_quantity') or 0)
p_start = data.get('production_start_time', '')
p_end = data.get('production_end_time', '')
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
# 全局流水号
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except:
next_global_id = None
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
final_barcode = data.get('barcode') or generated_sku
photo_list = data.get('product_photo', [])
quality_list = data.get('quality_report_link', [])
inspection_list = data.get('inspection_report_link', [])
if not isinstance(photo_list, list): photo_list = []
if not isinstance(quality_list, list): quality_list = []
if not isinstance(inspection_list, list): inspection_list = []
new_stock = StockProduct(
base_id=material.id,
global_print_id=next_global_id,
sku=generated_sku,
production_date=in_date_val, # 存入 DateTime
barcode=final_barcode,
serial_number=data.get('serial_number'),
status=data.get('status', '在库'),
warehouse_location=data.get('warehouse_location'),
in_quantity=in_qty,
stock_quantity=in_qty,
available_quantity=in_qty,
bom_code=data.get('bom_code'),
bom_version=data.get('bom_version'),
work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'),
production_time_range=time_range,
raw_material_cost=float(data.get('raw_material_cost') or 0),
manual_cost=float(data.get('manual_cost') or 0),
quality_status=data.get('quality_status', '合格'),
product_photo=json.dumps(photo_list),
quality_report_link=json.dumps(quality_list),
inspection_report_link=json.dumps(inspection_list),
detail_link=data.get('detail_link'),
remark=data.get('remark'),
sale_price=float(data.get('sale_price') or 0),
order_id=data.get('order_id')
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
from app.models.inbound.product import StockProduct
try:
stock = StockProduct.query.get(stock_id)
if not stock: raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
if 'serial_number' in data:
ProductInboundService._check_unique(
serial_number=data['serial_number'],
exclude_id=stock_id
)
fields = [
'barcode', 'serial_number', 'warehouse_location',
'status', 'quality_status', 'bom_code', 'bom_version',
'work_order_code', 'production_manager',
'detail_link', 'order_id', 'remark'
]
for f in fields:
if f in data: setattr(stock, f, data[f])
if 'product_photo' in data:
imgs = data['product_photo']
if isinstance(imgs, list): stock.product_photo = json.dumps(imgs)
if 'quality_report_link' in data:
imgs = data['quality_report_link']
if isinstance(imgs, list): stock.quality_report_link = json.dumps(imgs)
if 'inspection_report_link' in data:
imgs = data['inspection_report_link']
if isinstance(imgs, list): stock.inspection_report_link = json.dumps(imgs)
if 'sale_price' in data: stock.sale_price = float(data['sale_price'])
if 'raw_material_cost' in data: stock.raw_material_cost = float(data['raw_material_cost'])
if 'manual_cost' in data: stock.manual_cost = float(data['manual_cost'])
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
if 'production_start_time' in data or 'production_end_time' in data:
old_range = stock.production_time_range or " ~ "
parts = old_range.split(' ~ ')
old_start = parts[0] if len(parts) > 0 else ''
old_end = parts[1] if len(parts) > 1 else ''
start = data.get('production_start_time', old_start)
end = data.get('production_end_time', old_end)
stock.production_time_range = f"{start} ~ {end}"
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
from app.models.inbound.product import StockProduct
try:
stock = StockProduct.query.get(stock_id)
if stock:
db.session.delete(stock)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
"""获取出库历史"""
try:
records = TransOutbound.query.filter_by(
source_table='stock_product', stock_id=stock_id
).order_by(TransOutbound.outbound_time.desc()).all()
return [r.to_dict() for r in records]
except:
return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
from app.models.inbound.product import StockProduct
try:
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
if keyword:
query = query.filter(or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%'),
StockProduct.work_order_code.ilike(f'%{keyword}%'),
StockProduct.order_id.ilike(f'%{keyword}%'),
StockProduct.sku.ilike(f'%{keyword}%')
))
if not statuses:
statuses = ['在库', '借库']
if '已出库' in statuses:
query = query.filter(StockProduct.status.in_(statuses))
else:
query = query.filter(
and_(
StockProduct.status.in_(statuses),
StockProduct.stock_quantity > 0
)
)
# 按照 production_date (入库日期) 倒序排序
pagination = query.order_by(StockProduct.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
d = item.to_dict()
# 格式化日期
date_display = ''
if item.production_date:
try:
date_display = item.production_date.strftime('%Y-%m-%d')
except:
date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available']
d['product_photo'] = parse_img(item.product_photo)
d['quality_report_link'] = parse_img(item.quality_report_link)
d['inspection_report_link'] = parse_img(item.inspection_report_link)
d['global_print_id'] = item.global_print_id
items.append(d)
return {"total": pagination.total, "items": items}
except:
traceback.print_exc()
return {"total": 0, "items": []}

View File

@ -0,0 +1,449 @@
# app/services/inbound/semi_service.py
from app.extensions import db
from app.models.base import MaterialBase
from app.models.outbound import TransOutbound
from datetime import datetime, timedelta, timezone
from sqlalchemy import or_, func, text, and_
import traceback
import json
class SemiInboundService:
# ============================================================
# 0. 辅助:唯一性校验 (新增核心逻辑)
# ============================================================
@staticmethod
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
"""
校验半成品的唯一性
:param base_id: 基础物料ID
:param serial_number: 序列号
:param batch_number: 批号
:param exclude_id: 排除的ID
"""
from app.models.inbound.semi import StockSemi
# 1. 序列号 (SN) 校验 - 全局唯一
if serial_number:
query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
exists = query.first()
if exists:
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
if batch_number and base_id:
query = StockSemi.query.filter(
StockSemi.base_id == base_id,
StockSemi.batch_number == batch_number
)
if exclude_id:
query = query.filter(StockSemi.id != exclude_id)
if query.first():
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
# ============================================================
# 1. 基础物料搜索
# ============================================================
@staticmethod
def search_base_material(keyword):
try:
# 基础查询:必须是已启用的物料
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
# 如果有关键词,进行模糊匹配
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
)
# 统一逻辑按ID倒序限制20条
query = query.order_by(MaterialBase.id.desc()).limit(20)
results = []
for item in query.all():
results.append({
'id': item.id,
'name': item.name,
'spec': item.spec_model, # 对应前端 item.spec
'category': item.category,
'unit': item.unit,
'type': item.material_type, # 对应前端 item.type
'status': '启用'
})
return results
except Exception as e:
traceback.print_exc()
return []
# ============================================================
# 2. 新增入库逻辑
# ============================================================
@staticmethod
def handle_inbound(data):
from app.models.inbound.semi import StockSemi
try:
base_id = data.get('base_id')
if not base_id:
raise ValueError("必须选择基础物料 (缺少 base_id)")
material = MaterialBase.query.get(base_id)
if not material:
raise ValueError(f"ID为 {base_id} 的基础物料不存在")
# --- [核心修改] 执行唯一性校验 ---
SemiInboundService._check_unique(
base_id=base_id,
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number')
)
# [核心修改] 强制北京时间
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
in_date_val = current_time
if data.get('in_date'):
try:
date_str = str(data['in_date'])
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
else:
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
current_time.hour, current_time.minute, current_time.second)
except ValueError:
in_date_val = current_time
# 2. 处理生产时间
p_start = None
p_end = None
if data.get('production_start_time'):
try:
p_start = datetime.strptime(str(data['production_start_time']), '%Y-%m-%d %H:%M:%S')
except:
pass
if data.get('production_end_time'):
try:
p_end = datetime.strptime(str(data['production_end_time']), '%Y-%m-%d %H:%M:%S')
except:
pass
time_range_str = None
raw_range = data.get('production_time_range')
if isinstance(raw_range, list):
time_range_str = " ~ ".join([str(x) for x in raw_range])
elif isinstance(raw_range, str):
time_range_str = raw_range
# 3. 处理数值和成本
in_qty = float(data.get('in_quantity') or 0)
raw_cost = float(data.get('raw_material_cost') or 0)
manual_cost = float(data.get('manual_cost') or 0)
unit_total_cost = raw_cost + manual_cost
total_value = unit_total_cost * in_qty
# 4. 获取全局打印流水号
next_global_id = 0
try:
seq_sql = text("SELECT nextval('global_print_seq')")
result = db.session.execute(seq_sql)
next_global_id = result.scalar()
except Exception as e:
print("❌ 数据库序列 global_print_seq 不存在请执行SQL创建")
raise e
generated_sku = str(next_global_id).zfill(10)
final_sku = data.get('sku')
if not final_sku:
final_sku = generated_sku
final_barcode = data.get('barcode')
if not final_barcode:
final_barcode = final_sku
arrival_list = data.get('arrival_photo', [])
quality_report_list = data.get('quality_report_link', [])
if not isinstance(arrival_list, list): arrival_list = []
if not isinstance(quality_report_list, list): quality_report_list = []
# 8. 创建记录
new_stock = StockSemi(
base_id=material.id,
global_print_id=next_global_id,
sku=final_sku,
production_date=in_date_val, # 存入 DateTime
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'),
barcode=final_barcode,
status='在库',
quality_status=data.get('quality_status', '合格'),
in_quantity=in_qty,
stock_quantity=in_qty,
available_quantity=in_qty,
warehouse_location=data.get('warehouse_location'),
bom_code=data.get('bom_code'),
bom_version=data.get('bom_version'),
work_order_code=data.get('work_order_code'),
production_manager=data.get('production_manager'),
production_start_time=p_start,
production_end_time=p_end,
production_time_range=time_range_str,
raw_material_cost=raw_cost,
manual_cost=manual_cost,
total_price=total_value,
arrival_photo=json.dumps(arrival_list),
quality_report_link=json.dumps(quality_report_list),
detail_link=data.get('detail_link'),
remark=data.get('remark')
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
print("----- SemiInboundService Error -----")
traceback.print_exc()
raise e
# ============================================================
# 3. 更新逻辑
# ============================================================
@staticmethod
def update_inbound(stock_id, data):
from app.models.inbound.semi import StockSemi
try:
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# --- [核心修改] 编辑时也要校验唯一性 ---
new_base_id = data.get('base_id', stock.base_id)
new_sn = data.get('serial_number', stock.serial_number)
new_bn = data.get('batch_number', stock.batch_number)
SemiInboundService._check_unique(
base_id=new_base_id,
serial_number=new_sn,
batch_number=new_bn,
exclude_id=stock_id
)
field_mapping = {
'sku': 'sku',
'barcode': 'barcode',
'warehouse_location': 'warehouse_location',
'serial_number': 'serial_number',
'batch_number': 'batch_number',
'status': 'status',
'quality_status': 'quality_status',
'bom_code': 'bom_code',
'bom_version': 'bom_version',
'work_order_code': 'work_order_code',
'production_manager': 'production_manager',
'detail_link': 'detail_link',
'remark': 'remark'
}
for frontend_key, db_attr in field_mapping.items():
if frontend_key in data:
setattr(stock, db_attr, data[frontend_key])
if 'arrival_photo' in data:
imgs = data['arrival_photo']
if isinstance(imgs, list):
stock.arrival_photo = json.dumps(imgs)
if 'quality_report_link' in data:
imgs = data['quality_report_link']
if isinstance(imgs, list):
stock.quality_report_link = json.dumps(imgs)
if 'production_start_time' in data:
try:
if data['production_start_time']:
stock.production_start_time = datetime.strptime(str(data['production_start_time']),
'%Y-%m-%d %H:%M:%S')
else:
stock.production_start_time = None
except:
pass
if 'production_end_time' in data:
try:
if data['production_end_time']:
stock.production_end_time = datetime.strptime(str(data['production_end_time']),
'%Y-%m-%d %H:%M:%S')
else:
stock.production_end_time = None
except:
pass
if 'production_time_range' in data:
raw_range = data['production_time_range']
if isinstance(raw_range, list):
stock.production_time_range = " ~ ".join([str(x) for x in raw_range])
else:
stock.production_time_range = raw_range
qty_changed = False
cost_changed = False
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
diff = new_qty - float(stock.in_quantity)
if diff != 0:
stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
qty_changed = True
if 'raw_material_cost' in data:
stock.raw_material_cost = float(data['raw_material_cost'])
cost_changed = True
if 'manual_cost' in data:
stock.manual_cost = float(data['manual_cost'])
cost_changed = True
if cost_changed or qty_changed:
unit_total = float(stock.raw_material_cost) + float(stock.manual_cost)
stock.total_price = float(stock.in_quantity) * unit_total
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 4. 删除逻辑
# ============================================================
@staticmethod
def delete_inbound(stock_id):
from app.models.inbound.semi import StockSemi
try:
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
db.session.delete(stock)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
raise e
# ============================================================
# 5. 出库历史
# ============================================================
@staticmethod
def get_outbound_history(stock_id):
"""获取出库历史"""
try:
records = TransOutbound.query.filter_by(
source_table='stock_semi', stock_id=stock_id
).order_by(TransOutbound.outbound_time.desc()).all()
return [r.to_dict() for r in records]
except:
return []
# ============================================================
# 6. 获取列表
# ============================================================
@staticmethod
def get_list(page, limit, keyword=None, statuses=None):
from app.models.inbound.semi import StockSemi
try:
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
if keyword:
kw = f'%{keyword}%'
query = query.filter(
or_(
MaterialBase.name.ilike(kw),
MaterialBase.spec_model.ilike(kw),
StockSemi.batch_number.ilike(kw),
StockSemi.serial_number.ilike(kw),
StockSemi.sku.ilike(kw),
StockSemi.work_order_code.ilike(kw),
StockSemi.bom_code.ilike(kw)
)
)
if not statuses:
statuses = ['在库', '借库']
if '已出库' in statuses:
query = query.filter(StockSemi.status.in_(statuses))
else:
query = query.filter(
and_(
StockSemi.status.in_(statuses),
StockSemi.stock_quantity > 0
)
)
# 按照 production_date (入库日期) 倒序排序
pagination = query.order_by(StockSemi.production_date.desc()).paginate(page=page, per_page=limit,
error_out=False)
current_items = pagination.items
def parse_img(json_str):
if not json_str: return []
try:
return json.loads(json_str) if json_str.startswith('[') else [json_str]
except:
return []
items = []
for item in current_items:
d = item.to_dict()
# 格式化展示日期
date_display = ''
if item.production_date:
try:
date_display = item.production_date.strftime('%Y-%m-%d')
except:
date_display = str(item.production_date)[:10]
d['inbound_date'] = date_display
d['qty_stock'] = float(item.stock_quantity or 0)
d['qty_available'] = float(item.available_quantity or 0)
d['sum_stock'] = d['qty_stock']
d['sum_available'] = d['qty_available']
d['arrival_photo'] = parse_img(item.arrival_photo)
d['quality_report_link'] = parse_img(item.quality_report_link)
d['global_print_id'] = item.global_print_id
items.append(d)
return {"total": pagination.total, "items": items}
except Exception as e:
print(f"List Error: {e}")
traceback.print_exc()
return {"total": 0, "items": []}

View File

@ -0,0 +1,315 @@
import uuid
from datetime import datetime, timezone, timedelta
from sqlalchemy import or_, func, desc
from app.extensions import db
from app.models.outbound import TransOutbound
# 引入所有库存模型以进行查询
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.inbound.product import StockProduct
# 引入基础信息表
from app.models.base import MaterialBase
class OutboundService:
@staticmethod
def generate_outbound_no():
"""
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
例如: OUT-20260205-1558-0001
"""
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"OUT-{date_str}-"
existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \
.filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar()
sequence = existing_count + 1
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def get_stock_by_barcode(barcode):
"""
根据扫码内容查找对应的库存物品,并附带价格信息
"""
if not barcode:
return None
clean_code = barcode.strip()
def get_price(item, table_type):
if table_type == 'stock_product':
return float(item.sale_price) if item.sale_price else 0
elif table_type == 'stock_buy':
return float(item.unit_price) if item.unit_price else 0
return 0
prod = StockProduct.query.filter(
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
).first()
if prod:
res = OutboundService._format_scan_result(prod, 'stock_product')
res['price'] = get_price(prod, 'stock_product')
return res
semi = StockSemi.query.filter(
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
).first()
if semi:
res = OutboundService._format_scan_result(semi, 'stock_semi')
res['price'] = 0
return res
buy = StockBuy.query.filter(
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
).first()
if buy:
res = OutboundService._format_scan_result(buy, 'stock_buy')
res['price'] = get_price(buy, 'stock_buy')
return res
return None
@staticmethod
def _format_scan_result(item, table_name):
base_name = ""
base_spec = ""
base_cat = ""
base_type = ""
if hasattr(item, 'base') and item.base:
base_name = item.base.name
base_spec = item.base.spec_model
base_cat = item.base.category
base_type = item.base.material_type
if not base_name and hasattr(item, 'base_id') and item.base_id:
try:
base_info = MaterialBase.query.get(item.base_id)
if base_info:
base_name = base_info.name
base_spec = base_info.spec_model
base_cat = base_info.category
base_type = base_info.material_type
except Exception:
pass
if not base_name and hasattr(item, 'material_name'):
base_name = item.material_name
stock_qty = float(item.stock_quantity) if item.stock_quantity else 0
avail_qty = float(item.available_quantity) if item.available_quantity else 0
return {
'id': item.id,
'sku': item.sku,
'name': base_name or "未知物品",
'spec_model': base_spec or "",
'category': base_cat or "",
'material_type': base_type or "",
'source_table': table_name,
'stock_quantity': stock_qty,
'available_quantity': avail_qty,
'batch_number': getattr(item, 'batch_number', ''),
'warehouse_location': getattr(item, 'warehouse_location', ''),
'barcode': getattr(item, 'barcode', '')
}
@staticmethod
def create_outbound_batch(data, operator_name='System'):
items = data.get('items', [])
if not items:
raise ValueError("出库商品列表不能为空")
outbound_no = OutboundService.generate_outbound_no()
common_data = {
'outbound_no': outbound_no,
'consumer_name': data.get('consumer_name'),
'outbound_type': data.get('outbound_type', 'SALES'),
'signature_path': data.get('signature_path'),
'operator_name': operator_name,
'remark': data.get('remark')
}
beijing_tz = timezone(timedelta(hours=8))
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
try:
for item in items:
source_table = item.get('source_table')
stock_id = item.get('stock_id')
quantity = float(item.get('quantity', 0))
unit_price = float(item.get('price', 0))
if quantity <= 0:
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
ModelClass = model_map.get(source_table)
if not ModelClass:
continue
stock_record = ModelClass.query.with_for_update().get(stock_id)
if not stock_record:
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
if float(stock_record.available_quantity) < quantity:
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
new_record = TransOutbound(
sku=item.get('sku'),
source_table=source_table,
stock_id=stock_id,
barcode=item.get('barcode'),
quantity=quantity,
unit_price=unit_price,
outbound_time=current_time,
**common_data
)
db.session.add(new_record)
db.session.commit()
return outbound_no
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_grouped_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
"""
查询出库记录(按出库单号分组),包含详细物品信息
"""
# 1. 查询分页单号
stmt = db.session.query(
TransOutbound.outbound_no,
func.max(TransOutbound.outbound_time).label('max_time')
).group_by(TransOutbound.outbound_no)
if keyword:
stmt = stmt.filter(or_(
TransOutbound.outbound_no.ilike(f'%{keyword}%'),
TransOutbound.consumer_name.ilike(f'%{keyword}%'),
TransOutbound.sku.ilike(f'%{keyword}%')
))
if start_date and end_date:
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
stmt = stmt.order_by(desc('max_time'))
pagination = stmt.paginate(page=page, per_page=per_page, error_out=False)
outbound_nos = [row.outbound_no for row in pagination.items]
if not outbound_nos:
return {
'items': [],
'total': 0,
'pages': 0,
'current_page': page
}
# 2. 查询详细记录
details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all()
# 3. 组装数据并查询物品详情
grouped_map = {}
# 映射表模型以便查询
model_map = {
'stock_buy': StockBuy,
'stock_semi': StockSemi,
'stock_product': StockProduct
}
for d in details:
ono = d.outbound_no
if ono not in grouped_map:
grouped_map[ono] = {
'outbound_no': ono,
'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'),
'outbound_type': d.outbound_type,
'consumer_name': d.consumer_name,
'operator_name': d.operator_name,
'signature_path': d.signature_path,
'remark': d.remark,
'total_amount': 0.0,
'items': []
}
# --- 查询物品详细信息 (名称, 规格, 类型, 类别) ---
item_name = "未知物品"
item_spec = ""
item_cat = ""
item_type = ""
ModelClass = model_map.get(d.source_table)
if ModelClass and d.stock_id:
# 注意这里在循环中查询可能会有N+1问题但考虑到单页数据量通常每单条目不多暂时可接受
# 生产环境建议优化为预加载或批量查询
try:
stock_item = ModelClass.query.get(d.stock_id)
if stock_item and stock_item.base:
item_name = stock_item.base.name
item_spec = stock_item.base.spec_model
item_cat = stock_item.base.category
item_type = stock_item.base.material_type
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
base_info = MaterialBase.query.get(stock_item.base_id)
if base_info:
item_name = base_info.name
item_spec = base_info.spec_model
item_cat = base_info.category
item_type = base_info.material_type
except Exception as e:
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
# 计算金额
price = float(d.unit_price) if d.unit_price else 0
qty = float(d.quantity)
subtotal = price * qty
grouped_map[ono]['total_amount'] += subtotal
grouped_map[ono]['items'].append({
'sku': d.sku,
'name': item_name,
'spec_model': item_spec,
'category': item_cat,
'material_type': item_type,
'quantity': qty,
'unit_price': price,
'subtotal': subtotal
})
# 4. 排序输出
result_list = []
for ono in outbound_nos:
if ono in grouped_map:
obj = grouped_map[ono]
obj['items'].sort(key=lambda x: x['unit_price'], reverse=True)
obj['total_amount'] = round(obj['total_amount'], 2)
result_list.append(obj)
return {
'items': result_list,
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}

View File

@ -0,0 +1,267 @@
import socket
import base64
import os
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
# 引入条形码生成库
try:
import barcode
from barcode.writer import ImageWriter
except ImportError:
print("❌ 警告: 未安装 python-barcode 库,无法生成真实条形码。请执行: pip install python-barcode")
class LabelPrintService:
PRINTER_IP = "192.168.9.205"
PRINTER_PORT = 9100
# ================= 1. 尺寸与分辨率配置 (300 DPI) =================
DOTS_PER_MM = 12 # 300 DPI
LABEL_WIDTH_MM = 40
LABEL_HEIGHT_MM = 30
# 画布像素: 40mm -> 480px, 30mm -> 360px
LABEL_WIDTH = int(LABEL_WIDTH_MM * DOTS_PER_MM)
LABEL_HEIGHT = int(LABEL_HEIGHT_MM * DOTS_PER_MM)
# 顶部留白
TOP_MARGIN_MM = 1.5
TOP_MARGIN_PX = int(TOP_MARGIN_MM * DOTS_PER_MM)
# 定义左边距 (3mm) - 用于正文左对齐
MARGIN_LEFT = int(3 * DOTS_PER_MM)
# 定义右边距 (防止文字贴边,留 2mm)
MARGIN_RIGHT = int(2 * DOTS_PER_MM)
# 计算文字允许的最大像素宽度
MAX_TEXT_WIDTH = LABEL_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
@staticmethod
def _get_font(size):
"""获取字体"""
# 尝试加载中文字体,否则乱码
font_names = ["simhei.ttf", "msyh.ttf", "SimHei.ttf", "arial.ttf"]
base_dirs = [os.getcwd(), os.path.dirname(__file__)]
for d in base_dirs:
for name in font_names:
path = os.path.join(d, name)
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default()
@staticmethod
def _generate_barcode_image(content, width_px, height_px):
"""生成真实的条形码图片"""
try:
if not content: content = "0000000000"
code128 = barcode.get('code128', content, writer=ImageWriter())
buffer = BytesIO()
code128.write(buffer, options={"write_text": False, "module_height": 10.0, "quiet_zone": 1.0})
buffer.seek(0)
bc_img = Image.open(buffer)
return bc_img.resize((width_px, height_px), Image.Resampling.LANCZOS)
except Exception as e:
print(f"条形码生成失败: {e}")
return Image.new('RGB', (width_px, height_px), color='black')
@staticmethod
def draw_text_wrap(draw, text, x, y, font, max_width, line_spacing=0):
"""
[核心功能] 自动换行绘制文本
:param line_spacing: 行与行之间的额外像素距离
:return: 绘制结束后的 Y 坐标
"""
if not text:
return y
lines = []
current_line = ""
# 1. 计算折行逻辑
for char in text:
# 预测加入新字符后的宽度
test_line = current_line + char
width = font.getlength(test_line)
if width <= max_width:
current_line = test_line
else:
# 宽度超出,将当前行存入,新字符作为下一行开头
lines.append(current_line)
current_line = char
# 将最后剩余的内容加入
if current_line:
lines.append(current_line)
# 2. 绘制每一行
current_y = y
font_height = font.size # 获取字号高度
for line in lines:
# 边界检查:如果超出图片高度,停止绘制
if current_y + font_height > LabelPrintService.LABEL_HEIGHT:
break
# 绘制文字 (stroke_width=1 加粗)
draw.text((x, current_y), line, font=font, fill='black', stroke_width=1, stroke_fill='black')
# 更新 Y 坐标 (字高 + 行间距)
current_y += font_height + line_spacing
return current_y
@staticmethod
def _create_image_object(data):
"""
[绘图层] 生成标签图片
"""
# 1. 创建画布
img = Image.new('RGB', (LabelPrintService.LABEL_WIDTH, LabelPrintService.LABEL_HEIGHT), color='white')
d = ImageDraw.Draw(img)
# 2. 字体配置
# [保持] 正文内容维持 24号以节省空间
font_body = LabelPrintService._get_font(24)
# [修改] 编码(SKU)字体设置为 30号
font_code = LabelPrintService._get_font(30)
# 3. 数据准备
sku_code = data.get('sku')
if not sku_code:
sku_code = data.get('serial_number') or str(data.get('global_print_id', '0000000000')).zfill(10)
# ==================== 绘制布局 ====================
GLOBAL_OFFSET_X = LabelPrintService.MARGIN_LEFT
CURRENT_Y = LabelPrintService.TOP_MARGIN_PX
# --- A. 绘制条形码 (居中) ---
bc_w = int(37 * LabelPrintService.DOTS_PER_MM)
bc_h = int(8 * LabelPrintService.DOTS_PER_MM) # 高度
bc_img = LabelPrintService._generate_barcode_image(sku_code, bc_w, bc_h)
# [修改核心] 计算条形码的居中 X 坐标
# 公式:(标签总宽 - 条码宽) / 2
bc_x_centered = (LabelPrintService.LABEL_WIDTH - bc_w) // 2
img.paste(bc_img, (bc_x_centered, CURRENT_Y))
# --- B. 绘制条形码下方数字 (居中 + 30号字) ---
text_y_pos = CURRENT_Y + bc_h + 2
# [修改核心] 计算文字宽度 并 居中
text_width = font_code.getlength(sku_code)
text_x_centered = (LabelPrintService.LABEL_WIDTH - text_width) // 2
d.text(
(text_x_centered, text_y_pos),
sku_code,
font=font_code, # 使用30号字体
fill='black',
stroke_width=1,
stroke_fill='black'
)
# 更新 Y 坐标,准备开始绘制正文
# 30(字高) + 4(间距)
CURRENT_Y = text_y_pos + 30 + 4
# --- C. 绘制其余信息 (保持左对齐 + 24号字 + 自动换行) ---
# 1. 准备完整文本
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}"
# 底部文字
bottom_text = ""
if data.get('print_no'):
val = str(data.get('print_no'))
label_type = data.get('print_label', '')
bottom_text = f"{'SN' if label_type == '' else 'BN' if label_type == '' else 'NO'}: {val}"
elif data.get('serial_number'):
bottom_text = f"SN: {data.get('serial_number')}"
elif data.get('batch_number'):
bottom_text = f"BN: {data.get('batch_number')}"
else:
bottom_text = f"NO: {sku_code}"
# 2. 依次调用自动换行绘制函数 (使用正文字体 font_body且坐标使用 GLOBAL_OFFSET_X)
# 绘制名称
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, f"名: {name}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
)
CURRENT_Y += 2
# 绘制规格
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, f"规: {spec}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
)
CURRENT_Y += 2
# 绘制库位
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, f"库: {loc}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
)
CURRENT_Y += 2
# 绘制属性
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, f"属: {attr}", GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
)
CURRENT_Y += 2
# 绘制底部编号
CURRENT_Y = LabelPrintService.draw_text_wrap(
d, bottom_text, GLOBAL_OFFSET_X, CURRENT_Y, font_body, LabelPrintService.MAX_TEXT_WIDTH, line_spacing=2
)
return img
@staticmethod
def generate_preview_image(data):
img = LabelPrintService._create_image_object(data)
output_buffer = BytesIO()
img.save(output_buffer, format='JPEG')
base64_str = base64.b64encode(output_buffer.getvalue()).decode('utf-8')
return f"data:image/jpeg;base64,{base64_str}"
@staticmethod
def send_to_printer(data):
ip = LabelPrintService.PRINTER_IP
port = LabelPrintService.PRINTER_PORT
try:
img_rgb = LabelPrintService._create_image_object(data)
img_gray = img_rgb.convert('L')
img_bw = img_gray.convert('1', dither=Image.Dither.NONE)
bitmap_data = img_bw.tobytes()
width_bytes = (img_bw.width + 7) // 8
height_dots = img_bw.height
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"
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)}")

View File

@ -0,0 +1,114 @@
import socket
import datetime
class NetworkPrintService:
def __init__(self, ip='192.168.9.205', port=9100):
"""
初始化网络打印机服务
:param ip: 打印机IP默认 192.168.9.205
:param port: 端口,默认 9100
"""
self.ip = ip
self.port = port
def _send_to_printer(self, content):
"""底层发送方法"""
try:
# 建立 Socket 连接
with socket.socket(socket.socket.AF_INET, socket.socket.SOCK_STREAM) as s:
s.settimeout(5) # 设置5秒超时
s.connect((self.ip, self.port))
# 发送内容,使用 GB18030 编码以支持中文
s.sendall(content.encode('gb18030'))
# 发送切纸指令 (ESC/POS: GS V m)
# 十六进制: 1D 56 42 00
s.sendall(b'\x1d\x56\x42\x00')
return True, "打印成功"
except Exception as e:
print(f"[NetworkPrint Error] {str(e)}")
return False, f"打印失败: {str(e)}"
def print_outbound_selection(self, items):
"""
打印出库选单 (拣货单)
:param items: 选中的物品列表
"""
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = []
lines.append("\n")
lines.append("********************************")
lines.append(" 出库拣货确认单 ")
lines.append("********************************")
lines.append(f"打印时间: {timestamp}")
lines.append(f"待出库总数: {len(items)}")
lines.append("--------------------------------")
lines.append(f"{'名称':<14}{'规格/批号':<10}")
lines.append("--------------------------------")
for item in items:
# 获取名称,优先取 material_name, 其次 product_name
name = item.get('material_name') or item.get('product_name') or "未知物品"
if len(name) > 14: name = name[:13] + "." # 名称过长截断
standard = item.get('standard', '')
batch = item.get('batch_no', '')
uuid = item.get('uuid', '')[-6:] # 只显示UUID后6位
lines.append(f"{name:<14} {standard}")
lines.append(f"批号: {batch} | 尾号: {uuid}")
lines.append("- - - - - - - - - - - - - - - -")
lines.append("\n")
lines.append("库管员签字: ______________")
lines.append("领料人签字: ______________")
lines.append("\n\n\n") # 走纸
content = "\n".join(lines)
return self._send_to_printer(content)
def print_stocktake_report(self, data):
"""
打印盘点统计报告
:param data: 包含 total, scanned, missing, missing_items
"""
total = data.get('total', 0)
scanned = data.get('scanned', 0)
missing = data.get('missing', 0)
missing_items = data.get('missing_items', [])
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = []
lines.append("\n")
lines.append("================================")
lines.append(" 库存盘点统计报告 ")
lines.append("================================")
lines.append(f"盘点时间: {timestamp}")
lines.append(f"应盘总数: {total}")
lines.append(f"实盘(已扫): {scanned}")
lines.append(f"差异(未扫): {missing}")
lines.append("--------------------------------")
if missing == 0:
lines.append("【结果】: 账实相符,库存完美!")
else:
lines.append("【差异明细 (未扫码物品)】:")
for item in missing_items:
name = item.get('material_name') or item.get('product_name') or "未知"
batch = item.get('batch_no', '-')
# 兼容不同模型的字段
code = item.get('uuid', item.get('bar_code', 'N/A'))[-6:]
lines.append(f"[ ] {name}")
lines.append(f" 批:{batch} 码:{code}")
lines.append("\n")
lines.append("监盘人: ______________")
lines.append("\n\n\n")
content = "\n".join(lines)
return self._send_to_printer(content)

View File

@ -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

View File

@ -0,0 +1,185 @@
import uuid
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
}

View File

@ -0,0 +1,23 @@
# app/utils/constants.py
class UserRole:
SUPER_ADMIN = 'super_admin' # 超级管理员 (IRIS)
SUPERVISOR = 'supervisor' # 主管
FINANCE = 'finance' # 财务
WAREHOUSE_MGR = 'warehouse_manager' # 库管
INBOUND = 'inbound' # 入库员
OUTBOUND = 'outbound' # 出库员
PURCHASER = 'purchaser' # 采购员
SALES = 'sales' # 销售
# 角色中文映射(用于前端展示或日志)
ROLE_MAP = {
SUPER_ADMIN: '超级管理员',
SUPERVISOR: '主管',
FINANCE: '财务',
WAREHOUSE_MGR: '库管',
INBOUND: '入库员',
OUTBOUND: '出库员',
PURCHASER: '采购员',
SALES: '销售'
}

View File

@ -0,0 +1,30 @@
# app/utils/decorators.py
from functools import wraps
from flask_jwt_extended import get_jwt
from flask import jsonify
def role_required(*roles):
"""
自定义装饰器:检查用户角色
使用方法: @role_required('super_admin', 'finance')
"""
def wrapper(fn):
@wraps(fn)
def decorator(*args, **kwargs):
claims = get_jwt()
user_role = claims.get('role')
# 如果是超级管理员,拥有上帝视角,直接放行 (可选)
if user_role == 'super_admin':
return fn(*args, **kwargs)
if user_role not in roles:
return jsonify(msg='权限不足:您没有访问此资源的权限'), 403
return fn(*args, **kwargs)
return decorator
return wrapper

View File

@ -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

View File

@ -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 等其他服务

View 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 = '-'

View File

@ -6,3 +6,7 @@ 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
# [新增] 必须添加,用于处理 token 登录
Flask-JWT-Extended==4.6.0

View File

@ -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)

Binary file not shown.

View 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
View 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"]

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/jetbrains://idea/navigate/reference?project=inventory-web&path=public%2Firis.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>inventory-web</title>
</head>

26
inventory-web/nginx.conf Normal file
View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript;
# 1. 前端页面
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# 2. 后端接口代理
location /api {
# 'backend' 对应 docker-compose 里的服务名
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -12,13 +12,18 @@
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.3",
"element-plus": "^2.13.1",
"html5-qrcode": "^2.3.8",
"jspdf": "^2.5.1",
"jspdf-autotable": "^3.8.2",
"pinia": "^3.0.4",
"sass": "^1.97.3",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
"vue-router": "^4.6.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-basic-ssl": "^1.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -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

View File

@ -1,30 +1,200 @@
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { InfoFilled, SwitchButton, UserFilled } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute() // [新增] 获取当前路由对象
const userStore = useUserStore()
// [新增] 计算属性:判断当前是否是登录页
const isLoginPage = computed(() => {
return route.path === '/login'
})
// --- 退出登录逻辑 Start ---
const handleLogout = () => {
ElMessageBox.confirm(
'确定要退出系统吗?',
'提示',
{
confirmButtonText: '确定退出',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async () => {
// 1. 调用 Store 的 logout 清除状态
userStore.logout()
// 2. 提示消息
ElMessage({
type: 'success',
message: '已安全退出',
})
// 3. 强制跳转回登录页
await router.replace('/login')
})
.catch(() => {
// 取消操作
})
}
// --- 退出登录逻辑 End ---
</script>
<template>
<div>
<a href="https://vite.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
<div class="app-wrapper">
<header v-if="!isLoginPage" class="app-header">
<div class="logo-container">
<router-link to="/" class="home-link">
<img src="@/assets/iris.png" class="logo" alt="Logo" />
<span class="system-title">IRIS 库存管理系统</span>
</router-link>
</div>
<div class="header-right">
<div class="user-profile">
<el-avatar :size="32" :icon="UserFilled" class="user-avatar" />
<span class="user-name">{{ userStore.username || '管理员' }}</span>
</div>
<el-divider direction="vertical" />
<el-button
type="danger"
link
@click="handleLogout"
class="logout-btn"
>
<el-icon style="margin-right: 4px; font-size: 16px"><SwitchButton /></el-icon>
退出
</el-button>
</div>
</header>
<main class="app-content">
<router-view />
</main>
<footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本: 1.0 Beta (测试版)
</span>
</footer>
</div>
<HelloWorld msg="Vite + Vue" />
</template>
<style scoped>
<style>
.app-wrapper {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
background-color: #f5f7fa;
}
.app-header {
height: 60px;
background-color: #ffffff;
border-bottom: 1px solid #dcdfe6;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
flex-shrink: 0;
z-index: 1000;
}
.logo-container {
display: flex;
align-items: center;
height: 100%;
}
.home-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
cursor: pointer;
height: 100%;
user-select: none;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
height: 32px;
width: auto;
object-fit: contain;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
.system-title {
font-size: 18px;
font-weight: 600;
color: #303133;
letter-spacing: 0.5px;
white-space: nowrap;
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #42b883aa);
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.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>

View 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'
})
}

View File

@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getLabelPreview(data: any) {
return request({
url: '/common/print/preview',
method: 'post',
data
})
}
export function executePrint(data: any) {
return request({
url: '/common/print/execute',
method: 'post',
data
})
}

View File

@ -0,0 +1,20 @@
import request from '@/utils/request'
/**
* 上传文件通用接口
* @param file File 对象
*/
export function uploadFile(file: File) {
const formData = new FormData()
formData.append('file', file)
return request({
// ★★★ [修改] 去掉开头的 /api适配 request.ts 的 baseURL
url: '/v1/common/upload',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}

View File

@ -0,0 +1,63 @@
import request from '@/utils/request'
// 1. 获取列表
export function getBuyList(params: any) {
return request({
url: '/inbound/buy/list',
method: 'get',
params
})
}
// 2. 新增入库
export function createBuyInbound(data: any) {
return request({
url: '/inbound/buy/submit',
method: 'post',
data
})
}
// 3. 更新入库
export function updateBuyInbound(id: number, data: any) {
return request({
url: `/inbound/buy/${id}`,
method: 'put',
data
})
}
// 4. 删除入库
export function deleteBuyInbound(id: number) {
return request({
url: `/inbound/buy/${id}`,
method: 'delete'
})
}
// 5. 搜索基础物料
export function searchMaterialBase(keyword: string) {
return request({
url: '/inbound/buy/search-base',
method: 'get',
params: { keyword }
})
}
// 6. 文件上传 (用于图片/拍照)
export function uploadFile(data: FormData) {
return request({
url: '/common/upload', // 对应后端 /api/v1/common/upload
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' }
})
}
// 7. [新增] 文件删除
export function deleteFile(filename: string) {
return request({
url: `/common/files/${filename}`, // 对应后端 /api/v1/common/files/<filename>
method: 'delete'
})
}

View 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
})
}

View File

@ -0,0 +1,42 @@
import request from '@/utils/request'
// 注意 URL 已变为 /inbound/product/...
export function getProductList(params: any) {
return request({
url: '/inbound/product/list',
method: 'get',
params
})
}
export function createProductInbound(data: any) {
return request({
url: '/inbound/product/submit',
method: 'post',
data
})
}
export function updateProductInbound(id: number, data: any) {
return request({
url: `/inbound/product/${id}`,
method: 'put',
data
})
}
export function deleteProductInbound(id: number) {
return request({
url: `/inbound/product/${id}`,
method: 'delete'
})
}
export function searchMaterialBase(keyword: string) {
return request({
url: '/inbound/product/search-base',
method: 'get',
params: { keyword }
})
}

View File

@ -0,0 +1,45 @@
import request from '@/utils/request'
// 1. 获取列表
export function getSemiList(params: any) {
return request({
url: '/inbound/semi/list',
method: 'get',
params
})
}
// 2. 新增入库
export function createSemiInbound(data: any) {
return request({
url: '/inbound/semi/submit',
method: 'post',
data
})
}
// 3. 更新入库
export function updateSemiInbound(id: number, data: any) {
return request({
url: `/inbound/semi/${id}`,
method: 'put',
data
})
}
// 4. 删除入库
export function deleteSemiInbound(id: number) {
return request({
url: `/inbound/semi/${id}`,
method: 'delete'
})
}
// 5. 搜索基础物料
export function searchMaterialBase(keyword: string) {
return request({
url: '/inbound/semi/search-base',
method: 'get',
params: { keyword }
})
}

View File

@ -0,0 +1,31 @@
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
})
}

View File

@ -0,0 +1,45 @@
import request from '@/utils/request'
// 1. 获取基础信息列表
export function listMaterialBase(params: any) {
return request({
url: '/inbound/base/list',
method: 'get',
params
})
}
// 2. 新增基础信息
export function addMaterialBase(data: any) {
return request({
url: '/inbound/base/',
method: 'post',
data
})
}
// 3. 修改基础信息 (包含状态启用/禁用)
// 【修复点】: 必须在 URL 中拼接 data.id否则后端会报 405 Method Not Allowed
export function updateMaterialBase(data: any) {
return request({
url: `/inbound/base/${data.id}`,
method: 'put',
data
})
}
// 4. 删除基础信息
export function delMaterialBase(id: number) {
return request({
url: `/inbound/base/${id}`,
method: 'delete'
})
}
// 5. 获取详情 (可选,用于编辑回显)
export function getMaterialBase(id: number) {
return request({
url: `/inbound/base/${id}`,
method: 'get'
})
}

View 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
})
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@ -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

View File

@ -0,0 +1,228 @@
<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>
扫描成功3秒后继续...
</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 startScanning = async () => {
try {
// 1. 实例化
html5QrCode = new Html5Qrcode(scannerElementId, {
useBarCodeDetectorIfSupported: true,
formatsToSupport: [
Html5QrcodeSupportedFormats.CODE_128,
Html5QrcodeSupportedFormats.QR_CODE
],
verbose: false
})
// 2. 启动配置
const config = {
fps: 20,
qrbox: { width: 320, height: 60 },
disableFlip: false,
videoConstraints: {
facingMode: "environment",
width: { min: 1280, ideal: 1920, max: 3840 },
height: { min: 720, ideal: 1080, max: 2160 },
focusMode: "continuous",
advanced: [{ focusMode: "macro" }]
}
}
// 3. 启动
await html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText) => {
// ★ 核心修改:如果处于暂停冷却期,直接忽略后续扫描结果
if (isPaused.value) return
console.log(`Scan: ${decodedText}`)
// 1. 锁定状态
isPaused.value = true
// 2. 发送数据
emit('decode', decodedText)
// 3. 开启 3 秒倒计时解锁
setTimeout(() => {
isPaused.value = false
}, 3000)
},
(errorMessage) => {
// ignore
}
)
} catch (err: any) {
let msg = '无法启动摄像头'
const errStr = err.toString()
if (errStr.includes('Permission')) msg = '请允许摄像头权限'
else if (errStr.includes('Secure')) msg = '需要 HTTPS 或 localhost'
else if (errStr.includes('NotFound')) msg = '未检测到后置摄像头'
else if (errStr.includes('OverconstrainedError')) msg = '摄像头不支持高分辨率'
console.error("Scanner Error:", err)
errorMsg.value = msg
emit('error', msg)
}
}
const stopScanning = async () => {
if (html5QrCode) {
try {
if (html5QrCode.isScanning) {
await html5QrCode.stop()
}
html5QrCode.clear()
} catch (e) {
console.error("Stop failed", e)
}
}
}
onMounted(() => {
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: 12px;
}
.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;
border-radius: 12px;
}
.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: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 320px;
height: 60px;
border: 2px solid rgba(255, 0, 0, 0.6);
border-radius: 6px;
pointer-events: none;
box-shadow: 0 0 0 2000px rgba(0, 0, 0, 0.6);
z-index: 10;
display: flex;
justify-content: center;
align-items: center;
transition: all 0.3s;
}
/* ★ 扫描成功时的绿色框样式 */
.focus-tip.success {
border-color: #67c23a; /* 绿色边框 */
background: rgba(103, 194, 58, 0.1);
}
.scan-line {
width: 95%;
height: 2px;
background: #ff0000;
box-shadow: 0 0 4px #ff0000;
position: absolute;
animation: scan-move 1.5s infinite ease-in-out;
}
.scan-text {
position: absolute;
bottom: -35px;
color: rgba(255, 255, 255, 0.9);
font-size: 13px;
white-space: nowrap;
text-shadow: 0 1px 2px #000;
}
.scan-text-success {
color: #67c23a;
font-size: 16px;
font-weight: bold;
display: flex;
align-items: center;
gap: 5px;
text-shadow: 0 1px 3px rgba(0,0,0,0.8);
}
@keyframes scan-move {
0% { top: 10%; opacity: 0.5; }
50% { top: 90%; opacity: 1; }
100% { top: 10%; opacity: 0.5; }
}
</style>

View 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>

View 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>

View 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>

View 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: 210px; /* 固定侧边栏宽度 */
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: 20px; /* 给内部页面留出边距 */
box-sizing: border-box;
}
</style>

View File

@ -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')

View File

@ -0,0 +1,216 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
import { useUserStore } from '@/stores/user'
const routes: Array<RouteRecordRaw> = [
// 1. 登录页
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { hidden: true }
},
// 2. 首页 Dashboard
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '首页', icon: 'HomeFilled' }
}
]
},
// 3. 基础信息
{
path: '/material',
component: Layout,
redirect: '/material/index',
children: [
{
path: 'index',
name: 'MaterialBase',
component: () => import('@/views/material/list.vue'),
meta: { title: '基础信息', icon: 'Box' }
}
]
},
// 4. 库存管理 (入库)
{
path: '/inventory',
component: Layout,
meta: { title: '入库管理', icon: 'Shop' },
redirect: '/inventory/buy',
children: [
{
path: 'buy',
name: 'InventoryBuy',
component: () => import('@/views/stock/inbound/buy.vue'),
meta: { title: '采购件' }
},
{
path: 'semi',
name: 'InventorySemi',
component: () => import('@/views/stock/inbound/semi.vue'),
meta: { title: '半成品' }
},
{
path: 'product',
name: 'InventoryProduct',
component: () => import('@/views/stock/inbound/product.vue'),
meta: { title: '成品' }
},
// [原有] 入库记录整合
{
path: 'summary',
name: 'InventorySummary',
component: () => import('@/views/stock/inbound/inbound_summary.vue'),
meta: { title: '入库记录' }
},
{
path: 'service',
name: 'InventoryService',
component: () => import('@/views/stock/inbound/service.vue'),
meta: { title: '服务权益' }
},
// ★ [新增] 库存盘点页面 (查库/消除)
{
path: 'stocktake',
name: 'InventoryStocktake',
component: () => import('@/views/stock/stocktake/index.vue'),
meta: { title: '库存盘点' }
}
]
},
// 5. 出库管理
{
path: '/outbound',
component: Layout,
meta: { title: '出库管理', icon: 'Van' },
redirect: '/outbound/index',
children: [
// ★ [新增] 出库选单打印页面
{
path: 'selection',
name: 'OutboundSelection',
component: () => import('@/views/outbound/Selection.vue'),
meta: { title: '出库选单' }
},
{
path: 'create',
name: 'OutboundCreate',
component: () => import('@/views/outbound/create.vue'),
meta: { title: '扫码出库' }
},
{
path: 'index',
name: 'OutboundList',
component: () => import('@/views/outbound/index.vue'),
meta: { title: '出库记录' }
}
]
},
// 6. 业务操作
{
path: '/operation',
component: Layout,
meta: { title: '借库管理', icon: 'Operation' },
redirect: '/operation/borrow',
children: [
{
path: 'borrow',
name: 'OpBorrow',
component: () => import('@/views/transaction/borrow.vue'),
meta: { title: '借库' }
},
{
path: 'repair',
name: 'OpRepair',
component: () => import('@/views/transaction/return.vue'),
meta: { title: '返还' }
},
{
path: 'records',
name: 'OpRecords',
component: () => import('@/views/transaction/records.vue'),
meta: { title: '借还记录' }
}
]
},
// 7. 系统管理
{
path: '/system',
component: Layout,
meta: {
title: '系统管理',
icon: 'Setting',
roles: ['super_admin', 'supervisor']
},
children: [
{
path: 'user-create',
name: 'UserCreate',
component: () => import('@/views/system/UserCreate.vue'),
meta: { title: '账号开通', icon: 'User' }
}
]
},
// 404 路由
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard',
meta: { hidden: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// ==========================================
// 全局路由守卫
// ==========================================
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const token = userStore.token || localStorage.getItem('token')
const userRole = userStore.role || localStorage.getItem('role') || 'user'
if (to.path === '/login') {
if (token) {
next('/')
} else {
next()
}
return
}
if (!token) {
next({ path: '/login', replace: true })
return
}
if (to.meta.roles && Array.isArray(to.meta.roles)) {
if (to.meta.roles.includes(userRole)) {
next()
} else {
next('/dashboard')
}
} else {
next()
}
})
export default router

View File

View File

View File

@ -0,0 +1,83 @@
import { defineStore } from 'pinia'
import { login } from '@/api/auth'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
// 1. State: 初始化时优先从 localStorage 获取,防止刷新丢失
const token = ref(localStorage.getItem('token') || '')
const role = ref(localStorage.getItem('role') || '')
const username = ref(localStorage.getItem('username') || '')
// 2. Actions
// 登录逻辑
const handleLogin = async (loginForm: any) => {
try {
const res = await login(loginForm)
// [调试日志] 查看实际返回的数据结构
console.log('Login API Response:', res)
// ============================================================
// [关键修复] 兼容 Axios 拦截器的不同处理方式
// 如果拦截器已经返回了 response.data那么 res 本身就是数据对象
// ============================================================
const data = res.data || res
// 安全检查:确保 data 存在且包含 access_token
if (!data || !data.access_token) {
console.error('Login Error: 响应数据中缺少 access_token', data)
return false
}
// 更新 Pinia 状态 (内存)
token.value = data.access_token
// 处理用户信息 (确保后端返回结构中有 user 字段)
if (data.user) {
role.value = data.user.role || 'user' // 默认给个 user 角色防止空
username.value = data.user.username || '用户'
// 持久化存储用户信息
localStorage.setItem('role', role.value)
localStorage.setItem('username', username.value)
}
// 持久化存储 Token
localStorage.setItem('token', data.access_token)
return true // 返回 true 表示登录成功
} catch (error) {
console.error('Login failed:', error)
// 返回 false 表示登录失败Login 组件会据此停止跳转
return false
}
}
// 退出逻辑
const logout = () => {
// 1. 清空 Pinia 状态 (内存)
token.value = ''
role.value = ''
username.value = ''
// 2. 清空 LocalStorage (硬盘)
localStorage.removeItem('token')
localStorage.removeItem('role')
localStorage.removeItem('username')
}
// 3. Getters / Helpers
// 判断当前用户是否拥有某些角色
const hasRole = (roles: string[]) => {
return roles.includes(role.value)
}
return {
token,
role,
username,
handleLogin,
logout,
hasRole
}
})

View File

@ -1,18 +1,31 @@
/* inventory-web/src/style.css */
/* 1. 保留原有的字体定义,确保文字清晰好看 */
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
/* 颜色方案配置 */
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
/* 字体渲染优化 */
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* 2. 针对亮色模式的颜色适配 (保留) */
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
}
/* 3. 链接的基本样式 (保留,但通常 RouterLink 会覆盖) */
a {
font-weight: 500;
color: #646cff;
@ -22,58 +35,44 @@ a:hover {
color: #535bf2;
}
body {
/* -------------------------------------------------
【重要修改区域】
下面的代码是为了修复“无法铺满全屏”的问题
-------------------------------------------------
*/
/* 4. 全局盒模型修复:防止 padding 撑大元素 */
*, *::before, *::after {
box-sizing: border-box;
}
/* 5. 重置 body 和 html */
html, body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
padding: 0;
width: 100%;
height: 100%; /* 强制高度占满 */
/* !!! 删除了原有的 display: flex; place-items: center;
这是导致你页面缩在中间的罪魁祸首
*/
display: block;
overflow: hidden; /* 防止最外层出现双滚动条 */
}
/* 6. 重置 #app 挂载点 */
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
/* !!! 删除了 max-width: 1280px; padding: 2rem; text-align: center;
这是导致你页面两边留白、无法全屏的原因
*/
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
/* 注意:原文件中关于 button, .card 的样式已被删除,
因为你的项目中引入了 Element Plus
保留原生 button 样式会和 Element Plus 组件产生冲突。
*/

View File

View File

@ -0,0 +1,81 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user' // 引入 Store 获取 Token
// 1. 创建 axios 实例
const service = axios.create({
// 【关键修改】
// 设置为 '/api',请求会自动拼接成 http://localhost:5173/api/...
// 然后被 Vite 代理转发到 http://127.0.0.1:8000/api/...
baseURL: '/api',
timeout: 5000
})
// 2. 请求拦截器
service.interceptors.request.use(
(config) => {
// 在发送请求之前做些什么
// 注意:这里需要确保 Pinia 已经初始化,但在拦截器运行时组件早已加载,通常没问题
// 为了安全起见,也可以直接读 localStorage或者在函数内调用 store
const token = localStorage.getItem('token')
if (token && config.headers) {
// Flask-JWT-Extended 默认需要 'Bearer <token>' 格式
config.headers['Authorization'] = 'Bearer ' + token
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 3. 响应拦截器
service.interceptors.response.use(
(response) => {
// Axios 默认包了一层 data所以这里取 response.data
const res = response.data
// 如果后端返回的是标准 Flask jsonify 结果,通常没有 code 字段(除非你自己封装了)
// 如果你使用了标准 HTTP 状态码200, 201等Axios 会直接进入这里
// 只有当业务逻辑明确返回错误码时才报错 (根据你的后端封装调整)
if (res.code && res.code !== 200) {
ElMessage.error(res.msg || 'Error')
return Promise.reject(new Error(res.msg || 'Error'))
} else {
return res // 返回解包后的数据
}
},
(error) => {
console.log('err: ' + error) // for debug
let message = error.message || '请求失败'
// 处理 HTTP 状态码错误
if (error.response) {
const status = error.response.status
const data = error.response.data
if (status === 401) {
message = '登录已过期,请重新登录'
// 这里可以触发登出逻辑
localStorage.clear()
window.location.href = '/login'
} else if (status === 403) {
message = '权限不足'
} else if (status === 404) {
message = '请求的资源不存在'
} else if (status === 500) {
message = '服务器内部错误'
} else if (data && data.msg) {
// 优先显示后端返回的错误信息
message = data.msg
}
}
ElMessage.error(message)
return Promise.reject(error)
}
)
export default service

View File

View File

@ -0,0 +1,112 @@
<template>
<div class="dashboard-container">
<el-card class="welcome-card">
<template #header>
<div class="card-header">
<span class="title">👋 欢迎回来{{ userStore.username }}</span>
<el-tag type="success">系统运行正常</el-tag>
</div>
</template>
<div class="card-body">
<h2>IRIS 库存管理系统</h2>
<p class="subtitle">请选择您要进行的业务操作</p>
<div class="action-buttons">
<el-button type="primary" size="large" @click="handleNav('/inventory/buy')">
<el-icon style="margin-right: 5px"><ShoppingCart /></el-icon>
采购入库
</el-button>
<el-button type="success" size="large" @click="handleNav('/material/index')">
<el-icon style="margin-right: 5px"><Box /></el-icon>
基础信息
</el-button>
<el-button type="warning" size="large" @click="handleNav('/operation/borrow')">
<el-icon style="margin-right: 5px"><Operation /></el-icon>
借库申请
</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
// 1. 引入 User Store
import { useUserStore } from '@/stores/user'
// 引入需要的图标
import { Box, TrendCharts, ShoppingCart, Operation } from '@element-plus/icons-vue'
const router = useRouter()
// 2. 实例化 store
const userStore = useUserStore()
// 统一跳转函数
const handleNav = (path: string) => {
router.push(path)
}
</script>
<style scoped>
.dashboard-container {
/* 使用 100% 宽度和高度,利用 Flex 居中显示 */
height: calc(100vh - 84px); /* 减去顶部导航栏的高度,防止出现双滚动条 */
display: flex;
justify-content: center;
align-items: center;
background-color: #f0f2f5; /* 给背景加个淡灰色,突出卡片 */
}
.welcome-card {
width: 800px; /*稍微加宽一点 */
text-align: center;
border-radius: 8px; /* 圆角更好看 */
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-size: 18px;
font-weight: bold;
color: #303133;
}
.card-body {
padding: 20px 0;
}
.card-body h2 {
font-size: 28px;
color: #409EFF; /* 使用主题蓝 */
margin-bottom: 10px;
}
.subtitle {
color: #909399;
margin-bottom: 40px;
font-size: 14px;
}
.action-buttons {
display: flex;
justify-content: center;
gap: 20px;
flex-wrap: wrap; /* 防止屏幕过窄时按钮挤压 */
}
/* 给按钮加一点悬浮效果 */
.el-button {
transition: all 0.3s;
}
.el-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>

View File

@ -0,0 +1,133 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<h2>用户登录</h2>
</div>
</template>
<el-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
size="large"
@submit.prevent
>
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入用户名"
:prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
:prefix-icon="Lock"
show-password
@keydown.enter.prevent="onLogin"
/>
</el-form-item>
<el-button
type="primary"
native-type="button"
:loading="loading"
class="w-100"
@click="onLogin"
>
立即登录
</el-button>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessageBox } from 'element-plus' // 引入 ElMessageBox
import { User, Lock } from '@element-plus/icons-vue'
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const loginFormRef = ref()
const loginForm = reactive({ username: '', password: '' })
const loginRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const onLogin = async () => {
if (!loginFormRef.value) return
await loginFormRef.value.validate(async (valid: boolean) => {
if (valid) {
loading.value = true
try {
// 执行登录请求
const success = await userStore.handleLogin(loginForm)
if (success) {
// 成功:跳转
router.push('/dashboard')
} else {
// 失败(业务逻辑拒绝,如账号密码错):弹出模态框
showLoginFailAlert('用户名或密码错误')
}
} catch (error: any) {
// 失败(系统错误,如网络断开/500报错弹出模态框
// 优先取后端的报错信息,没有则显示默认
const msg = error.response?.data?.msg || error.message || '登录遇到未知错误'
showLoginFailAlert(msg)
} finally {
// 停止转圈,让用户可以看清弹窗
loading.value = false
}
}
})
}
// 封装错误弹窗
const showLoginFailAlert = (msg: string) => {
ElMessageBox.alert(msg, '登录失败', {
confirmButtonText: '确定',
type: 'error',
callback: () => {
// 点击确定后,清空密码框,让用户重试
// 页面绝对不会刷新,光标还在
loginForm.password = ''
}
})
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #2d3a4b;
}
.login-card {
width: 400px;
}
.card-header h2 {
text-align: center;
margin: 0;
color: #333;
}
.w-100 {
width: 100%;
}
</style>

View File

@ -0,0 +1,569 @@
<template>
<div class="app-container">
<el-card shadow="never">
<div class="filter-wrapper">
<div class="filter-container">
<el-input
v-model="queryParams.keyword"
placeholder="请输入名称、俗名或规格"
style="width: 240px; margin-right: 10px;"
clearable
@input="handleInputSearch"
/>
<el-select
v-model="queryParams.category"
placeholder="类别"
clearable
filterable
allow-create
default-first-option
style="width: 140px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.type"
placeholder="类型"
clearable
filterable
allow-create
default-first-option
style="width: 140px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-select
v-model="queryParams.isEnabled"
placeholder="状态"
clearable
style="width: 100px; margin-right: 10px;"
@change="handleQuery"
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" plain @click="handleQuery">搜索</el-button>
<el-button plain @click="resetQuery">重置</el-button>
</div>
<div class="right-toolbar">
<el-button type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button>
<el-tooltip content="刷新" placement="top">
<el-button circle :icon="Refresh" @click="getList" />
</el-tooltip>
<el-dropdown trigger="click" @command="handleSizeChange">
<el-button circle :icon="Rank" style="margin-left: 8px" title="表格密度" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="large">宽松 (默认)</el-dropdown-item>
<el-dropdown-item command="default">中等</el-dropdown-item>
<el-dropdown-item command="small">紧凑</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-popover placement="bottom" :width="150" trigger="click">
<template #reference>
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template>
<div class="column-setting-list">
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置
</div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.commonName.visible" label="俗名" />
<el-checkbox v-model="columns.category.visible" label="类别" />
<el-checkbox v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.visibilityLevel.visible" label="可见等级" />
<el-checkbox v-model="columns.files.visible" label="资料" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
</div>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
:size="tableSize"
style="width: 100%; margin-top: 15px"
>
<el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.name.visible" prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="columns.commonName.visible" prop="commonName" label="俗名" min-width="140" show-overflow-tooltip>
<template #default="scope">
<span v-if="scope.row.commonName">{{ scope.row.commonName }}</span>
<span v-else style="color: #ccc;">-</span>
</template>
</el-table-column>
<el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">{{ scope.row.category || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">{{ scope.row.type || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip />
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" />
<el-table-column v-if="columns.visibilityLevel.visible" prop="visibilityLevel" label="可见等级" min-width="100" align="center">
<template #default="scope">L{{ scope.row.visibilityLevel }}</template>
</el-table-column>
<el-table-column v-if="columns.files.visible" label="资料" min-width="100" align="center">
<template #default="scope">
<el-button v-if="scope.row.generalImage" link type="primary" :icon="Picture" title="查看图片" @click="openLink(scope.row.generalImage)" />
<el-button v-if="scope.row.generalManual" link type="primary" :icon="Document" title="查看说明书" @click="openLink(scope.row.generalManual)" />
</template>
</el-table-column>
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
<template #default="scope">
<el-switch
v-model="scope.row.isEnabled"
:active-value="1"
:inactive-value="0"
:loading="scope.row.statusLoading"
@change="handleStatusChange(scope.row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" min-width="150" fixed="right" align="center">
<template #default="scope">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; text-align: right;">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="total"
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
@size-change="getList"
@current-change="getList"
/>
</div>
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="600px"
append-to-body
@close="cancel"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-row>
<el-col :span="12">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="内部名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="俗名" prop="commonName">
<el-input v-model="form.commonName" placeholder="标准名称" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="类别" prop="category">
<el-autocomplete
v-model="form.category"
:fetch-suggestions="querySearchCategory"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="类型" prop="type">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" placeholder="如: 个, 台, 米" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="可见等级" prop="visibilityLevel">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0为最低9为最高)</span>
</el-form-item>
<el-form-item label="说明书链接" prop="generalManual">
<el-input v-model="form.generalManual" placeholder="请输入说明书URL链接" />
</el-form-item>
<el-form-item label="产品图链接" prop="generalImage">
<el-input v-model="form.generalImage" placeholder="请输入图片URL链接" />
</el-form-item>
<el-form-item label="状态" prop="isEnabled">
<el-radio-group v-model="form.isEnabled">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel"> </el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading"> </el-button>
</div>
</template>
</el-dialog>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Picture, Document, Refresh, Setting, Rank } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
import {
listMaterialBase,
addMaterialBase,
updateMaterialBase,
delMaterialBase
} from '@/api/material_base';
// --- 类型定义 ---
interface MaterialBaseVO {
id: number;
name: string;
commonName?: string; // ✅ 新增类型定义
category: string;
type: string;
spec: string;
unit: string;
visibilityLevel: number;
generalManual?: string;
generalImage?: string;
isEnabled: number;
statusLoading?: boolean;
}
interface QueryParams {
pageNum: number;
pageSize: number;
keyword: string;
category: string;
type: string;
isEnabled?: number;
}
// --- 响应式数据 ---
const loading = ref(false);
const total = ref(0);
const tableData = ref<MaterialBaseVO[]>([]);
const submitLoading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
const columns = reactive({
id: { visible: true },
name: { visible: true },
commonName: { visible: true }, // ✅ 新增列控制
category: { visible: true },
type: { visible: true },
spec: { visible: true },
unit: { visible: true },
visibilityLevel: { visible: true },
files: { visible: true },
isEnabled: { visible: true }
});
const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const queryParams = reactive<QueryParams>({
pageNum: 1,
pageSize: 10,
keyword: '',
category: '',
type: '',
isEnabled: undefined
});
// --- 弹窗与表单相关 ---
const dialog = reactive({
visible: false,
title: ''
});
const formRef = ref<FormInstance>();
const initForm = {
id: undefined,
name: '',
commonName: '', // ✅ 初始化新增字段
category: '',
type: '',
spec: '',
unit: '',
visibilityLevel: 0,
generalManual: '',
generalImage: '',
isEnabled: 1
};
const form = ref({...initForm});
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
category: [{ required: true, message: '请选择或输入类别', trigger: 'change' }],
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
});
// --- 业务逻辑方法 ---
const extractDynamicOptions = (items: MaterialBaseVO[]) => {
if (!items || items.length === 0) return;
const newCategories = new Set(categoryOptions.value);
const newTypes = new Set(typeOptions.value);
items.forEach(item => {
if (item.category) newCategories.add(item.category);
if (item.type) newTypes.add(item.type);
});
categoryOptions.value = Array.from(newCategories);
typeOptions.value = Array.from(newTypes);
};
const querySearchCategory = (queryString: string, cb: any) => {
const results = queryString
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: categoryOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const querySearchType = (queryString: string, cb: any) => {
const results = queryString
? typeOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: typeOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const getList = () => {
loading.value = true;
listMaterialBase(queryParams)
.then((response: any) => {
if (response && response.data) {
tableData.value = response.data.items;
total.value = response.data.total;
extractDynamicOptions(tableData.value);
} else {
tableData.value = [];
total.value = 0;
}
})
.catch((err) => {
console.error(err);
tableData.value = [];
})
.finally(() => {
loading.value = false;
});
};
let searchTimer: ReturnType<typeof setTimeout> | null = null;
const handleInputSearch = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
queryParams.pageNum = 1;
getList();
}, 500);
};
const handleQuery = () => {
queryParams.pageNum = 1;
getList();
};
const resetQuery = () => {
queryParams.keyword = '';
queryParams.category = '';
queryParams.type = '';
queryParams.isEnabled = undefined;
handleQuery();
};
const handleSizeChange = (command: 'large' | 'default' | 'small') => {
tableSize.value = command;
};
const handleAdd = () => {
resetForm();
dialog.title = '新增基础信息';
dialog.visible = true;
};
const handleEdit = (row: MaterialBaseVO) => {
resetForm();
dialog.title = '编辑基础信息';
dialog.visible = true;
nextTick(() => {
Object.assign(form.value, row);
});
};
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name });
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name)) {
ElMessage.error(`添加失败:已存在名称为 "${name}" 的基础信息!`);
return true;
}
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec });
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec)) {
ElMessage.error(`添加失败:已存在规格/编号为 "${spec}" 的基础信息!`);
return true;
}
} catch (e) {
return false;
}
return false;
};
const submitForm = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
if (!form.value.id) {
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) {
submitLoading.value = false;
return;
}
}
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
const actionText = form.value.id ? '修改' : '新增';
await requestApi(form.value);
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
getList();
} catch (error) {
console.error(error);
} finally {
submitLoading.value = false;
}
}
});
};
const cancel = () => {
dialog.visible = false;
resetForm();
};
const resetForm = () => {
form.value = {...initForm};
if (formRef.value) formRef.value.resetFields();
};
const handleStatusChange = (row: MaterialBaseVO) => {
row.statusLoading = true;
const text = row.isEnabled === 1 ? "启用" : "停用";
const updateData = { id: row.id, isEnabled: row.isEnabled };
updateMaterialBase(updateData)
.then(() => ElMessage.success(`${text} "${row.name}"`))
.catch(() => { row.isEnabled = row.isEnabled === 1 ? 0 : 1; })
.finally(() => { row.statusLoading = false; });
};
const handleDelete = (row: MaterialBaseVO) => {
ElMessageBox.confirm(
`是否确认删除名称为 "${row.name}" 的数据项?`,
"警告",
{ confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
).then(() => {
delMaterialBase(row.id).then(() => {
ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
getList();
});
}).catch(() => {});
};
const openLink = (url: string) => {
if (!url) return;
window.open(url, '_blank');
}
onMounted(() => {
getList();
});
</script>
<style scoped>
.app-container {
padding: 20px;
}
.filter-wrapper {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-container {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.right-toolbar {
display: flex;
align-items: center;
}
.column-setting-list {
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,302 @@
<template>
<div class="app-container">
<el-card shadow="always">
<template #header>
<div class="card-header">
<div class="header-left">
<span class="title">出库拣货选单</span>
<span class="subtitle">(打印目标: 192.168.9.205)</span>
</div>
<div>
<el-button type="success" :icon="Printer" :disabled="selectedItems.length === 0" @click="handlePreview">
生成并预览出库单
</el-button>
</div>
</div>
</template>
<div class="filter-container">
<el-row :gutter="20">
<el-col :span="12">
<el-input
v-model="searchKeyword"
placeholder="请输入物料名称 或 规格型号 进行搜索"
class="search-input"
clearable
:prefix-icon="Search"
/>
</el-col>
<el-col :span="12" style="text-align: right;">
<el-button type="primary" plain :icon="Upload" @click="handleImportBom">
导入 BOM
</el-button>
<el-button type="primary" plain :icon="Plus" @click="handleCreateBom">
创建 BOM
</el-button>
</el-col>
</el-row>
</div>
<el-alert
v-if="selectedItems.length > 0"
:title="`当前已选中 ${selectedItems.length} 项物品`"
type="success"
show-icon
style="margin-bottom: 15px"
:closable="false"
/>
<el-table
v-loading="loading"
:data="filteredTableData"
style="width: 100%"
@selection-change="handleSelectionChange"
row-key="uuid"
border
height="600"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTypeTag(row.type)">{{ row.typeLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-html="highlightKeyword(row.name)"></span>
</template>
</el-table-column>
<el-table-column prop="standard" label="规格型号" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-html="highlightKeyword(row.standard)"></span>
</template>
</el-table-column>
<el-table-column prop="batch_no" label="批号" width="120" />
<el-table-column prop="uuid" label="条码UUID" width="280" show-overflow-tooltip />
<el-table-column prop="create_time" label="入库时间" width="170" />
</el-table>
</el-card>
<el-dialog
v-model="previewVisible"
title="出库单打印预览"
width="800px"
destroy-on-close
>
<div class="print-preview-content">
<el-alert title="请核对以下清单,确认无误后点击下方【确认打印】按钮" type="warning" :closable="false" style="margin-bottom: 10px;" />
<el-table :data="selectedItems" border size="small" style="width: 100%">
<el-table-column prop="typeLabel" label="类型" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column prop="standard" label="规格" />
<el-table-column prop="batch_no" label="批号" width="100" />
<el-table-column prop="uuid" label="条码UUID" show-overflow-tooltip />
</el-table>
<div class="summary-info" style="margin-top: 20px; text-align: right; font-weight: bold;">
总计出库数量: <span style="color: red; font-size: 18px;">{{ selectedItems.length }}</span>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="previewVisible = false">取消</el-button>
<el-button type="primary" :loading="printLoading" @click="confirmPrint">
确认打印
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Printer, Search, Upload, Plus } from '@element-plus/icons-vue'
import { getAllStock, printSelectionList } from '@/api/inbound/stock'
import { ElMessage, ElMessageBox } from 'element-plus'
// --- 类型定义 ---
interface BaseStockItem {
id: number | string;
standard: string;
batch_no: string;
uuid: string;
create_time: string;
// 原始数据中可能存在的字段
material_name?: string;
product_name?: string;
}
// 统一后的显示对象
interface DisplayItem extends BaseStockItem {
name: string; // 统一后的名称
type: 'material' | 'semi' | 'product'; // 类型标识
typeLabel: string; // 类型中文名
}
// --- 状态变量 ---
const loading = ref(false)
const printLoading = ref(false)
const searchKeyword = ref('') // 搜索关键词
const previewVisible = ref(false) // 预览弹窗控制
// 原始扁平化数据
const allStockData = ref<DisplayItem[]>([])
// 当前选中的行
const selectedItems = ref<DisplayItem[]>([])
// --- 计算属性:前端模糊搜索过滤 ---
const filteredTableData = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
if (!keyword) {
return allStockData.value
}
return allStockData.value.filter(item => {
const nameMatch = item.name && item.name.toLowerCase().includes(keyword)
const stdMatch = item.standard && item.standard.toLowerCase().includes(keyword)
// 也可以加上UUID搜索
const uuidMatch = item.uuid && item.uuid.toLowerCase().includes(keyword)
return nameMatch || stdMatch || uuidMatch
})
})
// --- 方法 ---
// 1. 获取并处理数据
const fetchData = async () => {
loading.value = true
try {
const res: any = await getAllStock()
// 假设 res 结构为 { materials: [], semis: [], products: [] }
const materials = (res.materials || []).map((item: any) => ({
...item,
name: item.material_name,
type: 'material',
typeLabel: '采购件'
}))
const semis = (res.semis || []).map((item: any) => ({
...item,
name: item.material_name || item.product_name, // 半成品字段名不确定,做个兼容
type: 'semi',
typeLabel: '半成品'
}))
const products = (res.products || []).map((item: any) => ({
...item,
name: item.product_name,
type: 'product',
typeLabel: '成品'
}))
// 合并所有数据
allStockData.value = [...materials, ...semis, ...products]
} catch (error) {
console.error(error)
ElMessage.error('无法获取库存数据')
} finally {
loading.value = false
}
}
// 2. 表格选择
const handleSelectionChange = (val: DisplayItem[]) => {
selectedItems.value = val
}
// 3. 点击“生成并预览”
const handlePreview = () => {
if (selectedItems.value.length === 0) {
ElMessage.warning('请先勾选需要出库的物品')
return
}
previewVisible.value = true
}
// 4. 确认打印 (在弹窗中触发)
const confirmPrint = async () => {
printLoading.value = true
try {
// 这里调用真实的打印接口
await printSelectionList(selectedItems.value)
ElMessage.success('指令已发送,请前往打印机(192.168.9.205)取单')
previewVisible.value = false // 关闭弹窗
// 可选:打印后是否清空选中?
// selectedItems.value = []
// 注意el-table 需要调用 clearSelection 方法来清空UI选中状态
} catch (err) {
ElMessage.error('打印请求失败')
} finally {
printLoading.value = false
}
}
// 5. BOM 操作占位函数
const handleImportBom = () => {
// TODO: 打开上传文件的 Dialog 或者跳转页面
ElMessage.info('点击了导入BOM请实现具体逻辑')
}
const handleCreateBom = () => {
// TODO: 打开新建 BOM 的表单
ElMessage.info('点击了创建BOM请实现具体逻辑')
}
// 辅助函数:高亮关键词 (可选)
const highlightKeyword = (text: string) => {
if (!searchKeyword.value || !text) return text
const reg = new RegExp(searchKeyword.value, 'gi')
return text.replace(reg, (match) => `<span style="color: red; font-weight: bold;">${match}</span>`)
}
// 辅助函数:标签颜色
const getTypeTag = (type: string) => {
switch (type) {
case 'material': return 'info'
case 'semi': return 'warning'
case 'product': return 'success'
default: return ''
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left .title {
font-size: 18px;
font-weight: bold;
margin-right: 10px;
}
.header-left .subtitle {
font-size: 12px;
color: #909399;
}
.filter-container {
margin-bottom: 20px;
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
}
.search-input {
width: 100%;
max-width: 400px;
}
</style>

View File

@ -0,0 +1,563 @@
<template>
<div class="app-container mobile-optimized">
<el-card class="box-card" shadow="never">
<template #header>
<div class="card-header">
<div class="title-box">
<span>批量出库作业</span>
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
已选 {{ cartItems.length }}
</el-tag>
</div>
<div class="header-price" v-if="totalAmount > 0">
¥{{ totalAmount.toFixed(2) }}
</div>
</div>
</template>
<div class="scan-section">
<div v-if="showCamera" class="camera-wrapper">
<QrScanner @decode="onScanSuccess" />
<div class="scan-overlay">
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
关闭摄像头
</el-button>
</div>
</div>
<div v-else class="camera-placeholder" @click="showCamera = true">
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
<span class="text">点击开启扫码</span>
</div>
<div class="input-box">
<el-input
v-model="barcodeInput"
placeholder="扫描或输入条码回车"
@keyup.enter="handleManualInput"
clearable
ref="barcodeRef"
size="large"
>
<template #prefix>
<el-icon><Scissor /></el-icon>
</template>
<template #append>
<el-button @click="handleManualInput">添加</el-button>
</template>
</el-input>
</div>
</div>
<div class="cart-section">
<div v-if="cartItems.length > 0">
<el-table :data="cartItems" border stripe style="width: 100%">
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
<el-table-column label="库存" width="70" align="center">
<template #default="{row}">
<el-tag :type="row.available_quantity > 0 ? 'success' : 'danger'" size="small">
{{ parseFloat(row.available_quantity) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="出库数" width="130" align="center">
<template #default="{row}">
<el-input-number
v-model="row.out_quantity"
:min="1"
:max="parseFloat(row.available_quantity)"
size="small"
style="width: 100px"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" fixed="right">
<template #default="{$index}">
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else description="暂无商品,请扫码添加" :image-size="80" />
</div>
<div v-if="cartItems.length > 0" class="form-section">
<el-divider content-position="left">出库单据信息</el-divider>
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
<el-row :gutter="15">
<el-col :span="24" :md="8">
<el-form-item label="出库类型" prop="outbound_type">
<el-select v-model="form.outbound_type" placeholder="请选择类型" style="width: 100%">
<el-option label="销售出库" value="SALES" />
<el-option label="内部领用" value="USE" />
<el-option label="调拨出库" value="TRANSFER" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24" :md="8">
<el-form-item label="领用人/客户" prop="consumer_name">
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="24" :md="8">
<el-form-item label="经办人 (库管)" prop="operator_name">
<el-select
v-model="form.operator_name"
filterable
allow-create
default-first-option
placeholder="选择或输入"
style="width: 100%"
>
<el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注说明" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="可选填" />
</el-form-item>
<el-form-item label="电子签名确认" required>
<div class="signature-box" @click="openSignatureDialog">
<div v-if="signaturePreviewUrl" class="signed-img">
<img :src="signaturePreviewUrl" alt="签名" />
<span class="re-sign-tip">点击重签</span>
</div>
<div v-else class="unsigned-placeholder">
<el-icon :size="24"><EditPen /></el-icon>
<span>点击此处进行全屏签名</span>
</div>
</div>
</el-form-item>
<div class="bottom-actions">
<el-button @click="clearAll" icon="Refresh">清空</el-button>
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
确认出库
</el-button>
</div>
</el-form>
</div>
</el-card>
<el-dialog
v-model="showSignatureDialog"
fullscreen
destroy-on-close
:show-close="false"
class="fullscreen-signature-dialog"
@opened="initCanvas"
>
<div class="signature-wrapper">
<div class="signature-canvas-container" ref="canvasContainerRef">
<canvas
ref="nativeCanvasRef"
class="native-canvas"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="stopDrawing"
@mouseleave="stopDrawing"
@touchstart="startDrawing"
@touchmove="draw"
@touchend="stopDrawing"
></canvas>
<div class="canvas-tip">请在此区域横屏书写</div>
</div>
<div class="signature-sidebar">
<div class="sidebar-title">电子签名</div>
<div class="sidebar-actions">
<el-button type="warning" @click="clearCanvas">重写</el-button>
<el-button @click="handleSignCancel">取消</el-button>
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
// --- 状态定义 ---
const barcodeInput = ref('')
const cartItems = ref<any[]>([])
const loading = ref(false)
const showCamera = ref(false) // ★ 核心修改:默认改为 false
const barcodeRef = ref()
const formRef = ref()
const userStore = useUserStore()
// 签名相关
const showSignatureDialog = ref(false)
const signaturePreviewUrl = ref('')
const signatureFile = ref<File | null>(null)
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
const canvasContainerRef = ref<HTMLElement | null>(null)
const ctx = ref<CanvasRenderingContext2D | null>(null)
const isDrawing = ref(false)
const lastX = ref(0)
const lastY = ref(0)
const operatorOptions = ref<string[]>([])
const form = reactive({
outbound_type: 'SALES',
consumer_name: '',
operator_name: '',
remark: ''
})
const rules = {
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }]
}
// 计算总金额
const totalAmount = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
})
// --- 初始化 ---
onMounted(() => {
if (userStore.username) {
form.operator_name = userStore.username
operatorOptions.value.push(userStore.username)
}
loadHistoryOperators()
})
const loadHistoryOperators = async () => {
try {
const res = await getOutboundList({ page: 1, limit: 50 })
if (res.data && res.data.items) {
const names = new Set<string>()
if (userStore.username) names.add(userStore.username)
res.data.items.forEach((group: any) => {
if (group.operator_name) names.add(group.operator_name)
})
operatorOptions.value = Array.from(names)
}
} catch (e) {
console.error('加载历史操作员失败', e)
}
}
// --- 核心扫码逻辑 (适配 QrScanner 组件) ---
const onScanSuccess = (code: string) => {
if (!code) return
const trimCode = code.trim()
// ★★★ 核心修改:防误触校验 ★★★
// 1. 正则校验:只允许 数字、字母、横杠、点
// 这样可以屏蔽掉条码解析错误产生的 { } $ # 等乱码
const validPattern = /^[A-Za-z0-9\-\.]+$/
if (!validPattern.test(trimCode)) {
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
return
}
// 2. 长度校验:避免误扫到环境中的短数字
if (trimCode.length < 3) {
ElMessage.warning('扫描结果过短,请对准重试')
return
}
// 防抖:防止同一条码连续触发
if (loading.value) return
barcodeInput.value = trimCode
handleManualInput() // 复用手动输入逻辑
}
const handleManualInput = async () => {
const code = barcodeInput.value.trim()
if (!code) return
try {
loading.value = true
// 1. 检查购物车重复
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
if (existIndex > -1) {
const item = cartItems.value[existIndex]
const maxQty = parseFloat(item.available_quantity)
if (item.out_quantity < maxQty) {
item.out_quantity++
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
if (navigator.vibrate) navigator.vibrate(50)
} else {
ElMessage.warning(`库存不足 (余: ${maxQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
}
barcodeInput.value = ''
return
}
// 2. 调用 API 查询
const res = await getStockByBarcode(code)
if (res.data) {
const item = res.data
const availQty = parseFloat(item.available_quantity || 0)
if (availQty <= 0) {
ElMessage.warning(`库存不足或已出库 (余: ${availQty})`)
if (navigator.vibrate) navigator.vibrate([100, 50, 100])
} else {
// 加入购物车
cartItems.value.push({
...item,
out_quantity: 1,
price: parseFloat(item.price || 0)
})
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
}
barcodeInput.value = ''
}
} catch (error: any) {
if (error.response && error.response.status === 404) {
ElMessage.error(`未找到条码: ${code}`)
} else {
ElMessage.error('查询出错')
}
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
} finally {
loading.value = false
// 聚焦输入框,方便连续扫
nextTick(() => { barcodeRef.value?.focus() })
}
}
const removeFromCart = (index: number) => {
cartItems.value.splice(index, 1)
}
const clearAll = () => {
ElMessageBox.confirm('确定清空所有已选商品吗?', '提示', { type: 'warning' })
.then(() => {
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
})
}
// --- 提交逻辑 ---
const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加商品')
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
if (!signatureFile.value) {
ElMessage.error('请进行电子签名')
return
}
try {
loading.value = true
// 上传签名
const uploadRes = await uploadFile(signatureFile.value)
const signatureUrl = uploadRes.data.url
const itemsPayload = cartItems.value.map(item => ({
stock_id: item.id,
source_table: item.source_table,
sku: item.sku,
barcode: item.barcode,
quantity: item.out_quantity,
price: item.price
}))
await submitOutbound({
items: itemsPayload,
outbound_type: form.outbound_type,
consumer_name: form.consumer_name,
operator_name: form.operator_name,
remark: form.remark,
signature_path: signatureUrl
})
ElMessage.success('出库成功')
// 重置
cartItems.value = []
form.consumer_name = ''
form.remark = ''
signatureFile.value = null
signaturePreviewUrl.value = ''
loadHistoryOperators()
} catch (error) {
console.error(error)
ElMessage.error('提交失败')
} finally {
loading.value = false
}
})
}
// --- 签名逻辑 (Canvas) ---
const openSignatureDialog = () => { showSignatureDialog.value = true }
const initCanvas = async () => {
await nextTick()
const canvas = nativeCanvasRef.value
const container = canvasContainerRef.value
if (canvas && container) {
canvas.width = container.clientWidth
canvas.height = container.clientHeight
ctx.value = canvas.getContext('2d')
if (ctx.value) {
ctx.value.lineWidth = 4
ctx.value.lineCap = 'round'
ctx.value.lineJoin = 'round'
ctx.value.strokeStyle = '#000000'
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
}
}
}
const getPos = (e: MouseEvent | TouchEvent) => {
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
const rect = nativeCanvasRef.value.getBoundingClientRect()
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
return { x: clientX - rect.left, y: clientY - rect.top }
}
const startDrawing = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
isDrawing.value = true
const { x, y } = getPos(e)
lastX.value = x; lastY.value = y
ctx.value?.beginPath()
ctx.value?.moveTo(x, y)
}
const draw = (e: MouseEvent | TouchEvent) => {
e.preventDefault()
if (!isDrawing.value || !ctx.value) return
const { x, y } = getPos(e)
ctx.value.lineTo(x, y)
ctx.value.stroke()
}
const stopDrawing = () => { isDrawing.value = false }
const clearCanvas = () => {
if (!ctx.value || !nativeCanvasRef.value) return
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
ctx.value.fillStyle = '#ffffff'
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
}
const handleSignConfirm = () => {
nativeCanvasRef.value?.toBlob((blob) => {
if (blob) {
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
signatureFile.value = file
signaturePreviewUrl.value = URL.createObjectURL(file)
showSignatureDialog.value = false
}
}, 'image/png')
}
const handleSignCancel = () => { showSignatureDialog.value = false }
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
})
</script>
<style scoped>
.app-container.mobile-optimized {
padding: 10px; max-width: 600px; margin: 0 auto;
}
/* 头部 */
.card-header { display: flex; justify-content: space-between; align-items: center; }
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
.header-price { font-size: 18px; color: #F56C6C; font-weight: bold; }
/* 扫码区 */
.scan-section { margin-bottom: 20px; }
.camera-wrapper {
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
}
.scan-overlay {
position: absolute; bottom: 10px; right: 10px; z-index: 10;
}
.camera-placeholder {
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
display: flex; flex-direction: column; justify-content: center; align-items: center;
color: #909399; margin-bottom: 10px; cursor: pointer;
}
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
/* 表单与购物车 */
.cart-section { margin-bottom: 20px; }
.form-section { background: #fff; }
.signature-box {
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
}
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
.signed-img img { max-height: 90px; }
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
.bottom-actions .el-button { width: 48%; }
/* 全屏签名弹窗 */
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
.signature-wrapper { display: flex; width: 100%; height: 100%; }
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
.canvas-tip {
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
}
.signature-sidebar {
width: 120px; background: #333; color: #fff;
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
}
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
@media screen and (max-width: 768px) {
.signature-wrapper { flex-direction: column; }
.signature-canvas-container { flex: 1; }
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
.sidebar-title { display: none; }
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
.sidebar-actions .el-button { flex: 1; height: 40px; }
}
</style>

View File

@ -0,0 +1,201 @@
<template>
<div class="app-container">
<div class="filter-container">
<el-input
v-model="listQuery.keyword"
placeholder="单号/姓名/SKU"
style="width: 200px;"
class="filter-item"
clearable
@keyup.enter="fetchData"
/>
<el-date-picker
v-model="listQuery.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
class="filter-item"
style="margin-left: 10px;"
value-format="YYYY-MM-DD"
/>
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="fetchData">查询</el-button>
<el-button type="success" class="filter-item" @click="$router.push('/outbound/create')">新建出库</el-button>
</div>
<el-table
:data="list"
v-loading="loading"
border
style="width: 100%; margin-top: 20px;"
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
>
<el-table-column type="expand">
<template #default="props">
<div style="padding: 10px 40px; background: #fafafa;">
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
<el-table :data="props.row.items" border size="small">
<el-table-column prop="sku" label="SKU" width="150" />
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
<el-table-column prop="material_type" label="类型" width="120" show-overflow-tooltip />
<el-table-column prop="category" label="类别" width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="quantity" label="数量" width="100" />
<el-table-column prop="unit_price" label="单价" width="120">
<template #default="{row}">¥{{ row.unit_price }}</template>
</el-table-column>
<el-table-column prop="subtotal" label="小计">
<template #default="{row}">
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</template>
</el-table-column>
<el-table-column prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
<el-table-column prop="outbound_time" label="出库时间" width="170" align="center">
<template #default="{ row }">
<span>{{ row.outbound_time ? row.outbound_time.substring(0, 16) : '' }}</span>
</template>
</el-table-column>
<el-table-column prop="outbound_type" label="类型" width="100" align="center">
<template #default="{ row }">
<el-tag :type="getTagType(row.outbound_type)">{{ formatType(row.outbound_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_amount" label="总金额" width="120" align="right">
<template #default="{ row }">
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
<el-table-column prop="operator_name" label="操作员" min-width="100" show-overflow-tooltip />
<el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
<el-table-column label="签名" width="120" align="center">
<template #default="{ row }">
<div v-if="row.signature_path" class="signature-cell">
<el-image
style="width: 80px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6;"
:src="row.signature_path"
:preview-src-list="[row.signature_path]"
preview-teleported
fit="contain"
hide-on-click-modal
>
<template #error>
<div class="image-slot">
<el-icon><Picture /></el-icon>
</div>
</template>
</el-image>
</div>
<span v-else style="color: #909399; font-size: 12px;">未签名</span>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; text-align: right;">
<el-pagination
background
layout="prev, pager, next"
:total="total"
:page-size="listQuery.limit"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue'
import { getOutboundList } from '@/api/outbound'
import { Picture } from '@element-plus/icons-vue'
const list = ref([])
const total = ref(0)
const loading = ref(false)
const listQuery = reactive({
page: 1,
limit: 10,
keyword: '',
dateRange: []
})
const fetchData = async () => {
loading.value = true
try {
const params = {
...listQuery,
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null
}
const res = await getOutboundList(params)
list.value = res.data.items || []
total.value = res.data.total || 0
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const handlePageChange = (val: number) => {
listQuery.page = val
fetchData()
}
const formatType = (type: string) => {
const map: any = {
'SALES': '销售出库',
'USE': '内部领用',
'TRANSFER': '调拨',
'SCRAP': '报废'
}
return map[type] || type
}
const getTagType = (type: string) => {
const map: any = {
'SALES': 'success',
'USE': 'warning',
'TRANSFER': 'info',
'SCRAP': 'danger'
}
return map[type] || ''
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.signature-cell {
display: flex;
justify-content: center;
align-items: center;
padding: 2px 0;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f7fa;
color: #909399;
}
</style>

View File

@ -0,0 +1,875 @@
<template>
<div class="buy-module">
<div class="header-tools">
<div class="left-tools">
<el-input
v-model="queryParams.keyword"
placeholder="🔍 搜索物料名称 / 规格 / 批号 / SN / 供应商..."
class="search-input"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 300px; margin-right: 10px;"
>
<template #append>
<el-button :icon="Search" @click="fetchData"/>
</template>
</el-input>
<el-select
v-model="queryParams.statuses"
multiple
collapse-tags
placeholder="状态筛选"
style="width: 220px;"
@change="fetchData"
>
<el-option label="在库" value="在库" />
<el-option label="借库" value="借库" />
<el-option label="已出库" value="已出库" />
</el-select>
</div>
<div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">采购入库登记</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference>
<el-button :icon="Setting" class="action-btn">表头</el-button>
</template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
<div class="col-group-title" style="margin-top:10px">库存与商务</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
class="modern-table"
highlight-current-row
header-cell-class-name="table-header-gray"
>
<template v-for="col in allColumns" :key="col.prop">
<el-table-column
v-if="visibleColumnProps.includes(col.prop)"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '140'"
show-overflow-tooltip
>
<template #default="scope" v-if="col.prop === 'material_name'">
<span class="clickable-text" @click="handleUpdate(scope.row)">
{{ scope.row.material_name }}
</span>
</template>
<template #default="scope" v-else-if="col.prop === 'sn_bn'">
<div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span>
<span class="id-text">{{ scope.row.serial_number }}</span>
</div>
<div v-else-if="scope.row.batch_number" class="id-cell">
<span class="prefix-tag bn">BN</span>
<span class="id-text">{{ scope.row.batch_number }}</span>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'">
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>
{{ scope.row.status }}
</el-tag>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</span>
</template>
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image
style="width: 40px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6; cursor: zoom-in;"
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
>
<template #error>
<div class="image-slot"><el-icon><Picture /></el-icon></div>
</template>
</el-image>
<span v-if="getImagesOnly(scope.row[col.prop]).length > 1" class="more-images-badge">+{{getImagesOnly(scope.row[col.prop]).length}}</span>
</div>
<div v-else-if="hasExternalLink(scope.row[col.prop])" style="text-align: center;">
<el-tag size="small" type="info"><el-icon><Link /></el-icon> 链接</el-tag>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop.includes('link')">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
<el-icon><Link/></el-icon> 查看
</el-link>
</template>
<template #default="scope" v-else-if="['unit_price', 'total_price'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop], scope.row.currency) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon> 打印
</el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination-bar"
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchData"
@current-change="fetchData"
/>
<el-dialog
v-model="visible"
:title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'"
width="1000px"
top="4vh"
destroy-on-close
:close-on-click-modal="false"
class="stylish-dialog compact-layout"
>
<div class="dialog-scroll-container">
<el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card">
<div class="card-title">
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索锁定物料)</span>
</div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 15px;">
<el-col :span="10">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title">
<el-icon class="icon"><House/></el-icon>
<span>2. 入库详情</span>
</div>
<div class="card-content">
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码"/></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: A-01-02"/></el-form-item>
</el-col>
</el-row>
<div class="identity-panel">
<el-row>
<el-col :span="24" style="margin-bottom: 8px;">
<el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked" size="small" class="custom-radio-group">
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
</el-radio-group>
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 历史锁定 (同物料遵循历史模式)</span>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="批号" prop="batch_number">
<el-input v-model="form.batch_number" :placeholder="entryMode === 'batch' ? '系统生成...' : '不可用'" :disabled="entryMode === 'serial'" clearable>
<template #prefix><span class="prefix-tag bn">BN</span></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable>
<template #prefix><span class="prefix-tag sn">SN</span></template>
</el-input>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="20" style="margin-top: 10px;">
<el-col :span="6">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/>
</el-form-item>
</el-col>
<template v-if="dialogStatus === 'update'">
<el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6">
<el-form-item label="库存状态" prop="status">
<el-select v-model="form.status" style="width:100%">
<el-option label="在库" value="在库"/>
<el-option label="已出库" value="已出库"/>
<el-option label="借库" value="借库"/>
</el-select>
</el-form-item>
</el-col>
</template>
<el-col :span="6">
<el-form-item label="到检状态" prop="inspection_status">
<el-select v-model="form.inspection_status" style="width:100%">
<el-option label="未检" value="未检"><span style="color:#909399">⚪ 未检</span></el-option>
<el-option label="合格" value="合格"><span style="color:#67C23A">🟢 合格</span></el-option>
<el-option label="不合格" value="不合格"><span style="color:#F56C6C">🔴 不合格</span></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="form.arrival_photo" placeholder="图片列表" style="display:none;" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="检测报告" prop="inspection_report">
<div class="upload-container">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="inspection_report_url" placeholder="如有外部报告链接请在此输入 (选填)" style="margin-top: 8px;" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.inspection_report" placeholder="图片列表" style="display:none;" />
</el-form-item>
</el-col>
</el-row>
<div class="divider-text">商务与采购信息</div>
<el-row :gutter="20">
<el-col :span="6">
<el-form-item label="币种">
<el-autocomplete v-model="form.currency" :fetch-suggestions="querySearchCurrency" placeholder="币种" style="width: 100%" :trigger-on-focus="true">
<template #default="{ item }"><span>{{ item.value }}</span><span style="float:right; color:#999; font-size:12px">{{ item.desc }}</span></template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="含税单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="供应商"><el-autocomplete v-model="form.supplier_name" :fetch-suggestions="querySearchSupplier" placeholder="输入或选择供应商" style="width: 100%" clearable :trigger-on-focus="true" @select="handleSupplierSelect"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="采购人"><el-autocomplete v-model="form.purchaser" :fetch-suggestions="querySearchPurchaser" placeholder="输入采购人" style="width: 100%" clearable :trigger-on-focus="true" @select="handlePurchaserSelect"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="采购邮箱"><el-autocomplete v-model="form.purchaser_email" :fetch-suggestions="querySearchEmail" placeholder="输入邮箱" style="width: 100%" clearable :trigger-on-focus="true" @select="handleEmailSelect"/></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"><el-form-item label="原始链接"><el-input v-model="form.source_link" placeholder="http://"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="详情链接"><el-input v-model="form.detail_link" placeholder="http://"/></el-form-item></el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }}</el-button>
</div>
</template>
</el-dialog>
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div>
</div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"><p>打印机 IP: 192.168.9.205</p><p>尺寸: 40mm x 30mm</p></div>
</div>
<template #footer>
<div class="dialog-footer"><el-button @click="printVisible = false">取消</el-button><el-button type="primary" :loading="printing" @click="confirmPrint"><el-icon><Printer/></el-icon>确认打印</el-button></div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, onMounted, watch} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Delete, Picture} from '@element-plus/icons-vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import dayjs from 'dayjs'
import {
getBuyList,
createBuyInbound,
updateBuyInbound,
deleteBuyInbound,
searchMaterialBase,
uploadFile,
deleteFile
} from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print'
// ------------------------------------
// 状态与变量
// ------------------------------------
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
const searchLoading = ref(false)
const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = reactive({
page: 1,
pageSize: 15,
keyword: '',
statuses: ['在库', '借库']
})
const materialOptions = ref<any[]>([])
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const currentPrintData = ref<any>({})
const entryMode = ref('batch')
const modeLocked = ref(false)
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
const arrivalFileList = ref<any[]>([])
const reportFileList = ref<any[]>([])
const cameraInputRef = ref<HTMLInputElement | null>(null)
const currentCameraField = ref<'arrival_photo' | 'inspection_report'>('arrival_photo')
const inspection_report_url = ref('')
// 基础列
const baseColumns = [
{prop: 'material_name', label: '名称'},
{prop: 'material_type', label: '类型'},
{prop: 'category', label: '类别'},
{prop: 'spec_model', label: '规格型号'},
{prop: 'unit', label: '单位'},
]
// 库存与商务列
const stockColumns = [
{prop: 'id', label: 'ID', minWidth: '60'},
{prop: 'base_id', label: 'BaseID', minWidth: '80'},
{prop: 'sku', label: 'SKU', minWidth: '120'},
{prop: 'inbound_date', label: '入库日期', minWidth: '120'},
{prop: 'barcode', label: '条码', minWidth: '120'},
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
{prop: 'status', label: '状态', minWidth: '100'},
{prop: 'inspection_status', label: '到检', minWidth: '100'},
{prop: 'qty_inbound', label: '入库量', minWidth: '100'},
{prop: 'qty_stock', label: '库存数', minWidth: '100'},
{prop: 'qty_available', label: '可用数', minWidth: '100'},
{prop: 'warehouse_loc', label: '库位', minWidth: '120'},
{prop: 'unit_price', label: '单价', minWidth: '120'},
{prop: 'total_price', label: '总价', minWidth: '120'},
{prop: 'currency', label: '币种', minWidth: '80'},
{prop: 'exchange_rate', label: '汇率', minWidth: '80'},
{prop: 'supplier_name', label: '供应商', minWidth: '150'},
{prop: 'purchaser', label: '采购人', minWidth: '100'},
{prop: 'purchaser_email', label: '邮箱', minWidth: '150'},
{prop: 'source_link', label: '采购链接', minWidth: '100'},
{prop: 'detail_link', label: '详情链接', minWidth: '100'},
{prop: 'arrival_photo', label: '到货图', minWidth: '100'},
{prop: 'inspection_report', label: '检测报告', minWidth: '100'}
]
const allColumns = [...baseColumns, ...stockColumns]
const STORAGE_KEY_COLS = 'stock_buy_visible_columns_v2'
const defaultColumns = [
'material_name', 'material_type', 'category', 'spec_model', 'unit',
'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status',
'unit_price', 'total_price', 'supplier_name', 'purchaser', 'qty_stock', 'qty_available', 'arrival_photo', 'inspection_report'
]
const getSavedColumns = () => { try { const saved = localStorage.getItem(STORAGE_KEY_COLS); return saved ? JSON.parse(saved) : defaultColumns } catch (e) { return defaultColumns } }
const visibleColumnProps = ref(getSavedColumns())
watch(visibleColumnProps, (newVal) => { localStorage.setItem(STORAGE_KEY_COLS, JSON.stringify(newVal)) }, {deep: true})
const form = reactive({
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '',
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '',
arrival_photo: [] as string[], inspection_report: [] as string[]
})
// 历史记录辅助函数
const HISTORY_KEYS = { SUPPLIER: 'history_suppliers', PURCHASER: 'history_purchasers', EMAIL: 'history_emails', MATERIAL: 'history_materials' }
const saveToHistory = (key: string, value: string) => {
if (!value) return
try {
const existing = localStorage.getItem(key)
let list = existing ? JSON.parse(existing) : []
list = list.filter((i: string) => i !== value)
list.unshift(value)
if (list.length > 20) list = list.slice(0, 20)
localStorage.setItem(key, JSON.stringify(list))
} catch (e) { console.error('save history failed', e) }
}
const getHistoryList = (key: string): any[] => {
try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({value: v})) } catch (e) { return [] }
}
const saveMaterialHistory = (item: any) => {
if (!item || !item.id) return
const key = HISTORY_KEYS.MATERIAL
try {
let list = JSON.parse(localStorage.getItem(key) || '[]')
list = list.filter((i: any) => i.id !== item.id)
list.unshift({...item, isHistory: true})
if (list.length > 10) list = list.slice(0, 10)
localStorage.setItem(key, JSON.stringify(list))
} catch (e) {}
}
const getMaterialHistory = () => {
try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] }
}
const createFilter = (queryString: string) => { return (item: any) => (item.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0) }
const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({value: i})) }
const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => {
const tableList = getTableDataUnique(tableField)
const historyList = getHistoryList(storageKey)
const map = new Map()
historyList.forEach(i => map.set(i.value, i))
tableList.forEach(i => map.set(i.value, i))
const allList = Array.from(map.values())
const results = queryString ? allList.filter(createFilter(queryString)) : allList
cb(results)
}
const querySearchSupplier = (qs: string, cb: any) => mixedSearch(qs, 'supplier_name', HISTORY_KEYS.SUPPLIER, cb)
const handleSupplierSelect = (item: any) => saveToHistory(HISTORY_KEYS.SUPPLIER, item.value)
const querySearchPurchaser = (qs: string, cb: any) => mixedSearch(qs, 'purchaser', HISTORY_KEYS.PURCHASER, cb)
const handlePurchaserSelect = (item: any) => saveToHistory(HISTORY_KEYS.PURCHASER, item.value)
const querySearchEmail = (qs: string, cb: any) => mixedSearch(qs, 'purchaser_email', HISTORY_KEYS.EMAIL, cb)
const handleEmailSelect = (item: any) => saveToHistory(HISTORY_KEYS.EMAIL, item.value)
const currencyOptions = [{value: 'CNY', desc: '人民币'}, {value: 'USD', desc: '美元'}, {value: 'EUR', desc: '欧元'}]
const querySearchCurrency = (queryString: string, cb: any) => { cb(queryString ? currencyOptions.filter(createFilter(queryString)) : currencyOptions) }
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query)
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
if (!query) {
const history = getMaterialHistory()
const historyIds = new Set(history.map((h: any) => h.id))
materialOptions.value = [...history, ...apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))]
} else { materialOptions.value = apiResults }
} finally { searchLoading.value = false }
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
saveMaterialHistory(item)
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
checkHistoryAndSetMode(item.id)
}
}
// ------------------------------------
// 校验规则
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback()
// 前端仅做当前页面的简单重复提示,真正的校验在后端
const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
return false
})
if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
else callback()
}
const validateIdentity = (rule: any, value: any, callback: any) => {
if (entryMode.value === 'serial' && !form.serial_number && rule.field === 'serial_number') callback(new Error('SN必填'))
else if (entryMode.value === 'batch' && !form.batch_number && rule.field === 'batch_number') callback(new Error('批号必填'))
else callback()
}
const rules = {
base_id: [{required: true, message: '请选择物料', trigger: 'change'}],
in_quantity: [{required: true, message: '请输入数量', trigger: 'blur'}],
serial_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}],
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
}
// 自动计算批号逻辑
const checkHistoryAndSetMode = async (baseId: number) => {
try {
const res: any = await getBuyList({page: 1, pageSize: 1000}) // 获取最近数据
const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId)
if (historyItems.length > 0) {
modeLocked.value = true
// 找最新的那条记录
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
if (latest.serial_number) {
entryMode.value = 'serial'
form.serial_number = ''
form.batch_number = ''
} else {
entryMode.value = 'batch'
form.serial_number = ''
// 自动递增批号
form.batch_number = incrementBatchNumber(latest.batch_number || '000000')
}
} else {
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = '000001'
}
if (formRef.value) {
formRef.value.clearValidate('serial_number')
formRef.value.clearValidate('batch_number')
}
} catch (e) {
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = '000001'
}
}
const incrementBatchNumber = (batchStr: string) => {
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
return (parseInt(batchStr, 10) + 1).toString().padStart(6, '0')
}
const handleEntryModeChange = (val: string) => {
if (val === 'batch') {
form.serial_number = ''
form.batch_number = '000001'
if(formRef.value) formRef.value.clearValidate('serial_number')
} else {
form.batch_number = ''
if(formRef.value) formRef.value.clearValidate('batch_number')
}
}
watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) })
const fetchData = async () => {
loading.value = true
try {
const params = { ...queryParams, statuses: queryParams.statuses.join(',') }
const res: any = await getBuyList(params)
tableData.value = res.data.items || []
total.value = res.data.total || 0
} finally { loading.value = false }
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = ''
visible.value = true
materialOptions.value = []
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
resetForm()
modeLocked.value = true
Object.assign(form, {
id: row.id, base_id: row.base_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, inspection_status: row.inspection_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
unit_price: Number(row.unit_price), total_price: Number(row.total_price), currency: row.currency, exchange_rate: Number(row.exchange_rate),
supplier_name: row.supplier_name, purchaser: row.purchaser, purchaser_email: row.purchaser_email,
source_link: row.source_link, detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], inspection_report: row.inspection_report || []
})
arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.inspection_report || []
const reportImgs = reports.filter(r => !isExternalLink(r))
const reportLinks = reports.filter(r => isExternalLink(r))
reportFileList.value = reportImgs.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
inspection_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category }]
visible.value = true
}
// ------------------------------------
// 提交逻辑
// ------------------------------------
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitting.value = true
const finalReportList = [...form.inspection_report]
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) }
try {
if (dialogStatus.value === 'create') {
const res: any = await createBuyInbound(payload)
ElMessage.success('入库成功')
if (res.data) {
ElMessage.info('发送打印指令...')
try { await executePrint(res.data); ElMessage.success('打印指令已发送') }
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
}
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
// 成功后保存历史记录
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name)
saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser)
saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
await fetchData()
visible.value = false
} catch (e: any) {
// 重点:捕获后端唯一性校验错误
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
}
})
}
// 其他辅助函数保持不变
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
const hasExternalLink = (list: string[]) => { return !list ? false : list.some(item => isExternalLink(item)) }
const beforeAvatarUpload = (rawFile: any) => {
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') { ElMessage.error('仅支持 JPG/PNG'); return false }
if (rawFile.size / 1024 / 1024 > 5) { ElMessage.error('图片不能超过 5MB'); return false }
return true
}
const customUpload = async (options: any, targetField: 'arrival_photo' | 'inspection_report') => {
const { file, onSuccess, onError } = options
const formData = new FormData()
formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url
form[targetField].push(newUrl)
ElMessage.success('上传成功')
onSuccess(res)
} else { ElMessage.error(res.msg || '上传失败'); onError(new Error(res.msg)) }
} catch (e) { ElMessage.error('网络错误'); onError(e) }
}
const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' | 'inspection_report') => {
try {
const urlToRemove = form[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form[targetField] = form[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) { const filename = urlToRemove.split('/').pop(); if (filename) await deleteFile(filename) }
ElMessage.success('已删除')
} catch (e) { console.error(e) }
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
const triggerCamera = (field: 'arrival_photo' | 'inspection_report') => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
const handleCameraFile = async (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
const file = input.files[0]
if (!beforeAvatarUpload(file)) { input.value = ''; return }
const formData = new FormData(); formData.append('file', file)
const loadingMsg = ElMessage.loading({ message: '照片上传中...', duration: 0 })
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; const field = currentCameraField.value
form[field].push(newUrl)
if (field === 'arrival_photo') arrivalFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
else reportFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
ElMessage.success('拍照上传成功')
} else { ElMessage.error(res.msg || '上传失败') }
} catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' }
}
}
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data }
catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
}
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('指令已发送'); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', inspection_status: '未检', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00, supplier_name: '', purchaser: '', purchaser_email: '', source_link: '', detail_link: '', arrival_photo: [], inspection_report: [] })
}
const getStatusType = (status: string) => { const map: any = {'在库': 'success', '出库': 'info', '损耗': 'danger'}; return map[status] || 'warning' }
const formatMoney = (val: any, currency = '¥') => { const num = Number(val); return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}` }
onMounted(() => fetchData())
</script>
<style scoped>
/* 样式部分保持不变,直接复用原有代码 */
.buy-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
.right-tools { display: flex; gap: 10px; align-items: center; }
.action-btn { font-weight: 500; }
.modern-table { border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; font-weight: 600; height: 50px; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; background: #ecf5ff; padding: 0 4px; border-radius: 4px; margin-right: 4px; font-size: 12px; }
.tag-bn { color: #67C23A; font-weight: bold; font-family: monospace; background: #f0f9eb; padding: 0 4px; border-radius: 4px; margin-right: 4px; font-size: 12px; }
.id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; }
.money-text { font-family: 'Consolas', monospace; color: #303133; }
.stock-num { font-weight: bold; color: #333; font-size: 15px; }
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.sum-tag { margin-left: 4px; transform: scale(0.9); }
:deep(.el-dialog__body) { padding: 0; overflow: hidden; }
.dialog-scroll-container { padding: 15px 20px; max-height: 70vh; overflow-y: auto; overflow-x: hidden; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 15px; overflow: hidden; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 14px; color: #303133; display: flex; align-items: center; }
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
.card-content { padding: 15px 20px; }
.basic-card { border-left: 4px solid #409EFF; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; font-size: 13px; }
.inbound-card { border-left: 4px solid #67C23A; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 12px; margin-bottom: 15px; }
.custom-radio-group { margin-bottom: 10px; }
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
.prefix-tag { font-weight: bold; font-size: 12px; padding: 0 5px; border-radius: 4px; }
.prefix-tag.bn { color: #67C23A; background: #f0f9eb; }
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; }
.divider-text { display: flex; align-items: center; text-align: center; margin: 20px 0 15px; color: #909399; font-size: 13px; font-weight: 500; }
.divider-text::before, .divider-text::after { content: ''; flex: 1; border-bottom: 1px solid #ebeef5; }
.divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 15px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 15px 20px; background: #fff; border-top: 1px solid #ebeef5; }
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; }
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
.empty-preview { color: #909399; }
.more-images-badge { margin-left: 5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 6px; font-size: 12px; }
.clickable-text { color: #409EFF; cursor: pointer; font-weight: 500; text-decoration: underline; }
.clickable-text:hover { color: #66b1ff; }
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
.camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; }
</style>

View File

@ -0,0 +1,167 @@
<template>
<div class="app-container">
<div class="filter-container">
<el-input
v-model="listQuery.keyword"
placeholder="SKU / 名称 / 规格 / 批次号 / 来源"
style="width: 300px;"
class="filter-item"
clearable
@keyup.enter="handleFilter"
/>
<el-select v-model="listQuery.source_type" placeholder="全部来源" clearable class="filter-item" style="width: 140px; margin-left: 10px;">
<el-option label="采购入库" value="buy" />
<el-option label="半成品生产" value="semi" />
<el-option label="成品完工" value="product" />
</el-select>
<el-date-picker
v-model="listQuery.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
class="filter-item"
style="margin-left: 10px;"
value-format="YYYY-MM-DD"
@change="handleFilter"
/>
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="handleFilter">查询</el-button>
</div>
<el-table
:data="list"
v-loading="loading"
border
stripe
style="width: 100%; margin-top: 20px;"
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
>
<el-table-column prop="sku" label="SKU" min-width="140" fixed sortable show-overflow-tooltip />
<el-table-column prop="name" label="物品名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span style="font-weight: 500;">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column prop="spec_model" label="规格型号" min-width="140" show-overflow-tooltip />
<el-table-column label="分类" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.category }}</span>
<span v-if="row.category && row.material_type"> / </span>
<span>{{ row.material_type }}</span>
</template>
</el-table-column>
<el-table-column prop="type_label" label="入库来源" width="110" align="center">
<template #default="{ row }">
<el-tag :type="getSourceTag(row.source_type)" effect="plain">
{{ row.type_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="inbound_date" label="入库/生产日期" width="120" align="center" sortable />
<el-table-column label="入库数量" width="110" align="center">
<template #default="{ row }">
<span style="font-weight: bold; color: #409EFF;">{{ row.quantity }}</span>
</template>
</el-table-column>
<el-table-column prop="batch_number" label="批次/序列号" min-width="140" show-overflow-tooltip />
<el-table-column prop="source_info" label="供应商/负责人" min-width="140" show-overflow-tooltip />
<el-table-column prop="status" label="当前状态" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-tag size="small" :type="getStatusTag(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;">
<el-pagination
v-model:current-page="listQuery.page"
v-model:page-size="listQuery.per_page"
:total="total"
:page-sizes="[10, 20, 50, 100, 200]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleFilter"
@current-change="fetchData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getInboundSummaryList } from '@/api/inbound/inbound_summary'
const list = ref([])
const total = ref(0)
const loading = ref(false)
const listQuery = reactive({
page: 1,
per_page: 20, // 默认每页20
keyword: '',
source_type: '',
dateRange: null as any
})
const fetchData = async () => {
loading.value = true
try {
const params = {
page: listQuery.page,
per_page: listQuery.per_page,
keyword: listQuery.keyword,
source_type: listQuery.source_type,
start_date: listQuery.dateRange ? listQuery.dateRange[0] : null,
end_date: listQuery.dateRange ? listQuery.dateRange[1] : null
}
const res = await getInboundSummaryList(params)
if (res.data) {
list.value = res.data.items || []
total.value = res.data.total || 0
}
} catch (error) {
console.error('获取入库记录失败', error)
} finally {
loading.value = false
}
}
// 查询操作重置页码
const handleFilter = () => {
listQuery.page = 1
fetchData()
}
// 来源类型的 Tag 颜色
const getSourceTag = (type: string) => {
if (type === 'buy') return 'success' // 绿色
if (type === 'semi') return 'warning' // 橙色
if (type === 'product') return 'primary' // 蓝色
return 'info'
}
// [新增] 状态的 Tag 颜色逻辑
const getStatusTag = (status: string) => {
if (!status) return 'info'
if (status.includes('已出库')) return 'info' // 灰色
if (status.includes('部分')) return 'warning' // 橙色
if (status.includes('不合格') || status.includes('异常')) return 'danger' // 红色
return 'success' // 默认(如:合格、在库)为绿色
}
onMounted(() => {
fetchData()
})
</script>

View File

@ -0,0 +1,27 @@
<template>
<div class="inbound-container" style="padding: 20px;">
<el-tabs v-model="activeModule" type="border-card">
<el-tab-pane label="物料采购入库" name="buy">
<BuyInbound v-if="activeModule === 'buy'" />
</el-tab-pane>
<el-tab-pane label="半成品入库" name="semi">
<SemiInbound v-if="activeModule === 'semi'" />
</el-tab-pane>
<el-tab-pane label="成品入库" name="product">
<ProductInbound v-if="activeModule === 'product'" />
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// 因为在同一个文件夹下,直接用 ./ 即可
import BuyInbound from './buy.vue'
import SemiInbound from './semi.vue'
import ProductInbound from './product.vue'
const activeModule = ref('buy')
</script>

View File

@ -0,0 +1,667 @@
<template>
<div class="product-module">
<div class="header-tools">
<div class="left-tools">
<el-input
v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / SN / 工单 / 订单号..."
class="search-input"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 300px; margin-right: 10px;"
>
<template #append><el-button :icon="Search" @click="fetchData" /></template>
</el-input>
<el-select
v-model="queryParams.statuses"
multiple
collapse-tags
placeholder="状态筛选"
style="width: 220px;"
@change="fetchData"
>
<el-option label="在库" value="在库" />
<el-option label="借库" value="借库" />
<el-option label="已出库" value="已出库" />
</el-select>
</div>
<div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">成品入库登记</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference><el-button :icon="Setting" class="action-btn">表头</el-button></template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-row :gutter="10">
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
</el-row>
</el-checkbox-group>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
class="modern-table"
header-cell-class-name="table-header-gray"
>
<template v-for="col in allColumns" :key="col.prop">
<el-table-column
v-if="visibleColumnProps.includes(col.prop)"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '110'"
show-overflow-tooltip
>
<template #default="scope" v-if="col.prop === 'material_name'">
<span class="clickable-text" @click="handleUpdate(scope.row)">
{{ scope.row.material_name }}
</span>
</template>
<template #default="scope" v-else-if="['serial_number'].includes(col.prop)">
<div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span>
<span class="id-text">{{ scope.row.serial_number }}</span>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'">
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>{{ scope.row.status }}</el-tag>
</template>
<template #default="scope" v-else-if="col.prop === 'quality_status'">
<el-tag :type="getQualityType(scope.row.quality_status)" effect="dark" size="small">{{ scope.row.quality_status }}</el-tag>
</template>
<template #default="scope" v-else-if="['product_photo', 'quality_report_link', 'inspection_report_link'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image
style="width: 40px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6; cursor: zoom-in;"
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
>
<template #error>
<div class="image-slot"><el-icon><Picture /></el-icon></div>
</template>
</el-image>
<span v-if="getImagesOnly(scope.row[col.prop]).length > 1" class="more-images-badge">+{{getImagesOnly(scope.row[col.prop]).length}}</span>
</div>
<div v-else-if="hasExternalLink(scope.row[col.prop])" style="text-align: center;">
<el-tag size="small" type="info"><el-icon><Link /></el-icon> 链接</el-tag>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="['detail_link'].includes(col.prop)">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
<el-icon><Link /></el-icon> 查看
</el-link>
</template>
<template #default="scope" v-else-if="['sale_price', 'raw_material_cost', 'manual_cost'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon><Printer/></el-icon>
</el-button>
<el-button link type="primary" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除" @confirm="handleDelete(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination class="pagination-bar" v-model:current-page="queryParams.page" v-model:page-size="queryParams.pageSize" :total="total" layout="total, sizes, prev, pager, next" background @change="fetchData" />
<el-dialog v-model="visible" :title="dialogStatus === 'create' ? '成品入库' : '编辑成品'" width="1100px" top="5vh" :close-on-click-modal="false" class="stylish-dialog">
<div class="dialog-scroll-container">
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card">
<div class="card-title"><el-icon class="icon"><Box /></el-icon><span>1. 基础信息</span></div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10">
<el-form-item label="物料搜索" prop="base_id">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
placeholder="搜名称/规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
<div class="option-item">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view" /></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title"><el-icon class="icon"><House /></el-icon><span>2. 入库详情</span></div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="SKU" prop="sku"><el-input v-model="form.sku" placeholder="自动生成" disabled /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="自动生成" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled /></el-form-item></el-col>
</el-row>
<div class="identity-panel">
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="序列号(SN)" prop="serial_number">
<el-input v-model="form.serial_number" placeholder="必填: 唯一序列号" clearable><template #prefix><span class="prefix-tag sn">SN</span></template></el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="24" style="margin-top:15px">
<template v-if="dialogStatus === 'update'">
<el-col :span="6">
<el-form-item label="库存状态" prop="status">
<el-select v-model="form.status" style="width:100%">
<el-option label="在库" value="在库"/>
<el-option label="借库" value="借库"/>
<el-option label="已出库" value="已出库"/>
</el-select>
</el-form-item>
</el-col>
</template>
<el-col :span="6">
<el-form-item label="质量状态">
<el-select v-model="form.quality_status" style="width:100%">
<el-option label="合格" value="合格" /><el-option label="不合格" value="不合格" /><el-option label="待检" value="待检" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="dialogStatus === 'update' ? 12 : 18">
<el-form-item label="成品实拍" prop="product_photo">
<div class="upload-container">
<el-upload v-model:file-list="productPhotoList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'product_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'product_photo')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('product_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="form.product_photo" style="display:none" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container">
<el-upload v-model:file-list="qualityFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="quality_url" placeholder="外部链接..." style="margin-top:8px" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.quality_report_link" style="display:none" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检测报告" prop="inspection_report_link">
<div class="upload-container">
<el-upload v-model:file-list="inspectionFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'inspection_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'inspection_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('inspection_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="inspection_url" placeholder="外部链接..." style="margin-top:8px" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.inspection_report_link" style="display:none" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<div class="form-card production-card">
<div class="card-title"><el-icon class="icon"><Setting /></el-icon><span>3. 生产与销售信息</span></div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" /></el-form-item></el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="订单号"><el-input v-model="form.order_id" placeholder="关联销售订单" /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="负责人">
<el-autocomplete v-model="form.production_manager" :fetch-suggestions="querySearchManager" placeholder="输入或选择负责人" style="width: 100%" clearable :trigger-on-focus="true" @select="handleManagerSelect"/>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="产品定价"><el-input-number v-model="form.sale_price" :precision="2" style="width:100%"><template #prefix>¥</template></el-input-number></el-form-item></el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="生产时间">
<el-date-picker v-model="form.production_time_range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="6"><el-form-item label="原料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="人工成本"><el-input-number v-model="form.manual_cost" :precision="2" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="24" style="margin-top:10px">
<el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" /></el-form-item></el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
{{ dialogStatus === 'create' ? '提交并打印' : '保存修改' }}
</el-button>
</div>
</template>
</el-dialog>
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile" />
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%">
<img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" />
</el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div>
</div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"><p>打印机 IP: 192.168.9.205</p><p>尺寸: 40mm x 30mm</p></div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="printVisible = false">取消</el-button>
<el-button type="primary" :loading="printing" @click="confirmPrint">
<el-icon><Printer/></el-icon> 确认打印
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link, InfoFilled, Printer, Camera, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import { getLabelPreview, executePrint } from '@/api/common/print'
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
const searchLoading = ref(false)
const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] })
const materialOptions = ref<any[]>([])
// 打印相关变量
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const currentPrintData = ref<any>({})
// 图片/拍照相关
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
// 3个独立的列表
const productPhotoList = ref<any[]>([]) // 成品实拍
const qualityFileList = ref<any[]>([]) // 质量报告
const inspectionFileList = ref<any[]>([]) // 检测报告
const cameraInputRef = ref<HTMLInputElement | null>(null)
const currentCameraField = ref<'product_photo' | 'quality_report_link' | 'inspection_report_link'>('product_photo')
const quality_url = ref('')
const inspection_url = ref('')
// [核心优化] 所有列定义
const allColumns = [
{ prop: 'material_name', label: '名称', minWidth: '140' },
{ prop: 'sku', label: 'SKU', minWidth: '110' },
{ prop: 'serial_number', label: '序列号', minWidth: '130' },
{ prop: 'qty_stock', label: '库存', minWidth: '90' },
{ prop: 'status', label: '状态', minWidth: '90' },
{ prop: 'quality_status', label: '质量', minWidth: '90' },
{ prop: 'spec_model', label: '规格', minWidth: '120' },
{ prop: 'unit', label: '单位', minWidth: '80' },
{ prop: 'product_photo', label: '实拍图', minWidth: '100' },
{ prop: 'sale_price', label: '售价', minWidth: '100' },
{ prop: 'order_id', label: '订单号', minWidth: '120' },
{ prop: 'work_order_code', label: '工单号', minWidth: '120' },
{ prop: 'quality_report_link', label: '质量报告', minWidth: '100' },
{ prop: 'inspection_report_link', label: '检测报告', minWidth: '100' },
{ prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'production_manager', label: '负责人', minWidth: '100' },
{ prop: 'raw_material_cost', label: '原料成本', minWidth: '100' },
{ prop: 'manual_cost', label: '人工成本', minWidth: '100' },
{ prop: 'inbound_date', label: '生产日期', minWidth: '120' },
{ prop: 'detail_link', label: '详情', minWidth: '100' }
]
const defaultVisibleCols = ['material_name', 'sku', 'serial_number', 'qty_stock', 'status', 'quality_status', 'product_photo', 'sale_price', 'order_id']
const STORAGE_KEY = 'stock_product_visible_columns_v2'
const getSavedColumns = () => { try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : defaultVisibleCols } catch (e) { return defaultVisibleCols } }
const visibleColumnProps = ref(getSavedColumns())
watch(visibleColumnProps, (newVal) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal)) }, { deep: true })
const form = reactive({
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '',
sku: '', barcode: '', serial_number: '', in_date: '',
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
warehouse_location: '', status: '在库', quality_status: '合格',
bom_code: '', bom_version: '', work_order_code: '', order_id: '',
production_manager: '', production_time_range: [] as string[],
raw_material_cost: 0, manual_cost: 0, sale_price: 0,
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
})
// ------------------------------------
// 校验规则 (前端 pre-check)
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback()
// 简单的列表前端查重
const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true
return false
})
if (isDuplicate) callback(new Error('当前列表页存在相同SN(后端将进行全局校验)'))
else callback()
}
const rules = {
base_id: [{ required: true, message: '必选', trigger: 'change' }],
serial_number: [{ required: true, message: '必填', trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
}
const HISTORY_KEYS = { PRODUCTION_MANAGER: 'history_product_managers', MATERIAL: 'history_product_materials' }
const saveToHistory = (key: string, value: string) => { if (!value) return; try { const existing = localStorage.getItem(key); let list = existing ? JSON.parse(existing) : []; list = list.filter((i: string) => i !== value); list.unshift(value); if (list.length > 20) list = list.slice(0, 20); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
const getHistoryList = (key: string): any[] => { try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({ value: v })) } catch (e) { return [] } }
const saveMaterialHistory = (item: any) => { if (!item || !item.id) return; const key = HISTORY_KEYS.MATERIAL; try { let list = JSON.parse(localStorage.getItem(key) || '[]'); list = list.filter((i: any) => i.id !== item.id); list.unshift({ ...item, isHistory: true }); if (list.length > 10) list = list.slice(0, 10); localStorage.setItem(key, JSON.stringify(list)) } catch (e) {} }
const getMaterialHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] } }
// ------------------------------------
// Material Search & Population Logic
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query)
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
if (!query) {
const history = getMaterialHistory()
const historyIds = new Set(history.map((h: any) => h.id))
const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))
materialOptions.value = [...history, ...filteredApi]
} else { materialOptions.value = apiResults }
} finally { searchLoading.value = false }
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
saveMaterialHistory(item)
// Auto-populate readonly fields
form.material_name = item.name
form.spec_model = item.spec
form.material_type = item.type
form.category = item.category
form.unit = item.unit
}
}
// ------------------------------------
// Autocomplete (Manager)
// ------------------------------------
const createFilter = (qs: string) => { return (item: any) => (item.value.toLowerCase().indexOf(qs.toLowerCase()) === 0) }
const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({ value: i })) }
const mixedSearch = (qs: string, tableField: string, storageKey: string, cb: any) => { const tableList = getTableDataUnique(tableField); const historyList = getHistoryList(storageKey); const map = new Map(); historyList.forEach(i => map.set(i.value, i)); tableList.forEach(i => map.set(i.value, i)); const allList = Array.from(map.values()); const results = qs ? allList.filter(createFilter(qs)) : allList; cb(results) }
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
const fetchData = async () => {
loading.value = true;
try {
const params = { ...queryParams, statuses: queryParams.statuses.join(',') }
const res: any = await getProductList(params);
tableData.value = res.data.items || [];
total.value = res.data.total || 0
} finally { loading.value = false }
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true
materialOptions.value = []
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
Object.assign(form, {
...row,
product_photo: row.product_photo || [],
quality_report_link: row.quality_report_link || [],
inspection_report_link: row.inspection_report_link || [],
in_quantity: Number(row.qty_inbound),
raw_material_cost: Number(row.raw_material_cost),
manual_cost: Number(row.manual_cost),
sale_price: Number(row.sale_price)
})
if(row.production_start_time && row.production_end_time) { form.production_time_range = [row.production_start_time, row.production_end_time] } else { form.production_time_range = [] }
productPhotoList.value = form.product_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qReports = form.quality_report_link || []
qualityFileList.value = qReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const qLinks = qReports.filter(r => isExternalLink(r))
quality_url.value = qLinks.length > 0 ? qLinks[0] : ''
const iReports = form.inspection_report_link || []
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }]
visible.value = true
}
const getImageUrl = (url: string) => { if (!url) return ''; if (url.startsWith('http')) return url; return url }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { if (!list) return []; return list.filter(item => !isExternalLink(item)) }
const hasExternalLink = (list: string[]) => { if (!list) return false; return list.some(item => isExternalLink(item)) }
const beforeAvatarUpload = (rawFile: any) => { if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') { ElMessage.error('仅支持 JPG/PNG'); return false } if (rawFile.size / 1024 / 1024 > 5) { ElMessage.error('图片不能超过 5MB'); return false } return true }
const customUpload = async (options: any, targetField: 'product_photo' | 'quality_report_link' | 'inspection_report_link') => {
const { file, onSuccess, onError } = options
const formData = new FormData(); formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; form[targetField].push(newUrl); ElMessage.success('上传成功'); onSuccess(res)
} else { ElMessage.error(res.msg || '上传失败'); onError(new Error(res.msg)) }
} catch (e) { ElMessage.error('网络错误'); onError(e) }
}
const handleRemoveImage = async (uploadFile: any, targetField: 'product_photo' | 'quality_report_link' | 'inspection_report_link') => {
try {
const urlToRemove = form[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form[targetField] = form[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) { const filename = urlToRemove.split('/').pop(); if (filename) await deleteFile(filename) }
ElMessage.success('已删除')
} catch (e) { console.error(e) }
}
const triggerCamera = (field: any) => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
const handleCameraFile = async (event: Event) => {
const input = event.target as HTMLInputElement; if (input.files && input.files[0]) {
const file = input.files[0]; if (!beforeAvatarUpload(file)) { input.value = ''; return }
const formData = new FormData(); formData.append('file', file); const loadingMsg = ElMessage.loading({ message: '上传中...', duration: 0 })
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; const field = currentCameraField.value; form[field].push(newUrl)
if (field === 'product_photo') productPhotoList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
else if (field === 'quality_report_link') qualityFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
else inspectionFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
ElMessage.success('拍照上传成功')
} else { ElMessage.error(res.msg || '上传失败') }
} catch (e) { ElMessage.error('网络错误') } finally { loadingMsg.close(); input.value = '' }
}
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => {
if(valid) {
submitting.value = true
const qList = [...form.quality_report_link]
const qImages = qList.filter(item => !isExternalLink(item))
if (quality_url.value && !qList.includes(quality_url.value)) qImages.push(quality_url.value)
else if (quality_url.value) qImages.push(quality_url.value)
const iList = [...form.inspection_report_link]
const iImages = iList.filter(item => !isExternalLink(item))
if (inspection_url.value && !iList.includes(inspection_url.value)) iImages.push(inspection_url.value)
else if (inspection_url.value) iImages.push(inspection_url.value)
const payload = { ...form, quality_report_link: qImages, inspection_report_link: iImages, production_start_time: form.production_time_range?.[0], production_end_time: form.production_time_range?.[1] }
delete payload.production_time_range
try {
if(dialogStatus.value === 'create') {
const res: any = await createProductInbound(payload)
ElMessage.success('入库成功')
const newItem = res.data
if (newItem) { ElMessage.info('发送打印...'); try { await executePrint(newItem); ElMessage.success('指令已发送') } catch (e: any) { ElMessage.warning('打印失败') } }
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
visible.value = false; fetchData()
} catch(e:any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
}
})
}
const handleDelete = async (row: any) => { try { await deleteProductInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch(e) { ElMessage.error('删除失败') } }
const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } catch (e) { ElMessage.error('预览失败') } finally { printLoading.value = false }
}
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('已发送'); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: 0, manual_cost: 0, sale_price: 0, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
}
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
const getQualityType = (s:string) => ({'合格':'success','不合格':'danger','待检':'info'}[s]||'info')
const formatMoney = (val:any) => isNaN(Number(val)) ? '-' : `¥ ${Number(val).toFixed(2)}`
onMounted(() => fetchData())
</script>
<style scoped>
.product-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
.right-tools { display: flex; gap: 10px; align-items: center; }
.modern-table { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
.stock-num { font-weight: bold; font-size: 15px; }
.form-card { background: #fff; border-radius: 8px; margin-bottom: 20px; border: 1px solid #e4e7ed; overflow: hidden; }
.card-title { background: #fcfcfc; padding: 10px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.card-title .icon { font-size: 18px; }
.card-content { padding: 20px; }
.basic-card { border-left: 4px solid #409EFF; } .basic-card .icon { color: #409EFF; }
.inbound-card { border-left: 4px solid #67C23A; } .inbound-card .icon { color: #67C23A; }
.production-card { border-left: 4px solid #E6A23C; } .production-card .icon { color: #E6A23C; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; padding: 15px; margin: 10px 0; border-radius: 6px; }
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; padding: 0 5px; font-weight: bold; border-radius: 4px; }
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; }
.opt-spec { color: #8492a6; font-size: 12px; margin-right: 10px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background: #f5f7fa; border-bottom: 1px solid #dcdfe6; padding-left: 0; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; padding: 20px; border-top: 1px solid #ebeef5; }
.money-text { font-family: 'Consolas', monospace; color: #303133; }
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
.empty-preview { color: #909399; }
.more-images-badge { margin-left: 5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 6px; font-size: 12px; }
.clickable-text { color: #409EFF; cursor: pointer; font-weight: 500; text-decoration: underline; }
.id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; }
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
.camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; }
</style>

View File

@ -0,0 +1,856 @@
<template>
<div class="semi-module">
<div class="header-tools">
<div class="left-tools">
<el-input
v-model="queryParams.keyword"
placeholder="🔍 搜索物料 / 批号 / SN / 工单号 / BOM..."
class="search-input"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
style="width: 300px; margin-right: 10px;"
>
<template #append>
<el-button :icon="Search" @click="fetchData"/>
</template>
</el-input>
<el-select
v-model="queryParams.statuses"
multiple
collapse-tags
placeholder="状态筛选"
style="width: 220px;"
@change="fetchData"
>
<el-option label="在库" value="在库" />
<el-option label="借库" value="借库" />
<el-option label="已出库" value="已出库" />
</el-select>
</div>
<div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">半成品入库登记</el-button>
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
<template #reference>
<el-button :icon="Setting" class="action-btn">表头</el-button>
</template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<div class="col-group-title">基础信息</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in baseColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
<div class="col-group-title" style="margin-top:10px">生产与库存</div>
<el-row :gutter="10">
<el-col :span="12" v-for="c in stockColumns" :key="c.prop">
<el-checkbox :label="c.prop">{{ c.label }}</el-checkbox>
</el-col>
</el-row>
</el-checkbox-group>
</el-popover>
</div>
</div>
<el-table
v-loading="loading"
:data="tableData"
border
stripe
style="width: 100%"
class="modern-table"
highlight-current-row
header-cell-class-name="table-header-gray"
>
<template v-for="col in allColumns" :key="col.prop">
<el-table-column
v-if="visibleColumnProps.includes(col.prop)"
:prop="col.prop"
:label="col.label"
:min-width="col.minWidth || '140'"
show-overflow-tooltip
>
<template #default="scope" v-if="col.prop === 'material_name'">
<span class="clickable-text" @click="handleUpdate(scope.row)">
{{ scope.row.material_name }}
</span>
</template>
<template #default="scope" v-else-if="col.prop === 'sn_bn'">
<div v-if="scope.row.serial_number" class="id-cell">
<span class="prefix-tag sn">SN</span>
<span class="id-text">{{ scope.row.serial_number }}</span>
</div>
<div v-else-if="scope.row.batch_number" class="id-cell">
<span class="prefix-tag bn">BN</span>
<span class="id-text">{{ scope.row.batch_number }}</span>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.qty_stock }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_available'">
<span class="avail-num">{{ scope.row.qty_available }}</span>
</template>
<template #default="scope" v-else-if="col.prop === 'status'">
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>
{{ scope.row.status }}
</el-tag>
</template>
<template #default="scope" v-else-if="col.prop === 'quality_status'">
<el-tag :type="getQualityType(scope.row.quality_status)" effect="dark" size="small">
{{ scope.row.quality_status }}
</el-tag>
</template>
<template #default="scope" v-else-if="['arrival_photo', 'quality_report_link'].includes(col.prop)">
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
<el-image
style="width: 40px; height: 40px; border-radius: 4px; border: 1px solid #dcdfe6; cursor: zoom-in;"
:src="getImageUrl(getImagesOnly(scope.row[col.prop])[0])"
:preview-src-list="getImagesOnly(scope.row[col.prop]).map(u => getImageUrl(u))"
preview-teleported
fit="cover"
>
<template #error>
<div class="image-slot"><el-icon><Picture /></el-icon></div>
</template>
</el-image>
<span v-if="getImagesOnly(scope.row[col.prop]).length > 1" class="more-images-badge">+{{getImagesOnly(scope.row[col.prop]).length}}</span>
</div>
<div v-else-if="hasExternalLink(scope.row[col.prop])" style="text-align: center;">
<el-tag size="small" type="info"><el-icon><Link /></el-icon> 链接</el-tag>
</div>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="['detail_link'].includes(col.prop)">
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank"
:underline="false">
<el-icon>
<Link/>
</el-icon>
查看
</el-link>
</template>
<template #default="scope" v-else-if="['raw_material_cost', 'manual_cost', 'unit_total_cost'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="220" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="warning" size="default" @click="handlePrint(row)">
<el-icon>
<Printer/>
</el-icon>
打印
</el-button>
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条记录吗不可恢复" @confirm="handleDelete(row)" width="220">
<template #reference>
<el-button link type="danger" size="default">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination-bar"
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[15, 30, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="fetchData"
@current-change="fetchData"
/>
<el-dialog
v-model="visible"
:title="dialogStatus === 'create' ? '新增半成品入库' : '编辑半成品信息'"
width="1050px"
top="5vh"
destroy-on-close
:close-on-click-modal="false"
class="stylish-dialog"
>
<div class="dialog-scroll-container">
<el-form :model="form" label-width="100px" ref="formRef" :rules="rules" size="default" class="stylish-form">
<div class="form-card basic-card">
<div class="card-title">
<el-icon class="icon"><Box/></el-icon>
<span>1. 基础信息</span>
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
</div>
<div class="card-content">
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="10">
<el-form-item label="物料搜索" prop="base_id">
<el-select
v-model="form.base_id"
filterable
remote
reserve-keyword
placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span>
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="14" style="display: flex; align-items: center;">
<span class="search-tip">
<el-icon><InfoFilled/></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
</span>
</el-col>
</el-row>
<div class="read-only-grid">
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
</el-row>
</div>
</div>
</div>
<div class="form-card inbound-card">
<div class="card-title">
<el-icon class="icon"><House/></el-icon>
<span>2. 入库详情</span>
</div>
<div class="card-content">
<el-row :gutter="24">
<el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" placeholder="系统自动生成" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="入库日期" prop="in_date"><el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01"/></el-form-item></el-col>
</el-row>
<div class="identity-panel">
<el-row>
<el-col :span="24" style="margin-bottom: 8px;">
<el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked" size="small" class="custom-radio-group">
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
</el-radio-group>
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 根据历史记录已锁定</span>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="批号" prop="batch_number">
<el-input v-model="form.batch_number" :placeholder="entryMode === 'batch' ? '系统生成...' : '不可用'" :disabled="entryMode === 'serial'" clearable>
<template #prefix><span class="prefix-tag bn">BN</span></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="form.serial_number" :placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'" :disabled="entryMode === 'batch'" clearable>
<template #prefix><span class="prefix-tag sn">SN</span></template>
</el-input>
</el-form-item>
</el-col>
</el-row>
</div>
<el-row :gutter="24" style="margin-top: 15px;">
<el-col :span="6">
<el-form-item label="入库数量" prop="in_quantity">
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input"/>
</el-form-item>
</el-col>
<template v-if="dialogStatus === 'update'">
<el-col :span="6"><el-form-item label="当前库存" prop="stock_quantity"><el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="当前可用" prop="available_quantity"><el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false"/></el-form-item></el-col>
<el-col :span="6">
<el-form-item label="库存状态" prop="status">
<el-select v-model="form.status" style="width:100%">
<el-option label="在库" value="在库"/>
<el-option label="借库" value="借库"/>
<el-option label="已出库" value="已出库"/>
</el-select>
</el-form-item>
</el-col>
</template>
<el-col :span="6">
<el-form-item label="质量状态" prop="quality_status">
<el-select v-model="form.quality_status" style="width:100%">
<el-option label="待检" value="待检"><span style="color:#909399">⚪ 待检</span></el-option>
<el-option label="合格" value="合格"><span style="color:#67C23A">🟢 合格</span></el-option>
<el-option label="不合格" value="不合格"><span style="color:#F56C6C">🔴 不合格</span></el-option>
<el-option label="返修中" value="返修中"><span style="color:#E6A23C">🟠 返修中</span></el-option>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="24">
<el-form-item label="到货图片" prop="arrival_photo">
<div class="upload-container">
<el-upload v-model:file-list="arrivalFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'arrival_photo')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'arrival_photo')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('arrival_photo')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="form.arrival_photo" placeholder="图片列表" style="display:none;" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="质量报告" prop="quality_report_link">
<div class="upload-container">
<el-upload v-model:file-list="reportFileList" action="#" list-type="picture-card" multiple :http-request="(opts) => customUpload(opts, 'quality_report_link')" :on-preview="handlePreviewPicture" :on-remove="(file) => handleRemoveImage(file, 'quality_report_link')" :before-upload="beforeAvatarUpload">
<el-icon><Plus /></el-icon>
</el-upload>
<div class="camera-card" @click="triggerCamera('quality_report_link')"><el-icon><Camera /></el-icon><span class="text">拍照</span></div>
</div>
<el-input v-model="quality_report_url" placeholder="如有外部报告链接请在此输入 (选填)" style="margin-top: 8px;" clearable><template #prefix><el-icon><Link /></el-icon></template></el-input>
<el-input v-model="form.quality_report_link" placeholder="列表" style="display:none;" />
</el-form-item>
</el-col>
</el-row>
</div>
</div>
<div class="form-card production-card">
<div class="card-title">
<el-icon class="icon"><Setting/></el-icon>
<span>3. 生产与成本信息</span>
</div>
<div class="card-content">
<div class="divider-text">生产任务信息</div>
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" placeholder="WO-xxx"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" placeholder="BOM-xxx"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" placeholder="v1.0"/></el-form-item></el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="生产负责人">
<el-autocomplete v-model="form.production_manager" :fetch-suggestions="querySearchManager" placeholder="输入或选择负责人" style="width: 100%" clearable :trigger-on-focus="true" @select="handleManagerSelect">
<template #default="{ item }"><span>{{ item.value }}</span></template>
</el-autocomplete>
</el-form-item>
</el-col>
<el-col :span="16">
<el-form-item label="生产时间">
<el-date-picker v-model="form.production_time_range" type="datetimerange" range-separator="" start-placeholder="开始时间" end-placeholder="结束时间" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%"/>
</el-form-item>
</el-col>
</el-row>
<div class="divider-text">成本核算 (单件)</div>
<el-row :gutter="24">
<el-col :span="8"><el-form-item label="原材料成本"><el-input-number v-model="form.raw_material_cost" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="手动/工时"><el-input-number v-model="form.manual_cost" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单件总成本"><el-input-number v-model="form.unit_total_cost" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input"/></el-form-item></el-col>
</el-row>
<el-row :gutter="24" style="margin-top:10px">
<el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" placeholder="外部生产系统详情页 http://"/></el-form-item></el-col>
</el-row>
</div>
</div>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false" size="large">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }}
</el-button>
</div>
</template>
</el-dialog>
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
<div v-else class="empty-preview">正在生成预览...</div>
</div>
<div style="margin-top: 20px; font-size: 14px; color: #666;"><p>打印机 IP: 192.168.9.205</p><p>尺寸: 40mm x 30mm</p></div>
</div>
<template #footer>
<div class="dialog-footer"><el-button @click="printVisible = false">取消</el-button><el-button type="primary" :loading="printing" @click="confirmPrint"><el-icon><Printer/></el-icon>确认打印</el-button></div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, onMounted, watch} from 'vue'
import {Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer, Camera, Picture} from '@element-plus/icons-vue'
import {ElMessage} from 'element-plus'
import dayjs from 'dayjs'
import {
getSemiList,
createSemiInbound,
updateSemiInbound,
deleteSemiInbound,
searchMaterialBase
} from '@/api/inbound/semi'
import { uploadFile, deleteFile } from '@/api/inbound/buy'
import {getLabelPreview, executePrint} from '@/api/common/print'
// ------------------------------------
// 状态与变量
// ------------------------------------
const loading = ref(false)
const submitting = ref(false)
const visible = ref(false)
const searchLoading = ref(false)
const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([])
const total = ref(0)
const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '', statuses: ['在库', '借库'] })
const materialOptions = ref<any[]>([])
// 打印相关变量
const printVisible = ref(false)
const printLoading = ref(false)
const printing = ref(false)
const previewUrl = ref('')
const currentPrintData = ref<any>({})
// 图片/拍照相关
const dialogImageUrl = ref('')
const dialogVisibleImage = ref(false)
const arrivalFileList = ref<any[]>([])
const reportFileList = ref<any[]>([])
const cameraInputRef = ref<HTMLInputElement | null>(null)
const currentCameraField = ref<'arrival_photo' | 'quality_report_link'>('arrival_photo')
const quality_report_url = ref('')
const entryMode = ref('batch')
const modeLocked = ref(false)
// 列定义
const baseColumns = [
{prop: 'material_name', label: '名称'},
{prop: 'category', label: '类别'},
{prop: 'material_type', label: '类型'},
{prop: 'spec_model', label: '规格型号'},
{prop: 'unit', label: '单位'},
]
const stockColumns = [
{prop: 'id', label: 'ID', minWidth: '60'},
{prop: 'base_id', label: 'BaseID', minWidth: '80'},
{prop: 'sku', label: 'SKU', minWidth: '120'},
{prop: 'inbound_date', label: '入库日期', minWidth: '120'},
{prop: 'barcode', label: '条码', minWidth: '120'},
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
{prop: 'status', label: '状态', minWidth: '100'},
{prop: 'quality_status', label: '质量状态', minWidth: '100'},
{prop: 'qty_inbound', label: '入库量', minWidth: '100'},
{prop: 'qty_stock', label: '库存数', minWidth: '100'},
{prop: 'qty_available', label: '可用数', minWidth: '100'},
{prop: 'warehouse_loc', label: '库位', minWidth: '120'},
{prop: 'bom_code', label: 'BOM编号', minWidth: '120'},
{prop: 'bom_version', label: 'BOM版本', minWidth: '90'},
{prop: 'work_order_code', label: '工单号', minWidth: '120'},
{prop: 'raw_material_cost', label: '原料成本', minWidth: '100'},
{prop: 'manual_cost', label: '人工成本', minWidth: '100'},
{prop: 'unit_total_cost', label: '单件总本', minWidth: '100'},
{prop: 'production_manager', label: '生产负责人', minWidth: '100'},
{prop: 'production_start_time', label: '生产开始', minWidth: '160'},
{prop: 'production_end_time', label: '生产结束', minWidth: '160'},
{prop: 'arrival_photo', label: '到货图', minWidth: '100'},
{prop: 'quality_report_link', label: '质量报告', minWidth: '100'},
{prop: 'detail_link', label: '详情链接', minWidth: '100'},
]
const allColumns = [...baseColumns, ...stockColumns]
const STORAGE_KEY = 'stock_semi_visible_columns_v2'
const defaultColumns = ['material_name', 'spec_model', 'unit', 'inbound_date', 'sn_bn', 'status', 'quality_status', 'bom_code', 'work_order_code', 'qty_stock', 'qty_available', 'unit_total_cost', 'arrival_photo', 'quality_report_link']
const getSavedColumns = () => { try { const saved = localStorage.getItem(STORAGE_KEY); return saved ? JSON.parse(saved) : defaultColumns } catch (e) { return defaultColumns } }
const visibleColumnProps = ref(getSavedColumns())
watch(visibleColumnProps, (newVal) => { localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal)) }, {deep: true})
const form = reactive({
id: undefined, base_id: undefined as number | undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '',
sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '',
bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0,
production_manager: '', production_time_range: [] as string[], arrival_photo: [] as string[], quality_report_link: [] as string[], detail_link: ''
})
// ------------------------------------
// 历史记录管理器
// ------------------------------------
const HISTORY_KEYS = { PRODUCTION_MANAGER: 'history_production_managers', MATERIAL: 'history_semi_materials' }
const saveToHistory = (key: string, value: string) => {
if (!value) return
try {
const existing = localStorage.getItem(key)
let list = existing ? JSON.parse(existing) : []
list = list.filter((i: string) => i !== value)
list.unshift(value)
if (list.length > 20) list = list.slice(0, 20)
localStorage.setItem(key, JSON.stringify(list))
} catch (e) { console.error('save history failed', e) }
}
const getHistoryList = (key: string): any[] => { try { return (JSON.parse(localStorage.getItem(key) || '[]')).map((v: string) => ({value: v})) } catch (e) { return [] } }
const saveMaterialHistory = (item: any) => {
if (!item || !item.id) return
const key = HISTORY_KEYS.MATERIAL
try {
let list = JSON.parse(localStorage.getItem(key) || '[]')
list = list.filter((i: any) => i.id !== item.id)
list.unshift({...item, isHistory: true})
if (list.length > 10) list = list.slice(0, 10)
localStorage.setItem(key, JSON.stringify(list))
} catch (e) {}
}
const getMaterialHistory = () => { try { return JSON.parse(localStorage.getItem(HISTORY_KEYS.MATERIAL) || '[]') } catch (e) { return [] } }
// ------------------------------------
// Autocomplete & Search Logic
// ------------------------------------
const createFilter = (queryString: string) => { return (item: any) => (item.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0) }
const getTableDataUnique = (field: string) => { return Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean))).map(i => ({value: i})) }
const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => {
const tableList = getTableDataUnique(tableField)
const historyList = getHistoryList(storageKey)
const map = new Map()
historyList.forEach(i => map.set(i.value, i))
tableList.forEach(i => map.set(i.value, i))
const allList = Array.from(map.values())
const results = queryString ? allList.filter(createFilter(queryString)) : allList
cb(results)
}
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
// ------------------------------------
// Material Search (Matches Buy.vue)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterial('') }
const handleSearchMaterial = async (query: string) => {
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query)
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
if (!query) {
const history = getMaterialHistory()
const historyIds = new Set(history.map((h: any) => h.id))
materialOptions.value = [...history, ...apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))]
} else { materialOptions.value = apiResults }
} finally { searchLoading.value = false }
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (item) {
saveMaterialHistory(item)
// Populate form fields
form.material_name = item.name
form.spec_model = item.spec
form.category = item.category
form.unit = item.unit
form.material_type = item.type
// Trigger batch/serial logic specific to Semi
checkHistoryAndSetMode(item.id)
}
}
// ------------------------------------
// Validation Logic
// ------------------------------------
const validateUnique = (rule: any, value: string, callback: any) => {
if (!value) return callback()
const isDuplicate = tableData.value.some((row: any) => {
if (dialogStatus.value === 'update' && row.id === form.id) return false
if (rule.field === 'serial_number' && row.serial_number === value) return true
// 批号校验需要同时匹配物料
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
return false
})
if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
else callback()
}
const validateIdentity = (rule: any, value: any, callback: any) => {
if (entryMode.value === 'serial' && !form.serial_number && rule.field === 'serial_number') callback(new Error('SN必填'))
else if (entryMode.value === 'batch' && !form.batch_number && rule.field === 'batch_number') callback(new Error('批号必填'))
else callback()
}
const rules = {
base_id: [{required: true, message: '请选择物料', trigger: 'change'}],
in_quantity: [{required: true, message: '请输入数量', trigger: 'blur'}],
serial_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}],
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
}
// ------------------------------------
// Core Logic
// ------------------------------------
const checkHistoryAndSetMode = async (baseId: number) => {
try {
const res: any = await getSemiList({page: 1, pageSize: 1000})
const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId)
if (historyItems.length > 0) {
modeLocked.value = true
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
if (latest.serial_number) { entryMode.value = 'serial'; form.serial_number = ''; form.batch_number = '' }
else { entryMode.value = 'batch'; form.serial_number = ''; form.batch_number = incrementBatchNumber(latest.batch_number || '000000') }
} else { modeLocked.value = false; entryMode.value = 'batch'; form.batch_number = '000001' }
if(formRef.value) { formRef.value.clearValidate('serial_number'); formRef.value.clearValidate('batch_number') }
} catch (e) { modeLocked.value = false; entryMode.value = 'batch'; form.batch_number = '000001' }
}
const incrementBatchNumber = (batchStr: string) => {
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
const num = parseInt(batchStr, 10)
return (num + 1).toString().padStart(6, '0')
}
const handleEntryModeChange = (val: string) => {
if (val === 'batch') { form.serial_number = ''; form.batch_number = '000001'; if(formRef.value) formRef.value.clearValidate('serial_number') }
else { form.batch_number = ''; if(formRef.value) formRef.value.clearValidate('batch_number') }
}
watch(() => [form.raw_material_cost, form.manual_cost], () => { form.unit_total_cost = Number((form.raw_material_cost + form.manual_cost).toFixed(2)) })
const fetchData = async () => {
loading.value = true
try {
const params = { ...queryParams, statuses: queryParams.statuses.join(',') }
const res: any = await getSemiList(params)
tableData.value = res.data.items || []
total.value = res.data.total || 0
} finally { loading.value = false }
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = ''
visible.value = true
materialOptions.value = []
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
resetForm()
modeLocked.value = true
Object.assign(form, {
id: row.id, base_id: row.base_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category,
unit: row.unit, material_type: row.material_type, sku: row.sku, barcode: row.barcode, in_date: row.inbound_date,
warehouse_location: row.warehouse_loc, status: row.status, quality_status: row.quality_status,
in_quantity: Number(row.qty_inbound), stock_quantity: Number(row.qty_stock), available_quantity: Number(row.qty_available),
bom_code: row.bom_code, bom_version: row.bom_version, work_order_code: row.work_order_code,
raw_material_cost: Number(row.raw_material_cost) || 0, manual_cost: Number(row.manual_cost) || 0,
production_manager: row.production_manager,
production_time_range: (row.production_start_time && row.production_end_time) ? [row.production_start_time, row.production_end_time] : [],
detail_link: row.detail_link,
arrival_photo: row.arrival_photo || [], quality_report_link: row.quality_report_link || []
})
arrivalFileList.value = form.arrival_photo.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const reports = form.quality_report_link || []
const reportImgs = reports.filter(r => !isExternalLink(r))
const reportLinks = reports.filter(r => isExternalLink(r))
reportFileList.value = reportImgs.map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
quality_report_url.value = reportLinks.length > 0 ? reportLinks[0] : ''
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, isHistory: false }]
visible.value = true
}
const getImageUrl = (url: string) => { if (!url) return ''; return url.startsWith('http') ? url : url }
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
const hasExternalLink = (list: string[]) => { return !list ? false : list.some(item => isExternalLink(item)) }
const beforeAvatarUpload = (rawFile: any) => {
if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') { ElMessage.error('仅支持 JPG/PNG'); return false }
if (rawFile.size / 1024 / 1024 > 5) { ElMessage.error('图片不能超过 5MB'); return false }
return true
}
const customUpload = async (options: any, targetField: 'arrival_photo' | 'quality_report_link') => {
const { file, onSuccess, onError } = options
const formData = new FormData(); formData.append('file', file)
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; form[targetField].push(newUrl); ElMessage.success('上传成功'); onSuccess(res)
} else { ElMessage.error(res.msg || '上传失败'); onError(new Error(res.msg)) }
} catch (e) { ElMessage.error('网络错误'); onError(e) }
}
const handleRemoveImage = async (uploadFile: any, targetField: 'arrival_photo' | 'quality_report_link') => {
try {
const urlToRemove = form[targetField].find(u => getImageUrl(u) === uploadFile.url) || uploadFile.url
form[targetField] = form[targetField].filter(u => u !== urlToRemove)
if (!isExternalLink(urlToRemove)) { const filename = urlToRemove.split('/').pop(); if (filename) await deleteFile(filename) }
ElMessage.success('已删除')
} catch (e) { console.error(e) }
}
const handlePreviewPicture = (uploadFile: any) => { dialogImageUrl.value = uploadFile.url!; dialogVisibleImage.value = true }
const triggerCamera = (field: 'arrival_photo' | 'quality_report_link') => { currentCameraField.value = field; if (cameraInputRef.value) cameraInputRef.value.click() }
const handleCameraFile = async (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files && input.files[0]) {
const file = input.files[0]
if (!beforeAvatarUpload(file)) { input.value = ''; return }
const formData = new FormData(); formData.append('file', file)
const loadingMsg = ElMessage.loading({ message: '照片上传中...', duration: 0 })
try {
const res: any = await uploadFile(formData)
if (res.code === 200) {
const newUrl = res.data.url; const field = currentCameraField.value
form[field].push(newUrl)
if (field === 'arrival_photo') arrivalFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
else reportFileList.value.push({ name: newUrl.split('/').pop(), url: getImageUrl(newUrl) })
ElMessage.success('拍照上传成功')
} else { ElMessage.error(res.msg || '上传失败') }
} catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' }
}
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitting.value = true
const finalReportList = [...form.quality_report_link]
if (quality_report_url.value && !finalReportList.includes(quality_report_url.value)) finalReportList.push(quality_report_url.value)
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
if (quality_report_url.value) onlyImages.push(quality_report_url.value)
const payload: any = { ...form, quality_report_link: onlyImages, in_quantity: Number(form.in_quantity), raw_material_cost: Number(form.raw_material_cost), manual_cost: Number(form.manual_cost), production_start_time: form.production_time_range?.[0] || null, production_end_time: form.production_time_range?.[1] || null }
delete payload.production_time_range
try {
if (dialogStatus.value === 'create') {
const res: any = await createSemiInbound(payload)
ElMessage.success('入库成功')
const newItem = res.data
if (newItem) {
ElMessage.info('正在发送打印指令...')
try { await executePrint(newItem); ElMessage.success('打印指令已发送') }
catch (printErr: any) { ElMessage.warning('入库成功,但自动打印失败:' + (printErr.msg || '未知错误')) }
}
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
await fetchData(); visible.value = false
} catch (e: any) {
// 捕获后端报错
ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false }
}
})
}
const handleDelete = async (row: any) => { try { await deleteSemiInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
const handlePrint = async (row: any) => {
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
currentPrintData.value = { global_print_id: row.global_print_id, material_name: row.material_name, spec_model: row.spec_model, category: row.category, material_type: row.material_type, warehouse_loc: row.warehouse_loc, serial_number: row.serial_number, batch_number: row.batch_number, sku: row.sku }
try { const res: any = await getLabelPreview(currentPrintData.value); previewUrl.value = res.data } catch (e) { ElMessage.error('预览生成失败') } finally { printLoading.value = false }
}
const confirmPrint = async () => { printing.value = true; try { await executePrint(currentPrintData.value); ElMessage.success('指令已发送'); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', category: '', unit: '', material_type: '', sku: '', barcode: '', in_date: '', serial_number: '', batch_number: '', status: '在库', quality_status: '合格', in_quantity: 1, stock_quantity: 1, available_quantity: 1, warehouse_location: '', bom_code: '', bom_version: '', work_order_code: '', raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0, production_manager: '', production_time_range: [], arrival_photo: [], quality_report_link: [], detail_link: '' })
}
const getStatusType = (status: string) => { const map: any = { '在库': 'success', '出库': 'info', '借库': 'warning', '损耗': 'danger' }; return map[status] || 'warning' }
const getQualityType = (status: string) => { const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }; return map[status] || 'info' }
const formatMoney = (val: any) => { const num = Number(val); return isNaN(num) ? '-' : `¥ ${num.toFixed(2)}` }
onMounted(() => fetchData())
</script>
<style scoped>
.semi-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
.right-tools { display: flex; gap: 10px; align-items: center; }
.action-btn { font-weight: 500; }
.modern-table { border-radius: 8px; overflow: hidden; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
:deep(.table-header-gray th) { background-color: #f8f9fb !important; color: #606266; font-weight: 600; height: 50px; }
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
.tag-bn { color: #67C23A; font-weight: bold; font-family: monospace; }
.money-text { font-family: 'Consolas', monospace; color: #303133; }
.stock-num { font-weight: bold; color: #333; font-size: 15px; }
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
.id-cell { display: flex; align-items: center; }
.id-text { font-family: monospace; color: #606266; }
.stylish-form .form-card { background: #fff; border-radius: 8px; border: 1px solid #e4e7ed; margin-bottom: 20px; overflow: hidden; }
.card-title { background: #fcfcfc; padding: 12px 20px; border-bottom: 1px solid #ebeef5; font-weight: 600; font-size: 15px; color: #303133; display: flex; align-items: center; }
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
.card-content { padding: 20px; }
.basic-card { border-left: 4px solid #409EFF; }
.inbound-card { border-left: 4px solid #67C23A; }
.production-card { border-left: 4px solid #E6A23C; } .production-card .card-title .icon { color: #E6A23C; }
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 15px; margin-bottom: 20px; }
.custom-radio-group { margin-bottom: 10px; }
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
.prefix-tag { font-weight: bold; font-size: 12px; padding: 0 5px; border-radius: 4px; }
.prefix-tag.bn { color: #67C23A; background: #f0f9eb; }
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; }
.divider-text { display: flex; align-items: center; text-align: center; margin: 30px 0 20px; color: #909399; font-size: 14px; font-weight: 500; }
.divider-text::before, .divider-text::after { content: ''; flex: 1; border-bottom: 1px solid #ebeef5; }
.divider-text::before { margin-right: 15px; }
.divider-text::after { margin-left: 15px; }
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding: 20px; border-top: 1px solid #ebeef5; }
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
.opt-name { font-weight: bold; }
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
:deep(.el-upload-list--picture-card .el-upload-list__item) { width: 100px; height: 100px; }
.camera-card { width: 100px; height: 100px; background-color: #fbfdff; border: 1px dashed #c0ccda; border-radius: 6px; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; align-items: center; cursor: pointer; transition: all 0.3s; color: #8c939d; }
.camera-card:hover { border-color: #409EFF; color: #409EFF; }
.camera-card .text { font-size: 12px; margin-top: 5px; }
.camera-card .el-icon { font-size: 24px; }
</style>

Some files were not shown because too many files have changed in this diff Show More