refactor(后端): 将工资与工作区业务逻辑下沉服务层

将月份状态、资料输出、调整单编排及工作区查询、
CSV 生成移至现有服务层,路由仅绑定参数并返回响应。

保持接口路径、参数、响应模型和权限依赖一致,
补充九项内存库行为回归并同步测试记录。
This commit is contained in:
liuwenxin committed 2026-11-02 09:42:55 +08:00
1 parent 019817d6dc
commit 71b7846a79
9 files changed
+426 -192

No files matched your search

+14
View File
@@ -22,6 +22,20 @@ from app.services.payroll_calculation import latest_input
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):
with audit_service.audit(
session,
+14
View File
@@ -15,6 +15,20 @@ def current_month() -> str:
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):
"""INSERT 的唯一键串行化首次建行,避免两个请求同时认为月份不存在。"""
ensure_payroll_writer(operator)
@@ -534,6 +534,26 @@ def _detail_of(session: Session, settlement: MonthlySettlement) -> SettlementDet
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(
session: Session,
payload,
+148 -1
View File
@@ -12,8 +12,9 @@ from sqlalchemy.orm import defer
from app.core.exceptions import BadRequestError, NotFoundError
from app.core.response import PageResult
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.services.tplus.report_labels import ACTION_LABELS, reason_label, spreadsheet_value
from app.services.tplus.task_service import _source_database, new_batch_no
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):
"""调用方持有原有全局锁;中断任务保留明细,禁止不明状态自动应用。"""
for old in session.scalars(select(TPlusWorkRun).where(TPlusWorkRun.status == "RUNNING")):