Files
KCGL/inventory-backend/app/services/scrap_approval_service.py
yueli 8755837fe8 fix(timezone): 审批单据时间统一为 naive 北京时间,消除 8 小时偏差
成因:approved_at / executed_at 列是 timestamp without time zone,而赋值用了
带时区的 datetime.now(timezone(timedelta(hours=8)))。psycopg2 对 naive 列不会
剥掉 tzinfo,而是转成 naive UTC 再写入,结果比同为北京时间的 created_at 早 8 小时。
(代码里并无 datetime.utcnow(),真实成因是 aware 值被驱动的隐式 UTC 转换。)

改动:
  · outbound_service / borrow_service 的审批分支
    datetime.now(beijing_tz).replace(tzinfo=None) → beijing_time()
    (免审批分支已在上一轮改为 beijing_time,此处补齐审批分支);
  · purchase_service 审批/完结分支同源缺陷一并修复;
  · scrap_approval / scrap_approval_service 的 _beijing()、_beijing_now() 由
    tz-aware 改为 naive —— 配合上述迁移把列类型改为 naive,
    若仍返回 aware 值,timestamptz→timestamp 后会反向早 8 小时。

实测四表(scrap/outbound/borrow/purchase)created_at 与 approved_at 差值
均在同一秒内(-1ms ~ -12ms)。
2026-09-10 10:14:31 +08:00

245 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()
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,
})
# ★ 是否需审批:命中需审批物料 或 库管代建(force_approval)
from app.services.approval_control import resolve_approval_control
need_approval, flagged_materials = resolve_approval_control(normalized)
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 = []
req = ScrapApproval(
request_no=ScrapApprovalService.generate_request_no(),
applicant_id=applicant_id,
remark=remark,
)
req.set_items(normalized)
req.set_allowed_approvers(allowed_approvers)
if need_approval:
req.status = 0 # 待审批
else:
# 默认不审批:直接进入“待执行”
req.status = 1
req.actual_approver_id = applicant_id
req.approved_at = _beijing()
db.session.add(req)
db.session.commit()
logger.info(f"[ScrapApproval] 提交成功 {req.request_no} need_approval={need_approval}")
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 execute(request_id, operator_name='System'):
models = _stock_models()
req = db.session.get(ScrapApproval, request_id)
if not req:
raise ValueError("报废申请不存在")
if req.status != 1:
raise ValueError("仅“已通过(待执行)”的报废单可执行")
items = req.get_items()
if not items:
raise ValueError("报废明细为空,无法执行")
for it in items:
model = models.get(it.get('source_table'))
if not model:
raise ValueError(f"不支持的报废来源:{it.get('source_table')}")
row = model.query.with_for_update().get(int(it['stock_id']))
if not row:
raise ValueError(f"库存记录已不存在({it.get('sku')}")
qty = float(it.get('scrap_qty') or 0)
avail = float(getattr(row, 'available_quantity', 0) or 0)
if qty <= 0 or qty > avail:
raise ValueError(f"库存 {it.get('sku') or ''} 可用不足(剩 {avail}),无法报废 {qty}")
# ★ 真正扣减可用库存
row.available_quantity = avail - qty
db.session.flush()
# 写报废流水(台账)
db.session.add(TransScrap(
sku=it.get('sku') or getattr(row, 'sku', '') or '',
source_table=it.get('source_table'),
stock_id=int(it['stock_id']),
quantity=qty,
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,
))
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}")
return req