- 审批:删 _flush_order 撞键内部重试(rollback 会回掉同事务业务行造成 静默丢数据,异常改由 create 外层重试整体重来/update 路径审计收敛); act() 出参固化前置到 commit 之前(commit 后查询失败会把已通过的审批 记成失败审计);驳回/撤回清空方案生效行(防被否决方案进 M3 快照); keyword 改 contains(autoescape=True) 对齐全库转义口径;撤单抽 withdraw_pending_orders 供编辑清空方案时显式调用 - 业务:删业务遇在途审批单禁删 400、终态审批单随 _hard_delete 清理; 编辑清空方案同步撤回在途单;历史离职参与人编辑放行(冗余姓名固化 语义,仅新增参与人做在职校验) - 迁移:business_secondary_allocations 补 uk(business_id, personnel_id) (模型/迁移 py/导出 SQL/init-dev 四方同步,开发库往返重放验证) - 设置:upload_logo 改同步 def(阻塞操作不卡事件循环)+ file.size 先拦; company_name schema 剥空白防全空白入库;logo 旧文件清理移到 commit 成功后(防库指旧路径而文件已删断链);uploadLogo 走 request() 解包 (原直发 service.post 不解包信封,logoUrl 会被置 undefined) - 备份:restore_drill --drop-exec 先清全部历史 drill 库再演练并即删本轮 (原语义永远删错对象,每轮净增一份整库拷贝) - 测试:conftest 清理补 system_settings 种子行与 uploads/logo 文件; 新增 5 例(驳回清方案/清空撤单/在途禁删终态随删/离职放行/BOOL 边界); 全量回归 701 passed - 遗留 2 项记 AI_CONTEXT(方案版本承载、审批单快照,依赖 ④ 分摊语义) Co-Authored-By: Claude Code <noreply@anthropic.com>
300 lines
10 KiB
Python
300 lines
10 KiB
Python
"""业务数据仓储:业务单、明细、状态留痕与归属留痕。
|
||
|
||
只读写数据并 flush;commit/rollback 与业务异常一律在 service 层。
|
||
归属留痕复用 ownership_change_logs(owner_type=2 为业务维度,常量在
|
||
models/customer.py 定义)。
|
||
"""
|
||
|
||
from datetime import date
|
||
|
||
from sqlalchemy import Row, Select, delete, exists, func, or_, select
|
||
from sqlalchemy.orm import Session, aliased
|
||
|
||
from app.models import (
|
||
Business,
|
||
BusinessItem,
|
||
BusinessStatusLog,
|
||
Customer,
|
||
OwnershipChangeLog,
|
||
Personnel,
|
||
Receipt,
|
||
SecondaryAllocation,
|
||
Shipment,
|
||
)
|
||
from app.models.customer import OWNER_TYPE_BUSINESS
|
||
from app.repositories import ownership_log_repository
|
||
|
||
|
||
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 has_docs(session: Session, business_id: int) -> bool:
|
||
"""业务名下是否已有发货单或回款(换客户/删除保护的引用检查)。
|
||
|
||
单条 OR exists 一次往返;新增挂靠业务的单据类型时在此补充条件,
|
||
编辑与删除两处共用同一判定,防「delete 补了检查、update 漏补」的分叉。
|
||
"""
|
||
stmt = select(
|
||
or_(
|
||
exists().where(Shipment.business_id == business_id),
|
||
exists().where(Receipt.business_id == business_id),
|
||
)
|
||
)
|
||
return bool(session.execute(stmt).scalar())
|
||
|
||
|
||
def create(session: Session, business: Business) -> None:
|
||
"""新增业务行(不 commit,flush 让自增 id 就位)。"""
|
||
session.add(business)
|
||
session.flush()
|
||
|
||
|
||
def get_with_names(
|
||
session: Session, business_id: int, *, for_update: bool = False
|
||
) -> tuple[Business, str, str | None, str | None] | None:
|
||
"""业务行 + 客户名 + 负责人名 + 协助人名(DTO 与审计摘要共用)。
|
||
|
||
for_update 供编辑/状态流转/归属变更使用:与发货/回款写路径的
|
||
get_by_id(for_update=True) 同一锁口径,编辑事务与单据写事务互斥,
|
||
「有发货禁换客户」的快照读校验不再被并发新增旁路。
|
||
"""
|
||
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)
|
||
.outerjoin(owner, owner.id == Business.owner_personnel_id)
|
||
.outerjoin(assistant, assistant.id == Business.assistant_personnel_id)
|
||
.where(Business.id == business_id)
|
||
)
|
||
if for_update:
|
||
stmt = stmt.with_for_update()
|
||
return session.execute(stmt).first()
|
||
|
||
|
||
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 list_allocations(session: Session, business_id: int) -> list[SecondaryAllocation]:
|
||
"""二次分配方案(详情回显;空列表=未设置方案)。"""
|
||
return list(
|
||
session.execute(
|
||
select(SecondaryAllocation)
|
||
.where(SecondaryAllocation.business_id == business_id)
|
||
.order_by(SecondaryAllocation.id)
|
||
).scalars()
|
||
)
|
||
|
||
|
||
def replace_allocations(
|
||
session: Session, business_id: int, rows: list[SecondaryAllocation]
|
||
) -> None:
|
||
"""全量替换二次分配方案(不 commit,事务边界在服务层)。
|
||
|
||
本表有 uk(business_id, personnel_id),**必须**沿用先删后 flush 再插的
|
||
防御写法:同 flush 内 INSERT 先于 DELETE 处理,直接删加插会撞唯一键。
|
||
"""
|
||
session.execute(
|
||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||
)
|
||
session.flush()
|
||
for row in rows:
|
||
row.business_id = business_id
|
||
session.add(row)
|
||
session.flush()
|
||
|
||
|
||
def delete_allocations(session: Session, business_id: int) -> None:
|
||
"""清空业务的二次分配方案(审批驳回/撤回时由 approval_service 调用)。
|
||
|
||
被否决的方案不得以「当前生效行」身份留在表里(M3 核算快照会取到);
|
||
方案事实由审批单留痕与操作日志 request_data 承载。
|
||
"""
|
||
session.execute(
|
||
delete(SecondaryAllocation).where(SecondaryAllocation.business_id == business_id)
|
||
)
|
||
session.flush()
|
||
|
||
|
||
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)。"""
|
||
ownership_log_repository.add(session, log)
|
||
|
||
|
||
def list_ownership_logs(
|
||
session: Session, business_id: int, *, offset: int, limit: int
|
||
) -> list[Row]:
|
||
"""某业务的归属变更留痕(共享实现,owner_type/business_id 参数化)。"""
|
||
return ownership_log_repository.list_by_ref(
|
||
session,
|
||
owner_type=OWNER_TYPE_BUSINESS,
|
||
ref_column=OwnershipChangeLog.business_id,
|
||
ref_id=business_id,
|
||
offset=offset,
|
||
limit=limit,
|
||
)
|
||
|
||
|
||
def count_ownership_logs(session: Session, business_id: int) -> int:
|
||
return ownership_log_repository.count_by_ref(
|
||
session,
|
||
owner_type=OWNER_TYPE_BUSINESS,
|
||
ref_column=OwnershipChangeLog.business_id,
|
||
ref_id=business_id,
|
||
)
|