feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
52
server/scripts/generate_finance_report.py
Normal file
52
server/scripts/generate_finance_report.py
Normal 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()
|
||||
@@ -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(
|
||||
|
||||
347
server/scripts/rename_half_year_expense_demo_claim_numbers.py
Normal file
347
server/scripts/rename_half_year_expense_demo_claim_numbers.py
Normal 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()
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user