PayrollManagementSystem/backend/app/services/business_service.py
liuwenxin ccfd20651b feat(calc): 二次分摊拆行与 #12 单笔侧开关——按方案比例拆给参与人、余额归负责人
- _personal_rate 的 TPL_MANAGER_SHARE 分支读 manager.personal.included:1=按业务员递减公式计提,0=单笔落 0(现状)
- _load_allocations 装载生效方案并防御拦截(合计超 100%、参与人含负责人)
- _persist_ledger 拆行落库:参与人=份额×比例(q2)、负责人=q2 全额−已分累计(行合计守恒防分漂);快照带 alloc_plan/alloc_role/alloc_ratio
- _prepare_mode 重算改 list_active_by_receipt 作废全部拆分行;uk 三列版支持同回款按人多行
- business_service 保存侧同口径校验(超 100% 400、负责人自分 400、hint 文案定稿)
- 集成用例 +5(拆行守恒/叠加进度登记/重算全作废/双拦截)+保存侧 3 条

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 13:15:59 +08:00

930 lines
38 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.

"""业务管理业务编排(需求 2.2.1/2.2.4)。
事务边界在本层:repository 只 flush,commit/rollback 只写在这里。
- 业务编号自动生成(no_generator.next_no):MAX+FOR UPDATE 锁当日序号区间,
残余竞态以唯一键(1062)冲突重试一次兜底
- 五项汇总冗余(total_sale/cost/ship/received/freight)由 ``_recalc_totals``
单点重算 —— 明细/发货/回款任何写路径收口到此函数,禁止各处手写
- 状态机:1执行中 → 2已完成 / 3已终止;终态不可再变更(一期口径,开放问题 #8);
终止必填原因;每次流转写 business_status_logs(创建写首条 old=new=1)
- 归属变更(负责/协助)写 ownership_change_logs(owner_type=2),同事务一次
commit;只影响变更后核算,不回溯历史(对照表 #19)
- 毛利润视图为只读派生值(复用 formula.profit_rate/converted_profit),
阈值按 asOf 取 M1 参数快照 —— 落库即漂移,M3 台账才是固化点
"""
import logging
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import Row, func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.constants import (
ACTION_CREATE,
ACTION_DELETE,
ACTION_UPDATE,
MODULE_BUSINESS,
NO_PREFIX_BUSINESS,
)
from app.core.db_errors import is_duplicate_entry
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageQuery, PageResult
from app.models import (
ApprovalOrder,
Business,
BusinessItem,
BusinessStatusLog,
Customer,
OwnershipChangeLog,
Personnel,
Receipt,
SecondaryAllocation,
Shipment,
User,
)
from app.models.approval import APPROVAL_TYPE_SECONDARY_ALLOCATION
from app.models.business import (
BUSINESS_STATUS_ACTIVE,
BUSINESS_STATUS_DONE,
)
from app.models.customer import OWNER_TYPE_BUSINESS
from app.repositories import business_repository, customer_repository, personnel_repository
from app.schemas.business import (
BusinessCreateIn,
BusinessDetailOut,
BusinessItemIn,
BusinessItemOut,
BusinessOut,
BusinessOwnershipIn,
BusinessQuery,
BusinessStatusIn,
BusinessStatusLogOut,
BusinessUpdateIn,
GrossProfitCategoryOut,
GrossProfitOut,
GrossProfitRowOut,
OwnershipChangeLogOut,
SecondaryAllocationIn,
SecondaryAllocationOut,
)
from app.services import approval_service, audit_service, param_service, personnel_service
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import converted_profit, profit_rate
from app.services.no_generator import next_no
logger = logging.getLogger(__name__)
# 编号唯一键冲突后的重试次数:MAX+FOR UPDATE 已覆盖绝大多数竞态,
# 重试是为极端场景(锁等待超时后重入)兜底 —— 无限重试反而掩盖死锁类缺陷
_NO_RETRY = 1
def _to_out(row: tuple) -> BusinessOut:
"""(business, company_name, owner_name, assistant_name) → 响应模型。
金额冗余列出参 str;汇总列可能为 NULL(server_default 只对 INSERT 生效,
存量行可能为 NULL),统一转 "0" 保持契约稳定。
"""
business, company_name, owner_name, assistant_name = row
return BusinessOut(
id=business.id,
business_no=business.business_no,
customer_id=business.customer_id,
customer_name=company_name,
business_date=business.business_date,
status=business.status,
owner_personnel_id=business.owner_personnel_id,
owner_personnel_name=owner_name,
assistant_personnel_id=business.assistant_personnel_id,
assistant_personnel_name=assistant_name,
is_special=business.is_special,
special_note=business.special_note,
total_sale_amount=_str_or_zero(business.total_sale_amount),
total_cost_amount=_str_or_zero(business.total_cost_amount),
total_ship_amount=_str_or_zero(business.total_ship_amount),
total_received_amount=_str_or_zero(business.total_received_amount),
total_freight_amount=_str_or_zero(business.total_freight_amount),
remark=business.remark,
)
def _str_or_zero(value: Decimal | None) -> str:
return str(value) if value is not None else "0"
def _item_to_out(item: BusinessItem) -> BusinessItemOut:
return BusinessItemOut(
id=item.id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model,
unit=item.unit,
product_category=item.product_category,
quantity=str(item.quantity),
sale_price=str(item.sale_price) if item.sale_price is not None else None,
sale_amount=str(item.sale_amount),
cost_amount=str(item.cost_amount) if item.cost_amount is not None else None,
guided_price=str(item.guided_price) if item.guided_price is not None else None,
remark=item.remark,
)
def _build_item_rows(business_id: int, items: list[BusinessItemIn]) -> list[BusinessItem]:
"""入参明细行转 ORM 行:sale_amount 服务层按 数量×单价 计算固化。
不信任前端传值(schema 亦无 sale_amount 入参)——单价可空时销售额记 0,
由用户在编辑中补价后重算(毛利润视图中销售额 0 的行全部为 0,防除零)。
乘积显式 quantize 两位固化:数量(2位)×单价(4位) 可产 6 位小数,直写
DECIMAL(14,2) 会被 MySQL 静默舍入且回显漂移(创建响应读内存值、复查
读库值前后不一致);HALF_UP 与 MySQL 的 DECIMAL 舍入方向一致。
"""
rows: list[BusinessItem] = []
for item in items:
quantity = Decimal(item.quantity)
sale_price = Decimal(item.sale_price) if item.sale_price is not None else None
sale_amount = (
(quantity * sale_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if sale_price is not None
else Decimal("0.00")
)
rows.append(
BusinessItem(
business_id=business_id,
product_id=item.product_id,
product_name=item.product_name,
spec_model=item.spec_model or "",
unit=item.unit,
product_category=item.product_category,
quantity=quantity,
sale_price=sale_price,
sale_amount=sale_amount,
cost_amount=Decimal(item.cost_amount) if item.cost_amount is not None else None,
guided_price=Decimal(item.guided_price) if item.guided_price is not None else None,
remark=item.remark,
)
)
return rows
def _recalc_totals(session: Session, business: Business) -> None:
"""重算业务的五项汇总冗余(明细/发货/回款写路径统一收口)。
五条独立聚合查询(各表单列 SUM,走 business_id 索引)——业务单据量级
为单业务数行,不值得为省查询做单大 JOIN。
"""
sale = session.execute(
_sum(BusinessItem.sale_amount, BusinessItem.business_id, business.id)
).scalar_one()
cost = session.execute(
_sum(BusinessItem.cost_amount, BusinessItem.business_id, business.id)
).scalar_one()
ship = session.execute(_sum_ship_items(business.id)).scalar_one()
freight = session.execute(
_sum(Shipment.freight_amount, Shipment.business_id, business.id)
).scalar_one()
received = session.execute(_sum(Receipt.amount, Receipt.business_id, business.id)).scalar_one()
business.total_sale_amount = sale or Decimal("0")
business.total_cost_amount = cost or Decimal("0")
business.total_ship_amount = ship or Decimal("0")
business.total_freight_amount = freight or Decimal("0")
business.total_received_amount = received or Decimal("0")
def _sum(column, match_column, value) -> object:
return select(func.sum(column)).where(match_column == value)
def _sum_ship_items(business_id: int):
"""发货明细行金额合计(经发货单关联到业务)。"""
from app.models import ShipmentItem
return (
select(func.sum(ShipmentItem.amount))
.select_from(ShipmentItem)
.join(Shipment, Shipment.id == ShipmentItem.shipment_id)
.where(Shipment.business_id == business_id)
)
def _generate_no(session: Session) -> str:
"""生成业务编号(MAX+FOR UPDATE;1062 兜底重试一次,见模块 docstring)。"""
return next_no(session, model=Business, column_name="business_no", prefix=NO_PREFIX_BUSINESS)
def _ensure_personnel_active(session: Session, personnel_id: int, label: str) -> Personnel:
"""归属人员须存在且在职(共享校验收口于 personnel_service.ensure_active)。"""
return personnel_service.ensure_active(session, personnel_id, label=label)
def _ensure_customer(session: Session, customer_id: int) -> Customer:
customer = customer_repository.get_by_id(session, customer_id)
if customer is None:
raise NotFoundError("客户不存在")
return customer
def _is_duplicate_entry(exc: IntegrityError) -> bool:
return is_duplicate_entry(exc)
def list_businesses(session: Session, query: BusinessQuery) -> PageResult[BusinessOut]:
"""分页查询业务(附客户名与归属人名)。"""
rows = business_repository.list_businesses(
session,
offset=query.offset,
limit=query.page_size,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
total = business_repository.count_businesses(
session,
keyword=query.keyword,
status=query.status,
customer_id=query.customer_id,
owner_personnel_id=query.owner_personnel_id,
date_from=query.date_from,
date_to=query.date_to,
)
return PageResult[BusinessOut](
records=[_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def get_detail(session: Session, business_id: int) -> BusinessDetailOut:
"""业务详情(附明细行与二次分配方案,供编辑抽屉回显)。
:raises NotFoundError: 业务不存在
"""
row = business_repository.get_with_names(session, business_id)
if row is None:
raise NotFoundError("业务不存在")
out = BusinessDetailOut(**_to_out(row).model_dump())
out.items = [
_item_to_out(item) for item in business_repository.list_items(session, business_id)
]
allocations = business_repository.list_allocations(session, business_id)
out.secondary_allocations = [
SecondaryAllocationOut(
personnel_id=a.personnel_id, personnel_name=a.personnel_name, ratio=str(a.ratio)
)
for a in allocations
]
out.allocation_hint = _allocation_hint(allocations)
return out
def _build_allocation_rows(
session: Session,
business_id: int,
items: list[SecondaryAllocationIn],
*,
old_ids: set[int],
) -> list[SecondaryAllocation]:
"""构造二次分配明细行:人员校验 + 姓名冗余固化 + 比例转 Decimal。
:param old_ids: 编辑前已参与的人员 ID 集合(create 传空集)。这些人的
行是**历史事实**——personnel_name 冗余固化的设计本就允许离职后回显,
编辑业务(哪怕只改备注)不应被历史参与人的离职状态卡死;仅对
**新加入**的参与人做在职校验。
:raises BadRequestError: 新增参与人员不存在或已离职 / 比例合计超 100%
/ 参与人含合同负责人(口径见澄清 #1/#2 答复 2026-10-19:分配绩效、
余额归合同负责营销人员——超 100% 会把负责人份额算成负数,负责人
自分份额则双重计入)
"""
business = business_repository.get_by_id(session, business_id)
if business is None:
raise NotFoundError("业务不存在")
total = sum((Decimal(item.ratio) for item in items), Decimal("0"))
if total > Decimal("100"):
raise BadRequestError(
f"二次分配比例合计 {total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)}%"
" 超过 100%(余额归合同负责营销人员,比例不可超满额)"
)
owner_id = business.owner_personnel_id
if owner_id is not None and any(item.personnel_id == owner_id for item in items):
raise BadRequestError("分摊参与人不可包含合同负责营销人员(负责人得余额份额)")
rows: list[SecondaryAllocation] = []
for item in items:
if item.personnel_id in old_ids:
person = personnel_repository.get_by_id(session, item.personnel_id)
if person is None:
raise BadRequestError("分摊参与人员不存在")
else:
person = _ensure_personnel_active(session, item.personnel_id, "分摊参与")
rows.append(
SecondaryAllocation(
business_id=business_id,
personnel_id=item.personnel_id,
personnel_name=person.personnel_name,
ratio=Decimal(item.ratio),
)
)
return rows
def _allocation_hint(allocations: list[SecondaryAllocation]) -> str | None:
"""方案合计/余额提示(不要求合计满额是需求原文;余额归属 = 澄清 #2 答复)。
无方案返回 None;合计恰满额只报合计,避免「余额 0」噪音。
"""
if not allocations:
return None
total = sum((a.ratio for a in allocations), Decimal("0"))
total_text = total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if total == Decimal("100"):
return f"二次分配合计 {total_text}%"
rest = (Decimal("100") - total).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"二次分配合计 {total_text}%,余额 {rest}%(归合同负责营销人员)"
def create_business(
session: Session,
payload: BusinessCreateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""创建业务 + 明细行 + 首条状态留痕,一个事务。
创建时指定的归属不写归属留痕(留痕记「变更」,创建即初始事实)。
:raises NotFoundError: 客户不存在
:raises BadRequestError: 归属人员不存在或已离职 / 明细校验不通过
"""
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_CREATE,
summary=f"创建业务(客户ID={payload.customer_id},明细 {len(payload.items)} 行)",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
_ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
# 编号唯一键冲突的重试整体收在审计上下文内:rollback 后同一事务重来,
# 成功/最终失败各只记一条审计(吞异常 return 会被记成假成功,重入
# 整个 create_* 则违反「不支持嵌套」约定);上限 _NO_RETRY 防无限重试
# 掩盖死锁类缺陷。审计摘要预取的是首次编号——重试成功时入库编号见
# 应用日志(AuditRecord frozen,不回填)。
for attempt in range(_NO_RETRY + 1):
try:
business = Business(
business_no=_generate_no(session),
customer_id=payload.customer_id,
business_date=payload.business_date,
status=BUSINESS_STATUS_ACTIVE,
owner_personnel_id=payload.owner_personnel_id,
assistant_personnel_id=payload.assistant_personnel_id,
is_special=payload.is_special,
special_note=payload.special_note,
remark=payload.remark,
)
business_repository.create(session, business)
business_repository.replace_items(
session, business.id, _build_item_rows(business.id, payload.items)
)
_recalc_totals(session, business)
business_repository.add_status_log(
session,
BusinessStatusLog(
business_id=business.id,
old_status=BUSINESS_STATUS_ACTIVE,
new_status=BUSINESS_STATUS_ACTIVE,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 二次分配方案与业务同事务(需求:只出现在合同刚建立时);
# 放在重试循环内——rollback 后方案行随业务一并重来
business_repository.replace_allocations(
session,
business.id,
_build_allocation_rows(
session, business.id, payload.secondary_allocations, old_ids=set()
),
)
# 方案非空才提单:开关关闭=自动通过留痕,开启=待财务复核。
# AP 编号撞键时 IntegrityError 穿透 → 外层编号重试循环把
# 业务/方案/审批单整体重来
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business.id,
refer_no=business.business_no,
submitter=operator,
)
customer = customer_repository.get_by_id(session, business.customer_id)
out = _to_out((business, customer.company_name if customer else None, None, None))
new_id, new_no = business.id, business.business_no # 固化:commit 后不碰 ORM
session.commit()
break
except IntegrityError as exc:
session.rollback()
if _is_duplicate_entry(exc) and attempt < _NO_RETRY:
continue
raise
logger.info("创建业务 | business_id=%s | no=%s", new_id, new_no)
return out
def update_business(
session: Session,
business_id: int,
payload: BusinessUpdateIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""编辑业务:全量替换明细 + 重算汇总;归属不在此端点(防旁路)。
客户可改,但有发货或回款记录后禁改 —— 单据 FK 挂在业务上,换客户会让
发货/回款语义断裂(明细同理随业务走)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 业务已完成或已终止 / 归属人员不合法 / 有发货或回款时换客户
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
business = row[0] if row else None
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=f"修改业务 {business.business_no if business else f'#{business_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, owner_name, assistant_name = row
# 终态门槛与发货/回款写准入同口径:明细与汇总是 M3 核算基线,
# 已完成/已终止的业务禁改(发货/回款服务以同一理由禁写终态业务)
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("业务已完成或已终止,禁止编辑业务")
if payload.customer_id != business.customer_id and business_repository.has_docs(
session, business_id
):
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
customer = _ensure_customer(session, payload.customer_id)
if payload.owner_personnel_id is not None:
_ensure_personnel_active(session, payload.owner_personnel_id, "负责")
if payload.assistant_personnel_id is not None:
_ensure_personnel_active(session, payload.assistant_personnel_id, "协助")
business.customer_id = payload.customer_id
business.business_date = payload.business_date
business.is_special = payload.is_special
business.special_note = payload.special_note
business.remark = payload.remark
# 归属刻意不赋值:留痕只由 /ownership 端点产生,编辑路径改归属构成
# 「改归属不留痕」的旁路(与 customers 编辑不触归属同一口径)
business_repository.replace_items(
session, business_id, _build_item_rows(business_id, payload.items)
)
# 方案全量替换(空数组=清空);终态门槛已挡「修改影响已结算月份」的
# 前置场景(月结在 ④,快照固化后按同一哲学隔离)
old_ids = {
a.personnel_id for a in business_repository.list_allocations(session, business_id)
}
business_repository.replace_allocations(
session,
business_id,
_build_allocation_rows(
session, business_id, payload.secondary_allocations, old_ids=old_ids
),
)
# 提单/撤单与方案同步生命周期:非空=提单(内含撤旧建新);空=仅撤回
# 在途单 —— 否则旧在途单挂着已不存在的方案继续走审批
if payload.secondary_allocations:
approval_service.submit_order(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
refer_no=business.business_no,
submitter=operator,
)
else:
approval_service.withdraw_pending_orders(
session,
order_type=APPROVAL_TYPE_SECONDARY_ALLOCATION,
ref_id=business_id,
operator=operator,
)
_recalc_totals(session, business)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新值;名称字段
# 从首查 row 取(换客户时 _ensure_customer 已给出新公司名)
out = _to_out((business, customer.company_name, owner_name, assistant_name))
session.commit()
logger.info("修改业务 | business_id=%s", business_id)
return out
def delete_business(
session: Session,
business_id: int,
*,
operator: User,
meta: RequestMeta | None = None,
) -> None:
"""删除业务(物理删除):有发货/回款 → 400 引导走「已终止」。
无单据引用的业务(建错的空单)方可删除;删除时连带清理明细、状态留痕
与业务维度的归属留痕(无外键固化表,须手动清)。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 存在发货或回款记录 / 存在在途审批单
"""
business = business_repository.get_by_id(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_DELETE,
summary=f"删除业务 {business.business_no if business else f'#{business_id}'}",
),
meta=meta,
):
if business is None:
raise NotFoundError("业务不存在")
if business_repository.has_docs(session, business_id):
raise BadRequestError("业务已有发货或回款记录,请改为已终止而非删除")
# 在途审批单(ref_id 固化无外键,业务删了单还挂着且可被继续审批):
# 禁删引导先完成/撤回审批,而不是静默留下指向已删业务的活单
if _count_pending_approvals(session, business_id):
raise BadRequestError("该业务有在途审批单,请先完成或撤回审批后再删除")
business_no = business.business_no
_hard_delete(session, business_id)
session.commit()
logger.info("删除业务 | business_id=%s | no=%s", business_id, business_no)
def _count_pending_approvals(session: Session, business_id: int) -> int:
"""该业务的在途审批单数(status 1/2;终态单随业务删除一并清理)。"""
from sqlalchemy import func, select
from app.models import ApprovalOrder
from app.models.approval import (
APPROVAL_STATUS_PENDING_FINANCE,
APPROVAL_STATUS_PENDING_LEADER,
)
return int(
session.execute(
select(func.count())
.select_from(ApprovalOrder)
.where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
ApprovalOrder.status.in_(
(APPROVAL_STATUS_PENDING_FINANCE, APPROVAL_STATUS_PENDING_LEADER)
),
)
).scalar_one()
)
def _hard_delete(session: Session, business_id: int) -> None:
"""按依赖序删除:明细 → 二次分配方案 → 审批单(steps 随 CASCADE)→
状态留痕 → 业务维度归属留痕 → 主表。"""
from sqlalchemy import delete
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
session.execute(
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
)
session.execute(
delete(ApprovalOrder).where(
ApprovalOrder.order_type == APPROVAL_TYPE_SECONDARY_ALLOCATION,
ApprovalOrder.ref_id == business_id,
)
)
session.execute(delete(BusinessStatusLog).where(BusinessStatusLog.business_id == business_id))
session.execute(
delete(OwnershipChangeLog).where(
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
OwnershipChangeLog.business_id == business_id,
)
)
session.execute(delete(Business).where(Business.id == business_id))
def change_status(
session: Session,
business_id: int,
payload: BusinessStatusIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""状态流转:1→2 / 1→3,终态不可再变更;每次流转写留痕。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 非执行中状态流转 / 目标与当前相同
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=(
f"业务状态变更 {row[0].business_no if row else f'#{business_id}'} → "
f"{'已完成' if payload.status == BUSINESS_STATUS_DONE else '已终止'}"
),
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, _, _ = row
if business.status != BUSINESS_STATUS_ACTIVE:
raise BadRequestError("已完成或已终止的业务不可再变更状态")
if business.status == payload.status:
raise BadRequestError("状态未变化")
# 变更前状态须在赋值前固化:ORM 属性赋值后 old_status 就取不到旧值
# (留痕会记成 old==new 失真,时间线无法回溯)
old_status = business.status
business.status = payload.status
business_repository.add_status_log(
session,
BusinessStatusLog(
business_id=business_id,
old_status=old_status,
new_status=payload.status,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 锁定行上已赋值,同一身份映射实例的 _to_out 即反映新状态
out = _to_out(row)
session.commit()
logger.info(
"业务状态变更 | business_id=%s | %s -> %s",
business_id,
old_status,
payload.status,
)
return out
def change_ownership(
session: Session,
business_id: int,
payload: BusinessOwnershipIn,
*,
operator: User,
meta: RequestMeta | None = None,
) -> BusinessOut:
"""归属变更(负责/协助):同事务「更新主表 + 插留痕」,一次 commit。
负责与协助之一发生变化即记一条留痕(前后值都含两个字段);两者均未变化
→ 400「归属未变化」。
:raises NotFoundError: 业务不存在
:raises BadRequestError: 归属人员不存在或已离职 / 归属未变化
"""
row = business_repository.get_with_names(session, business_id, for_update=True)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_BUSINESS,
action=ACTION_UPDATE,
summary=f"调整业务归属 {row[0].business_no if row else f'#{business_id}'}",
request_data=payload.model_dump(exclude_none=True),
),
meta=meta,
):
if row is None:
raise NotFoundError("业务不存在")
business, _, old_owner_name, old_assistant_name = row
# 归属变更的出参姓名来自校验函数的返回行(锁定读):首查姓名是变更前
# 固化标量,新归属人名必须按新 ID 重取
new_owner = (
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
if payload.new_owner_personnel_id is not None
else None
)
new_assistant = (
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
if payload.new_assistant_personnel_id is not None
else None
)
owner_changed = business.owner_personnel_id != payload.new_owner_personnel_id
assistant_changed = business.assistant_personnel_id != payload.new_assistant_personnel_id
if not owner_changed and not assistant_changed:
raise BadRequestError("归属未变化")
old_owner_id = business.owner_personnel_id
old_assistant_id = business.assistant_personnel_id
business.owner_personnel_id = payload.new_owner_personnel_id
business.assistant_personnel_id = payload.new_assistant_personnel_id
business_repository.add_ownership_log(
session,
OwnershipChangeLog(
owner_type=OWNER_TYPE_BUSINESS,
business_id=business_id,
old_owner_personnel_id=old_owner_id,
new_owner_personnel_id=payload.new_owner_personnel_id,
old_assistant_personnel_id=old_assistant_id,
new_assistant_personnel_id=payload.new_assistant_personnel_id,
change_reason=payload.reason,
operator_id=operator.id,
operator_name=operator.username,
),
)
# 出参组装:ID 在锁定行上已赋值(_to_out 读内存值即反映新值),姓名
# 一侧「变更项用新 ID 的人名、未变项沿用首查名」——首查名是变更前快照
out = _to_out(
(
business,
row[1],
new_owner.personnel_name if new_owner is not None else old_owner_name,
new_assistant.personnel_name if new_assistant is not None else old_assistant_name,
)
)
session.commit()
logger.info(
"调整业务归属 | business_id=%s | owner %s->%s | assistant %s->%s",
business_id,
old_owner_id,
payload.new_owner_personnel_id,
old_assistant_id,
payload.new_assistant_personnel_id,
)
return out
def list_ownership_logs(
session: Session, business_id: int, query: PageQuery
) -> PageResult[OwnershipChangeLogOut]:
"""某业务的归属变更留痕(分页)。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
rows = business_repository.list_ownership_logs(
session, business_id, offset=query.offset, limit=query.page_size
)
total = business_repository.count_ownership_logs(session, business_id=business_id)
return PageResult[OwnershipChangeLogOut](
records=[_log_to_out(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def _log_to_out(row: Row) -> OwnershipChangeLogOut:
log, old_owner_name, new_owner_name = row
return OwnershipChangeLogOut(
id=log.id,
business_id=log.business_id,
old_owner_personnel_id=log.old_owner_personnel_id,
new_owner_personnel_id=log.new_owner_personnel_id,
old_assistant_personnel_id=log.old_assistant_personnel_id,
new_assistant_personnel_id=log.new_assistant_personnel_id,
old_owner_name=old_owner_name,
new_owner_name=new_owner_name,
change_reason=log.change_reason,
operator_id=log.operator_id,
operator_name=log.operator_name,
create_time=log.create_time,
)
def list_status_logs(
session: Session, business_id: int, query: PageQuery
) -> PageResult[BusinessStatusLogOut]:
"""某业务的状态流转时间线(分页,时间正序)。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
rows = business_repository.list_status_logs(
session, business_id, offset=query.offset, limit=query.page_size
)
total = business_repository.count_status_logs(session, business_id=business_id)
return PageResult[BusinessStatusLogOut](
records=[BusinessStatusLogOut.model_validate(row) for row in rows],
total=total,
page_num=query.page_num,
page_size=query.page_size,
)
def gross_profit(session: Session, business_id: int, as_of: date | None) -> GrossProfitOut:
"""毛利润数据视图(需求 2.2.4):行级派生值 + 类别汇总,实时计算不落库。
阈值按 asOf(默认今日)取 M1 参数快照(product.threshold,按行类别维度,
存储口径为 % 数如 30,直接进 converted_profit);毛利润率按 M1 预览同一
出参口径输出 Fraction 分数字符串(如 "97/10000");成本缺失的行派生列为
None 并标注原因;汇总只聚合可计算行。
:raises NotFoundError: 业务不存在
"""
if business_repository.get_by_id(session, business_id) is None:
raise NotFoundError("业务不存在")
as_of = as_of or date.today()
snapshot = param_service.resolve_snapshot(session, as_of)
def threshold_of(category: str) -> Fraction:
return snapshot.fraction("product.threshold", product_category=category)
rows_out: list[GrossProfitRowOut] = []
missing_count = 0
# 类别聚合容器:{类别: [sale, cost]},成本缺失行不计入该类别聚合
agg: dict[str, list[Decimal]] = {}
for item in business_repository.list_items(session, business_id):
threshold = threshold_of(item.product_category)
row_kwargs: dict = {
"item_id": item.id,
"product_category": item.product_category,
"product_name": item.product_name,
"spec_model": item.spec_model,
"quantity": str(item.quantity),
"sale_amount": str(item.sale_amount),
"threshold": str(threshold),
}
if item.cost_amount is None:
missing_count += 1
row_kwargs.update(
cost_amount=None,
profit_amount=None,
profit_rate=None,
converted_profit=None,
missing_reason="成本缺失,无法计算毛利润",
)
else:
profit = item.sale_amount - item.cost_amount
rate = profit_rate(profit, item.sale_amount)
conv = converted_profit(item.sale_amount, rate, threshold)
row_kwargs.update(
cost_amount=str(item.cost_amount),
profit_amount=str(profit),
profit_rate=str(rate),
converted_profit=str(conv),
missing_reason=None,
)
bucket = agg.setdefault(item.product_category, [Decimal("0"), Decimal("0")])
bucket[0] += item.sale_amount
bucket[1] += item.cost_amount
rows_out.append(GrossProfitRowOut(**row_kwargs))
categories = [
GrossProfitCategoryOut(
product_category=category,
sale_amount=str(sale),
cost_amount=str(cost),
profit_amount=str(sale - cost),
profit_rate=str(profit_rate(sale - cost, sale)),
threshold=str(threshold_of(category)),
converted_profit=str(
converted_profit(sale, profit_rate(sale - cost, sale), threshold_of(category))
),
)
for category, [sale, cost] in agg.items()
]
return GrossProfitOut(
business_id=business_id,
as_of=as_of,
rows=rows_out,
categories=categories,
missing_count=missing_count,
)