feat(borrow,outbound): 默认免审批改造——命中物料需审批才走原流程

- models/base.py 加 is_approval_required 列及 isApprovalRequired 序列化;field_permissions 登记
- 新增 POST /inbound/base/batch-approval(批量设需审批,仿批量质检)
- borrow_service.submit_approval / outbound_service.create_request:明细含需审批物料→须选审批人走原审批;否则创建即 status=1(待库管执行)、不发审批邮件
- 判定按 (name,spec_model) 反查启用物料
This commit is contained in:
yueli
2026-09-09 09:31:18 +08:00
parent e03c035ee0
commit 4cd3eefdf4
5 changed files with 164 additions and 25 deletions

View File

@ -539,6 +539,44 @@ def batch_set_inspection():
return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500 return jsonify({"code": 500, "msg": f"批量设置强制质检失败: {str(e)}"}), 500
# ==============================================================================
# 2.8 批量设置“出库/借库需审批” (POST /api/v1/inbound/base/batch-approval)
# ==============================================================================
@inbound_base_bp.route('/batch-approval', methods=['POST'])
@permission_required('material_list:operation')
def batch_set_approval_required():
"""
批量设置物料“出库/借库需审批”标记(仿强制质检批量接口)。
请求体: { "ids": [1,2,3], "isApprovalRequired": true }
"""
try:
data = request.get_json()
if not data:
return jsonify({"code": 400, "msg": "No data provided"}), 400
ids = data.get('ids', [])
is_approval_required = bool(data.get('isApprovalRequired', False))
if not ids:
return jsonify({"code": 400, "msg": "请选择要设置的物料"}), 400
updated_count = 0
for base_id in ids:
material = MaterialBase.query.get(base_id)
if material:
material.is_approval_required = is_approval_required
updated_count += 1
db.session.commit()
return jsonify({
"code": 200,
"msg": f"批量设置成功,已更新 {updated_count} 条记录",
"data": {"updated": updated_count}
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"批量设置需审批失败: {str(e)}")
return jsonify({"code": 500, "msg": f"批量设置需审批失败: {str(e)}"}), 500
# ============================================================================== # ==============================================================================
# 2.7 智能分组求最大连号 API (GET /api/v1/inbound/base/spec-latest) # 2.7 智能分组求最大连号 API (GET /api/v1/inbound/base/spec-latest)
# ============================================================================== # ==============================================================================

View File

@ -39,6 +39,9 @@ class MaterialBase(db.Model):
# 强制质检标记(采购入库时必须上传检测报告) # 强制质检标记(采购入库时必须上传检测报告)
is_inspection_required = db.Column(db.Boolean, default=False, comment='是否强制要求质检') is_inspection_required = db.Column(db.Boolean, default=False, comment='是否强制要求质检')
# 出库/借库需审批标记:命中该物料的出/借申请仍需走审批;否则默认自动通过、直接交库管执行
is_approval_required = db.Column(db.Boolean, default=False, comment='出库/借库需审批')
# 参考价格 # 参考价格
reference_price = db.Column(db.Numeric(10, 2), nullable=True, comment='参考价格') reference_price = db.Column(db.Numeric(10, 2), nullable=True, comment='参考价格')
@ -99,6 +102,8 @@ class MaterialBase(db.Model):
'isEnabled': bool(self.is_enabled), 'isEnabled': bool(self.is_enabled),
# 强制质检标记 # 强制质检标记
'isInspectionRequired': bool(self.is_inspection_required), 'isInspectionRequired': bool(self.is_inspection_required),
# 出库/借库需审批标记
'isApprovalRequired': bool(self.is_approval_required),
# 参考价格 # 参考价格
'referencePrice': float(self.reference_price) if self.reference_price is not None else None, 'referencePrice': float(self.reference_price) if self.reference_price is not None else None,
} }

View File

@ -33,6 +33,33 @@ class BorrowApprovalService:
return f"APR-BOR-{date_str}-{time_str}-{sequence:04d}" return f"APR-BOR-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def _items_require_approval(items):
"""
明细中任一物料命中“出库/借库需审批”→ 需要审批。
申请明细只存 name/spec_model,故按 (name, spec_model) 反查启用物料判定。
"""
from app.models.base import MaterialBase
seen = set()
for item in items:
name = str(item.get('name') or '').strip()
spec = str(item.get('spec_model') or '').strip()
if not name:
continue
key = (name, spec)
if key in seen:
continue
seen.add(key)
hit = MaterialBase.query.filter(
MaterialBase.name == name,
MaterialBase.spec_model == spec,
MaterialBase.is_enabled == True,
MaterialBase.is_approval_required == True
).first()
if hit:
return True
return False
@staticmethod @staticmethod
def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None, def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None,
borrower_name=None): borrower_name=None):
@ -76,14 +103,19 @@ class BorrowApprovalService:
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}") raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
if not allowed_approvers: # ★ 需审批判定:明细含“出库/借库需审批”物料 → 走原审批流程;否则默认自动通过
raise ValueError("必须指定至少一位审批人") need_approval = BorrowApprovalService._items_require_approval(items)
if need_approval and not approver_id:
raise ValueError("该申请包含需审批的物料,请选择审批人后再提交")
if approver_id: if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}] allowed_approvers = [{"type": "user", "value": int(approver_id)}]
elif not need_approval:
allowed_approvers = [] # 免审批单不绑定审批人
request_no = BorrowApprovalService.generate_request_no() request_no = BorrowApprovalService.generate_request_no()
if need_approval:
approval = BorrowApproval( approval = BorrowApproval(
request_no=request_no, request_no=request_no,
applicant_id=applicant_id, applicant_id=applicant_id,
@ -91,14 +123,30 @@ class BorrowApprovalService:
borrower_name=borrower_name, borrower_name=borrower_name,
status=0, # 待审批 status=0, # 待审批
) )
else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
from datetime import datetime, timezone, timedelta
approval = BorrowApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
borrower_name=borrower_name,
status=1,
actual_approver_id=applicant_id,
approved_at=datetime.now(timezone(timedelta(hours=8))),
)
approval.set_items(items) approval.set_items(items)
if allowed_approvers:
approval.set_allowed_approvers(allowed_approvers) approval.set_allowed_approvers(allowed_approvers)
else:
approval.allowed_approvers = '[]'
db.session.add(approval) db.session.add(approval)
db.session.commit() db.session.commit()
# ★ 创建成功后,发送邮件通知审批人(静默处理,不阻断主流程) # 仅需审批单通知审批人;免审批单静默进入“待库管执行”
if need_approval:
BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id) BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
return approval return approval

View File

@ -700,6 +700,33 @@ class OutboundApprovalService:
return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}" return f"APR-OUT-{date_str}-{time_str}-{sequence:04d}"
@staticmethod
def _items_require_approval(items):
"""
明细中任一物料命中“出库/借库需审批”→ 需要审批。
申请明细只存 name/spec_model,故按 (name, spec_model) 反查启用物料判定。
"""
from app.models.base import MaterialBase
seen = set()
for item in items:
name = str(item.get('name') or '').strip()
spec = str(item.get('spec_model') or '').strip()
if not name:
continue
key = (name, spec)
if key in seen:
continue
seen.add(key)
hit = MaterialBase.query.filter(
MaterialBase.name == name,
MaterialBase.spec_model == spec,
MaterialBase.is_enabled == True,
MaterialBase.is_approval_required == True
).first()
if hit:
return True
return False
@staticmethod @staticmethod
def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None, outbound_type=None): def create_request(applicant_id, items, allowed_approvers, remark=None, approver_id=None, outbound_type=None):
""" """
@ -745,16 +772,20 @@ class OutboundApprovalService:
except (TypeError, ValueError) as e: except (TypeError, ValueError) as e:
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}") raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
# ★ 校验 allowed_approvers 非空 # ★ 需审批判定:明细含“出库/借库需审批”物料 → 走原审批流程;否则默认自动通过
if not allowed_approvers: need_approval = OutboundApprovalService._items_require_approval(items)
raise ValueError("必须指定至少一位审批人") if need_approval and not approver_id:
raise ValueError("该申请包含需审批的物料,请选择审批人后再提交")
# ★ 指定审批人模式:approver_id 覆盖 allowed_approvers # ★ 指定审批人模式:approver_id 覆盖 allowed_approvers
if approver_id: if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}] allowed_approvers = [{"type": "user", "value": int(approver_id)}]
elif not need_approval:
allowed_approvers = [] # 免审批单不绑定审批人
request_no = OutboundApprovalService.generate_request_no() request_no = OutboundApprovalService.generate_request_no()
if need_approval:
approval = OutboundApproval( approval = OutboundApproval(
request_no=request_no, request_no=request_no,
applicant_id=applicant_id, applicant_id=applicant_id,
@ -762,15 +793,31 @@ class OutboundApprovalService:
outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出 outbound_type=outbound_type, # 申请时确定的出库类型,扫码出库时带出
status=0, # 待审批 status=0, # 待审批
) )
else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
from datetime import datetime, timezone, timedelta
approval = OutboundApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
outbound_type=outbound_type,
status=1,
actual_approver_id=applicant_id,
approved_at=datetime.now(timezone(timedelta(hours=8))),
)
# 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录 # 直接存储前端传来的物料信息快照,不查询/不关联具体库存记录
approval.set_items(items) approval.set_items(items)
if allowed_approvers:
approval.set_allowed_approvers(allowed_approvers) approval.set_allowed_approvers(allowed_approvers)
else:
approval.allowed_approvers = '[]'
db.session.add(approval) db.session.add(approval)
db.session.commit() db.session.commit()
# ★ 创建成功后,发送邮件通知审批人(精确通知 approver_id 对应的邮箱) # 仅需审批单通知审批人;免审批单静默进入“待库管执行”
if need_approval:
OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id) OutboundApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
return approval return approval

View File

@ -14,6 +14,7 @@ STOCK_FIELD_RBAC_MAPPING = {
"category": "material_list:category", "type": "material_list:type", "category": "material_list:category", "type": "material_list:type",
"spec": "material_list:spec", "unit": "material_list:unit", "spec": "material_list:spec", "unit": "material_list:unit",
"companyName": "material_list:companyName", "isInspectionRequired": "material_list:isInspectionRequired", "companyName": "material_list:companyName", "isInspectionRequired": "material_list:isInspectionRequired",
"isApprovalRequired": "material_list:operation",
"generalImage": "material_list:files", "generalManual": "material_list:files", "generalImage": "material_list:files", "generalManual": "material_list:files",
"productImageRemark": "material_list:productImageRemark", "productImageRemark": "material_list:productImageRemark",
"manualLinkRemark": "material_list:manualLinkRemark", "manualLinkRemark": "material_list:manualLinkRemark",