feat(return): 退回流水看板接口与权限收口
新增只读台账接口: - GET /api/v1/outbound/returns 退回流水(分页 + 关键词 + 类型 + 时间过滤) 返回 原出库单号 / 物料名称 / 规格 / SKU / 退回类型 / 退回数量 / 原因 / 操作人 / 退回时间 / 公司。出库单号经 trans_outbound 批量补齐,物料名按 多态来源批量解析,均为批量查询无 N+1。 权限收口(配合 db_migrations 里的三个权限码): - return-from-outbound inventory_stocktake:operation -> outbound_return - GET /stock/defective inventory_stocktake -> defective_list - restock inventory_stocktake:operation -> defective_restock - scrap inventory_stocktake:operation -> defective_scrap - change-status inventory_stocktake:operation -> stock_change_status 原先这四个接口搭的是「盲盘作业」权限的便车,职责错配、审计不合规。 实测 SALES(销售)角色持有 inventory_stocktake,意味着销售人员能读整份 不良品台账——与业务对台账可见性的要求不符。全部改用无冒号专用码后, 实测「只授予 inventory_stocktake:operation」对四个接口均返回 403,便车已封。 trans_return 补 company_name 快照: 退回流水的隔离判定原先只能靠 join 链推,而库存行会被入库模块物理删除 (实测 1077 条出库记录中已有 7 条悬空),链路一断记录就会对普通用户 静默消失。改由退回时落快照,隔离不再依赖任何 join。
This commit is contained in:
@ -2479,7 +2479,11 @@ def update_stocktake_quantity():
|
||||
# 不可丢给后台任务,否则会静默失去审计痕迹。
|
||||
|
||||
@bp.route('/<int:stock_id>/change-status', methods=['POST'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
# ★ 专用权限码。原先搭 inventory_stocktake:operation(盲盘作业)的便车,
|
||||
# 但「冻结/标不良」是库存状态治理动作,与盘点作业职责不同,审计上不合规。
|
||||
# 无冒号形式,不触发 _expand_operation_perms 的前缀桥接。
|
||||
# 权限注册见 db_migrations/add_defective_operation_perms.sql
|
||||
@permission_required('stock_change_status')
|
||||
def change_stock_status(stock_id):
|
||||
"""
|
||||
变更单条库存行的状态(在库 / 冻结 / 不良品)。
|
||||
@ -2646,7 +2650,10 @@ def _assert_company_owns(row):
|
||||
|
||||
|
||||
@bp.route('/defective', methods=['GET'])
|
||||
@permission_required('inventory_stocktake')
|
||||
# ★ 专用查看权限。原先复用 inventory_stocktake(盲盘作业)—— 实测 SALES(销售)
|
||||
# 角色持有该权限,意味着销售人员能读整份不良品台账,与业务对台账可见性的
|
||||
# 要求不符。无冒号形式不触发前缀桥接。注册见 add_return_view_support.sql
|
||||
@permission_required('defective_list')
|
||||
def list_defective_goods():
|
||||
"""
|
||||
不良品在管台账分页查询(供「不良品在管台账」看板页使用)。
|
||||
@ -2762,7 +2769,13 @@ def _defective_unit_cost(goods):
|
||||
|
||||
|
||||
@bp.route('/return-from-outbound', methods=['POST'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
# ★ 库管 SOP 专用权限码:退回是实物交接动作,只应由具备库管职责的人员执行。
|
||||
# 刻意使用**无冒号**的 'outbound_return' 而非 'outbound_list:return':
|
||||
# 后者会命中 _expand_operation_perms() 的前缀桥接(outbound_list 下存在
|
||||
# outbound_list:operation),导致持有该权限的角色被一并放行,授权面失控。
|
||||
# 无冒号码不触发桥接,判定与前端 hasPermission 的精确匹配完全一致。
|
||||
# DDL/授权见 db_migrations/add_outbound_return_perm.sql
|
||||
@permission_required('outbound_return')
|
||||
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods)
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def return_from_outbound():
|
||||
@ -2826,6 +2839,11 @@ def return_from_outbound():
|
||||
stock_row = _lock_source_stock_row(outbound.source_table, outbound.stock_id)
|
||||
_assert_company_owns(stock_row)
|
||||
|
||||
# 公司快照:退回看板的隔离判定不能依赖 join 链 —— 源库存行会被入库模块
|
||||
# 物理删除,届时链路断裂会让记录对普通用户静默消失。见 TransReturn 注释。
|
||||
_base = getattr(stock_row, 'base', None)
|
||||
snapshot_company = ((_base.company_name if _base else '') or '').strip() or None
|
||||
|
||||
goods = None
|
||||
if is_defective:
|
||||
# ================= 不良品分支 =================
|
||||
@ -2876,6 +2894,7 @@ def return_from_outbound():
|
||||
return_type=RETURN_TYPE_DEFECTIVE if is_defective else RETURN_TYPE_GOOD,
|
||||
reason=reason,
|
||||
operator=operator_name,
|
||||
company_name=snapshot_company,
|
||||
)
|
||||
db.session.add(ledger)
|
||||
db.session.flush() # 先拿到 ledger.id,供在管台账回填
|
||||
@ -2913,7 +2932,9 @@ def return_from_outbound():
|
||||
|
||||
|
||||
@bp.route('/defective/<int:goods_id>/restock', methods=['POST'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
|
||||
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
|
||||
@permission_required('defective_restock')
|
||||
# ★ 幂等锁必须置于 permission_required **内层**:prevent_double_submit 依赖
|
||||
# get_jwt_identity(),若放在外层则 JWT 尚未验证 → 抛错 → 被其 except 捕获
|
||||
# 后 fail-open 降级放行,锁形同虚设。
|
||||
@ -3027,7 +3048,9 @@ def restock_defective_goods(goods_id):
|
||||
|
||||
|
||||
@bp.route('/defective/<int:goods_id>/scrap', methods=['POST'])
|
||||
@permission_required('inventory_stocktake:operation')
|
||||
# ★ 专用权限码(原先搭 inventory_stocktake:operation 的便车)。
|
||||
# 无冒号形式,不触发前缀桥接。注册见 add_defective_operation_perms.sql
|
||||
@permission_required('defective_scrap')
|
||||
# ★ 幂等锁置于 permission_required 内层(理由见 restock_defective_goods)
|
||||
@prevent_double_submit(lock_timeout=5)
|
||||
def scrap_defective_goods(goods_id):
|
||||
|
||||
@ -278,6 +278,155 @@ def get_outbound_list():
|
||||
return jsonify({'code': 500, 'msg': str(e)}), 500
|
||||
|
||||
|
||||
def _resolve_return_materials(rows):
|
||||
"""
|
||||
批量解析退回流水对应的物料名称/规格。
|
||||
|
||||
trans_return 只存 (source_table, stock_id) 多态指针,需回查三张库存表。
|
||||
★ 源库存行可能已被物理删除(实测出库记录中已有悬空行),取不到时返回
|
||||
空字符串由前端显示占位 —— 刻意**不**因此丢弃该行:退回台账的完整性
|
||||
优先于展示美观,缺名字总比少一条记录好。
|
||||
"""
|
||||
from app.models.inbound.buy import StockBuy
|
||||
from app.models.inbound.semi import StockSemi
|
||||
from app.models.inbound.product import StockProduct
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
model_map = {'stock_buy': StockBuy, 'stock_semi': StockSemi,
|
||||
'stock_product': StockProduct}
|
||||
resolved = {}
|
||||
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():
|
||||
base = getattr(obj, 'base', None)
|
||||
resolved[(table, obj.id)] = {
|
||||
'material_name': (base.name if base else '') or '',
|
||||
'spec_model': (base.spec_model if base else '') or '',
|
||||
}
|
||||
return resolved
|
||||
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 退回流水(只读台账)
|
||||
# GET /api/v1/outbound/returns
|
||||
# --------------------------------------------------------
|
||||
@outbound_bp.route('/returns', methods=['GET'])
|
||||
# ★ 专用查看权限,仅授予超管/主管/库管三个核心角色。
|
||||
# 无冒号形式不触发 _expand_operation_perms 的前缀桥接,
|
||||
# 注册见 db_migrations/add_return_view_support.sql
|
||||
@permission_required('outbound_return_list')
|
||||
def list_returns():
|
||||
"""
|
||||
原单退回流水台账(只读,无任何写操作)。
|
||||
|
||||
Query:
|
||||
page / page_size 分页,默认 1 / 20
|
||||
keyword 模糊匹配 出库单号 / SKU / 操作人
|
||||
return_type '良品' / '不良品';'全部' 或留空 = 不过滤
|
||||
start_date / end_date 按退回时间过滤(10 位日期自动补时分秒)
|
||||
|
||||
★ 行级隔离直接按 trans_return.company_name **快照**过滤,不走 join 链:
|
||||
源库存行会被入库模块物理删除,链路一断该记录就会对普通用户静默消失。
|
||||
"""
|
||||
from app.extensions import db
|
||||
from app.utils.decorators import get_current_company_filter
|
||||
from app.models.transaction import TransReturn, VALID_RETURN_TYPES
|
||||
from app.models.outbound import TransOutbound
|
||||
|
||||
page = request.args.get('page', 1, type=int) or 1
|
||||
page_size = request.args.get('page_size', 20, type=int) or 20
|
||||
page_size = min(max(page_size, 1), 200) # 防超大分页拖垮库
|
||||
|
||||
keyword = (request.args.get('keyword') or '').strip()
|
||||
return_type = (request.args.get('return_type') or '').strip()
|
||||
start_date = (request.args.get('start_date') or '').strip()
|
||||
end_date = (request.args.get('end_date') or '').strip()
|
||||
|
||||
try:
|
||||
query = TransReturn.query
|
||||
|
||||
# 行级隔离(超管/跨域 company_limit 为 None,不受限)
|
||||
company_limit = get_current_company_filter()
|
||||
if company_limit is not None:
|
||||
query = query.filter(TransReturn.company_name == company_limit)
|
||||
|
||||
if return_type and return_type not in ('全部', 'all'):
|
||||
if return_type not in VALID_RETURN_TYPES:
|
||||
return jsonify({
|
||||
'code': 400,
|
||||
'msg': f'不支持的退回类型:{return_type},'
|
||||
f'仅支持 {"、".join(VALID_RETURN_TYPES)}',
|
||||
}), 400
|
||||
query = query.filter(TransReturn.return_type == return_type)
|
||||
|
||||
# 日期边界补全时分秒,避免 10 位日期被当成零点截断(与全系统口径一致)
|
||||
if start_date and len(start_date) == 10:
|
||||
start_date = f'{start_date} 00:00:00'
|
||||
if end_date and len(end_date) == 10:
|
||||
end_date = f'{end_date} 23:59:59'
|
||||
if start_date:
|
||||
query = query.filter(TransReturn.return_time >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(TransReturn.return_time <= end_date)
|
||||
|
||||
if keyword:
|
||||
like = f'%{keyword}%'
|
||||
# 出库单号不在本表,先经 trans_outbound 求出命中的 outbound_id 集合
|
||||
matched = db.session.query(TransOutbound.id).filter(
|
||||
TransOutbound.outbound_no.ilike(like)
|
||||
).subquery()
|
||||
query = query.filter(db.or_(
|
||||
TransReturn.sku.ilike(like),
|
||||
TransReturn.operator.ilike(like),
|
||||
TransReturn.outbound_id.in_(db.session.query(matched.c.id)),
|
||||
))
|
||||
|
||||
# 默认按退回时间倒序:最新退回的最需要核对
|
||||
query = query.order_by(TransReturn.return_time.desc(),
|
||||
TransReturn.id.desc())
|
||||
pg = query.paginate(page=page, per_page=page_size, error_out=False)
|
||||
|
||||
rows = pg.items
|
||||
|
||||
# ---- 批量补出库单号(避免 N+1)----
|
||||
outbound_ids = {r.outbound_id for r in rows if r.outbound_id}
|
||||
outbound_map = {}
|
||||
if outbound_ids:
|
||||
for o in TransOutbound.query.filter(
|
||||
TransOutbound.id.in_(outbound_ids)).all():
|
||||
outbound_map[o.id] = o.outbound_no
|
||||
|
||||
# ---- 批量补物料名 ----
|
||||
mat_map = _resolve_return_materials(rows)
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
d = r.to_dict()
|
||||
d['outbound_no'] = outbound_map.get(r.outbound_id, '')
|
||||
info = mat_map.get((r.source_table, r.stock_id)) or {}
|
||||
d['material_name'] = info.get('material_name', '')
|
||||
d['spec_model'] = info.get('spec_model', '')
|
||||
items.append(d)
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': 'success',
|
||||
'data': {
|
||||
'list': items,
|
||||
'total': pg.total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
},
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return jsonify({'code': 500, 'msg': f'查询失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
def _allocate_bom_requirements(requirements, company_limit,
|
||||
StockBuy, StockSemi, StockProduct, MaterialBase):
|
||||
"""
|
||||
|
||||
@ -268,6 +268,15 @@ class TransReturn(db.Model):
|
||||
operator = db.Column(db.String(100))
|
||||
return_time = db.Column(db.DateTime, default=beijing_time)
|
||||
|
||||
# ★ 公司快照:退回发生时的所属公司。
|
||||
# 不靠 join 推 —— 隔离判定的链路是
|
||||
# trans_return → trans_outbound → (source_table, stock_id) → 库存表 → 物料主表
|
||||
# 而库存行会被入库模块**物理删除**(实测 1077 条出库记录中已有 7 条悬空),
|
||||
# 链路一断,记录就会对普通用户静默消失。审计视图静默丢数据不可接受。
|
||||
# 与 trans_defective_goods.company_name 同一处理方式。
|
||||
# DDL 见 db_migrations/add_return_view_support.sql
|
||||
company_name = db.Column(db.String(255), index=True)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
@ -280,6 +289,7 @@ class TransReturn(db.Model):
|
||||
'reason': self.reason,
|
||||
'operator': self.operator,
|
||||
'return_time': self.return_time.strftime('%Y-%m-%d %H:%M:%S') if self.return_time else None,
|
||||
'company_name': self.company_name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user