feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
120
server/scripts/audit_expense_claim_statuses.py
Normal file
120
server/scripts/audit_expense_claim_statuses.py
Normal file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = SERVER_DIR / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from app.db.session import get_session_factory # noqa: E402
|
||||
from app.models.financial_record import ExpenseClaim # noqa: E402
|
||||
from app.services.expense_claim_status_registry import ( # noqa: E402
|
||||
is_known_approval_stage,
|
||||
is_known_claim_status,
|
||||
normalize_expense_claim_state,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Audit expense claim status consistency.")
|
||||
parser.add_argument("--sample-limit", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
claims = list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim).order_by(
|
||||
ExpenseClaim.claim_no.asc(),
|
||||
ExpenseClaim.created_at.asc(),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
payload = audit_claims(claims, sample_limit=max(args.sample_limit, 0))
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def audit_claims(claims: list[ExpenseClaim], *, sample_limit: int) -> dict[str, Any]:
|
||||
status_counts: Counter[str] = Counter()
|
||||
stage_counts: Counter[str] = Counter()
|
||||
status_stage_counts: Counter[str] = Counter()
|
||||
doc_type_counts: Counter[str] = Counter()
|
||||
unknown_statuses: Counter[str] = Counter()
|
||||
unknown_stages: Counter[str] = Counter()
|
||||
normalization_counts: Counter[str] = Counter()
|
||||
samples: list[dict[str, Any]] = []
|
||||
|
||||
for claim in claims:
|
||||
status = str(claim.status or "").strip()
|
||||
stage = str(claim.approval_stage or "").strip()
|
||||
doc_type = _doc_type(claim)
|
||||
status_counts[status or "<empty>"] += 1
|
||||
stage_counts[stage or "<empty>"] += 1
|
||||
status_stage_counts[f"{status or '<empty>'} | {stage or '<empty>'}"] += 1
|
||||
doc_type_counts[doc_type] += 1
|
||||
|
||||
if not is_known_claim_status(status):
|
||||
unknown_statuses[status or "<empty>"] += 1
|
||||
if not is_known_approval_stage(stage):
|
||||
unknown_stages[stage or "<empty>"] += 1
|
||||
|
||||
normalized = normalize_expense_claim_state(
|
||||
status,
|
||||
stage,
|
||||
claim_no=claim.claim_no,
|
||||
expense_type=claim.expense_type,
|
||||
)
|
||||
if normalized.changed:
|
||||
key = (
|
||||
f"{status or '<empty>'}/{stage or '<empty>'}"
|
||||
f" -> {normalized.status}/{normalized.approval_stage}"
|
||||
)
|
||||
normalization_counts[key] += 1
|
||||
if len(samples) < sample_limit:
|
||||
samples.append(
|
||||
{
|
||||
"claim_no": claim.claim_no,
|
||||
"doc_type": doc_type,
|
||||
"status": status,
|
||||
"approval_stage": stage,
|
||||
"normalized_status": normalized.status,
|
||||
"normalized_approval_stage": normalized.approval_stage,
|
||||
"status_code": normalized.status_code,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"claim_count": len(claims),
|
||||
"doc_type_counts": dict(doc_type_counts),
|
||||
"status_counts": dict(status_counts),
|
||||
"approval_stage_counts": dict(stage_counts),
|
||||
"status_stage_counts": dict(status_stage_counts),
|
||||
"unknown_statuses": dict(unknown_statuses),
|
||||
"unknown_approval_stages": dict(unknown_stages),
|
||||
"normalization_needed": sum(normalization_counts.values()),
|
||||
"normalization_counts": dict(normalization_counts),
|
||||
"normalization_samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def _doc_type(claim: ExpenseClaim) -> str:
|
||||
claim_no = str(claim.claim_no or "").strip().upper()
|
||||
expense_type = str(claim.expense_type or "").strip().lower()
|
||||
if claim_no.startswith(("AP-", "APP-")) or expense_type.endswith("_application"):
|
||||
return "application"
|
||||
if claim_no.startswith("SIM-EXP-2026"):
|
||||
return "sim_reimbursement"
|
||||
return "reimbursement"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
396
server/scripts/mock_half_year_expense_demo_attachments.py
Normal file
396
server/scripts/mock_half_year_expense_demo_attachments.py
Normal file
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = SERVER_DIR / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from app.db.session import get_session_factory # noqa: E402
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem # noqa: E402
|
||||
from app.services.demo_company_simulation_catalog import SIM_CLAIM_PREFIX # noqa: E402
|
||||
from app.services.expense_claim_attachment_storage import ( # noqa: E402
|
||||
ExpenseClaimAttachmentStorage,
|
||||
)
|
||||
|
||||
DOCUMENT_BY_ITEM_TYPE = {
|
||||
"hotel": ("hotel_invoice", "酒店住宿票据", "hotel", "住宿票据"),
|
||||
"hotel_ticket": ("hotel_invoice", "酒店住宿票据", "hotel", "住宿票据"),
|
||||
"transport": ("transport_receipt", "乘车票据", "transport", "交通票据"),
|
||||
"train_ticket": ("train_ticket", "火车/高铁票", "travel", "差旅票据"),
|
||||
"flight_ticket": ("flight_itinerary", "航空行程单", "travel", "差旅票据"),
|
||||
"ride_ticket": ("taxi_receipt", "出租车/网约车票据", "transport", "交通票据"),
|
||||
"meal": ("meal_receipt", "餐饮发票", "meal", "餐饮票据"),
|
||||
"entertainment": ("meal_receipt", "餐饮发票", "meal", "餐饮票据"),
|
||||
"office": ("office_invoice", "办公用品发票", "office", "办公票据"),
|
||||
"communication": ("telecom_invoice", "通信服务发票", "communication", "通信票据"),
|
||||
"travel_allowance": ("allowance_sheet", "差旅补贴测算单", "travel", "差旅测算"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MockAttachmentSummary:
|
||||
mode: str
|
||||
sim_claims: int
|
||||
sim_items: int
|
||||
attachments_to_mock: int
|
||||
missing_material_items: int
|
||||
compliant_attachments: int
|
||||
violation_attachments: int
|
||||
already_mocked: int
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Mock attachment files and OCR metadata for half-year simulated claims."
|
||||
)
|
||||
parser.add_argument("--apply", action="store_true", help="Write mock attachment files.")
|
||||
args = parser.parse_args()
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
try:
|
||||
summary = mock_attachments(db, apply=args.apply)
|
||||
if args.apply:
|
||||
db.commit()
|
||||
print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2))
|
||||
if not args.apply:
|
||||
print("dry-run only; pass --apply after confirmation to write mock attachments.")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def mock_attachments(db, *, apply: bool) -> MockAttachmentSummary:
|
||||
claims = _sim_claims(db)
|
||||
storage = ExpenseClaimAttachmentStorage()
|
||||
attachments_to_mock = 0
|
||||
missing_material_items = 0
|
||||
compliant_attachments = 0
|
||||
violation_attachments = 0
|
||||
already_mocked = 0
|
||||
sim_items = 0
|
||||
|
||||
for claim_index, claim in enumerate(claims, start=1):
|
||||
items = list(claim.items or [])
|
||||
sim_items += len(items)
|
||||
for item_index, item in enumerate(items, start=1):
|
||||
if _has_existing_mock(storage, item):
|
||||
already_mocked += 1
|
||||
continue
|
||||
if _should_leave_missing(claim_index, item_index, claim):
|
||||
missing_material_items += 1
|
||||
if apply:
|
||||
item.invoice_id = None
|
||||
continue
|
||||
|
||||
violated = _is_violation_sample(claim_index, item_index, claim)
|
||||
attachments_to_mock += 1
|
||||
violation_attachments += int(violated)
|
||||
compliant_attachments += int(not violated)
|
||||
if apply:
|
||||
_write_mock_attachment(
|
||||
storage=storage,
|
||||
claim=claim,
|
||||
item=item,
|
||||
claim_index=claim_index,
|
||||
item_index=item_index,
|
||||
violated=violated,
|
||||
)
|
||||
|
||||
if apply:
|
||||
claim.invoice_count = sum(
|
||||
1 for item in items if str(item.invoice_id or "").strip()
|
||||
)
|
||||
|
||||
return MockAttachmentSummary(
|
||||
mode="apply" if apply else "dry-run",
|
||||
sim_claims=len(claims),
|
||||
sim_items=sim_items,
|
||||
attachments_to_mock=attachments_to_mock,
|
||||
missing_material_items=missing_material_items,
|
||||
compliant_attachments=compliant_attachments,
|
||||
violation_attachments=violation_attachments,
|
||||
already_mocked=already_mocked,
|
||||
)
|
||||
|
||||
|
||||
def _sim_claims(db) -> list[ExpenseClaim]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim)
|
||||
.options(selectinload(ExpenseClaim.items))
|
||||
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||
.order_by(ExpenseClaim.claim_no.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _has_existing_mock(storage: ExpenseClaimAttachmentStorage, item: ExpenseClaimItem) -> bool:
|
||||
file_path = storage.resolve_item_path(item)
|
||||
if file_path is None or not file_path.exists():
|
||||
return False
|
||||
metadata = storage.read_meta(file_path)
|
||||
return str(metadata.get("source") or "") == "half_year_expense_demo_mock"
|
||||
|
||||
|
||||
def _should_leave_missing(claim_index: int, item_index: int, claim: ExpenseClaim) -> bool:
|
||||
if str(claim.status or "").strip().lower() in {"draft", "returned"}:
|
||||
return (claim_index + item_index) % 4 == 0
|
||||
return (claim_index + item_index) % 19 == 0
|
||||
|
||||
|
||||
def _is_violation_sample(claim_index: int, item_index: int, claim: ExpenseClaim) -> bool:
|
||||
if claim.hermes_risk_flag or claim.risk_flags_json:
|
||||
return True
|
||||
return (claim_index * 7 + item_index * 3) % 11 == 0
|
||||
|
||||
|
||||
def _write_mock_attachment(
|
||||
*,
|
||||
storage: ExpenseClaimAttachmentStorage,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
claim_index: int,
|
||||
item_index: int,
|
||||
violated: bool,
|
||||
) -> None:
|
||||
document_type, document_label, scene_code, scene_label = _document_meta(item.item_type)
|
||||
filename = f"{claim.claim_no}-{item_index:02d}-{document_type}.txt"
|
||||
attachment_dir = storage.build_item_dir(claim.id, item.id)
|
||||
attachment_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = attachment_dir / filename
|
||||
ocr_text = _ocr_text(
|
||||
claim=claim,
|
||||
item=item,
|
||||
document_label=document_label,
|
||||
claim_index=claim_index,
|
||||
item_index=item_index,
|
||||
violated=violated,
|
||||
)
|
||||
file_path.write_text(ocr_text, encoding="utf-8")
|
||||
item.invoice_id = storage.to_storage_key(file_path)
|
||||
storage.write_meta(
|
||||
file_path,
|
||||
_meta_payload(
|
||||
storage_key=item.invoice_id,
|
||||
filename=filename,
|
||||
file_path=file_path,
|
||||
claim=claim,
|
||||
item=item,
|
||||
document_type=document_type,
|
||||
document_label=document_label,
|
||||
scene_code=scene_code,
|
||||
scene_label=scene_label,
|
||||
ocr_text=ocr_text,
|
||||
violated=violated,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _document_meta(item_type: str) -> tuple[str, str, str, str]:
|
||||
return DOCUMENT_BY_ITEM_TYPE.get(
|
||||
str(item_type or "").strip().lower(),
|
||||
("invoice", "费用发票", "other", "其他票据"),
|
||||
)
|
||||
|
||||
|
||||
def _ocr_text(
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
document_label: str,
|
||||
claim_index: int,
|
||||
item_index: int,
|
||||
violated: bool,
|
||||
) -> str:
|
||||
invoice_no = f"MOCK{claim_index:04d}{item_index:02d}"
|
||||
amount = _display_amount(item.item_amount)
|
||||
merchant = _merchant_name(item.item_type, violated)
|
||||
violation_line = (
|
||||
"校验提示:票据金额或场景需要人工复核。"
|
||||
if violated
|
||||
else "校验提示:票据字段与报销明细一致。"
|
||||
)
|
||||
return "\n".join(
|
||||
[
|
||||
f"票据类型:{document_label}",
|
||||
f"发票号码:{invoice_no}",
|
||||
f"开票方:{merchant}",
|
||||
f"购买方:{claim.department_name}",
|
||||
f"发生日期:{item.item_date.isoformat()}",
|
||||
f"发生地点:{item.item_location}",
|
||||
f"金额:{amount}",
|
||||
f"关联报销单:{claim.claim_no}",
|
||||
violation_line,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _merchant_name(item_type: str, violated: bool) -> str:
|
||||
normalized = str(item_type or "").strip().lower()
|
||||
if violated:
|
||||
return {
|
||||
"hotel": "上海云栖酒店有限公司",
|
||||
"transport": "跨城交通服务商",
|
||||
"office": "综合采购供应商",
|
||||
"meal": "高端商务餐饮有限公司",
|
||||
}.get(normalized, "异常样本供应商")
|
||||
return {
|
||||
"hotel": "合规住宿服务有限公司",
|
||||
"transport": "合规出行服务有限公司",
|
||||
"travel_allowance": "系统差旅补贴测算",
|
||||
"office": "合规办公用品有限公司",
|
||||
"communication": "合规通信服务有限公司",
|
||||
"meal": "合规餐饮服务有限公司",
|
||||
}.get(normalized, "合规票据供应商")
|
||||
|
||||
|
||||
def _meta_payload(
|
||||
*,
|
||||
storage_key: str,
|
||||
filename: str,
|
||||
file_path: Path,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
document_type: str,
|
||||
document_label: str,
|
||||
scene_code: str,
|
||||
scene_label: str,
|
||||
ocr_text: str,
|
||||
violated: bool,
|
||||
) -> dict[str, Any]:
|
||||
amount_text = _display_amount(item.item_amount)
|
||||
document_info = {
|
||||
"document_type": document_type,
|
||||
"document_type_label": document_label,
|
||||
"scene_code": scene_code,
|
||||
"scene_label": scene_label,
|
||||
"fields": [
|
||||
{"key": "invoice_no", "label": "发票号码", "value": _invoice_no(filename)},
|
||||
{"key": "invoice_date", "label": "开票日期", "value": item.item_date.isoformat()},
|
||||
{"key": "amount", "label": "金额", "value": amount_text},
|
||||
{"key": "location", "label": "地点", "value": str(item.item_location or "")},
|
||||
{
|
||||
"key": "merchant",
|
||||
"label": "开票方",
|
||||
"value": _merchant_name(item.item_type, violated),
|
||||
},
|
||||
],
|
||||
}
|
||||
requirement_check = _requirement_payload(
|
||||
violated,
|
||||
item,
|
||||
document_type,
|
||||
document_label,
|
||||
scene_code,
|
||||
scene_label,
|
||||
)
|
||||
ocr_summary = f"{document_label},金额 {amount_text},{'需复核' if violated else '字段匹配'}。"
|
||||
return {
|
||||
"source": "half_year_expense_demo_mock",
|
||||
"file_name": filename,
|
||||
"storage_key": storage_key,
|
||||
"media_type": "text/plain",
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
"uploaded_at": datetime.now(UTC).isoformat(),
|
||||
"previewable": False,
|
||||
"preview_kind": "",
|
||||
"preview_storage_key": "",
|
||||
"preview_media_type": "",
|
||||
"preview_file_name": "",
|
||||
"analysis": _analysis_payload(violated, claim, item),
|
||||
"document_info": document_info,
|
||||
"requirement_check": requirement_check,
|
||||
"ocr_status": "mocked",
|
||||
"ocr_error": "",
|
||||
"ocr_text": ocr_text,
|
||||
"ocr_summary": ocr_summary,
|
||||
"ocr_avg_score": 0.97 if not violated else 0.81,
|
||||
"ocr_line_count": len(ocr_text.splitlines()),
|
||||
"ocr_classification_source": "mock_rule",
|
||||
"ocr_classification_confidence": 0.96 if not violated else 0.78,
|
||||
"ocr_classification_evidence": [document_label, scene_label],
|
||||
"ocr_warnings": ["mock违规样本"] if violated else [],
|
||||
}
|
||||
|
||||
|
||||
def _analysis_payload(
|
||||
violated: bool,
|
||||
claim: ExpenseClaim,
|
||||
item: ExpenseClaimItem,
|
||||
) -> dict[str, Any]:
|
||||
if violated:
|
||||
return {
|
||||
"severity": "warning",
|
||||
"label": "需复核",
|
||||
"headline": "票据字段存在合规疑点",
|
||||
"summary": "系统 mock 的 OCR 字段与报销场景存在偏差,用于演示违规样本。",
|
||||
"points": [
|
||||
f"报销单 {claim.claim_no} 金额或场景需要人工复核。",
|
||||
f"费用明细:{item.item_reason},金额 {_display_amount(item.item_amount)}。",
|
||||
],
|
||||
"rule_basis": ["票据金额与费用明细一致性", "票据场景与费用科目匹配"],
|
||||
"suggestion": "请核对票据原件、业务事由和费用归口后再提交或付款。",
|
||||
}
|
||||
return {
|
||||
"severity": "success",
|
||||
"label": "合规",
|
||||
"headline": "票据字段与报销明细一致",
|
||||
"summary": "系统 mock 的 OCR 字段已覆盖金额、日期、地点和票据类型。",
|
||||
"points": [
|
||||
f"金额 {_display_amount(item.item_amount)} 与费用明细一致。",
|
||||
f"票据类型匹配 {item.item_reason}。",
|
||||
],
|
||||
"rule_basis": ["基础票据完整性", "金额一致性"],
|
||||
"suggestion": "当前材料可作为演示合规样本。",
|
||||
}
|
||||
|
||||
|
||||
def _requirement_payload(
|
||||
violated: bool,
|
||||
item: ExpenseClaimItem,
|
||||
document_type: str,
|
||||
document_label: str,
|
||||
scene_code: str,
|
||||
scene_label: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"matches": not violated,
|
||||
"current_expense_type": str(item.item_type or "other"),
|
||||
"current_expense_type_label": str(item.item_reason or "费用明细"),
|
||||
"allowed_scene_labels": [scene_label],
|
||||
"recognized_scene_code": scene_code,
|
||||
"recognized_scene_label": scene_label,
|
||||
"recognized_document_type": document_type,
|
||||
"recognized_document_type_label": document_label,
|
||||
"message": "材料匹配,可继续处理。" if not violated else "材料存在疑点,建议人工复核。",
|
||||
}
|
||||
|
||||
|
||||
def _invoice_no(filename: str) -> str:
|
||||
return Path(filename).stem.replace("-", "").upper()[-20:]
|
||||
|
||||
|
||||
def _display_amount(value: Decimal | float | int | str | None) -> str:
|
||||
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
return f"{amount:.2f}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
570
server/scripts/repair_half_year_expense_demo_distribution.py
Normal file
570
server/scripts/repair_half_year_expense_demo_distribution.py
Normal file
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, date, datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = SERVER_DIR / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from app.db.session import get_session_factory # noqa: E402
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction # noqa: E402
|
||||
from app.models.employee import Employee # noqa: E402
|
||||
from app.models.financial_record import ExpenseClaim # noqa: E402
|
||||
from app.models.organization import OrganizationUnit # noqa: E402
|
||||
from app.services.demo_company_simulation_catalog import ( # noqa: E402
|
||||
BUDGETED_STATUSES,
|
||||
PENDING_STATUSES,
|
||||
SIM_BUDGET_PREFIX,
|
||||
SIM_CLAIM_PREFIX,
|
||||
SIM_EMPLOYEE_PREFIX,
|
||||
SIM_PROJECT_CODE,
|
||||
SIM_RESERVATION_PREFIX,
|
||||
SIM_TRANSACTION_PREFIX,
|
||||
SUBJECT_LABELS,
|
||||
SUCCESS_STATUSES,
|
||||
target_budget_usage,
|
||||
)
|
||||
from app.services.demo_company_simulation_filters import is_admin_employee_like # noqa: E402
|
||||
from app.services.employee_behavior_profile_service import ( # noqa: E402
|
||||
EmployeeBehaviorProfileService,
|
||||
)
|
||||
from app.services.expense_claim_status_registry import ( # noqa: E402
|
||||
normalize_expense_claim_state,
|
||||
)
|
||||
|
||||
DEPARTMENT_PLAN = (
|
||||
("TECH-DEPT", Decimal("0.30")),
|
||||
("MARKET-DEPT", Decimal("0.24")),
|
||||
("PRODUCTION-DEPT", Decimal("0.18")),
|
||||
("FINANCE-DEPT", Decimal("0.12")),
|
||||
("HR-DEPT", Decimal("0.10")),
|
||||
("PRESIDENT-OFFICE", Decimal("0.06")),
|
||||
)
|
||||
RECENT_PENDING_PER_DEPARTMENT = 3
|
||||
RECENT_DATES = (
|
||||
datetime(2026, 6, 1, 10, 0, tzinfo=UTC),
|
||||
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
|
||||
datetime(2026, 6, 2, 6, 0, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RepairSummary:
|
||||
mode: str
|
||||
sim_employees: int
|
||||
sim_claims: int
|
||||
employee_department_plan: dict[str, int]
|
||||
claim_department_plan: dict[str, int]
|
||||
recent_pending_plan: dict[str, int]
|
||||
rebuilt_budget_allocations: int
|
||||
rebuilt_budget_transactions: int
|
||||
rebuilt_budget_reservations: int
|
||||
before_all_department_amounts: dict[str, str]
|
||||
before_recent_pending_amounts: dict[str, str]
|
||||
after_all_department_amounts: dict[str, str]
|
||||
after_recent_pending_amounts: dict[str, str]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Repair simulated half-year demo data distribution."
|
||||
)
|
||||
parser.add_argument("--apply", action="store_true", help="Apply repair. Default is dry-run.")
|
||||
parser.add_argument(
|
||||
"--refresh-profiles",
|
||||
action="store_true",
|
||||
help="After --apply, refresh employee behavior profile snapshots for simulated employees.",
|
||||
)
|
||||
parser.add_argument("--profile-limit", type=int, default=120)
|
||||
args = parser.parse_args()
|
||||
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
try:
|
||||
summary = repair_distribution(db, apply=args.apply)
|
||||
profile_refresh = None
|
||||
if args.apply and args.refresh_profiles:
|
||||
profile_refresh = _refresh_company_profiles(db, limit=args.profile_limit)
|
||||
if args.apply:
|
||||
db.commit()
|
||||
payload = summary.to_dict()
|
||||
if profile_refresh is not None:
|
||||
payload["profile_refresh"] = profile_refresh
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if not args.apply:
|
||||
print("dry-run only; pass --apply after confirmation to repair simulated data.")
|
||||
elif not args.refresh_profiles:
|
||||
print("pass --refresh-profiles to generate employee behavior profile snapshots.")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def repair_distribution(db, *, apply: bool) -> RepairSummary:
|
||||
departments = _canonical_departments(db)
|
||||
if len(departments) < len(DEPARTMENT_PLAN):
|
||||
missing = [code for code, _ in DEPARTMENT_PLAN if code not in departments]
|
||||
raise RuntimeError(f"missing canonical departments: {missing}")
|
||||
|
||||
sim_employees = _sim_employees(db)
|
||||
sim_claims = _sim_claims(db)
|
||||
before_all = _department_amounts(sim_claims)
|
||||
before_recent = _recent_pending_amounts(sim_claims)
|
||||
|
||||
employee_plan = _counts_by_weight(len(sim_employees))
|
||||
claim_plan = _counts_by_weight(len(sim_claims))
|
||||
recent_claims = _recent_claims(sim_claims)
|
||||
fixed_recent_plan = {code: RECENT_PENDING_PER_DEPARTMENT for code, _ in DEPARTMENT_PLAN}
|
||||
regular_plan = {
|
||||
code: max(claim_plan.get(code, 0) - fixed_recent_plan.get(code, 0), 0)
|
||||
for code, _ in DEPARTMENT_PLAN
|
||||
}
|
||||
|
||||
if apply:
|
||||
_normalize_sim_claim_workflow(sim_claims)
|
||||
_redistribute_employees(sim_employees, departments, employee_plan)
|
||||
db.flush()
|
||||
employees_by_dept = _employees_by_department(db)
|
||||
_redistribute_regular_claims(
|
||||
[claim for claim in sim_claims if claim not in set(recent_claims)],
|
||||
departments,
|
||||
employees_by_dept,
|
||||
regular_plan,
|
||||
)
|
||||
_repair_recent_pending_claims(recent_claims, departments, employees_by_dept)
|
||||
db.flush()
|
||||
_rebuild_sim_budget(db, sim_claims, departments)
|
||||
db.flush()
|
||||
|
||||
after_claims = (
|
||||
_sim_claims(db)
|
||||
if apply
|
||||
else _preview_claims(sim_claims, departments, claim_plan)
|
||||
)
|
||||
after_all = _department_amounts(after_claims)
|
||||
after_recent = _recent_pending_amounts(after_claims)
|
||||
allocation_count, transaction_count, reservation_count = _planned_budget_counts(after_claims)
|
||||
|
||||
return RepairSummary(
|
||||
mode="apply" if apply else "dry-run",
|
||||
sim_employees=len(sim_employees),
|
||||
sim_claims=len(sim_claims),
|
||||
employee_department_plan=employee_plan,
|
||||
claim_department_plan=claim_plan,
|
||||
recent_pending_plan=fixed_recent_plan,
|
||||
rebuilt_budget_allocations=allocation_count,
|
||||
rebuilt_budget_transactions=transaction_count,
|
||||
rebuilt_budget_reservations=reservation_count,
|
||||
before_all_department_amounts=before_all,
|
||||
before_recent_pending_amounts=before_recent,
|
||||
after_all_department_amounts=after_all,
|
||||
after_recent_pending_amounts=after_recent,
|
||||
)
|
||||
|
||||
|
||||
def _refresh_company_profiles(db, *, limit: int) -> dict[str, object]:
|
||||
capped_limit = max(1, min(int(limit or 120), 500))
|
||||
employees = list(
|
||||
db.scalars(select(Employee).order_by(Employee.employee_no.asc())).all()
|
||||
)
|
||||
employee_ids = [
|
||||
employee.id
|
||||
for employee in employees
|
||||
if not is_admin_employee_like(employee)
|
||||
][:capped_limit]
|
||||
service = EmployeeBehaviorProfileService(db)
|
||||
snapshot_count = 0
|
||||
for employee_id in employee_ids:
|
||||
snapshots = service.refresh_employee_profiles(
|
||||
employee_id=employee_id,
|
||||
window_days=(30, 90, 180),
|
||||
expense_type_scope="overall",
|
||||
source_task_type="half_year_expense_demo_repair",
|
||||
commit=False,
|
||||
)
|
||||
snapshot_count += len(snapshots)
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"target_employee_count": len(employee_ids),
|
||||
"snapshot_count": snapshot_count,
|
||||
"window_days": [30, 90, 180],
|
||||
"source_task_type": "half_year_expense_demo_repair",
|
||||
"scope": "all_non_admin_employees",
|
||||
}
|
||||
|
||||
|
||||
def _canonical_departments(db) -> dict[str, OrganizationUnit]:
|
||||
department_codes = [code for code, _weight in DEPARTMENT_PLAN]
|
||||
rows = db.scalars(
|
||||
select(OrganizationUnit).where(OrganizationUnit.unit_code.in_(department_codes))
|
||||
).all()
|
||||
return {row.unit_code: row for row in rows}
|
||||
|
||||
|
||||
def _sim_employees(db) -> list[Employee]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit))
|
||||
.where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
|
||||
.order_by(Employee.employee_no.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _sim_claims(db) -> list[ExpenseClaim]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(ExpenseClaim)
|
||||
.options(selectinload(ExpenseClaim.items))
|
||||
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||
.order_by(ExpenseClaim.claim_no.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None:
|
||||
for claim in claims:
|
||||
normalized = normalize_expense_claim_state(
|
||||
claim.status,
|
||||
claim.approval_stage,
|
||||
claim_no=claim.claim_no,
|
||||
expense_type=claim.expense_type,
|
||||
is_application_claim=False,
|
||||
)
|
||||
claim.status = normalized.status
|
||||
claim.approval_stage = normalized.approval_stage
|
||||
|
||||
|
||||
def _counts_by_weight(total: int) -> dict[str, int]:
|
||||
raw = [(code, total * weight) for code, weight in DEPARTMENT_PLAN]
|
||||
counts = {code: int(value) for code, value in raw}
|
||||
remainder = total - sum(counts.values())
|
||||
remainder_order = sorted(
|
||||
raw,
|
||||
key=lambda item: item[1] - int(item[1]),
|
||||
reverse=True,
|
||||
)
|
||||
for code, _value in remainder_order[:remainder]:
|
||||
counts[code] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _redistribute_employees(
|
||||
employees: list[Employee],
|
||||
departments: dict[str, OrganizationUnit],
|
||||
plan: dict[str, int],
|
||||
) -> None:
|
||||
index = 0
|
||||
for code, _weight in DEPARTMENT_PLAN:
|
||||
department = departments[code]
|
||||
for employee in employees[index : index + plan.get(code, 0)]:
|
||||
employee.organization_unit = department
|
||||
employee.cost_center = department.cost_center
|
||||
employee.location = department.location
|
||||
employee.finance_owner_name = f"{department.name}财务BP"
|
||||
index += plan.get(code, 0)
|
||||
|
||||
|
||||
def _employees_by_department(db) -> dict[str, list[Employee]]:
|
||||
rows = db.scalars(
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit))
|
||||
.where(Employee.organization_unit_id.is_not(None))
|
||||
.order_by(Employee.employee_no.asc())
|
||||
).all()
|
||||
grouped: dict[str, list[Employee]] = defaultdict(list)
|
||||
for employee in rows:
|
||||
unit = employee.organization_unit
|
||||
if unit is not None and unit.unit_code:
|
||||
grouped[unit.unit_code].append(employee)
|
||||
return grouped
|
||||
|
||||
|
||||
def _redistribute_regular_claims(
|
||||
claims: list[ExpenseClaim],
|
||||
departments: dict[str, OrganizationUnit],
|
||||
employees_by_dept: dict[str, list[Employee]],
|
||||
plan: dict[str, int],
|
||||
) -> None:
|
||||
index = 0
|
||||
for code, _weight in DEPARTMENT_PLAN:
|
||||
department = departments[code]
|
||||
employees = employees_by_dept.get(code) or []
|
||||
for offset, claim in enumerate(claims[index : index + plan.get(code, 0)]):
|
||||
employee = employees[offset % len(employees)] if employees else None
|
||||
_assign_claim_department(claim, department, employee)
|
||||
index += plan.get(code, 0)
|
||||
|
||||
|
||||
def _repair_recent_pending_claims(
|
||||
claims: list[ExpenseClaim],
|
||||
departments: dict[str, OrganizationUnit],
|
||||
employees_by_dept: dict[str, list[Employee]],
|
||||
) -> None:
|
||||
index = 0
|
||||
for code, _weight in DEPARTMENT_PLAN:
|
||||
department = departments[code]
|
||||
employees = employees_by_dept.get(code) or []
|
||||
for offset in range(RECENT_PENDING_PER_DEPARTMENT):
|
||||
claim = claims[index]
|
||||
employee = employees[offset % len(employees)] if employees else None
|
||||
_assign_claim_department(claim, department, employee)
|
||||
claim.status = "submitted"
|
||||
claim.approval_stage = "财务审批" if offset % 2 == 0 else "直属领导审批"
|
||||
claim.occurred_at = RECENT_DATES[offset] - _hours(2)
|
||||
claim.submitted_at = RECENT_DATES[offset]
|
||||
claim.updated_at = RECENT_DATES[offset] + _hours(1)
|
||||
index += 1
|
||||
|
||||
|
||||
def _assign_claim_department(
|
||||
claim: ExpenseClaim,
|
||||
department: OrganizationUnit,
|
||||
employee: Employee | None,
|
||||
) -> None:
|
||||
claim.department_id = department.id
|
||||
claim.department_name = department.name
|
||||
if employee is not None:
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = employee.name
|
||||
claim.location = department.location or claim.location
|
||||
|
||||
|
||||
def _rebuild_sim_budget(
|
||||
db,
|
||||
claims: list[ExpenseClaim],
|
||||
departments: dict[str, OrganizationUnit],
|
||||
) -> None:
|
||||
for model, field, prefix in (
|
||||
(BudgetTransaction, BudgetTransaction.transaction_no, SIM_TRANSACTION_PREFIX),
|
||||
(BudgetReservation, BudgetReservation.reservation_no, SIM_RESERVATION_PREFIX),
|
||||
(BudgetAllocation, BudgetAllocation.budget_no, SIM_BUDGET_PREFIX),
|
||||
):
|
||||
for row in db.scalars(select(model).where(field.like(f"{prefix}%"))).all():
|
||||
db.delete(row)
|
||||
db.flush()
|
||||
|
||||
groups: dict[tuple[int, str, str, str, str], list[ExpenseClaim]] = defaultdict(list)
|
||||
for claim in claims:
|
||||
if claim.status not in BUDGETED_STATUSES:
|
||||
continue
|
||||
subject_code = "meal" if claim.expense_type == "entertainment" else claim.expense_type
|
||||
quarter = ((claim.occurred_at.month - 1) // 3) + 1
|
||||
period_key = f"{claim.occurred_at.year}Q{quarter}"
|
||||
cost_center = _claim_cost_center(claim, departments)
|
||||
key = (claim.occurred_at.year, period_key, claim.department_id, cost_center, subject_code)
|
||||
groups[key].append(claim)
|
||||
|
||||
allocation_index = 1
|
||||
transaction_index = 1
|
||||
for key, group_claims in sorted(groups.items()):
|
||||
year, period_key, department_id, cost_center, subject_code = key
|
||||
total_used = sum((Decimal(claim.amount or 0) for claim in group_claims), Decimal("0.00"))
|
||||
original_amount = (
|
||||
total_used / target_budget_usage(period_key, subject_code, allocation_index)
|
||||
).quantize(Decimal("0.01"))
|
||||
allocation = BudgetAllocation(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{SIM_BUDGET_PREFIX}:{key}")),
|
||||
budget_no=f"{SIM_BUDGET_PREFIX}-R{allocation_index:04d}",
|
||||
fiscal_year=year,
|
||||
period_type="quarter",
|
||||
period_key=period_key,
|
||||
department_id=department_id,
|
||||
department_name=group_claims[0].department_name,
|
||||
cost_center=cost_center,
|
||||
project_code=SIM_PROJECT_CODE,
|
||||
subject_code=subject_code,
|
||||
subject_name=SUBJECT_LABELS.get(subject_code, subject_code),
|
||||
original_amount=max(original_amount, Decimal("3000.00")),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="warn",
|
||||
description="半年报销模拟数据部门分布修复预算池",
|
||||
created_by="simulation",
|
||||
updated_by="simulation",
|
||||
)
|
||||
db.add(allocation)
|
||||
db.flush()
|
||||
for claim in group_claims:
|
||||
db.add(_budget_transaction(allocation.id, claim, transaction_index))
|
||||
if claim.status in PENDING_STATUSES:
|
||||
db.add(_budget_reservation(allocation.id, claim, transaction_index))
|
||||
transaction_index += 1
|
||||
allocation_index += 1
|
||||
|
||||
|
||||
def _budget_transaction(allocation_id: str, claim: ExpenseClaim, index: int) -> BudgetTransaction:
|
||||
transaction_no = f"{SIM_TRANSACTION_PREFIX}-R{index:04d}"
|
||||
transaction_type = "consume" if claim.status in SUCCESS_STATUSES else "reserve"
|
||||
return BudgetTransaction(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{transaction_no}")),
|
||||
transaction_no=transaction_no,
|
||||
allocation_id=allocation_id,
|
||||
source_type="claim",
|
||||
source_id=claim.id,
|
||||
source_no=claim.claim_no,
|
||||
transaction_type=transaction_type,
|
||||
amount=Decimal(claim.amount or 0),
|
||||
before_available_amount=Decimal("0.00"),
|
||||
after_available_amount=Decimal("0.00"),
|
||||
operator="simulation",
|
||||
reason="修复后模拟数据预算台账",
|
||||
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True, "repair": True},
|
||||
created_at=claim.submitted_at or claim.occurred_at,
|
||||
)
|
||||
|
||||
|
||||
def _budget_reservation(allocation_id: str, claim: ExpenseClaim, index: int) -> BudgetReservation:
|
||||
reservation_no = f"{SIM_RESERVATION_PREFIX}-R{index:04d}"
|
||||
return BudgetReservation(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"repair:{reservation_no}")),
|
||||
reservation_no=reservation_no,
|
||||
allocation_id=allocation_id,
|
||||
source_type="claim",
|
||||
source_id=claim.id,
|
||||
source_no=claim.claim_no,
|
||||
source_status="active",
|
||||
amount=Decimal(claim.amount or 0),
|
||||
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True, "repair": True},
|
||||
created_at=claim.submitted_at or claim.occurred_at,
|
||||
)
|
||||
|
||||
|
||||
def _recent_claims(claims: list[ExpenseClaim]) -> list[ExpenseClaim]:
|
||||
needed = RECENT_PENDING_PER_DEPARTMENT * len(DEPARTMENT_PLAN)
|
||||
return sorted(claims, key=lambda claim: Decimal(claim.amount or 0), reverse=True)[:needed]
|
||||
|
||||
|
||||
def _department_amounts(claims: list[ExpenseClaim]) -> dict[str, str]:
|
||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||
for claim in claims:
|
||||
buckets[claim.department_name or "待补充"] += Decimal(claim.amount or 0)
|
||||
return _format_amounts(buckets)
|
||||
|
||||
|
||||
def _recent_pending_amounts(claims: list[ExpenseClaim]) -> dict[str, str]:
|
||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||
for claim in claims:
|
||||
if claim.status not in PENDING_STATUSES:
|
||||
continue
|
||||
submitted_at = claim.submitted_at or claim.occurred_at
|
||||
if not submitted_at:
|
||||
continue
|
||||
day = submitted_at.date()
|
||||
if date(2026, 6, 1) <= day <= date(2026, 6, 2):
|
||||
buckets[claim.department_name or "待补充"] += Decimal(claim.amount or 0)
|
||||
return _format_amounts(buckets)
|
||||
|
||||
|
||||
def _preview_claims(
|
||||
claims: list[ExpenseClaim],
|
||||
departments: dict[str, OrganizationUnit],
|
||||
claim_plan: dict[str, int],
|
||||
) -> list[ExpenseClaim]:
|
||||
preview: list[ExpenseClaim] = []
|
||||
recent_claims = _recent_claims(claims)
|
||||
recent_claim_set = set(recent_claims)
|
||||
regular_claims = [claim for claim in claims if claim not in recent_claim_set]
|
||||
index = 0
|
||||
for code, _weight in DEPARTMENT_PLAN:
|
||||
department = departments[code]
|
||||
count = max(claim_plan.get(code, 0) - RECENT_PENDING_PER_DEPARTMENT, 0)
|
||||
for claim in regular_claims[index : index + count]:
|
||||
preview.append(_clone_claim(claim, department.name, claim.status, claim.submitted_at))
|
||||
index += count
|
||||
recent_index = 0
|
||||
for code, _weight in DEPARTMENT_PLAN:
|
||||
department = departments[code]
|
||||
for offset in range(RECENT_PENDING_PER_DEPARTMENT):
|
||||
preview.append(
|
||||
_clone_claim(
|
||||
recent_claims[recent_index],
|
||||
department.name,
|
||||
"submitted",
|
||||
RECENT_DATES[offset],
|
||||
)
|
||||
)
|
||||
recent_index += 1
|
||||
return preview
|
||||
|
||||
|
||||
def _clone_claim(
|
||||
claim: ExpenseClaim,
|
||||
department_name: str,
|
||||
status: str,
|
||||
submitted_at: datetime | None,
|
||||
) -> Any:
|
||||
return type(
|
||||
"ClaimPreview",
|
||||
(),
|
||||
{
|
||||
"department_name": department_name,
|
||||
"status": status,
|
||||
"submitted_at": submitted_at,
|
||||
"occurred_at": claim.occurred_at,
|
||||
"expense_type": claim.expense_type,
|
||||
"amount": claim.amount,
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
def _planned_budget_counts(claims: list[Any]) -> tuple[int, int, int]:
|
||||
allocation_keys = set()
|
||||
transaction_count = 0
|
||||
reservation_count = 0
|
||||
for claim in claims:
|
||||
if claim.status not in BUDGETED_STATUSES:
|
||||
continue
|
||||
submitted_at = claim.submitted_at or claim.occurred_at
|
||||
period_key = f"{submitted_at.year}Q{((submitted_at.month - 1) // 3) + 1}"
|
||||
allocation_keys.add((period_key, claim.department_name, getattr(claim, "expense_type", "")))
|
||||
transaction_count += 1
|
||||
reservation_count += int(claim.status in PENDING_STATUSES)
|
||||
return len(allocation_keys), transaction_count, reservation_count
|
||||
|
||||
|
||||
def _claim_cost_center(
|
||||
claim: ExpenseClaim,
|
||||
departments: dict[str, OrganizationUnit],
|
||||
) -> str | None:
|
||||
for department in departments.values():
|
||||
if department.id == claim.department_id:
|
||||
return department.cost_center
|
||||
return None
|
||||
|
||||
|
||||
def _format_amounts(buckets: dict[str, Decimal]) -> dict[str, str]:
|
||||
return {
|
||||
key: str(value.quantize(Decimal("0.01")))
|
||||
for key, value in sorted(buckets.items(), key=lambda item: item[1], reverse=True)
|
||||
}
|
||||
|
||||
|
||||
def _hours(value: int):
|
||||
from datetime import timedelta
|
||||
|
||||
return timedelta(hours=value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
111
server/scripts/seed_half_year_expense_demo.py
Normal file
111
server/scripts/seed_half_year_expense_demo.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = SERVER_DIR / "src"
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
|
||||
from app.db.session import get_session_factory # noqa: E402
|
||||
from app.models.employee import Employee # noqa: E402
|
||||
from app.services.demo_company_simulation_filters import is_admin_employee_like # noqa: E402
|
||||
from app.services.demo_company_simulation_seed import ( # noqa: E402
|
||||
HalfYearExpenseSimulationSeeder,
|
||||
SimulationConfig,
|
||||
)
|
||||
from app.services.employee_behavior_profile_service import ( # noqa: E402
|
||||
EmployeeBehaviorProfileService,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Seed half-year simulated reimbursement, budget, and employee data.",
|
||||
)
|
||||
parser.add_argument("--target-employees", type=int, default=100)
|
||||
parser.add_argument("--start-date", type=date.fromisoformat, default=date(2026, 1, 1))
|
||||
parser.add_argument("--months", type=int, default=6)
|
||||
parser.add_argument("--seed", type=int, default=20260602)
|
||||
parser.add_argument("--apply", action="store_true", help="Write data. Default is dry-run only.")
|
||||
parser.add_argument(
|
||||
"--refresh-profiles",
|
||||
action="store_true",
|
||||
help="After --apply, refresh employee behavior profile snapshots for simulated employees.",
|
||||
)
|
||||
parser.add_argument("--profile-limit", type=int, default=120)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
config = SimulationConfig(
|
||||
target_employees=args.target_employees,
|
||||
start_date=args.start_date,
|
||||
months=args.months,
|
||||
seed=args.seed,
|
||||
)
|
||||
session_factory = get_session_factory()
|
||||
with session_factory() as db:
|
||||
seeder = HalfYearExpenseSimulationSeeder(db, config)
|
||||
try:
|
||||
summary = seeder.apply() if args.apply else seeder.preview()
|
||||
profile_refresh = None
|
||||
if args.apply and args.refresh_profiles:
|
||||
profile_refresh = refresh_company_profiles(db, limit=args.profile_limit)
|
||||
elif args.apply:
|
||||
db.commit()
|
||||
payload = summary.to_dict()
|
||||
if profile_refresh is not None:
|
||||
payload["profile_refresh"] = profile_refresh
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if not args.apply:
|
||||
print("dry-run only; pass --apply after confirmation to write simulated data.")
|
||||
elif not args.refresh_profiles:
|
||||
print("pass --refresh-profiles to generate employee behavior profile snapshots.")
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def refresh_company_profiles(db, *, limit: int) -> dict[str, object]:
|
||||
capped_limit = max(1, min(int(limit or 120), 500))
|
||||
employees = list(
|
||||
db.scalars(select(Employee).order_by(Employee.employee_no.asc())).all()
|
||||
)
|
||||
employee_ids = [
|
||||
employee.id
|
||||
for employee in employees
|
||||
if not is_admin_employee_like(employee)
|
||||
][:capped_limit]
|
||||
service = EmployeeBehaviorProfileService(db)
|
||||
snapshot_count = 0
|
||||
for employee_id in employee_ids:
|
||||
snapshots = service.refresh_employee_profiles(
|
||||
employee_id=employee_id,
|
||||
window_days=(30, 90, 180),
|
||||
expense_type_scope="overall",
|
||||
source_task_type="half_year_expense_demo_seed",
|
||||
commit=False,
|
||||
)
|
||||
snapshot_count += len(snapshots)
|
||||
|
||||
db.commit()
|
||||
return {
|
||||
"target_employee_count": len(employee_ids),
|
||||
"snapshot_count": snapshot_count,
|
||||
"window_days": [30, 90, 180],
|
||||
"source_task_type": "half_year_expense_demo_seed",
|
||||
"scope": "all_non_admin_employees",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -17,5 +17,8 @@ class FinanceDashboardRead(BaseModel):
|
||||
spend_by_category: list[dict[str, Any]] = Field(default_factory=list)
|
||||
exception_mix: list[dict[str, Any]] = Field(default_factory=list)
|
||||
department_ranking: list[dict[str, Any]] = Field(default_factory=list)
|
||||
employee_ranking: list[dict[str, Any]] = Field(default_factory=list)
|
||||
top_claims: list[dict[str, Any]] = Field(default_factory=list)
|
||||
bottlenecks: list[dict[str, Any]] = Field(default_factory=list)
|
||||
budget_summary: dict[str, Any] = Field(default_factory=dict)
|
||||
budget_metrics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
@@ -163,24 +163,13 @@ def build_application_system_estimate(
|
||||
lodging_display = format_application_money(lodging)
|
||||
allowance_display = format_application_money(allowance)
|
||||
total_display = format_application_money(total_amount)
|
||||
band_label = {
|
||||
"premium": "一线/高频城市",
|
||||
"remote": "远途地区",
|
||||
"coastal": "沿海城市",
|
||||
"default": "普通城市",
|
||||
}[location_band]
|
||||
query_label = query_date or "出行日期待确认"
|
||||
|
||||
return {
|
||||
"amount": f"{total_display}元",
|
||||
"lodging_daily_cap": f"{format_application_money(lodging_daily)}元/天",
|
||||
"subsidy_daily_cap": f"{format_application_money(allowance_daily)}元/天",
|
||||
"transport_policy": (
|
||||
f"已查询 {query_label} {mode}参考票价,按{band_label}往返 {transport_display}元预估"
|
||||
f"(查询耗时 {simulated_latency_ms}ms),报销阶段按真实票据复核"
|
||||
),
|
||||
"transport_policy": f"预估交通费用 {transport_display}元",
|
||||
"policy_estimate": (
|
||||
f"交通 {transport_display}元(按 {query_label} 参考票价) + 住宿 {lodging_display}元"
|
||||
f"交通 {transport_display}元 + 住宿 {lodging_display}元"
|
||||
f" + 补贴 {allowance_display}元 = {total_display}元({days}天)"
|
||||
),
|
||||
"matched_city": str(location or "").strip(),
|
||||
|
||||
274
server/src/app/services/demo_company_simulation_catalog.py
Normal file
274
server/src/app/services/demo_company_simulation_catalog.py
Normal file
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
SIM_EMPLOYEE_PREFIX = "SIM2026"
|
||||
SIM_CLAIM_PREFIX = "SIM-EXP-2026"
|
||||
SIM_BUDGET_PREFIX = "SIM-BUD-2026"
|
||||
SIM_TRANSACTION_PREFIX = "SIM-BTX-2026"
|
||||
SIM_RESERVATION_PREFIX = "SIM-BRS-2026"
|
||||
SIM_RISK_PREFIX = "SIM-RISK-2026"
|
||||
SIM_PROJECT_CODE = "SIM-DEMO"
|
||||
DEFAULT_PASSWORD = "123456"
|
||||
|
||||
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
||||
PENDING_STATUSES = {
|
||||
"submitted",
|
||||
"review",
|
||||
"pending_review",
|
||||
"manager_review",
|
||||
"budget_review",
|
||||
"finance_review",
|
||||
"approving",
|
||||
}
|
||||
BUDGETED_STATUSES = SUCCESS_STATUSES | PENDING_STATUSES
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationConfig:
|
||||
target_employees: int = 100
|
||||
start_date: date = date(2026, 1, 1)
|
||||
months: int = 6
|
||||
seed: int = 20260602
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SimulationSummary:
|
||||
mode: str
|
||||
current_employee_count: int
|
||||
target_employee_count: int
|
||||
selected_employee_count: int
|
||||
employees_to_create: int
|
||||
claims_to_create: int
|
||||
claim_items_to_create: int
|
||||
budget_allocations_to_create: int
|
||||
budget_transactions_to_create: int
|
||||
budget_reservations_to_create: int
|
||||
risk_observations_to_create: int
|
||||
period_start: str
|
||||
period_end: str
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DepartmentRef:
|
||||
id: str
|
||||
unit_code: str
|
||||
name: str
|
||||
cost_center: str
|
||||
location: str
|
||||
manager_name: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EmployeeRef:
|
||||
id: str
|
||||
employee_no: str
|
||||
name: str
|
||||
email: str
|
||||
grade: str
|
||||
position: str
|
||||
department: DepartmentRef
|
||||
is_new: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClaimItemPlan:
|
||||
item_date: date
|
||||
item_type: str
|
||||
item_reason: str
|
||||
item_location: str
|
||||
item_amount: Decimal
|
||||
invoice_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ClaimPlan:
|
||||
id: str
|
||||
claim_no: str
|
||||
employee: EmployeeRef
|
||||
expense_type: str
|
||||
reason: str
|
||||
location: str
|
||||
amount: Decimal
|
||||
invoice_count: int
|
||||
occurred_at: datetime
|
||||
submitted_at: datetime | None
|
||||
status: str
|
||||
approval_stage: str | None
|
||||
risk_flags: list[dict[str, Any]]
|
||||
hermes_risk_flag: bool
|
||||
items: list[ClaimItemPlan]
|
||||
|
||||
@property
|
||||
def period_key(self) -> str:
|
||||
quarter = ((self.occurred_at.month - 1) // 3) + 1
|
||||
return f"{self.occurred_at.year}Q{quarter}"
|
||||
|
||||
@property
|
||||
def budget_subject_code(self) -> str:
|
||||
return "meal" if self.expense_type == "entertainment" else self.expense_type
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AllocationPlan:
|
||||
key: tuple[int, str, str, str, str]
|
||||
department: DepartmentRef
|
||||
subject_code: str
|
||||
subject_name: str
|
||||
period_key: str
|
||||
original_amount: Decimal
|
||||
|
||||
|
||||
DEFAULT_DEPARTMENTS = (
|
||||
DepartmentRef("sim-dept-tech", "TECH-DEPT", "技术部", "CC-6100", "北京", "吴磊"),
|
||||
DepartmentRef("sim-dept-market", "MARKET-DEPT", "市场部", "CC-4100", "上海", "刘思雨"),
|
||||
DepartmentRef("sim-dept-finance", "FINANCE-DEPT", "财务部", "CC-2100", "上海", "张晓晴"),
|
||||
DepartmentRef("sim-dept-hr", "HR-DEPT", "人力资源部", "CC-3200", "杭州", "陈硕"),
|
||||
DepartmentRef("sim-dept-prod", "PRODUCTION-DEPT", "生产部", "CC-7200", "南京", "梁雨辰"),
|
||||
DepartmentRef("sim-dept-office", "PRESIDENT-OFFICE", "总裁办", "CC-1000", "上海", "李文静"),
|
||||
)
|
||||
|
||||
SUBJECT_LABELS = {
|
||||
"travel": "差旅",
|
||||
"meal": "招待费",
|
||||
"office": "办公用品",
|
||||
"communication": "通信",
|
||||
}
|
||||
SUBJECT_BASE_AMOUNTS = {
|
||||
"travel": Decimal("5600.00"),
|
||||
"meal": Decimal("1800.00"),
|
||||
"office": Decimal("820.00"),
|
||||
"communication": Decimal("320.00"),
|
||||
}
|
||||
DEPARTMENT_CLAIM_WEIGHTS = {
|
||||
"TECH-DEPT": {"travel": 4, "meal": 1, "office": 3, "communication": 2},
|
||||
"MARKET-DEPT": {"travel": 5, "meal": 4, "office": 1, "communication": 1},
|
||||
"FINANCE-DEPT": {"travel": 2, "meal": 1, "office": 3, "communication": 2},
|
||||
"HR-DEPT": {"travel": 2, "meal": 2, "office": 3, "communication": 1},
|
||||
"PRODUCTION-DEPT": {"travel": 3, "meal": 1, "office": 4, "communication": 1},
|
||||
"PRESIDENT-OFFICE": {"travel": 4, "meal": 3, "office": 2, "communication": 1},
|
||||
}
|
||||
DEPARTMENT_EMPLOYEE_WEIGHTS = {
|
||||
"TECH-DEPT": 30,
|
||||
"MARKET-DEPT": 24,
|
||||
"PRODUCTION-DEPT": 20,
|
||||
"FINANCE-DEPT": 12,
|
||||
"HR-DEPT": 9,
|
||||
"PRESIDENT-OFFICE": 5,
|
||||
}
|
||||
GRADE_FACTORS = {
|
||||
"P3": Decimal("0.82"),
|
||||
"P4": Decimal("0.92"),
|
||||
"P5": Decimal("1.00"),
|
||||
"P6": Decimal("1.15"),
|
||||
"P7": Decimal("1.32"),
|
||||
"P8": Decimal("1.55"),
|
||||
}
|
||||
MONTH_FACTORS = {
|
||||
1: Decimal("0.86"),
|
||||
2: Decimal("0.72"),
|
||||
3: Decimal("1.05"),
|
||||
4: Decimal("1.12"),
|
||||
5: Decimal("1.22"),
|
||||
6: Decimal("1.34"),
|
||||
}
|
||||
|
||||
|
||||
def build_employee_name(index: int) -> str:
|
||||
surnames = ("林", "许", "周", "唐", "沈", "陆", "韩", "钱", "冯", "邹", "顾", "夏")
|
||||
names = ("嘉宁", "思远", "雨桐", "景行", "明轩", "若琳", "子涵", "安琪", "奕辰", "诗涵")
|
||||
return f"{surnames[index % len(surnames)]}{names[(index * 3) % len(names)]}"
|
||||
|
||||
|
||||
def grade_for_index(index: int) -> str:
|
||||
grades = ("P3", "P4", "P4", "P5", "P5", "P6", "P6", "P7", "P8")
|
||||
return grades[index % len(grades)]
|
||||
|
||||
|
||||
def position_for_grade(grade: str) -> str:
|
||||
return {
|
||||
"P3": "专员",
|
||||
"P4": "高级专员",
|
||||
"P5": "主管",
|
||||
"P6": "经理",
|
||||
"P7": "高级经理",
|
||||
"P8": "部门负责人",
|
||||
}.get(grade, "员工")
|
||||
|
||||
|
||||
def claim_reason(expense_type: str, department_name: str, occurred_day: date) -> str:
|
||||
labels = {
|
||||
"travel": "客户拜访与项目交付差旅",
|
||||
"meal": "客户沟通与商务招待",
|
||||
"office": "团队办公用品采购",
|
||||
"communication": "项目通信与移动办公",
|
||||
}
|
||||
return f"{department_name}{occurred_day.month}月{labels.get(expense_type, '业务费用')}"
|
||||
|
||||
|
||||
def item_reason(expense_type: str) -> str:
|
||||
return {
|
||||
"meal": "商务招待餐费",
|
||||
"office": "办公用品采购",
|
||||
"communication": "通信服务费",
|
||||
}.get(expense_type, "业务费用")
|
||||
|
||||
|
||||
def claim_location(default_location: str, claim_index: int) -> str:
|
||||
cities = ("上海", "北京", "深圳", "广州", "杭州", "南京", "成都", "武汉")
|
||||
return cities[claim_index % len(cities)] or default_location
|
||||
|
||||
|
||||
def risk_type(claim_index: int, expense_type: str) -> tuple[str, str]:
|
||||
options = (
|
||||
("amount_outlier", "金额异常"),
|
||||
("budget_pressure", "预算压力偏高"),
|
||||
("missing_material", "材料不完整"),
|
||||
("duplicate_invoice", "疑似重复票据"),
|
||||
("split_billing", "疑似拆分报销"),
|
||||
)
|
||||
if expense_type == "travel" and claim_index % 5 == 0:
|
||||
return "location_mismatch", "地点不一致"
|
||||
return options[claim_index % len(options)]
|
||||
|
||||
|
||||
def target_budget_usage(period_key: str, subject_code: str, index: int) -> Decimal:
|
||||
sequence = (
|
||||
Decimal("0.62"),
|
||||
Decimal("0.74"),
|
||||
Decimal("0.83"),
|
||||
Decimal("0.91"),
|
||||
Decimal("1.06"),
|
||||
)
|
||||
usage = sequence[index % len(sequence)]
|
||||
if period_key.endswith("Q2") and subject_code in {"travel", "meal"}:
|
||||
usage += Decimal("0.07")
|
||||
return min(usage, Decimal("1.12"))
|
||||
|
||||
|
||||
def department_from_row(row: Any | None) -> DepartmentRef:
|
||||
if row is None:
|
||||
return DEFAULT_DEPARTMENTS[0]
|
||||
return DepartmentRef(
|
||||
id=row.id,
|
||||
unit_code=row.unit_code,
|
||||
name=row.name,
|
||||
cost_center=row.cost_center or "",
|
||||
location=row.location or "上海",
|
||||
manager_name=row.manager_name or "",
|
||||
)
|
||||
|
||||
|
||||
def updated_at_for_claim_plan(plan: ClaimPlan) -> datetime:
|
||||
from datetime import timedelta
|
||||
|
||||
base = plan.submitted_at or plan.occurred_at
|
||||
if plan.status in SUCCESS_STATUSES | {"rejected", "returned"}:
|
||||
return base + timedelta(hours=2 + int(plan.claim_no[-2:]) % 24)
|
||||
return base + timedelta(hours=1)
|
||||
79
server/src/app/services/demo_company_simulation_filters.py
Normal file
79
server/src/app/services/demo_company_simulation_filters.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
ADMIN_KEYWORDS = {
|
||||
"admin",
|
||||
"administrator",
|
||||
"root",
|
||||
"system",
|
||||
"sysadmin",
|
||||
"superadmin",
|
||||
}
|
||||
ADMIN_CN_KEYWORDS = ("管理员", "系统")
|
||||
APPLICATION_EXPENSE_TYPES = {
|
||||
"application",
|
||||
"expense_application",
|
||||
"travel_application",
|
||||
"trip_application",
|
||||
"preapproval",
|
||||
}
|
||||
APPLICATION_CLAIM_PREFIXES = ("AP-", "APP-", "TA-")
|
||||
RECENT_VISIBLE_CLAIM_START = 501
|
||||
RECENT_VISIBLE_CLAIM_END = 950
|
||||
|
||||
|
||||
def is_admin_identity(*values: Any) -> bool:
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
lowered = text.lower()
|
||||
if not text:
|
||||
continue
|
||||
if lowered in ADMIN_KEYWORDS:
|
||||
return True
|
||||
if any(token in lowered for token in ADMIN_KEYWORDS):
|
||||
return True
|
||||
if any(token in text for token in ADMIN_CN_KEYWORDS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def is_admin_employee_like(employee: Any) -> bool:
|
||||
return is_admin_identity(
|
||||
getattr(employee, "employee_no", None),
|
||||
getattr(employee, "name", None),
|
||||
getattr(employee, "email", None),
|
||||
)
|
||||
|
||||
|
||||
def is_application_claim(claim: Any) -> bool:
|
||||
expense_type = str(getattr(claim, "expense_type", "") or "").strip().lower()
|
||||
claim_no = str(getattr(claim, "claim_no", "") or "").strip().upper()
|
||||
if expense_type in APPLICATION_EXPENSE_TYPES:
|
||||
return True
|
||||
return claim_no.startswith(APPLICATION_CLAIM_PREFIXES)
|
||||
|
||||
|
||||
def is_finance_reimbursement_claim(claim: Any) -> bool:
|
||||
if is_application_claim(claim):
|
||||
return False
|
||||
return not is_admin_identity(
|
||||
getattr(claim, "employee_name", None),
|
||||
getattr(claim, "employee_id", None),
|
||||
)
|
||||
|
||||
|
||||
def recent_visible_claim_day(
|
||||
months: list[date],
|
||||
*,
|
||||
employee_index: int,
|
||||
claim_index: int,
|
||||
) -> date | None:
|
||||
if not months or not (RECENT_VISIBLE_CLAIM_START <= claim_index <= RECENT_VISIBLE_CLAIM_END):
|
||||
return None
|
||||
month = months[-1]
|
||||
_, max_day = calendar.monthrange(month.year, month.month)
|
||||
day = min(2, max_day)
|
||||
return month.replace(day=1 + ((employee_index + claim_index) % day))
|
||||
821
server/src/app/services/demo_company_simulation_seed.py
Normal file
821
server/src/app/services/demo_company_simulation_seed.py
Normal file
@@ -0,0 +1,821 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import random
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.models.role import Role
|
||||
from app.services.demo_company_simulation_catalog import (
|
||||
BUDGETED_STATUSES,
|
||||
DEFAULT_DEPARTMENTS,
|
||||
DEFAULT_PASSWORD,
|
||||
DEPARTMENT_CLAIM_WEIGHTS,
|
||||
DEPARTMENT_EMPLOYEE_WEIGHTS,
|
||||
GRADE_FACTORS,
|
||||
MONTH_FACTORS,
|
||||
PENDING_STATUSES,
|
||||
SIM_BUDGET_PREFIX,
|
||||
SIM_CLAIM_PREFIX,
|
||||
SIM_EMPLOYEE_PREFIX,
|
||||
SIM_PROJECT_CODE,
|
||||
SIM_RESERVATION_PREFIX,
|
||||
SIM_RISK_PREFIX,
|
||||
SIM_TRANSACTION_PREFIX,
|
||||
SUBJECT_BASE_AMOUNTS,
|
||||
SUBJECT_LABELS,
|
||||
SUCCESS_STATUSES,
|
||||
AllocationPlan,
|
||||
ClaimItemPlan,
|
||||
ClaimPlan,
|
||||
DepartmentRef,
|
||||
EmployeeRef,
|
||||
SimulationConfig,
|
||||
SimulationSummary,
|
||||
build_employee_name,
|
||||
claim_location,
|
||||
claim_reason,
|
||||
department_from_row,
|
||||
grade_for_index,
|
||||
item_reason,
|
||||
position_for_grade,
|
||||
risk_type,
|
||||
target_budget_usage,
|
||||
updated_at_for_claim_plan,
|
||||
)
|
||||
from app.services.demo_company_simulation_filters import (
|
||||
is_admin_employee_like,
|
||||
recent_visible_claim_day,
|
||||
)
|
||||
|
||||
|
||||
class HalfYearExpenseSimulationSeeder:
|
||||
def __init__(self, db: Session, config: SimulationConfig | None = None) -> None:
|
||||
self.db = db
|
||||
self.config = config or SimulationConfig()
|
||||
self.rng = random.Random(self.config.seed)
|
||||
|
||||
def preview(self) -> SimulationSummary:
|
||||
return self._run(apply=False)
|
||||
|
||||
def apply(self) -> SimulationSummary:
|
||||
return self._run(apply=True)
|
||||
|
||||
def _run(self, *, apply: bool) -> SimulationSummary:
|
||||
Base.metadata.create_all(bind=self.db.get_bind())
|
||||
departments = self._department_refs(apply=apply)
|
||||
current_employee_count = self._employee_count()
|
||||
planned_employees = self._build_new_employee_refs(departments, current_employee_count)
|
||||
|
||||
if apply:
|
||||
self._ensure_user_role()
|
||||
self._create_missing_employees(planned_employees)
|
||||
self.db.flush()
|
||||
|
||||
employees = self._employee_refs(departments)
|
||||
if not apply:
|
||||
employees = [*employees, *planned_employees]
|
||||
|
||||
selected_employees = self._select_company_employees(employees)
|
||||
claim_plans = self._build_claim_plans(selected_employees)
|
||||
allocation_plans = self._build_allocation_plans(claim_plans)
|
||||
|
||||
allocation_map, allocation_count = self._ensure_allocations(
|
||||
allocation_plans,
|
||||
apply=apply,
|
||||
)
|
||||
claim_count, item_count = self._ensure_claims(claim_plans, apply=apply)
|
||||
transaction_count, reservation_count = self._ensure_budget_usage(
|
||||
claim_plans,
|
||||
allocation_map,
|
||||
apply=apply,
|
||||
)
|
||||
risk_count = self._ensure_risk_observations(claim_plans, apply=apply)
|
||||
|
||||
return SimulationSummary(
|
||||
mode="apply" if apply else "dry-run",
|
||||
current_employee_count=current_employee_count,
|
||||
target_employee_count=self.config.target_employees,
|
||||
selected_employee_count=len(selected_employees),
|
||||
employees_to_create=len(planned_employees),
|
||||
claims_to_create=claim_count,
|
||||
claim_items_to_create=item_count,
|
||||
budget_allocations_to_create=allocation_count,
|
||||
budget_transactions_to_create=transaction_count,
|
||||
budget_reservations_to_create=reservation_count,
|
||||
risk_observations_to_create=risk_count,
|
||||
period_start=self.config.start_date.isoformat(),
|
||||
period_end=self._period_end().isoformat(),
|
||||
)
|
||||
|
||||
def _department_refs(self, *, apply: bool) -> list[DepartmentRef]:
|
||||
rows = list(
|
||||
self.db.scalars(
|
||||
select(OrganizationUnit)
|
||||
.where(OrganizationUnit.unit_type == "department")
|
||||
.order_by(OrganizationUnit.unit_code.asc())
|
||||
).all()
|
||||
)
|
||||
if rows:
|
||||
return [department_from_row(row) for row in rows]
|
||||
if not apply:
|
||||
return list(DEFAULT_DEPARTMENTS)
|
||||
|
||||
for item in DEFAULT_DEPARTMENTS:
|
||||
self.db.add(
|
||||
OrganizationUnit(
|
||||
id=item.id,
|
||||
unit_code=item.unit_code,
|
||||
name=item.name,
|
||||
unit_type="department",
|
||||
cost_center=item.cost_center,
|
||||
location=item.location,
|
||||
manager_name=item.manager_name,
|
||||
)
|
||||
)
|
||||
self.db.flush()
|
||||
return list(DEFAULT_DEPARTMENTS)
|
||||
|
||||
def _employee_count(self) -> int:
|
||||
employees = list(self.db.scalars(select(Employee)).all())
|
||||
return sum(1 for employee in employees if not is_admin_employee_like(employee))
|
||||
|
||||
def _build_new_employee_refs(
|
||||
self,
|
||||
departments: list[DepartmentRef],
|
||||
current_employee_count: int,
|
||||
) -> list[EmployeeRef]:
|
||||
missing_count = max(self.config.target_employees - current_employee_count, 0)
|
||||
if missing_count <= 0:
|
||||
return []
|
||||
|
||||
existing_nos = set(self.db.scalars(select(Employee.employee_no)).all())
|
||||
refs: list[EmployeeRef] = []
|
||||
next_index = 1
|
||||
while len(refs) < missing_count:
|
||||
employee_no = f"{SIM_EMPLOYEE_PREFIX}{next_index:03d}"
|
||||
next_index += 1
|
||||
if employee_no in existing_nos:
|
||||
continue
|
||||
department = self._weighted_department(departments, len(refs))
|
||||
grade = grade_for_index(len(refs))
|
||||
refs.append(
|
||||
EmployeeRef(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{employee_no}")),
|
||||
employee_no=employee_no,
|
||||
name=build_employee_name(len(refs)),
|
||||
email=f"{employee_no.lower()}@xf.com",
|
||||
grade=grade,
|
||||
position=position_for_grade(grade),
|
||||
department=department,
|
||||
is_new=True,
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
def _ensure_user_role(self) -> Role:
|
||||
role = self.db.scalar(select(Role).where(Role.role_code == "user"))
|
||||
if role is not None:
|
||||
return role
|
||||
role = Role(
|
||||
role_code="user",
|
||||
name="使用者",
|
||||
description="可以发起费用申请、报销和查看个人单据。",
|
||||
)
|
||||
self.db.add(role)
|
||||
self.db.flush()
|
||||
return role
|
||||
|
||||
def _create_missing_employees(self, refs: list[EmployeeRef]) -> None:
|
||||
if not refs:
|
||||
return
|
||||
user_role = self._ensure_user_role()
|
||||
existing_nos = set(self.db.scalars(select(Employee.employee_no)).all())
|
||||
departments_by_id = {row.id: row for row in self.db.scalars(select(OrganizationUnit)).all()}
|
||||
for ref in refs:
|
||||
if ref.employee_no in existing_nos:
|
||||
continue
|
||||
employee = Employee(
|
||||
id=ref.id,
|
||||
employee_no=ref.employee_no,
|
||||
name=ref.name,
|
||||
email=ref.email,
|
||||
gender="女" if int(ref.employee_no[-1]) % 2 == 0 else "男",
|
||||
phone=f"139{int(ref.employee_no[-3:]):08d}",
|
||||
join_date=date(2025, (int(ref.employee_no[-3:]) % 12) + 1, 10),
|
||||
location=ref.department.location,
|
||||
position=ref.position,
|
||||
grade=ref.grade,
|
||||
cost_center=ref.department.cost_center,
|
||||
finance_owner_name=f"{ref.department.name}财务BP",
|
||||
bank_name="招商银行",
|
||||
bank_account_no=f"622588{int(ref.employee_no[-3:]):013d}",
|
||||
bank_account_name=ref.name,
|
||||
password_hash=hash_password(DEFAULT_PASSWORD),
|
||||
employment_status="在职",
|
||||
sync_state="已同步",
|
||||
compliance_score=92 + int(ref.employee_no[-3:]) % 8,
|
||||
organization_unit=departments_by_id.get(ref.department.id),
|
||||
roles=[user_role],
|
||||
last_sync_at=datetime.now(UTC),
|
||||
)
|
||||
self.db.add(employee)
|
||||
|
||||
def _employee_refs(self, departments: list[DepartmentRef]) -> list[EmployeeRef]:
|
||||
department_by_id = {item.id: item for item in departments}
|
||||
fallback_departments = departments or list(DEFAULT_DEPARTMENTS)
|
||||
rows = list(
|
||||
self.db.scalars(
|
||||
select(Employee)
|
||||
.options(selectinload(Employee.organization_unit))
|
||||
.order_by(Employee.employee_no.asc())
|
||||
).all()
|
||||
)
|
||||
refs: list[EmployeeRef] = []
|
||||
for index, employee in enumerate(rows):
|
||||
department = (
|
||||
department_by_id.get(str(employee.organization_unit_id or ""))
|
||||
or department_from_row(employee.organization_unit)
|
||||
if employee.organization_unit is not None
|
||||
else fallback_departments[index % len(fallback_departments)]
|
||||
)
|
||||
refs.append(
|
||||
EmployeeRef(
|
||||
id=employee.id,
|
||||
employee_no=employee.employee_no,
|
||||
name=employee.name,
|
||||
email=employee.email,
|
||||
grade=employee.grade or "P4",
|
||||
position=employee.position or "员工",
|
||||
department=department,
|
||||
is_new=False,
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
def _select_company_employees(self, employees: list[EmployeeRef]) -> list[EmployeeRef]:
|
||||
sorted_employees = sorted(
|
||||
(employee for employee in employees if not is_admin_employee_like(employee)),
|
||||
key=lambda item: item.employee_no,
|
||||
)
|
||||
target = max(1, self.config.target_employees)
|
||||
return sorted_employees[:target] if len(sorted_employees) > target else sorted_employees
|
||||
|
||||
def _build_claim_plans(self, employees: list[EmployeeRef]) -> list[ClaimPlan]:
|
||||
plans: list[ClaimPlan] = []
|
||||
months = self._month_starts()
|
||||
claim_index = 1
|
||||
for employee_index, employee in enumerate(employees):
|
||||
count = self._claim_count_for_employee(employee, employee_index)
|
||||
for local_index in range(count):
|
||||
occurred_day = self._claim_day(
|
||||
months,
|
||||
employee_index,
|
||||
local_index,
|
||||
claim_index,
|
||||
)
|
||||
expense_type = self._expense_type_for_employee(employee)
|
||||
amount = self._claim_amount(employee, expense_type, occurred_day)
|
||||
status, stage = self._status_for_claim(employee_index, local_index)
|
||||
risk_flags = self._risk_flags(employee, expense_type, amount, claim_index)
|
||||
submitted_at = None
|
||||
if status != "draft":
|
||||
submitted_at = datetime.combine(occurred_day, datetime.min.time(), tzinfo=UTC)
|
||||
submitted_at += timedelta(hours=9 + (claim_index % 7))
|
||||
occurred_at = datetime.combine(occurred_day, datetime.min.time(), tzinfo=UTC)
|
||||
occurred_at += timedelta(hours=8 + (claim_index % 9))
|
||||
plans.append(
|
||||
ClaimPlan(
|
||||
id=str(
|
||||
uuid.uuid5(
|
||||
uuid.NAMESPACE_DNS,
|
||||
f"x-financial:{SIM_CLAIM_PREFIX}:{claim_index}",
|
||||
)
|
||||
),
|
||||
claim_no=f"{SIM_CLAIM_PREFIX}-{claim_index:04d}",
|
||||
employee=employee,
|
||||
expense_type=expense_type,
|
||||
reason=claim_reason(
|
||||
expense_type,
|
||||
employee.department.name,
|
||||
occurred_day,
|
||||
),
|
||||
location=claim_location(employee.department.location, claim_index),
|
||||
amount=amount,
|
||||
invoice_count=1 + (claim_index % 3),
|
||||
occurred_at=occurred_at,
|
||||
submitted_at=submitted_at,
|
||||
status=status,
|
||||
approval_stage=stage,
|
||||
risk_flags=risk_flags,
|
||||
hermes_risk_flag=bool(risk_flags and claim_index % 2 == 0),
|
||||
items=self._claim_items(expense_type, amount, occurred_day, claim_index),
|
||||
)
|
||||
)
|
||||
claim_index += 1
|
||||
return plans
|
||||
|
||||
def _build_allocation_plans(self, claim_plans: list[ClaimPlan]) -> list[AllocationPlan]:
|
||||
bucket_amounts: dict[tuple[int, str, str, str, str], Decimal] = {}
|
||||
bucket_departments: dict[tuple[int, str, str, str, str], DepartmentRef] = {}
|
||||
for plan in claim_plans:
|
||||
if plan.status not in BUDGETED_STATUSES:
|
||||
continue
|
||||
department = plan.employee.department
|
||||
key = (
|
||||
plan.occurred_at.year,
|
||||
plan.period_key,
|
||||
department.id,
|
||||
department.cost_center,
|
||||
plan.budget_subject_code,
|
||||
)
|
||||
bucket_amounts[key] = bucket_amounts.get(key, Decimal("0.00")) + plan.amount
|
||||
bucket_departments[key] = department
|
||||
|
||||
plans: list[AllocationPlan] = []
|
||||
for index, (key, used_amount) in enumerate(sorted(bucket_amounts.items())):
|
||||
year, period_key, _department_id, _cost_center, subject_code = key
|
||||
target_usage = target_budget_usage(period_key, subject_code, index)
|
||||
original_amount = max(
|
||||
(used_amount / target_usage).quantize(Decimal("0.01")),
|
||||
Decimal("3000.00"),
|
||||
)
|
||||
plans.append(
|
||||
AllocationPlan(
|
||||
key=key,
|
||||
department=bucket_departments[key],
|
||||
subject_code=subject_code,
|
||||
subject_name=SUBJECT_LABELS.get(subject_code, subject_code),
|
||||
period_key=period_key,
|
||||
original_amount=original_amount,
|
||||
)
|
||||
)
|
||||
return plans
|
||||
|
||||
def _ensure_allocations(
|
||||
self,
|
||||
plans: list[AllocationPlan],
|
||||
*,
|
||||
apply: bool,
|
||||
) -> tuple[dict[tuple[int, str, str, str, str], str], int]:
|
||||
allocation_map: dict[tuple[int, str, str, str, str], str] = {}
|
||||
created_count = 0
|
||||
for index, plan in enumerate(plans, start=1):
|
||||
existing = self._find_sim_allocation(plan)
|
||||
if existing is not None:
|
||||
allocation_map[plan.key] = existing.id
|
||||
continue
|
||||
created_count += 1
|
||||
allocation_id = str(
|
||||
uuid.uuid5(
|
||||
uuid.NAMESPACE_DNS,
|
||||
f"x-financial:{SIM_BUDGET_PREFIX}:{plan.key}",
|
||||
)
|
||||
)
|
||||
allocation_map[plan.key] = allocation_id
|
||||
if not apply:
|
||||
continue
|
||||
self.db.add(
|
||||
BudgetAllocation(
|
||||
id=allocation_id,
|
||||
budget_no=f"{SIM_BUDGET_PREFIX}-{index:04d}",
|
||||
fiscal_year=plan.key[0],
|
||||
period_type="quarter",
|
||||
period_key=plan.period_key,
|
||||
department_id=plan.department.id,
|
||||
department_name=plan.department.name,
|
||||
cost_center=plan.department.cost_center,
|
||||
project_code=SIM_PROJECT_CODE,
|
||||
subject_code=plan.subject_code,
|
||||
subject_name=plan.subject_name,
|
||||
original_amount=plan.original_amount,
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="warn",
|
||||
description="半年报销模拟数据预算池",
|
||||
created_by="simulation",
|
||||
updated_by="simulation",
|
||||
)
|
||||
)
|
||||
if apply:
|
||||
self.db.flush()
|
||||
return allocation_map, created_count
|
||||
|
||||
def _ensure_claims(self, plans: list[ClaimPlan], *, apply: bool) -> tuple[int, int]:
|
||||
existing_claim_nos = set(
|
||||
self.db.scalars(
|
||||
select(ExpenseClaim.claim_no).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||
).all()
|
||||
)
|
||||
claim_count = 0
|
||||
item_count = 0
|
||||
for plan in plans:
|
||||
if plan.claim_no in existing_claim_nos:
|
||||
continue
|
||||
claim_count += 1
|
||||
item_count += len(plan.items)
|
||||
if not apply:
|
||||
continue
|
||||
claim = ExpenseClaim(
|
||||
id=plan.id,
|
||||
claim_no=plan.claim_no,
|
||||
employee_id=plan.employee.id,
|
||||
employee_name=plan.employee.name,
|
||||
department_id=plan.employee.department.id,
|
||||
department_name=plan.employee.department.name,
|
||||
project_code=SIM_PROJECT_CODE,
|
||||
expense_type=plan.expense_type,
|
||||
reason=plan.reason,
|
||||
location=plan.location,
|
||||
amount=plan.amount,
|
||||
currency="CNY",
|
||||
invoice_count=plan.invoice_count,
|
||||
occurred_at=plan.occurred_at,
|
||||
submitted_at=plan.submitted_at,
|
||||
status=plan.status,
|
||||
approval_stage=plan.approval_stage,
|
||||
risk_flags_json=plan.risk_flags,
|
||||
hermes_risk_flag=plan.hermes_risk_flag,
|
||||
created_at=plan.occurred_at,
|
||||
updated_at=updated_at_for_claim_plan(plan),
|
||||
)
|
||||
claim.items = [
|
||||
ExpenseClaimItem(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{plan.claim_no}:{index}")),
|
||||
item_date=item.item_date,
|
||||
item_type=item.item_type,
|
||||
item_reason=item.item_reason,
|
||||
item_location=item.item_location,
|
||||
item_amount=item.item_amount,
|
||||
invoice_id=item.invoice_id,
|
||||
)
|
||||
for index, item in enumerate(plan.items, start=1)
|
||||
]
|
||||
self.db.add(claim)
|
||||
if apply:
|
||||
self.db.flush()
|
||||
return claim_count, item_count
|
||||
|
||||
def _ensure_budget_usage(
|
||||
self,
|
||||
plans: list[ClaimPlan],
|
||||
allocation_map: dict[tuple[int, str, str, str, str], str],
|
||||
*,
|
||||
apply: bool,
|
||||
) -> tuple[int, int]:
|
||||
existing_transactions = set(
|
||||
self.db.scalars(
|
||||
select(BudgetTransaction.transaction_no).where(
|
||||
BudgetTransaction.transaction_no.like(f"{SIM_TRANSACTION_PREFIX}%")
|
||||
)
|
||||
).all()
|
||||
)
|
||||
existing_reservations = set(
|
||||
self.db.scalars(
|
||||
select(BudgetReservation.reservation_no).where(
|
||||
BudgetReservation.reservation_no.like(f"{SIM_RESERVATION_PREFIX}%")
|
||||
)
|
||||
).all()
|
||||
)
|
||||
transaction_count = 0
|
||||
reservation_count = 0
|
||||
for index, plan in enumerate(plans, start=1):
|
||||
if plan.status not in BUDGETED_STATUSES:
|
||||
continue
|
||||
allocation_id = allocation_map.get(self._allocation_key(plan))
|
||||
if not allocation_id:
|
||||
continue
|
||||
transaction_no = f"{SIM_TRANSACTION_PREFIX}-{index:04d}"
|
||||
if transaction_no not in existing_transactions:
|
||||
transaction_count += 1
|
||||
if apply:
|
||||
self.db.add(self._transaction_for_plan(plan, allocation_id, transaction_no))
|
||||
if plan.status in PENDING_STATUSES:
|
||||
reservation_no = f"{SIM_RESERVATION_PREFIX}-{index:04d}"
|
||||
if reservation_no not in existing_reservations:
|
||||
reservation_count += 1
|
||||
if apply:
|
||||
self.db.add(self._reservation_for_plan(plan, allocation_id, reservation_no))
|
||||
if apply:
|
||||
self.db.flush()
|
||||
return transaction_count, reservation_count
|
||||
|
||||
def _ensure_risk_observations(self, plans: list[ClaimPlan], *, apply: bool) -> int:
|
||||
existing_keys = set(
|
||||
self.db.scalars(
|
||||
select(RiskObservation.observation_key).where(
|
||||
RiskObservation.observation_key.like(f"{SIM_RISK_PREFIX}%")
|
||||
)
|
||||
).all()
|
||||
)
|
||||
count = 0
|
||||
for index, plan in enumerate(plans, start=1):
|
||||
if not plan.risk_flags:
|
||||
continue
|
||||
key = f"{SIM_RISK_PREFIX}-{index:04d}"
|
||||
if key in existing_keys:
|
||||
continue
|
||||
count += 1
|
||||
if not apply:
|
||||
continue
|
||||
first_flag = plan.risk_flags[0]
|
||||
self.db.add(
|
||||
RiskObservation(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{key}")),
|
||||
observation_key=key,
|
||||
subject_type="expense_claim",
|
||||
subject_key=plan.claim_no,
|
||||
subject_label=plan.claim_no,
|
||||
claim_id=plan.id,
|
||||
claim_no=plan.claim_no,
|
||||
risk_type="simulation",
|
||||
risk_signal=str(first_flag.get("event_type") or "amount_outlier"),
|
||||
title=str(first_flag.get("label") or "模拟风险观察"),
|
||||
description=str(first_flag.get("message") or ""),
|
||||
risk_score=int(first_flag.get("risk_score") or 72),
|
||||
risk_level=str(first_flag.get("severity") or "medium"),
|
||||
confidence_score=0.78,
|
||||
control_stage="reimbursement",
|
||||
control_mode="manual_review",
|
||||
automation_mode="simulation",
|
||||
source="half_year_expense_simulation",
|
||||
algorithm_version="simulation.v1",
|
||||
status="pending_review",
|
||||
evidence_json=[
|
||||
{"label": "报销单号", "value": plan.claim_no},
|
||||
{"label": "金额", "value": str(plan.amount)},
|
||||
],
|
||||
ontology_json={"scenario": "expense", "intent": "risk_check"},
|
||||
created_at=plan.submitted_at or plan.occurred_at,
|
||||
updated_at=updated_at_for_claim_plan(plan),
|
||||
)
|
||||
)
|
||||
if apply:
|
||||
self.db.flush()
|
||||
return count
|
||||
|
||||
def _find_sim_allocation(self, plan: AllocationPlan) -> BudgetAllocation | None:
|
||||
year, period_key, department_id, cost_center, subject_code = plan.key
|
||||
stmt = (
|
||||
select(BudgetAllocation)
|
||||
.where(BudgetAllocation.fiscal_year == year)
|
||||
.where(BudgetAllocation.period_key == period_key)
|
||||
.where(BudgetAllocation.subject_code == subject_code)
|
||||
.where(BudgetAllocation.project_code == SIM_PROJECT_CODE)
|
||||
.where(
|
||||
or_(
|
||||
BudgetAllocation.department_id == department_id,
|
||||
BudgetAllocation.cost_center == cost_center,
|
||||
BudgetAllocation.department_name == plan.department.name,
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return self.db.scalar(stmt)
|
||||
|
||||
def _transaction_for_plan(
|
||||
self,
|
||||
plan: ClaimPlan,
|
||||
allocation_id: str,
|
||||
transaction_no: str,
|
||||
) -> BudgetTransaction:
|
||||
transaction_type = "consume" if plan.status in SUCCESS_STATUSES else "reserve"
|
||||
return BudgetTransaction(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{transaction_no}")),
|
||||
transaction_no=transaction_no,
|
||||
allocation_id=allocation_id,
|
||||
source_type="claim",
|
||||
source_id=plan.id,
|
||||
source_no=plan.claim_no,
|
||||
transaction_type=transaction_type,
|
||||
amount=plan.amount,
|
||||
before_available_amount=Decimal("0.00"),
|
||||
after_available_amount=Decimal("0.00"),
|
||||
operator="simulation",
|
||||
reason=(
|
||||
"半年报销模拟数据预算核销"
|
||||
if transaction_type == "consume"
|
||||
else "半年报销模拟数据预算预占"
|
||||
),
|
||||
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True},
|
||||
created_at=plan.submitted_at or plan.occurred_at,
|
||||
)
|
||||
|
||||
def _reservation_for_plan(
|
||||
self,
|
||||
plan: ClaimPlan,
|
||||
allocation_id: str,
|
||||
reservation_no: str,
|
||||
) -> BudgetReservation:
|
||||
return BudgetReservation(
|
||||
id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"x-financial:{reservation_no}")),
|
||||
reservation_no=reservation_no,
|
||||
allocation_id=allocation_id,
|
||||
source_type="claim",
|
||||
source_id=plan.id,
|
||||
source_no=plan.claim_no,
|
||||
source_status="active",
|
||||
amount=plan.amount,
|
||||
consumed_amount=Decimal("0.00"),
|
||||
released_amount=Decimal("0.00"),
|
||||
context_json={"project_code": SIM_PROJECT_CODE, "simulated": True},
|
||||
created_at=plan.submitted_at or plan.occurred_at,
|
||||
)
|
||||
|
||||
def _allocation_key(self, plan: ClaimPlan) -> tuple[int, str, str, str, str]:
|
||||
department = plan.employee.department
|
||||
return (
|
||||
plan.occurred_at.year,
|
||||
plan.period_key,
|
||||
department.id,
|
||||
department.cost_center,
|
||||
plan.budget_subject_code,
|
||||
)
|
||||
|
||||
def _month_starts(self) -> list[date]:
|
||||
current = self.config.start_date.replace(day=1)
|
||||
months: list[date] = []
|
||||
for _ in range(max(1, self.config.months)):
|
||||
months.append(current)
|
||||
year = current.year + (1 if current.month == 12 else 0)
|
||||
month = 1 if current.month == 12 else current.month + 1
|
||||
current = date(year, month, 1)
|
||||
return months
|
||||
|
||||
def _period_end(self) -> date:
|
||||
months = self._month_starts()
|
||||
last_month = months[-1]
|
||||
return last_month.replace(day=calendar.monthrange(last_month.year, last_month.month)[1])
|
||||
|
||||
def _claim_day(
|
||||
self,
|
||||
months: list[date],
|
||||
employee_index: int,
|
||||
local_index: int,
|
||||
claim_index: int,
|
||||
) -> date:
|
||||
visible_day = recent_visible_claim_day(
|
||||
months,
|
||||
employee_index=employee_index,
|
||||
claim_index=claim_index,
|
||||
)
|
||||
if visible_day is not None:
|
||||
return visible_day
|
||||
month = months[(employee_index + local_index * 2) % len(months)]
|
||||
_, max_day = calendar.monthrange(month.year, month.month)
|
||||
day = 1 + ((employee_index * 7 + local_index * 11 + self.rng.randint(0, 5)) % max_day)
|
||||
return month.replace(day=day)
|
||||
|
||||
def _weighted_department(self, departments: list[DepartmentRef], index: int) -> DepartmentRef:
|
||||
weighted: list[DepartmentRef] = []
|
||||
by_code = {item.unit_code: item for item in departments}
|
||||
for code, weight in DEPARTMENT_EMPLOYEE_WEIGHTS.items():
|
||||
if code in by_code:
|
||||
weighted.extend([by_code[code]] * weight)
|
||||
weighted = weighted or departments or list(DEFAULT_DEPARTMENTS)
|
||||
return weighted[index % len(weighted)]
|
||||
|
||||
def _expense_type_for_employee(self, employee: EmployeeRef) -> str:
|
||||
weights = DEPARTMENT_CLAIM_WEIGHTS.get(
|
||||
employee.department.unit_code,
|
||||
{"travel": 3, "meal": 2, "office": 2, "communication": 1},
|
||||
)
|
||||
subjects = list(weights)
|
||||
return self.rng.choices(subjects, weights=[weights[item] for item in subjects], k=1)[0]
|
||||
|
||||
def _claim_count_for_employee(self, employee: EmployeeRef, index: int) -> int:
|
||||
base = 7 + (index % 5)
|
||||
if employee.department.unit_code in {"MARKET-DEPT", "TECH-DEPT"}:
|
||||
base += 3
|
||||
elif employee.department.unit_code in {"PRODUCTION-DEPT", "PRESIDENT-OFFICE"}:
|
||||
base += 2
|
||||
if employee.grade in {"P7", "P8"}:
|
||||
base += 2
|
||||
return max(6, min(base, 16))
|
||||
|
||||
def _claim_amount(
|
||||
self,
|
||||
employee: EmployeeRef,
|
||||
expense_type: str,
|
||||
occurred_day: date,
|
||||
) -> Decimal:
|
||||
subject = "meal" if expense_type == "entertainment" else expense_type
|
||||
base = SUBJECT_BASE_AMOUNTS.get(subject, Decimal("1000.00"))
|
||||
grade_factor = GRADE_FACTORS.get(employee.grade, Decimal("1.00"))
|
||||
month_factor = MONTH_FACTORS.get(occurred_day.month, Decimal("1.00"))
|
||||
department_factor = (
|
||||
Decimal("1.18")
|
||||
if employee.department.unit_code == "MARKET-DEPT"
|
||||
else Decimal("1.00")
|
||||
)
|
||||
noise = Decimal(str(self.rng.uniform(0.72, 1.42))).quantize(Decimal("0.01"))
|
||||
return (base * grade_factor * month_factor * department_factor * noise).quantize(
|
||||
Decimal("0.01")
|
||||
)
|
||||
|
||||
def _status_for_claim(self, employee_index: int, local_index: int) -> tuple[str, str | None]:
|
||||
selector = (employee_index * 11 + local_index * 17 + self.config.seed) % 100
|
||||
if selector < 42:
|
||||
return "paid", "已付款"
|
||||
if selector < 62:
|
||||
return "approved", "归档入账"
|
||||
if selector < 75:
|
||||
return "pending_payment", "待付款"
|
||||
if selector < 84:
|
||||
return "submitted", "财务审批"
|
||||
if selector < 92:
|
||||
return "submitted", "直属领导审批"
|
||||
if selector < 96:
|
||||
return "returned", "待补充"
|
||||
if selector < 99:
|
||||
return "rejected", "已驳回"
|
||||
return "draft", "待提交"
|
||||
|
||||
def _risk_flags(
|
||||
self,
|
||||
employee: EmployeeRef,
|
||||
expense_type: str,
|
||||
amount: Decimal,
|
||||
claim_index: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
base_probability = Decimal("0.10")
|
||||
if amount >= SUBJECT_BASE_AMOUNTS.get(expense_type, Decimal("1000.00")) * Decimal("1.55"):
|
||||
base_probability += Decimal("0.08")
|
||||
if employee.department.unit_code in {"MARKET-DEPT", "PRESIDENT-OFFICE"}:
|
||||
base_probability += Decimal("0.04")
|
||||
if Decimal(str(self.rng.random())) > base_probability:
|
||||
return []
|
||||
event_type, label = risk_type(claim_index, expense_type)
|
||||
severity = "high" if amount > Decimal("9000.00") or claim_index % 7 == 0 else "medium"
|
||||
return [
|
||||
{
|
||||
"source": "half_year_expense_simulation",
|
||||
"event_type": event_type,
|
||||
"severity": severity,
|
||||
"label": label,
|
||||
"message": (
|
||||
f"{employee.name} 的"
|
||||
f"{SUBJECT_LABELS.get(expense_type, expense_type)}样本触发{label}。"
|
||||
),
|
||||
"risk_score": 82 if severity == "high" else 68,
|
||||
"created_at": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
]
|
||||
|
||||
def _claim_items(
|
||||
self,
|
||||
expense_type: str,
|
||||
amount: Decimal,
|
||||
occurred_day: date,
|
||||
claim_index: int,
|
||||
) -> list[ClaimItemPlan]:
|
||||
if expense_type == "travel":
|
||||
hotel = (amount * Decimal("0.48")).quantize(Decimal("0.01"))
|
||||
transport = (amount * Decimal("0.37")).quantize(Decimal("0.01"))
|
||||
allowance = amount - hotel - transport
|
||||
return [
|
||||
self._item("hotel", "项目出差住宿", hotel, occurred_day, claim_index, 1),
|
||||
self._item("transport", "项目往返交通", transport, occurred_day, claim_index, 2),
|
||||
self._item("travel_allowance", "差旅补贴", allowance, occurred_day, claim_index, 3),
|
||||
]
|
||||
return [
|
||||
self._item(
|
||||
expense_type,
|
||||
item_reason(expense_type),
|
||||
amount,
|
||||
occurred_day,
|
||||
claim_index,
|
||||
1,
|
||||
)
|
||||
]
|
||||
|
||||
def _item(
|
||||
self,
|
||||
item_type: str,
|
||||
reason: str,
|
||||
amount: Decimal,
|
||||
item_date: date,
|
||||
claim_index: int,
|
||||
item_index: int,
|
||||
) -> ClaimItemPlan:
|
||||
return ClaimItemPlan(
|
||||
item_date=item_date,
|
||||
item_type=item_type,
|
||||
item_reason=reason,
|
||||
item_location=claim_location("上海", claim_index + item_index),
|
||||
item_amount=amount.quantize(Decimal("0.01")),
|
||||
invoice_id=f"SIM-INV-2026-{claim_index:04d}-{item_index}",
|
||||
)
|
||||
@@ -31,7 +31,12 @@ BUDGET_MONITOR_APPROVAL_GRADE = "P8"
|
||||
CLAIM_DELETE_ROLE_CODES = {"executive"}
|
||||
ARCHIVED_CLAIM_STATUSES = ("approved", "completed", "paid")
|
||||
APPLICATION_ARCHIVED_STAGES = (APPROVAL_DONE_STAGE, "申请归档", "completed")
|
||||
ARCHIVED_REIMBURSEMENT_STAGES = (ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE, "completed")
|
||||
ARCHIVED_REIMBURSEMENT_STAGES = (
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
"payment",
|
||||
"completed",
|
||||
)
|
||||
|
||||
|
||||
class ExpenseClaimAccessPolicy:
|
||||
@@ -640,9 +645,23 @@ class ExpenseClaimAccessPolicy:
|
||||
include_approval_scope: bool = False,
|
||||
) -> Any:
|
||||
conditions = self.build_personal_claim_conditions(current_user)
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
|
||||
if self.has_privileged_claim_access(current_user):
|
||||
company_reimbursement_condition = and_(
|
||||
func.lower(func.coalesce(ExpenseClaim.status, "")) != "draft",
|
||||
func.lower(func.coalesce(ExpenseClaim.expense_type, "")) != "application",
|
||||
~func.lower(func.coalesce(ExpenseClaim.expense_type, "")).like(
|
||||
"%\\_application",
|
||||
escape="\\",
|
||||
),
|
||||
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("AP-%"),
|
||||
~func.upper(func.coalesce(ExpenseClaim.claim_no, "")).like("APP-%"),
|
||||
~self.build_archived_claim_condition(),
|
||||
)
|
||||
conditions.append(company_reimbursement_condition)
|
||||
|
||||
if include_approval_scope:
|
||||
role_codes = self.normalize_role_codes(current_user)
|
||||
if current_user.is_admin or "executive" in role_codes:
|
||||
conditions.append(ExpenseClaim.status.in_(("submitted", PAYMENT_PENDING_STATUS, "returned")))
|
||||
elif "finance" in role_codes:
|
||||
|
||||
@@ -64,6 +64,12 @@ class ExpenseClaimApplicationHandoffMixin:
|
||||
"application_amount": application_amount,
|
||||
"application_time": application_time,
|
||||
"application_transport_mode": str(detail.get("transport_mode") or "").strip(),
|
||||
"application_lodging_daily_cap": str(detail.get("lodging_daily_cap") or "").strip(),
|
||||
"application_subsidy_daily_cap": str(detail.get("subsidy_daily_cap") or "").strip(),
|
||||
"application_transport_policy": str(detail.get("transport_policy") or "").strip(),
|
||||
"application_policy_estimate": str(detail.get("policy_estimate") or "").strip(),
|
||||
"application_rule_name": str(detail.get("rule_name") or "").strip(),
|
||||
"application_rule_version": str(detail.get("rule_version") or "").strip(),
|
||||
}
|
||||
|
||||
def _create_reimbursement_draft_from_application(
|
||||
|
||||
@@ -327,7 +327,11 @@ class ExpenseClaimDraftFlowMixin:
|
||||
)
|
||||
self._sync_claim_from_items(claim)
|
||||
elif skip_primary_item:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
self._clear_application_link_placeholder_items(claim, context_json=context_json)
|
||||
if claim.items:
|
||||
self._sync_claim_from_items(claim)
|
||||
else:
|
||||
self._sync_application_link_draft_without_items(claim)
|
||||
else:
|
||||
self._upsert_primary_item(
|
||||
claim=claim,
|
||||
@@ -394,6 +398,61 @@ class ExpenseClaimDraftFlowMixin:
|
||||
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
|
||||
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
|
||||
|
||||
def _clear_application_link_placeholder_items(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
*,
|
||||
context_json: dict[str, Any],
|
||||
) -> None:
|
||||
application_amounts = self._resolve_application_amount_candidates(context_json)
|
||||
for item in list(claim.items or []):
|
||||
if not self._is_application_link_placeholder_item(
|
||||
item,
|
||||
claim=claim,
|
||||
context_json=context_json,
|
||||
application_amounts=application_amounts,
|
||||
):
|
||||
continue
|
||||
claim.items.remove(item)
|
||||
self.db.delete(item)
|
||||
|
||||
def _is_application_link_placeholder_item(
|
||||
self,
|
||||
item: ExpenseClaimItem,
|
||||
*,
|
||||
claim: ExpenseClaim,
|
||||
context_json: dict[str, Any],
|
||||
application_amounts: set[Decimal],
|
||||
) -> bool:
|
||||
if str(item.invoice_id or "").strip():
|
||||
return False
|
||||
|
||||
item_type = str(item.item_type or "").strip().lower()
|
||||
if item_type in DOCUMENT_FACT_ITEM_TYPES:
|
||||
return False
|
||||
if item_type in SYSTEM_GENERATED_ITEM_TYPES:
|
||||
return True
|
||||
|
||||
claim_type = str(claim.expense_type or "").strip().lower()
|
||||
if item_type and claim_type and item_type != claim_type:
|
||||
return False
|
||||
|
||||
amount = self._parse_context_money_amount(item.item_amount)
|
||||
if application_amounts and amount is not None and amount > Decimal("0.00") and amount not in application_amounts:
|
||||
return False
|
||||
|
||||
reason = str(item.item_reason or "").strip()
|
||||
if not reason or reason == "待补充":
|
||||
return True
|
||||
|
||||
review_values = self._normalize_context_object(context_json.get("review_form_values"))
|
||||
linked_reasons = {
|
||||
str(review_values.get(key) or "").strip()
|
||||
for key in ("application_reason", "reason", "business_reason")
|
||||
}
|
||||
linked_reasons.add(str(claim.reason or "").strip())
|
||||
return reason in {value for value in linked_reasons if value}
|
||||
|
||||
def _should_skip_application_link_placeholder_item(
|
||||
self,
|
||||
*,
|
||||
@@ -405,23 +464,10 @@ class ExpenseClaimDraftFlowMixin:
|
||||
) -> bool:
|
||||
if document_specs or attachment_count > 0:
|
||||
return False
|
||||
if claim is not None and list(claim.items or []):
|
||||
return False
|
||||
if self._build_application_link_flag(context_json) is None:
|
||||
return False
|
||||
|
||||
application_amounts = self._resolve_application_amount_candidates(context_json)
|
||||
review_values = self._normalize_context_object(context_json.get("review_form_values"))
|
||||
raw_amount = str(review_values.get("amount") or "").strip()
|
||||
if raw_amount:
|
||||
parsed_amount = self._parse_context_money_amount(raw_amount)
|
||||
if parsed_amount is None:
|
||||
return True
|
||||
return bool(application_amounts and parsed_amount in application_amounts)
|
||||
|
||||
if amount is None or amount <= Decimal("0.00"):
|
||||
return True
|
||||
return bool(application_amounts and amount in application_amounts)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
|
||||
@@ -497,7 +543,26 @@ class ExpenseClaimDraftFlowMixin:
|
||||
application_amount_label = pick("application_amount_label", "applicationAmountLabel")
|
||||
application_reason = pick("application_reason", "applicationReason", "reason")
|
||||
application_location = pick("application_location", "applicationLocation", "location")
|
||||
application_date = pick("application_date", "applicationDate", "business_time", "time_range")
|
||||
application_time = pick(
|
||||
"application_business_time",
|
||||
"applicationBusinessTime",
|
||||
"application_time",
|
||||
"applicationTime",
|
||||
"business_time",
|
||||
"businessTime",
|
||||
"time_range",
|
||||
"timeRange",
|
||||
"time",
|
||||
)
|
||||
application_date = pick("application_date", "applicationDate")
|
||||
application_days = pick("application_days", "applicationDays", "days")
|
||||
application_transport_mode = pick("application_transport_mode", "applicationTransportMode", "transport_mode", "transportMode")
|
||||
application_lodging_daily_cap = pick("application_lodging_daily_cap", "applicationLodgingDailyCap", "lodging_daily_cap", "lodgingDailyCap")
|
||||
application_subsidy_daily_cap = pick("application_subsidy_daily_cap", "applicationSubsidyDailyCap", "subsidy_daily_cap", "subsidyDailyCap")
|
||||
application_transport_policy = pick("application_transport_policy", "applicationTransportPolicy", "transport_policy", "transportPolicy")
|
||||
application_policy_estimate = pick("application_policy_estimate", "applicationPolicyEstimate", "policy_estimate", "policyEstimate")
|
||||
application_rule_name = pick("application_rule_name", "applicationRuleName", "rule_name", "ruleName")
|
||||
application_rule_version = pick("application_rule_version", "applicationRuleVersion", "rule_version", "ruleVersion")
|
||||
application_status = pick("application_status", "applicationStatus")
|
||||
application_status_label = pick("application_status_label", "applicationStatusLabel")
|
||||
|
||||
@@ -517,7 +582,17 @@ class ExpenseClaimDraftFlowMixin:
|
||||
"application_location": application_location,
|
||||
"application_amount": application_amount,
|
||||
"application_amount_label": application_amount_label,
|
||||
"application_time": application_date,
|
||||
"application_time": application_time or application_date,
|
||||
"application_business_time": application_time,
|
||||
"application_date": application_date,
|
||||
"application_days": application_days,
|
||||
"application_transport_mode": application_transport_mode,
|
||||
"application_lodging_daily_cap": application_lodging_daily_cap,
|
||||
"application_subsidy_daily_cap": application_subsidy_daily_cap,
|
||||
"application_transport_policy": application_transport_policy,
|
||||
"application_policy_estimate": application_policy_estimate,
|
||||
"application_rule_name": application_rule_name,
|
||||
"application_rule_version": application_rule_version,
|
||||
},
|
||||
"review_form_values": review_values,
|
||||
"expense_scene_selection": scene_selection,
|
||||
|
||||
@@ -158,21 +158,139 @@ class ExpenseClaimItemSyncMixin:
|
||||
end_date = start_date
|
||||
|
||||
days = (end_date - start_date).days + 1
|
||||
application_days = self._resolve_travel_allowance_days_from_application_link(claim)
|
||||
explicit_days = max(
|
||||
(self._extract_travel_day_count(item.item_reason) for item in business_items),
|
||||
default=0,
|
||||
)
|
||||
unique_dates = {value for value in dated_items}
|
||||
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
|
||||
return application_days
|
||||
if explicit_days > 0:
|
||||
days = explicit_days
|
||||
end_date = start_date + timedelta(days=days - 1)
|
||||
if application_days is not None and application_days[0] > days and len(unique_dates) <= 1:
|
||||
return application_days
|
||||
return max(1, days), start_date, end_date
|
||||
existing_days = self._extract_travel_allowance_days(existing_allowance)
|
||||
unique_dates = {value for value in dated_items}
|
||||
if existing_days > days and len(unique_dates) <= 1:
|
||||
days = existing_days
|
||||
end_date = start_date + timedelta(days=days - 1)
|
||||
return max(1, days), start_date, end_date
|
||||
|
||||
def _resolve_travel_allowance_days_from_application_link(
|
||||
self,
|
||||
claim: ExpenseClaim,
|
||||
) -> tuple[int, date, date] | None:
|
||||
values = self._collect_application_link_values(claim)
|
||||
if not values:
|
||||
return None
|
||||
|
||||
time_text = str(
|
||||
values.get("application_business_time")
|
||||
or values.get("business_time")
|
||||
or values.get("time_range")
|
||||
or values.get("application_time")
|
||||
or values.get("time")
|
||||
or ""
|
||||
).strip()
|
||||
dates = self._extract_application_link_dates(time_text)
|
||||
if len(dates) >= 2:
|
||||
start_date, end_date = dates[0], dates[-1]
|
||||
if end_date < start_date:
|
||||
start_date, end_date = end_date, start_date
|
||||
return max(1, (end_date - start_date).days + 1), start_date, end_date
|
||||
|
||||
days = self._extract_travel_day_count(
|
||||
str(values.get("application_days") or values.get("days") or "").strip()
|
||||
)
|
||||
if days <= 0:
|
||||
return None
|
||||
start_date = dates[0] if dates else claim.occurred_at.date() if claim.occurred_at is not None else date.today()
|
||||
end_date = start_date + timedelta(days=days - 1)
|
||||
return days, start_date, end_date
|
||||
|
||||
def _collect_application_link_values(self, claim: ExpenseClaim) -> dict[str, Any]:
|
||||
values: dict[str, Any] = {}
|
||||
for flag in list(claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict):
|
||||
continue
|
||||
if str(flag.get("source") or "").strip() not in {"application_link", "application_handoff"}:
|
||||
continue
|
||||
for source in (
|
||||
flag.get("expense_scene_selection"),
|
||||
flag.get("review_form_values"),
|
||||
flag.get("application_detail"),
|
||||
flag,
|
||||
):
|
||||
if isinstance(source, dict):
|
||||
values.update(source)
|
||||
linked_detail = self._resolve_linked_application_detail_values(values)
|
||||
for key, value in linked_detail.items():
|
||||
values.setdefault(key, value)
|
||||
return values
|
||||
|
||||
def _resolve_linked_application_detail_values(self, values: dict[str, Any]) -> dict[str, Any]:
|
||||
application_claim = self._find_linked_application_claim(values)
|
||||
if application_claim is None:
|
||||
return {}
|
||||
|
||||
detail: dict[str, Any] = {}
|
||||
for flag in list(application_claim.risk_flags_json or []):
|
||||
if not isinstance(flag, dict) or str(flag.get("source") or "").strip() != "application_detail":
|
||||
continue
|
||||
payload = flag.get("application_detail") or flag.get("applicationDetail") or {}
|
||||
if isinstance(payload, dict):
|
||||
detail.update(payload)
|
||||
if detail.get("time"):
|
||||
detail.setdefault("application_time", detail.get("time"))
|
||||
if detail.get("days"):
|
||||
detail.setdefault("application_days", detail.get("days"))
|
||||
if detail.get("transport_mode"):
|
||||
detail.setdefault("application_transport_mode", detail.get("transport_mode"))
|
||||
if detail.get("location"):
|
||||
detail.setdefault("application_location", detail.get("location"))
|
||||
if detail.get("reason"):
|
||||
detail.setdefault("application_reason", detail.get("reason"))
|
||||
if application_claim.occurred_at is not None:
|
||||
detail.setdefault("application_time", application_claim.occurred_at.date().isoformat())
|
||||
detail.setdefault("time", application_claim.occurred_at.date().isoformat())
|
||||
detail.setdefault("application_reason", str(application_claim.reason or "").strip())
|
||||
detail.setdefault("application_location", str(application_claim.location or "").strip())
|
||||
return {str(key): value for key, value in detail.items() if str(value or "").strip()}
|
||||
|
||||
def _find_linked_application_claim(self, values: dict[str, Any]) -> ExpenseClaim | None:
|
||||
application_claim_id = str(
|
||||
values.get("application_claim_id")
|
||||
or values.get("applicationClaimId")
|
||||
or ""
|
||||
).strip()
|
||||
if application_claim_id:
|
||||
linked_claim = self.db.get(ExpenseClaim, application_claim_id)
|
||||
if linked_claim is not None:
|
||||
return linked_claim
|
||||
|
||||
application_claim_no = str(
|
||||
values.get("application_claim_no")
|
||||
or values.get("applicationClaimNo")
|
||||
or ""
|
||||
).strip()
|
||||
if not application_claim_no:
|
||||
return None
|
||||
return self.db.scalar(
|
||||
select(ExpenseClaim).where(ExpenseClaim.claim_no == application_claim_no)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_application_link_dates(value: str) -> list[date]:
|
||||
dates: list[date] = []
|
||||
for matched in re.findall(r"\d{4}-\d{2}-\d{2}", str(value or "")):
|
||||
try:
|
||||
dates.append(date.fromisoformat(matched))
|
||||
except ValueError:
|
||||
continue
|
||||
return dates
|
||||
|
||||
@staticmethod
|
||||
def _extract_travel_allowance_days(item: ExpenseClaimItem | None) -> int:
|
||||
if item is None:
|
||||
|
||||
@@ -314,7 +314,13 @@ class ExpenseClaimOntologyResolverMixin:
|
||||
) -> datetime | None:
|
||||
review_form_values = context_json.get("review_form_values")
|
||||
if isinstance(review_form_values, dict):
|
||||
for key in ("occurred_date", "time_range", "business_time"):
|
||||
for key in (
|
||||
"occurred_date",
|
||||
"time_range",
|
||||
"business_time",
|
||||
"application_business_time",
|
||||
"application_time",
|
||||
):
|
||||
value = str(review_form_values.get(key) or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
@@ -322,7 +328,9 @@ class ExpenseClaimOntologyResolverMixin:
|
||||
parsed = date.fromisoformat(value)
|
||||
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
||||
except ValueError:
|
||||
continue
|
||||
parsed = ExpenseClaimOntologyResolverMixin._resolve_first_date_from_text(value)
|
||||
if parsed is not None:
|
||||
return datetime(parsed.year, parsed.month, parsed.day, tzinfo=UTC)
|
||||
|
||||
start_date = ontology.time_range.start_date
|
||||
if start_date:
|
||||
@@ -333,6 +341,21 @@ class ExpenseClaimOntologyResolverMixin:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_first_date_from_text(value: str) -> date | None:
|
||||
match = re.search(r"20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}", str(value or ""))
|
||||
if not match:
|
||||
return None
|
||||
normalized = match.group(0).replace("/", "-").replace(".", "-")
|
||||
parts = [part for part in normalized.split("-") if part]
|
||||
if len(parts) != 3:
|
||||
return None
|
||||
try:
|
||||
year, month, day = (int(part) for part in parts)
|
||||
return date(year, month, day)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_amount(
|
||||
entities: list[OntologyEntity],
|
||||
|
||||
224
server/src/app/services/expense_claim_status_registry.py
Normal file
224
server/src/app/services/expense_claim_status_registry.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
PAYMENT_PENDING_STAGE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExpenseClaimStatusSpec:
|
||||
code: int
|
||||
value: str
|
||||
label: str
|
||||
terminal: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExpenseClaimState:
|
||||
status: str
|
||||
approval_stage: str
|
||||
status_code: int | None
|
||||
status_label: str
|
||||
changed: bool
|
||||
|
||||
|
||||
CLAIM_STATUS_REGISTRY: dict[str, ExpenseClaimStatusSpec] = {
|
||||
"draft": ExpenseClaimStatusSpec(10, "draft", "草稿"),
|
||||
"submitted": ExpenseClaimStatusSpec(20, "submitted", "审批中"),
|
||||
"approved": ExpenseClaimStatusSpec(30, "approved", "已通过"),
|
||||
"pending_payment": ExpenseClaimStatusSpec(40, "pending_payment", "待付款"),
|
||||
"paid": ExpenseClaimStatusSpec(50, "paid", "已付款", terminal=True),
|
||||
"returned": ExpenseClaimStatusSpec(60, "returned", "待补充"),
|
||||
"rejected": ExpenseClaimStatusSpec(70, "rejected", "已驳回", terminal=True),
|
||||
}
|
||||
|
||||
CLAIM_STATUS_ALIASES = {
|
||||
"review": "submitted",
|
||||
"pending_review": "submitted",
|
||||
"approving": "submitted",
|
||||
"manager_review": "submitted",
|
||||
"budget_review": "submitted",
|
||||
"finance_review": "submitted",
|
||||
"completed": "approved",
|
||||
"complete": "approved",
|
||||
"payment": "pending_payment",
|
||||
"supplement": "returned",
|
||||
"草稿": "draft",
|
||||
"待提交": "draft",
|
||||
"已提交": "submitted",
|
||||
"审批中": "submitted",
|
||||
"审核中": "submitted",
|
||||
"审批完成": "approved",
|
||||
"已通过": "approved",
|
||||
"归档入账": "approved",
|
||||
"待付款": "pending_payment",
|
||||
"已付款": "paid",
|
||||
"待补充": "returned",
|
||||
"已驳回": "rejected",
|
||||
}
|
||||
|
||||
CANONICAL_APPROVAL_STAGES = {
|
||||
"",
|
||||
"待提交",
|
||||
DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
APPROVAL_DONE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
PAYMENT_PENDING_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
"待补充",
|
||||
"已驳回",
|
||||
}
|
||||
|
||||
STAGE_ALIASES = {
|
||||
"draft": "待提交",
|
||||
"review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"pending_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"approving": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"manager_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"budget_review": BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
"finance_review": FINANCE_APPROVAL_STAGE,
|
||||
"pending_payment": PAYMENT_PENDING_STAGE,
|
||||
"supplement": "待补充",
|
||||
"rejected": "已驳回",
|
||||
"草稿": "待提交",
|
||||
"审核中": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
}
|
||||
|
||||
STATUS_DEFAULT_STAGE = {
|
||||
"draft": "待提交",
|
||||
"submitted": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"pending_payment": PAYMENT_PENDING_STAGE,
|
||||
"paid": PAYMENT_PAID_STAGE,
|
||||
"returned": "待补充",
|
||||
"rejected": "已驳回",
|
||||
}
|
||||
|
||||
LEGACY_REVIEW_STATUS_STAGE = {
|
||||
"review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"pending_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"approving": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"manager_review": DIRECT_MANAGER_APPROVAL_STAGE,
|
||||
"budget_review": BUDGET_MANAGER_APPROVAL_STAGE,
|
||||
"finance_review": FINANCE_APPROVAL_STAGE,
|
||||
}
|
||||
|
||||
|
||||
def normalize_claim_status(value: Any) -> str:
|
||||
raw = str(value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
lowered = raw.lower()
|
||||
if lowered in CLAIM_STATUS_REGISTRY:
|
||||
return lowered
|
||||
return CLAIM_STATUS_ALIASES.get(lowered) or CLAIM_STATUS_ALIASES.get(raw) or raw
|
||||
|
||||
|
||||
def claim_status_code(value: Any) -> int | None:
|
||||
status = normalize_claim_status(value)
|
||||
spec = CLAIM_STATUS_REGISTRY.get(status)
|
||||
return spec.code if spec is not None else None
|
||||
|
||||
|
||||
def claim_status_label(value: Any) -> str:
|
||||
status = normalize_claim_status(value)
|
||||
spec = CLAIM_STATUS_REGISTRY.get(status)
|
||||
return spec.label if spec is not None else str(value or "").strip()
|
||||
|
||||
|
||||
def is_known_claim_status(value: Any) -> bool:
|
||||
return normalize_claim_status(value) in CLAIM_STATUS_REGISTRY
|
||||
|
||||
|
||||
def is_known_approval_stage(value: Any) -> bool:
|
||||
stage = str(value or "").strip()
|
||||
normalized_stage = _normalize_stage_alias(stage)
|
||||
return stage in CANONICAL_APPROVAL_STAGES or normalized_stage in CANONICAL_APPROVAL_STAGES
|
||||
|
||||
|
||||
def is_application_claim_reference(
|
||||
*,
|
||||
claim_no: str | None = None,
|
||||
expense_type: str | None = None,
|
||||
) -> bool:
|
||||
normalized_no = str(claim_no or "").strip().upper()
|
||||
normalized_type = str(expense_type or "").strip().lower()
|
||||
return (
|
||||
normalized_no.startswith(("AP-", "APP-"))
|
||||
or normalized_type == "application"
|
||||
or normalized_type.endswith("_application")
|
||||
)
|
||||
|
||||
|
||||
def normalize_expense_claim_state(
|
||||
status: Any,
|
||||
approval_stage: Any,
|
||||
*,
|
||||
claim_no: str | None = None,
|
||||
expense_type: str | None = None,
|
||||
is_application_claim: bool | None = None,
|
||||
) -> ExpenseClaimState:
|
||||
original_status = str(status or "").strip()
|
||||
original_stage = str(approval_stage or "").strip()
|
||||
normalized_status = normalize_claim_status(original_status)
|
||||
normalized_stage = _normalize_stage_alias(original_stage)
|
||||
application = (
|
||||
is_application_claim
|
||||
if is_application_claim is not None
|
||||
else is_application_claim_reference(claim_no=claim_no, expense_type=expense_type)
|
||||
)
|
||||
|
||||
legacy_status = original_status.lower()
|
||||
if legacy_status in LEGACY_REVIEW_STATUS_STAGE:
|
||||
normalized_stage = LEGACY_REVIEW_STATUS_STAGE[legacy_status]
|
||||
elif normalized_status == "approved":
|
||||
normalized_stage = _approved_stage(original_stage, application)
|
||||
elif normalized_status == "pending_payment":
|
||||
normalized_stage = PAYMENT_PENDING_STAGE
|
||||
elif normalized_status == "paid":
|
||||
normalized_stage = PAYMENT_PAID_STAGE
|
||||
elif normalized_status in STATUS_DEFAULT_STAGE and not normalized_stage:
|
||||
normalized_stage = STATUS_DEFAULT_STAGE[normalized_status]
|
||||
|
||||
if normalized_status == "submitted" and normalized_stage in {"payment", "completed"}:
|
||||
normalized_stage = DIRECT_MANAGER_APPROVAL_STAGE
|
||||
|
||||
spec = CLAIM_STATUS_REGISTRY.get(normalized_status)
|
||||
return ExpenseClaimState(
|
||||
status=normalized_status,
|
||||
approval_stage=normalized_stage,
|
||||
status_code=spec.code if spec is not None else None,
|
||||
status_label=spec.label if spec is not None else normalized_status,
|
||||
changed=normalized_status != original_status or normalized_stage != original_stage,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_stage_alias(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
lowered = value.lower()
|
||||
return STAGE_ALIASES.get(lowered) or STAGE_ALIASES.get(value) or value
|
||||
|
||||
|
||||
def _approved_stage(raw_stage: str, is_application_claim: bool) -> str:
|
||||
stage = _normalize_stage_alias(raw_stage)
|
||||
lowered = str(raw_stage or "").strip().lower()
|
||||
if is_application_claim:
|
||||
if not stage or lowered == "completed":
|
||||
return APPROVAL_DONE_STAGE
|
||||
return stage
|
||||
if stage in {ARCHIVE_ACCOUNTING_STAGE, PAYMENT_PAID_STAGE}:
|
||||
return stage
|
||||
if lowered in {"completed", "complete", ""} or stage == APPROVAL_DONE_STAGE:
|
||||
return ARCHIVE_ACCOUNTING_STAGE
|
||||
return stage
|
||||
@@ -12,9 +12,9 @@ from sqlalchemy.orm import Session
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.schemas.finance_dashboard import FinanceDashboardRead
|
||||
from app.services.budget_support import BudgetSupportMixin
|
||||
from app.services.demo_company_simulation_filters import is_finance_reimbursement_claim
|
||||
from app.services.expense_claim_constants import EXPENSE_TYPE_LABELS
|
||||
|
||||
SLA_TARGET_HOURS = Decimal("8.0")
|
||||
@@ -30,6 +30,17 @@ PENDING_STATUSES = {
|
||||
SUCCESS_STATUSES = {"approved", "pending_payment", "paid", "completed"}
|
||||
EXCLUDED_SPEND_STATUSES = {"draft", "rejected", "returned", "supplement", "deleted"}
|
||||
EMPTY_DONUT = [{"name": "暂无数据", "value": 0, "color": "#cbd5e1"}]
|
||||
EXPENSE_TYPE_ALIASES = {
|
||||
"travel_application": "travel",
|
||||
"business_travel": "travel",
|
||||
"trip": "travel",
|
||||
"traffic": "travel",
|
||||
"transportation": "travel",
|
||||
"hotel": "travel",
|
||||
"accommodation": "travel",
|
||||
"business_meal": "meal",
|
||||
"communication_fee": "communication",
|
||||
}
|
||||
CHART_COLORS = [
|
||||
"var(--theme-primary)",
|
||||
"var(--chart-blue)",
|
||||
@@ -55,6 +66,17 @@ RISK_SIGNAL_LABELS = {
|
||||
"location_mismatch": "地点不一致",
|
||||
"amount_outlier": "金额异常",
|
||||
"preapproval_absent": "缺少事前申请",
|
||||
"missing_material": "材料不完整",
|
||||
"budget_pressure": "预算压力偏高",
|
||||
"budget_overrun": "预算超支",
|
||||
"budget_warning": "预算预警",
|
||||
"over_budget": "预算超支",
|
||||
"invoice_abnormal": "发票异常",
|
||||
"invoice_missing": "缺少发票",
|
||||
"missing_invoice": "缺少发票",
|
||||
"policy_violation": "政策不符",
|
||||
"abnormal_frequency": "频次异常",
|
||||
"manual_review": "人工复核",
|
||||
}
|
||||
|
||||
|
||||
@@ -83,31 +105,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
trend_start, trend_end, trend_labels = self._resolve_trend_scope(trend_range, now)
|
||||
department_start, department_end = self._resolve_department_scope(department_range, now)
|
||||
|
||||
claims = self._fetch_claims()
|
||||
observations = self._fetch_risk_observations()
|
||||
claims = [
|
||||
claim for claim in self._fetch_claims() if is_finance_reimbursement_claim(claim)
|
||||
]
|
||||
scope_claims = self._claims_between(claims, start, end)
|
||||
previous_claims = self._claims_between(claims, previous_start, start)
|
||||
trend_claims = self._claims_between(claims, trend_start, trend_end)
|
||||
department_claims = self._claims_between(claims, department_start, department_end)
|
||||
scope_observations = self._observations_between(observations, start, end)
|
||||
|
||||
totals = self._totals(scope_claims, scope_observations, now)
|
||||
previous_totals = self._totals(previous_claims, [], now)
|
||||
totals = self._totals(scope_claims)
|
||||
previous_totals = self._totals(previous_claims)
|
||||
|
||||
return FinanceDashboardRead(
|
||||
range_key=resolved_key,
|
||||
start_date=start.date().isoformat(),
|
||||
end_date=(end - timedelta(days=1)).date().isoformat(),
|
||||
generated_at=now.isoformat(),
|
||||
has_real_data=bool(claims or observations or self._fetch_budget_allocations(now.year)),
|
||||
has_real_data=bool(claims or self._fetch_budget_allocations(now.year)),
|
||||
totals=totals,
|
||||
metric_meta=self._metric_meta(totals, previous_totals),
|
||||
trend=self._trend(trend_labels, trend_claims, now),
|
||||
spend_by_category=self._spend_by_category(scope_claims),
|
||||
exception_mix=self._exception_mix(scope_claims, scope_observations),
|
||||
exception_mix=self._payment_status_mix(scope_claims),
|
||||
department_ranking=self._department_ranking(department_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims, now),
|
||||
employee_ranking=self._employee_ranking(department_claims),
|
||||
top_claims=self._top_claims(department_claims),
|
||||
bottlenecks=self._bottlenecks(scope_claims),
|
||||
budget_summary=self._budget_summary(now.year),
|
||||
budget_metrics=self._budget_metrics(now.year),
|
||||
)
|
||||
|
||||
def _ensure_storage_ready(self) -> None:
|
||||
@@ -117,10 +142,6 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
stmt = select(ExpenseClaim).order_by(ExpenseClaim.created_at.asc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_risk_observations(self) -> list[RiskObservation]:
|
||||
stmt = select(RiskObservation).order_by(RiskObservation.created_at.asc())
|
||||
return list(self.db.scalars(stmt).all())
|
||||
|
||||
def _fetch_budget_allocations(self, fiscal_year: int) -> list[BudgetAllocation]:
|
||||
stmt = (
|
||||
select(BudgetAllocation)
|
||||
@@ -192,50 +213,49 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
) -> list[ExpenseClaim]:
|
||||
return [claim for claim in claims if start <= self._claim_time(claim) < end]
|
||||
|
||||
def _observations_between(
|
||||
self,
|
||||
observations: list[RiskObservation],
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> list[RiskObservation]:
|
||||
return [item for item in observations if start <= self._as_utc(item.created_at) < end]
|
||||
|
||||
def _totals(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
observations: list[RiskObservation],
|
||||
now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
active_claims = [claim for claim in claims if self._status(claim) not in {"draft", "deleted"}]
|
||||
pending_claims = [claim for claim in active_claims if self._status(claim) in PENDING_STATUSES]
|
||||
success_claims = [claim for claim in active_claims if self._status(claim) in SUCCESS_STATUSES]
|
||||
risk_claim_keys = {self._claim_key(claim) for claim in active_claims if self._has_claim_risk(claim)}
|
||||
observation_keys = {
|
||||
str(item.claim_no or item.subject_key or item.id).strip()
|
||||
for item in observations
|
||||
if str(item.status or "").strip().lower() != "false_positive"
|
||||
}
|
||||
sla_hours = [self._claim_sla_hours(claim, now) for claim in active_claims if claim.submitted_at]
|
||||
sla_met = sum(1 for hours in sla_hours if hours <= SLA_TARGET_HOURS)
|
||||
clean_success = sum(1 for claim in success_claims if not self._has_claim_risk(claim))
|
||||
active_claims = [
|
||||
claim for claim in claims if self._status(claim) not in {"draft", "deleted"}
|
||||
]
|
||||
spend_claims = [
|
||||
claim for claim in active_claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
]
|
||||
pending_payment_claims = [
|
||||
claim for claim in spend_claims if self._status(claim) == "pending_payment"
|
||||
]
|
||||
paid_claims = [claim for claim in spend_claims if self._status(claim) == "paid"]
|
||||
total_amount = sum((self._claim_amount(claim) for claim in spend_claims), Decimal("0.00"))
|
||||
pending_payment_amount = sum(
|
||||
(self._claim_amount(claim) for claim in pending_payment_claims),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
budget_summary = self._budget_summary(datetime.now(UTC).year)
|
||||
avg_amount = (
|
||||
total_amount / Decimal(str(len(spend_claims)))
|
||||
if spend_claims
|
||||
else Decimal("0.00")
|
||||
)
|
||||
|
||||
return {
|
||||
"pendingCount": len(pending_claims),
|
||||
"pendingAmount": self._decimal_number(sum((self._claim_amount(claim) for claim in pending_claims), Decimal("0.00"))),
|
||||
"avgSla": self._decimal_number(self._average(sla_hours)),
|
||||
"autoPassRate": self._percent(clean_success, len(active_claims)),
|
||||
"riskCount": len({key for key in risk_claim_keys | observation_keys if key}),
|
||||
"slaRate": self._percent(sla_met, len(sla_hours)),
|
||||
"reimbursementAmount": self._decimal_number(total_amount),
|
||||
"reimbursementCount": len(spend_claims),
|
||||
"pendingPaymentAmount": self._decimal_number(pending_payment_amount),
|
||||
"avgClaimAmount": self._decimal_number(avg_amount),
|
||||
"budgetUsageRate": float(budget_summary.get("ratio") or 0),
|
||||
"paymentClearanceRate": self._percent(len(paid_claims), len(spend_claims)),
|
||||
}
|
||||
|
||||
def _metric_meta(self, current: dict[str, Any], previous: dict[str, Any]) -> dict[str, Any]:
|
||||
unit_by_key = {
|
||||
"pendingCount": "单",
|
||||
"pendingAmount": "元",
|
||||
"avgSla": "h",
|
||||
"autoPassRate": "%",
|
||||
"riskCount": "单",
|
||||
"slaRate": "%",
|
||||
"reimbursementAmount": "元",
|
||||
"reimbursementCount": "单",
|
||||
"pendingPaymentAmount": "元",
|
||||
"avgClaimAmount": "元",
|
||||
"budgetUsageRate": "%",
|
||||
"paymentClearanceRate": "%",
|
||||
}
|
||||
meta: dict[str, Any] = {}
|
||||
for key, current_value in current.items():
|
||||
@@ -257,28 +277,34 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
claims: list[ExpenseClaim],
|
||||
now: datetime,
|
||||
) -> dict[str, Any]:
|
||||
applications = [0 for _ in labels]
|
||||
approved = [0 for _ in labels]
|
||||
claim_count = [0 for _ in labels]
|
||||
claim_amount = [Decimal("0.00") for _ in labels]
|
||||
success_count = [0 for _ in labels]
|
||||
hours: list[list[Decimal]] = [[] for _ in labels]
|
||||
index = {label: idx for idx, label in enumerate(labels)}
|
||||
|
||||
for claim in claims:
|
||||
if self._status(claim) == "draft":
|
||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
label = self._date_label(self._claim_time(claim).date())
|
||||
if label not in index:
|
||||
continue
|
||||
bucket = index[label]
|
||||
applications[bucket] += 1
|
||||
claim_count[bucket] += 1
|
||||
claim_amount[bucket] += self._claim_amount(claim)
|
||||
if self._status(claim) in SUCCESS_STATUSES:
|
||||
approved[bucket] += 1
|
||||
success_count[bucket] += 1
|
||||
if claim.submitted_at:
|
||||
hours[bucket].append(self._claim_sla_hours(claim, now))
|
||||
|
||||
return {
|
||||
"labels": labels,
|
||||
"applications": applications,
|
||||
"approved": approved,
|
||||
"claimCount": claim_count,
|
||||
"claimAmount": [self._decimal_number(value) for value in claim_amount],
|
||||
"successCount": success_count,
|
||||
# 兼容旧前端字段;新财务看板不再使用审批趋势语义。
|
||||
"applications": claim_count,
|
||||
"approved": success_count,
|
||||
"avgHours": [self._decimal_number(self._average(row)) for row in hours],
|
||||
}
|
||||
|
||||
@@ -287,79 +313,178 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
for claim in claims:
|
||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
label = EXPENSE_TYPE_LABELS.get(str(claim.expense_type or "").strip(), claim.expense_type)
|
||||
buckets[str(label or "其他费用")] += self._claim_amount(claim)
|
||||
buckets[self._expense_type_label(claim.expense_type)] += self._claim_amount(claim)
|
||||
|
||||
rows = [
|
||||
{"name": name, "value": self._decimal_number(value), "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
||||
for index, (name, value) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
|
||||
{
|
||||
"name": name,
|
||||
"value": self._decimal_number(value),
|
||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||
}
|
||||
for index, (name, value) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
]
|
||||
return rows or EMPTY_DONUT
|
||||
|
||||
def _exception_mix(
|
||||
self,
|
||||
claims: list[ExpenseClaim],
|
||||
observations: list[RiskObservation],
|
||||
) -> list[dict[str, Any]]:
|
||||
def _payment_status_mix(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, int] = defaultdict(int)
|
||||
|
||||
for observation in observations:
|
||||
key = str(observation.risk_signal or observation.risk_type or "").strip()
|
||||
buckets[RISK_SIGNAL_LABELS.get(key, key.replace("_", " ") or "风险观察")] += 1
|
||||
|
||||
if not buckets:
|
||||
for claim in claims:
|
||||
if self._status(claim) in {"draft", "deleted"}:
|
||||
continue
|
||||
for label in self._claim_risk_labels(claim):
|
||||
buckets[label] += 1
|
||||
for claim in claims:
|
||||
status = self._status(claim)
|
||||
if status in {"draft", "deleted"}:
|
||||
continue
|
||||
buckets[self._finance_status_label(status)] += 1
|
||||
|
||||
rows = [
|
||||
{"name": name, "value": count, "color": CHART_COLORS[index % len(CHART_COLORS)]}
|
||||
for index, (name, count) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6])
|
||||
for index, (name, count) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
]
|
||||
return rows or EMPTY_DONUT
|
||||
|
||||
def _department_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
pending_amounts: dict[str, Decimal] = defaultdict(Decimal)
|
||||
for claim in claims:
|
||||
if self._status(claim) not in PENDING_STATUSES:
|
||||
status = self._status(claim)
|
||||
if status in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
buckets[str(claim.department_name or "未归属部门")] += self._claim_amount(claim)
|
||||
department_name = str(claim.department_name or "").strip()
|
||||
if self._is_missing_finance_dimension(department_name):
|
||||
continue
|
||||
amount = self._claim_amount(claim)
|
||||
buckets[department_name] += amount
|
||||
counts[department_name] += 1
|
||||
if status in PENDING_STATUSES:
|
||||
pending_amounts[department_name] += amount
|
||||
|
||||
rows = [
|
||||
{
|
||||
"name": name,
|
||||
"amount": self._decimal_number(amount),
|
||||
"value": self._decimal_number(amount),
|
||||
"count": counts[name],
|
||||
"pendingAmount": self._decimal_number(pending_amounts[name]),
|
||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||
}
|
||||
for index, (name, amount) in enumerate(sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:5])
|
||||
for index, (name, amount) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
]
|
||||
return rows
|
||||
|
||||
def _bottlenecks(self, claims: list[ExpenseClaim], now: datetime) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, list[Decimal]] = defaultdict(list)
|
||||
def _employee_ranking(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
buckets: dict[str, Decimal] = defaultdict(Decimal)
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
departments: dict[str, str] = {}
|
||||
for claim in claims:
|
||||
if self._status(claim) not in PENDING_STATUSES:
|
||||
if self._status(claim) in EXCLUDED_SPEND_STATUSES:
|
||||
continue
|
||||
stage = self._stage_label(claim)
|
||||
buckets[stage].append(self._claim_sla_hours(claim, now))
|
||||
employee_name = str(claim.employee_name or "").strip()
|
||||
if self._is_missing_finance_dimension(employee_name):
|
||||
continue
|
||||
amount = self._claim_amount(claim)
|
||||
buckets[employee_name] += amount
|
||||
counts[employee_name] += 1
|
||||
departments.setdefault(employee_name, str(claim.department_name or "").strip())
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, (stage, values) in enumerate(sorted(buckets.items(), key=lambda item: self._average(item[1]), reverse=True)[:3]):
|
||||
avg_hours = self._average(values)
|
||||
rows.append(
|
||||
{
|
||||
"name": stage,
|
||||
"role": "审批节点",
|
||||
"duration": f"{self._decimal_number(avg_hours):.1f} h",
|
||||
"status": self._duration_status(avg_hours),
|
||||
"tone": self._duration_tone(avg_hours),
|
||||
"avatar": stage[:1] or str(index + 1),
|
||||
}
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"department": departments.get(name, ""),
|
||||
"amount": self._decimal_number(amount),
|
||||
"value": self._decimal_number(amount),
|
||||
"count": counts[name],
|
||||
"avgAmount": self._decimal_number(amount / Decimal(str(counts[name]))),
|
||||
"color": CHART_COLORS[index % len(CHART_COLORS)],
|
||||
}
|
||||
for index, (name, amount) in enumerate(
|
||||
sorted(buckets.items(), key=lambda item: item[1], reverse=True)[:6]
|
||||
)
|
||||
return rows
|
||||
]
|
||||
|
||||
def _top_claims(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
spend_claims = [
|
||||
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
]
|
||||
return [
|
||||
{
|
||||
"claimNo": claim.claim_no,
|
||||
"employeeName": claim.employee_name,
|
||||
"departmentName": self._display_finance_dimension(
|
||||
claim.department_name,
|
||||
fallback="未归属部门",
|
||||
),
|
||||
"expenseTypeLabel": self._expense_type_label(claim.expense_type),
|
||||
"amount": self._decimal_number(self._claim_amount(claim)),
|
||||
"amountLabel": self._currency(self._claim_amount(claim)),
|
||||
"statusLabel": self._finance_status_label(self._status(claim)),
|
||||
}
|
||||
for claim in sorted(spend_claims, key=self._claim_amount, reverse=True)[:6]
|
||||
]
|
||||
|
||||
def _bottlenecks(self, claims: list[ExpenseClaim]) -> list[dict[str, Any]]:
|
||||
active_claims = [
|
||||
claim for claim in claims if self._status(claim) not in EXCLUDED_SPEND_STATUSES
|
||||
]
|
||||
pending_payment_claims = [
|
||||
claim for claim in active_claims if self._status(claim) == "pending_payment"
|
||||
]
|
||||
paid_claims = [claim for claim in active_claims if self._status(claim) == "paid"]
|
||||
submitted_claims = [
|
||||
claim for claim in active_claims if self._status(claim) in PENDING_STATUSES
|
||||
]
|
||||
budget_rows = self._budget_focus_rows()
|
||||
|
||||
pending_payment_amount = sum(
|
||||
(self._claim_amount(claim) for claim in pending_payment_claims),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
high_claim = max(
|
||||
(self._claim_amount(claim) for claim in active_claims),
|
||||
default=Decimal("0.00"),
|
||||
)
|
||||
payment_clearance = self._percent(len(paid_claims), len(active_claims))
|
||||
|
||||
rows = [
|
||||
*budget_rows,
|
||||
self._focus_item(
|
||||
name="待付款",
|
||||
role="资金计划",
|
||||
duration=self._currency(pending_payment_amount),
|
||||
status=f"{len(pending_payment_claims)} 单",
|
||||
tone="warning" if pending_payment_claims else "success",
|
||||
avatar="付",
|
||||
),
|
||||
self._focus_item(
|
||||
name="高额单据",
|
||||
role="费用集中度",
|
||||
duration=self._currency(high_claim),
|
||||
status="本期最高",
|
||||
tone="warning" if high_claim >= Decimal("10000") else "success",
|
||||
avatar="高",
|
||||
),
|
||||
self._focus_item(
|
||||
name="待入账",
|
||||
role="月结准备",
|
||||
duration=f"{len(submitted_claims)} 单",
|
||||
status="待流转" if submitted_claims else "已清理",
|
||||
tone="warning" if submitted_claims else "success",
|
||||
avatar="账",
|
||||
),
|
||||
self._focus_item(
|
||||
name="付款完成率",
|
||||
role="付款执行",
|
||||
duration=f"{payment_clearance:.1f}%",
|
||||
status=f"{len(paid_claims)} 单已付",
|
||||
tone="success" if payment_clearance >= 80 else "warning",
|
||||
avatar="率",
|
||||
),
|
||||
]
|
||||
priority = {"danger": 0, "warning": 1, "success": 2}
|
||||
return sorted(rows, key=lambda item: priority.get(str(item.get("tone")), 3))[:6]
|
||||
|
||||
def _budget_summary(self, fiscal_year: int) -> dict[str, Any]:
|
||||
allocations = self._fetch_budget_allocations(fiscal_year)
|
||||
@@ -384,6 +509,149 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
"left": self._currency(available),
|
||||
}
|
||||
|
||||
def _budget_metrics(self, fiscal_year: int) -> list[dict[str, Any]]:
|
||||
allocations = self._fetch_budget_allocations(fiscal_year)
|
||||
total = Decimal("0.00")
|
||||
consumed = Decimal("0.00")
|
||||
reserved = Decimal("0.00")
|
||||
available = Decimal("0.00")
|
||||
over_count = 0
|
||||
warning_count = 0
|
||||
|
||||
for allocation in allocations:
|
||||
balance = self.get_balance(allocation)
|
||||
total += balance.total_amount
|
||||
consumed += balance.consumed_amount
|
||||
reserved += balance.reserved_amount
|
||||
available += balance.available_amount
|
||||
if balance.available_amount < Decimal("0.00"):
|
||||
over_count += 1
|
||||
continue
|
||||
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
|
||||
warning_count += 1
|
||||
|
||||
used = consumed + reserved
|
||||
usage_rate = Decimal("0.00")
|
||||
if total > Decimal("0.00"):
|
||||
usage_rate = (used / total) * Decimal("100")
|
||||
|
||||
return [
|
||||
self._budget_metric(
|
||||
label="预算池数量",
|
||||
value=f"{len(allocations)} 个",
|
||||
detail="年度有效预算池",
|
||||
tone="neutral",
|
||||
icon="mdi mdi-database-outline",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="总预算",
|
||||
value=self._currency(total),
|
||||
detail="原始预算 + 调整",
|
||||
tone="neutral",
|
||||
icon="mdi mdi-cash-register",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="已用预算",
|
||||
value=self._currency(used),
|
||||
detail=f"使用率 {self._decimal_number(usage_rate):.1f}%",
|
||||
tone="warning" if usage_rate >= Decimal("80") else "success",
|
||||
icon="mdi mdi-chart-arc",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="预占预算",
|
||||
value=self._currency(reserved),
|
||||
detail="待流转单据占用",
|
||||
tone="warning" if reserved > Decimal("0.00") else "success",
|
||||
icon="mdi mdi-lock-outline",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="可用预算",
|
||||
value=self._currency(available),
|
||||
detail="可继续使用额度",
|
||||
tone="danger" if available < Decimal("0.00") else "success",
|
||||
icon="mdi mdi-wallet-outline",
|
||||
),
|
||||
self._budget_metric(
|
||||
label="预警预算池",
|
||||
value=f"{warning_count} 个",
|
||||
detail=f"超支 {over_count} 个",
|
||||
tone="danger" if over_count else "warning" if warning_count else "success",
|
||||
icon="mdi mdi-alert-outline",
|
||||
),
|
||||
]
|
||||
|
||||
def _budget_metric(
|
||||
self,
|
||||
*,
|
||||
label: str,
|
||||
value: str,
|
||||
detail: str,
|
||||
tone: str,
|
||||
icon: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"label": label,
|
||||
"value": value,
|
||||
"detail": detail,
|
||||
"tone": tone,
|
||||
"icon": icon,
|
||||
}
|
||||
|
||||
def _budget_focus_rows(self) -> list[dict[str, Any]]:
|
||||
allocations = self._fetch_budget_allocations(datetime.now(UTC).year)
|
||||
over_count = 0
|
||||
warning_count = 0
|
||||
over_amount = Decimal("0.00")
|
||||
warning_used = Decimal("0.00")
|
||||
|
||||
for allocation in allocations:
|
||||
balance = self.get_balance(allocation)
|
||||
if balance.available_amount < Decimal("0.00"):
|
||||
over_count += 1
|
||||
over_amount += abs(balance.available_amount)
|
||||
continue
|
||||
if balance.usage_rate >= Decimal(str(allocation.warning_threshold or 80)):
|
||||
warning_count += 1
|
||||
warning_used += balance.reserved_amount + balance.consumed_amount
|
||||
|
||||
return [
|
||||
self._focus_item(
|
||||
name="预算超支",
|
||||
role="预算控制",
|
||||
duration=f"{over_count} 个池",
|
||||
status=self._currency(over_amount),
|
||||
tone="danger" if over_count else "success",
|
||||
avatar="超",
|
||||
),
|
||||
self._focus_item(
|
||||
name="预算预警",
|
||||
role="预算控制",
|
||||
duration=f"{warning_count} 个池",
|
||||
status=self._currency(warning_used),
|
||||
tone="warning" if warning_count else "success",
|
||||
avatar="预",
|
||||
),
|
||||
]
|
||||
|
||||
def _focus_item(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
role: str,
|
||||
duration: str,
|
||||
status: str,
|
||||
tone: str,
|
||||
avatar: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"name": name,
|
||||
"role": role,
|
||||
"duration": duration,
|
||||
"status": status,
|
||||
"tone": tone,
|
||||
"avatar": avatar,
|
||||
}
|
||||
|
||||
def _claim_time(self, claim: ExpenseClaim) -> datetime:
|
||||
return self._as_utc(claim.submitted_at or claim.occurred_at or claim.created_at)
|
||||
|
||||
@@ -410,10 +678,14 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
labels.append("风险扫描命中")
|
||||
for flag in self._risk_flags(claim):
|
||||
if isinstance(flag, dict):
|
||||
label = str(flag.get("label") or flag.get("message") or flag.get("type") or "").strip()
|
||||
label = str(flag.get("label") or flag.get("message") or "").strip()
|
||||
if not label:
|
||||
label = self._risk_signal_label(
|
||||
flag.get("type") or flag.get("risk_signal") or ""
|
||||
)
|
||||
else:
|
||||
label = str(flag or "").strip()
|
||||
labels.append(label or "规则异常")
|
||||
label = self._risk_signal_label(flag)
|
||||
labels.append(self._display_risk_label(label))
|
||||
return labels
|
||||
|
||||
def _risk_flags(self, claim: ExpenseClaim) -> list[Any]:
|
||||
@@ -424,6 +696,70 @@ class FinanceDashboardService(BudgetSupportMixin):
|
||||
stage = str(claim.approval_stage or self._status(claim) or "").strip().lower()
|
||||
return STAGE_LABELS.get(stage, stage.replace("_", " ").strip() or "待审批")
|
||||
|
||||
def _finance_status_label(self, status: str) -> str:
|
||||
labels = {
|
||||
"submitted": "审批中",
|
||||
"review": "审批中",
|
||||
"pending_review": "审批中",
|
||||
"manager_review": "审批中",
|
||||
"budget_review": "审批中",
|
||||
"finance_review": "审批中",
|
||||
"approving": "审批中",
|
||||
"approved": "已入账",
|
||||
"pending_payment": "待付款",
|
||||
"paid": "已付款",
|
||||
"returned": "待补充",
|
||||
"rejected": "已驳回",
|
||||
}
|
||||
return labels.get(str(status or "").strip().lower(), "其他")
|
||||
|
||||
def _expense_type_label(self, value: str | None) -> str:
|
||||
raw = str(value or "").strip()
|
||||
normalized = raw.lower().replace(" ", "_").replace("-", "_")
|
||||
normalized = EXPENSE_TYPE_ALIASES.get(normalized, normalized)
|
||||
if normalized.endswith("_application"):
|
||||
normalized = normalized.removesuffix("_application")
|
||||
return EXPENSE_TYPE_LABELS.get(normalized, "其他费用")
|
||||
|
||||
def _is_missing_finance_dimension(self, value: str | None) -> bool:
|
||||
normalized = str(value or "").strip()
|
||||
return not normalized or normalized in {
|
||||
"待补充",
|
||||
"待确认",
|
||||
"未归属部门",
|
||||
"未归属",
|
||||
"N/A",
|
||||
"n/a",
|
||||
"-",
|
||||
}
|
||||
|
||||
def _display_finance_dimension(self, value: str | None, *, fallback: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
return fallback if self._is_missing_finance_dimension(text) else text
|
||||
|
||||
def _risk_signal_label(self, value: Any) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
return "风险观察"
|
||||
key = normalized.lower().replace(" ", "_").replace("-", "_")
|
||||
if key in RISK_SIGNAL_LABELS:
|
||||
return RISK_SIGNAL_LABELS[key]
|
||||
return self._display_risk_label(normalized)
|
||||
|
||||
def _display_risk_label(self, value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return "风险观察"
|
||||
key = text.lower().replace(" ", "_").replace("-", "_")
|
||||
if key in RISK_SIGNAL_LABELS:
|
||||
return RISK_SIGNAL_LABELS[key]
|
||||
if self._contains_cjk(text):
|
||||
return text
|
||||
return "风险观察"
|
||||
|
||||
def _contains_cjk(self, value: str) -> bool:
|
||||
return any("\u4e00" <= char <= "\u9fff" for char in value)
|
||||
|
||||
def _status(self, claim: ExpenseClaim) -> str:
|
||||
return str(claim.status or "").strip().lower()
|
||||
|
||||
|
||||
@@ -14,8 +14,10 @@ from app.schemas.user_agent import (
|
||||
UserAgentResponse,
|
||||
UserAgentSuggestedAction,
|
||||
)
|
||||
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
|
||||
from app.services.expense_claim_access_policy import ExpenseClaimAccessPolicy
|
||||
from app.services.expense_claim_risk_stage import with_risk_business_stage
|
||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||
from app.services.document_numbering import (
|
||||
build_document_number,
|
||||
generate_unique_expense_claim_no,
|
||||
@@ -25,6 +27,11 @@ from app.services.user_agent_application_dates import (
|
||||
resolve_application_days_from_time_range,
|
||||
)
|
||||
from app.services.user_agent_application_locations import normalize_application_location
|
||||
from app.services.user_agent_application_summary import (
|
||||
build_application_summary,
|
||||
build_application_summary_table,
|
||||
resolve_application_time_label,
|
||||
)
|
||||
from app.services.application_system_estimate import apply_application_system_estimate_to_facts
|
||||
|
||||
APPLICATION_CONTEXT_VALUES = {
|
||||
@@ -35,7 +42,7 @@ APPLICATION_CONTEXT_VALUES = {
|
||||
"preapproval",
|
||||
}
|
||||
APPLICATION_BASE_FIELDS = ("time", "location", "reason")
|
||||
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
|
||||
APPLICATION_TIME_LABELS = ("行程时间", "出发时间", "返回时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
|
||||
APPLICATION_FIELD_LABELS = (
|
||||
"申请类型",
|
||||
"费用类型",
|
||||
@@ -202,7 +209,7 @@ class UserAgentApplicationMixin:
|
||||
facts: dict[str, str],
|
||||
step: str,
|
||||
) -> str:
|
||||
recognized_table = self._build_application_summary_table(facts, include_empty=False)
|
||||
recognized_table = build_application_summary_table(facts, include_empty=False)
|
||||
|
||||
if step == "ask_missing":
|
||||
missing_fields = self._resolve_application_missing_fields(facts)
|
||||
@@ -234,7 +241,7 @@ class UserAgentApplicationMixin:
|
||||
if step == "duplicate":
|
||||
application_no = str(facts.get("application_no") or "").strip()
|
||||
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
|
||||
time_label = self._resolve_application_time_label(facts)
|
||||
time_label = resolve_application_time_label(facts)
|
||||
return "\n\n".join(
|
||||
[
|
||||
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
|
||||
@@ -247,7 +254,7 @@ class UserAgentApplicationMixin:
|
||||
return "\n\n".join(
|
||||
[
|
||||
"这是费用申请核对结果,请核对:",
|
||||
self._build_application_summary_table(facts),
|
||||
build_application_summary_table(facts),
|
||||
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。",
|
||||
]
|
||||
)
|
||||
@@ -375,9 +382,71 @@ class UserAgentApplicationMixin:
|
||||
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
|
||||
if range_days:
|
||||
facts["days"] = f"{range_days}天"
|
||||
self._apply_rule_center_travel_policy_to_application_facts(payload, facts)
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
return facts
|
||||
|
||||
def _apply_rule_center_travel_policy_to_application_facts(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
facts: dict[str, str],
|
||||
) -> None:
|
||||
if "差旅" not in str(facts.get("application_type") or "") and "出差" not in str(facts.get("application_type") or ""):
|
||||
return
|
||||
|
||||
location = str(facts.get("location") or "").strip()
|
||||
grade = str(facts.get("grade") or "").strip()
|
||||
if not location or not grade:
|
||||
return
|
||||
|
||||
days = self._parse_application_days_count(facts.get("days", "")) or 1
|
||||
try:
|
||||
result = TravelReimbursementCalculatorService(self.db).calculate(
|
||||
TravelReimbursementCalculatorRequest(days=days, location=location, grade=grade),
|
||||
self._build_application_current_user(payload),
|
||||
)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
hotel_rate = self._format_application_policy_money(result.hotel_rate)
|
||||
hotel_amount = self._format_application_policy_money(result.hotel_amount)
|
||||
allowance_rate = self._format_application_policy_money(result.total_allowance_rate)
|
||||
allowance_amount = self._format_application_policy_money(result.allowance_amount)
|
||||
if hotel_rate:
|
||||
facts["lodging_daily_cap"] = f"{hotel_rate}元/天"
|
||||
if hotel_amount:
|
||||
facts["hotel_amount"] = f"{hotel_amount}元"
|
||||
if allowance_rate:
|
||||
facts["subsidy_daily_cap"] = f"{allowance_rate}元/天"
|
||||
if allowance_amount:
|
||||
facts["allowance_amount"] = f"{allowance_amount}元"
|
||||
if str(result.matched_city or "").strip():
|
||||
facts["matched_city"] = str(result.matched_city).strip()
|
||||
if str(result.rule_name or "").strip():
|
||||
facts["rule_name"] = str(result.rule_name).strip()
|
||||
if str(result.rule_version or "").strip():
|
||||
facts["rule_version"] = str(result.rule_version).strip()
|
||||
|
||||
@staticmethod
|
||||
def _format_application_policy_money(value: object) -> str:
|
||||
try:
|
||||
amount = Decimal(str(value or "0")).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
return ""
|
||||
if amount == amount.to_integral():
|
||||
return f"{int(amount):,}"
|
||||
return f"{amount:,.2f}".rstrip("0").rstrip(".")
|
||||
|
||||
@staticmethod
|
||||
def _parse_application_days_count(value: object) -> int:
|
||||
match = re.search(r"\d+", str(value or ""))
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(match.group(0)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_preview_facts(context_json: dict[str, object]) -> dict[str, str]:
|
||||
preview = context_json.get("application_preview")
|
||||
@@ -496,6 +565,17 @@ class UserAgentApplicationMixin:
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_time_from_text(message: str) -> str:
|
||||
departure_time = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("出发时间", "出发日期"),
|
||||
)
|
||||
return_time = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
("返回时间", "返回日期"),
|
||||
)
|
||||
if departure_time and return_time:
|
||||
return departure_time if departure_time == return_time else f"{departure_time} 至 {return_time}"
|
||||
|
||||
labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
|
||||
message,
|
||||
APPLICATION_TIME_LABELS,
|
||||
@@ -543,6 +623,13 @@ class UserAgentApplicationMixin:
|
||||
@staticmethod
|
||||
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
|
||||
label_pattern = "|".join(re.escape(label) for label in labels)
|
||||
table_match = re.search(
|
||||
rf"\|\s*(?:{label_pattern})\s*\|\s*(?P<value>[^|\n]+?)\s*\|",
|
||||
str(message or ""),
|
||||
)
|
||||
if table_match:
|
||||
return table_match.group("value").strip()
|
||||
|
||||
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
|
||||
match = re.search(
|
||||
rf"(?:{label_pattern})[::]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)",
|
||||
@@ -644,7 +731,7 @@ class UserAgentApplicationMixin:
|
||||
return ""
|
||||
|
||||
text = re.sub(
|
||||
r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*",
|
||||
r"^(?:行程时间|出发时间|返回时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*",
|
||||
"",
|
||||
text,
|
||||
)
|
||||
@@ -843,73 +930,6 @@ class UserAgentApplicationMixin:
|
||||
return "会务费用申请"
|
||||
return "差旅费用申请"
|
||||
|
||||
@staticmethod
|
||||
def _resolve_application_time_label(facts: dict[str, str]) -> str:
|
||||
application_type = str(facts.get("application_type") or "").strip()
|
||||
if "差旅" in application_type or "出差" in application_type:
|
||||
return "行程时间"
|
||||
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
|
||||
return "招待时间"
|
||||
return "申请时间"
|
||||
|
||||
@classmethod
|
||||
def _build_application_summary(cls, facts: dict[str, str]) -> str:
|
||||
time_label = cls._resolve_application_time_label(facts)
|
||||
return "\n".join(
|
||||
f"{label}:{value or '待补充'}"
|
||||
for label, value in (
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("姓名", facts.get("applicant", "")),
|
||||
("部门", facts.get("department", "")),
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
(time_label, facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("系统预估费用", facts.get("amount", "")),
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_application_summary_table(
|
||||
cls,
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
include_empty: bool = True,
|
||||
) -> str:
|
||||
time_label = cls._resolve_application_time_label(facts)
|
||||
rows = [
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("姓名", facts.get("applicant", "")),
|
||||
("部门", facts.get("department", "")),
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
(time_label, facts.get("time", "")),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("系统预估费用", facts.get("amount", "")),
|
||||
]
|
||||
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||
if not visible_rows:
|
||||
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
|
||||
lines = ["| 字段 | 内容 |", "| --- | --- |"]
|
||||
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _create_expense_application_record(
|
||||
self,
|
||||
payload: UserAgentRequest,
|
||||
@@ -1204,7 +1224,7 @@ class UserAgentApplicationMixin:
|
||||
return UserAgentDraftPayload(
|
||||
draft_type="expense_application",
|
||||
title=str(facts.get("application_type") or "费用申请").strip() or "费用申请",
|
||||
body=self._build_application_summary(facts),
|
||||
body=build_application_summary(facts),
|
||||
confirmation_required=False,
|
||||
claim_id=claim.id,
|
||||
claim_no=claim.claim_no,
|
||||
|
||||
100
server/src/app/services/user_agent_application_summary.py
Normal file
100
server/src/app/services/user_agent_application_summary.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def resolve_application_time_label(facts: dict[str, str]) -> str:
|
||||
application_type = str(facts.get("application_type") or "").strip()
|
||||
if "差旅" in application_type or "出差" in application_type:
|
||||
return "出发时间"
|
||||
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
|
||||
return "招待时间"
|
||||
return "申请时间"
|
||||
|
||||
|
||||
def _is_travel_application(facts: dict[str, str]) -> bool:
|
||||
application_type = str(facts.get("application_type") or "").strip()
|
||||
return "差旅" in application_type or "出差" in application_type
|
||||
|
||||
|
||||
def _extract_application_day_count(value: str) -> int:
|
||||
match = re.search(r"(\d{1,2})\s*天", str(value or ""))
|
||||
if not match:
|
||||
return 0
|
||||
try:
|
||||
return max(0, int(match.group(1)))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _add_application_days(start_date: str, days: int) -> str:
|
||||
if not start_date or days <= 1:
|
||||
return start_date
|
||||
try:
|
||||
value = datetime.fromisoformat(start_date)
|
||||
except ValueError:
|
||||
return start_date
|
||||
return (value + timedelta(days=days - 1)).date().isoformat()
|
||||
|
||||
|
||||
def _resolve_application_trip_dates(facts: dict[str, str]) -> tuple[str, str]:
|
||||
time_text = str(facts.get("time") or "").strip()
|
||||
matched_dates = re.findall(r"\d{4}-\d{2}-\d{2}", time_text)
|
||||
start_date = matched_dates[0] if matched_dates else time_text
|
||||
end_date = matched_dates[-1] if len(matched_dates) >= 2 else ""
|
||||
if not end_date or end_date == start_date:
|
||||
end_date = _add_application_days(start_date, _extract_application_day_count(facts.get("days", "")))
|
||||
return start_date, end_date or start_date
|
||||
|
||||
|
||||
def build_application_time_rows(facts: dict[str, str]) -> list[tuple[str, str]]:
|
||||
if _is_travel_application(facts):
|
||||
start_date, end_date = _resolve_application_trip_dates(facts)
|
||||
return [
|
||||
("出发时间", start_date),
|
||||
("返回时间", end_date),
|
||||
]
|
||||
return [(resolve_application_time_label(facts), facts.get("time", ""))]
|
||||
|
||||
|
||||
def build_application_summary_rows(facts: dict[str, str]) -> list[tuple[str, str]]:
|
||||
return [
|
||||
("申请类型", facts.get("application_type", "")),
|
||||
("姓名", facts.get("applicant", "")),
|
||||
("部门", facts.get("department", "")),
|
||||
("岗位", facts.get("position", "")),
|
||||
("职级", facts.get("grade", "")),
|
||||
("直属领导", facts.get("manager_name", "")),
|
||||
*build_application_time_rows(facts),
|
||||
("地点", facts.get("location", "")),
|
||||
("事由", facts.get("reason", "")),
|
||||
("天数", facts.get("days", "")),
|
||||
("出行方式", facts.get("transport_mode", "")),
|
||||
("住宿上限/天", facts.get("lodging_daily_cap", "")),
|
||||
("补贴标准/天", facts.get("subsidy_daily_cap", "")),
|
||||
("交通费用口径", facts.get("transport_policy", "")),
|
||||
("规则测算参考", facts.get("policy_estimate", "")),
|
||||
("系统预估费用", facts.get("amount", "")),
|
||||
]
|
||||
|
||||
|
||||
def build_application_summary(facts: dict[str, str]) -> str:
|
||||
return "\n".join(
|
||||
f"{label}:{value or '待补充'}"
|
||||
for label, value in build_application_summary_rows(facts)
|
||||
)
|
||||
|
||||
|
||||
def build_application_summary_table(
|
||||
facts: dict[str, str],
|
||||
*,
|
||||
include_empty: bool = True,
|
||||
) -> str:
|
||||
rows = build_application_summary_rows(facts)
|
||||
visible_rows = rows if include_empty else [(label, value) for label, value in rows if str(value or "").strip()]
|
||||
if not visible_rows:
|
||||
visible_rows = [("申请描述", "已收到,正在按费用申请上下文继续整理")]
|
||||
lines = ["| 字段 | 内容 |", "| --- | --- |"]
|
||||
lines.extend(f"| {label} | {value or '待补充'} |" for label, value in visible_rows)
|
||||
return "\n".join(lines)
|
||||
@@ -190,6 +190,11 @@ class UserAgentReviewSlotMixin:
|
||||
if not cleaned_key:
|
||||
continue
|
||||
normalized[cleaned_key] = str(value or "").strip()
|
||||
if not normalized.get("transport_mode"):
|
||||
for alias in ("transportMode", "application_transport_mode", "applicationTransportMode"):
|
||||
if normalized.get(alias):
|
||||
normalized["transport_mode"] = normalized[alias]
|
||||
break
|
||||
return normalized
|
||||
|
||||
|
||||
|
||||
188
server/tests/test_demo_company_simulation_seed.py
Normal file
188
server/tests/test_demo_company_simulation_seed.py
Normal file
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import create_engine, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.budget import BudgetAllocation, BudgetReservation, BudgetTransaction
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||
from app.models.organization import OrganizationUnit
|
||||
from app.models.risk_observation import RiskObservation
|
||||
from app.services.budget import BudgetService
|
||||
from app.services.demo_company_simulation_seed import (
|
||||
SIM_CLAIM_PREFIX,
|
||||
SIM_EMPLOYEE_PREFIX,
|
||||
HalfYearExpenseSimulationSeeder,
|
||||
SimulationConfig,
|
||||
)
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
return session_factory()
|
||||
|
||||
|
||||
def seed_company(db: Session) -> None:
|
||||
tech = OrganizationUnit(
|
||||
id="dept-tech",
|
||||
unit_code="TECH-DEPT",
|
||||
name="技术部",
|
||||
unit_type="department",
|
||||
cost_center="CC-6100",
|
||||
location="北京",
|
||||
)
|
||||
market = OrganizationUnit(
|
||||
id="dept-market",
|
||||
unit_code="MARKET-DEPT",
|
||||
name="市场部",
|
||||
unit_type="department",
|
||||
cost_center="CC-4100",
|
||||
location="上海",
|
||||
)
|
||||
db.add_all([tech, market])
|
||||
for index in range(3):
|
||||
db.add(
|
||||
Employee(
|
||||
id=f"emp-existing-{index}",
|
||||
employee_no=f"E-EXISTING-{index}",
|
||||
name=f"现有员工{index}",
|
||||
email=f"existing-{index}@xf.com",
|
||||
grade="P5",
|
||||
position="主管",
|
||||
organization_unit=tech if index % 2 == 0 else market,
|
||||
cost_center="CC-6100" if index % 2 == 0 else "CC-4100",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_half_year_simulation_preview_and_apply_are_idempotent() -> None:
|
||||
with build_session() as db:
|
||||
seed_company(db)
|
||||
config = SimulationConfig(target_employees=8, start_date=date(2026, 1, 1), months=6, seed=7)
|
||||
|
||||
preview = HalfYearExpenseSimulationSeeder(db, config).preview()
|
||||
|
||||
assert preview.mode == "dry-run"
|
||||
assert preview.current_employee_count == 3
|
||||
assert preview.employees_to_create == 5
|
||||
assert preview.claims_to_create >= 24
|
||||
assert preview.budget_allocations_to_create > 0
|
||||
assert preview.budget_transactions_to_create > 0
|
||||
|
||||
applied = HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||
db.commit()
|
||||
|
||||
assert applied.mode == "apply"
|
||||
assert applied.employees_to_create == 5
|
||||
assert db.scalar(select(func.count()).select_from(Employee)) == 8
|
||||
assert db.scalar(select(func.count()).select_from(ExpenseClaim)) == applied.claims_to_create
|
||||
assert (
|
||||
db.scalar(select(func.count()).select_from(ExpenseClaimItem))
|
||||
== applied.claim_items_to_create
|
||||
)
|
||||
assert (
|
||||
db.scalar(select(func.count()).select_from(BudgetAllocation))
|
||||
== applied.budget_allocations_to_create
|
||||
)
|
||||
assert (
|
||||
db.scalar(select(func.count()).select_from(BudgetTransaction))
|
||||
== applied.budget_transactions_to_create
|
||||
)
|
||||
assert (
|
||||
db.scalar(select(func.count()).select_from(BudgetReservation))
|
||||
== applied.budget_reservations_to_create
|
||||
)
|
||||
assert (
|
||||
db.scalar(select(func.count()).select_from(RiskObservation))
|
||||
== applied.risk_observations_to_create
|
||||
)
|
||||
|
||||
repeated = HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||
db.commit()
|
||||
|
||||
assert repeated.employees_to_create == 0
|
||||
assert repeated.claims_to_create == 0
|
||||
assert repeated.budget_allocations_to_create == 0
|
||||
assert repeated.budget_transactions_to_create == 0
|
||||
assert repeated.budget_reservations_to_create == 0
|
||||
assert repeated.risk_observations_to_create == 0
|
||||
|
||||
|
||||
def test_half_year_simulation_feeds_budget_summary() -> None:
|
||||
with build_session() as db:
|
||||
seed_company(db)
|
||||
config = SimulationConfig(
|
||||
target_employees=10,
|
||||
start_date=date(2026, 1, 1),
|
||||
months=6,
|
||||
seed=11,
|
||||
)
|
||||
HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||
db.commit()
|
||||
|
||||
summary = BudgetService(db).get_summary(fiscal_year=2026, period_key="2026Q2")
|
||||
sim_claim_count = db.scalar(
|
||||
select(func.count()).select_from(ExpenseClaim).where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||
)
|
||||
sim_employee_count = db.scalar(
|
||||
select(func.count()).select_from(Employee).where(Employee.employee_no.like(f"{SIM_EMPLOYEE_PREFIX}%"))
|
||||
)
|
||||
|
||||
assert sim_claim_count and sim_claim_count >= 30
|
||||
assert sim_employee_count == 7
|
||||
assert summary.trend
|
||||
assert {item.period_key for item in summary.trend} == {"2026Q1", "2026Q2"}
|
||||
assert summary.warning_count + summary.over_budget_count > 0
|
||||
|
||||
|
||||
def test_half_year_simulation_excludes_admin_and_visible_month_has_real_volume() -> None:
|
||||
with build_session() as db:
|
||||
seed_company(db)
|
||||
db.add(
|
||||
Employee(
|
||||
id="emp-admin",
|
||||
employee_no="ADMIN",
|
||||
name="admin",
|
||||
email="admin@xf.com",
|
||||
grade="P8",
|
||||
position="admin",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
config = SimulationConfig(
|
||||
target_employees=100,
|
||||
start_date=date(2026, 1, 1),
|
||||
months=6,
|
||||
seed=20260602,
|
||||
)
|
||||
HalfYearExpenseSimulationSeeder(db, config).apply()
|
||||
db.commit()
|
||||
|
||||
admin_claim_count = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ExpenseClaim)
|
||||
.where(ExpenseClaim.employee_name == "admin")
|
||||
)
|
||||
visible_claim_count = db.scalar(
|
||||
select(func.count())
|
||||
.select_from(ExpenseClaim)
|
||||
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
|
||||
.where(ExpenseClaim.occurred_at >= datetime(2026, 6, 1, tzinfo=UTC))
|
||||
.where(ExpenseClaim.occurred_at < datetime(2026, 6, 3, tzinfo=UTC))
|
||||
)
|
||||
|
||||
assert admin_claim_count == 0
|
||||
assert visible_claim_count is not None
|
||||
assert 400 <= visible_claim_count <= 500
|
||||
@@ -416,6 +416,13 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
||||
"application_amount": "3000",
|
||||
"application_amount_label": "¥3,000",
|
||||
"application_business_time": "2026-02-20 至 2026-02-23",
|
||||
"application_date": "2026-06-02T00:58:00Z",
|
||||
"application_days": "4 天",
|
||||
"application_transport_mode": "火车",
|
||||
"application_lodging_daily_cap": "600元/天",
|
||||
"application_subsidy_daily_cap": "120元/天",
|
||||
"application_transport_policy": "按真实票据复核",
|
||||
"application_policy_estimate": "交通 1,160元 + 住宿 2,400元 + 补贴 480元",
|
||||
},
|
||||
"expense_scene_selection": {
|
||||
"expense_type": "travel",
|
||||
@@ -432,6 +439,7 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
||||
assert claim.location == "上海"
|
||||
assert claim.amount == Decimal("0.00")
|
||||
assert claim.invoice_count == 0
|
||||
assert claim.occurred_at.date() == date(2026, 2, 20)
|
||||
assert claim.items == []
|
||||
link_flag = next(
|
||||
flag
|
||||
@@ -439,7 +447,221 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
|
||||
if isinstance(flag, dict) and flag.get("source") == "application_link"
|
||||
)
|
||||
assert link_flag["application_claim_no"] == "AP-202606-001"
|
||||
assert link_flag["application_detail"]["application_time"] == "2026-02-20 至 2026-02-23"
|
||||
assert link_flag["application_detail"]["application_business_time"] == "2026-02-20 至 2026-02-23"
|
||||
assert link_flag["application_detail"]["application_date"] == "2026-06-02T00:58:00Z"
|
||||
assert link_flag["application_detail"]["application_amount"] == "3000"
|
||||
assert link_flag["application_detail"]["application_days"] == "4 天"
|
||||
assert link_flag["application_detail"]["application_transport_mode"] == "火车"
|
||||
assert link_flag["application_detail"]["application_lodging_daily_cap"] == "600元/天"
|
||||
assert link_flag["application_detail"]["application_subsidy_daily_cap"] == "120元/天"
|
||||
|
||||
|
||||
def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> None:
|
||||
user_id = "linked-application-existing-placeholder@example.com"
|
||||
message = (
|
||||
"报销类型:差旅费\n"
|
||||
"关联申请单:AP-202606-002 / 支撑国网仿生产服务器部署 / 上海 / ¥3,000\n"
|
||||
"报销票据:草稿生成后在详情中上传"
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5105",
|
||||
name="关联员工",
|
||||
email=user_id,
|
||||
grade="P5",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
existing_claim = ExpenseClaim(
|
||||
claim_no="RE-202606020001-PLACEHOLDER",
|
||||
employee_id=employee.id,
|
||||
employee_name="关联员工",
|
||||
department_name="技术部",
|
||||
project_code=None,
|
||||
expense_type="travel",
|
||||
reason="支撑国网仿生产服务器部署",
|
||||
location="上海",
|
||||
amount=Decimal("3000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
existing_claim.items = [
|
||||
ExpenseClaimItem(
|
||||
claim_id=existing_claim.id,
|
||||
item_date=date(2026, 2, 20),
|
||||
item_type="travel",
|
||||
item_reason="支撑国网仿生产服务器部署",
|
||||
item_location="上海",
|
||||
item_amount=Decimal("3000.00"),
|
||||
invoice_id=None,
|
||||
)
|
||||
]
|
||||
db.add(existing_claim)
|
||||
db.commit()
|
||||
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id=user_id,
|
||||
)
|
||||
)
|
||||
result = ExpenseClaimService(db).upsert_draft_from_ontology(
|
||||
run_id=ontology.run_id,
|
||||
user_id=user_id,
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json={
|
||||
"name": "关联员工",
|
||||
"draft_claim_id": existing_claim.id,
|
||||
"user_input_text": message,
|
||||
"review_action": "save_draft",
|
||||
"review_form_values": {
|
||||
"expense_type": "差旅费",
|
||||
"amount": "¥3,000",
|
||||
"reason": "支撑国网仿生产服务器部署",
|
||||
"location": "上海",
|
||||
"business_location": "上海",
|
||||
"application_claim_id": "application-linked-existing-placeholder",
|
||||
"application_claim_no": "AP-202606-002",
|
||||
"application_reason": "支撑国网仿生产服务器部署",
|
||||
"application_location": "上海",
|
||||
"application_amount": "3000",
|
||||
"application_amount_label": "¥3,000",
|
||||
},
|
||||
"expense_scene_selection": {
|
||||
"expense_type": "travel",
|
||||
"application_claim_id": "application-linked-existing-placeholder",
|
||||
"application_claim_no": "AP-202606-002",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||
assert claim is not None
|
||||
assert claim.id == existing_claim.id
|
||||
assert claim.amount == Decimal("0.00")
|
||||
assert claim.invoice_count == 0
|
||||
assert claim.items == []
|
||||
|
||||
|
||||
def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5106",
|
||||
name="关联差旅员工",
|
||||
email="linked-application-allowance@example.com",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
|
||||
claim = build_claim(expense_type="travel", location="上海")
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = employee.name
|
||||
claim.amount = Decimal("354.00")
|
||||
claim.items[0].item_date = date(2026, 2, 20)
|
||||
claim.items[0].item_type = "train_ticket"
|
||||
claim.items[0].item_reason = "武汉-上海"
|
||||
claim.items[0].item_location = "上海"
|
||||
claim.items[0].item_amount = Decimal("354.00")
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "application_link",
|
||||
"application_claim_no": "AP-202606-003",
|
||||
"application_detail": {
|
||||
"application_time": "2026-02-20 至 2026-02-23",
|
||||
"application_days": "4 天",
|
||||
"application_location": "上海",
|
||||
"application_transport_mode": "火车",
|
||||
},
|
||||
}
|
||||
]
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
service._sync_claim_from_items(claim)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
|
||||
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
||||
assert "4天" in allowance_item.item_reason
|
||||
assert allowance_item.item_date == date(2026, 2, 23)
|
||||
|
||||
|
||||
def test_sync_travel_allowance_backfills_range_from_linked_application_claim() -> None:
|
||||
with build_session() as db:
|
||||
employee = Employee(
|
||||
employee_no="E5107",
|
||||
name="旧关联差旅员工",
|
||||
email="linked-application-allowance-backfill@example.com",
|
||||
grade="P4",
|
||||
)
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
application_claim = ExpenseClaim(
|
||||
claim_no="AP-202606-004",
|
||||
employee_id=employee.id,
|
||||
employee_name=employee.name,
|
||||
department_name="技术部",
|
||||
project_code=None,
|
||||
expense_type="travel_application",
|
||||
reason="支撑国网仿生产环境部署",
|
||||
location="上海",
|
||||
amount=Decimal("3000.00"),
|
||||
currency="CNY",
|
||||
invoice_count=0,
|
||||
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
|
||||
status="approved",
|
||||
approval_stage="审批完成",
|
||||
risk_flags_json=[
|
||||
{
|
||||
"source": "application_detail",
|
||||
"application_detail": {
|
||||
"time": "2026-02-20 至 2026-02-23",
|
||||
"days": "4 天",
|
||||
"location": "上海",
|
||||
"reason": "支撑国网仿生产环境部署",
|
||||
"transport_mode": "火车",
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
db.add(application_claim)
|
||||
db.flush()
|
||||
|
||||
claim = build_claim(expense_type="travel", location="上海")
|
||||
claim.employee_id = employee.id
|
||||
claim.employee_name = employee.name
|
||||
claim.amount = Decimal("354.00")
|
||||
claim.items[0].item_date = date(2026, 2, 20)
|
||||
claim.items[0].item_type = "train_ticket"
|
||||
claim.items[0].item_reason = "武汉-上海"
|
||||
claim.items[0].item_location = "上海"
|
||||
claim.items[0].item_amount = Decimal("354.00")
|
||||
claim.risk_flags_json = [
|
||||
{
|
||||
"source": "application_link",
|
||||
"application_claim_no": "AP-202606-004",
|
||||
}
|
||||
]
|
||||
db.add(claim)
|
||||
db.commit()
|
||||
|
||||
service = ExpenseClaimService(db)
|
||||
service._sync_claim_from_items(claim)
|
||||
db.commit()
|
||||
db.refresh(claim)
|
||||
|
||||
allowance_item = next(item for item in claim.items if item.item_type == "travel_allowance")
|
||||
assert "4天" in allowance_item.item_reason
|
||||
assert allowance_item.item_date == date(2026, 2, 23)
|
||||
|
||||
|
||||
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
||||
@@ -2858,6 +3080,83 @@ def test_list_claims_limits_finance_to_personal_records() -> None:
|
||||
assert claims[0].claim_no == "EXP-FIN-OWN"
|
||||
|
||||
|
||||
def test_list_claims_returns_company_reimbursements_for_finance_document_center() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="finance@example.com",
|
||||
name="财务",
|
||||
role_codes=["finance"],
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-COMPANY-SUBMITTED",
|
||||
employee_name="乙",
|
||||
department_name="市场部",
|
||||
project_code="PRJ-MKT",
|
||||
expense_type="travel",
|
||||
reason="客户拜访差旅",
|
||||
location="上海",
|
||||
amount=Decimal("1200.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
|
||||
status="submitted",
|
||||
approval_stage="finance_review",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-COMPANY-DRAFT",
|
||||
employee_name="丙",
|
||||
department_name="技术部",
|
||||
project_code="PRJ-TECH",
|
||||
expense_type="office",
|
||||
reason="办公用品",
|
||||
location="北京",
|
||||
amount=Decimal("300.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 11, 12, 0, tzinfo=UTC),
|
||||
submitted_at=None,
|
||||
status="draft",
|
||||
approval_stage="待提交",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="EXP-FIN-COMPANY-PAID",
|
||||
employee_name="丁",
|
||||
department_name="财务部",
|
||||
project_code="PRJ-FIN",
|
||||
expense_type="meal",
|
||||
reason="客户沟通",
|
||||
location="杭州",
|
||||
amount=Decimal("500.00"),
|
||||
currency="CNY",
|
||||
invoice_count=1,
|
||||
occurred_at=datetime(2026, 5, 12, 9, 0, tzinfo=UTC),
|
||||
submitted_at=datetime(2026, 5, 12, 10, 0, tzinfo=UTC),
|
||||
status="paid",
|
||||
approval_stage="payment",
|
||||
risk_flags_json=[],
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
claim_nos = {claim.claim_no for claim in ExpenseClaimService(db).list_claims(current_user)}
|
||||
archived_nos = {
|
||||
claim.claim_no for claim in ExpenseClaimService(db).list_archived_claims(current_user)
|
||||
}
|
||||
|
||||
assert "EXP-FIN-COMPANY-SUBMITTED" in claim_nos
|
||||
assert "EXP-FIN-COMPANY-DRAFT" not in claim_nos
|
||||
assert "EXP-FIN-COMPANY-PAID" not in claim_nos
|
||||
assert "EXP-FIN-COMPANY-PAID" in archived_nos
|
||||
|
||||
|
||||
def test_list_claims_limits_executive_to_personal_records() -> None:
|
||||
current_user = CurrentUserContext(
|
||||
username="executive@example.com",
|
||||
@@ -3822,6 +4121,12 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
"reason": "支撑国网服务器上线部署",
|
||||
"days": "3 天",
|
||||
"transport_mode": "高铁",
|
||||
"lodging_daily_cap": "600元/天",
|
||||
"subsidy_daily_cap": "120元/天",
|
||||
"transport_policy": "按真实票据复核",
|
||||
"policy_estimate": "交通按真实票据 + 住宿 1,800元 + 补贴 360元",
|
||||
"rule_name": "差旅标准规则",
|
||||
"rule_version": "2026.05",
|
||||
"amount": "12000.00",
|
||||
},
|
||||
},
|
||||
@@ -3899,6 +4204,13 @@ def test_direct_manager_can_route_application_claim_to_budget_approval_then_budg
|
||||
and flag.get("application_detail", {}).get("application_reason") == "支撑国网服务器上线部署"
|
||||
and flag.get("application_detail", {}).get("application_days") == "3 天"
|
||||
and flag.get("application_detail", {}).get("application_transport_mode") == "高铁"
|
||||
and flag.get("application_detail", {}).get("application_lodging_daily_cap") == "600元/天"
|
||||
and flag.get("application_detail", {}).get("application_subsidy_daily_cap") == "120元/天"
|
||||
and flag.get("application_detail", {}).get("application_transport_policy") == "按真实票据复核"
|
||||
and flag.get("application_detail", {}).get("application_policy_estimate")
|
||||
== "交通按真实票据 + 住宿 1,800元 + 补贴 360元"
|
||||
and flag.get("application_detail", {}).get("application_rule_name") == "差旅标准规则"
|
||||
and flag.get("application_detail", {}).get("application_rule_version") == "2026.05"
|
||||
and flag.get("leader_opinion") == "业务必要,同意申请。"
|
||||
and flag.get("budget_opinion") == "预算额度可承接,同意。"
|
||||
for flag in generated_draft.risk_flags_json
|
||||
|
||||
52
server/tests/test_expense_claim_status_registry.py
Normal file
52
server/tests/test_expense_claim_status_registry.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from app.services.expense_claim_status_registry import (
|
||||
claim_status_code,
|
||||
normalize_expense_claim_state,
|
||||
)
|
||||
from app.services.expense_claim_workflow_constants import (
|
||||
APPROVAL_DONE_STAGE,
|
||||
ARCHIVE_ACCOUNTING_STAGE,
|
||||
FINANCE_APPROVAL_STAGE,
|
||||
PAYMENT_PAID_STAGE,
|
||||
PAYMENT_PENDING_STAGE,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_legacy_finance_review_to_submitted_finance_stage() -> None:
|
||||
state = normalize_expense_claim_state(
|
||||
"finance_review",
|
||||
"finance_review",
|
||||
claim_no="SIM-EXP-2026-0001",
|
||||
expense_type="travel",
|
||||
)
|
||||
|
||||
assert state.status == "submitted"
|
||||
assert state.approval_stage == FINANCE_APPROVAL_STAGE
|
||||
assert state.status_code == 20
|
||||
assert state.changed is True
|
||||
|
||||
|
||||
def test_normalize_reimbursement_archive_stage_differs_from_application_done() -> None:
|
||||
reimbursement_state = normalize_expense_claim_state(
|
||||
"approved",
|
||||
"completed",
|
||||
claim_no="SIM-EXP-2026-0002",
|
||||
expense_type="travel",
|
||||
)
|
||||
application_state = normalize_expense_claim_state(
|
||||
"approved",
|
||||
"completed",
|
||||
claim_no="AP-20260602-0001",
|
||||
expense_type="travel_application",
|
||||
)
|
||||
|
||||
assert reimbursement_state.approval_stage == ARCHIVE_ACCOUNTING_STAGE
|
||||
assert application_state.approval_stage == APPROVAL_DONE_STAGE
|
||||
|
||||
|
||||
def test_normalize_payment_stages_by_status() -> None:
|
||||
pending_state = normalize_expense_claim_state("pending_payment", "payment")
|
||||
paid_state = normalize_expense_claim_state("paid", "payment")
|
||||
|
||||
assert pending_state.approval_stage == PAYMENT_PENDING_STAGE
|
||||
assert paid_state.approval_stage == PAYMENT_PAID_STAGE
|
||||
assert claim_status_code("paid") == 50
|
||||
@@ -25,7 +25,7 @@ def build_session() -> Session:
|
||||
return session_factory()
|
||||
|
||||
|
||||
def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> None:
|
||||
def test_finance_dashboard_service_aggregates_claim_budget_and_payment_data() -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with build_session() as db:
|
||||
@@ -85,6 +85,24 @@ def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> No
|
||||
created_at=now - timedelta(hours=1),
|
||||
updated_at=now - timedelta(hours=1),
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="AP-DASH-ADMIN-001",
|
||||
employee_name="admin",
|
||||
department_name="Finance",
|
||||
expense_type="travel_application",
|
||||
reason="admin pre-approval should not enter reimbursement metrics",
|
||||
location="Shanghai",
|
||||
amount=Decimal("999999.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(minutes=20),
|
||||
submitted_at=now - timedelta(minutes=10),
|
||||
status="paid",
|
||||
approval_stage="payment",
|
||||
risk_flags_json=[],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(minutes=20),
|
||||
updated_at=now - timedelta(minutes=10),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.add(
|
||||
@@ -144,12 +162,175 @@ def test_finance_dashboard_service_aggregates_claim_risk_and_budget_data() -> No
|
||||
)
|
||||
|
||||
assert dashboard.has_real_data is True
|
||||
assert dashboard.totals["pendingCount"] == 1
|
||||
assert dashboard.totals["pendingAmount"] == 1200.0
|
||||
assert dashboard.totals["riskCount"] == 1
|
||||
assert dashboard.totals["reimbursementCount"] == 2
|
||||
assert dashboard.totals["reimbursementAmount"] == 2000.0
|
||||
assert dashboard.totals["pendingPaymentAmount"] == 0.0
|
||||
assert dashboard.trend["applications"][-1] >= 1
|
||||
assert "AP-DASH-ADMIN-001" not in str(dashboard.trend)
|
||||
assert dashboard.spend_by_category[0]["value"] == 1200.0
|
||||
assert dashboard.department_ranking[0]["name"] == "财务部"
|
||||
assert dashboard.department_ranking[0]["amount"] == 1200.0
|
||||
assert dashboard.employee_ranking[0]["name"] == "陈雨晴"
|
||||
assert dashboard.top_claims[0]["claimNo"] == "CLM-DASH-001"
|
||||
assert "AP-DASH-ADMIN-001" not in str(dashboard.top_claims)
|
||||
assert dashboard.budget_summary["ratio"] == 40.0
|
||||
assert dashboard.budget_summary["used"] == "¥4,000"
|
||||
metric_labels = {item["label"] for item in dashboard.budget_metrics}
|
||||
assert {"预算池数量", "总预算", "已用预算", "可用预算", "预警预算池"}.issubset(
|
||||
metric_labels
|
||||
)
|
||||
|
||||
|
||||
def test_finance_dashboard_uses_financial_terms_instead_of_approval_terms() -> None:
|
||||
now = datetime.now(UTC)
|
||||
|
||||
with build_session() as db:
|
||||
db.add_all(
|
||||
[
|
||||
ExpenseClaim(
|
||||
claim_no="CLM-DASH-LABEL-001",
|
||||
employee_name="林嘉宁",
|
||||
department_name="市场部",
|
||||
expense_type="travel_application",
|
||||
reason="客户拜访差旅",
|
||||
location="上海",
|
||||
amount=Decimal("700.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(hours=2),
|
||||
submitted_at=now - timedelta(hours=1),
|
||||
status="submitted",
|
||||
approval_stage="finance_review",
|
||||
risk_flags_json=[{"type": "budget_pressure"}],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(hours=2),
|
||||
updated_at=now - timedelta(hours=1),
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="CLM-DASH-LABEL-002",
|
||||
employee_name="周思远",
|
||||
department_name="财务部",
|
||||
expense_type="meal",
|
||||
reason="客户沟通",
|
||||
location="杭州",
|
||||
amount=Decimal("300.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(days=1),
|
||||
submitted_at=now - timedelta(days=1),
|
||||
status="paid",
|
||||
approval_stage="payment",
|
||||
risk_flags_json=[],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(days=1),
|
||||
updated_at=now - timedelta(days=1),
|
||||
),
|
||||
ExpenseClaim(
|
||||
claim_no="CLM-DASH-LABEL-003",
|
||||
employee_name="reimbursement-user",
|
||||
department_name="甯傚満閮?,
|
||||
expense_type="travel",
|
||||
reason="real travel reimbursement",
|
||||
location="Shanghai",
|
||||
amount=Decimal("700.00"),
|
||||
invoice_count=1,
|
||||
occurred_at=now - timedelta(hours=2),
|
||||
submitted_at=now - timedelta(hours=1),
|
||||
status="submitted",
|
||||
approval_stage="finance_review",
|
||||
risk_flags_json=[],
|
||||
hermes_risk_flag=False,
|
||||
created_at=now - timedelta(hours=2),
|
||||
updated_at=now - timedelta(hours=1),
|
||||
),
|
||||
]
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
RiskObservation(
|
||||
observation_key="risk-dashboard-label-001",
|
||||
subject_type="expense_claim",
|
||||
subject_key="CLM-DASH-LABEL-001",
|
||||
subject_label="CLM-DASH-LABEL-001",
|
||||
claim_no="CLM-DASH-LABEL-001",
|
||||
risk_type="policy",
|
||||
risk_signal="missing_material",
|
||||
title="材料不完整",
|
||||
risk_level="medium",
|
||||
status="pending_review",
|
||||
created_at=now - timedelta(minutes=30),
|
||||
updated_at=now - timedelta(minutes=30),
|
||||
),
|
||||
RiskObservation(
|
||||
observation_key="risk-dashboard-label-002",
|
||||
subject_type="expense_claim",
|
||||
subject_key="CLM-DASH-LABEL-001",
|
||||
subject_label="CLM-DASH-LABEL-001",
|
||||
claim_no="CLM-DASH-LABEL-001",
|
||||
risk_type="budget",
|
||||
risk_signal="budget_pressure",
|
||||
title="预算压力偏高",
|
||||
risk_level="high",
|
||||
status="pending_review",
|
||||
created_at=now - timedelta(minutes=20),
|
||||
updated_at=now - timedelta(minutes=20),
|
||||
),
|
||||
]
|
||||
)
|
||||
allocation = BudgetAllocation(
|
||||
budget_no="BUD-DASH-LABEL-001",
|
||||
fiscal_year=now.year,
|
||||
period_type="year",
|
||||
period_key=f"{now.year}",
|
||||
department_name="市场部",
|
||||
subject_code="travel",
|
||||
subject_name="差旅费",
|
||||
original_amount=Decimal("1000.00"),
|
||||
adjusted_amount=Decimal("0.00"),
|
||||
status="active",
|
||||
warning_threshold=Decimal("80.00"),
|
||||
control_action="warn",
|
||||
)
|
||||
db.add(allocation)
|
||||
db.flush()
|
||||
db.add(
|
||||
BudgetTransaction(
|
||||
transaction_no="BTX-DASH-LABEL-001",
|
||||
allocation_id=allocation.id,
|
||||
source_type="expense_claim",
|
||||
source_id="CLM-DASH-LABEL-003",
|
||||
source_no="CLM-DASH-LABEL-003",
|
||||
transaction_type="consume",
|
||||
amount=Decimal("1250.00"),
|
||||
before_available_amount=Decimal("1000.00"),
|
||||
after_available_amount=Decimal("-250.00"),
|
||||
operator="finance",
|
||||
reason="测试超支",
|
||||
created_at=now - timedelta(minutes=10),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
dashboard = FinanceDashboardService(db).build_dashboard(
|
||||
range_key="近10日",
|
||||
trend_range="近7天",
|
||||
department_range="本月",
|
||||
)
|
||||
|
||||
spend_names = {item["name"] for item in dashboard.spend_by_category}
|
||||
focus_names = {item["name"] for item in dashboard.bottlenecks}
|
||||
|
||||
assert "差旅" in spend_names
|
||||
assert "travel_application" not in str(dashboard.spend_by_category)
|
||||
assert "风险" not in str(dashboard.exception_mix)
|
||||
assert "异常" not in str(dashboard.exception_mix)
|
||||
assert "missing material" not in str(dashboard.exception_mix).lower()
|
||||
assert "budget pressure" not in str(dashboard.exception_mix).lower()
|
||||
assert dashboard.trend["claimCount"][-1] == 1
|
||||
assert dashboard.trend["claimAmount"][-1] == 700.0
|
||||
assert dashboard.trend["applications"] == dashboard.trend["claimCount"]
|
||||
assert dashboard.department_ranking[0]["name"] == "市场部"
|
||||
assert dashboard.department_ranking[0]["amount"] == 700.0
|
||||
assert {"预算超支", "待付款", "高额单据"}.issubset(focus_names)
|
||||
assert "风险金额" not in focus_names
|
||||
assert "材料待补" not in focus_names
|
||||
assert all(item["role"] != "审批节点" for item in dashboard.bottlenecks)
|
||||
assert len(dashboard.budget_metrics) == 6
|
||||
|
||||
@@ -710,7 +710,8 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
|
||||
assert response.status == "blocked"
|
||||
assert response.trace_summary.scenario == "expense"
|
||||
assert "费用申请" in result["answer"]
|
||||
assert "| 行程时间 | 2026-05-25" in result["answer"]
|
||||
assert "| 出发时间 | 2026-05-25 |" in result["answer"]
|
||||
assert "| 返回时间 | 2026-05-27 |" in result["answer"]
|
||||
assert "请先在下面选择报销场景" not in result["answer"]
|
||||
assert result.get("review_payload") is None
|
||||
|
||||
@@ -773,8 +774,10 @@ def test_orchestrator_application_session_guides_transport_estimate_and_submit(
|
||||
assert "这是费用申请核对结果" in second.result["answer"]
|
||||
assert "| 事由 | 支持上海国网服务器部署 |" in second.result["answer"]
|
||||
assert "| 系统预估费用 |" in second.result["answer"]
|
||||
assert "按 2026-05-25 参考票价" in second.result["answer"]
|
||||
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in second.result["answer"]
|
||||
assert "2,330元" in second.result["answer"]
|
||||
assert "参考票价" not in second.result["answer"]
|
||||
assert "查询耗时" not in second.result["answer"]
|
||||
assert "请核对上述信息无误" in second.result["answer"]
|
||||
assert "[确认](#application-submit)" in second.result["answer"]
|
||||
assert second.status == "blocked"
|
||||
|
||||
@@ -209,7 +209,8 @@ def test_user_agent_application_context_uses_application_language() -> None:
|
||||
|
||||
assert "费用申请" in response.answer
|
||||
assert "| 字段 | 内容 |" in response.answer
|
||||
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
||||
assert "| 出发时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 返回时间 | 2026-05-27 |" in response.answer
|
||||
assert "支持上海国网服务器部署" in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
assert "请先在下面选择报销场景" not in response.answer
|
||||
@@ -224,7 +225,8 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
|
||||
with session_factory() as db:
|
||||
response = build_application_user_agent_response(db, message)
|
||||
|
||||
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
|
||||
assert "| 出发时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 返回时间 | 2026-05-27 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
|
||||
assert "当前还需要先补充:申请事由" not in response.answer
|
||||
@@ -250,7 +252,8 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
|
||||
yili_response = build_application_user_agent_response(db, yili_message)
|
||||
beijing_response = build_application_user_agent_response(db, beijing_message)
|
||||
|
||||
assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
|
||||
assert "| 出发时间 | 2026-05-25 |" in yili_response.answer
|
||||
assert "| 返回时间 | 2026-05-27 |" in yili_response.answer
|
||||
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
|
||||
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
|
||||
assert "伊犁出差" not in yili_response.answer
|
||||
@@ -289,7 +292,8 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
|
||||
)
|
||||
)
|
||||
|
||||
assert "| 行程时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 出发时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 返回时间 | 2026-05-25 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
@@ -317,10 +321,10 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice()
|
||||
assert "| 出行方式 | 飞机 |" in response.answer
|
||||
assert "| 系统预估费用 |" in response.answer
|
||||
assert "交通" in response.answer
|
||||
assert "参考票价" in response.answer
|
||||
assert "按 2026-05-25 参考票价" in response.answer
|
||||
assert "| 交通费用口径 | 预估交通费用 2,330元 |" in response.answer
|
||||
assert "2,330元" in response.answer
|
||||
assert "查询耗时" in response.answer
|
||||
assert "参考票价" not in response.answer
|
||||
assert "查询耗时" not in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
assert response.suggested_actions == []
|
||||
|
||||
@@ -362,14 +366,64 @@ def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> N
|
||||
)
|
||||
)
|
||||
|
||||
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
|
||||
assert "| 出发时间 | 2026-02-20 |" in response.answer
|
||||
assert "| 返回时间 | 2026-02-23 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
|
||||
assert "| 天数 | 4天 |" in response.answer
|
||||
assert "| 住宿上限/天 | 450元/天 |" in response.answer
|
||||
assert "| 补贴标准/天 | 100元/天 |" in response.answer
|
||||
assert "| 规则测算参考 | 交通 2,460元 + 住宿 1,800元 + 补贴 400元 = 4,660元(4天) |" in response.answer
|
||||
assert "| 发生时间 |" not in response.answer
|
||||
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
|
||||
|
||||
|
||||
def test_user_agent_application_keeps_labeled_reason_in_structured_travel_form() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = (
|
||||
"发生时间:2026-02-20 至 2026-02-23\n"
|
||||
"地点:上海\n"
|
||||
"事由:支撑国网仿生产环境建设\n"
|
||||
"天数:4天"
|
||||
)
|
||||
context_json = {
|
||||
"session_type": "application",
|
||||
"entry_source": "application",
|
||||
"name": "曹笑竹",
|
||||
"department_name": "技术部",
|
||||
"position": "财务智能化产品经理",
|
||||
"manager_name": "向万红",
|
||||
"grade": "P5",
|
||||
}
|
||||
with session_factory() as db:
|
||||
ontology = SemanticOntologyService(db).parse(
|
||||
OntologyParseRequest(
|
||||
query=message,
|
||||
user_id="pytest-structured-application-reason@example.com",
|
||||
context_json=context_json,
|
||||
)
|
||||
)
|
||||
response = UserAgentService(db).respond(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-structured-application-reason@example.com",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
tool_payload={"clarification_required": ontology.clarification_required},
|
||||
)
|
||||
)
|
||||
|
||||
assert "| 申请类型 | 差旅费用申请 |" in response.answer
|
||||
assert "| 出发时间 | 2026-02-20 |" in response.answer
|
||||
assert "| 返回时间 | 2026-02-23 |" in response.answer
|
||||
assert "| 地点 | 上海市 |" in response.answer
|
||||
assert "| 事由 | 支撑国网仿生产环境建设 |" in response.answer
|
||||
assert "| 天数 | 4天 |" in response.answer
|
||||
assert "申请事由" not in response.answer
|
||||
assert "当前还需要补充:出行方式" in response.answer
|
||||
|
||||
|
||||
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
|
||||
session_factory = build_session_factory()
|
||||
message = "去上海出差,支撑国网仿生产服务器部署,火车"
|
||||
@@ -418,7 +472,8 @@ def test_user_agent_application_derives_days_from_selected_date_range() -> None:
|
||||
)
|
||||
)
|
||||
|
||||
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
|
||||
assert "| 出发时间 | 2026-02-20 |" in response.answer
|
||||
assert "| 返回时间 | 2026-02-23 |" in response.answer
|
||||
assert "| 天数 | 4天 |" in response.answer
|
||||
assert "| 天数 | 待补充 |" not in response.answer
|
||||
assert "(4天)" in response.answer
|
||||
@@ -452,7 +507,8 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
|
||||
)
|
||||
|
||||
assert "这是费用申请核对结果" in response.answer
|
||||
assert "| 行程时间 | 2026-05-29 至 2026-05-31 |" in response.answer
|
||||
assert "| 出发时间 | 2026-05-29 |" in response.answer
|
||||
assert "| 返回时间 | 2026-05-31 |" in response.answer
|
||||
assert response.requires_confirmation is True
|
||||
|
||||
|
||||
@@ -547,7 +603,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
"| 字段 | 内容 |\n"
|
||||
"| --- | --- |\n"
|
||||
"| 申请类型 | 差旅费用申请 |\n"
|
||||
"| 行程时间 | 2026-05-25 |\n"
|
||||
"| 出发时间 | 2026-05-25 |\n"
|
||||
"| 返回时间 | 2026-05-27 |\n"
|
||||
"| 地点 | 上海市 |\n"
|
||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||
"| 天数 | 3天 |\n"
|
||||
@@ -585,7 +642,8 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
|
||||
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
||||
session_factory = build_session_factory()
|
||||
initial_message = (
|
||||
"行程时间:2026-05-25 至 2026-05-27\n"
|
||||
"出发时间:2026-05-25\n"
|
||||
"返回时间:2026-05-27\n"
|
||||
"地点:上海\n"
|
||||
"事由:支持上海国网服务器部署\n"
|
||||
"天数:3天\n"
|
||||
@@ -597,7 +655,8 @@ def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
|
||||
"| 字段 | 内容 |\n"
|
||||
"| --- | --- |\n"
|
||||
"| 申请类型 | 差旅费用申请 |\n"
|
||||
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
|
||||
"| 出发时间 | 2026-05-25 |\n"
|
||||
"| 返回时间 | 2026-05-27 |\n"
|
||||
"| 地点 | 上海市 |\n"
|
||||
"| 事由 | 支持上海国网服务器部署 |\n"
|
||||
"| 天数 | 3天 |\n"
|
||||
@@ -1385,6 +1444,7 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
|
||||
"application_location": "北京",
|
||||
"application_amount": "3000元",
|
||||
"application_business_time": "2026-06-01 至 2026-06-03",
|
||||
"application_transport_mode": "火车",
|
||||
},
|
||||
"user_input_text": message,
|
||||
}
|
||||
@@ -1412,6 +1472,15 @@ def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
|
||||
assert slot_map["location"].value == "北京"
|
||||
assert slot_map["amount"].value == "3000.00元"
|
||||
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
|
||||
assert UserAgentService._resolve_review_form_values(
|
||||
UserAgentRequest(
|
||||
run_id=ontology.run_id,
|
||||
user_id="pytest-linked-application-review@example.com",
|
||||
message=message,
|
||||
ontology=ontology,
|
||||
context_json=context_json,
|
||||
)
|
||||
)["transport_mode"] == "火车"
|
||||
assert "事由说明" not in response.review_payload.missing_slots
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user