PayrollManagementSystem/backend/app/repositories/customer_repository.py
liuwenxin fba8cfc215 fix(business): 删除保护补齐与单价小数位口径修复(e2e 发现)
- 客户删除保护:被业务引用 → 400(T4 占位注释在业务表就位后漏补,FK 500 暴露)
- 人员删除保护:业务归属(负责/协助)引用纳入检查(同样漏检 500)
- schema 单价类列(DECIMAL 14,4)小数位放行至 4 位:回显 '5000.0000'
  原样提交被两位校验永久拒收(编辑任何带精度单价的历史行都失败)
- 补 2 个集成用例锁定;文案同步「客户归属或业务归属引用」

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:06:46 +08:00

195 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""客户数据访问(含归属变更留痕)。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from sqlalchemy import ColumnElement, Row, Select, func, or_, select
from sqlalchemy.orm import Session, aliased
from app.models import Customer, OwnershipChangeLog, Personnel
from app.models.customer import OWNER_TYPE_CUSTOMER
from app.repositories.query_utils import fuzzy
# 归属留痕表对人员 ID 无外键固化:新旧人员姓名靠对 personnel 的两次外联带出,
# 人员被删除后姓名为 null,行仍完整可读
_old_owner = aliased(Personnel)
_new_owner = aliased(Personnel)
# ==================== 读取 ====================
def get_by_id(session: Session, customer_id: int) -> Customer | None:
"""按主键查询客户。"""
return session.get(Customer, customer_id)
def count_business_refs(session: Session, customer_id: int) -> int:
"""被业务单据引用的次数(删除保护;业务表就位后补齐的检查)。"""
from app.models import Business
return int(
session.execute(
select(func.count())
.select_from(Business)
.where(Business.customer_id == customer_id)
).scalar_one()
)
def get_with_owner_name(session: Session, customer_id: int) -> Row | None:
"""查单个客户并带出归属人员姓名(详情、编辑回显与归属变更共用)。"""
stmt = _base_select().where(Customer.id == customer_id)
return session.execute(stmt).first()
def get_by_company_owner(session: Session, company_name: str, owner_name: str) -> Customer | None:
"""按「公司名称 + 负责人」查客户(uk_customers_company_owner 的先查后写兜底)。"""
stmt = select(Customer).where(
Customer.company_name == company_name,
Customer.owner_name == owner_name,
)
return session.execute(stmt).scalar_one_or_none()
def list_customers(
session: Session,
*,
offset: int,
limit: int,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> list[Row]:
"""分页查询客户,行内容为 (客户, 归属人员姓名)。
归属人员外键非空但统一用 outerjoin:人员行不可考(被物理删除的脏数据)
不应让客户行整个查不出来(同 personnel_repository 的韧性口径)。
排序按 id 倒序:客户无排序字段,新增的靠前符合列表页习惯。
"""
stmt = _apply_filters(
_base_select(),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
stmt = stmt.order_by(Customer.id.desc()).offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_customers(
session: Session,
*,
keyword: str | None = None,
customer_type: str | None = None,
owner_personnel_id: int | None = None,
) -> int:
"""统计符合条件的客户总数,供分页使用。
筛选条件全部落在主表上,计数无需 join(列表与计数的筛选口径由
``_apply_filters`` 单点保证)。
"""
stmt = _apply_filters(
select(func.count(Customer.id)),
keyword=keyword,
customer_type=customer_type,
owner_personnel_id=owner_personnel_id,
)
return int(session.execute(stmt).scalar_one())
def list_ownership_logs(
session: Session,
*,
customer_id: int,
offset: int,
limit: int,
) -> list[Row]:
"""查某客户的归属变更留痕,行内容为 (留痕, 旧归属人名, 新归属人名)。
按 (create_time, id) 双键倒序:create_time 是秒级精度,同一秒内多条
记录排序不确定,id 兜底保证翻页稳定(与操作日志同一理由)。
"""
stmt = (
select(OwnershipChangeLog, _old_owner.personnel_name, _new_owner.personnel_name)
.outerjoin(_old_owner, OwnershipChangeLog.old_owner_personnel_id == _old_owner.id)
.outerjoin(_new_owner, OwnershipChangeLog.new_owner_personnel_id == _new_owner.id)
.where(
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
OwnershipChangeLog.customer_id == customer_id,
)
.order_by(OwnershipChangeLog.create_time.desc(), OwnershipChangeLog.id.desc())
.offset(offset)
.limit(limit)
)
return list(session.execute(stmt).all())
def count_ownership_logs(session: Session, *, customer_id: int) -> int:
"""统计某客户的归属留痕总数,供分页使用。"""
stmt = select(func.count(OwnershipChangeLog.id)).where(
OwnershipChangeLog.owner_type == OWNER_TYPE_CUSTOMER,
OwnershipChangeLog.customer_id == customer_id,
)
return int(session.execute(stmt).scalar_one())
def _base_select() -> Select:
"""客户主表外联归属人员姓名。"""
return select(Customer, Personnel.personnel_name).outerjoin(
Personnel, Customer.owner_personnel_id == Personnel.id
)
def _apply_filters(
stmt: Select,
*,
keyword: str | None,
customer_type: str | None,
owner_personnel_id: int | None,
) -> Select:
"""应用通用筛选条件。列表与计数共用,避免两处口径不一致。"""
if keyword:
stmt = stmt.where(_keyword_clause(keyword))
if customer_type:
stmt = stmt.where(Customer.customer_type == customer_type)
if owner_personnel_id is not None:
stmt = stmt.where(Customer.owner_personnel_id == owner_personnel_id)
return stmt
def _keyword_clause(keyword: str) -> ColumnElement[bool]:
"""关键词条件:公司名称或客户负责人两列 OR 模糊。
借 ``query_utils.fuzzy`` 在空 select 上生成单列条件后取 whereclause 做
OR 合并 —— LIKE 通配符转义口径单点维护;两处手写 ``contains`` 迟早有
一处漏掉 ``autoescape``(人名含下划线是常态,不转义会当单字符通配执行)。
"""
probe = select(Customer.id)
return or_(
fuzzy(probe, Customer.company_name, keyword).whereclause,
fuzzy(probe, Customer.owner_name, keyword).whereclause,
)
# ==================== 写入 ====================
def create(session: Session, customer: Customer) -> Customer:
"""新增客户。只 flush 以取得自增 id。"""
session.add(customer)
session.flush()
return customer
def delete(session: Session, customer: Customer) -> None:
"""删除客户。调用方须先完成引用保护校验。"""
session.delete(customer)
session.flush()
def add_ownership_log(session: Session, log: OwnershipChangeLog) -> OwnershipChangeLog:
"""写入一条归属变更留痕。只 flush 不 commit,与主表归属更新同事务提交。"""
session.add(log)
session.flush()
return log