feat: initialize ERP base platform (extracted from HMS)

- Stripped 11 business crates (health, ai, dialysis, plugins)
- Cleaned AppState, AppConfig, main.rs from business coupling
- Reduced migrations from 169 to 53 (base-only)
- Removed health_provider trait from erp-core
- Removed business integration tests
- Removed gateway rate limiting middleware
- Base capabilities: auth, RBAC, JWT, config, workflow, message, plugin, audit, crypto, RLS, multi-tenant

Cargo check: OK
Cargo test: OK
This commit is contained in:
iven
2026-05-31 20:35:57 +08:00
commit 59856ac2fc
639 changed files with 124710 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
// apps/web/e2e/fixtures/api-client.ts
import type {
PatientData, DoctorData, VitalSignsData, ScheduleData,
AppointmentData, FollowUpTemplateData, FollowUpTaskData, AlertRuleData,
} from './test-data';
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
interface ApiResponse<T> { success: boolean; data: T }
interface Versioned { id: string; version: number }
type VEntity<T> = T & Versioned;
interface LoginResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: { id: string; username: string; display_name: string; roles: string[] };
}
export class ApiClient {
private token = '';
async login(username?: string, password?: string): Promise<LoginResponse> {
const res = await this.rawPost<{ success: boolean; data: LoginResponse }>(
'/auth/login',
{
username: username || process.env.E2E_ADMIN_USER || 'admin',
password: password || process.env.E2E_ADMIN_PASS || 'Admin@2026',
},
);
this.token = res.data.access_token;
return res.data;
}
async loginAsAdmin(): Promise<LoginResponse> {
return this.login();
}
getToken(): string { return this.token; }
async createPatient(overrides?: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/patients', overrides ?? {});
}
async updatePatient(id: string, version: number, data: Partial<PatientData>): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/patients/${id}`, { ...data, version });
}
async deletePatient(id: string, version: number): Promise<void> {
await this.del(`/health/patients/${id}`, { version });
}
async createDoctor(overrides?: Partial<DoctorData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctors', overrides ?? {});
}
async deleteDoctor(id: string, version: number): Promise<void> {
await this.del(`/health/doctors/${id}`, { version });
}
async createVitalSigns(patientId: string, overrides?: Partial<VitalSignsData>): Promise<VEntity<Record<string, unknown>>> {
return this.post(`/health/patients/${patientId}/vital-signs`, overrides ?? {});
}
async deleteVitalSigns(patientId: string, id: string, version: number): Promise<void> {
await this.del(`/health/patients/${patientId}/vital-signs/${id}`, { version });
}
async createSchedule(overrides: ScheduleData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/doctor-schedules', overrides);
}
async deleteSchedule(id: string, version: number): Promise<void> {
await this.del(`/health/doctor-schedules/${id}`, { version });
}
async createAppointment(overrides: AppointmentData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/appointments', overrides);
}
async updateAppointmentStatus(id: string, version: number, status: string): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/appointments/${id}/status`, { status, version });
}
async deleteAppointment(id: string, version: number): Promise<void> {
await this.del(`/health/appointments/${id}`, { version });
}
async createFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-templates', overrides ?? {});
}
async deleteFollowUpTemplate(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-templates/${id}`, { version });
}
async createFollowUpTask(overrides: FollowUpTaskData): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/follow-up-tasks', overrides);
}
async deleteFollowUpTask(id: string, version: number): Promise<void> {
await this.del(`/health/follow-up-tasks/${id}`, { version });
}
async createAlertRule(overrides?: Partial<AlertRuleData>): Promise<VEntity<Record<string, unknown>>> {
return this.post('/health/alert-rules', overrides ?? {});
}
async deleteAlertRule(id: string, version: number): Promise<void> {
await this.del(`/health/alert-rules/${id}`, { version });
}
async listAlerts(): Promise<VEntity<Record<string, unknown>>[]> {
const res = await this.get<{ data: VEntity<Record<string, unknown>>[] }>('/health/alerts');
return res.data ?? [];
}
async acknowledgeAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/acknowledge`, { version });
}
async resolveAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/resolve`, { version });
}
async dismissAlert(id: string, version: number): Promise<VEntity<Record<string, unknown>>> {
return this.put(`/health/alerts/${id}/dismiss`, { version });
}
private async headers(): Promise<Record<string, string>> {
return {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
};
}
private async parseJson<T>(res: Response, method: string, path: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`${method} ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`${method} ${path} failed: ${json.error ?? 'unknown'}`);
return json.data as T;
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, { headers: await this.headers() });
return this.parseJson<T>(res, 'GET', path);
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: await this.headers(),
body: JSON.stringify(body),
});
return this.parseJson<T>(res, 'POST', path);
}
private async put<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'PUT',
headers: await this.headers(),
body: JSON.stringify(body),
});
return this.parseJson<T>(res, 'PUT', path);
}
private async del(path: string, body?: unknown): Promise<void> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
headers: await this.headers(),
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 204) return;
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`DELETE ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`DELETE ${path} failed: ${json.error ?? 'unknown'}`);
}
private async rawPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST ${path} → HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const json = await res.json();
if (!json.success) throw new Error(`POST ${path} failed: ${json.error ?? 'unknown'}`);
return json as T;
}
}

View File

@@ -0,0 +1,73 @@
// apps/web/e2e/fixtures/auth.fixture.ts
import { test as base, type Page } from '@playwright/test';
import { ApiClient } from './api-client';
const API_BASE = process.env.E2E_API_URL || 'http://localhost:3000/api/v1';
type E2eFixtures = {
api: ApiClient;
authenticatedPage: Page;
};
interface LoginResult {
access_token: string;
refresh_token: string;
user: object;
}
async function login(): Promise<LoginResult> {
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: process.env.E2E_ADMIN_USER || 'admin',
password: process.env.E2E_ADMIN_PASS || 'Admin@2026',
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status}: ${text.slice(0, 100)}`);
}
const json = await res.json();
if (json.success) return json.data;
throw new Error(`Login unsuccessful: ${json.error ?? 'unknown'}`);
} catch (err) {
if (attempt === 4) throw err;
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
}
}
throw new Error('Login failed after 5 attempts');
}
export const test = base.extend<E2eFixtures>({
api: async ({}, use) => {
const { access_token } = await login();
const client = new ApiClient();
client['token'] = access_token;
await use(client);
},
authenticatedPage: async ({ page }, use) => {
const { access_token, refresh_token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.refresh);
localStorage.setItem('user', JSON.stringify(args.userData));
}, { token: access_token, refresh: refresh_token, userData: user });
await use(page);
},
page: async ({ page }, use) => {
const { access_token, refresh_token, user } = await login();
await page.addInitScript((args) => {
localStorage.setItem('access_token', args.token);
localStorage.setItem('refresh_token', args.refresh);
localStorage.setItem('user', JSON.stringify(args.userData));
}, { token: access_token, refresh: refresh_token, userData: user });
await use(page);
},
});
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,198 @@
// apps/web/e2e/fixtures/test-data.ts
export interface PatientData {
name: string;
gender?: string;
birth_date?: string;
blood_type?: string;
id_number?: string;
allergy_history?: string;
medical_history_summary?: string;
emergency_contact_name?: string;
emergency_contact_phone?: string;
source?: string;
notes?: string;
}
export interface DoctorData {
name: string;
department?: string;
title?: string;
specialty?: string;
phone?: string;
license_number?: string;
status?: string;
}
export interface VitalSignsData {
record_date: string;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
heart_rate?: number;
body_temperature?: number;
spo2?: number;
blood_sugar?: number;
weight?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
source?: string;
}
export interface ScheduleData {
doctor_id: string;
schedule_date: string;
start_time: string;
end_time: string;
max_appointments?: number;
period_type?: string;
}
export interface AppointmentData {
patient_id: string;
doctor_id: string;
schedule_id: string;
appointment_date: string;
start_time: string;
end_time: string;
reason?: string;
}
export interface FollowUpTemplateData {
name: string;
description?: string;
follow_up_type: string;
applicable_scope?: string;
fields?: Array<{
label: string;
field_key: string;
field_type: string;
required?: boolean;
options?: string;
}>;
}
export interface FollowUpTaskData {
patient_id: string;
follow_up_type: string;
planned_date: string;
assigned_to?: string;
content_template?: string;
}
export interface AlertRuleData {
name: string;
device_type: string;
condition_type: string;
condition_params: Record<string, unknown>;
severity?: string;
description?: string;
apply_tags?: Record<string, unknown>;
notify_roles?: Array<string>;
cooldown_minutes?: number;
}
let counter = 0;
function uid(): string {
counter += 1;
return `${Date.now()}_${counter}_${Math.random().toString(36).slice(2, 6)}`;
}
export function makePatient(overrides?: Partial<PatientData>): PatientData {
const id = uid();
return {
name: `E2E患者_${id}`,
gender: 'male',
birth_date: '1990-01-15',
id_number: `110101199001${String(Math.random()).slice(2, 8)}`,
...overrides,
};
}
export function makeDoctor(overrides?: Partial<DoctorData>): DoctorData {
const id = uid();
return {
name: `E2E医生_${id}`,
department: '内科',
title: '主治医师',
specialty: '全科',
license_number: `DOC${id}`,
...overrides,
};
}
export function makeVitalSigns(overrides?: Partial<VitalSignsData>): VitalSignsData {
return {
record_date: new Date().toISOString().slice(0, 10),
systolic_bp_morning: 120,
diastolic_bp_morning: 80,
heart_rate: 72,
body_temperature: 36.5,
spo2: 98,
source: 'web_e2e',
...overrides,
};
}
export function makeSchedule(doctorId: string, overrides?: Partial<ScheduleData>): ScheduleData {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const date = tomorrow.toISOString().slice(0, 10);
return {
doctor_id: doctorId,
schedule_date: date,
start_time: '09:00',
end_time: '12:00',
max_appointments: 10,
...overrides,
};
}
export function makeAppointment(patientId: string, doctorId: string, scheduleId: string, overrides?: Partial<AppointmentData>): AppointmentData {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const date = tomorrow.toISOString().slice(0, 10);
return {
patient_id: patientId,
doctor_id: doctorId,
schedule_id: scheduleId,
appointment_date: date,
start_time: '09:00',
end_time: '10:00',
reason: 'E2E测试预约',
...overrides,
};
}
export function makeFollowUpTemplate(overrides?: Partial<FollowUpTemplateData>): FollowUpTemplateData {
return {
name: `E2E随访模板_${uid()}`,
description: 'E2E自动创建的随访模板',
follow_up_type: 'phone',
...overrides,
};
}
export function makeFollowUpTask(patientId: string, _templateId: string, overrides?: Partial<FollowUpTaskData>): FollowUpTaskData {
const plannedDate = new Date();
plannedDate.setDate(plannedDate.getDate() + 7);
return {
patient_id: patientId,
follow_up_type: 'phone',
planned_date: plannedDate.toISOString().slice(0, 10),
...overrides,
};
}
export function makeAlertRule(overrides?: Partial<AlertRuleData>): AlertRuleData {
return {
name: `E2E告警规则_${uid()}`,
device_type: 'heart_rate',
condition_type: 'single_threshold',
condition_params: { direction: 'above', value: 50 },
severity: 'warning',
description: 'E2E测试低阈值规则用于触发告警',
...overrides,
};
}