6 Commits

Author SHA1 Message Date
6f2f077751 feat(purchase): 采购页搜索区对齐出库记录版式
原顶部工具栏是裸 div + 状态单选,现改为 el-form :inline 版式,与出库记录 /
报废记录一致:

  状态      全部 / 待审批 / 已通过 / 已驳回 / 已完成 / 已完结
  搜索      [搜索类型下拉] + 关键词输入,500ms 防抖
  采购日期  日期范围选择
  按钮      查询 / 重置 / 新建采购申请

交互细节:
· 搜索类型切换、清空输入时立即查询(取消防抖等待);
· 「重置」恢复默认的「待审批」状态(该页默认视图),而非清成"全部"——
  与页面初始状态保持一致;
· 重置同时清空关键词、搜索类型、日期范围。

api/purchase.ts 的 getPurchaseList 补充新参数的类型定义。
2026-09-10 17:41:29 +08:00
e93107458a feat(purchase): 采购列表支持关键词搜索与日期范围
背景
----
采购管理此前只有状态筛选,无法按单号/物料/申请人检索,也无法按采购
日期收窄范围。现对齐出库/报废记录的搜索语义,便于用户迁移使用习惯。

后端
----
PurchaseService.get_purchase_list 新增参数:
  keyword / search_type / start_date / end_date

search_type 取值(与出库、报废一致):
  all          单号 | 物料名称 | 规格型号 | 备注 | 申请人
  no           单号
  name         物料名称
  spec_model   规格型号
  requester    申请人

实现要点
--------
· 申请人姓名存在 SysUser.username,格式「姓名/账号」(如 韩善龙/hanshanlong),
  ilike 直接匹配整串即可命中;
· 该类字段只在 SysUser 上,故**按需 join** —— 单号/名称/规格分支不联表,
  避免无谓开销;
· 公司隔离分支也需 join SysUser,与关键词分支可能重复 join 同一目标,
  会产生笛卡尔积导致结果翻倍,故按需补 join 并用 distinct 兜底;
· 日期范围按 purchase_date 过滤(该列是 Date,无需补时分秒)。

实测:名称'充电器'→1 单,申请人'韩善龙'→21 单,日期 05-13→2 单,
      关键词不存在→0 单,状态+搜索组合→14 单。
2026-09-10 17:41:22 +08:00
a4a9afb6db feat(scan): 出库/借库扫码页接入草稿,切换单据不清空
交互
----
无需「暂停」按钮 —— 在下拉框切换单据这个动作本身就是暂停:
  切走 → 自动存当前单据的进度
  切回 → 自动恢复,并提示「已恢复上次的扫码进度(N 项)」
下拉框对扫到一半的单据显示橙色「已扫 N」徽标,不必逐个点开试。
提交成功后自动清除该单据的草稿。

修复的三个 bug
--------------
1) 切换时把 A 的内容存到了 B 名下
   v-model="selectedRequestId" 的 computed setter 会**先于** @change 把
   selectedRequest 改成新单,故 handleRequestChange 里读到的是新单。
   新增 activeRequestId ref 记录「界面上真正显示的是哪张单」,
   保存时显式传入离开的那张单的 ID。

2) 切回时把目标单的旧草稿删了
   原先写了「购物车为空则清除草稿」,但切换瞬间购物车必然为空,
   于是切回 A 时触发了清除。现改为空清单只跳过保存、不清除;
   清理由「提交成功」或「用户点清空列表」显式触发。

3) 恢复后名称/规格为空、出库数显示 NaN
   draftPayload 只存了 4 个字段(stock_id/source_table/sku/quantity),
   而购物车表格绑定的是 name/spec_model/available_quantity/out_quantity
   —— 全都没存。现保存完整快照,并在恢复时归一化
   (out_quantity ?? quantity)以兼容已存在的旧草稿。

补充:恢复后刷新实时库存
------------------------
草稿里的 available_quantity 是扫描那一刻的快照,跨时间恢复可能已过期
(期间别人出库/借出会消耗可用量)。恢复后复用 /alternatives 端点拉一次
实时可用量:数量超了会明确提示「N 项物料的实际库存已少于你扫的数量」,
避免工人扫满后到提交时才被后端拒绝。失败不阻断,沿用草稿快照。

另:离开页面(路由跳转)时存草稿并弹确认;beforeunload 用 sendBeacon
尽力保存(该路径无法带 Authorization 头,可能失败,但防抖保存已覆盖
绝大部分内容)。
2026-09-10 17:21:24 +08:00
ec66c33b06 feat(scan-draft): 扫码草稿表与接口,支持暂停后继续扫码
场景
----
扫码出库/借库的作业可能很长(一张单几十项),工人常需中途暂停去处理
更紧急的单据。改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。

由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 ——
因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是
需要重扫,不会造成库存错乱。

隔离粒度
--------
按 (user_id, biz_type, request_id) 一人一单:每个人扫自己的草稿,互不影响;
同一人可同时持有多张单据的草稿(正是「暂停 A 去出 B」的场景)。
user_id 一律取自 JWT,不接受入参覆盖,故不可能读写他人草稿。

为什么整单存一个 JSON(而非每条明细一行)
------------------------------------------
1. 保存是「全量覆盖」语义,逐行存无增量更新的收益;
2. 恢复时需要物料名称/规格/库位等展示字段,逐行方案只能回查申请单的
   items_json —— 而历史单据的 items_json 不含 stock_id,回查会错配。
   整单快照把展示字段一并存下,恢复零依赖,对老单据同样可靠。

接口
----
GET    /api/v1/scan-draft           读取草稿
POST   /api/v1/scan-draft           保存(全量覆盖;空清单则删除)
DELETE /api/v1/scan-draft           清除(提交成功后调用)
GET    /api/v1/scan-draft/overview  各单据进度,供下拉徽标

实现要点
--------
· items_json 列是 jsonb,模型必须用 db.JSON —— 用 db.Text 会让 psycopg2
  拿到 Python list/dict 时无法适配,报 "can't adapt type 'dict'",而异常
  被接口的 except 吞掉后 POST 仍返回"成功",问题极难发现(开发中实际踩到);
· 概览接口做防御:残留的空草稿不参与展示。

实测:保存/读回、跨单据隔离(B 读 A 的草稿为 0 项)、全量覆盖、
      提交后清除,全部符合预期。
2026-09-10 17:21:14 +08:00
a07432981e feat(borrow): 扫码借库页的清单对齐扫码出库页
问题
----
扫码借库页(views/transaction/borrow.vue)的「审批计划清单」与扫码出库页
的「计划出库清单」样式不一致:

  · 标题:审批计划清单 / 计划出库清单
  · 类型列:借库有、出库已删
  · 数量列:借库用「审批数量」且排在最右;出库是「计划数量」且前置
  · 备选库位:借库没有
  · 清单容器:借库灰底 #f5f7fa,出库是淡绿 #f0f9eb + 边框

改动(五项全部对齐)
--------------------
· 标题改为「计划借用清单」;
· 移除「类型」列;
· 「计划数量」前置到序号之后,橙色 #E6A23C 加粗 15px;
· 新增备选库位:库位格子整体可点击 + popover(与出库页逐字相同的实现);
· 清单容器改为淡绿背景 #f0f9eb + #e1f3d8 边框 + 圆角,标题绿色加粗。

备选库位复用了出库模块的 /alternatives 端点。已核实前提:借库申请单的
items_json 由预占改造写入 base_id / stock_id / source_table,故「推荐/备选」
标注可正常工作(对历史单据无这些字段时会安全回退为纯文本)。

定位说明
--------
借库有三个页面(选单 / 扫码 / 审批),上一轮按「计划清单」关键词搜索时
命中了审批页的展开行,改错了位置。本提交修正的是真正的扫码借库页。
2026-09-10 16:17:50 +08:00
f8267c6ee2 style(borrow): 审批页物料明细数量前置,清单标题对齐出库
背景
----
借库审批页展开行的「计划数量」排在表格最右(库位之后),与扫码出库页
的前置做法不一致 —— 列多时容易被容器裁掉,工人看不到数量。

改动
----
· 计划数量从最右移到序号之后,橙色加粗 + 字号 15px;
· 清单标题由朴素文字改为与出库页同款的标题栏结构
  (planned-header + planned-title + 绿色标签);
· 补上 .planned-header / .planned-title 样式定义(该页原先没有)。

注:本轮定位时曾误以为这是用户反馈的「扫码借库页」,实际扫码借库页是
views/transaction/borrow.vue(见下一提交)。此处是同类问题的另一处,
一并修正,非误改。
2026-09-10 16:17:42 +08:00
12 changed files with 1188 additions and 54 deletions

View File

@ -0,0 +1,42 @@
-- =============================================================================
-- 扫码草稿表scan_draft
--
-- 背景
-- 扫码出库/借库的作业过程可能很长(一张单几十项),工人常需中途暂停去处理
-- 更紧急的单据。改造前切换单据会直接清空已扫内容,刷新/退出页面更是全部丢失。
--
-- 库存已在申请审批通过时**预占**,暂停期间不会被他人抢走 —— 因此草稿只需
-- 记录「扫到哪了」,不涉及任何库存操作,纯状态保存。
--
-- 隔离粒度:(user_id, biz_type, request_id)
-- 一人一单 —— 每个人扫自己的草稿,互不影响;同一人可同时持有多张单据的草稿。
--
-- 为什么整单存一个 JSON而非每条明细一行
-- 1. 保存是「全量覆盖」语义,逐行存并无增量更新的收益;
-- 2. 恢复时需要物料名称/规格/库位等展示字段,逐行存方案只能回查申请单
-- 的 items_json —— 而历史单据的 items_json 不含 stock_id回查会错配。
-- 整单快照把展示字段一并存下,恢复时零依赖、对老单据同样可靠。
--
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
-- =============================================================================
BEGIN;
DROP TABLE IF EXISTS scan_draft;
CREATE TABLE scan_draft (
id SERIAL PRIMARY KEY,
user_id integer NOT NULL,
biz_type varchar(20) NOT NULL, -- outbound / borrow
request_id integer NOT NULL, -- 关联的申请单ID
request_no varchar(100), -- 单号快照,便于展示免联表
-- 整单快照:[{stock_id, source_table, sku, name, spec_model,
-- warehouse_location, available_quantity, out_quantity, price}, ...]
items_json jsonb NOT NULL DEFAULT '[]'::jsonb,
updated_at timestamp without time zone NOT NULL DEFAULT timezone('Asia/Shanghai', now()),
created_at timestamp without time zone NOT NULL DEFAULT timezone('Asia/Shanghai', now()),
CONSTRAINT uq_scan_draft UNIQUE (user_id, biz_type, request_id)
);
CREATE INDEX IF NOT EXISTS ix_scan_draft_lookup ON scan_draft(user_id, biz_type);
COMMIT;

View File

@ -197,6 +197,20 @@ def create_app():
except ImportError as e:
print(f"❌ 错误: MyRequests 模块导入失败: {e}")
# -----------------------------------------------------
# 2.85 扫码草稿(出库/借库作业中途暂停用)
# 按 (user_id, biz_type, request_id) 隔离,仅需登录态。
# 模型在此显式导入,确保审计监听器注册时已进入 db.metadata。
# -----------------------------------------------------
try:
from app.models.scan_draft import ScanDraft # noqa: F401
from app.api.v1.scan_draft import scan_draft_bp
app.register_blueprint(scan_draft_bp, url_prefix='/api/v1/scan-draft')
app.register_blueprint(scan_draft_bp, url_prefix='/api/scan-draft', name='scan_draft_legacy')
print("✅ ScanDraft 模块注册成功")
except ImportError as e:
print(f"❌ 错误: ScanDraft 模块导入失败: {e}")
# 2.9 初始化审计日志菜单和权限(防重复)
# -----------------------------------------------------
try:

View File

@ -65,7 +65,13 @@ def get_purchase_list():
page = int(request.args.get('page', 1))
per_page = int(request.args.get('limit', 20))
status = request.args.get('status')
status = int(status) if status is not None else None
status = int(status) if status not in (None, '', 'all') else None
# 搜索参数(与出库/报废记录同一套语义,便于用户迁移习惯)
keyword = (request.args.get('keyword') or '').strip()
search_type = (request.args.get('search_type') or 'all').strip()
start_date = (request.args.get('start_date') or '').strip()
end_date = (request.args.get('end_date') or '').strip()
user_id = get_current_user_id()
can_view_all = _user_has_purchase_perm()
@ -74,7 +80,11 @@ def get_purchase_list():
page=page,
per_page=per_page,
requester_id=None if can_view_all else user_id,
status=status
status=status,
keyword=keyword,
search_type=search_type,
start_date=start_date,
end_date=end_date,
)
# ★ 字段级价格过滤

View File

@ -0,0 +1,219 @@
# inventory-backend/app/api/v1/scan_draft.py
"""
扫码草稿接口(出库 / 借库共用)。
场景
----
扫码作业可能很长(一张单几十项),工人常需中途暂停去处理更紧急的单据。
改造前切换单据会清空已扫内容,刷新/退出页面则全部丢失。
由于库存在申请审批通过时已**预占**,暂停期间货不会被他人抢走 ——
因此草稿只记录「扫到哪了」,**不涉及任何库存操作**。即使草稿丢失也只是
需要重扫,不会造成库存错乱。
隔离
----
按 (user_id, biz_type, request_id) 隔离:一人一单,互不影响。
user_id 一律取自 JWT**不接受入参覆盖**,因此不可能读写他人的草稿。
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
import traceback
from app.extensions import db
from app.models.scan_draft import ScanDraft
scan_draft_bp = Blueprint('scan_draft', __name__)
BIZ_TYPES = {'outbound', 'borrow'}
def _parse_identity():
identity = get_jwt_identity()
if not identity:
return None, None, None, '用户未登录'
biz_type = (request.args.get('biz_type') or '').strip()
if biz_type not in BIZ_TYPES:
return None, None, None, f"biz_type 无效(仅支持 {', '.join(sorted(BIZ_TYPES))}"
request_id = request.args.get('request_id', type=int)
if not request_id:
return None, None, None, 'request_id 不能为空'
return int(identity), biz_type, request_id, None
def _find(user_id, biz_type, request_id):
return ScanDraft.query.filter_by(
user_id=user_id, biz_type=biz_type, request_id=request_id
).first()
# --------------------------------------------------------
# 读取草稿GET /api/v1/scan-draft?biz_type=outbound&request_id=123
# --------------------------------------------------------
@scan_draft_bp.route('', methods=['GET'])
@jwt_required()
def get_scan_draft():
"""
读取当前用户在某张单据上的扫码草稿。
返回 { items: [...完整购物车快照...], item_count, total_qty, updated_at }
无草稿时 items 为空数组(非 404便于前端直接使用
"""
try:
user_id, biz_type, request_id, err = _parse_identity()
if err:
return jsonify({'code': 400, 'msg': err}), 400
draft = _find(user_id, biz_type, request_id)
items = draft.get_items() if draft else []
return jsonify({
'code': 200, 'msg': 'success',
'data': {
'items': items,
'item_count': len(items),
'total_qty': round(sum(float(i.get('out_quantity') or 0) for i in items), 4),
'updated_at': (
draft.updated_at.strftime('%Y-%m-%d %H:%M:%S')
if draft and draft.updated_at else None
),
}
}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'读取草稿失败: {str(e)}'}), 500
# --------------------------------------------------------
# 保存草稿POST /api/v1/scan-draft
# Body: { biz_type, request_id, request_no?, items: [...完整购物车快照...] }
#
# ★ 全量覆盖语义:提交的 items 即当前完整清单。
# 前端把 cartItems 整体发过来即可,不必逐条比对增删 —— 逻辑单一,
# 也不会出现「已移除的物料在草稿里阴魂不散」。
#
# ★ 注意items 为空时会**删除**该单据的草稿(而非存一个空草稿)。
# 这符合「清空列表 = 放弃这次作业」的直觉,也避免残留空记录影响徽标。
# --------------------------------------------------------
@scan_draft_bp.route('', methods=['POST'])
@jwt_required()
def save_scan_draft():
try:
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
user_id = int(identity)
data = request.get_json(silent=True) or {}
biz_type = (data.get('biz_type') or '').strip()
if biz_type not in BIZ_TYPES:
return jsonify({'code': 400, 'msg': f"biz_type 无效(仅支持 {', '.join(sorted(BIZ_TYPES))}"}), 400
try:
request_id = int(data.get('request_id'))
except (TypeError, ValueError):
return jsonify({'code': 400, 'msg': 'request_id 无效'}), 400
request_no = (data.get('request_no') or '')[:100]
items = data.get('items') or []
draft = _find(user_id, biz_type, request_id)
# 空清单 → 删除草稿
if not items:
if draft:
db.session.delete(draft)
db.session.commit()
return jsonify({'code': 200, 'msg': 'success',
'data': {'saved': 0, 'cleared': True}}), 200
if draft is None:
draft = ScanDraft(user_id=user_id, biz_type=biz_type, request_id=request_id)
db.session.add(draft)
draft.request_no = request_no
draft.set_items(items)
db.session.commit()
return jsonify({
'code': 200, 'msg': 'success',
'data': {'saved': len(items)}
}), 200
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'保存草稿失败: {str(e)}'}), 500
# --------------------------------------------------------
# 清除草稿DELETE /api/v1/scan-draft?biz_type=outbound&request_id=123
# 提交成功后调用,避免下次打开该单据时恢复出已提交的内容。
# --------------------------------------------------------
@scan_draft_bp.route('', methods=['DELETE'])
@jwt_required()
def clear_scan_draft():
try:
user_id, biz_type, request_id, err = _parse_identity()
if err:
return jsonify({'code': 400, 'msg': err}), 400
draft = _find(user_id, biz_type, request_id)
deleted = 0
if draft:
db.session.delete(draft)
db.session.commit()
deleted = 1
return jsonify({'code': 200, 'msg': 'success', 'data': {'deleted': deleted}}), 200
except Exception as e:
db.session.rollback()
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'清除草稿失败: {str(e)}'}), 500
# --------------------------------------------------------
# 草稿概览GET /api/v1/scan-draft/overview?biz_type=outbound
#
# 供单据下拉显示「已扫 N 项」进度徽标 —— 工人扫开页面就能看到哪张单
# 之前扫到一半,不必逐个点开试。
# --------------------------------------------------------
@scan_draft_bp.route('/overview', methods=['GET'])
@jwt_required()
def get_draft_overview():
try:
identity = get_jwt_identity()
if not identity:
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
biz_type = (request.args.get('biz_type') or '').strip()
if biz_type not in BIZ_TYPES:
return jsonify({'code': 400, 'msg': 'biz_type 无效'}), 400
rows = ScanDraft.query.filter_by(
user_id=int(identity), biz_type=biz_type
).all()
drafts = []
for r in rows:
items = r.get_items()
if not items:
continue # 防御:残留的空草稿不参与展示
drafts.append({
'request_id': r.request_id,
'request_no': r.request_no or '',
'item_count': len(items),
'total_qty': round(sum(float(i.get('out_quantity') or 0) for i in items), 4),
'updated_at': r.updated_at.strftime('%Y-%m-%d %H:%M:%S') if r.updated_at else None,
})
return jsonify({'code': 200, 'msg': 'success', 'data': {'drafts': drafts}}), 200
except Exception as e:
traceback.print_exc()
return jsonify({'code': 500, 'msg': f'获取草稿概览失败: {str(e)}'}), 500

View File

@ -0,0 +1,59 @@
import json
from app.extensions import db, beijing_time
class ScanDraft(db.Model):
"""
扫码草稿(出库 / 借库作业中途暂停用)
场景:一张单几十项,工人扫到一半需要去处理更紧急的单据,事后回来继续。
库存已在申请审批通过时预占,暂停期间不会被他人抢走,因此草稿只需记录
「扫到哪了」,不涉及库存操作。
隔离粒度 (user_id, biz_type, request_id):一人一单;同一人可同时持有
多张单据的草稿(正是「暂停 A 去出 B」的场景
为什么整单存 JSON 而非逐条明细行:
恢复时需要物料名称/规格/库位等展示字段。逐行存方案只能回查申请单的
items_json —— 而历史单据的 items_json 不含 stock_id回查会错配。
整单快照把展示字段一并存下,恢复时零依赖,对老单据同样可靠。
"""
__tablename__ = 'scan_draft'
__table_args__ = (
db.UniqueConstraint('user_id', 'biz_type', 'request_id', name='uq_scan_draft'),
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, nullable=False, index=True)
biz_type = db.Column(db.String(20), nullable=False) # outbound / borrow
request_id = db.Column(db.Integer, nullable=False)
request_no = db.Column(db.String(100)) # 单号快照,展示免联表
# ★ 必须用 db.JSON 而非 db.Text —— 数据库列是 jsonb
# (见 db_migrations/add_scan_draft.sql
# db.Text 会让 psycopg2 拿到 Python list/dict 时无法适配,
# 报 "can't adapt type 'dict'";而用 db.Text 存 json.dumps 的字符串
# 又会与 jsonb 列的类型语义打架。类型一致才是正解。
items_json = db.Column(db.JSON, nullable=False, default=list)
updated_at = db.Column(db.DateTime, default=beijing_time, onupdate=beijing_time, nullable=False)
created_at = db.Column(db.DateTime, default=beijing_time, nullable=False)
def set_items(self, items):
self.items_json = items or []
def get_items(self):
v = self.items_json
if not v:
return []
if isinstance(v, list):
return v
# 兼容历史数据(若该列曾被写成 JSON 字符串)
try:
parsed = json.loads(v)
return parsed if isinstance(parsed, list) else []
except (ValueError, TypeError):
return []
@property
def item_count(self):
return len(self.get_items())

View File

@ -180,11 +180,20 @@ class PurchaseService:
return purchase
@staticmethod
def get_purchase_list(page=1, per_page=20, requester_id=None, status=None):
"""获取采购申请列表,普通用户只看自己的,主管/超管看同公司全部"""
def get_purchase_list(page=1, per_page=20, requester_id=None, status=None,
keyword=None, search_type='all',
start_date=None, end_date=None):
"""
获取采购申请列表,普通用户只看自己的,主管/超管看同公司全部。
搜索参数(与出库/报废记录保持同一套语义,便于用户迁移习惯):
keyword 关键词
search_type all / no(单号) / name(物料名称) / spec_model / requester(申请人)
start_date / end_date 按采购日期过滤(含边界)
"""
from app.utils.decorators import get_current_company_filter
from app.models.system import SysUser
from sqlalchemy import or_
from sqlalchemy import or_, cast, String
query = PurchaseRequest.query
@ -194,15 +203,57 @@ class PurchaseService:
if status is not None:
query = query.filter(PurchaseRequest.status == status)
# ---- 关键词搜索 ----
#
# 申请人姓名存在 SysUser.username格式为「姓名/账号」(如 韩善龙/hanshanlong
# 因此按「姓名」搜索时用 ilike 直接匹配整串即可命中。
# 该类字段只存在于 SysUser故需要 join单号/名称/规格则不需要 ——
# 只在必要时 join避免无谓的联表。
needs_user = bool(keyword) and search_type in ('requester', 'all')
if needs_user:
query = query.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id)
if keyword:
kw = f'%{keyword}%'
if search_type == 'no':
query = query.filter(PurchaseRequest.request_no.ilike(kw))
elif search_type == 'name':
query = query.filter(PurchaseRequest.name.ilike(kw))
elif search_type == 'spec_model':
query = query.filter(PurchaseRequest.spec_model.ilike(kw))
elif search_type == 'requester':
query = query.filter(SysUser.username.ilike(kw))
else: # all —— 覆盖单号 / 名称 / 规格 / 备注 / 申请人
query = query.filter(or_(
PurchaseRequest.request_no.ilike(kw),
PurchaseRequest.name.ilike(kw),
PurchaseRequest.spec_model.ilike(kw),
PurchaseRequest.remark.ilike(kw),
SysUser.username.ilike(kw),
))
# ---- 日期范围(按采购日期)----
if start_date:
query = query.filter(PurchaseRequest.purchase_date >= start_date)
if end_date:
query = query.filter(PurchaseRequest.purchase_date <= end_date)
# 【行级数据隔离】同公司可见:匹配 MaterialBase.company_name 或 SysUser.department
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.outerjoin(MaterialBase, PurchaseRequest.base_id == MaterialBase.id) \
.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id) \
.filter(or_(
MaterialBase.company_name == company_limit,
SysUser.department == company_limit
))
query = query.outerjoin(MaterialBase, PurchaseRequest.base_id == MaterialBase.id)
# SysUser 可能已被上面的关键词分支 join 过 —— 重复 join 会产生
# 笛卡尔积导致结果翻倍,此处按需补 join。
if not needs_user:
query = query.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id)
query = query.filter(or_(
MaterialBase.company_name == company_limit,
SysUser.department == company_limit
))
# ★ 兜底去重:上述 join 在特定组合下(如 base_id 为空 + requester 匹配)
# 仍可能产生重复行,用 distinct 保证每个单据只出现一次。
query = query.distinct()
query = query.order_by(PurchaseRequest.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)

View File

@ -239,3 +239,55 @@ export function getMyRequests(params: {
params
})
}
// ==========================================
// 扫码草稿(出库/借库作业中途暂停用)
// ==========================================
export interface ScanDraftItem {
stock_id: number
source_table: string
sku?: string
quantity: number
}
/** 读取草稿:返回该用户在这张单上已扫的内容 */
export function getScanDraft(bizType: 'outbound' | 'borrow', requestId: number) {
return request({
url: '/v1/scan-draft',
method: 'get',
params: { biz_type: bizType, request_id: requestId }
})
}
/** 保存草稿:全量覆盖(提交的 items 即当前完整清单) */
export function saveScanDraft(
bizType: 'outbound' | 'borrow',
requestId: number,
items: ScanDraftItem[],
requestNo?: string
) {
return request({
url: '/v1/scan-draft',
method: 'post',
data: { biz_type: bizType, request_id: requestId, request_no: requestNo || '', items }
})
}
/** 清除草稿:提交成功后调用 */
export function clearScanDraft(bizType: 'outbound' | 'borrow', requestId: number) {
return request({
url: '/v1/scan-draft',
method: 'delete',
params: { biz_type: bizType, request_id: requestId }
})
}
/** 草稿概览:供单据下拉显示「已扫 N 项」进度徽标 */
export function getScanDraftOverview(bizType: 'outbound' | 'borrow') {
return request({
url: '/v1/scan-draft/overview',
method: 'get',
params: { biz_type: bizType }
})
}

View File

@ -46,10 +46,18 @@ export interface Approver {
}
// 获取采购申请列表
//
// 搜索参数语义与出库/报废记录保持一致:
// search_type: all / no(单号) / name(物料名称) / spec_model / requester(申请人)
// start_date / end_date: 按采购日期过滤
export function getPurchaseList(params: {
page?: number
limit?: number
status?: number
status?: number | ''
keyword?: string
search_type?: 'all' | 'no' | 'name' | 'spec_model' | 'requester'
start_date?: string
end_date?: string
}) {
return request({
url: '/purchase',

View File

@ -30,9 +30,11 @@
<el-table-column type="expand" width="60" align="center">
<template #default="{ row }">
<div style="padding: 12px 24px; background: #f5f7fa;">
<p style="margin: 0 0 10px 0; font-weight: bold; font-size: 13px; color: #606266;">
物料明细 {{ row.items?.length || 0 }}
</p>
<!-- 与扫码出库页的计划出库清单标题样式对齐 -->
<div class="planned-header">
<span class="planned-title">计划借用清单</span>
<el-tag type="success" size="small">{{ row.items?.length || 0 }} </el-tag>
</div>
<el-table
v-if="row.items?.length"
:data="row.items"
@ -41,6 +43,15 @@
style="width: 100%;"
>
<el-table-column type="index" label="序号" width="60" align="center" />
<!-- 计划数量前置与扫码出库页保持一致
原先排在表格最右库位之后列多时容易被容器裁掉看不见 -->
<el-table-column label="计划数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #E6A23C; font-weight: bold; font-size: 15px;">
{{ item.quantity ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<!-- 库位 + 备选库位现场进不去时可改扫其它批次 -->
@ -84,11 +95,6 @@
<span v-else>{{ item.warehouse_location || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="quantity" label="计划数量" width="100" align="center">
<template #default="{ row: item }">
<span style="color: #F56C6C; font-weight: bold;">{{ item.quantity ?? '-' }}</span>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无物料明细" :image-size="60" />
</div>
@ -494,6 +500,15 @@ onMounted(() => {
</script>
<style scoped>
/* ★ 计划清单标题:与扫码出库页的「计划出库清单」保持一致 */
.planned-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* ★ 备选库位:库位旁的图标与浮层(与出库执行页同款) */
/* 库位格子整体可点击:工人不必精准点小图标,点单元格任意位置即可 */
.loc-cell {

View File

@ -39,6 +39,10 @@
:label="req.request_no"
>
<span>{{ req.request_no }}</span>
<!-- 草稿进度徽标一眼看出哪张单之前扫到一半不必逐个点开试 -->
<el-tag v-if="draftProgress[req.id]" type="warning" size="small" effect="dark" style="margin-left:6px">
已扫 {{ draftProgress[req.id] }}
</el-tag>
<el-divider direction="vertical" />
<span>{{ req.applicant_name || '未知申请人' }}</span>
<el-divider direction="vertical" />
@ -372,10 +376,14 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives } from '@/api/outbound'
import {
getStockByBarcode, submitOutbound, getOutboundList, getApprovalRequestList, getStockAlternatives,
getScanDraft, saveScanDraft, clearScanDraft, getScanDraftOverview,
} from '@/api/outbound'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -516,6 +524,7 @@ const loadApprovalRequests = async () => {
try {
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
approvalRequests.value = res.data?.items || []
await loadDraftOverview() // ★ 一并取草稿进度,用于下拉徽标
} catch (e) {
console.error('加载审批单列表失败', e)
} finally {
@ -523,7 +532,12 @@ const loadApprovalRequests = async () => {
}
}
const handleRequestChange = (val: number | null) => {
const handleRequestChange = async (val: number | null) => {
// ★ 关键:此刻 selectedRequest 已被 v-model 的 setter 改成了新单,
// 所以必须用 activeRequestId 拿到「刚离开的那张单」来保存。
const leavingId = activeRequestId.value
await saveDraftNow(leavingId)
if (!val) {
selectedRequest.value = null
form.remark = ''
@ -538,10 +552,187 @@ const handleRequestChange = (val: number | null) => {
form.outbound_type = selectedRequest.value.outbound_type
}
}
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
cartItems.value = []
signatureFile.value = null
signaturePreviewUrl.value = ''
activeRequestId.value = val // ★ 切换到新单,后续保存以它为准
// ★ 载入目标单据的草稿(若之前扫到一半,这里自动恢复)
if (val) await restoreDraft(val)
}
// ============================================================================
// ★ 扫码草稿
//
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来还要接着扫。
// 库存在申请审批通过时已**预占**,暂停期间不会被他人抢走,因此草稿只需
// 记住「扫到哪了」,不涉及任何库存操作 —— 即使草稿丢了也只是重扫,
// 不会造成库存错乱。
//
// 保存时机:扫码后防抖 800ms 自动存;切换单据/离开页面时立即存。
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
// ============================================================================
let draftTimer: ReturnType<typeof setTimeout> | null = null
// 各单据的草稿进度 { request_id: 已扫项数 },用于下拉徽标
const draftProgress = ref<Record<number, number>>({})
const loadDraftOverview = async () => {
try {
const res: any = await getScanDraftOverview('outbound')
const map: Record<number, number> = {}
for (const d of res?.data?.drafts || []) {
map[d.request_id] = d.item_count
}
draftProgress.value = map
} catch (e) {
console.warn('加载草稿概览失败', e)
}
}
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
// 原先只存 (stock_id, source_table, sku, quantity),恢复后名称/规格/库存
// 都是空的、出库数显示 NaN —— 因为购物车表格绑定的是 name / spec_model /
// available_quantity / out_quantity而这些字段根本没被存下来。
const draftPayload = () => cartItems.value.map((it: any) => ({
id: it.id,
source_table: it.source_table,
sku: it.sku || '',
name: it.name || '',
spec_model: it.spec_model || '',
warehouse_location: it.warehouse_location || '',
barcode: it.barcode || '',
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
out_quantity: Number(it.out_quantity) || 0,
}))
// 监听中的单据ID —— 用于精确知道「哪张单的草稿需要存」
//
// ★ 为什么需要它v-model="selectedRequestId" 的 computed setter 会**先于**
// @change 把 selectedRequest 改成新单据。若在 handleRequestChange 里读
// selectedRequest.value拿到的已经是新单 —— 会把 A 的内容存到 B 名下。
// 用一个独立的 ref 记录「当前界面上显示的是哪张单」,保存时以此为准。
const activeRequestId = ref<number | null>(null)
// 立即保存(切换单据、离开页面、提交前调用)
//
// 显式接收 requestId切换场景下必须传「旧单ID」不能依赖 selectedRequest。
const saveDraftNow = async (requestId?: number | null) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const rid = requestId ?? activeRequestId.value
if (!rid) return
const items = draftPayload()
// ★ 空清单**不清除草稿**。
// 原先"空则清除"是为了处理用户主动清空列表的情况,但它会在切换单据时
// 误删目标单的旧草稿(切换瞬间购物车必然为空)—— 这正是"切回来就没了"
// 的直接原因。现在空清单只是不保存,清理由「提交成功」或用户显式清空触发。
if (items.length === 0) return
try {
await saveScanDraft('outbound', rid, items, selectedRequest.value?.request_no)
draftProgress.value[rid] = items.length
} catch (e) {
// 草稿保存失败不阻断作业:工人手上还在扫,报错反而干扰
console.warn('保存草稿失败', e)
}
}
// 防抖保存(扫码后调用)
const scheduleSaveDraft = () => {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraftNow(), 800)
}
// 恢复草稿到购物车
//
// 草稿存的是完整购物车快照(含名称/规格/库位等展示字段),因此直接还原即可,
// 无需回查申请单的 items_json —— 历史单据的 items_json 不含 stock_id
// 回查反而会错配。
const restoreDraft = async (requestId: number) => {
try {
const res: any = await getScanDraft('outbound', requestId)
const items = res?.data?.items || []
if (!items.length) return
// 归一化:老草稿用 quantity新草稿用 out_quantity缺失字段补默认值
// 避免购物车出现 NaNNaN 会在提交时被兜底成 1造成静默的数量错误
cartItems.value = items.map((it: any) => ({
...it,
id: it.id ?? it.stock_id,
out_quantity: Number(it.out_quantity ?? it.quantity) || 0,
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
name: it.name || '',
spec_model: it.spec_model || '',
}))
ElMessage.success(`已恢复上次的扫码进度(${items.length} 项)`)
// ★ 刷新库存:草稿里的 available_quantity 是**扫描那一刻**的快照,
// 若期间别人出库了,界面会显示陈旧数字,工人扫满后到提交时才被
// 后端拒绝(白扫一场)。这里重新拉一次实时可用量。
await refreshStockFromDraft()
} catch (e) {
console.warn('恢复草稿失败', e)
}
}
/**
* 用实时库存刷新购物车行的「库存」列。
*
* 草稿里的 available_quantity 是扫描那一刻的快照,恢复时可能已过期
* (期间别人出库/借出/报废都会消耗可用量)。这里按 base_id 查一次实时
* 可用量,避免工人基于陈旧数字扫满、到提交时才发现不够。
*
* 复用 /alternatives 端点 —— 它本来就按 base_id 返回各库存行的实时可用量。
* 失败不阻断:拿不到实时值就沿用草稿快照,至少不影响继续作业。
*/
const refreshStockFromDraft = async () => {
const plan = selectedRequest.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
try {
const results = await Promise.all(
(baseIds as number[]).map(bid => getStockAlternatives(bid).catch(() => null))
)
const latestByKey = new Map<string, number>()
for (const r of results) {
for (const a of ((r as any)?.data?.items || [])) {
latestByKey.set(`${a.source_table}_${a.stock_id}`, Number(a.available_quantity) || 0)
}
}
if (!latestByKey.size) return
let changed = 0
for (const row of cartItems.value) {
const key = `${row.source_table}_${row.id}`
if (!latestByKey.has(key)) continue
const latest = latestByKey.get(key)!
if (latest !== Number(row.available_quantity)) {
row.available_quantity = latest
changed++
}
}
// 已扫数量超过最新可用量 → 明确提示,避免到提交时才发现
const over = cartItems.value.filter(
(r: any) => Number(r.out_quantity) > Number(r.available_quantity)
)
if (over.length > 0) {
ElMessage.warning(
`${over.length} 项物料的实际库存已少于你扫的数量` +
`(如 ${over[0].name || over[0].sku}),请核对后再提交`
)
} else if (changed > 0) {
ElMessage.info(`已刷新 ${changed} 项物料的实时库存`)
}
} catch (e) {
console.warn('刷新库存失败', e) // 失败沿用草稿快照
}
}
// ★ 按单出库模式:校验扫码是否在计划内
@ -710,6 +901,7 @@ const handleManualInput = async () => {
out_quantity: 1,
price: parseFloat(item.price || 0)
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
@ -753,6 +945,12 @@ const clearAll = () => {
signaturePreviewUrl.value = ''
barcodeInput.value = ''
// ★ 按单模式:仅清空购物车,保留申请单选择
// 用户显式清空 = 放弃本次作业 → 同步清除草稿与徽标
const rid = activeRequestId.value
if (rid) {
clearScanDraft('outbound', rid).catch(() => {})
delete draftProgress.value[rid]
}
})
}
@ -769,6 +967,8 @@ const submitForm = async () => {
ElMessage.warning('请先选择要出库的审批申请单')
return
}
// 记下单据ID清空 cartItems 后 selectedRequest 可能被重置,草稿清除需要它
const submittedRequestId = selectedRequest.value.id
await formRef.value.validate(async (valid: boolean) => {
if (!valid) return
@ -828,6 +1028,12 @@ const submitForm = async () => {
ElMessage.success('出库成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开时恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('outbound', submittedRequestId).catch(() => {})
delete draftProgress.value[submittedRequestId] // 同步移除下拉徽标
}
// 5. 成功后重置页面
cartItems.value = []
form.consumer_name = ''
@ -926,7 +1132,57 @@ const handleSignCancel = () => { showSignatureDialog.value = false }
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
})
// ★ 离开页面前:立即存草稿(防抖中未落盘的内容会丢)
//
// 只有清单非空时才提示 —— 空清单离开是正常操作,弹窗只会造成干扰。
// 草稿已在后端,所以提示文案是"可恢复"而非"会丢失",语气相应放松。
onBeforeRouteLeave(async (_to, _from, next) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const hasItems = cartItems.value.length > 0 && selectedRequest.value?.id
if (hasItems) {
await saveDraftNow() // 先确保落盘,再询问
try {
await ElMessageBox.confirm(
`当前单据【${selectedRequest.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗`,
'离开确认',
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
)
} catch (e) {
return next(false) // 用户选择留下
}
}
next()
})
// 刷新/关闭浏览器时同步存草稿
//
// 注意:不能用异步请求(浏览器不会等待),改用 sendBeacon 在卸载期间
// 投递一个"尽力而为"的保存。失败也无妨 —— 扫码过程中的防抖保存已经
// 落盘了绝大部分内容,这里只补最后 800ms 的差量。
const handleBeforeUnload = () => {
const req = selectedRequest.value
if (!req?.id || cartItems.value.length === 0) return
try {
const token = localStorage.getItem('token') || ''
const blob = new Blob([JSON.stringify({
biz_type: 'outbound', request_id: req.id, request_no: req.request_no,
items: draftPayload(),
})], { type: 'application/json' })
// sendBeacon 无法自定义 header故用查询参数带上 token仅用于此处的
// 非敏感草稿保存;后端仍以 JWT 校验身份,缺失时该次保存被忽略)
navigator.sendBeacon?.(
`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob
)
} catch (e) { /* 尽力而为,失败不影响 */ }
}
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) })
onUnmounted(() => { window.removeEventListener('beforeunload', handleBeforeUnload) })
</script>
<style scoped>

View File

@ -1,22 +1,60 @@
<template>
<div class="app-container">
<!-- 顶部工具栏 -->
<div class="filter-container">
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<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>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
新建采购申请
</el-button>
</div>
<!-- 顶部工具栏搜索 + 状态筛选搜索区对齐出库记录版式 -->
<el-form :inline="true" class="filter-form" @submit.prevent>
<el-form-item label="状态">
<el-radio-group v-model="filterStatus" @change="handleStatusChange">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button :label="0">待审批</el-radio-button>
<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-form-item>
<el-form-item label="搜索">
<el-input
v-model="keyword"
placeholder="输入关键词"
style="width: 240px"
clearable
@input="debouncedSearch"
@clear="handleClearSearch"
>
<template #prepend>
<el-select v-model="searchType" placeholder="搜索类型" style="width: 110px" @change="handleSearchImmediate">
<el-option label="全部" value="all" />
<el-option label="单号" value="no" />
<el-option label="物料名称" value="name" />
<el-option label="规格型号" value="spec_model" />
<el-option label="申请人" value="requester" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="采购日期">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
style="width: 260px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Refresh" @click="fetchData">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<el-button type="success" :icon="Plus" @click="openCreateDialog">
新建采购申请
</el-button>
</el-form-item>
</el-form>
<!-- 按批次分组的列表 -->
<el-table v-loading="loading" :data="list" border stripe fit style="width: 100%; margin-top: 16px;" row-key="batchKey">
@ -441,6 +479,43 @@ const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
// --- 搜索(语义与出库/报废记录一致)---
const keyword = ref('')
const searchType = ref<'all' | 'no' | 'name' | 'spec_model' | 'requester'>('all')
const dateRange = ref<string[]>([])
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const debouncedSearch = () => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
page.value = 1
fetchData()
}, 500)
}
const handleClearSearch = () => {
if (debounceTimer) clearTimeout(debounceTimer)
page.value = 1
fetchData()
}
// 搜索类型切换:立即查询(取消防抖等待)
const handleSearchImmediate = () => {
if (debounceTimer) clearTimeout(debounceTimer)
page.value = 1
fetchData()
}
const resetFilter = () => {
if (debounceTimer) clearTimeout(debounceTimer)
filterStatus.value = 0 // 回到默认的「待审批」
keyword.value = ''
searchType.value = 'all'
dateRange.value = []
page.value = 1
fetchData()
}
const submitLoading = ref(false)
const rejectLoading = ref(false)
@ -772,8 +847,17 @@ const formatUserName = (name: string): string => {
const fetchData = async () => {
loading.value = true
try {
const params: any = { page: page.value, limit: pageSize.value }
const params: any = {
page: page.value,
limit: pageSize.value,
keyword: keyword.value.trim(),
search_type: searchType.value,
}
if (filterStatus.value !== '') params.status = filterStatus.value
if (dateRange.value && dateRange.value.length === 2) {
params.start_date = dateRange.value[0]
params.end_date = dateRange.value[1]
}
const res: any = await getPurchaseList(params)
const items = res.data?.items || []
@ -1323,6 +1407,16 @@ onMounted(() => {
</script>
<style scoped>
/* ★ 搜索区版式:与出库记录 / 报废记录保持一致 */
.filter-form {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.filter-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.app-container { padding: 20px; }
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }

View File

@ -39,25 +39,63 @@
<p class="select-tip">仅显示已通过status=1的审批单</p>
</div>
<!-- 审批计划清单预览 -->
<!-- 计划借用清单预览与扫码出库页的计划出库清单对齐 -->
<div v-if="selectedApproval" class="planned-items-section">
<div class="planned-header">
<span class="planned-title">审批计划清单</span>
<span class="planned-title">计划借用清单</span>
<el-tag type="success" size="small">{{ plannedItems.length }} </el-tag>
</div>
<el-table :data="plannedItems" border size="small" style="width: 100%;">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column label="类型" width="80" align="center">
<!-- 计划数量前置与出库页一致原先排在表格最右列多时易被裁掉 -->
<el-table-column label="计划数量" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" type="info">{{ row.material_type || '-' }}</el-tag>
<span style="color: #E6A23C; font-weight: bold; font-size: 15px;">
{{ row.quantity ?? '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
<el-table-column prop="spec_model" label="规格" min-width="100" show-overflow-tooltip />
<el-table-column prop="warehouse_location" label="库位" width="100" show-overflow-tooltip />
<el-table-column label="审批数量" width="90" align="center">
<!-- 库位 + 备选库位现场进不去时可改扫其它批次 -->
<el-table-column label="库位" width="170">
<template #default="{ row }">
<span style="color: #E6A23C; font-weight: bold;">{{ row.quantity ?? '-' }}</span>
<el-popover
v-if="row.base_id"
placement="right"
:width="340"
trigger="click"
@show="loadAlternatives(row)"
>
<!-- 整个库位格子都是点击热区工人不必精准点图标 -->
<template #reference>
<span class="loc-cell" title="点击查看该物料的所有可选库位">
<span class="loc-text">{{ row.warehouse_location || '-' }}</span>
<el-icon class="alt-icon"><LocationInformation /></el-icon>
</span>
</template>
<div v-loading="altLoading" class="alt-panel">
<div class="alt-title">
该物料的可选库位
<span v-if="altTotal > 0" class="alt-total">合计可用 {{ altTotal }}</span>
</div>
<div v-if="!altLoading && altItems.length === 0" class="alt-empty">
暂无其它可用库位
</div>
<div v-for="a in altItems" :key="a.source_table + '_' + a.stock_id" class="alt-row">
<el-tag :type="a.is_locked ? 'success' : 'info'" size="small" effect="plain">
{{ a.is_locked ? '推荐' : '备选' }}
</el-tag>
<span class="alt-loc">{{ a.warehouse_location || '(无库位)' }}</span>
<span class="alt-qty">可用 {{ a.available_quantity }}</span>
</div>
<div v-if="altItems.length" class="alt-hint">
现场取不到推荐库位时可直接扫备选库位的条码借用
</div>
</div>
</el-popover>
<span v-else>{{ row.warehouse_location || '-' }}</span>
</template>
</el-table-column>
</el-table>
@ -317,10 +355,11 @@
<script setup lang="ts">
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
import QrScanner from '@/components/QrScanner/index.vue'
import { getStockByBarcode } from '@/api/outbound'
import { getStockByBarcode, getStockAlternatives, getScanDraft, saveScanDraft, clearScanDraft } from '@/api/outbound'
import { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
import { uploadFile } from '@/api/common/upload'
import { useUserStore } from '@/stores/user'
@ -367,6 +406,37 @@ const selectedApproval = computed(() =>
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
// ============================================================================
// ★ 备选库位(与扫码出库页同款)
//
// 申请单已把货预占在某库位,工人现场可能进不去,需要改扫同物料的其它批次
// (后端执行端按 base_id 校验身份、允许换批次)。此处复用出库模块的
// /alternatives 端点:按 available_quantity > 0 过滤,只给真正能拿的库位。
// 点击图标才发请求,避免展开单据时打出一片并发查询。
// ============================================================================
const altLoading = ref(false)
const altItems = ref<any[]>([])
const altTotal = ref(0)
const loadAlternatives = async (row: any) => {
if (!row?.base_id) return
altLoading.value = true
altItems.value = []
altTotal.value = 0
try {
const res: any = await getStockAlternatives(row.base_id, {
stock_id: row.stock_id,
source_table: row.source_table,
})
altItems.value = res?.data?.items || []
altTotal.value = res?.data?.total_available || 0
} catch (e) {
ElMessage.error('查询备选库位失败')
} finally {
altLoading.value = false
}
}
// ★ 扫码进度:总需扫数(计划数量总和)/ 已扫数(购物车数量总和)
const scanTotalQty = computed(() =>
plannedItems.value.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0)
@ -417,7 +487,12 @@ const loadApprovalRequests = async () => {
}
// ★ 切换审批单时:带出申请时填写的归还日期/长期借用,并清空购物车和签名,防止跨单据污染
const handleApprovalChange = (val: number | null) => {
const handleApprovalChange = async (val: number | null) => {
// ★ 关键selectedApproval 已被 computed 更新为新单,
// 故用 activeApprovalId 拿到「刚离开的那张单」来保存
const leavingId = activeApprovalId.value
await saveDraftNow(leavingId)
if (!val) {
selectedApprovalId.value = null
}
@ -441,6 +516,140 @@ const handleApprovalChange = (val: number | null) => {
signatureFile.value = null
signaturePreviewUrl.value = ''
barcodeInput.value = ''
activeApprovalId.value = val // ★ 切换到新单
// ★ 载入目标单据的草稿(之前扫到一半则自动恢复)
if (val) await restoreDraft(val)
}
// ============================================================================
// ★ 扫码草稿(与扫码出库页同款)
//
// 场景:一张单几十项,扫到一半被更紧急的单打断,回来接着扫。
// 库存在申请审批通过时已预占,暂停期间不会被他人抢走,故草稿只记「扫到哪了」。
// 隔离:后端按 (user_id, 单据ID) 隔离,一人一单互不影响。
// ============================================================================
let draftTimer: ReturnType<typeof setTimeout> | null = null
// ★ 必须保存购物车行的**全部展示字段**,不能只存定位信息。
// 原先只存 4 个字段,恢复后名称/库存为空、借用数显示 NaN。
const draftPayload = () => cartItems.value.map((it: any) => ({
id: it.id,
source_table: it.source_table,
sku: it.sku || '',
name: it.name || '',
spec_model: it.spec_model || '',
warehouse_location: it.warehouse_location || '',
barcode: it.barcode || '',
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
out_quantity: Number(it.out_quantity) || 0,
}))
// 监听中的单据ID
//
// ★ 为什么需要它selectedApproval 是由 selectedApprovalId 派生的 computed
// 切换时它**立刻**变成新单。若在 handleApprovalChange 里读 selectedApproval
// 拿到的已经是新单 —— 会把 A 的内容存到 B 名下。用一个独立的 ref 记录
// 「当前界面上显示的是哪张单」,保存时以此为准。
const activeApprovalId = ref<number | null>(null)
const saveDraftNow = async (requestId?: number | null) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const rid = requestId ?? activeApprovalId.value
if (!rid) return
const items = draftPayload()
// ★ 空清单不清除草稿:切换单据瞬间购物车必然为空,若此时清除会误删
// 目标单的旧草稿(这正是"切回来就没了"的直接原因)。清理由提交成功触发。
if (items.length === 0) return
try {
await saveScanDraft('borrow', rid, items, selectedApproval.value?.request_no)
} catch (e) {
console.warn('保存草稿失败', e) // 不阻断作业
}
}
const scheduleSaveDraft = () => {
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(() => saveDraftNow(), 800)
}
const restoreDraft = async (requestId: number) => {
try {
const res: any = await getScanDraft('borrow', requestId)
const items = res?.data?.items || []
if (!items.length) return
// 归一化:老草稿用 quantity新草稿用 out_quantity缺失字段补默认值
// 避免购物车出现 NaNNaN 提交时会被兜底成 1造成静默的数量错误
cartItems.value = items.map((it: any) => ({
...it,
id: it.id ?? it.stock_id,
out_quantity: Number(it.out_quantity ?? it.quantity) || 0,
available_quantity: Number(it.available_quantity) || 0,
price: Number(it.price) || 0,
name: it.name || '',
spec_model: it.spec_model || '',
}))
ElMessage.success(`已恢复上次的扫码进度(${items.length} 项)`)
// ★ 刷新库存:草稿里的 available_quantity 是扫描那一刻的快照,
// 期间别人出库/借出会消耗可用量,需重新拉取实时值。
await refreshStockFromDraft()
} catch (e) {
console.warn('恢复草稿失败', e)
}
}
/**
* 用实时库存刷新购物车行的「库存」列(与扫码出库页同款)。
* 复用 /alternatives 端点;失败不阻断,沿用草稿快照。
*/
const refreshStockFromDraft = async () => {
const plan = selectedApproval.value?.items || []
const baseIds = [...new Set(plan.map((p: any) => p.base_id).filter(Boolean))]
if (!baseIds.length) return
try {
const results = await Promise.all(
(baseIds as number[]).map(bid => getStockAlternatives(bid).catch(() => null))
)
const latestByKey = new Map<string, number>()
for (const r of results) {
for (const a of ((r as any)?.data?.items || [])) {
latestByKey.set(`${a.source_table}_${a.stock_id}`, Number(a.available_quantity) || 0)
}
}
if (!latestByKey.size) return
let changed = 0
for (const row of cartItems.value) {
const key = `${row.source_table}_${row.id}`
if (!latestByKey.has(key)) continue
const latest = latestByKey.get(key)!
if (latest !== Number(row.available_quantity)) {
row.available_quantity = latest
changed++
}
}
const over = cartItems.value.filter(
(r: any) => Number(r.out_quantity) > Number(r.available_quantity)
)
if (over.length > 0) {
ElMessage.warning(
`${over.length} 项物料的实际库存已少于你扫的数量` +
`(如 ${over[0].name || over[0].sku}),请核对后再提交`
)
} else if (changed > 0) {
ElMessage.info(`已刷新 ${changed} 项物料的实时库存`)
}
} catch (e) {
console.warn('刷新库存失败', e)
}
}
// ★ 扫码校验:比对扫描物料是否在审批计划清单内,且累计数量不超过审批上限
@ -610,6 +819,7 @@ const handleManualInput = async () => {
out_quantity: 1,
price: 0
})
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
ElMessage.success(`添加成功: ${item.name}`)
if (navigator.vibrate) navigator.vibrate(100)
barcodeInput.value = ''
@ -665,6 +875,8 @@ const submitForm = async () => {
if (!formRef.value) return
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
// 记下单据ID清空 cartItems 后可能被重置,草稿清除需要它
const submittedRequestId = selectedApprovalId.value
await formRef.value.validate(async (valid: boolean) => {
if (!valid) {
@ -713,6 +925,12 @@ const submitForm = async () => {
})
ElMessage.success('借用成功')
// ★ 提交成功 → 清除该单据的草稿,避免下次打开恢复出已提交的内容
if (submittedRequestId) {
clearScanDraft('borrow', submittedRequestId).catch(() => {})
}
cartItems.value = []
form.borrower_name = ''
form.expected_return_time = ''
@ -814,7 +1032,46 @@ onMounted(() => {
onUnmounted(() => {
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
if (draftTimer) clearTimeout(draftTimer)
window.removeEventListener('beforeunload', handleBeforeUnload)
})
// ★ 离开页面前立即存草稿(防抖中未落盘的内容会丢)
onBeforeRouteLeave(async (_to, _from, next) => {
if (draftTimer) { clearTimeout(draftTimer); draftTimer = null }
const hasItems = cartItems.value.length > 0 && selectedApproval.value?.id
if (hasItems) {
await saveDraftNow()
try {
await ElMessageBox.confirm(
`当前单据【${selectedApproval.value.request_no}】已扫 ${cartItems.value.length} 项尚未提交。\n\n` +
`已自动保存为草稿,下次选择该单据时可继续扫码。\n确认离开吗`,
'离开确认',
{ confirmButtonText: '离开', cancelButtonText: '留下继续', type: 'warning' }
)
} catch (e) {
return next(false)
}
}
next()
})
// 刷新/关闭浏览器时尽力保存sendBeacon 不阻塞卸载)
const handleBeforeUnload = () => {
const req = selectedApproval.value
if (!req?.id || cartItems.value.length === 0) return
try {
const token = localStorage.getItem('token') || ''
const blob = new Blob([JSON.stringify({
biz_type: 'borrow', request_id: req.id, request_no: req.request_no,
items: draftPayload(),
})], { type: 'application/json' })
navigator.sendBeacon?.(`/api/v1/scan-draft?token=${encodeURIComponent(token)}`, blob)
} catch (e) { /* 尽力而为 */ }
}
onMounted(() => { window.addEventListener('beforeunload', handleBeforeUnload) })
</script>
<style scoped>
@ -830,10 +1087,67 @@ onUnmounted(() => {
.approval-request-select { margin-bottom: 16px; }
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
/* 计划清单 */
.planned-items-section { margin-bottom: 16px; background: #f5f7fa; border-radius: 6px; padding: 12px; }
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.planned-title { font-size: 13px; font-weight: bold; color: #606266; }
/* 计划清单:与扫码出库页的「计划出库清单」保持一致的视觉 */
.planned-items-section {
margin-bottom: 16px;
padding: 12px;
background: #f0f9eb;
border: 1px solid #e1f3d8;
border-radius: 8px;
}
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.planned-title { font-weight: bold; font-size: 14px; color: #67C23A; }
/* ★ 备选库位:库位格子整体可点击(与扫码出库页同款) */
.loc-cell {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.loc-cell:hover { background-color: #ecf5ff; }
.loc-cell .loc-text { color: #409EFF; }
.alt-icon {
font-size: 18px;
color: #409EFF;
vertical-align: middle;
flex-shrink: 0;
}
.loc-cell:hover .alt-icon { color: #66b1ff; }
.alt-panel { font-size: 13px; }
.alt-title {
font-weight: bold;
color: #303133;
margin-bottom: 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.alt-total { font-weight: normal; color: #67C23A; font-size: 12px; }
.alt-empty { color: #909399; font-size: 12px; padding: 6px 0; }
.alt-row {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 0;
border-bottom: 1px dashed #ebeef5;
}
.alt-row:last-of-type { border-bottom: none; }
.alt-loc { flex: 1; color: #409EFF; font-weight: 500; }
.alt-qty { color: #606266; font-size: 12px; }
.alt-hint {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
color: #E6A23C;
font-size: 12px;
line-height: 1.5;
}
/* 扫码区 */
.scan-section { margin-bottom: 20px; }