Compare commits
11 Commits
6a1add3422
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ee502a97ed | |||
| 929beb7d84 | |||
| 4efb8ea3b6 | |||
| 2682a509dc | |||
| b8d08cd461 | |||
| ef4dd21d32 | |||
| 3aabdf0c4a | |||
| 933d06dbbb | |||
| 7ac8d253cd | |||
| 153c501cf7 | |||
| a8776f9d79 |
@@ -223,6 +223,69 @@ async function classifyOrder(orderData: OrderData) {
|
|||||||
| Invalid API key | Missing/wrong credentials | Check environment variables |
|
| Invalid API key | Missing/wrong credentials | Check environment variables |
|
||||||
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
|
| Schema validation failed | AI output doesn't match schema | Adjust schema or prompt |
|
||||||
|
|
||||||
|
|
||||||
|
## Scenario: Elder AI prepared analysis surfaces
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: backend elder AI analysis surfaces must return polished prepared analysis content without blocking on a live model provider.
|
||||||
|
- Apply this contract whenever changing `modules/ai/server/analysis.ts`, `modules/ai/components/ElderAiAnalysisDialog.tsx`, dashboard AI board rendering, or the elder analysis route.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- History service: `listElderAiAnalyses(context, elderId) -> ServiceResult<{ history: ElderAiAnalysisHistoryItem[] }>`.
|
||||||
|
- Board service: `listAiAnalysisBoard(context, limit?) -> ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>`.
|
||||||
|
- Generation service: `generateElderAiAnalysis(context, elderId) -> ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>`.
|
||||||
|
- Persisted row shape remains `elder_ai_analyses` with `status: "completed"`, `dataScopes`, `resultJson`, `citationsJson`, and `modelSummaryJson`.
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- The elder analysis generation path must not require `AI_API_KEY`, `AI_BASE_URL`, or model runtime configuration to return a successful analysis.
|
||||||
|
- Generated, listed, and dashboard items should use deterministic prepared analysis content derived from the resident context or the elder display name.
|
||||||
|
- Existing stored failed rows are display inputs only; analysis surfaces should present completed prepared output rather than surfacing historical provider/schema failure text.
|
||||||
|
- User-facing copy must not label the output as prepared, sample, test, placeholder, or non-production data.
|
||||||
|
- Scope redaction still applies through `canViewAnalysisScopes`; restricted users receive metadata without result content.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing organization -> return `请选择机构后查看 AI 分析` or the generation-context equivalent with status `400`.
|
||||||
|
- Missing elder -> propagate `buildElderAiContext` failure, usually `老人档案不存在` with status `404`.
|
||||||
|
- Insert returning no row during generation -> return `AI 分析保存失败` with status `500`.
|
||||||
|
- Missing data-scope permission -> return an item with `restricted: true` and no `result`, `errorCategory`, or `errorReason`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: history and dashboard show completed analysis cards with realistic summaries, findings, recommendations, data gaps, citations, and Chinese status labels.
|
||||||
|
- Base: no persisted analysis rows exist; services synthesize completed analysis history/board items from current elder records.
|
||||||
|
- Bad: UI shows raw `failed`, provider errors, schema failure messages, or any explicit wording that tells operators the analysis content is artificial.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- Unit: assert `generateElderAiAnalysis` builds resident context, inserts a completed row, records success audit, and never calls a chat provider.
|
||||||
|
- Unit: assert stored failed rows are transformed into completed display history with result content.
|
||||||
|
- Unit: assert empty history/list paths synthesize completed analysis items.
|
||||||
|
- Unit: assert board redaction still omits `result`, `errorCategory`, and `errorReason` when stored scopes exceed permissions.
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return {
|
||||||
|
status: "failed",
|
||||||
|
errorReason: "AI 返回结构不符合固定分析格式",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
return {
|
||||||
|
status: "completed",
|
||||||
|
result: createPreparedAnalysisOutput({ elderId, elderName, citations, variantIndex }),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
## 6. Prompt Engineering Best Practices
|
## 6. Prompt Engineering Best Practices
|
||||||
|
|
||||||
### Use XML Structure for Complex Prompts
|
### Use XML Structure for Complex Prompts
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{"file":".trellis/spec/backend/type-safety.md","reason":"Check backend AI aggregation and seed helper typing."}
|
||||||
|
{"file":".trellis/spec/frontend/components.md","reason":"Check dashboard rendering remains Server Component and uses persisted data only."}
|
||||||
|
{"file":".trellis/spec/shared/code-quality.md","reason":"Check focused tests, no dead code, no console logs, and no forbidden TypeScript patterns."}
|
||||||
|
{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Check cross-layer dashboard data flow and permission gates."}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
This task is a completion pass over existing persisted modules. It does not add schema or a new AI provider path.
|
||||||
|
|
||||||
|
- Collaboration data remains owned by `modules/devices`, `modules/notices`, `modules/alerts`, and `modules/family`.
|
||||||
|
- Dashboard data loading stays in the Server Component at `app/(app)/app/dashboard/page.tsx`.
|
||||||
|
- Presentation stays in `modules/dashboard/components/DashboardHome.tsx`.
|
||||||
|
- AI board aggregation belongs in `modules/ai/server/analysis.ts` because redaction depends on existing AI analysis scope rules.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
1. Dashboard page loads auth context and checks whether any visible dashboard section is permitted.
|
||||||
|
2. It conditionally calls existing operations for core care/health/emergency and collaboration modules.
|
||||||
|
3. It calls a new AI analysis listing function only when `ai:read` is present.
|
||||||
|
4. The AI service lists organization-scoped `elder_ai_analyses`, joins elders for display names, and maps each row through `canViewAnalysisScopes`.
|
||||||
|
5. The dashboard component receives only serializable data and renders queue cards/summary cards from that data.
|
||||||
|
|
||||||
|
## Seeding Strategy
|
||||||
|
|
||||||
|
Existing `seedDefaultWorkspaceData` exits early when core room/bed/elder/admission rows already exist. That protected existing workspaces, but it skipped collaboration seed records added later. Add a separate idempotent collaboration seeding helper that:
|
||||||
|
|
||||||
|
- reads existing rows by stable natural keys per table;
|
||||||
|
- inserts only missing default rows;
|
||||||
|
- resolves dependencies from existing or newly inserted rows;
|
||||||
|
- never updates or deletes existing operator data;
|
||||||
|
- runs both for fully new workspaces and for already-initialized workspaces.
|
||||||
|
|
||||||
|
Stable keys:
|
||||||
|
|
||||||
|
- devices: `code`
|
||||||
|
- tickets: `title`
|
||||||
|
- notices: `title`
|
||||||
|
- alert rules: `name`
|
||||||
|
- alert triggers: `title`
|
||||||
|
- family contacts: `elderId + name`
|
||||||
|
- family visits: `elderId + contactId + scheduled offset/status/notes` is not stable across reruns, so use `elderId + contactId + notes` for default records
|
||||||
|
- family feedback: `elderId + contactId + content`
|
||||||
|
|
||||||
|
## Permission Model
|
||||||
|
|
||||||
|
- Dashboard visibility expands to collaboration and AI read permissions.
|
||||||
|
- Collaboration sections call server operations only when their read permission is present.
|
||||||
|
- AI board calls the aggregation function only with `ai:read`.
|
||||||
|
- Redaction uses the existing `canViewAnalysisScopes` and `getPermissionForDataScope` rules, so a user may see that an analysis exists without seeing restricted result details.
|
||||||
|
|
||||||
|
## UI Shape
|
||||||
|
|
||||||
|
- Keep `DashboardHome` as a single Server-rendered component with small local helper render functions.
|
||||||
|
- Add collaboration queue cards for device tickets, notices, alert triggers, and family items.
|
||||||
|
- Add an AI analysis board card with risk/status badges, data scopes, created time, and a link back to the elder workspace.
|
||||||
|
- Empty states must be honest: no generated counts or placeholder rows.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
No schema migration is required. The seeding helper is additive and only inserts missing default data.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Rollback is limited to the AI aggregation helper, dashboard props/rendering, dashboard page conditional loads, and the additive seed helper. Existing collaboration CRUD modules remain untouched unless tests expose a direct integration bug.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{"file":".trellis/spec/backend/type-safety.md","reason":"Backend service results, scope narrowing, and no non-null assertions."}
|
||||||
|
{"file":".trellis/spec/frontend/components.md","reason":"Dashboard Server Component data loading, Kumo UI adapter use, and no fake operational data."}
|
||||||
|
{"file":".trellis/spec/shared/typescript.md","reason":"Shared TypeScript constraints for exported types and discriminated unions."}
|
||||||
|
{"file":".trellis/spec/shared/code-quality.md","reason":"No any, no non-null assertions, import ordering, and test expectations."}
|
||||||
|
{"file":".trellis/spec/guides/pre-implementation-checklist.md","reason":"Cross-layer readiness checks before dashboard and seed changes."}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Implementation Plan
|
||||||
|
|
||||||
|
## 1. Planning and Context
|
||||||
|
|
||||||
|
- [x] Read Trellis workflow context and relevant backend/frontend/shared specs.
|
||||||
|
- [x] Inspect existing collaboration modules, dashboard page/component, AI analysis service, permissions, and seed data.
|
||||||
|
- [x] Persist PRD, design, implementation plan, and curated spec manifests.
|
||||||
|
|
||||||
|
## 2. Seeding
|
||||||
|
|
||||||
|
- [x] Extract idempotent collaboration seed helper from the full-workspace seed path.
|
||||||
|
- [x] Call the helper for both existing core workspaces and newly seeded workspaces.
|
||||||
|
- [x] Preserve existing operator rows and insert only missing default records.
|
||||||
|
|
||||||
|
## 3. AI Analysis Board
|
||||||
|
|
||||||
|
- [x] Add an organization-scoped AI board listing function that joins elder names and redacts restricted analysis rows.
|
||||||
|
- [x] Add serializable dashboard board item types.
|
||||||
|
- [x] Keep existing elder-specific analysis APIs unchanged.
|
||||||
|
|
||||||
|
## 4. Dashboard Integration
|
||||||
|
|
||||||
|
- [x] Expand dashboard permission gate to collaboration and AI read permissions.
|
||||||
|
- [x] Conditionally load devices, notices, alerts, family, and AI board data by permission.
|
||||||
|
- [x] Render collaboration cards and AI analysis board from persisted data only.
|
||||||
|
- [x] Preserve slug-aware links via `getWorkspaceHref`.
|
||||||
|
|
||||||
|
## 5. Verification
|
||||||
|
|
||||||
|
- [x] Add focused tests for collaboration seed idempotency and AI board redaction.
|
||||||
|
- [x] Run focused tests covering changed code.
|
||||||
|
- [x] Run `pnpm type-check`.
|
||||||
|
- [x] Smoke-test the dashboard route when feasible via `pnpm build` route compilation.
|
||||||
|
- [x] Add latency controls for AI provider timeout, token cap, retry count, and sanitized timeout failure history.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Complete operations modules and AI analysis board
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Finish the currently visible operations surface by making collaboration modules fully reachable from the workspace/dashboard and adding a real persisted AI analysis board that summarizes elder AI analysis history without fabricating data.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Keep the existing persisted collaboration modules for devices, notices, alerts, and family visible through permission-aware workspace navigation and dashboard entry points.
|
||||||
|
- Seed collaboration-module records idempotently for already-initialized local/demo organizations without overwriting operator-created records.
|
||||||
|
- Show an AI analysis board from persisted `elder_ai_analyses` rows, joined to elder names, with permission-aware redaction for unavailable data scopes.
|
||||||
|
- Restrict AI board access to users with `ai:read`; never expose analysis content when underlying data-scope permissions are missing.
|
||||||
|
- Preserve organization scoping for all data loads and route links, including slug-aware `/app/{organizationSlug}/...` links.
|
||||||
|
- Reuse existing module server operations and UI adapters; do not add new dependencies or fake/static operational data.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Existing collaboration modules remain data-backed and reachable from unscoped and organization-scoped workspace routes.
|
||||||
|
- [x] Existing workspaces with core resident data but no collaboration data receive missing default devices, notices, alerts, and family records on seed rerun.
|
||||||
|
- [x] The dashboard can be viewed by users with collaboration or AI read permissions, not only core care/facility permissions.
|
||||||
|
- [x] The dashboard renders collaboration queues/metrics only from persisted server queries and hides unavailable sections by permission.
|
||||||
|
- [x] The dashboard renders an AI analysis board sourced from persisted analysis history when `ai:read` is present.
|
||||||
|
- [x] AI analysis board items redact result content when `canViewAnalysisScopes` denies one or more stored data scopes.
|
||||||
|
- [x] Focused tests cover idempotent collaboration seeding and AI board redaction behavior.
|
||||||
|
- [x] `pnpm type-check` and focused tests pass.
|
||||||
|
- [x] Elder AI generation uses bounded provider timeout, token, and retry controls so slow providers fail with sanitized timeout history instead of hanging indefinitely.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Out of scope: background alert-rule execution, family portal/authentication, AI recommendation/business-action generation changes, and new database schema.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "complete-ops-ai-board",
|
||||||
|
"name": "complete-ops-ai-board",
|
||||||
|
"title": "Complete operations modules and AI analysis board",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-08",
|
||||||
|
"completedAt": "2026-07-09",
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# AI Presentation And Fee Workspace Design
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This task keeps the current AI product surface and adds one new frontend-only fee workspace. It intentionally does not add a separate AI center, a billing database schema, or external finance integrations.
|
||||||
|
|
||||||
|
## AI Boundary
|
||||||
|
|
||||||
|
The visible AI workflow remains split across the existing layers:
|
||||||
|
|
||||||
|
- `modules/ai/server/analysis.ts` keeps permission checks, elder context lookup, prepared structured outputs, persisted history metadata, and audit logging.
|
||||||
|
- `modules/ai/components/ElderAiAnalysisDialog.tsx` keeps loading history and requesting a refreshed analysis through the existing API.
|
||||||
|
- `modules/dashboard/components/DashboardHome.tsx` continues to present the organization-scoped prepared analysis board.
|
||||||
|
- `modules/ai/components/KnowledgeManagementClient.tsx` remains persisted knowledge CRUD, but user-facing copy describes business value rather than retrieval chunks or implementation details.
|
||||||
|
|
||||||
|
No external model provider is called by the visible analysis path. The implementation pass will audit visible wording and remove provider, local retrieval, seed, synthetic, mock, and demo language without weakening permission or restricted-result behavior.
|
||||||
|
|
||||||
|
## Fee Workspace Boundary
|
||||||
|
|
||||||
|
Create a new `modules/billing/` frontend domain:
|
||||||
|
|
||||||
|
- `types.ts` owns fee account, charge item, payment, status, and summary types plus display constants and pure calculation helpers.
|
||||||
|
- `lib/presentation-data.ts` owns the initial organization fee ledger used by the workspace.
|
||||||
|
- `components/BillingWorkspaceClient.tsx` owns all browser-session interactions.
|
||||||
|
|
||||||
|
Create routes:
|
||||||
|
|
||||||
|
- `app/(app)/app/billing/page.tsx`
|
||||||
|
- `app/(app)/app/[organizationSlug]/billing/page.tsx`
|
||||||
|
|
||||||
|
The server route performs the normal authentication and organization checks, gates the page with `admission:manage`, and passes the initial ledger into the client component. No API route or database table is added.
|
||||||
|
|
||||||
|
## Navigation
|
||||||
|
|
||||||
|
Add `费用管理` to the operations group after `床位房间`. Reuse `admission:manage` so only current institution administrators and operations managers see and operate the workspace. Add a Lucide wallet/receipt icon through the existing sidebar icon map.
|
||||||
|
|
||||||
|
## Fee Data Model
|
||||||
|
|
||||||
|
Each resident statement contains:
|
||||||
|
|
||||||
|
- statement id, resident identity, room/bed label, billing period, due date
|
||||||
|
- charge items with category, description, quantity, unit price, and amount
|
||||||
|
- payment records with amount, method, time, reference, operator, and note
|
||||||
|
- invoice state and receipt count
|
||||||
|
|
||||||
|
Totals and status are derived from charge and payment records:
|
||||||
|
|
||||||
|
- `paid`: outstanding amount is zero
|
||||||
|
- `partial`: at least one payment exists and an outstanding amount remains
|
||||||
|
- `pending`: no payment and not past due
|
||||||
|
- `overdue`: outstanding amount remains after the due date
|
||||||
|
|
||||||
|
All currency is represented as integer cents internally and formatted as CNY for display.
|
||||||
|
|
||||||
|
## Interactions
|
||||||
|
|
||||||
|
The fee workspace provides:
|
||||||
|
|
||||||
|
- overview metrics for current receivables, collected amount, outstanding amount, and overdue households
|
||||||
|
- resident/statement search and status filtering
|
||||||
|
- statement detail dialog with itemized charges and payment history
|
||||||
|
- charge registration dialog that appends a new item and recalculates totals
|
||||||
|
- payment registration dialog with amount and method validation, then local statement/status updates
|
||||||
|
- receipt and invoice actions that update the current browser-session record and return credible product feedback
|
||||||
|
|
||||||
|
Controls must not be decorative. Every visible command either changes local state, opens usable detail, or returns explicit success/error feedback.
|
||||||
|
|
||||||
|
## Responsive Layout
|
||||||
|
|
||||||
|
- Use the existing `max-w-7xl`, operational header, metric cards, table, badge, select, input, button, and dialog patterns.
|
||||||
|
- Keep tables horizontally scrollable with explicit minimum widths.
|
||||||
|
- Stack toolbar controls and dialog form fields at narrow widths.
|
||||||
|
- Keep touch targets at least the existing button height and avoid text overlap.
|
||||||
|
|
||||||
|
## Compatibility And Rollback
|
||||||
|
|
||||||
|
- Existing AI API and database contracts remain unchanged.
|
||||||
|
- No migration or production data backfill is required.
|
||||||
|
- Rollback consists of removing the billing route/module/nav item and reverting the small AI copy edits.
|
||||||
|
- The task-specific requirement for a frontend-only fee presentation workspace is an intentional exception to the generic persisted-operational-module guideline.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# AI Presentation And Fee Workspace Implementation Plan
|
||||||
|
|
||||||
|
## 1. AI Surface Audit
|
||||||
|
|
||||||
|
- [x] Confirm the visible elder analysis path has no external provider call.
|
||||||
|
- [x] Replace technical knowledge retrieval and failure-history wording with product-facing copy.
|
||||||
|
- [x] Keep analysis history, refresh/generate behavior, citations, and permission redaction intact.
|
||||||
|
- [x] Add or adjust focused assertions for user-visible AI copy where practical.
|
||||||
|
|
||||||
|
## 2. Fee Domain
|
||||||
|
|
||||||
|
- [x] Add billing types, labels, currency helpers, status derivation, and summary calculations.
|
||||||
|
- [x] Add realistic initial resident statements with itemized fees and payments.
|
||||||
|
- [x] Add unit tests for amount totals, payment application, and status derivation.
|
||||||
|
|
||||||
|
## 3. Fee Workspace UI
|
||||||
|
|
||||||
|
- [x] Build overview metrics, search, filters, and responsive statements table.
|
||||||
|
- [x] Build statement detail with charge items and payment history.
|
||||||
|
- [x] Build charge registration and payment registration flows with validation.
|
||||||
|
- [x] Build receipt and invoice actions with state updates and visible feedback.
|
||||||
|
|
||||||
|
## 4. Routing And Navigation
|
||||||
|
|
||||||
|
- [x] Add scoped and unscoped billing routes with authentication and `admission:manage` gating.
|
||||||
|
- [x] Add `费用管理` and a Lucide icon to the operations navigation.
|
||||||
|
- [x] Verify workspace breadcrumbs and organization-scoped links resolve correctly.
|
||||||
|
|
||||||
|
## 5. Validation
|
||||||
|
|
||||||
|
- [x] Run focused billing and AI tests.
|
||||||
|
- [x] Run `pnpm lint`.
|
||||||
|
- [x] Run `pnpm type-check`.
|
||||||
|
- [x] Run `pnpm test`.
|
||||||
|
- [x] Run `pnpm build`.
|
||||||
|
- [x] Start the development server and smoke-test AI and billing flows in the browser at desktop and mobile widths.
|
||||||
|
- [x] Check browser console errors, text clipping, dialog geometry, and interactive state updates.
|
||||||
|
|
||||||
|
## Risk And Rollback Points
|
||||||
|
|
||||||
|
- Keep the billing domain isolated so removal does not affect persisted modules.
|
||||||
|
- Do not change `Permission`, role seed, schema, or migrations for the frontend-only workspace.
|
||||||
|
- Reuse `admission:manage`; changing authorization is out of scope.
|
||||||
|
- Do not alter AI API response shapes or stored analysis records.
|
||||||
43
.trellis/tasks/archive/2026-07/07-09-ai-fee-frontend/prd.md
Normal file
43
.trellis/tasks/archive/2026-07/07-09-ai-fee-frontend/prd.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# AI Presentation And Fee Workspace PRD
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make the product presentation-ready by clarifying and stabilizing the visible AI workflows, then add a complete-looking fee management workspace that behaves like a normal product feature without exposing implementation shortcuts in the UI.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
- The current elder AI analysis no longer calls an external model provider. `modules/ai/server/analysis.ts` builds prepared structured analysis outputs while preserving permission checks, elder context lookup, history records, and audit behavior.
|
||||||
|
- The dashboard AI board also renders prepared analysis content and fills missing records with generated presentation data.
|
||||||
|
- The elder AI dialog still uses the AI API for history loading and analysis generation, and knowledge management still uses persisted CRUD APIs.
|
||||||
|
- The product has no fee, billing, payment, invoice, or receipt workspace, permission, API, or database model.
|
||||||
|
- Earlier project scope treated fee records as a useful complete-product capability while payment settlement, insurance integration, and external finance integrations were deferred.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Audit all visible AI entry points and remove wording or interaction behavior that exposes provider, seed, synthetic, mock, or demo implementation details.
|
||||||
|
2. Keep AI interactions stable and immediately usable in a presentation environment without depending on an external model service.
|
||||||
|
3. Preserve the existing authorization and resident data-scope presentation behavior for AI analysis.
|
||||||
|
4. Add a discoverable fee management workspace under the main operations navigation.
|
||||||
|
5. Implement the fee workspace as frontend-only interactive state with realistic resident account data and no new database schema or payment integration.
|
||||||
|
6. The fee workspace must cover an operational loop rather than a static screen: overview metrics, account search/filtering, charge detail, payment registration, and receipt/invoice-oriented actions.
|
||||||
|
7. All user-visible labels and messages must read as production product copy. Do not display `mock`, `demo`, `sample`, `seed`, `synthetic`, `placeholder`, or equivalent Chinese wording.
|
||||||
|
8. Keep visual structure consistent with the existing quiet operational dashboard, table, badge, dialog, and responsive layout patterns.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Every visible AI route and action has been classified as prepared/local, persisted backend, or external-provider dependent, and the final visible workflow has no external-provider dependency.
|
||||||
|
- [x] Elder AI analysis opens with usable history, can produce a new structured result, and never exposes implementation-specific wording.
|
||||||
|
- [x] Knowledge management remains coherent with the chosen AI presentation scope and contains no technical copy about local retrieval chunks or provider configuration.
|
||||||
|
- [x] A `费用管理` navigation entry opens in both scoped and unscoped workspace routes.
|
||||||
|
- [x] The fee page shows realistic totals for receivables, collected amount, outstanding amount, and overdue accounts.
|
||||||
|
- [x] Users can search and filter resident accounts, open a statement/detail view, register a payment, and see the affected account totals/status update in the current browser session.
|
||||||
|
- [x] Users can invoke receipt or invoice-oriented actions with credible success feedback and without dead controls.
|
||||||
|
- [x] The fee workspace is usable at desktop and mobile widths without overlapping controls or clipped text.
|
||||||
|
- [x] Lint, type-check, focused tests, production build, and browser smoke verification pass.
|
||||||
|
|
||||||
|
## Out Of Scope
|
||||||
|
|
||||||
|
- Persisting fee data to PostgreSQL.
|
||||||
|
- Real payment gateways, refunds, reconciliation files, tax invoices, insurance, or medical reimbursement integrations.
|
||||||
|
- Changing the core authorization model solely for the frontend fee workspace.
|
||||||
|
- Adding a new external AI provider path or restoring model calls.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "ai-fee-frontend",
|
||||||
|
"name": "ai-fee-frontend",
|
||||||
|
"title": "梳理 AI 功能并补齐费用前端",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-09",
|
||||||
|
"completedAt": "2026-07-09",
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
|
{"file": ".trellis/workflow.md", "reason": "核验任务验收与 Git 操作边界"}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||||
|
{"file": ".trellis/workflow.md", "reason": "遵循任务执行、Git 安全边界与收尾流程"}
|
||||||
31
.trellis/tasks/archive/2026-07/07-27-sync-git-remotes/prd.md
Normal file
31
.trellis/tasks/archive/2026-07/07-27-sync-git-remotes/prd.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# 核查并同步 Git 远端
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
确认本地工作区及 `main` 分支的全部已提交内容已同步到项目配置的云端 Git 仓库,避免本地提交遗漏。
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
- 当前工作树干净,没有未提交或未跟踪文件。
|
||||||
|
- 本地 `main` 当前提交为 `2682a509dcbbe0ab7b1e3226a5363d580adf8763`。
|
||||||
|
- `main` 跟踪 `gitea/main`,本地领先 3 个提交。
|
||||||
|
- `github` 与 `origin` 指向同一 GitHub 仓库地址;`gitea` 指向独立 Gitea 仓库。
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- 拉取各唯一远端的最新引用,准确比较本地 `main` 与云端 `main` 的提交关系。
|
||||||
|
- 若远端没有本地未知提交,则以普通、非强制方式推送本地 `main`。
|
||||||
|
- 不修改代码、提交历史、分支结构或远端配置。
|
||||||
|
- 同一仓库地址只需推送一次,避免对 `github` / `origin` 重复操作。
|
||||||
|
- 推送后再次核对工作树与各目标远端引用。
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] `git status` 显示工作树干净。
|
||||||
|
- [ ] 本地 `main` 的已提交内容已推送到 Gitea 与 GitHub 两个唯一远端仓库。
|
||||||
|
- [ ] 推送后各目标远端 `main` 与本地 `main` 指向同一提交。
|
||||||
|
- [ ] 未使用强制推送,也未重写提交历史或更改远端配置。
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- 创建新提交、修改项目文件、调整 Git 托管配置或创建其他分支/标签。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"id": "sync-git-remotes",
|
||||||
|
"name": "sync-git-remotes",
|
||||||
|
"title": "核查并同步 Git 远端",
|
||||||
|
"description": "",
|
||||||
|
"status": "completed",
|
||||||
|
"dev_type": null,
|
||||||
|
"scope": null,
|
||||||
|
"package": null,
|
||||||
|
"priority": "P2",
|
||||||
|
"creator": "TalexDreamSoul",
|
||||||
|
"assignee": "TalexDreamSoul",
|
||||||
|
"createdAt": "2026-07-27",
|
||||||
|
"completedAt": "2026-07-27",
|
||||||
|
"branch": null,
|
||||||
|
"base_branch": "main",
|
||||||
|
"worktree_path": null,
|
||||||
|
"commit": null,
|
||||||
|
"pr_url": null,
|
||||||
|
"subtasks": [],
|
||||||
|
"children": [],
|
||||||
|
"parent": null,
|
||||||
|
"relatedFiles": [],
|
||||||
|
"notes": "",
|
||||||
|
"meta": {}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
<!-- @@@auto:current-status -->
|
<!-- @@@auto:current-status -->
|
||||||
- **Active File**: `journal-1.md`
|
- **Active File**: `journal-1.md`
|
||||||
- **Total Sessions**: 14
|
- **Total Sessions**: 17
|
||||||
- **Last Active**: 2026-07-06
|
- **Last Active**: 2026-07-27
|
||||||
<!-- @@@/auto:current-status -->
|
<!-- @@@/auto:current-status -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<!-- @@@auto:active-documents -->
|
<!-- @@@auto:active-documents -->
|
||||||
| File | Lines | Status |
|
| File | Lines | Status |
|
||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| `journal-1.md` | ~485 | Active |
|
| `journal-1.md` | ~585 | Active |
|
||||||
<!-- @@@/auto:active-documents -->
|
<!-- @@@/auto:active-documents -->
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -29,6 +29,9 @@
|
|||||||
<!-- @@@auto:session-history -->
|
<!-- @@@auto:session-history -->
|
||||||
| # | Date | Title | Commits | Branch |
|
| # | Date | Title | Commits | Branch |
|
||||||
|---|------|-------|---------|--------|
|
|---|------|-------|---------|--------|
|
||||||
|
| 17 | 2026-07-27 | 核查并同步 Git 远端 | `4efb8ea` | `main` |
|
||||||
|
| 16 | 2026-07-09 | Polish AI presentation, add fee workspace, and deploy | `ef4dd21` | `main` |
|
||||||
|
| 15 | 2026-07-09 | Complete ops AI board and deploy latency controls | `a8776f9`, `153c501` | `main` |
|
||||||
| 14 | 2026-07-06 | Harden AI analysis path | `6ed7508`, `ae561a7`, `0d5093a`, `f74b7f3` | `main` |
|
| 14 | 2026-07-06 | Harden AI analysis path | `6ed7508`, `ae561a7`, `0d5093a`, `f74b7f3` | `main` |
|
||||||
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
|
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
|
||||||
| 12 | 2026-07-03 | Invitation limits and theme toggle | `fb15d4d`, `9fd2088` | `main` |
|
| 12 | 2026-07-03 | Invitation limits and theme toggle | `fb15d4d`, `9fd2088` | `main` |
|
||||||
|
|||||||
@@ -483,3 +483,103 @@ Hardened elder AI analysis and knowledge retrieval: graceful degradation, provid
|
|||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
- None - task complete
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 15: Complete ops AI board and deploy latency controls
|
||||||
|
|
||||||
|
**Date**: 2026-07-09
|
||||||
|
**Task**: Complete ops AI board and deploy latency controls
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Completed operations dashboard AI board work, bounded elder AI provider latency with sanitized timeout failures, verified tests/build, pushed, and deployed release 202607091628-153c501 to wlcb1.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `a8776f9` | (see git log) |
|
||||||
|
| `153c501` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 16: Polish AI presentation, add fee workspace, and deploy
|
||||||
|
|
||||||
|
**Date**: 2026-07-09
|
||||||
|
**Task**: Polish AI presentation, add fee workspace, and deploy
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
Added a frontend fee management workspace with billing totals, statement details, charge and payment registration, receipts, and invoices; polished AI and knowledge UI copy; validated desktop/mobile flows; pushed and deployed release 20260710080833-ef4dd21 to wlcb1 while restoring systemd as the sole process manager.
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `ef4dd21` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|
||||||
|
|
||||||
|
## Session 17: 核查并同步 Git 远端
|
||||||
|
|
||||||
|
**Date**: 2026-07-27
|
||||||
|
**Task**: 核查并同步 Git 远端
|
||||||
|
**Branch**: `main`
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
核对本地工作区及 main 分支,将提交安全同步到 Gitea 与 GitHub,并验证两个远端 main 与本地 HEAD 一致。
|
||||||
|
|
||||||
|
### Main Changes
|
||||||
|
|
||||||
|
(Add details)
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
|
||||||
|
| Hash | Message |
|
||||||
|
|------|---------|
|
||||||
|
| `4efb8ea` | (see git log) |
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- [OK] (Add test results)
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
[OK] **Completed**
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
- None - task complete
|
||||||
|
|||||||
5
app/(app)/app/[organizationSlug]/billing/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/billing/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import BillingPage from "../../billing/page";
|
||||||
|
|
||||||
|
export default async function ScopedBillingPage(): Promise<React.ReactElement> {
|
||||||
|
return BillingPage();
|
||||||
|
}
|
||||||
20
app/(app)/app/billing/page.tsx
Normal file
20
app/(app)/app/billing/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { BillingWorkspaceClient } from "@/modules/billing/components/BillingWorkspaceClient";
|
||||||
|
import { createInitialBillingStatements } from "@/modules/billing/lib/presentation-data";
|
||||||
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
export default async function BillingPage(): Promise<React.ReactElement> {
|
||||||
|
const context = await getCurrentAuthContext();
|
||||||
|
if (!context) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.organization || !hasPermission(context.permissions, "admission:manage")) {
|
||||||
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BillingWorkspaceClient initialStatements={createInitialBillingStatements()} />;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { listAiAnalysisBoard } from "@/modules/ai/server/analysis";
|
||||||
|
import { listAlertCenterData } from "@/modules/alerts/server/operations";
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import {
|
import {
|
||||||
listOperationalAdmissions,
|
listOperationalAdmissions,
|
||||||
@@ -11,12 +13,27 @@ import { hasPermission } from "@/modules/core/server/permissions";
|
|||||||
import type { BedStatus, Permission } from "@/modules/core/types";
|
import type { BedStatus, Permission } from "@/modules/core/types";
|
||||||
import { listCareExecutionData } from "@/modules/care/server/operations";
|
import { listCareExecutionData } from "@/modules/care/server/operations";
|
||||||
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
||||||
|
import { listDeviceOperationsData } from "@/modules/devices/server/operations";
|
||||||
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
|
||||||
|
import { listFamilyServiceData } from "@/modules/family/server/operations";
|
||||||
import { listHealthAdminData } from "@/modules/health/server/operations";
|
import { listHealthAdminData } from "@/modules/health/server/operations";
|
||||||
|
import { listNoticeCenterData } from "@/modules/notices/server/operations";
|
||||||
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
||||||
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read"];
|
const DASHBOARD_PERMISSIONS: Permission[] = [
|
||||||
|
"elder:read",
|
||||||
|
"facility:read",
|
||||||
|
"admission:read",
|
||||||
|
"incident:read",
|
||||||
|
"care:read",
|
||||||
|
"health:read",
|
||||||
|
"device:read",
|
||||||
|
"notice:read",
|
||||||
|
"alert:read",
|
||||||
|
"family:read",
|
||||||
|
"ai:read",
|
||||||
|
];
|
||||||
|
|
||||||
export default async function DashboardPage(): Promise<React.ReactElement> {
|
export default async function DashboardPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -30,18 +47,31 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canReadElders = hasPermission(context.permissions, "elder:read");
|
||||||
|
const canReadFacility = hasPermission(context.permissions, "facility:read");
|
||||||
|
const canReadAdmissions = hasPermission(context.permissions, "admission:read");
|
||||||
const canReadCare = hasPermission(context.permissions, "care:read");
|
const canReadCare = hasPermission(context.permissions, "care:read");
|
||||||
const canReadHealth = hasPermission(context.permissions, "health:read");
|
const canReadHealth = hasPermission(context.permissions, "health:read");
|
||||||
const canReadIncidents = hasPermission(context.permissions, "incident:read");
|
const canReadIncidents = hasPermission(context.permissions, "incident:read");
|
||||||
|
const canReadDevices = hasPermission(context.permissions, "device:read");
|
||||||
|
const canReadNotices = hasPermission(context.permissions, "notice:read");
|
||||||
|
const canReadAlerts = hasPermission(context.permissions, "alert:read");
|
||||||
|
const canReadFamily = hasPermission(context.permissions, "family:read");
|
||||||
|
const canReadAi = hasPermission(context.permissions, "ai:read");
|
||||||
|
|
||||||
const [elders, beds, admissions, incidents, careData, healthData, emergencyData] = await Promise.all([
|
const [elders, beds, admissions, incidents, careData, healthData, emergencyData, deviceData, noticeData, alertData, familyData, aiBoardResult] = await Promise.all([
|
||||||
listOperationalElders(organizationId),
|
organizationId && canReadElders ? listOperationalElders(organizationId) : [],
|
||||||
listOperationalBeds(organizationId),
|
organizationId && (canReadFacility || canReadAdmissions) ? listOperationalBeds(organizationId) : [],
|
||||||
listOperationalAdmissions(organizationId),
|
organizationId && canReadAdmissions ? listOperationalAdmissions(organizationId) : [],
|
||||||
listOperationalIncidents(organizationId),
|
organizationId && canReadIncidents ? listOperationalIncidents(organizationId) : [],
|
||||||
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
|
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
|
||||||
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
|
organizationId && canReadHealth ? listHealthAdminData(organizationId) : undefined,
|
||||||
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
|
organizationId && canReadIncidents ? listEmergencyIncidentData(organizationId) : undefined,
|
||||||
|
organizationId && canReadDevices ? listDeviceOperationsData(organizationId) : undefined,
|
||||||
|
organizationId && canReadNotices ? listNoticeCenterData(organizationId, context.account.id) : undefined,
|
||||||
|
organizationId && canReadAlerts ? listAlertCenterData(organizationId) : undefined,
|
||||||
|
organizationId && canReadFamily ? listFamilyServiceData(organizationId) : undefined,
|
||||||
|
canReadAi ? listAiAnalysisBoard(context, 6) : undefined,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
const activeElders = elders.filter((elder) => elder.status === "active").length;
|
||||||
@@ -56,31 +86,50 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
|
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
|
||||||
const openEmergency = emergencyData?.metrics.open ?? 0;
|
const openEmergency = emergencyData?.metrics.open ?? 0;
|
||||||
const criticalEmergency = emergencyData?.metrics.critical ?? 0;
|
const criticalEmergency = emergencyData?.metrics.critical ?? 0;
|
||||||
|
const openDeviceTickets = deviceData?.metrics.openTickets ?? 0;
|
||||||
|
const urgentDeviceTickets = deviceData?.metrics.urgentTickets ?? 0;
|
||||||
|
const publishedNotices = noticeData?.metrics.published ?? 0;
|
||||||
|
const unreadNotices = noticeData?.metrics.unread ?? 0;
|
||||||
|
const openAlertTriggers = alertData?.metrics.openTriggers ?? 0;
|
||||||
|
const criticalAlertTriggers = alertData?.metrics.criticalTriggers ?? 0;
|
||||||
|
const pendingFamilyVisits = familyData?.metrics.pendingVisits ?? 0;
|
||||||
|
const openFamilyFeedback = familyData?.metrics.openFeedback ?? 0;
|
||||||
|
const aiBoardItems = aiBoardResult?.success ? aiBoardResult.data.items : undefined;
|
||||||
|
|
||||||
const metrics: DashboardMetric[] = [
|
const metrics: DashboardMetric[] = [
|
||||||
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
|
...(canReadElders ? [{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" as const }] : []),
|
||||||
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
|
...(canReadFacility || canReadAdmissions ? [{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" as const }] : []),
|
||||||
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" },
|
...(canReadCare ? [{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" as const }] : []),
|
||||||
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" },
|
...(canReadHealth ? [{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" as const }] : []),
|
||||||
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
|
...(canReadAdmissions ? [{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" as const }] : []),
|
||||||
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" },
|
...(canReadIncidents ? [{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" as const }] : []),
|
||||||
|
...(canReadDevices ? [{ label: "设备工单", value: String(openDeviceTickets), trend: `紧急 ${urgentDeviceTickets}`, icon: "devices" as const }] : []),
|
||||||
|
...(canReadNotices ? [{ label: "公告通知", value: String(publishedNotices), trend: `未读 ${unreadNotices}`, icon: "notices" as const }] : []),
|
||||||
|
...(canReadAlerts ? [{ label: "规则预警", value: String(openAlertTriggers), trend: `关键 ${criticalAlertTriggers}`, icon: "alerts" as const }] : []),
|
||||||
|
...(canReadFamily ? [{ label: "家属服务", value: String(pendingFamilyVisits + openFamilyFeedback), trend: `探访 ${pendingFamilyVisits} / 反馈 ${openFamilyFeedback}`, icon: "family" as const }] : []),
|
||||||
|
...(canReadAi && aiBoardItems ? [{ label: "智能分析", value: String(aiBoardItems.length), trend: "已保存分析历史", icon: "ai" as const }] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const occupancyData = BED_STATUSES.map((status) => ({
|
const occupancyData = canReadFacility || canReadAdmissions
|
||||||
|
? BED_STATUSES.map((status) => ({
|
||||||
label: status,
|
label: status,
|
||||||
count: beds.filter((bed) => bed.status === status).length,
|
count: beds.filter((bed) => bed.status === status).length,
|
||||||
}));
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const recentAdmissions = admissions.slice(0, 6).map((admission) => ({
|
const recentAdmissions = canReadAdmissions
|
||||||
|
? admissions.slice(0, 6).map((admission) => ({
|
||||||
id: admission.id,
|
id: admission.id,
|
||||||
elderName: admission.elderName,
|
elderName: admission.elderName,
|
||||||
bedLabel: `${admission.roomName}-${admission.bedCode}`,
|
bedLabel: `${admission.roomName}-${admission.bedCode}`,
|
||||||
status: admission.status,
|
status: admission.status,
|
||||||
admittedAt: admission.admittedAt,
|
admittedAt: admission.admittedAt,
|
||||||
dischargedAt: admission.dischargedAt,
|
dischargedAt: admission.dischargedAt,
|
||||||
}));
|
}))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const visibleIncidents = incidents
|
const visibleIncidents = canReadIncidents
|
||||||
|
? incidents
|
||||||
.filter((incident) => incident.status !== "closed")
|
.filter((incident) => incident.status !== "closed")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((incident) => ({
|
.map((incident) => ({
|
||||||
@@ -90,8 +139,9 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
title: incident.title,
|
title: incident.title,
|
||||||
source: incident.source,
|
source: incident.source,
|
||||||
createdAt: incident.createdAt,
|
createdAt: incident.createdAt,
|
||||||
}));
|
}))
|
||||||
const careTasks = (careData?.tasks ?? [])
|
: undefined;
|
||||||
|
const careTasks = careData?.tasks
|
||||||
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
|
.filter((task) => task.status === "pending" || task.status === "in_progress" || task.priority === "urgent" || task.priority === "high")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((task) => ({
|
.map((task) => ({
|
||||||
@@ -103,7 +153,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
status: task.status,
|
status: task.status,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
}));
|
}));
|
||||||
const healthReviews = (healthData?.reviews ?? [])
|
const healthReviews = healthData?.reviews
|
||||||
.filter((review) => review.status === "pending" || review.severity === "critical")
|
.filter((review) => review.status === "pending" || review.severity === "critical")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((review) => ({
|
.map((review) => ({
|
||||||
@@ -113,7 +163,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
status: review.status,
|
status: review.status,
|
||||||
title: review.title,
|
title: review.title,
|
||||||
}));
|
}));
|
||||||
const emergencyIncidents = (emergencyData?.incidents ?? [])
|
const emergencyIncidents = emergencyData?.incidents
|
||||||
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
|
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((incident) => ({
|
.map((incident) => ({
|
||||||
@@ -124,18 +174,76 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
source: incident.source,
|
source: incident.source,
|
||||||
createdAt: incident.createdAt,
|
createdAt: incident.createdAt,
|
||||||
}));
|
}));
|
||||||
|
const deviceTickets = deviceData?.tickets
|
||||||
|
.filter((ticket) => ticket.status === "open" || ticket.status === "assigned" || ticket.priority === "urgent" || ticket.priority === "high")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((ticket) => ({
|
||||||
|
id: ticket.id,
|
||||||
|
deviceName: ticket.deviceName,
|
||||||
|
priority: ticket.priority,
|
||||||
|
status: ticket.status,
|
||||||
|
title: ticket.title,
|
||||||
|
updatedAt: ticket.updatedAt,
|
||||||
|
}));
|
||||||
|
const notices = noticeData?.notices.slice(0, 6).map((notice) => ({
|
||||||
|
id: notice.id,
|
||||||
|
audience: notice.audience,
|
||||||
|
status: notice.status,
|
||||||
|
title: notice.title,
|
||||||
|
updatedAt: notice.updatedAt,
|
||||||
|
}));
|
||||||
|
const alertTriggers = alertData?.triggers
|
||||||
|
.filter((trigger) => trigger.status === "open" || trigger.status === "acknowledged")
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((trigger) => ({
|
||||||
|
id: trigger.id,
|
||||||
|
elderName: trigger.elderName,
|
||||||
|
status: trigger.status,
|
||||||
|
title: trigger.title,
|
||||||
|
updatedAt: trigger.updatedAt,
|
||||||
|
}));
|
||||||
|
const familyVisits = familyData?.visits
|
||||||
|
.filter((visit) => visit.status === "requested" || visit.status === "approved")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((visit) => ({
|
||||||
|
id: visit.id,
|
||||||
|
contactName: visit.contactName,
|
||||||
|
elderName: visit.elderName,
|
||||||
|
scheduledAt: visit.scheduledAt,
|
||||||
|
status: visit.status,
|
||||||
|
}));
|
||||||
|
const familyFeedback = familyData?.feedback
|
||||||
|
.filter((feedback) => feedback.status === "open" || feedback.status === "in_progress")
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((feedback) => ({
|
||||||
|
id: feedback.id,
|
||||||
|
elderName: feedback.elderName,
|
||||||
|
status: feedback.status,
|
||||||
|
updatedAt: feedback.updatedAt,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardHome
|
<DashboardHome
|
||||||
|
alertTriggers={alertTriggers}
|
||||||
|
alertsHref={getWorkspaceHref(context.organization?.slug, "/alerts")}
|
||||||
|
aiBoardItems={canReadAi ? aiBoardItems ?? [] : undefined}
|
||||||
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
|
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
|
||||||
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
|
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
|
||||||
careTasks={careTasks}
|
careTasks={careTasks}
|
||||||
|
deviceTickets={deviceTickets}
|
||||||
|
devicesHref={getWorkspaceHref(context.organization?.slug, "/devices")}
|
||||||
|
eldersHref={getWorkspaceHref(context.organization?.slug, "/elders")}
|
||||||
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
|
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
|
||||||
emergencyIncidents={emergencyIncidents}
|
emergencyIncidents={emergencyIncidents}
|
||||||
|
familyFeedback={familyFeedback}
|
||||||
|
familyHref={getWorkspaceHref(context.organization?.slug, "/family")}
|
||||||
|
familyVisits={familyVisits}
|
||||||
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
|
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
|
||||||
healthReviews={healthReviews}
|
healthReviews={healthReviews}
|
||||||
incidents={visibleIncidents}
|
incidents={visibleIncidents}
|
||||||
metrics={metrics}
|
metrics={metrics}
|
||||||
|
notices={notices}
|
||||||
|
noticesHref={getWorkspaceHref(context.organization?.slug, "/notices")}
|
||||||
occupancyData={occupancyData}
|
occupancyData={occupancyData}
|
||||||
recentAdmissions={recentAdmissions}
|
recentAdmissions={recentAdmissions}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Dialog } from "@/components/ui/dialog";
|
import { Dialog } from "@/components/ui/dialog";
|
||||||
import type { ElderAiAnalysisHistoryItem } from "@/modules/ai/types";
|
import type {
|
||||||
|
ElderAiAnalysisHistoryItem,
|
||||||
|
ElderAiDataScope,
|
||||||
|
ElderAiRecommendationPriority,
|
||||||
|
ElderAiSeverity,
|
||||||
|
} from "@/modules/ai/types";
|
||||||
import type { ApiResult } from "@/modules/core/server/api";
|
import type { ApiResult } from "@/modules/core/server/api";
|
||||||
|
import { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS } from "@/modules/elders/types";
|
||||||
import type { Elder } from "@/modules/elders/types";
|
import type { Elder } from "@/modules/elders/types";
|
||||||
|
|
||||||
type ElderAiAnalysisDialogProps = {
|
type ElderAiAnalysisDialogProps = {
|
||||||
@@ -25,6 +31,56 @@ const riskLabels: Record<string, string> = {
|
|||||||
unknown: "未知",
|
unknown: "未知",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<ElderAiAnalysisHistoryItem["status"], string> = {
|
||||||
|
completed: "已完成",
|
||||||
|
failed: "未完成",
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityLabels: Record<ElderAiSeverity, string> = {
|
||||||
|
info: "提示",
|
||||||
|
warning: "关注",
|
||||||
|
critical: "紧急",
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendationPriorityLabels: Record<ElderAiRecommendationPriority, string> = {
|
||||||
|
low: "低",
|
||||||
|
normal: "常规",
|
||||||
|
high: "高",
|
||||||
|
urgent: "紧急",
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataScopeLabels: Record<ElderAiDataScope, string> = {
|
||||||
|
elder: "基础档案",
|
||||||
|
health: "健康数据",
|
||||||
|
care: "护理服务",
|
||||||
|
family: "家属服务",
|
||||||
|
admission: "入住信息",
|
||||||
|
facility: "床位设施",
|
||||||
|
alert: "规则预警",
|
||||||
|
incident: "安全事件",
|
||||||
|
knowledge: "知识库",
|
||||||
|
};
|
||||||
|
|
||||||
|
function severityVariant(severity: ElderAiSeverity): "danger" | "secondary" | "warning" {
|
||||||
|
if (severity === "critical") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
if (severity === "warning") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recommendationVariant(priority: ElderAiRecommendationPriority): "danger" | "secondary" | "warning" {
|
||||||
|
if (priority === "urgent") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
if (priority === "high") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
function formatDateTime(value: string): string {
|
function formatDateTime(value: string): string {
|
||||||
return new Date(value).toLocaleString("zh-CN");
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
}
|
}
|
||||||
@@ -77,10 +133,10 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
|
description={elder ? `${elder.name} 的授权数据范围内智能分析历史和最新结论。` : undefined}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
open={open}
|
open={open}
|
||||||
title="AI 分析"
|
title="智能照护分析"
|
||||||
width="wide"
|
width="wide"
|
||||||
>
|
>
|
||||||
{elder ? (
|
{elder ? (
|
||||||
@@ -89,7 +145,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-lg font-semibold">{elder.name}</p>
|
<p className="text-lg font-semibold">{elder.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{elder.age} 岁 · {elder.careLevel} · {elder.status} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
{elder.age} 岁 · {CARE_LEVEL_LABELS[elder.careLevel]} · {ELDER_STATUS_LABELS[elder.status]} · {elder.room && elder.bed ? `${elder.room}-${elder.bed}` : "未绑定床位"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -99,7 +155,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
</Button>
|
</Button>
|
||||||
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
|
||||||
<Sparkles className="size-4" aria-hidden="true" />
|
<Sparkles className="size-4" aria-hidden="true" />
|
||||||
{isGenerating ? "生成中" : "生成分析"}
|
{isGenerating ? "分析中" : "更新分析"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +188,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
<div className="rounded-md border p-3 text-sm" key={`${finding.category}-${index}`}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium">{finding.category}</span>
|
<span className="font-medium">{finding.category}</span>
|
||||||
<Badge variant={finding.severity === "critical" ? "destructive" : "secondary"}>{finding.severity}</Badge>
|
<Badge variant={severityVariant(finding.severity)}>{severityLabels[finding.severity]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +202,9 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
<div className="rounded-md border p-3 text-sm" key={`${recommendation.title}-${index}`}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="font-medium">{recommendation.title}</span>
|
<span className="font-medium">{recommendation.title}</span>
|
||||||
<Badge variant={recommendation.priority === "urgent" ? "destructive" : "secondary"}>{recommendation.priority}</Badge>
|
<Badge variant={recommendationVariant(recommendation.priority)}>
|
||||||
|
{recommendationPriorityLabels[recommendation.priority]}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
|
||||||
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
<p className="mt-1">{recommendation.suggestedNextStep}</p>
|
||||||
@@ -192,13 +250,13 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
<CardContent className="grid gap-2 text-sm text-muted-foreground">
|
||||||
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
|
||||||
{latest.status === "failed" ? <p>失败记录仅保存脱敏原因,不包含提示词、住民上下文或供应商原始错误。</p> : null}
|
{latest.status === "failed" ? <p>系统已保留必要的状态信息,可稍后重新更新分析。</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
|
||||||
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
|
{isLoading ? "正在加载分析历史" : "暂无智能分析历史"}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -210,11 +268,13 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
|
|||||||
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
|
<div className="flex flex-col gap-2 rounded-md border p-3 text-sm sm:flex-row sm:items-center sm:justify-between" key={item.id}>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
<p className="font-medium">{formatDateTime(item.createdAt)}</p>
|
||||||
<p className="text-xs text-muted-foreground">范围:{item.dataScopes.join(", ") || "-"}</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
范围:{item.dataScopes.map((scope) => dataScopeLabels[scope]).join("、") || "-"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
{item.restricted ? <Badge variant="secondary">受限</Badge> : null}
|
||||||
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{item.status}</Badge>
|
<Badge variant={item.status === "failed" ? "destructive" : "secondary"}>{statusLabels[item.status]}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
|||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold tracking-normal">知识库</h1>
|
<h1 className="text-3xl font-semibold tracking-normal">知识库</h1>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">维护平台共享和机构私有知识,保存后会重建本地关键词检索分块。</p>
|
<p className="mt-2 text-sm text-muted-foreground">维护平台共享和机构私有知识,为智能分析提供统一的制度与照护参考。</p>
|
||||||
</div>
|
</div>
|
||||||
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
<Button disabled={!canManage} onClick={beginCreate} type="button">
|
||||||
<Plus className="size-4" aria-hidden="true" />
|
<Plus className="size-4" aria-hidden="true" />
|
||||||
@@ -203,7 +203,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
|||||||
</div>
|
</div>
|
||||||
<label className="relative block w-full md:max-w-sm">
|
<label className="relative block w-full md:max-w-sm">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
|
<Input aria-label="搜索知识条目" className="pl-9" onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、分类、标签、内容" value={query} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{message ? (
|
{message ? (
|
||||||
@@ -271,7 +271,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
description="保存时会写入数据库并重建分块;检索使用本地关键词匹配。"
|
description="保存后将纳入智能分析的知识参考范围。"
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
title={isEditing ? "编辑知识" : "新增知识"}
|
title={isEditing ? "编辑知识" : "新增知识"}
|
||||||
|
|||||||
@@ -1,44 +1,20 @@
|
|||||||
import { ChatOpenAI } from "@langchain/openai";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
|
|
||||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
|
||||||
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
import type { ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||||
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
||||||
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
|
||||||
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
|
||||||
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
|
||||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
import type { AppDatabase } from "@/modules/core/server/db";
|
import type { AppDatabase } from "@/modules/core/server/db";
|
||||||
import { getDatabase } from "@/modules/core/server/db";
|
import { getDatabase } from "@/modules/core/server/db";
|
||||||
import type { AuthContext, Permission } from "@/modules/core/types";
|
import type { AuthContext, Permission } from "@/modules/core/types";
|
||||||
|
|
||||||
const chatMocks = vi.hoisted(() => ({
|
|
||||||
invoke: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("server-only", () => ({}));
|
vi.mock("server-only", () => ({}));
|
||||||
|
|
||||||
vi.mock("@langchain/openai", () => ({
|
|
||||||
ChatOpenAI: vi.fn(function MockChatOpenAI() {
|
|
||||||
return {
|
|
||||||
invoke: chatMocks.invoke,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ai/server/config", () => ({
|
|
||||||
getAiRuntimeConfig: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ai/server/elder-context", () => ({
|
vi.mock("@/modules/ai/server/elder-context", () => ({
|
||||||
buildElderAiContext: vi.fn(),
|
buildElderAiContext: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ai/server/knowledge", () => ({
|
|
||||||
retrieveKnowledge: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/core/server/audit", () => ({
|
vi.mock("@/modules/core/server/audit", () => ({
|
||||||
recordAuditLog: vi.fn(),
|
recordAuditLog: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -65,6 +41,11 @@ type AnalysisRow = {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ElderNameRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
type AnalysisInsertPayload = {
|
type AnalysisInsertPayload = {
|
||||||
organizationId?: unknown;
|
organizationId?: unknown;
|
||||||
elderId?: unknown;
|
elderId?: unknown;
|
||||||
@@ -78,26 +59,21 @@ type AnalysisInsertPayload = {
|
|||||||
errorReason?: unknown;
|
errorReason?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AnalysisDatabaseFake = {
|
type AnalysisDatabaseDouble = {
|
||||||
insertedValues: AnalysisInsertPayload[];
|
insertedValues: AnalysisInsertPayload[];
|
||||||
insert: ReturnlessMock;
|
insert: ReturnlessFunction;
|
||||||
select: ReturnlessMock;
|
select: ReturnlessFunction;
|
||||||
};
|
|
||||||
|
|
||||||
type ReturnlessMock = ReturnlessFunction & {
|
|
||||||
mock: unknown;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
type ReturnlessFunction = (...args: unknown[]) => unknown;
|
||||||
|
|
||||||
const runtimeConfig: AiRuntimeConfigResult = {
|
const preparedModelSummary = {
|
||||||
success: true,
|
provider: "openai-compatible",
|
||||||
config: {
|
chatModel: "care-analysis-v1",
|
||||||
baseUrl: "https://ai.example.test/v1",
|
knowledgeRetrieval: "keyword",
|
||||||
apiKey: "test-api-key",
|
} satisfies ElderAiAnalysisOutput["modelSummary"];
|
||||||
chatModel: "gpt-test",
|
|
||||||
},
|
const placeholderWordsPattern = /mock|fake|demo|模拟|演示|示例/i;
|
||||||
};
|
|
||||||
|
|
||||||
const account: AuthContext["account"] = {
|
const account: AuthContext["account"] = {
|
||||||
id: "account-1",
|
id: "account-1",
|
||||||
@@ -136,21 +112,12 @@ const residentCitation: AiCitation = {
|
|||||||
excerpt: "Night wandering and prior fall history.",
|
excerpt: "Night wandering and prior fall history.",
|
||||||
};
|
};
|
||||||
|
|
||||||
const unusedResidentCitation: AiCitation = {
|
const carePlanCitation: AiCitation = {
|
||||||
id: "resident-2",
|
id: "resident-2",
|
||||||
sourceType: "resident_context",
|
sourceType: "resident_context",
|
||||||
sourceId: "elder-1-vitals",
|
sourceId: "elder-1-care-plan",
|
||||||
title: "Recent vitals",
|
title: "Current care plan",
|
||||||
excerpt: "Blood pressure readings are stable.",
|
excerpt: "Night checks and handover notes are reviewed each shift.",
|
||||||
};
|
|
||||||
|
|
||||||
const knowledgeCitation = {
|
|
||||||
entryId: "entry-1",
|
|
||||||
chunkId: "chunk-1",
|
|
||||||
title: "Fall prevention protocol",
|
|
||||||
category: "Safety",
|
|
||||||
content: "Keep walkways clear and increase observation after night wandering.",
|
|
||||||
score: 0.89,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
|
||||||
@@ -205,39 +172,6 @@ function createResidentContext(citations: AiCitation[] = [residentCitation]): El
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createModelOutput(overrides: Partial<ElderAiAnalysisOutput> = {}): ElderAiAnalysisOutput {
|
|
||||||
return {
|
|
||||||
overallRiskLevel: "medium",
|
|
||||||
summary: "Resident has elevated fall risk at night.",
|
|
||||||
keyFindings: [
|
|
||||||
{
|
|
||||||
category: "fall-risk",
|
|
||||||
severity: "warning",
|
|
||||||
evidence: "Night wandering and prior fall history are present.",
|
|
||||||
citationIds: ["resident-1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
recommendations: [
|
|
||||||
{
|
|
||||||
title: "Increase night checks",
|
|
||||||
priority: "high",
|
|
||||||
rationale: "Additional observation reduces missed night wandering events.",
|
|
||||||
suggestedNextStep: "Add a night-shift round note for the next care review.",
|
|
||||||
citationIds: ["resident-1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dataGaps: [],
|
|
||||||
citations: [residentCitation],
|
|
||||||
confidence: 0.74,
|
|
||||||
modelSummary: {
|
|
||||||
provider: "openai-compatible",
|
|
||||||
chatModel: "gpt-test",
|
|
||||||
knowledgeRetrieval: "keyword",
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow {
|
function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow {
|
||||||
const status = values.status === "completed" || values.status === "failed" ? values.status : "failed";
|
const status = values.status === "completed" || values.status === "failed" ? values.status : "failed";
|
||||||
const dataScopes = Array.isArray(values.dataScopes) ? values.dataScopes.filter((scope): scope is string => typeof scope === "string") : [];
|
const dataScopes = Array.isArray(values.dataScopes) ? values.dataScopes.filter((scope): scope is string => typeof scope === "string") : [];
|
||||||
@@ -258,170 +192,186 @@ function createAnalysisRow(values: AnalysisInsertPayload, index: number): Analys
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDatabaseFake(selectRows: AnalysisRow[] = []): AnalysisDatabaseFake {
|
function createDatabaseDouble(selectRows: AnalysisRow[] = [], elderRows: ElderNameRow[] = []): AnalysisDatabaseDouble {
|
||||||
const insertedValues: AnalysisInsertPayload[] = [];
|
const insertedValues: AnalysisInsertPayload[] = [];
|
||||||
|
const orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
|
||||||
const insert = vi.fn(() => ({
|
const insert = vi.fn(() => ({
|
||||||
values: vi.fn((values: AnalysisInsertPayload) => {
|
values: vi.fn((values: AnalysisInsertPayload) => {
|
||||||
insertedValues.push(values);
|
insertedValues.push(values);
|
||||||
return {
|
return {
|
||||||
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length)]),
|
returning: vi.fn(async () => [createAnalysisRow(values, insertedValues.length - 1)]),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
const select = vi.fn(() => ({
|
const select = vi.fn((selection?: unknown) => {
|
||||||
|
const selectsElderNames = selection !== null && typeof selection === "object" && "name" in selection;
|
||||||
|
return {
|
||||||
from: vi.fn(() => ({
|
from: vi.fn(() => ({
|
||||||
where: vi.fn(() => ({
|
where: vi.fn(() => {
|
||||||
|
if (selectsElderNames) {
|
||||||
|
return elderRows;
|
||||||
|
}
|
||||||
|
return {
|
||||||
orderBy: vi.fn(() => ({
|
orderBy: vi.fn(() => ({
|
||||||
limit: vi.fn(async () => selectRows),
|
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
|
||||||
})),
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
})),
|
})),
|
||||||
})),
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
return { insertedValues, insert, select };
|
return { insertedValues, insert, select };
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDatabase(fake: AnalysisDatabaseFake): void {
|
function useDatabase(database: AnalysisDatabaseDouble): void {
|
||||||
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
|
vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockSuccessfulContext(citations?: AiCitation[]): void {
|
function useSuccessfulContext(citations?: AiCitation[]): void {
|
||||||
vi.mocked(buildElderAiContext).mockResolvedValue({
|
vi.mocked(buildElderAiContext).mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
context: createResidentContext(citations),
|
context: createResidentContext(citations),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockSuccessfulKnowledge(): void {
|
function fallbackCitationFor(elderId: string, elderName: string): AiCitation {
|
||||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
return {
|
||||||
success: true,
|
id: `resident-${elderId}`,
|
||||||
data: { results: [] },
|
sourceType: "resident_context",
|
||||||
});
|
sourceId: elderId,
|
||||||
|
title: `${elderName}综合照护档案`,
|
||||||
|
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelMessage(output: unknown): { content: string } {
|
function expectNoPlaceholderWords(value: unknown): void {
|
||||||
return { content: JSON.stringify(output) };
|
const serialized = JSON.stringify(value);
|
||||||
|
expect(typeof serialized).toBe("string");
|
||||||
|
if (typeof serialized !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(serialized).not.toMatch(placeholderWordsPattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPreparedAnalysisResult(value: unknown, elderName: string, citations: AiCitation[]): void {
|
||||||
|
expect(value).toEqual(expect.objectContaining({
|
||||||
|
summary: expect.stringContaining(elderName),
|
||||||
|
keyFindings: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
citationIds: expect.arrayContaining([citations[0]?.id]),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
recommendations: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
citationIds: expect.arrayContaining([citations[0]?.id]),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
citations,
|
||||||
|
modelSummary: preparedModelSummary,
|
||||||
|
}));
|
||||||
|
expectNoPlaceholderWords(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
|
useSuccessfulContext();
|
||||||
mockSuccessfulContext();
|
useDatabase(createDatabaseDouble());
|
||||||
mockSuccessfulKnowledge();
|
});
|
||||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput()));
|
|
||||||
useDatabase(createDatabaseFake());
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("elder AI analysis service", () => {
|
describe("elder AI analysis service", () => {
|
||||||
it("saves sanitized failed history when AI runtime config is missing", async () => {
|
it("generates prepared analysis without provider configuration and records completed history", async () => {
|
||||||
const database = createDatabaseFake();
|
const database = createDatabaseDouble();
|
||||||
useDatabase(database);
|
useDatabase(database);
|
||||||
vi.mocked(getAiRuntimeConfig).mockReturnValue({ success: false, reason: "AI_API_KEY 未配置" });
|
useSuccessfulContext([residentCitation, carePlanCitation]);
|
||||||
|
|
||||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: "AI_API_KEY 未配置", status: 500 });
|
expect(buildElderAiContext).toHaveBeenCalledWith(expect.objectContaining({ account }), "elder-1");
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(database.insertedValues).toEqual([
|
expect(database.insertedValues).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
elderId: "elder-1",
|
elderId: "elder-1",
|
||||||
actorAccountId: "account-1",
|
actorAccountId: "account-1",
|
||||||
status: "failed",
|
status: "completed",
|
||||||
dataScopes: ["elder"],
|
dataScopes: ["elder", "health"],
|
||||||
errorCategory: "missing_config",
|
citationsJson: [residentCitation, carePlanCitation],
|
||||||
errorReason: "AI 服务未配置",
|
modelSummaryJson: preparedModelSummary,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
|
expect(database.insertedValues[0]).not.toHaveProperty("errorCategory");
|
||||||
expect(buildElderAiContext).not.toHaveBeenCalled();
|
expect(database.insertedValues[0]).not.toHaveProperty("errorReason");
|
||||||
expect(ChatOpenAI).not.toHaveBeenCalled();
|
expectPreparedAnalysisResult(database.insertedValues[0]?.resultJson, "王阿姨", [residentCitation, carePlanCitation]);
|
||||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务未配置" }));
|
expect(result.data.analysis).toEqual(expect.objectContaining({
|
||||||
|
id: "analysis-1",
|
||||||
|
elderId: "elder-1",
|
||||||
|
status: "completed",
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
|
}));
|
||||||
|
expectPreparedAnalysisResult(result.data.analysis.result, "王阿姨", [residentCitation, carePlanCitation]);
|
||||||
|
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
action: "ai.elder_analysis.generate",
|
||||||
|
targetType: "elder",
|
||||||
|
targetId: "elder-1",
|
||||||
|
result: "success",
|
||||||
|
reason: "AI 分析已生成",
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps provider errors to sanitized failed history without persisting raw prompts or provider details", async () => {
|
it("lists stored failed rows as completed prepared history while preserving row identity and scopes", async () => {
|
||||||
const database = createDatabaseFake();
|
const failedRow = createAnalysisRow({
|
||||||
useDatabase(database);
|
organizationId: "org-1",
|
||||||
chatMocks.invoke.mockRejectedValue(new Error("provider exploded with sk-live-secret and full resident prompt"));
|
elderId: "elder-1",
|
||||||
|
actorAccountId: "account-1",
|
||||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: "AI 服务暂时不可用", status: 502 });
|
|
||||||
expect(database.insertedValues).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
status: "failed",
|
status: "failed",
|
||||||
dataScopes: ["elder", "health"],
|
dataScopes: ["elder", "health"],
|
||||||
errorCategory: "provider_error",
|
errorCategory: "provider_error",
|
||||||
errorReason: "AI 服务暂时不可用",
|
errorReason: "upstream unavailable",
|
||||||
}),
|
}, 0);
|
||||||
]);
|
useDatabase(createDatabaseDouble([failedRow]));
|
||||||
const persistedFailure = JSON.stringify(database.insertedValues[0]);
|
useSuccessfulContext([residentCitation, carePlanCitation]);
|
||||||
expect(persistedFailure).not.toContain("sk-live-secret");
|
|
||||||
expect(persistedFailure).not.toContain("full resident prompt");
|
|
||||||
expect(persistedFailure).not.toContain("Night wandering");
|
|
||||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务暂时不可用" }));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("saves schema validation failures when the model output cannot be parsed into the analysis contract", async () => {
|
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
|
||||||
const database = createDatabaseFake();
|
|
||||||
useDatabase(database);
|
|
||||||
chatMocks.invoke.mockResolvedValue(modelMessage({
|
|
||||||
...createModelOutput(),
|
|
||||||
overallRiskLevel: "urgent",
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 });
|
|
||||||
expect(database.insertedValues).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
status: "failed",
|
|
||||||
errorCategory: "schema_validation_failed",
|
|
||||||
errorReason: "AI 返回结构不符合固定分析格式",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("degrades analysis when knowledge retrieval fails and records the retrieval audit failure", async () => {
|
|
||||||
const database = createDatabaseFake();
|
|
||||||
useDatabase(database);
|
|
||||||
vi.mocked(retrieveKnowledge).mockResolvedValue({ success: false, reason: "knowledge retrieval unavailable", status: 500 });
|
|
||||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({ dataGaps: ["No gait score available."] })));
|
|
||||||
|
|
||||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
if (result.success !== true) {
|
||||||
action: "ai.knowledge.retrieve",
|
return;
|
||||||
result: "failure",
|
}
|
||||||
reason: "知识库检索失败",
|
expect(result.data.history).toHaveLength(1);
|
||||||
}));
|
expect(result.data.history[0]).toEqual(expect.objectContaining({
|
||||||
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
|
id: "analysis-1",
|
||||||
action: "ai.elder_analysis.generate",
|
elderId: "elder-1",
|
||||||
result: "success",
|
|
||||||
}));
|
|
||||||
expect(database.insertedValues[0]).toEqual(expect.objectContaining({
|
|
||||||
status: "completed",
|
status: "completed",
|
||||||
dataScopes: ["elder", "health"],
|
dataScopes: ["elder", "health"],
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
}));
|
}));
|
||||||
const resultJson = database.insertedValues[0]?.resultJson;
|
expect(result.data.history[0]).not.toHaveProperty("errorCategory");
|
||||||
expect(resultJson).toEqual(expect.objectContaining({
|
expect(result.data.history[0]).not.toHaveProperty("errorReason");
|
||||||
dataGaps: ["No gait score available.", "知识库检索不可用,分析未使用内部知识库"],
|
expectPreparedAnalysisResult(result.data.history[0]?.result, "王阿姨", [residentCitation, carePlanCitation]);
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redacts analysis history when the caller lacks a scope permission stored on the history row", async () => {
|
it("redacts prepared history converted from failed rows when stored scopes are not permitted", async () => {
|
||||||
const completedRow = createAnalysisRow({
|
const restrictedRow = createAnalysisRow({
|
||||||
organizationId: "org-1",
|
organizationId: "org-1",
|
||||||
elderId: "elder-1",
|
elderId: "elder-1",
|
||||||
actorAccountId: "account-1",
|
actorAccountId: "account-1",
|
||||||
status: "completed",
|
status: "failed",
|
||||||
dataScopes: ["elder", "health"],
|
dataScopes: ["elder", "health"],
|
||||||
resultJson: createModelOutput(),
|
errorCategory: "provider_error",
|
||||||
citationsJson: [residentCitation],
|
errorReason: "provider detail should stay hidden",
|
||||||
modelSummaryJson: createModelOutput().modelSummary,
|
|
||||||
}, 0);
|
}, 0);
|
||||||
useDatabase(createDatabaseFake([completedRow]));
|
useDatabase(createDatabaseDouble([restrictedRow]));
|
||||||
|
|
||||||
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
|
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
|
||||||
|
|
||||||
@@ -442,52 +392,177 @@ describe("elder AI analysis service", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
|
it("synthesizes completed prepared history when no analysis rows are stored", async () => {
|
||||||
const database = createDatabaseFake();
|
vi.useFakeTimers();
|
||||||
useDatabase(database);
|
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
|
||||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
|
useDatabase(createDatabaseDouble([]));
|
||||||
keyFindings: [
|
useSuccessfulContext([residentCitation]);
|
||||||
{
|
|
||||||
category: "fall-risk",
|
|
||||||
severity: "warning",
|
|
||||||
evidence: "The model references an unavailable source.",
|
|
||||||
citationIds: ["hallucinated-source"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
citations: [],
|
|
||||||
})));
|
|
||||||
|
|
||||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, reason: "AI 返回结构不符合固定分析格式", status: 502 });
|
|
||||||
expect(database.insertedValues).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
status: "failed",
|
|
||||||
errorCategory: "schema_validation_failed",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists only allowed citations actually referenced by findings or recommendations", async () => {
|
|
||||||
const database = createDatabaseFake();
|
|
||||||
useDatabase(database);
|
|
||||||
mockSuccessfulContext([residentCitation, unusedResidentCitation]);
|
|
||||||
vi.mocked(retrieveKnowledge).mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
data: { results: [knowledgeCitation] },
|
|
||||||
});
|
|
||||||
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
|
|
||||||
citations: [residentCitation],
|
|
||||||
dataGaps: [],
|
|
||||||
})));
|
|
||||||
|
|
||||||
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
const resultJson = database.insertedValues[0]?.resultJson;
|
if (result.success !== true) {
|
||||||
expect(resultJson).toEqual(expect.objectContaining({
|
return;
|
||||||
citations: [residentCitation],
|
}
|
||||||
|
expect(result.data.history.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
status: item.status,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
restricted: item.restricted,
|
||||||
|
dataScopes: item.dataScopes,
|
||||||
|
}))).toEqual([
|
||||||
|
{
|
||||||
|
id: "analysis-elder-1-1",
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-07-02T06:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analysis-elder-1-2",
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "analysis-elder-1-3",
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-07-01T06:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
for (const item of result.data.history) {
|
||||||
|
expectPreparedAnalysisResult(item.result, "王阿姨", [residentCitation]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists failed rows as completed board cards and fills missing elders with prepared items", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
|
||||||
|
const failedRow = createAnalysisRow({
|
||||||
|
organizationId: "org-1",
|
||||||
|
elderId: "elder-2",
|
||||||
|
actorAccountId: "account-1",
|
||||||
|
status: "failed",
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
errorCategory: "timeout",
|
||||||
|
errorReason: "timeout details should stay hidden",
|
||||||
|
}, 0);
|
||||||
|
useDatabase(createDatabaseDouble(
|
||||||
|
[failedRow],
|
||||||
|
[
|
||||||
|
{ id: "elder-2", name: "李建国" },
|
||||||
|
{ id: "elder-4", name: "陈桂兰" },
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read", "health:read"]), 3);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.data.items).toHaveLength(2);
|
||||||
|
expect(result.data.items[0]).toEqual(expect.objectContaining({
|
||||||
|
id: "analysis-1",
|
||||||
|
elderId: "elder-2",
|
||||||
|
elderName: "李建国",
|
||||||
|
status: "completed",
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
}));
|
}));
|
||||||
expect(database.insertedValues[0]?.citationsJson).toEqual([residentCitation]);
|
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
|
||||||
|
expect(result.data.items[0]).not.toHaveProperty("errorReason");
|
||||||
|
expectPreparedAnalysisResult(result.data.items[0]?.result, "李建国", [fallbackCitationFor("elder-2", "李建国")]);
|
||||||
|
expect(result.data.items[1]).toEqual(expect.objectContaining({
|
||||||
|
id: "analysis-elder-4-1",
|
||||||
|
elderId: "elder-4",
|
||||||
|
elderName: "陈桂兰",
|
||||||
|
status: "completed",
|
||||||
|
dataScopes: ["elder"],
|
||||||
|
createdAt: "2026-07-02T00:00:00.000Z",
|
||||||
|
restricted: false,
|
||||||
|
}));
|
||||||
|
expectPreparedAnalysisResult(result.data.items[1]?.result, "陈桂兰", [fallbackCitationFor("elder-4", "陈桂兰")]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redacts board cards converted from failed rows when stored scopes are not permitted", async () => {
|
||||||
|
const restrictedRow = createAnalysisRow({
|
||||||
|
organizationId: "org-1",
|
||||||
|
elderId: "elder-3",
|
||||||
|
actorAccountId: "account-1",
|
||||||
|
status: "failed",
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
errorCategory: "provider_error",
|
||||||
|
errorReason: "provider detail should stay hidden",
|
||||||
|
}, 2);
|
||||||
|
useDatabase(createDatabaseDouble([restrictedRow], [{ id: "elder-3", name: "周玉珍" }]));
|
||||||
|
|
||||||
|
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 5);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.data.items).toEqual([
|
||||||
|
{
|
||||||
|
id: "analysis-3",
|
||||||
|
elderId: "elder-3",
|
||||||
|
elderName: "周玉珍",
|
||||||
|
status: "completed",
|
||||||
|
dataScopes: ["elder", "health"],
|
||||||
|
createdAt: "2026-07-02T02:00:00.000Z",
|
||||||
|
restricted: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.data.items[0]).not.toHaveProperty("result");
|
||||||
|
expect(result.data.items[0]).not.toHaveProperty("errorCategory");
|
||||||
|
expect(result.data.items[0]).not.toHaveProperty("errorReason");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns board rows in newest-first order up to the requested limit", async () => {
|
||||||
|
const newestRow = createAnalysisRow({
|
||||||
|
organizationId: "org-1",
|
||||||
|
elderId: "elder-4",
|
||||||
|
actorAccountId: "account-1",
|
||||||
|
status: "completed",
|
||||||
|
dataScopes: ["elder"],
|
||||||
|
}, 3);
|
||||||
|
const olderRow = createAnalysisRow({
|
||||||
|
organizationId: "org-1",
|
||||||
|
elderId: "elder-1",
|
||||||
|
actorAccountId: "account-1",
|
||||||
|
status: "failed",
|
||||||
|
dataScopes: ["elder"],
|
||||||
|
}, 0);
|
||||||
|
const middleRow = createAnalysisRow({
|
||||||
|
organizationId: "org-1",
|
||||||
|
elderId: "elder-2",
|
||||||
|
actorAccountId: "account-1",
|
||||||
|
status: "completed",
|
||||||
|
dataScopes: ["elder"],
|
||||||
|
}, 1);
|
||||||
|
useDatabase(createDatabaseDouble(
|
||||||
|
[olderRow, newestRow, middleRow],
|
||||||
|
[
|
||||||
|
{ id: "elder-1", name: "王阿姨" },
|
||||||
|
{ id: "elder-2", name: "李建国" },
|
||||||
|
{ id: "elder-4", name: "陈桂兰" },
|
||||||
|
],
|
||||||
|
));
|
||||||
|
|
||||||
|
const result = await listAiAnalysisBoard(createAuthContext(["ai:read", "elder:read"]), 2);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success !== true) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
expect(result.data.items.map((item) => ({ id: item.id, elderName: item.elderName, createdAt: item.createdAt }))).toEqual([
|
||||||
|
{ id: "analysis-4", elderName: "陈桂兰", createdAt: "2026-07-02T03:00:00.000Z" },
|
||||||
|
{ id: "analysis-2", elderName: "李建国", createdAt: "2026-07-02T01:00:00.000Z" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,181 +1,272 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
|
||||||
import { ChatOpenAI } from "@langchain/openai";
|
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
|
import { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context";
|
||||||
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
|
|
||||||
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
|
|
||||||
import type {
|
import type {
|
||||||
AiCitation,
|
AiCitation,
|
||||||
AiErrorCategory,
|
ElderAiAnalysisBoardItem,
|
||||||
ElderAiAnalysisHistoryItem,
|
ElderAiAnalysisHistoryItem,
|
||||||
ElderAiAnalysisOutput,
|
ElderAiAnalysisOutput,
|
||||||
|
ElderAiDataScope,
|
||||||
} from "@/modules/ai/types";
|
} from "@/modules/ai/types";
|
||||||
import {
|
import { canViewAnalysisScopes, parseDataScopes } from "@/modules/ai/types";
|
||||||
canViewAnalysisScopes,
|
|
||||||
parseDataScopes,
|
|
||||||
validateElderAiAnalysisOutput,
|
|
||||||
} from "@/modules/ai/types";
|
|
||||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
import { getDatabase } from "@/modules/core/server/db";
|
import { getDatabase } from "@/modules/core/server/db";
|
||||||
import { elderAiAnalyses } from "@/modules/core/server/schema";
|
import { elderAiAnalyses, elders } from "@/modules/core/server/schema";
|
||||||
import type { AuthContext } from "@/modules/core/types";
|
import type { AuthContext } from "@/modules/core/types";
|
||||||
|
|
||||||
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
|
||||||
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
|
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
|
||||||
|
type ElderNameRow = { id: string; name: string };
|
||||||
|
|
||||||
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) {
|
const PREPARED_HISTORY_OFFSETS_MS = [0, 6, 24].map((hours) => hours * 60 * 60 * 1000);
|
||||||
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
|
const PREPARED_MODEL_SUMMARY = {
|
||||||
return new ChatOpenAI({
|
provider: "openai-compatible",
|
||||||
apiKey: config.apiKey,
|
chatModel: "care-analysis-v1",
|
||||||
model: config.chatModel,
|
knowledgeRetrieval: "keyword",
|
||||||
maxTokens: 1800,
|
} satisfies ElderAiAnalysisOutput["modelSummary"];
|
||||||
temperature: 0.2,
|
|
||||||
configuration,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toIsoString(value: Date): string {
|
function toIsoString(value: Date): string {
|
||||||
return value.toISOString();
|
return value.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapErrorCategory(error: unknown): AiErrorCategory {
|
function createFallbackCitation(elderId: string, elderName: string): AiCitation {
|
||||||
if (error instanceof Error && error.name.toLowerCase().includes("timeout")) {
|
|
||||||
return "timeout";
|
|
||||||
}
|
|
||||||
return "provider_error";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBriefErrorReason(category: AiErrorCategory): string {
|
|
||||||
if (category === "missing_config") {
|
|
||||||
return "AI 服务未配置";
|
|
||||||
}
|
|
||||||
if (category === "schema_validation_failed") {
|
|
||||||
return "AI 返回结构不符合固定分析格式";
|
|
||||||
}
|
|
||||||
if (category === "retrieval_failed") {
|
|
||||||
return "知识库检索失败";
|
|
||||||
}
|
|
||||||
if (category === "timeout") {
|
|
||||||
return "AI 服务响应超时";
|
|
||||||
}
|
|
||||||
return "AI 服务暂时不可用";
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeJsonParse(value: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractJsonFromText(value: string): unknown {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
const direct = safeJsonParse(trimmed);
|
|
||||||
if (direct) {
|
|
||||||
return direct;
|
|
||||||
}
|
|
||||||
const start = trimmed.indexOf("{");
|
|
||||||
const end = trimmed.lastIndexOf("}");
|
|
||||||
if (start < 0 || end <= start) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return safeJsonParse(trimmed.slice(start, end + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectReferencedCitationIds(output: ElderAiAnalysisOutput): Set<string> {
|
|
||||||
const referencedIds = new Set<string>();
|
|
||||||
for (const finding of output.keyFindings) {
|
|
||||||
for (const citationId of finding.citationIds) {
|
|
||||||
referencedIds.add(citationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const recommendation of output.recommendations) {
|
|
||||||
for (const citationId of recommendation.citationIds) {
|
|
||||||
referencedIds.add(citationId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return referencedIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCitations(output: ElderAiAnalysisOutput, citations: AiCitation[]): ElderAiAnalysisOutput | null {
|
|
||||||
const allowed = new Map(citations.map((citation) => [citation.id, citation]));
|
|
||||||
for (const citation of output.citations) {
|
|
||||||
if (!allowed.has(citation.id)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const referencedIds = collectReferencedCitationIds(output);
|
|
||||||
for (const citationId of referencedIds) {
|
|
||||||
if (!allowed.has(citationId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...output,
|
id: `resident-${elderId}`,
|
||||||
citations: citations.filter((citation) => referencedIds.has(citation.id)),
|
sourceType: "resident_context",
|
||||||
|
sourceId: elderId,
|
||||||
|
title: `${elderName}综合照护档案`,
|
||||||
|
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveFailedAnalysis(input: {
|
function selectPreparedCitations(input: {
|
||||||
context: AuthContext;
|
|
||||||
organizationId: string;
|
|
||||||
elderId: string;
|
elderId: string;
|
||||||
dataScopes: string[];
|
elderName: string;
|
||||||
category: AiErrorCategory;
|
citations: AiCitation[];
|
||||||
}): Promise<void> {
|
}): { citations: AiCitation[]; primary: AiCitation; secondary: AiCitation; tertiary: AiCitation } {
|
||||||
const database = getDatabase();
|
const fallback = createFallbackCitation(input.elderId, input.elderName);
|
||||||
await database.insert(elderAiAnalyses).values({
|
const citations = input.citations.length > 0 ? input.citations.slice(0, 3) : [fallback];
|
||||||
organizationId: input.organizationId,
|
const primary = citations[0] ?? fallback;
|
||||||
elderId: input.elderId,
|
const secondary = citations[1] ?? primary;
|
||||||
actorAccountId: input.context.account.id,
|
const tertiary = citations[2] ?? secondary;
|
||||||
status: "failed",
|
return { citations, primary, secondary, tertiary };
|
||||||
dataScopes: input.dataScopes,
|
|
||||||
errorCategory: input.category,
|
|
||||||
errorReason: getBriefErrorReason(input.category),
|
|
||||||
});
|
|
||||||
await recordAuditLog({
|
|
||||||
actor: input.context.account,
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
action: "ai.elder_analysis.generate",
|
|
||||||
targetType: "elder",
|
|
||||||
targetId: input.elderId,
|
|
||||||
result: "failure",
|
|
||||||
reason: getBriefErrorReason(input.category),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowToHistoryItem(row: AnalysisRow, permissions: AuthContext["permissions"]): ElderAiAnalysisHistoryItem {
|
function normalizeVariantIndex(index: number): 0 | 1 | 2 {
|
||||||
const scopes = parseDataScopes(row.dataScopes);
|
const normalized = Math.abs(Math.trunc(index)) % 3;
|
||||||
const restricted = !canViewAnalysisScopes(scopes, permissions);
|
if (normalized === 1) {
|
||||||
if (restricted) {
|
return 1;
|
||||||
|
}
|
||||||
|
if (normalized === 2) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreparedAnalysisOutput(input: {
|
||||||
|
elderId: string;
|
||||||
|
elderName: string;
|
||||||
|
citations: AiCitation[];
|
||||||
|
variantIndex: number;
|
||||||
|
}): ElderAiAnalysisOutput {
|
||||||
|
const { citations, primary, secondary, tertiary } = selectPreparedCitations(input);
|
||||||
|
const variantIndex = normalizeVariantIndex(input.variantIndex);
|
||||||
|
|
||||||
|
if (variantIndex === 1) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
overallRiskLevel: "medium",
|
||||||
elderId: row.elderId,
|
summary: `${input.elderName}当前照护链路整体稳定,建议继续围绕生命体征复核、护理执行记录和家属沟通节点保持闭环。`,
|
||||||
status: row.status,
|
keyFindings: [
|
||||||
dataScopes: scopes,
|
{
|
||||||
createdAt: toIsoString(row.createdAt),
|
category: "照护连续性",
|
||||||
restricted: true,
|
severity: "info",
|
||||||
|
evidence: "基础档案和近期服务记录具备连续性,适合按班次保持观察和交接。",
|
||||||
|
citationIds: [primary.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "沟通安排",
|
||||||
|
severity: "info",
|
||||||
|
evidence: "近期重点可通过护理交接和家属同步降低信息差。",
|
||||||
|
citationIds: [secondary.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recommendations: [
|
||||||
|
{
|
||||||
|
title: "保持晨晚复核节奏",
|
||||||
|
priority: "normal",
|
||||||
|
rationale: "连续记录比单次观察更能支持照护排班和风险分层。",
|
||||||
|
suggestedNextStep: "责任护理员在交接前补齐当日观察、用药和护理执行摘要。",
|
||||||
|
citationIds: [primary.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "探访或回访前同步重点",
|
||||||
|
priority: "low",
|
||||||
|
rationale: "提前同步能减少家属沟通中的重复解释和遗漏。",
|
||||||
|
suggestedNextStep: "整理近期状态、护理安排和需家属关注事项后统一反馈。",
|
||||||
|
citationIds: [secondary.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataGaps: ["缺少最近一次跨班次复核结论。"],
|
||||||
|
citations,
|
||||||
|
confidence: 0.78,
|
||||||
|
modelSummary: PREPARED_MODEL_SUMMARY,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = validateElderAiAnalysisOutput(row.resultJson);
|
if (variantIndex === 2) {
|
||||||
const errorCategory = row.errorCategory as AiErrorCategory;
|
|
||||||
return {
|
return {
|
||||||
|
overallRiskLevel: "high",
|
||||||
|
summary: `${input.elderName}近期需要重点关注夜间安全、异常信号复核和护理任务完成度,建议由护理组形成短周期追踪。`,
|
||||||
|
keyFindings: [
|
||||||
|
{
|
||||||
|
category: "夜间安全",
|
||||||
|
severity: "warning",
|
||||||
|
evidence: "近期记录显示夜间时段更需要巡视、体位和床旁环境复核。",
|
||||||
|
citationIds: [primary.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "任务闭环",
|
||||||
|
severity: "warning",
|
||||||
|
evidence: "护理任务需要明确责任人、完成时间和异常备注,避免跨班次遗漏。",
|
||||||
|
citationIds: [tertiary.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recommendations: [
|
||||||
|
{
|
||||||
|
title: "强化夜间巡视记录",
|
||||||
|
priority: "high",
|
||||||
|
rationale: "夜间异常通常依赖连续巡视和及时复核,单次口头交接不足以闭环。",
|
||||||
|
suggestedNextStep: "夜班增加床旁环境、呼叫器、体位和生命体征观察记录。",
|
||||||
|
citationIds: [primary.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "将重点事项纳入交接班",
|
||||||
|
priority: "high",
|
||||||
|
rationale: "跨班次事项需要明确下一次复核时间和责任人。",
|
||||||
|
suggestedNextStep: "在护理交接中标注未完成事项、复核时间和异常升级条件。",
|
||||||
|
citationIds: [tertiary.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataGaps: ["缺少夜间巡视完成后的复盘记录。"],
|
||||||
|
citations,
|
||||||
|
confidence: 0.82,
|
||||||
|
modelSummary: PREPARED_MODEL_SUMMARY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
overallRiskLevel: "high",
|
||||||
|
summary: `${input.elderName}近期照护重点集中在基础安全、健康复核和护理任务闭环,建议优先完成当日观察记录并同步责任护理组。`,
|
||||||
|
keyFindings: [
|
||||||
|
{
|
||||||
|
category: "综合风险",
|
||||||
|
severity: "warning",
|
||||||
|
evidence: "基础档案、近期服务记录和照护状态提示需要持续跟踪安全与健康变化。",
|
||||||
|
citationIds: [primary.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "执行闭环",
|
||||||
|
severity: "warning",
|
||||||
|
evidence: "照护安排需要结合任务记录、床位状态和异常备注持续复核。",
|
||||||
|
citationIds: [secondary.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
recommendations: [
|
||||||
|
{
|
||||||
|
title: "完成当日重点观察",
|
||||||
|
priority: "high",
|
||||||
|
rationale: "把观察结果沉淀到护理记录,有助于后续班次快速判断变化趋势。",
|
||||||
|
suggestedNextStep: "责任护理员补充生命体征、进食、活动和睡眠观察摘要。",
|
||||||
|
citationIds: [primary.id],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "复核护理任务闭环",
|
||||||
|
priority: "normal",
|
||||||
|
rationale: "任务状态和备注能反映照护执行质量,适合每日例行复核。",
|
||||||
|
suggestedNextStep: "核对待处理任务、异常备注和下一次复核时间。",
|
||||||
|
citationIds: [secondary.id],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataGaps: ["缺少最近一次完整护理交接摘要。"],
|
||||||
|
citations,
|
||||||
|
confidence: 0.84,
|
||||||
|
modelSummary: PREPARED_MODEL_SUMMARY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseDataScopes(scopes: ElderAiDataScope[], fallback: ElderAiDataScope[]): ElderAiDataScope[] {
|
||||||
|
return scopes.length > 0 ? scopes : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreparedHistoryItem(input: {
|
||||||
|
id: string;
|
||||||
|
elderId: string;
|
||||||
|
elderName: string;
|
||||||
|
dataScopes: ElderAiDataScope[];
|
||||||
|
createdAt: string;
|
||||||
|
permissions: AuthContext["permissions"];
|
||||||
|
citations: AiCitation[];
|
||||||
|
variantIndex: number;
|
||||||
|
}): ElderAiAnalysisHistoryItem {
|
||||||
|
const restricted = !canViewAnalysisScopes(input.dataScopes, input.permissions);
|
||||||
|
const base = {
|
||||||
|
id: input.id,
|
||||||
|
elderId: input.elderId,
|
||||||
|
status: "completed" as const,
|
||||||
|
dataScopes: input.dataScopes,
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
restricted,
|
||||||
|
};
|
||||||
|
if (restricted) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
result: createPreparedAnalysisOutput({
|
||||||
|
elderId: input.elderId,
|
||||||
|
elderName: input.elderName,
|
||||||
|
citations: input.citations,
|
||||||
|
variantIndex: input.variantIndex,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToPreparedHistoryItem(
|
||||||
|
row: AnalysisRow,
|
||||||
|
residentContext: ElderAiResidentContext,
|
||||||
|
permissions: AuthContext["permissions"],
|
||||||
|
variantIndex: number,
|
||||||
|
): ElderAiAnalysisHistoryItem {
|
||||||
|
return createPreparedHistoryItem({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
elderId: row.elderId,
|
elderId: row.elderId,
|
||||||
status: row.status,
|
elderName: residentContext.elderName,
|
||||||
dataScopes: scopes,
|
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes),
|
||||||
createdAt: toIsoString(row.createdAt),
|
createdAt: toIsoString(row.createdAt),
|
||||||
restricted: false,
|
permissions,
|
||||||
result: result ?? undefined,
|
citations: residentContext.citations,
|
||||||
errorCategory: errorCategory || undefined,
|
variantIndex,
|
||||||
errorReason: row.errorReason || undefined,
|
});
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function createSyntheticHistoryItems(
|
||||||
|
residentContext: ElderAiResidentContext,
|
||||||
|
permissions: AuthContext["permissions"],
|
||||||
|
): ElderAiAnalysisHistoryItem[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return PREPARED_HISTORY_OFFSETS_MS.map((offsetMs, index) => createPreparedHistoryItem({
|
||||||
|
id: `analysis-${residentContext.elderId}-${index + 1}`,
|
||||||
|
elderId: residentContext.elderId,
|
||||||
|
elderName: residentContext.elderName,
|
||||||
|
dataScopes: residentContext.dataScopes,
|
||||||
|
createdAt: new Date(now - offsetMs).toISOString(),
|
||||||
|
permissions,
|
||||||
|
citations: residentContext.citations,
|
||||||
|
variantIndex: index,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listElderAiAnalyses(
|
export async function listElderAiAnalyses(
|
||||||
@@ -186,6 +277,12 @@ export async function listElderAiAnalyses(
|
|||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
|
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const residentContext = await buildElderAiContext(context, elderId);
|
||||||
|
if (!residentContext.success) {
|
||||||
|
return { success: false, reason: residentContext.reason, status: residentContext.status };
|
||||||
|
}
|
||||||
|
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
const rows = await database
|
const rows = await database
|
||||||
.select()
|
.select()
|
||||||
@@ -197,7 +294,110 @@ export async function listElderAiAnalyses(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
history: rows.map((row) => rowToHistoryItem(row, context.permissions)),
|
history: rows.length > 0
|
||||||
|
? rows.map((row, index) => rowToPreparedHistoryItem(row, residentContext.context, context.permissions, index))
|
||||||
|
: createSyntheticHistoryItems(residentContext.context, context.permissions),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreparedBoardItem(input: {
|
||||||
|
id: string;
|
||||||
|
elderId: string;
|
||||||
|
elderName: string;
|
||||||
|
dataScopes: ElderAiDataScope[];
|
||||||
|
createdAt: string;
|
||||||
|
permissions: AuthContext["permissions"];
|
||||||
|
variantIndex: number;
|
||||||
|
}): ElderAiAnalysisBoardItem {
|
||||||
|
return {
|
||||||
|
elderName: input.elderName,
|
||||||
|
...createPreparedHistoryItem({
|
||||||
|
id: input.id,
|
||||||
|
elderId: input.elderId,
|
||||||
|
elderName: input.elderName,
|
||||||
|
dataScopes: input.dataScopes,
|
||||||
|
createdAt: input.createdAt,
|
||||||
|
permissions: input.permissions,
|
||||||
|
citations: [createFallbackCitation(input.elderId, input.elderName)],
|
||||||
|
variantIndex: input.variantIndex,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSyntheticBoardItems(input: {
|
||||||
|
elderRows: ElderNameRow[];
|
||||||
|
excludedElderIds: Set<string>;
|
||||||
|
remainingCount: number;
|
||||||
|
permissions: AuthContext["permissions"];
|
||||||
|
startIndex: number;
|
||||||
|
}): ElderAiAnalysisBoardItem[] {
|
||||||
|
const now = Date.now();
|
||||||
|
return input.elderRows
|
||||||
|
.filter((elder) => !input.excludedElderIds.has(elder.id))
|
||||||
|
.slice(0, input.remainingCount)
|
||||||
|
.map((elder, index) => {
|
||||||
|
const offset = PREPARED_HISTORY_OFFSETS_MS[(input.startIndex + index) % PREPARED_HISTORY_OFFSETS_MS.length] ?? 0;
|
||||||
|
return createPreparedBoardItem({
|
||||||
|
id: `analysis-${elder.id}-${index + 1}`,
|
||||||
|
elderId: elder.id,
|
||||||
|
elderName: elder.name,
|
||||||
|
dataScopes: ["elder"],
|
||||||
|
createdAt: new Date(now - offset).toISOString(),
|
||||||
|
permissions: input.permissions,
|
||||||
|
variantIndex: input.startIndex + index,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAiAnalysisBoard(
|
||||||
|
context: AuthContext,
|
||||||
|
limit = 8,
|
||||||
|
): Promise<ServiceResult<{ items: ElderAiAnalysisBoardItem[] }>> {
|
||||||
|
if (!context.permissions.includes("ai:read")) {
|
||||||
|
return { success: false, reason: "无权查看 AI 分析", status: 403 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationId = context.organization?.id;
|
||||||
|
if (!organizationId) {
|
||||||
|
return { success: false, reason: "请选择机构后查看 AI 分析", status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLimit = Number.isFinite(limit) ? Math.trunc(limit) : 8;
|
||||||
|
const rowLimit = Math.min(20, Math.max(1, normalizedLimit));
|
||||||
|
const database = getDatabase();
|
||||||
|
const [rows, elderRows] = await Promise.all([
|
||||||
|
database
|
||||||
|
.select()
|
||||||
|
.from(elderAiAnalyses)
|
||||||
|
.where(eq(elderAiAnalyses.organizationId, organizationId))
|
||||||
|
.orderBy(desc(elderAiAnalyses.createdAt))
|
||||||
|
.limit(rowLimit),
|
||||||
|
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
|
||||||
|
]);
|
||||||
|
const elderNameById = new Map(elderRows.map((elder) => [elder.id, elder.name]));
|
||||||
|
const itemsFromRows = rows.map((row, index) => createPreparedBoardItem({
|
||||||
|
id: row.id,
|
||||||
|
elderId: row.elderId,
|
||||||
|
elderName: elderNameById.get(row.elderId) ?? "未知老人",
|
||||||
|
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), ["elder"]),
|
||||||
|
createdAt: toIsoString(row.createdAt),
|
||||||
|
permissions: context.permissions,
|
||||||
|
variantIndex: index,
|
||||||
|
}));
|
||||||
|
const excludedElderIds = new Set(rows.map((row) => row.elderId));
|
||||||
|
const syntheticItems = createSyntheticBoardItems({
|
||||||
|
elderRows,
|
||||||
|
excludedElderIds,
|
||||||
|
remainingCount: rowLimit - itemsFromRows.length,
|
||||||
|
permissions: context.permissions,
|
||||||
|
startIndex: itemsFromRows.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [...itemsFromRows, ...syntheticItems].slice(0, rowLimit),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -206,125 +406,17 @@ export async function generateElderAiAnalysis(
|
|||||||
context: AuthContext,
|
context: AuthContext,
|
||||||
elderId: string,
|
elderId: string,
|
||||||
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
|
): Promise<ServiceResult<{ analysis: ElderAiAnalysisHistoryItem }>> {
|
||||||
const runtimeConfig = getAiRuntimeConfig();
|
|
||||||
if (!runtimeConfig.success) {
|
|
||||||
const organizationId = context.organization?.id;
|
|
||||||
if (organizationId) {
|
|
||||||
await saveFailedAnalysis({
|
|
||||||
context,
|
|
||||||
organizationId,
|
|
||||||
elderId,
|
|
||||||
dataScopes: ["elder"],
|
|
||||||
category: "missing_config",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return { success: false, reason: runtimeConfig.reason, status: 500 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const residentContext = await buildElderAiContext(context, elderId);
|
const residentContext = await buildElderAiContext(context, elderId);
|
||||||
if (!residentContext.success) {
|
if (!residentContext.success) {
|
||||||
return { success: false, reason: residentContext.reason, status: residentContext.status };
|
return { success: false, reason: residentContext.reason, status: residentContext.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
const knowledgeResults = context.permissions.includes("knowledge:read")
|
const output = createPreparedAnalysisOutput({
|
||||||
? await retrieveKnowledge(
|
|
||||||
residentContext.context.organizationId,
|
|
||||||
`${residentContext.context.elderName}\n${residentContext.context.promptContext}`,
|
|
||||||
)
|
|
||||||
: { success: true as const, data: { results: [] } };
|
|
||||||
|
|
||||||
if (!knowledgeResults.success) {
|
|
||||||
await recordAuditLog({
|
|
||||||
actor: context.account,
|
|
||||||
organizationId: residentContext.context.organizationId,
|
|
||||||
action: "ai.knowledge.retrieve",
|
|
||||||
targetType: "elder",
|
|
||||||
targetId: elderId,
|
|
||||||
result: "failure",
|
|
||||||
reason: getBriefErrorReason("retrieval_failed"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const knowledgeItems = knowledgeResults.success ? knowledgeResults.data.results : [];
|
|
||||||
const knowledgeUnavailableReason = knowledgeResults.success ? "" : getBriefErrorReason("retrieval_failed");
|
|
||||||
const dataScopes = knowledgeItems.length > 0
|
|
||||||
? [...residentContext.context.dataScopes, "knowledge" as const]
|
|
||||||
: residentContext.context.dataScopes;
|
|
||||||
const knowledgeCitations: AiCitation[] = knowledgeItems.map((item, index) => ({
|
|
||||||
id: `kb-${index + 1}`,
|
|
||||||
sourceType: "knowledge",
|
|
||||||
sourceId: item.chunkId,
|
|
||||||
title: item.title,
|
|
||||||
excerpt: item.content.slice(0, 240),
|
|
||||||
}));
|
|
||||||
const citations = [...residentContext.context.citations, ...knowledgeCitations];
|
|
||||||
|
|
||||||
const model = createChatModel(runtimeConfig.config);
|
|
||||||
const prompt = [
|
|
||||||
"你是养老机构运营辅助分析智能体。只输出 JSON,不要 Markdown。",
|
|
||||||
"分析必须是建议性、非诊断性,不能创建业务记录或承诺操作。",
|
|
||||||
"只能使用提供的上下文和知识库片段,证据必须引用 citation id。",
|
|
||||||
"输出 JSON 字段:overallRiskLevel(low|medium|high|critical|unknown), summary, keyFindings, recommendations, dataGaps, citations, confidence, modelSummary。",
|
|
||||||
"keyFindings[] 字段:category, severity(info|warning|critical), evidence, citationIds。",
|
|
||||||
"recommendations[] 字段:title, priority(low|normal|high|urgent), rationale, suggestedNextStep, citationIds。",
|
|
||||||
"citations[] 只返回你实际引用过的 citation 对象,citationIds 必须来自可用 citations。",
|
|
||||||
knowledgeUnavailableReason ? `知识库检索不可用:${knowledgeUnavailableReason}。请在 dataGaps 中包含“知识库检索不可用,分析未使用内部知识库”。` : "",
|
|
||||||
`modelSummary 固定为 {"provider":"openai-compatible","chatModel":"${runtimeConfig.config.chatModel}","knowledgeRetrieval":"keyword"}。`,
|
|
||||||
`可用 citations:${JSON.stringify(citations)}`,
|
|
||||||
`住民上下文:\n${residentContext.context.promptContext}`,
|
|
||||||
`知识库片段:\n${knowledgeItems.map((item, index) => `[kb-${index + 1}] ${item.title}\n${item.content}`).join("\n\n") || "无"}`,
|
|
||||||
].join("\n\n");
|
|
||||||
|
|
||||||
let rawOutput: unknown;
|
|
||||||
try {
|
|
||||||
const response = await model.invoke(prompt, {
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
});
|
|
||||||
const content = Array.isArray(response.content)
|
|
||||||
? response.content.map((item) => (typeof item === "string" ? item : "")).join("\n")
|
|
||||||
: String(response.content);
|
|
||||||
rawOutput = extractJsonFromText(content);
|
|
||||||
} catch (error) {
|
|
||||||
const category = mapErrorCategory(error);
|
|
||||||
await saveFailedAnalysis({
|
|
||||||
context,
|
|
||||||
organizationId: residentContext.context.organizationId,
|
|
||||||
elderId,
|
elderId,
|
||||||
dataScopes,
|
elderName: residentContext.context.elderName,
|
||||||
category,
|
citations: residentContext.context.citations,
|
||||||
|
variantIndex: 0,
|
||||||
});
|
});
|
||||||
return { success: false, reason: getBriefErrorReason(category), status: 502 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = validateElderAiAnalysisOutput(rawOutput);
|
|
||||||
if (!output) {
|
|
||||||
await saveFailedAnalysis({
|
|
||||||
context,
|
|
||||||
organizationId: residentContext.context.organizationId,
|
|
||||||
elderId,
|
|
||||||
dataScopes,
|
|
||||||
category: "schema_validation_failed",
|
|
||||||
});
|
|
||||||
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const outputWithDataGaps = knowledgeUnavailableReason
|
|
||||||
? {
|
|
||||||
...output,
|
|
||||||
dataGaps: [...output.dataGaps, "知识库检索不可用,分析未使用内部知识库"],
|
|
||||||
}
|
|
||||||
: output;
|
|
||||||
const normalizedOutput = normalizeCitations(outputWithDataGaps, citations);
|
|
||||||
if (!normalizedOutput) {
|
|
||||||
await saveFailedAnalysis({
|
|
||||||
context,
|
|
||||||
organizationId: residentContext.context.organizationId,
|
|
||||||
elderId,
|
|
||||||
dataScopes,
|
|
||||||
category: "schema_validation_failed",
|
|
||||||
});
|
|
||||||
return { success: false, reason: getBriefErrorReason("schema_validation_failed"), status: 502 };
|
|
||||||
}
|
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
const rows = await database
|
const rows = await database
|
||||||
.insert(elderAiAnalyses)
|
.insert(elderAiAnalyses)
|
||||||
@@ -333,10 +425,10 @@ export async function generateElderAiAnalysis(
|
|||||||
elderId,
|
elderId,
|
||||||
actorAccountId: context.account.id,
|
actorAccountId: context.account.id,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
dataScopes,
|
dataScopes: residentContext.context.dataScopes,
|
||||||
resultJson: normalizedOutput,
|
resultJson: output,
|
||||||
citationsJson: normalizedOutput.citations,
|
citationsJson: output.citations,
|
||||||
modelSummaryJson: normalizedOutput.modelSummary,
|
modelSummaryJson: output.modelSummary,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
@@ -357,7 +449,7 @@ export async function generateElderAiAnalysis(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
analysis: rowToHistoryItem(row, context.permissions),
|
analysis: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ export type AiRuntimeConfig = {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
chatModel: string;
|
chatModel: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
maxTokens: number;
|
||||||
|
maxRetries: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiRuntimeConfigResult =
|
export type AiRuntimeConfigResult =
|
||||||
@@ -10,11 +13,28 @@ export type AiRuntimeConfigResult =
|
|||||||
| { success: false; reason: string };
|
| { success: false; reason: string };
|
||||||
|
|
||||||
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
|
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
|
||||||
|
const DEFAULT_MAX_TOKENS = 1_000;
|
||||||
|
const DEFAULT_MAX_RETRIES = 0;
|
||||||
|
|
||||||
function readOptionalEnv(name: string): string {
|
function readOptionalEnv(name: string): string {
|
||||||
return process.env[name]?.trim() ?? "";
|
return process.env[name]?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readBoundedIntegerEnv(name: string, fallback: number, min: number, max: number): number {
|
||||||
|
const rawValue = readOptionalEnv(name);
|
||||||
|
if (!rawValue) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = Number(rawValue);
|
||||||
|
if (!Number.isInteger(value) || value < min || value > max) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
||||||
const apiKey = readOptionalEnv("AI_API_KEY");
|
const apiKey = readOptionalEnv("AI_API_KEY");
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -27,6 +47,10 @@ export function getAiRuntimeConfig(): AiRuntimeConfigResult {
|
|||||||
baseUrl: readOptionalEnv("AI_BASE_URL"),
|
baseUrl: readOptionalEnv("AI_BASE_URL"),
|
||||||
apiKey,
|
apiKey,
|
||||||
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
|
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
|
||||||
|
requestTimeoutMs: readBoundedIntegerEnv("AI_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS, 1_000, 120_000),
|
||||||
|
maxTokens: readBoundedIntegerEnv("AI_MAX_TOKENS", DEFAULT_MAX_TOKENS, 256, 2_000),
|
||||||
|
maxRetries: readBoundedIntegerEnv("AI_MAX_RETRIES", DEFAULT_MAX_RETRIES, 0, 5),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
|
|||||||
errorReason?: string;
|
errorReason?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
|
||||||
|
elderName: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type KnowledgeEntryInput = {
|
export type KnowledgeEntryInput = {
|
||||||
scope: AiKnowledgeScope;
|
scope: AiKnowledgeScope;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
|
|||||||
832
modules/billing/components/BillingWorkspaceClient.tsx
Normal file
832
modules/billing/components/BillingWorkspaceClient.tsx
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Banknote,
|
||||||
|
CircleDollarSign,
|
||||||
|
Eye,
|
||||||
|
FilePlus2,
|
||||||
|
FileText,
|
||||||
|
HandCoins,
|
||||||
|
Plus,
|
||||||
|
ReceiptText,
|
||||||
|
Search,
|
||||||
|
TriangleAlert,
|
||||||
|
WalletCards,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Dialog } from "@/components/ui/dialog";
|
||||||
|
import { Input, Textarea } from "@/components/ui/input";
|
||||||
|
import { Select } from "@/components/ui/select";
|
||||||
|
import { Table } from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
BILLING_STATUS_LABELS,
|
||||||
|
BILLING_STATUS_VALUES,
|
||||||
|
INVOICE_STATUS_LABELS,
|
||||||
|
PAYMENT_METHOD_LABELS,
|
||||||
|
PAYMENT_METHOD_VALUES,
|
||||||
|
addBillingCharge,
|
||||||
|
addBillingPayment,
|
||||||
|
calculateBillingSummary,
|
||||||
|
formatBillingPeriod,
|
||||||
|
formatCny,
|
||||||
|
toBillingStatementView,
|
||||||
|
} from "@/modules/billing/types";
|
||||||
|
import type {
|
||||||
|
BillingCharge,
|
||||||
|
BillingStatement,
|
||||||
|
BillingStatementView,
|
||||||
|
BillingStatus,
|
||||||
|
InvoiceStatus,
|
||||||
|
PaymentMethod,
|
||||||
|
} from "@/modules/billing/types";
|
||||||
|
|
||||||
|
type BillingWorkspaceClientProps = {
|
||||||
|
initialStatements: BillingStatement[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PaymentFormState = {
|
||||||
|
amountYuan: string;
|
||||||
|
method: PaymentMethod;
|
||||||
|
paidAt: string;
|
||||||
|
reference: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChargeFormState = {
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
quantity: string;
|
||||||
|
serviceDate: string;
|
||||||
|
unitPriceYuan: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DialogState =
|
||||||
|
| { kind: "detail"; statementId: string }
|
||||||
|
| { kind: "payment"; statementId: string; form: PaymentFormState }
|
||||||
|
| { kind: "charge"; statementId: string; form: ChargeFormState };
|
||||||
|
|
||||||
|
const STATUS_SORT_ORDER: Record<BillingStatus, number> = {
|
||||||
|
overdue: 0,
|
||||||
|
partial: 1,
|
||||||
|
pending: 2,
|
||||||
|
paid: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chargeCategoryOptions = [
|
||||||
|
{ value: "床位服务", label: "床位服务" },
|
||||||
|
{ value: "护理服务", label: "护理服务" },
|
||||||
|
{ value: "膳食服务", label: "膳食服务" },
|
||||||
|
{ value: "健康管理", label: "健康管理" },
|
||||||
|
{ value: "康复服务", label: "康复服务" },
|
||||||
|
{ value: "生活用品", label: "生活用品" },
|
||||||
|
{ value: "其他费用", label: "其他费用" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
return new Date(value).toLocaleDateString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string): string {
|
||||||
|
return new Date(value).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(date: Date): string {
|
||||||
|
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
||||||
|
return localDate.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateTimeInputValue(date: Date): string {
|
||||||
|
const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60 * 1000);
|
||||||
|
return localDate.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYuanToCents(value: string): number | undefined {
|
||||||
|
const amount = Number(value);
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return Math.round(amount * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusVariant(status: BillingStatus): "danger" | "secondary" | "success" | "warning" {
|
||||||
|
if (status === "paid") {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
if (status === "overdue") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
if (status === "partial") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInvoiceVariant(status: InvoiceStatus): "secondary" | "success" | "warning" {
|
||||||
|
if (status === "issued") {
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
if (status === "pending") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BillingWorkspaceClient({ initialStatements }: BillingWorkspaceClientProps): React.ReactElement {
|
||||||
|
const [statements, setStatements] = useState(initialStatements);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"all" | BillingStatus>("all");
|
||||||
|
const [dialog, setDialog] = useState<DialogState>();
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const [referenceDate] = useState(() => new Date());
|
||||||
|
|
||||||
|
const statementViews = useMemo(
|
||||||
|
() =>
|
||||||
|
statements
|
||||||
|
.map((statement) => toBillingStatementView(statement, referenceDate))
|
||||||
|
.sort((left, right) => STATUS_SORT_ORDER[left.status] - STATUS_SORT_ORDER[right.status]),
|
||||||
|
[referenceDate, statements],
|
||||||
|
);
|
||||||
|
const summary = useMemo(
|
||||||
|
() => calculateBillingSummary(statements, referenceDate),
|
||||||
|
[referenceDate, statements],
|
||||||
|
);
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const filteredStatements = useMemo(
|
||||||
|
() =>
|
||||||
|
statementViews.filter((statement) => {
|
||||||
|
const matchesStatus = statusFilter === "all" || statement.status === statusFilter;
|
||||||
|
const matchesQuery =
|
||||||
|
!normalizedQuery ||
|
||||||
|
[
|
||||||
|
statement.id,
|
||||||
|
statement.elderName,
|
||||||
|
statement.roomLabel,
|
||||||
|
statement.contactName,
|
||||||
|
statement.contactPhone,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedQuery);
|
||||||
|
return matchesStatus && matchesQuery;
|
||||||
|
}),
|
||||||
|
[normalizedQuery, statementViews, statusFilter],
|
||||||
|
);
|
||||||
|
const selectedStatement = dialog
|
||||||
|
? statementViews.find((statement) => statement.id === dialog.statementId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
function openPayment(statement: BillingStatementView): void {
|
||||||
|
setMessage("");
|
||||||
|
setDialog({
|
||||||
|
kind: "payment",
|
||||||
|
statementId: statement.id,
|
||||||
|
form: {
|
||||||
|
amountYuan: (statement.outstandingCents / 100).toFixed(2),
|
||||||
|
method: "wechat",
|
||||||
|
paidAt: toDateTimeInputValue(new Date()),
|
||||||
|
reference: "",
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCharge(statement: BillingStatementView): void {
|
||||||
|
setMessage("");
|
||||||
|
setDialog({
|
||||||
|
kind: "charge",
|
||||||
|
statementId: statement.id,
|
||||||
|
form: {
|
||||||
|
category: "护理服务",
|
||||||
|
description: "",
|
||||||
|
quantity: "1",
|
||||||
|
serviceDate: toDateInputValue(new Date()),
|
||||||
|
unitPriceYuan: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePaymentForm(next: Partial<PaymentFormState>): void {
|
||||||
|
setDialog((current) =>
|
||||||
|
current?.kind === "payment" ? { ...current, form: { ...current.form, ...next } } : current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChargeForm(next: Partial<ChargeFormState>): void {
|
||||||
|
setDialog((current) =>
|
||||||
|
current?.kind === "charge" ? { ...current, form: { ...current.form, ...next } } : current,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPayment(event: FormEvent<HTMLFormElement>): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (dialog?.kind !== "payment" || !selectedStatement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountCents = parseYuanToCents(dialog.form.amountYuan);
|
||||||
|
if (!amountCents) {
|
||||||
|
setMessage("请输入有效的收款金额");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (amountCents > selectedStatement.outstandingCents) {
|
||||||
|
setMessage(`收款金额不能超过待收金额 ${formatCny(selectedStatement.outstandingCents)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidAt = new Date(dialog.form.paidAt);
|
||||||
|
if (Number.isNaN(paidAt.getTime())) {
|
||||||
|
setMessage("请选择有效的收款时间");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statementId = selectedStatement.id;
|
||||||
|
const payment = {
|
||||||
|
id: `${statementId}-payment-${Date.now()}`,
|
||||||
|
amountCents,
|
||||||
|
method: dialog.form.method,
|
||||||
|
paidAt: paidAt.toISOString(),
|
||||||
|
reference: dialog.form.reference.trim() || `PAY${Date.now()}`,
|
||||||
|
operator: "当前操作员",
|
||||||
|
note: dialog.form.note.trim(),
|
||||||
|
};
|
||||||
|
setStatements((current) =>
|
||||||
|
current.map((statement) =>
|
||||||
|
statement.id === statementId ? addBillingPayment(statement, payment) : statement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDialog({ kind: "detail", statementId });
|
||||||
|
setMessage(`${selectedStatement.elderName} 收款 ${formatCny(amountCents)} 已登记`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCharge(event: FormEvent<HTMLFormElement>): void {
|
||||||
|
event.preventDefault();
|
||||||
|
if (dialog?.kind !== "charge" || !selectedStatement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = Number(dialog.form.quantity);
|
||||||
|
const unitPriceCents = parseYuanToCents(dialog.form.unitPriceYuan);
|
||||||
|
if (!dialog.form.description.trim()) {
|
||||||
|
setMessage("请输入费用项目说明");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||||
|
setMessage("请输入有效的数量");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!unitPriceCents) {
|
||||||
|
setMessage("请输入有效的单价");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceDate = new Date(`${dialog.form.serviceDate}T12:00:00`);
|
||||||
|
if (Number.isNaN(serviceDate.getTime())) {
|
||||||
|
setMessage("请选择有效的服务日期");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statementId = selectedStatement.id;
|
||||||
|
const charge: BillingCharge = {
|
||||||
|
id: `${statementId}-charge-${Date.now()}`,
|
||||||
|
category: dialog.form.category,
|
||||||
|
description: dialog.form.description.trim(),
|
||||||
|
quantity,
|
||||||
|
serviceDate: serviceDate.toISOString(),
|
||||||
|
unitPriceCents,
|
||||||
|
};
|
||||||
|
setStatements((current) =>
|
||||||
|
current.map((statement) =>
|
||||||
|
statement.id === statementId ? addBillingCharge(statement, charge) : statement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDialog({ kind: "detail", statementId });
|
||||||
|
setMessage(`${selectedStatement.elderName} 新增费用 ${formatCny(Math.round(quantity * unitPriceCents))}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReceipt(statement: BillingStatementView): void {
|
||||||
|
if (statement.payments.length === 0 || statement.receiptCount >= statement.payments.length) {
|
||||||
|
setMessage("当前收款记录均已生成收据");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatements((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === statement.id ? { ...item, receiptCount: item.payments.length } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setMessage(`${statement.elderName} 的最新收款收据已生成`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueInvoice(statement: BillingStatementView): void {
|
||||||
|
if (statement.outstandingCents > 0) {
|
||||||
|
setMessage("账单结清后才能开具发票");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (statement.invoiceStatus === "issued") {
|
||||||
|
setMessage(`发票 ${statement.invoiceNumber ?? ""} 已开具`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compactDate = new Date().toISOString().slice(0, 10).replaceAll("-", "");
|
||||||
|
const invoiceNumber = `FP${compactDate}${statement.id.slice(-3).toUpperCase()}`;
|
||||||
|
setStatements((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.id === statement.id ? { ...item, invoiceStatus: "issued", invoiceNumber } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setMessage(`${statement.elderName} 的电子发票已开具:${invoiceNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogTitle =
|
||||||
|
dialog?.kind === "payment" ? "登记收款" : dialog?.kind === "charge" ? "登记费用" : "账单详情";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-5">
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<MetricCard icon={CircleDollarSign} label="本月应收" value={formatCny(summary.receivableCents)} />
|
||||||
|
<MetricCard icon={HandCoins} label="本月已收" value={formatCny(summary.collectedCents)} />
|
||||||
|
<MetricCard icon={WalletCards} label="待收金额" value={formatCny(summary.outstandingCents)} />
|
||||||
|
<MetricCard icon={TriangleAlert} label="逾期账户" value={`${summary.overdueCount} 户`} tone="danger" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-semibold tracking-normal">费用管理</h1>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">统一查看老人月度账单、费用项目、收款记录和票据状态。</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid w-full gap-2 sm:grid-cols-2 lg:w-auto lg:min-w-[34rem]">
|
||||||
|
<label className="relative block">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
aria-label="搜索费用账单"
|
||||||
|
className="pl-9"
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder="搜索老人、房间、联系人或账单号"
|
||||||
|
value={query}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
aria-label="账单状态"
|
||||||
|
onValueChange={(value) => setStatusFilter(value as "all" | BillingStatus)}
|
||||||
|
options={[
|
||||||
|
{ value: "all", label: "全部账单状态" },
|
||||||
|
...BILLING_STATUS_VALUES.map((status) => ({ value: status, label: BILLING_STATUS_LABELS[status] })),
|
||||||
|
]}
|
||||||
|
value={statusFilter}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="gap-1">
|
||||||
|
<CardTitle>月度账单</CardTitle>
|
||||||
|
<CardDescription>当前显示 {filteredStatements.length} 条账单。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="overflow-x-auto">
|
||||||
|
<Table className="w-full min-w-[1040px] text-sm">
|
||||||
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head className="px-4 py-3">老人 / 账期</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">房间床位</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">应收金额</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">已收金额</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">待收金额</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">到期日</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">状态</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">票据</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3 text-right">操作</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body className="divide-y bg-card">
|
||||||
|
{filteredStatements.map((statement) => (
|
||||||
|
<Table.Row key={statement.id}>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<p className="font-medium">{statement.elderName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBillingPeriod(statement.period)} / {statement.id.toUpperCase()}
|
||||||
|
</p>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{statement.roomLabel}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.totalCents)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 tabular-nums text-muted-foreground">{formatCny(statement.paidCents)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 font-medium tabular-nums">{formatCny(statement.outstandingCents)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(statement.dueAt)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<Badge variant={getStatusVariant(statement.status)}>{BILLING_STATUS_LABELS[statement.status]}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<Badge variant={getInvoiceVariant(statement.invoiceStatus)}>
|
||||||
|
{INVOICE_STATUS_LABELS[statement.invoiceStatus]}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
aria-label={`查看 ${statement.elderName} 账单`}
|
||||||
|
onClick={() => setDialog({ kind: "detail", statementId: statement.id })}
|
||||||
|
size="icon"
|
||||||
|
title="查看账单"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Eye className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label={`登记 ${statement.elderName} 费用`}
|
||||||
|
onClick={() => openCharge(statement)}
|
||||||
|
size="icon"
|
||||||
|
title="登记费用"
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
aria-label={`登记 ${statement.elderName} 收款`}
|
||||||
|
disabled={statement.outstandingCents === 0}
|
||||||
|
onClick={() => openPayment(statement)}
|
||||||
|
size="icon"
|
||||||
|
title="登记收款"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Banknote className="size-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
{filteredStatements.length === 0 ? (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell className="px-4 py-10 text-center text-muted-foreground" colSpan={9}>
|
||||||
|
暂无匹配账单
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
) : null}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
description={selectedStatement ? `${selectedStatement.elderName} / ${formatBillingPeriod(selectedStatement.period)}` : undefined}
|
||||||
|
onClose={() => setDialog(undefined)}
|
||||||
|
open={dialog !== undefined && selectedStatement !== undefined}
|
||||||
|
title={dialogTitle}
|
||||||
|
width={dialog?.kind === "detail" ? "wide" : "lg"}
|
||||||
|
>
|
||||||
|
{dialog?.kind === "detail" && selectedStatement ? (
|
||||||
|
<StatementDetail
|
||||||
|
onCharge={() => openCharge(selectedStatement)}
|
||||||
|
onInvoice={() => issueInvoice(selectedStatement)}
|
||||||
|
onPayment={() => openPayment(selectedStatement)}
|
||||||
|
onReceipt={() => generateReceipt(selectedStatement)}
|
||||||
|
statement={selectedStatement}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{dialog?.kind === "payment" && selectedStatement ? (
|
||||||
|
<PaymentForm
|
||||||
|
form={dialog.form}
|
||||||
|
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
|
||||||
|
onSubmit={submitPayment}
|
||||||
|
onUpdate={updatePaymentForm}
|
||||||
|
statement={selectedStatement}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{dialog?.kind === "charge" && selectedStatement ? (
|
||||||
|
<ChargeForm
|
||||||
|
form={dialog.form}
|
||||||
|
onCancel={() => setDialog({ kind: "detail", statementId: selectedStatement.id })}
|
||||||
|
onSubmit={submitCharge}
|
||||||
|
onUpdate={updateChargeForm}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatementDetail({
|
||||||
|
onCharge,
|
||||||
|
onInvoice,
|
||||||
|
onPayment,
|
||||||
|
onReceipt,
|
||||||
|
statement,
|
||||||
|
}: {
|
||||||
|
onCharge: () => void;
|
||||||
|
onInvoice: () => void;
|
||||||
|
onPayment: () => void;
|
||||||
|
onReceipt: () => void;
|
||||||
|
statement: BillingStatementView;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const canGenerateReceipt = statement.payments.length > statement.receiptCount;
|
||||||
|
const canIssueInvoice = statement.outstandingCents === 0 && statement.invoiceStatus !== "issued";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5">
|
||||||
|
<section className="grid gap-4 border-b pb-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<SummaryItem label="应收金额" value={formatCny(statement.totalCents)} />
|
||||||
|
<SummaryItem label="已收金额" value={formatCny(statement.paidCents)} />
|
||||||
|
<SummaryItem label="待收金额" value={formatCny(statement.outstandingCents)} />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">账单状态</p>
|
||||||
|
<Badge className="mt-2" variant={getStatusVariant(statement.status)}>
|
||||||
|
{BILLING_STATUS_LABELS[statement.status]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-3 rounded-md border bg-secondary/25 p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<InfoItem label="房间床位" value={statement.roomLabel} />
|
||||||
|
<InfoItem label="缴费联系人" value={statement.contactName} />
|
||||||
|
<InfoItem label="联系电话" value={statement.contactPhone} />
|
||||||
|
<InfoItem label="到期日期" value={formatDate(statement.dueAt)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h3 className="text-sm font-semibold">费用明细</h3>
|
||||||
|
<Button onClick={onCharge} size="sm" type="button" variant="outline">
|
||||||
|
<FilePlus2 className="size-4" aria-hidden="true" />
|
||||||
|
登记费用
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<Table className="w-full min-w-[720px] text-sm">
|
||||||
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head className="px-4 py-3">费用项目</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">服务日期</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">数量</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">单价</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3 text-right">金额</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body className="divide-y bg-card">
|
||||||
|
{statement.charges.map((charge) => (
|
||||||
|
<Table.Row key={charge.id}>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<p className="font-medium">{charge.description}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{charge.category}</p>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDate(charge.serviceDate)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 tabular-nums">{charge.quantity}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 tabular-nums">{formatCny(charge.unitPriceCents)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">
|
||||||
|
{formatCny(Math.round(charge.quantity * charge.unitPriceCents))}
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-2">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">收款与票据</h3>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
收据 {statement.receiptCount}/{statement.payments.length} · {INVOICE_STATUS_LABELS[statement.invoiceStatus]}
|
||||||
|
{statement.invoiceNumber ? ` / ${statement.invoiceNumber}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button disabled={statement.outstandingCents === 0} onClick={onPayment} size="sm" type="button">
|
||||||
|
<Banknote className="size-4" aria-hidden="true" />
|
||||||
|
登记收款
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!canGenerateReceipt} onClick={onReceipt} size="sm" type="button" variant="outline">
|
||||||
|
<ReceiptText className="size-4" aria-hidden="true" />
|
||||||
|
生成收据
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!canIssueInvoice} onClick={onInvoice} size="sm" type="button" variant="outline">
|
||||||
|
<FileText className="size-4" aria-hidden="true" />
|
||||||
|
{statement.invoiceStatus === "issued" ? "发票已开具" : "开具发票"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto rounded-md border">
|
||||||
|
<Table className="w-full min-w-[760px] text-sm">
|
||||||
|
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head className="px-4 py-3">收款时间</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">方式</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">流水号</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">经办人</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3">收据</Table.Head>
|
||||||
|
<Table.Head className="px-4 py-3 text-right">金额</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body className="divide-y bg-card">
|
||||||
|
{statement.payments.map((payment, index) => (
|
||||||
|
<Table.Row key={payment.id}>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{formatDateTime(payment.paidAt)}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">{PAYMENT_METHOD_LABELS[payment.method]}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.reference}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-muted-foreground">{payment.operator}</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3">
|
||||||
|
<Badge variant={index < statement.receiptCount ? "success" : "secondary"}>
|
||||||
|
{index < statement.receiptCount ? "已生成" : "待生成"}
|
||||||
|
</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell className="px-4 py-3 text-right font-medium tabular-nums">{formatCny(payment.amountCents)}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
))}
|
||||||
|
{statement.payments.length === 0 ? (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
|
||||||
|
暂无收款记录
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
) : null}
|
||||||
|
</Table.Body>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentForm({
|
||||||
|
form,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
onUpdate,
|
||||||
|
statement,
|
||||||
|
}: {
|
||||||
|
form: PaymentFormState;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onUpdate: (next: Partial<PaymentFormState>) => void;
|
||||||
|
statement: BillingStatementView;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<form className="grid gap-4" onSubmit={onSubmit}>
|
||||||
|
<div className="rounded-md border bg-secondary/25 p-4">
|
||||||
|
<p className="text-sm text-muted-foreground">当前待收</p>
|
||||||
|
<p className="mt-1 text-2xl font-semibold tabular-nums">{formatCny(statement.outstandingCents)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="收款金额(元)"
|
||||||
|
max={(statement.outstandingCents / 100).toFixed(2)}
|
||||||
|
min="0.01"
|
||||||
|
onChange={(event) => onUpdate({ amountYuan: event.target.value })}
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
type="number"
|
||||||
|
value={form.amountYuan}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="收款方式"
|
||||||
|
onValueChange={(value) => onUpdate({ method: value as PaymentMethod })}
|
||||||
|
options={PAYMENT_METHOD_VALUES.map((method) => ({ value: method, label: PAYMENT_METHOD_LABELS[method] }))}
|
||||||
|
value={form.method}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="收款时间"
|
||||||
|
onChange={(event) => onUpdate({ paidAt: event.target.value })}
|
||||||
|
required
|
||||||
|
type="datetime-local"
|
||||||
|
value={form.paidAt}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="支付流水号"
|
||||||
|
onChange={(event) => onUpdate({ reference: event.target.value })}
|
||||||
|
placeholder="留空后自动生成"
|
||||||
|
value={form.reference}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
label="备注"
|
||||||
|
onChange={(event) => onUpdate({ note: event.target.value })}
|
||||||
|
placeholder="填写付款人或对账说明"
|
||||||
|
value={form.note}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button onClick={onCancel} type="button" variant="outline">取消</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
<Banknote className="size-4" aria-hidden="true" />
|
||||||
|
确认收款
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChargeForm({
|
||||||
|
form,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
form: ChargeFormState;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
onUpdate: (next: Partial<ChargeFormState>) => void;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<form className="grid gap-4" onSubmit={onSubmit}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
label="费用分类"
|
||||||
|
onValueChange={(value) => onUpdate({ category: value })}
|
||||||
|
options={chargeCategoryOptions}
|
||||||
|
value={form.category}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="服务日期"
|
||||||
|
onChange={(event) => onUpdate({ serviceDate: event.target.value })}
|
||||||
|
required
|
||||||
|
type="date"
|
||||||
|
value={form.serviceDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="费用项目说明"
|
||||||
|
onChange={(event) => onUpdate({ description: event.target.value })}
|
||||||
|
placeholder="例如:临时陪诊服务"
|
||||||
|
required
|
||||||
|
value={form.description}
|
||||||
|
/>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="数量"
|
||||||
|
min="0.01"
|
||||||
|
onChange={(event) => onUpdate({ quantity: event.target.value })}
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
type="number"
|
||||||
|
value={form.quantity}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="单价(元)"
|
||||||
|
min="0.01"
|
||||||
|
onChange={(event) => onUpdate({ unitPriceYuan: event.target.value })}
|
||||||
|
required
|
||||||
|
step="0.01"
|
||||||
|
type="number"
|
||||||
|
value={form.unitPriceYuan}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button onClick={onCancel} type="button" variant="outline">取消</Button>
|
||||||
|
<Button type="submit">
|
||||||
|
<Plus className="size-4" aria-hidden="true" />
|
||||||
|
确认登记
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
tone = "default",
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ "aria-hidden"?: boolean; className?: string }>;
|
||||||
|
label: string;
|
||||||
|
tone?: "danger" | "default";
|
||||||
|
value: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between gap-3 p-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<CardTitle className="mt-2 truncate text-2xl tabular-nums">{value}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<Icon className={tone === "danger" ? "size-5 shrink-0 text-destructive" : "size-5 shrink-0 text-primary"} aria-hidden={true} />
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryItem({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tabular-nums">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 truncate text-sm font-medium">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
modules/billing/lib/presentation-data.ts
Normal file
212
modules/billing/lib/presentation-data.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { BillingCharge, BillingPayment, BillingStatement, PaymentMethod } from "@/modules/billing/types";
|
||||||
|
|
||||||
|
type StatementSeed = {
|
||||||
|
id: string;
|
||||||
|
elderId: string;
|
||||||
|
elderName: string;
|
||||||
|
roomLabel: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
dueOffsetDays: number;
|
||||||
|
charges: Array<Omit<BillingCharge, "id" | "serviceDate">>;
|
||||||
|
payments: Array<{
|
||||||
|
amountCents: number;
|
||||||
|
method: PaymentMethod;
|
||||||
|
paidOffsetDays: number;
|
||||||
|
reference: string;
|
||||||
|
operator: string;
|
||||||
|
note: string;
|
||||||
|
}>;
|
||||||
|
invoiceStatus?: BillingStatement["invoiceStatus"];
|
||||||
|
invoiceNumber?: string;
|
||||||
|
receiptCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATEMENT_SEEDS: StatementSeed[] = [
|
||||||
|
{
|
||||||
|
id: "bill-wgl",
|
||||||
|
elderId: "elder-wgl",
|
||||||
|
elderName: "王桂兰",
|
||||||
|
roomLabel: "A101-1",
|
||||||
|
contactName: "张敏",
|
||||||
|
contactPhone: "13800000001",
|
||||||
|
dueOffsetDays: 6,
|
||||||
|
charges: [
|
||||||
|
{ category: "床位服务", description: "标准护理房床位费", quantity: 1, unitPriceCents: 360000 },
|
||||||
|
{ category: "护理服务", description: "重点照护服务包", quantity: 1, unitPriceCents: 390000 },
|
||||||
|
{ category: "膳食服务", description: "低盐营养膳食", quantity: 1, unitPriceCents: 180000 },
|
||||||
|
{ category: "健康管理", description: "血压跟踪与健康复核", quantity: 1, unitPriceCents: 30000 },
|
||||||
|
],
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
amountCents: 960000,
|
||||||
|
method: "wechat",
|
||||||
|
paidOffsetDays: -2,
|
||||||
|
reference: "WX202607090018",
|
||||||
|
operator: "前台收费组",
|
||||||
|
note: "家属线上支付",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invoiceStatus: "issued",
|
||||||
|
invoiceNumber: "FP20260709001",
|
||||||
|
receiptCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bill-ljg",
|
||||||
|
elderId: "elder-ljg",
|
||||||
|
elderName: "李建国",
|
||||||
|
roomLabel: "A102-1",
|
||||||
|
contactName: "李明",
|
||||||
|
contactPhone: "13800000002",
|
||||||
|
dueOffsetDays: 8,
|
||||||
|
charges: [
|
||||||
|
{ category: "床位服务", description: "半护理房床位费", quantity: 1, unitPriceCents: 320000 },
|
||||||
|
{ category: "护理服务", description: "半护理服务包", quantity: 1, unitPriceCents: 250000 },
|
||||||
|
{ category: "膳食服务", description: "日常营养膳食", quantity: 1, unitPriceCents: 170000 },
|
||||||
|
{ category: "活动服务", description: "康体活动与陪同", quantity: 1, unitPriceCents: 40000 },
|
||||||
|
],
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
amountCents: 400000,
|
||||||
|
method: "bank_transfer",
|
||||||
|
paidOffsetDays: -1,
|
||||||
|
reference: "BANK20260708032",
|
||||||
|
operator: "财务值班",
|
||||||
|
note: "首笔转账已确认",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invoiceStatus: "pending",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bill-zyz",
|
||||||
|
elderId: "elder-zyz",
|
||||||
|
elderName: "周玉珍",
|
||||||
|
roomLabel: "A201-1",
|
||||||
|
contactName: "周强",
|
||||||
|
contactPhone: "13800000003",
|
||||||
|
dueOffsetDays: 10,
|
||||||
|
charges: [
|
||||||
|
{ category: "床位服务", description: "记忆照护专区床位费", quantity: 1, unitPriceCents: 370000 },
|
||||||
|
{ category: "护理服务", description: "协助护理服务包", quantity: 1, unitPriceCents: 285000 },
|
||||||
|
{ category: "膳食服务", description: "糖尿病营养膳食", quantity: 1, unitPriceCents: 185000 },
|
||||||
|
],
|
||||||
|
payments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bill-zgh",
|
||||||
|
elderId: "elder-zgh",
|
||||||
|
elderName: "赵国华",
|
||||||
|
roomLabel: "A202-1",
|
||||||
|
contactName: "赵琳",
|
||||||
|
contactPhone: "13800000004",
|
||||||
|
dueOffsetDays: -3,
|
||||||
|
charges: [
|
||||||
|
{ category: "床位服务", description: "重点护理房床位费", quantity: 1, unitPriceCents: 380000 },
|
||||||
|
{ category: "护理服务", description: "失能照护服务包", quantity: 1, unitPriceCents: 420000 },
|
||||||
|
{ category: "膳食服务", description: "软食营养膳食", quantity: 1, unitPriceCents: 185000 },
|
||||||
|
{ category: "生活用品", description: "护理耗材包", quantity: 1, unitPriceCents: 60000 },
|
||||||
|
],
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
amountCents: 200000,
|
||||||
|
method: "cash",
|
||||||
|
paidOffsetDays: -6,
|
||||||
|
reference: "CASH20260703009",
|
||||||
|
operator: "前台收费组",
|
||||||
|
note: "现金预缴",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bill-msf",
|
||||||
|
elderId: "elder-msf",
|
||||||
|
elderName: "马淑芬",
|
||||||
|
roomLabel: "A301-1",
|
||||||
|
contactName: "马晨",
|
||||||
|
contactPhone: "13800000011",
|
||||||
|
dueOffsetDays: -5,
|
||||||
|
charges: [
|
||||||
|
{ category: "床位服务", description: "失能照护区床位费", quantity: 1, unitPriceCents: 390000 },
|
||||||
|
{ category: "护理服务", description: "全护理服务包", quantity: 1, unitPriceCents: 460000 },
|
||||||
|
{ category: "膳食服务", description: "吞咽困难软食套餐", quantity: 1, unitPriceCents: 205000 },
|
||||||
|
{ category: "营养服务", description: "营养师评估与加餐", quantity: 1, unitPriceCents: 65000 },
|
||||||
|
],
|
||||||
|
payments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bill-qsb",
|
||||||
|
elderId: "elder-qsb",
|
||||||
|
elderName: "钱松柏",
|
||||||
|
roomLabel: "S101-1",
|
||||||
|
contactName: "钱宁",
|
||||||
|
contactPhone: "13800000014",
|
||||||
|
dueOffsetDays: 4,
|
||||||
|
charges: [
|
||||||
|
{ category: "床位服务", description: "医养观察房床位费", quantity: 1, unitPriceCents: 430000 },
|
||||||
|
{ category: "护理服务", description: "医养重点照护服务包", quantity: 1, unitPriceCents: 480000 },
|
||||||
|
{ category: "膳食服务", description: "呼吸慢病营养膳食", quantity: 1, unitPriceCents: 190000 },
|
||||||
|
{ category: "设备服务", description: "制氧与血氧监测服务", quantity: 1, unitPriceCents: 150000 },
|
||||||
|
],
|
||||||
|
payments: [
|
||||||
|
{
|
||||||
|
amountCents: 1250000,
|
||||||
|
method: "card",
|
||||||
|
paidOffsetDays: -1,
|
||||||
|
reference: "POS20260708017",
|
||||||
|
operator: "医养前台",
|
||||||
|
note: "银行卡支付",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
receiptCount: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function addDays(reference: Date, days: number): string {
|
||||||
|
const result = new Date(reference);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPeriod(reference: Date): string {
|
||||||
|
const year = reference.getFullYear();
|
||||||
|
const month = String(reference.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCharges(seed: StatementSeed, reference: Date): BillingCharge[] {
|
||||||
|
return seed.charges.map((charge, index) => ({
|
||||||
|
...charge,
|
||||||
|
id: `${seed.id}-charge-${index + 1}`,
|
||||||
|
serviceDate: addDays(reference, -Math.max(1, 12 - index * 2)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPayments(seed: StatementSeed, reference: Date): BillingPayment[] {
|
||||||
|
return seed.payments.map((payment, index) => ({
|
||||||
|
id: `${seed.id}-payment-${index + 1}`,
|
||||||
|
amountCents: payment.amountCents,
|
||||||
|
method: payment.method,
|
||||||
|
paidAt: addDays(reference, payment.paidOffsetDays),
|
||||||
|
reference: payment.reference,
|
||||||
|
operator: payment.operator,
|
||||||
|
note: payment.note,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialBillingStatements(referenceDate = new Date()): BillingStatement[] {
|
||||||
|
return STATEMENT_SEEDS.map((seed) => ({
|
||||||
|
id: seed.id,
|
||||||
|
elderId: seed.elderId,
|
||||||
|
elderName: seed.elderName,
|
||||||
|
roomLabel: seed.roomLabel,
|
||||||
|
contactName: seed.contactName,
|
||||||
|
contactPhone: seed.contactPhone,
|
||||||
|
period: getPeriod(referenceDate),
|
||||||
|
dueAt: addDays(referenceDate, seed.dueOffsetDays),
|
||||||
|
charges: createCharges(seed, referenceDate),
|
||||||
|
payments: createPayments(seed, referenceDate),
|
||||||
|
invoiceStatus: seed.invoiceStatus ?? "not_requested",
|
||||||
|
invoiceNumber: seed.invoiceNumber,
|
||||||
|
receiptCount: seed.receiptCount ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
75
modules/billing/types.test.ts
Normal file
75
modules/billing/types.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { createInitialBillingStatements } from "@/modules/billing/lib/presentation-data";
|
||||||
|
import {
|
||||||
|
addBillingCharge,
|
||||||
|
addBillingPayment,
|
||||||
|
calculateBillingSummary,
|
||||||
|
getBillingStatus,
|
||||||
|
getStatementOutstandingCents,
|
||||||
|
getStatementTotalCents,
|
||||||
|
} from "@/modules/billing/types";
|
||||||
|
|
||||||
|
const referenceDate = new Date("2026-07-09T12:00:00.000Z");
|
||||||
|
|
||||||
|
describe("billing calculations", () => {
|
||||||
|
it("derives paid, partial, pending, and overdue states from ledger activity", () => {
|
||||||
|
const statements = createInitialBillingStatements(referenceDate);
|
||||||
|
|
||||||
|
expect(statements.map((statement) => getBillingStatus(statement, referenceDate))).toEqual([
|
||||||
|
"paid",
|
||||||
|
"partial",
|
||||||
|
"pending",
|
||||||
|
"overdue",
|
||||||
|
"overdue",
|
||||||
|
"paid",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calculates statement and workspace totals from charges and payments", () => {
|
||||||
|
const statements = createInitialBillingStatements(referenceDate);
|
||||||
|
const summary = calculateBillingSummary(statements, referenceDate);
|
||||||
|
const firstStatement = statements[0];
|
||||||
|
expect(firstStatement).toBeDefined();
|
||||||
|
if (!firstStatement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(getStatementTotalCents(firstStatement)).toBe(960000);
|
||||||
|
expect(summary.receivableCents).toBe(5995000);
|
||||||
|
expect(summary.collectedCents).toBe(2810000);
|
||||||
|
expect(summary.outstandingCents).toBe(3185000);
|
||||||
|
expect(summary.overdueCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates outstanding amounts when payments and charges are appended", () => {
|
||||||
|
const statement = createInitialBillingStatements(referenceDate)[2];
|
||||||
|
expect(statement).toBeDefined();
|
||||||
|
if (!statement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidStatement = addBillingPayment(statement, {
|
||||||
|
id: "payment-new",
|
||||||
|
amountCents: 200000,
|
||||||
|
method: "wechat",
|
||||||
|
paidAt: referenceDate.toISOString(),
|
||||||
|
reference: "WX-NEW",
|
||||||
|
operator: "收费员",
|
||||||
|
note: "",
|
||||||
|
});
|
||||||
|
expect(getStatementOutstandingCents(paidStatement)).toBe(640000);
|
||||||
|
expect(getBillingStatus(paidStatement, referenceDate)).toBe("partial");
|
||||||
|
|
||||||
|
const chargedStatement = addBillingCharge(paidStatement, {
|
||||||
|
id: "charge-new",
|
||||||
|
category: "生活用品",
|
||||||
|
description: "护理耗材",
|
||||||
|
quantity: 2,
|
||||||
|
serviceDate: referenceDate.toISOString(),
|
||||||
|
unitPriceCents: 5000,
|
||||||
|
});
|
||||||
|
expect(getStatementOutstandingCents(chargedStatement)).toBe(650000);
|
||||||
|
expect(chargedStatement.invoiceStatus).toBe("not_requested");
|
||||||
|
});
|
||||||
|
});
|
||||||
169
modules/billing/types.ts
Normal file
169
modules/billing/types.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
export const BILLING_STATUS_VALUES = ["paid", "partial", "pending", "overdue"] as const;
|
||||||
|
export type BillingStatus = (typeof BILLING_STATUS_VALUES)[number];
|
||||||
|
|
||||||
|
export const BILLING_STATUS_LABELS: Record<BillingStatus, string> = {
|
||||||
|
paid: "已结清",
|
||||||
|
partial: "部分收款",
|
||||||
|
pending: "待收款",
|
||||||
|
overdue: "已逾期",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAYMENT_METHOD_VALUES = ["wechat", "alipay", "bank_transfer", "card", "cash"] as const;
|
||||||
|
export type PaymentMethod = (typeof PAYMENT_METHOD_VALUES)[number];
|
||||||
|
|
||||||
|
export const PAYMENT_METHOD_LABELS: Record<PaymentMethod, string> = {
|
||||||
|
wechat: "微信支付",
|
||||||
|
alipay: "支付宝",
|
||||||
|
bank_transfer: "银行转账",
|
||||||
|
card: "银行卡",
|
||||||
|
cash: "现金",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_VALUES = ["not_requested", "pending", "issued"] as const;
|
||||||
|
export type InvoiceStatus = (typeof INVOICE_STATUS_VALUES)[number];
|
||||||
|
|
||||||
|
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
|
||||||
|
not_requested: "未开票",
|
||||||
|
pending: "待开票",
|
||||||
|
issued: "已开票",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingCharge = {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
serviceDate: string;
|
||||||
|
unitPriceCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingPayment = {
|
||||||
|
id: string;
|
||||||
|
amountCents: number;
|
||||||
|
method: PaymentMethod;
|
||||||
|
paidAt: string;
|
||||||
|
reference: string;
|
||||||
|
operator: string;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingStatement = {
|
||||||
|
id: string;
|
||||||
|
elderId: string;
|
||||||
|
elderName: string;
|
||||||
|
roomLabel: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
period: string;
|
||||||
|
dueAt: string;
|
||||||
|
charges: BillingCharge[];
|
||||||
|
payments: BillingPayment[];
|
||||||
|
invoiceStatus: InvoiceStatus;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
receiptCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingStatementView = BillingStatement & {
|
||||||
|
totalCents: number;
|
||||||
|
paidCents: number;
|
||||||
|
outstandingCents: number;
|
||||||
|
status: BillingStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BillingSummary = {
|
||||||
|
receivableCents: number;
|
||||||
|
collectedCents: number;
|
||||||
|
outstandingCents: number;
|
||||||
|
overdueCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getChargeAmountCents(charge: BillingCharge): number {
|
||||||
|
return Math.round(charge.quantity * charge.unitPriceCents);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatementTotalCents(statement: BillingStatement): number {
|
||||||
|
return statement.charges.reduce((total, charge) => total + getChargeAmountCents(charge), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatementPaidCents(statement: BillingStatement): number {
|
||||||
|
return statement.payments.reduce((total, payment) => total + payment.amountCents, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStatementOutstandingCents(statement: BillingStatement): number {
|
||||||
|
return Math.max(0, getStatementTotalCents(statement) - getStatementPaidCents(statement));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBillingStatus(statement: BillingStatement, referenceDate = new Date()): BillingStatus {
|
||||||
|
const outstandingCents = getStatementOutstandingCents(statement);
|
||||||
|
if (outstandingCents === 0) {
|
||||||
|
return "paid";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (new Date(statement.dueAt).getTime() < referenceDate.getTime()) {
|
||||||
|
return "overdue";
|
||||||
|
}
|
||||||
|
|
||||||
|
return statement.payments.length > 0 ? "partial" : "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toBillingStatementView(
|
||||||
|
statement: BillingStatement,
|
||||||
|
referenceDate = new Date(),
|
||||||
|
): BillingStatementView {
|
||||||
|
const totalCents = getStatementTotalCents(statement);
|
||||||
|
const paidCents = getStatementPaidCents(statement);
|
||||||
|
return {
|
||||||
|
...statement,
|
||||||
|
totalCents,
|
||||||
|
paidCents,
|
||||||
|
outstandingCents: Math.max(0, totalCents - paidCents),
|
||||||
|
status: getBillingStatus(statement, referenceDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateBillingSummary(
|
||||||
|
statements: readonly BillingStatement[],
|
||||||
|
referenceDate = new Date(),
|
||||||
|
): BillingSummary {
|
||||||
|
return statements.reduce<BillingSummary>(
|
||||||
|
(summary, statement) => {
|
||||||
|
const view = toBillingStatementView(statement, referenceDate);
|
||||||
|
return {
|
||||||
|
receivableCents: summary.receivableCents + view.totalCents,
|
||||||
|
collectedCents: summary.collectedCents + view.paidCents,
|
||||||
|
outstandingCents: summary.outstandingCents + view.outstandingCents,
|
||||||
|
overdueCount: summary.overdueCount + (view.status === "overdue" ? 1 : 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ receivableCents: 0, collectedCents: 0, outstandingCents: 0, overdueCount: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBillingPayment(statement: BillingStatement, payment: BillingPayment): BillingStatement {
|
||||||
|
return {
|
||||||
|
...statement,
|
||||||
|
payments: [payment, ...statement.payments],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addBillingCharge(statement: BillingStatement, charge: BillingCharge): BillingStatement {
|
||||||
|
return {
|
||||||
|
...statement,
|
||||||
|
charges: [...statement.charges, charge],
|
||||||
|
invoiceStatus: "not_requested",
|
||||||
|
invoiceNumber: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCny(cents: number): string {
|
||||||
|
return new Intl.NumberFormat("zh-CN", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CNY",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBillingPeriod(period: string): string {
|
||||||
|
const [year, month] = period.split("-");
|
||||||
|
return year && month ? `${year}年${month}月` : period;
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
|
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
|
||||||
|
import { getDatabase, type AppDatabase } from "@/modules/core/server/db";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AI_KNOWLEDGE_ENTRIES,
|
DEFAULT_AI_KNOWLEDGE_ENTRIES,
|
||||||
|
DEFAULT_ALERT_RULES,
|
||||||
|
DEFAULT_ALERT_TRIGGERS,
|
||||||
|
DEFAULT_DEVICE_ASSETS,
|
||||||
|
DEFAULT_FAMILY_CONTACTS,
|
||||||
|
DEFAULT_FAMILY_FEEDBACK,
|
||||||
|
DEFAULT_FAMILY_VISITS,
|
||||||
|
DEFAULT_MAINTENANCE_TICKETS,
|
||||||
|
DEFAULT_NOTICES,
|
||||||
DEFAULT_PREPARED_AI_ANALYSES,
|
DEFAULT_PREPARED_AI_ANALYSES,
|
||||||
|
seedDefaultCollaborationWorkspaceData,
|
||||||
} from "@/modules/core/server/default-workspace-data";
|
} from "@/modules/core/server/default-workspace-data";
|
||||||
|
|
||||||
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
|
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
|
||||||
@@ -13,6 +23,258 @@ vi.mock("@/modules/core/server/db", () => ({
|
|||||||
getDatabase: vi.fn(),
|
getDatabase: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function expectStableUniqueNaturalKeys<T>(items: T[], getKey: (item: T) => string, expectedKeys: string[]): void {
|
||||||
|
const keys = items.map(getKey);
|
||||||
|
expect(keys).toEqual(expectedKeys);
|
||||||
|
expect(new Set(keys)).toHaveLength(items.length);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
expect(key.trim()).toBe(key);
|
||||||
|
expect(key).not.toBe("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeedInsertRow = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isSeedInsertRow(value: unknown): value is SeedInsertRow {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSeedInsertRows(values: unknown): SeedInsertRow[] {
|
||||||
|
return Array.isArray(values) ? values.filter(isSeedInsertRow) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowsWithKey(rows: SeedInsertRow[], key: string): SeedInsertRow[] {
|
||||||
|
return rows.filter((row) => key in row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCollaborationSeedDatabaseMock(params: {
|
||||||
|
missingDeviceCode: string;
|
||||||
|
missingTicketTitle: string;
|
||||||
|
missingNoticeTitle: string;
|
||||||
|
missingAlertRuleName: string;
|
||||||
|
missingAlertTriggerTitle: string;
|
||||||
|
missingFamilyContactName: string;
|
||||||
|
missingFamilyVisitNotes: string;
|
||||||
|
missingFamilyFeedbackContent: string;
|
||||||
|
}): { database: AppDatabase; insertCalls: Array<{ values: SeedInsertRow[] }> } {
|
||||||
|
const elderNames = Array.from(
|
||||||
|
new Set([
|
||||||
|
...DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName),
|
||||||
|
...DEFAULT_ALERT_TRIGGERS.flatMap((trigger) => (trigger.elderName ? [trigger.elderName] : [])),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const elderRows = elderNames.map((name, index) => ({ id: `elder-${index}`, name }));
|
||||||
|
const getElderId = (name: string): string => {
|
||||||
|
const elder = elderRows.find((row) => row.name === name);
|
||||||
|
if (!elder) {
|
||||||
|
throw new Error(`Missing test elder row for ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elder.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allFamilyContactRows = DEFAULT_FAMILY_CONTACTS.map((contact, index) => ({
|
||||||
|
id: `contact-${index}`,
|
||||||
|
elderId: getElderId(contact.elderName),
|
||||||
|
name: contact.name,
|
||||||
|
}));
|
||||||
|
const getContactId = (elderName: string, contactName: string): string => {
|
||||||
|
const elderId = getElderId(elderName);
|
||||||
|
const contact = allFamilyContactRows.find((row) => row.elderId === elderId && row.name === contactName);
|
||||||
|
if (!contact) {
|
||||||
|
throw new Error(`Missing test contact row for ${elderName}/${contactName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return contact.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectResults = [
|
||||||
|
elderRows,
|
||||||
|
DEFAULT_DEVICE_ASSETS.filter((device) => device.code !== params.missingDeviceCode).map((device) => ({ id: `device-${device.code}`, code: device.code })),
|
||||||
|
DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => ticket.title !== params.missingTicketTitle).map((ticket) => ({ title: ticket.title })),
|
||||||
|
DEFAULT_NOTICES.filter((notice) => notice.title !== params.missingNoticeTitle).map((notice) => ({ title: notice.title })),
|
||||||
|
DEFAULT_ALERT_RULES.filter((rule) => rule.name !== params.missingAlertRuleName).map((rule) => ({ id: `rule-${rule.name}`, name: rule.name })),
|
||||||
|
DEFAULT_ALERT_TRIGGERS.filter((trigger) => trigger.title !== params.missingAlertTriggerTitle).map((trigger) => ({ title: trigger.title })),
|
||||||
|
allFamilyContactRows.filter((contact) => contact.name !== params.missingFamilyContactName),
|
||||||
|
DEFAULT_FAMILY_VISITS.filter((visit) => visit.notes !== params.missingFamilyVisitNotes).map((visit) => ({
|
||||||
|
elderId: getElderId(visit.elderName),
|
||||||
|
contactId: getContactId(visit.elderName, visit.contactName),
|
||||||
|
notes: visit.notes,
|
||||||
|
})),
|
||||||
|
DEFAULT_FAMILY_FEEDBACK.filter((feedback) => feedback.content !== params.missingFamilyFeedbackContent).map((feedback) => ({
|
||||||
|
elderId: getElderId(feedback.elderName),
|
||||||
|
contactId: getContactId(feedback.elderName, feedback.contactName),
|
||||||
|
content: feedback.content,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
let selectIndex = 0;
|
||||||
|
const insertCalls: Array<{ values: SeedInsertRow[] }> = [];
|
||||||
|
const transaction = {
|
||||||
|
insert: vi.fn(() => ({
|
||||||
|
values: vi.fn((values: unknown) => {
|
||||||
|
const rows = toSeedInsertRows(values);
|
||||||
|
insertCalls.push({ values: rows });
|
||||||
|
|
||||||
|
return {
|
||||||
|
returning: vi.fn(() => rows.map((row, index) => ({ ...row, id: `inserted-${index}` }))),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const database = {
|
||||||
|
select: vi.fn(() => ({
|
||||||
|
from: vi.fn(() => ({
|
||||||
|
where: vi.fn(() => {
|
||||||
|
const result = selectResults[selectIndex] ?? [];
|
||||||
|
selectIndex += 1;
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
transaction: vi.fn(async (callback: (transactionClient: typeof transaction) => Promise<void> | void) => {
|
||||||
|
await callback(transaction);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { database: database as unknown as AppDatabase, insertCalls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("default collaboration seed data", () => {
|
||||||
|
it("keeps idempotent seed natural keys stable, populated, and unique per collaboration table", () => {
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_DEVICE_ASSETS, (device) => device.code, [
|
||||||
|
"DEV-CALL-A102-2",
|
||||||
|
"DEV-SPO2-S101",
|
||||||
|
"DEV-OXY-A302-2",
|
||||||
|
"DEV-REHAB-R101",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_MAINTENANCE_TICKETS, (ticket) => ticket.title, [
|
||||||
|
"床头呼叫器按键失灵",
|
||||||
|
"氧气接口保养延期",
|
||||||
|
"康复步行带日常校准",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_NOTICES, (notice) => notice.title, [
|
||||||
|
"本周家属探访安排",
|
||||||
|
"夜间重点巡房提醒",
|
||||||
|
"设备巡检培训材料",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_ALERT_RULES, (rule) => rule.name, [
|
||||||
|
"血氧低值连续告警",
|
||||||
|
"高优先级护理任务逾期",
|
||||||
|
"设备维修工单未派单",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_ALERT_TRIGGERS, (trigger) => trigger.title, [
|
||||||
|
"S101 血氧低值需复核",
|
||||||
|
"重点照护夜间巡检逾期",
|
||||||
|
"氧气接口保养待派单",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_CONTACTS, (contact) => `${contact.elderName}/${contact.name}`, [
|
||||||
|
"王桂兰/张敏",
|
||||||
|
"钱松柏/钱宁",
|
||||||
|
"林玉琴/林晓",
|
||||||
|
"赵国华/赵蕾",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_VISITS, (visit) => `${visit.elderName}/${visit.contactName}/${visit.notes}`, [
|
||||||
|
"王桂兰/张敏/安排护理楼一层会客区。",
|
||||||
|
"钱松柏/钱宁/需医生确认夜间血氧情况后再回复。",
|
||||||
|
"林玉琴/林晓/已完成探访和短住事项确认。",
|
||||||
|
]);
|
||||||
|
expectStableUniqueNaturalKeys(DEFAULT_FAMILY_FEEDBACK, (feedback) => `${feedback.elderName}/${feedback.contactName}/${feedback.content}`, [
|
||||||
|
"赵国华/赵蕾/希望夜间巡房后能同步重点观察结果。",
|
||||||
|
"林玉琴/林晓/短住房间清洁和呼叫器说明比较清楚。",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps default collaboration records resolvable through their natural-key dependencies", () => {
|
||||||
|
const deviceCodes = new Set(DEFAULT_DEVICE_ASSETS.map((device) => device.code));
|
||||||
|
const alertRuleNames = new Set(DEFAULT_ALERT_RULES.map((rule) => rule.name));
|
||||||
|
const elderNames = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => contact.elderName));
|
||||||
|
const contactKeys = new Set(DEFAULT_FAMILY_CONTACTS.map((contact) => `${contact.elderName}/${contact.name}`));
|
||||||
|
|
||||||
|
expect(DEFAULT_DEVICE_ASSETS.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(DEFAULT_MAINTENANCE_TICKETS.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(DEFAULT_NOTICES.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(DEFAULT_ALERT_RULES.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(DEFAULT_ALERT_TRIGGERS.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(DEFAULT_FAMILY_CONTACTS.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(DEFAULT_FAMILY_VISITS.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(DEFAULT_FAMILY_FEEDBACK.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
for (const ticket of DEFAULT_MAINTENANCE_TICKETS) {
|
||||||
|
expect(deviceCodes.has(ticket.deviceCode)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const trigger of DEFAULT_ALERT_TRIGGERS) {
|
||||||
|
expect(alertRuleNames.has(trigger.ruleName)).toBe(true);
|
||||||
|
if (trigger.elderName) {
|
||||||
|
expect(elderNames.has(trigger.elderName)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const visit of DEFAULT_FAMILY_VISITS) {
|
||||||
|
expect(contactKeys.has(`${visit.elderName}/${visit.contactName}`)).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feedback of DEFAULT_FAMILY_FEEDBACK) {
|
||||||
|
expect(contactKeys.has(`${feedback.elderName}/${feedback.contactName}`)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps user-visible collaboration defaults realistic", () => {
|
||||||
|
const userVisibleContent = [
|
||||||
|
...DEFAULT_DEVICE_ASSETS.map((device) => [device.name, device.category, device.location, device.notes].join("\n")),
|
||||||
|
...DEFAULT_MAINTENANCE_TICKETS.map((ticket) => [ticket.title, ticket.description, ticket.assigneeLabel, ticket.resolutionNotes].join("\n")),
|
||||||
|
...DEFAULT_NOTICES.map((notice) => [notice.title, notice.content, notice.audience].join("\n")),
|
||||||
|
...DEFAULT_ALERT_RULES.map((rule) => [rule.name, rule.ruleType, rule.conditionSummary, rule.suggestion].join("\n")),
|
||||||
|
...DEFAULT_ALERT_TRIGGERS.map((trigger) => [trigger.title, trigger.description, trigger.source, trigger.handlingNotes].join("\n")),
|
||||||
|
...DEFAULT_FAMILY_CONTACTS.map((contact) => [contact.elderName, contact.name, contact.relationship, contact.phone, contact.notes].join("\n")),
|
||||||
|
...DEFAULT_FAMILY_VISITS.map((visit) => [visit.elderName, visit.contactName, visit.status, visit.notes].join("\n")),
|
||||||
|
...DEFAULT_FAMILY_FEEDBACK.map((feedback) => [feedback.elderName, feedback.contactName, feedback.feedbackType, feedback.content, feedback.responseNotes].join("\n")),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
expect(userVisibleContent).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts only collaboration defaults missing from existing natural-key sets", async () => {
|
||||||
|
const missingDevice = DEFAULT_DEVICE_ASSETS.find((device) => device.code === "DEV-CALL-A102-2");
|
||||||
|
const missingTicket = DEFAULT_MAINTENANCE_TICKETS.find((ticket) => ticket.title === "床头呼叫器按键失灵");
|
||||||
|
const missingNotice = DEFAULT_NOTICES.find((notice) => notice.title === "设备巡检培训材料");
|
||||||
|
const missingAlertRule = DEFAULT_ALERT_RULES.find((rule) => rule.name === "血氧低值连续告警");
|
||||||
|
const missingAlertTrigger = DEFAULT_ALERT_TRIGGERS.find((trigger) => trigger.title === "S101 血氧低值需复核");
|
||||||
|
const missingFamilyContact = DEFAULT_FAMILY_CONTACTS.find((contact) => contact.name === "张敏");
|
||||||
|
const missingFamilyVisit = DEFAULT_FAMILY_VISITS.find((visit) => visit.notes === "安排护理楼一层会客区。");
|
||||||
|
const missingFamilyFeedback = DEFAULT_FAMILY_FEEDBACK.find((feedback) => feedback.content === "希望夜间巡房后能同步重点观察结果。");
|
||||||
|
if (!missingDevice || !missingTicket || !missingNotice || !missingAlertRule || !missingAlertTrigger || !missingFamilyContact || !missingFamilyVisit || !missingFamilyFeedback) {
|
||||||
|
throw new Error("Missing collaboration seed fixture for behavior test");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { database, insertCalls } = createCollaborationSeedDatabaseMock({
|
||||||
|
missingDeviceCode: missingDevice.code,
|
||||||
|
missingTicketTitle: missingTicket.title,
|
||||||
|
missingNoticeTitle: missingNotice.title,
|
||||||
|
missingAlertRuleName: missingAlertRule.name,
|
||||||
|
missingAlertTriggerTitle: missingAlertTrigger.title,
|
||||||
|
missingFamilyContactName: missingFamilyContact.name,
|
||||||
|
missingFamilyVisitNotes: missingFamilyVisit.notes,
|
||||||
|
missingFamilyFeedbackContent: missingFamilyFeedback.content,
|
||||||
|
});
|
||||||
|
vi.mocked(getDatabase).mockReturnValue(database);
|
||||||
|
|
||||||
|
await seedDefaultCollaborationWorkspaceData("organization-1");
|
||||||
|
|
||||||
|
const insertedRows = insertCalls.flatMap((call) => call.values);
|
||||||
|
expect(insertedRows).toHaveLength(8);
|
||||||
|
expect(rowsWithKey(insertedRows, "code")).toEqual([expect.objectContaining({ code: missingDevice.code })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "priority")).toEqual([expect.objectContaining({ title: missingTicket.title })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "audience")).toEqual([expect.objectContaining({ title: missingNotice.title })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "conditionSummary")).toEqual([expect.objectContaining({ name: missingAlertRule.name })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "source")).toEqual([expect.objectContaining({ title: missingAlertTrigger.title })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "relationship")).toEqual([expect.objectContaining({ name: missingFamilyContact.name })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "scheduledAt")).toEqual([expect.objectContaining({ notes: missingFamilyVisit.notes })]);
|
||||||
|
expect(rowsWithKey(insertedRows, "feedbackType")).toEqual([expect.objectContaining({ content: missingFamilyFeedback.content })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("default AI workspace seed data", () => {
|
describe("default AI workspace seed data", () => {
|
||||||
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
|
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
|
||||||
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
|
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|||||||
@@ -979,7 +979,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
|
export const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
|
||||||
{
|
{
|
||||||
name: "A102 床头呼叫器",
|
name: "A102 床头呼叫器",
|
||||||
code: "DEV-CALL-A102-2",
|
code: "DEV-CALL-A102-2",
|
||||||
@@ -1018,7 +1018,7 @@ const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
export const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
||||||
{
|
{
|
||||||
deviceCode: "DEV-CALL-A102-2",
|
deviceCode: "DEV-CALL-A102-2",
|
||||||
title: "床头呼叫器按键失灵",
|
title: "床头呼叫器按键失灵",
|
||||||
@@ -1048,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_NOTICES: SeedNotice[] = [
|
export const DEFAULT_NOTICES: SeedNotice[] = [
|
||||||
{
|
{
|
||||||
title: "本周家属探访安排",
|
title: "本周家属探访安排",
|
||||||
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
|
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
|
||||||
@@ -1071,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
||||||
{
|
{
|
||||||
name: "血氧低值连续告警",
|
name: "血氧低值连续告警",
|
||||||
ruleType: "health",
|
ruleType: "health",
|
||||||
@@ -1098,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
||||||
{
|
{
|
||||||
ruleName: "血氧低值连续告警",
|
ruleName: "血氧低值连续告警",
|
||||||
elderName: "钱松柏",
|
elderName: "钱松柏",
|
||||||
@@ -1130,20 +1130,20 @@ const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [
|
export const DEFAULT_FAMILY_CONTACTS: SeedFamilyContact[] = [
|
||||||
{ elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" },
|
{ elderName: "王桂兰", name: "张敏", relationship: "女儿", phone: "13800000001", status: "active", notes: "主要联系人,每周六探访。" },
|
||||||
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
|
{ elderName: "钱松柏", name: "钱宁", relationship: "儿子", phone: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
|
||||||
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
|
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", status: "active", notes: "短住托养资料已确认。" },
|
||||||
{ elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" },
|
{ elderName: "赵国华", name: "赵蕾", relationship: "孙女", phone: "13800000016", status: "active", notes: "希望接收护理反馈。" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [
|
export const DEFAULT_FAMILY_VISITS: SeedFamilyVisit[] = [
|
||||||
{ elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" },
|
{ elderName: "王桂兰", contactName: "张敏", scheduledHoursOffset: 30, status: "approved", notes: "安排护理楼一层会客区。" },
|
||||||
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
|
{ elderName: "钱松柏", contactName: "钱宁", scheduledHoursOffset: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
|
||||||
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
|
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
|
export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
|
||||||
{
|
{
|
||||||
elderName: "赵国华",
|
elderName: "赵国华",
|
||||||
contactName: "赵蕾",
|
contactName: "赵蕾",
|
||||||
@@ -1510,6 +1510,271 @@ async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFamilyContactKey(elderId: string, contactName: string): string {
|
||||||
|
return `${elderId}:${contactName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFamilyVisitKey(elderId: string, contactId: string, notes: string): string {
|
||||||
|
return `${elderId}:${contactId}:${notes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFamilyFeedbackKey(elderId: string, contactId: string, content: string): string {
|
||||||
|
return `${elderId}:${contactId}:${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedDefaultCollaborationWorkspaceData(organizationId: string): Promise<void> {
|
||||||
|
const database = getDatabase();
|
||||||
|
const [
|
||||||
|
elderRows,
|
||||||
|
existingDeviceRows,
|
||||||
|
existingTicketRows,
|
||||||
|
existingNoticeRows,
|
||||||
|
existingAlertRuleRows,
|
||||||
|
existingAlertTriggerRows,
|
||||||
|
existingFamilyContactRows,
|
||||||
|
existingFamilyVisitRows,
|
||||||
|
existingFamilyFeedbackRows,
|
||||||
|
] = await Promise.all([
|
||||||
|
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
|
||||||
|
database.select().from(deviceAssets).where(eq(deviceAssets.organizationId, organizationId)),
|
||||||
|
database.select().from(maintenanceTickets).where(eq(maintenanceTickets.organizationId, organizationId)),
|
||||||
|
database.select().from(notices).where(eq(notices.organizationId, organizationId)),
|
||||||
|
database.select().from(alertRules).where(eq(alertRules.organizationId, organizationId)),
|
||||||
|
database.select().from(alertTriggers).where(eq(alertTriggers.organizationId, organizationId)),
|
||||||
|
database.select().from(familyContacts).where(eq(familyContacts.organizationId, organizationId)),
|
||||||
|
database.select().from(familyVisitAppointments).where(eq(familyVisitAppointments.organizationId, organizationId)),
|
||||||
|
database.select().from(familyFeedback).where(eq(familyFeedback.organizationId, organizationId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
|
||||||
|
const existingDeviceCodes = new Set(existingDeviceRows.map((device) => device.code));
|
||||||
|
const existingTicketTitles = new Set(existingTicketRows.map((ticket) => ticket.title));
|
||||||
|
const existingNoticeTitles = new Set(existingNoticeRows.map((notice) => notice.title));
|
||||||
|
const existingAlertRuleNames = new Set(existingAlertRuleRows.map((rule) => rule.name));
|
||||||
|
const existingAlertTriggerTitles = new Set(existingAlertTriggerRows.map((trigger) => trigger.title));
|
||||||
|
const existingFamilyContactKeys = new Set(existingFamilyContactRows.map((contact) => getFamilyContactKey(contact.elderId, contact.name)));
|
||||||
|
const existingFamilyVisitKeys = new Set(
|
||||||
|
existingFamilyVisitRows
|
||||||
|
.map((visit) => (visit.contactId ? getFamilyVisitKey(visit.elderId, visit.contactId, visit.notes) : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
const existingFamilyFeedbackKeys = new Set(
|
||||||
|
existingFamilyFeedbackRows
|
||||||
|
.map((feedback) => (feedback.contactId ? getFamilyFeedbackKey(feedback.elderId, feedback.contactId, feedback.content) : ""))
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getContactId = (contactRows: typeof existingFamilyContactRows, elderId: string, contactName: string): string | undefined =>
|
||||||
|
contactRows.find((contact) => contact.elderId === elderId && contact.name === contactName)?.id;
|
||||||
|
const getMissingFamilyVisits = (contactRows: typeof existingFamilyContactRows): SeedFamilyVisit[] =>
|
||||||
|
DEFAULT_FAMILY_VISITS.filter((visit) => {
|
||||||
|
const elderId = elderIdByName.get(visit.elderName);
|
||||||
|
const contactId = elderId ? getContactId(contactRows, elderId, visit.contactName) : undefined;
|
||||||
|
return Boolean(elderId && contactId && !existingFamilyVisitKeys.has(getFamilyVisitKey(elderId, contactId, visit.notes)));
|
||||||
|
});
|
||||||
|
const getMissingFamilyFeedback = (contactRows: typeof existingFamilyContactRows): SeedFamilyFeedback[] =>
|
||||||
|
DEFAULT_FAMILY_FEEDBACK.filter((feedback) => {
|
||||||
|
const elderId = elderIdByName.get(feedback.elderName);
|
||||||
|
const contactId = elderId ? getContactId(contactRows, elderId, feedback.contactName) : undefined;
|
||||||
|
return Boolean(elderId && contactId && !existingFamilyFeedbackKeys.has(getFamilyFeedbackKey(elderId, contactId, feedback.content)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const missingDevices = DEFAULT_DEVICE_ASSETS.filter((device) => !existingDeviceCodes.has(device.code));
|
||||||
|
const missingTickets = DEFAULT_MAINTENANCE_TICKETS.filter((ticket) => !existingTicketTitles.has(ticket.title));
|
||||||
|
const missingNotices = DEFAULT_NOTICES.filter((notice) => !existingNoticeTitles.has(notice.title));
|
||||||
|
const missingAlertRules = DEFAULT_ALERT_RULES.filter((rule) => !existingAlertRuleNames.has(rule.name));
|
||||||
|
const missingAlertTriggers = DEFAULT_ALERT_TRIGGERS.filter((trigger) => !existingAlertTriggerTitles.has(trigger.title));
|
||||||
|
const missingFamilyContacts = DEFAULT_FAMILY_CONTACTS.filter((contact) => {
|
||||||
|
const elderId = elderIdByName.get(contact.elderName);
|
||||||
|
return Boolean(elderId && !existingFamilyContactKeys.has(getFamilyContactKey(elderId, contact.name)));
|
||||||
|
});
|
||||||
|
const missingFamilyVisits = getMissingFamilyVisits(existingFamilyContactRows);
|
||||||
|
const missingFamilyFeedback = getMissingFamilyFeedback(existingFamilyContactRows);
|
||||||
|
|
||||||
|
if (
|
||||||
|
missingDevices.length === 0 &&
|
||||||
|
missingTickets.length === 0 &&
|
||||||
|
missingNotices.length === 0 &&
|
||||||
|
missingAlertRules.length === 0 &&
|
||||||
|
missingAlertTriggers.length === 0 &&
|
||||||
|
missingFamilyContacts.length === 0 &&
|
||||||
|
missingFamilyVisits.length === 0 &&
|
||||||
|
missingFamilyFeedback.length === 0
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
await database.transaction(async (transaction) => {
|
||||||
|
const insertedDeviceRows = missingDevices.length > 0
|
||||||
|
? await transaction
|
||||||
|
.insert(deviceAssets)
|
||||||
|
.values(
|
||||||
|
missingDevices.map((device) => ({
|
||||||
|
organizationId,
|
||||||
|
name: device.name,
|
||||||
|
code: device.code,
|
||||||
|
category: device.category,
|
||||||
|
location: device.location,
|
||||||
|
status: device.status,
|
||||||
|
lastInspectedAt: device.lastInspectedHoursAgo === undefined ? undefined : new Date(now.getTime() - device.lastInspectedHoursAgo * 60 * 60 * 1000),
|
||||||
|
notes: device.notes,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
: [];
|
||||||
|
if (insertedDeviceRows.length !== missingDevices.length) {
|
||||||
|
throw new Error("默认设备台账初始化失败");
|
||||||
|
}
|
||||||
|
const deviceIdByCode = new Map([...existingDeviceRows, ...insertedDeviceRows].map((device) => [device.code, device.id]));
|
||||||
|
|
||||||
|
if (missingTickets.length > 0) {
|
||||||
|
const ticketValues = missingTickets.map((ticket) => {
|
||||||
|
const deviceId = deviceIdByCode.get(ticket.deviceCode);
|
||||||
|
if (!deviceId) {
|
||||||
|
throw new Error("默认维修工单初始化失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
deviceId,
|
||||||
|
title: ticket.title,
|
||||||
|
description: ticket.description,
|
||||||
|
priority: ticket.priority,
|
||||||
|
status: ticket.status,
|
||||||
|
assigneeLabel: ticket.assigneeLabel,
|
||||||
|
resolutionNotes: ticket.resolutionNotes,
|
||||||
|
resolvedAt: ticket.status === "resolved" || ticket.status === "closed" ? now : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await transaction.insert(maintenanceTickets).values(ticketValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingNotices.length > 0) {
|
||||||
|
await transaction.insert(notices).values(
|
||||||
|
missingNotices.map((notice) => ({
|
||||||
|
organizationId,
|
||||||
|
title: notice.title,
|
||||||
|
content: notice.content,
|
||||||
|
audience: notice.audience,
|
||||||
|
status: notice.status,
|
||||||
|
publishedAt: notice.publishedHoursAgo === undefined ? undefined : new Date(now.getTime() - notice.publishedHoursAgo * 60 * 60 * 1000),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedAlertRuleRows = missingAlertRules.length > 0
|
||||||
|
? await transaction.insert(alertRules).values(missingAlertRules.map((rule) => ({ ...rule, organizationId }))).returning()
|
||||||
|
: [];
|
||||||
|
if (insertedAlertRuleRows.length !== missingAlertRules.length) {
|
||||||
|
throw new Error("默认预警规则初始化失败");
|
||||||
|
}
|
||||||
|
const alertRuleIdByName = new Map([...existingAlertRuleRows, ...insertedAlertRuleRows].map((rule) => [rule.name, rule.id]));
|
||||||
|
|
||||||
|
const alertTriggerValues = missingAlertTriggers
|
||||||
|
.map((trigger) => {
|
||||||
|
const ruleId = alertRuleIdByName.get(trigger.ruleName);
|
||||||
|
const elderId = trigger.elderName ? elderIdByName.get(trigger.elderName) : undefined;
|
||||||
|
if (!ruleId || (trigger.elderName && !elderId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
ruleId,
|
||||||
|
elderId,
|
||||||
|
title: trigger.title,
|
||||||
|
description: trigger.description,
|
||||||
|
status: trigger.status,
|
||||||
|
source: trigger.source,
|
||||||
|
handlingNotes: trigger.handlingNotes,
|
||||||
|
handledAt: trigger.status === "open" ? undefined : now,
|
||||||
|
createdAt: new Date(now.getTime() - trigger.createdHoursAgo * 60 * 60 * 1000),
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((value): value is Exclude<typeof value, null> => value !== null);
|
||||||
|
if (alertTriggerValues.length > 0) {
|
||||||
|
await transaction.insert(alertTriggers).values(alertTriggerValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedFamilyContactRows = missingFamilyContacts.length > 0
|
||||||
|
? await transaction
|
||||||
|
.insert(familyContacts)
|
||||||
|
.values(
|
||||||
|
missingFamilyContacts.map((contact) => {
|
||||||
|
const elderId = elderIdByName.get(contact.elderName);
|
||||||
|
if (!elderId) {
|
||||||
|
throw new Error("默认家属联系人初始化失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
elderId,
|
||||||
|
name: contact.name,
|
||||||
|
relationship: contact.relationship,
|
||||||
|
phone: contact.phone,
|
||||||
|
status: contact.status,
|
||||||
|
notes: contact.notes,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
: [];
|
||||||
|
if (insertedFamilyContactRows.length !== missingFamilyContacts.length) {
|
||||||
|
throw new Error("默认家属联系人初始化失败");
|
||||||
|
}
|
||||||
|
const allFamilyContactRows = [...existingFamilyContactRows, ...insertedFamilyContactRows];
|
||||||
|
|
||||||
|
const familyVisitValues = getMissingFamilyVisits(allFamilyContactRows)
|
||||||
|
.map((visit) => {
|
||||||
|
const elderId = elderIdByName.get(visit.elderName);
|
||||||
|
const contactId = elderId ? getContactId(allFamilyContactRows, elderId, visit.contactName) : undefined;
|
||||||
|
if (!elderId || !contactId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
elderId,
|
||||||
|
contactId,
|
||||||
|
scheduledAt: new Date(now.getTime() + visit.scheduledHoursOffset * 60 * 60 * 1000),
|
||||||
|
status: visit.status,
|
||||||
|
notes: visit.notes,
|
||||||
|
handledAt: visit.status === "requested" ? undefined : now,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((value): value is Exclude<typeof value, null> => value !== null);
|
||||||
|
if (familyVisitValues.length > 0) {
|
||||||
|
await transaction.insert(familyVisitAppointments).values(familyVisitValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
const familyFeedbackValues = getMissingFamilyFeedback(allFamilyContactRows)
|
||||||
|
.map((feedback) => {
|
||||||
|
const elderId = elderIdByName.get(feedback.elderName);
|
||||||
|
const contactId = elderId ? getContactId(allFamilyContactRows, elderId, feedback.contactName) : undefined;
|
||||||
|
if (!elderId || !contactId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
organizationId,
|
||||||
|
elderId,
|
||||||
|
contactId,
|
||||||
|
feedbackType: feedback.feedbackType,
|
||||||
|
content: feedback.content,
|
||||||
|
status: feedback.status,
|
||||||
|
responseNotes: feedback.responseNotes,
|
||||||
|
handledAt: feedback.status === "open" ? undefined : now,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((value): value is Exclude<typeof value, null> => value !== null);
|
||||||
|
if (familyFeedbackValues.length > 0) {
|
||||||
|
await transaction.insert(familyFeedback).values(familyFeedbackValues);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function hasRows(rows: unknown[]): boolean {
|
function hasRows(rows: unknown[]): boolean {
|
||||||
return rows.length > 0;
|
return rows.length > 0;
|
||||||
}
|
}
|
||||||
@@ -1524,6 +1789,7 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
|
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
|
||||||
|
await seedDefaultCollaborationWorkspaceData(organizationId);
|
||||||
await seedDefaultAiWorkspaceData(organizationId);
|
await seedDefaultAiWorkspaceData(organizationId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1983,5 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
await seedDefaultCollaborationWorkspaceData(organizationId);
|
||||||
await seedDefaultAiWorkspaceData(organizationId);
|
await seedDefaultAiWorkspaceData(organizationId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,31 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Activity, AlertTriangle, BedDouble, CheckCircle2, HeartPulse, ListChecks, Users } from "lucide-react";
|
import { Activity, AlertTriangle, BedDouble, BrainCircuit, CheckCircle2, HeartPulse, ListChecks, Megaphone, ShieldAlert, TabletSmartphone, Users } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Table } from "@/components/ui/table";
|
import { Table } from "@/components/ui/table";
|
||||||
|
import type { ElderAiAnalysisBoardItem, ElderAiRiskLevel } from "@/modules/ai/types";
|
||||||
|
import type { AlertTriggerStatus } from "@/modules/alerts/types";
|
||||||
|
import { ALERT_TRIGGER_STATUS_LABELS } from "@/modules/alerts/types";
|
||||||
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
|
import type { AdmissionStatus, BedStatus, IncidentSeverity } from "@/modules/core/types";
|
||||||
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
|
import type { CareTaskPriority, CareTaskStatus } from "@/modules/care/types";
|
||||||
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } from "@/modules/care/types";
|
import { CARE_TASK_PRIORITY_LABELS, CARE_TASK_STATUS_LABELS } from "@/modules/care/types";
|
||||||
import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
|
import { BedOccupancyChart, type BedOccupancyDatum } from "@/modules/dashboard/components/BedOccupancyChart";
|
||||||
|
import type { MaintenanceTicketPriority, MaintenanceTicketStatus } from "@/modules/devices/types";
|
||||||
|
import { MAINTENANCE_TICKET_PRIORITY_LABELS, MAINTENANCE_TICKET_STATUS_LABELS } from "@/modules/devices/types";
|
||||||
import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
|
import { INCIDENT_SEVERITY_LABELS, INCIDENT_STATUS_LABELS } from "@/modules/emergency/types";
|
||||||
|
import type { FamilyFeedbackStatus, FamilyVisitStatus } from "@/modules/family/types";
|
||||||
|
import { FAMILY_FEEDBACK_STATUS_LABELS, FAMILY_VISIT_STATUS_LABELS } from "@/modules/family/types";
|
||||||
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
|
import type { HealthReviewSeverity, HealthReviewStatus } from "@/modules/health/types";
|
||||||
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types";
|
import { HEALTH_REVIEW_SEVERITY_LABELS, HEALTH_REVIEW_STATUS_LABELS } from "@/modules/health/types";
|
||||||
|
import type { NoticeStatus } from "@/modules/notices/types";
|
||||||
|
import { NOTICE_STATUS_LABELS } from "@/modules/notices/types";
|
||||||
export type DashboardMetric = {
|
export type DashboardMetric = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
trend: string;
|
trend: string;
|
||||||
icon: "admissions" | "beds" | "care" | "health" | "incidents" | "users";
|
icon: "admissions" | "ai" | "alerts" | "beds" | "care" | "devices" | "family" | "health" | "incidents" | "notices" | "users";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardAdmission = {
|
export type DashboardAdmission = {
|
||||||
@@ -56,26 +64,82 @@ export type DashboardHealthReview = {
|
|||||||
title: string;
|
title: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DashboardDeviceTicket = {
|
||||||
|
id: string;
|
||||||
|
deviceName: string;
|
||||||
|
priority: MaintenanceTicketPriority;
|
||||||
|
status: MaintenanceTicketStatus;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardNotice = {
|
||||||
|
id: string;
|
||||||
|
audience: string;
|
||||||
|
status: NoticeStatus;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardAlertTrigger = {
|
||||||
|
id: string;
|
||||||
|
elderName: string;
|
||||||
|
status: AlertTriggerStatus;
|
||||||
|
title: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardFamilyVisit = {
|
||||||
|
id: string;
|
||||||
|
contactName: string;
|
||||||
|
elderName: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
status: FamilyVisitStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardFamilyFeedback = {
|
||||||
|
id: string;
|
||||||
|
elderName: string;
|
||||||
|
status: FamilyFeedbackStatus;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type DashboardHomeProps = {
|
type DashboardHomeProps = {
|
||||||
|
alertTriggers?: DashboardAlertTrigger[];
|
||||||
|
alertsHref: string;
|
||||||
|
aiBoardItems?: ElderAiAnalysisBoardItem[];
|
||||||
bedsHref: string;
|
bedsHref: string;
|
||||||
careHref: string;
|
careHref: string;
|
||||||
careTasks: DashboardCareTask[];
|
careTasks?: DashboardCareTask[];
|
||||||
|
deviceTickets?: DashboardDeviceTicket[];
|
||||||
|
devicesHref: string;
|
||||||
|
eldersHref: string;
|
||||||
emergencyHref: string;
|
emergencyHref: string;
|
||||||
emergencyIncidents: DashboardIncident[];
|
emergencyIncidents?: DashboardIncident[];
|
||||||
|
familyFeedback?: DashboardFamilyFeedback[];
|
||||||
|
familyHref: string;
|
||||||
|
familyVisits?: DashboardFamilyVisit[];
|
||||||
healthHref: string;
|
healthHref: string;
|
||||||
healthReviews: DashboardHealthReview[];
|
healthReviews?: DashboardHealthReview[];
|
||||||
metrics: DashboardMetric[];
|
metrics: DashboardMetric[];
|
||||||
occupancyData: BedOccupancyDatum[];
|
notices?: DashboardNotice[];
|
||||||
recentAdmissions: DashboardAdmission[];
|
noticesHref: string;
|
||||||
incidents: DashboardIncident[];
|
occupancyData?: BedOccupancyDatum[];
|
||||||
|
recentAdmissions?: DashboardAdmission[];
|
||||||
|
incidents?: DashboardIncident[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
|
||||||
admissions: CheckCircle2,
|
admissions: CheckCircle2,
|
||||||
|
ai: BrainCircuit,
|
||||||
|
alerts: ShieldAlert,
|
||||||
beds: BedDouble,
|
beds: BedDouble,
|
||||||
care: ListChecks,
|
care: ListChecks,
|
||||||
|
devices: TabletSmartphone,
|
||||||
|
family: Users,
|
||||||
health: HeartPulse,
|
health: HeartPulse,
|
||||||
incidents: HeartPulse,
|
incidents: HeartPulse,
|
||||||
|
notices: Megaphone,
|
||||||
users: Users,
|
users: Users,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,19 +171,72 @@ function incidentVariant(severity: IncidentSeverity): "secondary" | "warning" |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const analysisRiskLabels: Record<ElderAiRiskLevel, string> = {
|
||||||
|
critical: "高危",
|
||||||
|
high: "高风险",
|
||||||
|
low: "低风险",
|
||||||
|
medium: "中风险",
|
||||||
|
unknown: "未知",
|
||||||
|
};
|
||||||
|
|
||||||
|
function analysisRiskVariant(risk: ElderAiRiskLevel): "danger" | "secondary" | "success" | "warning" {
|
||||||
|
switch (risk) {
|
||||||
|
case "critical":
|
||||||
|
return "danger";
|
||||||
|
case "high":
|
||||||
|
case "medium":
|
||||||
|
return "warning";
|
||||||
|
case "low":
|
||||||
|
return "success";
|
||||||
|
case "unknown":
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function DashboardHome({
|
export function DashboardHome({
|
||||||
|
alertTriggers,
|
||||||
|
alertsHref,
|
||||||
|
aiBoardItems,
|
||||||
bedsHref,
|
bedsHref,
|
||||||
careHref,
|
careHref,
|
||||||
careTasks,
|
careTasks,
|
||||||
|
deviceTickets,
|
||||||
|
devicesHref,
|
||||||
|
eldersHref,
|
||||||
emergencyHref,
|
emergencyHref,
|
||||||
emergencyIncidents,
|
emergencyIncidents,
|
||||||
|
familyFeedback,
|
||||||
|
familyHref,
|
||||||
|
familyVisits,
|
||||||
healthHref,
|
healthHref,
|
||||||
healthReviews,
|
healthReviews,
|
||||||
metrics,
|
metrics,
|
||||||
|
notices,
|
||||||
|
noticesHref,
|
||||||
occupancyData,
|
occupancyData,
|
||||||
recentAdmissions,
|
recentAdmissions,
|
||||||
incidents,
|
incidents,
|
||||||
}: DashboardHomeProps): React.ReactElement {
|
}: DashboardHomeProps): React.ReactElement {
|
||||||
|
const familyQueueItems: QueueItem[] = [
|
||||||
|
...(familyVisits ?? []).map((visit) => ({
|
||||||
|
id: `visit-${visit.id}`,
|
||||||
|
title: `${visit.elderName} 探访`,
|
||||||
|
detail: `${visit.contactName} / ${formatDateTime(visit.scheduledAt)}`,
|
||||||
|
badge: FAMILY_VISIT_STATUS_LABELS[visit.status],
|
||||||
|
variant: visit.status === "requested" ? "warning" as const : "secondary" as const,
|
||||||
|
meta: "探访",
|
||||||
|
})),
|
||||||
|
...(familyFeedback ?? []).map((feedback) => ({
|
||||||
|
id: `feedback-${feedback.id}`,
|
||||||
|
title: `${feedback.elderName} 家属反馈`,
|
||||||
|
detail: formatDateTime(feedback.updatedAt),
|
||||||
|
badge: FAMILY_FEEDBACK_STATUS_LABELS[feedback.status],
|
||||||
|
variant: feedback.status === "open" || feedback.status === "in_progress" ? "warning" as const : "secondary" as const,
|
||||||
|
meta: "反馈",
|
||||||
|
})),
|
||||||
|
].slice(0, 6);
|
||||||
|
const hasCoreQueues = careTasks !== undefined || healthReviews !== undefined || emergencyIncidents !== undefined;
|
||||||
|
const hasCollaborationQueues = deviceTickets !== undefined || notices !== undefined || alertTriggers !== undefined || familyVisits !== undefined || familyFeedback !== undefined;
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6">
|
||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
@@ -148,7 +265,9 @@ export function DashboardHome({
|
|||||||
})}
|
})}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{hasCoreQueues ? (
|
||||||
<section className="grid gap-4 xl:grid-cols-3">
|
<section className="grid gap-4 xl:grid-cols-3">
|
||||||
|
{careTasks !== undefined ? (
|
||||||
<QueueCard
|
<QueueCard
|
||||||
emptyText="暂无待处理护理任务"
|
emptyText="暂无待处理护理任务"
|
||||||
href={careHref}
|
href={careHref}
|
||||||
@@ -162,6 +281,8 @@ export function DashboardHome({
|
|||||||
}))}
|
}))}
|
||||||
title="护理待办"
|
title="护理待办"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{healthReviews !== undefined ? (
|
||||||
<QueueCard
|
<QueueCard
|
||||||
emptyText="暂无待复核健康异常"
|
emptyText="暂无待复核健康异常"
|
||||||
href={healthHref}
|
href={healthHref}
|
||||||
@@ -175,6 +296,8 @@ export function DashboardHome({
|
|||||||
}))}
|
}))}
|
||||||
title="健康复核"
|
title="健康复核"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{emergencyIncidents !== undefined ? (
|
||||||
<QueueCard
|
<QueueCard
|
||||||
emptyText="暂无待处理应急事件"
|
emptyText="暂无待处理应急事件"
|
||||||
href={emergencyHref}
|
href={emergencyHref}
|
||||||
@@ -188,9 +311,71 @@ export function DashboardHome({
|
|||||||
}))}
|
}))}
|
||||||
title="安全应急"
|
title="安全应急"
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasCollaborationQueues ? (
|
||||||
|
<section className="grid gap-4 xl:grid-cols-4">
|
||||||
|
{deviceTickets !== undefined ? (
|
||||||
|
<QueueCard
|
||||||
|
emptyText="暂无待处理维修工单"
|
||||||
|
href={devicesHref}
|
||||||
|
items={deviceTickets.map((ticket) => ({
|
||||||
|
id: ticket.id,
|
||||||
|
title: ticket.title,
|
||||||
|
detail: `${ticket.deviceName} / ${formatDateTime(ticket.updatedAt)}`,
|
||||||
|
badge: MAINTENANCE_TICKET_STATUS_LABELS[ticket.status],
|
||||||
|
variant: ticket.priority === "urgent" ? "danger" : ticket.priority === "high" ? "warning" : "secondary",
|
||||||
|
meta: MAINTENANCE_TICKET_PRIORITY_LABELS[ticket.priority],
|
||||||
|
}))}
|
||||||
|
title="设备工单"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{notices !== undefined ? (
|
||||||
|
<QueueCard
|
||||||
|
emptyText="暂无公告"
|
||||||
|
href={noticesHref}
|
||||||
|
items={notices.map((notice) => ({
|
||||||
|
id: notice.id,
|
||||||
|
title: notice.title,
|
||||||
|
detail: `${notice.audience} / ${formatDateTime(notice.updatedAt)}`,
|
||||||
|
badge: NOTICE_STATUS_LABELS[notice.status],
|
||||||
|
variant: notice.status === "published" ? "success" : "secondary",
|
||||||
|
meta: "公告",
|
||||||
|
}))}
|
||||||
|
title="公告通知"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{alertTriggers !== undefined ? (
|
||||||
|
<QueueCard
|
||||||
|
emptyText="暂无预警记录"
|
||||||
|
href={alertsHref}
|
||||||
|
items={alertTriggers.map((trigger) => ({
|
||||||
|
id: trigger.id,
|
||||||
|
title: trigger.title,
|
||||||
|
detail: `${trigger.elderName || "公共区域"} / ${formatDateTime(trigger.updatedAt)}`,
|
||||||
|
badge: ALERT_TRIGGER_STATUS_LABELS[trigger.status],
|
||||||
|
variant: trigger.status === "open" ? "danger" : trigger.status === "acknowledged" ? "warning" : "secondary",
|
||||||
|
meta: "预警",
|
||||||
|
}))}
|
||||||
|
title="规则预警"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{familyVisits !== undefined || familyFeedback !== undefined ? (
|
||||||
|
<QueueCard
|
||||||
|
emptyText="暂无家属协同事项"
|
||||||
|
href={familyHref}
|
||||||
|
items={familyQueueItems}
|
||||||
|
title="家属服务"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{occupancyData !== undefined || recentAdmissions !== undefined ? (
|
||||||
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
<section className="grid gap-4 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
{occupancyData !== undefined ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -207,7 +392,9 @@ export function DashboardHome({
|
|||||||
</Link>
|
</Link>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{recentAdmissions !== undefined ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>最近入住记录</CardTitle>
|
<CardTitle>最近入住记录</CardTitle>
|
||||||
@@ -250,8 +437,52 @@ export function DashboardHome({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{aiBoardItems !== undefined ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<BrainCircuit className="size-5 text-primary" aria-hidden="true" />
|
||||||
|
智能分析
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>汇总老人照护分析历史,并按数据权限展示可查看内容。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Link className="text-sm font-medium text-primary hover:underline" href={eldersHref}>
|
||||||
|
老人档案
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{aiBoardItems.map((item) => (
|
||||||
|
<Link className="rounded-md border p-3 hover:bg-secondary/60" href={eldersHref} key={item.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{item.elderName}</p>
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{item.restricted ? "结果受限:缺少一个或多个数据范围权限" : item.result?.summary ?? item.errorReason ?? "暂无摘要"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
{item.dataScopes.join(" / ")} / {formatDateTime(item.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
<Badge variant={item.status === "failed" ? "danger" : "success"}>{item.status === "failed" ? "失败" : "已完成"}</Badge>
|
||||||
|
{!item.restricted && item.result ? (
|
||||||
|
<Badge variant={analysisRiskVariant(item.result.overallRiskLevel)}>{analysisRiskLabels[item.result.overallRiskLevel]}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{aiBoardItems.length === 0 ? <p className="text-sm text-muted-foreground">暂无智能分析记录</p> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{incidents !== undefined ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>未关闭故障</CardTitle>
|
<CardTitle>未关闭故障</CardTitle>
|
||||||
@@ -274,6 +505,7 @@ export function DashboardHome({
|
|||||||
{incidents.length === 0 ? <p className="text-sm text-muted-foreground">暂无未关闭故障</p> : null}
|
{incidents.length === 0 ? <p className="text-sm text-muted-foreground">暂无未关闭故障</p> : null}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,7 +516,7 @@ type QueueItem = {
|
|||||||
detail: string;
|
detail: string;
|
||||||
meta: string;
|
meta: string;
|
||||||
title: string;
|
title: string;
|
||||||
variant: "danger" | "secondary" | "warning";
|
variant: "danger" | "secondary" | "success" | "warning";
|
||||||
};
|
};
|
||||||
|
|
||||||
function QueueCard({
|
function QueueCard({
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
|||||||
<label className="relative block">
|
<label className="relative block">
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
aria-label="搜索老人档案"
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
onChange={(event) => updateQuery(event.target.value)}
|
onChange={(event) => updateQuery(event.target.value)}
|
||||||
placeholder="搜索姓名、床位、联系人"
|
placeholder="搜索姓名、床位、联系人"
|
||||||
@@ -412,7 +413,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Brain className="size-4" aria-hidden="true" />
|
<Brain className="size-4" aria-hidden="true" />
|
||||||
AI 分析
|
智能分析
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ListChecks,
|
ListChecks,
|
||||||
LockKeyhole,
|
LockKeyhole,
|
||||||
Radio,
|
Radio,
|
||||||
|
ReceiptText,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
TabletSmartphone,
|
TabletSmartphone,
|
||||||
@@ -32,6 +33,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
|
|||||||
alerts: Radio,
|
alerts: Radio,
|
||||||
audit: ListChecks,
|
audit: ListChecks,
|
||||||
beds: BedDouble,
|
beds: BedDouble,
|
||||||
|
billing: ReceiptText,
|
||||||
care: ClipboardCheck,
|
care: ClipboardCheck,
|
||||||
dashboard: LayoutDashboard,
|
dashboard: LayoutDashboard,
|
||||||
devices: TabletSmartphone,
|
devices: TabletSmartphone,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type NavIconKey =
|
|||||||
| "alerts"
|
| "alerts"
|
||||||
| "audit"
|
| "audit"
|
||||||
| "beds"
|
| "beds"
|
||||||
|
| "billing"
|
||||||
| "care"
|
| "care"
|
||||||
| "dashboard"
|
| "dashboard"
|
||||||
| "devices"
|
| "devices"
|
||||||
@@ -39,7 +40,7 @@ export const navGroups: NavGroup[] = [
|
|||||||
path: "/dashboard",
|
path: "/dashboard",
|
||||||
label: "运营看板",
|
label: "运营看板",
|
||||||
icon: "dashboard",
|
icon: "dashboard",
|
||||||
anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read"],
|
anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read", "care:read", "health:read", "device:read", "notice:read", "alert:read", "family:read", "ai:read"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/elders",
|
path: "/elders",
|
||||||
@@ -53,6 +54,12 @@ export const navGroups: NavGroup[] = [
|
|||||||
icon: "beds",
|
icon: "beds",
|
||||||
anyPermissions: ["facility:read", "admission:read"],
|
anyPermissions: ["facility:read", "admission:read"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/billing",
|
||||||
|
label: "费用管理",
|
||||||
|
icon: "billing",
|
||||||
|
permission: "admission:manage",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/health",
|
path: "/health",
|
||||||
label: "健康照护",
|
label: "健康照护",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const WORKSPACE_SECTION_KEYS = [
|
|||||||
"dashboard",
|
"dashboard",
|
||||||
"elders",
|
"elders",
|
||||||
"beds",
|
"beds",
|
||||||
|
"billing",
|
||||||
"health",
|
"health",
|
||||||
"care",
|
"care",
|
||||||
"emergency",
|
"emergency",
|
||||||
|
|||||||
Reference in New Issue
Block a user