采购件,半成品,产品页面初步完成

This commit is contained in:
dxc
2026-01-29 09:27:56 +08:00
parent b0df5c7458
commit 06ba2d7563
11 changed files with 836 additions and 244 deletions

View File

@ -1,4 +1,5 @@
# 文件路径: inventory-backend/app/__init__.py
from flask import Flask
from config import Config
from app.extensions import db, migrate, cors
@ -22,15 +23,18 @@ def create_app():
# 注册入库聚合模块 (Inbound)
try:
# 指向聚合文件: app/api/v1/inbound/__init__.py
# 该文件里应该包含了 buy, semi, base, product 的聚合逻辑
from app.api.v1.inbound import inbound_bp
# 注册父蓝图,路由前缀为 /api/v1/inbound
# 最终路由效果:
# /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
# /api/v1/inbound + /semi/list -> /api/v1/inbound/semi/list
# /api/v1/inbound + /buy/list -> /api/v1/inbound/buy/list
# /api/v1/inbound + /semi/list -> /api/v1/inbound/semi/list
# /api/v1/inbound + /product/list -> /api/v1/inbound/product/list
# /api/v1/inbound + /base/search -> /api/v1/inbound/base/search
app.register_blueprint(inbound_bp, url_prefix='/api/v1/inbound')
print("✅ Inbound (Buy & Semi) 模块注册成功")
print("✅ Inbound (Buy, Semi, Product, Base) 模块注册成功")
except ImportError as e:
print(f"❌ 错误: Inbound 模块导入失败: {e}")
@ -40,14 +44,22 @@ def create_app():
# =========================================================
with app.app_context():
try:
# ✅ 修正点:引用新路径 (不再引用 app.models.stock)
from app.models.inbound.buy import StockBuy
from app.models.inbound.semi import StockSemi
# 1. 基础物料
from app.models.material import MaterialBase
# 2. 采购入库
from app.models.inbound.buy import StockBuy
# 3. 半成品入库
from app.models.inbound.semi import StockSemi
# 4. 成品入库 (新增)
from app.models.inbound.product import StockProduct
# 如果是开发环境且没有迁移文件,可以取消注释下面这行来创建表
# 开发环境如果需要自动建表,可以取消注释
# db.create_all()
except ImportError as e:
# 建议打印错误,防止因为文件名拼写错误导致静默失败
print(f"⚠️ 模型预加载失败: {e}")
except Exception as e:
print(f"⚠️ 模型预加载发生未知错误: {e}")
return app

View File

@ -1,22 +1,14 @@
from flask import Blueprint
# 1. 导入子模块蓝图
# 注意:确保 .buy, .semi, .base 文件在同级目录下真实存在
from .buy import inbound_buy_bp
from .semi import inbound_semi_bp
# 如果你还有 base.py 文件,就取消注释下面这行
from .base import inbound_base_bp
# 导入 product
from .product import inbound_product_bp
# 2. 创建父级聚合蓝图
inbound_bp = Blueprint('inbound', __name__)
# 3. 挂载子蓝图
# 访问地址: /api/v1/inbound/buy/list
inbound_bp.register_blueprint(inbound_buy_bp, url_prefix='/buy')
# 访问地址: /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')
# 挂载 product前缀改为 /product
inbound_bp.register_blueprint(inbound_product_bp, url_prefix='/product')

View File

@ -1,51 +1,55 @@
# 文件路径: app/api/v1/inbound/base.py
from flask import Blueprint, request, jsonify
# 修改为这一行,指向 app/services/inbound/base_service.py
from app.services.inbound.base_service import MaterialBaseService
import traceback
# 定义蓝图
# name='inbound_base' 确保全局唯一,防止和其他蓝图重名
inbound_base_bp = Blueprint('inbound_base', __name__)
# ------------------------------------------------------------------
# 1. 获取基础信息列表 (GET)
# 路由: /api/v1/inbound/base/list
# ------------------------------------------------------------------
@inbound_base_bp.route('/list', methods=['GET'])
def get_list():
# ==============================================================================
# 1. 搜索接口 (GET /api/v1/inbound/base/search)
# ==============================================================================
@inbound_base_bp.route('/search', methods=['GET'])
def search_base():
try:
# 获取分页参数
page = request.args.get('pageNum', 1, type=int)
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')
}
# 调用 Service 层逻辑
result = MaterialBaseService.get_list(page, limit, filters)
return jsonify({
"code": 200,
"msg": "success",
"data": result
})
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. 新增基础信息 (POST)
# 路由: /api/v1/inbound/base/
# ------------------------------------------------------------------
# ==============================================================================
# 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 add_material():
def create():
try:
data = request.get_json()
if not data:
@ -53,44 +57,37 @@ def add_material():
MaterialBaseService.create_material(data)
return jsonify({"code": 200, "msg": "新增成功"})
except ValueError as ve:
# 捕获业务逻辑验证错误如名称重复)
return jsonify({"code": 400, "msg": str(ve)}), 400
except ValueError as e:
# 捕获业务逻辑验证错误 (如名称为空)
return jsonify({"code": 400, "msg": str(e)}), 400
except Exception as e:
# 捕获系统错误
traceback.print_exc()
return jsonify({"code": 500, "msg": "系统错误"}), 500
return jsonify({"code": 500, "msg": f"系统错误: {str(e)}"}), 500
# ------------------------------------------------------------------
# 3. 修改基础信息 (PUT)
# 路由: /api/v1/inbound/base/
# ------------------------------------------------------------------
@inbound_base_bp.route('/', methods=['PUT'])
def update_material():
# ==============================================================================
# 4. 修改接口 (PUT /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['PUT'])
def update(id):
try:
data = request.get_json()
if not data or not data.get('id'):
return jsonify({"code": 400, "msg": "ID不能为空"}), 400
MaterialBaseService.update_material(data.get('id'), data)
return jsonify({"code": 200, "msg": "更新成功"})
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
# ------------------------------------------------------------------
# 4. 删除基础信息 (DELETE)
# 路由: /api/v1/inbound/base/<id>
# ------------------------------------------------------------------
# ==============================================================================
# 5. 删除接口 (DELETE /api/v1/inbound/base/<id>)
# ==============================================================================
@inbound_base_bp.route('/<int:id>', methods=['DELETE'])
def delete_material(id):
def delete(id):
try:
MaterialBaseService.delete_material(id)
return jsonify({"code": 200, "msg": "删除成功"})
except ValueError as ve:
# 捕获依赖检查错误(如已被库存引用)
return jsonify({"code": 400, "msg": str(ve)}), 400
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -0,0 +1,50 @@
from flask import Blueprint, request, jsonify
# 引用更名后的服务
from app.services.inbound.product_service import ProductInboundService
import traceback
# 蓝图命名改为 inbound_product_bp
inbound_product_bp = Blueprint('inbound_product', __name__)
@inbound_product_bp.route('/search-base', methods=['GET'])
def search_base():
try:
data = ProductInboundService.search_base_material(request.args.get('keyword', ''))
return jsonify({"code": 200, "msg": "success", "data": data})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@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', '')
result = ProductInboundService.get_list(page, limit, keyword)
return jsonify({"code": 200, "msg": "success", "data": result})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@inbound_product_bp.route('/submit', methods=['POST'])
def submit():
try:
ProductInboundService.handle_inbound(request.get_json())
return jsonify({"code": 200, "msg": "入库成功"})
except Exception as e:
return jsonify({"code": 500, "msg": str(e)}), 500
@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:
return jsonify({"code": 500, "msg": str(e)}), 500
@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:
return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -1,6 +1,5 @@
# app/models/buy.py
# app/models/inbound/buy.py
from app.extensions import db
from datetime import datetime
class StockBuy(db.Model):
@ -10,74 +9,54 @@ class StockBuy(db.Model):
"""
__tablename__ = 'stock_buy'
# 主键
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))
in_date = db.Column(db.Date)
barcode = db.Column(db.String(100)) # 条码
serial_number = db.Column(db.String(100)) # 序列号
batch_number = db.Column(db.String(100)) # 批号
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))
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)
# --- 状态与位置 ---
status = db.Column(db.String(50)) # 在库/出库/损耗
inspection_status = db.Column(db.String(50)) # 未检/合格/不合格
warehouse_location = db.Column(db.String(100))
# --- 财务与商务 ---
# 财务与商务
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))
# [关键映射区]Python属性名 = DB列名
# 前端传 purchaser -> 存入 buyer_name
buyer_name = db.Column(db.String(100))
# 前端传 purchaser_email -> 存入 buyer_email
buyer_email = db.Column(db.String(100))
# 前端传 source_link -> 存入 original_link
original_link = db.Column(db.Text)
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)
arrival_photo = db.Column(db.Text)
# [这就是报错缺失的字段],请确保执行了 ALTER TABLE
remark = db.Column(db.Text)
# 注意SQL 中没有 remark 字段,这里已移除
# 关系定义
# 建立与 MaterialBase 的关系,方便通过 stock.material 访问基础信息
# 关系定义
material = db.relationship('MaterialBase', back_populates='stock_buys')
def to_dict(self):
"""
序列化:将模型转换为字典,主要用于单条查询或内部调用
列表查询主要依赖 Service 层的手动构建以提高性能
"""
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,
'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '',
'barcode': self.barcode,
@ -86,27 +65,23 @@ class StockBuy(db.Model):
'warehouse_loc': self.warehouse_location,
'status': self.status,
'inspection_status': self.inspection_status,
'remark': self.remark,
# 数量 (转为float防止json序列化报错)
'in_quantity': float(self.in_quantity or 0),
'qty_inbound': 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), # 兼容字段
'qty_stock': float(self.stock_quantity or 0),
'available_quantity': float(self.available_quantity or 0),
'qty_available': 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, # 映射回前端
'purchaser': self.buyer_name,
'purchaser_email': self.buyer_email,
'source_link': self.original_link,
'detail_link': self.detail_link,
'arrival_photo': self.arrival_photo
}

View File

@ -0,0 +1,102 @@
# app/models/inbound/product.py
from app.extensions import db
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.Date)
barcode = db.Column(db.String(100))
serial_number = db.Column(db.String(100))
# SQL 无 batch_number
# 数量
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)) # 映射 SQL: bom_id
bom_version = db.Column(db.String(50))
work_order_code = db.Column('work_order_id', db.String(100)) # 映射 SQL: work_order_id
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)) # 映射 SQL: producer_name
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)
# 成品特有字段
sale_price = db.Column(db.Numeric(19, 4), default=0)
inspection_report_link = db.Column(db.Text)
order_id = db.Column(db.String(100))
# 关系定义
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
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,
'quality_report_link': self.quality_report_link,
'detail_link': self.detail_link,
'sale_price': float(self.sale_price or 0),
'inspection_report_link': self.inspection_report_link,
'order_id': self.order_id
}

View File

@ -1,6 +1,5 @@
# app/models/inbound/semi.py
from app.extensions import db
from datetime import datetime
class StockSemi(db.Model):
@ -10,80 +9,43 @@ class StockSemi(db.Model):
"""
__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 无 batch_number此处移除
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)) # 仓库位
# 状态与位置
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))
# 半成品特有字段 (SQL 字段映射)
bom_code = db.Column('bom_id', db.String(100)) # 映射 SQL: bom_id
bom_version = db.Column(db.String(50))
work_order_code = db.Column('work_order_id', db.String(100)) # 映射 SQL: work_order_id
# 工单 相关
# 数据库列名: 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)
# 成本 相关
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_manager = db.Column('producer_name', db.String(100)) # 映射 SQL: producer_name
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) # 详细信息链接
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
@ -91,47 +53,35 @@ class StockSemi(db.Model):
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), # 兼容字段
'qty_inbound': float(self.in_quantity or 0),
'stock_quantity': float(self.stock_quantity or 0),
'qty_stock': 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), # 兼容字段
'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, # 前端展示总成本用
'unit_total_cost': unit_total,
'production_manager': self.production_manager,
# 时间范围 (SQL存的是字符串直接返回即可或者根据需要拆分)
# 如果 service 层存的是 "Start ~ End",这里直接返回
'production_time_range': self.production_time_range,
# 为了兼容前端分开的时间字段(如果有):
# 简单的时间拆分逻辑,防止 split 报错
'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(' ~ ')[

View File

@ -1,7 +1,5 @@
# app/models/material.py
from app.extensions import db
from datetime import datetime
class MaterialBase(db.Model):
"""
@ -10,78 +8,50 @@ class MaterialBase(db.Model):
"""
__tablename__ = 'material_base'
# 1. 基础字段 (保持不变)
# 1. 基础字段
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False, comment='基础信息名称')
# 类别 (对应 SQL: category)
category = db.Column(db.String(100), comment='类别')
# 类型 (对应 SQL: material_type) -> 前端 prop="type"
material_type = db.Column(db.String(100), comment='类型')
# 规格型号 (对应 SQL: spec_model) -> 前端 prop="spec"
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='通用产品图链接')
manual_link = db.Column(db.Text, comment='通用说明书')
product_image = db.Column(db.Text, comment='通用产品图')
# 启用状态
is_enabled = db.Column(db.Boolean, default=True, comment='是否启用')
# ============================================================
# 时间字段 (保持你原本的注释状态,以免报错)
# ============================================================
# create_time = db.Column(db.DateTime, default=datetime.utcnow)
# update_time = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================
# 关联关系区域 (修改重点)
# 关联关系区域
# ============================================================
# 1. 关联采购库存 (StockBuy) - 保持不变
# 注意:确保 app/models/inbound/buy.py 中的 StockBuy 定义了 back_populates='material'
# 1. 关联采购库存 (StockBuy)
stock_buys = db.relationship('StockBuy', back_populates='material', lazy='dynamic')
# 2. 【新增】关联半成品库存 (StockSemi)
# 注意:确保 app/models/inbound/semi.py 中的 StockSemi 定义了 back_populates='material'
# 这样以后可以通过 material.stock_semis 来访问该物料下的所有半成品库存记录
# 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):
"""
序列化方法将模型转换为字典供API返回JSON使用
序列化方法
"""
return {
'id': self.id,
'name': self.name,
'category': self.category,
# =========================================
# 关键映射区 (保持不变)
# =========================================
# 数据库叫 material_type -> 前端叫 type
'type': self.material_type,
# 数据库叫 spec_model -> 前端叫 spec
'spec': self.spec_model,
'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,
# 时间字段保持注释
# 'createTime': self.create_time.strftime('%Y-%m-%d %H:%M:%S') if hasattr(self, 'create_time') and self.create_time else None
}

View File

@ -0,0 +1,173 @@
from app.extensions import db
from app.models.material import MaterialBase
# 引用新的 product 路径
from app.models.inbound.product import StockProduct
from datetime import datetime
from sqlalchemy import or_, func
import traceback
class ProductInboundService:
@staticmethod
def search_base_material(keyword):
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
})
return results
except:
return []
@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("物料不存在")
in_date_val = datetime.utcnow().date()
if data.get('in_date'):
try:
in_date_val = datetime.strptime(str(data['in_date'])[:10], '%Y-%m-%d').date()
except:
pass
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
new_stock = StockProduct(
base_id=material.id,
sku=data.get('sku'),
production_date=in_date_val,
barcode=data.get('barcode'),
serial_number=data.get('serial_number'),
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', '合格'),
quality_report_link=data.get('quality_report_link'),
detail_link=data.get('detail_link'),
sale_price=float(data.get('sale_price') or 0),
inspection_report_link=data.get('inspection_report_link'),
order_id=data.get('order_id')
)
db.session.add(new_stock)
db.session.commit()
return new_stock
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def update_inbound(stock_id, data):
try:
stock = StockProduct.query.get(stock_id)
if not stock: raise ValueError("记录不存在")
fields = [
'sku', 'barcode', 'serial_number', 'warehouse_location',
'status', 'quality_status', 'bom_code', 'bom_version',
'work_order_code', 'production_manager', 'quality_report_link',
'detail_link', 'inspection_report_link', 'order_id'
]
for f in fields:
if f in data: setattr(stock, f, data[f])
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(' ~ ')
start = data.get('production_start_time', parts[0] if len(parts) > 0 else '')
end = data.get('production_end_time', parts[1] if len(parts) > 1 else '')
stock.production_time_range = f"{start} ~ {end}"
db.session.commit()
return stock
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def delete_inbound(stock_id):
try:
stock = StockProduct.query.get(stock_id)
if stock:
db.session.delete(stock)
db.session.commit()
except Exception as e:
db.session.rollback()
raise e
@staticmethod
def get_list(page, limit, keyword=None):
try:
query = db.session.query(StockProduct).outerjoin(MaterialBase, StockProduct.base_id == MaterialBase.id)
if keyword:
query = query.filter(or_(
MaterialBase.name.ilike(f'%{keyword}%'),
StockProduct.serial_number.ilike(f'%{keyword}%'),
StockProduct.work_order_code.ilike(f'%{keyword}%'),
StockProduct.order_id.ilike(f'%{keyword}%')
))
pagination = query.order_by(StockProduct.id.desc()).paginate(page=page, per_page=limit, error_out=False)
base_ids = list(set([i.base_id for i in pagination.items]))
stock_map = {}
if base_ids:
aggs = db.session.query(
StockProduct.base_id,
func.sum(StockProduct.stock_quantity).label('s'),
func.sum(StockProduct.available_quantity).label('a')
).filter(StockProduct.base_id.in_(base_ids)).group_by(StockProduct.base_id).all()
for a in aggs: stock_map[a.base_id] = {'s': float(a.s or 0), 'a': float(a.a or 0)}
items = []
for item in pagination.items:
d = item.to_dict()
stats = stock_map.get(item.base_id, {'s': 0, 'a': 0})
d['sum_stock'] = stats['s']
d['sum_available'] = stats['a']
items.append(d)
return {"total": pagination.total, "items": items}
except:
traceback.print_exc()
return {"total": 0, "items": []}

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/base/search',
method: 'get',
params: { keyword }
})
}

View File

@ -1,11 +1,340 @@
<script setup lang="ts">
</script>
<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">
<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">
<el-row :gutter="10">
<el-col :span="8" v-for="c in allColumns" :key="c.prop"><el-checkbox :value="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 || '120'" show-overflow-tooltip>
<template #default="scope" v-if="['serial_number'].includes(col.prop)">
<span v-if="scope.row[col.prop]" class="tag-sn">{{ 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 === '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', 'inspection_report_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="160" fixed="right" align="center">
<template #default="{ row }">
<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">
<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></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">
<el-select v-model="form.base_id" filterable remote reserve-keyword placeholder="搜名称/规格..." :remote-method="handleSearchMaterial" :loading="searchLoading" style="width: 100%" @change="onMaterialSelected">
<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></div>
</el-option>
</el-select>
</el-form-item>
</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.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" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" /></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">
<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="9"><el-form-item label="质量报告"><el-input v-model="form.quality_report_link" placeholder="链接" /></el-form-item></el-col>
<el-col :span="9"><el-form-item label="检测报告"><el-input v-model="form.inspection_report_link" placeholder="产品检测报告链接" /></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-input v-model="form.production_manager" /></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>
<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">提交</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Search, Box, House, Link } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
// 引用更新后的 product.ts
import { getProductList, createProductInbound, updateProductInbound, deleteProductInbound, searchMaterialBase } from '@/api/inbound/product'
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 allColumns = [
{ prop: 'material_name', label: '名称', minWidth: '120' },
{ prop: 'spec_model', label: '规格', minWidth: '120' },
{ prop: 'sku', label: 'SKU', minWidth: '100' },
{ prop: 'serial_number', label: '序列号', minWidth: '140' },
{ prop: 'qty_stock', label: '库存', minWidth: '80' },
{ prop: 'status', label: '状态', minWidth: '80' },
{ prop: 'quality_status', label: '质量', minWidth: '80' },
{ prop: 'sale_price', label: '售价', minWidth: '100' },
{ prop: 'order_id', label: '订单号', minWidth: '120' },
{ prop: 'work_order_code', label: '工单号', minWidth: '120' },
{ prop: 'bom_code', label: 'BOM', minWidth: '100' },
{ prop: 'inspection_report_link', label: '检测报告', minWidth: '100' },
{ prop: 'quality_report_link', label: '质量报告', 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' }
]
const visibleColumnProps = ref(allColumns.map(c => c.prop))
const form = reactive({
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '',
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: '', inspection_report_link: '', detail_link: ''
})
const rules = {
base_id: [{ required: true, message: '必选', trigger: 'change' }],
serial_number: [{ required: true, message: '必填', trigger: 'blur' }],
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
}
const fetchData = async () => {
loading.value = true
try {
const res: any = await getProductList(queryParams)
tableData.value = res.data.items || []
total.value = res.data.total || 0
} finally { loading.value = false }
}
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 }
}
}
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.material_type = item.type
}
}
const handleCreate = () => {
dialogStatus.value = 'create'
resetForm()
form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true
}
const handleUpdate = (row: any) => {
dialogStatus.value = 'update'
Object.assign(form, row)
// 转换时间格式
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 = []
}
visible.value = true
}
const submitForm = async () => {
await formRef.value.validate(async (valid: boolean) => {
if(valid) {
submitting.value = true
try {
const payload = { ...form,
production_start_time: form.production_time_range?.[0],
production_end_time: form.production_time_range?.[1]
}
if(dialogStatus.value === 'create') await createProductInbound(payload)
else await updateProductInbound(form.id!, payload)
ElMessage.success('操作成功')
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 resetForm = () => {
materialOptions.value = []
Object.assign(form, {
id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '',
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: '', detail_link: ''
})
}
const getStatusType = (s:string) => ({'在库':'success','出库':'info','损耗':'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; border-radius: 8px; }
.modern-table { border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05); }
.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-content { padding: 20px; }
.basic-card { border-left: 4px solid #409EFF; }
.inbound-card { border-left: 4px solid #67C23A; }
.production-card { border-left: 4px solid #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; }
.option-item { display: flex; justify-content: space-between; width: 100%; }
.opt-spec { color: #8492a6; font-size: 12px; }
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background: #f5f7fa; border-bottom: 1px solid #dcdfe6; }
</style>