借库逻辑实现
This commit is contained in:
@ -80,18 +80,21 @@ def create_app():
|
|||||||
|
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
|
# 2.4 注册业务操作模块 (Transactions - 借还/维修/报废)
|
||||||
|
# ★★★ 关键修改:将前缀改为 /api/v1/transactions 以匹配前端请求 ★★★
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
try:
|
try:
|
||||||
from app.api.v1.transactions import trans_bp
|
from app.api.v1.transactions import trans_bp
|
||||||
app.register_blueprint(trans_bp, url_prefix='/api/v1/trans')
|
# 标准: /api/v1/transactions/borrow
|
||||||
app.register_blueprint(trans_bp, url_prefix='/api/trans', name='trans_legacy')
|
app.register_blueprint(trans_bp, url_prefix='/api/v1/transactions')
|
||||||
|
# 兼容: /api/transactions/borrow
|
||||||
|
app.register_blueprint(trans_bp, url_prefix='/api/transactions', name='trans_legacy')
|
||||||
print("✅ Transactions 模块注册成功")
|
print("✅ Transactions 模块注册成功")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# 允许模块不存在时不崩溃
|
# 允许模块不存在时不崩溃,但在开发借还功能时这里报错说明 trans_bp 定义有问题
|
||||||
print(f"⚠️ 提示: Transaction 模块尚未创建或导入失败: {e}")
|
print(f"⚠️ 提示: Transaction 模块导入失败 (请检查 app/api/v1/transactions.py): {e}")
|
||||||
|
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
# 2.5 ★ [新增] 注册出库模块 (Outbound)
|
# 2.5 注册出库模块 (Outbound)
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
try:
|
try:
|
||||||
from app.api.v1.outbound import outbound_bp
|
from app.api.v1.outbound import outbound_bp
|
||||||
@ -114,11 +117,12 @@ def create_app():
|
|||||||
from app.models.inbound.semi import StockSemi
|
from app.models.inbound.semi import StockSemi
|
||||||
from app.models.inbound.product import StockProduct
|
from app.models.inbound.product import StockProduct
|
||||||
|
|
||||||
# ★ [新增] 出库模型 (确保迁移工具能检测到 trans_outbound 表)
|
# 出库模型
|
||||||
from app.models.outbound import TransOutbound
|
from app.models.outbound import TransOutbound
|
||||||
|
|
||||||
# 系统与业务模型
|
# 系统与业务模型
|
||||||
from app.models.system import SysUser, SysLog
|
from app.models.system import SysUser, SysLog
|
||||||
|
# 确保借还模型被加载
|
||||||
from app.models.transaction import TransBorrow, TransRepair, TransScrap
|
from app.models.transaction import TransBorrow, TransRepair, TransScrap
|
||||||
|
|
||||||
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)
|
# 首次运行时可取消注释自动建表 (但在生产环境建议使用 flask db upgrade)
|
||||||
|
|||||||
@ -1,12 +1,58 @@
|
|||||||
from flask import Blueprint, jsonify
|
from flask import Blueprint, jsonify, request
|
||||||
|
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||||
|
from app.services.trans_service import TransService
|
||||||
|
import traceback
|
||||||
|
|
||||||
# 定义蓝图
|
trans_bp = Blueprint('transactions', __name__, url_prefix='/transactions')
|
||||||
# 注意:这个变量名 trans_bp 必须与 app/__init__.py 中注册时引用的名字一致
|
|
||||||
trans_bp = Blueprint('transactions', __name__)
|
|
||||||
|
|
||||||
@trans_bp.route('/test', methods=['GET'])
|
|
||||||
def test_transaction():
|
# --- 借库接口 ---
|
||||||
"""
|
@trans_bp.route('/borrow', methods=['POST'])
|
||||||
测试接口:用于验证 Transaction 模块是否加载成功
|
@jwt_required()
|
||||||
"""
|
def create_borrow():
|
||||||
return jsonify({"message": "Transaction module is working", "status": "success"})
|
data = request.get_json()
|
||||||
|
try:
|
||||||
|
no = TransService.create_borrow(data)
|
||||||
|
return jsonify({'code': 200, 'msg': '借用成功', 'data': {'borrow_no': no}})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- 还库辅助:扫码查找借出记录 ---
|
||||||
|
@trans_bp.route('/return/scan', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def scan_borrowed_item():
|
||||||
|
barcode = request.args.get('barcode')
|
||||||
|
if not barcode:
|
||||||
|
return jsonify({'code': 400, 'msg': '无条码'}), 400
|
||||||
|
|
||||||
|
res = TransService.scan_for_return(barcode)
|
||||||
|
if res:
|
||||||
|
return jsonify({'code': 200, 'data': res})
|
||||||
|
else:
|
||||||
|
return jsonify({'code': 404, 'msg': '未找到该物品的未还记录'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- 还库提交 ---
|
||||||
|
@trans_bp.route('/return', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
def submit_return():
|
||||||
|
data = request.get_json()
|
||||||
|
user = get_jwt_identity() # 库管
|
||||||
|
try:
|
||||||
|
TransService.process_return(data, operator_name=user)
|
||||||
|
return jsonify({'code': 200, 'msg': '还库成功'})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- 记录列表 ---
|
||||||
|
@trans_bp.route('/records', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_records():
|
||||||
|
status = request.args.get('status', 'all')
|
||||||
|
page = int(request.args.get('page', 1))
|
||||||
|
keyword = request.args.get('keyword', '')
|
||||||
|
|
||||||
|
res = TransService.get_records(page=page, limit=10, status=status, keyword=keyword)
|
||||||
|
return jsonify({'code': 200, 'data': res})
|
||||||
@ -2,95 +2,52 @@ from app.extensions import db
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
# 1. 借用表
|
|
||||||
class TransBorrow(db.Model):
|
class TransBorrow(db.Model):
|
||||||
__tablename__ = 'trans_borrow'
|
__tablename__ = 'trans_borrow'
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
sku = db.Column(db.String(100), index=True) # 加索引优化查询
|
borrow_no = db.Column(db.String(100))
|
||||||
|
|
||||||
|
sku = db.Column(db.String(100))
|
||||||
source_table = db.Column(db.String(50))
|
source_table = db.Column(db.String(50))
|
||||||
stock_id = db.Column(db.Integer)
|
stock_id = db.Column(db.Integer)
|
||||||
|
barcode = db.Column(db.String(100))
|
||||||
|
|
||||||
quantity = db.Column(db.Numeric(19, 4))
|
quantity = db.Column(db.Numeric(19, 4))
|
||||||
|
|
||||||
|
# 借出信息
|
||||||
|
borrower_name = db.Column(db.String(100))
|
||||||
borrow_time = db.Column(db.DateTime, default=datetime.now)
|
borrow_time = db.Column(db.DateTime, default=datetime.now)
|
||||||
|
borrow_signature = db.Column(db.Text) # 借用人签字
|
||||||
expected_return_time = db.Column(db.DateTime)
|
expected_return_time = db.Column(db.DateTime)
|
||||||
|
|
||||||
borrower_name = db.Column(db.String(100))
|
# 归还信息
|
||||||
actual_return_time = db.Column(db.DateTime)
|
is_returned = db.Column(db.Boolean, default=False)
|
||||||
approver_name = db.Column(db.String(100))
|
return_time = db.Column(db.DateTime)
|
||||||
|
return_operator = db.Column(db.String(100)) # 库管
|
||||||
|
return_signature = db.Column(db.Text) # 库管签字
|
||||||
|
return_location = db.Column(db.String(100)) # 归还库位
|
||||||
|
|
||||||
# 状态:borrowed(借出), returned(已还), overdue(逾期)
|
|
||||||
status = db.Column(db.String(20), default='borrowed')
|
status = db.Column(db.String(20), default='borrowed')
|
||||||
|
remark = db.Column(db.Text)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
|
'borrow_no': self.borrow_no,
|
||||||
'sku': self.sku,
|
'sku': self.sku,
|
||||||
|
'barcode': self.barcode,
|
||||||
'quantity': float(self.quantity) if self.quantity else 0,
|
'quantity': float(self.quantity) if self.quantity else 0,
|
||||||
'borrower_name': self.borrower_name,
|
'borrower_name': self.borrower_name,
|
||||||
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M:%S') if self.borrow_time else None,
|
'borrow_time': self.borrow_time.strftime('%Y-%m-%d %H:%M') if self.borrow_time else '',
|
||||||
'status': self.status
|
'borrow_signature': self.borrow_signature,
|
||||||
}
|
'expected_return_time': self.expected_return_time.strftime(
|
||||||
|
'%Y-%m-%d %H:%M') if self.expected_return_time else '',
|
||||||
|
'is_returned': self.is_returned,
|
||||||
# 2. 维修表
|
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M') if self.return_time else '',
|
||||||
class TransRepair(db.Model):
|
'return_operator': self.return_operator,
|
||||||
__tablename__ = 'trans_repair'
|
'return_signature': self.return_signature,
|
||||||
|
'return_location': self.return_location,
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
'status': self.status,
|
||||||
sku = db.Column(db.String(100), index=True)
|
'remark': self.remark
|
||||||
source_table = db.Column(db.String(50))
|
|
||||||
stock_id = db.Column(db.Integer)
|
|
||||||
|
|
||||||
arrival_date = db.Column(db.Date)
|
|
||||||
expected_repair_time = db.Column(db.String(100))
|
|
||||||
shipping_date = db.Column(db.Date)
|
|
||||||
|
|
||||||
is_self_made = db.Column(db.Boolean, default=False)
|
|
||||||
related_product_id = db.Column(db.Integer)
|
|
||||||
related_contract_id = db.Column(db.String(100))
|
|
||||||
|
|
||||||
repair_manager = db.Column(db.String(100))
|
|
||||||
fault_description = db.Column(db.Text)
|
|
||||||
repair_result = db.Column(db.Text)
|
|
||||||
|
|
||||||
cost_price = db.Column(db.Numeric(19, 4))
|
|
||||||
sale_price = db.Column(db.Numeric(19, 4))
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'sku': self.sku,
|
|
||||||
'status': 'repaired' if self.repair_result else 'pending',
|
|
||||||
'manager': self.repair_manager
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# 3. 报废表
|
|
||||||
class TransScrap(db.Model):
|
|
||||||
__tablename__ = 'trans_scrap'
|
|
||||||
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
sku = db.Column(db.String(100), index=True)
|
|
||||||
source_table = db.Column(db.String(50))
|
|
||||||
stock_id = db.Column(db.Integer)
|
|
||||||
quantity = db.Column(db.Numeric(19, 4))
|
|
||||||
|
|
||||||
reason = db.Column(db.Text)
|
|
||||||
operator_name = db.Column(db.String(100))
|
|
||||||
operation_time = db.Column(db.DateTime, default=datetime.now)
|
|
||||||
|
|
||||||
approver_name = db.Column(db.String(100))
|
|
||||||
approval_status = db.Column(db.String(20), default='pending') # pending, approved, rejected
|
|
||||||
|
|
||||||
cost_at_scrap = db.Column(db.Numeric(19, 4))
|
|
||||||
total_loss = db.Column(db.Numeric(19, 4))
|
|
||||||
|
|
||||||
def to_dict(self):
|
|
||||||
return {
|
|
||||||
'id': self.id,
|
|
||||||
'sku': self.sku,
|
|
||||||
'quantity': float(self.quantity) if self.quantity else 0,
|
|
||||||
'total_loss': float(self.total_loss) if self.total_loss else 0,
|
|
||||||
'reason': self.reason
|
|
||||||
}
|
}
|
||||||
@ -1,9 +1,8 @@
|
|||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
# 引用新的模型类 StockBuy
|
|
||||||
from app.models.inbound.buy import StockBuy
|
from app.models.inbound.buy import StockBuy
|
||||||
from app.models.base import MaterialBase
|
from app.models.base import MaterialBase
|
||||||
|
|
||||||
# 尝试导入出库模型,如果不存在则忽略(防止报错影响入库功能)
|
# 尝试导入出库模型,如果不存在则忽略
|
||||||
try:
|
try:
|
||||||
from app.models.outbound import TransOutbound
|
from app.models.outbound import TransOutbound
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -17,28 +16,70 @@ import json
|
|||||||
|
|
||||||
class BuyInboundService:
|
class BuyInboundService:
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 0. 辅助:唯一性校验 (核心修复)
|
||||||
|
# ============================================================
|
||||||
|
@staticmethod
|
||||||
|
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||||
|
"""
|
||||||
|
校验序列号和批号的唯一性逻辑
|
||||||
|
:param base_id: 当前物料的基础ID
|
||||||
|
:param serial_number: 序列号
|
||||||
|
:param batch_number: 批号
|
||||||
|
:param exclude_id: 排除的ID (用于编辑模式)
|
||||||
|
"""
|
||||||
|
# 1. 序列号 (SN) 全局唯一校验
|
||||||
|
# 解释: 不同规格的物料通常也不应该有相同的SN,防止扫码混淆
|
||||||
|
if serial_number:
|
||||||
|
query = StockBuy.query.filter(StockBuy.serial_number == serial_number)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(StockBuy.id != exclude_id)
|
||||||
|
|
||||||
|
exists = query.first()
|
||||||
|
if exists:
|
||||||
|
# 获取占用该SN的物料名称,提示更友好
|
||||||
|
occupied_name = exists.material.name if exists.material else "未知物料"
|
||||||
|
raise ValueError(f"序列号【{serial_number}】已存在!被物料 [{occupied_name}] 占用,请核查。")
|
||||||
|
|
||||||
|
# 2. 批号 (BN) 同物料唯一校验
|
||||||
|
# 解释: 不同规格的物料可以有相同的批号(如都有 001 批次),但同一个物料不能重复建单
|
||||||
|
if batch_number and base_id:
|
||||||
|
query = StockBuy.query.filter(
|
||||||
|
StockBuy.base_id == base_id,
|
||||||
|
StockBuy.batch_number == batch_number
|
||||||
|
)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(StockBuy.id != exclude_id)
|
||||||
|
|
||||||
|
if query.first():
|
||||||
|
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复录入,可直接在该批次下追加库存。")
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 1. 基础物料搜索
|
# 1. 基础物料搜索
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_base_material(keyword):
|
def search_base_material(keyword):
|
||||||
"""搜索基础物料"""
|
|
||||||
try:
|
try:
|
||||||
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
query = MaterialBase.query.filter(MaterialBase.is_enabled == True)
|
||||||
if keyword:
|
if keyword:
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
or_(
|
or_(
|
||||||
MaterialBase.name.ilike(f'%{keyword}%'),
|
MaterialBase.name.ilike(f'%{keyword}%'),
|
||||||
MaterialBase.spec_model.ilike(f'%{keyword}%')
|
MaterialBase.spec_model.ilike(f'%{keyword}%'),
|
||||||
|
MaterialBase.pinyin.ilike(f'%{keyword}%') # 假设有拼音搜索
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||||
results = []
|
results = []
|
||||||
for item in query.all():
|
for item in query.all():
|
||||||
results.append({
|
results.append({
|
||||||
'id': item.id, 'name': item.name, 'spec': item.spec_model,
|
'id': item.id,
|
||||||
'category': item.category, 'unit': item.unit,
|
'name': item.name,
|
||||||
'type': item.material_type, 'status': '启用'
|
'spec': item.spec_model, # 确保这里字段对应正确
|
||||||
|
'category': item.category,
|
||||||
|
'unit': item.unit,
|
||||||
|
'type': item.material_type,
|
||||||
|
'status': '启用'
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -46,76 +87,76 @@ class BuyInboundService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 2. 新增入库逻辑 (强制北京时间)
|
# 2. 新增入库逻辑
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_inbound(data):
|
def handle_inbound(data):
|
||||||
"""新增入库"""
|
|
||||||
try:
|
try:
|
||||||
base_id = data.get('base_id')
|
base_id = data.get('base_id')
|
||||||
if not base_id: raise ValueError("必须选择基础物料")
|
if not base_id:
|
||||||
material = MaterialBase.query.get(base_id)
|
raise ValueError("必须选择基础物料")
|
||||||
if not material: raise ValueError("物料不存在")
|
|
||||||
|
|
||||||
# [核心修改] 获取当前北京时间 (UTC+8)
|
material = MaterialBase.query.get(base_id)
|
||||||
# 无论服务器在 UTC 还是其他时区,这里强制转换为 UTC+8 并去掉时区信息存入数据库
|
if not material:
|
||||||
|
raise ValueError("所选物料不存在")
|
||||||
|
|
||||||
|
# --- [修复点] 执行唯一性校验 ---
|
||||||
|
BuyInboundService._check_unique(
|
||||||
|
base_id=base_id,
|
||||||
|
serial_number=data.get('serial_number'),
|
||||||
|
batch_number=data.get('batch_number')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 时间处理 (强制北京时间)
|
||||||
beijing_tz = timezone(timedelta(hours=8))
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||||
|
|
||||||
in_date_val = current_time
|
in_date_val = current_time
|
||||||
|
|
||||||
if data.get('in_date'):
|
if data.get('in_date'):
|
||||||
try:
|
try:
|
||||||
date_str = str(data['in_date'])
|
date_str = str(data['in_date'])
|
||||||
# 如果前端传了时分秒,尝试直接解析
|
|
||||||
if len(date_str) > 10:
|
if len(date_str) > 10:
|
||||||
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
in_date_val = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
|
||||||
else:
|
else:
|
||||||
# 如果只传了日期,使用该日期 + 当前北京时间的时分秒
|
|
||||||
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
|
d_temp = datetime.strptime(date_str, '%Y-%m-%d')
|
||||||
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
|
in_date_val = datetime(d_temp.year, d_temp.month, d_temp.day,
|
||||||
current_time.hour, current_time.minute, current_time.second)
|
current_time.hour, current_time.minute, current_time.second)
|
||||||
except:
|
except:
|
||||||
# 解析失败则使用当前北京时间
|
|
||||||
in_date_val = current_time
|
in_date_val = current_time
|
||||||
|
|
||||||
in_qty = float(data.get('in_quantity') or 0)
|
in_qty = float(data.get('in_quantity') or 0)
|
||||||
u_price = float(data.get('unit_price') or 0)
|
u_price = float(data.get('unit_price') or 0)
|
||||||
|
|
||||||
# [核心逻辑] 获取全局打印流水号
|
# 获取全局打印ID
|
||||||
try:
|
try:
|
||||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||||
result = db.session.execute(seq_sql)
|
result = db.session.execute(seq_sql)
|
||||||
next_global_id = result.scalar()
|
next_global_id = result.scalar()
|
||||||
except Exception:
|
except:
|
||||||
# 如果序列不存在,回退处理(或在数据库创建序列)
|
|
||||||
print("Warning: Sequence global_print_seq not found.")
|
|
||||||
next_global_id = None
|
next_global_id = None
|
||||||
|
|
||||||
# SKU 生成逻辑:如果没有 ID,用临时随机数或空;通常应该依赖 next_global_id
|
# SKU 生成
|
||||||
if next_global_id:
|
if next_global_id:
|
||||||
generated_sku = str(next_global_id).zfill(10)
|
generated_sku = str(next_global_id).zfill(10)
|
||||||
else:
|
else:
|
||||||
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S') # 降级方案
|
generated_sku = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
|
||||||
final_barcode = data.get('barcode') or generated_sku
|
final_barcode = data.get('barcode') or generated_sku
|
||||||
|
|
||||||
arrival_list = data.get('arrival_photo', [])
|
arrival_list = data.get('arrival_photo', [])
|
||||||
report_list = data.get('inspection_report', [])
|
report_list = data.get('inspection_report', [])
|
||||||
if not isinstance(arrival_list, list): arrival_list = []
|
|
||||||
if not isinstance(report_list, list): report_list = []
|
|
||||||
|
|
||||||
new_stock = StockBuy(
|
new_stock = StockBuy(
|
||||||
base_id=material.id,
|
base_id=material.id,
|
||||||
global_print_id=next_global_id,
|
global_print_id=next_global_id,
|
||||||
sku=generated_sku,
|
sku=generated_sku,
|
||||||
barcode=final_barcode,
|
barcode=final_barcode,
|
||||||
in_date=in_date_val, # 存入 DateTime 对象
|
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'),
|
||||||
status=data.get('status', '在库'),
|
status=data.get('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,
|
||||||
inspection_status=data.get('inspection_status', '未检'),
|
inspection_status=data.get('inspection_status', '未检'),
|
||||||
warehouse_location=data.get('warehouse_location'),
|
warehouse_location=data.get('warehouse_location'),
|
||||||
@ -143,13 +184,27 @@ class BuyInboundService:
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update_inbound(stock_id, data):
|
def update_inbound(stock_id, data):
|
||||||
"""更新入库"""
|
|
||||||
try:
|
try:
|
||||||
stock = StockBuy.query.get(stock_id)
|
stock = StockBuy.query.get(stock_id)
|
||||||
if not stock: raise ValueError("记录不存在")
|
if not stock:
|
||||||
|
raise ValueError("记录不存在")
|
||||||
|
|
||||||
|
# --- [修复点] 编辑时也要校验唯一性 (排除自身ID) ---
|
||||||
|
# 如果修改了物料(base_id),或者修改了SN/BN,都需要校验
|
||||||
|
new_base_id = data.get('base_id', stock.base_id)
|
||||||
|
new_sn = data.get('serial_number', stock.serial_number)
|
||||||
|
new_bn = data.get('batch_number', stock.batch_number)
|
||||||
|
|
||||||
|
BuyInboundService._check_unique(
|
||||||
|
base_id=new_base_id,
|
||||||
|
serial_number=new_sn,
|
||||||
|
batch_number=new_bn,
|
||||||
|
exclude_id=stock_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 更新字段
|
||||||
field_mapping = {
|
field_mapping = {
|
||||||
'sku': 'sku', 'barcode': 'barcode',
|
'sku': 'sku', 'barcode': 'barcode', 'base_id': 'base_id',
|
||||||
'warehouse_location': 'warehouse_location',
|
'warehouse_location': 'warehouse_location',
|
||||||
'serial_number': 'serial_number', 'batch_number': 'batch_number',
|
'serial_number': 'serial_number', 'batch_number': 'batch_number',
|
||||||
'status': 'status', 'inspection_status': 'inspection_status',
|
'status': 'status', 'inspection_status': 'inspection_status',
|
||||||
@ -166,9 +221,9 @@ class BuyInboundService:
|
|||||||
if 'inspection_report' in data and isinstance(data['inspection_report'], list):
|
if 'inspection_report' in data and isinstance(data['inspection_report'], list):
|
||||||
stock.inspection_report = json.dumps(data['inspection_report'])
|
stock.inspection_report = json.dumps(data['inspection_report'])
|
||||||
|
|
||||||
|
# 库存数量变更逻辑
|
||||||
if 'in_quantity' in data:
|
if 'in_quantity' in data:
|
||||||
new_qty = float(data['in_quantity'])
|
new_qty = float(data['in_quantity'])
|
||||||
# 计算差值,同步更新库存量和可用量
|
|
||||||
diff = new_qty - float(stock.in_quantity)
|
diff = new_qty - float(stock.in_quantity)
|
||||||
if diff != 0:
|
if diff != 0:
|
||||||
stock.in_quantity = new_qty
|
stock.in_quantity = new_qty
|
||||||
@ -190,7 +245,6 @@ class BuyInboundService:
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_inbound(stock_id):
|
def delete_inbound(stock_id):
|
||||||
"""删除入库"""
|
|
||||||
try:
|
try:
|
||||||
stock = StockBuy.query.get(stock_id)
|
stock = StockBuy.query.get(stock_id)
|
||||||
if not stock: raise ValueError("记录不存在")
|
if not stock: raise ValueError("记录不存在")
|
||||||
@ -202,34 +256,13 @@ class BuyInboundService:
|
|||||||
raise e
|
raise e
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 5. 获取出库流转历史
|
# 5. 获取列表
|
||||||
# ============================================================
|
|
||||||
@staticmethod
|
|
||||||
def get_outbound_history(stock_id):
|
|
||||||
"""获取出库历史"""
|
|
||||||
if not TransOutbound:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
records = TransOutbound.query.filter_by(
|
|
||||||
source_table='stock_buy', stock_id=stock_id
|
|
||||||
).order_by(TransOutbound.outbound_time.desc()).all()
|
|
||||||
return [r.to_dict() for r in records]
|
|
||||||
except:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 6. 获取列表 (修改:按时间倒序排序 + 展示只显示日期)
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_list(page, limit, keyword=None, statuses=None):
|
def get_list(page, limit, keyword=None, statuses=None):
|
||||||
"""
|
|
||||||
获取列表
|
|
||||||
:param statuses: 状态列表 (e.g. ['在库', '借库', '已出库'])
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
query = db.session.query(StockBuy).outerjoin(MaterialBase, StockBuy.base_id == MaterialBase.id)
|
||||||
|
|
||||||
# 1. 关键词搜索
|
|
||||||
if keyword:
|
if keyword:
|
||||||
kw = f'%{keyword}%'
|
kw = f'%{keyword}%'
|
||||||
query = query.filter(
|
query = query.filter(
|
||||||
@ -243,23 +276,14 @@ class BuyInboundService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. 状态筛选
|
|
||||||
if not statuses:
|
if not statuses:
|
||||||
statuses = ['在库', '借库']
|
statuses = ['在库', '借库']
|
||||||
|
|
||||||
if '已出库' in statuses:
|
if '已出库' in statuses:
|
||||||
# 如果明确查已出库,可以包含库存为0的
|
|
||||||
query = query.filter(StockBuy.status.in_(statuses))
|
query = query.filter(StockBuy.status.in_(statuses))
|
||||||
else:
|
else:
|
||||||
# 默认查在库,必须保证库存 > 0
|
query = query.filter(and_(StockBuy.status.in_(statuses), StockBuy.stock_quantity > 0))
|
||||||
query = query.filter(
|
|
||||||
and_(
|
|
||||||
StockBuy.status.in_(statuses),
|
|
||||||
StockBuy.stock_quantity > 0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# [核心修改] 按照入库时间倒序排序 (从近到远)
|
|
||||||
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
|
pagination = query.order_by(StockBuy.in_date.desc()).paginate(page=page, per_page=limit, error_out=False)
|
||||||
current_items = pagination.items
|
current_items = pagination.items
|
||||||
|
|
||||||
@ -275,7 +299,6 @@ class BuyInboundService:
|
|||||||
qty_stock = float(item.stock_quantity or 0)
|
qty_stock = float(item.stock_quantity or 0)
|
||||||
qty_avail = float(item.available_quantity or 0)
|
qty_avail = float(item.available_quantity or 0)
|
||||||
|
|
||||||
# [核心修改] 格式化展示日期,去掉时分秒
|
|
||||||
date_display = ''
|
date_display = ''
|
||||||
if item.in_date:
|
if item.in_date:
|
||||||
try:
|
try:
|
||||||
@ -286,6 +309,7 @@ class BuyInboundService:
|
|||||||
d = {
|
d = {
|
||||||
'id': item.id,
|
'id': item.id,
|
||||||
'base_id': item.base_id,
|
'base_id': item.base_id,
|
||||||
|
# 确保这里从关联的 MaterialBase 获取规格型号
|
||||||
'material_name': item.material.name if item.material else '',
|
'material_name': item.material.name if item.material else '',
|
||||||
'spec_model': item.material.spec_model if item.material else '',
|
'spec_model': item.material.spec_model if item.material else '',
|
||||||
'category': item.material.category if item.material else '',
|
'category': item.material.category if item.material else '',
|
||||||
@ -293,21 +317,15 @@ class BuyInboundService:
|
|||||||
'material_type': item.material.material_type if item.material else '',
|
'material_type': item.material.material_type if item.material else '',
|
||||||
|
|
||||||
'sku': item.sku,
|
'sku': item.sku,
|
||||||
'inbound_date': date_display, # 前端展示用的日期字符串
|
'inbound_date': date_display,
|
||||||
'barcode': item.barcode,
|
'barcode': item.barcode,
|
||||||
'serial_number': item.serial_number,
|
'serial_number': item.serial_number,
|
||||||
'batch_number': item.batch_number,
|
'batch_number': item.batch_number,
|
||||||
|
|
||||||
'status': item.status,
|
'status': item.status,
|
||||||
'inspection_status': item.inspection_status,
|
'inspection_status': item.inspection_status,
|
||||||
|
|
||||||
'qty_inbound': float(item.in_quantity or 0),
|
'qty_inbound': float(item.in_quantity or 0),
|
||||||
'qty_stock': qty_stock,
|
'qty_stock': qty_stock,
|
||||||
'qty_available': qty_avail,
|
'qty_available': qty_avail,
|
||||||
|
|
||||||
'sum_stock': qty_stock,
|
|
||||||
'sum_available': qty_avail,
|
|
||||||
|
|
||||||
'warehouse_loc': item.warehouse_location,
|
'warehouse_loc': item.warehouse_location,
|
||||||
'unit_price': float(item.unit_price or 0),
|
'unit_price': float(item.unit_price or 0),
|
||||||
'total_price': float(item.total_price or 0),
|
'total_price': float(item.total_price or 0),
|
||||||
@ -320,8 +338,7 @@ class BuyInboundService:
|
|||||||
'detail_link': item.detail_link,
|
'detail_link': item.detail_link,
|
||||||
'arrival_photo': parse_img(item.arrival_photo),
|
'arrival_photo': parse_img(item.arrival_photo),
|
||||||
'inspection_report': parse_img(item.inspection_report),
|
'inspection_report': parse_img(item.inspection_report),
|
||||||
'global_print_id': item.global_print_id,
|
'global_print_id': item.global_print_id
|
||||||
'global_print_id_str': f"{item.global_print_id:08d}" if item.global_print_id else ""
|
|
||||||
}
|
}
|
||||||
items.append(d)
|
items.append(d)
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,30 @@ import json
|
|||||||
class ProductInboundService:
|
class ProductInboundService:
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 1. 基础物料搜索 (已修正:完全对齐 Buy/Semi 的逻辑)
|
# 0. 辅助:唯一性校验 (新增核心逻辑)
|
||||||
|
# ============================================================
|
||||||
|
@staticmethod
|
||||||
|
def _check_unique(serial_number, exclude_id=None):
|
||||||
|
"""
|
||||||
|
校验成品的唯一性
|
||||||
|
:param serial_number: 序列号
|
||||||
|
:param exclude_id: 排除的ID (编辑模式用)
|
||||||
|
"""
|
||||||
|
from app.models.inbound.product import StockProduct
|
||||||
|
|
||||||
|
# 成品强校验序列号 (SN) - SN应该是全局唯一的
|
||||||
|
if serial_number:
|
||||||
|
query = StockProduct.query.filter(StockProduct.serial_number == serial_number)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(StockProduct.id != exclude_id)
|
||||||
|
|
||||||
|
exists = query.first()
|
||||||
|
if exists:
|
||||||
|
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
|
||||||
|
raise ValueError(f"序列号【{serial_number}】已存在!被成品 [{occupied_name}] 占用,请核查。")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. 基础物料搜索
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_base_material(keyword):
|
def search_base_material(keyword):
|
||||||
@ -31,16 +54,16 @@ class ProductInboundService:
|
|||||||
# 3. 排序与限制:按ID倒序,取最新20条
|
# 3. 排序与限制:按ID倒序,取最新20条
|
||||||
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
query = query.order_by(MaterialBase.id.desc()).limit(20)
|
||||||
|
|
||||||
# 4. 结果封装:确保字段名与前端 Vue 的 handleSelect 方法一致
|
# 4. 结果封装
|
||||||
results = []
|
results = []
|
||||||
for item in query.all():
|
for item in query.all():
|
||||||
results.append({
|
results.append({
|
||||||
'id': item.id,
|
'id': item.id,
|
||||||
'name': item.name,
|
'name': item.name,
|
||||||
'spec': item.spec_model, # 对应前端: item.spec
|
'spec': item.spec_model,
|
||||||
'category': item.category, # 对应前端: item.category
|
'category': item.category,
|
||||||
'unit': item.unit, # 对应前端: item.unit
|
'unit': item.unit,
|
||||||
'type': item.material_type, # 对应前端: item.type
|
'type': item.material_type,
|
||||||
'status': '启用'
|
'status': '启用'
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
@ -49,7 +72,7 @@ class ProductInboundService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 2. 新增入库逻辑 (强制北京时间)
|
# 2. 新增入库逻辑 (强制北京时间 + 唯一性校验)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_inbound(data):
|
def handle_inbound(data):
|
||||||
@ -61,6 +84,11 @@ class ProductInboundService:
|
|||||||
material = MaterialBase.query.get(base_id)
|
material = MaterialBase.query.get(base_id)
|
||||||
if not material: raise ValueError("物料不存在")
|
if not material: raise ValueError("物料不存在")
|
||||||
|
|
||||||
|
# --- [核心修改] 执行唯一性校验 ---
|
||||||
|
ProductInboundService._check_unique(
|
||||||
|
serial_number=data.get('serial_number')
|
||||||
|
)
|
||||||
|
|
||||||
# [核心修改] 强制北京时间
|
# [核心修改] 强制北京时间
|
||||||
beijing_tz = timezone(timedelta(hours=8))
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||||
@ -86,11 +114,14 @@ class ProductInboundService:
|
|||||||
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
|
time_range = f"{p_start} ~ {p_end}" if p_start or p_end else None
|
||||||
|
|
||||||
# 全局流水号
|
# 全局流水号
|
||||||
seq_sql = text("SELECT nextval('global_print_seq')")
|
try:
|
||||||
result = db.session.execute(seq_sql)
|
seq_sql = text("SELECT nextval('global_print_seq')")
|
||||||
next_global_id = result.scalar()
|
result = db.session.execute(seq_sql)
|
||||||
|
next_global_id = result.scalar()
|
||||||
|
except:
|
||||||
|
next_global_id = None
|
||||||
|
|
||||||
generated_sku = str(next_global_id).zfill(10)
|
generated_sku = str(next_global_id).zfill(10) if next_global_id else datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
final_barcode = data.get('barcode') or generated_sku
|
final_barcode = data.get('barcode') or generated_sku
|
||||||
|
|
||||||
photo_list = data.get('product_photo', [])
|
photo_list = data.get('product_photo', [])
|
||||||
@ -156,6 +187,13 @@ class ProductInboundService:
|
|||||||
stock = StockProduct.query.get(stock_id)
|
stock = StockProduct.query.get(stock_id)
|
||||||
if not stock: raise ValueError("记录不存在")
|
if not stock: raise ValueError("记录不存在")
|
||||||
|
|
||||||
|
# --- [核心修改] 编辑时也要校验唯一性 ---
|
||||||
|
if 'serial_number' in data:
|
||||||
|
ProductInboundService._check_unique(
|
||||||
|
serial_number=data['serial_number'],
|
||||||
|
exclude_id=stock_id
|
||||||
|
)
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
'barcode', 'serial_number', 'warehouse_location',
|
'barcode', 'serial_number', 'warehouse_location',
|
||||||
'status', 'quality_status', 'bom_code', 'bom_version',
|
'status', 'quality_status', 'bom_code', 'bom_version',
|
||||||
|
|||||||
@ -11,7 +11,44 @@ import json
|
|||||||
class SemiInboundService:
|
class SemiInboundService:
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# 1. 基础物料搜索 (已修复:支持空关键词返回最新数据)
|
# 0. 辅助:唯一性校验 (新增核心逻辑)
|
||||||
|
# ============================================================
|
||||||
|
@staticmethod
|
||||||
|
def _check_unique(base_id, serial_number, batch_number, exclude_id=None):
|
||||||
|
"""
|
||||||
|
校验半成品的唯一性
|
||||||
|
:param base_id: 基础物料ID
|
||||||
|
:param serial_number: 序列号
|
||||||
|
:param batch_number: 批号
|
||||||
|
:param exclude_id: 排除的ID
|
||||||
|
"""
|
||||||
|
from app.models.inbound.semi import StockSemi
|
||||||
|
|
||||||
|
# 1. 序列号 (SN) 校验 - 全局唯一
|
||||||
|
if serial_number:
|
||||||
|
query = StockSemi.query.filter(StockSemi.serial_number == serial_number)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(StockSemi.id != exclude_id)
|
||||||
|
|
||||||
|
exists = query.first()
|
||||||
|
if exists:
|
||||||
|
occupied_name = exists.material.name if (hasattr(exists, 'material') and exists.material) else "未知物料"
|
||||||
|
raise ValueError(f"序列号【{serial_number}】已存在!被半成品 [{occupied_name}] 占用,请核查。")
|
||||||
|
|
||||||
|
# 2. 批号 (BN) 校验 - 同物料下不能重复开单
|
||||||
|
if batch_number and base_id:
|
||||||
|
query = StockSemi.query.filter(
|
||||||
|
StockSemi.base_id == base_id,
|
||||||
|
StockSemi.batch_number == batch_number
|
||||||
|
)
|
||||||
|
if exclude_id:
|
||||||
|
query = query.filter(StockSemi.id != exclude_id)
|
||||||
|
|
||||||
|
if query.first():
|
||||||
|
raise ValueError(f"该物料已存在批号【{batch_number}】,请勿重复建单,建议在原批次上追加库存。")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 1. 基础物料搜索
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def search_base_material(keyword):
|
def search_base_material(keyword):
|
||||||
@ -63,6 +100,13 @@ class SemiInboundService:
|
|||||||
if not material:
|
if not material:
|
||||||
raise ValueError(f"ID为 {base_id} 的基础物料不存在")
|
raise ValueError(f"ID为 {base_id} 的基础物料不存在")
|
||||||
|
|
||||||
|
# --- [核心修改] 执行唯一性校验 ---
|
||||||
|
SemiInboundService._check_unique(
|
||||||
|
base_id=base_id,
|
||||||
|
serial_number=data.get('serial_number'),
|
||||||
|
batch_number=data.get('batch_number')
|
||||||
|
)
|
||||||
|
|
||||||
# [核心修改] 强制北京时间
|
# [核心修改] 强制北京时间
|
||||||
beijing_tz = timezone(timedelta(hours=8))
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||||
@ -194,6 +238,18 @@ class SemiInboundService:
|
|||||||
if not stock:
|
if not stock:
|
||||||
raise ValueError("记录不存在")
|
raise ValueError("记录不存在")
|
||||||
|
|
||||||
|
# --- [核心修改] 编辑时也要校验唯一性 ---
|
||||||
|
new_base_id = data.get('base_id', stock.base_id)
|
||||||
|
new_sn = data.get('serial_number', stock.serial_number)
|
||||||
|
new_bn = data.get('batch_number', stock.batch_number)
|
||||||
|
|
||||||
|
SemiInboundService._check_unique(
|
||||||
|
base_id=new_base_id,
|
||||||
|
serial_number=new_sn,
|
||||||
|
batch_number=new_bn,
|
||||||
|
exclude_id=stock_id
|
||||||
|
)
|
||||||
|
|
||||||
field_mapping = {
|
field_mapping = {
|
||||||
'sku': 'sku',
|
'sku': 'sku',
|
||||||
'barcode': 'barcode',
|
'barcode': 'barcode',
|
||||||
|
|||||||
@ -0,0 +1,185 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from app.extensions import db
|
||||||
|
from app.models.transaction import TransBorrow
|
||||||
|
from app.models.inbound.buy import StockBuy
|
||||||
|
from app.models.inbound.semi import StockSemi
|
||||||
|
from app.models.inbound.product import StockProduct
|
||||||
|
from sqlalchemy import desc, func
|
||||||
|
|
||||||
|
|
||||||
|
class TransService:
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_borrow_no():
|
||||||
|
"""
|
||||||
|
生成借用单号: BOR-yyyyMMdd-0001 (按日流水)
|
||||||
|
逻辑:统计当天已存在的不同借用单号数量,+1 作为新序号
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
date_str = now.strftime('%Y%m%d')
|
||||||
|
prefix = f"BOR-{date_str}-"
|
||||||
|
|
||||||
|
# 使用 count distinct 来计算当天有多少个不同的借用单 (因为一单多货会占多行)
|
||||||
|
count = db.session.query(func.count(func.distinct(TransBorrow.borrow_no))) \
|
||||||
|
.filter(TransBorrow.borrow_no.like(f"{prefix}%")).scalar()
|
||||||
|
|
||||||
|
sequence = count + 1
|
||||||
|
return f"{prefix}{sequence:04d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_borrow(data, operator_name='System'):
|
||||||
|
"""
|
||||||
|
借库逻辑:减少可用库存,不减总库存
|
||||||
|
"""
|
||||||
|
items = data.get('items', [])
|
||||||
|
borrower_name = data.get('borrower_name')
|
||||||
|
signature = data.get('signature_path') # 借用人签字
|
||||||
|
|
||||||
|
if not items: raise ValueError("物品列表为空")
|
||||||
|
if not borrower_name: raise ValueError("请输入借用人")
|
||||||
|
if not signature: raise ValueError("借用人必须签字")
|
||||||
|
|
||||||
|
borrow_no = TransService.generate_borrow_no()
|
||||||
|
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in items:
|
||||||
|
source_table = item.get('source_table')
|
||||||
|
stock_id = item.get('id')
|
||||||
|
qty = float(item.get('out_quantity', 0))
|
||||||
|
|
||||||
|
ModelClass = model_map.get(source_table)
|
||||||
|
if not ModelClass: continue
|
||||||
|
|
||||||
|
stock = ModelClass.query.with_for_update().get(stock_id)
|
||||||
|
if not stock: raise ValueError(f"库存不存在 ID:{stock_id}")
|
||||||
|
|
||||||
|
if float(stock.available_quantity) < qty:
|
||||||
|
raise ValueError(f"SKU {stock.sku} 可用库存不足")
|
||||||
|
|
||||||
|
# 1. 冻结库存 (只减可用)
|
||||||
|
stock.available_quantity = float(stock.available_quantity) - qty
|
||||||
|
|
||||||
|
# 2. 创建借用单
|
||||||
|
record = TransBorrow(
|
||||||
|
borrow_no=borrow_no,
|
||||||
|
sku=stock.sku,
|
||||||
|
source_table=source_table,
|
||||||
|
stock_id=stock.id,
|
||||||
|
barcode=stock.barcode,
|
||||||
|
quantity=qty,
|
||||||
|
borrower_name=borrower_name,
|
||||||
|
borrow_signature=signature,
|
||||||
|
remark=data.get('remark'),
|
||||||
|
expected_return_time=data.get('expected_return_time'),
|
||||||
|
status='borrowed',
|
||||||
|
is_returned=False
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return borrow_no
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def scan_for_return(barcode):
|
||||||
|
"""
|
||||||
|
扫码还库:查找未归还记录,并返回当前物品的库位
|
||||||
|
"""
|
||||||
|
records = TransBorrow.query.filter_by(barcode=barcode, is_returned=False).all()
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 取第一条未还记录
|
||||||
|
record = records[0]
|
||||||
|
|
||||||
|
# 获取当前库存表中的实时库位
|
||||||
|
current_location = ""
|
||||||
|
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||||
|
ModelClass = model_map.get(record.source_table)
|
||||||
|
|
||||||
|
if ModelClass:
|
||||||
|
stock = ModelClass.query.get(record.stock_id)
|
||||||
|
if stock:
|
||||||
|
current_location = stock.warehouse_location
|
||||||
|
|
||||||
|
res_dict = record.to_dict()
|
||||||
|
res_dict['current_location'] = current_location # 用于前端对比和预填
|
||||||
|
return res_dict
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_return(data, operator_name):
|
||||||
|
"""
|
||||||
|
还库逻辑:
|
||||||
|
1. 恢复可用库存
|
||||||
|
2. 更新库位 (如果有变动)
|
||||||
|
3. 记录库管签字
|
||||||
|
"""
|
||||||
|
items = data.get('items', [])
|
||||||
|
signature = data.get('signature_path') # 库管签字
|
||||||
|
|
||||||
|
if not items: raise ValueError("还库列表为空")
|
||||||
|
if not signature: raise ValueError("库管必须签字确认")
|
||||||
|
|
||||||
|
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi, 'stock_product': StockProduct}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in items:
|
||||||
|
borrow_id = item.get('id')
|
||||||
|
# 前端如果没有填 return_location,应该在提交前处理好,或者这里做 fallback
|
||||||
|
# 这里假设前端传来的 return_location 就是最终要保存的库位
|
||||||
|
final_location = item.get('return_location')
|
||||||
|
|
||||||
|
record = TransBorrow.query.with_for_update().get(borrow_id)
|
||||||
|
if not record or record.is_returned:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ModelClass = model_map.get(record.source_table)
|
||||||
|
if ModelClass:
|
||||||
|
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||||
|
if stock:
|
||||||
|
# 1. 恢复可用库存
|
||||||
|
stock.available_quantity = float(stock.available_quantity) + float(record.quantity)
|
||||||
|
|
||||||
|
# 2. 更新库位 (如果提供了有效值)
|
||||||
|
if final_location:
|
||||||
|
stock.warehouse_location = final_location
|
||||||
|
|
||||||
|
# 3. 更新借用单状态
|
||||||
|
record.is_returned = True
|
||||||
|
record.status = 'returned'
|
||||||
|
record.return_time = datetime.now()
|
||||||
|
record.return_operator = operator_name
|
||||||
|
record.return_signature = signature
|
||||||
|
record.return_location = final_location
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_records(page=1, limit=10, status='all', keyword=None):
|
||||||
|
q = TransBorrow.query
|
||||||
|
if status == 'borrowed':
|
||||||
|
q = q.filter(TransBorrow.is_returned == False)
|
||||||
|
elif status == 'returned':
|
||||||
|
q = q.filter(TransBorrow.is_returned == True)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(TransBorrow.borrower_name.ilike(f'%{keyword}%') |
|
||||||
|
TransBorrow.sku.ilike(f'%{keyword}%') |
|
||||||
|
TransBorrow.borrow_no.ilike(f'%{keyword}%'))
|
||||||
|
|
||||||
|
q = q.order_by(desc(TransBorrow.borrow_time))
|
||||||
|
pagination = q.paginate(page=page, per_page=limit, error_out=False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'items': [r.to_dict() for r in pagination.items],
|
||||||
|
'total': pagination.total,
|
||||||
|
'page': page,
|
||||||
|
'limit': limit
|
||||||
|
}
|
||||||
@ -123,7 +123,7 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
{
|
{
|
||||||
path: '/operation',
|
path: '/operation',
|
||||||
component: Layout,
|
component: Layout,
|
||||||
meta: { title: '其他业务', icon: 'Operation' },
|
meta: { title: '借库管理', icon: 'Operation' },
|
||||||
redirect: '/operation/borrow',
|
redirect: '/operation/borrow',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@ -136,13 +136,13 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
path: 'repair',
|
path: 'repair',
|
||||||
name: 'OpRepair',
|
name: 'OpRepair',
|
||||||
component: () => import('@/views/transaction/return.vue'),
|
component: () => import('@/views/transaction/return.vue'),
|
||||||
meta: { title: '维修' }
|
meta: { title: '返还' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'scrap',
|
path: 'records',
|
||||||
name: 'OpScrap',
|
name: 'OpRecords',
|
||||||
component: () => import('@/views/transaction/scrap.vue'),
|
component: () => import('@/views/transaction/records.vue'),
|
||||||
meta: { title: '报废' }
|
meta: { title: '借还记录' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -92,20 +92,19 @@
|
|||||||
<span v-else class="text-placeholder">-</span>
|
<span v-else class="text-placeholder">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
|
|
||||||
<span class="stock-num">{{ scope.row.qty_stock }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default="scope" v-else-if="col.prop === 'qty_available'">
|
|
||||||
<span class="avail-num">{{ scope.row.qty_available }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #default="scope" v-else-if="col.prop === 'status'">
|
<template #default="scope" v-else-if="col.prop === 'status'">
|
||||||
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>
|
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>
|
||||||
{{ scope.row.status }}
|
{{ scope.row.status }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
|
||||||
|
<span class="stock-num">{{ scope.row.qty_stock }}</span>
|
||||||
|
</template>
|
||||||
|
<template #default="scope" v-else-if="col.prop === 'qty_available'">
|
||||||
|
<span class="avail-num">{{ scope.row.qty_available }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)">
|
<template #default="scope" v-else-if="['arrival_photo', 'inspection_report'].includes(col.prop)">
|
||||||
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
|
<div v-if="getImagesOnly(scope.row[col.prop]).length > 0" style="display: flex; align-items: center; justify-content: center;">
|
||||||
<el-image
|
<el-image
|
||||||
@ -128,12 +127,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default="scope" v-else-if="col.prop.includes('link')">
|
<template #default="scope" v-else-if="col.prop.includes('link')">
|
||||||
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank"
|
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
|
||||||
:underline="false">
|
<el-icon><Link/></el-icon> 查看
|
||||||
<el-icon>
|
|
||||||
<Link/>
|
|
||||||
</el-icon>
|
|
||||||
查看
|
|
||||||
</el-link>
|
</el-link>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -146,10 +141,7 @@
|
|||||||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="warning" size="default" @click="handlePrint(row)">
|
<el-button link type="warning" size="default" @click="handlePrint(row)">
|
||||||
<el-icon>
|
<el-icon><Printer/></el-icon> 打印
|
||||||
<Printer/>
|
|
||||||
</el-icon>
|
|
||||||
打印
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
||||||
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
||||||
@ -234,10 +226,8 @@
|
|||||||
<div class="read-only-grid">
|
<div class="read-only-grid">
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
|
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view"/></el-form-item></el-col>
|
||||||
|
|
||||||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
|
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view"/></el-form-item></el-col>
|
||||||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
|
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view"/></el-form-item></el-col>
|
||||||
|
|
||||||
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
|
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view"/></el-form-item></el-col>
|
||||||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
|
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view"/></el-form-item></el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@ -273,7 +263,7 @@
|
|||||||
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
|
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
|
||||||
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
|
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 历史锁定</span>
|
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock/></el-icon> 历史锁定 (同物料遵循历史模式)</span>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@ -385,7 +375,9 @@
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/>
|
<input type="file" ref="cameraInputRef" accept="image/*" capture="environment" style="display: none" @change="handleCameraFile"/>
|
||||||
|
|
||||||
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
|
||||||
|
|
||||||
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
|
||||||
<div style="text-align: center;">
|
<div style="text-align: center;">
|
||||||
<div v-loading="printLoading" class="preview-box">
|
<div v-loading="printLoading" class="preview-box">
|
||||||
@ -455,23 +447,20 @@ const inspection_report_url = ref('')
|
|||||||
// 基础列
|
// 基础列
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
{prop: 'material_name', label: '名称'},
|
{prop: 'material_name', label: '名称'},
|
||||||
{prop: 'material_type', label: '类型'}, // 移到类别前面
|
{prop: 'material_type', label: '类型'},
|
||||||
{prop: 'category', label: '类别'},
|
{prop: 'category', label: '类别'},
|
||||||
{prop: 'spec_model', label: '规格型号'},
|
{prop: 'spec_model', label: '规格型号'},
|
||||||
{prop: 'unit', label: '单位'},
|
{prop: 'unit', label: '单位'},
|
||||||
]
|
]
|
||||||
|
|
||||||
// [修改] 库存与商务列配置:将序列号/批号改为 "序列号/批号"
|
// 库存与商务列
|
||||||
const stockColumns = [
|
const stockColumns = [
|
||||||
{prop: 'id', label: 'ID', minWidth: '60'},
|
{prop: 'id', label: 'ID', minWidth: '60'},
|
||||||
{prop: 'base_id', label: 'BaseID', minWidth: '80'},
|
{prop: 'base_id', label: 'BaseID', minWidth: '80'},
|
||||||
{prop: 'sku', label: 'SKU', minWidth: '120'},
|
{prop: 'sku', label: 'SKU', minWidth: '120'},
|
||||||
{prop: 'inbound_date', label: '入库日期', minWidth: '120'},
|
{prop: 'inbound_date', label: '入库日期', minWidth: '120'},
|
||||||
{prop: 'barcode', label: '条码', minWidth: '120'},
|
{prop: 'barcode', label: '条码', minWidth: '120'},
|
||||||
|
|
||||||
// 新的合并列,修改 label 为 "序列号/批号"
|
|
||||||
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
|
{prop: 'sn_bn', label: '序列号/批号', minWidth: '160'},
|
||||||
|
|
||||||
{prop: 'status', label: '状态', minWidth: '100'},
|
{prop: 'status', label: '状态', minWidth: '100'},
|
||||||
{prop: 'inspection_status', label: '到检', minWidth: '100'},
|
{prop: 'inspection_status', label: '到检', minWidth: '100'},
|
||||||
{prop: 'qty_inbound', label: '入库量', minWidth: '100'},
|
{prop: 'qty_inbound', label: '入库量', minWidth: '100'},
|
||||||
@ -492,11 +481,8 @@ const stockColumns = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const allColumns = [...baseColumns, ...stockColumns]
|
const allColumns = [...baseColumns, ...stockColumns]
|
||||||
// [修改] 更新 key 以强制用户获取新默认值
|
|
||||||
const STORAGE_KEY_COLS = 'stock_buy_visible_columns_v2'
|
const STORAGE_KEY_COLS = 'stock_buy_visible_columns_v2'
|
||||||
|
|
||||||
// [修改] 默认列配置:加入 'sn_bn' 和 'warehouse_loc'
|
|
||||||
// 同时这里也要对应上方的顺序变化,先 material_type 后 category
|
|
||||||
const defaultColumns = [
|
const defaultColumns = [
|
||||||
'material_name', 'material_type', 'category', 'spec_model', 'unit',
|
'material_name', 'material_type', 'category', 'spec_model', 'unit',
|
||||||
'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status',
|
'inbound_date', 'sn_bn', 'warehouse_loc', 'status', 'inspection_status',
|
||||||
@ -516,7 +502,7 @@ const form = reactive({
|
|||||||
arrival_photo: [] as string[], inspection_report: [] as string[]
|
arrival_photo: [] as string[], inspection_report: [] as string[]
|
||||||
})
|
})
|
||||||
|
|
||||||
// ... (以下逻辑保持不变)
|
// 历史记录辅助函数
|
||||||
const HISTORY_KEYS = { SUPPLIER: 'history_suppliers', PURCHASER: 'history_purchasers', EMAIL: 'history_emails', MATERIAL: 'history_materials' }
|
const HISTORY_KEYS = { SUPPLIER: 'history_suppliers', PURCHASER: 'history_purchasers', EMAIL: 'history_emails', MATERIAL: 'history_materials' }
|
||||||
const saveToHistory = (key: string, value: string) => {
|
const saveToHistory = (key: string, value: string) => {
|
||||||
if (!value) return
|
if (!value) return
|
||||||
@ -595,15 +581,19 @@ const onMaterialSelected = (val: number) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 校验规则
|
||||||
|
// ------------------------------------
|
||||||
const validateUnique = (rule: any, value: string, callback: any) => {
|
const validateUnique = (rule: any, value: string, callback: any) => {
|
||||||
if (!value) return callback()
|
if (!value) return callback()
|
||||||
|
// 前端仅做当前页面的简单重复提示,真正的校验在后端
|
||||||
const isDuplicate = tableData.value.some((row: any) => {
|
const isDuplicate = tableData.value.some((row: any) => {
|
||||||
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||||||
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||||||
if (rule.field === 'batch_number' && row.batch_number === value) return true
|
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
if (isDuplicate) callback(new Error('编号重复'))
|
if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
|
||||||
else callback()
|
else callback()
|
||||||
}
|
}
|
||||||
const validateIdentity = (rule: any, value: any, callback: any) => {
|
const validateIdentity = (rule: any, value: any, callback: any) => {
|
||||||
@ -618,26 +608,54 @@ const rules = {
|
|||||||
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
|
batch_number: [{validator: validateIdentity, trigger: 'blur'}, {validator: validateUnique, trigger: 'blur'}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动计算批号逻辑
|
||||||
const checkHistoryAndSetMode = async (baseId: number) => {
|
const checkHistoryAndSetMode = async (baseId: number) => {
|
||||||
try {
|
try {
|
||||||
const res: any = await getBuyList({page: 1, pageSize: 1000})
|
const res: any = await getBuyList({page: 1, pageSize: 1000}) // 获取最近数据
|
||||||
const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId)
|
const historyItems = (res.data.items || []).filter((item: any) => item.base_id === baseId)
|
||||||
|
|
||||||
if (historyItems.length > 0) {
|
if (historyItems.length > 0) {
|
||||||
modeLocked.value = true
|
modeLocked.value = true
|
||||||
|
// 找最新的那条记录
|
||||||
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
|
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 = '' }
|
if (latest.serial_number) {
|
||||||
else { entryMode.value = 'batch'; form.serial_number = ''; form.batch_number = incrementBatchNumber(latest.batch_number || '000000') }
|
entryMode.value = 'serial'
|
||||||
} else { modeLocked.value = false; entryMode.value = 'batch'; form.batch_number = '000001' }
|
form.serial_number = ''
|
||||||
if (formRef.value) { formRef.value.clearValidate('serial_number'); formRef.value.clearValidate('batch_number') }
|
form.batch_number = ''
|
||||||
} catch (e) { modeLocked.value = false; entryMode.value = 'batch'; form.batch_number = '000001' }
|
} else {
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.serial_number = ''
|
||||||
|
// 自动递增批号
|
||||||
|
form.batch_number = incrementBatchNumber(latest.batch_number || '000000')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
modeLocked.value = false
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.batch_number = '000001'
|
||||||
|
}
|
||||||
|
if (formRef.value) {
|
||||||
|
formRef.value.clearValidate('serial_number')
|
||||||
|
formRef.value.clearValidate('batch_number')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
modeLocked.value = false
|
||||||
|
entryMode.value = 'batch'
|
||||||
|
form.batch_number = '000001'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const incrementBatchNumber = (batchStr: string) => {
|
const incrementBatchNumber = (batchStr: string) => {
|
||||||
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
|
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
|
||||||
return (parseInt(batchStr, 10) + 1).toString().padStart(6, '0')
|
return (parseInt(batchStr, 10) + 1).toString().padStart(6, '0')
|
||||||
}
|
}
|
||||||
const handleEntryModeChange = (val: string) => {
|
const handleEntryModeChange = (val: string) => {
|
||||||
if (val === 'batch') { form.serial_number = ''; form.batch_number = '000001'; if(formRef.value) formRef.value.clearValidate('serial_number') }
|
if (val === 'batch') {
|
||||||
else { form.batch_number = ''; if(formRef.value) formRef.value.clearValidate('batch_number') }
|
form.serial_number = ''
|
||||||
|
form.batch_number = '000001'
|
||||||
|
if(formRef.value) formRef.value.clearValidate('serial_number')
|
||||||
|
} else {
|
||||||
|
form.batch_number = ''
|
||||||
|
if(formRef.value) formRef.value.clearValidate('batch_number')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) })
|
watch(() => [form.in_quantity, form.unit_price], () => { form.total_price = Number((form.in_quantity * form.unit_price).toFixed(4)) })
|
||||||
|
|
||||||
@ -688,6 +706,46 @@ const handleUpdate = (row: any) => {
|
|||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 提交逻辑
|
||||||
|
// ------------------------------------
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (valid) {
|
||||||
|
submitting.value = true
|
||||||
|
const finalReportList = [...form.inspection_report]
|
||||||
|
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
|
||||||
|
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
|
||||||
|
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
|
||||||
|
const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) }
|
||||||
|
try {
|
||||||
|
if (dialogStatus.value === 'create') {
|
||||||
|
const res: any = await createBuyInbound(payload)
|
||||||
|
ElMessage.success('入库成功')
|
||||||
|
if (res.data) {
|
||||||
|
ElMessage.info('发送打印指令...')
|
||||||
|
try { await executePrint(res.data); ElMessage.success('打印指令已发送') }
|
||||||
|
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
|
||||||
|
}
|
||||||
|
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||||
|
|
||||||
|
// 成功后保存历史记录
|
||||||
|
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name)
|
||||||
|
saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser)
|
||||||
|
saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
|
||||||
|
|
||||||
|
await fetchData()
|
||||||
|
visible.value = false
|
||||||
|
} catch (e: any) {
|
||||||
|
// 重点:捕获后端唯一性校验错误
|
||||||
|
ElMessage.error(e.msg || '操作失败')
|
||||||
|
} finally { submitting.value = false }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他辅助函数保持不变
|
||||||
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
const getImageUrl = (url: string) => { return !url ? '' : (url.startsWith('http') ? url : url) }
|
||||||
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
const isExternalLink = (str: string) => { return str && (str.startsWith('http://') || str.startsWith('https://')) && !str.includes('/api/v1/common/files') }
|
||||||
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
|
const getImagesOnly = (list: string[]) => { return !list ? [] : list.filter(item => !isExternalLink(item)) }
|
||||||
@ -740,33 +798,6 @@ const handleCameraFile = async (event: Event) => {
|
|||||||
} catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' }
|
} catch (e) { ElMessage.error('网络错误,上传失败') } finally { loadingMsg.close(); input.value = '' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = async () => {
|
|
||||||
if (!formRef.value) return
|
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
|
||||||
if (valid) {
|
|
||||||
submitting.value = true
|
|
||||||
const finalReportList = [...form.inspection_report]
|
|
||||||
if (inspection_report_url.value && !finalReportList.includes(inspection_report_url.value)) finalReportList.push(inspection_report_url.value)
|
|
||||||
const onlyImages = finalReportList.filter(item => !isExternalLink(item))
|
|
||||||
if (inspection_report_url.value) onlyImages.push(inspection_report_url.value)
|
|
||||||
const payload = { ...form, inspection_report: onlyImages, in_quantity: Number(form.in_quantity), unit_price: Number(form.unit_price) }
|
|
||||||
try {
|
|
||||||
if (dialogStatus.value === 'create') {
|
|
||||||
const res: any = await createBuyInbound(payload)
|
|
||||||
ElMessage.success('入库成功')
|
|
||||||
if (res.data) {
|
|
||||||
ElMessage.info('发送打印指令...')
|
|
||||||
try { await executePrint(res.data); ElMessage.success('打印指令已发送') }
|
|
||||||
catch (printErr: any) { ElMessage.warning('打印失败:' + (printErr.msg || '未知错误')) }
|
|
||||||
}
|
|
||||||
} else { await updateBuyInbound(form.id!, payload); ElMessage.success('更新成功') }
|
|
||||||
saveToHistory(HISTORY_KEYS.SUPPLIER, form.supplier_name); saveToHistory(HISTORY_KEYS.PURCHASER, form.purchaser); saveToHistory(HISTORY_KEYS.EMAIL, form.purchaser_email)
|
|
||||||
await fetchData(); visible.value = false
|
|
||||||
} catch (e: any) { ElMessage.error(e.msg || '操作失败') } finally { submitting.value = false }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
|
const handleDelete = async (row: any) => { try { await deleteBuyInbound(row.id); ElMessage.success('删除成功'); fetchData() } catch (e) { ElMessage.error('删除失败') } }
|
||||||
const handlePrint = async (row: any) => {
|
const handlePrint = async (row: any) => {
|
||||||
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
|
printVisible.value = true; printLoading.value = true; previewUrl.value = ''
|
||||||
@ -786,6 +817,7 @@ onMounted(() => fetchData())
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 样式部分保持不变,直接复用原有代码 */
|
||||||
.buy-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
|
.buy-module { background: #f5f7fa; padding: 20px; min-height: 100vh; }
|
||||||
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
|
.header-tools { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; background: #fff; padding: 15px 20px; border-radius: 8px; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05); }
|
||||||
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
|
.left-tools { display: flex; gap: 10px; align-items: center; flex: 1; }
|
||||||
|
|||||||
@ -425,9 +425,24 @@ const form = reactive({
|
|||||||
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
|
quality_report_link: [] as string[], inspection_report_link: [] as string[], product_photo: [] as string[], detail_link: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 校验规则 (前端 pre-check)
|
||||||
|
// ------------------------------------
|
||||||
|
const validateUnique = (rule: any, value: string, callback: any) => {
|
||||||
|
if (!value) return callback()
|
||||||
|
// 简单的列表前端查重
|
||||||
|
const isDuplicate = tableData.value.some((row: any) => {
|
||||||
|
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||||||
|
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (isDuplicate) callback(new Error('当前列表页存在相同SN(后端将进行全局校验)'))
|
||||||
|
else callback()
|
||||||
|
}
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
base_id: [{ required: true, message: '必选', trigger: 'change' }],
|
base_id: [{ required: true, message: '必选', trigger: 'change' }],
|
||||||
serial_number: [{ required: true, message: '必填', trigger: 'blur' }],
|
serial_number: [{ required: true, message: '必填', trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
|
||||||
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
|
in_quantity: [{ required: true, message: '必填', trigger: 'blur' }]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -585,7 +600,10 @@ const submitForm = async () => {
|
|||||||
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
|
} else { await updateProductInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||||
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
||||||
visible.value = false; fetchData()
|
visible.value = false; fetchData()
|
||||||
} catch(e:any) { ElMessage.error(e.msg || '失败') } finally { submitting.value = false }
|
} catch(e:any) {
|
||||||
|
// 捕获后端报错
|
||||||
|
ElMessage.error(e.msg || '操作失败')
|
||||||
|
} finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -611,10 +611,11 @@ const validateUnique = (rule: any, value: string, callback: any) => {
|
|||||||
const isDuplicate = tableData.value.some((row: any) => {
|
const isDuplicate = tableData.value.some((row: any) => {
|
||||||
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||||||
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||||||
if (rule.field === 'batch_number' && row.batch_number === value) return true
|
// 批号校验需要同时匹配物料
|
||||||
|
if (rule.field === 'batch_number' && row.batch_number === value && row.base_id === form.base_id) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
if (isDuplicate) callback(new Error('编号重复'))
|
if (isDuplicate) callback(new Error('当前列表页存在相同编号(后端将进行全局校验)'))
|
||||||
else callback()
|
else callback()
|
||||||
}
|
}
|
||||||
const validateIdentity = (rule: any, value: any, callback: any) => {
|
const validateIdentity = (rule: any, value: any, callback: any) => {
|
||||||
@ -778,7 +779,10 @@ const submitForm = async () => {
|
|||||||
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
|
} else { await updateSemiInbound(form.id!, payload); ElMessage.success('更新成功') }
|
||||||
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
||||||
await fetchData(); visible.value = false
|
await fetchData(); visible.value = false
|
||||||
} catch (e: any) { ElMessage.error(e.msg || '操作失败') } finally { submitting.value = false }
|
} catch (e: any) {
|
||||||
|
// 捕获后端报错
|
||||||
|
ElMessage.error(e.msg || '操作失败')
|
||||||
|
} finally { submitting.value = false }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -822,12 +826,8 @@ onMounted(() => fetchData())
|
|||||||
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
|
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
|
||||||
.card-content { padding: 20px; }
|
.card-content { padding: 20px; }
|
||||||
.basic-card { border-left: 4px solid #409EFF; }
|
.basic-card { border-left: 4px solid #409EFF; }
|
||||||
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
|
|
||||||
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
|
|
||||||
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
|
|
||||||
.inbound-card { border-left: 4px solid #67C23A; }
|
.inbound-card { border-left: 4px solid #67C23A; }
|
||||||
.production-card { border-left: 4px solid #E6A23C; }
|
.production-card { border-left: 4px solid #E6A23C; } .production-card .card-title .icon { color: #E6A23C; }
|
||||||
.production-card .card-title .icon { color: #E6A23C; }
|
|
||||||
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 15px; margin-bottom: 20px; }
|
.identity-panel { background: #fffbf0; border: 1px dashed #e6a23c; border-radius: 6px; padding: 15px; margin-bottom: 20px; }
|
||||||
.custom-radio-group { margin-bottom: 10px; }
|
.custom-radio-group { margin-bottom: 10px; }
|
||||||
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
|
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
|
||||||
@ -839,16 +839,12 @@ onMounted(() => fetchData())
|
|||||||
.divider-text::before { margin-right: 15px; }
|
.divider-text::before { margin-right: 15px; }
|
||||||
.divider-text::after { margin-left: 15px; }
|
.divider-text::after { margin-left: 15px; }
|
||||||
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding: 20px; border-top: 1px solid #ebeef5; }
|
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; padding: 20px; border-top: 1px solid #ebeef5; }
|
||||||
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center;}
|
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center; }
|
||||||
.opt-name { font-weight: bold; }
|
.opt-name { font-weight: bold; }
|
||||||
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
|
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
|
||||||
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
|
.is-text-view :deep(.el-input__wrapper) { box-shadow: none !important; background-color: #f5f7fa; border-bottom: 1px solid #dcdfe6; border-radius: 0; padding-left: 0; }
|
||||||
.preview-box { min-height: 150px; display: flex; justify-content: center; align-items: center; background: #f5f7fa; border-radius: 4px; }
|
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
|
||||||
.empty-preview { color: #909399; }
|
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
|
||||||
.more-images-badge { margin-left: 5px; background: #909399; color: #fff; border-radius: 10px; padding: 0 6px; font-size: 12px; }
|
|
||||||
.clickable-text { color: #409EFF; cursor: pointer; font-weight: 500; text-decoration: underline; }
|
|
||||||
.clickable-text:hover { color: #66b1ff; }
|
|
||||||
/* Scroll container specific to Product style */
|
|
||||||
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
|
.dialog-scroll-container { padding: 20px; max-height: 70vh; overflow-y: auto; }
|
||||||
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
|
.upload-container { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
|
:deep(.el-upload--picture-card) { width: 100px; height: 100px; line-height: 100px; }
|
||||||
|
|||||||
@ -1 +1,507 @@
|
|||||||
<template><div style="padding:20px;"><h2>借库申请</h2></div></template>
|
<template>
|
||||||
|
<div class="app-container mobile-optimized">
|
||||||
|
<el-card class="box-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="title-box">
|
||||||
|
<span>借库作业 (领用人签字)</span>
|
||||||
|
<el-tag v-if="cartItems.length > 0" type="warning" size="small" effect="dark">
|
||||||
|
已选 {{ cartItems.length }} 项
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="scan-section">
|
||||||
|
<div v-if="showCamera" class="camera-wrapper">
|
||||||
|
<QrScanner @decode="onScanSuccess" />
|
||||||
|
<div class="scan-overlay">
|
||||||
|
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
||||||
|
关闭摄像头
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="camera-placeholder" @click="showCamera = true">
|
||||||
|
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||||
|
<span class="text">点击开启扫码</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-box">
|
||||||
|
<el-input
|
||||||
|
v-model="barcodeInput"
|
||||||
|
placeholder="扫描或输入条码回车"
|
||||||
|
@keyup.enter="handleManualInput"
|
||||||
|
clearable
|
||||||
|
ref="barcodeRef"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Scissor /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="handleManualInput">添加</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cart-section">
|
||||||
|
<div v-if="cartItems.length > 0">
|
||||||
|
<el-table :data="cartItems" border stripe style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="物品名称" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column label="可用库存" width="90" align="center">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="借用数" width="130" align="center">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-input-number
|
||||||
|
v-model="row.out_quantity"
|
||||||
|
:min="1"
|
||||||
|
:max="parseFloat(row.available_quantity)"
|
||||||
|
size="small"
|
||||||
|
style="width: 100px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="60" align="center" fixed="right">
|
||||||
|
<template #default="{$index}">
|
||||||
|
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无物品,请扫码借出" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cartItems.length > 0" class="form-section">
|
||||||
|
<el-divider content-position="left">借用登记信息</el-divider>
|
||||||
|
|
||||||
|
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
|
||||||
|
<el-row :gutter="15">
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="领用人/借用人" prop="borrower_name">
|
||||||
|
<el-input v-model="form.borrower_name" placeholder="请输入姓名" size="large" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :span="24">
|
||||||
|
<el-form-item label="预计归还日期" prop="expected_return_time">
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.expected_return_time"
|
||||||
|
type="date"
|
||||||
|
placeholder="请选择日期"
|
||||||
|
style="width: 100%"
|
||||||
|
size="large"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disabledDate"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="备注说明" prop="remark">
|
||||||
|
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="用途说明..." />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="领用人签名确认" required>
|
||||||
|
<div class="signature-box" @click="openSignatureDialog">
|
||||||
|
<div v-if="signaturePreviewUrl" class="signed-img">
|
||||||
|
<img :src="signaturePreviewUrl" alt="签名" />
|
||||||
|
<span class="re-sign-tip">点击重签</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="unsigned-placeholder">
|
||||||
|
<el-icon :size="24"><EditPen /></el-icon>
|
||||||
|
<span>点击此处进行全屏签名</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="bottom-actions">
|
||||||
|
<el-button @click="clearAll" icon="Refresh">清空</el-button>
|
||||||
|
<el-button type="primary" size="large" :loading="loading" @click="submitForm" icon="Select">
|
||||||
|
确认借出
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="showSignatureDialog"
|
||||||
|
fullscreen
|
||||||
|
destroy-on-close
|
||||||
|
:show-close="false"
|
||||||
|
class="fullscreen-signature-dialog"
|
||||||
|
@opened="initCanvas"
|
||||||
|
>
|
||||||
|
<div class="signature-wrapper">
|
||||||
|
<div class="signature-canvas-container" ref="canvasContainerRef">
|
||||||
|
<canvas
|
||||||
|
ref="nativeCanvasRef"
|
||||||
|
class="native-canvas"
|
||||||
|
@mousedown="startDrawing"
|
||||||
|
@mousemove="draw"
|
||||||
|
@mouseup="stopDrawing"
|
||||||
|
@mouseleave="stopDrawing"
|
||||||
|
@touchstart="startDrawing"
|
||||||
|
@touchmove="draw"
|
||||||
|
@touchend="stopDrawing"
|
||||||
|
></canvas>
|
||||||
|
<div class="canvas-tip">请在此区域横屏书写</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-sidebar">
|
||||||
|
<div class="sidebar-title">电子签名</div>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<el-button type="warning" @click="clearCanvas">重写</el-button>
|
||||||
|
<el-button @click="handleSignCancel">取消</el-button>
|
||||||
|
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, nextTick, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||||
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
|
import { getStockByBarcode } from '@/api/outbound'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { uploadFile } from '@/api/common/upload'
|
||||||
|
|
||||||
|
// --- 状态定义 ---
|
||||||
|
const barcodeInput = ref('')
|
||||||
|
const cartItems = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCamera = ref(false)
|
||||||
|
const barcodeRef = ref()
|
||||||
|
const formRef = ref()
|
||||||
|
|
||||||
|
// 签名相关
|
||||||
|
const showSignatureDialog = ref(false)
|
||||||
|
const signaturePreviewUrl = ref('')
|
||||||
|
const signatureFile = ref<File | null>(null)
|
||||||
|
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const canvasContainerRef = ref<HTMLElement | null>(null)
|
||||||
|
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||||
|
const isDrawing = ref(false)
|
||||||
|
const lastX = ref(0)
|
||||||
|
const lastY = ref(0)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
borrower_name: '',
|
||||||
|
expected_return_time: '',
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// ★ 修改点:增强校验规则
|
||||||
|
const rules = {
|
||||||
|
borrower_name: [
|
||||||
|
{ required: true, message: '请输入借用人姓名', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
expected_return_time: [
|
||||||
|
{ required: true, message: '请选择预计归还日期', trigger: 'change' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 新增:禁止选择今天之前的日期
|
||||||
|
const disabledDate = (time: Date) => {
|
||||||
|
return time.getTime() < Date.now() - 8.64e7 // 禁止选择昨天及之前
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 核心扫码逻辑 ---
|
||||||
|
const onScanSuccess = (code: string) => {
|
||||||
|
if (!code) return
|
||||||
|
const trimCode = code.trim()
|
||||||
|
|
||||||
|
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
||||||
|
if (!validPattern.test(trimCode)) {
|
||||||
|
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimCode.length < 3) {
|
||||||
|
ElMessage.warning('扫描结果过短,请对准重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
barcodeInput.value = trimCode
|
||||||
|
handleManualInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualInput = async () => {
|
||||||
|
const code = barcodeInput.value.trim()
|
||||||
|
if (!code) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// 查重
|
||||||
|
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
||||||
|
if (existIndex > -1) {
|
||||||
|
const item = cartItems.value[existIndex]
|
||||||
|
const maxQty = parseFloat(item.available_quantity)
|
||||||
|
if (item.out_quantity < maxQty) {
|
||||||
|
item.out_quantity++
|
||||||
|
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||||
|
if (navigator.vibrate) navigator.vibrate(50)
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(`库存不足 (余: ${maxQty})`)
|
||||||
|
}
|
||||||
|
barcodeInput.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查库
|
||||||
|
const res = await getStockByBarcode(code)
|
||||||
|
if (res.data) {
|
||||||
|
const item = res.data
|
||||||
|
const availQty = parseFloat(item.available_quantity || 0)
|
||||||
|
|
||||||
|
if (availQty <= 0) {
|
||||||
|
ElMessage.warning(`库存不足 (余: ${availQty})`)
|
||||||
|
} else {
|
||||||
|
cartItems.value.push({
|
||||||
|
...item,
|
||||||
|
out_quantity: 1,
|
||||||
|
price: 0
|
||||||
|
})
|
||||||
|
ElMessage.success(`添加成功: ${item.name}`)
|
||||||
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
|
}
|
||||||
|
barcodeInput.value = ''
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response && error.response.status === 404) {
|
||||||
|
ElMessage.error(`未找到条码: ${code}`)
|
||||||
|
} else {
|
||||||
|
ElMessage.error('查询出错')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
nextTick(() => { barcodeRef.value?.focus() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFromCart = (index: number) => {
|
||||||
|
cartItems.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
|
||||||
|
.then(() => {
|
||||||
|
cartItems.value = []
|
||||||
|
form.borrower_name = ''
|
||||||
|
form.remark = ''
|
||||||
|
form.expected_return_time = ''
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.value = ''
|
||||||
|
barcodeInput.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 提交逻辑 ---
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formRef.value) return
|
||||||
|
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
||||||
|
|
||||||
|
// ★ 核心修改:等待校验通过后再提交,否则报错会被拦截在前端
|
||||||
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) {
|
||||||
|
ElMessage.error('请填写完整的必填项(姓名、归还日期)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signatureFile.value) {
|
||||||
|
ElMessage.error('请领用人进行电子签名')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
// 上传签名
|
||||||
|
const uploadRes = await uploadFile(signatureFile.value)
|
||||||
|
const signatureUrl = uploadRes.data.url
|
||||||
|
|
||||||
|
await request({
|
||||||
|
url: '/v1/transactions/borrow',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
items: cartItems.value,
|
||||||
|
...form, // 此时 form.expected_return_time 已经是 YYYY-MM-DD 格式
|
||||||
|
signature_path: signatureUrl
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ElMessage.success('借用成功')
|
||||||
|
cartItems.value = []
|
||||||
|
form.borrower_name = ''
|
||||||
|
form.expected_return_time = ''
|
||||||
|
form.remark = ''
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.value = ''
|
||||||
|
showCamera.value = false
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(error)
|
||||||
|
ElMessage.error(error.response?.data?.msg || '提交失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 签名逻辑 ---
|
||||||
|
const openSignatureDialog = () => { showSignatureDialog.value = true }
|
||||||
|
|
||||||
|
const initCanvas = async () => {
|
||||||
|
await nextTick()
|
||||||
|
const canvas = nativeCanvasRef.value
|
||||||
|
const container = canvasContainerRef.value
|
||||||
|
|
||||||
|
if (canvas && container) {
|
||||||
|
canvas.width = container.clientWidth
|
||||||
|
canvas.height = container.clientHeight
|
||||||
|
ctx.value = canvas.getContext('2d')
|
||||||
|
if (ctx.value) {
|
||||||
|
ctx.value.lineWidth = 4
|
||||||
|
ctx.value.lineCap = 'round'
|
||||||
|
ctx.value.lineJoin = 'round'
|
||||||
|
ctx.value.strokeStyle = '#000000'
|
||||||
|
ctx.value.fillStyle = '#ffffff'
|
||||||
|
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPos = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
|
||||||
|
const rect = nativeCanvasRef.value.getBoundingClientRect()
|
||||||
|
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
|
||||||
|
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
|
||||||
|
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isDrawing.value = true
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
lastX.value = x; lastY.value = y
|
||||||
|
ctx.value?.beginPath()
|
||||||
|
ctx.value?.moveTo(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = (e: MouseEvent | TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isDrawing.value || !ctx.value) return
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
ctx.value.lineTo(x, y)
|
||||||
|
ctx.value.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrawing = () => { isDrawing.value = false }
|
||||||
|
|
||||||
|
const clearCanvas = () => {
|
||||||
|
if (!ctx.value || !nativeCanvasRef.value) return
|
||||||
|
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
|
||||||
|
ctx.value.fillStyle = '#ffffff'
|
||||||
|
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignConfirm = () => {
|
||||||
|
nativeCanvasRef.value?.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
|
||||||
|
signatureFile.value = file
|
||||||
|
signaturePreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
showSignatureDialog.value = false
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignCancel = () => { showSignatureDialog.value = false }
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container.mobile-optimized {
|
||||||
|
padding: 10px; max-width: 600px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* 扫码区 */
|
||||||
|
.scan-section { margin-bottom: 20px; }
|
||||||
|
.camera-wrapper {
|
||||||
|
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.scan-overlay {
|
||||||
|
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
||||||
|
}
|
||||||
|
.camera-placeholder {
|
||||||
|
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 表单与购物车 */
|
||||||
|
.cart-section { margin-bottom: 20px; }
|
||||||
|
.form-section { background: #fff; }
|
||||||
|
.signature-box {
|
||||||
|
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
|
||||||
|
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
|
||||||
|
.signed-img img { max-height: 90px; }
|
||||||
|
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
|
||||||
|
|
||||||
|
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
|
||||||
|
.bottom-actions .el-button { width: 48%; }
|
||||||
|
|
||||||
|
/* 全屏签名弹窗 */
|
||||||
|
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
|
||||||
|
.signature-wrapper { display: flex; width: 100%; height: 100%; }
|
||||||
|
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
|
||||||
|
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
|
.canvas-tip {
|
||||||
|
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||||
|
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
.signature-sidebar {
|
||||||
|
width: 120px; background: #333; color: #fff;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
|
||||||
|
}
|
||||||
|
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
|
||||||
|
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
|
||||||
|
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.signature-wrapper { flex-direction: column; }
|
||||||
|
.signature-canvas-container { flex: 1; }
|
||||||
|
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
|
||||||
|
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
|
||||||
|
.sidebar-title { display: none; }
|
||||||
|
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
|
||||||
|
.sidebar-actions .el-button { flex: 1; height: 40px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
inventory-web/src/views/transaction/records.vue
Normal file
198
inventory-web/src/views/transaction/records.vue
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<div class="filter-container">
|
||||||
|
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
|
||||||
|
<el-radio-button label="all">全部</el-radio-button>
|
||||||
|
<el-radio-button label="borrowed">未归还</el-radio-button>
|
||||||
|
<el-radio-button label="returned">已归还</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<el-input v-model="keyword" placeholder="搜索借用人/SKU" style="width: 200px" @keyup.enter="fetchData" />
|
||||||
|
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="list"
|
||||||
|
border
|
||||||
|
stripe
|
||||||
|
style="margin-top:20px"
|
||||||
|
v-loading="loading"
|
||||||
|
:row-class-name="tableRowClassName"
|
||||||
|
>
|
||||||
|
<el-table-column prop="borrow_no" label="单号" width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="borrower_name" label="借用人" width="100" />
|
||||||
|
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="borrow_time" label="借出时间" width="160" sortable />
|
||||||
|
|
||||||
|
<el-table-column label="归还时间 / 预计" min-width="200">
|
||||||
|
<template #default="{row}">
|
||||||
|
<div v-if="row.status === 'returned'">
|
||||||
|
<el-tag type="success" size="small">实际</el-tag>
|
||||||
|
{{ row.return_time || '-' }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<el-tag type="info" size="small">预计</el-tag>
|
||||||
|
{{ formatExpectedTime(row.expected_return_time).text }}
|
||||||
|
<span :class="formatExpectedTime(row.expected_return_time).cssClass">
|
||||||
|
{{ formatExpectedTime(row.expected_return_time).diffText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-tag :type="row.status==='returned'?'success':'warning'">
|
||||||
|
{{ row.status==='returned'?'已还':'借出中' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="归还库位" min-width="120">
|
||||||
|
<template #default="{row}">
|
||||||
|
<span v-if="row.return_location">{{ row.return_location }}</span>
|
||||||
|
<span v-else style="color:#ccc">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="电子签名" width="140" align="center">
|
||||||
|
<template #default="{row}">
|
||||||
|
<div style="display:flex; justify-content: center; gap:10px">
|
||||||
|
<el-popover trigger="hover" placement="top" v-if="row.borrow_signature" width="220">
|
||||||
|
<template #reference><el-tag size="small">借</el-tag></template>
|
||||||
|
<img :src="row.borrow_signature" style="width:200px; border:1px solid #eee" />
|
||||||
|
</el-popover>
|
||||||
|
|
||||||
|
<el-popover trigger="hover" placement="top" v-if="row.return_signature" width="220">
|
||||||
|
<template #reference><el-tag type="success" size="small">还</el-tag></template>
|
||||||
|
<img :src="row.return_signature" style="width:200px; border:1px solid #eee" />
|
||||||
|
</el-popover>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
@current-change="handlePage"
|
||||||
|
style="margin-top:10px; text-align:right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import dayjs from 'dayjs' // 建议使用 dayjs 处理日期,如果没有安装,可以用原生 Date
|
||||||
|
import 'dayjs/locale/zh-cn' // 导入中文包
|
||||||
|
dayjs.locale('zh-cn')
|
||||||
|
|
||||||
|
const list = ref<any[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
// ★ 修改点:默认状态改为 'borrowed' (未归还)
|
||||||
|
const status = ref('borrowed')
|
||||||
|
const keyword = ref('')
|
||||||
|
const page = ref(1)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await request({
|
||||||
|
url: '/v1/transactions/records',
|
||||||
|
method: 'get',
|
||||||
|
params: {
|
||||||
|
page: page.value,
|
||||||
|
status: status.value,
|
||||||
|
keyword: keyword.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
list.value = res.data.items
|
||||||
|
total.value = res.data.total
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePage = (val: number) => {
|
||||||
|
page.value = val
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 新增:格式化预计归还时间及倒计时逻辑
|
||||||
|
const formatExpectedTime = (timeStr: string) => {
|
||||||
|
if (!timeStr) return { text: '-', diffText: '', cssClass: '' }
|
||||||
|
|
||||||
|
// 后端返回的可能是 YYYY-MM-DD HH:mm:ss,我们只取日期部分比较
|
||||||
|
const expected = dayjs(timeStr).startOf('day')
|
||||||
|
const today = dayjs().startOf('day')
|
||||||
|
const diffDays = expected.diff(today, 'day')
|
||||||
|
|
||||||
|
let diffText = ''
|
||||||
|
let cssClass = ''
|
||||||
|
|
||||||
|
// 这里的 timeStr 只展示前10位 (日期),或者展示完整
|
||||||
|
// 需求说单号规则是日期,预计归还也主要看日期
|
||||||
|
const displayTime = timeStr.substring(0, 10)
|
||||||
|
|
||||||
|
if (diffDays < 0) {
|
||||||
|
// 逾期
|
||||||
|
diffText = ` (逾期 ${Math.abs(diffDays)} 天)`
|
||||||
|
cssClass = 'text-danger'
|
||||||
|
} else if (diffDays === 0) {
|
||||||
|
// 今天到期
|
||||||
|
diffText = ` (今天到期)`
|
||||||
|
cssClass = 'text-warning'
|
||||||
|
} else {
|
||||||
|
// 剩余
|
||||||
|
diffText = ` (剩 ${diffDays} 天)`
|
||||||
|
cssClass = 'text-normal' // 正常,或者灰色
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: displayTime, diffText, cssClass }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 新增:表格行样式逻辑
|
||||||
|
const tableRowClassName = ({ row }: { row: any }) => {
|
||||||
|
// 如果已归还,不标颜色
|
||||||
|
if (row.status === 'returned') return ''
|
||||||
|
if (!row.expected_return_time) return ''
|
||||||
|
|
||||||
|
const expected = dayjs(row.expected_return_time).startOf('day')
|
||||||
|
const today = dayjs().startOf('day')
|
||||||
|
const diffDays = expected.diff(today, 'day')
|
||||||
|
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return 'danger-row' // 逾期标红
|
||||||
|
} else if (diffDays === 0) {
|
||||||
|
return 'warning-row' // 当天标黄
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 注意:Element Plus Table 的 row-class-name 样式通常不能放在 scoped 中 */
|
||||||
|
.el-table .warning-row {
|
||||||
|
--el-table-tr-bg-color: #fdf6ec !important; /* 浅橙色/黄色 */
|
||||||
|
}
|
||||||
|
.el-table .danger-row {
|
||||||
|
--el-table-tr-bg-color: #fef0f0 !important; /* 浅红色 */
|
||||||
|
color: #F56C6C; /* 文字变红增强警示 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字颜色辅助类 */
|
||||||
|
.text-danger {
|
||||||
|
color: #F56C6C;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.text-warning {
|
||||||
|
color: #E6A23C;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.text-normal {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1 +1,447 @@
|
|||||||
<template><div style="padding:20px;"><h2>维修登记</h2></div></template>
|
<template>
|
||||||
|
<div class="app-container mobile-optimized">
|
||||||
|
<el-card class="box-card" shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="title-box">
|
||||||
|
<span>还库作业 (库管签字)</span>
|
||||||
|
<el-tag v-if="returnList.length > 0" type="success" size="small" effect="dark">
|
||||||
|
待还 {{ returnList.length }} 项
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="scan-section">
|
||||||
|
<div v-if="showCamera" class="camera-wrapper">
|
||||||
|
<QrScanner @decode="onScanSuccess" />
|
||||||
|
<div class="scan-overlay">
|
||||||
|
<el-button type="info" size="small" bg text @click="showCamera = false" icon="Close">
|
||||||
|
关闭摄像头
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="camera-placeholder" @click="showCamera = true">
|
||||||
|
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||||
|
<span class="text">点击开启扫码</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="input-box">
|
||||||
|
<el-input
|
||||||
|
v-model="barcode"
|
||||||
|
placeholder="扫描已借出物品条码"
|
||||||
|
@keyup.enter="scanItem"
|
||||||
|
clearable
|
||||||
|
ref="barcodeRef"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon><Scissor /></el-icon>
|
||||||
|
</template>
|
||||||
|
<template #append>
|
||||||
|
<el-button @click="scanItem">识别</el-button>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cart-section">
|
||||||
|
<div v-if="returnList.length > 0">
|
||||||
|
<el-table :data="returnList" border stripe style="width: 100%">
|
||||||
|
<el-table-column prop="borrower_name" label="借用人" width="90" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||||
|
|
||||||
|
<el-table-column label="归还库位(可改)" min-width="160">
|
||||||
|
<template #default="{row}">
|
||||||
|
<el-input
|
||||||
|
v-model="row.return_location"
|
||||||
|
:placeholder="`原: ${row.current_location || '无'}`"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #append v-if="row.return_location !== row.current_location">
|
||||||
|
<span style="color: #E6A23C; font-size: 12px;">变更</span>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
|
||||||
|
<el-table-column label="操作" width="60" align="center" fixed="right">
|
||||||
|
<template #default="{$index}">
|
||||||
|
<el-button type="danger" icon="Delete" circle size="small" @click="returnList.splice($index, 1)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="请扫描已借出的条码" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="returnList.length > 0" class="form-section">
|
||||||
|
<el-divider content-position="left">还库确认</el-divider>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 10px; font-size: 14px; color: #606266; padding: 0 10px;">
|
||||||
|
请库管员在此签字确认入库:
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item required>
|
||||||
|
<div class="signature-box" @click="openSignatureDialog">
|
||||||
|
<div v-if="signaturePreviewUrl" class="signed-img">
|
||||||
|
<img :src="signaturePreviewUrl" alt="签名" />
|
||||||
|
<span class="re-sign-tip">点击重签</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="unsigned-placeholder">
|
||||||
|
<el-icon :size="24"><EditPen /></el-icon>
|
||||||
|
<span>点击此处进行库管签名</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="bottom-actions">
|
||||||
|
<el-button @click="clearAll" icon="Refresh">清空</el-button>
|
||||||
|
<el-button type="success" size="large" :loading="loading" @click="preSubmitCheck" icon="Select">
|
||||||
|
确认归还
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="showSignatureDialog"
|
||||||
|
fullscreen
|
||||||
|
destroy-on-close
|
||||||
|
:show-close="false"
|
||||||
|
class="fullscreen-signature-dialog"
|
||||||
|
@opened="initCanvas"
|
||||||
|
>
|
||||||
|
<div class="signature-wrapper">
|
||||||
|
<div class="signature-canvas-container" ref="canvasContainerRef">
|
||||||
|
<canvas
|
||||||
|
ref="nativeCanvasRef"
|
||||||
|
class="native-canvas"
|
||||||
|
@mousedown="startDrawing"
|
||||||
|
@mousemove="draw"
|
||||||
|
@mouseup="stopDrawing"
|
||||||
|
@mouseleave="stopDrawing"
|
||||||
|
@touchstart="startDrawing"
|
||||||
|
@touchmove="draw"
|
||||||
|
@touchend="stopDrawing"
|
||||||
|
></canvas>
|
||||||
|
<div class="canvas-tip">请在此区域横屏书写</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-sidebar">
|
||||||
|
<div class="sidebar-title">电子签名</div>
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<el-button type="warning" @click="clearCanvas">重写</el-button>
|
||||||
|
<el-button @click="handleSignCancel">取消</el-button>
|
||||||
|
<el-button type="success" class="confirm-btn" @click="handleSignConfirm">确认使用</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, nextTick, onUnmounted } from 'vue'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { uploadFile } from '@/api/common/upload'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||||
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
|
|
||||||
|
// --- 状态 ---
|
||||||
|
const barcode = ref('')
|
||||||
|
const returnList = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCamera = ref(false)
|
||||||
|
const barcodeRef = ref()
|
||||||
|
|
||||||
|
// 签名状态
|
||||||
|
const showSignatureDialog = ref(false)
|
||||||
|
const signaturePreviewUrl = ref('')
|
||||||
|
const signatureFile = ref<File | null>(null)
|
||||||
|
const nativeCanvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const canvasContainerRef = ref<HTMLElement | null>(null)
|
||||||
|
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||||
|
const isDrawing = ref(false)
|
||||||
|
const lastX = ref(0)
|
||||||
|
const lastY = ref(0)
|
||||||
|
|
||||||
|
// --- 扫码回调 (复刻) ---
|
||||||
|
const onScanSuccess = (code: string) => {
|
||||||
|
if (!code) return
|
||||||
|
const trimCode = code.trim()
|
||||||
|
|
||||||
|
const validPattern = /^[A-Za-z0-9\-\.]+$/
|
||||||
|
if (!validPattern.test(trimCode)) {
|
||||||
|
ElMessage.warning(`识别到异常符号,已忽略:${trimCode}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimCode.length < 3) {
|
||||||
|
ElMessage.warning('扫描结果过短,请对准重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
barcode.value = trimCode
|
||||||
|
scanItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
const scanItem = async () => {
|
||||||
|
const code = barcode.value.trim()
|
||||||
|
if(!code) return
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const res = await request({
|
||||||
|
url: '/v1/transactions/return/scan',
|
||||||
|
method: 'get',
|
||||||
|
params: { barcode: code }
|
||||||
|
})
|
||||||
|
|
||||||
|
if(returnList.value.some(i => i.id === res.data.id)) {
|
||||||
|
ElMessage.warning('已在清单中')
|
||||||
|
barcode.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = res.data
|
||||||
|
// 默认将归还库位填为当前库位
|
||||||
|
item.return_location = item.current_location || ''
|
||||||
|
|
||||||
|
returnList.value.push(item)
|
||||||
|
barcode.value = ''
|
||||||
|
ElMessage.success('识别成功')
|
||||||
|
} catch(e) {
|
||||||
|
ElMessage.error('未找到该物品的未还记录')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
nextTick(() => { barcodeRef.value?.focus() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
ElMessageBox.confirm('确定清空所有待还物品吗?', '提示', { type: 'warning' })
|
||||||
|
.then(() => {
|
||||||
|
returnList.value = []
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.value = ''
|
||||||
|
barcode.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 提交前检查 (库位变更警告) ---
|
||||||
|
const preSubmitCheck = async () => {
|
||||||
|
if (returnList.value.length === 0) return
|
||||||
|
|
||||||
|
if (!signatureFile.value) {
|
||||||
|
ElMessage.error('库管必须签字确认')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 处理空输入:默认为原库位
|
||||||
|
returnList.value.forEach(item => {
|
||||||
|
if (!item.return_location || item.return_location.trim() === '') {
|
||||||
|
item.return_location = item.current_location
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. 检测变更
|
||||||
|
const changedItems = returnList.value.filter(
|
||||||
|
item => item.return_location !== item.current_location
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 弹窗确认
|
||||||
|
if (changedItems.length > 0) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`检测到 ${changedItems.length} 个物品的库位发生变更。\n\n请务必打印新的标签并贴在物品上!\n\n是否确认继续?`,
|
||||||
|
'库位变更提醒',
|
||||||
|
{
|
||||||
|
confirmButtonText: '已知晓,确认归还',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
center: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
submitReturn()
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
submitReturn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitReturn = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const upRes = await uploadFile(signatureFile.value!)
|
||||||
|
|
||||||
|
await request({
|
||||||
|
url: '/v1/transactions/return',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
items: returnList.value,
|
||||||
|
signature_path: upRes.data.url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success('还库成功')
|
||||||
|
returnList.value = []
|
||||||
|
signatureFile.value = null
|
||||||
|
signaturePreviewUrl.value = ''
|
||||||
|
showCamera.value = false // 关闭摄像头
|
||||||
|
} catch(e: any) {
|
||||||
|
ElMessage.error(e.response?.data?.msg || '提交失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 签名逻辑 (复刻) ---
|
||||||
|
const openSignatureDialog = () => { showSignatureDialog.value = true }
|
||||||
|
|
||||||
|
const initCanvas = async () => {
|
||||||
|
await nextTick()
|
||||||
|
const canvas = nativeCanvasRef.value
|
||||||
|
const container = canvasContainerRef.value
|
||||||
|
|
||||||
|
if (canvas && container) {
|
||||||
|
canvas.width = container.clientWidth
|
||||||
|
canvas.height = container.clientHeight
|
||||||
|
ctx.value = canvas.getContext('2d')
|
||||||
|
if (ctx.value) {
|
||||||
|
ctx.value.lineWidth = 4
|
||||||
|
ctx.value.lineCap = 'round'
|
||||||
|
ctx.value.lineJoin = 'round'
|
||||||
|
ctx.value.strokeStyle = '#000000'
|
||||||
|
ctx.value.fillStyle = '#ffffff'
|
||||||
|
ctx.value.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPos = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
|
||||||
|
const rect = nativeCanvasRef.value.getBoundingClientRect()
|
||||||
|
const clientX = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientX : (e as MouseEvent).clientX
|
||||||
|
const clientY = e.type.startsWith('touch') ? (e as TouchEvent).touches[0].clientY : (e as MouseEvent).clientY
|
||||||
|
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
isDrawing.value = true
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
lastX.value = x; lastY.value = y
|
||||||
|
ctx.value?.beginPath()
|
||||||
|
ctx.value?.moveTo(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = (e: MouseEvent | TouchEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!isDrawing.value || !ctx.value) return
|
||||||
|
const { x, y } = getPos(e)
|
||||||
|
ctx.value.lineTo(x, y)
|
||||||
|
ctx.value.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopDrawing = () => { isDrawing.value = false }
|
||||||
|
|
||||||
|
const clearCanvas = () => {
|
||||||
|
if (!ctx.value || !nativeCanvasRef.value) return
|
||||||
|
ctx.value.clearRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
|
||||||
|
ctx.value.fillStyle = '#ffffff'
|
||||||
|
ctx.value.fillRect(0, 0, nativeCanvasRef.value.width, nativeCanvasRef.value.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignConfirm = () => {
|
||||||
|
nativeCanvasRef.value?.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const file = new File([blob], `sign_${Date.now()}.png`, { type: 'image/png' })
|
||||||
|
signatureFile.value = file
|
||||||
|
signaturePreviewUrl.value = URL.createObjectURL(file)
|
||||||
|
showSignatureDialog.value = false
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSignCancel = () => { showSignatureDialog.value = false }
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container.mobile-optimized {
|
||||||
|
padding: 10px; max-width: 600px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||||
|
|
||||||
|
/* 扫码区 */
|
||||||
|
.scan-section { margin-bottom: 20px; }
|
||||||
|
.camera-wrapper {
|
||||||
|
height: 25vh; background: #000; border-radius: 12px; overflow: hidden; position: relative; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.scan-overlay {
|
||||||
|
position: absolute; bottom: 10px; right: 10px; z-index: 10;
|
||||||
|
}
|
||||||
|
.camera-placeholder {
|
||||||
|
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||||
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 表单与购物车 */
|
||||||
|
.cart-section { margin-bottom: 20px; }
|
||||||
|
.form-section { background: #fff; }
|
||||||
|
.signature-box {
|
||||||
|
border: 1px dashed #dcdfe6; border-radius: 6px; height: 100px;
|
||||||
|
background: #fcfcfc; display: flex; justify-content: center; align-items: center; cursor: pointer;
|
||||||
|
}
|
||||||
|
.unsigned-placeholder { display: flex; flex-direction: column; align-items: center; color: #909399; font-size: 13px; }
|
||||||
|
.signed-img img { max-height: 90px; }
|
||||||
|
.re-sign-tip { display: block; text-align: center; font-size: 12px; color: #409EFF; margin-top: 2px; }
|
||||||
|
|
||||||
|
.bottom-actions { display: flex; justify-content: space-between; margin-top: 30px; }
|
||||||
|
.bottom-actions .el-button { width: 48%; }
|
||||||
|
|
||||||
|
/* 全屏签名弹窗 */
|
||||||
|
:deep(.fullscreen-signature-dialog .el-dialog__body) { padding: 0; height: 100%; display: flex; }
|
||||||
|
.signature-wrapper { display: flex; width: 100%; height: 100%; }
|
||||||
|
.signature-canvas-container { flex: 1; position: relative; background: #fff; overflow: hidden; }
|
||||||
|
.native-canvas { display: block; width: 100%; height: 100%; touch-action: none; }
|
||||||
|
.canvas-tip {
|
||||||
|
position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||||
|
color: #ccc; font-size: 20px; pointer-events: none; opacity: 0.5; writing-mode: vertical-lr;
|
||||||
|
}
|
||||||
|
.signature-sidebar {
|
||||||
|
width: 120px; background: #333; color: #fff;
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 10px;
|
||||||
|
}
|
||||||
|
.sidebar-title { writing-mode: vertical-rl; font-size: 18px; letter-spacing: 5px; margin-bottom: 30px; font-weight: bold; }
|
||||||
|
.sidebar-actions { display: flex; flex-direction: column; gap: 20px; width: 100%; }
|
||||||
|
.sidebar-actions .el-button { width: 100%; margin: 0; height: 50px; }
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.signature-wrapper { flex-direction: column; }
|
||||||
|
.signature-canvas-container { flex: 1; }
|
||||||
|
.canvas-tip { writing-mode: horizontal-tb; bottom: 50%; }
|
||||||
|
.signature-sidebar { width: 100%; height: auto; flex-direction: row; padding: 10px; justify-content: space-between; }
|
||||||
|
.sidebar-title { display: none; }
|
||||||
|
.sidebar-actions { flex-direction: row; width: 100%; gap: 10px; }
|
||||||
|
.sidebar-actions .el-button { flex: 1; height: 40px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1 +0,0 @@
|
|||||||
<template><div style="padding:20px;"><h2>报废处理</h2></div></template>
|
|
||||||
Reference in New Issue
Block a user