PayrollManagementSystem/backend/app/api/v1/base/customers.py
liuwenxin 91fc6f6293 fix(product): 删除引用保护/单价小数位校验,IntegrityError 全局兜底转 400
- delete_product 补 count_refs 引用检查(业务明细+发货明细 RESTRICT
  外键)与并发窗口 IntegrityError→400 兜底(customer 先例同构),
  docstring 与两个 api 层过时注释同步更新
- 产品 schema 复用 schemas/business._check_amount:sale_price 走
  (PRICE_MAX, scale=4)、stock_quantity 走 AMOUNT_MAX——此前私写
  _parse_non_negative 不查小数位与容量,5 位小数被 Numeric(14,4)
  静默截断(舍入即算错钱);新增 6 个秒级 schema 单测锁定口径
- 全局注册 IntegrityError handler:漏补引用保护的删除类操作收敛为
  400 通用文案(e2e 撞见一个补一个的模式无法保证全覆盖),完整
  异常记日志;rating/customer 的「非 1062 按 500」注释同步更正

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 18:48:32 +08:00

143 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""客户管理接口(需求 2.1.3)。
归属调整有专属端点 ``PUT /{id}/ownership``:它是唯一写归属留痕的入口,
编辑端点的 schema 不含归属字段 —— 防止出现「改了归属却无留痕」的旁路
(原因详见 `schemas.customer.CustomerUpdateIn`)。
与 `api/v1/system` 的权限分工:基础数据是**业务操作**,登录即可访问
(不要求管理员);写操作同样经 `RequestMetaDep` 带入请求元信息,
由 service 层写入操作日志。
"""
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.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import customer_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.get("", response_model=ApiResponse[PageResult[CustomerOut]], summary="客户列表")
def list_customers(
session: DbSession,
_: CurrentUser,
query: Annotated[CustomerQuery, Query()],
) -> ApiResponse[PageResult[CustomerOut]]:
"""分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名)。"""
return ok(customer_service.list_customers(session, query))
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer(
session: DbSession,
current_user: CurrentUser,
payload: CustomerCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""新增客户。客户分类须在注册表内;「公司名称+负责人」组合唯一。"""
return ok(
customer_service.create_customer(session, payload, operator=current_user, meta=meta),
message="客户已创建",
)
@router.get("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="客户详情")
def get_customer(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[CustomerOut]:
"""按主键查询客户详情。"""
return ok(customer_service.get_customer(session, customer_id))
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""编辑客户档案。
**不含归属** —— 归属调整统一走 ``PUT /{id}/ownership``;编辑表单即使
提交 ownerPersonnelId 也会被忽略,归属以归属端点为准。
"""
return ok(
customer_service.update_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="已保存",
)
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
"""删除客户(物理删除)。
已存在关联业务时返回 400(先处理相关业务单据)。
"""
customer_service.delete_customer(session, customer_id, operator=current_user, meta=meta)
return ok(message="客户已删除")
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
summary="归属变更",
)
def change_ownership(
session: DbSession,
current_user: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""调整客户归属(唯一写归属留痕的入口)。
只影响变更后核算,不回溯历史(对照表 #19);新旧归属人员ID任一为 null
表示清空归属。新旧归属相同报「归属未变化」。
"""
return ok(
customer_service.change_ownership(
session, customer_id, payload, operator=current_user, meta=meta
),
message="归属已调整",
)
@router.get(
"/{customer_id}/ownership-logs",
response_model=ApiResponse[PageResult[OwnershipChangeLogOut]],
summary="归属变更留痕",
)
def list_ownership_logs(
session: DbSession,
_: CurrentUser,
customer_id: Annotated[int, Path(ge=1)],
query: Annotated[PageQuery, Query()],
) -> ApiResponse[PageResult[OwnershipChangeLogOut]]:
"""查某客户的归属变更留痕(分页,按变更时间倒序,附新旧归属人员姓名)。"""
return ok(customer_service.list_ownership_logs(session, customer_id, query))