Compare commits
28 Commits
bf104535e5
...
5249649cfa
| Author | SHA1 | Date | |
|---|---|---|---|
| 5249649cfa | |||
| 6ba20fa3db | |||
| 7a3ffd8ba5 | |||
| 2eee792059 | |||
| cf76310340 | |||
| c6e8c887ec | |||
| f5aa2f481b | |||
| 7e281cf285 | |||
| e026c00a85 | |||
| e492000c3e | |||
| 22c8ce55eb | |||
| 3cb44c8f18 | |||
| 74271d53bc | |||
| a6c21fd313 | |||
| 4c41e1ec13 | |||
| 1000deda2f | |||
| f499346f74 | |||
| 664127ee30 | |||
| 4b62c81baa | |||
| b68dfbf23d | |||
| efb88c8f9c | |||
| 3a9ec81c51 | |||
| 25bb35e265 | |||
| f18e61a71a | |||
| 9e393ff998 | |||
| 8d90b3c774 | |||
| 021c92ee8a | |||
| 5abc504619 |
@ -90,54 +90,70 @@ def get_stock_record(source_table, stock_id, for_update=False):
|
|||||||
|
|
||||||
def get_stock_info(uuid_or_barcode):
|
def get_stock_info(uuid_or_barcode):
|
||||||
"""
|
"""
|
||||||
根据 uuid 或 barcode 查询库存信息
|
根据 uuid 或 barcode 查询库存信息(★ 精确匹配优先,性能与准确性兼顾)
|
||||||
返回: (item, source_table, stock_id)
|
|
||||||
|
修复: 原来用 ilike %x% 全表模糊搜索 + .first(),
|
||||||
|
在 SKU 前缀相同的场景会命中错误记录或漏匹配。
|
||||||
|
改为: 精确匹配(==)优先,命中即返回;无精确命中再回退模糊搜索。
|
||||||
|
|
||||||
|
返回: (item, source_table, stock_id) 或 (None, None, None)
|
||||||
"""
|
"""
|
||||||
# 清洗输入:去掉前后空格和换行符
|
# 清洗输入:去掉前后空格和换行符
|
||||||
uuid_or_barcode = str(uuid_or_barcode).strip()
|
code = str(uuid_or_barcode).strip()
|
||||||
|
if not code:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
# 1. 成品
|
# ===== 精确匹配优先(走索引,快且准) =====
|
||||||
if StockProduct:
|
exact_checks = [
|
||||||
print(f"🔍 [QUERY DEBUG] 正在成品表搜关键词: {uuid_or_barcode}")
|
(StockProduct, lambda c: db.or_(
|
||||||
item = StockProduct.query.filter(
|
StockProduct.barcode == c,
|
||||||
db.or_(
|
StockProduct.sku == c,
|
||||||
StockProduct.barcode.ilike(f"%{uuid_or_barcode}%"),
|
StockProduct.serial_number == c
|
||||||
StockProduct.sku.ilike(f"%{uuid_or_barcode}%"),
|
), 'stock_product'),
|
||||||
StockProduct.serial_number.ilike(f"%{uuid_or_barcode}%")
|
(StockSemi, lambda c: db.or_(
|
||||||
)
|
StockSemi.barcode == c,
|
||||||
).first()
|
StockSemi.sku == c,
|
||||||
if item:
|
StockSemi.serial_number == c
|
||||||
print(f"✅ [QUERY DEBUG] 命中成品! ID={item.id}, SKU={item.sku}")
|
), 'stock_semi'),
|
||||||
return (item, 'stock_product', item.id)
|
(StockBuy, lambda c: db.or_(
|
||||||
else:
|
StockBuy.barcode == c,
|
||||||
print(f"❌ [QUERY DEBUG] 成品表查询结束,无匹配项")
|
StockBuy.sku == c
|
||||||
|
), 'stock_buy'),
|
||||||
|
]
|
||||||
|
|
||||||
# 2. 半成品
|
for model, cond_fn, table_name in exact_checks:
|
||||||
if StockSemi:
|
if not model:
|
||||||
print(f"🔍 [QUERY DEBUG] 正在半成品表搜关键词: {uuid_or_barcode}")
|
continue
|
||||||
item = StockSemi.query.filter(
|
item = model.query.filter(cond_fn(code)).first()
|
||||||
db.or_(
|
|
||||||
StockSemi.barcode.ilike(f"%{uuid_or_barcode}%"),
|
|
||||||
StockSemi.sku.ilike(f"%{uuid_or_barcode}%"),
|
|
||||||
StockSemi.serial_number.ilike(f"%{uuid_or_barcode}%")
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if item:
|
if item:
|
||||||
print(f"✅ [QUERY DEBUG] 命中半成品! ID={item.id}, SKU={item.sku}")
|
return (item, table_name, item.id)
|
||||||
return (item, 'stock_semi', item.id)
|
|
||||||
|
|
||||||
# 3. 采购件
|
# ===== 精确未命中 → 回退模糊搜索(保留旧行为兜底) =====
|
||||||
if StockBuy:
|
fuzzy_checks = [
|
||||||
print(f"🔍 [QUERY DEBUG] 正在采购件表搜关键词: {uuid_or_barcode}")
|
(StockProduct, lambda c: db.or_(
|
||||||
item = StockBuy.query.filter(
|
StockProduct.barcode.ilike(f"%{c}%"),
|
||||||
db.or_(
|
StockProduct.sku.ilike(f"%{c}%"),
|
||||||
StockBuy.barcode.ilike(f"%{uuid_or_barcode}%"),
|
StockProduct.serial_number.ilike(f"%{c}%")
|
||||||
StockBuy.sku.ilike(f"%{uuid_or_barcode}%")
|
), 'stock_product'),
|
||||||
)
|
(StockSemi, lambda c: db.or_(
|
||||||
).first()
|
StockSemi.barcode.ilike(f"%{c}%"),
|
||||||
|
StockSemi.sku.ilike(f"%{c}%"),
|
||||||
|
StockSemi.serial_number.ilike(f"%{c}%")
|
||||||
|
), 'stock_semi'),
|
||||||
|
(StockBuy, lambda c: db.or_(
|
||||||
|
StockBuy.barcode.ilike(f"%{c}%"),
|
||||||
|
StockBuy.sku.ilike(f"%{c}%")
|
||||||
|
), 'stock_buy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for model, cond_fn, table_name in fuzzy_checks:
|
||||||
|
if not model:
|
||||||
|
continue
|
||||||
|
item = model.query.filter(cond_fn(code)).first()
|
||||||
if item:
|
if item:
|
||||||
print(f"✅ [QUERY DEBUG] 命中采购件! ID={item.id}, SKU={item.sku}")
|
return (item, table_name, item.id)
|
||||||
return (item, 'stock_buy', item.id)
|
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
return (None, None, None)
|
return (None, None, None)
|
||||||
|
|
||||||
@ -411,6 +427,42 @@ def get_stock_list():
|
|||||||
return _do_get_stock_list(permission_prefix='outbound_selection')
|
return _do_get_stock_list(permission_prefix='outbound_selection')
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 盘库/出库/借库 扫码精确匹配接口
|
||||||
|
# GET /api/v1/inbound/stock/scan?barcode=xxx
|
||||||
|
# 精确匹配优先,替代前端 pageSize:10 模糊搜索 + find() 的漏匹配问题
|
||||||
|
# --------------------------------------------------------
|
||||||
|
@bp.route('/scan', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def scan_stock_by_barcode():
|
||||||
|
"""根据条码精确匹配库存记录(一次返回唯一命中,性能好且准确)"""
|
||||||
|
try:
|
||||||
|
barcode = request.args.get('barcode', '').strip()
|
||||||
|
if not barcode:
|
||||||
|
return jsonify({'code': 400, 'msg': 'barcode 不能为空'}), 400
|
||||||
|
|
||||||
|
item, source_table, stock_id = get_stock_info(barcode)
|
||||||
|
if not item:
|
||||||
|
return jsonify({'code': 404, 'msg': f'未找到该物料库存: {barcode}'}), 404
|
||||||
|
|
||||||
|
d = item.to_dict()
|
||||||
|
d['stock_type'] = source_table.replace('stock_', '')
|
||||||
|
d['type'] = source_table.replace('stock_', '')
|
||||||
|
d['source_table'] = source_table
|
||||||
|
d['stock_id'] = stock_id
|
||||||
|
# 兼容前端字段
|
||||||
|
if hasattr(item, 'base') and item.base:
|
||||||
|
d['name'] = d.get('material_name') or item.base.name or ''
|
||||||
|
d['standard'] = d.get('spec_model') or item.base.spec_model or ''
|
||||||
|
d['stock_quantity'] = float(d.get('stock_quantity') or d.get('qty_stock') or 0)
|
||||||
|
d['available_quantity'] = float(d.get('available_quantity') or d.get('qty_available') or 0)
|
||||||
|
|
||||||
|
return jsonify({'code': 200, 'msg': 'success', 'data': d}), 200
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# --- 草稿箱接口 ---
|
# --- 草稿箱接口 ---
|
||||||
|
|
||||||
@bp.route('/draft/list', methods=['GET'])
|
@bp.route('/draft/list', methods=['GET'])
|
||||||
@ -425,12 +477,17 @@ def get_drafts():
|
|||||||
limit = request.args.get('limit', 20, type=int)
|
limit = request.args.get('limit', 20, type=int)
|
||||||
keyword = request.args.get('keyword', '', type=str)
|
keyword = request.args.get('keyword', '', type=str)
|
||||||
session_id = request.args.get('session_id')
|
session_id = request.args.get('session_id')
|
||||||
|
uuid = request.args.get('uuid', '', type=str)
|
||||||
|
|
||||||
query = StocktakeDraft.query
|
query = StocktakeDraft.query
|
||||||
|
|
||||||
if session_id:
|
if session_id:
|
||||||
query = query.filter_by(session_id=session_id)
|
query = query.filter_by(session_id=session_id)
|
||||||
|
|
||||||
|
# ★ 按 uuid 精确过滤(扫码时检测该物料是否已盘)
|
||||||
|
if uuid:
|
||||||
|
query = query.filter_by(uuid=uuid)
|
||||||
|
|
||||||
# 先执行查询获取所有记录
|
# 先执行查询获取所有记录
|
||||||
drafts = query.all()
|
drafts = query.all()
|
||||||
|
|
||||||
@ -545,8 +602,10 @@ def add_draft():
|
|||||||
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量
|
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量
|
||||||
adjusted_stock_qty = stock_qty - total_borrowed
|
adjusted_stock_qty = stock_qty - total_borrowed
|
||||||
|
|
||||||
# 查找是否已存在
|
# ★ 查找是否已存在:按 (session_id, uuid) 去重,不按 user_id 隔离
|
||||||
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first()
|
# 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
|
||||||
|
# 之前按 user_id 匹配导致每个用户各建一条 → 重复记录
|
||||||
|
draft = StocktakeDraft.query.filter_by(uuid=uuid, session_id=session_id).first()
|
||||||
|
|
||||||
if draft:
|
if draft:
|
||||||
# 如果已存在,更新数量和时间
|
# 如果已存在,更新数量和时间
|
||||||
@ -556,6 +615,8 @@ def add_draft():
|
|||||||
draft.diff_qty = quantity - adjusted_stock_qty
|
draft.diff_qty = quantity - adjusted_stock_qty
|
||||||
draft.source_table = source_table
|
draft.source_table = source_table
|
||||||
draft.stock_id = stock_id
|
draft.stock_id = stock_id
|
||||||
|
# 更新操作用户(最后操作者)
|
||||||
|
draft.user_id = user_id
|
||||||
# ★ 新增: 保存备注
|
# ★ 新增: 保存备注
|
||||||
if remark is not None:
|
if remark is not None:
|
||||||
draft.remark = remark.strip() if isinstance(remark, str) else remark
|
draft.remark = remark.strip() if isinstance(remark, str) else remark
|
||||||
@ -1545,14 +1606,18 @@ def get_draft_merged_list():
|
|||||||
@permission_required('inventory_stocktake')
|
@permission_required('inventory_stocktake')
|
||||||
def get_all_stocktake_items():
|
def get_all_stocktake_items():
|
||||||
"""
|
"""
|
||||||
获取所有应盘物资清单(库存 > 0 的物料)
|
获取应盘物资清单(库存 > 0 的物料)— ★ 分页返回,禁止全量
|
||||||
作为盘点基数,用于统计已盘/未盘数量
|
|
||||||
|
性能优化: 原来三次 .all() 全量加载 + 内存排序,库存量大时打开极慢。
|
||||||
|
改为: 分页返回 {items(当前页), total(总数), total_scanned(已盘数)}。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
keyword = request.args.get('keyword', '', type=str)
|
keyword = request.args.get('keyword', '', type=str).strip()
|
||||||
|
page = max(1, request.args.get('page', 1, type=int))
|
||||||
|
pageSize = min(200, max(1, request.args.get('pageSize', 50, type=int)))
|
||||||
|
|
||||||
all_items = []
|
all_items = []
|
||||||
|
|
||||||
# 1. 采购件
|
# 1. 采购件
|
||||||
buy_query = StockBuy.query.filter(StockBuy.stock_quantity > 0)
|
buy_query = StockBuy.query.filter(StockBuy.stock_quantity > 0)
|
||||||
if keyword:
|
if keyword:
|
||||||
@ -1568,7 +1633,6 @@ def get_all_stocktake_items():
|
|||||||
'id': item.id,
|
'id': item.id,
|
||||||
'sku': item.sku or '',
|
'sku': item.sku or '',
|
||||||
'barcode': item.barcode or '',
|
'barcode': item.barcode or '',
|
||||||
# ★ 安全提取批号/序列号:使用 getattr 降级
|
|
||||||
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
||||||
'material_name': item.base.name if item.base else '',
|
'material_name': item.base.name if item.base else '',
|
||||||
'spec_model': item.base.spec_model if item.base else '',
|
'spec_model': item.base.spec_model if item.base else '',
|
||||||
@ -1577,7 +1641,7 @@ def get_all_stocktake_items():
|
|||||||
'source_table': 'stock_buy',
|
'source_table': 'stock_buy',
|
||||||
'warehouse_location': item.warehouse_location or ''
|
'warehouse_location': item.warehouse_location or ''
|
||||||
})
|
})
|
||||||
|
|
||||||
# 2. 半成品
|
# 2. 半成品
|
||||||
if StockSemi:
|
if StockSemi:
|
||||||
semi_query = StockSemi.query.filter(StockSemi.stock_quantity > 0)
|
semi_query = StockSemi.query.filter(StockSemi.stock_quantity > 0)
|
||||||
@ -1594,7 +1658,6 @@ def get_all_stocktake_items():
|
|||||||
'id': item.id,
|
'id': item.id,
|
||||||
'sku': item.sku or '',
|
'sku': item.sku or '',
|
||||||
'barcode': item.barcode or '',
|
'barcode': item.barcode or '',
|
||||||
# ★ 安全提取批号/序列号:使用 getattr 降级
|
|
||||||
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
||||||
'material_name': item.base.name if item.base else '',
|
'material_name': item.base.name if item.base else '',
|
||||||
'spec_model': item.base.spec_model if item.base else '',
|
'spec_model': item.base.spec_model if item.base else '',
|
||||||
@ -1603,7 +1666,7 @@ def get_all_stocktake_items():
|
|||||||
'source_table': 'stock_semi',
|
'source_table': 'stock_semi',
|
||||||
'warehouse_location': item.warehouse_location or ''
|
'warehouse_location': item.warehouse_location or ''
|
||||||
})
|
})
|
||||||
|
|
||||||
# 3. 成品
|
# 3. 成品
|
||||||
if StockProduct:
|
if StockProduct:
|
||||||
product_query = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
product_query = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
||||||
@ -1620,7 +1683,6 @@ def get_all_stocktake_items():
|
|||||||
'id': item.id,
|
'id': item.id,
|
||||||
'sku': item.sku or '',
|
'sku': item.sku or '',
|
||||||
'barcode': item.barcode or '',
|
'barcode': item.barcode or '',
|
||||||
# ★ 安全提取批号/序列号:使用 getattr 降级 (成品无此字段则为空)
|
|
||||||
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
'batch_no': getattr(item, 'batch_number', None) or getattr(item, 'sn', None) or getattr(item, 'serial_number', None) or '',
|
||||||
'material_name': item.base.name if item.base else '',
|
'material_name': item.base.name if item.base else '',
|
||||||
'spec_model': item.base.spec_model if item.base else '',
|
'spec_model': item.base.spec_model if item.base else '',
|
||||||
@ -1629,18 +1691,35 @@ def get_all_stocktake_items():
|
|||||||
'source_table': 'stock_product',
|
'source_table': 'stock_product',
|
||||||
'warehouse_location': item.warehouse_location or ''
|
'warehouse_location': item.warehouse_location or ''
|
||||||
})
|
})
|
||||||
|
|
||||||
# 按 SKU 排序
|
# 按 SKU 排序
|
||||||
all_items.sort(key=lambda x: (x['sku'] or '').lower())
|
all_items.sort(key=lambda x: (x['sku'] or '').lower())
|
||||||
|
|
||||||
|
# ★ 分页切片
|
||||||
|
total = len(all_items)
|
||||||
|
start = (page - 1) * pageSize
|
||||||
|
paged = all_items[start:start + pageSize]
|
||||||
|
|
||||||
|
# 统计已盘数量(该 session 下已扫的)
|
||||||
|
session_id = request.args.get('session_id', '', type=str)
|
||||||
|
total_scanned = 0
|
||||||
|
if session_id:
|
||||||
|
from app.models.inbound.stocktake import StocktakeDraft
|
||||||
|
total_scanned = StocktakeDraft.query.filter(
|
||||||
|
StocktakeDraft.session_id == session_id
|
||||||
|
).count()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'code': 200,
|
'code': 200,
|
||||||
'data': {
|
'data': {
|
||||||
'items': all_items,
|
'items': paged,
|
||||||
'total': len(all_items)
|
'total': total,
|
||||||
|
'total_scanned': total_scanned,
|
||||||
|
'page': page,
|
||||||
|
'pageSize': pageSize
|
||||||
}
|
}
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|||||||
@ -157,6 +157,38 @@ def get_purchase_detail(purchase_id):
|
|||||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# 3.5 按批次查询采购申请(同一批提交的多条记录,批次=request_no去掉末尾流水号)
|
||||||
|
# GET /api/v1/purchase/batch/<batch_key>
|
||||||
|
# --------------------------------------------------------
|
||||||
|
@purchase_bp.route('/batch/<path:batch_key>', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_purchase_by_batch(batch_key):
|
||||||
|
"""按批次前缀(request_no 去掉末尾4位流水号)查询同一批的所有记录"""
|
||||||
|
try:
|
||||||
|
from app.models.purchase import PurchaseRequest
|
||||||
|
records = PurchaseRequest.query.filter(
|
||||||
|
PurchaseRequest.request_no.like(f"{batch_key}-%")
|
||||||
|
).order_by(PurchaseRequest.request_no.asc()).all()
|
||||||
|
|
||||||
|
user_id = get_current_user_id()
|
||||||
|
has_perm = _user_has_purchase_perm()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in records:
|
||||||
|
d = r.to_dict()
|
||||||
|
# 非本人且无权限时,价格字段剥离
|
||||||
|
if d['requester_id'] != user_id and not has_perm:
|
||||||
|
for k in ('unit_price', 'total_price', 'tax_rate'):
|
||||||
|
d.pop(k, None)
|
||||||
|
items.append(d)
|
||||||
|
|
||||||
|
return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_key': batch_key, 'items': items}}), 200
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
# 4. 审批采购申请
|
# 4. 审批采购申请
|
||||||
# PATCH /api/v1/purchase/<id>/approve
|
# PATCH /api/v1/purchase/<id>/approve
|
||||||
@ -205,6 +237,87 @@ def approve_purchase_request(purchase_id):
|
|||||||
# 5. 获取可选审批人列表
|
# 5. 获取可选审批人列表
|
||||||
# GET /api/v1/purchase/approvers
|
# GET /api/v1/purchase/approvers
|
||||||
# --------------------------------------------------------
|
# --------------------------------------------------------
|
||||||
|
@purchase_bp.route('/next-batch-seq', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
def get_next_batch_seq():
|
||||||
|
"""获取今日下一个采购批次号(前端提交一批时调用,同批所有行共享)"""
|
||||||
|
try:
|
||||||
|
next_seq = PurchaseService.get_next_batch_seq()
|
||||||
|
return jsonify({'code': 200, 'msg': '获取成功', 'data': {'batch_seq': next_seq}}), 200
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@purchase_bp.route('/batch-approve', methods=['POST'])
|
||||||
|
@jwt_required()
|
||||||
|
@permission_required('inbound_purchase:operation')
|
||||||
|
def batch_approve_purchase():
|
||||||
|
"""
|
||||||
|
批量审批采购申请(解决循环提交导致的多条记录需多次审批问题)
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"ids": [1, 2, 3], // 按 ID 列表审批
|
||||||
|
或
|
||||||
|
"batch_key": "PUR-20260831-1021", // 按批次前缀审批整批(同批流水号连续)
|
||||||
|
"action": "approve" | "reject",
|
||||||
|
"reject_reason": "驳回原因(reject 时必填)"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = get_current_user_id()
|
||||||
|
data = request.get_json() or {}
|
||||||
|
ids = data.get('ids', [])
|
||||||
|
batch_key = data.get('batch_key')
|
||||||
|
action = data.get('action', 'approve')
|
||||||
|
reject_reason = data.get('reject_reason')
|
||||||
|
|
||||||
|
# 若传 batch_key(request_no 去掉末尾流水号的前缀),查出该批次所有待审批记录
|
||||||
|
if batch_key:
|
||||||
|
from app.models.purchase import PurchaseRequest
|
||||||
|
records = PurchaseRequest.query.filter(
|
||||||
|
PurchaseRequest.request_no.like(f"{batch_key}-%"),
|
||||||
|
PurchaseRequest.status == 0
|
||||||
|
).all()
|
||||||
|
ids = [r.id for r in records]
|
||||||
|
if not ids:
|
||||||
|
return jsonify({'code': 400, 'msg': '该批次没有待审批的记录'}), 400
|
||||||
|
|
||||||
|
if not ids or not isinstance(ids, list):
|
||||||
|
return jsonify({'code': 400, 'msg': 'ids 或 batch_key 不能为空'}), 400
|
||||||
|
if action not in ('approve', 'reject'):
|
||||||
|
return jsonify({'code': 400, 'msg': '无效的审批操作'}), 400
|
||||||
|
if action == 'reject' and not reject_reason:
|
||||||
|
return jsonify({'code': 400, 'msg': '驳回时必须提供原因'}), 400
|
||||||
|
|
||||||
|
success_ids = []
|
||||||
|
error_items = []
|
||||||
|
for pid in ids:
|
||||||
|
try:
|
||||||
|
purchase = PurchaseService.approve_purchase_request(
|
||||||
|
purchase_id=pid,
|
||||||
|
user_id=user_id,
|
||||||
|
action=action,
|
||||||
|
reject_reason=reject_reason
|
||||||
|
)
|
||||||
|
success_ids.append(purchase.id)
|
||||||
|
except ValueError as e:
|
||||||
|
error_items.append({'id': pid, 'msg': str(e)})
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
error_items.append({'id': pid, 'msg': str(e)})
|
||||||
|
|
||||||
|
msg = f'批量审批完成: 成功 {len(success_ids)} 条'
|
||||||
|
if error_items:
|
||||||
|
msg += f', 失败 {len(error_items)} 条'
|
||||||
|
return jsonify({'code': 200, 'msg': msg, 'data': {'success_ids': success_ids, 'errors': error_items}}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({'code': 500, 'msg': f'服务器内部错误: {str(e)}'}), 500
|
||||||
|
|
||||||
|
|
||||||
@purchase_bp.route('/approvers', methods=['GET'])
|
@purchase_bp.route('/approvers', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def get_purchase_approvers():
|
def get_purchase_approvers():
|
||||||
|
|||||||
@ -9,16 +9,58 @@ from app.models.base import MaterialBase
|
|||||||
class PurchaseService:
|
class PurchaseService:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_request_no():
|
def generate_request_no(batch_seq: int = None):
|
||||||
"""生成采购单号: PUR-yyyyMMdd-HHmm-当日流水(4位)"""
|
"""
|
||||||
|
生成采购单号: PUR-yyyyMMdd-HHmm-批次-批内序号
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batch_seq: 今日第几次采购批次(前端提交一批时传入,同一批共享)
|
||||||
|
None 时回退为旧格式 PUR-日期-时间-流水
|
||||||
|
|
||||||
|
返回:
|
||||||
|
- 有 batch_seq: PUR-20260831-1021-0001-0001(批次号-批内序号)
|
||||||
|
- 无 batch_seq: PUR-20260831-1021-0001(旧格式兼容)
|
||||||
|
"""
|
||||||
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
|
now = datetime.now(beijing_tz)
|
||||||
|
date_str = now.strftime('%Y%m%d')
|
||||||
|
time_str = now.strftime('%H%M')
|
||||||
|
|
||||||
|
if batch_seq is not None:
|
||||||
|
# 批次前缀: PUR-日期-时间-批次
|
||||||
|
batch_prefix = f"PUR-{date_str}-{time_str}-{batch_seq:04d}"
|
||||||
|
# 批内序号: 该批次前缀下的记录数 + 1
|
||||||
|
item_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
|
||||||
|
.filter(PurchaseRequest.request_no.like(f"{batch_prefix}-%")).scalar()
|
||||||
|
return f"{batch_prefix}-{(item_count + 1):04d}"
|
||||||
|
|
||||||
|
# 旧格式兼容: PUR-日期-时间-流水
|
||||||
|
prefix = f"PUR-{date_str}-{time_str}-"
|
||||||
|
existing_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
|
||||||
|
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
|
||||||
|
return f"{prefix}{(existing_count + 1):04d}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_next_batch_seq():
|
||||||
|
"""
|
||||||
|
返回今日下一个采购批次号(今日第几次)
|
||||||
|
统计今日已有的不同批次(单号第4段)数量,+1
|
||||||
|
"""
|
||||||
beijing_tz = timezone(timedelta(hours=8))
|
beijing_tz = timezone(timedelta(hours=8))
|
||||||
now = datetime.now(beijing_tz)
|
now = datetime.now(beijing_tz)
|
||||||
date_str = now.strftime('%Y%m%d')
|
date_str = now.strftime('%Y%m%d')
|
||||||
time_str = now.strftime('%H%M')
|
time_str = now.strftime('%H%M')
|
||||||
prefix = f"PUR-{date_str}-{time_str}-"
|
prefix = f"PUR-{date_str}-{time_str}-"
|
||||||
existing_count = db.session.query(func.count(func.distinct(PurchaseRequest.request_no))) \
|
|
||||||
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).scalar()
|
# 查询今日所有单号,提取第4段(批次号)去重
|
||||||
return f"{prefix}{(existing_count + 1):04d}"
|
rows = db.session.query(PurchaseRequest.request_no) \
|
||||||
|
.filter(PurchaseRequest.request_no.like(f"{prefix}%")).all()
|
||||||
|
batch_seqs = set()
|
||||||
|
for (rn,) in rows:
|
||||||
|
parts = rn.split('-')
|
||||||
|
if len(parts) >= 4:
|
||||||
|
batch_seqs.add(parts[3])
|
||||||
|
return len(batch_seqs) + 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def auto_fill_from_material(keyword: str):
|
def auto_fill_from_material(keyword: str):
|
||||||
@ -47,7 +89,8 @@ class PurchaseService:
|
|||||||
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
|
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
|
||||||
unit_price, total_price, approver_id, base_id (可选)
|
unit_price, total_price, approver_id, base_id (可选)
|
||||||
"""
|
"""
|
||||||
request_no = PurchaseService.generate_request_no()
|
batch_seq = data.get('batch_seq')
|
||||||
|
request_no = PurchaseService.generate_request_no(batch_seq=batch_seq)
|
||||||
|
|
||||||
purchase_date = data.get('purchase_date')
|
purchase_date = data.get('purchase_date')
|
||||||
if isinstance(purchase_date, str):
|
if isinstance(purchase_date, str):
|
||||||
|
|||||||
@ -19,6 +19,15 @@ export function getStockList(params: { page?: number; pageSize?: number; keyword
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 扫码精确匹配库存(盘库/出库/借库通用,替代模糊搜索漏匹配问题)
|
||||||
|
export function scanStockByBarcode(barcode: string) {
|
||||||
|
return request({
|
||||||
|
url: '/v1/inbound/stock/scan',
|
||||||
|
method: 'get',
|
||||||
|
params: { barcode }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 打印出库选单
|
// 打印出库选单
|
||||||
// 修改后: 去掉开头的 /api
|
// 修改后: 去掉开头的 /api
|
||||||
export function printSelectionList(items: any[]) {
|
export function printSelectionList(items: any[]) {
|
||||||
|
|||||||
@ -87,6 +87,36 @@ export function approvePurchase(id: number, data: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量审批采购申请(循环提交产生的多条记录可一次审批)
|
||||||
|
export function batchApprovePurchase(data: {
|
||||||
|
ids?: number[]
|
||||||
|
batch_key?: string
|
||||||
|
action: 'approve' | 'reject'
|
||||||
|
reject_reason?: string
|
||||||
|
}) {
|
||||||
|
return request({
|
||||||
|
url: '/purchase/batch-approve',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按批次前缀查询同批次的采购申请记录(批次=request_no去掉末尾流水号)
|
||||||
|
export function getPurchaseByBatch(batchKey: string) {
|
||||||
|
return request({
|
||||||
|
url: `/purchase/batch/${encodeURIComponent(batchKey)}`,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取今日下一个采购批次号(提交一批时调用,同批所有行共享)
|
||||||
|
export function getNextBatchSeq() {
|
||||||
|
return request({
|
||||||
|
url: '/purchase/next-batch-seq',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 获取可选审批人列表
|
// 获取可选审批人列表
|
||||||
export function getPurchaseApprovers() {
|
export function getPurchaseApprovers() {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@ -819,7 +819,7 @@ const openBomSelect = async () => {
|
|||||||
const rec = bomShortageRecords.value[no]
|
const rec = bomShortageRecords.value[no]
|
||||||
return `• ${no}(${rec.items.length} 种缺货,${rec.time})`
|
return `• ${no}(${rec.items.length} 种缺货,${rec.time})`
|
||||||
}).join('\n') +
|
}).join('\n') +
|
||||||
`\n\n建议先补货后再出库,避免漏出。是否查看?`,
|
`\n\n建议先补货后再出库,避免漏出。`,
|
||||||
'存在未完成出库',
|
'存在未完成出库',
|
||||||
{ confirmButtonText: '知道了', cancelButtonText: '忽略', type: 'warning' }
|
{ confirmButtonText: '知道了', cancelButtonText: '忽略', type: 'warning' }
|
||||||
).catch(() => {})
|
).catch(() => {})
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
<el-radio-button :label="1">已通过</el-radio-button>
|
<el-radio-button :label="1">已通过</el-radio-button>
|
||||||
<el-radio-button :label="2">已驳回</el-radio-button>
|
<el-radio-button :label="2">已驳回</el-radio-button>
|
||||||
<el-radio-button :label="3">已完成</el-radio-button>
|
<el-radio-button :label="3">已完成</el-radio-button>
|
||||||
|
<el-radio-button :label="4">已完结</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||||
</div>
|
</div>
|
||||||
@ -133,6 +134,19 @@
|
|||||||
驳回
|
驳回
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- ★ 已通过的申请单:库管/主管可手动完结 -->
|
||||||
|
<template v-else-if="row.status === 1">
|
||||||
|
<el-button
|
||||||
|
v-if="canCloseRequest"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
size="small"
|
||||||
|
:loading="row._closing"
|
||||||
|
@click="handleCloseRequest(row)"
|
||||||
|
>
|
||||||
|
完结
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
<span v-else style="color: #c0c4cc;">-</span>
|
<span v-else style="color: #c0c4cc;">-</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -178,10 +192,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Refresh, Warning } from '@element-plus/icons-vue'
|
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { getApprovalRequestList, approveRequest } from '@/api/outbound'
|
import { getApprovalRequestList, approveRequest, closeRequest } from '@/api/outbound'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@ -207,14 +221,14 @@ const userNameCache = ref<Record<number, string>>({})
|
|||||||
// --- 工具函数 ---
|
// --- 工具函数 ---
|
||||||
const statusText = (status: number) => {
|
const statusText = (status: number) => {
|
||||||
const map: Record<number, string> = {
|
const map: Record<number, string> = {
|
||||||
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
|
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'
|
||||||
}
|
}
|
||||||
return map[status] ?? '-'
|
return map[status] ?? '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusTagType = (status: number) => {
|
const statusTagType = (status: number) => {
|
||||||
const map: Record<number, string> = {
|
const map: Record<number, string> = {
|
||||||
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
|
0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info'
|
||||||
}
|
}
|
||||||
return map[status] ?? 'info'
|
return map[status] ?? 'info'
|
||||||
}
|
}
|
||||||
@ -224,6 +238,40 @@ const getApplicantName = (id: number | null) => {
|
|||||||
return userNameCache.value[id] ?? `用户 #${id}`
|
return userNameCache.value[id] ?? `用户 #${id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 完结权限:超级管理员 / 拥有出库操作权限(库管)或审批操作权限(主管)
|
||||||
|
const canCloseRequest = computed(() =>
|
||||||
|
userStore.role === 'SUPER_ADMIN' ||
|
||||||
|
userStore.hasPermission('outbound_create:operation') ||
|
||||||
|
userStore.hasPermission('outbound_approval:operation') ||
|
||||||
|
userStore.username === 'IRIS'
|
||||||
|
)
|
||||||
|
|
||||||
|
// ★ 完结已通过的审批单
|
||||||
|
const handleCloseRequest = async (row: any) => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定强制完结审批单【${row.request_no}】吗?\n\n` +
|
||||||
|
`此操作将使该单从「已通过」列表中移除,且不可恢复。\n` +
|
||||||
|
`若该单已部分出库,请确认无需再关联此单。`,
|
||||||
|
'⚠️ 强制完结确认',
|
||||||
|
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return // 用户取消
|
||||||
|
}
|
||||||
|
|
||||||
|
row._closing = true
|
||||||
|
try {
|
||||||
|
await closeRequest(row.id)
|
||||||
|
ElMessage.success(`审批单 ${row.request_no} 已完结`)
|
||||||
|
fetchData()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err?.msg || err?.message || '完结失败')
|
||||||
|
} finally {
|
||||||
|
row._closing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getApproverName = (id: number | null) => {
|
const getApproverName = (id: number | null) => {
|
||||||
if (!id) return '-'
|
if (!id) return '-'
|
||||||
return userNameCache.value[id] ?? `用户 #${id}`
|
return userNameCache.value[id] ?? `用户 #${id}`
|
||||||
|
|||||||
@ -46,12 +46,6 @@
|
|||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
|
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
|
||||||
<div v-if="selectedRequest && userStore.hasPermission('outbound_create:operation')" style="margin-top: 8px; display: flex; align-items: center; gap: 8px;">
|
|
||||||
<el-button type="danger" plain size="small" :loading="closingRequest" @click="handleCloseRequest">
|
|
||||||
强制完结此单
|
|
||||||
</el-button>
|
|
||||||
<span style="color: #F56C6C; font-size: 12px;">作废后该单将从下拉列表移除,出库记录将无法关联此单</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ★ 按单出库:计划清单预览 -->
|
<!-- ★ 按单出库:计划清单预览 -->
|
||||||
@ -80,6 +74,27 @@
|
|||||||
|
|
||||||
<div class="scan-section">
|
<div class="scan-section">
|
||||||
|
|
||||||
|
<!-- ★ 扫码进度条 -->
|
||||||
|
<div v-if="selectedRequest && scanTotalQty > 0" class="scan-progress">
|
||||||
|
<div class="progress-info">
|
||||||
|
<span>扫码进度</span>
|
||||||
|
<el-tag type="success" size="small">{{ scanScannedTypes }}/{{ scanTotalTypes }} 种</el-tag>
|
||||||
|
<el-tag type="primary" size="small">{{ scanScannedQty }}/{{ scanTotalQty }} 件</el-tag>
|
||||||
|
<el-button
|
||||||
|
v-if="unscannedCount > 0"
|
||||||
|
type="warning" plain size="small" style="margin-left: auto;"
|
||||||
|
@click="showUnscannedDialog = true"
|
||||||
|
>
|
||||||
|
未扫清单 ({{ unscannedCount }})
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="scanTotalQty > 0 ? Math.min(100, Math.round(scanScannedQty / scanTotalQty * 100)) : 0"
|
||||||
|
:stroke-width="8"
|
||||||
|
:color="scanScannedQty >= scanTotalQty ? '#67C23A' : '#409EFF'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="!selectedRequestId">
|
<template v-if="!selectedRequestId">
|
||||||
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||||
@ -247,6 +262,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ★ 未扫清单弹窗 -->
|
||||||
|
<el-dialog v-model="showUnscannedDialog" title="未扫清单" width="600px" destroy-on-close>
|
||||||
|
<el-alert
|
||||||
|
v-if="unscannedList.length > 0"
|
||||||
|
:title="`以下 ${unscannedList.length} 种物料还未扫满,请补扫:`"
|
||||||
|
type="warning" :closable="false" show-icon style="margin-bottom: 12px;"
|
||||||
|
/>
|
||||||
|
<el-table :data="unscannedList" border size="small" max-height="400">
|
||||||
|
<el-table-column type="index" label="#" width="40" align="center" />
|
||||||
|
<el-table-column prop="name" label="物料名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="spec" label="规格型号" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="计划" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ row.planQty }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已扫" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color: #67C23A;">{{ row.scannedQty }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="待扫" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color: #F56C6C; font-weight: bold;">{{ row.remaining }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showUnscannedDialog = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="showUnscannedDialog = false; showCamera = true">去扫码</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showSignatureDialog"
|
v-model="showSignatureDialog"
|
||||||
fullscreen
|
fullscreen
|
||||||
@ -290,7 +336,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
|||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||||
import QrScanner from '@/components/QrScanner/index.vue'
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, closeRequest } from '@/api/outbound'
|
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
|
||||||
import { uploadFile } from '@/api/common/upload'
|
import { uploadFile } from '@/api/common/upload'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@ -309,7 +355,6 @@ const formRef = ref()
|
|||||||
const approvalRequests = ref<any[]>([])
|
const approvalRequests = ref<any[]>([])
|
||||||
const selectedRequest = ref<any>(null)
|
const selectedRequest = ref<any>(null)
|
||||||
const requestsLoading = ref(false)
|
const requestsLoading = ref(false)
|
||||||
const closingRequest = ref(false)
|
|
||||||
|
|
||||||
// 签名相关
|
// 签名相关
|
||||||
const showSignatureDialog = ref(false)
|
const showSignatureDialog = ref(false)
|
||||||
@ -357,6 +402,42 @@ const selectedRequestId = computed({
|
|||||||
|
|
||||||
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
|
const plannedItems = computed(() => selectedRequest.value?.items ?? [])
|
||||||
|
|
||||||
|
// ★ 扫码进度:总需扫数(计划数量总和)/ 已扫数(购物车数量总和)
|
||||||
|
const scanTotalQty = computed(() =>
|
||||||
|
plannedItems.value.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0)
|
||||||
|
)
|
||||||
|
const scanScannedQty = computed(() =>
|
||||||
|
cartItems.value.reduce((sum, it) => sum + (Number(it.out_quantity) || 0), 0)
|
||||||
|
)
|
||||||
|
// 已扫物品种类数 / 计划物品种类数
|
||||||
|
const scanScannedTypes = computed(() => cartItems.value.length)
|
||||||
|
const scanTotalTypes = computed(() => plannedItems.value.length)
|
||||||
|
|
||||||
|
// ★ 未扫清单:计划中还没扫满的物料
|
||||||
|
const unscannedList = computed(() => {
|
||||||
|
return plannedItems.value.map(plan => {
|
||||||
|
const planQty = Number(plan.quantity) || 0
|
||||||
|
// 已扫数量(按名称+规格匹配)
|
||||||
|
const scannedQty = cartItems.value
|
||||||
|
.filter(ci => {
|
||||||
|
const ciName = (ci.name || '').trim()
|
||||||
|
const ciSpec = (ci.spec_model || ci.standard || '').trim()
|
||||||
|
return ciName === (plan.name || '').trim() &&
|
||||||
|
ciSpec === (plan.spec_model || '').trim()
|
||||||
|
})
|
||||||
|
.reduce((sum, ci) => sum + (Number(ci.out_quantity) || 0), 0)
|
||||||
|
return {
|
||||||
|
name: plan.name || '',
|
||||||
|
spec: plan.spec_model || '',
|
||||||
|
planQty,
|
||||||
|
scannedQty,
|
||||||
|
remaining: Math.max(0, planQty - scannedQty)
|
||||||
|
}
|
||||||
|
}).filter(item => item.remaining > 0) // 只保留未扫满的
|
||||||
|
})
|
||||||
|
const unscannedCount = computed(() => unscannedList.value.length)
|
||||||
|
const showUnscannedDialog = ref(false)
|
||||||
|
|
||||||
// ★ 加载已审批通过的申请单
|
// ★ 加载已审批通过的申请单
|
||||||
const loadApprovalRequests = async () => {
|
const loadApprovalRequests = async () => {
|
||||||
requestsLoading.value = true
|
requestsLoading.value = true
|
||||||
@ -387,37 +468,6 @@ const handleRequestChange = (val: number | null) => {
|
|||||||
signaturePreviewUrl.value = ''
|
signaturePreviewUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// ★ 强制完结当前选中的申请单(作废,状态 1-已通过 → 4-已完结)
|
|
||||||
const handleCloseRequest = async () => {
|
|
||||||
if (!selectedRequest.value) return ElMessage.warning('请先选择要完结的审批单')
|
|
||||||
const req = selectedRequest.value
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(
|
|
||||||
`确定强制完结审批单【${req.request_no}】吗?\n\n` +
|
|
||||||
`此操作将使该单从「已通过」列表中移除,且不可恢复。\n` +
|
|
||||||
`若该单已部分出库,请确认无需再关联此单。`,
|
|
||||||
'⚠️ 强制完结确认',
|
|
||||||
{ confirmButtonText: '确认完结', cancelButtonText: '取消', type: 'warning' }
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
return // 用户取消
|
|
||||||
}
|
|
||||||
|
|
||||||
closingRequest.value = true
|
|
||||||
try {
|
|
||||||
await closeRequest(req.id)
|
|
||||||
ElMessage.success(`审批单 ${req.request_no} 已完结`)
|
|
||||||
// 完结后刷新列表并清空当前选择
|
|
||||||
selectedRequest.value = null
|
|
||||||
await loadApprovalRequests()
|
|
||||||
} catch (err: any) {
|
|
||||||
ElMessage.error(err?.msg || err?.message || '完结失败')
|
|
||||||
} finally {
|
|
||||||
closingRequest.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ★ 按单出库模式:校验扫码是否在计划内
|
// ★ 按单出库模式:校验扫码是否在计划内
|
||||||
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
|
const validateAgainstPlan = (scannedName: string, scannedSpec: string, scannedQty: number): string | null => {
|
||||||
const normalizedName = scannedName.trim()
|
const normalizedName = scannedName.trim()
|
||||||
@ -534,6 +584,17 @@ const handleManualInput = async () => {
|
|||||||
|
|
||||||
const maxQty = parseFloat(item.available_quantity)
|
const maxQty = parseFloat(item.available_quantity)
|
||||||
if (item.out_quantity < maxQty) {
|
if (item.out_quantity < maxQty) {
|
||||||
|
// ★ 重复扫码:弹窗确认是否 +1,防止手滑重复扫
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`【${item.name} × ${item.spec_model}】已在清单中(当前已扫 ${item.out_quantity} 个)。\n\n确认再 +1 吗?`,
|
||||||
|
'重复扫码确认',
|
||||||
|
{ confirmButtonText: '确认 +1', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
barcodeInput.value = ''
|
||||||
|
return // 用户取消,不加
|
||||||
|
}
|
||||||
item.out_quantity++
|
item.out_quantity++
|
||||||
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||||
if (navigator.vibrate) navigator.vibrate(50)
|
if (navigator.vibrate) navigator.vibrate(50)
|
||||||
@ -837,6 +898,21 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* 扫码区(卡片内触发器) */
|
/* 扫码区(卡片内触发器) */
|
||||||
.scan-section { margin-bottom: 20px; }
|
.scan-section { margin-bottom: 20px; }
|
||||||
|
.scan-progress {
|
||||||
|
background: #f5f7fa;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -240,6 +240,7 @@
|
|||||||
border
|
border
|
||||||
row-key="uniqueKey"
|
row-key="uniqueKey"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
:row-class-name="(row) => row._justScanned ? 'just-scanned-row' : ''"
|
||||||
>
|
>
|
||||||
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="material_name" label="名称" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="material_name" label="名称" min-width="120" show-overflow-tooltip />
|
||||||
@ -413,7 +414,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { getStockList, getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity } from '@/api/inbound/stock'
|
import { getAllStocktakeItems, getDraftMergedList, updateStocktakeQuantity, scanStockByBarcode } from '@/api/inbound/stock'
|
||||||
import QrScanner from '@/components/QrScanner/index.vue'
|
import QrScanner from '@/components/QrScanner/index.vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
import { Search, VideoPlay, VideoPause, List, Checked, Download, ArrowRight, Cloudy, Edit, EditPen, CameraFilled, Close, WarningFilled } from '@element-plus/icons-vue'
|
||||||
@ -494,14 +495,19 @@ const listTotalFiltered = ref(0) // 过滤后的总数
|
|||||||
const currentSessionId = ref<string>('')
|
const currentSessionId = ref<string>('')
|
||||||
|
|
||||||
// 获取应盘物资清单(盘点基数)
|
// 获取应盘物资清单(盘点基数)
|
||||||
const fetchAllStockItems = async () => {
|
const fetchAllStockItems = async (page = 1) => {
|
||||||
try {
|
try {
|
||||||
// ★ 必须传递 session_id,用于隔离会话
|
// ★ 分页拉取:默认每页 200 条,避免全量加载卡顿
|
||||||
const res: any = await getAllStocktakeItems({ session_id: currentSessionId.value })
|
const res: any = await getAllStocktakeItems({
|
||||||
|
session_id: currentSessionId.value,
|
||||||
|
page,
|
||||||
|
pageSize: 200
|
||||||
|
})
|
||||||
if (res && res.code === 200) {
|
if (res && res.code === 200) {
|
||||||
allStockItems.value = res.data.items || []
|
allStockItems.value = res.data.items || []
|
||||||
// ★ 使用返回的 total 获取真实总数,而不是受限的数组长度
|
// ★ 使用返回的 total 获取真实总数,而不是数组长度
|
||||||
totalStockCount.value = res.data.total || allStockItems.value.length
|
totalStockCount.value = res.data.total || allStockItems.value.length
|
||||||
|
totalScannedCount.value = res.data.total_scanned || 0
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取应盘物资清单失败', e)
|
console.error('获取应盘物资清单失败', e)
|
||||||
@ -511,11 +517,9 @@ const fetchAllStockItems = async () => {
|
|||||||
// 过滤后的列表数据(直接使用已过滤的 listData)
|
// 过滤后的列表数据(直接使用已过滤的 listData)
|
||||||
const filteredListData = computed(() => listData.value)
|
const filteredListData = computed(() => listData.value)
|
||||||
|
|
||||||
// 统计信息:从全量数据中计算(脱离视图依赖)
|
// 统计信息:用后端返回的真实总数(不依赖全量数组长度)
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const total = allStockItems.value.length
|
const total = totalStockCount.value || allStockItems.value.length
|
||||||
if (total === 0) return { total: 0, scanned: 0, varianceItems: 0 }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total,
|
total,
|
||||||
scanned: totalScannedCount.value,
|
scanned: totalScannedCount.value,
|
||||||
@ -736,58 +740,54 @@ const onScanSuccess = async (code: string) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实时查询后端匹配
|
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find(),避免漏匹配)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await getStockList({
|
const res: any = await scanStockByBarcode(trimCode)
|
||||||
page: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
keyword: trimCode
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
|
if (!res || !res.data) {
|
||||||
ElMessage.error(`未找到该物料库存: ${trimCode}`)
|
|
||||||
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找匹配的物料
|
|
||||||
const foundItem = res.data.list.find((i: any) =>
|
|
||||||
i.uuid === trimCode || i.sku === trimCode || i.barcode === trimCode || i.bar_code === trimCode
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!foundItem) {
|
|
||||||
ElMessage.error(`未找到该物料库存: ${trimCode}`)
|
ElMessage.error(`未找到该物料库存: ${trimCode}`)
|
||||||
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
|
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const foundItem = res.data
|
||||||
if (navigator.vibrate) navigator.vibrate(100)
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
|
|
||||||
// 关闭全屏扫码,弹出填数对话框
|
// 关闭全屏扫码,弹出填数对话框
|
||||||
showCamera.value = false
|
showCamera.value = false
|
||||||
|
|
||||||
// 处理数据格式
|
// 处理数据格式(后端已返回 source_table / stock_id / name / standard)
|
||||||
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
||||||
const type = foundItem.stock_type || foundItem.type || 'material'
|
const type = foundItem.stock_type || foundItem.type || 'material'
|
||||||
|
const sourceTable = foundItem.source_table || typeToSourceTable(type)
|
||||||
|
const stockId = foundItem.stock_id || foundItem.id
|
||||||
const item: StockItem = {
|
const item: StockItem = {
|
||||||
...foundItem,
|
...foundItem,
|
||||||
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
|
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
|
||||||
standard: foundItem.spec_model || foundItem.standard || foundItem.model || '',
|
standard: foundItem.standard || foundItem.spec_model || foundItem.model || '',
|
||||||
sku: foundItem.sku || '',
|
sku: foundItem.sku || '',
|
||||||
uuid: foundItem.uuid || foundItem.sku || '',
|
uuid: foundItem.uuid || foundItem.sku || '',
|
||||||
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
||||||
qty_stock: stock,
|
qty_stock: stock,
|
||||||
qty_actual: 1,
|
qty_actual: 1,
|
||||||
scanned: true,
|
scanned: true,
|
||||||
uniqueKey: `${type}_${foundItem.id}`,
|
uniqueKey: `${type}_${stockId}`,
|
||||||
source_table: typeToSourceTable(type),
|
source_table: sourceTable,
|
||||||
stock_id: foundItem.id
|
stock_id: stockId
|
||||||
}
|
}
|
||||||
|
|
||||||
openQtyDialog(item)
|
openQtyDialog(item)
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
ElMessage.error('查询库存失败')
|
// ★ 友好错误提示:404=条码不在系统中,其他=网络/服务器错误
|
||||||
|
const status = e?.response?.status || e?.status
|
||||||
|
if (status === 404) {
|
||||||
|
ElMessage.warning(`条码 [${trimCode}] 未在库存系统中找到,请确认条码是否正确`)
|
||||||
|
} else {
|
||||||
|
const msg = e?.msg || e?.message || '查询库存失败'
|
||||||
|
ElMessage.error(`查询失败: ${msg}`)
|
||||||
|
}
|
||||||
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -820,63 +820,105 @@ const handleManualInput = async () => {
|
|||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await getStockList({
|
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find())
|
||||||
page: 1,
|
const res: any = await scanStockByBarcode(code)
|
||||||
pageSize: 10,
|
|
||||||
keyword: code
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
|
if (!res || !res.data) {
|
||||||
ElMessage.error(`未找到该物料库存: ${code}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const foundItem = res.data.list.find((i: any) =>
|
|
||||||
i.uuid === code || i.sku === code || i.barcode === code || i.bar_code === code
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!foundItem) {
|
|
||||||
ElMessage.error(`未找到该物料库存: ${code}`)
|
ElMessage.error(`未找到该物料库存: ${code}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const foundItem = res.data
|
||||||
if (navigator.vibrate) navigator.vibrate(100)
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
|
|
||||||
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
||||||
const type = foundItem.stock_type || foundItem.type || 'material'
|
const type = foundItem.stock_type || foundItem.type || 'material'
|
||||||
|
const sourceTable = foundItem.source_table || typeToSourceTable(type)
|
||||||
|
const stockId = foundItem.stock_id || foundItem.id
|
||||||
const item: StockItem = {
|
const item: StockItem = {
|
||||||
...foundItem,
|
...foundItem,
|
||||||
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
|
name: foundItem.name || foundItem.material_name || foundItem.product_name || '未知物品',
|
||||||
standard: foundItem.spec_model || foundItem.standard || foundItem.model || '',
|
standard: foundItem.standard || foundItem.spec_model || foundItem.model || '',
|
||||||
sku: foundItem.sku || '',
|
sku: foundItem.sku || '',
|
||||||
uuid: foundItem.uuid || foundItem.sku || '',
|
uuid: foundItem.uuid || foundItem.sku || '',
|
||||||
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
||||||
qty_stock: stock,
|
qty_stock: stock,
|
||||||
qty_actual: 1,
|
qty_actual: 1,
|
||||||
scanned: true,
|
scanned: true,
|
||||||
uniqueKey: `${type}_${foundItem.id}`,
|
uniqueKey: `${type}_${stockId}`,
|
||||||
source_table: typeToSourceTable(type),
|
source_table: sourceTable,
|
||||||
stock_id: foundItem.id
|
stock_id: stockId
|
||||||
}
|
}
|
||||||
|
|
||||||
openQtyDialog(item)
|
openQtyDialog(item)
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
ElMessage.error('查询库存失败')
|
// ★ 友好错误提示:404=条码不在系统中,其他=网络/服务器错误
|
||||||
|
const status = e?.response?.status || e?.status
|
||||||
|
if (status === 404) {
|
||||||
|
ElMessage.warning(`条码 [${code}] 未在库存系统中找到,请确认条码是否正确`)
|
||||||
|
} else {
|
||||||
|
const msg = e?.msg || e?.message || '查询库存失败'
|
||||||
|
ElMessage.error(`查询失败: ${msg}`)
|
||||||
|
}
|
||||||
|
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||||
console.error(e)
|
console.error(e)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openQtyDialog = (item: StockItem) => {
|
const openQtyDialog = async (item: StockItem) => {
|
||||||
currentItem.value = item
|
// ★ 重复扫码检测:精确查该物料是否已在本 session 盘过
|
||||||
inputQty.value = item.scanned ? item.qty_actual : 1
|
let existingQty: number | null = null
|
||||||
showQtyDialog.value = true
|
try {
|
||||||
|
const uuidForQuery = item.uuid || item.sku || ''
|
||||||
|
const dupRes: any = await request({
|
||||||
|
url: '/v1/inbound/stock/draft/list',
|
||||||
|
method: 'get',
|
||||||
|
params: { session_id: currentSessionId.value, uuid: uuidForQuery, limit: 1 }
|
||||||
|
})
|
||||||
|
const dupItems = dupRes?.items || dupRes?.data?.items || []
|
||||||
|
if (dupItems.length > 0 && dupItems[0].quantity !== undefined) {
|
||||||
|
existingQty = Number(dupItems[0].quantity)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 查询失败不阻断,视为未盘过
|
||||||
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
if (existingQty !== null) {
|
||||||
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
|
// 已盘过 → 提示当前值,询问是否修改
|
||||||
if(inputEl) inputEl.focus()
|
try {
|
||||||
})
|
await ElMessageBox.confirm(
|
||||||
|
`⚠️ 【${item.name} × ${item.standard}】已盘过,当前实盘为 ${existingQty}。\n\n是否修改本次盘点的数量?`,
|
||||||
|
'重复扫码提示',
|
||||||
|
{
|
||||||
|
confirmButtonText: '修改数量',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// 用户确认修改 → 预填当前值,弹输入框
|
||||||
|
currentItem.value = item
|
||||||
|
inputQty.value = existingQty || 1
|
||||||
|
showQtyDialog.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
|
||||||
|
if(inputEl) inputEl.focus()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
// 用户取消,不修改
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 未盘过 → 正常弹输入框
|
||||||
|
currentItem.value = item
|
||||||
|
inputQty.value = item.scanned ? item.qty_actual : 1
|
||||||
|
showQtyDialog.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
|
||||||
|
if(inputEl) inputEl.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleManualConfirm = () => {
|
const handleManualConfirm = () => {
|
||||||
@ -901,12 +943,35 @@ const syncToBackend = (uuid: string, quantity: number, remark: string) => {
|
|||||||
syncStatus.value = 'success'
|
syncStatus.value = 'success'
|
||||||
// 静默刷新统计数字
|
// 静默刷新统计数字
|
||||||
fetchInventoryList(true)
|
fetchInventoryList(true)
|
||||||
|
// ★ 扫码成功:该物品置顶到当前视图第一行并高亮
|
||||||
|
pinScannedItem(uuid)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
syncStatus.value = 'failed'
|
syncStatus.value = 'failed'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ★ 扫码成功置顶高亮:把刚扫的物品移到列表顶部,方便确认
|
||||||
|
const pinScannedItem = (uuid: string) => {
|
||||||
|
// 1. 置顶到主表格(merged-list 当前页 listData)
|
||||||
|
const listIdx = listData.value.findIndex(it =>
|
||||||
|
(it.uuid && it.uuid === uuid) || (it.sku && it.sku === uuid) ||
|
||||||
|
(it.barcode && it.barcode === uuid)
|
||||||
|
)
|
||||||
|
if (listIdx > -1) {
|
||||||
|
const item = listData.value.splice(listIdx, 1)[0]
|
||||||
|
item._justScanned = true
|
||||||
|
listData.value.unshift(item)
|
||||||
|
setTimeout(() => { item._justScanned = false }, 3000)
|
||||||
|
}
|
||||||
|
// 2. 同步置顶到 allStockItems(应盘基数)
|
||||||
|
const idx = allStockItems.value.findIndex(it => it.uuid === uuid || it.sku === uuid)
|
||||||
|
if (idx > -1) {
|
||||||
|
const item = allStockItems.value.splice(idx, 1)[0]
|
||||||
|
allStockItems.value.unshift(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateAndSync = async (item: StockItem, quantity: number, remark: string = '') => {
|
const updateAndSync = async (item: StockItem, quantity: number, remark: string = '') => {
|
||||||
// 直接保存到后端,不使用本地缓存
|
// 直接保存到后端,不使用本地缓存
|
||||||
item.scanned = true
|
item.scanned = true
|
||||||
@ -1257,6 +1322,14 @@ const goToVarianceReview = () => {
|
|||||||
}
|
}
|
||||||
.drawer-footer { margin-top: 10px; flex-shrink: 0; }
|
.drawer-footer { margin-top: 10px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* ★ 扫码成功置顶行高亮(浅绿背景) */
|
||||||
|
:deep(.just-scanned-row) {
|
||||||
|
background: #f0f9eb !important;
|
||||||
|
}
|
||||||
|
:deep(.just-scanned-row td) {
|
||||||
|
background: #f0f9eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
.qty-content { padding: 10px 0; }
|
.qty-content { padding: 10px 0; }
|
||||||
.item-info { background: #f5f7fa; padding: 10px; border-radius: 6px; margin-bottom: 20px; }
|
.item-info { background: #f5f7fa; padding: 10px; border-radius: 6px; margin-bottom: 20px; }
|
||||||
.info-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 14px; }
|
.info-row { display: flex; justify-content: space-between; margin-bottom: 8px; font-size: 14px; }
|
||||||
|
|||||||
@ -64,6 +64,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="scan-section">
|
<div class="scan-section">
|
||||||
|
|
||||||
|
<!-- ★ 扫码进度条 -->
|
||||||
|
<div v-if="selectedApproval && scanTotalQty > 0" class="scan-progress">
|
||||||
|
<div class="progress-info">
|
||||||
|
<span>扫码进度</span>
|
||||||
|
<el-tag type="success" size="small">{{ scanScannedTypes }}/{{ scanTotalTypes }} 种</el-tag>
|
||||||
|
<el-tag type="primary" size="small">{{ scanScannedQty }}/{{ scanTotalQty }} 件</el-tag>
|
||||||
|
<el-button
|
||||||
|
v-if="unscannedCount > 0"
|
||||||
|
type="warning" plain size="small" style="margin-left: auto;"
|
||||||
|
@click="showUnscannedDialog = true"
|
||||||
|
>
|
||||||
|
未扫清单 ({{ unscannedCount }})
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="scanTotalQty > 0 ? Math.min(100, Math.round(scanScannedQty / scanTotalQty * 100)) : 0"
|
||||||
|
:stroke-width="8"
|
||||||
|
:color="scanScannedQty >= scanTotalQty ? '#67C23A' : '#409EFF'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="!selectedApprovalId">
|
<template v-if="!selectedApprovalId">
|
||||||
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||||
@ -224,6 +246,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ★ 未扫清单弹窗 -->
|
||||||
|
<el-dialog v-model="showUnscannedDialog" title="未扫清单" width="600px" destroy-on-close>
|
||||||
|
<el-alert
|
||||||
|
v-if="unscannedList.length > 0"
|
||||||
|
:title="`以下 ${unscannedList.length} 种物料还未扫满,请补扫:`"
|
||||||
|
type="warning" :closable="false" show-icon style="margin-bottom: 12px;"
|
||||||
|
/>
|
||||||
|
<el-table :data="unscannedList" border size="small" max-height="400">
|
||||||
|
<el-table-column type="index" label="#" width="40" align="center" />
|
||||||
|
<el-table-column prop="name" label="物料名称" min-width="140" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="spec" label="规格型号" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="计划" width="70" align="center">
|
||||||
|
<template #default="{ row }">{{ row.planQty }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="已扫" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color: #67C23A;">{{ row.scannedQty }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="待扫" width="70" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span style="color: #F56C6C; font-weight: bold;">{{ row.remaining }}</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showUnscannedDialog = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="showUnscannedDialog = false; showCamera = true">去扫码</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="showSignatureDialog"
|
v-model="showSignatureDialog"
|
||||||
fullscreen
|
fullscreen
|
||||||
@ -314,6 +367,42 @@ const selectedApproval = computed(() =>
|
|||||||
|
|
||||||
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
|
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
|
||||||
|
|
||||||
|
// ★ 扫码进度:总需扫数(计划数量总和)/ 已扫数(购物车数量总和)
|
||||||
|
const scanTotalQty = computed(() =>
|
||||||
|
plannedItems.value.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0)
|
||||||
|
)
|
||||||
|
const scanScannedQty = computed(() =>
|
||||||
|
cartItems.value.reduce((sum, it) => sum + (Number(it.out_quantity) || 0), 0)
|
||||||
|
)
|
||||||
|
// 已扫物品种类数 / 计划物品种类数
|
||||||
|
const scanScannedTypes = computed(() => cartItems.value.length)
|
||||||
|
const scanTotalTypes = computed(() => plannedItems.value.length)
|
||||||
|
|
||||||
|
// ★ 未扫清单:计划中还没扫满的物料
|
||||||
|
const unscannedList = computed(() => {
|
||||||
|
return plannedItems.value.map(plan => {
|
||||||
|
const planQty = Number(plan.quantity) || 0
|
||||||
|
// 已扫数量(按名称+规格匹配)
|
||||||
|
const scannedQty = cartItems.value
|
||||||
|
.filter(ci => {
|
||||||
|
const ciName = (ci.name || '').trim()
|
||||||
|
const ciSpec = (ci.spec_model || ci.standard || '').trim()
|
||||||
|
return ciName === (plan.name || '').trim() &&
|
||||||
|
ciSpec === (plan.spec_model || '').trim()
|
||||||
|
})
|
||||||
|
.reduce((sum, ci) => sum + (Number(ci.out_quantity) || 0), 0)
|
||||||
|
return {
|
||||||
|
name: plan.name || '',
|
||||||
|
spec: plan.spec_model || '',
|
||||||
|
planQty,
|
||||||
|
scannedQty,
|
||||||
|
remaining: Math.max(0, planQty - scannedQty)
|
||||||
|
}
|
||||||
|
}).filter(item => item.remaining > 0) // 只保留未扫满的
|
||||||
|
})
|
||||||
|
const unscannedCount = computed(() => unscannedList.value.length)
|
||||||
|
const showUnscannedDialog = ref(false)
|
||||||
|
|
||||||
// ★ 加载已通过审批的借库申请单列表
|
// ★ 加载已通过审批的借库申请单列表
|
||||||
const loadApprovalRequests = async () => {
|
const loadApprovalRequests = async () => {
|
||||||
requestsLoading.value = true
|
requestsLoading.value = true
|
||||||
@ -456,6 +545,17 @@ const handleManualInput = async () => {
|
|||||||
|
|
||||||
const maxQty = parseFloat(item.available_quantity)
|
const maxQty = parseFloat(item.available_quantity)
|
||||||
if (item.out_quantity < maxQty) {
|
if (item.out_quantity < maxQty) {
|
||||||
|
// ★ 重复扫码:弹窗确认是否 +1,防止手滑重复扫
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`【${item.name} × ${item.spec_model}】已在清单中(当前已扫 ${item.out_quantity} 个)。\n\n确认再 +1 吗?`,
|
||||||
|
'重复扫码确认',
|
||||||
|
{ confirmButtonText: '确认 +1', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
barcodeInput.value = ''
|
||||||
|
return // 用户取消,不加
|
||||||
|
}
|
||||||
item.out_quantity++
|
item.out_quantity++
|
||||||
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||||
if (navigator.vibrate) navigator.vibrate(50)
|
if (navigator.vibrate) navigator.vibrate(50)
|
||||||
@ -721,6 +821,21 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* 扫码区 */
|
/* 扫码区 */
|
||||||
.scan-section { margin-bottom: 20px; }
|
.scan-section { margin-bottom: 20px; }
|
||||||
|
.scan-progress {
|
||||||
|
background: #f5f7fa;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.progress-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.camera-placeholder {
|
.camera-placeholder {
|
||||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user