Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions TRUNG_AI_USAGE_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Quy ước:

| # | Ngày | Người dùng | Công cụ / model | Phạm vi (module/file) | Prompt chính | Phần AI sinh | Phần tự sửa/viết | Cách kiểm chứng | Commit/PR |
|---|------|-----------|-----------------|-----------------------|--------------|--------------|------------------|-----------------|-----------|
| 70 | 10/11/2026 | junUTE (Trung) | Claude Code (Claude Sonnet 5.5, `claude-sonnet-5-5`) | Doctor B6: `scheduling/slots/slot-status.rules.ts`, `available-slots.reader.ts`, hai test `slot-status.rules.spec.ts`, `available-slots.reader.int-spec.ts`; `scheduling/scheduling.module.ts` và `scheduling.module.spec.ts`; `docs/features/doctor/TRACKING.md`; `TRUNG_AI_USAGE_LOG.md` | "đến B6 issue của JunUTE" | Đọc issue #280 và PLAN, viết luật thuần, `AvailableSlotsReader` (`list`, `listMany` 3 truy vấn), test đơn vị + MySQL thật, đăng ký ở `SchedulingModule` | AI viết test AV-08/AV-09 thiếu việc để trống cả bốn cột giờ khi đặt ngày nghỉ nên vi phạm `chk_lich_hoat_dong_ngay`; người dùng chạy full `test:int` và phát hiện, AI sửa test (lỗi ở test, không ở reader). Người dùng review, tự tạo nhánh/commit/PR | Chạy `oxlint`, `tsc`, `vitest run src/scheduling src/doctor` (85), `test:int` toàn bộ (1020/1021 trước khi sửa) rồi riêng file reader 14/14 trên MySQL 8.4. **Chưa chạy:** `nest build`, `npm run dup`, chạy lại `test:int` toàn bộ sau khi sửa | Chưa có PR |
| 69 | 10/11/2026 | junUTE (Trung) | Claude Code (Claude Sonnet 5.5, `claude-sonnet-5-5`) | Doctor B5: `scheduling/leave/leave-overlap.rules.ts`, `leave.reader.ts`, hai test `leave-overlap.rules.spec.ts`, `leave.reader.int-spec.ts`; `scheduling/scheduling.module.ts` và `scheduling.module.spec.ts`; `docs/features/doctor/TRACKING.md`; `TRUNG_AI_USAGE_LOG.md` | "bắt đầu triển khai b5" | Viết test trước (luật thuần + MySQL thật), rồi luật thuần, `LeaveReader` (`findBlockingLeave`, `listBlocking`), đăng ký và xuất ở `SchedulingModule` | AI import kiểu enum từ `doctor/entities` vào `scheduling/` (vi phạm H20); tự phát hiện khi đối chiếu luật kiến trúc trước khi chạy test, sửa bằng hai kiểu khai báo lại trong `scheduling/leave`. Người dùng review, tự tạo nhánh/commit/PR. Lần này chạy test tuần tự, không chạy song song trên cùng DB | Chạy `tsc`, `oxlint`, `nest build`, `npm test` (2505), `test:int` `scheduling` + `doctor` (34, riêng B5 8/8) trên MySQL 8.4. **Chưa chạy:** `test:int` toàn bộ, `npm run dup` | Chưa có PR |
| 68 | 09/11/2026 | junUTE (Trung) | Claude Code (Claude Sonnet 5.5, `claude-sonnet-5-5`) | Doctor B3: `doctor/shifts/shift-assignment.{controller,service,repository}.ts`; test `test/doctor-shift-assign.e2e-spec.ts`; `docs/database/clinix_quy_tac_nghiep_vu.md` (mục B3); `docs/TECH_DEBT.md` (#87); `docs/features/doctor/TRACKING.md`; `TRUNG_AI_USAGE_LOG.md` | "vậy giờ triển khai b3", "tiến hành" | Viết test e2e trước, rồi `assign` (khoá bac_si một câu, FOR SHARE dịch vụ, chạy lại buildPlan dưới khoá, insert lô, sinh khung giờ, audit, thử lại 1213/1205), repository, controller; soạn mục quy tắc và dòng nợ | Người dùng review, tự tạo nhánh và commit. Test đầu có 1 ca đỏ do AI viết `ORDER BY ca` sai thứ tự enum (sửa test, không sửa mã). **Sai sót quy trình của AI:** theo dõi `test:int` bằng điều kiện nhận diện tiến trình sai nên báo "đã kết thúc" nhầm, rồi chạy phép đo song song trên cùng DB `clinix_test`; kết quả của cả hai lần bị bỏ, DB test lỗi migration tạm thời; AI không tự dừng được tiến trình (lệnh bị chặn) nên nhờ người dùng. File đo tạm đã xoá | Chạy `tsc`, `oxlint`, `nest build`, `npm test` (2497), e2e `doctor-shift-assign` 15/15 và `doctor-shift-preview` 14/14 trên MySQL 8.4. **Chưa chạy:** `test:int` đầy đủ, `npm run dup`, đo 1.000 ca | `e959e9c` (cục bộ), chưa có PR |
| 67 | 09/11/2026 | junUTE (Trung) | Claude Code (Claude Sonnet 5.5, `claude-sonnet-5-5`) | Doctor P4: `docs/database/clinix_mysql.sql`, `clinix.dbml`, `clinix_quy_tac_nghiep_vu.md`; migration `1793361600000-nhat-ky-phan-cong-nghi-phep.ts` + `migrations/index.ts`; `admin/entities/enums.ts`; `audit/audit-entry.ts`, `audit-validation.ts`, `testing/audit-fixtures.ts`; test `audit-validation.spec.ts`, `audit-enum-parity.int-spec.ts`, `audit.int-spec.ts`, `migrations.int-spec.ts`, `schema-parity.int-spec.ts`, `migration-source.spec.ts`, `testing/official-schema.ts`; `docs/features/doctor/TRACKING.md`; `TRUNG_AI_USAGE_LOG.md` | "vậy tiến hành P4 trước" | Mở rộng enum nhật ký (4 hành động, 2 loại đối tượng) ở ba file schema, migration `MODIFY`, đổi `TargetFor` thành bảng tra, kiểm payload danh sách trắng, test MG-06/MG-08/TD-06 | Người dùng review, tự tạo nhánh/commit/push/mở PR; AI tự phát hiện test so DDL migration P3 với schema chính thức sẽ đỏ sau khi đổi enum và sửa bằng cách che hai danh sách enum; lỡ định dạng lại 2 file có sẵn ngoài phạm vi bằng prettier, đã hoàn lại ngay | Chạy `tsc`, `oxlint`, `nest build`, `npm test` (2497), `test:int` audit + database (62) và admin (50) trên MySQL 8.4; đều pass | Chưa có PR |
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/scheduling/scheduling.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { Test } from '@nestjs/testing';
import { DataSource } from 'typeorm';
import { AvailableSlotsReader } from './slots/available-slots.reader.js';
import { BranchHoursReader } from './branch-hours/branch-hours.reader.js';
import { LeaveReader } from './leave/leave.reader.js';
import { SchedulingModule } from './scheduling.module.js';

// Doctor P2 — SchedulingModule dựng được độc lập (không kéo module khác) và xuất BranchHoursReader cho booking/search.
describe('SchedulingModule', () => {
it('tự dựng được và cung cấp BranchHoursReader, LeaveReader', async () => {
// AvailableSlotsReader cần DataSource (do AppModule/TypeOrmModule cấp); ở đây giả để dựng module độc lập.
const moduleRef = await Test.createTestingModule({
imports: [SchedulingModule],
}).compile();
})
.useMocker((token) => (token === DataSource ? {} : undefined))
.compile();
expect(moduleRef.get(AvailableSlotsReader)).toBeInstanceOf(
AvailableSlotsReader,
);
expect(moduleRef.get(BranchHoursReader)).toBeInstanceOf(BranchHoursReader);
expect(moduleRef.get(LeaveReader)).toBeInstanceOf(LeaveReader);
});
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/scheduling/scheduling.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { Module } from '@nestjs/common';
import { BranchHoursReader } from './branch-hours/branch-hours.reader.js';
import { LeaveReader } from './leave/leave.reader.js';
import { AvailableSlotsReader } from './slots/available-slots.reader.js';
import { SlotGeneratorService } from './slots/slot-generator.js';

// Module dùng chung do `doctor` sở hữu; KHÔNG phụ thuộc appointments/ (H20). Các provider khác
// (AvailableSlotsReader, ShiftQueries) được thêm ở B6, B7. B5: LeaveReader (D4).
// Module dùng chung do `doctor` sở hữu; KHÔNG phụ thuộc appointments/ (H20). ShiftQueries được thêm ở B7.
// B5: LeaveReader (D4). B6: AvailableSlotsReader (D3).
@Module({
providers: [BranchHoursReader, SlotGeneratorService, LeaveReader],
exports: [BranchHoursReader, SlotGeneratorService, LeaveReader],
providers: [
BranchHoursReader,
SlotGeneratorService,
LeaveReader,
AvailableSlotsReader,
],
exports: [
BranchHoursReader,
SlotGeneratorService,
LeaveReader,
AvailableSlotsReader,
],
})
export class SchedulingModule {}
312 changes: 312 additions & 0 deletions apps/api/src/scheduling/slots/available-slots.reader.int-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
import type { DataSource } from 'typeorm';
import { useIntegrationHooks } from '../../auth/testing/int-hooks.js';
import { DoctorFixtures } from '../../doctor/testing/doctor-fixtures.js';
import { BranchHoursReader } from '../branch-hours/branch-hours.reader.js';
import { LeaveReader } from '../leave/leave.reader.js';
import { AvailableSlotsReader } from './available-slots.reader.js';

// Doctor B6 trên MySQL 8.4 THẬT: AV-01…AV-13, AV-15, AV-16, HD-02, HD-12 (AV-14 và luật thuần ở slot-status.rules.spec.ts).
let db: DataSource;
let fx: DoctorFixtures;
let reader: AvailableSlotsReader;
useIntegrationHooks((d) => {
db = d;
fx = new DoctorFixtures(d);
reader = new AvailableSlotsReader(
d,
new BranchHoursReader(),
new LeaveReader(),
);
});

const NGAY = '2026-12-07'; // Thứ Hai
const NOW = new Date('2026-12-01T00:00:00+07:00'); // trước mọi khung giờ của test
const handled = (admin: string) => ({
nguoiXuLyId: admin,
thoiDiemXuLy: '2026-12-01 09:00:00',
});

/** Bác sĩ có một ca sáng (8 ô 30 phút) vào NGAY. */
async function doctorWithMorning() {
const w = await fx.world();
const { shift, slotIds } = await fx.shiftWithSlots(w.doctor.profile, {
ngay: NGAY,
ca: 'CaSang',
});
return { w, id: w.doctor.account.taiKhoanId, shift, slotIds };
}

const setStatus = (
id: string,
trangThai: string,
giuChoDen: string | null = null,
) =>
db.query(
'UPDATE khung_gio SET trang_thai = ?, giu_cho_den = ? WHERE khung_gio_id = ?',
[trangThai, giuChoDen, id],
);

describe('AvailableSlotsReader trên MySQL thật', () => {
it('AV-01 trạng thái từng ô: ConTrong, DaDat, DangGiuCho còn hạn, DangGiuCho hết hạn', async () => {
const { id, slotIds } = await doctorWithMorning();
await setStatus(slotIds[1] as string, 'DaDat');
await setStatus(slotIds[2] as string, 'DangGiuCho', '2026-12-06 10:00:00');
await setStatus(slotIds[3] as string, 'DangGiuCho', '2026-11-30 10:00:00');
const day = await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(day.khungGio.slice(0, 4).map((s) => s.trangThai)).toEqual([
'ConTrong',
'DaDat',
'DangGiuCho',
'ConTrong',
]);
});

it('AV-01 ô đã quá giờ (đồng hồ giả) là HetHan, ô sau đó còn trống', async () => {
const { id } = await doctorWithMorning();
const day = await reader.list({
bacSiId: id,
ngay: NGAY,
now: new Date(`${NGAY}T08:45:00+07:00`),
});
expect(day.khungGio.slice(0, 3).map((s) => s.trangThai)).toEqual([
'HetHan',
'HetHan',
'ConTrong',
]);
});

it('AV-02 nghỉ ChoDuyet phủ ô: bị loại, ngày là NghiPhep', async () => {
const { id } = await doctorWithMorning();
await fx.leaveRequest({ bacSiId: id, batDau: NGAY });
const day = await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(day.khungGio).toEqual([]);
expect(day.trangThaiNgay).toBe('NghiPhep');
});

it('AV-03/AV-04 DaDuyet loại ô; TuChoi không ảnh hưởng', async () => {
const { w, id } = await doctorWithMorning();
const admin = w.systemAdmin.account.taiKhoanId;
const approved = await fx.leaveRequest({
bacSiId: id,
batDau: NGAY,
trangThai: 'DaDuyet',
...handled(admin),
});
expect(
(await reader.list({ bacSiId: id, ngay: NGAY, now: NOW })).khungGio,
).toEqual([]);
await db.query(
'UPDATE yeu_cau_nghi_phep SET trang_thai = ?, nguoi_xu_ly_id = ?, thoi_diem_xu_ly = ? WHERE yeu_cau_nghi_phep_id = ?',
['TuChoi', admin, '2026-12-01 09:00:00', approved],
);
const day = await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(day.khungGio).toHaveLength(8);
expect(day.trangThaiNgay).toBe('CoLich');
});

it('AV-05 TheoCa CaSang: chỉ ca sáng bị loại, ca chiều còn', async () => {
const { w, id } = await doctorWithMorning();
await fx.shiftWithSlots(w.doctor.profile, { ngay: NGAY, ca: 'CaChieu' });
await fx.leaveRequest({
bacSiId: id,
hinhThuc: 'TheoCa',
ca: 'CaSang',
batDau: NGAY,
});
const day = await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(new Set(day.khungGio.map((s) => s.ca))).toEqual(
new Set(['CaChieu']),
);
expect(day.trangThaiNgay).toBe('CoLich');
});

it('AV-06 CaNgay nhiều ngày: ngày giữa và hai biên bị loại, ngoài khoảng còn', async () => {
const { w, id } = await doctorWithMorning();
for (const ngay of ['2026-12-06', '2026-12-08', '2026-12-09'])
await fx.shiftWithSlots(w.doctor.profile, { ngay, ca: 'CaSang' });
await fx.leaveRequest({
bacSiId: id,
batDau: '2026-12-06',
ketThuc: '2026-12-08',
});
const count = async (ngay: string) =>
(await reader.list({ bacSiId: id, ngay, now: NOW })).khungGio.length;
expect([
await count('2026-12-06'),
await count(NGAY),
await count('2026-12-08'),
await count('2026-12-09'),
]).toEqual([0, 0, 0, 8]);
});

it('AV-07 lịch hoạt động thu hẹp sau khi sinh: ô ngoài giờ hiện hành bị ẩn', async () => {
const { w, id } = await doctorWithMorning();
await db.query(
"UPDATE chi_nhanh_lich_hoat_dong SET gio_ket_thuc_sang = '10:00:00' WHERE chi_nhanh_id = ? AND thu_trong_tuan = 1",
[w.branch.chiNhanhId],
);
const day = await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(day.khungGio).toHaveLength(4); // 08:00, 08:30, 09:00, 09:30 (kết thúc ≤ 10:00)
});

it('AV-08/AV-09 trạng thái ngày: KhongCoLich, ChiNhanhNghi (nghỉ và chưa khai báo) kèm thuNghi', async () => {
const w = await fx.world();
const id = w.doctor.account.taiKhoanId;
const at = () => reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(await at()).toMatchObject({
trangThaiNgay: 'KhongCoLich',
khungGio: [],
});
await db.query(
// chk_lich_hoat_dong_ngay: ngày nghỉ thì cả bốn cột giờ phải để trống.
`UPDATE chi_nhanh_lich_hoat_dong
SET hoat_dong = 0, gio_bat_dau_sang = NULL, gio_ket_thuc_sang = NULL, gio_bat_dau_chieu = NULL, gio_ket_thuc_chieu = NULL
WHERE chi_nhanh_id = ? AND thu_trong_tuan = 1`,
[w.branch.chiNhanhId],
);
expect(await at()).toMatchObject({
trangThaiNgay: 'ChiNhanhNghi',
thuNghi: 1,
});
await db.query(
'DELETE FROM chi_nhanh_lich_hoat_dong WHERE chi_nhanh_id = ? AND thu_trong_tuan = 1',
[w.branch.chiNhanhId],
);
expect(await at()).toMatchObject({
trangThaiNgay: 'ChiNhanhNghi',
thuNghi: 1,
});
});

it('AV-10 khungGioId là chuỗi duy nhất; ketThuc = batDau + thời lượng; giờ VN ra đúng UTC (M4)', async () => {
const { id, slotIds } = await doctorWithMorning();
const { khungGio } = await reader.list({
bacSiId: id,
ngay: NGAY,
now: NOW,
});
expect(khungGio.map((s) => s.khungGioId)).toEqual(slotIds);
expect(new Set(khungGio.map((s) => s.khungGioId)).size).toBe(8);
expect(khungGio[0]).toMatchObject({
batDau: '2026-12-07T01:00:00.000Z',
ketThuc: '2026-12-07T01:30:00.000Z',
ca: 'CaSang',
});
});

it('AV-11/HD-12 listMany bằng list từng người và đủ 4 trangThaiNgay', async () => {
const { w, id } = await doctorWithMorning();
const resting = await fx.admin.doctor(w.branch);
await fx.shiftWithSlots(resting.profile, { ngay: NGAY, ca: 'CaSang' });
await fx.leaveRequest({
bacSiId: resting.account.taiKhoanId,
batDau: NGAY,
});
const idle = await fx.admin.doctor(w.branch);
const closedBranch = await fx.admin.branch();
await fx.admin.weeklySchedule(closedBranch.chiNhanhId, {
hoatDong: false,
gioBatDauSang: null,
gioKetThucSang: null,
gioBatDauChieu: null,
gioKetThucChieu: null,
});
const offBranch = await fx.admin.doctor(closedBranch);
const ids = [
id,
resting.account.taiKhoanId,
idle.account.taiKhoanId,
offBranch.account.taiKhoanId,
];

const many = await reader.listMany({ bacSiIds: ids, ngay: NGAY, now: NOW });
expect(many.map((d) => d.trangThaiNgay)).toEqual([
'CoLich',
'NghiPhep',
'KhongCoLich',
'ChiNhanhNghi',
]);
for (const one of many)
expect(
await reader.list({ bacSiId: one.bacSiId, ngay: NGAY, now: NOW }),
).toEqual(one);
});

it('AV-12 listMany 20 bác sĩ chỉ dùng ≤ 4 truy vấn', async () => {
const w = await fx.world();
const ids = [w.doctor.account.taiKhoanId];
for (let i = 0; i < 19; i += 1)
ids.push((await fx.admin.doctor(w.branch)).account.taiKhoanId);
await fx.shiftWithSlots(w.doctor.profile, { ngay: NGAY, ca: 'CaSang' });
const spy = vi.spyOn(db.manager, 'query');
await reader.listMany({ bacSiIds: ids, ngay: NGAY, now: NOW });
expect(spy.mock.calls.length).toBeLessThanOrEqual(4);
spy.mockRestore();
});

it('AV-13/HD-02 chỉ đọc; chạy trong em của người gọi: thấy dữ liệu chưa commit, không mở giao dịch riêng', async () => {
const { id } = await doctorWithMorning();
const before = await db.query('SELECT COUNT(*) AS n FROM khung_gio');
await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(await db.query('SELECT COUNT(*) AS n FROM khung_gio')).toEqual(
before,
);

const w = await fx.world();
await expect(
db.transaction(async (em) => {
await new DoctorFixtures(em).shiftWithSlots(w.doctor.profile, {
ngay: NGAY,
ca: 'CaSang',
});
const inside = await reader.list(
{ bacSiId: w.doctor.account.taiKhoanId, ngay: NGAY, now: NOW },
em,
);
expect(inside.khungGio).toHaveLength(8);
throw new Error('rollback');
}),
).rejects.toThrow('rollback');
expect(
(
await reader.list({
bacSiId: w.doctor.account.taiKhoanId,
ngay: NGAY,
now: NOW,
})
).khungGio,
).toEqual([]);
});

it('AV-15 bác sĩ không có ca hoặc không tồn tại: rỗng, không ném', async () => {
const w = await fx.world();
expect(
await reader.listMany({ bacSiIds: [], ngay: NGAY, now: NOW }),
).toEqual([]);
expect(
await reader.list({ bacSiId: '999999999', ngay: NGAY, now: NOW }),
).toMatchObject({ trangThaiNgay: 'KhongCoLich', khungGio: [] });
expect(
await reader.list({
bacSiId: w.doctor.account.taiKhoanId,
ngay: NGAY,
now: NOW,
}),
).toMatchObject({ khungGio: [] });
});

it('AV-16 ca mang chi nhánh khác chi nhánh hiện tại của bác sĩ: dùng giờ của chi nhánh trên ca', async () => {
const { id, shift } = await doctorWithMorning();
const other = await fx.admin.branch();
await fx.admin.weeklySchedule(other.chiNhanhId, {
gioBatDauSang: '09:00:00',
});
await db.query(
'UPDATE lich_lam_viec SET chi_nhanh_id = ? WHERE lich_lam_viec_id = ?',
[other.chiNhanhId, shift.lichLamViecId],
);
const day = await reader.list({ bacSiId: id, ngay: NGAY, now: NOW });
expect(day.khungGio).toHaveLength(6); // 09:00 … 11:30; 08:00 và 08:30 nằm ngoài giờ chi nhánh trên ca
expect(day.khungGio[0]?.batDau).toBe('2026-12-07T02:00:00.000Z');
});
});
Loading
Loading