出库进行修改,确保可以进行多个样例的出库以及出库的记录展示
This commit is contained in:
@ -18,7 +18,7 @@ def scan_barcode():
|
||||
return jsonify({'code': 400, 'msg': '请提供条码'}), 400
|
||||
|
||||
try:
|
||||
# 调用 Service 层去三个表中查找
|
||||
# 调用 Service 层去三个表中查找 (Service已更新,会返回价格)
|
||||
result = OutboundService.get_stock_by_barcode(barcode)
|
||||
|
||||
if result:
|
||||
@ -39,7 +39,7 @@ def scan_barcode():
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 2. 提交出库单接口
|
||||
# 2. 提交出库单接口 (批量)
|
||||
# POST /api/v1/outbound
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('', methods=['POST'])
|
||||
@ -54,26 +54,26 @@ def create_outbound():
|
||||
if not current_user_name:
|
||||
current_user_name = 'Unknown'
|
||||
|
||||
# ★ [修改] 获取最终的操作员名称
|
||||
# 优先取前端传来的 operator_name (你在前端下拉框选的人)
|
||||
# 如果前端没传,则回退使用当前登录用户的名字
|
||||
# 获取最终的操作员名称
|
||||
final_operator = data.get('operator_name')
|
||||
if not final_operator:
|
||||
final_operator = current_user_name
|
||||
|
||||
# 必填校验
|
||||
required_fields = ['stock_id', 'source_table', 'quantity', 'consumer_name', 'signature_path']
|
||||
for field in required_fields:
|
||||
if field not in data or not data[field]:
|
||||
return jsonify({'code': 400, 'msg': f'缺少必填字段: {field}'}), 400
|
||||
# 必填校验 (针对整个单据)
|
||||
# items 必须是列表且不为空,consumer_name 和 signature_path 必填
|
||||
if 'items' not in data or not data['items']:
|
||||
return jsonify({'code': 400, 'msg': '出库商品列表不能为空'}), 400
|
||||
|
||||
if not data.get('consumer_name') or not data.get('signature_path'):
|
||||
return jsonify({'code': 400, 'msg': '领用人及签名信息缺失'}), 400
|
||||
|
||||
try:
|
||||
# ★ [修改] 将确认后的操作员名称传给 Service
|
||||
outbound_record = OutboundService.create_outbound(data, operator_name=final_operator)
|
||||
# ★ [修改] 调用批量创建服务
|
||||
outbound_no = OutboundService.create_outbound_batch(data, operator_name=final_operator)
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '出库成功',
|
||||
'data': outbound_record.to_dict()
|
||||
'data': {'outbound_no': outbound_no}
|
||||
})
|
||||
except ValueError as e:
|
||||
# 业务逻辑错误 (如库存不足)
|
||||
@ -84,7 +84,7 @@ def create_outbound():
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 3. 获取出库记录列表
|
||||
# 3. 获取出库记录列表 (分组展示)
|
||||
# GET /api/v1/outbound
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('', methods=['GET'])
|
||||
@ -94,8 +94,10 @@ def get_outbound_list():
|
||||
page = int(request.args.get('page', 1))
|
||||
limit = int(request.args.get('limit', 10))
|
||||
keyword = request.args.get('keyword', '')
|
||||
# 如果前端传了日期范围,可以解析处理,这里暂略
|
||||
|
||||
result = OutboundService.get_list(page, limit, keyword)
|
||||
# ★ [修改] 调用分组查询服务
|
||||
result = OutboundService.get_grouped_list(page, limit, keyword)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
|
||||
@ -6,7 +6,8 @@ class TransOutbound(db.Model):
|
||||
__tablename__ = 'trans_outbound'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
outbound_no = db.Column(db.String(100), unique=True, nullable=False) # 出库单号
|
||||
# 修改:不再唯一,因为批量出库时多个商品共用一个单号
|
||||
outbound_no = db.Column(db.String(100), nullable=False)
|
||||
|
||||
# 关联源库存信息
|
||||
sku = db.Column(db.String(100))
|
||||
@ -18,6 +19,9 @@ class TransOutbound(db.Model):
|
||||
outbound_type = db.Column(db.String(50), default='SALES') # SALES(销售), USE(领用), TRANSFER(调拨)
|
||||
quantity = db.Column(db.Numeric(19, 4), nullable=False)
|
||||
|
||||
# [新增] 出库时的单价,用于计算金额
|
||||
unit_price = db.Column(db.Numeric(19, 2), default=0)
|
||||
|
||||
# 签字与追溯
|
||||
consumer_name = db.Column(db.String(100)) # 领用人/客户
|
||||
signature_path = db.Column(db.Text) # 电子签名图片路径
|
||||
@ -34,6 +38,7 @@ class TransOutbound(db.Model):
|
||||
'source_table': self.source_table,
|
||||
'outbound_type': self.outbound_type,
|
||||
'quantity': float(self.quantity) if self.quantity else 0,
|
||||
'unit_price': float(self.unit_price) if self.unit_price else 0,
|
||||
'consumer_name': self.consumer_name,
|
||||
'signature_path': self.signature_path,
|
||||
'outbound_time': self.outbound_time.strftime('%Y-%m-%d %H:%M:%S') if self.outbound_time else None,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta # [修改] 引入 timezone 和 timedelta
|
||||
from sqlalchemy import or_
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import or_, func, desc
|
||||
from app.extensions import db
|
||||
from app.models.outbound import TransOutbound
|
||||
|
||||
@ -8,7 +8,7 @@ from app.models.outbound import TransOutbound
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
# ★★★ [关键] 引入基础信息表,用于手动补全名称
|
||||
# 引入基础信息表
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
|
||||
@ -17,82 +17,90 @@ class OutboundService:
|
||||
@staticmethod
|
||||
def generate_outbound_no():
|
||||
"""
|
||||
生成出库单号: OUT-yyyyMMdd-随机码
|
||||
[修改] 强制使用北京时间生成日期前缀
|
||||
生成出库单号: OUT-yyyyMMdd-HHmm-当日流水(4位)
|
||||
例如: OUT-20260205-1558-0001
|
||||
"""
|
||||
# 获取北京时间
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz)
|
||||
now = datetime.now(beijing_tz)
|
||||
|
||||
date_str = current_time.strftime('%Y%m%d')
|
||||
short_uuid = uuid.uuid4().hex[:6].upper()
|
||||
return f"OUT-{date_str}-{short_uuid}"
|
||||
date_str = now.strftime('%Y%m%d')
|
||||
time_str = now.strftime('%H%M')
|
||||
|
||||
prefix = f"OUT-{date_str}-"
|
||||
|
||||
existing_count = db.session.query(func.count(func.distinct(TransOutbound.outbound_no))) \
|
||||
.filter(TransOutbound.outbound_no.like(f"{prefix}%")).scalar()
|
||||
|
||||
sequence = existing_count + 1
|
||||
return f"OUT-{date_str}-{time_str}-{sequence:04d}"
|
||||
|
||||
@staticmethod
|
||||
def get_stock_by_barcode(barcode):
|
||||
"""
|
||||
[核心逻辑] 根据扫码内容查找对应的库存物品
|
||||
查找顺序: 成品 (StockProduct) -> 半成品 (StockSemi) -> 采购件 (StockBuy)
|
||||
匹配逻辑: 匹配 barcode 字段 OR sku 字段
|
||||
根据扫码内容查找对应的库存物品,并附带价格信息
|
||||
"""
|
||||
if not barcode:
|
||||
return None
|
||||
|
||||
clean_code = barcode.strip()
|
||||
|
||||
# --- 1. 查找成品表 (StockProduct) ---
|
||||
def get_price(item, table_type):
|
||||
if table_type == 'stock_product':
|
||||
return float(item.sale_price) if item.sale_price else 0
|
||||
elif table_type == 'stock_buy':
|
||||
return float(item.unit_price) if item.unit_price else 0
|
||||
return 0
|
||||
|
||||
prod = StockProduct.query.filter(
|
||||
or_(StockProduct.barcode == clean_code, StockProduct.sku == clean_code)
|
||||
).first()
|
||||
if prod:
|
||||
return OutboundService._format_scan_result(prod, 'stock_product')
|
||||
res = OutboundService._format_scan_result(prod, 'stock_product')
|
||||
res['price'] = get_price(prod, 'stock_product')
|
||||
return res
|
||||
|
||||
# --- 2. 查找半成品表 (StockSemi) ---
|
||||
semi = StockSemi.query.filter(
|
||||
or_(StockSemi.barcode == clean_code, StockSemi.sku == clean_code)
|
||||
).first()
|
||||
if semi:
|
||||
return OutboundService._format_scan_result(semi, 'stock_semi')
|
||||
res = OutboundService._format_scan_result(semi, 'stock_semi')
|
||||
res['price'] = 0
|
||||
return res
|
||||
|
||||
# --- 3. 查找采购件表 (StockBuy) ---
|
||||
buy = StockBuy.query.filter(
|
||||
or_(StockBuy.barcode == clean_code, StockBuy.sku == clean_code)
|
||||
).first()
|
||||
if buy:
|
||||
return OutboundService._format_scan_result(buy, 'stock_buy')
|
||||
res = OutboundService._format_scan_result(buy, 'stock_buy')
|
||||
res['price'] = get_price(buy, 'stock_buy')
|
||||
return res
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_scan_result(item, table_name):
|
||||
"""
|
||||
[核心修复] 格式化返回数据,确保名称和规格一定能取到
|
||||
"""
|
||||
base_name = ""
|
||||
base_spec = ""
|
||||
base_cat = ""
|
||||
base_type = ""
|
||||
|
||||
# -------------------------------------------------------
|
||||
# 修复逻辑:强制获取基础信息
|
||||
# -------------------------------------------------------
|
||||
|
||||
# 步骤 1: 尝试通过 ORM 关联获取 (如果有定义 relationship)
|
||||
if hasattr(item, 'base') and item.base:
|
||||
base_name = item.base.name
|
||||
base_spec = item.base.spec_model
|
||||
base_cat = item.base.category
|
||||
base_type = item.base.material_type
|
||||
|
||||
# 步骤 2: [关键] 如果步骤1失败,但有 base_id,则手动查询 MaterialBase 表
|
||||
# 这能解决“扫码有库存但显示未知物品”的问题
|
||||
if not base_name and hasattr(item, 'base_id') and item.base_id:
|
||||
try:
|
||||
# 手动查基础表
|
||||
base_info = MaterialBase.query.get(item.base_id)
|
||||
if base_info:
|
||||
base_name = base_info.name
|
||||
base_spec = base_info.spec_model
|
||||
except Exception as e:
|
||||
print(f"基础信息查询失败: {e}")
|
||||
base_cat = base_info.category
|
||||
base_type = base_info.material_type
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 步骤 3: 兜底逻辑,某些旧表可能直接存了 material_name 字段
|
||||
if not base_name and hasattr(item, 'material_name'):
|
||||
base_name = item.material_name
|
||||
|
||||
@ -102,107 +110,205 @@ class OutboundService:
|
||||
return {
|
||||
'id': item.id,
|
||||
'sku': item.sku,
|
||||
'name': base_name or "未知物品", # 此时应该能正确显示名称了
|
||||
'spec_model': base_spec or "", # 此时应该能正确显示规格了
|
||||
'source_table': table_name, # 标记来源表
|
||||
'stock_quantity': stock_qty, # 当前库存
|
||||
'available_quantity': avail_qty, # 可用库存
|
||||
'name': base_name or "未知物品",
|
||||
'spec_model': base_spec or "",
|
||||
'category': base_cat or "",
|
||||
'material_type': base_type or "",
|
||||
'source_table': table_name,
|
||||
'stock_quantity': stock_qty,
|
||||
'available_quantity': avail_qty,
|
||||
'batch_number': getattr(item, 'batch_number', ''),
|
||||
'warehouse_location': getattr(item, 'warehouse_location', ''),
|
||||
'barcode': getattr(item, 'barcode', '')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def create_outbound(data, operator_name='System'):
|
||||
"""
|
||||
[核心逻辑] 执行出库:扣减对应表的库存 + 创建记录
|
||||
"""
|
||||
source_table = data.get('source_table')
|
||||
stock_id = data.get('stock_id')
|
||||
quantity = float(data.get('quantity', 0))
|
||||
def create_outbound_batch(data, operator_name='System'):
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
raise ValueError("出库商品列表不能为空")
|
||||
|
||||
if quantity <= 0:
|
||||
raise ValueError("出库数量必须大于0")
|
||||
outbound_no = OutboundService.generate_outbound_no()
|
||||
|
||||
common_data = {
|
||||
'outbound_no': outbound_no,
|
||||
'consumer_name': data.get('consumer_name'),
|
||||
'outbound_type': data.get('outbound_type', 'SALES'),
|
||||
'signature_path': data.get('signature_path'),
|
||||
'operator_name': operator_name,
|
||||
'remark': data.get('remark')
|
||||
}
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
|
||||
# 1. 动态映射表模型
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct
|
||||
}
|
||||
|
||||
ModelClass = model_map.get(source_table)
|
||||
if not ModelClass:
|
||||
raise ValueError(f"无效的数据来源表: {source_table}")
|
||||
|
||||
# 2. 锁定并查询库存 (使用 with_for_update 防止并发超卖)
|
||||
stock_item = ModelClass.query.with_for_update().get(stock_id)
|
||||
if not stock_item:
|
||||
raise ValueError("库存记录不存在或已被删除")
|
||||
|
||||
# 3. 校验库存充足
|
||||
current_avail = float(stock_item.available_quantity)
|
||||
if current_avail < quantity:
|
||||
raise ValueError(f"库存不足!当前可用: {current_avail}, 请求出库: {quantity}")
|
||||
|
||||
try:
|
||||
# 4. 扣减库存
|
||||
stock_item.stock_quantity = float(stock_item.stock_quantity) - quantity
|
||||
stock_item.available_quantity = float(stock_item.available_quantity) - quantity
|
||||
for item in items:
|
||||
source_table = item.get('source_table')
|
||||
stock_id = item.get('stock_id')
|
||||
quantity = float(item.get('quantity', 0))
|
||||
unit_price = float(item.get('price', 0))
|
||||
|
||||
# [新增] 计算北京时间
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
# replace(tzinfo=None) 是为了让存入数据库的时间变为 naive time (不带时区信息的本地时间)
|
||||
# 这样数据库看起来就是 "2023-10-27 15:00:00" 而不是 UTC 时间
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
if quantity <= 0:
|
||||
raise ValueError(f"SKU {item.get('sku')} 的出库数量必须大于0")
|
||||
|
||||
# 5. 创建出库记录
|
||||
new_outbound = TransOutbound(
|
||||
outbound_no=OutboundService.generate_outbound_no(),
|
||||
sku=data.get('sku'),
|
||||
source_table=source_table,
|
||||
stock_id=stock_id,
|
||||
barcode=data.get('barcode'),
|
||||
outbound_type=data.get('outbound_type', 'SALES'),
|
||||
quantity=quantity,
|
||||
consumer_name=data.get('consumer_name'),
|
||||
signature_path=data.get('signature_path'), # 存储签名的 URL
|
||||
ModelClass = model_map.get(source_table)
|
||||
if not ModelClass:
|
||||
continue
|
||||
|
||||
# [关键] 显式设置北京时间,覆盖 Model 中的 default=datetime.now (UTC)
|
||||
outbound_time=current_time,
|
||||
stock_record = ModelClass.query.with_for_update().get(stock_id)
|
||||
if not stock_record:
|
||||
raise ValueError(f"库存记录不存在 (ID: {stock_id})")
|
||||
|
||||
operator_name=operator_name,
|
||||
remark=data.get('remark')
|
||||
)
|
||||
if float(stock_record.available_quantity) < quantity:
|
||||
raise ValueError(f"SKU {stock_record.sku} 库存不足,当前可用: {stock_record.available_quantity}")
|
||||
|
||||
stock_record.stock_quantity = float(stock_record.stock_quantity) - quantity
|
||||
stock_record.available_quantity = float(stock_record.available_quantity) - quantity
|
||||
|
||||
new_record = TransOutbound(
|
||||
sku=item.get('sku'),
|
||||
source_table=source_table,
|
||||
stock_id=stock_id,
|
||||
barcode=item.get('barcode'),
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
outbound_time=current_time,
|
||||
**common_data
|
||||
)
|
||||
db.session.add(new_record)
|
||||
|
||||
db.session.add(new_outbound)
|
||||
db.session.commit()
|
||||
|
||||
return new_outbound
|
||||
return outbound_no
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
|
||||
"""查询出库历史记录"""
|
||||
query = TransOutbound.query.order_by(TransOutbound.outbound_time.desc())
|
||||
def get_grouped_list(page=1, per_page=10, keyword=None, start_date=None, end_date=None):
|
||||
"""
|
||||
查询出库记录(按出库单号分组),包含详细物品信息
|
||||
"""
|
||||
# 1. 查询分页单号
|
||||
stmt = db.session.query(
|
||||
TransOutbound.outbound_no,
|
||||
func.max(TransOutbound.outbound_time).label('max_time')
|
||||
).group_by(TransOutbound.outbound_no)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(or_(
|
||||
stmt = stmt.filter(or_(
|
||||
TransOutbound.outbound_no.ilike(f'%{keyword}%'),
|
||||
TransOutbound.consumer_name.ilike(f'%{keyword}%'),
|
||||
TransOutbound.sku.ilike(f'%{keyword}%')
|
||||
))
|
||||
|
||||
if start_date and end_date:
|
||||
query = query.filter(TransOutbound.outbound_time.between(start_date, end_date))
|
||||
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
|
||||
|
||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||
stmt = stmt.order_by(desc('max_time'))
|
||||
|
||||
pagination = stmt.paginate(page=page, per_page=per_page, error_out=False)
|
||||
outbound_nos = [row.outbound_no for row in pagination.items]
|
||||
|
||||
if not outbound_nos:
|
||||
return {
|
||||
'items': [],
|
||||
'total': 0,
|
||||
'pages': 0,
|
||||
'current_page': page
|
||||
}
|
||||
|
||||
# 2. 查询详细记录
|
||||
details = TransOutbound.query.filter(TransOutbound.outbound_no.in_(outbound_nos)).all()
|
||||
|
||||
# 3. 组装数据并查询物品详情
|
||||
grouped_map = {}
|
||||
|
||||
# 映射表模型以便查询
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct
|
||||
}
|
||||
|
||||
for d in details:
|
||||
ono = d.outbound_no
|
||||
if ono not in grouped_map:
|
||||
grouped_map[ono] = {
|
||||
'outbound_no': ono,
|
||||
'outbound_time': d.outbound_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'outbound_type': d.outbound_type,
|
||||
'consumer_name': d.consumer_name,
|
||||
'operator_name': d.operator_name,
|
||||
'signature_path': d.signature_path,
|
||||
'remark': d.remark,
|
||||
'total_amount': 0.0,
|
||||
'items': []
|
||||
}
|
||||
|
||||
# --- 查询物品详细信息 (名称, 规格, 类型, 类别) ---
|
||||
item_name = "未知物品"
|
||||
item_spec = ""
|
||||
item_cat = ""
|
||||
item_type = ""
|
||||
|
||||
ModelClass = model_map.get(d.source_table)
|
||||
if ModelClass and d.stock_id:
|
||||
# 注意:这里在循环中查询可能会有N+1问题,但考虑到单页数据量(通常每单条目不多),暂时可接受
|
||||
# 生产环境建议优化为预加载或批量查询
|
||||
try:
|
||||
stock_item = ModelClass.query.get(d.stock_id)
|
||||
if stock_item and stock_item.base:
|
||||
item_name = stock_item.base.name
|
||||
item_spec = stock_item.base.spec_model
|
||||
item_cat = stock_item.base.category
|
||||
item_type = stock_item.base.material_type
|
||||
elif stock_item and hasattr(stock_item, 'base_id') and stock_item.base_id:
|
||||
base_info = MaterialBase.query.get(stock_item.base_id)
|
||||
if base_info:
|
||||
item_name = base_info.name
|
||||
item_spec = base_info.spec_model
|
||||
item_cat = base_info.category
|
||||
item_type = base_info.material_type
|
||||
except Exception as e:
|
||||
print(f"Error fetching detail for stock_id {d.stock_id}: {e}")
|
||||
|
||||
# 计算金额
|
||||
price = float(d.unit_price) if d.unit_price else 0
|
||||
qty = float(d.quantity)
|
||||
subtotal = price * qty
|
||||
|
||||
grouped_map[ono]['total_amount'] += subtotal
|
||||
|
||||
grouped_map[ono]['items'].append({
|
||||
'sku': d.sku,
|
||||
'name': item_name,
|
||||
'spec_model': item_spec,
|
||||
'category': item_cat,
|
||||
'material_type': item_type,
|
||||
'quantity': qty,
|
||||
'unit_price': price,
|
||||
'subtotal': subtotal
|
||||
})
|
||||
|
||||
# 4. 排序输出
|
||||
result_list = []
|
||||
for ono in outbound_nos:
|
||||
if ono in grouped_map:
|
||||
obj = grouped_map[ono]
|
||||
obj['items'].sort(key=lambda x: x['unit_price'], reverse=True)
|
||||
obj['total_amount'] = round(obj['total_amount'], 2)
|
||||
result_list.append(obj)
|
||||
|
||||
return {
|
||||
'items': [item.to_dict() for item in pagination.items],
|
||||
'items': result_list,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages,
|
||||
'current_page': page
|
||||
|
||||
@ -1,13 +1,32 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface OutboundSubmitData {
|
||||
// 购物车商品项接口
|
||||
export interface CartItem {
|
||||
id: number
|
||||
sku: string
|
||||
name: string
|
||||
spec_model: string
|
||||
source_table: string
|
||||
stock_id: number
|
||||
stock_quantity: number
|
||||
available_quantity: number
|
||||
barcode: string
|
||||
price: number // 单价
|
||||
out_quantity: number // 本次出库数量
|
||||
}
|
||||
|
||||
// 提交出库单的数据结构
|
||||
export interface OutboundSubmitData {
|
||||
items: Array<{
|
||||
sku: string
|
||||
source_table: string
|
||||
stock_id: number
|
||||
barcode: string
|
||||
quantity: number
|
||||
price: number
|
||||
}>
|
||||
outbound_type: string
|
||||
quantity: number
|
||||
consumer_name: string
|
||||
operator_name: string
|
||||
signature_path: string // 上传后返回的图片路径
|
||||
remark?: string
|
||||
}
|
||||
@ -23,6 +42,7 @@ export interface ScanResult {
|
||||
batch_number?: string
|
||||
warehouse_location?: string
|
||||
barcode?: string
|
||||
price?: number // 扫描返回的价格
|
||||
}
|
||||
|
||||
/**
|
||||
@ -31,7 +51,6 @@ export interface ScanResult {
|
||||
*/
|
||||
export function getStockByBarcode(barcode: string) {
|
||||
return request<any, ScanResult>({
|
||||
// ★★★ [修改] 去掉开头的 /api,Axios 会自动拼接 baseURL
|
||||
url: '/v1/outbound/scan',
|
||||
method: 'get',
|
||||
params: { barcode }
|
||||
@ -39,11 +58,10 @@ export function getStockByBarcode(barcode: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交出库单
|
||||
* 提交出库单 (批量)
|
||||
*/
|
||||
export function submitOutbound(data: OutboundSubmitData) {
|
||||
return request({
|
||||
// ★★★ [修改] 去掉开头的 /api
|
||||
url: '/v1/outbound',
|
||||
method: 'post',
|
||||
data
|
||||
@ -55,7 +73,6 @@ export function submitOutbound(data: OutboundSubmitData) {
|
||||
*/
|
||||
export function getOutboundList(params: any) {
|
||||
return request({
|
||||
// ★★★ [修改] 去掉开头的 /api
|
||||
url: '/v1/outbound',
|
||||
method: 'get',
|
||||
params
|
||||
|
||||
@ -3,14 +3,20 @@
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>扫码出库作业台</span>
|
||||
<el-button type="primary" @click="toggleCamera">
|
||||
{{ showCamera ? '关闭摄像头' : '打开摄像头扫码' }}
|
||||
</el-button>
|
||||
<span>批量扫码出库作业台</span>
|
||||
<div class="header-right">
|
||||
<span v-if="cartItems.length > 0" class="summary-text">
|
||||
已选: <span class="num">{{ cartItems.length }}</span> 项 |
|
||||
总金额: <span class="price">¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</span>
|
||||
<el-button type="primary" @click="toggleCamera" style="margin-left: 10px;">
|
||||
{{ showCamera ? '关闭摄像头' : '打开摄像头扫码' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!currentItem" class="scan-area">
|
||||
<div class="scan-area">
|
||||
<div v-if="showCamera" id="reader" class="camera-box"></div>
|
||||
<div v-if="isHttpAndNotLocal" class="http-warning">
|
||||
注意:当前为 HTTP 环境,摄像头可能无法启动。请使用 HTTPS 或 localhost,或配置浏览器安全白名单。
|
||||
@ -19,58 +25,76 @@
|
||||
<div class="input-box">
|
||||
<el-input
|
||||
v-model="barcodeInput"
|
||||
placeholder="请扫描条码或手动输入后回车"
|
||||
placeholder="请连续扫描条码或手动输入后回车"
|
||||
@keyup.enter="handleScan"
|
||||
clearable
|
||||
ref="barcodeRef"
|
||||
class="scan-input"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Scissor /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="handleScan">确定</el-button>
|
||||
<el-button @click="handleScan">添加商品</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="confirm-area">
|
||||
<el-descriptions title="货物信息" border :column="1">
|
||||
<el-descriptions-item label="名称">{{ currentItem.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格/型号">{{ currentItem.spec_model }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前库存">
|
||||
<el-tag :type="currentItem.available_quantity > 0 ? 'success' : 'danger'">
|
||||
{{ currentItem.stock_quantity }} (可用: {{ currentItem.available_quantity }})
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="库位">{{ currentItem.warehouse_location || '暂无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="cart-area mt-20" v-if="cartItems.length > 0">
|
||||
<el-table :data="cartItems" border stripe style="width: 100%">
|
||||
<el-table-column prop="name" label="名称" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" width="150" />
|
||||
<el-table-column label="单价" width="120">
|
||||
<template #default="{row}">
|
||||
¥{{ row.price }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" width="100">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="row.available_quantity > 0 ? 'success' : 'danger'">{{ row.available_quantity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="出库数量" width="160">
|
||||
<template #default="{row}">
|
||||
<el-input-number v-model="row.out_quantity" :min="1" :max="row.available_quantity" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="120">
|
||||
<template #default="{row}">
|
||||
<span style="color: #F56C6C; font-weight: bold;">¥{{ (row.price * row.out_quantity).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<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="暂无扫描商品,请扫描上方条码进行添加" />
|
||||
|
||||
<el-form :model="form" ref="formRef" :rules="rules" label-position="top" class="mt-20">
|
||||
<div v-if="cartItems.length > 0" class="confirm-area mt-20">
|
||||
<el-divider content-position="left">单据信息</el-divider>
|
||||
|
||||
<el-form :model="form" ref="formRef" :rules="rules" label-position="top">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="出库类型" prop="outbound_type">
|
||||
<el-select v-model="form.outbound_type" placeholder="请选择">
|
||||
<el-select v-model="form.outbound_type" placeholder="请选择" style="width: 100%">
|
||||
<el-option label="销售出库" value="SALES" />
|
||||
<el-option label="内部领用" value="USE" />
|
||||
<el-option label="调拨" value="TRANSFER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="出库数量" prop="quantity">
|
||||
<el-input-number v-model="form.quantity" :min="1" :max="currentItem.available_quantity" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="领用人/客户姓名" prop="consumer_name">
|
||||
<el-input v-model="form.consumer_name" placeholder="请输入姓名" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="操作员 (库管)" prop="operator_name">
|
||||
<el-select
|
||||
v-model="form.operator_name"
|
||||
@ -108,8 +132,8 @@
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="resetScan">取消 / 重新扫码</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="submitForm">确认出库</el-button>
|
||||
<el-button @click="clearAll">清空重置</el-button>
|
||||
<el-button type="primary" size="large" :loading="loading" @click="submitForm">确认批量出库</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
@ -154,16 +178,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'
|
||||
import { Scissor, EditPen } from '@element-plus/icons-vue'
|
||||
import { getStockByBarcode, submitOutbound, getOutboundList, type ScanResult } from '@/api/outbound'
|
||||
import { Scissor, EditPen, Delete } from '@element-plus/icons-vue'
|
||||
import { getStockByBarcode, submitOutbound, getOutboundList } from '@/api/outbound'
|
||||
import { uploadFile } from '@/api/common/upload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
// --- 状态定义 ---
|
||||
const barcodeInput = ref('')
|
||||
const currentItem = ref<ScanResult | null>(null)
|
||||
const cartItems = ref<any[]>([]) // 购物车列表
|
||||
const loading = ref(false)
|
||||
const showCamera = ref(false)
|
||||
const html5QrCode = ref<Html5Qrcode | null>(null)
|
||||
@ -188,7 +212,6 @@ const operatorOptions = ref<string[]>([])
|
||||
|
||||
const form = reactive({
|
||||
outbound_type: 'SALES',
|
||||
quantity: 1,
|
||||
consumer_name: '',
|
||||
operator_name: '',
|
||||
remark: ''
|
||||
@ -196,7 +219,6 @@ const form = reactive({
|
||||
|
||||
const rules = {
|
||||
consumer_name: [{ required: true, message: '请输入领用人姓名', trigger: 'blur' }],
|
||||
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
|
||||
operator_name: [{ required: true, message: '请指定操作员', trigger: 'change' }]
|
||||
}
|
||||
|
||||
@ -206,6 +228,11 @@ const isHttpAndNotLocal = computed(() => {
|
||||
return !isHttps && !isLocal
|
||||
})
|
||||
|
||||
// 计算总金额
|
||||
const totalAmount = computed(() => {
|
||||
return cartItems.value.reduce((sum, item) => sum + (item.price * item.out_quantity), 0)
|
||||
})
|
||||
|
||||
// --- 初始化逻辑 ---
|
||||
onMounted(() => {
|
||||
if (userStore.username) {
|
||||
@ -221,9 +248,10 @@ const loadHistoryOperators = async () => {
|
||||
if (res.data && res.data.items) {
|
||||
const names = new Set<string>()
|
||||
if (userStore.username) names.add(userStore.username)
|
||||
res.data.items.forEach((item: any) => {
|
||||
if (item.operator_name) {
|
||||
names.add(item.operator_name)
|
||||
// 注意:现在的列表结构变了,operator_name 在外层
|
||||
res.data.items.forEach((group: any) => {
|
||||
if (group.operator_name) {
|
||||
names.add(group.operator_name)
|
||||
}
|
||||
})
|
||||
operatorOptions.value = Array.from(names)
|
||||
@ -233,149 +261,139 @@ const loadHistoryOperators = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 签名逻辑 ---
|
||||
|
||||
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()
|
||||
let clientX, clientY
|
||||
|
||||
if (e.type.startsWith('touch')) {
|
||||
clientX = (e as TouchEvent).touches[0].clientX
|
||||
clientY = (e as TouchEvent).touches[0].clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
clientY = (e as MouseEvent).clientY
|
||||
}
|
||||
|
||||
return {
|
||||
x: clientX - rect.left,
|
||||
y: clientY - rect.top
|
||||
}
|
||||
}
|
||||
|
||||
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
isDrawing.value = true
|
||||
const { x, y } = getPos(e)
|
||||
lastX.value = x
|
||||
lastY.value = y
|
||||
|
||||
if(ctx.value) {
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, y)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isDrawing.value || !ctx.value) return
|
||||
|
||||
const { x, y } = getPos(e)
|
||||
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(lastX.value, lastY.value)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
|
||||
lastX.value = x
|
||||
lastY.value = y
|
||||
}
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing.value = false
|
||||
if (ctx.value) ctx.value.beginPath()
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!nativeCanvasRef.value) return
|
||||
|
||||
nativeCanvasRef.value.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], `signature_${Date.now()}.png`, { type: 'image/png' })
|
||||
signatureFile.value = file
|
||||
signaturePreviewUrl.value = URL.createObjectURL(file)
|
||||
showSignatureDialog.value = false
|
||||
} else {
|
||||
ElMessage.error('生成签名失败')
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
const handleSignCancel = () => {
|
||||
showSignatureDialog.value = false
|
||||
}
|
||||
|
||||
// --- 扫码逻辑 ---
|
||||
// --- 扫码逻辑 (加入购物车) ---
|
||||
const handleScan = async () => {
|
||||
if (!barcodeInput.value) return
|
||||
const code = barcodeInput.value.trim()
|
||||
if (!code) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await getStockByBarcode(barcodeInput.value)
|
||||
|
||||
// 1. 检查是否已经在购物车中
|
||||
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
||||
if (existIndex > -1) {
|
||||
// 存在则数量+1
|
||||
const item = cartItems.value[existIndex]
|
||||
if (item.out_quantity < item.available_quantity) {
|
||||
item.out_quantity++
|
||||
ElMessage.success(`商品 ${item.name} 数量+1`)
|
||||
} else {
|
||||
ElMessage.warning(`库存不足 (余: ${item.available_quantity})`)
|
||||
}
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 新商品调用 API
|
||||
const res = await getStockByBarcode(code)
|
||||
if (res.data) {
|
||||
const item = res.data
|
||||
if (item.available_quantity <= 0) {
|
||||
ElMessage.warning(`该商品库存不足或已出库!(当前库存: ${item.available_quantity})`)
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
currentItem.value = item
|
||||
if (html5QrCode.value && html5QrCode.value.isScanning) {
|
||||
await stopCamera()
|
||||
} else {
|
||||
// 加入购物车,默认出库 1
|
||||
cartItems.value.push({
|
||||
...item,
|
||||
out_quantity: 1,
|
||||
price: item.price || 0
|
||||
})
|
||||
ElMessage.success('添加成功')
|
||||
}
|
||||
barcodeInput.value = ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
ElMessage.error(`未找到条码 ${barcodeInput.value} 的入库记录,请先入库!`)
|
||||
ElMessage.error(`未找到条码 ${barcodeInput.value} 的入库记录`)
|
||||
} else {
|
||||
console.error(error)
|
||||
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.consumer_name = ''
|
||||
form.remark = ''
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
barcodeInput.value = ''
|
||||
})
|
||||
}
|
||||
|
||||
// --- 提交逻辑 (批量提交) ---
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
if (cartItems.value.length === 0) {
|
||||
ElMessage.warning('购物车为空,请先扫码')
|
||||
return
|
||||
}
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
|
||||
if (!signatureFile.value) {
|
||||
ElMessage.error('请进行电子签名')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 1. 上传签名
|
||||
const uploadRes = await uploadFile(signatureFile.value)
|
||||
const signatureUrl = uploadRes.data.url
|
||||
|
||||
// 2. 构造 items 数据
|
||||
const itemsPayload = cartItems.value.map(item => ({
|
||||
stock_id: item.id,
|
||||
source_table: item.source_table,
|
||||
sku: item.sku,
|
||||
barcode: item.barcode,
|
||||
quantity: item.out_quantity,
|
||||
price: item.price
|
||||
}))
|
||||
|
||||
// 3. 提交业务单据
|
||||
await submitOutbound({
|
||||
items: itemsPayload,
|
||||
outbound_type: form.outbound_type,
|
||||
consumer_name: form.consumer_name,
|
||||
operator_name: form.operator_name,
|
||||
remark: form.remark,
|
||||
signature_path: signatureUrl
|
||||
})
|
||||
|
||||
ElMessage.success('批量出库成功')
|
||||
// 清空
|
||||
cartItems.value = []
|
||||
form.consumer_name = ''
|
||||
form.remark = ''
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
|
||||
// 刷新操作员列表
|
||||
loadHistoryOperators()
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --- 摄像头控制 ---
|
||||
const toggleCamera = async () => {
|
||||
if (showCamera.value) {
|
||||
@ -435,64 +453,106 @@ const stopCamera = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 提交逻辑 ---
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
if (!currentItem.value) return
|
||||
|
||||
if (!signatureFile.value) {
|
||||
ElMessage.error('请进行电子签名')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const uploadRes = await uploadFile(signatureFile.value)
|
||||
const signatureUrl = uploadRes.data.url
|
||||
|
||||
await submitOutbound({
|
||||
sku: currentItem.value.sku,
|
||||
source_table: currentItem.value.source_table,
|
||||
stock_id: currentItem.value.id,
|
||||
barcode: barcodeInput.value,
|
||||
outbound_type: form.outbound_type,
|
||||
quantity: form.quantity,
|
||||
consumer_name: form.consumer_name,
|
||||
operator_name: form.operator_name,
|
||||
remark: form.remark,
|
||||
signature_path: signatureUrl
|
||||
})
|
||||
|
||||
ElMessage.success('出库成功')
|
||||
resetScan()
|
||||
loadHistoryOperators()
|
||||
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
// --- 签名核心逻辑 ---
|
||||
const openSignatureDialog = () => {
|
||||
showSignatureDialog.value = true
|
||||
}
|
||||
|
||||
const resetScan = () => {
|
||||
currentItem.value = null
|
||||
barcodeInput.value = ''
|
||||
form.consumer_name = ''
|
||||
form.quantity = 1
|
||||
form.remark = ''
|
||||
if (userStore.username) {
|
||||
form.operator_name = userStore.username
|
||||
const initCanvas = async () => {
|
||||
// 必须等待 DOM 更新完成,确保 dialog 里的 ref 已经挂载
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
console.error('Canvas 初始化失败:无法获取 DOM 元素')
|
||||
}
|
||||
signatureFile.value = null
|
||||
signaturePreviewUrl.value = ''
|
||||
nextTick(() => {
|
||||
barcodeRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const getPos = (e: MouseEvent | TouchEvent) => {
|
||||
if (!nativeCanvasRef.value) return { x: 0, y: 0 }
|
||||
const rect = nativeCanvasRef.value.getBoundingClientRect()
|
||||
let clientX, clientY
|
||||
if (e.type.startsWith('touch')) {
|
||||
clientX = (e as TouchEvent).touches[0].clientX
|
||||
clientY = (e as TouchEvent).touches[0].clientY
|
||||
} else {
|
||||
clientX = (e as MouseEvent).clientX
|
||||
clientY = (e as MouseEvent).clientY
|
||||
}
|
||||
return { x: clientX - rect.left, y: clientY - rect.top }
|
||||
}
|
||||
|
||||
const startDrawing = (e: MouseEvent | TouchEvent) => {
|
||||
// 阻止默认事件(如页面滚动)
|
||||
e.preventDefault()
|
||||
isDrawing.value = true
|
||||
const { x, y } = getPos(e)
|
||||
lastX.value = x
|
||||
lastY.value = y
|
||||
if(ctx.value) {
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(x, y)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
const draw = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
if (!isDrawing.value || !ctx.value) return
|
||||
const { x, y } = getPos(e)
|
||||
ctx.value.beginPath()
|
||||
ctx.value.moveTo(lastX.value, lastY.value)
|
||||
ctx.value.lineTo(x, y)
|
||||
ctx.value.stroke()
|
||||
lastX.value = x
|
||||
lastY.value = y
|
||||
}
|
||||
|
||||
const stopDrawing = () => {
|
||||
isDrawing.value = false
|
||||
if (ctx.value) ctx.value.beginPath()
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (!nativeCanvasRef.value) return
|
||||
nativeCanvasRef.value.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const file = new File([blob], `signature_${Date.now()}.png`, { type: 'image/png' })
|
||||
signatureFile.value = file
|
||||
signaturePreviewUrl.value = URL.createObjectURL(file)
|
||||
showSignatureDialog.value = false
|
||||
} else {
|
||||
ElMessage.error('生成签名失败')
|
||||
}
|
||||
}, 'image/png')
|
||||
}
|
||||
|
||||
const handleSignCancel = () => {
|
||||
showSignatureDialog.value = false
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
@ -504,7 +564,6 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 原有样式保持不变 */
|
||||
.scan-area {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
@ -526,7 +585,7 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
}
|
||||
.confirm-area {
|
||||
max-width: 600px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.mt-20 {
|
||||
@ -537,7 +596,6 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.signature-display-area {
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
@ -549,9 +607,6 @@ onUnmounted(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.signed-image-box {
|
||||
text-align: center;
|
||||
}
|
||||
.signed-image-box img {
|
||||
max-height: 100px;
|
||||
max-width: 100%;
|
||||
@ -559,23 +614,32 @@ onUnmounted(() => {
|
||||
margin-bottom: 5px;
|
||||
border: 1px solid #eee;
|
||||
}
|
||||
/* 新增:头部右侧信息 */
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.summary-text {
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.summary-text .num { color: #409EFF; font-weight: bold; margin: 0 4px; }
|
||||
.summary-text .price { color: #F56C6C; font-weight: bold; margin: 0 4px; font-size: 16px; }
|
||||
|
||||
/* --- 响应式全屏弹窗样式 --- */
|
||||
/* 响应式签名弹窗 */
|
||||
:deep(.fullscreen-signature-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 默认布局(电脑/平板):左右结构 */
|
||||
.signature-wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%; /* 使用 100% 适应弹窗 body */
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.signature-canvas-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
@ -584,7 +648,6 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.native-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@ -592,8 +655,6 @@ onUnmounted(() => {
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 右侧边栏(默认) */
|
||||
.signature-sidebar {
|
||||
width: 150px;
|
||||
background: #333;
|
||||
@ -607,7 +668,6 @@ onUnmounted(() => {
|
||||
z-index: 10;
|
||||
box-shadow: -2px 0 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 4px;
|
||||
@ -615,69 +675,50 @@ onUnmounted(() => {
|
||||
font-weight: bold;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.sidebar-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sidebar-actions .el-button {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
margin-top: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* --- ★ 手机端适配 (屏幕宽度小于 768px) --- */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* 1. 改为上下布局:画布在上,按钮在下 */
|
||||
.signature-wrapper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 2. 画布区域自动填充剩余空间 */
|
||||
.signature-canvas-container {
|
||||
flex: 1;
|
||||
height: auto; /* 高度自适应 */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 3. 底部工具栏样式重写 */
|
||||
.signature-sidebar {
|
||||
width: 100%; /* 宽度占满 */
|
||||
height: auto; /* 高度由内容撑开 */
|
||||
flex-direction: row; /* 内容横向排列 */
|
||||
padding: 15px; /* 增加内边距 */
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-direction: row;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
/* 放在底部 */
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* 4. 隐藏竖排文字标题,节省空间 */
|
||||
.sidebar-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 5. 按钮组改为横向排列 */
|
||||
.sidebar-actions {
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 6. 按钮大小调整,均匀分布 */
|
||||
.sidebar-actions .el-button {
|
||||
flex: 1; /* 三个按钮平分宽度 */
|
||||
height: 44px; /* 适合手指点击的高度 */
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 去掉确认按钮原本的顶部外边距 */
|
||||
.confirm-btn {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
@ -30,7 +30,33 @@
|
||||
style="width: 100%; margin-top: 20px;"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
|
||||
>
|
||||
<el-table-column prop="outbound_no" label="出库单号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<div style="padding: 10px 40px; background: #fafafa;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #409EFF;">商品明细 (按单价降序)</h4>
|
||||
<el-table :data="props.row.items" border size="small">
|
||||
<el-table-column prop="sku" label="SKU" width="150" />
|
||||
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="material_type" label="类型" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="category" label="类别" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="quantity" label="数量" width="100" />
|
||||
<el-table-column prop="unit_price" label="单价" width="120">
|
||||
<template #default="{row}">¥{{ row.unit_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="subtotal" label="小计">
|
||||
<template #default="{row}">
|
||||
<span style="color: #F56C6C; font-weight: bold;">¥{{ row.subtotal.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="outbound_no" label="出库单号" min-width="200" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="outbound_time" label="出库时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
@ -44,9 +70,11 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sku" label="SKU" min-width="140" show-overflow-tooltip />
|
||||
|
||||
<el-table-column prop="quantity" label="数量" width="90" align="center" />
|
||||
<el-table-column prop="total_amount" label="总金额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #F56C6C; font-weight: bold; font-size: 15px;">¥{{ row.total_amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="consumer_name" label="领用/客户" min-width="120" show-overflow-tooltip />
|
||||
|
||||
@ -76,18 +104,31 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="listQuery.limit"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { getOutboundList } from '@/api/outbound'
|
||||
import { Picture } from '@element-plus/icons-vue' // 引入图标用于图片加载失败显示
|
||||
import { Picture } from '@element-plus/icons-vue'
|
||||
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const listQuery = reactive({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: '',
|
||||
dateRange: []
|
||||
})
|
||||
@ -95,8 +136,15 @@ const listQuery = reactive({
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getOutboundList(listQuery)
|
||||
const params = {
|
||||
...listQuery,
|
||||
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
|
||||
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null
|
||||
}
|
||||
|
||||
const res = await getOutboundList(params)
|
||||
list.value = res.data.items || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
@ -104,6 +152,11 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePageChange = (val: number) => {
|
||||
listQuery.page = val
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const formatType = (type: string) => {
|
||||
const map: any = {
|
||||
'SALES': '销售出库',
|
||||
@ -114,7 +167,6 @@ const formatType = (type: string) => {
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
// 辅助函数:根据类型返回 Tag 颜色
|
||||
const getTagType = (type: string) => {
|
||||
const map: any = {
|
||||
'SALES': 'success',
|
||||
|
||||
Reference in New Issue
Block a user