- 编号 YW{日期}-{序号}:MAX+FOR UPDATE 并发安全 + 1062 兜底重试(no_generator 三单据共用)
- 明细全量替换重算五项汇总冗余;销售额服务层按数量×单价固化不信任前端
- 状态机终态拦截、终止必填原因、创建写首条留痕;归属变更同事务留痕防旁路
- 毛利润视图按行实时计算(不含运费 #2),阈值按 asOf 取 M1 快照,率输出
Fraction 分数字符串同 M1 预览口径;成本缺失行标注无法计算
- 入参契约统一为蛇形直出(撤回 customer/business 的驼峰 alias,与前端
businessRoles.ts 模式对齐)
Co-Authored-By: Claude Code <noreply@anthropic.com>
748 lines
29 KiB
Python
748 lines
29 KiB
Python
"""业务管理业务编排(需求 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 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.exceptions import BadRequestError, NotFoundError
|
||
from app.core.response import PageQuery, PageResult
|
||
from app.models import (
|
||
Business,
|
||
BusinessItem,
|
||
BusinessStatusLog,
|
||
Customer,
|
||
OwnershipChangeLog,
|
||
Personnel,
|
||
Receipt,
|
||
Shipment,
|
||
User,
|
||
)
|
||
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,
|
||
)
|
||
from app.services import audit_service, param_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
|
||
|
||
# personnel.status 的在职值(与 customer_service 同口径,注释见彼处)
|
||
_PERSONNEL_STATUS_ACTIVE = 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,防除零)。
|
||
"""
|
||
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) if sale_price is not None else Decimal("0")
|
||
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:
|
||
"""归属人员须存在且在职(人员侧复用校验文案口径)。"""
|
||
person = personnel_repository.get_by_id(session, personnel_id)
|
||
if person is None or person.status != _PERSONNEL_STATUS_ACTIVE:
|
||
raise BadRequestError(f"{label}人员不存在或已离职")
|
||
return person
|
||
|
||
|
||
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:
|
||
args = getattr(exc.orig, "args", ())
|
||
return bool(args) and args[0] == 1062
|
||
|
||
|
||
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)
|
||
]
|
||
return out
|
||
|
||
|
||
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, "协助")
|
||
|
||
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,
|
||
),
|
||
)
|
||
customer = customer_repository.get_by_id(session, business.customer_id)
|
||
out = _to_out((business, customer.company_name if customer else None, None, None))
|
||
session.commit()
|
||
except IntegrityError as exc:
|
||
session.rollback()
|
||
if _is_duplicate_entry(exc):
|
||
# 编号生成的残余竞态:唯一键兜底重试一次(防无限重试掩盖死锁)
|
||
return _retry_create(session, payload, operator=operator, meta=meta)
|
||
raise
|
||
|
||
logger.info("创建业务 | business_id=%s | no=%s", business.id, business.business_no)
|
||
return out
|
||
|
||
|
||
def _retry_create(
|
||
session: Session,
|
||
payload: BusinessCreateIn,
|
||
*,
|
||
operator: User,
|
||
meta: RequestMeta | None,
|
||
) -> BusinessOut:
|
||
"""编号撞唯一键后的单次重试(防无限重试掩盖死锁类缺陷)。"""
|
||
return create_business(session, payload, operator=operator, meta=meta)
|
||
|
||
|
||
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)
|
||
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, _, _, _ = row
|
||
|
||
if payload.customer_id != business.customer_id:
|
||
has_docs = session.query(Shipment.id).filter(
|
||
Shipment.business_id == business_id
|
||
).first() is not None or (
|
||
session.query(Receipt.id).filter(Receipt.business_id == business_id).first()
|
||
is not None
|
||
)
|
||
if has_docs:
|
||
raise BadRequestError("业务已有发货或回款记录,不能更换客户")
|
||
_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)
|
||
)
|
||
_recalc_totals(session, business)
|
||
out = _to_out(business_repository.get_with_names(session, business_id))
|
||
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 (
|
||
session.query(Shipment.id).filter(Shipment.business_id == business_id).first()
|
||
is not None
|
||
or session.query(Receipt.id).filter(Receipt.business_id == business_id).first()
|
||
is not None
|
||
):
|
||
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 _hard_delete(session: Session, business_id: int) -> None:
|
||
"""按依赖序删除:明细 → 状态留痕 → 业务维度归属留痕 → 主表。"""
|
||
from sqlalchemy import delete
|
||
|
||
session.execute(delete(BusinessItem).where(BusinessItem.business_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)
|
||
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("状态未变化")
|
||
|
||
business.status = payload.status
|
||
business_repository.add_status_log(
|
||
session,
|
||
BusinessStatusLog(
|
||
business_id=business_id,
|
||
old_status=business.status,
|
||
new_status=payload.status,
|
||
change_reason=payload.reason,
|
||
operator_id=operator.id,
|
||
operator_name=operator.username,
|
||
),
|
||
)
|
||
out = _to_out(business_repository.get_with_names(session, business_id))
|
||
session.commit()
|
||
logger.info(
|
||
"业务状态变更 | business_id=%s | %s -> %s",
|
||
business_id,
|
||
business.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)
|
||
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, _, _, _ = row
|
||
if payload.new_owner_personnel_id is not None:
|
||
_ensure_personnel_active(session, payload.new_owner_personnel_id, "负责")
|
||
if payload.new_assistant_personnel_id is not None:
|
||
_ensure_personnel_active(session, payload.new_assistant_personnel_id, "协助")
|
||
|
||
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,
|
||
),
|
||
)
|
||
out = _to_out(business_repository.get_with_names(session, business_id))
|
||
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,
|
||
)
|