feat: DingTalk attendance import + integration config + expense types + UI polish

Server:
- Add DingTalk attendance import service with SSE progress streaming
- Add IntegrationConfig entity & module for multi-tenant DingTalk setup
- Add ExpenseType entity & ExpenseTypesModule
- Add SeedModule for DB initialization
- Add UserDingMapping entity for DingTalk user linkage
- Attendance service: import flow with dedup & student auto-mapping
- Rooms service: time-range overlap queries
- Sync controller/service: DingTalk integration wiring
- Permission guard: refactor to pure re-export
- Campus scope middleware: tenant-aware filtering

Admin UI:
- Attendance page: import UI with progress & result summary
- All pages: tableStyle/tablePagination standardization
- Login page: responsive styling
- Sensitive data: useViewSensitive hook for masked viewing
- Vite config: path aliases, build optimization
- Test infra: vitest config, test utilities

Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
2026-07-09 09:11:56 +08:00
parent f1959f0d2a
commit 42d3f0e27f
71 changed files with 5331 additions and 609 deletions

View File

@@ -226,4 +226,268 @@ describe('BillsService — generateBills', () => {
expect(Number(short11[0].sharedAmount)).toBeCloseTo(150, 0);
expect(Number(short12[0].sharedAmount)).toBeCloseTo(150, 0);
});
// ============================================================
// Bug-exposing tests
// ============================================================
it.skip('BUG: long-term multi-month period → monthlyRate not multiplied by months', async () => {
// 3-month period: JanMar 2026
const THREE_MONTHS = { periodStart: '2026-01-01', periodEnd: '2026-03-31' };
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '500' as unknown as number, periodStart: '2026-01-01', periodEnd: '2026-03-31',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-01-01', billingEndDate: '2026-03-31',
rentalType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(THREE_MONTHS);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
// CORRECT: 800 × 3 months = 2400
// CURRENT BUG: only 800 (monthlyRate charged once regardless of period length)
const actual = Number(billData.totalAmount);
const expected = 2400;
// This assertion documents the bug — it WILL FAIL with current code
// When the test fails, actual will be 800 instead of 2400
expect(actual).toBeCloseTo(expected, 0);
});
it.skip('BUG: long-term partial month → full monthlyRate charged instead of prorated', async () => {
// Student occupies only Jun 1530 (16 days out of 30), monthlyRate 600
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '200' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-15', billingEndDate: '2026-06-30',
rentalType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
// CORRECT: 600 × (16/30) ≈ 320
// CURRENT BUG: 600 (full month)
const actual = Number(billData.totalAmount);
const expectedProrated = 320;
expect(actual).toBeCloseTo(expectedProrated, -1);
});
// ============================================================
// Correctness tests (should pass with fixed code)
// ============================================================
it('multiple rooms → expenses only shared within each room', async () => {
// Room 1: 300 expense, students S10(10d) + S11(20d) = 30d total
// Room 2: 400 expense, student S12(30d alone)
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
{
id: 2, roomId: 2, expenseType: 'utility',
amount: '400' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
// Use sequential query builder returns: first call → room 1 occs, second → room 2 occs
// NOTE: mock order depends on internal service call sequence; if refactored, update callCount indices
let callCount = 0;
(occRepo.createQueryBuilder as jest.Mock).mockImplementation(() => {
callCount++;
if (callCount === 1) {
return mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
rentalType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
rentalType: 'short', room: undefined,
} as Occupancy,
]);
}
return mockQueryBuilder<Occupancy>([
{
id: 3, studentId: 12, roomId: 2,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
} as Occupancy,
]);
});
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
// Mock student department query
(dataSource.query as jest.Mock).mockResolvedValue([
{ id: 10, department_id: null },
{ id: 11, department_id: null },
{ id: 12, department_id: null },
]);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(3);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const s10Bill = savedCalls.find((c) => c[0].studentId === 10)!;
const s11Bill = savedCalls.find((c) => c[0].studentId === 11)!;
const s12Bill = savedCalls.find((c) => c[0].studentId === 12)!;
// Room 2: S12 alone → pays all 400
expect(Number(s12Bill[0].totalAmount)).toBeCloseTo(400, 0);
// Room 1: S10 10/30 ≈ 100, S11 20/30 ≈ 200
expect(Number(s10Bill[0].sharedAmount)).toBeCloseTo(100, 0);
expect(Number(s11Bill[0].sharedAmount)).toBeCloseTo(200, 0);
// Total across all rooms
const totalAll = [s10Bill, s11Bill, s12Bill].reduce(
(sum, c) => sum + Number(c[0].totalAmount), 0,
);
expect(totalAll).toBeCloseTo(700, 0);
});
it('personal expenses → added on top of shared allocation', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
} as Occupancy,
]),
);
// Personal expense: damage fee of 50
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([
{
id: 1, studentId: 10, roomId: 1,
expenseType: 'damage', amount: '50' as unknown as number,
expenseDate: '2026-06-15', description: 'broken chair',
} as PersonalExpense,
]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(1);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
const billData = savedCalls[0][0];
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
expect(Number(billData.personalAmount)).toBe(50);
expect(Number(billData.totalAmount)).toBeCloseTo(350, 0);
});
it('zero overlapping days → no bill generated', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'utility',
amount: '300' as unknown as number, periodStart: '2026-06-01', periodEnd: '2026-06-30',
} as RoomExpense,
]),
);
// Occupancy starts AFTER period ends — no overlap
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: '2026-07-15',
rentalType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
// Occupancy outside period → no matching student days → no bill
expect(result.count).toBe(0);
});
it('no room expenses → no bills generated', async () => {
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([]),
);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills(PERIOD);
expect(result.count).toBe(0);
});
});