feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

View 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()

View 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()

View 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()

View 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()

View File

@@ -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)

View File

@@ -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(),

View 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)

View 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))

View 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}",
)

View File

@@ -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:

View File

@@ -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(

View File

@@ -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,

View File

@@ -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:

View File

@@ -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],

View 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

View File

@@ -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()

View File

@@ -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,

View 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)

View File

@@ -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

View 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

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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"

View File

@@ -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