refactor(后端): 将工资与工作区业务逻辑下沉服务层
将月份状态、资料输出、调整单编排及工作区查询、 CSV 生成移至现有服务层,路由仅绑定参数并返回响应。 保持接口路径、参数、响应模型和权限依赖一致, 补充九项内存库行为回归并同步测试记录。
This commit is contained in:
parent
019817d6dc
commit
71b7846a79
@ -18,39 +18,12 @@ def month_state(
|
|||||||
session: DbSession,
|
session: DbSession,
|
||||||
_: LoginUser,
|
_: LoginUser,
|
||||||
):
|
):
|
||||||
from sqlalchemy import select
|
return ok(payroll_months.month_state(session, month))
|
||||||
|
|
||||||
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")
|
@router.get("/businesses/{business_id}/input")
|
||||||
def input_detail(business_id: int, session: DbSession, _: LoginUser):
|
def input_detail(business_id: int, session: DbSession, _: LoginUser):
|
||||||
record = payroll_calculation.latest_input(session, business_id)
|
return ok(payroll_inputs.detail(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")
|
@router.post("/businesses/{business_id}/input")
|
||||||
@ -90,19 +63,6 @@ def allocations(
|
|||||||
def adjustment(
|
def adjustment(
|
||||||
payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep
|
payload: AllocationAdjustmentIn, session: DbSession, actor: PayrollWriter, meta: RequestMetaDep
|
||||||
):
|
):
|
||||||
from app.core.exceptions import BadRequestError
|
return ok(
|
||||||
from app.models import PerformanceLedger
|
settlement_service.create_allocation_adjustment(session, payload, operator=actor, meta=meta)
|
||||||
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))
|
|
||||||
|
|||||||
@ -3,17 +3,12 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Path, Query
|
from fastapi import APIRouter, Path, Query
|
||||||
from sqlalchemy import func, select
|
|
||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
|
from app.api.deps import DbSession, PayrollWriter, WorkbenchReader
|
||||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
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.schemas.tplus_workbench import FactIn, SourceChoiceQuery, WorkCreateIn, WorkQuery
|
||||||
from app.services.tplus import workbench
|
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()
|
router = APIRouter()
|
||||||
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
|
RunNo = Annotated[str, Path(pattern=r"^[A-Za-z0-9_-]+$", max_length=32)]
|
||||||
@ -36,33 +31,7 @@ def fact_history(
|
|||||||
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
|
session: DbSession, _: WorkbenchReader, fact_id: int, query: Annotated[PageQuery, Query()]
|
||||||
):
|
):
|
||||||
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
||||||
from app.core.exceptions import NotFoundError
|
return ok(workbench.fact_history(session, fact_id, query))
|
||||||
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])
|
@router.post("/work-runs", response_model=ApiResponse[dict])
|
||||||
@ -98,126 +67,14 @@ def source_choices(
|
|||||||
query: Annotated[SourceChoiceQuery, Query()],
|
query: Annotated[SourceChoiceQuery, Query()],
|
||||||
):
|
):
|
||||||
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
|
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
|
||||||
from app.core.exceptions import BadRequestError
|
return ok(workbench.source_choices(session, query))
|
||||||
|
|
||||||
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")
|
@router.get("/work-runs/{run_no}/report")
|
||||||
def work_report(session: DbSession, _: WorkbenchReader, run_no: RunNo):
|
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(
|
return StreamingResponse(
|
||||||
rows(),
|
workbench.report_csv(session, run_no),
|
||||||
media_type="text/csv; charset=utf-8",
|
media_type="text/csv; charset=utf-8",
|
||||||
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
|
headers={"Content-Disposition": f'attachment; filename="{run_no}-preview.csv"'},
|
||||||
)
|
)
|
||||||
|
|||||||
@ -22,6 +22,20 @@ from app.services.payroll_calculation import latest_input
|
|||||||
from app.services.tplus.workbench import digest
|
from app.services.tplus.workbench import digest
|
||||||
|
|
||||||
|
|
||||||
|
def detail(session, business_id):
|
||||||
|
"""返回最新核算资料;未准备资料的业务保持空结果。"""
|
||||||
|
record = latest_input(session, business_id)
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": record.id,
|
||||||
|
"version": record.version,
|
||||||
|
"source": record.source_database,
|
||||||
|
"fingerprint": record.fingerprint,
|
||||||
|
"data": record.data_json,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def setup(session, business_id, payload, operator, meta=None):
|
def setup(session, business_id, payload, operator, meta=None):
|
||||||
with audit_service.audit(
|
with audit_service.audit(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@ -15,6 +15,20 @@ def current_month() -> str:
|
|||||||
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m")
|
return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
def month_state(session, month):
|
||||||
|
"""查询生成锁及制作人,兼容升级前已存在的月结记录。"""
|
||||||
|
validate_month(month)
|
||||||
|
control = session.scalar(select(PayrollMonth).where(PayrollMonth.month == month))
|
||||||
|
existing = session.scalar(
|
||||||
|
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"month": month,
|
||||||
|
"allocation_locked": bool(existing or (control and control.generated_at)),
|
||||||
|
"maker_name": control.maker_name if control else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def lock_month(session, month, operator):
|
def lock_month(session, month, operator):
|
||||||
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
|
||||||
ensure_payroll_writer(operator)
|
ensure_payroll_writer(operator)
|
||||||
|
|||||||
@ -534,6 +534,26 @@ def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDet
|
|||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
def create_allocation_adjustment(session, payload, *, operator, meta=None):
|
||||||
|
"""校验已生成工资的原台账,并将分配差额转换为当月调整单。"""
|
||||||
|
from app.models import PerformanceLedger
|
||||||
|
from app.schemas.settlement import AdjustmentCreateIn
|
||||||
|
from app.services import payroll_months
|
||||||
|
|
||||||
|
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 create_adjustment(session, data, operator=operator, meta=meta)
|
||||||
|
|
||||||
|
|
||||||
def create_adjustment(
|
def create_adjustment(
|
||||||
session: Session,
|
session: Session,
|
||||||
payload,
|
payload,
|
||||||
|
|||||||
@ -12,8 +12,9 @@ from sqlalchemy.orm import defer
|
|||||||
from app.core.exceptions import BadRequestError, NotFoundError
|
from app.core.exceptions import BadRequestError, NotFoundError
|
||||||
from app.core.response import PageResult
|
from app.core.response import PageResult
|
||||||
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
from app.core.tplus_lock import TPlusBusyError, sync_lock
|
||||||
from app.models import TPlusFact, TPlusWorkLine, TPlusWorkRun
|
from app.models import TPlusFact, TPlusSyncRecord, TPlusWorkLine, TPlusWorkRun
|
||||||
from app.schemas.tplus_workbench import WorkCreateIn
|
from app.schemas.tplus_workbench import WorkCreateIn
|
||||||
|
from app.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
|
||||||
from app.services.tplus.task_service import _source_database, new_batch_no
|
from app.services.tplus.task_service import _source_database, new_batch_no
|
||||||
|
|
||||||
RULE_VERSION = "2026-10-30.2"
|
RULE_VERSION = "2026-10-30.2"
|
||||||
@ -244,6 +245,152 @@ def list_lines(session, number, query):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fact_history(session, fact_id, query):
|
||||||
|
"""按事实身份分页查看修改历史,不允许跨账套读取。"""
|
||||||
|
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 PageResult(
|
||||||
|
records=[fact_out(f) for f in rows],
|
||||||
|
total=total or 0,
|
||||||
|
page_num=query.page_num,
|
||||||
|
page_size=query.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def source_choices(session, query):
|
||||||
|
"""仅提供挑选资料所需的源编号和名称,不返回电话、地址和原始载荷。"""
|
||||||
|
from app.services.tplus.snapshots import source_statement
|
||||||
|
|
||||||
|
try:
|
||||||
|
statement = source_statement(session, _source_database(), query.entity)
|
||||||
|
except 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 PageResult(
|
||||||
|
records=records, total=total or 0, page_num=query.page_num, page_size=query.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def report_csv(session, run_no):
|
||||||
|
"""导出全部预演明细,防止源单号被电子表格当作公式执行。"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
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 rows()
|
||||||
|
|
||||||
|
|
||||||
def execute_next(session):
|
def execute_next(session):
|
||||||
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
|
||||||
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
|
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):
|
||||||
|
|||||||
@ -1,5 +1,15 @@
|
|||||||
# 后端测试
|
# 后端测试
|
||||||
|
|
||||||
|
## 审查问题修复核验(2026-11-02)
|
||||||
|
|
||||||
|
- 修复正式核算起付比例的日期范围:2026-03-01 前的发货由期初应收承接,不再计入新业务销售额分母;与 FIFO 的启用边界保持一致。
|
||||||
|
- 新增 2 项历史发货回归(含 2 月末边界)。修复前均因起付门槛误判而失败;修复后,期初 100 元、旧发货 100 元、新发货 100 元、回款 200 元的样本正确支付新业务全部绩效。
|
||||||
|
- 将工资月份状态、核算资料出参、分配调整编排,以及 T+ 事实历史、源记录选择、CSV 生成迁入 service;路由的路径、参数、响应模型和权限依赖保持一致。
|
||||||
|
- 新增 9 项内存库回归,覆盖月份锁定及旧月结兼容、历史分页与账套隔离、搜索通配符转义、报告提前校验、资料与当月调整归属、CSV 前导零和公式防护。
|
||||||
|
- 验证:`backend/.venv-workbench/Scripts/python.exe -m pytest tests/unit -q --tb=short -p no:cacheprovider`,**804 passed**;受影响文件 Ruff 检查和差异空白检查通过。保留现有 Starlette TestClient 弃用提醒。
|
||||||
|
- 提交前从暂存区导出独立目录验证:**797 项单元测试通过**(未包含另一路首页的 7 项单元测试),暂存 Python 文件 Ruff、前端类型检查和生产构建通过;保留既有大分块提醒。独立目录仅使用临时测试配置,未复制真实凭据。
|
||||||
|
- 相关 11 项 MySQL 集成用例已通过收集检查,但本轮未执行;未运行数据库迁移或清理,不将内存库结果记为目标 MySQL 或生产验收。前端无改动,未重复构建。
|
||||||
|
|
||||||
> AI 摘要:2026-10-28 的 P0.5 连接修复已通过 19 项定点测试和 1001 项完整回归;使用本轮独立 MySQL/Redis 容器,未连接客户 T+ 源库。真实备份源 10.50.1600 验收仍未执行。
|
> AI 摘要:2026-10-28 的 P0.5 连接修复已通过 19 项定点测试和 1001 项完整回归;使用本轮独立 MySQL/Redis 容器,未连接客户 T+ 源库。真实备份源 10.50.1600 验收仍未执行。
|
||||||
|
|
||||||
## 连接策略修复核验(2026-10-28)
|
## 连接策略修复核验(2026-10-28)
|
||||||
|
|||||||
@ -41,7 +41,6 @@ def workspace(monkeypatch):
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
|
monkeypatch.setattr("app.services.tplus.task_service.get_settings", lambda: settings)
|
||||||
monkeypatch.setattr(workbench, "_source_database", lambda: source)
|
monkeypatch.setattr(workbench, "_source_database", lambda: source)
|
||||||
monkeypatch.setattr("app.api.v1.system.tplus_workbench._source_database", lambda: source)
|
|
||||||
with get_session_factory()() as db:
|
with get_session_factory()() as db:
|
||||||
yield db, source
|
yield db, source
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|||||||
213
backend/tests/unit/test_payroll_workbench_api.py
Normal file
213
backend/tests/unit/test_payroll_workbench_api.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"""用独立内存库守住路由分层调整前后的分页、账套隔离和工资状态契约。"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.v1 import payroll as payroll_api
|
||||||
|
from app.api.v1.system import tplus_workbench as workbench_api
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.exceptions import NotFoundError
|
||||||
|
from app.core.response import PageQuery
|
||||||
|
from app.models import (
|
||||||
|
AdjustmentOrder,
|
||||||
|
MonthlySettlement,
|
||||||
|
PayrollMonth,
|
||||||
|
PerformanceLedger,
|
||||||
|
TPlusFact,
|
||||||
|
TPlusWorkLine,
|
||||||
|
TPlusWorkRun,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from app.schemas.payroll import AllocationAdjustmentIn
|
||||||
|
from app.schemas.tplus_workbench import FactIn, SourceChoiceQuery
|
||||||
|
from app.services import payroll_calculation, payroll_months, settlement_service
|
||||||
|
from app.services.tplus import task_service, workbench
|
||||||
|
from tests.unit.test_formal_payroll import imported
|
||||||
|
from tests.unit.test_formal_payroll import payroll_case as payroll_case
|
||||||
|
from tests.unit.test_tplus_workbench import fact, source
|
||||||
|
from tests.unit.test_tplus_workbench import work_session as work_session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", ["empty", "draft", "generated", "legacy"])
|
||||||
|
def test_月份状态兼容未生成和历史工资(work_session, state):
|
||||||
|
s = work_session
|
||||||
|
user = User(username="month_maker", password="test_hash_only", status=1)
|
||||||
|
s.add(user)
|
||||||
|
s.flush()
|
||||||
|
if state in ("draft", "generated"):
|
||||||
|
s.add(
|
||||||
|
PayrollMonth(
|
||||||
|
month="2026-03",
|
||||||
|
maker_id=user.id,
|
||||||
|
maker_name=user.username,
|
||||||
|
generated_at=datetime(2026, 3, 31) if state == "generated" else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif state == "legacy":
|
||||||
|
s.add(
|
||||||
|
MonthlySettlement(
|
||||||
|
settlement_month="2026-03", submitter_id=user.id, submitter_name=user.username
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
response = payroll_api.month_state("2026-03", s, user)
|
||||||
|
|
||||||
|
assert response.data == {
|
||||||
|
"month": "2026-03",
|
||||||
|
"allocation_locked": state in ("generated", "legacy"),
|
||||||
|
"maker_name": user.username if state in ("draft", "generated") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def source_config(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
task_service,
|
||||||
|
"resolve_tplus_settings",
|
||||||
|
lambda _settings: SimpleNamespace(t_plus_database="test_source"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_事实历史分页保持倒序且隔离账套(work_session, source_config):
|
||||||
|
s = work_session
|
||||||
|
first = fact(s, "PRODUCT", "9", {"category": "municipal_water", "unit": "件"})
|
||||||
|
workbench.save_fact(
|
||||||
|
s,
|
||||||
|
FactIn(
|
||||||
|
kind="PRODUCT",
|
||||||
|
fact_key="9",
|
||||||
|
expected_version=1,
|
||||||
|
evidence="修改分类的测试依据",
|
||||||
|
data={"category": "rail_transit", "unit": "件"},
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
foreign = TPlusFact(
|
||||||
|
source_database="other_source",
|
||||||
|
kind="PRODUCT",
|
||||||
|
fact_key="9",
|
||||||
|
version=99,
|
||||||
|
data_json={"category": "rail_transit", "unit": "件"},
|
||||||
|
evidence="其他账套资料",
|
||||||
|
requested_by=1,
|
||||||
|
)
|
||||||
|
s.add(foreign)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
page = workbench_api.fact_history(s, None, first["id"], PageQuery(pageSize=1)).data
|
||||||
|
next_page = workbench_api.fact_history(
|
||||||
|
s, None, first["id"], PageQuery(pageNum=2, pageSize=1)
|
||||||
|
).data
|
||||||
|
|
||||||
|
assert page.total == next_page.total == 2
|
||||||
|
assert [row["version"] for row in page.records] == [2]
|
||||||
|
assert [row["version"] for row in next_page.records] == [1]
|
||||||
|
with pytest.raises(NotFoundError, match="补充资料不存在"):
|
||||||
|
workbench_api.fact_history(s, None, foreign.id, PageQuery())
|
||||||
|
|
||||||
|
|
||||||
|
def test_源选择按字面搜索且只返回必要字段(work_session, source_config):
|
||||||
|
s = work_session
|
||||||
|
source(
|
||||||
|
s,
|
||||||
|
"products",
|
||||||
|
[
|
||||||
|
{"id": 1, "code": "CODE_%1", "name": "目标产品", "unit_name": "件"},
|
||||||
|
{"id": 2, "code": "CODE_X1", "name": "其他产品", "unit_name": "件"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
page = workbench_api.source_choices(
|
||||||
|
s, None, SourceChoiceQuery(entity="products", search="CODE_%", pageSize=1)
|
||||||
|
).data
|
||||||
|
|
||||||
|
assert page.total == 1
|
||||||
|
assert page.records == [
|
||||||
|
{"source_id": "1", "source_no": "CODE_%1", "name": "目标产品", "parent_id": "", "date": ""}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_报告在开始流式响应前校验运行存在及账套(work_session, source_config):
|
||||||
|
with pytest.raises(NotFoundError, match="工作区运行不存在"):
|
||||||
|
workbench_api.work_report(work_session, None, "missing-run")
|
||||||
|
|
||||||
|
|
||||||
|
def test_正式输入及分配调整接口保留原台账与当月归属(payroll_case, monkeypatch):
|
||||||
|
s, user, _ = payroll_case
|
||||||
|
monkeypatch.setattr(get_settings(), "payroll_allow_legacy_calculation", False)
|
||||||
|
monkeypatch.setattr(get_settings(), "payroll_approval_enabled", False)
|
||||||
|
monkeypatch.setattr(payroll_months, "current_month", lambda: "2026-04")
|
||||||
|
assert payroll_api.input_detail(99999, s, user).data is None
|
||||||
|
business = imported(payroll_case)
|
||||||
|
detail = payroll_api.input_detail(business.id, s, user).data
|
||||||
|
assert detail["version"] == 1 and detail["source"] == "test_source"
|
||||||
|
assert detail["data"]["maps"]["receipts"]
|
||||||
|
result = payroll_calculation.calculate(s, business.id, "2026-03", operator=user)
|
||||||
|
original = s.get(PerformanceLedger, result["ledger_ids"][0])
|
||||||
|
settlement = settlement_service.generate(s, "2026-03", operator=user)
|
||||||
|
|
||||||
|
response = payroll_api.adjustment(
|
||||||
|
AllocationAdjustmentIn(
|
||||||
|
ledger_id=original.id,
|
||||||
|
personnel_id=original.personnel_id,
|
||||||
|
direction=1,
|
||||||
|
amount="1.00",
|
||||||
|
reason="核对原工资分配差额",
|
||||||
|
),
|
||||||
|
s,
|
||||||
|
user,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
order = s.get(AdjustmentOrder, response.data["id"])
|
||||||
|
assert response.data["status"] == 2
|
||||||
|
assert order.settlement_month == "2026-04" and order.source_ledger_id == original.id
|
||||||
|
assert order.attribution_json["manager_id"] == original.snapshot_json["manager_id"]
|
||||||
|
assert s.get(MonthlySettlement, settlement.id).total_payable == settlement.total_payable
|
||||||
|
|
||||||
|
|
||||||
|
def test_报告保留完整字段前导零与公式防护(work_session):
|
||||||
|
s = work_session
|
||||||
|
s.add(
|
||||||
|
TPlusWorkRun(
|
||||||
|
run_no="report-run",
|
||||||
|
request_key=str(uuid4()),
|
||||||
|
source_database="test_source",
|
||||||
|
requested_by=1,
|
||||||
|
kind="CALC_PREVIEW",
|
||||||
|
status="SUCCEEDED",
|
||||||
|
input_json={},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.add(
|
||||||
|
TPlusWorkLine(
|
||||||
|
run_no="report-run",
|
||||||
|
line_no=1,
|
||||||
|
entity_type="group",
|
||||||
|
source_id="group-1",
|
||||||
|
action="READY",
|
||||||
|
data_json={
|
||||||
|
"receipt_no": "00043",
|
||||||
|
"shipment_no": "=1+1",
|
||||||
|
"amount": "100.00",
|
||||||
|
"rate_percent": "0.2%",
|
||||||
|
"performance": "0.20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
content = "".join(workbench.report_csv(s, "report-run"))
|
||||||
|
rows = list(csv.DictReader(io.StringIO(content.lstrip("\ufeff"))))
|
||||||
|
|
||||||
|
assert content.startswith("\ufeff") and len(rows) == 1
|
||||||
|
assert len(rows[0]) == 21
|
||||||
|
assert rows[0]["回款单号"] == "'00043" and rows[0]["发货单号"] == "'=1+1"
|
||||||
|
assert rows[0]["匹配金额"] == "100.00" and rows[0]["计提比例"] == "0.2%"
|
||||||
|
assert rows[0]["计提金额"] == "0.20" and rows[0]["说明"] == "仅供核对,未写入正式工资"
|
||||||
Loading…
Reference in New Issue
Block a user