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):
|
||||
"""
|
||||
根据 uuid 或 barcode 查询库存信息
|
||||
返回: (item, source_table, stock_id)
|
||||
根据 uuid 或 barcode 查询库存信息(★ 精确匹配优先,性能与准确性兼顾)
|
||||
|
||||
修复: 原来用 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:
|
||||
print(f"🔍 [QUERY DEBUG] 正在成品表搜关键词: {uuid_or_barcode}")
|
||||
item = StockProduct.query.filter(
|
||||
db.or_(
|
||||
StockProduct.barcode.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockProduct.sku.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockProduct.serial_number.ilike(f"%{uuid_or_barcode}%")
|
||||
)
|
||||
).first()
|
||||
if item:
|
||||
print(f"✅ [QUERY DEBUG] 命中成品! ID={item.id}, SKU={item.sku}")
|
||||
return (item, 'stock_product', item.id)
|
||||
else:
|
||||
print(f"❌ [QUERY DEBUG] 成品表查询结束,无匹配项")
|
||||
# ===== 精确匹配优先(走索引,快且准) =====
|
||||
exact_checks = [
|
||||
(StockProduct, lambda c: db.or_(
|
||||
StockProduct.barcode == c,
|
||||
StockProduct.sku == c,
|
||||
StockProduct.serial_number == c
|
||||
), 'stock_product'),
|
||||
(StockSemi, lambda c: db.or_(
|
||||
StockSemi.barcode == c,
|
||||
StockSemi.sku == c,
|
||||
StockSemi.serial_number == c
|
||||
), 'stock_semi'),
|
||||
(StockBuy, lambda c: db.or_(
|
||||
StockBuy.barcode == c,
|
||||
StockBuy.sku == c
|
||||
), 'stock_buy'),
|
||||
]
|
||||
|
||||
# 2. 半成品
|
||||
if StockSemi:
|
||||
print(f"🔍 [QUERY DEBUG] 正在半成品表搜关键词: {uuid_or_barcode}")
|
||||
item = StockSemi.query.filter(
|
||||
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()
|
||||
for model, cond_fn, table_name in exact_checks:
|
||||
if not model:
|
||||
continue
|
||||
item = model.query.filter(cond_fn(code)).first()
|
||||
if item:
|
||||
print(f"✅ [QUERY DEBUG] 命中半成品! ID={item.id}, SKU={item.sku}")
|
||||
return (item, 'stock_semi', item.id)
|
||||
return (item, table_name, item.id)
|
||||
|
||||
# 3. 采购件
|
||||
if StockBuy:
|
||||
print(f"🔍 [QUERY DEBUG] 正在采购件表搜关键词: {uuid_or_barcode}")
|
||||
item = StockBuy.query.filter(
|
||||
db.or_(
|
||||
StockBuy.barcode.ilike(f"%{uuid_or_barcode}%"),
|
||||
StockBuy.sku.ilike(f"%{uuid_or_barcode}%")
|
||||
)
|
||||
).first()
|
||||
# ===== 精确未命中 → 回退模糊搜索(保留旧行为兜底) =====
|
||||
fuzzy_checks = [
|
||||
(StockProduct, lambda c: db.or_(
|
||||
StockProduct.barcode.ilike(f"%{c}%"),
|
||||
StockProduct.sku.ilike(f"%{c}%"),
|
||||
StockProduct.serial_number.ilike(f"%{c}%")
|
||||
), 'stock_product'),
|
||||
(StockSemi, lambda c: db.or_(
|
||||
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:
|
||||
print(f"✅ [QUERY DEBUG] 命中采购件! ID={item.id}, SKU={item.sku}")
|
||||
return (item, 'stock_buy', item.id)
|
||||
return (item, table_name, item.id)
|
||||
|
||||
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')
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 盘库/出库/借库 扫码精确匹配接口
|
||||
# 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'])
|
||||
@ -425,12 +477,17 @@ def get_drafts():
|
||||
limit = request.args.get('limit', 20, type=int)
|
||||
keyword = request.args.get('keyword', '', type=str)
|
||||
session_id = request.args.get('session_id')
|
||||
uuid = request.args.get('uuid', '', type=str)
|
||||
|
||||
query = StocktakeDraft.query
|
||||
|
||||
if session_id:
|
||||
query = query.filter_by(session_id=session_id)
|
||||
|
||||
# ★ 按 uuid 精确过滤(扫码时检测该物料是否已盘)
|
||||
if uuid:
|
||||
query = query.filter_by(uuid=uuid)
|
||||
|
||||
# 先执行查询获取所有记录
|
||||
drafts = query.all()
|
||||
|
||||
@ -545,8 +602,10 @@ def add_draft():
|
||||
# 调整后的账面可用库存 = 账面总库存 - 借出未还数量
|
||||
adjusted_stock_qty = stock_qty - total_borrowed
|
||||
|
||||
# 查找是否已存在
|
||||
draft = StocktakeDraft.query.filter_by(user_id=user_id, uuid=uuid, session_id=session_id).first()
|
||||
# ★ 查找是否已存在:按 (session_id, uuid) 去重,不按 user_id 隔离
|
||||
# 修复: 同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
|
||||
# 之前按 user_id 匹配导致每个用户各建一条 → 重复记录
|
||||
draft = StocktakeDraft.query.filter_by(uuid=uuid, session_id=session_id).first()
|
||||
|
||||
if draft:
|
||||
# 如果已存在,更新数量和时间
|
||||
@ -556,6 +615,8 @@ def add_draft():
|
||||
draft.diff_qty = quantity - adjusted_stock_qty
|
||||
draft.source_table = source_table
|
||||
draft.stock_id = stock_id
|
||||
# 更新操作用户(最后操作者)
|
||||
draft.user_id = user_id
|
||||
# ★ 新增: 保存备注
|
||||
if remark is not None:
|
||||
draft.remark = remark.strip() if isinstance(remark, str) else remark
|
||||
@ -1545,14 +1606,18 @@ def get_draft_merged_list():
|
||||
@permission_required('inventory_stocktake')
|
||||
def get_all_stocktake_items():
|
||||
"""
|
||||
获取所有应盘物资清单(库存 > 0 的物料)
|
||||
作为盘点基数,用于统计已盘/未盘数量
|
||||
获取应盘物资清单(库存 > 0 的物料)— ★ 分页返回,禁止全量
|
||||
|
||||
性能优化: 原来三次 .all() 全量加载 + 内存排序,库存量大时打开极慢。
|
||||
改为: 分页返回 {items(当前页), total(总数), total_scanned(已盘数)}。
|
||||
"""
|
||||
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 = []
|
||||
|
||||
|
||||
# 1. 采购件
|
||||
buy_query = StockBuy.query.filter(StockBuy.stock_quantity > 0)
|
||||
if keyword:
|
||||
@ -1568,7 +1633,6 @@ def get_all_stocktake_items():
|
||||
'id': item.id,
|
||||
'sku': item.sku 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 '',
|
||||
'material_name': item.base.name 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',
|
||||
'warehouse_location': item.warehouse_location or ''
|
||||
})
|
||||
|
||||
|
||||
# 2. 半成品
|
||||
if StockSemi:
|
||||
semi_query = StockSemi.query.filter(StockSemi.stock_quantity > 0)
|
||||
@ -1594,7 +1658,6 @@ def get_all_stocktake_items():
|
||||
'id': item.id,
|
||||
'sku': item.sku 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 '',
|
||||
'material_name': item.base.name 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',
|
||||
'warehouse_location': item.warehouse_location or ''
|
||||
})
|
||||
|
||||
|
||||
# 3. 成品
|
||||
if StockProduct:
|
||||
product_query = StockProduct.query.filter(StockProduct.stock_quantity > 0)
|
||||
@ -1620,7 +1683,6 @@ def get_all_stocktake_items():
|
||||
'id': item.id,
|
||||
'sku': item.sku 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 '',
|
||||
'material_name': item.base.name 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',
|
||||
'warehouse_location': item.warehouse_location or ''
|
||||
})
|
||||
|
||||
|
||||
# 按 SKU 排序
|
||||
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({
|
||||
'code': 200,
|
||||
'data': {
|
||||
'items': all_items,
|
||||
'total': len(all_items)
|
||||
'items': paged,
|
||||
'total': total,
|
||||
'total_scanned': total_scanned,
|
||||
'page': page,
|
||||
'pageSize': pageSize
|
||||
}
|
||||
}), 200
|
||||
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
@ -157,6 +157,38 @@ def get_purchase_detail(purchase_id):
|
||||
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. 审批采购申请
|
||||
# PATCH /api/v1/purchase/<id>/approve
|
||||
@ -205,6 +237,87 @@ def approve_purchase_request(purchase_id):
|
||||
# 5. 获取可选审批人列表
|
||||
# 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'])
|
||||
@jwt_required()
|
||||
def get_purchase_approvers():
|
||||
|
||||
@ -9,16 +9,58 @@ from app.models.base import MaterialBase
|
||||
class PurchaseService:
|
||||
|
||||
@staticmethod
|
||||
def generate_request_no():
|
||||
"""生成采购单号: PUR-yyyyMMdd-HHmm-当日流水(4位)"""
|
||||
def generate_request_no(batch_seq: int = None):
|
||||
"""
|
||||
生成采购单号: 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))
|
||||
now = datetime.now(beijing_tz)
|
||||
date_str = now.strftime('%Y%m%d')
|
||||
time_str = now.strftime('%H%M')
|
||||
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}"
|
||||
|
||||
# 查询今日所有单号,提取第4段(批次号)去重
|
||||
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
|
||||
def auto_fill_from_material(keyword: str):
|
||||
@ -47,7 +89,8 @@ class PurchaseService:
|
||||
data 包含: name, spec_model, quantity, purchase_date, supplier_link, remark, images,
|
||||
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')
|
||||
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
|
||||
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() {
|
||||
return request({
|
||||
|
||||
@ -819,7 +819,7 @@ const openBomSelect = async () => {
|
||||
const rec = bomShortageRecords.value[no]
|
||||
return `• ${no}(${rec.items.length} 种缺货,${rec.time})`
|
||||
}).join('\n') +
|
||||
`\n\n建议先补货后再出库,避免漏出。是否查看?`,
|
||||
`\n\n建议先补货后再出库,避免漏出。`,
|
||||
'存在未完成出库',
|
||||
{ confirmButtonText: '知道了', cancelButtonText: '忽略', type: 'warning' }
|
||||
).catch(() => {})
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<el-radio-button :label="1">已通过</el-radio-button>
|
||||
<el-radio-button :label="2">已驳回</el-radio-button>
|
||||
<el-radio-button :label="3">已完成</el-radio-button>
|
||||
<el-radio-button :label="4">已完结</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
</div>
|
||||
@ -133,6 +134,19 @@
|
||||
驳回
|
||||
</el-button>
|
||||
</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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@ -178,10 +192,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getApprovalRequestList, approveRequest } from '@/api/outbound'
|
||||
import { getApprovalRequestList, approveRequest, closeRequest } from '@/api/outbound'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
@ -207,14 +221,14 @@ const userNameCache = ref<Record<number, string>>({})
|
||||
// --- 工具函数 ---
|
||||
const statusText = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
|
||||
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'
|
||||
}
|
||||
return map[status] ?? '-'
|
||||
}
|
||||
|
||||
const statusTagType = (status: number) => {
|
||||
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'
|
||||
}
|
||||
@ -224,6 +238,40 @@ const getApplicantName = (id: number | null) => {
|
||||
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) => {
|
||||
if (!id) return '-'
|
||||
return userNameCache.value[id] ?? `用户 #${id}`
|
||||
|
||||
@ -46,12 +46,6 @@
|
||||
</el-option>
|
||||
</el-select>
|
||||
<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>
|
||||
|
||||
<!-- ★ 按单出库:计划清单预览 -->
|
||||
@ -80,6 +74,27 @@
|
||||
|
||||
<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">
|
||||
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||
@ -247,6 +262,37 @@
|
||||
</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
|
||||
v-model="showSignatureDialog"
|
||||
fullscreen
|
||||
@ -290,7 +336,7 @@ import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
||||
import QrScanner from '@/components/QrScanner/index.vue'
|
||||
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, closeRequest } from '@/api/outbound'
|
||||
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList } from '@/api/outbound'
|
||||
import { uploadFile } from '@/api/common/upload'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@ -309,7 +355,6 @@ const formRef = ref()
|
||||
const approvalRequests = ref<any[]>([])
|
||||
const selectedRequest = ref<any>(null)
|
||||
const requestsLoading = ref(false)
|
||||
const closingRequest = ref(false)
|
||||
|
||||
// 签名相关
|
||||
const showSignatureDialog = ref(false)
|
||||
@ -357,6 +402,42 @@ const selectedRequestId = computed({
|
||||
|
||||
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 () => {
|
||||
requestsLoading.value = true
|
||||
@ -387,37 +468,6 @@ const handleRequestChange = (val: number | null) => {
|
||||
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 normalizedName = scannedName.trim()
|
||||
@ -534,6 +584,17 @@ const handleManualInput = async () => {
|
||||
|
||||
const maxQty = parseFloat(item.available_quantity)
|
||||
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++
|
||||
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||
if (navigator.vibrate) navigator.vibrate(50)
|
||||
@ -837,6 +898,21 @@ onUnmounted(() => {
|
||||
|
||||
/* 扫码区(卡片内触发器) */
|
||||
.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 {
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
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
|
||||
row-key="uniqueKey"
|
||||
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="material_name" label="名称" min-width="120" show-overflow-tooltip />
|
||||
@ -413,7 +414,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { ElMessage, ElMessageBox } from 'element-plus'
|
||||
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 fetchAllStockItems = async () => {
|
||||
const fetchAllStockItems = async (page = 1) => {
|
||||
try {
|
||||
// ★ 必须传递 session_id,用于隔离会话
|
||||
const res: any = await getAllStocktakeItems({ session_id: currentSessionId.value })
|
||||
// ★ 分页拉取:默认每页 200 条,避免全量加载卡顿
|
||||
const res: any = await getAllStocktakeItems({
|
||||
session_id: currentSessionId.value,
|
||||
page,
|
||||
pageSize: 200
|
||||
})
|
||||
if (res && res.code === 200) {
|
||||
allStockItems.value = res.data.items || []
|
||||
// ★ 使用返回的 total 获取真实总数,而不是受限的数组长度
|
||||
// ★ 使用返回的 total 获取真实总数,而不是数组长度
|
||||
totalStockCount.value = res.data.total || allStockItems.value.length
|
||||
totalScannedCount.value = res.data.total_scanned || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取应盘物资清单失败', e)
|
||||
@ -511,11 +517,9 @@ const fetchAllStockItems = async () => {
|
||||
// 过滤后的列表数据(直接使用已过滤的 listData)
|
||||
const filteredListData = computed(() => listData.value)
|
||||
|
||||
// 统计信息:从全量数据中计算(脱离视图依赖)
|
||||
// 统计信息:用后端返回的真实总数(不依赖全量数组长度)
|
||||
const stats = computed(() => {
|
||||
const total = allStockItems.value.length
|
||||
if (total === 0) return { total: 0, scanned: 0, varianceItems: 0 }
|
||||
|
||||
const total = totalStockCount.value || allStockItems.value.length
|
||||
return {
|
||||
total,
|
||||
scanned: totalScannedCount.value,
|
||||
@ -736,58 +740,54 @@ const onScanSuccess = async (code: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 实时查询后端匹配
|
||||
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find(),避免漏匹配)
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getStockList({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: trimCode
|
||||
})
|
||||
const res: any = await scanStockByBarcode(trimCode)
|
||||
|
||||
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
|
||||
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) {
|
||||
if (!res || !res.data) {
|
||||
ElMessage.error(`未找到该物料库存: ${trimCode}`)
|
||||
if (navigator.vibrate) navigator.vibrate([200, 50, 200])
|
||||
return
|
||||
}
|
||||
|
||||
const foundItem = res.data
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
|
||||
// 关闭全屏扫码,弹出填数对话框
|
||||
showCamera.value = false
|
||||
|
||||
// 处理数据格式
|
||||
// 处理数据格式(后端已返回 source_table / stock_id / name / standard)
|
||||
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
||||
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 = {
|
||||
...foundItem,
|
||||
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 || '',
|
||||
uuid: foundItem.uuid || foundItem.sku || '',
|
||||
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
||||
qty_stock: stock,
|
||||
qty_actual: 1,
|
||||
scanned: true,
|
||||
uniqueKey: `${type}_${foundItem.id}`,
|
||||
source_table: typeToSourceTable(type),
|
||||
stock_id: foundItem.id
|
||||
uniqueKey: `${type}_${stockId}`,
|
||||
source_table: sourceTable,
|
||||
stock_id: stockId
|
||||
}
|
||||
|
||||
openQtyDialog(item)
|
||||
} catch (e) {
|
||||
ElMessage.error('查询库存失败')
|
||||
} catch (e: any) {
|
||||
// ★ 友好错误提示: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)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@ -820,63 +820,105 @@ const handleManualInput = async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await getStockList({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
keyword: code
|
||||
})
|
||||
// ★ 精确匹配查询后端(替代 pageSize:10 模糊搜索 + find())
|
||||
const res: any = await scanStockByBarcode(code)
|
||||
|
||||
if (!res || !res.data || !res.data.list || res.data.list.length === 0) {
|
||||
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) {
|
||||
if (!res || !res.data) {
|
||||
ElMessage.error(`未找到该物料库存: ${code}`)
|
||||
return
|
||||
}
|
||||
|
||||
const foundItem = res.data
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
|
||||
const stock = parseFloat(foundItem.stock_quantity || foundItem.qty_stock || 0)
|
||||
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 = {
|
||||
...foundItem,
|
||||
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 || '',
|
||||
uuid: foundItem.uuid || foundItem.sku || '',
|
||||
bar_code: foundItem.bar_code || foundItem.barcode || '',
|
||||
qty_stock: stock,
|
||||
qty_actual: 1,
|
||||
scanned: true,
|
||||
uniqueKey: `${type}_${foundItem.id}`,
|
||||
source_table: typeToSourceTable(type),
|
||||
stock_id: foundItem.id
|
||||
uniqueKey: `${type}_${stockId}`,
|
||||
source_table: sourceTable,
|
||||
stock_id: stockId
|
||||
}
|
||||
|
||||
openQtyDialog(item)
|
||||
} catch (e) {
|
||||
ElMessage.error('查询库存失败')
|
||||
} catch (e: any) {
|
||||
// ★ 友好错误提示: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)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openQtyDialog = (item: StockItem) => {
|
||||
currentItem.value = item
|
||||
inputQty.value = item.scanned ? item.qty_actual : 1
|
||||
showQtyDialog.value = true
|
||||
const openQtyDialog = async (item: StockItem) => {
|
||||
// ★ 重复扫码检测:精确查该物料是否已在本 session 盘过
|
||||
let existingQty: number | null = null
|
||||
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(() => {
|
||||
const inputEl = document.querySelector('.qty-dialog input') as HTMLInputElement
|
||||
if(inputEl) inputEl.focus()
|
||||
})
|
||||
if (existingQty !== null) {
|
||||
// 已盘过 → 提示当前值,询问是否修改
|
||||
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 = () => {
|
||||
@ -901,12 +943,35 @@ const syncToBackend = (uuid: string, quantity: number, remark: string) => {
|
||||
syncStatus.value = 'success'
|
||||
// 静默刷新统计数字
|
||||
fetchInventoryList(true)
|
||||
// ★ 扫码成功:该物品置顶到当前视图第一行并高亮
|
||||
pinScannedItem(uuid)
|
||||
})
|
||||
.catch(() => {
|
||||
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 = '') => {
|
||||
// 直接保存到后端,不使用本地缓存
|
||||
item.scanned = true
|
||||
@ -1257,6 +1322,14 @@ const goToVarianceReview = () => {
|
||||
}
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
@ -64,6 +64,28 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||
@ -224,6 +246,37 @@
|
||||
</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
|
||||
v-model="showSignatureDialog"
|
||||
fullscreen
|
||||
@ -314,6 +367,42 @@ const selectedApproval = computed(() =>
|
||||
|
||||
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 () => {
|
||||
requestsLoading.value = true
|
||||
@ -456,6 +545,17 @@ const handleManualInput = async () => {
|
||||
|
||||
const maxQty = parseFloat(item.available_quantity)
|
||||
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++
|
||||
ElMessage.success(`数量+1 (当前: ${item.out_quantity})`)
|
||||
if (navigator.vibrate) navigator.vibrate(50)
|
||||
@ -721,6 +821,21 @@ onUnmounted(() => {
|
||||
|
||||
/* 扫码区 */
|
||||
.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 {
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user