Compare commits
22 Commits
a12fcea86d
...
1ef9ae4ad9
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ef9ae4ad9 | |||
| fd0bfd3d9c | |||
| e437b3cece | |||
| c35ec9a659 | |||
| 23a7fc65f1 | |||
| 3cadd42338 | |||
| 1aa31334b1 | |||
| e152f16ebc | |||
| f589962e5c | |||
| f556514e72 | |||
| ed351f96ec | |||
| af2d1c10c6 | |||
| 7719943779 | |||
| b4b79c68a9 | |||
| 8755837fe8 | |||
| 2045a89330 | |||
| 8b61cdb67d | |||
| 98e657e289 | |||
| 1dbf74b7bc | |||
| da5357acb3 | |||
| ffbb2199f0 | |||
| 3d8fb198c5 |
31
db_migrations/add_scrap_approval.sql
Normal file
31
db_migrations/add_scrap_approval.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- =============================================================================
|
||||
-- 报废审批流 + 按单报废(镜像 借库/出库 审批框架)
|
||||
-- 1) 新表 scrap_approval(申请单)
|
||||
-- 2) trans_scrap 增加 scrap_request_no,关联申请单号
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scrap_approval (
|
||||
id SERIAL PRIMARY KEY,
|
||||
request_no varchar(100) UNIQUE NOT NULL,
|
||||
applicant_id integer NOT NULL,
|
||||
remark text,
|
||||
status integer NOT NULL DEFAULT 0, -- 0待审 1已通过(待执行) 2驳回 3已执行
|
||||
allowed_approvers text,
|
||||
actual_approver_id integer,
|
||||
approved_at timestamptz,
|
||||
reject_reason text,
|
||||
items_json text NOT NULL,
|
||||
executed_at timestamptz,
|
||||
executor_name varchar(100),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_scrap_approval_status ON scrap_approval(status);
|
||||
CREATE INDEX IF NOT EXISTS ix_scrap_approval_applicant ON scrap_approval(applicant_id);
|
||||
|
||||
ALTER TABLE trans_scrap ADD COLUMN IF NOT EXISTS scrap_request_no varchar(100);
|
||||
CREATE INDEX IF NOT EXISTS ix_trans_scrap_req_no ON trans_scrap(scrap_request_no);
|
||||
|
||||
COMMIT;
|
||||
57
db_migrations/add_scrap_perm.sql
Normal file
57
db_migrations/add_scrap_perm.sql
Normal file
@ -0,0 +1,57 @@
|
||||
-- =============================================================================
|
||||
-- 报废审批流权限码(动态权限,不硬编码角色)
|
||||
-- scrap_apply 提交报废申请 / 预检
|
||||
-- scrap_approval 查看审批列表 / 审批(通过/驳回)
|
||||
-- scrap_execute 按单报废执行(扣减库存)
|
||||
-- 授予:apply/execute 复制现有持 'scrap_create:operation' 的角色;
|
||||
-- approval 复制现有持 'scrap_list:operation' 的角色 + 超管/主管。
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
-- 1) 注册权限元素
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'scrap_create', '报废申请(提交)', 'scrap_apply', 'operation'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_apply');
|
||||
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'scrap_create', '按单报废(执行)', 'scrap_execute', 'operation'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_execute');
|
||||
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'scrap_list', '报废审批(查看/审批)', 'scrap_approval', 'operation'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_approval');
|
||||
|
||||
-- 2) scrap_apply / scrap_execute ← 现有持 scrap_create:operation 的角色
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT DISTINCT sp.role_code, v.target_code, 'element', sp.company_name
|
||||
FROM sys_role_permission sp
|
||||
CROSS JOIN (VALUES ('scrap_apply'), ('scrap_execute')) AS v(target_code)
|
||||
WHERE sp.target_code = 'scrap_create:operation' AND sp.type = 'element'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = sp.role_code AND r.target_code = v.target_code
|
||||
AND r.type = 'element' AND COALESCE(r.company_name,'') = COALESCE(sp.company_name,'')
|
||||
);
|
||||
|
||||
-- 3) scrap_approval ← 现有持 scrap_list:operation 的角色
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT DISTINCT sp.role_code, 'scrap_approval', 'element', sp.company_name
|
||||
FROM sys_role_permission sp
|
||||
WHERE sp.target_code = 'scrap_list:operation' AND sp.type = 'element'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = sp.role_code AND r.target_code = 'scrap_approval'
|
||||
AND r.type = 'element' AND COALESCE(r.company_name,'') = COALESCE(sp.company_name,'')
|
||||
);
|
||||
|
||||
-- 4) 超管 / 主管 兜底持有审批权
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT v.role_code, 'scrap_approval', 'element', NULL
|
||||
FROM (VALUES ('SUPER_ADMIN'), ('SUPERVISOR')) AS v(role_code)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = v.role_code AND r.target_code = 'scrap_approval' AND r.type = 'element'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
34
db_migrations/add_scrap_selection_perm.sql
Normal file
34
db_migrations/add_scrap_selection_perm.sql
Normal file
@ -0,0 +1,34 @@
|
||||
-- =============================================================================
|
||||
-- 补注册 scrap_selection 权限码
|
||||
--
|
||||
-- 背景:
|
||||
-- GET /api/v1/scrap/scan(按单报废执行页的扫码查库存接口)自始就声明需要
|
||||
-- scrap_selection,但该权限码从未写入 sys_element / sys_role_permission。
|
||||
-- 结果 permission_required('scrap_selection') 的精确匹配与展开匹配(前缀
|
||||
-- scrap_selection:)全部落空,导致除 SUPER_ADMIN 外没有任何角色能调用扫码接口。
|
||||
--
|
||||
-- 处理:
|
||||
-- 1) 注册 scrap_selection 权限元素(挂在 scrap_create 菜单下,与 scrap_apply /
|
||||
-- scrap_execute 同级);
|
||||
-- 2) 授予所有已持有 scrap_execute(按单报废执行)的角色,
|
||||
-- 保证「执行报废」的人必然可以扫码。
|
||||
--
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO sys_element (menu_code, name, code, element_type)
|
||||
SELECT 'scrap_create', '报废扫码(查询库存)', 'scrap_selection', 'operation'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM sys_element WHERE code = 'scrap_selection');
|
||||
|
||||
INSERT INTO sys_role_permission (role_code, target_code, type, company_name)
|
||||
SELECT DISTINCT sp.role_code, 'scrap_selection', 'element', sp.company_name
|
||||
FROM sys_role_permission sp
|
||||
WHERE sp.target_code = 'scrap_execute' AND sp.type = 'element'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_role_permission r
|
||||
WHERE r.role_code = sp.role_code AND r.target_code = 'scrap_selection'
|
||||
AND r.type = 'element' AND COALESCE(r.company_name,'') = COALESCE(sp.company_name,'')
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
43
db_migrations/unify_approval_timezone.sql
Normal file
43
db_migrations/unify_approval_timezone.sql
Normal file
@ -0,0 +1,43 @@
|
||||
-- =============================================================================
|
||||
-- 审批类单据时间口径统一(全部落到 naive 北京时间)
|
||||
--
|
||||
-- 背景:
|
||||
-- 1) 容器内 PostgreSQL 时区为 Etc/UTC,库级默认 now() / CURRENT_TIMESTAMP 均为 UTC;
|
||||
-- 2) scrap_approval 原先用 timestamptz 列 + tz-aware Python 值,而
|
||||
-- outbound_approval / borrow_approval 用 timestamp without time zone 列;
|
||||
-- 三张表口径不一致,且 outbound/borrow 的 DB 默认值 CURRENT_TIMESTAMP 是 UTC,
|
||||
-- 一旦有绕过 ORM 的写入就会产生 8 小时偏差。
|
||||
--
|
||||
-- 目标:
|
||||
-- · 三张审批单表的 created_at / updated_at 统一为 timestamp without time zone;
|
||||
-- · DB 侧默认值统一为 timezone('Asia/Shanghai', now()),即北京时间;
|
||||
-- · 应用层(SQLAlchemy 模型)始终显式传 beijing_time(),不依赖 DB 默认值。
|
||||
--
|
||||
-- 执行: docker exec -i inventory_db psql -U test -d inventory_system < 本文件
|
||||
-- =============================================================================
|
||||
BEGIN;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1) scrap_approval:timestamptz -> timestamp without time zone
|
||||
-- (该表此前无业务数据,转换不会造成既有时间语义漂移;
|
||||
-- ALTER 后原 timestamptz 值会按会话时区 UTC 取钟面值,如需保留请先备份)
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE scrap_approval
|
||||
ALTER COLUMN created_at TYPE timestamp without time zone,
|
||||
ALTER COLUMN updated_at TYPE timestamp without time zone,
|
||||
ALTER COLUMN approved_at TYPE timestamp without time zone,
|
||||
ALTER COLUMN executed_at TYPE timestamp without time zone;
|
||||
|
||||
ALTER TABLE scrap_approval ALTER COLUMN created_at SET DEFAULT timezone('Asia/Shanghai', now());
|
||||
ALTER TABLE scrap_approval ALTER COLUMN updated_at SET DEFAULT timezone('Asia/Shanghai', now());
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2) outbound_approval / borrow_approval:DB 默认值由 UTC 改为北京时间
|
||||
-- 列类型本就是 naive,无需转换
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE outbound_approval ALTER COLUMN created_at SET DEFAULT timezone('Asia/Shanghai', now());
|
||||
ALTER TABLE outbound_approval ALTER COLUMN updated_at SET DEFAULT timezone('Asia/Shanghai', now());
|
||||
ALTER TABLE borrow_approval ALTER COLUMN created_at SET DEFAULT timezone('Asia/Shanghai', now());
|
||||
ALTER TABLE borrow_approval ALTER COLUMN updated_at SET DEFAULT timezone('Asia/Shanghai', now());
|
||||
|
||||
COMMIT;
|
||||
@ -252,7 +252,8 @@ def _make_price_stripper(permission_prefix=None):
|
||||
仅当 permission_prefix 不在已知选单前缀列表中时放行(例如 material_list 场景由调用方自行处理)。
|
||||
"""
|
||||
# 已知的选单/借用前缀 → 无条件剥离所有价格成本
|
||||
SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake'}
|
||||
# ★ 新增前缀必须登记在此,否则该前缀的调用方会拿到价格/成本字段(Fail-Closed 失效)
|
||||
SELECTION_PREFIXES = {'outbound_selection', 'op_borrow_apply', 'inventory_stocktake', 'scrap_apply'}
|
||||
|
||||
def stripper(d, stock_category):
|
||||
if permission_prefix is None or permission_prefix in SELECTION_PREFIXES:
|
||||
@ -315,6 +316,7 @@ def _do_get_stock_list(permission_prefix=None):
|
||||
rows = q.all()
|
||||
for item in rows:
|
||||
d = item.to_dict()
|
||||
d['source_table'] = 'stock_buy' # ★ 报废等按单流程需要精准定位来源表
|
||||
d['stock_type'] = 'material'
|
||||
d['type'] = 'material'
|
||||
d['typeLabel'] = '采购件'
|
||||
@ -341,6 +343,7 @@ def _do_get_stock_list(permission_prefix=None):
|
||||
rows = q.all()
|
||||
for item in rows:
|
||||
d = item.to_dict()
|
||||
d['source_table'] = 'stock_semi' # ★ 精准来源表
|
||||
d['stock_type'] = 'semi'
|
||||
d['type'] = 'semi'
|
||||
d['typeLabel'] = '半成品'
|
||||
@ -370,6 +373,7 @@ def _do_get_stock_list(permission_prefix=None):
|
||||
rows = q.all()
|
||||
for item in rows:
|
||||
d = item.to_dict()
|
||||
d['source_table'] = 'stock_product' # ★ 精准来源表
|
||||
d['stock_type'] = 'product'
|
||||
d['type'] = 'product'
|
||||
d['typeLabel'] = '成品'
|
||||
|
||||
@ -175,6 +175,12 @@ def get_outbound_list():
|
||||
search_type = request.args.get('search_type', 'all')
|
||||
company = request.args.get('company', '')
|
||||
|
||||
# ★ 高级筛选:JSON 字符串 → 条件列表(解析失败退化为空,不影响主查询)
|
||||
from app.utils.advanced_filter import parse_advanced_filters
|
||||
advanced_filters = parse_advanced_filters(
|
||||
request.args.get('advancedFilters', '')
|
||||
)
|
||||
|
||||
# ★ 数据权限:普通用户只看“领用人=本人姓名(不含账号前缀)”的出库记录;管理者看全部
|
||||
consumer_name = None
|
||||
if not is_privileged_viewer():
|
||||
@ -189,7 +195,8 @@ def get_outbound_list():
|
||||
# ★ [修改] 调用分组查询服务,支持搜索类型
|
||||
result = OutboundService.get_grouped_list(
|
||||
page, limit, keyword, search_type=search_type,
|
||||
company=company, consumer_name=consumer_name
|
||||
company=company, consumer_name=consumer_name,
|
||||
advanced_filters=advanced_filters,
|
||||
)
|
||||
|
||||
# 字段级脱敏
|
||||
|
||||
@ -16,6 +16,29 @@ import math
|
||||
scrap_bp = Blueprint('scrap', __name__, url_prefix='/scrap')
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 报废记录字段级权限过滤(对齐 outbound 的 filter_item_by_permissions)
|
||||
#
|
||||
# 原先 /records 无条件剥离 cost_at_scrap / total_loss,导致即使角色持有
|
||||
# scrap_list:loss_amount 也看不到金额——与「损失金额」权限元素的存在相矛盾。
|
||||
# 改为按权限决定可见性,与出库记录保持一致。
|
||||
# ==============================================================================
|
||||
def filter_scrap_by_permissions(item, user_permissions):
|
||||
field_to_perm = {
|
||||
'total_loss': 'scrap_list:loss_amount',
|
||||
'loss_amount': 'scrap_list:loss_amount',
|
||||
}
|
||||
if 'scrap_list:*' in user_permissions:
|
||||
return item
|
||||
for field, perm_code in field_to_perm.items():
|
||||
if field in item and perm_code not in user_permissions:
|
||||
item[field] = None
|
||||
for sub in (item.get('items') or []):
|
||||
if isinstance(sub, dict):
|
||||
filter_scrap_by_permissions(sub, user_permissions)
|
||||
return item
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 辅助函数:获取当前用户的完整权限列表(基于角色查询)
|
||||
# ==============================================================================
|
||||
@ -104,6 +127,24 @@ def create_scrap():
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 2.1 报废申请专用库存列表(独立权限,解耦出库选单)
|
||||
# GET /api/v1/scrap/stock-list
|
||||
# --------------------------------------------------------
|
||||
@scrap_bp.route('/stock-list', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_apply')
|
||||
def get_scrap_stock_list():
|
||||
"""
|
||||
报废申请专用库存列表 — Fail-Closed: 剥离价格字段
|
||||
|
||||
与借库 /transactions/borrow/stock-list 同构:复用 _do_get_stock_list,
|
||||
但挂 scrap_apply 权限,报废申请人无需持有出库选单权限。
|
||||
"""
|
||||
from app.api.v1.inbound.stock import _do_get_stock_list
|
||||
return _do_get_stock_list(permission_prefix='scrap_apply')
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 3. 报废记录查询接口
|
||||
# GET /api/v1/scrap/records
|
||||
@ -117,6 +158,12 @@ def get_scrap_records():
|
||||
sku = request.args.get('sku', '')
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
keyword = request.args.get('keyword', '')
|
||||
search_type = request.args.get('search_type', 'all')
|
||||
|
||||
# ★ 高级筛选:JSON 字符串 → 条件列表
|
||||
from app.utils.advanced_filter import parse_advanced_filters
|
||||
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
|
||||
|
||||
try:
|
||||
result = ScrapService.query_records(
|
||||
@ -124,12 +171,17 @@ def get_scrap_records():
|
||||
page_size=page_size,
|
||||
sku=sku,
|
||||
start_date=start_date,
|
||||
end_date=end_date
|
||||
end_date=end_date,
|
||||
keyword=keyword,
|
||||
search_type=search_type,
|
||||
advanced_filters=advanced_filters,
|
||||
)
|
||||
# ★ Fail-Closed: 报废记录剥离成本字段
|
||||
for item in (result.get('list') or []):
|
||||
for k in ('cost_at_scrap', 'total_loss'):
|
||||
item.pop(k, None)
|
||||
# 损失金额按 scrap_list:loss_amount 权限决定可见性(原为无条件剥离,
|
||||
# 会让持有该权限的角色也看不到金额,与权限元素的存在相矛盾)
|
||||
perms = get_current_user_permissions()
|
||||
if 'scrap_list:*' not in perms:
|
||||
for item in (result.get('list') or []):
|
||||
filter_scrap_by_permissions(item, perms)
|
||||
return jsonify({'code': 200, 'msg': 'success', 'data': result})
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
@ -315,9 +367,97 @@ class ScrapService:
|
||||
db.session.commit()
|
||||
return {'count': len(created_records)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助:用户名解析(库里存 '张三/zhangsan' 格式时取斜杠前的姓名)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def query_records(page=1, page_size=50, sku='', start_date='', end_date=''):
|
||||
"""分页查询报废记录"""
|
||||
def _display_name(raw):
|
||||
if raw is None or raw == '':
|
||||
return ''
|
||||
s = str(raw)
|
||||
if s.isdigit():
|
||||
u = SysUser.query.get(int(s))
|
||||
if u and u.username:
|
||||
s = u.username
|
||||
return s.split('/')[0] if '/' in s else s
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助:批量解析报废行的物料名称/规格/库位/批号(多态 5 条来源路径)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _resolve_materials(rows):
|
||||
from app.models.transaction import TransBorrow
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
model_map = {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct,
|
||||
}
|
||||
resolved = {}
|
||||
|
||||
def _from_stock(stock_row):
|
||||
if not stock_row:
|
||||
return None
|
||||
base = getattr(stock_row, 'base', None)
|
||||
return {
|
||||
'material_name': (base.name if base else '') or '',
|
||||
'spec_model': (base.spec_model if base else '') or '',
|
||||
'warehouse_location': getattr(stock_row, 'warehouse_location', '') or '',
|
||||
'batch_number': (getattr(stock_row, 'batch_number', '')
|
||||
or getattr(stock_row, 'serial_number', '') or ''),
|
||||
}
|
||||
|
||||
# 1) 常规库存三表:批量拉取并 joinedload 物料主表
|
||||
for table, model in model_map.items():
|
||||
ids = {r.stock_id for r in rows if r.source_table == table and r.stock_id}
|
||||
if not ids:
|
||||
continue
|
||||
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
|
||||
resolved[(table, obj.id)] = _from_stock(obj)
|
||||
|
||||
# 2) 借库转报废:stock_id 指向 trans_borrow.id,再经其 source_table 找库存
|
||||
borrow_ids = {r.stock_id for r in rows if r.source_table == 'trans_borrow' and r.stock_id}
|
||||
if borrow_ids:
|
||||
borrows = TransBorrow.query.filter(TransBorrow.id.in_(borrow_ids)).all()
|
||||
inner_ids = {}
|
||||
for b in borrows:
|
||||
if b.source_table in model_map and b.stock_id:
|
||||
inner_ids.setdefault(b.source_table, set()).add(b.stock_id)
|
||||
inner_map = {}
|
||||
for table, ids in inner_ids.items():
|
||||
model = model_map[table]
|
||||
for obj in model.query.options(joinedload(model.base)).filter(model.id.in_(ids)).all():
|
||||
inner_map[(table, obj.id)] = _from_stock(obj)
|
||||
for b in borrows:
|
||||
info = inner_map.get((b.source_table, b.stock_id))
|
||||
if info:
|
||||
resolved[('trans_borrow', b.id)] = info
|
||||
|
||||
# 3) 维修单来源:物料名在 TransRepair 上,无规格
|
||||
repair_ids = {r.stock_id for r in rows if r.source_table == 'trans_repair' and r.stock_id}
|
||||
if repair_ids:
|
||||
for rp in TransRepair.query.filter(TransRepair.id.in_(repair_ids)).all():
|
||||
resolved[('trans_repair', rp.id)] = {
|
||||
'material_name': rp.material_name or '',
|
||||
'spec_model': '',
|
||||
'warehouse_location': '',
|
||||
'batch_number': getattr(rp, 'serial_number', '') or '',
|
||||
}
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def query_records(page=1, page_size=50, sku='', start_date='', end_date='',
|
||||
keyword='', search_type='all', advanced_filters=None):
|
||||
"""
|
||||
分页查询报废记录 —— ★ 按报废申请单号分组,返回「订单级」结果。
|
||||
|
||||
· 有 scrap_request_no:按单号分组;
|
||||
· 无单号(历史直接报废):按 操作时间(精确到分钟) + 操作人 虚拟分组,
|
||||
并生成可读的虚拟单号,避免历史数据成为无主记录。
|
||||
每单返回 items 明细数组、损失合计、申请人(取自 ScrapApproval)。
|
||||
"""
|
||||
query = TransScrap.query
|
||||
|
||||
if sku:
|
||||
@ -328,6 +468,116 @@ class ScrapService:
|
||||
if end_date:
|
||||
query = query.filter(TransScrap.operation_time <= end_date + ' 23:59:59')
|
||||
|
||||
# 单号 / SKU 可在 SQL 层直接过滤
|
||||
if keyword and search_type == 'no':
|
||||
query = query.filter(TransScrap.scrap_request_no.ilike(f'%{keyword}%'))
|
||||
elif keyword and search_type == 'sku':
|
||||
query = query.filter(TransScrap.sku.ilike(f'%{keyword}%'))
|
||||
|
||||
# ====================================================================
|
||||
# ★ 高级筛选
|
||||
#
|
||||
# 报废流水在 Python 侧按「单号 / (时间+操作人) 虚拟单号」分组,
|
||||
# 且历史数据 scrap_request_no 为 NULL,无法像出库/借还那样直接对
|
||||
# 单号列做 IN。因此这里统一用**行级等价键**表达父子关系:
|
||||
#
|
||||
# 1) 先用子级条件求出「命中行」;
|
||||
# 2) 把命中行折算成"同单等价键"谓词;
|
||||
# 3) 肯定操作符 → 放行同单全部行(OR),
|
||||
# 否定操作符 → 整单排除(NOT ...)。
|
||||
#
|
||||
# 等价键:有单号 → scrap_request_no 相等;
|
||||
# 无单号 → 同一分钟 + 同一操作人(与分组逻辑完全一致)。
|
||||
# ====================================================================
|
||||
if advanced_filters:
|
||||
from app.utils.advanced_filter import (
|
||||
build_predicate, is_negative, invert_condition,
|
||||
)
|
||||
from sqlalchemy import or_, and_, tuple_, func as _func
|
||||
|
||||
parent_map = {
|
||||
'no': TransScrap.scrap_request_no,
|
||||
'operator': TransScrap.operator_name,
|
||||
}
|
||||
child_map = {'sku': TransScrap.sku}
|
||||
|
||||
def _order_key_pred(rows):
|
||||
"""把若干命中行折算成「同单」谓词(OR 连接)"""
|
||||
keys = []
|
||||
for h in rows:
|
||||
if h.scrap_request_no:
|
||||
keys.append(and_(
|
||||
TransScrap.scrap_request_no.isnot(None),
|
||||
TransScrap.scrap_request_no == h.scrap_request_no,
|
||||
))
|
||||
else:
|
||||
keys.append(and_(
|
||||
TransScrap.scrap_request_no.is_(None),
|
||||
_func.date_trunc('minute', TransScrap.operation_time)
|
||||
== _func.date_trunc('minute', h.operation_time),
|
||||
TransScrap.operator_name == h.operator_name,
|
||||
))
|
||||
return or_(*keys) if keys else None
|
||||
|
||||
def _matched_rows(cond):
|
||||
"""按肯定形式求出「命中该子级条件的报废行」"""
|
||||
probe = invert_condition(cond)
|
||||
field = probe.get('field')
|
||||
if field == 'material_name':
|
||||
# 物料名不在流水表:先在三张库存表求出 (source_table, stock_id)
|
||||
# 命中集合,再用元组 IN 定位报废行。
|
||||
# ★ 用元组 IN 而非"单号 IN":后者对 scrap_request_no 为 NULL 的
|
||||
# 历史行永远匹配不上,会把历史数据整体漏掉。
|
||||
from app.models.base import MaterialBase
|
||||
name_col = MaterialBase.name
|
||||
name_pred = (name_col == probe.get('value')
|
||||
if probe.get('operator') == 'eq'
|
||||
else name_col.ilike(f"%{probe.get('value')}%"))
|
||||
pairs = []
|
||||
for SM, sv in [(StockBuy, 'stock_buy'), (StockSemi, 'stock_semi'),
|
||||
(StockProduct, 'stock_product')]:
|
||||
for r in (SM.query.join(MaterialBase, SM.base_id == MaterialBase.id)
|
||||
.filter(name_pred).all()):
|
||||
pairs.append((sv, r.id))
|
||||
if not pairs:
|
||||
return None
|
||||
return TransScrap.query.filter(
|
||||
tuple_(TransScrap.source_table, TransScrap.stock_id).in_(pairs)
|
||||
).all()
|
||||
|
||||
p = build_predicate(probe, child_map)
|
||||
if p is None:
|
||||
return None
|
||||
return TransScrap.query.filter(p).all()
|
||||
|
||||
for cond in advanced_filters:
|
||||
field = cond.get('field')
|
||||
|
||||
# --- 父级字段:标准 SQL 谓词,否定操作符语义无歧义 ---
|
||||
if field in parent_map:
|
||||
p = build_predicate(cond, parent_map)
|
||||
if p is not None:
|
||||
query = query.filter(p)
|
||||
continue
|
||||
|
||||
if field not in child_map and field != 'material_name':
|
||||
continue
|
||||
|
||||
# --- 子级字段:正/负操作符语义分派 ---
|
||||
negative = is_negative(cond)
|
||||
rows = _matched_rows(cond)
|
||||
|
||||
if not rows:
|
||||
# 没有命中行:肯定 → 结果为空;否定 → 无需排除任何单
|
||||
if not negative:
|
||||
query = query.filter(db.false())
|
||||
continue
|
||||
|
||||
key_pred = _order_key_pred(rows)
|
||||
if key_pred is None:
|
||||
continue
|
||||
query = query.filter(~key_pred if negative else key_pred)
|
||||
|
||||
# 【行级数据隔离】基于 JWT 多租户公司过滤
|
||||
# 通过 stock 表或 trans_repair 关联到 MaterialBase
|
||||
company_limit = get_current_company_filter()
|
||||
@ -385,84 +635,289 @@ class ScrapService:
|
||||
# 按时间倒序
|
||||
query = query.order_by(TransScrap.operation_time.desc())
|
||||
|
||||
total = query.count()
|
||||
records = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
# ==================================================================
|
||||
# ★ 分组:先取全量(报废量级小),在内存里按「订单」聚合后再分页
|
||||
# 有单号 → 按 scrap_request_no
|
||||
# 无单号 → 按 操作时间(分钟) + 操作人 虚拟分组
|
||||
# ==================================================================
|
||||
rows = query.all()
|
||||
mat_info = ScrapService._resolve_materials(rows)
|
||||
|
||||
# 遍历结果,补充操作人姓名、物料名称、规格
|
||||
result_list = []
|
||||
for r in records:
|
||||
item = r.to_dict()
|
||||
|
||||
# 1. 解析操作人姓名
|
||||
if r.operator_name:
|
||||
# operator_name 可能是用户ID或用户名,尝试解析为真实姓名
|
||||
try:
|
||||
# 尝试将 operator_name 当作用户ID查询
|
||||
user_id = int(r.operator_name)
|
||||
user = SysUser.query.get(user_id)
|
||||
if user:
|
||||
# 解析存储格式: "张三/zhangsan"
|
||||
raw_name = user.username
|
||||
if '/' in raw_name:
|
||||
item['operator_name'] = raw_name.split('/')[0]
|
||||
except (ValueError, TypeError):
|
||||
# 如果不是数字ID,保持原值
|
||||
pass
|
||||
|
||||
# 2. 多态解析物料名称与规格
|
||||
material_name = ''
|
||||
spec_model = ''
|
||||
|
||||
if r.source_table == 'trans_borrow':
|
||||
# 借库转报废:stock_id 指向 trans_borrow.id,通过源库存表取物料
|
||||
from app.models.transaction import TransBorrow
|
||||
borrow = TransBorrow.query.get(r.stock_id)
|
||||
if borrow:
|
||||
bstock_model = None
|
||||
if borrow.source_table == 'stock_buy':
|
||||
bstock_model = StockBuy.query.get(borrow.stock_id)
|
||||
elif borrow.source_table == 'stock_semi':
|
||||
bstock_model = StockSemi.query.get(borrow.stock_id)
|
||||
elif borrow.source_table == 'stock_product':
|
||||
bstock_model = StockProduct.query.get(borrow.stock_id)
|
||||
if bstock_model and hasattr(bstock_model, 'base_id') and bstock_model.base_id:
|
||||
base = MaterialBase.query.get(bstock_model.base_id)
|
||||
if base:
|
||||
material_name = base.name or ''
|
||||
spec_model = base.spec_model or ''
|
||||
elif r.source_table == 'trans_repair':
|
||||
# 维修单
|
||||
repair = TransRepair.query.get(r.stock_id)
|
||||
if repair:
|
||||
material_name = repair.material_name or ''
|
||||
spec_model = ''
|
||||
elif r.source_table in ['stock_buy', 'stock_semi', 'stock_product']:
|
||||
# 常规库存表
|
||||
stock_model = None
|
||||
if r.source_table == 'stock_buy':
|
||||
stock_model = StockBuy.query.get(r.stock_id)
|
||||
elif r.source_table == 'stock_semi':
|
||||
stock_model = StockSemi.query.get(r.stock_id)
|
||||
elif r.source_table == 'stock_product':
|
||||
stock_model = StockProduct.query.get(r.stock_id)
|
||||
|
||||
if stock_model and hasattr(stock_model, 'base_id') and stock_model.base_id:
|
||||
base = MaterialBase.query.get(stock_model.base_id)
|
||||
if base:
|
||||
material_name = base.name or ''
|
||||
spec_model = base.spec_model or ''
|
||||
elif stock_model and hasattr(stock_model, 'base') and stock_model.base:
|
||||
material_name = stock_model.base.name or ''
|
||||
spec_model = stock_model.base.spec_model or ''
|
||||
|
||||
item['material_name'] = material_name
|
||||
item['spec_model'] = spec_model
|
||||
|
||||
result_list.append(item)
|
||||
groups = {}
|
||||
for r in rows:
|
||||
if r.scrap_request_no:
|
||||
gkey = ('req', r.scrap_request_no)
|
||||
else:
|
||||
ts = r.operation_time.strftime('%Y-%m-%d %H:%M') if r.operation_time else '未知时间'
|
||||
gkey = ('legacy', ts, r.operator_name or '')
|
||||
|
||||
g = groups.get(gkey)
|
||||
if g is None:
|
||||
op_name = ScrapService._display_name(r.operator_name)
|
||||
if gkey[0] == 'req':
|
||||
req_no = r.scrap_request_no
|
||||
else:
|
||||
# 虚拟单号:无单号的历史直接报废,仍给出可读标识便于追溯
|
||||
ts_raw = r.operation_time.strftime('%Y%m%d%H%M') if r.operation_time else '000000000000'
|
||||
req_no = f"LEGACY-{ts_raw}-{op_name or '未知'}"
|
||||
g = {
|
||||
'scrap_request_no': req_no,
|
||||
'is_legacy': gkey[0] == 'legacy',
|
||||
'operator_name': op_name,
|
||||
'applicant_id': None,
|
||||
'applicant_name': '',
|
||||
'scrap_time': r.operation_time.strftime('%Y-%m-%d %H:%M:%S') if r.operation_time else '',
|
||||
'_sort_time': r.operation_time,
|
||||
'approval_status': r.approval_status or '',
|
||||
'total_loss': 0.0,
|
||||
'total_quantity': 0.0,
|
||||
'reason': r.reason or '',
|
||||
'items': [],
|
||||
}
|
||||
groups[gkey] = g
|
||||
|
||||
info = mat_info.get((r.source_table, r.stock_id), {})
|
||||
qty = float(r.quantity or 0)
|
||||
loss = float(r.total_loss or 0)
|
||||
g['total_quantity'] += qty
|
||||
g['total_loss'] += loss
|
||||
g['items'].append({
|
||||
'id': r.id,
|
||||
'sku': r.sku or '',
|
||||
'material_name': info.get('material_name', ''),
|
||||
'spec_model': info.get('spec_model', ''),
|
||||
'warehouse_location': info.get('warehouse_location', ''),
|
||||
'batch_number': info.get('batch_number', ''),
|
||||
'quantity': qty,
|
||||
'reason': r.reason or '',
|
||||
'source_table': r.source_table or '',
|
||||
'loss_amount': round(loss, 2),
|
||||
})
|
||||
|
||||
orders = list(groups.values())
|
||||
|
||||
# ★ 补申请人:有单号的取 ScrapApproval,无单号的以操作人兜底
|
||||
req_nos = [o['scrap_request_no'] for o in orders if not o['is_legacy']]
|
||||
if req_nos:
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
approver_cache = {}
|
||||
for ap in ScrapApproval.query.filter(
|
||||
ScrapApproval.request_no.in_(req_nos)
|
||||
).all():
|
||||
if ap.applicant_id not in approver_cache:
|
||||
# 统一走 _display_name,去掉 username 里的 '/账号' 后缀
|
||||
approver_cache[ap.applicant_id] = ScrapService._display_name(
|
||||
ScrapApproval._user_name(ap.applicant_id)
|
||||
)
|
||||
o = groups.get(('req', ap.request_no))
|
||||
if o:
|
||||
o['applicant_id'] = ap.applicant_id
|
||||
o['applicant_name'] = approver_cache.get(ap.applicant_id, '')
|
||||
for o in orders:
|
||||
if not o['applicant_name']:
|
||||
o['applicant_name'] = o['operator_name']
|
||||
o['total_loss'] = round(o['total_loss'], 2)
|
||||
o['items'].sort(key=lambda x: x['sku'] or '')
|
||||
|
||||
# 关键词过滤:单号/SKU 已在 SQL 层处理,这里处理操作人/申请人/物料名
|
||||
if keyword and search_type in ('all', 'name', 'material_name'):
|
||||
kw = keyword.lower()
|
||||
def _hit(o):
|
||||
if search_type == 'name':
|
||||
return kw in (o['operator_name'] or '').lower() or kw in (o['applicant_name'] or '').lower()
|
||||
if search_type == 'material_name':
|
||||
return any(kw in (it['material_name'] or '').lower() for it in o['items'])
|
||||
return (kw in (o['scrap_request_no'] or '').lower()
|
||||
or kw in (o['operator_name'] or '').lower()
|
||||
or kw in (o['applicant_name'] or '').lower()
|
||||
or any(kw in (it['sku'] or '').lower()
|
||||
or kw in (it['material_name'] or '').lower()
|
||||
or kw in (it['spec_model'] or '').lower() for it in o['items']))
|
||||
orders = [o for o in orders if _hit(o)]
|
||||
|
||||
orders.sort(key=lambda o: (o['_sort_time'] is None, o['_sort_time']), reverse=True)
|
||||
|
||||
total = len(orders)
|
||||
start = (page - 1) * page_size
|
||||
paged = orders[start:start + page_size]
|
||||
|
||||
for o in paged:
|
||||
o.pop('_sort_time', None)
|
||||
|
||||
return {
|
||||
'list': result_list,
|
||||
'list': paged,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'pageSize': page_size
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# 报废审批流(申请 → 审批 → 按单执行)—— 镜像 借库/出库 审批框架
|
||||
# 与旧 /scrap(直接报废)并存,旧入口不动
|
||||
# ==============================================================================
|
||||
|
||||
def _current_user_role():
|
||||
try:
|
||||
claims = get_jwt()
|
||||
return (claims.get('role') or '').upper()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
@scrap_bp.route('/request/check-approval', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_apply')
|
||||
def scrap_check_approval():
|
||||
"""
|
||||
提交前预检:报废一律需审批,本接口返回 need_approval=true,
|
||||
并附带命中「需审批物料」标记的明细,供前端展示审批提示文案。
|
||||
"""
|
||||
try:
|
||||
from app.services.approval_control import resolve_approval_control
|
||||
from app.services.scrap_approval_service import SCRAP_ALWAYS_REQUIRES_APPROVAL
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', []) or []
|
||||
_, flagged = resolve_approval_control(items)
|
||||
return jsonify({'code': 200, 'msg': 'success',
|
||||
'data': {'need_approval': SCRAP_ALWAYS_REQUIRES_APPROVAL,
|
||||
'materials': flagged}}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'预检失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@scrap_bp.route('/request', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_apply')
|
||||
def create_scrap_request():
|
||||
"""提交报废申请(不扣库存;扣减在库管执行时)"""
|
||||
try:
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
identity = get_jwt_identity()
|
||||
if not identity:
|
||||
return jsonify({'code': 401, 'msg': '用户未登录'}), 401
|
||||
data = request.get_json() or {}
|
||||
req = ScrapApprovalService.submit_approval(
|
||||
applicant_id=int(identity),
|
||||
items=data.get('items', []),
|
||||
allowed_approvers=data.get('allowed_approvers'),
|
||||
remark=data.get('remark'),
|
||||
approver_id=data.get('approver_id'),
|
||||
force_approval=(_current_user_role() == 'WAREHOUSE_MGR'),
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '报废申请已提交', 'data': req.to_dict()}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'提交报废申请失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@scrap_bp.route('/request', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_approval')
|
||||
def list_scrap_requests():
|
||||
"""
|
||||
报废申请列表。
|
||||
scope: mine(我提交的,默认) / pending(待我审批) / executable(待执行) / all(管理者)
|
||||
Query: page, limit, status(可选覆盖)
|
||||
"""
|
||||
try:
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
from app.utils.decorators import is_privileged_viewer
|
||||
identity = int(get_jwt_identity())
|
||||
|
||||
scope = (request.args.get('scope') or 'mine').lower()
|
||||
page = int(request.args.get('page', 1))
|
||||
limit = int(request.args.get('limit', 10))
|
||||
status = request.args.get('status')
|
||||
status = int(status) if status not in (None, '', 'all') else None
|
||||
priv = is_privileged_viewer()
|
||||
|
||||
kwargs = {'page': page, 'limit': limit, 'status': status}
|
||||
if scope == 'pending':
|
||||
kwargs['status'] = 0
|
||||
kwargs['approver_id'] = identity
|
||||
elif scope == 'executable':
|
||||
kwargs['status'] = 1
|
||||
elif scope == 'all':
|
||||
if not priv:
|
||||
kwargs['applicant_id'] = identity
|
||||
else: # mine
|
||||
kwargs['applicant_id'] = identity
|
||||
|
||||
data = ScrapApprovalService.get_list(**kwargs)
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': data}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'获取报废申请列表失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@scrap_bp.route('/request/<int:request_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_approval')
|
||||
def get_scrap_request_detail(request_id):
|
||||
try:
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
from app.utils.decorators import is_privileged_viewer
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
if not req:
|
||||
return jsonify({'code': 404, 'msg': '报废申请不存在'}), 404
|
||||
if not is_privileged_viewer() and int(req.applicant_id) != int(get_jwt_identity()):
|
||||
# 被指定审批人也可查看
|
||||
allowed = req.get_allowed_approvers() or []
|
||||
if str(get_jwt_identity()) not in [str(a.get('value')) for a in allowed if a.get('type') == 'user']:
|
||||
return jsonify({'code': 403, 'msg': '无权查看该报废申请'}), 403
|
||||
return jsonify({'code': 200, 'msg': '获取成功', 'data': req.to_dict()}), 200
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'获取报废申请详情失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@scrap_bp.route('/request/<int:request_id>/approve', methods=['PATCH'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_approval')
|
||||
def approve_scrap_request(request_id):
|
||||
"""审批报废申请(仅被指定审批人)"""
|
||||
try:
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
data = request.get_json() or {}
|
||||
req = ScrapApprovalService.approve(
|
||||
request_id, int(get_jwt_identity()),
|
||||
data.get('action'), data.get('reject_reason', '')
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '操作成功', 'data': req.to_dict()}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'审批报废申请失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@scrap_bp.route('/request/<int:request_id>/execute', methods=['POST'])
|
||||
@jwt_required()
|
||||
@permission_required('scrap_execute')
|
||||
def execute_scrap_request(request_id):
|
||||
"""
|
||||
按单报废:有 scrap_execute 权限者执行,扣减实物库存并写报废流水。
|
||||
|
||||
Body: { "items": [{"source_table", "stock_id", "quantity", "name"/"sku"(可选)}, ...] }
|
||||
实扫明细必须是该申请单批准明细的子集,且累计数量不得超过批准数量。
|
||||
"""
|
||||
try:
|
||||
from app.services.scrap_approval_service import ScrapApprovalService
|
||||
identity = int(get_jwt_identity())
|
||||
u = SysUser.query.get(identity)
|
||||
data = request.get_json(silent=True) or {}
|
||||
req = ScrapApprovalService.execute(
|
||||
request_id,
|
||||
operator_name=(u.username if u else str(identity)),
|
||||
scanned_items=data.get('items') or [],
|
||||
)
|
||||
return jsonify({'code': 200, 'msg': '已执行报废并扣减库存', 'data': req.to_dict()}), 200
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)}), 400
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'执行报废失败: {str(e)}'}), 500
|
||||
|
||||
@ -162,6 +162,12 @@ def get_records():
|
||||
page = int(request.args.get('page', 1))
|
||||
keyword = request.args.get('keyword', '')
|
||||
search_type = request.args.get('search_type', 'all')
|
||||
start_date = request.args.get('start_date', '')
|
||||
end_date = request.args.get('end_date', '')
|
||||
|
||||
# ★ 高级筛选:JSON 字符串 → 条件列表
|
||||
from app.utils.advanced_filter import parse_advanced_filters
|
||||
advanced_filters = parse_advanced_filters(request.args.get('advancedFilters', ''))
|
||||
|
||||
# ★ 数据权限:普通用户只看“借用人=本人姓名(不含账号前缀)”的借还记录;管理者看全部
|
||||
borrower_name = None
|
||||
@ -175,7 +181,9 @@ def get_records():
|
||||
|
||||
res = TransService.get_records(
|
||||
page=page, limit=10, status=status, keyword=keyword,
|
||||
search_type=search_type, borrower_name=borrower_name
|
||||
search_type=search_type, borrower_name=borrower_name,
|
||||
start_date=start_date, end_date=end_date,
|
||||
advanced_filters=advanced_filters,
|
||||
)
|
||||
|
||||
# ★ service 层异常时:code==500 的字典(带 traceback),需要直通到前端,便于排查
|
||||
@ -307,7 +315,7 @@ def approve_borrow_request(request_id):
|
||||
@permission_required('op_borrow_approval')
|
||||
def close_borrow_request(request_id):
|
||||
"""
|
||||
手动完结已通过的借库审批单(status 1-已通过 → 3-已完成)
|
||||
手动完结已通过的借库审批单(status 1-已通过 → 4-已完结)
|
||||
参照出库审批的完结逻辑,供库管/主管在未走扫码借出时强制完结
|
||||
"""
|
||||
try:
|
||||
|
||||
96
inventory-backend/app/models/scrap_approval.py
Normal file
96
inventory-backend/app/models/scrap_approval.py
Normal file
@ -0,0 +1,96 @@
|
||||
from app.extensions import db, beijing_time
|
||||
import json
|
||||
|
||||
|
||||
def _beijing_now():
|
||||
"""
|
||||
统一时间口径:naive 北京时间,与 created_at / OutboundApproval / BorrowApproval 一致。
|
||||
|
||||
★ 原先返回 tz-aware 时间,配合 timestamptz 列才正确;现已将该表列类型统一为
|
||||
timestamp without time zone(见 db_migrations/unify_approval_timezone.sql),
|
||||
故此处必须返回 naive 值,否则存库会被当 UTC 处理而早 8 小时。
|
||||
"""
|
||||
return beijing_time()
|
||||
|
||||
|
||||
class ScrapApproval(db.Model):
|
||||
"""
|
||||
报废申请/审批单(镜像 BorrowApproval / OutboundApproval)
|
||||
|
||||
与出/借库的关键差异:items_json 里每条明细必须携带
|
||||
source_table + stock_id(精准定位到某条实物库存记录),
|
||||
因为报废从“申请那一刻”就要求指明具体要报废的东西。
|
||||
"""
|
||||
__tablename__ = 'scrap_approval'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
request_no = db.Column(db.String(100), unique=True, nullable=False, index=True)
|
||||
|
||||
applicant_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
remark = db.Column(db.Text) # 报废原因
|
||||
|
||||
# 状态: 0-待审批, 1-已通过(待执行), 2-已驳回, 3-已执行(已报废)
|
||||
status = db.Column(db.Integer, default=0, nullable=False, index=True)
|
||||
|
||||
allowed_approvers = db.Column(db.Text) # JSON: [{"type":"user","value":123}, ...]
|
||||
actual_approver_id = db.Column(db.Integer, index=True)
|
||||
approved_at = db.Column(db.DateTime)
|
||||
reject_reason = db.Column(db.Text)
|
||||
|
||||
# 明细快照: [{"source_table","stock_id","sku","name","spec_model","location","batch_number","scrap_qty"}]
|
||||
items_json = db.Column(db.Text, nullable=False)
|
||||
|
||||
# 执行信息
|
||||
executed_at = db.Column(db.DateTime)
|
||||
executor_name = db.Column(db.String(100))
|
||||
|
||||
created_at = db.Column(db.DateTime, default=_beijing_now, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=_beijing_now, onupdate=_beijing_now, nullable=False)
|
||||
|
||||
def set_items(self, items):
|
||||
self.items_json = json.dumps(items or [], ensure_ascii=False)
|
||||
|
||||
def get_items(self):
|
||||
try:
|
||||
return json.loads(self.items_json) if self.items_json else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def set_allowed_approvers(self, approvers):
|
||||
self.allowed_approvers = json.dumps(approvers or [], ensure_ascii=False)
|
||||
|
||||
def get_allowed_approvers(self):
|
||||
try:
|
||||
return json.loads(self.allowed_approvers) if self.allowed_approvers else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _user_name(user_id):
|
||||
if not user_id:
|
||||
return ''
|
||||
try:
|
||||
from app.models.system import SysUser
|
||||
u = SysUser.query.get(user_id)
|
||||
return u.username if u else ''
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'request_no': self.request_no,
|
||||
'applicant_id': self.applicant_id,
|
||||
'applicant_name': self._user_name(self.applicant_id),
|
||||
'remark': self.remark or '',
|
||||
'status': self.status,
|
||||
'allowed_approvers': self.get_allowed_approvers(),
|
||||
'actual_approver_id': self.actual_approver_id,
|
||||
'actual_approver_name': self._user_name(self.actual_approver_id),
|
||||
'approved_at': self.approved_at.strftime('%Y-%m-%d %H:%M:%S') if self.approved_at else None,
|
||||
'reject_reason': self.reject_reason or '',
|
||||
'items': self.get_items(),
|
||||
'executed_at': self.executed_at.strftime('%Y-%m-%d %H:%M:%S') if self.executed_at else None,
|
||||
'executor_name': self.executor_name or '',
|
||||
'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None,
|
||||
}
|
||||
@ -199,6 +199,8 @@ class TransScrap(db.Model):
|
||||
approval_status = db.Column(db.String(20), default='pending')
|
||||
cost_at_scrap = db.Column(db.Numeric(19, 4))
|
||||
total_loss = db.Column(db.Numeric(19, 4))
|
||||
# ★ 关联报废申请单号(审批流写台账用;DDL 见 db_migrations/add_scrap_approval.sql)
|
||||
scrap_request_no = db.Column(db.String(100), index=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
||||
@ -26,18 +26,70 @@ def _is_int(v):
|
||||
return False
|
||||
|
||||
|
||||
def _stock_models():
|
||||
"""
|
||||
延迟导入三张实物库存表。
|
||||
|
||||
★ material_base 表本身没有 sku 列,SKU 只存在于 stock_buy / stock_semi /
|
||||
stock_product,所以按 SKU 反查物料时必须先经库存表做 sku → base_id 映射。
|
||||
"""
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
return {
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct,
|
||||
}
|
||||
|
||||
|
||||
def _material_by_sku(sku, source_table=None, cache=None):
|
||||
"""
|
||||
按 SKU 反查物料主表(经库存表 sku → base_id 映射后回查 material_base)。
|
||||
|
||||
优先用明细自带的 source_table 定位,命中不了再依次降级查其余两张表。
|
||||
返回 MaterialBase 或 None(None 表示该 SKU 在库存表中查无对应物料)。
|
||||
"""
|
||||
sku = str(sku or '').strip()
|
||||
if not sku:
|
||||
return None
|
||||
|
||||
cache = cache if cache is not None else {}
|
||||
if sku in cache:
|
||||
return cache[sku]
|
||||
|
||||
models = _stock_models()
|
||||
order = ([source_table] if source_table in models else []) + [
|
||||
k for k in models if k != source_table
|
||||
]
|
||||
|
||||
found = None
|
||||
for key in order:
|
||||
row = models[key].query.filter_by(sku=sku).first()
|
||||
if row and row.base_id:
|
||||
m = MaterialBase.query.get(int(row.base_id))
|
||||
if m:
|
||||
found = m
|
||||
break
|
||||
|
||||
cache[sku] = found
|
||||
return found
|
||||
|
||||
|
||||
def resolve_approval_control(items):
|
||||
"""
|
||||
判断一批出库/借库申请明细是否需要审批。
|
||||
判断一批出库/借库/报废申请明细是否需要审批。
|
||||
|
||||
判定优先级:
|
||||
1) 方案1(最高):明细带有效 base_id → 直接按物料主键查 is_approval_required,
|
||||
且只要有有效 ID,无论命中与否都不再做任何字符串兜底(杜绝同名/同规格误杀)。
|
||||
2) 方案2(无 ID 兜底):名称必须 100% 精确一致 AND 核心规格一致(斜杠前段,
|
||||
或前端未传规格视为防呆拦截),两条件同时成立才判定命中。
|
||||
判定优先级(逐级降级,任一级给出结论即停止,不再往下兜底):
|
||||
1) base_id —— 物料主键,最权威。只要有有效 ID,无论是否命中需审批都不再做字符串匹配
|
||||
(杜绝同名/同规格误杀)。
|
||||
2) SKU —— 经库存表 sku→base_id 反查物料。查到即定论;查无结果才降级。
|
||||
3) 名称 + 规格 —— 仅当 base_id 与 SKU 均缺失、或两者均查无结果时才启用的字符串兜底。
|
||||
名称必须 100% 精确一致 AND 核心规格一致(斜杠前段,或前端未传规格视为防呆拦截)。
|
||||
"""
|
||||
flagged = []
|
||||
seen = set()
|
||||
sku_cache = {}
|
||||
|
||||
def _record(m_obj, req_name):
|
||||
if m_obj.id in seen:
|
||||
@ -49,25 +101,38 @@ def resolve_approval_control(items):
|
||||
'base_id': m_obj.id,
|
||||
})
|
||||
|
||||
# 1. 查出所有“启用且需审批”的危险物料库
|
||||
def _decide_by_material(m_obj, req_name):
|
||||
"""给出物料的权威结论:命中需审批则记录;无论命中与否都不再降级"""
|
||||
if m_obj and m_obj.is_enabled and m_obj.is_approval_required:
|
||||
_record(m_obj, req_name)
|
||||
|
||||
# 1. 查出所有“启用且需审批”的危险物料库(字符串兜底时用)
|
||||
flagged_materials = _materials_flagged()
|
||||
|
||||
for item in items or []:
|
||||
name = str(item.get('name') or '').strip()
|
||||
spec = str(item.get('spec_model') or '').strip()
|
||||
base_id = item.get('base_id')
|
||||
sku = item.get('sku')
|
||||
source_table = item.get('source_table')
|
||||
|
||||
# --- 方案 1:最高优先级,依据 base_id 绝对判定 ---
|
||||
# --- 优先级 1:base_id 绝对判定 ---
|
||||
if base_id is not None:
|
||||
bid = int(base_id) if _is_int(base_id) else 0
|
||||
if bid > 0:
|
||||
m = MaterialBase.query.get(bid)
|
||||
if m and m.is_enabled and m.is_approval_required:
|
||||
_record(m, name)
|
||||
# ★ 关键:只要有有效 ID,无论是否需审批,绝不允许往下走字符串兜底
|
||||
_decide_by_material(MaterialBase.query.get(bid), name)
|
||||
# ★ 关键:只要有有效 ID,无论是否需审批,绝不允许往下走 SKU/字符串兜底
|
||||
continue
|
||||
|
||||
# --- 方案 2:无 ID 时的降级兜底 (名称精确相等 AND 核心规格相等) ---
|
||||
# --- 优先级 2:SKU 反查物料 ---
|
||||
if str(sku or '').strip():
|
||||
m = _material_by_sku(sku, source_table, sku_cache)
|
||||
if m:
|
||||
_decide_by_material(m, name)
|
||||
# ★ 查到物料即定论,不再走字符串兜底
|
||||
continue
|
||||
|
||||
# --- 优先级 3:无 ID 且 SKU 查无结果时的降级兜底 (名称精确相等 AND 核心规格相等) ---
|
||||
if not name:
|
||||
continue
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from app.extensions import db
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.borrow import BorrowApproval
|
||||
from app.models.system import SysUser
|
||||
|
||||
@ -139,7 +139,8 @@ class BorrowApprovalService:
|
||||
)
|
||||
else:
|
||||
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
|
||||
from datetime import datetime, timezone, timedelta
|
||||
# ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
|
||||
# approved_at 列是 timestamp without time zone,传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
|
||||
approval = BorrowApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
@ -147,7 +148,7 @@ class BorrowApprovalService:
|
||||
borrower_name=borrower_name,
|
||||
status=1,
|
||||
actual_approver_id=applicant_id,
|
||||
approved_at=datetime.now(timezone(timedelta(hours=8))),
|
||||
approved_at=beijing_time(),
|
||||
)
|
||||
|
||||
approval.set_items(items)
|
||||
@ -388,8 +389,8 @@ class BorrowApprovalService:
|
||||
Returns:
|
||||
(success: bool, message: str, approval: BorrowApproval or None)
|
||||
"""
|
||||
beijing_tz = pytz.timezone('Asia/Shanghai')
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
# ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
|
||||
current_time = beijing_time()
|
||||
|
||||
approval = BorrowApproval.query.get(request_id)
|
||||
if not approval:
|
||||
@ -464,18 +465,21 @@ class BorrowApprovalService:
|
||||
|
||||
@staticmethod
|
||||
def mark_completed(request_id):
|
||||
"""标记审批单为已完成(借库执行完成后调用)"""
|
||||
"""
|
||||
手动完结已通过的借库审批单(status 1-已通过 → 4-已完结,对齐出库审批的完结语义)。
|
||||
注意:真正“扫码借出执行完成”走 trans_service.execute_dispatch,置 status=3(已完成)。
|
||||
"""
|
||||
approval = BorrowApproval.query.get(request_id)
|
||||
if not approval:
|
||||
return False, "审批单不存在", None
|
||||
|
||||
if approval.status != 1:
|
||||
return False, f"只有已通过的审批单才能标记为完成 (当前状态: {approval.status})", None
|
||||
return False, f"只有已通过的审批单才能标记为完结 (当前状态: {approval.status})", None
|
||||
|
||||
try:
|
||||
approval.status = 3 # 已完成
|
||||
approval.status = 4 # 已完结
|
||||
db.session.commit()
|
||||
return True, "审批单已完成", approval
|
||||
return True, "审批单已完结", approval
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return False, f"操作失败: {str(e)}", None
|
||||
@ -2,7 +2,7 @@ import uuid # .material -> .base refactor checked
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import or_, func, desc, and_
|
||||
from sqlalchemy.orm import joinedload
|
||||
from app.extensions import db
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.outbound import TransOutbound, OutboundApproval
|
||||
|
||||
# 引入所有库存模型以进行查询
|
||||
@ -327,12 +327,13 @@ class OutboundService:
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None):
|
||||
def get_grouped_list(page=1, per_page=10, keyword=None, search_type='all', start_date=None, end_date=None, company=None, consumer_name=None, advanced_filters=None):
|
||||
"""
|
||||
查询出库记录(按出库单号分组),包含详细物品信息
|
||||
支持跨表搜索:单号、领用人、SKU、物料名称、规格型号
|
||||
search_type: all, no, name, sku, material_name, spec_model
|
||||
company: 可选的公司过滤参数
|
||||
advanced_filters: 高级筛选条件列表 [{'field','operator','value'}, ...]
|
||||
"""
|
||||
# 日期补全:解决零点截断问题
|
||||
if end_date and len(str(end_date).strip()) == 10:
|
||||
@ -536,6 +537,51 @@ class OutboundService:
|
||||
if keyword_conditions is not None:
|
||||
stmt = stmt.filter(keyword_conditions)
|
||||
|
||||
# ====================================================================
|
||||
# ★ 高级筛选:父级字段直接过滤,子级字段(SKU/物料名称)走
|
||||
# 「命中单号子查询 → 按单号 IN」的 EXISTS 语义。
|
||||
#
|
||||
# 绝不能写成 stmt.filter(TransOutbound.sku.ilike(...)):
|
||||
# 那样会在 GROUP BY 前收窄明细范围,展开行里的兄弟明细会丢失。
|
||||
# ====================================================================
|
||||
if advanced_filters:
|
||||
from app.utils.advanced_filter import (
|
||||
build_predicate, apply_child_condition,
|
||||
)
|
||||
|
||||
# 父级字段:单号/操作人本身就在流水表上,直接 filter(否定操作符
|
||||
# 走标准 SQL != / NOT LIKE 即可,语义无歧义)
|
||||
parent_field_map = {
|
||||
'no': TransOutbound.outbound_no,
|
||||
'operator': TransOutbound.operator_name,
|
||||
'consumer_name': TransOutbound.consumer_name,
|
||||
'outbound_type': TransOutbound.outbound_type,
|
||||
}
|
||||
# 子级字段:SKU 直接列 + 物料名称(需三表联查)
|
||||
child_field_map = {'sku': TransOutbound.sku}
|
||||
material_stock_models = [
|
||||
(StockBuy, 'stock_buy'),
|
||||
(StockSemi, 'stock_semi'),
|
||||
(StockProduct, 'stock_product'),
|
||||
]
|
||||
|
||||
for cond in advanced_filters:
|
||||
field = cond.get('field')
|
||||
|
||||
if field in parent_field_map:
|
||||
p = build_predicate(cond, parent_field_map)
|
||||
if p is not None:
|
||||
stmt = stmt.filter(p)
|
||||
continue
|
||||
|
||||
if field in child_field_map or field == 'material_name':
|
||||
# ★ 正/负操作符语义分派:否定 → 整单排除(NOT IN)
|
||||
stmt = apply_child_condition(
|
||||
stmt, TransOutbound.outbound_no, TransOutbound, cond,
|
||||
child_field_map, material_stock_models,
|
||||
)
|
||||
continue
|
||||
|
||||
if start_date and end_date:
|
||||
stmt = stmt.filter(TransOutbound.outbound_time.between(start_date, end_date))
|
||||
|
||||
@ -816,7 +862,8 @@ class OutboundApprovalService:
|
||||
)
|
||||
else:
|
||||
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
|
||||
from datetime import datetime, timezone, timedelta
|
||||
# ★ approved_at 必须与 created_at(beijing_time) 同为 naive 本地时间:
|
||||
# approved_at 列是 timestamp without time zone,传 aware 时间会被驱动转成 UTC 存库,导致早 8 小时
|
||||
approval = OutboundApproval(
|
||||
request_no=request_no,
|
||||
applicant_id=applicant_id,
|
||||
@ -824,7 +871,7 @@ class OutboundApprovalService:
|
||||
outbound_type=outbound_type,
|
||||
status=1,
|
||||
actual_approver_id=applicant_id,
|
||||
approved_at=datetime.now(timezone(timedelta(hours=8))),
|
||||
approved_at=beijing_time(),
|
||||
)
|
||||
|
||||
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
|
||||
@ -1006,8 +1053,8 @@ class OutboundApprovalService:
|
||||
"""
|
||||
from app.models.outbound import OutboundApproval
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
current_time = datetime.now(beijing_tz).replace(tzinfo=None)
|
||||
# ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
|
||||
current_time = beijing_time()
|
||||
|
||||
approval = OutboundApproval.query.get(request_id)
|
||||
if not approval:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta, date
|
||||
from sqlalchemy import func
|
||||
from app.extensions import db
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.purchase import PurchaseRequest
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
@ -145,8 +145,8 @@ class PurchaseService:
|
||||
if not purchase:
|
||||
raise ValueError("采购申请不存在")
|
||||
|
||||
beijing_tz = timezone(timedelta(hours=8))
|
||||
now = datetime.now(beijing_tz)
|
||||
# ★ approved_at 与 created_at(beijing_time) 同为 naive 本地时间,避免存库时被转成 UTC 早 8 小时
|
||||
now = beijing_time()
|
||||
|
||||
# ★ 完结:库管将「已通过(1)」的申请单置为「已完结(4)」(仿出库审批)
|
||||
if action == 'close':
|
||||
|
||||
376
inventory-backend/app/services/scrap_approval_service.py
Normal file
376
inventory-backend/app/services/scrap_approval_service.py
Normal file
@ -0,0 +1,376 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.extensions import db, beijing_time
|
||||
from app.models.scrap_approval import ScrapApproval
|
||||
from app.models.transaction import TransScrap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _beijing():
|
||||
"""
|
||||
统一时间口径:naive 北京时间。
|
||||
|
||||
★ scrap_approval 的时间列已统一为 timestamp without time zone
|
||||
(见 db_migrations/unify_approval_timezone.sql),必须返回 naive 值,
|
||||
否则 aware 值会被驱动转成 UTC 存库,比 created_at 早 8 小时。
|
||||
"""
|
||||
return beijing_time()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ★ 业务规则(单一事实来源):报废一律需审批
|
||||
#
|
||||
# 与出库/借库不同,报废不过滤 is_approval_required —— 无论物料是否命中该标记,
|
||||
# 所有报废申请都必须由指定审批人审批通过后才能执行。
|
||||
# 前端 apply/index.vue 的「审批人」必填项与此规则保持一致。
|
||||
# =============================================================================
|
||||
SCRAP_ALWAYS_REQUIRES_APPROVAL = True
|
||||
|
||||
|
||||
STOCK_MODELS = {}
|
||||
|
||||
|
||||
def _stock_models():
|
||||
"""延迟导入三张实物库存表,避免循环依赖"""
|
||||
if not STOCK_MODELS:
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
STOCK_MODELS.update({
|
||||
'stock_buy': StockBuy,
|
||||
'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct,
|
||||
})
|
||||
return STOCK_MODELS
|
||||
|
||||
|
||||
class ScrapApprovalService:
|
||||
|
||||
@staticmethod
|
||||
def generate_request_no():
|
||||
now = _beijing()
|
||||
prefix = f"APR-SCRAP-{now.strftime('%Y%m%d-%H%M')}-"
|
||||
n = db.session.query(func.count(func.distinct(ScrapApproval.request_no))) \
|
||||
.filter(ScrapApproval.request_no.like(f"{prefix}%")).scalar() or 0
|
||||
return f"{prefix}{(n + 1):04d}"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 提交申请
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def submit_approval(applicant_id, items, allowed_approvers=None, remark=None,
|
||||
approver_id=None, force_approval=False):
|
||||
"""
|
||||
提交报废申请(仅锁定“意向”,不扣库存;扣减在库管执行时进行)
|
||||
|
||||
items 每项必须包含 source_table + stock_id(精准实物),可带 scrap_qty / 快照字段。
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("报废明细不能为空")
|
||||
|
||||
models = _stock_models()
|
||||
normalized = []
|
||||
for idx, it in enumerate(items):
|
||||
st = (it.get('source_table') or '').strip()
|
||||
model = models.get(st)
|
||||
sid = it.get('stock_id')
|
||||
if not model or not sid:
|
||||
raise ValueError(f"第 {idx + 1} 条报废明细必须指定 source_table 与 stock_id(精准库存行)")
|
||||
try:
|
||||
sid = int(sid)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"第 {idx + 1} 条 stock_id 无效")
|
||||
row = model.query.get(sid)
|
||||
if not row:
|
||||
raise ValueError(f"第 {idx + 1} 条对应的库存记录不存在")
|
||||
try:
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量无效")
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量必须大于 0")
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
if qty > avail:
|
||||
raise ValueError(f"第 {idx + 1} 条报废数量({qty})超过可用库存({avail})")
|
||||
|
||||
base = getattr(row, 'base', None)
|
||||
normalized.append({
|
||||
'source_table': st,
|
||||
'stock_id': sid,
|
||||
'base_id': getattr(row, 'base_id', None),
|
||||
'sku': getattr(row, 'sku', '') or '',
|
||||
'name': (base.name if base else '') or it.get('name') or '',
|
||||
'spec_model': (base.spec_model if base else '') or it.get('spec_model') or '',
|
||||
'location': getattr(row, 'warehouse_location', '') or '',
|
||||
'batch_number': getattr(row, 'batch_number', '') or getattr(row, 'serial_number', '') or '',
|
||||
'scrap_qty': qty,
|
||||
'available_at_apply': avail,
|
||||
})
|
||||
|
||||
# ★ 报废一律需审批(见 SCRAP_ALWAYS_REQUIRES_APPROVAL)。
|
||||
# resolve_approval_control 仍调用,但仅用于生成「哪些物料命中需审批」的提示文案,
|
||||
# 不再用它决定是否需要审批。
|
||||
from app.services.approval_control import resolve_approval_control
|
||||
_, flagged_materials = resolve_approval_control(normalized)
|
||||
|
||||
if not approver_id:
|
||||
if flagged_materials:
|
||||
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
|
||||
raise ValueError(f"以下物料需审批报废:{_names}。请选择审批人后再提交")
|
||||
raise ValueError("报废申请必须选择审批人后再提交")
|
||||
|
||||
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
|
||||
|
||||
req = ScrapApproval(
|
||||
request_no=ScrapApprovalService.generate_request_no(),
|
||||
applicant_id=applicant_id,
|
||||
remark=remark,
|
||||
)
|
||||
req.set_items(normalized)
|
||||
req.set_allowed_approvers(allowed_approvers)
|
||||
|
||||
# ★ 恒为「待审批」,不再走免审批自动通过分支
|
||||
req.status = 0
|
||||
|
||||
db.session.add(req)
|
||||
db.session.commit()
|
||||
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} approver={approver_id}")
|
||||
return req
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 列表
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def get_list(page=1, limit=10, status=None, applicant_id=None, approver_id=None):
|
||||
query = ScrapApproval.query
|
||||
if status is not None:
|
||||
query = query.filter(ScrapApproval.status == status)
|
||||
if applicant_id is not None:
|
||||
query = query.filter(ScrapApproval.applicant_id == applicant_id)
|
||||
if approver_id is not None:
|
||||
query = query.filter(ScrapApproval.allowed_approvers.like(f'%"value": {approver_id}%'))
|
||||
query = query.order_by(ScrapApproval.created_at.desc())
|
||||
pg = query.paginate(page=page, per_page=limit, error_out=False)
|
||||
return {
|
||||
'items': [r.to_dict() for r in pg.items],
|
||||
'total': pg.total,
|
||||
'pages': pg.pages,
|
||||
'current_page': page,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 审批
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def approve(request_id, operator_id, action, reject_reason=None):
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
if not req:
|
||||
raise ValueError("报废申请不存在")
|
||||
if req.status != 0:
|
||||
raise ValueError("当前状态不允许审批(仅待审批可操作)")
|
||||
|
||||
# 仅被指定的审批人可操作
|
||||
allowed = req.get_allowed_approvers() or []
|
||||
user_entries = [str(a.get('value')) for a in allowed if a.get('type') == 'user']
|
||||
if user_entries and str(operator_id) not in user_entries:
|
||||
raise ValueError("只有被指定的审批人可以审批该申请")
|
||||
|
||||
if action == 'approve':
|
||||
req.status = 1
|
||||
req.actual_approver_id = operator_id
|
||||
req.approved_at = _beijing()
|
||||
req.reject_reason = None
|
||||
elif action == 'reject':
|
||||
req.status = 2
|
||||
req.actual_approver_id = operator_id
|
||||
req.reject_reason = reject_reason or '未说明原因'
|
||||
else:
|
||||
raise ValueError("无效的审批动作")
|
||||
db.session.commit()
|
||||
logger.info(f"[ScrapApproval] {req.request_no} 审批 {action} by {operator_id}")
|
||||
return req
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 执行(按单报废:扣减实物库存 + 写报废流水)
|
||||
# ------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _to_int(v):
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _norm_sku(sku):
|
||||
"""SKU 归一化:去首尾空白。空 SKU 返回 '',由调用方回退到行级匹配键。"""
|
||||
return str(sku or '').strip()
|
||||
|
||||
@staticmethod
|
||||
def _match_key(source_table, stock_id, sku):
|
||||
"""
|
||||
★ 扫码匹配键 —— SKU 优先。
|
||||
|
||||
已核验:SKU 在同一库存表内唯一,且不存在跨表重名(stock_buy /
|
||||
stock_semi / stock_product 三表交叉无冲突),故 SKU 可安全作为
|
||||
跨来源的稳定标识。
|
||||
|
||||
例外:个别历史库存行的 SKU 为空,这类行无法用 SKU 标识,回退为
|
||||
source_table + stock_id 复合键。前缀区分('sku:' / 'row:')保证
|
||||
空 SKU 行绝不会与任何正常 SKU 串键。
|
||||
"""
|
||||
s = ScrapApprovalService._norm_sku(sku)
|
||||
if s:
|
||||
return ('sku', s)
|
||||
return ('row', f"{source_table}#{stock_id}")
|
||||
|
||||
@staticmethod
|
||||
def _build_approved_index(items):
|
||||
"""
|
||||
申请单明细 → {匹配键: 批准信息}
|
||||
|
||||
每项保留其 source_table + stock_id 清单(rows),执行时据此定位到
|
||||
批准时指定的那条库存记录做加锁扣减。同一 SKU 若在批准单中出现多行,
|
||||
数量累加、rows 按批准顺序保留。
|
||||
"""
|
||||
index = {}
|
||||
for it in items:
|
||||
sid = ScrapApprovalService._to_int(it.get('stock_id'))
|
||||
st = str(it.get('source_table') or '').strip()
|
||||
if not st or sid is None:
|
||||
continue
|
||||
key = ScrapApprovalService._match_key(st, sid, it.get('sku'))
|
||||
entry = index.setdefault(key, {
|
||||
'qty': 0.0,
|
||||
'label': ScrapApprovalService._norm_sku(it.get('sku'))
|
||||
or it.get('name') or f"{st}#{sid}",
|
||||
'rows': [],
|
||||
})
|
||||
qty = float(it.get('scrap_qty') or 0)
|
||||
entry['qty'] += qty
|
||||
entry['rows'].append({'source_table': st, 'stock_id': sid, 'qty': qty})
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def _build_scanned_index(scanned_items, models):
|
||||
"""前端实扫明细 → {匹配键: 累计扫码数量}"""
|
||||
index = {}
|
||||
for idx, s in enumerate(scanned_items):
|
||||
sku = ScrapApprovalService._norm_sku(s.get('sku'))
|
||||
st = str(s.get('source_table') or '').strip()
|
||||
sid = ScrapApprovalService._to_int(s.get('stock_id'))
|
||||
if not sku and (not st or sid is None):
|
||||
raise ValueError(f"第 {idx + 1} 条扫码明细缺少 SKU,且无有效的 source_table / stock_id")
|
||||
if st and st not in models:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码来源不支持:{st}")
|
||||
qty = float(s.get('quantity') or 0)
|
||||
if qty <= 0:
|
||||
raise ValueError(f"第 {idx + 1} 条扫码数量必须大于 0")
|
||||
key = ScrapApprovalService._match_key(st, sid, sku)
|
||||
entry = index.setdefault(key, {
|
||||
'qty': 0.0,
|
||||
'label': sku or s.get('name') or f"{st}#{sid}",
|
||||
})
|
||||
entry['qty'] += qty
|
||||
return index
|
||||
|
||||
@staticmethod
|
||||
def execute(request_id, operator_name='System', scanned_items=None):
|
||||
"""
|
||||
按单执行报废:以「实际扫码明细」为准,按 SKU 匹配批准明细后扣减库存。
|
||||
|
||||
scanned_items: [{'sku', 'quantity', 'source_table', 'stock_id'(可选,空 SKU 时必填)}]
|
||||
· 以 SKU 为主校验键:扫码 SKU 必须在申请单 items_json 中存在;
|
||||
· 同一 SKU 多次扫码累加,累计不得超过该 SKU 的批准总量;
|
||||
· 允许合法子集(少扫 = 本次不报废该行);
|
||||
· 扣减时以批准单配对的 source_table + stock_id 定位库存行加锁。
|
||||
"""
|
||||
models = _stock_models()
|
||||
req = db.session.get(ScrapApproval, request_id)
|
||||
if not req:
|
||||
raise ValueError("报废申请不存在")
|
||||
if req.status != 1:
|
||||
raise ValueError("仅“已通过(待执行)”的报废单可执行")
|
||||
|
||||
approved_items = req.get_items()
|
||||
if not approved_items:
|
||||
raise ValueError("报废明细为空,无法执行")
|
||||
|
||||
# ★ 强制按单扫码:未提交实扫明细不允许执行
|
||||
if not scanned_items:
|
||||
raise ValueError("请先扫码并提交实际报废物料,再执行报废")
|
||||
|
||||
approved = ScrapApprovalService._build_approved_index(approved_items)
|
||||
scanned = ScrapApprovalService._build_scanned_index(scanned_items, models)
|
||||
|
||||
# ★ 校验一:扫码 SKU 必须在批准明细内
|
||||
for key, acc in scanned.items():
|
||||
if key not in approved:
|
||||
raise ValueError(
|
||||
f"SKU【{acc['label']}】不在该报废申请单的批准明细中(SKU 不匹配),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 校验二:同一 SKU 的累计扫码量不得超过批准总量
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
if acc['qty'] > appr['qty']:
|
||||
raise ValueError(
|
||||
f"SKU【{acc['label']}】扫码数量({acc['qty']})超出批准数量({appr['qty']}),禁止报废"
|
||||
)
|
||||
|
||||
# ★ 扣减:按批准单配对的 source_table + stock_id 定位库存行,逐行加锁扣减
|
||||
for key, acc in scanned.items():
|
||||
appr = approved[key]
|
||||
remaining = acc['qty']
|
||||
|
||||
for ref in appr['rows']:
|
||||
if remaining <= 0:
|
||||
break
|
||||
take = min(remaining, ref['qty'])
|
||||
if take <= 0:
|
||||
continue
|
||||
|
||||
st, sid = ref['source_table'], ref['stock_id']
|
||||
row = models[st].query.with_for_update().get(sid)
|
||||
if not row:
|
||||
raise ValueError(f"库存记录已不存在({acc['label']})")
|
||||
|
||||
avail = float(getattr(row, 'available_quantity', 0) or 0)
|
||||
stock = float(getattr(row, 'stock_quantity', 0) or 0)
|
||||
if take > avail:
|
||||
raise ValueError(f"库存 SKU【{acc['label']}】可用不足(剩 {avail}),无法报废 {take}")
|
||||
if take > stock:
|
||||
raise ValueError(f"库存 SKU【{acc['label']}】实物不足(剩 {stock}),无法报废 {take}")
|
||||
|
||||
# ★ 真正扣减:报废 = 实物销毁,实物库存与可用库存需同时扣减
|
||||
row.available_quantity = avail - take
|
||||
row.stock_quantity = stock - take
|
||||
db.session.flush()
|
||||
|
||||
# 写报废流水(台账)
|
||||
db.session.add(TransScrap(
|
||||
sku=getattr(row, 'sku', '') or acc['label'],
|
||||
source_table=st,
|
||||
stock_id=sid,
|
||||
quantity=take,
|
||||
reason=req.remark or '',
|
||||
operator_name=operator_name,
|
||||
approver_name=ScrapApproval._user_name(req.actual_approver_id),
|
||||
approval_status='executed',
|
||||
scrap_request_no=req.request_no,
|
||||
))
|
||||
|
||||
remaining -= take
|
||||
|
||||
req.status = 3
|
||||
req.executed_at = _beijing()
|
||||
req.executor_name = operator_name
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
f"[ScrapApproval] {req.request_no} 执行报废完成 by {operator_name} "
|
||||
f"(批准 {len(approved)} 项 / 实扫 {len(scanned)} 项)"
|
||||
)
|
||||
return req
|
||||
@ -381,10 +381,14 @@ class TransService:
|
||||
if ModelClass:
|
||||
stock = ModelClass.query.with_for_update().get(record.stock_id)
|
||||
if stock:
|
||||
stock.stock_quantity = float(stock.stock_quantity or 0) - pending_qty
|
||||
# 防止负库存
|
||||
if stock.stock_quantity < 0:
|
||||
stock.stock_quantity = 0
|
||||
stock_qty = float(stock.stock_quantity or 0)
|
||||
# 不足则报错整单回滚:直接夹到 0 会让 available_quantity > stock_quantity,
|
||||
# 凭空多出可用库存
|
||||
if pending_qty > stock_qty:
|
||||
raise ValueError(
|
||||
f"SKU {record.sku} 实物库存不足(剩 {stock_qty}),无法报废 {pending_qty}"
|
||||
)
|
||||
stock.stock_quantity = stock_qty - pending_qty
|
||||
|
||||
# 3. 创建报废记录
|
||||
scrap_record = TransScrap(
|
||||
@ -410,7 +414,9 @@ class TransService:
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all', borrower_name=None):
|
||||
def get_records(page=1, limit=10, status='all', keyword=None, search_type='all',
|
||||
borrower_name=None, start_date=None, end_date=None,
|
||||
advanced_filters=None):
|
||||
"""
|
||||
获取借还记录列表(按单号 borrow_no 维度分页,避免明细撑爆 pageSize)
|
||||
|
||||
@ -425,7 +431,14 @@ class TransService:
|
||||
状态过滤按"单号聚合"判定:
|
||||
- borrowed: 单号下至少有一条 is_returned=False
|
||||
- returned: 单号下所有明细 is_returned=True
|
||||
|
||||
日期范围按「借出时间」过滤,边界补全时分秒以解决零点截断问题。
|
||||
"""
|
||||
# 日期补全:与出库记录同口径
|
||||
if end_date and len(str(end_date).strip()) == 10:
|
||||
end_date = f"{str(end_date).strip()} 23:59:59"
|
||||
if start_date and len(str(start_date).strip()) == 10:
|
||||
start_date = f"{str(start_date).strip()} 00:00:00"
|
||||
try:
|
||||
# ====================================================================
|
||||
# 步骤 1a:构造"单号维度"基础子查询(GROUP BY borrow_no 在这里完成)
|
||||
@ -676,6 +689,63 @@ class TransService:
|
||||
order_subq.c.borrow_no.in_(company_borrow_nos_subq)
|
||||
)
|
||||
|
||||
# ====================================================================
|
||||
# ★ 高级筛选:父级字段直接过滤;子级字段(SKU/物料名称)走
|
||||
# 「命中单号子查询 → 按单号 IN」的 EXISTS 语义,
|
||||
# 避免在 GROUP BY 前收窄明细范围而丢失同单的兄弟明细。
|
||||
# ====================================================================
|
||||
if advanced_filters:
|
||||
from app.utils.advanced_filter import (
|
||||
build_predicate, apply_child_condition,
|
||||
)
|
||||
parent_map = {
|
||||
'no': TransBorrow.borrow_no,
|
||||
'operator': TransBorrow.borrower_name,
|
||||
'borrower_name': TransBorrow.borrower_name,
|
||||
}
|
||||
child_map = {'sku': TransBorrow.sku}
|
||||
material_stock_models = [
|
||||
(StockBuy, 'stock_buy'),
|
||||
(StockSemi, 'stock_semi'),
|
||||
(StockProduct, 'stock_product'),
|
||||
]
|
||||
for cond in advanced_filters:
|
||||
field = cond.get('field')
|
||||
|
||||
if field in parent_map:
|
||||
# 父级字段:标准 SQL 谓词即可
|
||||
p = build_predicate(cond, parent_map)
|
||||
if p is not None:
|
||||
borrow_no_q = borrow_no_q.filter(
|
||||
order_subq.c.borrow_no.in_(
|
||||
db.session.query(TransBorrow.borrow_no)
|
||||
.filter(p).distinct()
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if field in child_map or field == 'material_name':
|
||||
# ★ 子级字段:正/负操作符语义分派(否定 → 整单排除)
|
||||
borrow_no_q = apply_child_condition(
|
||||
borrow_no_q, order_subq.c.borrow_no, TransBorrow,
|
||||
cond, child_map, material_stock_models,
|
||||
)
|
||||
continue
|
||||
|
||||
# 日期范围过滤(按借出时间;边界已在入口补全时分秒)
|
||||
if start_date:
|
||||
borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_(
|
||||
db.session.query(TransBorrow.borrow_no)
|
||||
.filter(TransBorrow.borrow_time >= start_date)
|
||||
.distinct()
|
||||
))
|
||||
if end_date:
|
||||
borrow_no_q = borrow_no_q.filter(order_subq.c.borrow_no.in_(
|
||||
db.session.query(TransBorrow.borrow_no)
|
||||
.filter(TransBorrow.borrow_time <= end_date)
|
||||
.distinct()
|
||||
))
|
||||
|
||||
# 状态过滤(按"单号聚合"判定)
|
||||
if status == 'borrowed':
|
||||
# 单号下至少一条未还
|
||||
|
||||
259
inventory-backend/app/utils/advanced_filter.py
Normal file
259
inventory-backend/app/utils/advanced_filter.py
Normal file
@ -0,0 +1,259 @@
|
||||
"""
|
||||
高级筛选(Advanced Filters)共享解析与谓词构建工具。
|
||||
|
||||
设计背景
|
||||
--------
|
||||
记录类接口(出库 / 借还 / 报废)对外返回的是**按单号分组的订单**(父),
|
||||
而用户筛选用的字段往往落在**明细行**上(子),例如「SKU 包含 123」。
|
||||
|
||||
若直接在分组查询上加 .filter(TransOutbound.sku.ilike('%123%')),分组范围会
|
||||
被一起收窄——GROUP BY 之后只剩匹配的那条明细,展开行里的其它兄弟明细会
|
||||
凭空消失。正确做法是:先求出「含匹配明细的单号集合」,再让主查询按该集合
|
||||
过滤,即 IN / EXISTS 子查询语义。
|
||||
|
||||
本模块只负责保证三个模块的操作符语义不漂移:
|
||||
1. parse_advanced_filters() 解析并规整前端传来的 JSON 字符串
|
||||
2. build_predicate() 把单个条件翻译成 SQLAlchemy 谓词
|
||||
3. build_material_name_subquery() 物料名称的三表联查子查询(三模块共用)
|
||||
|
||||
字段 → 列的映射由各调用方提供(allowed_fields),因为三个模块的操作人/单号
|
||||
列名不同。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 与前端 operatorOptions 保持一致的受支持操作符
|
||||
SUPPORTED_OPERATORS = {
|
||||
'eq', 'ne', 'contains', 'not_contains', 'ge', 'le',
|
||||
}
|
||||
|
||||
# ★ 否定操作符 → 其等价的肯定形式。
|
||||
# 子级字段(SKU/物料名称)的否定语义必须借「肯定形式求反」来实现,
|
||||
# 不能直接用 SQL 的 != / NOT LIKE,原因见 apply_child_condition 的说明。
|
||||
NEGATIVE_OPERATORS = {
|
||||
'ne': 'eq',
|
||||
'not_contains': 'contains',
|
||||
}
|
||||
|
||||
|
||||
def invert_condition(condition):
|
||||
"""
|
||||
把否定条件翻转为等价的肯定条件(ne→eq,not_contains→contains)。
|
||||
|
||||
非否定操作符原样返回。
|
||||
"""
|
||||
op = condition.get('operator')
|
||||
if op in NEGATIVE_OPERATORS:
|
||||
flipped = dict(condition)
|
||||
flipped['operator'] = NEGATIVE_OPERATORS[op]
|
||||
return flipped
|
||||
return dict(condition)
|
||||
|
||||
|
||||
def is_negative(condition):
|
||||
return condition.get('operator') in NEGATIVE_OPERATORS
|
||||
|
||||
|
||||
def parse_advanced_filters(raw):
|
||||
"""
|
||||
解析 advancedFilters 查询参数。
|
||||
|
||||
前端传的是 JSON.stringify 后的数组字符串;同时兼容已被解析为 list 的情况
|
||||
(测试或内部调用)。任何解析失败都退化为空列表(不影响主查询),
|
||||
并记 warning 便于排查。
|
||||
"""
|
||||
if not raw:
|
||||
return []
|
||||
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(f"[advanced_filter] JSON 解析失败,已忽略: {e}")
|
||||
return []
|
||||
|
||||
if not isinstance(raw, list):
|
||||
logger.warning(f"[advanced_filter] 期望 list,实际 {type(raw).__name__},已忽略")
|
||||
return []
|
||||
|
||||
parsed = []
|
||||
for cond in raw:
|
||||
if not isinstance(cond, dict):
|
||||
continue
|
||||
field = str(cond.get('field') or '').strip()
|
||||
operator = str(cond.get('operator') or '').strip()
|
||||
value = cond.get('value')
|
||||
|
||||
# 字段与操作符必须有效;值允许为 0 / '0',故用 is None 判定而非真值
|
||||
if not field or operator not in SUPPORTED_OPERATORS or value is None:
|
||||
continue
|
||||
if isinstance(value, str) and value.strip() == '':
|
||||
continue
|
||||
|
||||
parsed.append({'field': field, 'operator': operator, 'value': value})
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def build_predicate(condition, allowed_fields):
|
||||
"""
|
||||
把单个条件翻译成 SQLAlchemy 谓词。
|
||||
|
||||
allowed_fields: {field_name: Column} —— 调用方给出的白名单映射,
|
||||
未登记的字段返回 None(杜绝任意列注入)。
|
||||
|
||||
数值型操作符 ge/le 在值无法转 float 时返回 None 跳过该条件,
|
||||
与既有 buy_service 的处理保持一致,避免整条查询 500。
|
||||
"""
|
||||
field = condition.get('field')
|
||||
operator = condition.get('operator')
|
||||
value = condition.get('value')
|
||||
|
||||
column = allowed_fields.get(field)
|
||||
if column is None:
|
||||
return None
|
||||
|
||||
if operator == 'eq':
|
||||
return column == value
|
||||
if operator == 'ne':
|
||||
return column != value
|
||||
if operator == 'contains':
|
||||
return column.ilike(f'%{value}%')
|
||||
if operator == 'not_contains':
|
||||
return ~column.ilike(f'%{value}%')
|
||||
if operator == 'ge':
|
||||
try:
|
||||
return column >= float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if operator == 'le':
|
||||
try:
|
||||
return column <= float(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def build_material_name_select(model, order_no_column, stock_models, keyword,
|
||||
operator='contains'):
|
||||
"""
|
||||
构造「物料名称命中」的单号查询(三表联查,出库/借还/报废共用)。
|
||||
|
||||
material_name 不在流水表上,需经 stock_buy / stock_semi / stock_product
|
||||
三张库存表 JOIN material_base 才能取到。任一来源命中即算命中,故三路
|
||||
union 后再交给调用方做 order_no_column.in_(...)。
|
||||
|
||||
这是"子级字段 → 父级订单"的核心:调用方对**单号**做 IN 过滤,
|
||||
而不是对明细行做 filter,从而保住同单的其它兄弟明细。
|
||||
|
||||
参数
|
||||
----
|
||||
model : 流水模型(TransOutbound / TransBorrow / TransScrap)
|
||||
order_no_column: 该模型上的单号列
|
||||
stock_models : [(StockModel, source_table_value), ...]
|
||||
keyword : 匹配值
|
||||
operator : 'contains'(默认)或 'eq'
|
||||
|
||||
返回
|
||||
----
|
||||
可直接用于 order_no_column.in_(...) 的 select,或 None(无有效来源/无关键词)。
|
||||
"""
|
||||
if not keyword:
|
||||
return None
|
||||
if not stock_models:
|
||||
return None
|
||||
|
||||
from sqlalchemy import and_
|
||||
from app.models.base import MaterialBase
|
||||
|
||||
matched = MaterialBase.name == keyword if operator == 'eq' \
|
||||
else MaterialBase.name.ilike(f'%{keyword}%')
|
||||
|
||||
selects = []
|
||||
for StockModel, source_value in stock_models:
|
||||
s = (
|
||||
db_session_query(model, order_no_column)
|
||||
.join(StockModel, and_(model.stock_id == StockModel.id,
|
||||
model.source_table == source_value))
|
||||
.join(MaterialBase, StockModel.base_id == MaterialBase.id)
|
||||
.filter(matched)
|
||||
)
|
||||
selects.append(s)
|
||||
|
||||
combined = selects[0]
|
||||
for extra in selects[1:]:
|
||||
combined = combined.union(extra)
|
||||
return combined
|
||||
|
||||
|
||||
def db_session_query(model, *columns):
|
||||
"""按需构造 Query(延迟导入 db,避免模块级导入环)"""
|
||||
from app.extensions import db
|
||||
return db.session.query(*columns)
|
||||
|
||||
|
||||
def apply_child_condition(stmt, order_no_column, model, condition,
|
||||
child_field_map, material_stock_models=None):
|
||||
"""
|
||||
★ 把**子级**条件应用到主查询上,正/负操作符语义有别。
|
||||
|
||||
为什么否定操作符不能直接用 SQL 的 != / NOT LIKE
|
||||
---------------------------------------------------
|
||||
订单是父、明细是子。若对明细行写 `sku != 'X'`,得到的是
|
||||
「本单存在某条 SKU 不等于 X 的明细」——多明细单几乎必然成立,
|
||||
于是「SKU 不等于 X」会返回几乎全部订单,与用户直觉相悖。
|
||||
|
||||
用户点「不等于」时的真实意图是:**整单排除**——只要单内任意一条明细
|
||||
命中 X,整张单就不该出现。即 NOT EXISTS / NOT IN 语义。
|
||||
|
||||
实现方式
|
||||
--------
|
||||
肯定操作符: order_no IN (含匹配明细的单号)
|
||||
否定操作符: order_no NOT IN (含匹配明细的单号)
|
||||
|
||||
两者的子查询完全一致(都用肯定形式的谓词),只是外层取反,
|
||||
因此 ne / not_contains 只需翻转为 eq / contains 后走同一条路径。
|
||||
|
||||
参数
|
||||
----
|
||||
stmt : 主查询(已 GROUP BY 或待过滤的 Query)
|
||||
order_no_column : 单号列
|
||||
model : 流水模型(用于物料名称的三表联查)
|
||||
condition : 单个高级筛选条件
|
||||
child_field_map : {field: Column} 明细级直接字段白名单(如 sku)
|
||||
material_stock_models : [(StockModel, source_table_value), ...],
|
||||
提供后可支持 material_name 字段
|
||||
|
||||
返回
|
||||
----
|
||||
过滤后的 stmt;条件不可用(字段未登记/值为非法类型)时原样返回。
|
||||
"""
|
||||
from app.extensions import db
|
||||
|
||||
negative = is_negative(condition)
|
||||
probe = invert_condition(condition) # 一律以肯定形式构造子查询
|
||||
field = probe.get('field')
|
||||
|
||||
inner = None
|
||||
if field == 'material_name':
|
||||
if not material_stock_models:
|
||||
return stmt
|
||||
sel = build_material_name_select(
|
||||
model, order_no_column, material_stock_models,
|
||||
probe.get('value'), operator=probe.get('operator', 'contains'),
|
||||
)
|
||||
if sel is None:
|
||||
return stmt
|
||||
inner = sel
|
||||
else:
|
||||
p = build_predicate(probe, child_field_map)
|
||||
if p is None:
|
||||
return stmt
|
||||
sub = db.session.query(order_no_column).filter(p).distinct().subquery()
|
||||
inner = sub.select().with_only_columns(sub.c[0])
|
||||
|
||||
# ★ 否定 → 整单排除(NOT IN);肯定 → 整单命中(IN)
|
||||
return stmt.filter(~order_no_column.in_(inner) if negative
|
||||
else order_no_column.in_(inner))
|
||||
@ -26,3 +26,95 @@ export function getScrapRecords(params: any) {
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 3.1 报废申请专用库存列表(独立于出库选单权限,Fail-Closed 剥离价格)
|
||||
export function getScrapStockList(params: { page?: number; pageSize?: number; keyword?: string }) {
|
||||
return request({
|
||||
url: '/v1/scrap/stock-list',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 报废申请/审批/执行(新流程,镜像出/借库)
|
||||
// ==========================================
|
||||
|
||||
// 4. 提交报废申请
|
||||
export function submitScrapRequest(data: {
|
||||
items: Array<{
|
||||
source_table: string
|
||||
stock_id: number
|
||||
scrap_qty: number
|
||||
name?: string
|
||||
spec_model?: string
|
||||
sku?: string
|
||||
location?: string
|
||||
batch_number?: string
|
||||
}>
|
||||
remark?: string
|
||||
approver_id?: number | null
|
||||
allowed_approvers?: Array<{ type: string; value: any }>
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/scrap/request',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 提交前预检:所选物料是否需要审批
|
||||
export function checkScrapApproval(data: { items: Array<{ name?: string; spec_model?: string; base_id?: number }> }) {
|
||||
return request({
|
||||
url: '/v1/scrap/request/check-approval',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 6. 报废申请列表(scope: mine | pending | executable | all)
|
||||
export function getScrapApprovals(params: {
|
||||
scope?: 'mine' | 'pending' | 'executable' | 'all'
|
||||
status?: number | ''
|
||||
page?: number
|
||||
limit?: number
|
||||
}) {
|
||||
return request({
|
||||
url: '/v1/scrap/request',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 7. 报废申请详情
|
||||
export function getScrapApprovalDetail(id: number) {
|
||||
return request({
|
||||
url: `/v1/scrap/request/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 8. 审批(通过/驳回)
|
||||
export function approveScrapRequest(id: number, data: { action: 'approve' | 'reject'; reject_reason?: string }) {
|
||||
return request({
|
||||
url: `/v1/scrap/request/${id}/approve`,
|
||||
method: 'patch',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 9. 按单报废执行(提交实扫明细,扣减库存)
|
||||
// items 必须是该申请单批准明细的子集,且累计数量不得超过批准数量
|
||||
export function executeScrapByRequest(id: number, items: Array<{
|
||||
source_table: string
|
||||
stock_id: number
|
||||
quantity: number
|
||||
sku?: string
|
||||
name?: string
|
||||
}>) {
|
||||
return request({
|
||||
url: `/v1/scrap/request/${id}/execute`,
|
||||
method: 'post',
|
||||
data: { items }
|
||||
})
|
||||
}
|
||||
|
||||
@ -251,6 +251,18 @@ const routes: Array<RouteRecordRaw> = [
|
||||
meta: { title: '报废管理', icon: 'Delete' },
|
||||
redirect: '/scrap/index',
|
||||
children: [
|
||||
{
|
||||
path: 'apply',
|
||||
name: 'ScrapApply',
|
||||
component: () => import('@/views/operation/scrap/apply/index.vue'),
|
||||
meta: { title: '报废申请' }
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'ScrapCreate',
|
||||
component: () => import('@/views/operation/scrap/create.vue'),
|
||||
meta: { title: '按单报废' }
|
||||
},
|
||||
{
|
||||
path: 'index',
|
||||
name: 'ScrapList',
|
||||
@ -258,10 +270,10 @@ const routes: Array<RouteRecordRaw> = [
|
||||
meta: { title: '报废记录' }
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
name: 'ScrapCreate',
|
||||
component: () => import('@/views/operation/scrap/create.vue'),
|
||||
meta: { title: '新建报废' }
|
||||
path: 'approval',
|
||||
name: 'ScrapApproval',
|
||||
component: () => import('@/views/operation/scrap/approval/index.vue'),
|
||||
meta: { title: '报废审批' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<el-radio-button :label="1">已通过</el-radio-button>
|
||||
<el-radio-button :label="2">已驳回</el-radio-button>
|
||||
<el-radio-button :label="3">已完成</el-radio-button>
|
||||
<el-radio-button :label="4">已完结</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
</div>
|
||||
@ -108,7 +109,7 @@
|
||||
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template v-else-if="row.status === 3">
|
||||
<template v-else-if="row.status === 3 || row.status === 4">
|
||||
<span style="color: #909399;">{{ getApproverName(row.actual_approver_id) }}</span>
|
||||
</template>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
@ -254,14 +255,14 @@ const userNameCache = ref<Record<number, string>>({})
|
||||
// --- 工具函数 ---
|
||||
const statusText = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成'
|
||||
0: '待审批', 1: '已通过', 2: '已驳回', 3: '已完成', 4: '已完结'
|
||||
}
|
||||
return map[status] ?? '-'
|
||||
}
|
||||
|
||||
const statusTagType = (status: number) => {
|
||||
const map: Record<number, string> = {
|
||||
0: 'warning', 1: 'success', 2: 'danger', 3: 'info'
|
||||
0: 'warning', 1: 'success', 2: 'danger', 3: 'info', 4: 'info'
|
||||
}
|
||||
return map[status] ?? 'info'
|
||||
}
|
||||
|
||||
494
inventory-web/src/views/operation/scrap/apply/index.vue
Normal file
494
inventory-web/src/views/operation/scrap/apply/index.vue
Normal file
@ -0,0 +1,494 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<el-card shadow="always">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="title">报废申请</span>
|
||||
<span class="subtitle">(请添加需要报废的物品)</span>
|
||||
</div>
|
||||
<div>
|
||||
<el-button type="danger" :disabled="cart.length === 0" @click="clearCart">清空列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button type="primary" :icon="Plus" @click="openManualSelect">手动添加库存</el-button>
|
||||
<el-button type="success" :icon="Select" :disabled="cart.length === 0" @click="openSubmitDialog">
|
||||
提交报废申请
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="cart.length === 0"
|
||||
title="清单为空,请点击右上角【手动添加库存】选择要报废的物品"
|
||||
type="info"
|
||||
center
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 20px"
|
||||
/>
|
||||
|
||||
<!-- ★ 主表格 = 已选清单(购物车),不再承载搜索结果,避免搜索重置选中态 -->
|
||||
<el-table
|
||||
v-else
|
||||
:data="cart"
|
||||
border
|
||||
row-key="uniqueKey"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="typeLabel" label="类型" width="90" align="center" />
|
||||
<el-table-column prop="name" label="物料名称" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="规格型号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="库位" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="color:#409EFF;">{{ row.location || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="批次/序列号" width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用库存" width="110" align="right">
|
||||
<template #default="{ row }">{{ row.available_qty }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本次报废" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-input-number
|
||||
v-model="row.scrap_qty"
|
||||
:min="1"
|
||||
:max="row.available_qty"
|
||||
:precision="0"
|
||||
:controls="false"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link @click="removeCartRow(row)">移除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="cart.length > 0" class="cart-summary">
|
||||
共 <span class="num">{{ cart.length }}</span> 种物品,
|
||||
合计报废 <span class="num">{{ totalScrapCount }}</span> 件
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ============================================================
|
||||
★ 选择库存物品(独立弹窗:搜索与已选解耦)
|
||||
reserve-selection + row-key 保证跨搜索/翻页保留勾选
|
||||
============================================================ -->
|
||||
<el-dialog
|
||||
v-model="manualDialogVisible"
|
||||
title="选择库存物品"
|
||||
width="85%"
|
||||
top="5vh"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="filter-container">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入物料名称 / 规格型号 / SKU 进行搜索"
|
||||
style="width: 320px"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
@input="filterStock"
|
||||
/>
|
||||
<span class="filter-tip">
|
||||
提示:点击表格行可勾选;<span style="color:#F56C6C;font-weight:bold;">修改数量会自动勾选</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
ref="manualTableRef"
|
||||
:data="stockList"
|
||||
v-loading="stockLoading"
|
||||
height="500"
|
||||
border
|
||||
row-key="uniqueKey"
|
||||
@selection-change="handleStockSelection"
|
||||
@row-click="handleRowClick"
|
||||
style="cursor: pointer"
|
||||
>
|
||||
<el-table-column type="selection" width="55" align="center" :reserve-selection="true" />
|
||||
<el-table-column label="类型" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.typeLabel }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="规格" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.standard || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sku" label="SKU" width="130" show-overflow-tooltip />
|
||||
<el-table-column label="库位" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.warehouse_location || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="批次/序列号" width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.batch_number || row.serial_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="available_quantity" label="可用库存" width="110" align="right" />
|
||||
<el-table-column label="本次报废" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<!-- ★ @click.stop:防止点击数字框冒泡触发 @row-click 反转勾选 -->
|
||||
<el-input-number
|
||||
v-model="row.scrap_qty"
|
||||
:min="1"
|
||||
:max="Number(row.available_quantity || 0)"
|
||||
:precision="0"
|
||||
size="small"
|
||||
style="width: 100%"
|
||||
placeholder="0"
|
||||
@click.stop
|
||||
@change="(val: any) => handleManualQuantityChange(val, row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
style="margin-top: 12px; justify-content: flex-end; display: flex;"
|
||||
v-model:current-page="stockPage"
|
||||
v-model:page-size="stockPageSize"
|
||||
:total="stockTotal"
|
||||
:page-sizes="[20, 50, 100, 200]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@size-change="handleStockSizeChange"
|
||||
@current-change="handleStockPageChange"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<span style="float:left; line-height:32px; color:#909399;">
|
||||
已勾选 <b style="color:#409EFF;">{{ tempSelection.length }}</b> 项
|
||||
<span v-if="alreadyInCartCount > 0" style="margin-left:6px;">
|
||||
(其中 {{ alreadyInCartCount }} 项已在清单中)
|
||||
</span>
|
||||
</span>
|
||||
<el-button @click="manualDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmManualAdd">确认添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 提交报废申请 -->
|
||||
<el-dialog v-model="dialogVisible" title="提交报废申请" width="760px" destroy-on-close :close-on-click-modal="false">
|
||||
<el-table :data="cart" border size="small" max-height="300">
|
||||
<el-table-column prop="name" label="物料" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="规格" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="location" label="库位" width="110" />
|
||||
<el-table-column label="批次" width="120">
|
||||
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.available_qty }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本次报废" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.scrap_qty" :min="1" :max="row.available_qty" :precision="0" :controls="false" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px" style="margin-top:14px;">
|
||||
<!-- ★ 业务规则:所有报废申请一律需审批,审批人恒为必填项 -->
|
||||
<el-form-item label="指定审批人" prop="approver_id">
|
||||
<div v-if="approvalText" style="color:#E6A23C; font-size:12px; line-height:1.4; margin-bottom:4px;">{{ approvalText }}</div>
|
||||
<el-select v-model="form.approver_id" placeholder="请选择审批人" style="width:100%" filterable>
|
||||
<el-option v-for="u in approvers" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="报废原因" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请填写报废原因(必填)" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">确认提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onBeforeUnmount } from 'vue'
|
||||
import { ElMessage, ElMessageBox, ElTable, ElForm } from 'element-plus'
|
||||
import { Plus, Search, Select } from '@element-plus/icons-vue'
|
||||
import { getScrapStockList } from '@/api/scrap'
|
||||
import { getApproversList } from '@/api/auth'
|
||||
import { submitScrapRequest, checkScrapApproval } from '@/api/scrap'
|
||||
|
||||
// --- 已选清单(唯一数据源,主表格 / 选择弹窗 / 提交弹窗共享同一份对象) ---
|
||||
const cart = ref<any[]>([])
|
||||
|
||||
// --- 选择库存物品弹窗 ---
|
||||
const manualDialogVisible = ref(false)
|
||||
const manualTableRef = ref<InstanceType<typeof ElTable>>()
|
||||
const stockList = ref<any[]>([]) // 弹窗内搜索结果(服务端分页)
|
||||
const stockTotal = ref(0)
|
||||
const stockPage = ref(1)
|
||||
const stockPageSize = ref(20)
|
||||
const stockLoading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const tempSelection = ref<any[]>([]) // 弹窗内暂存勾选
|
||||
let stockSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// --- 提交对话框 ---
|
||||
const dialogVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||
const approvers = ref<any[]>([])
|
||||
const approvalText = ref('')
|
||||
// ★ 预检结果:提交前由 checkScrapApproval 写入,供文案提示使用
|
||||
const isApprovalRequired = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
approver_id: null as number | null,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// ★ 业务规则:所有报废申请一律需审批 → 审批人与报废原因恒为必填
|
||||
const rules = {
|
||||
approver_id: [{ required: true, message: '请选择审批人', trigger: 'change' }],
|
||||
remark: [{ required: true, message: '请填写报废原因', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 库存行的唯一键:三张库存表主键各自独立,必须带表名前缀防跨表碰撞。
|
||||
// source_table 由后端稳定注入,取值恰好就是提交报废申请所需的 source_table。
|
||||
// ============================================================
|
||||
const stockKey = (row: any): string => `${row.source_table}_${row.id}`
|
||||
|
||||
// 库存行 → 购物车项
|
||||
const toCartItem = (row: any) => ({
|
||||
uniqueKey: stockKey(row),
|
||||
source_table: row.source_table,
|
||||
stock_id: row.id,
|
||||
base_id: row.base_id,
|
||||
name: row.name || row.material_name || '',
|
||||
spec_model: row.standard || row.spec_model || '',
|
||||
sku: row.sku || '',
|
||||
location: row.warehouse_location || row.warehouse_loc || '',
|
||||
batch_number: row.batch_number || row.serial_number || '',
|
||||
available_qty: Number(row.available_quantity || 0),
|
||||
scrap_qty: Number(row.scrap_qty) || 1,
|
||||
})
|
||||
|
||||
// --- 计算属性 ---
|
||||
const totalScrapCount = computed(() =>
|
||||
cart.value.reduce((sum, it) => sum + (Number(it.scrap_qty) || 0), 0)
|
||||
)
|
||||
const alreadyInCartCount = computed(() => {
|
||||
const keys = new Set(cart.value.map(it => it.uniqueKey))
|
||||
return tempSelection.value.filter(it => keys.has(stockKey(it))).length
|
||||
})
|
||||
|
||||
// --- 加载库存(服务端搜索 + 分页)---
|
||||
// 注意:不传 is_aggregated —— 报废要求 stock_id 精确到单条库存行,
|
||||
// 聚合后 id 会退化为组内代表项,导致「申请扣 A 行、实际报废 B 行」。
|
||||
const loadStockList = async () => {
|
||||
stockLoading.value = true
|
||||
try {
|
||||
const res: any = await getScrapStockList({
|
||||
page: stockPage.value,
|
||||
pageSize: stockPageSize.value,
|
||||
keyword: searchKeyword.value.trim(),
|
||||
})
|
||||
// ★ uniqueKey 必须物化到行对象上:reserve-selection 依赖 row-key 真实存在
|
||||
stockList.value = (res.data?.list || []).map((item: any) => ({
|
||||
...item,
|
||||
uniqueKey: stockKey(item),
|
||||
// 后端 to_dict 只输出 warehouse_loc,统一归一化,否则库位列恒为空
|
||||
warehouse_location: item.warehouse_location || item.warehouse_loc || item.full_path || '',
|
||||
}))
|
||||
stockTotal.value = res.data?.total || 0
|
||||
} catch (e) {
|
||||
ElMessage.error('加载库存失败')
|
||||
} finally {
|
||||
stockLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openManualSelect = async () => {
|
||||
manualDialogVisible.value = true
|
||||
stockPage.value = 1
|
||||
searchKeyword.value = ''
|
||||
tempSelection.value = []
|
||||
await loadStockList()
|
||||
}
|
||||
|
||||
// 搜索防抖 → 服务端过滤
|
||||
const filterStock = () => {
|
||||
if (stockSearchTimer) clearTimeout(stockSearchTimer)
|
||||
stockSearchTimer = setTimeout(() => {
|
||||
stockPage.value = 1
|
||||
loadStockList()
|
||||
}, 350)
|
||||
}
|
||||
|
||||
const handleStockPageChange = (page: number) => {
|
||||
stockPage.value = page
|
||||
loadStockList()
|
||||
}
|
||||
|
||||
const handleStockSizeChange = (size: number) => {
|
||||
stockPageSize.value = size
|
||||
stockPage.value = 1
|
||||
loadStockList()
|
||||
}
|
||||
|
||||
const handleStockSelection = (val: any[]) => { tempSelection.value = val }
|
||||
|
||||
// 点击行任意位置切换勾选(undefined = 取反)
|
||||
const handleRowClick = (row: any) => {
|
||||
manualTableRef.value?.toggleRowSelection(row, undefined)
|
||||
}
|
||||
|
||||
// 弹窗内数量变化联动勾选
|
||||
const handleManualQuantityChange = (val: number | undefined, row: any) => {
|
||||
if (val && val > 0) manualTableRef.value?.toggleRowSelection(row, true)
|
||||
else manualTableRef.value?.toggleRowSelection(row, false)
|
||||
}
|
||||
|
||||
// 确认添加:按 uniqueKey 回查当前页行取权威 scrap_qty(tempSelection 可能是旧对象快照)
|
||||
const confirmManualAdd = () => {
|
||||
if (tempSelection.value.length === 0) return ElMessage.warning('请先勾选需要添加的物品')
|
||||
|
||||
const existing = new Set(cart.value.map(it => it.uniqueKey))
|
||||
const toAdd: any[] = []
|
||||
for (const it of tempSelection.value) {
|
||||
const key = stockKey(it)
|
||||
if (existing.has(key)) continue
|
||||
// 优先用当前 stockList 里的同名行,保证拿到的是最新数量
|
||||
const fresh = stockList.value.find(s => stockKey(s) === key) || it
|
||||
toAdd.push(toCartItem(fresh))
|
||||
existing.add(key)
|
||||
}
|
||||
|
||||
if (toAdd.length === 0) {
|
||||
manualDialogVisible.value = false
|
||||
return ElMessage.warning('选中的物品已全部在清单中')
|
||||
}
|
||||
|
||||
cart.value.push(...toAdd)
|
||||
manualDialogVisible.value = false
|
||||
tempSelection.value = []
|
||||
ElMessage.success(`成功添加 ${toAdd.length} 项物品`)
|
||||
}
|
||||
|
||||
// --- 清单维护 ---
|
||||
const removeCartRow = (row: any) => {
|
||||
const idx = cart.value.findIndex(it => it.uniqueKey === row.uniqueKey)
|
||||
if (idx > -1) cart.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
const clearCart = () => {
|
||||
ElMessageBox.confirm('确定要清空已选清单吗?', '提示', { type: 'warning' })
|
||||
.then(() => { cart.value = [] })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (stockSearchTimer) clearTimeout(stockSearchTimer)
|
||||
})
|
||||
|
||||
// --- 提交 ---
|
||||
const loadApprovers = async () => {
|
||||
try {
|
||||
const res: any = await getApproversList()
|
||||
approvers.value = res.data || []
|
||||
} catch (e) { approvers.value = [] }
|
||||
}
|
||||
|
||||
const buildItems = () => cart.value.map(it => ({
|
||||
source_table: it.source_table,
|
||||
stock_id: it.stock_id,
|
||||
scrap_qty: Number(it.scrap_qty || 0),
|
||||
name: it.name,
|
||||
spec_model: it.spec_model,
|
||||
sku: it.sku,
|
||||
location: it.location,
|
||||
batch_number: it.batch_number,
|
||||
}))
|
||||
|
||||
// ★ 预检:调用 checkScrapApproval,把结果写入 isApprovalRequired 并生成提示文案。
|
||||
// 所有报废申请一律需审批,此处主要用于向用户展示「哪些物料触发了审批」。
|
||||
const runApprovalPrecheck = async (items: any[]) => {
|
||||
try {
|
||||
const chk: any = await checkScrapApproval({ items })
|
||||
isApprovalRequired.value = true // 业务上恒需审批
|
||||
const flagged: any[] = chk?.data?.materials || []
|
||||
approvalText.value = flagged.length > 0
|
||||
? `以下物料需审批报废:${flagged.map((m: any) => `${m.name}(${m.spec_model || '-'})`).join(';')}`
|
||||
: '所有报废申请均需审批,请选择审批人'
|
||||
} catch (e) {
|
||||
isApprovalRequired.value = true
|
||||
approvalText.value = '所有报废申请均需审批,请选择审批人'
|
||||
}
|
||||
}
|
||||
|
||||
const openSubmitDialog = async () => {
|
||||
if (cart.value.length === 0) return ElMessage.warning('请先添加要报废的物品')
|
||||
|
||||
form.approver_id = null
|
||||
form.remark = ''
|
||||
isApprovalRequired.value = true
|
||||
approvalText.value = '所有报废申请均需审批,请选择审批人'
|
||||
formRef.value?.clearValidate()
|
||||
|
||||
await loadApprovers()
|
||||
// 打开弹窗即预检,让用户在填写前就看到需审批提示
|
||||
await runApprovalPrecheck(buildItems())
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (!formRef.value) return
|
||||
// ★ 表单校验:审批人与报废原因均为必填
|
||||
await formRef.value.validate(async (valid: boolean) => {
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const items = buildItems()
|
||||
await runApprovalPrecheck(items)
|
||||
|
||||
await submitScrapRequest({
|
||||
items,
|
||||
remark: form.remark.trim(),
|
||||
approver_id: form.approver_id,
|
||||
})
|
||||
ElMessage.success('报废申请已提交,待审批人审批')
|
||||
dialogVisible.value = false
|
||||
cart.value = []
|
||||
} catch (err: any) {
|
||||
// 拦截器已按后端 data.msg 弹业务错误,HTTP 错误不重复
|
||||
if (!err?.response) ElMessage.error('网络异常,请重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.header-left { display: flex; align-items: baseline; gap: 8px; }
|
||||
.title { font-size: 18px; font-weight: bold; }
|
||||
.subtitle { font-size: 12px; color: #909399; }
|
||||
|
||||
.filter-container { display: flex; align-items: center; gap: 15px; margin-bottom: 12px; }
|
||||
.filter-tip { color: #909399; font-size: 12px; }
|
||||
|
||||
.cart-summary { margin-top: 15px; text-align: right; color: #606266; font-size: 14px; }
|
||||
.cart-summary .num { color: #F56C6C; font-weight: bold; }
|
||||
</style>
|
||||
295
inventory-web/src/views/operation/scrap/approval/index.vue
Normal file
295
inventory-web/src/views/operation/scrap/approval/index.vue
Normal file
@ -0,0 +1,295 @@
|
||||
<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-group>
|
||||
<el-button type="primary" :icon="Refresh" @click="fetchData">刷新</el-button>
|
||||
<el-radio-group v-model="scope" size="default" style="margin-left:12px;" @change="handleStatusChange">
|
||||
<el-radio-button label="pending">待我审批</el-radio-button>
|
||||
<el-radio-button label="executable">待执行</el-radio-button>
|
||||
<el-radio-button label="mine">我提交的</el-radio-button>
|
||||
<el-radio-button label="all">全部单</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
style="margin-top: 16px;"
|
||||
row-key="id"
|
||||
:expand-row-keys="expandedRows"
|
||||
@expand-change="handleExpandChange"
|
||||
>
|
||||
<!-- 展开行:本次报废对应的“选单内容”(冻结库存行) -->
|
||||
<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>
|
||||
<el-table v-if="row.items?.length" :data="row.items" border size="small" style="width: 100%;">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column label="来源" width="90" align="center">
|
||||
<template #default="{ row: item }">
|
||||
<el-tag size="small">{{ sourceLabel(item.source_table) }}</el-tag>
|
||||
</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 />
|
||||
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="location" label="库位" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="batch_number" label="批次/序列号" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="报废数量" width="100" align="center">
|
||||
<template #default="{ row: item }">
|
||||
<span style="color: #F56C6C; font-weight: bold;">{{ item.scrap_qty ?? '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else description="暂无报废明细" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="request_no" label="申请单号" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="toggleExpand(row)">
|
||||
{{ row.request_no }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="申请人" width="140">
|
||||
<template #default="{ row }">{{ row.applicant_name || getApplicantName(row.applicant_id) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="remark" label="申请原因" min-width="180" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="报废种类" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ row.items?.length || 0 }} 项</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="created_at" label="申请时间" width="170" />
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="审批信息" width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 1 || row.status === 3">
|
||||
<span style="color: #67C23A;">{{ row.actual_approver_name || getApproverName(row.actual_approver_id) }}</span>
|
||||
<br />
|
||||
<span style="font-size: 12px; color: #909399;">{{ row.approved_at || '' }}</span>
|
||||
</template>
|
||||
<template v-else-if="row.status === 2">
|
||||
<span style="color: #F56C6C;">已驳回</span>
|
||||
<el-tooltip v-if="row.reject_reason" :content="row.reject_reason" placement="top">
|
||||
<el-icon style="margin-left: 4px; cursor: pointer;"><Warning /></el-icon>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 0">
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('scrap_approval:operation') || userStore.role === 'SUPER_ADMIN'"
|
||||
type="success" size="small" :loading="row._approving" @click="handleApprove(row)"
|
||||
>通过</el-button>
|
||||
<el-button
|
||||
v-if="userStore.hasPermission('scrap_approval:operation') || userStore.role === 'SUPER_ADMIN'"
|
||||
type="danger" size="small" :loading="row._approving" @click="openRejectDialog(row)"
|
||||
>驳回</el-button>
|
||||
</template>
|
||||
<!-- ★ 已通过(待执行):持有 scrap_execute 者可执行按单报废 -->
|
||||
<template v-else-if="row.status === 1">
|
||||
<el-button
|
||||
v-if="canExecute"
|
||||
type="warning" plain size="small" :loading="row._executing" @click="openExecute(row)"
|
||||
>执行报废</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #c0c4cc;">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
background
|
||||
style="margin-top: 16px; justify-content: flex-end; display: flex;"
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
|
||||
<!-- 驳回原因 Dialog -->
|
||||
<el-dialog v-model="rejectDialogVisible" title="驳回报废申请" width="480px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="申请单号">
|
||||
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="驳回原因" required>
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请填写驳回原因" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="rejectLoading" @click="confirmReject">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getScrapApprovals, approveScrapRequest } from '@/api/scrap'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// --- 状态 ---
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filterStatus = ref<number | ''>(0) // 默认筛选待审批
|
||||
const scope = ref<'pending' | 'executable' | 'mine' | 'all'>('mine')
|
||||
const expandedRows = ref<string[]>([])
|
||||
|
||||
// 驳回 Dialog
|
||||
const rejectDialogVisible = ref(false)
|
||||
const currentRejectRow = ref<any>(null)
|
||||
const rejectReason = ref('')
|
||||
const rejectLoading = ref(false)
|
||||
|
||||
|
||||
const userNameCache = ref<Record<number, string>>({})
|
||||
|
||||
// --- 权限 ---
|
||||
const canExecute = computed(() =>
|
||||
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_execute')
|
||||
)
|
||||
|
||||
// --- 工具 ---
|
||||
const statusText = (status: number) => {
|
||||
const map: Record<number, string> = { 0: '待审批', 1: '已通过', 2: '已驳回', 3: '已执行' }
|
||||
return map[status] ?? '-'
|
||||
}
|
||||
const statusTagType = (status: number) => {
|
||||
const map: Record<number, string> = { 0: 'warning', 1: 'success', 2: 'danger', 3: 'info' }
|
||||
return map[status] ?? 'info'
|
||||
}
|
||||
const sourceLabel = (st: string) => ({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-'
|
||||
const getApplicantName = (id: number | null) => (id ? (userNameCache.value[id] ?? `用户 #${id}`) : '-')
|
||||
const getApproverName = (id: number | null) => (id ? (userNameCache.value[id] ?? `用户 #${id}`) : '-')
|
||||
|
||||
// --- 展开行 ---
|
||||
const toggleExpand = (row: any) => {
|
||||
const idx = expandedRows.value.indexOf(row.id)
|
||||
if (idx > -1) expandedRows.value.splice(idx, 1)
|
||||
else expandedRows.value.push(row.id)
|
||||
}
|
||||
const handleExpandChange = () => { /* 由 expandedRows 控制 */ }
|
||||
|
||||
// --- 数据获取 ---
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = { page: page.value, limit: pageSize.value, scope: scope.value }
|
||||
if (filterStatus.value !== '') params.status = filterStatus.value
|
||||
const res: any = await getScrapApprovals(params)
|
||||
const records = res.data?.items || []
|
||||
records.forEach((r: any) => {
|
||||
if (r.applicant_id && r.applicant_name) userNameCache.value[r.applicant_id] = r.applicant_name
|
||||
if (r.actual_approver_id && r.actual_approver_name) userNameCache.value[r.actual_approver_id] = r.actual_approver_name
|
||||
r._approving = false
|
||||
r._executing = false
|
||||
})
|
||||
list.value = records
|
||||
total.value = res.data?.total || records.length || 0
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '加载报废审批列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 筛选 / 分页 ---
|
||||
const handleStatusChange = () => { page.value = 1; expandedRows.value = []; fetchData() }
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchData() }
|
||||
const handleSizeChange = (s: number) => { pageSize.value = s; page.value = 1; fetchData() }
|
||||
|
||||
// --- 审批操作 ---
|
||||
const handleApprove = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要通过报废申请单 【${row.request_no}】 吗?`, '审批确认',
|
||||
{ confirmButtonText: '确定通过', cancelButtonText: '取消', type: 'info' })
|
||||
} catch { return }
|
||||
row._approving = true
|
||||
try {
|
||||
await approveScrapRequest(row.id, { action: 'approve' })
|
||||
ElMessage.success(`申请单 ${row.request_no} 已通过`)
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '审批操作失败')
|
||||
} finally { row._approving = false }
|
||||
}
|
||||
|
||||
const openRejectDialog = (row: any) => {
|
||||
currentRejectRow.value = row
|
||||
rejectReason.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
const confirmReject = async () => {
|
||||
const reason = rejectReason.value.trim()
|
||||
if (!reason) return ElMessage.warning('请填写驳回原因')
|
||||
rejectLoading.value = true
|
||||
try {
|
||||
await approveScrapRequest(currentRejectRow.value.id, { action: 'reject', reject_reason: reason })
|
||||
ElMessage.success(`申请单 ${currentRejectRow.value.request_no} 已驳回`)
|
||||
rejectDialogVisible.value = false
|
||||
await fetchData()
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.msg || '驳回操作失败')
|
||||
} finally { rejectLoading.value = false }
|
||||
}
|
||||
|
||||
// --- 按单报废执行:跳转到「按单报废执行」扫码页,由实扫明细驱动扣减 ---
|
||||
const openExecute = (row: any) => {
|
||||
router.push({ path: '/scrap/create', query: { requestId: row.id } })
|
||||
}
|
||||
|
||||
onMounted(() => { fetchData() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container { padding: 20px; }
|
||||
.filter-container { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
</style>
|
||||
@ -4,62 +4,163 @@
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="title-box">
|
||||
<span>报废作业</span>
|
||||
<span>按单报废执行</span>
|
||||
<el-tag v-if="cartItems.length > 0" type="danger" size="small" effect="dark">
|
||||
已选 {{ cartItems.length }} 项
|
||||
已扫 {{ cartItems.length }} 项
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="scan-section">
|
||||
<div v-if="hasPermission" class="camera-placeholder" @click="showCamera = true">
|
||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||
<span class="text">点击开启全屏扫码</span>
|
||||
<div class="mode-switch-bar">
|
||||
<el-tag type="danger" size="large" style="font-size: 14px;">按单执行</el-tag>
|
||||
<span class="mode-hint">请先选择已审批通过的报废申请单,再扫码报废</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1:选择已批准的报废申请单 -->
|
||||
<div class="approval-request-select">
|
||||
<el-select
|
||||
v-model="selectedRequestId"
|
||||
placeholder="请选择已审批通过的报废申请单"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
:loading="requestsLoading"
|
||||
@change="handleRequestChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="req in approvalRequests"
|
||||
:key="req.id"
|
||||
:value="req.id"
|
||||
:label="req.request_no"
|
||||
>
|
||||
<span>{{ req.request_no }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span>{{ req.applicant_name || '未知申请人' }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span style="color: #909399; font-size: 13px">{{ req.remark || '无备注' }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<p class="select-tip">仅显示已通过(status=1)的报废申请单</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2:待执行清单 -->
|
||||
<div v-if="selectedRequest" class="planned-items-section">
|
||||
<div class="planned-header">
|
||||
<span class="planned-title">待执行报废清单</span>
|
||||
<el-tag type="danger" size="small">{{ plannedItems.length }} 种</el-tag>
|
||||
</div>
|
||||
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||
<span class="text">无扫码权限</span>
|
||||
<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">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info">{{ sourceLabel(row.source_table) }}</el-tag>
|
||||
</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="sku" label="SKU" width="110" show-overflow-tooltip />
|
||||
<el-table-column prop="location" label="库位" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="批准数量" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #E6A23C; font-weight: bold;">{{ row.scrap_qty ?? '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已扫" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: scannedQtyOf(row) >= Number(row.scrap_qty) ? '#67C23A' : '#909399', fontWeight: 'bold' }">
|
||||
{{ scannedQtyOf(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="scan-section">
|
||||
<!-- 扫码进度条 -->
|
||||
<div v-if="selectedRequest && scanTotalQty > 0" class="scan-progress">
|
||||
<div class="progress-info">
|
||||
<span>扫码进度</span>
|
||||
<el-tag type="success" size="small">{{ scanScannedTypes }}/{{ scanTotalTypes }} 种</el-tag>
|
||||
<el-tag type="primary" size="small">{{ scanScannedQty }}/{{ scanTotalQty }} 件</el-tag>
|
||||
<el-button
|
||||
v-if="unscannedCount > 0"
|
||||
type="warning" plain size="small" style="margin-left: auto;"
|
||||
@click="showUnscannedDialog = true"
|
||||
>
|
||||
未扫清单 ({{ unscannedCount }})
|
||||
</el-button>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="scanTotalQty > 0 ? Math.min(100, Math.round(scanScannedQty / scanTotalQty * 100)) : 0"
|
||||
:stroke-width="8"
|
||||
:color="scanScannedQty >= scanTotalQty ? '#67C23A' : '#F56C6C'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-box">
|
||||
<el-input
|
||||
v-model="barcodeInput"
|
||||
placeholder="扫描或输入条码回车"
|
||||
@keyup.enter="handleManualInput"
|
||||
clearable
|
||||
ref="barcodeRef"
|
||||
size="large"
|
||||
:disabled="!hasPermission"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Scissor /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<template v-if="!selectedRequestId">
|
||||
<div class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||
<span class="text">请先选择报废申请单</span>
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<el-input v-model="barcodeInput" placeholder="请先选择报废申请单" disabled size="large">
|
||||
<template #prefix><el-icon><Scissor /></el-icon></template>
|
||||
<template #append><el-button disabled>添加</el-button></template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="hasPermission" class="camera-placeholder" @click="showCamera = true">
|
||||
<el-icon :size="40" color="#409EFF"><CameraFilled /></el-icon>
|
||||
<span class="text">点击开启全屏扫码</span>
|
||||
</div>
|
||||
<div v-else class="camera-placeholder" style="background-color: #f5f5f5; cursor: not-allowed;">
|
||||
<el-icon :size="40" color="#909399"><CameraFilled /></el-icon>
|
||||
<span class="text">无扫码权限</span>
|
||||
</div>
|
||||
<div class="input-box">
|
||||
<el-input
|
||||
v-model="barcodeInput"
|
||||
placeholder="扫描或输入条码回车"
|
||||
@keyup.enter="handleManualInput"
|
||||
clearable
|
||||
ref="barcodeRef"
|
||||
size="large"
|
||||
:disabled="!hasPermission"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Scissor /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="handleManualInput" :disabled="!hasPermission">添加</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="cart-section">
|
||||
<div v-if="cartItems.length > 0">
|
||||
<el-table :data="cartItems" border stripe style="width: 100%">
|
||||
<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="sku" label="SKU" width="120" show-overflow-tooltip />
|
||||
|
||||
<el-table-column label="可用库存" width="90" align="center">
|
||||
<el-table-column label="库存" width="70" align="center">
|
||||
<template #default="{row}">
|
||||
<el-tag type="info">{{ parseFloat(row.available_quantity) }}</el-tag>
|
||||
<el-tag :type="parseFloat(row.available_quantity) > 0 ? 'success' : 'danger'" size="small">
|
||||
{{ parseFloat(row.available_quantity) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="报废数" width="130" align="center">
|
||||
<template #default="{row}">
|
||||
<el-input-number
|
||||
v-model="row.quantity"
|
||||
v-model="row.scrap_quantity"
|
||||
:min="1"
|
||||
:max="parseFloat(row.available_quantity)"
|
||||
:max="maxScrapFor(row)"
|
||||
size="small"
|
||||
style="width: 100px"
|
||||
:disabled="!hasPermission"
|
||||
@ -67,14 +168,6 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="损失金额" width="100" align="center">
|
||||
<template #default="{row}">
|
||||
<span style="color: #F56C6C; font-weight: bold;">
|
||||
¥{{ ((row.price || 0) * row.quantity).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="hasPermission" label="操作" width="60" align="center" fixed="right">
|
||||
<template #default="{$index}">
|
||||
<el-button type="danger" icon="Delete" circle size="small" @click="removeFromCart($index)" />
|
||||
@ -86,29 +179,17 @@
|
||||
</div>
|
||||
|
||||
<div v-if="cartItems.length > 0" class="form-section">
|
||||
<el-divider content-position="left">报废登记信息</el-divider>
|
||||
<el-divider content-position="left">报废执行信息</el-divider>
|
||||
|
||||
<el-form :model="form" ref="formRef" label-position="top">
|
||||
<el-form-item label="报废原因" prop="reason" required>
|
||||
<el-input
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请详细填写报废原因,如:设备老化、损坏严重等"
|
||||
size="large"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注说明" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="补充说明..." />
|
||||
<el-form :model="form" label-position="top">
|
||||
<el-form-item label="报废原因(来自申请单)" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="2" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<div class="bottom-actions">
|
||||
<el-button v-if="hasPermission" @click="clearAll" icon="Refresh">清空</el-button>
|
||||
<el-button v-if="hasPermission" @click="clearAll" icon="Refresh">重扫</el-button>
|
||||
<el-button v-if="hasPermission" type="danger" size="large" :loading="loading" @click="submitForm" icon="Select">
|
||||
确认报废
|
||||
确认执行报废
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
@ -127,26 +208,60 @@
|
||||
</div>
|
||||
|
||||
<div class="scanner-footer">
|
||||
<p>请将条码/二维码放入镜头范围</p>
|
||||
<p v-if="cartItems.length > 0" class="current-count">已添加: {{ cartItems.length }} 项</p>
|
||||
<p>请对准条形码,识别成功后自动添加</p>
|
||||
<p v-if="cartItems.length > 0" class="current-count">当前已添加: {{ scanScannedQty }} 件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未扫清单弹窗 -->
|
||||
<el-dialog v-model="showUnscannedDialog" title="未扫清单" width="600px" destroy-on-close>
|
||||
<el-alert
|
||||
v-if="unscannedList.length > 0"
|
||||
:title="`以下 ${unscannedList.length} 种物料还未扫满,请补扫(未扫满也可提交,将按实扫数量报废):`"
|
||||
type="warning" :closable="false" show-icon style="margin-bottom: 12px;"
|
||||
/>
|
||||
<el-table :data="unscannedList" border size="small" max-height="400">
|
||||
<el-table-column type="index" label="#" width="40" align="center" />
|
||||
<el-table-column prop="name" label="物料名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="sku" label="SKU" min-width="110" show-overflow-tooltip />
|
||||
<el-table-column label="批准" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.planQty }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已扫" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #67C23A;">{{ row.scannedQty }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="待扫" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #F56C6C; font-weight: bold;">{{ row.remaining }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="showUnscannedDialog = false">关闭</el-button>
|
||||
<el-button type="primary" @click="showUnscannedDialog = false; showCamera = true">去扫码</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { ref, reactive, nextTick, computed, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Scissor, CameraFilled, Close, Refresh, Select, Delete } from '@element-plus/icons-vue'
|
||||
import { Scissor, CameraFilled } from '@element-plus/icons-vue'
|
||||
import QrScanner from '@/components/QrScanner/index.vue'
|
||||
import { scanBarcode, createScrap } from '@/api/scrap'
|
||||
import { scanBarcode, getScrapApprovals, executeScrapByRequest } from '@/api/scrap'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const hasPermission = userStore.hasPermission('scrap_create:operation')
|
||||
const hasPermission = computed(() =>
|
||||
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_execute')
|
||||
)
|
||||
|
||||
// --- 状态定义 ---
|
||||
const barcodeInput = ref('')
|
||||
@ -154,14 +269,171 @@ const cartItems = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const showCamera = ref(false)
|
||||
const barcodeRef = ref()
|
||||
const formRef = ref()
|
||||
|
||||
// 已批准的报废申请单
|
||||
const approvalRequests = ref<any[]>([])
|
||||
const selectedRequest = ref<any>(null)
|
||||
const requestsLoading = ref(false)
|
||||
|
||||
const showUnscannedDialog = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
reason: '',
|
||||
remark: ''
|
||||
reason: ''
|
||||
})
|
||||
|
||||
// --- 核心扫码逻辑 ---
|
||||
// --- 计算属性 ---
|
||||
const selectedRequestId = computed({
|
||||
get: () => selectedRequest.value?.id ?? null,
|
||||
set: (val) => {
|
||||
if (!val) {
|
||||
selectedRequest.value = null
|
||||
} else {
|
||||
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const plannedItems = computed<any[]>(() => selectedRequest.value?.items ?? [])
|
||||
|
||||
// 扫码进度
|
||||
const scanTotalQty = computed(() =>
|
||||
plannedItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_qty) || 0), 0)
|
||||
)
|
||||
const scanScannedQty = computed(() =>
|
||||
cartItems.value.reduce((sum: number, it: any) => sum + (Number(it.scrap_quantity) || 0), 0)
|
||||
)
|
||||
const scanScannedTypes = computed(() => cartItems.value.length)
|
||||
const scanTotalTypes = computed(() => plannedItems.value.length)
|
||||
|
||||
// ============================================================
|
||||
// ★ SKU 主键匹配(与后端 _match_key 完全同口径)
|
||||
//
|
||||
// SKU 在同一库存表内唯一、且不存在跨表重名,故以 SKU 作为扫码匹配的唯一标识。
|
||||
// 个别历史库存行 SKU 为空,这类行回退为 source_table + stock_id 复合键;
|
||||
// 前缀区分(sku: / row:)确保空 SKU 行绝不会与正常 SKU 串键。
|
||||
// ============================================================
|
||||
const normSku = (sku: any) => String(sku ?? '').trim()
|
||||
|
||||
const matchKey = (sourceTable: any, stockId: any, sku: any) => {
|
||||
const s = normSku(sku)
|
||||
return s ? `sku:${s}` : `row:${String(sourceTable)}#${String(stockId)}`
|
||||
}
|
||||
|
||||
// 按匹配键找批准明细行
|
||||
const findPlanByKey = (key: string) =>
|
||||
plannedItems.value.find((p: any) => matchKey(p.source_table, p.stock_id, p.sku) === key)
|
||||
|
||||
// 某匹配键的批准总量(同一 SKU 在批准单中出现多行时累加)
|
||||
const approvedQtyByKey = (key: string) =>
|
||||
plannedItems.value
|
||||
.filter((p: any) => matchKey(p.source_table, p.stock_id, p.sku) === key)
|
||||
.reduce((sum: number, p: any) => sum + (Number(p.scrap_qty) || 0), 0)
|
||||
|
||||
// 某匹配键在购物车中的已扫总量
|
||||
const scannedQtyByKey = (key: string) =>
|
||||
cartItems.value
|
||||
.filter((ci: any) => matchKey(ci.source_table, ci.id, ci.sku) === key)
|
||||
.reduce((sum: number, ci: any) => sum + (Number(ci.scrap_quantity) || 0), 0)
|
||||
|
||||
// 展示用:计划行的已扫数量
|
||||
const scannedQtyOf = (plan: any) =>
|
||||
scannedQtyByKey(matchKey(plan.source_table, plan.stock_id, plan.sku))
|
||||
|
||||
// 购物车某行还能再报废多少(该 SKU 批准量 - 其余行已占用量,且不超当前可用库存)
|
||||
const maxScrapFor = (row: any) => {
|
||||
const key = matchKey(row.source_table, row.id, row.sku)
|
||||
const approved = approvedQtyByKey(key)
|
||||
const others = cartItems.value
|
||||
.filter((ci: any) => ci !== row && matchKey(ci.source_table, ci.id, ci.sku) === key)
|
||||
.reduce((sum: number, ci: any) => sum + (Number(ci.scrap_quantity) || 0), 0)
|
||||
const avail = parseFloat(row.available_quantity) || 0
|
||||
return Math.max(0, Math.min(approved - others, avail))
|
||||
}
|
||||
|
||||
/**
|
||||
* ★ 扫码校验(SKU 主键)
|
||||
*
|
||||
* 返回 null 表示放行;返回字符串表示拦截原因(调用方以红色 toast 提示并阻断入车)。
|
||||
* · 扫码 SKU 不在批准明细中 → 拦截
|
||||
* · 该 SKU 累计扫码量 + 本次数量 > 批准量 → 拦截
|
||||
*/
|
||||
const validateAgainstPlan = (item: any, addQty: number): string | null => {
|
||||
const key = matchKey(item.source_table, item.id, item.sku)
|
||||
const plan = findPlanByKey(key)
|
||||
|
||||
if (!plan) {
|
||||
const s = normSku(item.sku)
|
||||
return s
|
||||
? `SKU【${s}】不在该报废申请单的批准明细中,禁止报废`
|
||||
: `该物料【${item.name || ''}】不在申请单的批准明细中,禁止报废`
|
||||
}
|
||||
|
||||
const approved = approvedQtyByKey(key)
|
||||
const already = scannedQtyByKey(key)
|
||||
|
||||
if (already + addQty > approved) {
|
||||
const label = normSku(plan.sku) || plan.name || ''
|
||||
return `SKU【${label}】超出批准数量(批准: ${approved},已扫: ${already},本次: ${addQty})`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 未扫清单
|
||||
const unscannedList = computed(() =>
|
||||
plannedItems.value.map((plan: any) => {
|
||||
const planQty = Number(plan.scrap_qty) || 0
|
||||
const scannedQty = scannedQtyOf(plan)
|
||||
return {
|
||||
name: plan.name || '',
|
||||
sku: plan.sku || '',
|
||||
planQty,
|
||||
scannedQty,
|
||||
remaining: Math.max(0, planQty - scannedQty)
|
||||
}
|
||||
}).filter((item: any) => item.remaining > 0)
|
||||
)
|
||||
const unscannedCount = computed(() => unscannedList.value.length)
|
||||
|
||||
const sourceLabel = (st: string) =>
|
||||
({ stock_buy: '采购件', stock_semi: '半成品', stock_product: '成品' } as any)[st] || st || '-'
|
||||
|
||||
// --- 加载已批准的报废申请单 ---
|
||||
const loadApprovalRequests = async () => {
|
||||
requestsLoading.value = true
|
||||
try {
|
||||
const res: any = await getScrapApprovals({ scope: 'executable', status: 1, page: 1, limit: 100 })
|
||||
approvalRequests.value = res.data?.items || []
|
||||
|
||||
// 支持从审批页跳转时带 requestId 直接选中
|
||||
const presetId = Number(route.query.requestId)
|
||||
if (presetId) {
|
||||
const hit = approvalRequests.value.find(r => r.id === presetId)
|
||||
if (hit) handleRequestChange(presetId)
|
||||
else ElMessage.warning('指定的报废申请单不可执行(可能已被执行或状态已变更)')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载报废申请单失败', e)
|
||||
ElMessage.error('加载报废申请单失败')
|
||||
} finally {
|
||||
requestsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRequestChange = (val: number | null) => {
|
||||
if (!val) {
|
||||
selectedRequest.value = null
|
||||
form.reason = ''
|
||||
} else {
|
||||
selectedRequest.value = approvalRequests.value.find(r => r.id === val) ?? null
|
||||
form.reason = selectedRequest.value?.remark || ''
|
||||
}
|
||||
// 切换申请单时清空购物车,防止已扫物品与新单据混淆
|
||||
cartItems.value = []
|
||||
barcodeInput.value = ''
|
||||
}
|
||||
|
||||
// --- 扫码逻辑 ---
|
||||
const onScanSuccess = (code: string) => {
|
||||
if (!code) return
|
||||
const trimCode = code.trim()
|
||||
@ -184,48 +456,94 @@ const onScanSuccess = (code: string) => {
|
||||
}
|
||||
|
||||
const handleManualInput = async () => {
|
||||
if (!hasPermission.value) {
|
||||
ElMessage.warning('无操作权限')
|
||||
return
|
||||
}
|
||||
const code = barcodeInput.value.trim()
|
||||
if (!code) return
|
||||
|
||||
if (!selectedRequest.value) {
|
||||
ElMessage.warning('请先选择要执行的报废申请单')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 查重
|
||||
const existIndex = cartItems.value.findIndex(item => item.barcode === code || item.sku === code)
|
||||
// 1. 购物车已有该 SKU → 累加(与批准明细同口径,按 SKU 去重)
|
||||
const existIndex = cartItems.value.findIndex(
|
||||
item => normSku(item.sku) === code || item.barcode === code || String(item.id) === code
|
||||
)
|
||||
if (existIndex > -1) {
|
||||
const item = cartItems.value[existIndex]
|
||||
const maxQty = parseFloat(item.available_quantity)
|
||||
if (item.quantity < maxQty) {
|
||||
item.quantity++
|
||||
ElMessage.success(`数量+1 (当前: ${item.quantity})`)
|
||||
if (navigator.vibrate) navigator.vibrate(50)
|
||||
} else {
|
||||
ElMessage.warning(`库存不足 (余: ${maxQty})`)
|
||||
|
||||
// ★ 越权/超量拦截:红色错误提示并阻断扫码
|
||||
const err = validateAgainstPlan(item, 1)
|
||||
if (err) {
|
||||
ElMessage.error(err)
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const maxQty = maxScrapFor(item)
|
||||
if (maxQty <= 0) {
|
||||
ElMessage.error(`【${item.name}】已达批准数量上限,无法继续报废`)
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`【${item.name} × ${item.spec_model || '-'}】已在清单中(当前已扫 ${item.scrap_quantity} 个)。\n\n确认再 +1 吗?`,
|
||||
'重复扫码确认',
|
||||
{ confirmButtonText: '确认 +1', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch (e) {
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
item.scrap_quantity++
|
||||
ElMessage.success(`数量+1 (当前: ${item.scrap_quantity})`)
|
||||
if (navigator.vibrate) navigator.vibrate(50)
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
// 查库
|
||||
const res = await scanBarcode(code)
|
||||
// 2. 查库
|
||||
const res: any = await scanBarcode(code)
|
||||
if (res.code === 200 && res.data) {
|
||||
const item = res.data
|
||||
const availQty = parseFloat(item.available_quantity || 0)
|
||||
|
||||
if (availQty <= 0) {
|
||||
ElMessage.warning(`库存不足 (余: ${availQty})`)
|
||||
} else {
|
||||
cartItems.value.push({
|
||||
...item,
|
||||
quantity: 1,
|
||||
price: item.price || 0
|
||||
})
|
||||
ElMessage.success(`添加成功: ${item.name}`)
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
// ★ 校验是否在批准明细内 / 是否超批准量
|
||||
const err = validateAgainstPlan(item, 1)
|
||||
if (err) {
|
||||
ElMessage.error(err)
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const availQty = parseFloat(item.available_quantity || 0)
|
||||
if (availQty <= 0) {
|
||||
ElMessage.error(`库存不足 (余: ${availQty})`)
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
barcodeInput.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
cartItems.value.push({
|
||||
...item,
|
||||
scrap_quantity: 1
|
||||
})
|
||||
ElMessage.success(`添加成功: ${item.name}`)
|
||||
if (navigator.vibrate) navigator.vibrate(100)
|
||||
barcodeInput.value = ''
|
||||
} else {
|
||||
ElMessage.error(res.msg || '未找到该物料')
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 404) {
|
||||
@ -233,6 +551,7 @@ const handleManualInput = async () => {
|
||||
} else {
|
||||
ElMessage.error('查询出错')
|
||||
}
|
||||
if (navigator.vibrate) navigator.vibrate([200, 100, 200])
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (!showCamera.value) {
|
||||
@ -242,251 +561,190 @@ const handleManualInput = async () => {
|
||||
}
|
||||
|
||||
const removeFromCart = (index: number) => {
|
||||
if (!hasPermission.value) {
|
||||
ElMessage.warning('无操作权限')
|
||||
return
|
||||
}
|
||||
cartItems.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
ElMessageBox.confirm('确定清空所有已选物品吗?', '提示', { type: 'warning' })
|
||||
if (!hasPermission.value) {
|
||||
ElMessage.warning('无操作权限')
|
||||
return
|
||||
}
|
||||
ElMessageBox.confirm('确定清空所有已扫物品吗?(保留申请单选择)', '提示', { type: 'warning' })
|
||||
.then(() => {
|
||||
cartItems.value = []
|
||||
form.reason = ''
|
||||
form.remark = ''
|
||||
barcodeInput.value = ''
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
// --- 提交逻辑 ---
|
||||
// --- 提交执行 ---
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
if (cartItems.value.length === 0) return ElMessage.warning('请先添加物品')
|
||||
|
||||
// 验证报废原因
|
||||
if (!form.reason.trim()) {
|
||||
ElMessage.error('请填写报废原因')
|
||||
if (!hasPermission.value) {
|
||||
ElMessage.warning('无操作权限')
|
||||
return
|
||||
}
|
||||
if (!selectedRequest.value) return ElMessage.warning('请先选择要执行的报废申请单')
|
||||
if (cartItems.value.length === 0) return ElMessage.warning('请先扫码添加报废物料')
|
||||
|
||||
// 检查报废数量
|
||||
const invalidItem = cartItems.value.find(item => !item.quantity || item.quantity <= 0)
|
||||
if (invalidItem) {
|
||||
ElMessage.warning('请填写有效的报废数量')
|
||||
return
|
||||
// 逐项复核批准量
|
||||
for (const item of cartItems.value) {
|
||||
const err = validateAgainstPlan(item, 0)
|
||||
if (err) return ElMessage.error(err)
|
||||
if (!item.scrap_quantity || item.scrap_quantity <= 0) {
|
||||
return ElMessage.warning('请填写有效的报废数量')
|
||||
}
|
||||
if (item.scrap_quantity > (parseFloat(item.available_quantity) || 0)) {
|
||||
return ElMessage.error(`【${item.name}】报废数量超过当前可用库存`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查报废数量是否超过可用库存
|
||||
const overstockItem = cartItems.value.find(item => item.quantity > parseFloat(item.available_quantity))
|
||||
if (overstockItem) {
|
||||
ElMessage.warning(`物料 ${overstockItem.sku} 报废数量超过可用库存`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认按实扫数量执行报废吗?\n申请单:${selectedRequest.value.request_no}\n共 ${cartItems.value.length} 项 / ${scanScannedQty.value} 件\n\n执行后不可撤销。`,
|
||||
'执行确认',
|
||||
{ confirmButtonText: '确认执行', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const data = {
|
||||
reason: form.reason,
|
||||
remark: form.remark,
|
||||
items: cartItems.value.map(item => ({
|
||||
id: item.id,
|
||||
sku: item.sku,
|
||||
source_table: item.source_table,
|
||||
quantity: item.quantity
|
||||
}))
|
||||
}
|
||||
const items = cartItems.value.map(item => ({
|
||||
source_table: item.source_table,
|
||||
stock_id: item.id,
|
||||
quantity: item.scrap_quantity,
|
||||
sku: item.sku ? String(item.sku) : '',
|
||||
name: item.name ? String(item.name) : ''
|
||||
}))
|
||||
|
||||
const res = await createScrap(data)
|
||||
if (res.code === 200) {
|
||||
ElMessage.success('报废提交成功')
|
||||
router.push('/scrap/index')
|
||||
} else {
|
||||
ElMessage.error(res.msg || '提交失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(error)
|
||||
ElMessage.error(error.response?.data?.msg || '提交失败')
|
||||
await executeScrapByRequest(selectedRequest.value.id, items)
|
||||
|
||||
ElMessage.success('已执行报废并扣减库存')
|
||||
router.push('/scrap/index')
|
||||
} catch (err: any) {
|
||||
// 拦截器已弹后端业务错误;无响应才算网络异常
|
||||
if (!err?.response) ElMessage.error('网络异常,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadApprovalRequests()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
padding: 10px;
|
||||
.app-container.mobile-optimized {
|
||||
padding: 10px; max-width: 700px; margin: 0 auto;
|
||||
}
|
||||
|
||||
.box-card {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.title-box { font-size: 16px; font-weight: bold; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.card-header {
|
||||
/* 模式条 */
|
||||
.mode-switch-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #fef0f0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #fde2e2;
|
||||
}
|
||||
.mode-hint { color: #909399; font-size: 13px; }
|
||||
|
||||
.title-box {
|
||||
/* 申请单选择 */
|
||||
.approval-request-select { margin-bottom: 16px; }
|
||||
.select-tip { margin: 6px 0 0 0; color: #909399; font-size: 12px; }
|
||||
|
||||
/* 待执行清单 */
|
||||
.planned-items-section {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: #fef0f0;
|
||||
border: 1px solid #fde2e2;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.planned-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.planned-title { font-weight: bold; font-size: 14px; color: #F56C6C; }
|
||||
|
||||
/* 扫码区 */
|
||||
.scan-section { margin-bottom: 20px; }
|
||||
.scan-progress {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.progress-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.scan-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, #ecf5ff 0%, #d9ecff 100%);
|
||||
border: 2px dashed #409EFF;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
height: 120px; background: #f5f7fa; border: 1px dashed #dcdfe6; border-radius: 8px;
|
||||
display: flex; flex-direction: column; justify-content: center; align-items: center;
|
||||
color: #909399; margin-bottom: 10px; cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.camera-placeholder:active { background: #e6e8eb; }
|
||||
.camera-placeholder .text { margin-top: 5px; font-size: 13px; }
|
||||
|
||||
.camera-placeholder:hover {
|
||||
background: linear-gradient(135deg, #d9ecff 0%, #b3d8ff 100%);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
.input-box :deep(.el-input__wrapper) { box-shadow: 0 0 0 1px #dcdfe6 inset; }
|
||||
|
||||
.camera-placeholder .text {
|
||||
margin-top: 10px;
|
||||
color: #409EFF;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-box {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.input-box :deep(.el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px #dcdfe6 inset;
|
||||
}
|
||||
|
||||
.input-box :deep(.el-input__wrapper:hover) {
|
||||
box-shadow: 0 0 0 1px #c0c4cc inset;
|
||||
}
|
||||
|
||||
.input-box :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px #409EFF inset;
|
||||
}
|
||||
|
||||
.cart-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.form-section :deep(.el-divider__text) {
|
||||
font-weight: bold;
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 15px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.bottom-actions .el-button--large {
|
||||
padding: 12px 30px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 全屏扫码样式 */
|
||||
/* 全屏扫码 */
|
||||
.fullscreen-scanner-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #000;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: #000; z-index: 9999;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
.scanner-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
height: 60px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 15px; background: rgba(0,0,0,0.6); color: #fff;
|
||||
position: absolute; top: 0; width: 100%; z-index: 10;
|
||||
}
|
||||
|
||||
.scanner-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.scanner-placeholder {
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.scanner-title { font-size: 16px; font-weight: bold; }
|
||||
.close-btn { background: rgba(255,255,255,0.2); border: none; color: #fff; }
|
||||
.scanner-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1; width: 100%; position: relative;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
:deep(.qr-scanner-container) {
|
||||
width: 100% !important; height: 100% !important; border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.scanner-footer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
position: absolute; bottom: 0; width: 100%;
|
||||
padding: 20px; background: rgba(0,0,0,0.6); color: #fff;
|
||||
text-align: center; z-index: 10;
|
||||
}
|
||||
.current-count { color: #F56C6C; font-weight: bold; margin-top: 5px; font-size: 16px; }
|
||||
|
||||
.scanner-footer p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
/* 表单与购物车 */
|
||||
.cart-section { margin-bottom: 20px; }
|
||||
.form-section { background: #fff; }
|
||||
.form-section :deep(.el-divider__text) { font-weight: bold; color: #F56C6C; }
|
||||
|
||||
.current-count {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #F56C6C;
|
||||
}
|
||||
.bottom-actions { display: flex; justify-content: space-between; margin-top: 20px; }
|
||||
.bottom-actions .el-button { width: 48%; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.title-box {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.camera-placeholder {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bottom-actions .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.title-box { font-size: 14px; }
|
||||
.camera-placeholder { height: 100px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,48 +1,162 @@
|
||||
<template>
|
||||
<div v-if="userStore.hasPermission('scrap_list')" class="app-container">
|
||||
<div class="filter-container">
|
||||
<el-input v-model="sku" placeholder="SKU" style="width: 200px" @keyup.enter="fetchData" />
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="margin-left: 10px; width: 240px"
|
||||
/>
|
||||
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
</div>
|
||||
<!-- ★ 高级搜索:与出库记录 / 借还记录统一版式 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="listQuery.keyword"
|
||||
placeholder="输入关键词"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
@input="debouncedSearch"
|
||||
@clear="handleClearSearch"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="单号" value="no" />
|
||||
<el-option label="操作人" value="name" />
|
||||
<el-option label="SKU" value="sku" />
|
||||
<el-option label="物料名称" value="material_name" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="报废日期">
|
||||
<el-date-picker
|
||||
v-model="listQuery.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" @click="fetchData">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
|
||||
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
|
||||
<el-popover
|
||||
v-model:visible="advancedFilterVisible"
|
||||
placement="bottom"
|
||||
title="高级筛选"
|
||||
width="600"
|
||||
trigger="manual"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
|
||||
高级筛选
|
||||
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="advanced-filter">
|
||||
<div
|
||||
v-for="(condition, index) in advancedConditions"
|
||||
:key="index"
|
||||
class="condition-row"
|
||||
>
|
||||
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
|
||||
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
|
||||
</el-select>
|
||||
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
|
||||
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
|
||||
</el-select>
|
||||
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
|
||||
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<el-button type="primary" link @click="addCondition">添加条件</el-button>
|
||||
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
|
||||
<el-button @click="resetAdvancedFilter">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
:data="list"
|
||||
border
|
||||
stripe
|
||||
style="margin-top: 20px"
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
border
|
||||
v-loading="loading"
|
||||
style="width: 100%; margin-top: 20px;"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
|
||||
>
|
||||
<el-table-column prop="sku" label="SKU" width="140" show-overflow-tooltip />
|
||||
<el-table-column label="物料名称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.material_name || '-' }}
|
||||
<!-- ★ 展开行:报废明细 -->
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<div style="padding: 10px 40px; background: #fafafa;">
|
||||
<h4 style="margin: 0 0 10px 0; color: #F56C6C;">报废明细 ({{ props.row.items?.length || 0 }} 项)</h4>
|
||||
<el-table :data="props.row.items" border size="small">
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="sku" label="SKU" width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.material_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.spec_model || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库位" width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.warehouse_location || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="批次/序列号" width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.batch_number || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
|
||||
<el-table-column v-if="canViewLoss" label="损失金额" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.loss_amount !== null && row.loss_amount !== undefined" style="color:#F56C6C;">
|
||||
¥{{ row.loss_amount.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.reason || '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规格型号" min-width="140">
|
||||
|
||||
<el-table-column label="报废单号" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.spec_model || '-' }}
|
||||
<span>{{ row.scrap_request_no }}</span>
|
||||
<el-tag v-if="row.is_legacy" size="small" type="info" style="margin-left:6px;">历史</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quantity" label="报废数量" width="100" align="right" />
|
||||
<el-table-column prop="reason" label="报废原因" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="operator_name" label="操作人" width="100" />
|
||||
<el-table-column prop="operation_time" label="报废时间" width="160" />
|
||||
<el-table-column label="损失金额" width="100" align="right">
|
||||
|
||||
<el-table-column prop="applicant_name" label="申请人" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.applicant_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="operator_name" label="操作人" width="110" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.operator_name || '-' }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="报废时间" width="170" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.total_loss ? `¥${row.total_loss}` : '-' }}
|
||||
<span>{{ row.scrap_time ? row.scrap_time.substring(0, 16) : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="approval_status" label="审批状态" width="100" align="center">
|
||||
|
||||
<el-table-column label="报废总数" width="100" align="right">
|
||||
<template #default="{ row }">{{ row.total_quantity }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column v-if="canViewLoss" label="总损失金额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.total_loss !== null && row.total_loss !== undefined" style="color:#F56C6C; font-weight:bold; font-size:15px;">
|
||||
¥{{ Number(row.total_loss).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="审批状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.approval_status)">
|
||||
{{ getStatusText(row.approval_status) }}
|
||||
@ -51,20 +165,21 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="pageSize"
|
||||
:current-page="page"
|
||||
@current-change="handlePage"
|
||||
style="margin-top: 10px; text-align: right"
|
||||
/>
|
||||
<div style="margin-top: 20px; text-align: right;">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next, total"
|
||||
:total="total"
|
||||
:page-size="listQuery.pageSize"
|
||||
:current-page="listQuery.page"
|
||||
@current-change="handlePage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { getScrapRecords } from '@/api/scrap'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@ -72,70 +187,165 @@ const userStore = useUserStore()
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
const sku = ref('')
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
const loading = ref(false)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const listQuery = reactive({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
keyword: '',
|
||||
search_type: 'all',
|
||||
dateRange: [] as string[],
|
||||
advancedFilters: [] as any[],
|
||||
})
|
||||
|
||||
// --- ★ 高级筛选 ---
|
||||
const advancedFilterVisible = ref(false)
|
||||
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
|
||||
const appliedConditions = ref<any[]>([]) // 已生效的条件(用于按钮角标)
|
||||
const fieldOptions = [
|
||||
{ value: 'no', label: '单号' },
|
||||
{ value: 'sku', label: 'SKU' },
|
||||
{ value: 'material_name', label: '物料名称' },
|
||||
{ value: 'operator', label: '操作人' },
|
||||
]
|
||||
const operatorOptions = [
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'eq', label: '等于' },
|
||||
{ value: 'not_contains', label: '不包含' },
|
||||
{ value: 'ne', label: '不等于' },
|
||||
]
|
||||
|
||||
const addCondition = () => {
|
||||
advancedConditions.value.push({ field: '', operator: '', value: '' })
|
||||
}
|
||||
const removeCondition = (index: number) => {
|
||||
advancedConditions.value.splice(index, 1)
|
||||
}
|
||||
const applyAdvancedFilter = () => {
|
||||
// 过滤掉不完整的条件行
|
||||
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
|
||||
listQuery.advancedFilters = valid
|
||||
appliedConditions.value = valid
|
||||
advancedFilterVisible.value = false
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
const resetAdvancedFilter = () => {
|
||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||
listQuery.advancedFilters = []
|
||||
appliedConditions.value = []
|
||||
advancedFilterVisible.value = false
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 损失金额可见性:与后端 scrap_list:loss_amount 权限一致
|
||||
const canViewLoss = computed(() =>
|
||||
userStore.role === 'SUPER_ADMIN' || userStore.hasPermission('scrap_list:loss_amount')
|
||||
)
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
sku: sku.value
|
||||
page: listQuery.page,
|
||||
pageSize: listQuery.pageSize,
|
||||
keyword: listQuery.keyword,
|
||||
search_type: listQuery.search_type,
|
||||
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
|
||||
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
|
||||
}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
if (listQuery.dateRange && listQuery.dateRange.length === 2) {
|
||||
params.start_date = listQuery.dateRange[0]
|
||||
params.end_date = listQuery.dateRange[1]
|
||||
}
|
||||
const res = await getScrapRecords(params)
|
||||
list.value = res.data.list || []
|
||||
total.value = res.data.total || 0
|
||||
const res: any = await getScrapRecords(params)
|
||||
list.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (e) {
|
||||
console.error('加载报废记录失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handlePage = (val: number) => {
|
||||
page.value = val
|
||||
listQuery.page = val
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
sku.value = ''
|
||||
dateRange.value = null
|
||||
page.value = 1
|
||||
listQuery.keyword = ''
|
||||
listQuery.search_type = 'all'
|
||||
listQuery.dateRange = []
|
||||
listQuery.advancedFilters = []
|
||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||
appliedConditions.value = []
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 报废台账写在执行/提交时,approval_status 的取值为这两个
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
executed: 'success',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
pending: 'warning'
|
||||
pending: 'warning',
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
approved: '已审批',
|
||||
executed: '已执行',
|
||||
approved: '已报废',
|
||||
rejected: '已拒绝',
|
||||
pending: '待审批'
|
||||
pending: '待审批',
|
||||
}
|
||||
return map[status] || status
|
||||
return map[status] || status || '-'
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.filter-container {
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 高级筛选条件行 */
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,41 +1,89 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<div class="filter-container">
|
||||
<CompanySelector v-model="listQuery.company" @change="fetchData" style="margin-right: 10px;" />
|
||||
<!-- ★ 高级搜索:与借还记录 / 报废记录统一版式 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item>
|
||||
<CompanySelector v-model="listQuery.company" @change="fetchData" />
|
||||
</el-form-item>
|
||||
|
||||
<el-input
|
||||
v-model="listQuery.keyword"
|
||||
placeholder="请输入关键词"
|
||||
style="width: 250px;"
|
||||
class="filter-item"
|
||||
clearable
|
||||
@input="debouncedSearch"
|
||||
@clear="handleClearSearch"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px" @change="debouncedSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="单号" value="no" />
|
||||
<el-option label="姓名" value="name" />
|
||||
<el-option label="SKU" value="sku" />
|
||||
<el-option label="物料名称" value="material_name" />
|
||||
<el-option label="规格型号" value="spec_model" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-date-picker
|
||||
v-model="listQuery.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
class="filter-item"
|
||||
style="margin-left: 10px;"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-button type="primary" class="filter-item" style="margin-left: 10px;" @click="fetchData">查询</el-button>
|
||||
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" class="filter-item" @click="$router.push('/outbound/create')">新建出库</el-button>
|
||||
</div>
|
||||
<el-form-item label="搜索">
|
||||
<el-input
|
||||
v-model="listQuery.keyword"
|
||||
placeholder="输入关键词"
|
||||
style="width: 240px;"
|
||||
clearable
|
||||
@input="debouncedSearch"
|
||||
@clear="handleClearSearch"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-select v-model="listQuery.search_type" placeholder="搜索类型" style="width: 110px" @change="handleSearchImmediate">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="单号" value="no" />
|
||||
<el-option label="姓名" value="name" />
|
||||
<el-option label="SKU" value="sku" />
|
||||
<el-option label="物料名称" value="material_name" />
|
||||
<el-option label="规格型号" value="spec_model" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="出库日期">
|
||||
<el-date-picker
|
||||
v-model="listQuery.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" @click="fetchData">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
|
||||
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
|
||||
<el-popover
|
||||
v-model:visible="advancedFilterVisible"
|
||||
placement="bottom"
|
||||
title="高级筛选"
|
||||
width="600"
|
||||
trigger="manual"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
|
||||
高级筛选
|
||||
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="advanced-filter">
|
||||
<div
|
||||
v-for="(condition, index) in advancedConditions"
|
||||
:key="index"
|
||||
class="condition-row"
|
||||
>
|
||||
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
|
||||
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
|
||||
</el-select>
|
||||
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
|
||||
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
|
||||
</el-select>
|
||||
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
|
||||
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<el-button type="primary" link @click="addCondition">添加条件</el-button>
|
||||
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
|
||||
<el-button @click="resetAdvancedFilter">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<el-button v-if="userStore.hasPermission('outbound_create:operation')" type="success" @click="$router.push('/outbound/create')">新建出库</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
:data="list"
|
||||
@ -220,16 +268,59 @@ const listQuery = reactive({
|
||||
keyword: '',
|
||||
search_type: 'all',
|
||||
dateRange: [],
|
||||
company: '' as string
|
||||
company: '' as string,
|
||||
advancedFilters: [] as any[],
|
||||
})
|
||||
|
||||
// --- ★ 高级筛选 ---
|
||||
const advancedFilterVisible = ref(false)
|
||||
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
|
||||
const appliedConditions = ref<any[]>([])
|
||||
const fieldOptions = [
|
||||
{ value: 'no', label: '单号' },
|
||||
{ value: 'sku', label: 'SKU' },
|
||||
{ value: 'material_name', label: '物料名称' },
|
||||
{ value: 'operator', label: '操作人' },
|
||||
]
|
||||
const operatorOptions = [
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'eq', label: '等于' },
|
||||
{ value: 'not_contains', label: '不包含' },
|
||||
{ value: 'ne', label: '不等于' },
|
||||
]
|
||||
|
||||
const addCondition = () => {
|
||||
advancedConditions.value.push({ field: '', operator: '', value: '' })
|
||||
}
|
||||
const removeCondition = (index: number) => {
|
||||
advancedConditions.value.splice(index, 1)
|
||||
}
|
||||
const applyAdvancedFilter = () => {
|
||||
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
|
||||
listQuery.advancedFilters = valid
|
||||
appliedConditions.value = valid
|
||||
advancedFilterVisible.value = false
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
const resetAdvancedFilter = () => {
|
||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||
listQuery.advancedFilters = []
|
||||
appliedConditions.value = []
|
||||
advancedFilterVisible.value = false
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
...listQuery,
|
||||
start_date: listQuery.dateRange && listQuery.dateRange[0] ? listQuery.dateRange[0] : null,
|
||||
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null
|
||||
end_date: listQuery.dateRange && listQuery.dateRange[1] ? listQuery.dateRange[1] : null,
|
||||
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
|
||||
advancedFilters: JSON.stringify(listQuery.advancedFilters || []),
|
||||
}
|
||||
|
||||
const res = await getOutboundList(params)
|
||||
@ -247,6 +338,25 @@ const handlePageChange = (val: number) => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 立即查询(搜索类型切换用,取消防抖)
|
||||
const handleSearchImmediate = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
listQuery.keyword = ''
|
||||
listQuery.search_type = 'all'
|
||||
listQuery.dateRange = []
|
||||
listQuery.advancedFilters = []
|
||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||
appliedConditions.value = []
|
||||
listQuery.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const formatType = (type: string) => {
|
||||
const map: any = {
|
||||
'SALES': '销售出库',
|
||||
@ -285,6 +395,24 @@ onBeforeUnmount(() => {
|
||||
</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;
|
||||
}
|
||||
|
||||
/* 高级筛选条件行 */
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.signature-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@ -1,33 +1,91 @@
|
||||
<template>
|
||||
<div v-if="userStore.hasPermission('op_records')" class="app-container">
|
||||
<div class="filter-container">
|
||||
<el-radio-group v-model="status" @change="fetchData" style="margin-right: 20px">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="borrowed">未归还</el-radio-button>
|
||||
<el-radio-button label="returned">已归还</el-radio-button>
|
||||
</el-radio-group>
|
||||
<!-- ★ 高级搜索:与出库记录 / 报废记录统一版式 -->
|
||||
<el-form :inline="true" class="filter-form" @submit.prevent>
|
||||
<el-form-item>
|
||||
<el-radio-group v-model="status" @change="handleSearchImmediate">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="borrowed">未归还</el-radio-button>
|
||||
<el-radio-button label="returned">已归还</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="请输入关键词"
|
||||
style="width: 250px"
|
||||
clearable
|
||||
@input="debouncedSearch"
|
||||
@clear="handleClearSearch"
|
||||
>
|
||||
<template #prepend>
|
||||
<el-select v-model="searchType" placeholder="类型" style="width: 110px" @change="debouncedSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="单号" value="no" />
|
||||
<el-option label="借用人" value="name" />
|
||||
<el-option label="SKU" value="sku" />
|
||||
<el-option label="物料名称" value="material_name" />
|
||||
<el-option label="规格型号" value="spec_model" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="fetchData">查询</el-button>
|
||||
</div>
|
||||
<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="SKU" value="sku" />
|
||||
<el-option label="物料名称" value="material_name" />
|
||||
<el-option label="规格型号" value="spec_model" />
|
||||
</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" @click="fetchData">查询</el-button>
|
||||
<el-button @click="resetFilter">重置</el-button>
|
||||
|
||||
<!-- ★ 高级筛选:对齐 material/list.vue 既有模式 -->
|
||||
<el-popover
|
||||
v-model:visible="advancedFilterVisible"
|
||||
placement="bottom"
|
||||
title="高级筛选"
|
||||
width="600"
|
||||
trigger="manual"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button plain @click="advancedFilterVisible = !advancedFilterVisible">
|
||||
高级筛选
|
||||
<el-badge v-if="appliedConditions.length" :value="appliedConditions.length" style="margin-left:6px" />
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="advanced-filter">
|
||||
<div
|
||||
v-for="(condition, index) in advancedConditions"
|
||||
:key="index"
|
||||
class="condition-row"
|
||||
>
|
||||
<el-select v-model="condition.field" placeholder="字段" style="width:180px" :teleported="false">
|
||||
<el-option v-for="f in fieldOptions" :key="f.value" :label="f.label" :value="f.value" />
|
||||
</el-select>
|
||||
<el-select v-model="condition.operator" placeholder="操作符" style="width:120px; margin-left:8px" :teleported="false">
|
||||
<el-option v-for="op in operatorOptions" :key="op.value" :label="op.label" :value="op.value" />
|
||||
</el-select>
|
||||
<el-input v-model="condition.value" placeholder="值" style="width:180px; margin-left:8px" />
|
||||
<el-button v-if="advancedConditions.length > 1" type="danger" link @click="removeCondition(index)" style="margin-left:8px">删除</el-button>
|
||||
</div>
|
||||
<div style="margin-top:12px">
|
||||
<el-button type="primary" link @click="addCondition">添加条件</el-button>
|
||||
<el-button type="primary" @click="applyAdvancedFilter">应用筛选</el-button>
|
||||
<el-button @click="resetAdvancedFilter">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table
|
||||
:data="list"
|
||||
@ -262,9 +320,51 @@ const total = ref(0)
|
||||
const status = ref('borrowed')
|
||||
const keyword = ref('')
|
||||
const searchType = ref('all')
|
||||
const dateRange = ref<string[]>([])
|
||||
const page = ref(1)
|
||||
const loading = ref(false)
|
||||
|
||||
// --- ★ 高级筛选 ---
|
||||
const advancedFilterVisible = ref(false)
|
||||
const advancedConditions = ref([{ field: '', operator: '', value: '' }])
|
||||
const appliedConditions = ref<any[]>([])
|
||||
const advancedFilters = ref<any[]>([])
|
||||
const fieldOptions = [
|
||||
{ value: 'no', label: '单号' },
|
||||
{ value: 'sku', label: 'SKU' },
|
||||
{ value: 'material_name', label: '物料名称' },
|
||||
{ value: 'borrower_name', label: '操作人' },
|
||||
]
|
||||
const operatorOptions = [
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'eq', label: '等于' },
|
||||
{ value: 'not_contains', label: '不包含' },
|
||||
{ value: 'ne', label: '不等于' },
|
||||
]
|
||||
|
||||
const addCondition = () => {
|
||||
advancedConditions.value.push({ field: '', operator: '', value: '' })
|
||||
}
|
||||
const removeCondition = (index: number) => {
|
||||
advancedConditions.value.splice(index, 1)
|
||||
}
|
||||
const applyAdvancedFilter = () => {
|
||||
const valid = advancedConditions.value.filter(c => c.field && c.operator && c.value !== '')
|
||||
advancedFilters.value = valid
|
||||
appliedConditions.value = valid
|
||||
advancedFilterVisible.value = false
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
const resetAdvancedFilter = () => {
|
||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||
advancedFilters.value = []
|
||||
appliedConditions.value = []
|
||||
advancedFilterVisible.value = false
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// ★ 借库报废相关
|
||||
const canScrap = computed(() =>
|
||||
userStore.role === 'SUPER_ADMIN' ||
|
||||
@ -329,15 +429,22 @@ const confirmScrap = async () => {
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: any = {
|
||||
page: page.value,
|
||||
status: status.value,
|
||||
keyword: keyword.value,
|
||||
search_type: searchType.value,
|
||||
// ★ 高级筛选:后端约定参数名为 advancedFilters,值为 JSON 字符串
|
||||
advancedFilters: JSON.stringify(advancedFilters.value || []),
|
||||
}
|
||||
if (dateRange.value && dateRange.value.length === 2) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const res = await request({
|
||||
url: '/v1/transactions/records',
|
||||
method: 'get',
|
||||
params: {
|
||||
page: page.value,
|
||||
status: status.value,
|
||||
keyword: keyword.value,
|
||||
search_type: searchType.value
|
||||
}
|
||||
params
|
||||
})
|
||||
|
||||
// ★ 按 borrow_no 分组聚合为主子表结构
|
||||
@ -371,6 +478,26 @@ const handlePage = (val: number) => {
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 立即查询(状态/类型切换用,取消防抖)
|
||||
const handleSearchImmediate = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetFilter = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
status.value = 'borrowed'
|
||||
keyword.value = ''
|
||||
searchType.value = 'all'
|
||||
dateRange.value = []
|
||||
advancedFilters.value = []
|
||||
advancedConditions.value = [{ field: '', operator: '', value: '' }]
|
||||
appliedConditions.value = []
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// ★ 新增:格式化预计归还时间及倒计时逻辑(交通灯预警系统)
|
||||
const formatExpectedTime = (timeStr: string) => {
|
||||
if (!timeStr) return { text: '无限期', diffText: '', style: {} }
|
||||
@ -418,5 +545,22 @@ onBeforeUnmount(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
/* ★ 统一高级搜索版式(与出库记录 / 报废记录一致) */
|
||||
.filter-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 高级筛选条件行 */
|
||||
.condition-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user