Compare commits
6 Commits
eeefaeb528
...
6f2f077751
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f2f077751 | |||
| e93107458a | |||
| a4a9afb6db | |||
| ec66c33b06 | |||
| a07432981e | |||
| f8267c6ee2 |
42
db_migrations/add_scan_draft.sql
Normal file
42
db_migrations/add_scan_draft.sql
Normal 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;
|
||||||
@ -197,6 +197,20 @@ def create_app():
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"❌ 错误: MyRequests 模块导入失败: {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 初始化审计日志菜单和权限(防重复)
|
# 2.9 初始化审计日志菜单和权限(防重复)
|
||||||
# -----------------------------------------------------
|
# -----------------------------------------------------
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -65,7 +65,13 @@ def get_purchase_list():
|
|||||||
page = int(request.args.get('page', 1))
|
page = int(request.args.get('page', 1))
|
||||||
per_page = int(request.args.get('limit', 20))
|
per_page = int(request.args.get('limit', 20))
|
||||||
status = request.args.get('status')
|
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()
|
user_id = get_current_user_id()
|
||||||
can_view_all = _user_has_purchase_perm()
|
can_view_all = _user_has_purchase_perm()
|
||||||
@ -74,7 +80,11 @@ def get_purchase_list():
|
|||||||
page=page,
|
page=page,
|
||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
requester_id=None if can_view_all else user_id,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ★ 字段级价格过滤
|
# ★ 字段级价格过滤
|
||||||
|
|||||||
219
inventory-backend/app/api/v1/scan_draft.py
Normal file
219
inventory-backend/app/api/v1/scan_draft.py
Normal 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
|
||||||
59
inventory-backend/app/models/scan_draft.py
Normal file
59
inventory-backend/app/models/scan_draft.py
Normal 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())
|
||||||
@ -180,11 +180,20 @@ class PurchaseService:
|
|||||||
return purchase
|
return purchase
|
||||||
|
|
||||||
@staticmethod
|
@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.utils.decorators import get_current_company_filter
|
||||||
from app.models.system import SysUser
|
from app.models.system import SysUser
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_, cast, String
|
||||||
|
|
||||||
query = PurchaseRequest.query
|
query = PurchaseRequest.query
|
||||||
|
|
||||||
@ -194,15 +203,57 @@ class PurchaseService:
|
|||||||
if status is not None:
|
if status is not None:
|
||||||
query = query.filter(PurchaseRequest.status == status)
|
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
|
# 【行级数据隔离】同公司可见:匹配 MaterialBase.company_name 或 SysUser.department
|
||||||
company_limit = get_current_company_filter()
|
company_limit = get_current_company_filter()
|
||||||
if company_limit is not None:
|
if company_limit is not None:
|
||||||
query = query.outerjoin(MaterialBase, PurchaseRequest.base_id == MaterialBase.id) \
|
query = query.outerjoin(MaterialBase, PurchaseRequest.base_id == MaterialBase.id)
|
||||||
.outerjoin(SysUser, PurchaseRequest.requester_id == SysUser.id) \
|
# SysUser 可能已被上面的关键词分支 join 过 —— 重复 join 会产生
|
||||||
.filter(or_(
|
# 笛卡尔积导致结果翻倍,此处按需补 join。
|
||||||
MaterialBase.company_name == company_limit,
|
if not needs_user:
|
||||||
SysUser.department == company_limit
|
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())
|
query = query.order_by(PurchaseRequest.created_at.desc())
|
||||||
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
|
||||||
|
|||||||
@ -239,3 +239,55 @@ export function getMyRequests(params: {
|
|||||||
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -46,10 +46,18 @@ export interface Approver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取采购申请列表
|
// 获取采购申请列表
|
||||||
|
//
|
||||||
|
// 搜索参数语义与出库/报废记录保持一致:
|
||||||
|
// search_type: all / no(单号) / name(物料名称) / spec_model / requester(申请人)
|
||||||
|
// start_date / end_date: 按采购日期过滤
|
||||||
export function getPurchaseList(params: {
|
export function getPurchaseList(params: {
|
||||||
page?: number
|
page?: number
|
||||||
limit?: 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({
|
return request({
|
||||||
url: '/purchase',
|
url: '/purchase',
|
||||||
|
|||||||
@ -30,9 +30,11 @@
|
|||||||
<el-table-column type="expand" width="60" align="center">
|
<el-table-column type="expand" width="60" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div style="padding: 12px 24px; background: #f5f7fa;">
|
<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 }} 项)
|
<div class="planned-header">
|
||||||
</p>
|
<span class="planned-title">计划借用清单</span>
|
||||||
|
<el-tag type="success" size="small">{{ row.items?.length || 0 }} 种</el-tag>
|
||||||
|
</div>
|
||||||
<el-table
|
<el-table
|
||||||
v-if="row.items?.length"
|
v-if="row.items?.length"
|
||||||
:data="row.items"
|
:data="row.items"
|
||||||
@ -41,6 +43,15 @@
|
|||||||
style="width: 100%;"
|
style="width: 100%;"
|
||||||
>
|
>
|
||||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
<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="name" label="名称" min-width="140" show-overflow-tooltip />
|
||||||
<el-table-column prop="spec_model" label="规格型号" min-width="120" 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>
|
<span v-else>{{ item.warehouse_location || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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-table>
|
||||||
<el-empty v-else description="暂无物料明细" :image-size="60" />
|
<el-empty v-else description="暂无物料明细" :image-size="60" />
|
||||||
</div>
|
</div>
|
||||||
@ -494,6 +500,15 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.loc-cell {
|
||||||
|
|||||||
@ -39,6 +39,10 @@
|
|||||||
:label="req.request_no"
|
:label="req.request_no"
|
||||||
>
|
>
|
||||||
<span>{{ req.request_no }}</span>
|
<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" />
|
<el-divider direction="vertical" />
|
||||||
<span>{{ req.applicant_name || '未知申请人' }}</span>
|
<span>{{ req.applicant_name || '未知申请人' }}</span>
|
||||||
<el-divider direction="vertical" />
|
<el-divider direction="vertical" />
|
||||||
@ -372,10 +376,14 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
import { ref, reactive, nextTick, onUnmounted, onMounted, computed } from 'vue'
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } 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 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 { uploadFile } from '@/api/common/upload'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
|
|
||||||
@ -516,6 +524,7 @@ const loadApprovalRequests = async () => {
|
|||||||
try {
|
try {
|
||||||
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
|
const res: any = await getApprovalRequestList({ status: 1, page: 1, pageSize: 100 })
|
||||||
approvalRequests.value = res.data?.items || []
|
approvalRequests.value = res.data?.items || []
|
||||||
|
await loadDraftOverview() // ★ 一并取草稿进度,用于下拉徽标
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载审批单列表失败', e)
|
console.error('加载审批单列表失败', e)
|
||||||
} finally {
|
} 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) {
|
if (!val) {
|
||||||
selectedRequest.value = null
|
selectedRequest.value = null
|
||||||
form.remark = ''
|
form.remark = ''
|
||||||
@ -538,10 +552,187 @@ const handleRequestChange = (val: number | null) => {
|
|||||||
form.outbound_type = selectedRequest.value.outbound_type
|
form.outbound_type = selectedRequest.value.outbound_type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
|
|
||||||
cartItems.value = []
|
cartItems.value = []
|
||||||
signatureFile.value = null
|
signatureFile.value = null
|
||||||
signaturePreviewUrl.value = ''
|
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;缺失字段补默认值,
|
||||||
|
// 避免购物车出现 NaN(NaN 会在提交时被兜底成 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,
|
out_quantity: 1,
|
||||||
price: parseFloat(item.price || 0)
|
price: parseFloat(item.price || 0)
|
||||||
})
|
})
|
||||||
|
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
|
||||||
ElMessage.success(`添加成功: ${item.name}`)
|
ElMessage.success(`添加成功: ${item.name}`)
|
||||||
if (navigator.vibrate) navigator.vibrate(100)
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
@ -753,6 +945,12 @@ const clearAll = () => {
|
|||||||
signaturePreviewUrl.value = ''
|
signaturePreviewUrl.value = ''
|
||||||
barcodeInput.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('请先选择要出库的审批申请单')
|
ElMessage.warning('请先选择要出库的审批申请单')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 记下单据ID:清空 cartItems 后 selectedRequest 可能被重置,草稿清除需要它
|
||||||
|
const submittedRequestId = selectedRequest.value.id
|
||||||
|
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
@ -828,6 +1028,12 @@ const submitForm = async () => {
|
|||||||
|
|
||||||
ElMessage.success('出库成功')
|
ElMessage.success('出库成功')
|
||||||
|
|
||||||
|
// ★ 提交成功 → 清除该单据的草稿,避免下次打开时恢复出已提交的内容
|
||||||
|
if (submittedRequestId) {
|
||||||
|
clearScanDraft('outbound', submittedRequestId).catch(() => {})
|
||||||
|
delete draftProgress.value[submittedRequestId] // 同步移除下拉徽标
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 成功后重置页面
|
// 5. 成功后重置页面
|
||||||
cartItems.value = []
|
cartItems.value = []
|
||||||
form.consumer_name = ''
|
form.consumer_name = ''
|
||||||
@ -926,7 +1132,57 @@ const handleSignCancel = () => { showSignatureDialog.value = false }
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -1,22 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
|
||||||
<!-- 顶部工具栏 -->
|
<!-- 顶部工具栏:搜索 + 状态筛选(搜索区对齐出库记录版式) -->
|
||||||
<div class="filter-container">
|
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||||
<span style="font-weight: bold; font-size: 15px; margin-right: 8px;">审批状态:</span>
|
<el-form-item label="状态">
|
||||||
<el-radio-group v-model="filterStatus" size="default" @change="handleStatusChange">
|
<el-radio-group v-model="filterStatus" @change="handleStatusChange">
|
||||||
<el-radio-button label="">全部</el-radio-button>
|
<el-radio-button label="">全部</el-radio-button>
|
||||||
<el-radio-button :label="0">待审批</el-radio-button>
|
<el-radio-button :label="0">待审批</el-radio-button>
|
||||||
<el-radio-button :label="1">已通过</el-radio-button>
|
<el-radio-button :label="1">已通过</el-radio-button>
|
||||||
<el-radio-button :label="2">已驳回</el-radio-button>
|
<el-radio-button :label="2">已驳回</el-radio-button>
|
||||||
<el-radio-button :label="3">已完成</el-radio-button>
|
<el-radio-button :label="3">已完成</el-radio-button>
|
||||||
<el-radio-button :label="4">已完结</el-radio-button>
|
<el-radio-button :label="4">已完结</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
</el-form-item>
|
||||||
<el-button type="success" :icon="Plus" @click="openCreateDialog">
|
|
||||||
新建采购申请
|
<el-form-item label="搜索">
|
||||||
</el-button>
|
<el-input
|
||||||
</div>
|
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">
|
<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 page = ref(1)
|
||||||
const pageSize = ref(20)
|
const pageSize = ref(20)
|
||||||
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
|
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 submitLoading = ref(false)
|
||||||
const rejectLoading = ref(false)
|
const rejectLoading = ref(false)
|
||||||
|
|
||||||
@ -772,8 +847,17 @@ const formatUserName = (name: string): string => {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 (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 res: any = await getPurchaseList(params)
|
||||||
const items = res.data?.items || []
|
const items = res.data?.items || []
|
||||||
@ -1323,6 +1407,16 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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; }
|
.app-container { padding: 20px; }
|
||||||
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
|||||||
@ -39,25 +39,63 @@
|
|||||||
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
|
<p class="select-tip">仅显示已通过(status=1)的审批单</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ★ 审批计划清单预览 -->
|
<!-- ★ 计划借用清单预览(与扫码出库页的「计划出库清单」对齐) -->
|
||||||
<div v-if="selectedApproval" class="planned-items-section">
|
<div v-if="selectedApproval" class="planned-items-section">
|
||||||
<div class="planned-header">
|
<div class="planned-header">
|
||||||
<span class="planned-title">审批计划清单</span>
|
<span class="planned-title">计划借用清单</span>
|
||||||
<el-tag type="success" size="small">{{ plannedItems.length }} 种</el-tag>
|
<el-tag type="success" size="small">{{ plannedItems.length }} 种</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="plannedItems" border size="small" style="width: 100%;">
|
<el-table :data="plannedItems" border size="small" style="width: 100%;">
|
||||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
<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 }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
<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="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 }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@ -317,10 +355,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
|
import { ref, reactive, nextTick, onUnmounted, computed } from 'vue'
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select } from '@element-plus/icons-vue'
|
import { Scissor, EditPen, Delete, CameraFilled, Close, Refresh, Select, LocationInformation } from '@element-plus/icons-vue'
|
||||||
import QrScanner from '@/components/QrScanner/index.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 { dispatchBorrow, getBorrowApprovalList } from '@/api/transaction'
|
||||||
import { uploadFile } from '@/api/common/upload'
|
import { uploadFile } from '@/api/common/upload'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@ -367,6 +406,37 @@ const selectedApproval = computed(() =>
|
|||||||
|
|
||||||
const plannedItems = computed(() => selectedApproval.value?.items ?? [])
|
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(() =>
|
const scanTotalQty = computed(() =>
|
||||||
plannedItems.value.reduce((sum, it) => sum + (Number(it.quantity) || 0), 0)
|
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) {
|
if (!val) {
|
||||||
selectedApprovalId.value = null
|
selectedApprovalId.value = null
|
||||||
}
|
}
|
||||||
@ -441,6 +516,140 @@ const handleApprovalChange = (val: number | null) => {
|
|||||||
signatureFile.value = null
|
signatureFile.value = null
|
||||||
signaturePreviewUrl.value = ''
|
signaturePreviewUrl.value = ''
|
||||||
barcodeInput.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;缺失字段补默认值,
|
||||||
|
// 避免购物车出现 NaN(NaN 提交时会被兜底成 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,
|
out_quantity: 1,
|
||||||
price: 0
|
price: 0
|
||||||
})
|
})
|
||||||
|
scheduleSaveDraft() // ★ 自动存草稿(防抖),中途离开可恢复
|
||||||
ElMessage.success(`添加成功: ${item.name}`)
|
ElMessage.success(`添加成功: ${item.name}`)
|
||||||
if (navigator.vibrate) navigator.vibrate(100)
|
if (navigator.vibrate) navigator.vibrate(100)
|
||||||
barcodeInput.value = ''
|
barcodeInput.value = ''
|
||||||
@ -665,6 +875,8 @@ const submitForm = async () => {
|
|||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
||||||
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
|
if (!selectedApprovalId.value) return ElMessage.warning('请选择关联的审批申请单')
|
||||||
|
// 记下单据ID:清空 cartItems 后可能被重置,草稿清除需要它
|
||||||
|
const submittedRequestId = selectedApprovalId.value
|
||||||
|
|
||||||
await formRef.value.validate(async (valid: boolean) => {
|
await formRef.value.validate(async (valid: boolean) => {
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
@ -713,6 +925,12 @@ const submitForm = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ElMessage.success('借用成功')
|
ElMessage.success('借用成功')
|
||||||
|
|
||||||
|
// ★ 提交成功 → 清除该单据的草稿,避免下次打开恢复出已提交的内容
|
||||||
|
if (submittedRequestId) {
|
||||||
|
clearScanDraft('borrow', submittedRequestId).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
cartItems.value = []
|
cartItems.value = []
|
||||||
form.borrower_name = ''
|
form.borrower_name = ''
|
||||||
form.expected_return_time = ''
|
form.expected_return_time = ''
|
||||||
@ -814,7 +1032,46 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (signaturePreviewUrl.value) URL.revokeObjectURL(signaturePreviewUrl.value)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -830,10 +1087,67 @@ onUnmounted(() => {
|
|||||||
.approval-request-select { margin-bottom: 16px; }
|
.approval-request-select { margin-bottom: 16px; }
|
||||||
.select-tip { color: #909399; font-size: 12px; margin: 4px 0 0 0; }
|
.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-items-section {
|
||||||
.planned-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
margin-bottom: 16px;
|
||||||
.planned-title { font-size: 13px; font-weight: bold; color: #606266; }
|
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; }
|
.scan-section { margin-bottom: 20px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user