打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
366 lines
16 KiB
Python
366 lines
16 KiB
Python
"""核算预演纯函数:只分配已明确的平账组,不写正式台账。"""
|
|
|
|
from datetime import date
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
from fractions import Fraction
|
|
|
|
from app.services import formula
|
|
from app.services.personnel_roles import role_on
|
|
from app.services.rate_rules import decide
|
|
|
|
CENT = Decimal("0.01")
|
|
ZERO = Decimal("0")
|
|
START = date(2026, 3, 1)
|
|
AUGUST = date(2026, 8, 1)
|
|
|
|
|
|
def money(value) -> Decimal:
|
|
"""拒绝非有限金额,不用浮点数参与计算。"""
|
|
if isinstance(value, float):
|
|
raise ValueError("金额必须为十进制字符串")
|
|
result = Decimal(str(value))
|
|
if not result.is_finite():
|
|
raise ValueError("金额必须为有限数字")
|
|
return result
|
|
|
|
|
|
def amount_text(value: Decimal) -> str:
|
|
"""在展示边界按分四舍五入。"""
|
|
return str(value.quantize(CENT, rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def _day(value) -> date:
|
|
return date.fromisoformat(str(value)[:10])
|
|
|
|
|
|
def _key(row):
|
|
return (_day(row["date"]), int(row["source_id"]), int(row.get("line_id", 0)))
|
|
|
|
|
|
def _beneficiary(shipment, receipt, facts, as_of):
|
|
owner = str(shipment.get("personnel_id") or "")
|
|
applicable = sorted(
|
|
(f for f in facts if f.get("business_key") == shipment.get("business_key")),
|
|
key=lambda f: (_day(f["effective_date"]), f.get("version", 0)),
|
|
)
|
|
for fact in applicable:
|
|
# 在职交接仍按每次发货的归属,不把整笔绩效按固定比例拆分。
|
|
if (
|
|
fact["mode"] == "RESIGNATION"
|
|
and _day(fact["effective_date"]) <= _day(as_of)
|
|
and owner == str(fact["from_personnel_id"])
|
|
):
|
|
owner = str(fact["to_personnel_id"])
|
|
return owner
|
|
|
|
|
|
def _calculate(shipment, receipt, matched, previously_matched, facts, params):
|
|
reasons = list(shipment.get("pending", [])) + list(receipt.get("pending", []))
|
|
if any(
|
|
handover.get("business_key") == shipment.get("business_key")
|
|
and handover.get("mode") == "ACTIVE"
|
|
and not handover.get("shipping_ownership_confirmed")
|
|
for handover in facts.get("handovers", [])
|
|
):
|
|
reasons.append("HANDOVER_SHIPMENTS_PENDING")
|
|
effective = max(_day(shipment["date"]), _day(receipt["date"]))
|
|
is_prepayment = _day(receipt["date"]) < _day(shipment["date"])
|
|
if is_prepayment and not params.get("prepayment_parameter_basis"):
|
|
reasons.append("PREPAYMENT_PARAMETERS_PENDING")
|
|
if effective >= AUGUST and not params.get("august_confirmed", False):
|
|
reasons.append("AUGUST_RULE_PENDING")
|
|
owner = _beneficiary(shipment, receipt, facts.get("handovers", []), facts["as_of"])
|
|
person = facts.get("personnel", {}).get(owner)
|
|
original_person = facts.get("personnel", {}).get(str(shipment.get("personnel_id")))
|
|
role = role_on(person, effective)
|
|
original_role = role_on(original_person, _day(shipment["date"]))
|
|
if not person:
|
|
reasons.append("PERSONNEL_PENDING")
|
|
elif person.get("status") == 0:
|
|
known_later_departure = any(
|
|
record.get("mode") == "RESIGNATION"
|
|
and record.get("business_key") == shipment.get("business_key")
|
|
and str(record.get("from_personnel_id")) == owner
|
|
and _day(record["effective_date"]) > _day(facts["as_of"])
|
|
for record in facts.get("handovers", [])
|
|
)
|
|
if not known_later_departure:
|
|
reasons.append("HANDOVER_PENDING")
|
|
if person and (role is None or original_role is None):
|
|
reasons.append("PERSONNEL_ROLE_PENDING")
|
|
cost = shipment.get("cost_total")
|
|
if shipment.get("cost_month_basis") == "RECEIPT":
|
|
cost = shipment.get("cost_by_month", {}).get(str(receipt["date"])[:7])
|
|
if cost is None:
|
|
reasons.append("GUIDANCE_PENDING")
|
|
category = shipment.get("category")
|
|
if category not in ("municipal_water", "rail_transit"):
|
|
reasons.append("PRODUCT_CATEGORY_PENDING")
|
|
customer_type = shipment.get("customer_type")
|
|
if customer_type not in ("personal", "manager_personal", "enterprise"):
|
|
reasons.append("CUSTOMER_CATEGORY_PENDING")
|
|
parameter_date = (
|
|
_day(receipt["date"])
|
|
if is_prepayment and params.get("prepayment_parameter_basis") == "RECEIPT"
|
|
else effective
|
|
)
|
|
rate_params = params.get(parameter_date.isoformat())
|
|
if rate_params is None:
|
|
reasons.append("PARAMETERS_PENDING")
|
|
if reasons:
|
|
return {"status": "PENDING", "reasons": sorted(set(reasons)), "performance": None}
|
|
total = money(shipment["amount"])
|
|
cost_total = money(cost)
|
|
# 累计舍入差分使同一发货拆成多笔回款时,成本分摊合计仍等于总成本。
|
|
before = (cost_total * previously_matched / total).quantize(CENT, rounding=ROUND_HALF_UP)
|
|
after = (cost_total * (previously_matched + matched) / total).quantize(
|
|
CENT, rounding=ROUND_HALF_UP
|
|
)
|
|
allocated_cost = after - before
|
|
threshold = Fraction(str(rate_params["thresholds"][category]))
|
|
converted = formula.converted_profit(
|
|
matched, formula.profit_rate(matched - allocated_cost, matched), threshold
|
|
)
|
|
days = formula.payment_days(_day(shipment["date"]), _day(receipt["date"]))
|
|
manager_own = original_role == "sales_manager"
|
|
rate, basis, rule = decide(
|
|
rate_params, category=category, customer_type=customer_type,
|
|
role_code="sales_manager" if manager_own else role,
|
|
days=days, prepayment=is_prepayment,
|
|
)
|
|
calculation_base = matched if basis == "sales" else converted
|
|
return {
|
|
"status": "READY",
|
|
"reasons": [],
|
|
"personnel_id": owner,
|
|
"role_code": role,
|
|
"original_role_code": original_role,
|
|
"manager_id": shipment.get("manager_id"),
|
|
"manager_pending": not bool(shipment.get("manager_id")),
|
|
"effective_date": effective.isoformat(),
|
|
"days": days,
|
|
"allocated_cost": amount_text(allocated_cost),
|
|
"converted_profit": amount_text(converted),
|
|
"profit_threshold": str(rate_params["thresholds"][category]),
|
|
"rate": str(rate),
|
|
"rate_percent": format(
|
|
(Decimal(rate.numerator) * 100 / Decimal(rate.denominator)).quantize(Decimal("0.0001")),
|
|
"f",
|
|
)
|
|
.rstrip("0")
|
|
.rstrip(".")
|
|
+ "%",
|
|
"parameters": rate_params,
|
|
"parameter_date": parameter_date.isoformat(),
|
|
"performance": amount_text(formula.performance_amount(calculation_base, rate)),
|
|
"calculation_base": amount_text(calculation_base),
|
|
"basis": basis,
|
|
"rule": rule,
|
|
"excluded_manager_own": manager_own or role == "sales_manager",
|
|
}
|
|
|
|
|
|
def preview_group(group, shipments, receipts, facts, params, until: date) -> dict:
|
|
"""按明确分组分配资金,缺条件时保留金额和原因,不猜测绩效。"""
|
|
if not group.get("scope_confirmed"):
|
|
return {"status": "PENDING", "reasons": ["GROUP_SCOPE_PENDING"], "lines": []}
|
|
if not group.get("opening_confirmed") or group.get("opening_balance") is None:
|
|
return {"status": "PENDING", "reasons": ["OPENING_BALANCE_PENDING"], "lines": []}
|
|
if not group.get("historical_settlement_confirmed"):
|
|
return {"status": "PENDING", "reasons": ["HISTORICAL_SETTLEMENT_PENDING"], "lines": []}
|
|
shipments = [row for row in shipments if START <= _day(row["date"]) <= until]
|
|
receipts = [row for row in receipts if START <= _day(row["date"]) <= until]
|
|
excluded_shipments = [row for row in shipments if row.get("performance_excluded")]
|
|
shipments = [row for row in shipments if not row.get("performance_excluded")]
|
|
excluded_receipts = sorted((row for row in receipts if row.get("exclusion")), key=_key)
|
|
receipts = [row for row in receipts if not row.get("exclusion")]
|
|
amount_pending = {
|
|
reason
|
|
for row in shipments
|
|
for reason in row.get("pending", [])
|
|
if reason in ("FREIGHT_PENDING", "TAX_AMOUNT_PENDING")
|
|
}
|
|
if amount_pending:
|
|
# 净销售额未知会改变后续所有分配,不能先猜零运费再计算同组其他业务。
|
|
return {"status": "PENDING", "reasons": sorted(amount_pending), "lines": []}
|
|
opening = money(group["opening_balance"])
|
|
if opening < 0:
|
|
raise ValueError("期初应收不能为负,预收余额应单独登记")
|
|
invalid = [r for r in (*shipments, *receipts) if money(r["amount"]) <= 0]
|
|
if invalid:
|
|
return {"status": "PENDING", "reasons": ["NEGATIVE_OR_ZERO_DOCUMENT"], "lines": []}
|
|
if any("NEGATIVE_EVENT_REVIEW_REQUIRED" in r.get("pending", []) for r in shipments):
|
|
return {"status": "PENDING", "reasons": ["NEGATIVE_EVENT_REVIEW_REQUIRED"], "lines": []}
|
|
if any(
|
|
money(r["amount"]) != money(r["amount"]).quantize(CENT) for r in (*shipments, *receipts)
|
|
):
|
|
return {"status": "PENDING", "reasons": ["AMOUNT_PRECISION_PENDING"], "lines": []}
|
|
groups = {str(r.get("group_key", "")) for r in (*shipments, *receipts)}
|
|
if groups - {str(group["key"])}:
|
|
raise ValueError("禁止跨平账组分配")
|
|
ship_rows = sorted(shipments, key=_key)
|
|
cash_rows = sorted(receipts, key=_key)
|
|
ship_keys = [(str(r["source_id"]), str(r.get("line_id", 0))) for r in ship_rows]
|
|
receipt_keys = [str(r["source_id"]) for r in cash_rows]
|
|
if len(set(ship_keys)) != len(ship_keys) or len(set(receipt_keys)) != len(receipt_keys):
|
|
raise ValueError("预演输入包含重复单据或明细")
|
|
# 凭证状态未证实的收款不能消耗后续发货,否则会改变其他款的 FIFO 顺序。
|
|
if any(not r.get("voucher_confirmed") for r in cash_rows):
|
|
reasons = {"VOUCHER_STATE_PENDING"}
|
|
for receipt in cash_rows:
|
|
reasons.update(receipt.get("pending", []))
|
|
return {"status": "PENDING", "reasons": sorted(reasons), "lines": []}
|
|
consumed = [ZERO for _ in ship_rows]
|
|
lines = [
|
|
{
|
|
"kind": "EXCLUDED",
|
|
"shipment_id": str(row["source_id"]),
|
|
"shipment_no": row.get("source_no"),
|
|
"shipment_date": row["date"],
|
|
"line_id": str(row.get("line_id", 0)),
|
|
"amount": amount_text(money(row["amount"])),
|
|
"status": "EXCLUDED",
|
|
"performance": None,
|
|
"reasons": [row.get("exclusion_reason") or "CORRECTION_EXCLUDED"],
|
|
"correction_pair_key": row.get("correction_pair_key"),
|
|
}
|
|
for row in excluded_shipments
|
|
] + [
|
|
{
|
|
"kind": "EXCLUDED",
|
|
"receipt_id": str(row["source_id"]),
|
|
"receipt_no": row.get("source_no"),
|
|
"receipt_date": row["date"],
|
|
"amount": amount_text(money(row["amount"])),
|
|
"status": "EXCLUDED",
|
|
"performance": None,
|
|
"reasons": [row["exclusion"]],
|
|
"voucher_evidence": row.get("voucher_evidence"),
|
|
"accounting_voucher_code": row.get("accounting_voucher_code"),
|
|
}
|
|
for row in excluded_receipts
|
|
]
|
|
remaining_opening = opening
|
|
cash_total = ZERO
|
|
for receipt in cash_rows:
|
|
available = money(receipt["amount"])
|
|
cash_total += available
|
|
if remaining_opening:
|
|
matched = min(available, remaining_opening)
|
|
if matched:
|
|
lines.append(
|
|
{
|
|
"kind": "OPENING",
|
|
"receipt_id": str(receipt["source_id"]),
|
|
"receipt_no": receipt.get("source_no"),
|
|
"receipt_date": receipt["date"],
|
|
"voucher_evidence": receipt.get("voucher_evidence"),
|
|
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
|
|
"amount": amount_text(matched),
|
|
"status": "LEGACY",
|
|
"performance": None,
|
|
"reasons": ["LEGACY_SYSTEM_CALCULATES"],
|
|
}
|
|
)
|
|
remaining_opening -= matched
|
|
available -= matched
|
|
for i, shipment in enumerate(ship_rows):
|
|
if available == 0:
|
|
break
|
|
remaining = money(shipment["amount"]) - consumed[i]
|
|
matched = min(available, remaining)
|
|
if matched <= 0:
|
|
continue
|
|
result = _calculate(
|
|
shipment,
|
|
receipt,
|
|
matched,
|
|
consumed[i],
|
|
{**facts, "as_of": until.isoformat()},
|
|
params,
|
|
)
|
|
lines.append(
|
|
{
|
|
"kind": "SHIPMENT",
|
|
"receipt_id": str(receipt["source_id"]),
|
|
"receipt_no": receipt.get("source_no"),
|
|
"receipt_date": receipt["date"],
|
|
"voucher_evidence": receipt.get("voucher_evidence"),
|
|
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
|
|
"shipment_id": str(shipment["source_id"]),
|
|
"shipment_no": shipment.get("source_no"),
|
|
"shipment_date": shipment["date"],
|
|
"line_id": str(shipment.get("line_id", 0)),
|
|
"product_id": shipment.get("product_id"),
|
|
"quantity": shipment.get("quantity"),
|
|
"cost_total": shipment.get("cost_total"),
|
|
"gross_tax_amount": shipment.get("gross_tax_amount"),
|
|
"freight_amount": shipment.get("freight_amount"),
|
|
"cost_month_basis": shipment.get("cost_month_basis"),
|
|
"cost_by_month": shipment.get("cost_by_month"),
|
|
"shipment_remaining_after": amount_text(remaining - matched),
|
|
"amount": amount_text(matched),
|
|
**result,
|
|
}
|
|
)
|
|
consumed[i] += matched
|
|
available -= matched
|
|
if available:
|
|
lines.append(
|
|
{
|
|
"kind": "PREPAYMENT",
|
|
"receipt_id": str(receipt["source_id"]),
|
|
"receipt_no": receipt.get("source_no"),
|
|
"receipt_date": receipt["date"],
|
|
"voucher_evidence": receipt.get("voucher_evidence"),
|
|
"accounting_voucher_code": receipt.get("accounting_voucher_code"),
|
|
"amount": amount_text(available),
|
|
"status": "WAITING_SHIPMENT",
|
|
"performance": None,
|
|
"reasons": ["NO_ACTUAL_SHIPMENT"],
|
|
}
|
|
)
|
|
allocated = sum((money(r["amount"]) for r in lines if r["kind"] != "EXCLUDED"), ZERO)
|
|
if allocated != cash_total:
|
|
raise ValueError("回款分配金额不守恒")
|
|
return {
|
|
"status": "PARTIAL" if any(r["status"] == "PENDING" for r in lines) else "SUCCEEDED",
|
|
"reasons": [],
|
|
"lines": lines,
|
|
"receipt_total": amount_text(cash_total),
|
|
"excluded_receipt_total": amount_text(
|
|
sum((money(r["amount"]) for r in excluded_receipts), ZERO)
|
|
),
|
|
"excluded_shipment_total": amount_text(
|
|
sum((money(r["amount"]) for r in excluded_shipments), ZERO)
|
|
),
|
|
"allocated_total": amount_text(allocated),
|
|
"opening_remaining": amount_text(remaining_opening),
|
|
"shipment_remaining": amount_text(
|
|
sum(
|
|
(
|
|
money(row["amount"]) - used
|
|
for row, used in zip(ship_rows, consumed, strict=True)
|
|
),
|
|
ZERO,
|
|
)
|
|
),
|
|
"prepayment_remaining": amount_text(
|
|
sum((money(r["amount"]) for r in lines if r["kind"] == "PREPAYMENT"), ZERO)
|
|
),
|
|
"performance_total": amount_text(
|
|
sum((money(r["performance"]) for r in lines if r.get("performance") is not None), ZERO)
|
|
),
|
|
"month_performance_total": amount_text(
|
|
sum(
|
|
(
|
|
money(r["performance"])
|
|
for r in lines
|
|
if r.get("performance") is not None
|
|
and r.get("effective_date", "")[:7] == until.isoformat()[:7]
|
|
),
|
|
ZERO,
|
|
)
|
|
),
|
|
}
|