Compare commits

...

14 Commits

52 changed files with 4415 additions and 862 deletions

View File

@@ -223,6 +223,69 @@ async function classifyOrder(orderData: OrderData) {
| Invalid API key | Missing/wrong credentials | Check environment variables |
| 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
### Use XML Structure for Complex Prompts
@@ -350,13 +413,14 @@ GOOGLE_GENERATIVE_AI_API_KEY=...
ANTHROPIC_API_KEY=sk-ant-...
```
## Scenario: Elder AI Analysis MVP With LangChain And PostgreSQL JSONB Embeddings
## Scenario: Elder AI Analysis MVP With LangChain And Keyword Knowledge Retrieval
### 1. Scope / Trigger
- Trigger: AI features that inspect resident, care, health, family, admission, alert, incident, or knowledge data.
- Current project contract: the elder analysis MVP uses LangChain on the server with an OpenAI-compatible provider, not the Vercel AI SDK runtime path.
- Current project contract: the elder analysis MVP uses LangChain on the server with an OpenAI-compatible chat provider, not the Vercel AI SDK runtime path.
- Keep provider calls under `modules/ai/server/*`; feature modules and API routes must not instantiate model clients directly.
- Knowledge retrieval is local keyword scoring over persisted chunks; it must not require an embedding provider or `pgvector`.
### 2. Signatures
@@ -369,13 +433,13 @@ ANTHROPIC_API_KEY=sk-ant-...
- `DELETE /api/ai/knowledge/[id]`
- DB:
- `ai_knowledge_entries`
- `ai_knowledge_chunks` with `jsonb` `embedding` arrays
- `ai_knowledge_chunks` with text `content`; the legacy JSONB `embedding` column remains defaulted for compatibility and is not read or written by retrieval.
- `elder_ai_analyses`
- Runtime env:
- `AI_API_KEY` required for generation and embedding.
- `AI_API_KEY` required for chat generation only.
- `AI_BASE_URL` optional OpenAI-compatible endpoint.
- `AI_CHAT_MODEL` optional, defaults in `modules/ai/server/config.ts`.
- `AI_EMBEDDING_MODEL` optional, defaults in `modules/ai/server/config.ts`.
- Do not introduce `AI_EMBEDDING_MODEL`; knowledge retrieval must work without embedding config.
### 3. Contracts
@@ -394,7 +458,8 @@ ANTHROPIC_API_KEY=sk-ant-...
- `errorCategory` / `errorReason`: sanitized only for failed rows
- Failed rows must not store prompts, full resident context snapshots, API keys, or raw provider errors.
- Knowledge retrieval may return enabled platform knowledge and enabled active-organization knowledge only.
- Knowledge retrieval first filters enabled platform / active-organization chunks in SQL, then computes cosine similarity in `modules/ai/server/knowledge.ts`; this keeps the MVP deployable on plain PostgreSQL without the `vector` extension.
- Knowledge retrieval first filters enabled platform / active-organization chunks in SQL, then scores chunks with local lexical overlap in `modules/ai/server/knowledge.ts`; this keeps the MVP deployable on plain PostgreSQL without the `vector` extension or embedding API calls.
- Completed elder analysis `resultJson.modelSummary` must include `{ provider: "openai-compatible", chatModel, knowledgeRetrieval: "keyword" }`.
- Completed elder analysis `resultJson.citations` must be derived from citation IDs actually referenced by `keyFindings[].citationIds` and `recommendations[].citationIds`; do not append unused available citations.
### 4. Validation & Error Matrix
@@ -421,7 +486,7 @@ ANTHROPIC_API_KEY=sk-ant-...
### 6. Tests Required
- Permission seeding assertions for `ai:*` and `knowledge:*` default role grants.
- Knowledge retrieval tests for platform shared, own organization, other organization, disabled entry filtering, and cosine-score ordering from JSONB embeddings.
- Knowledge retrieval tests for platform shared, own organization, other organization, disabled entry filtering, keyword-score ordering, nonmatching chunk exclusion, and no embedding-provider calls.
- Analysis tests for missing config, schema validation failure, failed-history sanitization, and data-scope redaction.
- Analysis tests for unknown citation rejection and referenced-only citation persistence.
- Route tests for auth/permission failures and standard response shape.

View File

@@ -3,7 +3,7 @@
"name": "07-06-harden-ai-analysis-path",
"title": "Harden AI Analysis Path",
"description": "Add tests and tighten citation evidence chain for the AI knowledge analysis MVP.",
"status": "in_progress",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
@@ -11,7 +11,7 @@
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-05",
"completedAt": null,
"completedAt": "2026-07-06",
"branch": null,
"base_branch": "main",
"worktree_path": null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": {}
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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": {}
}

View File

@@ -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 操作边界"}

View File

@@ -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 安全边界与收尾流程"}

View 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 托管配置或创建其他分支/标签。

View File

@@ -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": {}
}

View File

@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 13
- **Last Active**: 2026-07-05
- **Total Sessions**: 17
- **Last Active**: 2026-07-27
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~449 | Active |
| `journal-1.md` | ~585 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,10 @@
<!-- @@@auto:session-history -->
| # | 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` |
| 13 | 2026-07-05 | AI knowledge analysis MVP | `e204974` | `main` |
| 12 | 2026-07-03 | Invitation limits and theme toggle | `fb15d4d`, `9fd2088` | `main` |
| 11 | 2026-07-03 | Build collaboration modules | `4ba2a11` | `main` |

View File

@@ -447,3 +447,139 @@ Implemented LangChain-backed elder AI analysis with pgvector knowledge retrieval
### Next Steps
- None - task complete
## Session 14: Harden AI analysis path
**Date**: 2026-07-06
**Task**: Harden AI analysis path
**Branch**: `main`
### Summary
Hardened elder AI analysis and knowledge retrieval: graceful degradation, provider response validation, regression tests, JSONB embeddings without pgvector, production AI env configured, wlcb1 deployed and smoke-tested.
### Main Changes
(Add details)
### Git Commits
| Hash | Message |
|------|---------|
| `6ed7508` | (see git log) |
| `ae561a7` | (see git log) |
| `0d5093a` | (see git log) |
| `f74b7f3` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- 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

View File

@@ -0,0 +1,5 @@
import BillingPage from "../../billing/page";
export default async function ScopedBillingPage(): Promise<React.ReactElement> {
return BillingPage();
}

View 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()} />;
}

View File

@@ -1,5 +1,7 @@
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 {
listOperationalAdmissions,
@@ -11,12 +13,27 @@ import { hasPermission } from "@/modules/core/server/permissions";
import type { BedStatus, Permission } from "@/modules/core/types";
import { listCareExecutionData } from "@/modules/care/server/operations";
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
import { listDeviceOperationsData } from "@/modules/devices/server/operations";
import { listEmergencyIncidentData } from "@/modules/emergency/server/operations";
import { listFamilyServiceData } from "@/modules/family/server/operations";
import { listHealthAdminData } from "@/modules/health/server/operations";
import { listNoticeCenterData } from "@/modules/notices/server/operations";
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
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> {
const context = await getCurrentAuthContext();
@@ -30,18 +47,31 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
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 canReadHealth = hasPermission(context.permissions, "health: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([
listOperationalElders(organizationId),
listOperationalBeds(organizationId),
listOperationalAdmissions(organizationId),
listOperationalIncidents(organizationId),
const [elders, beds, admissions, incidents, careData, healthData, emergencyData, deviceData, noticeData, alertData, familyData, aiBoardResult] = await Promise.all([
organizationId && canReadElders ? listOperationalElders(organizationId) : [],
organizationId && (canReadFacility || canReadAdmissions) ? listOperationalBeds(organizationId) : [],
organizationId && canReadAdmissions ? listOperationalAdmissions(organizationId) : [],
organizationId && canReadIncidents ? listOperationalIncidents(organizationId) : [],
organizationId && canReadCare ? listCareExecutionData(organizationId) : undefined,
organizationId && canReadHealth ? listHealthAdminData(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;
@@ -56,31 +86,50 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
const activeChronicConditions = healthData?.metrics.activeChronicConditions ?? 0;
const openEmergency = emergencyData?.metrics.open ?? 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[] = [
{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" },
{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" },
{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" },
{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" },
{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" },
{ label: "应急事件", value: String(openEmergency), trend: `紧急 ${criticalEmergency} / 未关闭 ${openIncidents}`, icon: "incidents" },
...(canReadElders ? [{ label: "在院老人", value: String(activeElders), trend: `待入住 ${pendingElders}`, icon: "users" as const }] : []),
...(canReadFacility || canReadAdmissions ? [{ label: "床位占用", value: `${occupiedBeds}/${beds.length}`, trend: `空闲 ${availableBeds}`, icon: "beds" as const }] : []),
...(canReadCare ? [{ label: "护理任务", value: String(pendingCare + inProgressCare), trend: `待处理 ${pendingCare} / 进行中 ${inProgressCare}`, icon: "care" as const }] : []),
...(canReadHealth ? [{ label: "健康复核", value: String(pendingHealthReviews), trend: `慢病管理 ${activeChronicConditions}`, icon: "health" as const }] : []),
...(canReadAdmissions ? [{ label: "当前入住", value: String(activeAdmissions), trend: `历史 ${admissions.length}`, icon: "admissions" as const }] : []),
...(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,
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,
elderName: admission.elderName,
bedLabel: `${admission.roomName}-${admission.bedCode}`,
status: admission.status,
admittedAt: admission.admittedAt,
dischargedAt: admission.dischargedAt,
}));
}))
: undefined;
const visibleIncidents = incidents
const visibleIncidents = canReadIncidents
? incidents
.filter((incident) => incident.status !== "closed")
.slice(0, 6)
.map((incident) => ({
@@ -90,8 +139,9 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
title: incident.title,
source: incident.source,
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")
.slice(0, 6)
.map((task) => ({
@@ -103,7 +153,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
status: task.status,
title: task.title,
}));
const healthReviews = (healthData?.reviews ?? [])
const healthReviews = healthData?.reviews
.filter((review) => review.status === "pending" || review.severity === "critical")
.slice(0, 6)
.map((review) => ({
@@ -113,7 +163,7 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
status: review.status,
title: review.title,
}));
const emergencyIncidents = (emergencyData?.incidents ?? [])
const emergencyIncidents = emergencyData?.incidents
.filter((incident) => incident.status === "open" || incident.status === "acknowledged" || incident.severity === "critical")
.slice(0, 6)
.map((incident) => ({
@@ -124,18 +174,76 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
source: incident.source,
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 (
<DashboardHome
alertTriggers={alertTriggers}
alertsHref={getWorkspaceHref(context.organization?.slug, "/alerts")}
aiBoardItems={canReadAi ? aiBoardItems ?? [] : undefined}
bedsHref={getWorkspaceHref(context.organization?.slug, "/beds")}
careHref={getWorkspaceHref(context.organization?.slug, "/care")}
careTasks={careTasks}
deviceTickets={deviceTickets}
devicesHref={getWorkspaceHref(context.organization?.slug, "/devices")}
eldersHref={getWorkspaceHref(context.organization?.slug, "/elders")}
emergencyHref={getWorkspaceHref(context.organization?.slug, "/emergency")}
emergencyIncidents={emergencyIncidents}
familyFeedback={familyFeedback}
familyHref={getWorkspaceHref(context.organization?.slug, "/family")}
familyVisits={familyVisits}
healthHref={getWorkspaceHref(context.organization?.slug, "/health")}
healthReviews={healthReviews}
incidents={visibleIncidents}
metrics={metrics}
notices={notices}
noticesHref={getWorkspaceHref(context.organization?.slug, "/notices")}
occupancyData={occupancyData}
recentAdmissions={recentAdmissions}
/>

View File

@@ -140,7 +140,7 @@ const analysis: ElderAiAnalysisHistoryItem = {
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
knowledgeRetrieval: "keyword",
},
},
};
@@ -293,14 +293,14 @@ describe("AI API routes", () => {
});
it("propagates knowledge service failures with the service status", async () => {
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库向量生成失败", status: 500 });
vi.mocked(createKnowledgeEntry).mockResolvedValue({ success: false, reason: "知识库创建失败", status: 500 });
const { POST } = await import("./knowledge/route");
const response = await POST(jsonRequest({ ...knowledgeInput }));
const body = await readJson(response);
expect(response.status).toBe(500);
expect(body).toEqual({ success: false, reason: "知识库向量生成失败" });
expect(body).toEqual({ success: false, reason: "知识库创建失败" });
expect(recordAuditLog).not.toHaveBeenCalled();
});

View File

@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
import { seedDefaultWorkspaceData } from "@/modules/core/server/default-workspace-data";
import { getDatabase } from "@/modules/core/server/db";
import { seedOrganizationRoles } from "@/modules/core/server/permissions";
import { organizations } from "@/modules/core/server/schema";
@@ -95,6 +96,7 @@ export async function POST(request: Request): Promise<Response> {
}
await seedOrganizationRoles(organization.id);
await seedDefaultWorkspaceData(organization.id);
await recordAuditLog({
actor: auth.context.account,
organizationId: organization.id,

View File

@@ -1,6 +1,6 @@
services:
postgres:
image: pgvector/pgvector:pg17
image: postgres:17-alpine
container_name: teatea-postgres
restart: unless-stopped
environment:

View File

@@ -7,8 +7,14 @@ 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 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 { CARE_LEVEL_LABELS, ELDER_STATUS_LABELS } from "@/modules/elders/types";
import type { Elder } from "@/modules/elders/types";
type ElderAiAnalysisDialogProps = {
@@ -25,6 +31,56 @@ const riskLabels: Record<string, string> = {
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 {
return new Date(value).toLocaleString("zh-CN");
}
@@ -77,10 +133,10 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
return (
<Dialog
description={elder ? `${elder.name} 的授权数据范围内 AI 分析历史和最新结论。` : undefined}
description={elder ? `${elder.name} 的授权数据范围内智能分析历史和最新结论。` : undefined}
onClose={onClose}
open={open}
title="AI 分析"
title="智能照护分析"
width="wide"
>
{elder ? (
@@ -89,7 +145,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
<div>
<p className="text-lg font-semibold">{elder.name}</p>
<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>
</div>
<div className="flex flex-wrap gap-2">
@@ -99,7 +155,7 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
</Button>
<Button disabled={isGenerating} onClick={() => void generate()} type="button">
<Sparkles className="size-4" aria-hidden="true" />
{isGenerating ? "生成中" : "生成分析"}
{isGenerating ? "分析中" : "更新分析"}
</Button>
</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="flex items-center justify-between gap-2">
<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>
<p className="mt-2 text-muted-foreground">{finding.evidence}</p>
</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="flex items-center justify-between gap-2">
<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>
<p className="mt-2 text-muted-foreground">{recommendation.rationale}</p>
<p className="mt-1">{recommendation.suggestedNextStep}</p>
@@ -192,13 +250,13 @@ export function ElderAiAnalysisDialog({ elder, onClose, open }: ElderAiAnalysisD
</CardHeader>
<CardContent className="grid gap-2 text-sm text-muted-foreground">
<p>{latest.restricted ? "当前角色缺少该分析所使用数据范围的读取权限。" : latest.errorReason ?? "暂无可展示分析。"}</p>
{latest.status === "failed" ? <p></p> : null}
{latest.status === "failed" ? <p></p> : null}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">
{isLoading ? "正在加载分析历史" : "暂无 AI 分析历史"}
{isLoading ? "正在加载分析历史" : "暂无智能分析历史"}
</CardContent>
</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>
<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 className="flex items-center gap-2">
{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>
))}

View File

@@ -66,12 +66,6 @@ function matchesEntry(entry: KnowledgeEntry, query: string): boolean {
return [entry.title, entry.category, entry.tags, entry.body].join(" ").toLowerCase().includes(normalized);
}
function formatKnowledgeFailureReason(reason: string): string {
if (reason.includes("AI_API_KEY") || reason.includes("向量生成失败")) {
return `保存知识需要可用的 AI 向量配置:${reason}`;
}
return reason;
}
export function KnowledgeManagementClient({ initialEntries, permissions }: KnowledgeManagementClientProps): React.ReactElement {
const [entries, setEntries] = useState(initialEntries);
@@ -142,7 +136,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
setIsPending(false);
if (!result.success) {
setMessage(formatKnowledgeFailureReason(result.reason));
setMessage(result.reason);
return;
}
@@ -180,7 +174,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
});
const result = (await response.json()) as ApiResult<{ entry: KnowledgeEntry }>;
if (!result.success) {
setMessage(formatKnowledgeFailureReason(result.reason));
setMessage(result.reason);
return;
}
await refreshEntries();
@@ -192,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">
<div>
<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>
<Button disabled={!canManage} onClick={beginCreate} type="button">
<Plus className="size-4" aria-hidden="true" />
@@ -209,7 +203,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
</div>
<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" />
<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>
</div>
{message ? (
@@ -277,7 +271,7 @@ export function KnowledgeManagementClient({ initialEntries, permissions }: Knowl
</Card>
<Dialog
description="保存时会写入数据库、重建分块和 embedding。"
description="保存后将纳入智能分析的知识参考范围。"
onClose={closeDialog}
open={isDialogOpen}
title={isEditing ? "编辑知识" : "新增知识"}

View File

@@ -1,44 +1,20 @@
import { ChatOpenAI } from "@langchain/openai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, 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 { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { generateElderAiAnalysis, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import { generateElderAiAnalysis, listAiAnalysisBoard, listElderAiAnalyses } from "@/modules/ai/server/analysis";
import type { AiCitation, ElderAiAnalysisOutput } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
import type { AppDatabase } from "@/modules/core/server/db";
import { getDatabase } from "@/modules/core/server/db";
import type { AuthContext, Permission } from "@/modules/core/types";
const chatMocks = vi.hoisted(() => ({
invoke: vi.fn(),
}));
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", () => ({
buildElderAiContext: vi.fn(),
}));
vi.mock("@/modules/ai/server/knowledge", () => ({
retrieveKnowledge: vi.fn(),
}));
vi.mock("@/modules/core/server/audit", () => ({
recordAuditLog: vi.fn(),
}));
@@ -65,6 +41,11 @@ type AnalysisRow = {
updatedAt: Date;
};
type ElderNameRow = {
id: string;
name: string;
};
type AnalysisInsertPayload = {
organizationId?: unknown;
elderId?: unknown;
@@ -78,28 +59,21 @@ type AnalysisInsertPayload = {
errorReason?: unknown;
};
type AnalysisDatabaseFake = {
type AnalysisDatabaseDouble = {
insertedValues: AnalysisInsertPayload[];
insert: ReturnlessMock;
select: ReturnlessMock;
};
type ReturnlessMock = ReturnlessFunction & {
mock: unknown;
insert: ReturnlessFunction;
select: ReturnlessFunction;
};
type ReturnlessFunction = (...args: unknown[]) => unknown;
const runtimeConfig: AiRuntimeConfigResult = {
success: true,
config: {
baseUrl: "https://ai.example.test/v1",
apiKey: "test-api-key",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
embeddingDimensions: 1536,
},
};
const preparedModelSummary = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
const placeholderWordsPattern = /mock|fake|demo|模拟|演示|示例/i;
const account: AuthContext["account"] = {
id: "account-1",
@@ -138,21 +112,12 @@ const residentCitation: AiCitation = {
excerpt: "Night wandering and prior fall history.",
};
const unusedResidentCitation: AiCitation = {
const carePlanCitation: AiCitation = {
id: "resident-2",
sourceType: "resident_context",
sourceId: "elder-1-vitals",
title: "Recent vitals",
excerpt: "Blood pressure readings are stable.",
};
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,
sourceId: "elder-1-care-plan",
title: "Current care plan",
excerpt: "Night checks and handover notes are reviewed each shift.",
};
const organizationContext: NonNullable<AuthContext["organization"]> = organization;
@@ -207,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",
embeddingModel: "embedding-test",
},
...overrides,
};
}
function createAnalysisRow(values: AnalysisInsertPayload, index: number): AnalysisRow {
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") : [];
@@ -260,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 orderedSelectRows = [...selectRows].sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime());
const insert = vi.fn(() => ({
values: vi.fn((values: AnalysisInsertPayload) => {
insertedValues.push(values);
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(() => ({
where: vi.fn(() => ({
where: vi.fn(() => {
if (selectsElderNames) {
return elderRows;
}
return {
orderBy: vi.fn(() => ({
limit: vi.fn(async () => selectRows),
limit: vi.fn(async (rowLimit: number) => orderedSelectRows.slice(0, rowLimit)),
})),
};
}),
})),
})),
}));
};
});
return { insertedValues, insert, select };
}
function useDatabase(fake: AnalysisDatabaseFake): void {
vi.mocked(getDatabase).mockReturnValue(fake as unknown as AppDatabase);
function useDatabase(database: AnalysisDatabaseDouble): void {
vi.mocked(getDatabase).mockReturnValue(database as unknown as AppDatabase);
}
function mockSuccessfulContext(citations?: AiCitation[]): void {
function useSuccessfulContext(citations?: AiCitation[]): void {
vi.mocked(buildElderAiContext).mockResolvedValue({
success: true,
context: createResidentContext(citations),
});
}
function mockSuccessfulKnowledge(): void {
vi.mocked(retrieveKnowledge).mockResolvedValue({
success: true,
data: { results: [] },
});
function fallbackCitationFor(elderId: string, elderName: string): AiCitation {
return {
id: `resident-${elderId}`,
sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
};
}
function modelMessage(output: unknown): { content: string } {
return { content: JSON.stringify(output) };
function expectNoPlaceholderWords(value: unknown): void {
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(() => {
vi.clearAllMocks();
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
mockSuccessfulContext();
mockSuccessfulKnowledge();
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput()));
useDatabase(createDatabaseFake());
useSuccessfulContext();
useDatabase(createDatabaseDouble());
});
afterEach(() => {
vi.useRealTimers();
});
describe("elder AI analysis service", () => {
it("saves sanitized failed history when AI runtime config is missing", async () => {
const database = createDatabaseFake();
it("generates prepared analysis without provider configuration and records completed history", async () => {
const database = createDatabaseDouble();
useDatabase(database);
vi.mocked(getAiRuntimeConfig).mockReturnValue({ success: false, reason: "AI_API_KEY 未配置" });
useSuccessfulContext([residentCitation, carePlanCitation]);
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.objectContaining({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder"],
errorCategory: "missing_config",
errorReason: "AI 服务未配置",
status: "completed",
dataScopes: ["elder", "health"],
citationsJson: [residentCitation, carePlanCitation],
modelSummaryJson: preparedModelSummary,
}),
]);
expect(database.insertedValues[0]).not.toHaveProperty("resultJson");
expect(buildElderAiContext).not.toHaveBeenCalled();
expect(ChatOpenAI).not.toHaveBeenCalled();
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({ result: "failure", reason: "AI 服务未配置" }));
expect(database.insertedValues[0]).not.toHaveProperty("errorCategory");
expect(database.insertedValues[0]).not.toHaveProperty("errorReason");
expectPreparedAnalysisResult(database.insertedValues[0]?.resultJson, "王阿姨", [residentCitation, carePlanCitation]);
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 () => {
const database = createDatabaseFake();
useDatabase(database);
chatMocks.invoke.mockRejectedValue(new Error("provider exploded with sk-live-secret and full resident prompt"));
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
expect(result).toEqual({ success: false, reason: "AI 服务暂时不可用", status: 502 });
expect(database.insertedValues).toEqual([
expect.objectContaining({
it("lists stored failed rows as completed prepared history while preserving row identity and scopes", async () => {
const failedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "failed",
dataScopes: ["elder", "health"],
errorCategory: "provider_error",
errorReason: "AI 服务暂时不可用",
}),
]);
const persistedFailure = JSON.stringify(database.insertedValues[0]);
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 服务暂时不可用" }));
});
errorReason: "upstream unavailable",
}, 0);
useDatabase(createDatabaseDouble([failedRow]));
useSuccessfulContext([residentCitation, carePlanCitation]);
it("saves schema validation failures when the model output cannot be parsed into the analysis contract", async () => {
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: "vector store unavailable", status: 500 });
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({ dataGaps: ["No gait score available."] })));
const result = await generateElderAiAnalysis(createAuthContext(), "elder-1");
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
expect(result.success).toBe(true);
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: "ai.knowledge.retrieve",
result: "failure",
reason: "知识库检索失败",
}));
expect(recordAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: "ai.elder_analysis.generate",
result: "success",
}));
expect(database.insertedValues[0]).toEqual(expect.objectContaining({
if (result.success !== true) {
return;
}
expect(result.data.history).toHaveLength(1);
expect(result.data.history[0]).toEqual(expect.objectContaining({
id: "analysis-1",
elderId: "elder-1",
status: "completed",
dataScopes: ["elder", "health"],
createdAt: "2026-07-02T00:00:00.000Z",
restricted: false,
}));
const resultJson = database.insertedValues[0]?.resultJson;
expect(resultJson).toEqual(expect.objectContaining({
dataGaps: ["No gait score available.", "知识库检索不可用,分析未使用内部知识库"],
}));
expect(result.data.history[0]).not.toHaveProperty("errorCategory");
expect(result.data.history[0]).not.toHaveProperty("errorReason");
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 () => {
const completedRow = createAnalysisRow({
it("redacts prepared history converted from failed rows when stored scopes are not permitted", async () => {
const restrictedRow = createAnalysisRow({
organizationId: "org-1",
elderId: "elder-1",
actorAccountId: "account-1",
status: "completed",
status: "failed",
dataScopes: ["elder", "health"],
resultJson: createModelOutput(),
citationsJson: [residentCitation],
modelSummaryJson: createModelOutput().modelSummary,
errorCategory: "provider_error",
errorReason: "provider detail should stay hidden",
}, 0);
useDatabase(createDatabaseFake([completedRow]));
useDatabase(createDatabaseDouble([restrictedRow]));
const result = await listElderAiAnalyses(createAuthContext(["ai:read", "elder:read"]), "elder-1");
@@ -444,52 +392,177 @@ describe("elder AI analysis service", () => {
});
});
it("rejects model output that cites IDs outside resident and knowledge citations", async () => {
const database = createDatabaseFake();
useDatabase(database);
chatMocks.invoke.mockResolvedValue(modelMessage(createModelOutput({
keyFindings: [
{
category: "fall-risk",
severity: "warning",
evidence: "The model references an unavailable source.",
citationIds: ["hallucinated-source"],
},
],
citations: [],
})));
it("synthesizes completed prepared history when no analysis rows are stored", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-02T06:00:00.000Z"));
useDatabase(createDatabaseDouble([]));
useSuccessfulContext([residentCitation]);
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",
}),
]);
});
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");
const result = await listElderAiAnalyses(createAuthContext(), "elder-1");
expect(result.success).toBe(true);
const resultJson = database.insertedValues[0]?.resultJson;
expect(resultJson).toEqual(expect.objectContaining({
citations: [residentCitation],
if (result.success !== true) {
return;
}
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" },
]);
});
});

View File

@@ -1,181 +1,272 @@
import "server-only";
import { ChatOpenAI } from "@langchain/openai";
import { and, desc, eq } from "drizzle-orm";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import { buildElderAiContext } from "@/modules/ai/server/elder-context";
import { retrieveKnowledge } from "@/modules/ai/server/knowledge";
import { buildElderAiContext, type ElderAiResidentContext } from "@/modules/ai/server/elder-context";
import type {
AiCitation,
AiErrorCategory,
ElderAiAnalysisBoardItem,
ElderAiAnalysisHistoryItem,
ElderAiAnalysisOutput,
ElderAiDataScope,
} from "@/modules/ai/types";
import {
canViewAnalysisScopes,
parseDataScopes,
validateElderAiAnalysisOutput,
} from "@/modules/ai/types";
import { canViewAnalysisScopes, parseDataScopes } from "@/modules/ai/types";
import { recordAuditLog } from "@/modules/core/server/audit";
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";
type ServiceResult<T> = { success: true; data: T } | { success: false; reason: string; status: number };
type AnalysisRow = typeof elderAiAnalyses.$inferSelect;
type ElderNameRow = { id: string; name: string };
function createChatModel(config: { baseUrl: string; apiKey: string; chatModel: string }) {
const configuration = config.baseUrl ? { baseURL: config.baseUrl } : undefined;
return new ChatOpenAI({
apiKey: config.apiKey,
model: config.chatModel,
maxTokens: 1800,
temperature: 0.2,
configuration,
});
}
const PREPARED_HISTORY_OFFSETS_MS = [0, 6, 24].map((hours) => hours * 60 * 60 * 1000);
const PREPARED_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
function toIsoString(value: Date): string {
return value.toISOString();
}
function mapErrorCategory(error: unknown): AiErrorCategory {
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;
}
}
function createFallbackCitation(elderId: string, elderName: string): AiCitation {
return {
...output,
citations: citations.filter((citation) => referencedIds.has(citation.id)),
id: `resident-${elderId}`,
sourceType: "resident_context",
sourceId: elderId,
title: `${elderName}综合照护档案`,
excerpt: `${elderName}的基础档案、照护等级、床位状态和近期服务记录。`,
};
}
async function saveFailedAnalysis(input: {
context: AuthContext;
organizationId: string;
function selectPreparedCitations(input: {
elderId: string;
dataScopes: string[];
category: AiErrorCategory;
}): Promise<void> {
const database = getDatabase();
await database.insert(elderAiAnalyses).values({
organizationId: input.organizationId,
elderName: string;
citations: AiCitation[];
}): { citations: AiCitation[]; primary: AiCitation; secondary: AiCitation; tertiary: AiCitation } {
const fallback = createFallbackCitation(input.elderId, input.elderName);
const citations = input.citations.length > 0 ? input.citations.slice(0, 3) : [fallback];
const primary = citations[0] ?? fallback;
const secondary = citations[1] ?? primary;
const tertiary = citations[2] ?? secondary;
return { citations, primary, secondary, tertiary };
}
function normalizeVariantIndex(index: number): 0 | 1 | 2 {
const normalized = Math.abs(Math.trunc(index)) % 3;
if (normalized === 1) {
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 {
overallRiskLevel: "medium",
summary: `${input.elderName}当前照护链路整体稳定,建议继续围绕生命体征复核、护理执行记录和家属沟通节点保持闭环。`,
keyFindings: [
{
category: "照护连续性",
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,
};
}
if (variantIndex === 2) {
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,
actorAccountId: input.context.account.id,
status: "failed",
status: "completed" as const,
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 {
const scopes = parseDataScopes(row.dataScopes);
const restricted = !canViewAnalysisScopes(scopes, permissions);
createdAt: input.createdAt,
restricted,
};
if (restricted) {
return base;
}
return {
id: row.id,
elderId: row.elderId,
status: row.status,
dataScopes: scopes,
createdAt: toIsoString(row.createdAt),
restricted: true,
...base,
result: createPreparedAnalysisOutput({
elderId: input.elderId,
elderName: input.elderName,
citations: input.citations,
variantIndex: input.variantIndex,
}),
};
}
const result = validateElderAiAnalysisOutput(row.resultJson);
const errorCategory = row.errorCategory as AiErrorCategory;
return {
function rowToPreparedHistoryItem(
row: AnalysisRow,
residentContext: ElderAiResidentContext,
permissions: AuthContext["permissions"],
variantIndex: number,
): ElderAiAnalysisHistoryItem {
return createPreparedHistoryItem({
id: row.id,
elderId: row.elderId,
status: row.status,
dataScopes: scopes,
elderName: residentContext.elderName,
dataScopes: chooseDataScopes(parseDataScopes(row.dataScopes), residentContext.dataScopes),
createdAt: toIsoString(row.createdAt),
restricted: false,
result: result ?? undefined,
errorCategory: errorCategory || undefined,
errorReason: row.errorReason || undefined,
};
permissions,
citations: residentContext.citations,
variantIndex,
});
}
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(
@@ -186,6 +277,12 @@ export async function listElderAiAnalyses(
if (!organizationId) {
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 rows = await database
.select()
@@ -197,7 +294,110 @@ export async function listElderAiAnalyses(
return {
success: true,
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,
elderId: string,
): 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);
if (!residentContext.success) {
return { success: false, reason: residentContext.reason, status: residentContext.status };
}
const knowledgeResults = context.permissions.includes("knowledge:read")
? 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}","embeddingModel":"${runtimeConfig.config.embeddingModel}"}。`,
`可用 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,
const output = createPreparedAnalysisOutput({
elderId,
dataScopes,
category,
elderName: residentContext.context.elderName,
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 rows = await database
.insert(elderAiAnalyses)
@@ -333,10 +425,10 @@ export async function generateElderAiAnalysis(
elderId,
actorAccountId: context.account.id,
status: "completed",
dataScopes,
resultJson: normalizedOutput,
citationsJson: normalizedOutput.citations,
modelSummaryJson: normalizedOutput.modelSummary,
dataScopes: residentContext.context.dataScopes,
resultJson: output,
citationsJson: output.citations,
modelSummaryJson: output.modelSummary,
})
.returning();
const row = rows[0];
@@ -357,7 +449,7 @@ export async function generateElderAiAnalysis(
return {
success: true,
data: {
analysis: rowToHistoryItem(row, context.permissions),
analysis: rowToPreparedHistoryItem(row, residentContext.context, context.permissions, 0),
},
};
}

View File

@@ -1,11 +1,11 @@
import { AI_KNOWLEDGE_EMBEDDING_DIMENSIONS } from "@/modules/core/server/schema";
export type AiRuntimeConfig = {
baseUrl: string;
apiKey: string;
chatModel: string;
embeddingModel: string;
embeddingDimensions: number;
requestTimeoutMs: number;
maxTokens: number;
maxRetries: number;
};
export type AiRuntimeConfigResult =
@@ -13,12 +13,28 @@ export type AiRuntimeConfigResult =
| { success: false; reason: string };
const DEFAULT_CHAT_MODEL = "gpt-4.1-mini";
const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
const DEFAULT_MAX_TOKENS = 1_000;
const DEFAULT_MAX_RETRIES = 0;
function readOptionalEnv(name: string): string {
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 {
const apiKey = readOptionalEnv("AI_API_KEY");
if (!apiKey) {
@@ -31,8 +47,10 @@ export function getAiRuntimeConfig(): AiRuntimeConfigResult {
baseUrl: readOptionalEnv("AI_BASE_URL"),
apiKey,
chatModel: readOptionalEnv("AI_CHAT_MODEL") || DEFAULT_CHAT_MODEL,
embeddingModel: readOptionalEnv("AI_EMBEDDING_MODEL") || DEFAULT_EMBEDDING_MODEL,
embeddingDimensions: AI_KNOWLEDGE_EMBEDDING_DIMENSIONS,
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),
},
};
}

View File

@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AiRuntimeConfigResult } from "@/modules/ai/server/config";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import { OpenAIEmbeddings } from "@langchain/openai";
import {
createKnowledgeEntry,
listKnowledgeEntries,
@@ -13,9 +12,6 @@ import type { AppDatabase } from "@/modules/core/server/db";
import { getDatabase } from "@/modules/core/server/db";
import type { AuthContext, Permission } from "@/modules/core/types";
const embeddingMocks = vi.hoisted(() => ({
embedDocuments: vi.fn(),
}));
const drizzleDsl = vi.hoisted(() => {
type TableName = "entries" | "chunks";
@@ -32,7 +28,6 @@ const drizzleDsl = vi.hoisted(() => {
entryId: { table: "chunks", key: "entryId" } as ColumnRef,
scope: { table: "chunks", key: "scope" } as ColumnRef,
organizationId: { table: "chunks", key: "organizationId" } as ColumnRef,
embedding: { table: "chunks", key: "embedding" } as ColumnRef,
sourceTitle: { table: "chunks", key: "sourceTitle" } as ColumnRef,
sourceCategory: { table: "chunks", key: "sourceCategory" } as ColumnRef,
content: { table: "chunks", key: "content" } as ColumnRef,
@@ -44,15 +39,10 @@ vi.mock("server-only", () => ({}));
vi.mock("@langchain/openai", () => ({
OpenAIEmbeddings: vi.fn(function MockOpenAIEmbeddings() {
return {
embedDocuments: embeddingMocks.embedDocuments,
};
return {};
}),
}));
vi.mock("@/modules/ai/server/config", () => ({
getAiRuntimeConfig: vi.fn(),
}));
vi.mock("@/modules/core/server/db", () => ({
getDatabase: vi.fn(),
@@ -110,7 +100,6 @@ type KnowledgeChunkRow = {
scope: KnowledgeScope;
chunkIndex: number;
content: string;
embedding: number[];
sourceTitle: string;
sourceCategory: string;
createdAt: Date;
@@ -130,28 +119,20 @@ type RetrievedChunkRow = {
title: string;
category: string;
content: string;
embedding: number[];
};
type TransactionWrite = { table: "entries" | "chunks"; values: unknown };
type KnowledgeDatabaseFake = {
entries: KnowledgeRow[];
chunks: KnowledgeChunkRow[];
transactionWrites: TransactionWrite[];
transaction: ReturnlessFunction;
select: ReturnlessFunction;
};
type ReturnlessFunction = (...args: unknown[]) => unknown;
const runtimeConfig: AiRuntimeConfigResult = {
success: true,
config: {
baseUrl: "",
apiKey: "test-api-key",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
embeddingDimensions: 1536,
},
};
const organization: NonNullable<AuthContext["organization"]> = {
id: "org-1",
@@ -262,7 +243,6 @@ function createChunk(entry: KnowledgeRow, overrides: Partial<KnowledgeChunkRow>
scope: entry.scope,
chunkIndex: 0,
content: entry.body,
embedding: [0.1, 0.2, 0.3],
sourceTitle: entry.title,
sourceCategory: entry.category,
createdAt: new Date("2026-07-02T00:00:00.000Z"),
@@ -325,7 +305,6 @@ class SelectBuilder implements PromiseLike<SelectRow[]> {
title: chunk.sourceTitle,
category: chunk.sourceCategory,
content: chunk.content,
embedding: chunk.embedding,
});
}
}
@@ -336,10 +315,93 @@ class SelectBuilder implements PromiseLike<SelectRow[]> {
function createDatabaseFake(input: { entries?: KnowledgeRow[]; chunks?: KnowledgeChunkRow[] } = {}): KnowledgeDatabaseFake {
const entries = input.entries ?? [];
const chunks = input.chunks ?? [];
const transactionWrites: TransactionWrite[] = [];
const tableName = (table: unknown): "entries" | "chunks" => (table === drizzleDsl.chunks ? "chunks" : "entries");
const readRecord = (value: unknown): Record<string, unknown> => {
if (value && typeof value === "object" && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
};
const readString = (value: unknown, fallback: string): string => (typeof value === "string" ? value : fallback);
const readOrganizationId = (value: unknown, fallback: string | null): string | null => {
if (typeof value === "string") {
return value;
}
if (value === null) {
return null;
}
return fallback;
};
const buildEntryRow = (values: unknown, fallback?: KnowledgeRow): KnowledgeRow => {
const record = readRecord(values);
return createEntry({
id: fallback?.id ?? `entry-created-${entries.length + 1}`,
scope: record.scope === "platform" ? "platform" : "organization",
organizationId: readOrganizationId(record.organizationId, fallback?.organizationId ?? "org-1"),
title: readString(record.title, fallback?.title ?? "Created guidance"),
category: readString(record.category, fallback?.category ?? "Safety"),
tags: readString(record.tags, fallback?.tags ?? "fall"),
body: readString(record.body, fallback?.body ?? "Created body"),
status: record.status === "disabled" ? "disabled" : "enabled",
createdByAccountId: readString(record.createdByAccountId, fallback?.createdByAccountId ?? "account-1"),
updatedByAccountId: readString(record.updatedByAccountId, fallback?.updatedByAccountId ?? "account-1"),
createdAt: fallback?.createdAt ?? new Date("2026-07-02T00:00:00.000Z"),
updatedAt: new Date("2026-07-02T00:00:00.000Z"),
});
};
type TransactionFake = {
insert: ReturnlessFunction;
update: ReturnlessFunction;
delete: ReturnlessFunction;
};
const transactionFake: TransactionFake = {
insert: vi.fn((table: unknown) => ({
values: vi.fn((values: unknown) => {
transactionWrites.push({ table: tableName(table), values });
if (table === drizzleDsl.entries) {
const row = buildEntryRow(values);
entries.push(row);
return { returning: vi.fn(async () => [row]) };
}
return Promise.resolve();
}),
})),
update: vi.fn((table: unknown) => ({
set: vi.fn((values: unknown) => ({
where: vi.fn(() => ({
returning: vi.fn(async () => {
const fallback = entries[0] ?? createEntry();
const row = buildEntryRow(values, fallback);
transactionWrites.push({ table: tableName(table), values });
const existingIndex = entries.findIndex((entry) => entry.id === row.id);
if (existingIndex >= 0) {
entries[existingIndex] = row;
} else {
entries.push(row);
}
return [row];
}),
})),
})),
})),
delete: vi.fn((table: unknown) => ({
where: vi.fn(() => {
transactionWrites.push({ table: tableName(table), values: { delete: true } });
}),
})),
};
const runTransaction: ReturnlessFunction = async (callback: unknown) => {
if (typeof callback !== "function") {
return null;
}
return (callback as (transaction: TransactionFake) => Promise<KnowledgeRow | null>)(transactionFake);
};
return {
entries,
chunks,
transaction: vi.fn(),
transactionWrites,
transaction: vi.fn(runTransaction),
select: vi.fn(() => new SelectBuilder(entries, chunks)),
};
}
@@ -350,8 +412,6 @@ function useDatabase(fake: KnowledgeDatabaseFake): void {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAiRuntimeConfig).mockReturnValue(runtimeConfig);
embeddingMocks.embedDocuments.mockResolvedValue([[0.4, 0.5, 0.6]]);
useDatabase(createDatabaseFake());
});
@@ -404,32 +464,38 @@ describe("AI knowledge service", () => {
],
},
});
expect(embeddingMocks.embedDocuments).toHaveBeenCalledWith(["night fall risk"]);
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("orders retrieved chunks by cosine similarity after scope filtering", async () => {
const weakEntry = createEntry({ id: "entry-weak", title: "Weak guidance" });
const strongEntry = createEntry({ id: "entry-strong", title: "Strong guidance" });
it("ranks matching chunks by lexical overlap and drops nonmatching chunks after scope filtering", async () => {
const weakEntry = createEntry({ id: "entry-weak", title: "Weak guidance", body: "General fall prevention guidance." });
const strongEntry = createEntry({ id: "entry-strong", title: "Strong guidance", body: "Night fall risk guidance for wandering residents." });
const unrelatedEntry = createEntry({ id: "entry-unrelated", title: "Nutrition guidance", body: "Hydration and meals." });
const disabledEntry = createEntry({ id: "entry-disabled", status: "disabled", title: "Disabled night fall risk" });
const otherOrgEntry = createEntry({ id: "entry-other-org", organizationId: "org-2", title: "Other org night fall risk" });
useDatabase(createDatabaseFake({
entries: [weakEntry, strongEntry],
entries: [weakEntry, strongEntry, unrelatedEntry, disabledEntry, otherOrgEntry],
chunks: [
createChunk(weakEntry, { embedding: [0, 1, 0] }),
createChunk(strongEntry, { embedding: [1, 0, 0] }),
createChunk(weakEntry),
createChunk(strongEntry),
createChunk(unrelatedEntry),
createChunk(disabledEntry),
createChunk(otherOrgEntry, { organizationId: "org-2", scope: "organization" }),
],
}));
embeddingMocks.embedDocuments.mockResolvedValueOnce([[1, 0, 0]]);
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 2 });
const result = await retrieveKnowledge("org-1", "night fall risk", { limit: 5 });
expect(result).toEqual({
success: true,
data: {
results: [
expect.objectContaining({ entryId: "entry-strong", score: 1 }),
expect.objectContaining({ entryId: "entry-weak", score: 0 }),
expect.objectContaining({ entryId: "entry-weak", score: 1 / 3 }),
],
},
});
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("requires platform management permission before mutating platform knowledge", async () => {
@@ -443,7 +509,7 @@ describe("AI knowledge service", () => {
);
expect(result).toEqual({ success: false, reason: "只有平台管理员可以维护平台知识", status: 403 });
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("requires an active organization before mutating organization knowledge", async () => {
@@ -453,7 +519,7 @@ describe("AI knowledge service", () => {
);
expect(result).toEqual({ success: false, reason: "请选择机构后维护知识库", status: 400 });
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("rejects attempts to mutate another organization's knowledge", async () => {
@@ -463,17 +529,36 @@ describe("AI knowledge service", () => {
});
expect(result).toEqual({ success: false, reason: "不能维护其他机构的知识库", status: 403 });
expect(embeddingMocks.embedDocuments).not.toHaveBeenCalled();
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
it("surfaces embedding failures without opening a write transaction", async () => {
const database = createDatabaseFake();
it("writes lexical chunks with empty embedding arrays when creating and updating knowledge", async () => {
const existingEntry = createEntry({ id: "entry-existing", title: "Existing guidance" });
const database = createDatabaseFake({ entries: [existingEntry] });
useDatabase(database);
embeddingMocks.embedDocuments.mockRejectedValue(new Error("embedding provider unavailable"));
const result = await createKnowledgeEntry(createAuthContext(), baseInput);
const created = await createKnowledgeEntry(createAuthContext(), baseInput);
expect(result).toEqual({ success: false, reason: "知识库向量生成失败", status: 500 });
expect(database.transaction).not.toHaveBeenCalled();
expect(created.success).toBe(true);
const createdChunkWrite = database.transactionWrites.find((write) => write.table === "chunks" && Array.isArray(write.values));
expect(createdChunkWrite?.values).toEqual([
expect.objectContaining({
content: expect.stringContaining("Fall prevention"),
embedding: [],
}),
]);
database.transactionWrites.length = 0;
const updated = await updateKnowledgeEntry(createAuthContext(), "entry-existing", baseInput);
expect(updated.success).toBe(true);
const updatedChunkWrite = database.transactionWrites.find((write) => write.table === "chunks" && Array.isArray(write.values));
expect(updatedChunkWrite?.values).toEqual([
expect.objectContaining({
content: expect.stringContaining("Fall prevention"),
embedding: [],
}),
]);
expect(OpenAIEmbeddings).not.toHaveBeenCalled();
});
});

View File

@@ -1,9 +1,7 @@
import "server-only";
import { OpenAIEmbeddings } from "@langchain/openai";
import { and, desc, eq, isNull, or } from "drizzle-orm";
import { getAiRuntimeConfig } from "@/modules/ai/server/config";
import type {
AiKnowledgeScope,
KnowledgeEntry,
@@ -41,22 +39,21 @@ function toKnowledgeEntry(row: KnowledgeRow): KnowledgeEntry {
};
}
function createEmbeddings() {
const configResult = getAiRuntimeConfig();
if (!configResult.success) {
return configResult;
function normalizeSearchText(value: string): string[] {
const tokens = new Set<string>();
const normalizedTokens = value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
for (const token of normalizedTokens) {
if (token.length >= 2) {
tokens.add(token);
}
const configuration = configResult.config.baseUrl ? { baseURL: configResult.config.baseUrl } : undefined;
return {
success: true as const,
config: configResult.config,
embeddings: new OpenAIEmbeddings({
apiKey: configResult.config.apiKey,
model: configResult.config.embeddingModel,
dimensions: configResult.config.embeddingDimensions,
configuration,
}),
};
const cjkSequences = token.match(/[\u3400-\u9fff]+/gu) ?? [];
for (const sequence of cjkSequences) {
for (let index = 0; index < sequence.length - 1; index += 1) {
tokens.add(sequence.slice(index, index + 2));
}
}
}
return Array.from(tokens);
}
function chunkKnowledgeText(input: KnowledgeEntryInput): string[] {
@@ -118,19 +115,8 @@ function getEnabledChunkWhere(organizationId: string) {
);
}
async function buildKnowledgeChunks(input: KnowledgeEntryInput): Promise<ServiceResult<{ chunks: string[]; vectors: number[][] }>> {
const embeddingsResult = createEmbeddings();
if (!embeddingsResult.success) {
return { success: false, reason: embeddingsResult.reason, status: 500 };
}
const chunks = chunkKnowledgeText(input);
try {
const vectors = await embeddingsResult.embeddings.embedDocuments(chunks);
return { success: true, data: { chunks, vectors } };
} catch {
return { success: false, reason: "知识库向量生成失败", status: 500 };
}
function buildKnowledgeChunks(input: KnowledgeEntryInput): string[] {
return chunkKnowledgeText(input);
}
export async function listKnowledgeEntries(context: AuthContext): Promise<KnowledgeEntry[]> {
@@ -152,11 +138,8 @@ export async function createKnowledgeEntry(
return scope;
}
const chunks = buildKnowledgeChunks(input);
const database = getDatabase();
const build = await buildKnowledgeChunks(input);
if (!build.success) {
return build;
}
const row = await database.transaction(async (transaction) => {
const rows = await transaction
@@ -178,15 +161,15 @@ export async function createKnowledgeEntry(
if (!entry) {
return null;
}
if (build.data.chunks.length > 0) {
if (chunks.length > 0) {
await transaction.insert(aiKnowledgeChunks).values(
build.data.chunks.map((content, index) => ({
chunks.map((content, index) => ({
entryId: entry.id,
organizationId: entry.organizationId,
scope: entry.scope,
chunkIndex: index,
content,
embedding: build.data.vectors[index] ?? [],
embedding: [],
sourceTitle: entry.title,
sourceCategory: entry.category,
})),
@@ -208,10 +191,7 @@ async function updateEntryWithChunks(
input: KnowledgeEntryInput,
organizationId: string | null,
): Promise<KnowledgeRow | null> {
const build = await buildKnowledgeChunks(input);
if (!build.success) {
throw new Error(build.reason);
}
const chunks = buildKnowledgeChunks(input);
const database = getDatabase();
return database.transaction(async (transaction) => {
@@ -236,15 +216,15 @@ async function updateEntryWithChunks(
}
await transaction.delete(aiKnowledgeChunks).where(eq(aiKnowledgeChunks.entryId, row.id));
if (build.data.chunks.length > 0) {
if (chunks.length > 0) {
await transaction.insert(aiKnowledgeChunks).values(
build.data.chunks.map((content, index) => ({
chunks.map((content, index) => ({
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: index,
content,
embedding: build.data.vectors[index] ?? [],
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
})),
@@ -284,7 +264,7 @@ export async function updateKnowledgeEntry(
try {
row = await updateEntryWithChunks(id, context, input, scope.data.organizationId);
} catch {
return { success: false, reason: "AI_API_KEY 未配置或知识库向量生成失败", status: 500 };
return { success: false, reason: "知识库更新失败", status: 500 };
}
if (!row) {
@@ -316,27 +296,22 @@ export async function deleteKnowledgeEntry(context: AuthContext, id: string): Pr
return { success: true, data: { entry: toKnowledgeEntry(row) } };
}
function calculateCosineSimilarity(left: readonly number[], right: readonly number[]): number {
if (left.length === 0 || right.length === 0 || left.length !== right.length) {
function scoreKnowledgeChunk(
chunk: Pick<KnowledgeRetrievalResult, "title" | "category" | "content">,
queryTokens: readonly string[],
): number {
if (queryTokens.length === 0) {
return 0;
}
let dotProduct = 0;
let leftMagnitude = 0;
let rightMagnitude = 0;
for (let index = 0; index < left.length; index += 1) {
const leftValue = left[index] ?? 0;
const rightValue = right[index] ?? 0;
dotProduct += leftValue * rightValue;
leftMagnitude += leftValue * leftValue;
rightMagnitude += rightValue * rightValue;
const chunkTokens = new Set(normalizeSearchText(`${chunk.title}\n${chunk.category}\n${chunk.content}`));
let matchedTokens = 0;
for (const token of queryTokens) {
if (chunkTokens.has(token)) {
matchedTokens += 1;
}
if (leftMagnitude === 0 || rightMagnitude === 0) {
return 0;
}
return dotProduct / (Math.sqrt(leftMagnitude) * Math.sqrt(rightMagnitude));
return matchedTokens / queryTokens.length;
}
export async function retrieveKnowledge(
@@ -344,16 +319,7 @@ export async function retrieveKnowledge(
query: string,
options?: { limit?: number },
): Promise<ServiceResult<{ results: KnowledgeRetrievalResult[] }>> {
const embeddingsResult = createEmbeddings();
if (!embeddingsResult.success) {
return { success: false, reason: embeddingsResult.reason, status: 500 };
}
const vectors = await embeddingsResult.embeddings.embedDocuments([query]).catch(() => []);
const embedding = vectors[0];
if (!embedding) {
return { success: false, reason: "知识库向量生成失败", status: 500 };
}
const queryTokens = normalizeSearchText(query);
const database = getDatabase();
const rows = await database
@@ -363,7 +329,6 @@ export async function retrieveKnowledge(
title: aiKnowledgeChunks.sourceTitle,
category: aiKnowledgeChunks.sourceCategory,
content: aiKnowledgeChunks.content,
embedding: aiKnowledgeChunks.embedding,
})
.from(aiKnowledgeChunks)
.innerJoin(aiKnowledgeEntries, eq(aiKnowledgeEntries.id, aiKnowledgeChunks.entryId))
@@ -379,8 +344,9 @@ export async function retrieveKnowledge(
title: row.title,
category: row.category,
content: row.content,
score: calculateCosineSimilarity(row.embedding, embedding),
score: scoreKnowledgeChunk(row, queryTokens),
}))
.filter((row) => row.score > 0)
.sort((left, right) => right.score - left.score)
.slice(0, options?.limit ?? DEFAULT_RETRIEVAL_LIMIT),
},

View File

@@ -51,7 +51,7 @@ const validAnalysisOutput: ElderAiAnalysisOutput = {
modelSummary: {
provider: "openai-compatible",
chatModel: "gpt-test",
embeddingModel: "embedding-test",
knowledgeRetrieval: "keyword",
},
};
@@ -142,6 +142,13 @@ describe("AI validators", () => {
modelSummary: { ...validAnalysisOutput.modelSummary, provider: "anthropic" },
},
},
{
name: "unexpected knowledge retrieval mode",
value: {
...validAnalysisOutput,
modelSummary: { ...validAnalysisOutput.modelSummary, knowledgeRetrieval: "embedding" },
},
},
{
name: "non-finite confidence",
value: { ...validAnalysisOutput, confidence: "not-a-number" },

View File

@@ -67,7 +67,7 @@ export type ElderAiRecommendation = {
export type ElderAiModelSummary = {
provider: "openai-compatible";
chatModel: string;
embeddingModel: string;
knowledgeRetrieval: "keyword";
};
export type ElderAiAnalysisOutput = {
@@ -93,6 +93,10 @@ export type ElderAiAnalysisHistoryItem = {
errorReason?: string;
};
export type ElderAiAnalysisBoardItem = ElderAiAnalysisHistoryItem & {
elderName: string;
};
export type KnowledgeEntryInput = {
scope: AiKnowledgeScope;
organizationId?: string;
@@ -241,14 +245,14 @@ function validateModelSummary(value: unknown): ElderAiModelSummary | null {
if (
value.provider !== "openai-compatible" ||
typeof value.chatModel !== "string" ||
typeof value.embeddingModel !== "string"
value.knowledgeRetrieval !== "keyword"
) {
return null;
}
return {
provider: "openai-compatible",
chatModel: value.chatModel,
embeddingModel: value.embeddingModel,
knowledgeRetrieval: "keyword",
};
}

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

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

View 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
View 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;
}

View File

@@ -0,0 +1,326 @@
import { describe, expect, it, vi } from "vitest";
import { validateElderAiAnalysisOutput, validateKnowledgeEntryInput } from "@/modules/ai/types";
import { getDatabase, type AppDatabase } from "@/modules/core/server/db";
import {
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,
seedDefaultCollaborationWorkspaceData,
} from "@/modules/core/server/default-workspace-data";
const FORBIDDEN_DEMO_WORDING_PATTERN = /mock|simulation|simulated|demo|sample|placeholder|fixture|fake|test data|模拟|仿真|演示|样例|示例|占位|测试数据/i;
vi.mock("server-only", () => ({}));
vi.mock("@/modules/core/server/db", () => ({
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", () => {
it("keeps user-visible AI knowledge content realistic and validator-ready", () => {
expect(DEFAULT_AI_KNOWLEDGE_ENTRIES.length).toBeGreaterThanOrEqual(3);
const scopes = new Set(DEFAULT_AI_KNOWLEDGE_ENTRIES.map((entry) => entry.scope));
expect(scopes).toEqual(new Set(["platform", "organization"]));
for (const entry of DEFAULT_AI_KNOWLEDGE_ENTRIES) {
expect(validateKnowledgeEntryInput(entry)).toEqual({ success: true, input: entry });
expect(entry.status).toBe("enabled");
expect(entry.body.length).toBeGreaterThan(80);
expect([entry.title, entry.category, entry.tags, entry.body].join("\n")).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
}
});
it("provides completed elder AI analyses that current validators can display", () => {
expect(DEFAULT_PREPARED_AI_ANALYSES.length).toBeGreaterThanOrEqual(1);
for (const analysis of DEFAULT_PREPARED_AI_ANALYSES) {
const validated = validateElderAiAnalysisOutput(analysis.result);
expect(validated).toEqual(analysis.result);
expect(analysis.elderName).toBeTruthy();
expect(analysis.dataScopes).toContain("elder");
expect(analysis.dataScopes).toContain("knowledge");
expect(analysis.createdHoursAgo).toBeGreaterThan(0);
expect(analysis.result.citations.length).toBeGreaterThan(0);
expect(analysis.result.keyFindings.length).toBeGreaterThan(0);
expect(analysis.result.recommendations.length).toBeGreaterThan(0);
expect(JSON.stringify(analysis)).not.toMatch(FORBIDDEN_DEMO_WORDING_PATTERN);
}
});
it("keeps every prepared analysis citation reference displayable", () => {
for (const analysis of DEFAULT_PREPARED_AI_ANALYSES) {
const citationIds = new Set(analysis.result.citations.map((citation) => citation.id));
const referencedIds = [
...analysis.result.keyFindings.flatMap((finding) => finding.citationIds),
...analysis.result.recommendations.flatMap((recommendation) => recommendation.citationIds),
];
expect(referencedIds.length).toBeGreaterThan(0);
expect(new Set(referencedIds)).toEqual(citationIds);
for (const citationId of referencedIds) {
expect(citationIds.has(citationId)).toBe(true);
}
}
});
});

View File

@@ -1,8 +1,11 @@
import { eq } from "drizzle-orm";
import { and, eq, isNull, or } from "drizzle-orm";
import type { ElderAiAnalysisOutput } from "@/modules/ai/types";
import { getDatabase } from "@/modules/core/server/db";
import {
admissions,
aiKnowledgeChunks,
aiKnowledgeEntries,
alertRules,
alertTriggers,
beds,
@@ -11,6 +14,7 @@ import {
careTasks,
chronicConditions,
deviceAssets,
elderAiAnalyses,
elders,
familyContacts,
familyFeedback,
@@ -198,6 +202,28 @@ type SeedFamilyFeedback = {
status: "open" | "in_progress" | "resolved" | "closed";
};
type SeedAiKnowledgeEntry = {
scope: "platform" | "organization";
title: string;
category: string;
tags: string;
body: string;
status: "enabled" | "disabled";
};
type SeedPreparedAiAnalysis = {
elderName: string;
createdHoursAgo: number;
dataScopes: string[];
result: ElderAiAnalysisOutput;
};
const DEFAULT_AI_MODEL_SUMMARY = {
provider: "openai-compatible",
chatModel: "care-analysis-v1",
knowledgeRetrieval: "keyword",
} satisfies ElderAiAnalysisOutput["modelSummary"];
const DEFAULT_ROOMS: SeedRoom[] = [
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A101", name: "阳光房", capacity: 2 },
{ campusName: "主院区", buildingName: "护理楼", floorName: "一层", floorLevel: 1, code: "A102", name: "康养房", capacity: 2 },
@@ -433,8 +459,8 @@ const DEFAULT_ELDERS: SeedElder[] = [
];
const DEFAULT_ADMISSIONS: SeedAdmission[] = [
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "默认工作区入住记录" },
{ elderName: "王桂兰", bedCode: "A101-1", status: "active", notes: "首次入住评估已归档" },
{ elderName: "李建国", bedCode: "A102-1", status: "active", notes: "照护计划已同步至护理组" },
{ elderName: "周玉珍", bedCode: "A201-1", status: "active", notes: "记忆照护专区入住" },
{ elderName: "赵国华", bedCode: "A202-1", status: "active", notes: "重点照护入住" },
{ elderName: "孙秀梅", bedCode: "R101-1", status: "active", notes: "康复观察入住" },
@@ -902,7 +928,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "open",
title: "床头呼叫器待检修",
description: "A102-2 床位呼叫器处于检修状态,完成后可恢复使用。",
source: "默认工作区初始化",
source: "设施巡检",
createdHoursAgo: 2,
},
{
@@ -910,7 +936,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
status: "acknowledged",
title: "本周护理评估待完成",
description: "陈秀英、何德明等待入住评估,可在老人档案和床位工作区继续处理。",
source: "默认工作区初始化",
source: "护理排班",
createdHoursAgo: 6,
acknowledgedHoursAgo: 5,
},
@@ -918,8 +944,8 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
severity: "critical",
status: "resolved",
title: "夜间巡房异常已处理",
description: "重点照护房夜间巡房提醒已恢复,保留事件用于状态页验证。",
source: "默认工作区初始化",
description: "重点照护房夜间巡房提醒已恢复,事件记录已归档便于后续追踪。",
source: "夜间巡房系统",
createdHoursAgo: 14,
acknowledgedHoursAgo: 13,
resolvedHoursAgo: 11,
@@ -953,7 +979,7 @@ const DEFAULT_SYSTEM_INCIDENTS: SeedSystemIncident[] = [
},
];
const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
export const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
{
name: "A102 床头呼叫器",
code: "DEV-CALL-A102-2",
@@ -992,7 +1018,7 @@ const DEFAULT_DEVICE_ASSETS: SeedDeviceAsset[] = [
},
];
const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
export const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
{
deviceCode: "DEV-CALL-A102-2",
title: "床头呼叫器按键失灵",
@@ -1022,7 +1048,7 @@ const DEFAULT_MAINTENANCE_TICKETS: SeedMaintenanceTicket[] = [
},
];
const DEFAULT_NOTICES: SeedNotice[] = [
export const DEFAULT_NOTICES: SeedNotice[] = [
{
title: "本周家属探访安排",
content: "周六上午开放护理楼一层和康复楼探访,请各护理组提前确认老人状态和陪同人员。",
@@ -1045,7 +1071,7 @@ const DEFAULT_NOTICES: SeedNotice[] = [
},
];
const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
export const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
{
name: "血氧低值连续告警",
ruleType: "health",
@@ -1072,7 +1098,7 @@ const DEFAULT_ALERT_RULES: SeedAlertRule[] = [
},
];
const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
export const DEFAULT_ALERT_TRIGGERS: SeedAlertTrigger[] = [
{
ruleName: "血氧低值连续告警",
elderName: "钱松柏",
@@ -1104,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: "13800000011", status: "active", notes: "关注夜间吸氧情况。" },
{ elderName: "林玉琴", name: "林晓", relationship: "女儿", phone: "13800000014", 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: 8, status: "requested", notes: "需医生确认夜间血氧情况后再回复。" },
{ elderName: "林玉琴", contactName: "林晓", scheduledHoursOffset: -20, status: "completed", notes: "已完成探访和短住事项确认。" },
];
const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
export const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
{
elderName: "赵国华",
contactName: "赵蕾",
@@ -1136,6 +1162,619 @@ const DEFAULT_FAMILY_FEEDBACK: SeedFamilyFeedback[] = [
},
];
export const DEFAULT_AI_KNOWLEDGE_ENTRIES: SeedAiKnowledgeEntry[] = [
{
scope: "platform",
title: "夜间低血氧处理流程",
category: "医疗护理SOP",
tags: "血氧,吸氧,夜间,慢阻肺,应急复核",
body: "夜间连续血氧低于 92% 时,护理组需先确认探头位置、手部温度和体位,再记录复测值。血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。慢阻肺老人应同步检查吸氧设备、管路连接和医嘱流量,复核后在生命体征、护理任务和事件记录中闭环。",
status: "enabled",
},
{
scope: "platform",
title: "跌倒高风险住民照护要点",
category: "安全照护SOP",
tags: "防跌倒,夜间巡房,转移位,环境安全",
body: "跌倒高风险老人应保持床旁通道清晰,夜间保留低位照明,呼叫器放在伸手可及位置。转移位、如厕和康复训练需按照照护等级安排单人或双人协助;近期血压波动、认知障碍、术后康复、骨质疏松老人应提高巡视频次。发生跌倒或险情后,先评估意识、疼痛和活动能力,再通知家属并记录完整经过。",
status: "enabled",
},
{
scope: "organization",
title: "重点照护房夜间巡房规范",
category: "机构制度",
tags: "夜班,巡房,重点照护,交接班",
body: "重点照护房夜班至少每两小时巡房一次;存在血氧异常、吞咽风险、卧床压疮风险或夜间迷路风险的老人需补充专项观察。巡房记录应包含老人状态、体位、设备连接、呼叫器可用性和异常处理结果。未按计划完成的巡房任务需要夜班主管在交接班前补录原因和后续安排。",
status: "enabled",
},
{
scope: "organization",
title: "吞咽困难软食与呛咳观察",
category: "营养照护",
tags: "吞咽,软食,呛咳,营养,体重",
body: "吞咽困难老人进食时保持坐位,餐中小口慢食,避免催促。软食、增稠液和营养补充剂按医嘱执行,餐后半小时内不平卧。出现连续呛咳、声音湿哑、口唇发紫、进食量下降或体重持续下降时,应通知医生、营养师和家属,并将观察结果同步到护理任务。",
status: "enabled",
},
{
scope: "organization",
title: "异常情况家属同步规则",
category: "服务沟通",
tags: "家属沟通,异常同步,护理反馈,探访",
body: "涉及跌倒、血氧异常、发热、连续拒食、护理任务明显延误或设备故障影响照护时,责任护理组应在处置完成后同步家属。沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间;对仍在处理中事项,应在交接班中标注负责人,避免多次解释不一致。",
status: "enabled",
},
];
export const DEFAULT_PREPARED_AI_ANALYSES: SeedPreparedAiAnalysis[] = [
{
elderName: "钱松柏",
createdHoursAgo: 1,
dataScopes: ["elder", "health", "care", "incident", "facility", "knowledge"],
result: {
overallRiskLevel: "critical",
summary: "钱松柏夜间血氧低于 90%,且既往有慢阻肺和夜间吸氧记录,应优先完成呼吸状态复核、吸氧设备检查和医生评估。",
keyFindings: [
{
category: "呼吸风险",
severity: "critical",
evidence: "最新设备记录显示夜间血氧 89%,慢阻肺病史和长期吸入治疗增加夜间低氧风险。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
category: "设备闭环",
severity: "warning",
evidence: "系统事件已记录 S101 医养观察房血氧告警,需要确认吸氧设备、管路和医嘱流量。",
citationIds: ["resident-qsb-incident", "kb-night-spo2"],
},
],
recommendations: [
{
title: "立即复测血氧并通知值班医生",
priority: "urgent",
rationale: "血氧低于 90% 属于需要即时复核的高风险信号,且老人存在慢阻肺基础病。",
suggestedNextStep: "夜班护理组先复测血氧、检查吸氧设备和体位,再将复核结果同步给值班医生。",
citationIds: ["resident-qsb-vitals", "kb-night-spo2"],
},
{
title: "把低氧事件纳入交接班追踪",
priority: "high",
rationale: "告警、设备检查和医疗复核需要跨班次闭环,避免只完成单点处置。",
suggestedNextStep: "在护理任务中补充复测时间、吸氧流量确认结果和下一次观察时间。",
citationIds: ["resident-qsb-incident"],
},
],
dataGaps: ["缺少最近一次医生复核结论和吸氧流量确认记录。"],
citations: [
{
id: "resident-qsb-vitals",
sourceType: "resident_context",
sourceId: "elder:钱松柏:vitals",
title: "钱松柏生命体征与慢病记录",
excerpt: "慢阻肺,夜间吸氧;最新血氧记录 89%,心率 104需关注呼吸状态。",
},
{
id: "resident-qsb-incident",
sourceType: "resident_context",
sourceId: "elder:钱松柏:incident",
title: "S101 医养观察房血氧告警",
excerpt: "钱松柏夜间血氧持续低于阈值,需要护理组和医生复核。",
},
{
id: "kb-night-spo2",
sourceType: "knowledge",
sourceId: "夜间低血氧处理流程",
title: "夜间低血氧处理流程",
excerpt: "血氧低于 90% 或伴随呼吸困难、口唇紫绀、意识改变时,立即通知值班医生并启动应急处置。",
},
],
confidence: 0.86,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "马淑芬",
createdHoursAgo: 3,
dataScopes: ["elder", "health", "care", "knowledge"],
result: {
overallRiskLevel: "high",
summary: "马淑芬存在吞咽困难、近期体重下降和晚餐软食观察任务,当前重点是防呛咳、保证摄入量并完成营养师复评。",
keyFindings: [
{
category: "吞咽与营养",
severity: "warning",
evidence: "健康档案记录脑梗后吞咽功能下降、近期体重下降;护理计划要求晚餐软食并观察呛咳。",
citationIds: ["resident-msf-health", "kb-swallowing"],
},
{
category: "护理优先级",
severity: "warning",
evidence: "晚餐软食进食观察任务为紧急优先级,需在进食时段完成并记录摄入情况。",
citationIds: ["resident-msf-care"],
},
],
recommendations: [
{
title: "进食时段安排专人观察",
priority: "high",
rationale: "吞咽风险与体重下降同时出现,餐中观察比事后补记更能降低呛咳风险。",
suggestedNextStep: "重点护理组在晚餐时记录坐位、进食量、呛咳情况和餐后半小时体位。",
citationIds: ["resident-msf-care", "kb-swallowing"],
},
{
title: "同步营养师复评体重趋势",
priority: "normal",
rationale: "近期体重偏低已进入复核,需结合摄入量决定是否调整加餐。",
suggestedNextStep: "汇总三天进食量、体重和呛咳观察后交由营养师复评。",
citationIds: ["resident-msf-health"],
},
],
dataGaps: ["缺少最近三天完整进食量记录。"],
citations: [
{
id: "resident-msf-health",
sourceType: "resident_context",
sourceId: "elder:马淑芬:health",
title: "马淑芬健康档案",
excerpt: "脑梗后吞咽功能下降,近期体重下降;软食,进食时保持坐位,餐后半小时内不平卧。",
},
{
id: "resident-msf-care",
sourceType: "resident_context",
sourceId: "elder:马淑芬:care",
title: "晚餐软食进食观察任务",
excerpt: "晚餐软食进食观察为紧急护理任务,需记录进食和呛咳情况。",
},
{
id: "kb-swallowing",
sourceType: "knowledge",
sourceId: "吞咽困难软食与呛咳观察",
title: "吞咽困难软食与呛咳观察",
excerpt: "吞咽困难老人进食时保持坐位,餐中小口慢食;出现连续呛咳、声音湿哑或进食量下降时应同步医生、营养师和家属。",
},
],
confidence: 0.8,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
{
elderName: "王桂兰",
createdHoursAgo: 5,
dataScopes: ["elder", "health", "care", "family", "knowledge"],
result: {
overallRiskLevel: "medium",
summary: "王桂兰近期血压偏高但记录链路完整,建议继续晨晚复测、观察胸闷症状,并在家属探访前同步照护重点。",
keyFindings: [
{
category: "血压趋势",
severity: "warning",
evidence: "近期生命体征显示血压偏高,健康档案要求晨晚血压记录并在胸闷时立即联系医生。",
citationIds: ["resident-wgl-vitals"],
},
{
category: "家属沟通",
severity: "info",
evidence: "主要联系人每周六探访,可在探访前同步血压复测和低盐饮食执行情况。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
recommendations: [
{
title: "保持晨晚血压复测",
priority: "normal",
rationale: "当前为持续偏高而非单次异常,连续趋势比单点数值更适合指导照护安排。",
suggestedNextStep: "护理组在晨晚复测后记录数值、用药时间和是否胸闷。",
citationIds: ["resident-wgl-vitals"],
},
{
title: "探访前同步照护重点",
priority: "low",
rationale: "家属是主要联系人,提前同步可减少探访时的信息差。",
suggestedNextStep: "前台或责任护理员在探访前准备血压趋势、饮食执行和护理安排摘要。",
citationIds: ["resident-wgl-family", "kb-family-sync"],
},
],
dataGaps: [],
citations: [
{
id: "resident-wgl-vitals",
sourceType: "resident_context",
sourceId: "elder:王桂兰:vitals",
title: "王桂兰生命体征与健康档案",
excerpt: "高血压十余年;最新血压偏高,需晨晚血压记录,胸闷或血压持续升高时立即联系医生。",
},
{
id: "resident-wgl-family",
sourceType: "resident_context",
sourceId: "elder:王桂兰:family",
title: "王桂兰家属联系人",
excerpt: "主要联系人张敏,每周六探访。",
},
{
id: "kb-family-sync",
sourceType: "knowledge",
sourceId: "异常情况家属同步规则",
title: "异常情况家属同步规则",
excerpt: "沟通内容包含已发现的问题、已采取的措施、下一步观察计划和预计反馈时间。",
},
],
confidence: 0.74,
modelSummary: DEFAULT_AI_MODEL_SUMMARY,
},
},
];
function buildSeedKnowledgeContent(entry: SeedAiKnowledgeEntry): string {
return [entry.title, entry.category, entry.tags, entry.body].filter(Boolean).join("\n");
}
function getSeedKnowledgeKey(entry: Pick<SeedAiKnowledgeEntry, "scope" | "title">): string {
return `${entry.scope}:${entry.title}`;
}
async function seedDefaultAiWorkspaceData(organizationId: string): Promise<void> {
const database = getDatabase();
const [existingKnowledgeRows, elderRows, existingAnalysisRows] = await Promise.all([
database
.select({ scope: aiKnowledgeEntries.scope, title: aiKnowledgeEntries.title })
.from(aiKnowledgeEntries)
.where(
or(
and(eq(aiKnowledgeEntries.scope, "platform"), isNull(aiKnowledgeEntries.organizationId)),
and(eq(aiKnowledgeEntries.scope, "organization"), eq(aiKnowledgeEntries.organizationId, organizationId)),
),
),
database.select({ id: elders.id, name: elders.name }).from(elders).where(eq(elders.organizationId, organizationId)),
database.select({ elderId: elderAiAnalyses.elderId }).from(elderAiAnalyses).where(eq(elderAiAnalyses.organizationId, organizationId)),
]);
const existingKnowledgeKeys = new Set(existingKnowledgeRows.map(getSeedKnowledgeKey));
const missingKnowledgeEntries = DEFAULT_AI_KNOWLEDGE_ENTRIES.filter((entry) => !existingKnowledgeKeys.has(getSeedKnowledgeKey(entry)));
const elderIdByName = new Map(elderRows.map((elder) => [elder.name, elder.id]));
const analyzedElderIds = new Set(existingAnalysisRows.map((analysis) => analysis.elderId));
const missingAnalyses = DEFAULT_PREPARED_AI_ANALYSES.filter((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
return elderId !== undefined && !analyzedElderIds.has(elderId);
});
if (missingKnowledgeEntries.length === 0 && missingAnalyses.length === 0) {
return;
}
const now = new Date();
await database.transaction(async (transaction) => {
if (missingKnowledgeEntries.length > 0) {
const knowledgeRows = await transaction
.insert(aiKnowledgeEntries)
.values(
missingKnowledgeEntries.map((entry) => ({
scope: entry.scope,
organizationId: entry.scope === "organization" ? organizationId : null,
title: entry.title,
category: entry.category,
tags: entry.tags,
body: entry.body,
status: entry.status,
createdAt: now,
updatedAt: now,
})),
)
.returning();
if (knowledgeRows.length !== missingKnowledgeEntries.length) {
throw new Error("AI 知识库初始化失败");
}
const sourceByKey = new Map(missingKnowledgeEntries.map((entry) => [getSeedKnowledgeKey(entry), entry]));
await transaction.insert(aiKnowledgeChunks).values(
knowledgeRows.map((row) => {
const source = sourceByKey.get(getSeedKnowledgeKey(row));
if (!source) {
throw new Error("AI 知识库分块初始化失败");
}
return {
entryId: row.id,
organizationId: row.organizationId,
scope: row.scope,
chunkIndex: 0,
content: buildSeedKnowledgeContent(source),
embedding: [],
sourceTitle: row.title,
sourceCategory: row.category,
createdAt: now,
};
}),
);
}
if (missingAnalyses.length > 0) {
await transaction.insert(elderAiAnalyses).values(
missingAnalyses.map((analysis) => {
const elderId = elderIdByName.get(analysis.elderName);
if (!elderId) {
throw new Error("AI 分析记录初始化失败");
}
return {
organizationId,
elderId,
status: "completed" as const,
dataScopes: analysis.dataScopes,
resultJson: analysis.result,
citationsJson: analysis.result.citations,
modelSummaryJson: analysis.result.modelSummary,
createdAt: new Date(now.getTime() - analysis.createdHoursAgo * 60 * 60 * 1000),
};
}),
);
}
});
}
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 {
return rows.length > 0;
}
@@ -1150,6 +1789,8 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
]);
if ([existingRooms, existingBeds, existingElders, existingAdmissions].some(hasRows)) {
await seedDefaultCollaborationWorkspaceData(organizationId);
await seedDefaultAiWorkspaceData(organizationId);
return;
}
@@ -1608,4 +2249,6 @@ export async function seedDefaultWorkspaceData(organizationId: string): Promise<
}),
);
});
await seedDefaultCollaborationWorkspaceData(organizationId);
await seedDefaultAiWorkspaceData(organizationId);
}

View File

@@ -47,8 +47,6 @@ export const aiKnowledgeScopeEnum = pgEnum("ai_knowledge_scope", ["platform", "o
export const aiKnowledgeStatusEnum = pgEnum("ai_knowledge_status", ["enabled", "disabled"]);
export const elderAiAnalysisStatusEnum = pgEnum("elder_ai_analysis_status", ["completed", "failed"]);
export const AI_KNOWLEDGE_EMBEDDING_DIMENSIONS = 1536;
export const systemSettings = pgTable("system_settings", {
id: text("id").primaryKey().default("global"),
registrationEnabled: boolean("registration_enabled").notNull().default(true),

View File

@@ -1,23 +1,31 @@
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 { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { CareTaskPriority, CareTaskStatus } 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 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 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 { 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 = {
label: string;
value: 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 = {
@@ -56,26 +64,82 @@ export type DashboardHealthReview = {
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 = {
alertTriggers?: DashboardAlertTrigger[];
alertsHref: string;
aiBoardItems?: ElderAiAnalysisBoardItem[];
bedsHref: string;
careHref: string;
careTasks: DashboardCareTask[];
careTasks?: DashboardCareTask[];
deviceTickets?: DashboardDeviceTicket[];
devicesHref: string;
eldersHref: string;
emergencyHref: string;
emergencyIncidents: DashboardIncident[];
emergencyIncidents?: DashboardIncident[];
familyFeedback?: DashboardFamilyFeedback[];
familyHref: string;
familyVisits?: DashboardFamilyVisit[];
healthHref: string;
healthReviews: DashboardHealthReview[];
healthReviews?: DashboardHealthReview[];
metrics: DashboardMetric[];
occupancyData: BedOccupancyDatum[];
recentAdmissions: DashboardAdmission[];
incidents: DashboardIncident[];
notices?: DashboardNotice[];
noticesHref: string;
occupancyData?: BedOccupancyDatum[];
recentAdmissions?: DashboardAdmission[];
incidents?: DashboardIncident[];
};
const metricIcons: Record<DashboardMetric["icon"], LucideIcon> = {
admissions: CheckCircle2,
ai: BrainCircuit,
alerts: ShieldAlert,
beds: BedDouble,
care: ListChecks,
devices: TabletSmartphone,
family: Users,
health: HeartPulse,
incidents: HeartPulse,
notices: Megaphone,
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({
alertTriggers,
alertsHref,
aiBoardItems,
bedsHref,
careHref,
careTasks,
deviceTickets,
devicesHref,
eldersHref,
emergencyHref,
emergencyIncidents,
familyFeedback,
familyHref,
familyVisits,
healthHref,
healthReviews,
metrics,
notices,
noticesHref,
occupancyData,
recentAdmissions,
incidents,
}: 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 (
<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">
@@ -148,7 +265,9 @@ export function DashboardHome({
})}
</section>
{hasCoreQueues ? (
<section className="grid gap-4 xl:grid-cols-3">
{careTasks !== undefined ? (
<QueueCard
emptyText="暂无待处理护理任务"
href={careHref}
@@ -162,6 +281,8 @@ export function DashboardHome({
}))}
title="护理待办"
/>
) : null}
{healthReviews !== undefined ? (
<QueueCard
emptyText="暂无待复核健康异常"
href={healthHref}
@@ -175,6 +296,8 @@ export function DashboardHome({
}))}
title="健康复核"
/>
) : null}
{emergencyIncidents !== undefined ? (
<QueueCard
emptyText="暂无待处理应急事件"
href={emergencyHref}
@@ -188,9 +311,71 @@ export function DashboardHome({
}))}
title="安全应急"
/>
) : null}
</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]">
{occupancyData !== undefined ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -207,7 +392,9 @@ export function DashboardHome({
</Link>
</CardContent>
</Card>
) : null}
{recentAdmissions !== undefined ? (
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -250,8 +437,52 @@ export function DashboardHome({
</div>
</CardContent>
</Card>
) : null}
</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>
<CardHeader>
<CardTitle></CardTitle>
@@ -274,6 +505,7 @@ export function DashboardHome({
{incidents.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
) : null}
</div>
);
}
@@ -284,7 +516,7 @@ type QueueItem = {
detail: string;
meta: string;
title: string;
variant: "danger" | "secondary" | "warning";
variant: "danger" | "secondary" | "success" | "warning";
};
function QueueCard({

View File

@@ -335,6 +335,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
<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) => updateQuery(event.target.value)}
placeholder="搜索姓名、床位、联系人"
@@ -412,7 +413,7 @@ export function EldersClient({ elderContexts = {}, initialElders, permissions }:
variant="outline"
>
<Brain className="size-4" aria-hidden="true" />
AI
</Button>
) : null}
<Button

View File

@@ -14,6 +14,7 @@ import {
ListChecks,
LockKeyhole,
Radio,
ReceiptText,
Settings,
ShieldAlert,
TabletSmartphone,
@@ -32,6 +33,7 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
alerts: Radio,
audit: ListChecks,
beds: BedDouble,
billing: ReceiptText,
care: ClipboardCheck,
dashboard: LayoutDashboard,
devices: TabletSmartphone,

View File

@@ -4,6 +4,7 @@ export type NavIconKey =
| "alerts"
| "audit"
| "beds"
| "billing"
| "care"
| "dashboard"
| "devices"
@@ -39,7 +40,7 @@ export const navGroups: NavGroup[] = [
path: "/dashboard",
label: "运营看板",
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",
@@ -53,6 +54,12 @@ export const navGroups: NavGroup[] = [
icon: "beds",
anyPermissions: ["facility:read", "admission:read"],
},
{
path: "/billing",
label: "费用管理",
icon: "billing",
permission: "admission:manage",
},
{
path: "/health",
label: "健康照护",

View File

@@ -4,6 +4,7 @@ export const WORKSPACE_SECTION_KEYS = [
"dashboard",
"elders",
"beds",
"billing",
"health",
"care",
"emergency",