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:
|
||||
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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
# ★ 字段级价格过滤
|
||||
|
||||
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
|
||||
|
||||
@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)
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;缺失字段补默认值,
|
||||
// 避免购物车出现 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,
|
||||
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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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;缺失字段补默认值,
|
||||
// 避免购物车出现 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,
|
||||
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; }
|
||||
|
||||
Reference in New Issue
Block a user