feat(business): 业务管理接口(编号生成/明细/状态机/归属留痕/毛利润视图)
- 编号 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>
This commit is contained in:
parent
1625907380
commit
0cdc6246c4
@ -11,6 +11,8 @@ from app.api.v1 import auth
|
|||||||
from app.api.v1.base import customers as base_customers
|
from app.api.v1.base import customers as base_customers
|
||||||
from app.api.v1.base import personnel as base_personnel
|
from app.api.v1.base import personnel as base_personnel
|
||||||
from app.api.v1.base import products as base_products
|
from app.api.v1.base import products as base_products
|
||||||
|
from app.api.v1.business import businesses as business_businesses
|
||||||
|
from app.api.v1.business import ratings as business_ratings
|
||||||
from app.api.v1.system import business_roles as system_business_roles
|
from app.api.v1.system import business_roles as system_business_roles
|
||||||
from app.api.v1.system import logs as system_logs
|
from app.api.v1.system import logs as system_logs
|
||||||
from app.api.v1.system import params as system_params
|
from app.api.v1.system import params as system_params
|
||||||
@ -43,4 +45,10 @@ base_router.include_router(base_products.router)
|
|||||||
base_router.include_router(base_customers.router)
|
base_router.include_router(base_customers.router)
|
||||||
v1_router.include_router(base_router)
|
v1_router.include_router(base_router)
|
||||||
|
|
||||||
|
# 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问
|
||||||
|
business_router = APIRouter(prefix="/business")
|
||||||
|
business_router.include_router(business_businesses.router)
|
||||||
|
business_router.include_router(business_ratings.router)
|
||||||
|
v1_router.include_router(business_router)
|
||||||
|
|
||||||
api_router.include_router(v1_router)
|
api_router.include_router(v1_router)
|
||||||
|
|||||||
177
backend/app/api/v1/business/businesses.py
Normal file
177
backend/app/api/v1/business/businesses.py
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
"""业务管理接口(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
校验规则(编号生成、状态机、归属留痕、汇总重算)见 `business_service`
|
||||||
|
模块 docstring;基础数据属业务操作,登录即可访问。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
|
|
||||||
|
from app.api.deps import DbSession, RequestMetaDep, get_current_user
|
||||||
|
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||||
|
from app.models import User
|
||||||
|
from app.schemas.business import (
|
||||||
|
BusinessCreateIn,
|
||||||
|
BusinessDetailOut,
|
||||||
|
BusinessOut,
|
||||||
|
BusinessOwnershipIn,
|
||||||
|
BusinessQuery,
|
||||||
|
BusinessStatusIn,
|
||||||
|
BusinessStatusLogOut,
|
||||||
|
BusinessUpdateIn,
|
||||||
|
GrossProfitOut,
|
||||||
|
OwnershipChangeLogOut,
|
||||||
|
)
|
||||||
|
from app.services import business_service
|
||||||
|
|
||||||
|
# 基础数据是业务操作:登录即可,不要求管理员(区别于系统管理的 AdminUser)
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/businesses", tags=["业务管理"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ApiResponse[PageResult[BusinessOut]], summary="业务列表")
|
||||||
|
def list_businesses(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
query: Annotated[BusinessQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessOut]]:
|
||||||
|
"""分页查询业务。编号/公司名称模糊,可按状态/客户/归属人员/日期范围筛选。"""
|
||||||
|
return ok(business_service.list_businesses(session, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
|
||||||
|
def create_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
payload: BusinessCreateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""创建业务 + 明细行(≥1)。业务编号自动生成;创建写首条状态留痕。"""
|
||||||
|
return ok(
|
||||||
|
business_service.create_business(session, payload, operator=current_user, meta=meta),
|
||||||
|
message="业务已创建",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{business_id}", response_model=ApiResponse[BusinessDetailOut], summary="业务详情")
|
||||||
|
def get_business(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
) -> ApiResponse[BusinessDetailOut]:
|
||||||
|
"""业务详情,附产品明细行,供编辑抽屉一次回显。"""
|
||||||
|
return ok(business_service.get_detail(session, business_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
|
||||||
|
def update_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessUpdateIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""编辑业务(明细全量替换并重算汇总)。归属不在此端点调整,走 /ownership。"""
|
||||||
|
return ok(
|
||||||
|
business_service.update_business(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="已保存",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
|
||||||
|
def delete_business(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[None]:
|
||||||
|
"""删除业务。有发货或回款记录时禁删,应改为已终止。"""
|
||||||
|
business_service.delete_business(session, business_id, operator=current_user, meta=meta)
|
||||||
|
return ok(message="业务已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
|
||||||
|
def change_status(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessStatusIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""状态流转:执行中→已完成/已终止;终态不可再变更;终止必填原因。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_status(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="状态已变更",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
|
||||||
|
def change_ownership(
|
||||||
|
session: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
payload: BusinessOwnershipIn,
|
||||||
|
meta: RequestMetaDep,
|
||||||
|
) -> ApiResponse[BusinessOut]:
|
||||||
|
"""调整负责/协助营销人员,同事务写归属变更留痕(不回溯历史)。"""
|
||||||
|
return ok(
|
||||||
|
business_service.change_ownership(
|
||||||
|
session, business_id, payload, operator=current_user, meta=meta
|
||||||
|
),
|
||||||
|
message="归属已调整",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/ownership-logs",
|
||||||
|
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
|
||||||
|
summary="归属变更记录",
|
||||||
|
)
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
|
||||||
|
"""某业务的归属变更留痕(分页,按时间倒序)。"""
|
||||||
|
return ok(business_service.list_ownership_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/status-logs",
|
||||||
|
response_model=ApiResponse[PageResult[BusinessStatusLogOut]],
|
||||||
|
summary="状态流转记录",
|
||||||
|
)
|
||||||
|
def list_status_logs(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
query: Annotated[PageQuery, Query()],
|
||||||
|
) -> ApiResponse[PageResult[BusinessStatusLogOut]]:
|
||||||
|
"""某业务的状态流转时间线(分页,按时间正序,从创建起展示)。"""
|
||||||
|
return ok(business_service.list_status_logs(session, business_id, query))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{business_id}/gross-profit",
|
||||||
|
response_model=ApiResponse[GrossProfitOut],
|
||||||
|
summary="毛利润数据视图",
|
||||||
|
)
|
||||||
|
def gross_profit(
|
||||||
|
session: DbSession,
|
||||||
|
_: CurrentUser,
|
||||||
|
business_id: Annotated[int, Path(ge=1)],
|
||||||
|
as_of: Annotated[date | None, Query()] = None,
|
||||||
|
) -> ApiResponse[GrossProfitOut]:
|
||||||
|
"""按产品逐行实时计算毛利润与折算额(不含运费口径,对照表 #2)。
|
||||||
|
|
||||||
|
阈值按 asOf(默认今日)取参数快照;M3 核算实际按回款到账日取参。
|
||||||
|
"""
|
||||||
|
return ok(business_service.gross_profit(session, business_id, as_of))
|
||||||
257
backend/app/repositories/business_repository.py
Normal file
257
backend/app/repositories/business_repository.py
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
"""业务数据仓储:业务单、明细、状态留痕与归属留痕。
|
||||||
|
|
||||||
|
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||||||
|
归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在
|
||||||
|
models/customer.py 定义)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Row, Select, delete, func, or_, select
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import (
|
||||||
|
Business,
|
||||||
|
BusinessItem,
|
||||||
|
BusinessStatusLog,
|
||||||
|
Customer,
|
||||||
|
OwnershipChangeLog,
|
||||||
|
Personnel,
|
||||||
|
)
|
||||||
|
from app.models.customer import OWNER_TYPE_BUSINESS
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_id(session: Session, business_id: int, *, for_update: bool = False) -> Business | None:
|
||||||
|
"""按主键取业务行;for_update 供删除保护与状态机使用。"""
|
||||||
|
stmt = select(Business).where(Business.id == business_id)
|
||||||
|
if for_update:
|
||||||
|
stmt = stmt.with_for_update()
|
||||||
|
return session.execute(stmt).scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
def create(session: Session, business: Business) -> None:
|
||||||
|
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
|
||||||
|
session.add(business)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def get_with_names(
|
||||||
|
session: Session, business_id: int
|
||||||
|
) -> tuple[Business, str, str | None, str | None] | None:
|
||||||
|
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。"""
|
||||||
|
owner = aliased(Personnel)
|
||||||
|
assistant = aliased(Personnel)
|
||||||
|
row = session.execute(
|
||||||
|
select(Business, Customer.company_name, owner.personnel_name, assistant.personnel_name)
|
||||||
|
.join(Customer, Customer.id == Business.customer_id)
|
||||||
|
.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||||
|
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||||
|
.where(Business.id == business_id)
|
||||||
|
).first()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: Select,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
customer_id: int | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
) -> Select:
|
||||||
|
"""业务列表的筛选条件(调用方须已 join Customer)。"""
|
||||||
|
if keyword:
|
||||||
|
# 业务编号/公司名称双列 OR 模糊;contains(autoescape) 同 query_utils.fuzzy
|
||||||
|
# 的转义口径(fuzzy 为单列 where 语义,双列 OR 在此直接组合)
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Business.business_no.contains(keyword, autoescape=True),
|
||||||
|
Customer.company_name.contains(keyword, autoescape=True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(Business.status == status)
|
||||||
|
if customer_id is not None:
|
||||||
|
stmt = stmt.where(Business.customer_id == customer_id)
|
||||||
|
if owner_personnel_id is not None:
|
||||||
|
stmt = stmt.where(Business.owner_personnel_id == owner_personnel_id)
|
||||||
|
if date_from is not None:
|
||||||
|
stmt = stmt.where(Business.business_date >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
stmt = stmt.where(Business.business_date <= date_to)
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
|
||||||
|
def list_businesses(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
customer_id: int | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
) -> list[tuple[Business, str, str | None, str | None]]:
|
||||||
|
"""分页查询业务(附客户名/负责人名/协助人名),按 id 倒序。"""
|
||||||
|
owner = aliased(Personnel)
|
||||||
|
assistant = aliased(Personnel)
|
||||||
|
stmt = select(
|
||||||
|
Business, Customer.company_name, owner.personnel_name, assistant.personnel_name
|
||||||
|
).join(Customer, Customer.id == Business.customer_id)
|
||||||
|
stmt = _apply_filters(
|
||||||
|
stmt,
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
customer_id=customer_id,
|
||||||
|
owner_personnel_id=owner_personnel_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
stmt.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||||||
|
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||||||
|
.order_by(Business.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
return list(session.execute(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
|
def count_businesses(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
keyword: str | None,
|
||||||
|
status: int | None,
|
||||||
|
customer_id: int | None,
|
||||||
|
owner_personnel_id: int | None,
|
||||||
|
date_from: date | None,
|
||||||
|
date_to: date | None,
|
||||||
|
) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Business)
|
||||||
|
.join(Customer, Customer.id == Business.customer_id)
|
||||||
|
)
|
||||||
|
stmt = _apply_filters(
|
||||||
|
stmt,
|
||||||
|
keyword=keyword,
|
||||||
|
status=status,
|
||||||
|
customer_id=customer_id,
|
||||||
|
owner_personnel_id=owner_personnel_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
return int(session.execute(stmt).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
def list_items(session: Session, business_id: int) -> list[BusinessItem]:
|
||||||
|
"""业务明细行(编辑回显与汇总重算共用)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessItem)
|
||||||
|
.where(BusinessItem.business_id == business_id)
|
||||||
|
.order_by(BusinessItem.id)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_items(session: Session, business_id: int, rows: list[BusinessItem]) -> None:
|
||||||
|
"""全量替换明细:先删、立即 flush、再插入(replace_roles 先例的防御写法)。
|
||||||
|
|
||||||
|
同一 flush 中 SQLAlchemy 先处理 INSERT 后处理 DELETE,先删后插不落库
|
||||||
|
会在有唯一键的表上撞键;business_items 虽无唯一键,统一按此写法防御。
|
||||||
|
"""
|
||||||
|
session.execute(delete(BusinessItem).where(BusinessItem.business_id == business_id))
|
||||||
|
session.flush()
|
||||||
|
for row in rows:
|
||||||
|
row.business_id = business_id
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def count_items(session: Session, business_ids: list[int]) -> dict[int, int]:
|
||||||
|
"""业务明细行数(删除保护备用)。"""
|
||||||
|
if not business_ids:
|
||||||
|
return {}
|
||||||
|
rows = session.execute(
|
||||||
|
select(BusinessItem.business_id, func.count())
|
||||||
|
.where(BusinessItem.business_id.in_(business_ids))
|
||||||
|
.group_by(BusinessItem.business_id)
|
||||||
|
).all()
|
||||||
|
return dict(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def add_status_log(session: Session, log: BusinessStatusLog) -> None:
|
||||||
|
"""追加状态留痕(不 commit)。"""
|
||||||
|
session.add(log)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def list_status_logs(
|
||||||
|
session: Session, business_id: int, *, offset: int, limit: int
|
||||||
|
) -> list[BusinessStatusLog]:
|
||||||
|
"""状态流转时间线(详情页),按时间正序(时间线从创建起展示)。"""
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(BusinessStatusLog)
|
||||||
|
.where(BusinessStatusLog.business_id == business_id)
|
||||||
|
.order_by(BusinessStatusLog.create_time, BusinessStatusLog.id)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_status_logs(session: Session, business_id: int) -> int:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(BusinessStatusLog)
|
||||||
|
.where(BusinessStatusLog.business_id == business_id)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> None:
|
||||||
|
"""追加归属变更留痕(不 commit)。"""
|
||||||
|
session.add(log)
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def list_ownership_logs(
|
||||||
|
session: Session, business_id: int, *, offset: int, limit: int
|
||||||
|
) -> list[Row]:
|
||||||
|
"""某业务的归属变更留痕(join 人员出姓名;人员删除后为 None,留痕固化 ID 兜底)。"""
|
||||||
|
old_p = aliased(Personnel)
|
||||||
|
new_p = aliased(Personnel)
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
select(OwnershipChangeLog, old_p.personnel_name, new_p.personnel_name)
|
||||||
|
.outerjoin(old_p, old_p.id == OwnershipChangeLog.old_owner_personnel_id)
|
||||||
|
.outerjoin(new_p, new_p.id == OwnershipChangeLog.new_owner_personnel_id)
|
||||||
|
.where(
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
OwnershipChangeLog.business_id == business_id,
|
||||||
|
)
|
||||||
|
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||||||
|
return int(
|
||||||
|
session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(OwnershipChangeLog)
|
||||||
|
.where(
|
||||||
|
OwnershipChangeLog.owner_type == OWNER_TYPE_BUSINESS,
|
||||||
|
OwnershipChangeLog.business_id == business_id,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
324
backend/app/schemas/business.py
Normal file
324
backend/app/schemas/business.py
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
"""业务管理的请求/响应模型(需求 2.2.1/2.2.4)。
|
||||||
|
|
||||||
|
金额/数量入参出参一律 str(服务层 Decimal/Fraction,全程禁 float);
|
||||||
|
派生值(毛利润额/毛利润率/折算毛利润额)不在写接口入参内 —— 落库列只有
|
||||||
|
源数据,派生值由毛利润视图实时计算。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.core.constants import (
|
||||||
|
PRODUCT_CATEGORIES,
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER,
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT,
|
||||||
|
)
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
|
||||||
|
# DECIMAL(14,2) 容量上限:整数部分 12 位(同 personnel.py 的金额校验口径)
|
||||||
|
AMOUNT_MAX = Decimal("999999999999.99")
|
||||||
|
# DECIMAL(14,4) 容量上限:整数部分 10 位
|
||||||
|
PRICE_MAX = Decimal("9999999999.9999")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_amount(
|
||||||
|
value: str | None, label: str, cap: Decimal, allow_positive_only: bool = False
|
||||||
|
) -> str | None:
|
||||||
|
"""字符串金额的通用校验:数字/有限/非负/容量/两位小数。
|
||||||
|
|
||||||
|
小数位超过两位直接拒绝而非静默舍入 —— 舍入即算错钱(工资系统红线)。
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
amount = Decimal(value)
|
||||||
|
except InvalidOperation:
|
||||||
|
raise ValueError(f"{label}须为有效数字") from None
|
||||||
|
if not amount.is_finite():
|
||||||
|
raise ValueError(f"{label}须为有效数字")
|
||||||
|
if allow_positive_only:
|
||||||
|
if amount <= 0:
|
||||||
|
raise ValueError(f"{label}必须大于 0")
|
||||||
|
elif amount < 0:
|
||||||
|
raise ValueError(f"{label}不能为负数")
|
||||||
|
if amount > cap:
|
||||||
|
raise ValueError(f"{label}超出可录入上限")
|
||||||
|
if -amount.as_tuple().exponent > 2:
|
||||||
|
raise ValueError(f"{label}最多两位小数")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItemIn(BaseModel):
|
||||||
|
"""业务产品明细行(需求 2.2.1「支持添加多种产品,录入指导价」)。
|
||||||
|
|
||||||
|
product_category 保留蛇形:该值与 M1 参数维度值域同源(枚举码本就是
|
||||||
|
蛇形);其余标识类字段驼峰兼容(snake/驼峰双入,同 customer.py 契约)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
product_id: int | None = Field(default=None, ge=1, description="产品ID;空=自由录入")
|
||||||
|
product_name: str = Field(min_length=1, max_length=128, description="产品名称")
|
||||||
|
spec_model: str | None = Field(
|
||||||
|
default=None, max_length=128, description="规格型号,空=空串归一"
|
||||||
|
)
|
||||||
|
unit: str | None = Field(default=None, max_length=16, description="计量单位")
|
||||||
|
product_category: str = Field(
|
||||||
|
description="产品类别:municipal_water/rail_transit(行级折算锚点)"
|
||||||
|
)
|
||||||
|
quantity: str = Field(description="数量,>0")
|
||||||
|
sale_price: str | None = Field(default=None, description="销售单价(元)")
|
||||||
|
cost_amount: str | None = Field(
|
||||||
|
default=None, description="成本额(元);毛利润额 = 销售额 − 成本额"
|
||||||
|
)
|
||||||
|
guided_price: str | None = Field(default=None, description="指导价(元)")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("product_category")
|
||||||
|
@classmethod
|
||||||
|
def _check_category(cls, value: str) -> str:
|
||||||
|
if value not in PRODUCT_CATEGORIES:
|
||||||
|
raise ValueError("产品类别不合法")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("quantity")
|
||||||
|
@classmethod
|
||||||
|
def _check_quantity(cls, value: str) -> str:
|
||||||
|
return _check_amount(value, "数量", AMOUNT_MAX, allow_positive_only=True) or value
|
||||||
|
|
||||||
|
@field_validator("sale_price", "cost_amount", "guided_price")
|
||||||
|
@classmethod
|
||||||
|
def _check_prices(cls, value: str | None) -> str | None:
|
||||||
|
return _check_amount(value, "金额", PRICE_MAX)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessItemOut(BaseModel):
|
||||||
|
"""业务产品明细行响应。金额/数量一律 str。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
product_category: str
|
||||||
|
quantity: str
|
||||||
|
sale_price: str | None = None
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str | None = None
|
||||||
|
guided_price: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessOut(BaseModel):
|
||||||
|
"""业务响应(列表与写操作回包共用)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_no: str
|
||||||
|
customer_id: int
|
||||||
|
customer_name: str | None = None
|
||||||
|
business_date: date | None = None
|
||||||
|
status: int = 1
|
||||||
|
owner_personnel_id: int | None = None
|
||||||
|
owner_personnel_name: str | None = None
|
||||||
|
assistant_personnel_id: int | None = None
|
||||||
|
assistant_personnel_name: str | None = None
|
||||||
|
is_special: int = 0
|
||||||
|
special_note: str | None = None
|
||||||
|
total_sale_amount: str | None = None
|
||||||
|
total_cost_amount: str | None = None
|
||||||
|
total_ship_amount: str | None = None
|
||||||
|
total_received_amount: str | None = None
|
||||||
|
total_freight_amount: str | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessDetailOut(BusinessOut):
|
||||||
|
"""业务详情:附产品明细行。"""
|
||||||
|
|
||||||
|
items: list[BusinessItemOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessCreateIn(BaseModel):
|
||||||
|
"""创建业务:主表信息 + 明细行数组(明细 ≥1 行)。
|
||||||
|
|
||||||
|
字段名全线蛇形直出(M1 既有契约:前端 api 封装与 schemas 同名对应,
|
||||||
|
仅分页信封 list/pageNum 驼峰)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
customer_id: int = Field(ge=1, description="客户ID")
|
||||||
|
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
|
||||||
|
owner_personnel_id: int | None = Field(
|
||||||
|
default=None, ge=1, description="负责营销人员ID,可空=待指定"
|
||||||
|
)
|
||||||
|
assistant_personnel_id: int | None = Field(default=None, ge=1, description="协助营销人员ID")
|
||||||
|
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||||
|
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||||
|
items: list[BusinessItemIn] = Field(min_length=1, description="产品明细行,至少 1 行")
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_special_note(self) -> "BusinessCreateIn":
|
||||||
|
"""特殊业务须有说明:标记的意义就在于可追溯「特殊在哪」。"""
|
||||||
|
if self.is_special == 1 and not self.special_note:
|
||||||
|
raise ValueError("特殊业务必须填写说明")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessUpdateIn(BaseModel):
|
||||||
|
"""编辑业务(全量替换明细;归属不在此端点调整,走 /ownership)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
customer_id: int = Field(ge=1, description="客户ID")
|
||||||
|
business_date: date | None = Field(default=None, description="业务日期,ISO 格式")
|
||||||
|
owner_personnel_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="负责营销人员ID(编辑端点忽略,仅 /ownership 生效)",
|
||||||
|
)
|
||||||
|
assistant_personnel_id: int | None = Field(
|
||||||
|
default=None,
|
||||||
|
ge=1,
|
||||||
|
description="协助营销人员ID(编辑端点忽略)",
|
||||||
|
)
|
||||||
|
is_special: int = Field(default=0, ge=0, le=1, description="特殊业务标记:1是 0否")
|
||||||
|
special_note: str | None = Field(default=None, max_length=255, description="特殊业务说明")
|
||||||
|
items: list[BusinessItemIn] = Field(
|
||||||
|
min_length=1, description="产品明细行,至少 1 行(全量替换)"
|
||||||
|
)
|
||||||
|
remark: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_special_note(self) -> "BusinessUpdateIn":
|
||||||
|
if self.is_special == 1 and not self.special_note:
|
||||||
|
raise ValueError("特殊业务必须填写说明")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusIn(BaseModel):
|
||||||
|
"""状态流转:1执行中 → 2已完成 / 3已终止(终态不可再变更)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
status: int = Field(ge=1, le=3, description="目标状态:2已完成 3已终止")
|
||||||
|
reason: str | None = Field(default=None, max_length=255, description="变更原因(终止时必填)")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _check_reason(self) -> "BusinessStatusIn":
|
||||||
|
if self.status == 3 and not self.reason:
|
||||||
|
raise ValueError("终止业务必须填写原因")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessOwnershipIn(BaseModel):
|
||||||
|
"""业务归属变更(负责/协助;前后值写入留痕)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
new_owner_personnel_id: int | None = Field(default=None, ge=1)
|
||||||
|
new_assistant_personnel_id: int | None = Field(default=None, ge=1)
|
||||||
|
reason: str | None = Field(default=None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessQuery(PageQuery):
|
||||||
|
"""业务列表查询条件(筛选参数驼峰 alias,customer.py 同款契约)。"""
|
||||||
|
|
||||||
|
keyword: str | None = Field(default=None, max_length=32, description="业务编号/公司名称,模糊")
|
||||||
|
status: int | None = Field(default=None, ge=1, le=3, description="状态筛选")
|
||||||
|
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||||||
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="负责营销人员筛选")
|
||||||
|
date_from: date | None = Field(default=None, description="业务日期起(含)")
|
||||||
|
date_to: date | None = Field(default=None, description="业务日期止(含)")
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitRowOut(BaseModel):
|
||||||
|
"""毛利润视图行:行级源数据 + 派生值(实时计算,不落库)。"""
|
||||||
|
|
||||||
|
item_id: int
|
||||||
|
product_category: str
|
||||||
|
product_name: str
|
||||||
|
spec_model: str | None = None
|
||||||
|
quantity: str
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str | None = None
|
||||||
|
"""成本缺失(未录入)时为 None,派生列同为 None 并置 missing_reason"""
|
||||||
|
profit_amount: str | None = None
|
||||||
|
profit_rate: str | None = None
|
||||||
|
threshold: str
|
||||||
|
converted_profit: str | None = None
|
||||||
|
missing_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitCategoryOut(BaseModel):
|
||||||
|
"""按产品类别的毛利润汇总(可计算行聚合;成本缺失行不计入)。"""
|
||||||
|
|
||||||
|
product_category: str
|
||||||
|
sale_amount: str
|
||||||
|
cost_amount: str
|
||||||
|
profit_amount: str
|
||||||
|
profit_rate: str
|
||||||
|
threshold: str
|
||||||
|
converted_profit: str
|
||||||
|
|
||||||
|
|
||||||
|
class GrossProfitOut(BaseModel):
|
||||||
|
"""毛利润数据视图(需求 2.2.4):行级 + 类别汇总。
|
||||||
|
|
||||||
|
口径注释(前端须展示):毛利润率 = 毛利润额 / 销售额,**不含运费**
|
||||||
|
(对照表 #2 作废需规含运费公式);阈值按 asOf 取 M1 参数快照;M3 核算
|
||||||
|
实际按回款到账日取参,本视图默认今日仅供录入期核对。
|
||||||
|
"""
|
||||||
|
|
||||||
|
business_id: int
|
||||||
|
as_of: date
|
||||||
|
rows: list[GrossProfitRowOut] = Field(default_factory=list)
|
||||||
|
categories: list[GrossProfitCategoryOut] = Field(default_factory=list)
|
||||||
|
missing_count: int = 0
|
||||||
|
note: str = "毛利润率与折算额不含运费;M3 核算按回款到账日取参数版本"
|
||||||
|
|
||||||
|
|
||||||
|
class OwnershipChangeLogOut(BaseModel):
|
||||||
|
"""业务归属变更留痕(人员删除后姓名为 None,固化 ID 兜底可追溯)。"""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
old_owner_personnel_id: int | None = None
|
||||||
|
new_owner_personnel_id: int | None = None
|
||||||
|
old_assistant_personnel_id: int | None = None
|
||||||
|
new_assistant_personnel_id: int | None = None
|
||||||
|
old_owner_name: str | None = None
|
||||||
|
new_owner_name: str | None = None
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BusinessStatusLogOut(BaseModel):
|
||||||
|
"""业务状态流转记录(详情页时间线;创建首条 old=new=1)。"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
business_id: int
|
||||||
|
old_status: int
|
||||||
|
new_status: int
|
||||||
|
change_reason: str | None = None
|
||||||
|
operator_id: int | None = None
|
||||||
|
operator_name: str
|
||||||
|
create_time: datetime
|
||||||
|
|
||||||
|
|
||||||
|
# 供 schema 外部引用的两类别常量(类别汇总展示用)
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
PRODUCT_CATEGORY_MUNICIPAL_WATER: "市政水利",
|
||||||
|
PRODUCT_CATEGORY_RAIL_TRANSIT: "轨道交通",
|
||||||
|
}
|
||||||
@ -35,11 +35,11 @@ class CustomerCreateIn(BaseModel):
|
|||||||
|
|
||||||
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
|
owner_personnel_id 为**初始归属**,可空(客户先建档、后派归属也合法);
|
||||||
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
|
归属的后续调整不走本模型,统一走 ``PUT /{id}/ownership``(见
|
||||||
``CustomerUpdateIn`` 的说明)。camelCase 别名与蛇形字段名均可绑定,
|
``CustomerUpdateIn`` 的说明)。字段名蛇形直出(M1 契约:前端 api 封装
|
||||||
兼容前端两种拼写。
|
与 schema 同名对应,仅分页信封 list/pageNum 驼峰)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(str_strip_whitespace=True, populate_by_name=True)
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
|
company_name: str = Field(min_length=1, max_length=128, description="公司名称")
|
||||||
owner_name: str = Field(
|
owner_name: str = Field(
|
||||||
@ -59,7 +59,6 @@ class CustomerCreateIn(BaseModel):
|
|||||||
owner_personnel_id: int | None = Field(
|
owner_personnel_id: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=1,
|
ge=1,
|
||||||
alias="ownerPersonnelId",
|
|
||||||
description="归属负责营销人员ID(初始归属,可空)",
|
description="归属负责营销人员ID(初始归属,可空)",
|
||||||
)
|
)
|
||||||
remark: str | None = Field(default=None, max_length=255, description="备注")
|
remark: str | None = Field(default=None, max_length=255, description="备注")
|
||||||
@ -91,21 +90,13 @@ class CustomerUpdateIn(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class CustomerQuery(PageQuery):
|
class CustomerQuery(PageQuery):
|
||||||
"""客户列表查询条件。
|
"""客户列表查询条件(字段名蛇形直出,与前端 api 封装同名对应)。"""
|
||||||
|
|
||||||
customer_type / owner_personnel_id 同时接受蛇形(项目既有契约,见前端
|
|
||||||
`types.ts` 的说明)与驼峰别名,两种拼写不并存即可。
|
|
||||||
"""
|
|
||||||
|
|
||||||
keyword: str | None = Field(
|
keyword: str | None = Field(
|
||||||
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
default=None, max_length=64, description="公司名称或客户负责人,模糊匹配"
|
||||||
)
|
)
|
||||||
customer_type: str | None = Field(
|
customer_type: str | None = Field(default=None, max_length=32, description="客户分类筛选")
|
||||||
default=None, max_length=32, alias="customerType", description="客户分类筛选"
|
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人员ID筛选")
|
||||||
)
|
|
||||||
owner_personnel_id: int | None = Field(
|
|
||||||
default=None, ge=1, alias="ownerPersonnelId", description="归属人员ID筛选"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerOwnershipIn(BaseModel):
|
class CustomerOwnershipIn(BaseModel):
|
||||||
@ -114,12 +105,9 @@ class CustomerOwnershipIn(BaseModel):
|
|||||||
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
|
new_owner_personnel_id 可空:null 表示清空归属(客户暂无负责人也合法)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
|
||||||
|
|
||||||
new_owner_personnel_id: int | None = Field(
|
new_owner_personnel_id: int | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
ge=1,
|
ge=1,
|
||||||
alias="newOwnerPersonnelId",
|
|
||||||
description="变更后归属人员ID,null=清空归属",
|
description="变更后归属人员ID,null=清空归属",
|
||||||
)
|
)
|
||||||
reason: str | None = Field(default=None, max_length=255, description="变更原因")
|
reason: str | None = Field(default=None, max_length=255, description="变更原因")
|
||||||
|
|||||||
747
backend/app/services/business_service.py
Normal file
747
backend/app/services/business_service.py
Normal file
@ -0,0 +1,747 @@
|
|||||||
|
"""业务管理业务编排(需求 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,
|
||||||
|
)
|
||||||
46
backend/app/services/no_generator.py
Normal file
46
backend/app/services/no_generator.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""单据编号生成器(业务 YW / 发货 SH / 回款 HK 共用)。
|
||||||
|
|
||||||
|
规则:``{前缀}{YYYYMMDD}-{当日4位序号}``,序号按自然日重置。
|
||||||
|
并发安全:``SELECT MAX(no) ... FOR UPDATE`` 锁住当日序号区间 —— 后到的
|
||||||
|
事务会等待先到者提交后再读到新 MAX,序号单调不重;残余竞态(锁等待超时
|
||||||
|
等极端场景)由服务层捕唯一键冲突(1062)重试一次兜底。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
def today_prefix(prefix: str) -> str:
|
||||||
|
"""当日编号前缀(如 YW20261013-),LIKE 匹配与拼号共用同一来源防漂移。"""
|
||||||
|
return f"{prefix}{datetime.now().strftime('%Y%m%d')}-"
|
||||||
|
|
||||||
|
|
||||||
|
def next_no(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
model: type,
|
||||||
|
column_name: str,
|
||||||
|
prefix: str,
|
||||||
|
seq_width: int = 4,
|
||||||
|
) -> str:
|
||||||
|
"""生成下一个编号。
|
||||||
|
|
||||||
|
:param model: 编号所在的 ORM 模型(businesses/shipments/receipts)
|
||||||
|
:param column_name: 编号列名(business_no/shipment_no/receipt_batch_no)
|
||||||
|
:param prefix: 单据前缀(NO_PREFIX_* 常量)
|
||||||
|
:param seq_width: 序号位数(默认 4);超出宽度的序号按实际位数输出,
|
||||||
|
VARCHAR(32) 余量充足、唯一性不受影响
|
||||||
|
"""
|
||||||
|
like_prefix = today_prefix(prefix)
|
||||||
|
column = getattr(model, column_name)
|
||||||
|
max_no = session.execute(
|
||||||
|
select(column)
|
||||||
|
.where(column.like(f"{like_prefix}%"))
|
||||||
|
.order_by(column.desc())
|
||||||
|
.limit(1)
|
||||||
|
.with_for_update()
|
||||||
|
).scalar()
|
||||||
|
seq = int(max_no.rsplit("-", 1)[1]) + 1 if max_no else 1
|
||||||
|
return f"{like_prefix}{seq:0{seq_width}d}"
|
||||||
Loading…
Reference in New Issue
Block a user