Files
KCGL/inventory-backend/app/services/borrow_service.py

502 lines
20 KiB
Python
Raw Normal View History

from datetime import datetime
import pytz
from app.extensions import db, beijing_time
from app.models.borrow import BorrowApproval
from app.models.system import SysUser
class BorrowApprovalService:
"""借库审批服务"""
@staticmethod
def generate_request_no():
"""
生成审批单号: APR-BOR-yyyyMMdd-HHmm-当日流水(4位)
"""
beijing_tz = pytz.timezone('Asia/Shanghai')
now = datetime.now(beijing_tz)
date_str = now.strftime('%Y%m%d')
time_str = now.strftime('%H%M')
prefix = f"APR-BOR-{date_str}-"
latest = db.session.query(BorrowApproval.request_no).filter(
BorrowApproval.request_no.like(f"{prefix}%")
).order_by(BorrowApproval.id.desc()).first()
if latest:
last_seq = int(latest[0].split('-')[-1])
sequence = last_seq + 1
else:
sequence = 1
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
def submit_approval(applicant_id, items, allowed_approvers, remark=None, approver_id=None,
borrower_name=None, force_approval=False):
"""
提交借库申请(仅存储意向,不扣库存)
Args:
applicant_id: 申请人ID
items: 借库物品明细列表,每个物品应包含:
- name: 物料名称 (必填)
- spec_model: 规格型号 (必填)
- quantity: 计划借库数量 (必填)
- warehouse_location: 库位 (可选)
- remark: 物品备注 (可选)
allowed_approvers: 允许审批的人员/角色列表
approver_id: 指定审批人ID(可选)
remark: 申请说明
borrower_name: 借库人姓名(必填)
Returns:
BorrowApproval 实例
Raises:
ValueError: 当 items 为空或缺少必填字段时抛出
"""
if not items:
raise ValueError("借库物品列表不能为空")
# ★ 借库申请必填:申请原因;预计归还日期(除非勾选长期借用/不限归还)
if not str(remark or '').strip():
raise ValueError("借库申请原因不能为空")
_has_indefinite = any(bool(item.get('is_indefinite')) for item in items)
_has_date = any(str(item.get('expected_return_time') or '').strip() for item in items)
if not _has_indefinite and not _has_date:
raise ValueError("请填写预计归还日期,或勾选长期借用(不限归还)")
required_fields = ['name', 'spec_model', 'quantity']
for idx, item in enumerate(items):
missing_fields = [f for f in required_fields if f not in item or str(item.get(f) or '').strip() == '']
if missing_fields:
raise ValueError(
f"第 {idx + 1} 条物品缺少必填字段: {', '.join(missing_fields)}。"
f"必须包含: name, spec_model, quantity"
)
try:
qty = float(item.get('quantity', 0))
if qty <= 0:
raise ValueError(f"第 {idx + 1} 条物品的借库数量必须大于0")
except (TypeError, ValueError) as e:
raise ValueError(f"第 {idx + 1} 条物品的 quantity 格式无效: {str(e)}")
# ★ 需审批判定:库管代建(force_approval)或明细含需审批物料 → 走审批;否则默认自动通过
from app.services.approval_control import resolve_approval_control
need_approval, flagged_materials = resolve_approval_control(items)
if force_approval:
need_approval = True
if need_approval and not approver_id:
if force_approval:
raise ValueError("库管代建借库申请必须选择审批人后再提交")
_names = ";".join(f"{m['name']}({m['spec_model'] or '-'})" for m in flagged_materials)
raise ValueError(f"以下物料需审批出库/借库:{_names}。请选择审批人后再提交")
if approver_id:
allowed_approvers = [{"type": "user", "value": int(approver_id)}]
elif not need_approval:
allowed_approvers = [] # 免审批单不绑定审批人
request_no = BorrowApprovalService.generate_request_no()
if need_approval:
approval = BorrowApproval(
request_no=request_no,
applicant_id=applicant_id,
remark=remark,
borrower_name=borrower_name,
status=0, # 待审批
)
else:
# 默认不审批:创建即已通过(status=1),直接进入“待库管执行”
# ★ 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,
remark=remark,
borrower_name=borrower_name,
status=1,
actual_approver_id=applicant_id,
approved_at=beijing_time(),
)
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ==================================================================
# ★ Phase 1:库存预占
# 借出同样会占用库存(借出期间该批货不可再被他人领用),
# 因此与出库一致:提交即锁定具体批次并扣减 available_quantity。
# 借出归还时再归还,见 process_return()。
# ==================================================================
from app.services.inventory_reservation import reserve_for_items
from app.utils.decorators import get_current_company_filter
reserved_items, _shortages = reserve_for_items(
items, company_limit=get_current_company_filter(), strict=True,
)
approval.set_items(reserved_items)
if allowed_approvers:
approval.set_allowed_approvers(allowed_approvers)
else:
approval.allowed_approvers = '[]'
db.session.add(approval)
db.session.commit()
# 仅需审批单通知审批人;免审批单静默进入“待库管执行”
if need_approval:
BorrowApprovalService._notify_new_request(approval, applicant_id, approver_id=approver_id)
return approval
@staticmethod
def _get_emails_by_identifiers(applicant_id=None, role_codes=None):
"""
根据用户ID或角色列表查询邮箱地址
Args:
applicant_id: 用户ID (按 SysUser.id 查找)
role_codes: 角色代码列表,如 ['SUPERVISOR', 'SUPER_ADMIN']
Returns:
去重后的邮箱地址列表
"""
emails = []
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
emails.append(user.email)
if role_codes:
for code in role_codes:
users = SysUser.query.filter_by(role=code).all()
for u in users:
if u.email:
emails.append(u.email)
return list(set(emails))
@staticmethod
def _notify_new_request(approval, applicant_id, approver_id=None):
"""发送新借库申请通知邮件给审批人和申请人(静默处理,不阻断主流程)"""
try:
from flask import current_app
from app.utils.email_service import send_borrow_new_request_notify
from app.models.system import SysUser
applicant_name = ''
applicant_emails = []
# 1. 收集申请人信息
if applicant_id:
user = SysUser.query.get(int(applicant_id))
if user and user.email:
applicant_emails.append(user.email)
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or str(applicant_id))
# 2. 收集审批人信息
approver_emails = []
if approver_id:
user = SysUser.query.get(int(approver_id))
if user and user.email:
approver_emails.append(user.email)
else:
# 兜底:按角色查询
approvers = approval.get_allowed_approvers()
role_codes = []
for a in approvers:
if a.get('type') == 'role':
role_codes.append(a.get('value', ''))
approver_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=role_codes)
# 去重
all_emails = list(set(applicant_emails + approver_emails))
if not all_emails:
current_app.logger.info(f"[Email] 借库审批单 {approval.request_no} 无收件人邮箱,跳过通知")
return
# 3. 获取物料明细
items = approval.get_items()
# 4. 分别发送邮件
if applicant_emails:
try:
send_borrow_new_request_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=f"您的借库申请已提交,等待审批。{approval.remark or ''}",
items=items,
is_applicant_notify=True
)
except Exception as e:
current_app.logger.error(f"[Email] 通知申请人失败: {e}")
if approver_emails:
try:
send_borrow_new_request_notify(
to_emails=approver_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
remark=approval.remark or '',
items=items,
is_applicant_notify=False
)
except Exception as e:
current_app.logger.error(f"[Email] 通知审批人失败: {e}")
except Exception as e:
try:
from flask import current_app
current_app.logger.error(f"[Email] 发送新借库申请通知邮件失败: {e}")
except RuntimeError:
import traceback
traceback.print_exc()
@staticmethod
def _notify_approval_result(approval, approver_id, action):
"""发送借库审批结果通知邮件(静默处理,不阻断主流程)"""
import logging
logger = logging.getLogger(__name__)
try:
from app.utils.email_service import send_borrow_approval_result_notify, send_borrow_dispatch_notify
from app.models.system import SysUser as SU
# 1. 提取申请人信息
applicant_name = ''
applicant_emails = []
if approval.applicant_id:
user = SU.query.get(approval.applicant_id)
if user:
applicant_name = str(user.username).split('/')[0] if '/' in (user.username or '') else (user.username or '')
if user.email:
applicant_emails.append(user.email)
# 2. 提取物料明细
items = approval.get_items() if approval else []
# 3. 分支逻辑
if action == 'approve':
# 3.1 通知申请人(审批已通过,明确告知结果)
if applicant_emails:
try:
send_borrow_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=True,
reject_reason='',
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人(通过)失败: {e}")
else:
logger.warning("[Email] 申请人无邮箱,无法发送审批通过通知")
# 3.2 通知库管(请备货)
warehouse_role_codes = ['WAREHOUSE_MGR', 'OUTBOUND']
warehouse_emails = BorrowApprovalService._get_emails_by_identifiers(role_codes=warehouse_role_codes)
if warehouse_emails:
try:
send_borrow_dispatch_notify(
to_emails=warehouse_emails,
request_no=approval.request_no,
applicant_name=applicant_name,
items=items
)
except Exception as e:
logger.error(f"[Email] 通知库管失败: {e}")
else:
logger.warning("[Email] 无库管角色邮箱,无法发送备货通知")
elif action == 'reject':
# 3.3 通知申请人(已驳回)
if applicant_emails:
try:
send_borrow_approval_result_notify(
to_emails=applicant_emails,
request_no=approval.request_no,
is_passed=False,
reject_reason=approval.reject_reason or '未说明原因',
applicant_name=applicant_name
)
except Exception as e:
logger.error(f"[Email] 通知申请人驳回失败: {e}")
else:
logger.warning("[Email] 申请人无邮箱,无法发送驳回通知")
except Exception as e:
import traceback
traceback.print_exc()
logger.error(f"[Email] 外层发送异常: {e}")
@staticmethod
def can_approve(approval, user_id, user_role):
"""
检查用户是否有权限审批
"""
approvers = approval.get_allowed_approvers()
if user_role and user_role.upper() == 'SUPER_ADMIN':
return True
# 拥有 op_borrow_approval:operation 权限的用户可以审批任意借库单
# (前端按钮以此权限控制显示,后端也必须对齐)
from app.models.system import SysRolePermission
has_op = SysRolePermission.query.filter(
SysRolePermission.role_code == user_role,
SysRolePermission.target_code.in_(['op_borrow_approval:operation', 'op_borrow_approval:*'])
).first() is not None
if has_op:
return True
for approver in approvers:
approver_type = approver.get('type', '')
approver_value = approver.get('value', '')
if approver_type == 'user' and str(approver_value) == str(user_id):
return True
if approver_type == 'role' and approver_value == user_role:
return True
return False
@staticmethod
def approve(request_id, user_id, user_role, action='approve', reject_reason=None):
"""
执行审批操作
Returns:
(success: bool, message: str, approval: BorrowApproval or None)
"""
# ★ 统一取 naive 北京时间,与 created_at(beijing_time) 同口径
current_time = beijing_time()
approval = BorrowApproval.query.get(request_id)
if not approval:
return False, "审批单不存在", None
if approval.status != 0:
return False, f"审批单状态已更新,无法重复审批 (当前状态: {approval.status})", None
if not BorrowApprovalService.can_approve(approval, user_id, user_role):
return False, "您没有审批此单的权限", None
try:
if action == 'approve':
approval.status = 1 # 已通过
approval.actual_approver_id = user_id
approval.approved_at = current_time
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# 通过后预占继续保留,直到实际借出执行时才释放并扣减
elif action == 'reject':
feat(inventory): 库存预占生命周期,消除出库/借库超卖 问题:库存超卖 -------------- 改造前出库/借库申请只记录「要什么、要多少」,不绑定具体库存行, 真正的 available_quantity 扣减发生在执行阶段。于是多张申请可以同时 claim 同一批货,等到工人拿扫码枪时才发现货已被别人领走。 生命周期(三阶段) ------------------ 提交申请(预占) reserve_for_items() 用分配器把需求落到具体库存行,立即扣减 available_quantity, 并把 (stock_id, source_table, allocated_qty, reserved) 写回 items_json。 驳回(释放) release_reserved() 遍历 items_json 把预占量还回池子,避免货被永不执行的单永久占住。 扫码执行(覆盖) verify_scanned() + restore_then_deduct() 校验实扫身份/数量未超批准范围 → 释放全部预占 → 对实扫批次 同时扣减 available_quantity 与 stock_quantity。 身份键:base_id 主键 + SKU 兜底(重要设计决策) ----------------------------------------------- 本系统中 SKU 是**批次级**编号:同一 base_id 下每个入库批次各有不同的 SKU(实测 stock_buy 有 183 个物料是多批次的,如 base_id=2405 下有 0000001685 与 0000001974 两个 SKU)。 若以 SKU 作为身份主键,「申请时锁定 A 批、工人现场改扫 B 批」会被判为 身份不符而拒绝 —— 恰好否定了「物理覆盖」这个核心能力。 故改用 base_id(物料级、跨批次稳定,spec_model 由其唯一确定), 历史数据无 base_id 时降级为 (name, spec_model)。 可用量校验按物料汇总,而非按单批次 ---------------------------------- 开发中修正的一处缺陷:若逐行要求「该批次可用量 >= 该批次扫码量」, 工人改扫小批次时会被误拒。例如本单预占 A 批 5 件,改扫 B 批 2 件 + C 批 3 件,B 批自身只有 2 件可用,逐行校验即失败。实际这 5 件都是本单 锁定的货,理应允许。现按物料汇总校验可用量,按行校验实物库存。 改动文件 -------- · 新增 app/services/inventory_reservation.py(通用服务层) · outbound_service.create_request —— Phase 1 预占 · outbound_service.approve(reject) —— Phase 2 释放 · outbound_service.create_outbound_batch —— Phase 3 覆盖(移除原逐行扣减) · borrow_service.submit_approval —— Phase 1 · borrow_service.approve(reject) —— Phase 2 · trans_service.execute_dispatch —— Phase 3,并用统一身份键替换 原有的 (name, spec_model) 字符串匹配 实测(真实 HTTP 全链路) ------------------------ 初始 available=10 ① 提交申请(需5) → 200,available 10→5 预占生效 ② 审批通过 → available 仍为 5 预占保留 ③ 扫码执行(改扫另一批次 4 件) → 200 原批次恢复满额、实扫批次扣减(0,0),available=6, stock=6 单场景验证:预占 A 批改扫 B 批放行;驳回后可用量完全恢复; 扫其他物料被拒;批准 6 扫 8 被拒。
2026-09-10 14:59:10 +08:00
# ★ Phase 2:驳回即释放预占,避免货被永不执行的单占住
from app.services.inventory_reservation import release_reserved
release_reserved(approval.get_items())
approval.status = 2 # 已驳回
approval.reject_reason = reject_reason
else:
return False, "无效的审批操作", None
db.session.commit()
# ★ 审批后,发送邮件通知(静默处理,不阻断主流程)
BorrowApprovalService._notify_approval_result(approval, user_id, action)
return True, "审批成功", approval
except Exception as e:
db.session.rollback()
return False, f"审批失败: {str(e)}", None
@staticmethod
def get_request_list(page=1, per_page=10, applicant_id=None, status=None):
"""
获取审批单列表
"""
from app.models.system import SysUser
from app.utils.decorators import get_current_company_filter
from sqlalchemy import desc
query = BorrowApproval.query
if applicant_id:
query = query.filter(BorrowApproval.applicant_id == applicant_id)
if status is not None:
query = query.filter(BorrowApproval.status == status)
# 【行级数据隔离】通过申请人关联用户表过滤公司
company_limit = get_current_company_filter()
if company_limit is not None:
query = query.join(SysUser, BorrowApproval.applicant_id == SysUser.id) \
.filter(SysUser.department == company_limit)
query = query.order_by(desc(BorrowApproval.created_at))
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
return {
'items': [item.to_dict() for item in pagination.items],
'total': pagination.total,
'pages': pagination.pages,
'current_page': page
}
@staticmethod
def get_request_by_id(request_id):
"""根据ID获取审批单"""
return BorrowApproval.query.get(request_id)
@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
try:
approval.status = 4 # 已完结
db.session.commit()
return True, "审批单已完结", approval
except Exception as e:
db.session.rollback()
return False, f"操作失败: {str(e)}", None