feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -111,7 +111,7 @@ def _doc_type(claim: ExpenseClaim) -> str:
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"):
if str(claim.project_code or "").strip().upper() == "SIM-DEMO":
return "sim_reimbursement"
return "reimbursement"

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sys
from datetime import date
from pathlib import Path
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.core.agent_enums import AgentRunSource # noqa: E402
from app.db.session import get_session_factory # noqa: E402
from app.services.digital_employee_finance_report_task import ( # noqa: E402
DigitalEmployeeFinanceReportTaskService,
)
def main() -> None:
parser = argparse.ArgumentParser(description="Generate finance report PDF by digital employee.")
parser.add_argument("--type", choices=["weekly", "quarterly", "annual"], default="weekly")
parser.add_argument("--start-date", type=_parse_date, default=None)
parser.add_argument("--end-date", type=_parse_date, default=None)
parser.add_argument("--recipient", action="append", default=[])
parser.add_argument("--send-email", action="store_true")
parser.add_argument("--dry-run-email", action="store_true")
args = parser.parse_args()
session_factory = get_session_factory()
with session_factory() as db:
result = DigitalEmployeeFinanceReportTaskService(db).generate_report(
report_type=args.type,
start_date=args.start_date,
end_date=args.end_date,
recipients=args.recipient or None,
send_email=args.send_email or args.dry_run_email,
dry_run_email=args.dry_run_email,
source=AgentRunSource.USER_MESSAGE.value,
)
db.commit()
print(json.dumps(result, ensure_ascii=False, indent=2))
def _parse_date(value: str) -> date:
return date.fromisoformat(value)
if __name__ == "__main__":
main()

View File

@@ -20,7 +20,7 @@ if str(SRC_DIR) not in sys.path:
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.demo_company_simulation_catalog import SIM_PROJECT_CODE # noqa: E402
from app.services.expense_claim_attachment_storage import ( # noqa: E402
ExpenseClaimAttachmentStorage,
)
@@ -135,8 +135,8 @@ def _sim_claims(db) -> list[ExpenseClaim]:
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
.order_by(ExpenseClaim.claim_no.asc())
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
@@ -184,7 +184,7 @@ def _write_mock_attachment(
violated=violated,
)
file_path.write_text(ocr_text, encoding="utf-8")
item.invoice_id = storage.to_storage_key(file_path)
item.invoice_id = filename
storage.write_meta(
file_path,
_meta_payload(

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import re
import sys
import uuid
from dataclasses import asdict, dataclass
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 BudgetReservation, BudgetTransaction # noqa: E402
from app.models.financial_record import ExpenseClaim # noqa: E402
from app.models.risk_observation import RiskObservation # noqa: E402
from app.services.demo_company_simulation_catalog import ( # noqa: E402
SIM_CLAIM_ID_NAMESPACE,
SIM_PROJECT_CODE,
build_simulation_reimbursement_no,
)
from app.services.expense_claim_attachment_storage import ( # noqa: E402
ExpenseClaimAttachmentStorage,
)
LEGACY_CLAIM_PATTERN = re.compile(r"^SIM-EXP-2026-(\d+)$", flags=re.IGNORECASE)
@dataclass(frozen=True, slots=True)
class RenameSummary:
mode: str
legacy_claims: int
renamed_claims: int
budget_transactions_updated: int
budget_reservations_updated: int
risk_observations_updated: int
attachment_files_updated: int
attachment_items_updated: int
residual_attachment_texts_updated: int
samples: list[dict[str, str]]
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def main() -> None:
parser = argparse.ArgumentParser(
description="Rename legacy half-year demo claim numbers to canonical RE numbers."
)
parser.add_argument("--apply", action="store_true", help="write changes to the database")
parser.add_argument("--sample-limit", type=int, default=12)
args = parser.parse_args()
session_factory = get_session_factory()
with session_factory() as db:
summary = rename_demo_claim_numbers(
db,
apply=args.apply,
sample_limit=max(args.sample_limit, 0),
)
if args.apply:
db.commit()
else:
db.rollback()
print(json.dumps(summary.to_dict(), ensure_ascii=False, indent=2))
def rename_demo_claim_numbers(db, *, apply: bool, sample_limit: int) -> RenameSummary:
claims = _legacy_demo_claims(db)
rename_map = _build_rename_map(db, claims)
storage = ExpenseClaimAttachmentStorage()
transaction_updates = 0
reservation_updates = 0
risk_updates = 0
attachment_file_updates = 0
attachment_item_updates = 0
samples: list[dict[str, str]] = []
for claim in claims:
old_no = str(claim.claim_no or "").strip()
new_no = rename_map.get(old_no)
if not new_no:
continue
if len(samples) < sample_limit:
samples.append({"old": old_no, "new": new_no})
transaction_updates += _update_budget_transactions(db, old_no, new_no, apply=apply)
reservation_updates += _update_budget_reservations(db, old_no, new_no, apply=apply)
risk_updates += _update_risk_observations(db, claim, old_no, new_no, apply=apply)
file_count, item_count = _update_attachments(
storage,
claim,
old_no,
new_no,
apply=apply,
)
attachment_file_updates += file_count
attachment_item_updates += item_count
if apply:
claim.claim_no = new_no
residual_text_updates = _repair_residual_attachment_texts(
storage,
_demo_claims(db),
apply=apply,
)
return RenameSummary(
mode="apply" if apply else "dry-run",
legacy_claims=len(claims),
renamed_claims=len(rename_map),
budget_transactions_updated=transaction_updates,
budget_reservations_updated=reservation_updates,
risk_observations_updated=risk_updates,
attachment_files_updated=attachment_file_updates,
attachment_items_updated=attachment_item_updates,
residual_attachment_texts_updated=residual_text_updates,
samples=samples,
)
def _legacy_demo_claims(db) -> list[ExpenseClaim]:
return list(
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.where(ExpenseClaim.claim_no.like("SIM-EXP-2026-%"))
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
def _demo_claims(db) -> list[ExpenseClaim]:
return list(
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
def _build_rename_map(db, claims: list[ExpenseClaim]) -> dict[str, str]:
legacy_numbers = {str(claim.claim_no or "").strip() for claim in claims}
existing_numbers = set(db.scalars(select(ExpenseClaim.claim_no)).all()) - legacy_numbers
rename_map: dict[str, str] = {}
for fallback_index, claim in enumerate(claims, start=1):
old_no = str(claim.claim_no or "").strip()
sequence = _legacy_sequence(old_no) or fallback_index
timestamp = claim.occurred_at or claim.created_at or claim.submitted_at
new_no = build_simulation_reimbursement_no(timestamp, sequence)
if new_no in existing_numbers:
raise RuntimeError(f"canonical claim number already exists: {new_no}")
existing_numbers.add(new_no)
rename_map[old_no] = new_no
return rename_map
def _legacy_sequence(claim_no: str) -> int | None:
match = LEGACY_CLAIM_PATTERN.match(claim_no)
if not match:
return None
return int(match.group(1))
def _update_budget_transactions(db, old_no: str, new_no: str, *, apply: bool) -> int:
rows = list(
db.scalars(
select(BudgetTransaction).where(BudgetTransaction.source_no == old_no)
).all()
)
if apply:
for row in rows:
row.source_no = new_no
return len(rows)
def _update_budget_reservations(db, old_no: str, new_no: str, *, apply: bool) -> int:
rows = list(
db.scalars(
select(BudgetReservation).where(BudgetReservation.source_no == old_no)
).all()
)
if apply:
for row in rows:
row.source_no = new_no
return len(rows)
def _update_risk_observations(
db,
claim: ExpenseClaim,
old_no: str,
new_no: str,
*,
apply: bool,
) -> int:
rows = list(
db.scalars(
select(RiskObservation).where(
(RiskObservation.claim_id == claim.id)
| (RiskObservation.claim_no == old_no)
| (RiskObservation.subject_key == old_no)
)
).all()
)
if apply:
for row in rows:
row.claim_no = new_no if row.claim_no == old_no else row.claim_no
row.subject_key = new_no if row.subject_key == old_no else row.subject_key
row.subject_label = new_no if row.subject_label == old_no else row.subject_label
row.evidence_json = _replace_value(row.evidence_json, old_no, new_no)
row.ontology_json = _replace_value(row.ontology_json, old_no, new_no)
row.decision_trace_json = _replace_value(row.decision_trace_json, old_no, new_no)
return len(rows)
def _update_attachments(
storage: ExpenseClaimAttachmentStorage,
claim: ExpenseClaim,
old_no: str,
new_no: str,
*,
apply: bool,
) -> tuple[int, int]:
file_updates = 0
item_updates = 0
for item in list(claim.items or []):
invoice_id = str(item.invoice_id or "").strip()
if old_no not in invoice_id:
continue
new_invoice_id = invoice_id.replace(old_no, new_no)
item_updates += 1
if not apply:
file_updates += 1
continue
file_path = storage.resolve_item_path(item)
if file_path is not None and file_path.exists():
file_updates += 1
meta_payload = _replace_value(storage.read_meta(file_path), old_no, new_no)
new_file_path = file_path.with_name(file_path.name.replace(old_no, new_no))
meta_path = storage.meta_path(file_path)
new_meta_path = storage.meta_path(new_file_path)
file_path.rename(new_file_path)
if meta_path.exists():
meta_path.rename(new_meta_path)
storage.write_meta(new_file_path, meta_payload)
item.invoice_id = new_invoice_id
return file_updates, item_updates
def _repair_residual_attachment_texts(
storage: ExpenseClaimAttachmentStorage,
claims: list[ExpenseClaim],
*,
apply: bool,
) -> int:
sequence_by_claim_id = _simulation_sequence_by_claim_id(max(3000, len(claims) + 500))
updated = 0
for claim in claims:
sequence = sequence_by_claim_id.get(str(claim.id))
if sequence is None:
continue
old_no = f"SIM-EXP-2026-{sequence:04d}"
new_no = str(claim.claim_no or "").strip()
if not old_no or not new_no or old_no == new_no:
continue
for item in list(claim.items or []):
file_path = storage.resolve_item_path(item)
if file_path is None or not file_path.exists():
continue
if _replace_file_text(file_path, old_no, new_no, apply=apply):
updated += 1
if _replace_meta_text(storage, file_path, old_no, new_no, apply=apply):
updated += 1
return updated
def _simulation_sequence_by_claim_id(limit: int) -> dict[str, int]:
return {
str(
uuid.uuid5(
uuid.NAMESPACE_DNS,
f"x-financial:{SIM_CLAIM_ID_NAMESPACE}:{sequence}",
)
): sequence
for sequence in range(1, limit + 1)
}
def _replace_file_text(file_path: Path, old_no: str, new_no: str, *, apply: bool) -> bool:
try:
content = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return False
if old_no not in content:
return False
if apply:
file_path.write_text(content.replace(old_no, new_no), encoding="utf-8")
return True
def _replace_meta_text(
storage: ExpenseClaimAttachmentStorage,
file_path: Path,
old_no: str,
new_no: str,
*,
apply: bool,
) -> bool:
payload = storage.read_meta(file_path)
if not payload:
return False
replaced = _replace_value(payload, old_no, new_no)
if replaced == payload:
return False
if apply:
storage.write_meta(file_path, replaced)
return True
def _replace_value(value: Any, old_no: str, new_no: str) -> Any:
if isinstance(value, str):
return value.replace(old_no, new_no)
if isinstance(value, list):
return [_replace_value(item, old_no, new_no) for item in value]
if isinstance(value, dict):
return {key: _replace_value(item, old_no, new_no) for key, item in value.items()}
return value
if __name__ == "__main__":
main()

View File

@@ -29,7 +29,6 @@ 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,
@@ -60,6 +59,8 @@ RECENT_DATES = (
datetime(2026, 6, 1, 15, 0, tzinfo=UTC),
datetime(2026, 6, 2, 6, 0, tzinfo=UTC),
)
PERIOD_START = date(2026, 1, 1)
PERIOD_END = date(2026, 6, 2)
@dataclass(frozen=True, slots=True)
@@ -139,6 +140,7 @@ def repair_distribution(db, *, apply: bool) -> RepairSummary:
if apply:
_normalize_sim_claim_workflow(sim_claims)
_clamp_sim_claim_dates(sim_claims)
_redistribute_employees(sim_employees, departments, employee_plan)
db.flush()
employees_by_dept = _employees_by_department(db)
@@ -235,8 +237,8 @@ def _sim_claims(db) -> list[ExpenseClaim]:
db.scalars(
select(ExpenseClaim)
.options(selectinload(ExpenseClaim.items))
.where(ExpenseClaim.claim_no.like(f"{SIM_CLAIM_PREFIX}%"))
.order_by(ExpenseClaim.claim_no.asc())
.where(ExpenseClaim.project_code == SIM_PROJECT_CODE)
.order_by(ExpenseClaim.created_at.asc(), ExpenseClaim.claim_no.asc())
).all()
)
@@ -254,6 +256,23 @@ def _normalize_sim_claim_workflow(claims: list[ExpenseClaim]) -> None:
claim.approval_stage = normalized.approval_stage
def _clamp_sim_claim_dates(claims: list[ExpenseClaim]) -> None:
for index, claim in enumerate(claims):
occurred_at = claim.occurred_at or claim.submitted_at
if occurred_at is None:
continue
if PERIOD_START <= occurred_at.date() <= PERIOD_END:
continue
anchor = RECENT_DATES[index % len(RECENT_DATES)]
claim.occurred_at = anchor - _hours(2)
if claim.submitted_at is not None or claim.status != "draft":
claim.submitted_at = anchor
claim.created_at = claim.occurred_at
claim.updated_at = anchor + _hours(1)
for item in claim.items or []:
item.item_date = claim.occurred_at.date()
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}

View File

@@ -32,6 +32,7 @@ def parse_args() -> argparse.Namespace:
)
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("--end-date", type=date.fromisoformat, default=date(2026, 6, 2))
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.")
@@ -49,6 +50,7 @@ def main() -> None:
config = SimulationConfig(
target_employees=args.target_employees,
start_date=args.start_date,
end_date=args.end_date,
months=args.months,
seed=args.seed,
)