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:
@@ -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: Jan–Mar 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 15–30 (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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user