打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""客户管理接口(需求 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))
|