feat(核算)!: 添加 T+ 正式核算与采集托管

补齐源快照、资料预演、正式导入及工资核算的依赖链路。
新增版本化输入、逐段 FIFO、生成锁和结构化当月调整,
并提供默认关闭的周期采集、有限重试及 Windows 托管。

原始客户资料、私有配置及首页改动不纳入本次提交。

BREAKING CHANGE: 升级前需应用新增迁移,并通过
PAYROLL_MAKER_USER_ID 绑定实际工资制作人,否则业务只读。
旧单批次核算默认关闭;历史兼容需显式启用
PAYROLL_ALLOW_LEGACY_CALCULATION。生产启用和真实金额待验收。
This commit is contained in:
liuwenxin committed 2026-11-02 09:42:53 +08:00
1 parent 1b074576fe
commit 108bf87225
154 files changed
+9515 -645

No files matched your search

+4
View File
@@ -5,11 +5,13 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, client_ip, get_current_user, get_raw_token
from app.core.config import get_settings
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service
from app.services.payroll_access import can_write_payroll
router = APIRouter(prefix="/auth", tags=["认证"])
@@ -71,6 +73,8 @@ def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[
username=current_user.username,
real_name=current_user.real_name,
roles=get_role_codes(current_user),
can_write_payroll=can_write_payroll(current_user),
payroll_approval_enabled=get_settings().payroll_approval_enabled,
last_login_time=current_user.last_login_time,
)
)
+6 -6
View File
@@ -13,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.customer import (
@@ -46,7 +46,7 @@ def list_customers(
@router.post("", response_model=ApiResponse[CustomerOut], summary="新增客户")
def create_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: CustomerCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[CustomerOut]:
@@ -70,7 +70,7 @@ def get_customer(
@router.put("/{customer_id}", response_model=ApiResponse[CustomerOut], summary="编辑客户")
def update_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerUpdateIn,
meta: RequestMetaDep,
@@ -91,7 +91,7 @@ def update_customer(
@router.delete("/{customer_id}", response_model=ApiResponse[None], summary="删除客户")
def delete_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -110,7 +110,7 @@ def delete_customer(
)
def rate_customer(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerRatingIn,
meta: RequestMetaDep,
@@ -135,7 +135,7 @@ def rate_customer(
)
def change_ownership(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
customer_id: Annotated[int, Path(ge=1)],
payload: CustomerOwnershipIn,
meta: RequestMetaDep,
+7 -7
View File
@@ -11,7 +11,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.personnel import (
@@ -44,7 +44,7 @@ def list_personnel(
@router.post("", response_model=ApiResponse[PersonnelOut], summary="新增人员")
def create_personnel(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: PersonnelCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[PersonnelOut]:
@@ -68,7 +68,7 @@ def get_personnel(
@router.put("/{personnel_id}", response_model=ApiResponse[PersonnelOut], summary="编辑人员")
def update_personnel(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelCreateIn,
meta: RequestMetaDep,
@@ -85,7 +85,7 @@ def update_personnel(
@router.delete("/{personnel_id}", response_model=ApiResponse[None], summary="删除人员")
def delete_personnel(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -101,7 +101,7 @@ def delete_personnel(
)
def set_status(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PersonnelStatusIn,
meta: RequestMetaDep,
@@ -116,7 +116,7 @@ def set_status(
@router.put("/{personnel_id}/regions", response_model=ApiResponse[None], summary="维护辐射区域")
def replace_regions(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: RegionReplaceIn,
meta: RequestMetaDep,
@@ -131,7 +131,7 @@ def replace_regions(
@router.put("/{personnel_id}/plans", response_model=ApiResponse[None], summary="维护拓展计划")
def replace_plans(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
personnel_id: Annotated[int, Path(ge=1)],
payload: PlanReplaceIn,
meta: RequestMetaDep,
+5 -5
View File
@@ -14,7 +14,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.product import (
@@ -44,7 +44,7 @@ def list_products(
@router.post("", response_model=ApiResponse[ProductOut], summary="新增产品")
def create_product(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: ProductCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ProductOut]:
@@ -68,7 +68,7 @@ def get_product(
@router.put("/{product_id}", response_model=ApiResponse[ProductOut], summary="编辑产品")
def update_product(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
payload: ProductUpdateIn,
meta: RequestMetaDep,
@@ -85,7 +85,7 @@ def update_product(
@router.delete("/{product_id}", response_model=ApiResponse[None], summary="删除产品")
def delete_product(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -100,7 +100,7 @@ def delete_product(
@router.put("/{product_id}/status", response_model=ApiResponse[ProductOut], summary="启用/停用产品")
def set_status(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
product_id: Annotated[int, Path(ge=1)],
payload: ProductStatusIn,
meta: RequestMetaDep,
+6 -6
View File
@@ -9,7 +9,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import User
from app.schemas.business import (
@@ -45,7 +45,7 @@ def list_businesses(
@router.post("", response_model=ApiResponse[BusinessOut], summary="创建业务")
def create_business(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: BusinessCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[BusinessOut]:
@@ -69,7 +69,7 @@ def get_business(
@router.put("/{business_id}", response_model=ApiResponse[BusinessOut], summary="编辑业务")
def update_business(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessUpdateIn,
meta: RequestMetaDep,
@@ -86,7 +86,7 @@ def update_business(
@router.delete("/{business_id}", response_model=ApiResponse[None], summary="删除业务")
def delete_business(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
@@ -98,7 +98,7 @@ def delete_business(
@router.put("/{business_id}/status", response_model=ApiResponse[BusinessOut], summary="状态流转")
def change_status(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessStatusIn,
meta: RequestMetaDep,
@@ -115,7 +115,7 @@ def change_status(
@router.put("/{business_id}/ownership", response_model=ApiResponse[BusinessOut], summary="调整归属")
def change_ownership(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
business_id: Annotated[int, Path(ge=1)],
payload: BusinessOwnershipIn,
meta: RequestMetaDep,
+4 -4
View File
@@ -13,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.rating import RatingGradeCreateIn, RatingGradeOut, RatingGradeUpdateIn
@@ -36,7 +36,7 @@ def list_grades(
@router.post("", response_model=ApiResponse[RatingGradeOut], summary="新增评级等级")
def create_grade(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: RatingGradeCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[RatingGradeOut]:
@@ -50,7 +50,7 @@ def create_grade(
@router.put("/{grade_id}", response_model=ApiResponse[RatingGradeOut], summary="编辑评级等级")
def update_grade(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)],
payload: RatingGradeUpdateIn,
meta: RequestMetaDep,
@@ -65,7 +65,7 @@ def update_grade(
@router.delete("/{grade_id}", response_model=ApiResponse[None], summary="删除评级等级")
def delete_grade(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
grade_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
+4 -4
View File
@@ -9,7 +9,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.receipt import (
@@ -39,7 +39,7 @@ def list_receipts(
@router.post("", response_model=ApiResponse[ReceiptOut], summary="新增回款")
def create_receipt(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: ReceiptCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ReceiptOut]:
@@ -63,7 +63,7 @@ def get_receipt(
@router.put("/{receipt_id}", response_model=ApiResponse[ReceiptOut], summary="编辑回款")
def update_receipt(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)],
payload: ReceiptUpdateIn,
meta: RequestMetaDep,
@@ -80,7 +80,7 @@ def update_receipt(
@router.delete("/{receipt_id}", response_model=ApiResponse[None], summary="删除回款")
def delete_receipt(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
receipt_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
+4 -4
View File
@@ -8,7 +8,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, get_current_user
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.shipment import (
@@ -39,7 +39,7 @@ def list_shipments(
@router.post("", response_model=ApiResponse[ShipmentOut], summary="创建发货单")
def create_shipment(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
payload: ShipmentCreateIn,
meta: RequestMetaDep,
) -> ApiResponse[ShipmentOut]:
@@ -63,7 +63,7 @@ def get_shipment(
@router.put("/{shipment_id}", response_model=ApiResponse[ShipmentOut], summary="编辑发货单")
def update_shipment(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)],
payload: ShipmentUpdateIn,
meta: RequestMetaDep,
@@ -80,7 +80,7 @@ def update_shipment(
@router.delete("/{shipment_id}", response_model=ApiResponse[None], summary="删除发货单")
def delete_shipment(
session: DbSession,
current_user: CurrentUser,
current_user: PayrollWriter,
shipment_id: Annotated[int, Path(ge=1)],
meta: RequestMetaDep,
) -> ApiResponse[None]:
+5 -9
View File
@@ -10,18 +10,14 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from pydantic import BaseModel, Field
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_MARKETING_OPS
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.calc import CalcReceiptOut, LedgerOut, LedgerQuery
from app.services import calc_service
router = APIRouter(prefix="/calc", tags=["绩效工资 · 绩效核算"])
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/calc", tags=["绩效工资 · 绩效核算"])
_CalcWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
# 重算是授权动作:管理员/财务(营销后勤不可重算)
_RecalcUser = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE))]
@router.get("/ledger", response_model=ApiResponse[PageResult[LedgerOut]], summary="绩效台账")
@@ -50,7 +46,7 @@ def get_ledger(
def calc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _CalcWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""把回款固化为台账行(每笔回款仅一次;业务级一次性计提,重复回款仅登记进度)。"""
@@ -81,7 +77,7 @@ def recalc_receipt(
receipt_id: Annotated[int, Path(ge=1)],
payload: RecalcIn,
session: DbSession,
current_user: _RecalcUser,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[CalcReceiptOut]:
"""重算(管理员/财务):作废旧版本、按当前参数落新版本并留痕。"""
@@ -97,7 +93,7 @@ def recalc_receipt(
def batch_calc(
payload: BatchIn,
session: DbSession,
current_user: _CalcWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[list[dict]]:
"""批量核算:逐笔独立事务,返回逐笔成败清单(失败不中断其它笔)。"""
+108
View File
@@ -0,0 +1,108 @@
"""正式工资资料、FIFO核算和逐段明细入口。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import DbSession, LoginUser, PayrollWriter, RequestMetaDep
from app.core.response import PageQuery, ok
from app.schemas.payroll import AllocationAdjustmentIn, PayrollMonthIn, PayrollSetupIn
from app.services import payroll_calculation, payroll_inputs, payroll_months, settlement_service
router = APIRouter(prefix="/payroll", tags=["正式工资核算"])
@router.get("/months/{month}")
def month_state(
month: Annotated[str, Path(pattern=r"^20[0-9]{2}-(0[1-9]|1[0-2])$")],
session: DbSession,
_: LoginUser,
):
from sqlalchemy import select
from app.core.month import validate_month
from app.models import MonthlySettlement, PayrollMonth
validate_month(month)
control = session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
existing = session.scalar(
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
)
return ok(
{
"month": month,
"allocation_locked": bool(existing or (control and control.generated_at)),
"maker_name": control.maker_name if control else None,
}
)
@router.get("/businesses/{business_id}/input")
def input_detail(business_id: int, session: DbSession, _: LoginUser):
record = payroll_calculation.latest_input(session, business_id)
return ok(
None
if record is None
else {
"id": record.id,
"version": record.version,
"source": record.source_database,
"fingerprint": record.fingerprint,
"data": record.data_json,
}
)
@router.post("/businesses/{business_id}/input")
def save_input(
business_id: int,
payload: PayrollSetupIn,
session: DbSession,
actor: PayrollWriter,
meta: RequestMetaDep,
):
return ok(payroll_inputs.setup(session, business_id, payload, actor, meta))
@router.post("/businesses/{business_id}/calculate")
def calculate(
business_id: int,
payload: PayrollMonthIn,
session: DbSession,
actor: PayrollWriter,
meta: RequestMetaDep,
):
return ok(
payroll_calculation.calculate(
session, business_id, payload.month, operator=actor, meta=meta, reason=payload.reason
)
)
@router.get("/ledger/{ledger_id}/allocations")
def allocations(
ledger_id: int, query: Annotated[PageQuery, Query()], session: DbSession, _: LoginUser
):
return ok(payroll_calculation.allocation_page(session, ledger_id, query))
@router.post("/allocation-adjustments")
def adjustment(
payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep
):
from app.core.exceptions import BadRequestError
from app.models import PerformanceLedger
from app.schemas.settlement import AdjustmentCreateIn
old = session.get(PerformanceLedger, payload.ledger_id)
if old is None or old.settlement_id is None:
raise BadRequestError("请选择已生成工资的原台账")
data = AdjustmentCreateIn(
settlement_month=payroll_months.current_month(),
personnel_id=payload.personnel_id,
direction=payload.direction,
amount=payload.amount,
reason=payload.reason,
source_ledger_id=old.id,
)
return ok(settlement_service.create_adjustment(session, data, operator=actor, meta=meta))
+5 -8
View File
@@ -8,10 +8,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Path, Query
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_MARKETING_OPS
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, get_current_user
from app.core.response import ApiResponse, PageResult, ok
from app.models import User
from app.schemas.settlement import (
AdjustmentCreateIn,
SettlementDetailOut,
@@ -21,16 +19,15 @@ from app.schemas.settlement import (
)
from app.services import settlement_service
router = APIRouter(prefix="/settlements", tags=["绩效工资 · 月度结算"])
router = APIRouter(dependencies=[Depends(get_current_user)], prefix="/settlements", tags=["绩效工资 · 月度结算"])
_SettlementWriter = Annotated[User, Depends(require_roles(ROLE_ADMIN, ROLE_MARKETING_OPS))]
@router.post("/generate", response_model=ApiResponse[SettlementDetailOut], summary="生成月结快照")
def generate(
payload: SettlementGenerateIn,
session: DbSession,
current_user: _SettlementWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""按确认回款口径生成指定月份的结算快照(草稿)。"""
@@ -66,7 +63,7 @@ def get_settlement(
def submit(
settlement_id: Annotated[int, Path(ge=1)],
session: DbSession,
current_user: _SettlementWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[SettlementDetailOut]:
"""提交审批:开关关闭=自动通过并锁定;开启=待财务复核。"""
@@ -83,7 +80,7 @@ def submit(
def create_adjustment(
payload: AdjustmentCreateIn,
session: DbSession,
current_user: _SettlementWriter,
current_user: PayrollWriter,
meta: RequestMetaDep,
) -> ApiResponse[dict]:
"""新建调整单:开关关闭=提交即生效(自动通过留痕);开启=走审批。"""
+9 -7
View File
@@ -4,7 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.api.deps import DbSession, PayrollWriter, RequestMetaDep, WorkbenchReader
from app.api.v1.system.tplus_workbench import router as workbench_router
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.tplus import (
TPlusBatchDetailOut,
@@ -19,23 +20,24 @@ from app.schemas.tplus import (
from app.services.tplus import query_service, task_service
router = APIRouter(prefix="/tplus", tags=["系统管理 · T+ 数据同步"])
router.include_router(workbench_router)
BatchNumber = Annotated[str, Path(min_length=1, max_length=32, pattern=r"^[A-Za-z0-9_-]+$")]
@router.get("/overview", response_model=ApiResponse[TPlusOverviewOut])
def overview(session: DbSession, _: AdminUser):
def overview(session: DbSession, _: WorkbenchReader):
"""查询配置状态、任务统计和最近核验。"""
return ok(query_service.overview(session))
@router.get("/batches", response_model=ApiResponse[PageResult[TPlusBatchOut]])
def batches(session: DbSession, _: AdminUser, query: Annotated[TPlusBatchQuery, Query()]):
def batches(session: DbSession, _: WorkbenchReader, query: Annotated[TPlusBatchQuery, Query()]):
"""分页查询批次元数据。"""
return ok(query_service.list_batches(session, query))
@router.get("/batches/{batch_no}", response_model=ApiResponse[TPlusBatchDetailOut])
def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
def detail(session: DbSession, _: WorkbenchReader, batch_no: BatchNumber):
"""查询批次详情与受控的核验报告。"""
return ok(query_service.get_batch(session, batch_no))
@@ -43,7 +45,7 @@ def detail(session: DbSession, _: AdminUser, batch_no: BatchNumber):
@router.get("/batches/{batch_no}/errors", response_model=ApiResponse[PageResult[TPlusErrorOut]])
def errors(
session: DbSession,
_: AdminUser,
_: WorkbenchReader,
batch_no: BatchNumber,
query: Annotated[TPlusErrorQuery, Query()],
):
@@ -52,7 +54,7 @@ def errors(
@router.post("/batches", response_model=ApiResponse[TPlusBatchDetailOut])
def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload: TPlusCreateIn):
def create(session: DbSession, admin: PayrollWriter, meta: RequestMetaDep, payload: TPlusCreateIn):
"""将读取或核验任务持久化排队。"""
batch = task_service.create_task(session, payload, task_service.TaskActor(admin, meta))
return ok(query_service.get_batch(session, batch.batch_no))
@@ -61,7 +63,7 @@ def create(session: DbSession, admin: AdminUser, meta: RequestMetaDep, payload:
@router.post("/batches/{batch_no}/retry", response_model=ApiResponse[TPlusBatchDetailOut])
def retry(
session: DbSession,
admin: AdminUser,
admin: PayrollWriter,
meta: RequestMetaDep,
batch_no: BatchNumber,
payload: TPlusRetryIn,
@@ -0,0 +1,223 @@
"""T+ 基础导入和预演的管理员入口;HTTP 不执行长查询或核算。"""
from typing import Annotated
from fastapi import APIRouter, Path, Query
from sqlalchemy import func, select
from starlette.responses import StreamingResponse
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
from app.core.response import ApiResponse, PageQuery, PageResult, ok
from app.models import TPlusSyncRecord, TPlusWorkLine
from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery
from app.services.tplus import workbench
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.snapshots import source_statement
from app.services.tplus.task_service import _source_database
router = APIRouter()
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
@router.get("/facts", response_model=ApiResponse[PageResult[dict]])
def facts(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
"""有界查询每项补充资料的当前版本。"""
return ok(workbench.list_facts(session, query))
@router.post("/facts", response_model=ApiResponse[dict])
def save_fact(session: DbSession, admin: PayrollWriter, payload: FactIn):
"""追加有依据的人工事实;旧版本不覆盖。"""
return ok(workbench.save_fact(session, payload, admin.id))
@router.get("/facts/{fact_id}/history", response_model=ApiResponse[PageResult[dict]])
def fact_history(
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
):
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
from app.core.exceptions import NotFoundError
from app.models import TPlusFact
fact = session.get(TPlusFact, fact_id)
if fact is None or fact.source_database != _source_database():
raise NotFoundError("补充资料不存在")
conditions = [
TPlusFact.source_database == fact.source_database,
TPlusFact.kind == fact.kind,
TPlusFact.fact_key == fact.fact_key,
]
total = session.scalar(select(func.count()).select_from(TPlusFact).where(*conditions))
rows = session.scalars(
select(TPlusFact)
.where(*conditions)
.order_by(TPlusFact.version.desc())
.offset(query.offset)
.limit(query.page_size)
)
return ok(
PageResult(
records=[workbench.fact_out(f) for f in rows],
total=total or 0,
page_num=query.page_num,
page_size=query.page_size,
)
)
@router.post("/work-runs", response_model=ApiResponse[dict])
def create_work(session: DbSession, admin: PayrollWriter, payload: WorkCreateIn):
"""创建导入预览、执行导入或核算预演任务。"""
return ok(workbench.queue_work(session, payload, admin.id))
@router.get("/work-runs", response_model=ApiResponse[PageResult[dict]])
def work_runs(session: DbSession, _: WorkbenchReader, query: Annotated[WorkQuery, Query()]):
"""分页查询任务,所有金额结果仅为预演。"""
return ok(workbench.list_work(session, query))
@router.get("/work-runs/{run_no}", response_model=ApiResponse[dict])
def work_detail(session: DbSession, _: WorkbenchReader, run_no: RunNo):
"""查询规则版本、输入指纹及结果。"""
return ok(workbench.work_out(workbench.get_run(session, run_no)))
@router.get("/work-runs/{run_no}/lines", response_model=ApiResponse[PageResult[dict]])
def work_lines(
session: DbSession, _: WorkbenchReader, run_no: RunNo, query: Annotated[WorkQuery, Query()]
):
"""分页查看新增、待补及分配明细,不公开原始快照 JSON。"""
return ok(workbench.list_lines(session, run_no, query))
@router.get("/source-records", response_model=ApiResponse[PageResult[dict]])
def source_choices(
session: DbSession,
_: WorkbenchReader,
query: Annotated[SourceChoiceQuery, Query()],
):
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
from app.core.exceptions import BadRequestError
try:
statement = source_statement(session, _source_database(), query.entity)
except workbench.WorkbenchError:
raise BadRequestError("请先完整读取相关源数据") from None
if query.search:
escaped = query.search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
statement = statement.where(TPlusSyncRecord.source_no.like(f"%{escaped}%", escape="\\"))
total = session.scalar(select(func.count()).select_from(statement.subquery()))
rows = session.scalars(
statement.order_by(TPlusSyncRecord.source_id).offset(query.offset).limit(query.page_size)
)
records = [
{
"source_id": r.source_id,
"source_no": r.source_no,
"name": str(r.payload_json.get("name") or ""),
"parent_id": str(r.payload_json.get("source_parent_id") or ""),
"date": str(
r.payload_json.get("voucherdate") or r.payload_json.get("voucherDate") or ""
)[:10],
}
for r in rows
]
return ok(
PageResult(
records=records, total=total or 0, page_num=query.page_num, page_size=query.page_size
)
)
@router.get("/work-runs/{run_no}/report")
def work_report(session: DbSession, _: WorkbenchReader, run_no: RunNo):
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
import csv
import io
workbench.get_run(session, run_no)
def rows():
buffer = io.StringIO()
writer = csv.writer(buffer)
yield "\ufeff"
writer.writerow(
[
"运行编号",
"行号",
"类型",
"源ID或平账组",
"处理结果",
"源编号",
"名称",
"回款单号",
"关联凭证示例",
"发货单号",
"匹配金额",
"分配后该明细剩余应收",
"回款天数",
"指导成本分摊",
"折算利润",
"利润门槛(百分数)",
"计提比例",
"计提金额",
"归属工号",
"原因",
"说明",
]
)
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
statement = (
select(TPlusWorkLine)
.where(TPlusWorkLine.run_no == run_no)
.order_by(TPlusWorkLine.line_no)
)
for row in session.scalars(statement).yield_per(200):
data = row.data_json
values = data.get("values") or {}
fields = [
run_no,
row.line_no,
row.entity_type,
row.source_id,
ACTION_LABELS.get(row.action, row.action),
data.get("source_no", ""),
values.get("personnel_name")
or values.get("product_name")
or values.get("company_name")
or data.get("source_name")
or "",
*[
data.get(k, "")
for k in (
"receipt_no",
"accounting_voucher_code",
"shipment_no",
"amount",
"shipment_remaining_after",
"days",
"allocated_cost",
"converted_profit",
"profit_threshold",
"rate_percent",
"performance",
"personnel_id",
)
],
reason_label(row.reason)
or ("经理本人业务,已排除绩效汇总" if data.get("excluded_manager_own") else ""),
"仅供核对,未写入正式工资",
]
writer.writerow([spreadsheet_value(v) for v in fields])
yield buffer.getvalue()
buffer.seek(0)
buffer.truncate(0)
return StreamingResponse(
rows(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
)