PayrollManagementSystem/backend/app/api/v1/base/customers.py
liuwenxin 1e53792ad6 feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。
新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。
补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
2026-11-05 18:47:13 +08:00

182 lines
6.3 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, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import Customer, User
from app.schemas.category_batch import CustomerCategoryBatch, CustomerOwnershipBatch
from app.schemas.customer import (
CustomerCreateIn,
CustomerOut,
CustomerOwnershipIn,
CustomerQuery,
CustomerRatingIn,
CustomerUpdateIn,
OwnershipChangeLogOut,
)
from app.services import customer_service
router = APIRouter(prefix="/customers", tags=["客户管理"])
# 基础数据不要求管理员(见模块 docstring),登录用户即可操作
CurrentUser = Annotated[User, Depends(get_current_user)]
@router.put("/batch-category")
def batch_category(payload: CustomerCategoryBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
from app.services.category_batch import fill_missing
return ok(fill_missing(session, Customer, payload, actor, meta))
@router.put("/batch-ownership")
def batch_ownership(payload: CustomerOwnershipBatch, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep):
return ok(customer_service.batch_change_ownership(session, payload, operator=actor, meta=meta))
@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: PayrollWriter,
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: PayrollWriter,
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: PayrollWriter,
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}/rating",
response_model=ApiResponse[CustomerOut],
summary="保存客户评级",
)
def rate_customer(
session: DbSession,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
"""保存评级总分(评级计算器「保存评分」)。
评分是客户档案属性:存总分与评级时间,五项明细不落库(对照表 #25);
编辑端点不含评分 —— 评分只走本端点,由计算器统一入口写入。
"""
return ok(
customer_service.rate_customer(
session, customer_id, payload, operator=current_user, meta=meta
),
message="评级已保存",
)
@router.put(
"/{customer_id}/ownership",
response_model=ApiResponse[CustomerOut],
summary="归属变更",
)
def change_ownership(
session: DbSession,
current_user: PayrollWriter,
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))