diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..15e1b83 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +node_modules +npm-debug.log +Dockerfile +.dockerignore +.git +.gitignore +README.md +*.md +dist +build +.env +.env.local +.env.*.local +coverage +.nyc_output +.DS_Store +.vscode +.idea +*.swp +*.swo +*~ diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 0efe28a..be82c45 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -167,7 +167,7 @@ export class DepositsService { throw new BadRequestException('未找到待审批的退款申请'); } - deposit.refundStatus = null; + deposit.refundStatus = null as unknown as string; deposit.refundApprovedBy = userId; deposit.refundApprovedAt = new Date(); deposit.refundRejectedReason = reason; diff --git a/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md b/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md new file mode 100644 index 0000000..3ea252e --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md @@ -0,0 +1,586 @@ +# P2 Remaining Tasks Implementation Plan + +> **For agentic workers:** Use superpowers:subagent-driven-development (recommended) to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Complete the 4 genuinely remaining P2 items: dashboard classroom utilization stats, list page filter enhancements, deposit refund approval workflow, and scheduled sync implementation. + +**Architecture:** Backend enhancements (UtilizationStats endpoint, deposit refund workflow status transitions, sync stubs → real API calls) plus frontend enhancements (dashboard utilization section, list page filter bars). + +**Tech Stack:** NestJS 11 + TypeORM 0.3 (backend), React 19 + Ant Design 6 + ECharts (frontend), SQLite/MySQL, @nestjs/schedule (Cron). + +## Global Constraints + +- All list pages ≥ 50 records MUST have comprehensive filter bars (class, date range, status, source, etc.) +- Sensitive operations (refund approval) MUST log via OperationLogsService +- Follow existing NestJS module structure +- Dashboard stats MUST respect CampusScope data isolation + +--- + +### Task 1: Dashboard — Classroom Utilization Section + +**Files:** +- Modify: `apps/server/src/dashboard/dashboard.service.ts` (add `getClassroomUtilizationStats()`) +- Modify: `apps/server/src/dashboard/dashboard.controller.ts` (add `GET /dashboard/classroom-utilization`) +- Modify: `apps/admin/src/pages/Dashboard/index.tsx` (add utilization section below existing charts) + +**Interfaces:** +- Consumes: `Classroom`, `ClassSchedule`, `ClassroomRental` repos already injected +- Produces: `getClassroomUtilizationStats(): Promise` where `UtilizationStats = { totalClassrooms: number; inUseCount: number; utilizationRate: string; scheduleHours: number; rentalDays: number }` + +- [ ] **Step 1: Add `getClassroomUtilizationStats()` method to DashboardService** + +```typescript +// apps/server/src/dashboard/dashboard.service.ts — add after getClassroomOccupancy() + +async getClassroomUtilizationStats() { + const scopeIds = await this.scope.getScopeDepartmentIds(); + const totalClassrooms = await this.classroomRepo.count({ + where: await this.scope.filter({ status: Not('archived') }), + }); + + const today = new Date().toISOString().slice(0, 10); + + // Count classrooms with active schedules today + const schedQb = this.scheduleRepo + .createQueryBuilder('s') + .select('COUNT(DISTINCT s.classroomId)', 'cnt') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }); + if (scopeIds) schedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); + const schedResult = await schedQb.getRawOne(); + + // Count classrooms with active rentals today + const rentalQb = this.rentalRepo + .createQueryBuilder('r') + .select('COUNT(DISTINCT r.classroomId)', 'cnt') + .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); + if (scopeIds) rentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); + const rentalResult = await rentalQb.getRawOne(); + + // Combine: use Set merge of both + const combinedQb = this.scheduleRepo + .createQueryBuilder('s') + .select('s.classroomId') + .where('s.status = :active', { active: 'active' }) + .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) + .andWhere('s.startDate <= :today AND s.endDate >= :today', { today }) + .groupBy('s.classroomId'); + if (scopeIds) combinedQb.andWhere('s.departmentId IN (:...scopeIds)', { scopeIds }); + const schedIds = await combinedQb.getRawMany(); + + const combinedRentalQb = this.rentalRepo + .createQueryBuilder('r') + .select('r.classroomId') + .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) + .groupBy('r.classroomId'); + if (scopeIds) combinedRentalQb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds }); + const rentalIds = await combinedRentalQb.getRawMany(); + + const allInUseIds = new Set([ + ...schedIds.map((s: any) => s.classroomId), + ...rentalIds.map((r: any) => r.classroomId), + ]); + + const scheduleCount = parseInt(schedResult?.cnt || '0', 10); + const rentalCount = parseInt(rentalResult?.cnt || '0', 10); + const inUseCount = allInUseIds.size; + const utilizationRate = totalClassrooms > 0 + ? ((inUseCount / totalClassrooms) * 100).toFixed(1) + : '0'; + + return { + totalClassrooms, + inUseCount, + utilizationRate, + scheduleCount, + rentalCount, + }; +} +``` + +- [ ] **Step 2: Add controller endpoint** + +```typescript +// apps/server/src/dashboard/dashboard.controller.ts — add inside DashboardController + +@Get('classroom-utilization') +async getClassroomUtilization() { + return this.service.getClassroomUtilizationStats(); +} +``` + +- [ ] **Step 3: Add utilization section to Dashboard frontend** + +Add after the existing stats cards row and classroom occupancy chart section in `apps/admin/src/pages/Dashboard/index.tsx`: + +```typescript +// Add state +const [classroomUtil, setClassroomUtil] = useState<{ + totalClassrooms: number; + inUseCount: number; + utilizationRate: string; + scheduleCount: number; + rentalCount: number; +} | null>(null); + +// Add fetch in fetchData +const cu = await api.get('/dashboard/classroom-utilization'); +setClassroomUtil(cu); + +// Add a Card row after existing stat cards + + + + } /> + + + } /> + + + } + valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }} + /> + + + } /> + + + +``` + +- [ ] **Step 4: Verify** + +Run: `cd apps/server && npx jest --testPathPattern="dashboard" 2>/dev/null || echo "no tests yet"` +Start dev server, open dashboard, confirm utilization section renders with correct data. + +--- + +### Task 2: Deposit Refund Approval Workflow + +**Files:** +- Modify: `apps/server/src/entities/deposit.entity.ts` (add `refundStatus`, `refundRequestedAt`, `refundApprovedBy`, `refundApprovedAt`, `refundRejectedReason`) +- Modify: `apps/server/src/deposits/deposits.service.ts` (add `requestRefund`, `approveRefund`, `rejectRefund` methods) +- Modify: `apps/server/src/deposits/deposits.controller.ts` (add endpoints) +- Modify: `apps/server/src/deposits/dto/deposit.dto.ts` (add DTOs) +- Modify: `apps/admin/src/pages/Deposits/index.tsx` (add approval UI) + +**Interfaces:** +- Produces: `POST /deposits/:id/request-refund`, `PUT /deposits/:id/approve-refund`, `PUT /deposits/:id/reject-refund` + +- [ ] **Step 1: Add refund workflow fields to Deposit entity** + +```typescript +// apps/server/src/entities/deposit.entity.ts — add fields inside Deposit class + +@Column({ name: 'refund_status', length: 20, nullable: true }) +refundStatus: string; // 'pending_approval' | 'approved' | 'rejected' + +@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true }) +refundRequestedAt: Date; + +@Column({ name: 'refund_approved_by', type: 'integer', nullable: true }) +refundApprovedBy: number; + +@Column({ name: 'refund_approved_at', type: 'datetime', nullable: true }) +refundApprovedAt: Date; + +@Column({ name: 'refund_rejected_reason', length: 500, nullable: true }) +refundRejectedReason: string; +``` + +- [ ] **Step 2: Add DTOs** + +```typescript +// apps/server/src/deposits/dto/deposit.dto.ts — add exports + +export class RequestRefundDto { + @IsOptional() + @IsString() + reason?: string; +} + +export class ApproveRefundDto { + @IsNumber() + approvedBy: number; +} + +export class RejectRefundDto { + @IsString() + @IsNotEmpty() + reason: string; + + @IsNumber() + rejectedBy: number; +} +``` + +- [ ] **Step 3: Add service methods** + +```typescript +// apps/server/src/deposits/deposits.service.ts — add methods + +async requestRefund(id: number) { + const deposit = await this.repo.findOne({ where: { id } }); + if (!deposit) throw new NotFoundException('押金记录不存在'); + if (deposit.refundStatus === 'pending_approval') { + throw new BadRequestException('该押金已提交退还申请,等待审批中'); + } + if (deposit.refundStatus === 'approved') { + throw new BadRequestException('该押金已通过审批'); + } + deposit.refundStatus = 'pending_approval'; + deposit.refundRequestedAt = new Date(); + return this.repo.save(deposit); +} + +async approveRefund(id: number, approvedBy: number) { + const deposit = await this.repo.findOne({ where: { id } }); + if (!deposit) throw new NotFoundException('押金记录不存在'); + if (deposit.refundStatus !== 'pending_approval') { + throw new BadRequestException('该押金不在待审批状态'); + } + deposit.refundStatus = 'approved'; + deposit.refundApprovedBy = approvedBy; + deposit.refundApprovedAt = new Date(); + return this.repo.save(deposit); +} + +async rejectRefund(id: number, reason: string, rejectedBy: number) { + const deposit = await this.repo.findOne({ where: { id } }); + if (!deposit) throw new NotFoundException('押金记录不存在'); + if (deposit.refundStatus !== 'pending_approval') { + throw new BadRequestException('该押金不在待审批状态'); + } + deposit.refundStatus = 'rejected'; + deposit.refundApprovedBy = rejectedBy; + deposit.refundApprovedAt = new Date(); + deposit.refundRejectedReason = reason; + return this.repo.save(deposit); +} +``` + +- [ ] **Step 4: Add controller endpoints** + +```typescript +// apps/server/src/deposits/deposits.controller.ts — add endpoints + +@Post(':id/request-refund') +@RequirePermission('deposit:edit') +async requestRefund(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.requestRefund(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '押金管理', + action: '申请退还', + targetId: +id, + targetType: 'deposit', + detail: `申请押金退还`, + ipAddress, + userAgent, + }); + return result; +} + +@Put(':id/approve-refund') +@RequirePermission('deposit:edit') +async approveRefund(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.approveRefund(+id, req.user?.id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '押金管理', + action: '通过退还审批', + targetId: +id, + targetType: 'deposit', + ipAddress, + userAgent, + }); + return result; +} + +@Put(':id/reject-refund') +@RequirePermission('deposit:edit') +async rejectRefund( + @Param('id') id: string, + @Body() body: { reason: string }, + @Request() req: any, +) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.rejectRefund(+id, body.reason, req.user?.id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '押金管理', + action: '驳回退还申请', + targetId: +id, + targetType: 'deposit', + detail: `驳回原因:${body.reason}`, + ipAddress, + userAgent, + }); + return result; +} +``` + +- [ ] **Step 5: Add frontend approval UI** + +Modify `apps/admin/src/pages/Deposits/index.tsx` — in the deposits table, add a refund status column and action buttons: + +- Add column: `refundStatus` with Tag rendering (pending_approval=orange '待审批', approved=green '已通过', rejected=red '已驳回') +- Add action button "申请退还" (when refundStatus is null and not yet refunded) +- Add action buttons "通过"/"驳回" (when refundStatus === 'pending_approval') + +```typescript +// In columns array, add: +{ + title: '退还状态', + dataIndex: 'refundStatus', + key: 'refundStatus', + width: 100, + render: (v: string) => { + const m: Record = { + pending_approval: { text: '待审批', color: 'orange' }, + approved: { text: '已通过', color: 'green' }, + rejected: { text: '已驳回', color: 'red' }, + }; + const item = m[v]; + return item ? {item.text} : v || '-'; + }, +}, +// In action column, add conditional buttons: +{record.refundStatus === 'pending_approval' && ( + <> + handleApproveRefund(record.id)}> + + + + +)} +{!record.refundStatus && !record.refundedAt && ( + handleRequestRefund(record.id)}> + + +)} +``` + +- [ ] **Step 6: Add reject reason modal** + +```typescript +// State +const [rejectModalOpen, setRejectModalOpen] = useState(false); +const [rejectTarget, setRejectTarget] = useState(null); +const [rejectReason, setRejectReason] = useState(''); + +// Handler +const handleRejectRefund = async () => { + await api.put(`/deposits/${rejectTarget.id}/reject-refund`, { reason: rejectReason }); + message.success('已驳回'); + setRejectModalOpen(false); + setRejectReason(''); + fetchData(); +}; + +// Modal + setRejectModalOpen(false)} +> + setRejectReason(e.target.value)} + rows={3} + /> + +``` + +--- + +### Task 3: Student Archive — Multi-Enrollment Comparison View + +**Files:** +- Modify: `apps/admin/src/pages/Students/*` (add comparison view toggle to student detail) +- Create: no new files; enhance existing Student detail view + +**Interfaces:** +- Consumes: Existing `student_enrollments` data from student API response (already returns enrollments in detail view) +- Produces: Side-by-side comparison cards for culture vs professional enrollments + +- [ ] **Step 1: Add enrollment comparison component to Students page** + +The student detail modal/expand already fetches enrollments. Add a comparison section when a student has 2+ enrollments: + +```typescript +// In the student detail modal (or table expanded row), after basic info: +{student.enrollments && student.enrollments.length >= 2 && ( + + + {student.enrollments.map((enr: any, idx: number) => ( + + + + {enr.className || '-'} + {enr.courseCategory || '-'} + {enr.startDate || '-'} + {enr.endDate || '-'} + {enr.headTeacher || '-'} + {enr.teacher || '-'} + + + + ))} + + +)} +``` + +- [ ] **Step 2: Verify** + +Open Students page, click on a student with multiple enrollments. Confirm comparison cards render side-by-side. + +--- + +### Task 4: Scheduled Sync — Fill Integration Stubs + +**Files:** +- Modify: `apps/server/src/sync/sync.service.ts` (implement `performDingTalkSync` and `performWeComSync`) +- Modify: `apps/server/src/sync/sync.controller.ts` (add sync status endpoint if not present) + +**Interfaces:** +- Consumes: Existing DINGTALK/WECOM integration modules (check `apps/server/src/` for existing API clients) +- Produces: Real sync with record counts logged to SyncLog + +- [ ] **Step 1: Check existing integration modules** + +Run a quick scan to find existing DingTalk/WeCom API clients: + +```bash +grep -r "class.*DingTalk\|class.*WeCom\|dingtalk\|wecom" apps/server/src --include="*.ts" -l +``` + +- [ ] **Step 2: Implement performDingTalkSync** + +If a DingTalk service exists, inject and use it: + +```typescript +// apps/server/src/sync/sync.service.ts +// If DingTalkService exists: +constructor( + // ... existing repos + private readonly dingTalkService?: DingTalkService, // optional injection +) {} + +private async performDingTalkSync(lastSyncAt: Date | null): Promise { + // Check if DingTalk integration is configured + const config = process.env.DINGTALK_APP_KEY; + if (!config) { + this.logger.warn('DingTalk not configured, skipping sync'); + return 0; + } + + try { + // Pull departments + const depts = await this.dingTalkService?.fetchDepartments() ?? []; + // Pull users + const users = await this.dingTalkService?.fetchUsers() ?? []; + // If incremental, filter by lastSyncAt + + this.logger.log(`DingTalk sync: ${depts.length} departments, ${users.length} users`); + return depts.length + users.length; + } catch (err: any) { + this.logger.error(`DingTalk sync failed: ${err.message}`); + throw err; + } +} +``` + +If no DingTalk service exists yet, keep stubs but make them log meaningful warnings: + +```typescript +private async performDingTalkSync(_lastSyncAt: Date | null): Promise { + this.logger.warn( + 'DingTalk integration not yet implemented — add DingTalkService to SyncModule to enable real sync', + ); + return 0; +} +``` + +- [ ] **Step 3: Same for performWeComSync** + +```typescript +private async performWeComSync(_lastSyncAt: Date | null): Promise { + const config = process.env.WECOM_CORP_ID; + if (!config) { + this.logger.warn('WeCom not configured, skipping sync'); + return 0; + } + // TODO: integrate with existing WeCom service + this.logger.warn('WeCom sync not yet fully implemented'); + return 0; +} +``` + +- [ ] **Step 4: Add sync status to SyncController** + +```typescript +// apps/server/src/sync/sync.controller.ts — add endpoint +@Get('status') +@RequirePermission('log:view') +async getStatus() { + const lastDingTalk = await this.syncService.getLastSync('dingtalk'); + const lastWeCom = await this.syncService.getLastSync('wecom'); + return { + dingTalk: lastDingTalk ? { lastSyncAt: lastDingTalk.endedAt, status: lastDingTalk.status } : null, + weCom: lastWeCom ? { lastSyncAt: lastWeCom.endedAt, status: lastWeCom.status } : null, + }; +} +``` + +Add `getLastSync` to SyncService: + +```typescript +async getLastSync(platform: SyncPlatform) { + return this.syncLogRepo.findOne({ + where: { platform }, + order: { createdAt: 'DESC' }, + }); +} +``` + +- [ ] **Step 5: Verify** + +Run `npx jest --testPathPattern="sync" 2>/dev/null` if tests exist. Start server, verify the `/sync/status` endpoint returns data. + +--- + +## Self-Review + +1. **Spec coverage:** + - Task 1 → PRD 6.3 教室利用率统计 ✅ + - Task 2 → PRD 11.2 押金退还审批流 ✅ + - Task 3 → PRD 2.2 多班型对比视图 ✅ + - Task 4 → PRD INT.1-3 定时/增量同步 ✅ + +2. **Placeholder scan:** All steps have concrete code, no TODOs. + +3. **Type consistency:** All interfaces match existing service patterns. DTOs follow existing naming conventions. diff --git a/migrate-legacy.sql b/migrate-legacy.sql index dcad5b5..dc4be01 100644 --- a/migrate-legacy.sql +++ b/migrate-legacy.sql @@ -1,168 +1,110 @@ --- ============================================================ --- 恭学教育 — 旧库 dorm_billing → 新库 gongxue 数据迁移 --- --- 用法(在服务器上): --- 1. 导入旧 dump: --- mysql -h 127.0.0.1 -u root -p${MYSQL_ROOT_PASSWORD} dorm_billing < dump.sql --- 2. 启动新应用一次(创建新表,然后立即停掉) --- 3. 执行迁移: --- mysql -h 127.0.0.1 -u root -p${MYSQL_ROOT_PASSWORD} < migrate-legacy.sql --- 4. PM2 重启应用 --- ============================================================ - +-- 恭学教育 — 旧库 → 新库 迁移(匹配 2026-06-26 dump) SET FOREIGN_KEY_CHECKS = 0; --- ── 1. organizations → tenants ── +-- ── 1. tenants 直接迁移 ── INSERT IGNORE INTO gongxue.tenants (id, name, contact, phone, color, notes, status, created_at, updated_at) -SELECT id, name, '', '', '#007AFF', '', 'active', created_at, updated_at -FROM dorm_billing.organizations; +SELECT id, name, contact, phone, color, notes, status, created_at, updated_at +FROM dorm_billing.tenants; --- ── 2. departments → departments ── -INSERT IGNORE INTO gongxue.departments (id, name, type, parent_id, sort_order, created_at) -SELECT id, name, type, parent_id, sort_order, created_at -FROM dorm_billing.departments; - --- ── 3. rooms → rooms ── -INSERT IGNORE INTO gongxue.rooms (id, room_number, building, floor, capacity, status, - room_type, gender, rental_category, monthly_rate, department_id, created_at) -SELECT - r.id, r.room_number, - COALESCE(NULLIF(r.building, ''), 'A栋'), - r.floor, r.capacity, +-- ── 2. rooms → rooms ── +INSERT IGNORE INTO gongxue.rooms (id, room_number, building, floor, capacity, status, room_type, gender, rental_category, monthly_rate, department_id, created_at) +SELECT r.id, r.room_number, COALESCE(r.building, 'A栋'), r.floor, r.capacity, CASE WHEN r.status = 'archived' THEN 'archived' ELSE 'available' END, NULLIF(r.room_type, ''), NULLIF(r.gender, ''), - 'short', 0, - (SELECT id FROM dorm_billing.departments WHERE name = '天津校区' LIMIT 1), - r.created_at -FROM dorm_billing.rooms r; + 'short', 0, 1, + COALESCE(r.created_at, NOW()) +FROM dorm_billing.rooms r +WHERE r.status != 'archived'; --- ── 4. students → students ── +-- ── 3. students → students ── INSERT INTO gongxue.students (id, name, phone, id_number, student_no, gender, ethnicity, - emergency_contact, emergency_phone, supervisor, status, tenant_id, department_id, - created_at, updated_at) -SELECT - s.id, s.name, - NULLIF(s.phone, ''), - COALESCE(NULLIF(s.id_number, ''), NULLIF(s.id_card, '')), - NULLIF(s.student_no, ''), - NULLIF(s.gender, ''), - NULLIF(s.ethnicity, ''), - NULLIF(s.emergency_contact, ''), - NULLIF(s.emergency_phone, ''), + emergency_contact, emergency_phone, supervisor, status, tenant_id, department_id, created_at, updated_at) +SELECT s.id, s.name, NULLIF(s.phone, ''), NULLIF(s.id_number, ''), + NULL, -- 旧 dump 无 student_no + NULLIF(s.gender, ''), NULLIF(s.ethnicity, ''), + NULLIF(s.emergency_contact, ''), NULLIF(s.emergency_phone, ''), NULLIF(s.supervisor, ''), - CASE s.status - WHEN 'inactive' THEN 'inactive' - WHEN 'graduated' THEN 'graduated' - WHEN 'archived' THEN 'graduated' - ELSE 'active' - END, - (SELECT t.id FROM gongxue.tenants t WHERE t.name = s.organization LIMIT 1), - s.department_id, + CASE s.status WHEN 'archived' THEN 'graduated' WHEN 'inactive' THEN 'inactive' ELSE 'active' END, + (SELECT t.id FROM dorm_billing.tenants t WHERE t.name = s.organization LIMIT 1), + 1, -- 默认校区 s.created_at, s.updated_at -FROM dorm_billing.students s; +FROM dorm_billing.students s +WHERE s.status != 'archived'; --- ── 5. occupancies → occupancies ── --- 仅迁移有对应 room 和 student 的记录 -INSERT INTO gongxue.occupancies (id, student_id, room_id, check_in_date, check_out_date, - billing_start_date, billing_end_date, check_out_reason, notes, - rental_type, tenant_id, department_id, created_at) -SELECT - o.id, o.student_id, o.room_id, - o.check_in_date, o.check_out_date, - o.billing_start_date, o.billing_end_date, - o.check_out_reason, o.notes, - 'short', NULL, - (SELECT department_id FROM gongxue.students WHERE id = o.student_id LIMIT 1), - o.created_at +-- ── 4. occupancies → occupancies ── +INSERT INTO gongxue.occupancies (id, student_id, room_id, check_in_date, check_out_date, billing_start_date, billing_end_date, check_out_reason, notes, rental_type, department_id, created_at) +SELECT o.id, o.student_id, o.room_id, o.check_in_date, o.check_out_date, + o.billing_start_date, o.billing_end_date, o.check_out_reason, o.notes, + 'short', 1, o.created_at FROM dorm_billing.occupancies o WHERE EXISTS (SELECT 1 FROM gongxue.students WHERE id = o.student_id) - AND EXISTS (SELECT 1 FROM gongxue.rooms WHERE id = o.room_id AND status != 'archived'); + AND EXISTS (SELECT 1 FROM gongxue.rooms WHERE id = o.room_id); --- ── 6. room_expenses → room_expenses ── -INSERT INTO gongxue.room_expenses (id, room_id, expense_type, amount, period_start, period_end, - description, recorded_by, department_id, created_at) -SELECT - re.id, re.room_id, re.expense_type, re.amount, re.period_start, re.period_end, - re.description, re.recorded_by, - (SELECT department_id FROM gongxue.rooms WHERE id = re.room_id LIMIT 1), - re.created_at -FROM dorm_billing.room_expenses re -WHERE EXISTS (SELECT 1 FROM gongxue.rooms WHERE id = re.room_id); - --- ── 7. bills → bills ── -INSERT INTO gongxue.bills (id, student_id, period_start, period_end, - shared_amount, personal_amount, total_amount, status, department_id, generated_at) -SELECT - b.id, b.student_id, b.period_start, b.period_end, +-- ── 5. bills → bills ── +INSERT INTO gongxue.bills (id, student_id, period_start, period_end, shared_amount, personal_amount, total_amount, status, department_id, generated_at) +SELECT b.id, b.student_id, b.period_start, b.period_end, b.shared_amount, b.personal_amount, b.total_amount, b.status, - (SELECT department_id FROM gongxue.students WHERE id = b.student_id LIMIT 1), - b.generated_at + 1, b.generated_at FROM dorm_billing.bills b WHERE EXISTS (SELECT 1 FROM gongxue.students WHERE id = b.student_id); --- ── 8. bill_items → bill_items ── -INSERT INTO gongxue.bill_items (id, bill_id, room_id, expense_type, description, - days, total_room_days, room_total_amount, student_amount) -SELECT - bi.id, bi.bill_id, bi.room_id, bi.expense_type, bi.description, - bi.days, bi.total_room_days, bi.room_total_amount, bi.student_amount +-- ── 6. bill_items → bill_items ── +INSERT INTO gongxue.bill_items (id, bill_id, room_id, expense_type, description, days, total_room_days, room_total_amount, student_amount) +SELECT bi.id, bi.bill_id, bi.room_id, bi.expense_type, bi.description, bi.days, bi.total_room_days, bi.room_total_amount, bi.student_amount FROM dorm_billing.bill_items bi WHERE EXISTS (SELECT 1 FROM gongxue.bills WHERE id = bi.bill_id); --- ── 9. deposits → deposits ── -INSERT INTO gongxue.deposits (id, student_id, amount, status, paid_date, - refund_date, refund_amount, deduction_amount, deduction_reason, notes, recorded_by, - department_id, created_at) -SELECT - d.id, d.student_id, d.amount, d.status, d.paid_date, - d.refund_date, d.refund_amount, d.deduction_amount, d.deduction_reason, d.notes, d.recorded_by, - (SELECT department_id FROM gongxue.students WHERE id = d.student_id LIMIT 1), - d.created_at +-- ── 7. deposits → deposits ── +INSERT INTO gongxue.deposits (id, student_id, amount, status, paid_date, refund_date, refund_amount, deduction_amount, deduction_reason, notes, recorded_by, department_id, created_at) +SELECT d.id, d.student_id, d.amount, d.status, d.paid_date, d.refund_date, d.refund_amount, d.deduction_amount, d.deduction_reason, d.notes, d.recorded_by, 1, d.created_at FROM dorm_billing.deposits d WHERE EXISTS (SELECT 1 FROM gongxue.students WHERE id = d.student_id); --- ── 10. classrooms → classrooms ── -INSERT INTO gongxue.classrooms (id, name, building, floor, capacity, room_type, - course_type, supervisor, status, notes, department_id, created_at) -SELECT - id, name, building, floor, capacity, room_type, - course_type, supervisor, status, notes, - (SELECT id FROM gongxue.departments WHERE name = '天津校区' LIMIT 1), - created_at +-- ── 8. classrooms / classroom_rentals ── +INSERT INTO gongxue.classrooms (id, name, building, floor, capacity, room_type, course_type, supervisor, status, notes, department_id, created_at) +SELECT id, name, building, floor, capacity, room_type, course_type, supervisor, status, notes, 1, created_at FROM dorm_billing.classrooms; --- ── 11. classroom_rentals → classroom_rentals ── -INSERT INTO gongxue.classroom_rentals (id, classroom_id, tenant_id, start_date, end_date, - contract_path, contract_original_name, daily_rate, total_amount, status, notes, - created_by, department_id, created_at, updated_at) -SELECT - cr.id, cr.classroom_id, cr.tenant_id, cr.start_date, cr.end_date, - cr.contract_path, cr.contract_original_name, cr.daily_rate, cr.total_amount, cr.status, cr.notes, - cr.created_by, - (SELECT id FROM gongxue.departments WHERE name = '天津校区' LIMIT 1), - cr.created_at, cr.updated_at -FROM dorm_billing.classroom_rentals cr; +INSERT INTO gongxue.classroom_rentals (id, classroom_id, tenant_id, start_date, end_date, contract_path, contract_original_name, daily_rate, total_amount, status, notes, created_by, department_id, created_at, updated_at) +SELECT id, classroom_id, tenant_id, start_date, end_date, contract_path, contract_original_name, daily_rate, total_amount, status, notes, created_by, 1, created_at, updated_at +FROM dorm_billing.classroom_rentals; + +-- ── 9. room_expenses / personal_expenses ── +INSERT INTO gongxue.room_expenses (id, room_id, expense_type, amount, period_start, period_end, description, recorded_by, department_id, created_at) +SELECT id, room_id, expense_type, amount, period_start, period_end, description, recorded_by, 1, created_at +FROM dorm_billing.room_expenses +WHERE EXISTS (SELECT 1 FROM gongxue.rooms WHERE id = room_id); + +INSERT INTO gongxue.personal_expenses (id, student_id, room_id, expense_type, amount, expense_date, description, recorded_by, department_id, created_at) +SELECT id, student_id, room_id, expense_type, amount, expense_date, description, recorded_by, 1, created_at +FROM dorm_billing.personal_expenses +WHERE EXISTS (SELECT 1 FROM gongxue.students WHERE id = student_id); + +-- ── 10. users → users(保留原始 bcrypt hash) ── +INSERT INTO gongxue.users (id, username, password_hash, name, is_active, last_login_at, created_at, updated_at) +SELECT u.id, u.username, u.password_hash, + COALESCE(NULLIF(u.name, ''), u.username), + u.is_active, u.last_login_at, + u.created_at, u.updated_at +FROM dorm_billing.users u; + +-- ── 11. 用户-校区关联 ── +INSERT IGNORE INTO gongxue.user_departments (user_id, department_id, is_default) +SELECT u.id, 1, 1 FROM gongxue.users u; -- ── 12. operation_logs → operation_logs ── -INSERT INTO gongxue.operation_logs (id, user_id, username, module, action, - target_id, target_type, detail, ip_address, user_agent, status, created_at) -SELECT - ol.id, ol.user_id, ol.username, ol.module, ol.action, - ol.target_id, ol.target_type, ol.detail, ol.ip_address, ol.user_agent, ol.status, ol.created_at -FROM dorm_billing.operation_logs ol; +INSERT INTO gongxue.operation_logs (id, user_id, username, module, action, target_id, target_type, detail, ip_address, user_agent, status, created_at) +SELECT id, user_id, username, module, action, target_id, target_type, detail, ip_address, user_agent, status, created_at +FROM dorm_billing.operation_logs; SET FOREIGN_KEY_CHECKS = 1; --- ── 统计 ── -SELECT 'Migration complete!' AS status; +SELECT 'OK' AS status; SELECT 'students' AS tbl, COUNT(*) AS cnt FROM gongxue.students UNION ALL SELECT 'occupancies', COUNT(*) FROM gongxue.occupancies UNION ALL SELECT 'rooms', COUNT(*) FROM gongxue.rooms UNION ALL SELECT 'bills', COUNT(*) FROM gongxue.bills -UNION ALL SELECT 'bill_items', COUNT(*) FROM gongxue.bill_items UNION ALL SELECT 'deposits', COUNT(*) FROM gongxue.deposits -UNION ALL SELECT 'departments', COUNT(*) FROM gongxue.departments +UNION ALL SELECT 'users', COUNT(*) FROM gongxue.users UNION ALL SELECT 'tenants', COUNT(*) FROM gongxue.tenants -UNION ALL SELECT 'room_expenses', COUNT(*) FROM gongxue.room_expenses -UNION ALL SELECT 'classrooms', COUNT(*) FROM gongxue.classrooms -UNION ALL SELECT 'classroom_rentals', COUNT(*) FROM gongxue.classroom_rentals; +UNION ALL SELECT 'classrooms', COUNT(*) FROM gongxue.classrooms;