添加半成品页面进行数据

This commit is contained in:
dxc
2026-01-28 17:44:39 +08:00
parent cd55a6aee1
commit b0df5c7458
16 changed files with 1649 additions and 71 deletions

View File

@ -1,38 +1,53 @@
# 文件路径: inventory-backend/app/__init__.py
from flask import Flask from flask import Flask
from config import Config from config import Config
from app.extensions import db, migrate, cors from app.extensions import db, migrate, cors
def create_app(): def create_app():
app = Flask(__name__) app = Flask(__name__)
app.config.from_object(Config) app.config.from_object(Config)
# 初始化插件 # 1. 初始化插件
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
# 确保跨域配置正确,允许前端访问
# 确保跨域配置
cors.init_app(app, resources={r"/api/*": {"origins": "*"}}) cors.init_app(app, resources={r"/api/*": {"origins": "*"}})
# --- 注册蓝图 --- # =========================================================
# 2. 注册蓝图 (Blueprints)
# =========================================================
# 1. 保持原有的 stocks 模块 # 注册入库聚合模块 (Inbound)
try:
from app.api.v1.stocks import stocks_bp
app.register_blueprint(stocks_bp, url_prefix='/api/v1/stocks')
except ImportError as e:
print(f"⚠️ 警告: 原有 stocks 蓝图导入失败: {e}")
# 2. 注册新的入库聚合蓝图
# 核心:必须先导入,再注册。路径对应 app/api/v1/inbound/__init__.py
try: try:
# 指向聚合文件: app/api/v1/inbound/__init__.py
from app.api.v1.inbound import inbound_bp from app.api.v1.inbound import inbound_bp
# 最终路径结构:/api/v1/inbound/buy/list
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
print("✅ 入库模块蓝图注册成功")
except ImportError as e:
print(f"❌ 严重错误: 入库模块 inbound 蓝图导入失败: {e}")
# 打印路由映射,仅在本地调试时建议开启 # 注册父蓝图,路由前缀为 /api/v1/inbound
# with app.app_context(): # 最终路由效果:
# print(app.url_map) # /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
# /api/v1/inbound + /semi/list -> /api/v1/inbound/semi/list
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
print("✅ Inbound (Buy & Semi) 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Inbound 模块导入失败: {e}")
# =========================================================
# 3. 预加载数据模型 (解决 relationship 找不到模型的问题)
# =========================================================
with app.app_context():
try:
# ✅ 修正点:引用新路径 (不再引用 app.models.stock)
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
from app.models.material import MaterialBase
# 如果是开发环境且没有迁移文件,可以取消注释下面这行来创建表
# db.create_all()
except ImportError as e:
print(f"⚠️ 模型预加载失败: {e}")
return app return app

View File

@ -1,18 +1,22 @@
from flask import Blueprint from flask import Blueprint
# 1. 导入同目录下的 buy 模块 (假设文件名为 buy.py) # 1. 导入子模块蓝图
# 注意:确保 .buy, .semi, .base 文件在同级目录下真实存在
from .buy import inbound_buy_bp from .buy import inbound_buy_bp
from .semi import inbound_semi_bp
# 2. 【关键修改】导入同目录下的 base 模块 # 如果你还有 base.py 文件,就取消注释下面这行
# 使用相对导入 .base这样 Python 就会去 app/api/v1/inbound/base.py 找
from .base import inbound_base_bp from .base import inbound_base_bp
# 创建父级蓝图 'inbound' # 2. 创建父级聚合蓝图
inbound_bp = Blueprint('inbound', __name__) inbound_bp = Blueprint('inbound', __name__)
# 3. 挂载子蓝图 # 3. 挂载子蓝图
# 最终路由将是: /api/v1/inbound/buy/... # 访问地址: /api/v1/inbound/buy/list
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy') inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
# 最终路由将是: /api/v1/inbound/base/... # 访问地址: /api/v1/inbound/semi/list
inbound_bp.register_blueprint(inbound_semi_bp, url_prefix='/semi')
# 如果有 base
inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base') inbound_bp.register_blueprint(inbound_base_bp, url_prefix='/base')

View File

@ -0,0 +1,91 @@
from flask import Blueprint, request, jsonify
from app.services.inbound.semi_service import SemiInboundService
import traceback
# 定义蓝图url_prefix 通常在注册蓝图时指定,例如 /api/v1/inbound/semi
inbound_semi_bp = Blueprint('inbound_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', '')
result = SemiInboundService.get_list(page, limit, keyword)
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
SemiInboundService.handle_inbound(data)
return jsonify({"code": 200, "msg": "入库成功"})
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

View File

@ -1,2 +1,13 @@
# app/models/__init__.py
# 1. 基础物料
from app.models.material import MaterialBase from app.models.material import MaterialBase
from app.models.stock import StockBuy
# 2. 采购入库 (指向新路径)
from app.models.inbound.buy import StockBuy
# 3. 半成品入库 (指向新路径)
from app.models.inbound.semi import StockSemi
# 如果有其他模型 (比如 sys_user 等),保留它们
# from app.models.sys_user import SysUser

View File

@ -1,4 +1,4 @@
# app/models/stock.py # app/models/buy.py
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime

View File

@ -0,0 +1,143 @@
# app/models/inbound/semi.py
from app.extensions import db
from datetime import datetime
class StockSemi(db.Model):
"""
半成品入库库存表
对应数据库表: stock_semi
"""
__tablename__ = 'stock_semi'
# =========================================================
# 1. 基础字段 (Strictly matching SQL Schema)
# =========================================================
# 主键
id = db.Column(db.Integer, primary_key=True)
# 外键关联 material_base 表
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# 身份标识
sku = db.Column(db.String(100))
# SQL字段名为 production_date, 对应前端的 "入库日期/生产日期"
production_date = db.Column(db.Date)
barcode = db.Column(db.String(100)) # 条码
serial_number = db.Column(db.String(100)) # 序列号
# 注意:提供的 SQL 中 stock_semi 没有 batch_number 字段,这里不定义,以免报错。
# 如果后续数据库加上了该字段,请取消下方注释:
# 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)) # 仓库位
# =========================================================
# 2. 半成品特有字段 (SQL 字段映射)
# =========================================================
# BOM 相关
# 数据库列名: bom_id, Python属性: bom_code (为了适配前端习惯)
bom_code = db.Column('bom_id', db.String(100))
bom_version = db.Column(db.String(50))
# 工单 相关
# 数据库列名: work_order_id, Python属性: work_order_code
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) # 手动/人工成本
# 生产信息
# 数据库列名: producer_name, Python属性: production_manager
production_manager = db.Column('producer_name', db.String(100))
# 生产起止时间 (SQL定义为 VARCHAR(255))
production_time_range = db.Column(db.String(255))
# 质量与链接
quality_status = db.Column(db.String(50)) # 质量状态
quality_report_link = db.Column(db.Text) # 质量报告链接
detail_link = db.Column(db.Text) # 详细信息链接
# =========================================================
# 3. 关系定义
# =========================================================
# 建立与 MaterialBase 的关系
# 注意:确保 MaterialBase 模型中定义了 back_populates='stock_semis'
material = db.relationship('MaterialBase', back_populates='stock_semis')
def to_dict(self):
"""
序列化将模型转换为字典供API返回JSON使用
在这里处理字段名称转换,确保前端能正确显示数据
"""
# 计算单件总成本 (原料 + 人工)
raw_val = float(self.raw_material_cost or 0)
man_val = float(self.manual_cost or 0)
unit_total = raw_val + man_val
return {
'id': self.id,
'base_id': self.base_id,
# --- 级联基础信息 (防止 None 报错) ---
'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,
# 将 production_date 映射回前端通用的 inbound_date
'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, # SQL无此字段暂不返回
'warehouse_loc': self.warehouse_location,
'status': self.status,
# --- 数量 (转为float防止json序列化报错) ---
'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,
# 时间范围 (SQL存的是字符串直接返回即可或者根据需要拆分)
# 如果 service 层存的是 "Start ~ End",这里直接返回
'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,
'quality_report_link': self.quality_report_link,
'detail_link': self.detail_link
}

View File

@ -10,7 +10,7 @@ class MaterialBase(db.Model):
""" """
__tablename__ = 'material_base' __tablename__ = 'material_base'
# 1. 基础字段 (必须与 SQL 建表语句完全一致) # 1. 基础字段 (保持不变)
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, comment='基础信息名称') name = db.Column(db.String(255), nullable=False, comment='基础信息名称')
@ -32,26 +32,31 @@ class MaterialBase(db.Model):
manual_link = db.Column(db.Text, comment='通用说明书链接') manual_link = db.Column(db.Text, comment='通用说明书链接')
product_image = db.Column(db.Text, comment='通用产品图链接') product_image = db.Column(db.Text, comment='通用产品图链接')
# 启用状态 (注意SQL中是 boolean) # 启用状态
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用') is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
# ============================================================ # ============================================================
# ⚠️ 注意:你之前提供的 SQL 建表语句中【没有】下面这两个时间字段。 # 时间字段 (保持你原本的注释状态,以免报错)
# 如果数据库里没有这两列,代码运行到这里会报错 (UndefinedColumn)。
# 我先将其注释掉。如果你确认数据库已经 Alter Table 加了这两列,请取消注释。
# ============================================================ # ============================================================
# create_time = db.Column(db.DateTime, default=datetime.utcnow) # create_time = db.Column(db.DateTime, default=datetime.utcnow)
# update_time = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) # update_time = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# 【核心关联】 # ============================================================
# 关联采购库存表 (StockBuy)lazy='dynamic' 允许后续做 .count() 统计 # 关联关系区域 (修改重点)
# 确保 app/models/stock.py 中有 back_populates='material' # ============================================================
# 1. 关联采购库存 (StockBuy) - 保持不变
# 注意:确保 app/models/inbound/buy.py 中的 StockBuy 定义了 back_populates='material'
stock_buys = db.relationship('StockBuy', back_populates='material', lazy='dynamic') stock_buys = db.relationship('StockBuy', back_populates='material', lazy='dynamic')
# 2. 【新增】关联半成品库存 (StockSemi)
# 注意:确保 app/models/inbound/semi.py 中的 StockSemi 定义了 back_populates='material'
# 这样以后可以通过 material.stock_semis 来访问该物料下的所有半成品库存记录
stock_semis = db.relationship('StockSemi', back_populates='material', lazy='dynamic')
def to_dict(self): def to_dict(self):
""" """
序列化方法将模型转换为字典供API返回JSON使用 序列化方法将模型转换为字典供API返回JSON使用
这里是解决【前端表格空白】最关键的地方
""" """
return { return {
'id': self.id, 'id': self.id,
@ -59,7 +64,7 @@ class MaterialBase(db.Model):
'category': self.category, 'category': self.category,
# ========================================= # =========================================
# 关键映射区 (解决前后端字段名不一致问题) # 关键映射区 (保持不变)
# ========================================= # =========================================
# 数据库叫 material_type -> 前端叫 type # 数据库叫 material_type -> 前端叫 type
'type': self.material_type, 'type': self.material_type,
@ -74,11 +79,9 @@ class MaterialBase(db.Model):
'generalManual': self.manual_link, 'generalManual': self.manual_link,
'generalImage': self.product_image, 'generalImage': self.product_image,
# 状态处理:前端 Switch 通常接受 boolean 或 1/0 # 状态处理
# 数据库里的 true -> 返回 1 (启用)
# 数据库里的 false/None -> 返回 0 (禁用)
'isEnabled': 1 if self.is_enabled else 0, 'isEnabled': 1 if self.is_enabled else 0,
# 如果上方注释了 create_time这里也要注释否则会报错 # 时间字段保持注释
# 'createTime': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(self, 'create_time') and self.create_time else None # 'createTime': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(self, 'create_time') and self.create_time else None
} }

View File

@ -1,18 +1,62 @@
# 文件路径: app/services/inbound/base_service.py
from app.extensions import db from app.extensions import db
from app.models.material import MaterialBase from app.models.material import MaterialBase
from app.models.stock import StockBuy # 需要引入库存表做删除时的依赖检查
# ==============================================================================
# ✅ 正确的引用方式
# ==============================================================================
from app.models.inbound.buy import StockBuy # 引用采购库存模型
from app.models.inbound.semi import StockSemi # 引用半成品库存模型
from sqlalchemy import or_ from sqlalchemy import or_
import traceback import traceback
class MaterialBaseService: class MaterialBaseService:
"""
基础物料服务层
负责处理 MaterialBase 的增删改查及搜索逻辑
"""
@staticmethod
def search_material(keyword):
"""
根据关键字搜索已启用的基础物料
(供 /api/v1/inbound/base/search 接口调用)
"""
try:
if not keyword:
return []
# 搜索名称或规格型号,且必须是启用的
query = MaterialBase.query.filter(
MaterialBase.is_enabled == True,
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%')
)
).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 []
@staticmethod @staticmethod
def get_list(page, limit, filters=None): def get_list(page, limit, filters=None):
""" """
获取基础信息列表 获取基础信息列表 (带分页和筛选)
:param page: 页码
:param limit: 每页条数
:param filters: 筛选条件字典 {keyword, category, type, isEnabled}
""" """
try: try:
query = MaterialBase.query query = MaterialBase.query
@ -46,7 +90,6 @@ class MaterialBaseService:
except Exception as e: except Exception as e:
print(f"查询基础信息列表失败: {e}") print(f"查询基础信息列表失败: {e}")
# 生产环境建议记录日志
return {"total": 0, "items": []} return {"total": 0, "items": []}
@staticmethod @staticmethod
@ -65,16 +108,16 @@ class MaterialBaseService:
if exist: if exist:
raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})") raise ValueError(f"已存在相同名称和规格的数据 (ID: {exist.id})")
# 2. 创建对象 (注意前端驼峰 -> 后端下划线映射) # 2. 创建对象
new_material = MaterialBase( new_material = MaterialBase(
name=data['name'], name=data['name'],
spec_model=data['spec'], # 映射 spec_model=data['spec'],
category=data.get('category'), category=data.get('category'),
material_type=data.get('type'), # 映射 material_type=data.get('type'),
unit=data.get('unit'), unit=data.get('unit'),
visibility_level=data.get('visibilityLevel'), # 映射 visibility_level=data.get('visibilityLevel'),
manual_link=data.get('generalManual'), # 映射 manual_link=data.get('generalManual'),
product_image=data.get('generalImage'), # 映射 product_image=data.get('generalImage'),
is_enabled=True if data.get('isEnabled', 1) == 1 else False is_enabled=True if data.get('isEnabled', 1) == 1 else False
) )
@ -94,7 +137,7 @@ class MaterialBaseService:
if not material: if not material:
raise ValueError("数据不存在") raise ValueError("数据不存在")
# 更新字段 (仅更新传入的字段) # 更新字段
if 'name' in data: material.name = data['name'] if 'name' in data: material.name = data['name']
if 'spec' in data: material.spec_model = data['spec'] if 'spec' in data: material.spec_model = data['spec']
if 'category' in data: material.category = data['category'] if 'category' in data: material.category = data['category']
@ -116,19 +159,32 @@ class MaterialBaseService:
@staticmethod @staticmethod
def delete_material(m_id): def delete_material(m_id):
"""删除基础信息 (带依赖检查)""" """
删除基础信息 (带依赖检查)
✅ 已升级:同时检查采购库(Buy)和半成品库(Semi)
"""
try: try:
material = MaterialBase.query.get(m_id) material = MaterialBase.query.get(m_id)
if not material: if not material:
raise ValueError("数据不存在") raise ValueError("数据不存在")
# 1. 依赖检查:如果该基础信息已经在库存表(StockBuy)中使用,禁止物理删除 # 1. 依赖检查:采购入库引用
# 这里假设 StockBuy 表有一个外键或字段指向 MaterialBase (e.g., base_id) buy_usage_count = StockBuy.query.filter_by(base_id=m_id).count()
usage_count = StockBuy.query.filter_by(base_id=m_id).count()
if usage_count > 0:
raise ValueError(f"无法删除:该基础信息已被 {usage_count} 条库存记录引用,请先清理库存或仅禁用此条目。")
# 2. 执行删除 # 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.delete(material)
db.session.commit() db.session.commit()
return True return True

View File

@ -1,8 +1,8 @@
from app.extensions import db from app.extensions import db
from app.models.inbound.buy import StockBuy
from app.models.material import MaterialBase from app.models.material import MaterialBase
from app.models.stock import StockBuy
from datetime import datetime from datetime import datetime
from sqlalchemy import or_, func # 引入 func 用于聚合计算 from sqlalchemy import or_, func
import traceback import traceback

View File

@ -0,0 +1,339 @@
from app.extensions import db
from app.models.material import MaterialBase
from app.models.inbound.semi import StockSemi
from datetime import datetime
from sqlalchemy import or_, func
import traceback
class SemiInboundService:
@staticmethod
def search_base_material(keyword):
"""
搜索基础物料,逻辑与采购入库基本一致。
如果需要只搜索'半成品'类型的物料,可以在 filter 中增加条件。
"""
try:
if not keyword:
return []
query = MaterialBase.query.filter(
MaterialBase.is_enabled == True,
or_(
MaterialBase.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,
'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 handle_inbound(data):
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} 的基础物料不存在")
# 1. 处理入库日期
in_date_val = datetime.utcnow().date()
if data.get('in_date'):
try:
date_str = str(data['in_date'])[:10]
in_date_val = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError:
pass
# 2. 处理生产时间 (前端传来的是字符串 "YYYY-MM-DD HH:mm:ss")
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
# 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. 创建记录
new_stock = StockSemi(
base_id=material.id,
sku=data.get('sku'),
in_date=in_date_val,
# 标识信息
serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'),
barcode=data.get('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,
# 成本信息 (半成品特有)
raw_material_cost=raw_cost,
manual_cost=manual_cost,
unit_total_cost=unit_total_cost,
total_price=total_value, # 数据库字段可能复用 total_price 或叫 total_value
# 链接
quality_report_link=data.get('quality_report_link'),
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()
raise e
@staticmethod
def update_inbound(stock_id, data):
try:
print(f"----- UPDATE SEMI DEBUG: ID={stock_id} -----")
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
# 1. 简单字段映射
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',
'quality_report_link': 'quality_report_link',
'detail_link': 'detail_link'
}
for frontend_key, db_attr in field_mapping.items():
if frontend_key in data:
setattr(stock, db_attr, data[frontend_key])
# 2. 处理时间更新
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
# 3. 处理数量和成本变更联动
qty_changed = False
cost_changed = False
# 更新数量
if 'in_quantity' in data:
new_qty = float(data['in_quantity'])
old_qty = float(stock.in_quantity)
if new_qty != old_qty:
diff = new_qty - old_qty
stock.in_quantity = new_qty
stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff
qty_changed = True
# 更新成本 (原材料 or 人工)
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:
stock.unit_total_cost = float(stock.raw_material_cost) + float(stock.manual_cost)
if cost_changed or qty_changed:
stock.total_price = float(stock.in_quantity) * float(stock.unit_total_cost)
db.session.commit()
print("----- UPDATE SEMI SUCCESS -----")
return stock
except Exception as e:
db.session.rollback()
print(f"----- UPDATE SEMI FAILED: {str(e)} -----")
traceback.print_exc()
raise e
@staticmethod
def delete_inbound(stock_id):
try:
stock = StockSemi.query.get(stock_id)
if not stock:
raise ValueError("记录不存在")
db.session.delete(stock)
db.session.commit()
return True
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_list(page, limit, keyword=None):
try:
# 1. 查询分页数据
query = db.session.query(StockSemi).outerjoin(MaterialBase, StockSemi.base_id == MaterialBase.id)
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
StockSemi.batch_number.ilike(f'%{keyword}%'),
StockSemi.serial_number.ilike(f'%{keyword}%'),
StockSemi.sku.ilike(f'%{keyword}%'),
# 增加半成品特有的搜索字段
StockSemi.work_order_code.ilike(f'%{keyword}%'),
StockSemi.bom_code.ilike(f'%{keyword}%')
)
)
pagination = query.order_by(StockSemi.id.desc()).paginate(page=page, per_page=limit, error_out=False)
# 2. 聚合统计 (计算该物料的总库存)
current_items = pagination.items
base_ids = list(set([item.base_id for item in current_items if item.base_id]))
stock_map = {}
if base_ids:
aggregates = db.session.query(
StockSemi.base_id,
func.sum(StockSemi.stock_quantity).label('total_stock'),
func.sum(StockSemi.available_quantity).label('total_avail')
).filter(StockSemi.base_id.in_(base_ids)).group_by(StockSemi.base_id).all()
for agg in aggregates:
stock_map[agg.base_id] = {
'total_stock': float(agg.total_stock or 0),
'total_avail': float(agg.total_avail or 0)
}
items = []
for item in current_items:
mat_name = item.material.name if item.material else '未知物料'
mat_spec = item.material.spec_model if item.material else ''
mat_cat = item.material.category if item.material else ''
mat_unit = item.material.unit if item.material else ''
mat_type = item.material.material_type if item.material else ''
stats = stock_map.get(item.base_id, {'total_stock': 0, 'total_avail': 0})
d = {
'id': item.id,
'base_id': item.base_id,
'material_name': mat_name,
'spec_model': mat_spec,
'category': mat_cat,
'unit': mat_unit,
'material_type': mat_type,
'sku': item.sku,
'inbound_date': str(item.in_date) if item.in_date else '',
'barcode': item.barcode,
'serial_number': item.serial_number,
'batch_number': item.batch_number,
'status': item.status,
'quality_status': item.quality_status, # 半成品使用质量状态
'qty_inbound': float(item.in_quantity or 0),
'qty_stock': float(item.stock_quantity or 0),
'qty_available': float(item.available_quantity or 0),
'sum_stock': stats['total_stock'],
'sum_available': stats['total_avail'],
'warehouse_loc': item.warehouse_location,
# 半成品特有字段返回
'bom_code': item.bom_code,
'bom_version': item.bom_version,
'work_order_code': item.work_order_code,
'raw_material_cost': float(item.raw_material_cost or 0),
'manual_cost': float(item.manual_cost or 0),
'unit_total_cost': float(item.unit_total_cost or 0),
'production_manager': item.production_manager,
'production_start_time': str(item.production_start_time) if item.production_start_time else '',
'production_end_time': str(item.production_end_time) if item.production_end_time else '',
'quality_report_link': item.quality_report_link,
'detail_link': item.detail_link,
'remark': item.remark
}
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

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <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" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>inventory-web</title> <title>inventory-web</title>
</head> </head>

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

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

@ -1,11 +1,883 @@
<script setup lang="ts">
</script>
<template> <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"
>
<template #append>
<el-button :icon="Search" @click="fetchData" />
</template>
</el-input>
</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="['serial_number', 'batch_number'].includes(col.prop)">
<span v-if="scope.row[col.prop]" :class="col.prop === 'serial_number' ? 'tag-sn' : 'tag-bn'">
{{ scope.row[col.prop] }}
</span>
<span v-else class="text-placeholder">-</span>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
<span class="stock-num">{{ scope.row.sum_stock }}</span>
<el-tag size="small" type="info" effect="plain" class="sum-tag"></el-tag>
</template>
<template #default="scope" v-else-if="col.prop === 'qty_available'">
<span class="avail-num">{{ scope.row.sum_available }}</span>
<el-tag size="small" type="info" effect="plain" class="sum-tag"></el-tag>
</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="['quality_report_link', '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'].includes(col.prop)">
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
</template>
</el-table-column>
</template>
<el-table-column label="操作" width="160" fixed="right" align="center">
<template #default="{ row }">
<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"
>
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="large" 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="14">
<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"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
size="large"
>
<el-option
v-for="item in materialOptions"
:key="item.id"
:label="item.name + ' [' + item.spec + ']'"
:value="item.id"
>
<div class="option-item">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-spec">{{ item.spec }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="10">
<div class="info-alert">
<el-icon><InfoFilled /></el-icon> 仅展示状态为启用的基础物料
</div>
</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="选填" /></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: 12px;">
<el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked" 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-col :span="12"><el-form-item label="质量报告" prop="quality_report_link"><el-input v-model="form.quality_report_link" placeholder="报告链接 URL" /></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-input v-model="form.production_manager" /></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%">
<template #prefix>¥</template>
</el-input-number>
</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%">
<template #prefix>¥</template>
</el-input-number>
</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">
<template #prefix>¥</template>
</el-input-number>
</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>
<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>
</div>
</template> </template>
<style scoped> <script setup lang="ts">
import { ref, reactive, onMounted, watch, computed } from 'vue'
import { Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
// 假设这是半成品入库的API文件
import {
getSemiList,
createSemiInbound,
updateSemiInbound,
deleteSemiInbound,
searchMaterialBase
} from '@/api/inbound/semi'
// ------------------------------------
// 状态与变量
// ------------------------------------
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: '' })
const materialOptions = ref<any[]>([])
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: 'serial_number', label: '序列号', minWidth: '150' },
{ prop: 'batch_number', label: '批号', minWidth: '150' },
{ prop: 'status', label: '状态', minWidth: '100' },
{ prop: 'quality_status', label: '质量状态', minWidth: '100' }, // 对应 inspection_status
{ 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: 'production_manager', label: '生产负责人', minWidth: '100' },
{ prop: 'production_start_time', label: '生产开始', minWidth: '160' },
{ prop: 'production_end_time', label: '生产结束', minWidth: '160' },
{ prop: 'quality_report_link', label: '质量报告', minWidth: '100' },
{ prop: 'detail_link', label: '详情链接', minWidth: '100' },
]
const allColumns = [...baseColumns, ...stockColumns]
// 表头持久化
const STORAGE_KEY = 'stock_semi_visible_columns'
const defaultColumns = [
'material_name', 'spec_model', 'unit',
'inbound_date', 'serial_number', 'batch_number', 'status', 'quality_status',
'bom_code', 'work_order_code', 'qty_stock', 'qty_available'
]
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: '合格', // 原 inspection_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[], // [start, end]
quality_report_link: '',
detail_link: ''
})
// ------------------------------------
// 逻辑校验规则
// ------------------------------------
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) 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 getSemiList({ page: 1, pageSize: 1000 })
const allItems = res.data.items || []
const historyItems = allItems.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 = ''
const lastBatch = latest.batch_number || '000000'
form.batch_number = incrementBatchNumber(lastBatch)
}
} 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) {
console.error(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')
}
}
const handleSearchMaterial = async (query: string) => {
if (query) {
searchLoading.value = true
try {
const res: any = await searchMaterialBase(query)
materialOptions.value = res.data || []
} finally {
searchLoading.value = false
}
} else {
materialOptions.value = []
}
}
const onMaterialSelected = (val: number) => {
const item = materialOptions.value.find(i => i.id === val)
if (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)
}
}
// 自动计算单件总成本
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 res: any = await getSemiList(queryParams)
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
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
resetForm()
modeLocked.value = true
form.id = row.id
form.base_id = row.base_id
form.material_name = row.material_name
form.spec_model = row.spec_model
form.category = row.category
form.unit = row.unit
form.material_type = row.material_type
form.sku = row.sku
form.barcode = row.barcode
form.in_date = row.inbound_date
form.warehouse_location = row.warehouse_loc
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 = ''
}
form.status = row.status
form.quality_status = row.quality_status // 质量状态
form.in_quantity = Number(row.qty_inbound) || 0
form.stock_quantity = Number(row.qty_stock) || 0
form.available_quantity = Number(row.qty_available) || 0
// 映射新字段
form.bom_code = row.bom_code
form.bom_version = row.bom_version
form.work_order_code = row.work_order_code
form.raw_material_cost = Number(row.raw_material_cost) || 0
form.manual_cost = Number(row.manual_cost) || 0
form.production_manager = row.production_manager
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 = []
}
form.quality_report_link = row.quality_report_link
form.detail_link = row.detail_link
visible.value = true
}
const submitForm = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => {
if (valid) {
submitting.value = true
try {
// 解构时间范围
const payload: any = {
...form,
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 // 后端不需要数组
if (dialogStatus.value === 'create') {
await createSemiInbound(payload)
ElMessage.success('入库成功')
} else {
await updateSemiInbound(form.id!, payload)
ElMessage.success('更新成功')
}
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 resetForm = () => {
materialOptions.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: [],
quality_report_link: '', detail_link: ''
})
}
const getStatusType = (status: string) => {
const map: any = { '在库': 'success', '出库': 'info', '损耗': '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 { flex: 0 0 350px; }
.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; }
.sum-tag { margin-left: 4px; transform: scale(0.9); }
/* 弹窗与表单美化 */
.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; }
.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; }
/* 入库信息卡片 */
.inbound-card { border-left: 4px solid #67C23A; }
/* 生产与成本卡片 (橙色调) */
.production-card { border-left: 4px solid #E6A23C; }
.production-card .card-title .icon { color: #E6A23C; }
/* 身份区域 (SN/BN) */
.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; }
.info-alert { font-size: 12px; color: #909399; margin-top: 10px; display: flex; align-items: center; gap: 5px; }
.option-item { display: flex; justify-content: space-between; width: 100%; }
.opt-name { font-weight: bold; }
.opt-spec { color: #8492a6; font-size: 13px; }
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
</style> </style>