28 Commits

Author SHA1 Message Date
5249649cfa fix(stocktake): 扫码未找到物料时显示友好提示而非原始 404 错误
- 识别 HTTP 404 状态码 → 显示『条码 [xxx] 未在库存系统中找到,请确认条码是否正确』
- 其他错误 → 显示『查询失败: xxx』
- 扫码失败时振动反馈
2026-08-31 15:21:18 +08:00
6ba20fa3db fix(stocktake): 修复盘库重复记录 — 去重条件去掉 user_id
根因: draft/add 用 (user_id, uuid, session_id) 匹配去重,
同一盘点单多个用户(手机/平板不同账号)操作同一物料时,
每个用户各建一条 → 重复记录。

修复:
- 去重改为 (session_id, uuid) 唯一,不按 user_id 隔离
- 已存在则更新数量并记录最后操作用户
- 清理现有重复数据(保留每组最小id)
- 新增唯一索引 uq_stocktake_draft_session_uuid 防未来再重复

数据库需执行:
DELETE FROM stocktake_draft a USING stocktake_draft b
  WHERE a.session_id=b.session_id AND a.uuid=b.uuid AND a.id>b.id;
CREATE UNIQUE INDEX IF NOT EXISTS uq_stocktake_draft_session_uuid
  ON stocktake_draft(session_id, uuid);
2026-08-31 15:18:47 +08:00
7a3ffd8ba5 feat(stocktake): 重复扫码提示并确认是否修改
问题: 同一个码扫两遍输入两个数,后端 draft/add 直接覆盖,无任何提示
可能误改已盘数量

改进:
- 后端 draft/list 支持 uuid 过滤,可精确查某物料是否已盘
- 扫码弹输入框前,先精确查询该物料是否已盘
- 已盘过 → 弹窗提示『当前实盘为 X,是否修改?』
  确认后预填旧值供修改,取消则不改变
2026-08-31 15:13:52 +08:00
2eee792059 fix(stocktake): 删除 pinScannedItem 后多余的右花括号
多余的 } 导致 script 括号不匹配,SFC 解析错乱报 Unexpected token
2026-08-31 15:06:30 +08:00
cf76310340 fix(stocktake): 修复模板中 TS 类型注解导致的编译错误
row-class-name 的 (row: any) 在 Vue 模板中不合法,去掉类型注解
2026-08-31 15:03:22 +08:00
c6e8c887ec perf(stocktake): 应盘清单分页 + 扫码置顶高亮
性能修复(解决打开费劲):
- get_all_stocktake_items 改为分页返回 {items(当前页), total, total_scanned}
  不再三次 .all() 全量加载 + 内存排序
- 前端 fetchAllStockItems 分页拉取(每页200),stats 用后端返回的 total
  不依赖全量数组长度

体验优化:
- 扫码成功后将物品置顶到当前表格第一行并浅绿高亮3秒
  库管无需翻页找刚扫的物品,'嘀'一下确认数量即可继续
- 主表格行高亮样式 .just-scanned-row
2026-08-31 14:59:22 +08:00
f5aa2f481b feat(scan): 出库/借库扫码增加未扫清单弹窗
- 扫码进度条旁新增「未扫清单 (N)」按钮(有未扫满物料时显示)
- 弹窗列出计划中未扫满的物料:名称/规格/计划数量/已扫数量/待扫数量
- 提供「去扫码」快捷跳转,方便补扫漏掉的物料
- 未扫满 = 计划数量 > 已扫数量(含完全未扫和扫了一部分的情况)
2026-08-31 14:53:20 +08:00
7e281cf285 fix(stocktake): 盘库扫码改为精确匹配,修复漏匹配与性能问题
问题:
1. 准确性 bug: 前端用 getStockList({pageSize:10, keyword}) 模糊搜索 + find()
   当 SKU 前缀相同、目标不在前10条时,误报'未找到该物料库存'
2. 性能: 每次扫码全表 ilike %x% 搜索 3 张表,不走索引

修复:
- 后端 get_stock_info 改为精确匹配(==)优先,未命中再回退模糊搜索
- 新增 GET /inbound/stock/scan 扫码精确匹配接口,返回唯一命中
- 前端 onScanSuccess/handleManualInput 改用 scanStockByBarcode
  一次请求直接命中,不再依赖 pageSize:10 + find()
2026-08-31 14:49:24 +08:00
e026c00a85 refactor(outbound): 移除扫码出库页的强制完结按钮
- 删除 create.vue 中「强制完结此单」按钮及对应逻辑
- 完结功能保留在出库审批列表页(approval/index.vue)
- 出库执行页聚焦扫码作业
2026-08-31 14:38:51 +08:00
e492000c3e feat(scan): 出库/借库扫码进度显示 + 重复扫码确认
- 出库 create.vue + 借库 borrow.vue 均增加扫码进度条
  显示: 已扫/总数 种 + 已扫/总数 件 + 进度百分比
- 重复扫码时不再直接 +1,改为弹窗确认「确认 +1 / 取消」
  防止手滑重复扫或误操作
- 扫码进度条选中审批单后显示
2026-08-31 14:35:26 +08:00
22c8ce55eb fix(purchase): 批次号分组改为取单号前3段(PUR-日期-时间)
- 原逻辑 lastIndexOf 去末尾流水,导致同一天单子被合并成 PUR-日期
- 改为 split('-').slice(0,3) → PUR-20260625-1345
- 同一天不同分钟的单子正确分为不同批次;同一分钟内多条视为一批
2026-08-31 14:28:02 +08:00
3cb44c8f18 fix(purchase): 申请人显示只取中文名,去掉 /账号 后缀
- 新增 formatUserName: 韩善龙/hanshanlong → 韩善龙
- 列表批次主行申请人列、详情弹窗均应用
- 避免长账号名撑高行导致行高不一致
2026-08-31 14:12:23 +08:00
74271d53bc style(purchase): 统一列表行高
- CSS 固定主表格行高 52px,行内 line-height 20px
- 状态列'待审批 N 项'改为单行 inline 显示(不再撑高)
- 物品列加 white-space: nowrap 防止换行导致高度差异
2026-08-31 14:07:02 +08:00
a6c21fd313 style(purchase): 列表宽度优化 + 物品显示名称货号
- 主表格/展开明细表格加 width:100% + fit
- 自适应列用 min-width(批次号/物品),固定列保留 width(金额/日期/操作)
- 批次主行物品列:单个显示 名称+货号,多个显示 'N 种物料'
- 展开明细物品列:名称+货号 合并显示
2026-08-31 14:05:46 +08:00
4c41e1ec13 feat(purchase): 采购列表按批次分组展示 + 批次级一键审批
- 列表按批次分组(树形展开):一个批次一行,显示物品数/批次总价/申请人/状态汇总
- 展开批次行显示批内明细(每条记录状态/价格/操作)
- 批次行提供「审批本批」「驳回本批」一键操作(按批次前缀批量处理待审批记录)
- 保留逐条展开明细内的通过/驳回,以及顶部多选批量审批
- fetchData 按批次前缀聚合当前页数据
2026-08-31 14:00:31 +08:00
1000deda2f feat(purchase): 采购单号改为 批次-批内序号 格式,显式体现同批
按用户原始逻辑还原并扩展:
- 单号格式: PUR-日期-时间-批次-批内序号
  例: PUR-20260831-1021-0001-0001(今日第1次采购的第1条)
- generate_request_no 支持 batch_seq 参数生成批次+批内序号
- 新增 GET /purchase/next-batch-seq 返回今日下一个批次号
- 前端提交时获取批次号,同批所有行共享,循环提交生成连续批内序号
- 批次识别: 单号去掉末尾 -批内序号 即为批次前缀
- 保留旧格式兼容(无 batch_seq 时回退 PUR-日期-时间-流水)
2026-08-31 13:54:03 +08:00
f499346f74 refactor(purchase): 批次概念改为基于 request_no 前缀推导,撤销数据库字段
按用户反馈:不需要新增数据库字段,现有 request_no (PUR-20260831-1021-0001)
本身就能推导批次——去掉末尾4位流水号即为批次前缀。

- 撤销 purchase_request.batch_no 字段(模型/数据库)
- 撤销 create 接口的 batch_no 参数与 generate_batch_no
- 批次标识 = request_no 去掉末尾 -XXXX 段
  getBatchKey('PUR-20260831-1021-0001') = 'PUR-20260831-1021'
- 按批次查询/批量审批接口改用 batch_key (request_no LIKE 前缀)
- 前端列表批次号列、整批弹窗、一键审批本批均基于前缀推导
- 零数据库迁移,历史数据立即可用
2026-08-31 13:49:11 +08:00
664127ee30 feat(purchase): 采购批次号支持,同一批提交的记录可分组查看/统计
背景: 循环提交把一批物品生成多条独立申请,无法识别'哪些是同一批采购'

后端:
- purchase_request 模型新增 batch_no 字段(同一批提交共享)
- create 接口接收 batch_no,无则自动生成 BATCH-yyyyMMdd-HHmm-随机
- 新增 GET /purchase/batch/<batch_no> 按批次查询所有记录
- 批量审批接口支持按 batch_no 一键审批整批

前端:
- 提交时生成 batch_no 共享给所有行
- 列表新增批次号列(可点击)
- 新增'整批查看'弹窗:显示同批次所有记录 + 一键批量通过本批
- 操作列新增'整批'按钮

数据库需执行: ALTER TABLE purchase_request ADD COLUMN batch_no VARCHAR(100);
2026-08-31 13:46:46 +08:00
4b62c81baa feat(purchase): 采购申请批量审批(解决循环提交需多次审批)
问题: 一次提交 10 个物品生成 10 条独立申请,审批人需操作 10 次

后端:
- 新增 POST /api/v1/purchase/batch-approve 批量审批接口
  接收 ids + action,循环审批,成功/失败分别返回

前端:
- 待审批列表加多选列(仅 status=0 可勾选)
- 顶部批量操作栏:批量通过 / 批量驳回 / 取消选择
- 批量驳回弹窗统一填写驳回原因
2026-08-31 13:41:25 +08:00
b68dfbf23d feat(purchase): 商家链接改为每行独立
- 明细表新增「商家链接」列,每行独立输入
- 移除公共区商家链接,公共信息区只保留审批人
- 提交时每行携带自己的 supplier_link
2026-08-31 13:35:34 +08:00
efb88c8f9c fix(purchase): 税率列加宽以完整显示两位数字+%
- 明细表税率列 80px → 100px
- 列表页税率列 70px → 80px
- 税率选项补充 3%/5%/9%
2026-08-31 13:33:58 +08:00
3a9ec81c51 fix(purchase): 照片删除加确认 + 修复同步照片跨行删除导致图片缺失
问题: 同步照片后多行共享同一物理文件 URL,删除某行照片时
deleteFile 删除了物理文件,其他行引用同一 URL 变图片缺失

修复:
- 删除前检查照片被几行引用
- 若被多行引用: 弹窗询问【全部删除】(所有行移除+删文件)
  或【仅移出此行】(只从当前行移除引用,不删服务器文件)
- 若仅当前行引用: 普通确认后删除
- 普通删除也加确认弹窗
2026-08-31 13:31:08 +08:00
25bb35e265 feat(purchase): 表格列宽优化 + 迷你上传框 + 照片同步所有
- 每列显式指定 min-width/width,输入多的列用 min-width 防挤压
- 照片列固定 90px,上传框缩小到 60x60(::v-deep 覆盖样式)
- 新增「照片同步所有」按钮:深拷贝第一行照片到所有行
- 照片列保持红星必填标记
2026-08-31 13:24:17 +08:00
f18e61a71a fix(purchase): 修复 Copy 图标不存在导致的模块加载错误
@element-plus/icons-vue 没有 Copy 图标,改为 CopyDocument
2026-08-31 13:18:44 +08:00
9e393ff998 feat(purchase): 弹窗加大 + 移除公共图片 + 每行照片必填
- 弹窗宽度 1180px → 1350px,缓解表格输入框拥挤
- 移除底部公共「图片凭证」上传区,照片统一在每行上传
- 照片列表头加红星必填标记
- 提交时逐行校验照片必填:某行无照片则阻断并提示该行名称
- 清理废弃的公共图片逻辑(fileList/customUpload/handleRemoveImage/usePasteUpload)
2026-08-31 13:16:52 +08:00
8d90b3c774 feat(purchase): 采购申请弹窗加大 + 每行独立备注/照片 + 备注一键同步
- 弹窗加大到 1180px、表格 max-height 400 减少滚动
- 明细表新增「备注」列和「照片」列(每行独立上传,最多3张)
- 新增「备注同步所有」按钮:把第一行备注复制到所有行
- 提交时行备注优先、行照片优先,无则回退公共备注/公共图片
- 图片校验改为逐行(行内或公共至少其一)
2026-08-31 13:10:59 +08:00
021c92ee8a feat(purchase): 采购申请改为动态明细表,支持一行多物料批量提交
- 新建弹窗物料区改为动态明细表格:每行独立选择物料/规格/数量/日期/单价/总价/税率
- 支持 [+ 添加一行] 动态增行、行内删除
- 审批人/商家链接/备注/图片凭证为公共信息
- 提交时 for 循环逐行调用单条 createPurchase 接口,后端无需改动
- 行内价格半联动:改单价自动算总价,手动改总价后不再覆盖
2026-08-31 13:05:29 +08:00
5abc504619 feat(outbound): 审批列表页增加完结按钮 + 优化缺货提示文案
- 去掉缺货提醒末尾的『是否查看?』(纯文案,保持强提醒但不承诺展示清单)
- approval/index.vue 对已通过(status=1)的审批单新增「完结」按钮
  库管/主管/超管可见,完结后从已通过列表移除
- 筛选区新增「已完结」状态选项,状态标签支持 status=4
2026-08-31 10:46:33 +08:00
11 changed files with 1508 additions and 348 deletions

View File

@ -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()

View File

@ -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_keyrequest_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():

View File

@ -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):

View File

@ -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[]) {

View File

@ -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({

View File

@ -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(() => {})

View File

@ -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}`

View File

@ -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

View File

@ -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; }

View File

@ -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;