fix: null cast for refundStatus in TypeORM
This commit is contained in:
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@@ -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
|
||||
*~
|
||||
@@ -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;
|
||||
|
||||
586
docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md
Normal file
586
docs/superpowers/plans/2026-07-06-p2-remaining-tasks.md
Normal file
@@ -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<UtilizationStats>` 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
|
||||
<Card title="教室利用率" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="教室总数" value={classroomUtil?.totalClassrooms ?? '-'} prefix={<ReadOutlined />} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="今日使用" value={classroomUtil?.inUseCount ?? '-'} prefix={<CheckCircleOutlined />} />
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title="利用率"
|
||||
value={classroomUtil?.utilizationRate ?? '-'}
|
||||
suffix="%"
|
||||
prefix={<PercentageOutlined />}
|
||||
valueStyle={{ color: Number(classroomUtil?.utilizationRate ?? 0) > 70 ? '#34C759' : '#FF9500' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic title="内部排课" value={classroomUtil?.scheduleCount ?? '-'} prefix={<CalendarOutlined />} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
```
|
||||
|
||||
- [ ] **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<string, { text: string; color: string }> = {
|
||||
pending_approval: { text: '待审批', color: 'orange' },
|
||||
approved: { text: '已通过', color: 'green' },
|
||||
rejected: { text: '已驳回', color: 'red' },
|
||||
};
|
||||
const item = m[v];
|
||||
return item ? <Tag color={item.color}>{item.text}</Tag> : v || '-';
|
||||
},
|
||||
},
|
||||
// In action column, add conditional buttons:
|
||||
{record.refundStatus === 'pending_approval' && (
|
||||
<>
|
||||
<Popconfirm title="确认通过?" onConfirm={() => handleApproveRefund(record.id)}>
|
||||
<Button size="small" type="link" style={{ color: '#34C759' }}>通过</Button>
|
||||
</Popconfirm>
|
||||
<Button size="small" type="link" danger onClick={() => {
|
||||
setRejectTarget(record);
|
||||
setRejectModalOpen(true);
|
||||
}}>驳回</Button>
|
||||
</>
|
||||
)}
|
||||
{!record.refundStatus && !record.refundedAt && (
|
||||
<Popconfirm title="确认提交退还申请?" onConfirm={() => handleRequestRefund(record.id)}>
|
||||
<Button size="small" type="link">申请退还</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add reject reason modal**
|
||||
|
||||
```typescript
|
||||
// State
|
||||
const [rejectModalOpen, setRejectModalOpen] = useState(false);
|
||||
const [rejectTarget, setRejectTarget] = useState<any>(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
|
||||
<Modal
|
||||
title="驳回退还申请"
|
||||
open={rejectModalOpen}
|
||||
onOk={handleRejectRefund}
|
||||
onCancel={() => setRejectModalOpen(false)}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Modal>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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 && (
|
||||
<Card title="多班型对比" size="small" style={{ marginTop: 16 }}>
|
||||
<Row gutter={16}>
|
||||
{student.enrollments.map((enr: any, idx: number) => (
|
||||
<Col span={12} key={enr.id}>
|
||||
<Card
|
||||
size="small"
|
||||
title={enr.classType || `班型 ${idx + 1}`}
|
||||
style={{ background: idx === 0 ? '#f0f5ff' : '#f6ffed' }}
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="课程类别">{enr.courseCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">{enr.startDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">{enr.endDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="班主任">{enr.headTeacher || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="任课教师">{enr.teacher || '-'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **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<number> {
|
||||
// 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<number> {
|
||||
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<number> {
|
||||
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.
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user