针对于采购页面进行优化逻辑

This commit is contained in:
dxc
2026-01-28 11:22:08 +08:00
parent 87864a1c4f
commit e31ef59df0
6 changed files with 1228 additions and 526 deletions

View File

@ -2,49 +2,59 @@ from flask import Blueprint, request, jsonify
from app.services.inbound.buy_service import BuyInboundService from app.services.inbound.buy_service import BuyInboundService
import traceback import traceback
# 定义蓝图
inbound_buy_bp = Blueprint('inbound_buy', __name__) inbound_buy_bp = Blueprint('inbound_buy', __name__)
# ------------------------------------------------------------------
# 0. 基础物料搜索 (新增)
# ------------------------------------------------------------------
@inbound_buy_bp.route('/search-base', methods=['GET'])
def search_base():
"""
供前端下拉框远程搜索使用
Query Param: keyword (名称或规格)
"""
try:
keyword = request.args.get('keyword', '')
data = BuyInboundService.search_base_material(keyword)
return jsonify({
"code": 200,
"msg": "success",
"data": data
})
except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 1. 获取列表 (GET) # 1. 获取列表
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/list', methods=['GET']) @inbound_buy_bp.route('/list', methods=['GET'])
def get_list(): def get_list():
try: try:
page = request.args.get('page', 1, type=int) page = request.args.get('page', 1, type=int)
limit = request.args.get('pageSize', 15, type=int) limit = request.args.get('pageSize', 15, type=int)
result = BuyInboundService.get_list(page, limit) result = BuyInboundService.get_list(page, limit)
return jsonify({ return jsonify({"code": 200, "msg": "success", "data": result})
"code": 200,
"msg": "success",
"data": result
})
except Exception as e: except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 2. 新增入库 (POST) # 2. 新增入库
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/submit', methods=['POST']) @inbound_buy_bp.route('/submit', methods=['POST'])
def submit(): def submit():
try: try:
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400 return jsonify({"code": 400, "msg": "No data"}), 400
BuyInboundService.handle_inbound(data) BuyInboundService.handle_inbound(data)
return jsonify({"code": 200, "msg": "入库成功"}) return jsonify({"code": 200, "msg": "入库成功"})
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 3. 更新入库 (PUT) # 3. 更新入库
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['PUT']) @inbound_buy_bp.route('/<int:id>', methods=['PUT'])
def update_buy(id): def update_buy(id):
@ -53,12 +63,10 @@ def update_buy(id):
BuyInboundService.update_inbound(id, data) BuyInboundService.update_inbound(id, data)
return jsonify({"code": 200, "msg": "更新成功"}) return jsonify({"code": 200, "msg": "更新成功"})
except Exception as e: except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 4. 删除入库 (DELETE) # 4. 删除
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@inbound_buy_bp.route('/<int:id>', methods=['DELETE']) @inbound_buy_bp.route('/<int:id>', methods=['DELETE'])
def delete_buy(id): def delete_buy(id):
@ -66,5 +74,4 @@ def delete_buy(id):
BuyInboundService.delete_inbound(id) BuyInboundService.delete_inbound(id)
return jsonify({"code": 200, "msg": "删除成功"}) return jsonify({"code": 200, "msg": "删除成功"})
except Exception as e: except Exception as e:
traceback.print_exc()
return jsonify({"code": 500, "msg": str(e)}), 500 return jsonify({"code": 500, "msg": str(e)}), 500

View File

@ -1,69 +1,112 @@
#stock.py # app/models/stock.py
from app.extensions import db from app.extensions import db
from datetime import datetime from datetime import datetime
class StockBuy(db.Model): class StockBuy(db.Model):
"""
采购入库库存表
对应数据库表: stock_buy
"""
__tablename__ = 'stock_buy' __tablename__ = 'stock_buy'
# 主键
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
# 【核心关联】 # 【核心关联】外键关联 material_base 表
# 这里明确指定了 base_id 是外键,关联 material_base 表的 id
base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False) base_id = db.Column(db.Integer, db.ForeignKey('material_base.id'), nullable=False)
# --- 身份标识 ---
sku = db.Column(db.String(100)) sku = db.Column(db.String(100))
in_date = db.Column(db.Date) in_date = db.Column(db.Date)
serial_number = db.Column(db.String(100)) barcode = db.Column(db.String(100)) # 条码
batch_number = db.Column(db.String(100)) serial_number = db.Column(db.String(100)) # 序列号
batch_number = db.Column(db.String(100)) # 批号
# 数量 # --- 数量 ---
in_quantity = db.Column(db.Numeric(19, 4), default=0) in_quantity = db.Column(db.Numeric(19, 4), default=0)
stock_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) available_quantity = db.Column(db.Numeric(19, 4), default=0)
# 状态与位置 # --- 状态与位置 ---
status = db.Column(db.String(50)) status = db.Column(db.String(50)) # 在库/出库/损耗
inspection_status = db.Column(db.String(50)) inspection_status = db.Column(db.String(50)) # 未检/合格/不合格
warehouse_location = db.Column(db.String(100)) warehouse_location = db.Column(db.String(100))
# 财务与商务 # --- 财务与商务 ---
unit_price = db.Column(db.Numeric(19, 4), default=0) unit_price = db.Column(db.Numeric(19, 4), default=0)
total_price = db.Column(db.Numeric(19, 4), default=0) total_price = db.Column(db.Numeric(19, 4), default=0)
currency = db.Column(db.String(20), default='CNY') currency = db.Column(db.String(20), default='CNY')
exchange_rate = db.Column(db.Numeric(15, 6), default=1.0) exchange_rate = db.Column(db.Numeric(15, 6), default=1.0)
supplier_name = db.Column(db.String(255)) supplier_name = db.Column(db.String(255))
# [关键映射区]Python属性名 = DB列名
# 前端传 purchaser -> 存入 buyer_name
buyer_name = db.Column(db.String(100)) buyer_name = db.Column(db.String(100))
# 前端传 purchaser_email -> 存入 buyer_email
buyer_email = db.Column(db.String(100)) buyer_email = db.Column(db.String(100))
# 前端传 source_link -> 存入 original_link
original_link = db.Column(db.Text) original_link = db.Column(db.Text)
detail_link = db.Column(db.Text) detail_link = db.Column(db.Text)
arrival_photo = db.Column(db.Text) arrival_photo = db.Column(db.Text)
# 【核心关联】 # [这就是报错缺失的字段],请确保执行了 ALTER TABLE
# 建立对象级别的连接,方便通过 stock.material 访问基础信息 remark = db.Column(db.Text)
# 【关系定义】
# 建立与 MaterialBase 的关系,方便通过 stock.material 访问基础信息
material = db.relationship('MaterialBase', back_populates='stock_buys') material = db.relationship('MaterialBase', back_populates='stock_buys')
def to_dict(self): def to_dict(self):
"""序列化""" """
序列化:将模型转换为字典,主要用于单条查询或内部调用
列表查询主要依赖 Service 层的手动构建以提高性能
"""
return { return {
'id': self.id, 'id': self.id,
'base_id': self.base_id, # 前端需要这个ID来判断关联 'base_id': self.base_id,
'material_name': self.material.name if self.material else None,
'spec_model': self.material.spec_model if self.material else None,
'category': self.material.category if self.material else None,
'unit': self.material.unit if self.material else None,
'material_type': self.material.material_type if self.material else None,
# 级联基础信息 (防止 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, 'sku': self.sku,
'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else None, 'inbound_date': self.in_date.strftime('%Y-%m-%d') if self.in_date else '',
'barcode': self.barcode,
'serial_number': self.serial_number, 'serial_number': self.serial_number,
'batch_number': self.batch_number, 'batch_number': self.batch_number,
'qty_inbound': float(self.in_quantity) if self.in_quantity else 0,
'qty_stock': float(self.stock_quantity) if self.stock_quantity else 0,
'qty_available': float(self.available_quantity) if self.available_quantity else 0,
'warehouse_loc': self.warehouse_location, 'warehouse_loc': self.warehouse_location,
'status': self.status, 'status': self.status,
'price_unit': float(self.unit_price) if self.unit_price else 0, 'inspection_status': self.inspection_status,
'price_total': float(self.total_price) if self.total_price else 0, 'remark': self.remark,
'supplier_name': self.supplier_name
# 数量 (转为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), # 兼容字段
# 财务
'unit_price': float(self.unit_price or 0),
'total_price': float(self.total_price or 0),
'currency': self.currency,
'exchange_rate': float(self.exchange_rate or 1.0),
# 商务 (字段映射)
'supplier_name': self.supplier_name,
'purchaser': self.buyer_name, # 映射回前端
'purchaser_email': self.buyer_email, # 映射回前端
'source_link': self.original_link, # 映射回前端
'detail_link': self.detail_link,
'arrival_photo': self.arrival_photo
} }

View File

@ -1,77 +1,111 @@
# app/services/inbound/buy_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.stock import StockBuy
from datetime import datetime from datetime import datetime
from sqlalchemy import or_
import traceback import traceback
class BuyInboundService: class BuyInboundService:
@staticmethod @staticmethod
def handle_inbound(data): def search_base_material(keyword):
"""新增入库:自动关联/创建基础信息 + 创建库存记录""" """
搜索基础物料库
"""
try: try:
# 0. 基础校验 if not keyword:
if not data.get('spec_model') or not data.get('material_name'): return []
raise ValueError("缺少必要的物料名称或规格型号")
# 1. 关联逻辑:通过规格型号(spec_model)查找基础库 # 过滤条件:名称或规格包含关键词,且 is_enabled 为 True
material = MaterialBase.query.filter_by(spec_model=data['spec_model']).first() query = MaterialBase.query.filter(
MaterialBase.is_enabled == True,
# 如果不存在,则新建 MaterialBase or_(
if not material: MaterialBase.name.ilike(f'%{keyword}%'),
material = MaterialBase( MaterialBase.spec_model.ilike(f'%{keyword}%')
name=data['material_name'],
spec_model=data['spec_model'],
category=data.get('category'),
material_type='采购件',
unit=data.get('unit'),
visibility_level=data.get('visibility_level', 0),
manual_link=data.get('manual_link'),
product_image=data.get('product_image'),
is_enabled=True
) )
db.session.add(material) ).limit(20)
db.session.flush() # 立即执行,拿到 material.id
# 2. 处理日期 (兼容性处理) results = []
in_date_val = None 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:
# 1. 核心校验
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} 的基础物料不存在")
# 2. 处理日期 (防止空字符串报错)
in_date_val = datetime.utcnow().date()
if data.get('in_date'): if data.get('in_date'):
try: try:
in_date_val = datetime.strptime(data['in_date'], '%Y-%m-%d %H:%M:%S').date() date_str = str(data['in_date'])
# 截取前10位 YYYY-MM-DD
if len(date_str) > 10:
in_date_val = datetime.strptime(date_str[:10], '%Y-%m-%d').date()
else:
in_date_val = datetime.strptime(date_str, '%Y-%m-%d').date()
except ValueError: except ValueError:
try: pass # 格式错误则使用当前日期
in_date_val = datetime.strptime(data['in_date'], '%Y-%m-%d').date()
except ValueError:
in_date_val = datetime.utcnow().date()
# --- 修改部分:增加字段兼容性,确保前端传参能被正确读取 --- # 3. 数据转换
in_qty = float(data.get('in_quantity') or data.get('qty_inbound') or 0) in_qty = float(data.get('in_quantity') or 0)
u_price = float(data.get('unit_price') or data.get('price_unit') or 0) u_price = float(data.get('unit_price') or 0)
# ---------------------------------------------------
# 3. 创建 StockBuy # 4. 创建 StockBuy
new_stock = StockBuy( new_stock = StockBuy(
base_id=material.id, base_id=material.id,
sku=data.get('sku'), sku=data.get('sku'),
in_date=in_date_val, in_date=in_date_val,
serial_number=data.get('serial_number'), serial_number=data.get('serial_number'),
batch_number=data.get('batch_number'), batch_number=data.get('batch_number'),
barcode=data.get('barcode'),
# --- 状态与数量强制逻辑 ---
status='在库', status='在库',
inspection_status=data.get('inspection_status'),
in_quantity=in_qty, in_quantity=in_qty,
stock_quantity=in_qty, stock_quantity=in_qty, # 初始库存 = 入库量
available_quantity=in_qty, available_quantity=in_qty, # 初始可用 = 入库量
warehouse_location=data.get('warehouse_location') or data.get('warehouse_loc'), inspection_status=data.get('inspection_status', '未检'),
# ----------------
warehouse_location=data.get('warehouse_location'),
unit_price=u_price, unit_price=u_price,
total_price=in_qty * u_price, total_price=in_qty * u_price,
currency=data.get('currency', 'CNY'), currency=data.get('currency', 'CNY'),
exchange_rate=data.get('exchange_rate', 1.0), exchange_rate=data.get('exchange_rate', 1.0),
supplier_name=data.get('supplier_name'), supplier_name=data.get('supplier_name'),
buyer_name=data.get('buyer_name'),
buyer_email=data.get('buyer_email'), # [字段映射] 前端 -> DB
original_link=data.get('original_link'), buyer_name=data.get('purchaser'),
buyer_email=data.get('purchaser_email'),
original_link=data.get('source_link'),
detail_link=data.get('detail_link'), detail_link=data.get('detail_link'),
arrival_photo=data.get('arrival_photo') arrival_photo=data.get('arrival_photo'),
remark=data.get('remark')
) )
db.session.add(new_stock) db.session.add(new_stock)
@ -80,105 +114,182 @@ class BuyInboundService:
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print(f"Insert Error: {str(e)}")
raise e raise e
@staticmethod @staticmethod
def update_inbound(stock_id, data): def update_inbound(stock_id, data):
"""更新入库:支持级联更新基础信息 + 自动重算总价""" """
更新入库逻辑
"""
try: try:
print(f"----- UPDATE DEBUG: ID={stock_id} -----")
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: if not stock:
raise ValueError("记录不存在") raise ValueError("记录不存在")
# 1. 更新普通字段 (增加对 warehouse_loc 的兼容) # 1. 字段映射字典前端Key -> Model属性名
if 'serial_number' in data: stock.serial_number = data['serial_number'] # 使用映射可以避免写大量 if...else且逻辑更清晰
if 'batch_number' in data: stock.batch_number = data['batch_number'] field_mapping = {
if 'warehouse_location' in data: stock.warehouse_location = data['warehouse_location'] 'sku': 'sku',
if 'warehouse_loc' in data: stock.warehouse_location = data['warehouse_loc'] 'barcode': 'barcode',
if 'supplier_name' in data: stock.supplier_name = data['supplier_name'] 'warehouse_location': 'warehouse_location',
if 'status' in data: stock.status = data['status'] 'serial_number': 'serial_number',
if 'inspection_status' in data: stock.inspection_status = data['inspection_status'] 'batch_number': 'batch_number',
if 'arrival_photo' in data: stock.arrival_photo = data['arrival_photo'] 'status': 'status',
if 'remark' in data: stock.remark = data['remark'] 'inspection_status': 'inspection_status',
'supplier_name': 'supplier_name',
'detail_link': 'detail_link',
'arrival_photo': 'arrival_photo',
'remark': 'remark',
'currency': 'currency',
'exchange_rate': 'exchange_rate',
# 2. 级联更新基础信息 (MaterialBase) # 关键映射
if stock.material: 'purchaser': 'buyer_name',
if 'material_name' in data: stock.material.name = data['material_name'] 'purchaser_email': 'buyer_email',
if 'category' in data: stock.material.category = data['category'] 'source_link': 'original_link'
if 'unit' in data: stock.material.unit = data['unit'] }
# 3. 核心逻辑:数量与价格联动 (增加对前端返回字段名 qty_inbound 和 price_unit 的识别) # 遍历更新 (排除日期、数量、单价,下面单独处理)
for frontend_key, db_attr in field_mapping.items():
if frontend_key in data:
setattr(stock, db_attr, data[frontend_key])
# 2. 核心数值逻辑 (数量 & 单价 & 总价)
qty_changed = False qty_changed = False
price_changed = False price_changed = False
# (A) 数量变更 -> 更新库存和可用量 # (A) 处理入库数量变更
new_qty_input = data.get('in_quantity') or data.get('qty_inbound') if 'in_quantity' in data:
if new_qty_input is not None: new_qty = float(data['in_quantity'])
new_qty = float(new_qty_input)
old_qty = float(stock.in_quantity) old_qty = float(stock.in_quantity)
diff = new_qty - old_qty
if diff != 0: if new_qty != old_qty:
print(f"Quantity Changed: {old_qty} -> {new_qty}")
diff = new_qty - old_qty
stock.in_quantity = new_qty stock.in_quantity = new_qty
# 联动更新库存和可用量
stock.stock_quantity = float(stock.stock_quantity) + diff stock.stock_quantity = float(stock.stock_quantity) + diff
stock.available_quantity = float(stock.available_quantity) + diff stock.available_quantity = float(stock.available_quantity) + diff
qty_changed = True qty_changed = True
# (B) 单价变更 # (B) 处理单价变更
new_price_input = data.get('unit_price') or data.get('price_unit') if 'unit_price' in data:
if new_price_input is not None: new_price = float(data['unit_price'])
new_price = float(new_price_input) old_price = float(stock.unit_price)
if new_price != float(stock.unit_price):
if new_price != old_price:
print(f"Price Changed: {old_price} -> {new_price}")
stock.unit_price = new_price stock.unit_price = new_price
price_changed = True price_changed = True
# (C) 重算总价 # (C) 强制重算总价
if qty_changed or price_changed: if qty_changed or price_changed:
stock.total_price = float(stock.in_quantity) * float(stock.unit_price) stock.total_price = float(stock.in_quantity) * float(stock.unit_price)
print(f"New Total Price: {stock.total_price}")
db.session.commit() db.session.commit()
print("----- UPDATE SUCCESS -----")
return stock return stock
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print(f"----- UPDATE FAILED: {str(e)} -----")
traceback.print_exc()
raise e raise e
@staticmethod @staticmethod
def delete_inbound(stock_id): def delete_inbound(stock_id):
"""删除逻辑孤儿策略如果MaterialBase无其他引用则一并删除"""
try: try:
stock = StockBuy.query.get(stock_id) stock = StockBuy.query.get(stock_id)
if not stock: if not stock:
raise ValueError("记录不存在") raise ValueError("记录不存在")
# 1. 记下 base_id
material_id = stock.base_id
# 2. 删除库存记录
db.session.delete(stock) db.session.delete(stock)
db.session.flush()
# 3. 检查是否还有残留
remaining_count = StockBuy.query.filter_by(base_id=material_id).count()
if remaining_count == 0:
print(f"触发级联删除: MaterialBase ID {material_id} 已无关联,执行清理。")
material = MaterialBase.query.get(material_id)
if material:
db.session.delete(material)
db.session.commit() db.session.commit()
return True return True
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
print(f"删除失败: {e}")
raise e raise e
@staticmethod @staticmethod
def get_list(page, limit): def get_list(page, limit, keyword=None):
"""
列表查询
"""
try: try:
pagination = StockBuy.query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit) # 使用 Outer Join 确保即使 MaterialBase 物理删除,库存记录也不会报错或消失
items = [item.to_dict() for item in pagination.items] query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
# 搜索逻辑
if keyword:
query = query.filter(
or_(
MaterialBase.name.ilike(f'%{keyword}%'),
MaterialBase.spec_model.ilike(f'%{keyword}%'),
StockBuy.batch_number.ilike(f'%{keyword}%'),
StockBuy.serial_number.ilike(f'%{keyword}%'),
StockBuy.sku.ilike(f'%{keyword}%')
)
)
pagination = query.order_by(StockBuy.id.desc()).paginate(page=page, per_page=limit, error_out=False)
items = []
for item in pagination.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 ''
# 构建返回字典
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,
'inspection_status': item.inspection_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),
'warehouse_loc': item.warehouse_location,
'unit_price': float(item.unit_price or 0),
'total_price': float(item.total_price or 0),
'currency': item.currency,
'exchange_rate': float(item.exchange_rate or 1),
'supplier_name': item.supplier_name,
# [关键映射] DB -> Frontend
'purchaser': item.buyer_name,
'purchaser_email': item.buyer_email,
'source_link': item.original_link,
'detail_link': item.detail_link,
'arrival_photo': item.arrival_photo,
'remark': item.remark
}
items.append(d)
return {"total": pagination.total, "items": items} return {"total": pagination.total, "items": items}
except Exception as e: except Exception as e:
print(f"查询列表失败: {e}") # 打印错误到 Docker 日志
print(f"List Error: {e}")
traceback.print_exc()
return {"total": 0, "items": []} return {"total": 0, "items": []}

View File

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

View File

@ -1,34 +1,49 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<el-card shadow="never"> <el-card shadow="never">
<template #header> <div class="filter-wrapper">
<div class="card-header">
<el-button type="primary" @click="handleAdd">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增基础信息
</el-button>
</div>
</template>
<div class="filter-container"> <div class="filter-container">
<el-input <el-input
v-model="queryParams.keyword" v-model="queryParams.keyword"
placeholder="请输入基础信息名称或规格" placeholder="请输入名称或规格 (支持模糊搜索)"
style="width: 220px; margin-right: 10px;" style="width: 240px; margin-right: 10px;"
clearable clearable
@keyup.enter="handleQuery" @input="handleInputSearch"
/> />
<el-select v-model="queryParams.category" placeholder="基础信息类别" clearable style="width: 150px; margin-right: 10px;"> <el-select
<el-option label="采购件" value="PURCHASE" /> v-model="queryParams.category"
<el-option label="自制件" value="SELF_MADE" /> placeholder="类别"
clearable
filterable
allow-create
default-first-option
style="width: 140px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select v-model="queryParams.type" placeholder="物料类型" clearable style="width: 150px; margin-right: 10px;"> <el-select
<el-option label="电子料" value="ELEC" /> v-model="queryParams.type"
<el-option label="结构件" value="STRUCT" /> placeholder="类型"
clearable
filterable
allow-create
default-first-option
style="width: 140px; margin-right: 10px;"
@change="handleQuery"
>
<el-option v-for="item in typeOptions" :key="item" :label="item" :value="item" />
</el-select> </el-select>
<el-select v-model="queryParams.isEnabled" placeholder="状态" clearable style="width: 100px; margin-right: 10px;"> <el-select
v-model="queryParams.isEnabled"
placeholder="状态"
clearable
style="width: 100px; margin-right: 10px;"
@change="handleQuery"
>
<el-option label="启用" :value="1" /> <el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" /> <el-option label="禁用" :value="0" />
</el-select> </el-select>
@ -37,63 +52,76 @@
<el-button plain @click="resetQuery">重置</el-button> <el-button plain @click="resetQuery">重置</el-button>
</div> </div>
<div class="right-toolbar">
<el-button type="primary" @click="handleAdd" style="margin-right: 10px">
<el-icon style="margin-right: 5px"><Plus /></el-icon>新增
</el-button>
<el-tooltip content="刷新" placement="top">
<el-button circle :icon="Refresh" @click="getList" />
</el-tooltip>
<el-dropdown trigger="click" @command="handleSizeChange">
<el-button circle :icon="Rank" style="margin-left: 8px" title="表格密度" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="large">宽松 (默认)</el-dropdown-item>
<el-dropdown-item command="default">中等</el-dropdown-item>
<el-dropdown-item command="small">紧凑</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-popover placement="bottom" :width="150" trigger="click">
<template #reference>
<el-button circle :icon="Setting" style="margin-left: 8px" title="列设置" />
</template>
<div class="column-setting-list">
<div style="font-weight: bold; margin-bottom: 5px; border-bottom: 1px solid #eee; padding-bottom: 5px">
列展示设置
</div>
<el-checkbox v-model="columns.id.visible" label="ID" />
<el-checkbox v-model="columns.name.visible" label="名称" />
<el-checkbox v-model="columns.category.visible" label="类别" />
<el-checkbox v-model="columns.type.visible" label="类型" />
<el-checkbox v-model="columns.spec.visible" label="规格型号" />
<el-checkbox v-model="columns.unit.visible" label="单位" />
<el-checkbox v-model="columns.visibilityLevel.visible" label="可见等级" />
<el-checkbox v-model="columns.files.visible" label="资料" />
<el-checkbox v-model="columns.isEnabled.visible" label="状态" />
</div>
</el-popover>
</div>
</div>
<el-table <el-table
v-loading="loading" v-loading="loading"
:data="tableData" :data="tableData"
border border
stripe stripe
style="width: 100%; margin-top: 20px" :size="tableSize"
style="width: 100%; margin-top: 15px"
> >
<el-table-column prop="id" label="ID" width="80" align="center" /> <el-table-column v-if="columns.id.visible" prop="id" label="ID" min-width="80" align="center" fixed="left" />
<el-table-column v-if="columns.name.visible" prop="name" label="基础信息名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="name" label="基础信息名称" min-width="150" show-overflow-tooltip /> <el-table-column v-if="columns.category.visible" prop="category" label="类别" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">{{ scope.row.category || '-' }}</template>
<el-table-column prop="category" label="类别" width="100" align="center"> </el-table-column>
<el-table-column v-if="columns.type.visible" prop="type" label="类型" min-width="120" align="center" show-overflow-tooltip>
<template #default="scope">{{ scope.row.type || '-' }}</template>
</el-table-column>
<el-table-column v-if="columns.spec.visible" prop="spec" label="规格型号" min-width="180" show-overflow-tooltip />
<el-table-column v-if="columns.unit.visible" prop="unit" label="单位" min-width="80" align="center" />
<el-table-column v-if="columns.visibilityLevel.visible" prop="visibilityLevel" label="可见等级" min-width="100" align="center">
<template #default="scope">L{{ scope.row.visibilityLevel }}</template>
</el-table-column>
<el-table-column v-if="columns.files.visible" label="资料" min-width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-tag v-if="scope.row.category === 'PURCHASE'">采购件</el-tag> <el-button v-if="scope.row.generalImage" link type="primary" :icon="Picture" title="查看图片" @click="openLink(scope.row.generalImage)" />
<el-tag v-else-if="scope.row.category === 'SELF_MADE'" type="success">自制件</el-tag> <el-button v-if="scope.row.generalManual" link type="primary" :icon="Document" title="查看说明书" @click="openLink(scope.row.generalManual)" />
<el-tag v-else type="info">{{ scope.row.category || '-' }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column v-if="columns.isEnabled.visible" prop="isEnabled" label="是否启用" min-width="100" align="center">
<el-table-column prop="type" label="类型" width="100" align="center">
<template #default="scope">
<span v-if="scope.row.type === 'ELEC'">电子料</span>
<span v-else-if="scope.row.type === 'STRUCT'">结构件</span>
<span v-else>{{ scope.row.type || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="spec" label="规格型号" min-width="150" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="70" align="center" />
<el-table-column prop="visibilityLevel" label="可见等级" width="90" align="center">
<template #default="scope">
L{{ scope.row.visibilityLevel }}
</template>
</el-table-column>
<el-table-column label="资料" width="100" align="center">
<template #default="scope">
<el-button
v-if="scope.row.generalImage"
link type="primary"
:icon="Picture"
title="查看图片"
@click="openLink(scope.row.generalImage)"
/>
<el-button
v-if="scope.row.generalManual"
link type="primary"
:icon="Document"
title="查看说明书"
@click="openLink(scope.row.generalManual)"
/>
</template>
</el-table-column>
<el-table-column prop="isEnabled" label="是否启用" width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-switch <el-switch
v-model="scope.row.isEnabled" v-model="scope.row.isEnabled"
@ -104,8 +132,7 @@
/> />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" min-width="150" fixed="right" align="center">
<el-table-column label="操作" width="150" fixed="right" align="center">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button> <el-button link type="primary" size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button> <el-button link type="danger" size="small" @click="handleDelete(scope.row)">删除</el-button>
@ -116,7 +143,7 @@
<div style="margin-top: 20px; text-align: right;"> <div style="margin-top: 20px; text-align: right;">
<el-pagination <el-pagination
background background
layout="total, prev, pager, next, sizes" layout="total, sizes, prev, pager, next, jumper"
:total="total" :total="total"
v-model:current-page="queryParams.pageNum" v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize" v-model:page-size="queryParams.pageSize"
@ -126,20 +153,101 @@
/> />
</div> </div>
<el-dialog
v-model="dialog.visible"
:title="dialog.title"
width="600px"
append-to-body
@close="cancel"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入基础信息名称" />
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="类别" prop="category">
<el-autocomplete
v-model="form.category"
:fetch-suggestions="querySearchCategory"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="类型" prop="type">
<el-autocomplete
v-model="form.type"
:fetch-suggestions="querySearchType"
placeholder="可输入或选择"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="请输入规格型号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计量单位" prop="unit">
<el-input v-model="form.unit" placeholder="如: 个, 台, 米" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="可见等级" prop="visibilityLevel">
<el-input-number v-model="form.visibilityLevel" :min="0" :max="9" label="等级" />
<span style="margin-left: 10px; color: #999; font-size: 12px;">(0为最低9为最高)</span>
</el-form-item>
<el-form-item label="说明书链接" prop="generalManual">
<el-input v-model="form.generalManual" placeholder="请输入说明书URL链接" />
</el-form-item>
<el-form-item label="产品图链接" prop="generalImage">
<el-input v-model="form.generalImage" placeholder="请输入图片URL链接" />
</el-form-item>
<el-form-item label="状态" prop="isEnabled">
<el-radio-group v-model="form.isEnabled">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="cancel"> </el-button>
<el-button type="primary" @click="submitForm" :loading="submitLoading"> </el-button>
</div>
</template>
</el-dialog>
</el-card> </el-card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'; import { ref, reactive, onMounted, nextTick } from 'vue';
import { Plus, Picture, Document } from '@element-plus/icons-vue'; import { Plus, Picture, Document, Refresh, Setting, Rank } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage, ElMessageBox } from 'element-plus';
import type { FormInstance, FormRules } from 'element-plus';
// 【关键修改】引入刚才定义的 API 文件
import { import {
listMaterialBase, listMaterialBase,
delMaterialBase, addMaterialBase,
updateMaterialBase updateMaterialBase,
delMaterialBase
} from '@/api/material_base'; } from '@/api/material_base';
// --- 类型定义 --- // --- 类型定义 ---
@ -153,8 +261,8 @@ interface MaterialBaseVO {
visibilityLevel: number; visibilityLevel: number;
generalManual?: string; generalManual?: string;
generalImage?: string; generalImage?: string;
isEnabled: number; // 1 or 0 isEnabled: number;
statusLoading?: boolean; // 辅助字段 statusLoading?: boolean;
} }
interface QueryParams { interface QueryParams {
@ -170,6 +278,23 @@ interface QueryParams {
const loading = ref(false); const loading = ref(false);
const total = ref(0); const total = ref(0);
const tableData = ref<MaterialBaseVO[]>([]); const tableData = ref<MaterialBaseVO[]>([]);
const submitLoading = ref(false);
const tableSize = ref<'large' | 'default' | 'small'>('large');
const columns = reactive({
id: { visible: true },
name: { visible: true },
category: { visible: true },
type: { visible: true },
spec: { visible: true },
unit: { visible: true },
visibilityLevel: { visible: true },
files: { visible: true },
isEnabled: { visible: true }
});
const categoryOptions = ref<string[]>([]);
const typeOptions = ref<string[]>([]);
const queryParams = reactive<QueryParams>({ const queryParams = reactive<QueryParams>({
pageNum: 1, pageNum: 1,
@ -180,18 +305,78 @@ const queryParams = reactive<QueryParams>({
isEnabled: undefined isEnabled: undefined
}); });
// --- 弹窗与表单相关 ---
const dialog = reactive({
visible: false,
title: ''
});
const formRef = ref<FormInstance>();
const initForm = {
id: undefined,
name: '',
category: '',
type: '',
spec: '',
unit: '',
visibilityLevel: 0,
generalManual: '',
generalImage: '',
isEnabled: 1
};
const form = ref({...initForm});
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入基础信息名称', trigger: 'blur' }],
category: [{ required: true, message: '请选择或输入类别', trigger: 'change' }],
type: [{ required: true, message: '请选择或输入类型', trigger: 'change' }],
spec: [{ required: true, message: '请输入规格型号', trigger: 'blur' }],
unit: [{ required: true, message: '请输入单位', trigger: 'blur' }]
});
// --- 业务逻辑方法 --- // --- 业务逻辑方法 ---
// 获取数据 const extractDynamicOptions = (items: MaterialBaseVO[]) => {
if (!items || items.length === 0) return;
const newCategories = new Set(categoryOptions.value);
const newTypes = new Set(typeOptions.value);
items.forEach(item => {
if (item.category) newCategories.add(item.category);
if (item.type) newTypes.add(item.type);
});
categoryOptions.value = Array.from(newCategories);
typeOptions.value = Array.from(newTypes);
};
// 【核心新增】Autocomplete 的建议查询方法
// 格式化数据以适配 el-autocomplete 的回调参数格式 [{ value: 'abc' }]
const querySearchCategory = (queryString: string, cb: any) => {
const results = queryString
? categoryOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: categoryOptions.value;
// el-autocomplete 默认只展示 value 属性
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const querySearchType = (queryString: string, cb: any) => {
const results = queryString
? typeOptions.value.filter(item => item.toLowerCase().includes(queryString.toLowerCase()))
: typeOptions.value;
const formattedResults = results.map(item => ({ value: item }));
cb(formattedResults);
};
const getList = () => { const getList = () => {
loading.value = true; loading.value = true;
// 调用 API 文件中的 listMaterialBase
listMaterialBase(queryParams) listMaterialBase(queryParams)
.then((response: any) => { .then((response: any) => {
// 我们的 request.ts 已经处理了 code!=200这里直接拿 data
if (response && response.data) { if (response && response.data) {
tableData.value = response.data.items; tableData.value = response.data.items;
total.value = response.data.total; total.value = response.data.total;
extractDynamicOptions(tableData.value);
} else { } else {
tableData.value = []; tableData.value = [];
total.value = 0; total.value = 0;
@ -206,13 +391,20 @@ const getList = () => {
}); });
}; };
// 搜索 let searchTimer: ReturnType<typeof setTimeout> | null = null;
const handleInputSearch = () => {
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
queryParams.pageNum = 1;
getList();
}, 500);
};
const handleQuery = () => { const handleQuery = () => {
queryParams.pageNum = 1; queryParams.pageNum = 1;
getList(); getList();
}; };
// 重置
const resetQuery = () => { const resetQuery = () => {
queryParams.keyword = ''; queryParams.keyword = '';
queryParams.category = ''; queryParams.category = '';
@ -221,75 +413,111 @@ const resetQuery = () => {
handleQuery(); handleQuery();
}; };
// 新增 const handleSizeChange = (command: 'large' | 'default' | 'small') => {
tableSize.value = command;
};
const handleAdd = () => { const handleAdd = () => {
ElMessage.info("请实现新增弹窗逻辑"); resetForm();
// 逻辑dialogVisible.value = true dialog.title = '新增基础信息';
dialog.visible = true;
}; };
// 编辑
const handleEdit = (row: MaterialBaseVO) => { const handleEdit = (row: MaterialBaseVO) => {
console.log("点击编辑", row); resetForm();
ElMessage.info(`准备编辑 ID: ${row.id}`); dialog.title = '编辑基础信息';
// 逻辑:调用 getMaterialBase(row.id) 回显数据 dialog.visible = true;
nextTick(() => {
Object.assign(form.value, row);
});
};
const checkDuplicate = async (name: string, spec: string): Promise<boolean> => {
try {
const nameRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: name });
if (nameRes.data?.items?.some((item: MaterialBaseVO) => item.name === name)) {
ElMessage.error(`添加失败:已存在名称为 "${name}" 的基础信息!`);
return true;
}
const specRes: any = await listMaterialBase({ pageNum: 1, pageSize: 100, keyword: spec });
if (specRes.data?.items?.some((item: MaterialBaseVO) => item.spec === spec)) {
ElMessage.error(`添加失败:已存在规格/编号为 "${spec}" 的基础信息!`);
return true;
}
} catch (e) {
return false;
}
return false;
};
const submitForm = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
submitLoading.value = true;
try {
if (!form.value.id) {
const isDuplicate = await checkDuplicate(form.value.name, form.value.spec);
if (isDuplicate) {
submitLoading.value = false;
return;
}
}
const requestApi = form.value.id ? updateMaterialBase : addMaterialBase;
const actionText = form.value.id ? '修改' : '新增';
await requestApi(form.value);
ElMessage.success(`${actionText}成功`);
dialog.visible = false;
getList();
} catch (error) {
console.error(error);
} finally {
submitLoading.value = false;
}
}
});
};
const cancel = () => {
dialog.visible = false;
resetForm();
};
const resetForm = () => {
form.value = {...initForm};
if (formRef.value) formRef.value.resetFields();
}; };
// 状态切换 (实时保存)
const handleStatusChange = (row: MaterialBaseVO) => { const handleStatusChange = (row: MaterialBaseVO) => {
row.statusLoading = true; row.statusLoading = true;
const text = row.isEnabled === 1 ? "启用" : "停用"; const text = row.isEnabled === 1 ? "启用" : "停用";
const updateData = { id: row.id, isEnabled: row.isEnabled };
const updateData = {
id: row.id,
isEnabled: row.isEnabled
};
// 调用 API 文件中的 updateMaterialBase
updateMaterialBase(updateData) updateMaterialBase(updateData)
.then(() => { .then(() => ElMessage.success(`${text} "${row.name}"`))
ElMessage.success(`${text} "${row.name}"`); .catch(() => { row.isEnabled = row.isEnabled === 1 ? 0 : 1; })
}) .finally(() => { row.statusLoading = false; });
.catch(() => {
// 失败回滚
row.isEnabled = row.isEnabled === 1 ? 0 : 1;
})
.finally(() => {
row.statusLoading = false;
});
}; };
// 删除
const handleDelete = (row: MaterialBaseVO) => { const handleDelete = (row: MaterialBaseVO) => {
ElMessageBox.confirm( ElMessageBox.confirm(
`是否确认删除名称为 "${row.name}" 的数据项? \n如果该物料已有库存记录删除将会被拒绝。`, `是否确认删除名称为 "${row.name}" 的数据项?`,
"警告", "警告",
{ { confirmButtonText: "确定", cancelButtonText: "取消", type: "warning" }
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning"
}
).then(() => { ).then(() => {
// 调用 API 文件中的 delMaterialBase delMaterialBase(row.id).then(() => {
delMaterialBase(row.id)
.then(() => {
ElMessage.success("删除成功"); ElMessage.success("删除成功");
if (tableData.value.length === 1 && queryParams.pageNum > 1) { if (tableData.value.length === 1 && queryParams.pageNum > 1) queryParams.pageNum--;
queryParams.pageNum--;
}
getList(); getList();
}); });
}).catch(() => { }).catch(() => {});
// 取消
});
}; };
// 打开链接
const openLink = (url: string) => { const openLink = (url: string) => {
if (!url) return; if (!url) return;
window.open(url, '_blank'); window.open(url, '_blank');
} }
// 初始化
onMounted(() => { onMounted(() => {
getList(); getList();
}); });
@ -299,15 +527,25 @@ onMounted(() => {
.app-container { .app-container {
padding: 20px; padding: 20px;
} }
.card-header { .filter-wrapper {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
} }
.filter-container { .filter-container {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
margin-bottom: 20px; gap: 10px;
}
.right-toolbar {
display: flex;
align-items: center;
}
.column-setting-list {
display: flex;
flex-direction: column;
} }
</style> </style>

View File

@ -1,23 +1,40 @@
<template> <template>
<div class="buy-module"> <div class="buy-module">
<div class="header-tools"> <div class="header-tools">
<div class="left-actions"> <div class="left-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="big-font-btn">采购入库登记</el-button> <el-input
<el-button :icon="Refresh" @click="fetchData" class="big-font-btn">刷新数据</el-button> v-model="queryParams.keyword"
placeholder="搜索物料名称/规格/单号..."
class="search-input"
clearable
@clear="fetchData"
@keyup.enter="fetchData"
>
<template #append>
<el-button :icon="Search" @click="fetchData" />
</template>
</el-input>
</div> </div>
<el-popover placement="bottom" title="显示列配置" :width="400" trigger="click"> <div class="right-tools">
<el-button type="primary" :icon="Plus" @click="handleCreate" class="big-font-btn">采购入库登记</el-button>
<el-button :icon="Refresh" @click="fetchData" class="big-font-btn">刷新数据</el-button>
<el-popover placement="bottom-end" title="显示列配置" :width="600" trigger="click">
<template #reference> <template #reference>
<el-button :icon="Setting" class="big-font-btn">自定义表格表</el-button> <el-button :icon="Setting" class="big-font-btn">自定义表头</el-button>
</template> </template>
<el-checkbox-group v-model="visibleColumnProps" class="column-selector"> <el-checkbox-group v-model="visibleColumnProps" class="column-selector">
<el-divider content-position="left">基础层字段</el-divider> <el-row>
<el-checkbox v-for="c in baseColumns" :key="c.prop" :label="c.prop">{{ c.label }}</el-checkbox> <el-col :span="24"><el-divider content-position="left">基础信息 (只读)</el-divider></el-col>
<el-divider content-position="left">库存/财务层字段</el-divider> <el-col :span="8" v-for="c in baseColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
<el-checkbox v-for="c in stockColumns" :key="c.prop" :label="c.prop">{{ c.label }}</el-checkbox> <el-col :span="24"><el-divider content-position="left">库存与商务</el-divider></el-col>
<el-col :span="8" 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-checkbox-group>
</el-popover> </el-popover>
</div> </div>
</div>
<el-table <el-table
v-loading="loading" v-loading="loading"
@ -25,46 +42,46 @@
border border
stripe stripe
style="width: 100%" style="width: 100%"
size="default"
highlight-current-row
class="custom-big-table" class="custom-big-table"
highlight-current-row
> >
<template v-for="col in allColumns" :key="col.prop"> <template v-for="col in allColumns" :key="col.prop">
<el-table-column <el-table-column
v-if="visibleColumnProps.includes(col.prop)" v-if="visibleColumnProps.includes(col.prop)"
:prop="col.prop" :prop="col.prop"
:label="col.label" :label="col.label"
:min-width="col.minWidth || '150'" :min-width="col.minWidth || '140'"
show-overflow-tooltip show-overflow-tooltip
> >
<template #default="scope" v-if="col.prop === 'serial_batch'"> <template #default="scope" v-if="['serial_number', 'batch_number'].includes(col.prop)">
<span v-if="scope.row.serial_number" style="color: #409EFF; font-weight: bold;"> <span v-if="scope.row[col.prop]" :class="col.prop === 'serial_number' ? 'text-sn' : 'text-bn'">
SN: {{ scope.row.serial_number }} {{ scope.row[col.prop] }}
</span>
<span v-else-if="scope.row.batch_number" style="color: #67C23A; font-weight: bold;">
BN: {{ scope.row.batch_number }}
</span> </span>
<span v-else>-</span> <span v-else>-</span>
</template> </template>
<template #default="scope" v-else-if="col.prop === 'status'"> <template #default="scope" v-else-if="col.prop === 'status'">
<el-tag size="large" :type="getStatusType(scope.row.status)" style="font-size: 18px;"> <el-tag :type="getStatusType(scope.row.status)" effect="dark" size="large">
{{ scope.row.status }} {{ scope.row.status }}
</el-tag> </el-tag>
</template> </template>
<template #default="scope" v-else-if="['price_unit', 'price_total'].includes(col.prop)"> <template #default="scope" v-else-if="col.prop.includes('link')">
{{ formatMoney(scope.row[col.prop]) }} <el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank">查看</el-link>
</template>
<template #default="scope" v-else-if="['unit_price', 'total_price'].includes(col.prop)">
{{ formatMoney(scope.row[col.prop], scope.row.currency) }}
</template> </template>
</el-table-column> </el-table-column>
</template> </template>
<el-table-column label="操作" width="200" fixed="right" align="center"> <el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" size="large" @click="handleUpdate(row)" style="font-size: 15px;">编辑</el-button> <el-button link type="primary" size="large" @click="handleUpdate(row)">编辑</el-button>
<el-popconfirm title="确定删除该条入库记录吗?" @confirm="handleDelete(row)"> <el-popconfirm title="确定删除该条入库记录吗?" @confirm="handleDelete(row)">
<template #reference> <template #reference>
<el-button link type="danger" size="large" style="font-size: 15px;">删除</el-button> <el-button link type="danger" size="large">删除</el-button>
</template> </template>
</el-popconfirm> </el-popconfirm>
</template> </template>
@ -84,118 +101,197 @@
<el-dialog <el-dialog
v-model="visible" v-model="visible"
:title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'" :title="dialogStatus === 'create' ? '新增采购入库' : '编辑入库信息'"
width="950px" width="1100px"
top="3vh" top="3vh"
destroy-on-close destroy-on-close
:close-on-click-modal="false" :close-on-click-modal="false"
> >
<el-form :model="form" label-width="140px" ref="formRef" :rules="rules" size="large"> <el-form :model="form" label-width="120px" ref="formRef" :rules="rules" size="large" class="custom-form">
<div class="section-block">
<el-divider content-position="left"><el-icon><Box /></el-icon> <b>1. 基础物料信息 (必须已存在)</b></el-divider>
<el-divider content-position="left"><b>1. 基础核心层 (Material Base)</b></el-divider>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="8"> <el-col :span="12" v-if="dialogStatus === 'create'">
<el-form-item label="名称" prop="material_name"> <el-form-item label="搜索关联" prop="base_id">
<el-input v-model="form.material_name" :disabled="dialogStatus === 'update'" placeholder="必填" /> <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 + ' - ' + item.spec"
:value="item.id"
>
<span style="float: left; font-weight: bold;">{{ item.name }}</span>
<span style="float: right; color: #8492a6; font-size: 13px">{{ item.spec }}</span>
</el-option>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8">
<el-form-item label="规格型号" prop="spec_model"> <el-col :span="12" v-if="dialogStatus === 'create'">
<el-input v-model="form.spec_model" :disabled="dialogStatus === 'update'" placeholder="必填: 内部货号" /> <el-alert title="注:只能入库状态为'启用'的基础物料。" type="warning" :closable="false" show-icon />
</el-form-item>
</el-col> </el-col>
<el-col :span="8"><el-form-item label="计量单位" prop="unit"><el-input v-model="form.unit" /></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="类别" prop="category"><el-input v-model="form.category" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型" prop="material_type"><el-input v-model="form.material_type" disabled /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="可见等级" prop="visibility_level"><el-input-number v-model="form.visibility_level" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row> </el-row>
<el-divider content-position="left"><b>2. 实体库存层 (Stock Buy)</b></el-divider> <el-row :gutter="20" class="read-only-area">
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled placeholder="自动关联" /></el-form-item></el-col>
</el-row>
</div>
<div class="section-block">
<el-divider content-position="left"><el-icon><House /></el-icon> <b>2. 库存实体信息</b></el-divider>
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="8"> <el-col :span="6"><el-form-item label="编码/SKU" prop="sku"><el-input v-model="form.sku" /></el-form-item></el-col>
<el-form-item label="编码/SKU" prop="sku">
<el-input v-model="form.sku" placeholder="选填" /> <el-col :span="6">
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="入库日期" prop="in_date"> <el-form-item label="入库日期" prop="in_date">
<el-input v-model="form.in_date" disabled placeholder="系统自动生成"> <el-date-picker
<template #suffix><el-icon><Calendar /></el-icon></template> v-model="form.in_date"
</el-input> type="date"
</el-form-item> value-format="YYYY-MM-DD"
</el-col> style="width:100%"
<el-col :span="8"> disabled
<el-form-item label="库位" prop="warehouse_location"> placeholder="自动生成"
<el-input v-model="form.warehouse_location" /> />
</el-form-item> </el-form-item>
</el-col> </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-row> </el-row>
<el-row :gutter="20" style="background-color: #fffbf0; border-radius: 4px; padding-top:10px;"> <div class="sn-bn-row">
<el-col :span="12"> <el-row style="margin-bottom: 15px; padding-left: 20px;">
<el-form-item label="序列号" prop="serial_number">
<el-input v-model="form.serial_number" placeholder="设备SN码" clearable />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="批号" prop="batch_number">
<el-input v-model="form.batch_number" placeholder="生产批次号" clearable />
</el-form-item>
</el-col>
<el-col :span="24"> <el-col :span="24">
<div style="font-size: 14px; color: #e6a23c; margin-left: 140px; margin-bottom: 10px; line-height: 1;"> <el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked">
* 规则序列号与批号互斥且必填其一 (填写一个会自动清空另一个) <el-radio label="batch" size="large" border>按批号入库 (Batch)</el-radio>
<el-radio label="serial" size="large" border>按序列号入库 (SN)</el-radio>
</el-radio-group>
<div v-if="modeLocked" class="locked-tip">
<el-icon style="vertical-align: middle"><Lock /></el-icon>
该物料已有入库记录系统已自动匹配历史入库方式同物料同策略不可手动更改
</div> </div>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="20" style="margin-top: 10px;"> <el-row :gutter="20">
<el-col :span="12"><el-form-item label="到检状态" prop="inspection_status"><el-input v-model="form.inspection_status" /></el-form-item></el-col> <el-col :span="10">
<el-col :span="12"><el-form-item label="照片/到货图" prop="arrival_photo"><el-input v-model="form.arrival_photo" /></el-form-item></el-col> <el-form-item label="批号" prop="batch_number">
</el-row> <el-input
v-model="form.batch_number"
<el-row :gutter="20" style="background-color: #f8fcfd; padding-top: 18px; border-radius: 4px; margin-top: 10px;"> placeholder="系统自动生成"
<el-col :span="8"> :disabled="entryMode === 'serial'"
<el-form-item label="入库量" prop="in_quantity"> clearable
<el-input-number >
v-model="form.in_quantity" <template #prefix><span style="color:#67C23A; font-weight:bold">BN</span></template>
:min="1" </el-input>
:step="1"
:precision="0"
style="width:100%"
controls-position="right"
/>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="10">
<el-form-item label="序列号" prop="serial_number">
<el-input
v-model="form.serial_number"
placeholder="请扫描或输入设备SN"
:disabled="entryMode === 'batch'"
clearable
>
<template #prefix><span style="color:#409EFF; font-weight:bold">SN</span></template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="24" class="tip-col">
<span class="small-tip" v-if="entryMode === 'batch'">
* <b>批号模式</b>{{ modeLocked ? '根据历史记录' : '首次入库' }}系统已自动生成新批号
</span>
<span class="small-tip" v-else>
* <b>序列号模式</b>{{ modeLocked ? '根据历史记录' : '首次入库' }}请手动录入唯一SN码
</span>
</el-col>
</el-row>
</div>
<el-row :gutter="20" 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" style="width:100%" controls-position="right" />
</el-form-item>
</el-col>
<template v-if="dialogStatus === 'update'">
<el-col :span="6">
<el-form-item label="库存数量" prop="stock_quantity"> <el-form-item label="库存数量" prop="stock_quantity">
<el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false" /> <el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false" />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="6">
<el-form-item label="可用数量" prop="available_quantity"> <el-form-item label="可用数量" prop="available_quantity">
<el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false" /> <el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> <el-col :span="6">
<el-form-item label="库存状态" prop="status">
<el-divider content-position="left"><b>3. 财务与商务信息</b></el-divider> <el-select v-model="form.status" style="width:100%">
<el-row :gutter="20"> <el-option label="在库" value="在库" />
<el-col :span="8"><el-form-item label="单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" style="width:100%" controls-position="right" /></el-form-item></el-col> <el-option label="出库" value="出库" />
<el-col :span="8"><el-form-item label="总价" prop="total_price"><el-input-number v-model="form.total_price" :precision="4" style="width:100%" disabled :controls="false" /></el-form-item></el-col> <el-option label="损耗" value="损耗" />
<el-col :span="8"><el-form-item label="供应商" prop="supplier_name"><el-input v-model="form.supplier_name" /></el-form-item></el-col> </el-select>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" />
</el-form-item> </el-form-item>
</el-col>
</template>
<el-col :span="6">
<el-form-item label="到检状态" prop="inspection_status">
<el-select v-model="form.inspection_status" style="width:100%">
<el-option label="未检" value="未检" />
<el-option label="合格" value="合格" />
<el-option label="不合格" value="不合格" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="到货图" prop="arrival_photo"><el-input v-model="form.arrival_photo" /></el-form-item></el-col>
</el-row>
</div>
<div class="section-block">
<el-divider content-position="left"><el-icon><Money /></el-icon> <b>3. 商务信息</b></el-divider>
<el-row :gutter="20">
<el-col :span="6"><el-form-item label="币种"><el-input v-model="form.currency" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="汇率"><el-input-number v-model="form.exchange_rate" :precision="2" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="单价" prop="unit_price"><el-input-number v-model="form.unit_price" :precision="4" controls-position="right" style="width:100%"/></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总价"><el-input-number v-model="form.total_price" :precision="4" disabled :controls="false" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="8"><el-form-item label="供应商"><el-input v-model="form.supplier_name" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="采购人"><el-input v-model="form.purchaser" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="邮箱"><el-input v-model="form.purchaser_email" /></el-form-item></el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"><el-form-item label="原始链接"><el-input v-model="form.source_link" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="详情链接"><el-input v-model="form.detail_link" /></el-form-item></el-col>
</el-row>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="visible = false" style="font-size: 15px;">取消</el-button> <el-button @click="visible = false" class="big-font-btn">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitForm" style="font-size: 15px;"> <el-button type="primary" :loading="submitting" @click="submitForm" class="big-font-btn">
{{ dialogStatus === 'create' ? '确认入库' : '保存修改' }} {{ dialogStatus === 'create' ? '确认入库' : '保存修改' }}
</el-button> </el-button>
</template> </template>
@ -205,106 +301,218 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue' import { ref, reactive, onMounted, watch } from 'vue'
import { Plus, Setting, Refresh, Calendar } from '@element-plus/icons-vue' import { Plus, Setting, Refresh, Search, Lock, Box, House, Money } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getBuyList, createBuyInbound, updateBuyInbound, deleteBuyInbound } from '@/api/inbound/buy' import {
getBuyList,
createBuyInbound,
updateBuyInbound,
deleteBuyInbound,
searchMaterialBase
} from '@/api/inbound/buy'
// 状态控制 // ------------------------------------
// 状态与变量
// ------------------------------------
const loading = ref(false) const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
const formRef = ref() const formRef = ref()
const queryParams = reactive({ page: 1, pageSize: 15 }) const queryParams = reactive({ page: 1, pageSize: 15, keyword: '' })
const materialOptions = ref<any[]>([])
// --- 1. 列定义 --- // 新增:入库模式控制
const entryMode = ref('batch')
const modeLocked = ref(false)
// 列定义 (保持不变)
const baseColumns = [ const baseColumns = [
{ prop: 'material_name', label: '物料名称', minWidth: '200' }, { prop: 'material_name', label: '名称' },
{ prop: 'category', label: '类别', minWidth: '120' }, { prop: 'category', label: '类别' },
{ prop: 'spec_model', label: '规格型号', minWidth: '200' }, { prop: 'material_type', label: '类型' },
{ prop: 'unit', label: '单位', minWidth: '100' } { prop: 'spec_model', label: '规格型号' },
{ prop: 'unit', label: '单位' },
] ]
const stockColumns = [ const stockColumns = [
{ prop: 'sku', label: '编码/SKU', minWidth: '180' }, { prop: 'id', label: 'ID', minWidth: '60' },
{ prop: 'inbound_date', label: '入库日期', minWidth: '160' }, { prop: 'base_id', label: 'BaseID', minWidth: '80' },
{ prop: 'serial_batch', label: '序列号/批号', minWidth: '220' }, { prop: 'sku', label: 'SKU', minWidth: '120' },
{ prop: 'qty_stock', label: '库存数', minWidth: '120' }, { prop: 'inbound_date', label: '入库日期', minWidth: '120' },
{ prop: 'qty_available', label: '可用数', minWidth: '120' }, { prop: 'barcode', label: '条码', minWidth: '120' },
{ prop: 'qty_inbound', label: '入库量', minWidth: '120' }, { prop: 'serial_number', label: '序列号', minWidth: '150' },
{ prop: 'price_unit', label: '单价', minWidth: '150' }, { prop: 'batch_number', label: '批号', minWidth: '150' },
{ prop: 'price_total', label: '总价', minWidth: '150' }, { prop: 'status', label: '状态', minWidth: '100' },
{ prop: 'status', label: '状态', minWidth: '120' }, { prop: 'inspection_status', label: '到检', minWidth: '100' },
{ prop: 'warehouse_loc', label: '库位', minWidth: '150' }, { prop: 'qty_inbound', label: '入库量', minWidth: '100' },
{ prop: 'supplier_name', label: '供应商', minWidth: '200' } { prop: 'qty_stock', label: '库存数', minWidth: '100' },
{ prop: 'qty_available', label: '可用数', minWidth: '100' },
{ prop: 'warehouse_loc', label: '库位', minWidth: '120' },
{ prop: 'unit_price', label: '单价', minWidth: '120' },
{ prop: 'total_price', label: '总价', minWidth: '120' },
{ prop: 'currency', label: '币种', minWidth: '80' },
{ prop: 'exchange_rate', label: '汇率', minWidth: '80' },
{ prop: 'supplier_name', label: '供应商', minWidth: '150' },
{ prop: 'purchaser', label: '采购人', minWidth: '100' },
{ prop: 'purchaser_email', label: '邮箱', minWidth: '150' },
{ prop: 'source_link', label: '采购链接', minWidth: '100' },
{ prop: 'detail_link', label: '详情链接', minWidth: '100' },
{ prop: 'arrival_photo', label: '到货图', minWidth: '100' }
] ]
const allColumns = [...baseColumns, ...stockColumns] const allColumns = [...baseColumns, ...stockColumns]
// --- 2. 默认展示列 ---
const visibleColumnProps = ref([ const visibleColumnProps = ref([
'material_name', 'spec_model', 'inbound_date', 'material_name', 'category', 'material_type', 'spec_model', 'unit',
'serial_batch', 'qty_stock', 'qty_available', 'status' 'inbound_date', 'serial_number', 'batch_number', 'status', 'inspection_status',
'unit_price', 'total_price', 'supplier_name', 'purchaser'
]) ])
// --- 3. 表单对象 ---
const form = reactive({ const form = reactive({
id: undefined, id: undefined,
material_name: '', category: '', spec_model: '', unit: '个', base_id: undefined as number | undefined,
material_type: '采购件', visibility_level: 0, material_name: '', spec_model: '', category: '', unit: '', material_type: '',
sku: '', in_date: '', sku: '', barcode: '', in_date: '',
serial_number: '', batch_number: '', serial_number: '', batch_number: '',
status: '在库', inspection_status: '未检', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, in_quantity: 1, stock_quantity: 1, available_quantity: 1,
warehouse_location: '', unit_price: 0, total_price: 0, warehouse_location: '',
supplier_name: '', arrival_photo: '', remark: '' unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '',
source_link: '', detail_link: '', arrival_photo: ''
}) })
// --- 4. 校验逻辑 --- // ------------------------------------
const validateIdentity = (rule: any, value: any, callback: any) => { // 逻辑校验规则
if (!form.serial_number && !form.batch_number) { // ------------------------------------
callback(new Error('序列号和批号至少填写一项')) 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 { } else {
if (formRef.value) { callback()
if (rule.field === 'serial_number' && form.batch_number) formRef.value.clearValidate('batch_number')
if (rule.field === 'batch_number' && form.serial_number) formRef.value.clearValidate('serial_number')
} }
}
const validateIdentity = (rule: any, value: any, callback: any) => {
if (entryMode.value === 'serial' && !form.serial_number && rule.field === 'serial_number') {
callback(new Error('序列号模式下必填'))
} else if (entryMode.value === 'batch' && !form.batch_number && rule.field === 'batch_number') {
callback(new Error('批号模式下必填'))
} else {
callback() callback()
} }
} }
const rules = { const rules = {
material_name: [{ required: true, message: '必填', trigger: 'blur' }], base_id: [{ required: true, message: '请搜索并选择基础物料', trigger: 'change' }],
spec_model: [{ required: true, message: '必填', trigger: 'blur' }], in_quantity: [{ required: true, message: '请输入入库数量', trigger: 'blur' }],
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }], in_date: [{ required: true, message: '请选择日期', trigger: 'change' }],
serial_number: [{ validator: validateIdentity, trigger: 'blur' }], serial_number: [{ validator: validateIdentity, trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
batch_number: [{ validator: validateIdentity, trigger: 'blur' }] batch_number: [{ validator: validateIdentity, trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }]
} }
// --- 5. 监听逻辑 --- // ------------------------------------
watch(() => form.serial_number, (val) => { if (val && form.batch_number) form.batch_number = '' }) // 核心逻辑函数
watch(() => form.batch_number, (val) => { if (val && form.serial_number) form.serial_number = '' }) // ------------------------------------
watch(() => form.in_quantity, (newVal) => { const checkHistoryAndSetMode = async (baseId: number) => {
if (newVal !== undefined) { try {
if (dialogStatus.value === 'create') { const res: any = await getBuyList({ page: 1, pageSize: 1000 })
form.stock_quantity = newVal const allItems = res.data.items || []
form.available_quantity = newVal 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)
} }
form.total_price = Number((newVal * form.unit_price).toFixed(4)) } 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.in_quantity, form.unit_price], () => {
form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4))
}) })
watch(() => form.unit_price, (newVal) => { // CRUD 操作
if (newVal !== undefined) {
form.total_price = Number((newVal * (form.in_quantity || 0)).toFixed(4))
}
})
// --- 6. 核心操作 ---
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
@ -317,44 +525,67 @@ const fetchData = async () => {
const handleCreate = () => { const handleCreate = () => {
dialogStatus.value = 'create' dialogStatus.value = 'create'
resetForm() resetForm()
form.in_date = dayjs().format('YYYY-MM-DD HH:mm:ss') // 新增时自动设置当前时间,不可修改
form.in_date = dayjs().format('YYYY-MM-DD')
modeLocked.value = false
entryMode.value = 'batch'
form.batch_number = ''
visible.value = true visible.value = true
} }
const handleUpdate = (row: any) => { const handleUpdate = (row: any) => {
dialogStatus.value = 'update' dialogStatus.value = 'update'
resetForm() resetForm()
modeLocked.value = true
form.id = row.id form.id = row.id
form.base_id = row.base_id
form.material_name = row.material_name form.material_name = row.material_name
form.spec_model = row.spec_model form.spec_model = row.spec_model
form.category = row.category form.category = row.category
form.unit = row.unit form.unit = row.unit
form.material_type = row.material_type
form.sku = row.sku form.sku = row.sku
form.barcode = row.barcode
// 编辑时回显原有时间disabled 属性确保不可修改
form.in_date = row.inbound_date 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.inspection_status = row.inspection_status
form.in_quantity = Number(row.qty_inbound) || 0 form.in_quantity = Number(row.qty_inbound) || 0
form.stock_quantity = Number(row.qty_stock) || 0 form.stock_quantity = Number(row.qty_stock) || 0
form.available_quantity = Number(row.qty_available) || 0 form.available_quantity = Number(row.qty_available) || 0
form.unit_price = Number(row.price_unit) || 0
form.total_price = Number(row.price_total) || 0 form.unit_price = Number(row.unit_price) || 0
form.warehouse_location = row.warehouse_loc || '' form.total_price = Number(row.total_price) || 0
form.serial_number = row.serial_number || '' form.currency = row.currency
form.batch_number = row.batch_number || '' form.exchange_rate = Number(row.exchange_rate)
form.supplier_name = row.supplier_name || '' form.supplier_name = row.supplier_name
form.status = row.status || '在库' form.purchaser = row.purchaser
form.remark = row.remark || '' form.purchaser_email = row.purchaser_email
form.source_link = row.source_link
form.detail_link = row.detail_link
form.arrival_photo = row.arrival_photo
visible.value = true visible.value = true
} }
const handleDelete = async (row: any) => { // 核心修复:修复编辑不生效的问题
try {
await deleteBuyInbound(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (e: any) {
ElMessage.error('删除失败')
}
}
const submitForm = async () => { const submitForm = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate(async (valid: boolean) => { await formRef.value.validate(async (valid: boolean) => {
@ -365,57 +596,103 @@ const submitForm = async () => {
await createBuyInbound(form) await createBuyInbound(form)
ElMessage.success('入库成功') ElMessage.success('入库成功')
} else { } else {
await updateBuyInbound(form.id!, form) // 确保 ID 和数值类型正确传递
const payload = {
...form,
in_quantity: Number(form.in_quantity),
unit_price: Number(form.unit_price)
}
// 1. 等待更新完成
await updateBuyInbound(form.id!, payload)
ElMessage.success('更新成功') ElMessage.success('更新成功')
} }
// 2. 更新成功后,先刷新数据
await fetchData()
// 3. 数据刷新完毕,再关闭弹窗
visible.value = false visible.value = false
fetchData()
} catch (e: any) { } catch (e: any) {
ElMessage.error('提交失败') ElMessage.error(e.msg || '操作失败')
} finally { submitting.value = false } } finally {
submitting.value = false
}
} }
}) })
} }
const handleDelete = async (row: any) => {
try {
await deleteBuyInbound(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (e) {
ElMessage.error('删除失败')
}
}
const resetForm = () => { const resetForm = () => {
materialOptions.value = []
Object.assign(form, { Object.assign(form, {
id: undefined, id: undefined, base_id: undefined,
material_name: '', category: '', spec_model: '', unit: '', material_name: '', spec_model: '', category: '', unit: '', material_type: '',
material_type: '采购件', visibility_level: 0, sku: '', barcode: '', in_date: '',
sku: '', in_date: '',
serial_number: '', batch_number: '', serial_number: '', batch_number: '',
status: '在库', inspection_status: '未检', status: '在库', inspection_status: '未检',
in_quantity: 1, stock_quantity: 1, available_quantity: 1, in_quantity: 1, stock_quantity: 1, available_quantity: 1,
warehouse_location: '', unit_price: 0, total_price: 0, warehouse_location: '',
supplier_name: '', arrival_photo: '', remark: '' unit_price: 0, total_price: 0, currency: 'CNY', exchange_rate: 1.00,
supplier_name: '', purchaser: '', purchaser_email: '',
source_link: '', detail_link: '', arrival_photo: ''
}) })
} }
const getStatusType = (status: string) => { const getStatusType = (status: string) => {
return status === '在库' ? 'success' : 'info' const map: any = { '在库': 'success', '出库': 'info', '损耗': 'danger' }
return map[status] || 'warning'
} }
const formatMoney = (val: any) => { const formatMoney = (val: any, currency = '¥') => {
const num = Number(val) const num = Number(val)
return isNaN(num) ? '-' : `¥ ${num.toFixed(2)}` return isNaN(num) ? '-' : `${currency} ${num.toFixed(2)}`
} }
onMounted(() => fetchData()) onMounted(() => fetchData())
</script> </script>
<style scoped> <style scoped>
.buy-module { background: #fff; border-radius: 8px; padding: 15px; } .buy-module { background: #fff; padding: 15px; }
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
.column-selector { display: flex; flex-direction: column; padding: 10px; max-height: 400px; overflow-y: auto; }
.pagination-container { margin-top: 15px; display: flex; justify-content: flex-end; }
:deep(.el-divider--horizontal) { margin: 15px 0 15px 0; }
.custom-big-table { font-size: 15px !important; } /* 头部布局优化:左搜右操作 */
:deep(.el-table .el-table__cell) { padding: 12px 0; } .header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
:deep(.el-table th .cell) { font-size: 22px; font-weight: bold; } .left-tools { flex: 0 0 300px; /* 左侧固定宽度或自适应 */ }
:deep(.el-table td .cell) { line-height: 1.5; font-size: 15px; } .right-tools { display: flex; gap: 10px; align-items: center; }
.big-font-btn { font-size: 15px !important; padding: 15px 25px !important; }
:deep(.el-form-item__label) { font-size: 15px !important; } .search-input { width: 100%; }
:deep(.el-input__inner) { font-size: 15px !important; height: 45px; }
:deep(.el-divider__text) { font-size: 15px !important; } .custom-big-table { font-size: 14px; }
.section-block { margin-bottom: 20px; border: 1px solid #ebeef5; padding: 15px; border-radius: 4px; }
.read-only-area { background-color: #f5f7fa; padding: 10px; border-radius: 4px; }
.sn-bn-row {
background-color: #fffbf0;
border: 1px dashed #e6a23c;
padding: 15px 0 5px 0;
border-radius: 4px;
margin-top: 10px;
}
.text-sn { color: #409EFF; font-weight: bold; }
.text-bn { color: #67C23A; font-weight: bold; }
.small-tip { font-size: 12px; color: #909399; margin-left: 10px; }
.tip-col { padding-left: 20px; margin-bottom: 5px; margin-top: -10px;}
.locked-tip {
font-size: 12px;
color: #E6A23C;
margin-top: 5px;
background-color: #fdf6ec;
padding: 5px 10px;
border-radius: 4px;
display: inline-block;
}
</style> </style>