From 087077158da0e629fd70726927b71f16390a271c Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Thu, 9 Jul 2026 22:24:26 +0700 Subject: [PATCH 01/23] =?UTF-8?q?fix(recon):=20Pha=201=20=C4=91=E1=BB=99?= =?UTF-8?q?=20ch=C3=ADnh=20x=C3=A1c=20=E2=80=94=20cascade=20ph=C3=AD=20th?= =?UTF-8?q?=E1=BB=91ng=20nh=E1=BA=A5t=20+=20tie-out=20XLSX=20+=20t=C3=AAn?= =?UTF-8?q?=20V=C4=90V?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit adversarial (21 findings confirmed) → fix nhóm "sai tiền/dữ liệu trên biên bản ký gửi merchant": - #1/#5/#6: trích resolveFeeParams() dùng chung preview/create/batchCreate/cron. batchCreate + cron trước đây bỏ qua event_fee_overrides (áp merchant default thay vì rate override race); create() thiếu rate → fee=0 âm thầm. Nay cascade event override (LATEST effective_from) > merchant > fallback 5.5% (Danny chốt 2026-07-09), không bao giờ rơi 0%. +14 unit test. - #8: XLSX Section-3 cột "Tổng cộng" thiếu trừ discount → grand total > Section-1 GMV khi có giảm giá. Fix tie-out (CHANGE_COURSE dùng subtotal net). +test. - #11: manual participant_name = mã đơn (o.name) thay vì tên VĐV → ưu tiên first+last name. +test. - #2: thêm filter financial_status='paid' (defense-in-depth; verify PROD 2026-07-09: 100% đơn COMPLETE đều paid → no-op, chốt bất biến). Refuted (đã hết): parsePeriod off-by-1, reviewed_by, triggerByIds ObjectId 500. Deferred Pha 2: #3/#4/#7/#9/#10 (nhất quán fee.service/analytics), #12 (half-open interval — đụng shared util + fee.service/analytics). Toàn bộ 121 test reconciliation pass, build xanh. Co-Authored-By: Claude Opus 4.8 --- .../reconciliation/reconciliation.service.ts | 90 +++----- .../reconciliation-calc.service.spec.ts | 110 +++++++++ .../services/reconciliation-calc.service.ts | 7 +- .../services/reconciliation-query.service.ts | 4 + .../services/reconciliation.cron.ts | 5 +- .../reconciliation/services/xlsx.service.ts | 15 +- .../utils/fee-params.util.spec.ts | 209 ++++++++++++++++++ .../reconciliation/utils/fee-params.util.ts | 137 ++++++++++++ 8 files changed, 514 insertions(+), 63 deletions(-) create mode 100644 backend/src/modules/reconciliation/utils/fee-params.util.spec.ts create mode 100644 backend/src/modules/reconciliation/utils/fee-params.util.ts diff --git a/backend/src/modules/reconciliation/reconciliation.service.ts b/backend/src/modules/reconciliation/reconciliation.service.ts index efa3200c..cc55240e 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.ts @@ -30,6 +30,7 @@ import { CreateReconciliationDto } from './dto/create-reconciliation.dto'; import { UpdateReconciliationStatusDto } from './dto/update-reconciliation-status.dto'; import { BatchCreateReconciliationDto } from './dto/batch-create-reconciliation.dto'; import { DeleteBatchResponseDto } from './dto/delete-batch.dto'; +import { resolveFeeParams } from './utils/fee-params.util'; const BUCKET_NAME = env.s3.bucket; const REGION = env.s3.region; @@ -126,47 +127,20 @@ export class ReconciliationService { const tenant = await this.queryService.getTenant(dto.tenant_id); const config = await this.configModel.findOne({ tenantId: dto.tenant_id }); - // F-043 BR-43-05/16 — Cascade resolution với feeSource attribution. - // DTO values (admin preview override) ABSOLUTE priority. - // Else: event_fee_overrides[raceId] + effective_from <= period_start → TIER 0 - // Else: MerchantConfig.service_fee_rate → TIER 1 merchant_default - // Else: null (preview KHÔNG auto-fallback 5.5% — UI hiển thị "chưa cấu hình") - const override = config?.event_fee_overrides?.find( - (o) => - o.raceId === dto.mysql_race_id && - o.effective_from <= dto.period_start, - ); - - let feeRate: number | null; - let feeSource: - | 'admin_preview_override' - | 'event_override' - | 'merchant_default' - | 'unconfigured'; - if (dto.fee_rate_applied != null) { - feeRate = dto.fee_rate_applied; - feeSource = 'admin_preview_override'; - } else if (override?.service_fee_rate != null) { - feeRate = Number(override.service_fee_rate); - feeSource = 'event_override'; - } else if (config?.service_fee_rate != null) { - feeRate = Number(config.service_fee_rate); - feeSource = 'merchant_default'; - } else { - feeRate = null; - feeSource = 'unconfigured'; - } - - const manualFeePerTicket = - dto.manual_fee_per_ticket ?? - override?.manual_fee_per_ticket ?? - config?.manual_fee_per_ticket ?? - 5000; - const feeVatRate = - dto.fee_vat_rate ?? - override?.fee_vat_rate ?? - config?.fee_vat_rate ?? - 0; + // F-doi-soat P1 (#1/#5/#6) — cascade thống nhất qua resolveFeeParams: + // admin override > event_fee_overrides[raceId] (LATEST effective_from + // <= period_start) > merchant default > fallback 5.5% (KHÔNG rơi 0% âm thầm). + const { feeRate, manualFeePerTicket, feeVatRate, feeSource, eventOverride } = + resolveFeeParams({ + config, + mysqlRaceId: dto.mysql_race_id, + periodStart: dto.period_start, + adminOverride: { + fee_rate_applied: dto.fee_rate_applied, + manual_fee_per_ticket: dto.manual_fee_per_ticket, + fee_vat_rate: dto.fee_vat_rate, + }, + }); const { fiveBibOrders, manualOrders, missingPaymentRef } = await this.queryService.queryOrders( @@ -197,12 +171,7 @@ export class ReconciliationService { fee_source: feeSource, // F-043 — Override metadata nếu fee_source = 'event_override' (UI tooltip) event_override_meta: - feeSource === 'event_override' && override - ? { - effective_from: override.effective_from, - note: override.note, - } - : null, + feeSource === 'event_override' ? eventOverride : null, ...summary, line_items: lineItems, manual_orders: manualOrderRows, @@ -214,10 +183,23 @@ export class ReconciliationService { async create(dto: CreateReconciliationDto): Promise { const tenant = await this.queryService.getTenant(dto.tenant_id); + const config = await this.configModel + .findOne({ tenantId: dto.tenant_id }) + .lean(); - const feeRate = dto.fee_rate_applied ?? null; - const manualFeePerTicket = dto.manual_fee_per_ticket ?? 5000; - const feeVatRate = dto.fee_vat_rate ?? 0; + // F-doi-soat P1 (#1/#5/#6) — resolve cascade. Nếu dto không truyền rate + // (batch/cron, hoặc POST trực tiếp thiếu field) thì cascade override > + // merchant > 5.5%. KHÔNG để feeRate=null → fee=0 âm thầm. + const { feeRate, manualFeePerTicket, feeVatRate } = resolveFeeParams({ + config, + mysqlRaceId: dto.mysql_race_id, + periodStart: dto.period_start, + adminOverride: { + fee_rate_applied: dto.fee_rate_applied, + manual_fee_per_ticket: dto.manual_fee_per_ticket, + fee_vat_rate: dto.fee_vat_rate, + }, + }); const manualAdjustment = dto.manual_adjustment ?? 0; const { fiveBibOrders, manualOrders } = @@ -548,8 +530,6 @@ export class ReconciliationService { const preflight = await this.preflightService.run(merchantId, dto.period); merchantName = preflight.merchant_name; - const config = await this.configModel.findOne({ tenantId: merchantId }).lean(); - if (dto.skip_errors) { const hasErrors = preflight.warnings.some((w) => w.severity === 'ERROR'); if (hasErrors) { @@ -591,15 +571,15 @@ export class ReconciliationService { continue; } + // F-doi-soat P1 (#1) — KHÔNG truyền fee params: create() tự cascade + // (event_fee_overrides > merchant > 5.5%). Trước đây truyền + // config.service_fee_rate ?? 5.5 → BỎ QUA event override race. const doc = await this.create({ tenant_id: merchantId, mysql_race_id: race.race_id, race_title: race.race_name, period_start, period_end, - fee_rate_applied: config?.service_fee_rate ?? 5.5, - manual_fee_per_ticket: config?.manual_fee_per_ticket ?? 5000, - fee_vat_rate: config?.fee_vat_rate ?? 0, manual_adjustment: 0, adjustment_note: null, signed_date_str: null, diff --git a/backend/src/modules/reconciliation/services/reconciliation-calc.service.spec.ts b/backend/src/modules/reconciliation/services/reconciliation-calc.service.spec.ts index 465ce5fe..3ed974ad 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-calc.service.spec.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-calc.service.spec.ts @@ -185,3 +185,113 @@ describe('ReconciliationCalcService.buildLineItems — FEATURE-030 add-on dedup' expect(lineItems[0].add_on_price).toBe(30000); // ✅ DEDUP — KHÔNG 60K }); }); + +describe('ReconciliationCalcService — Pha 1 accuracy fixes (#8 tie-out, #11 tên VĐV)', () => { + let service: ReconciliationCalcService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ReconciliationCalcService], + }).compile(); + service = module.get(ReconciliationCalcService); + }); + + function row(opts: { + order_id: number; + order_category?: string; + type_name?: string; + distance?: string; + line_price?: number; + qty?: number; + total_discounts?: number; + total_add_on_price?: number; + subtotal_price?: number; + }) { + return { + order_id: opts.order_id, + order_category: opts.order_category ?? 'ORDINARY', + type_name: opts.type_name ?? 'Regular', + distance: opts.distance ?? '10KM', + line_price: opts.line_price ?? 500000, + origin_price: opts.line_price ?? 500000, + qty: opts.qty ?? 1, + total_discounts: opts.total_discounts ?? 0, + total_add_on_price: opts.total_add_on_price ?? 0, + subtotal_price: opts.subtotal_price ?? 500000, + }; + } + + // Cùng công thức col-8 trong xlsx.service.ts (#8): net đóng vào Section-1 GMV. + const lineNet = (li: any) => + li.order_category === 'CHANGE_COURSE' + ? li.subtotal + : li.subtotal + li.add_on_price - li.discount_amount; + + it('#8: đơn có GIẢM GIÁ → Σ(cột Tổng cộng) khớp Section-1 GMV (trước fix lệch đúng discount)', () => { + const rows = [ + // line_price 500K, discount 100K → subtotal_price 400K (net cash) + row({ order_id: 1, line_price: 500000, qty: 1, total_discounts: 100000, subtotal_price: 400000 }), + row({ order_id: 2, line_price: 850000, qty: 1, subtotal_price: 850000 }), + ]; + const lineItems = service.buildLineItems(rows); + const summary = service.calculateSummary(rows, [], 7, 5000, 0, 0); + + expect(summary.gross_revenue).toBe(1250000); // 400K + 850K + const sumNet = lineItems.reduce((s, li) => s + lineNet(li), 0); + expect(sumNet).toBe(summary.gross_revenue); // ✅ tie-out sau fix + + // Bug cũ (subtotal + add_on, KHÔNG trừ discount) = 1.350.000 ≠ GMV, lệch 100K + const buggy = lineItems.reduce((s, li) => s + li.subtotal + li.add_on_price, 0); + expect(buggy).toBe(1350000); + expect(buggy).not.toBe(summary.gross_revenue); + }); + + it('#8: add-on + discount cùng lúc vẫn tie-out với GMV', () => { + // subtotal_price = 500K×2 − 50K + 120K = 1.070.000 + const rows = [ + row({ order_id: 5, line_price: 500000, qty: 2, total_discounts: 50000, total_add_on_price: 120000, subtotal_price: 1070000 }), + ]; + const lineItems = service.buildLineItems(rows); + const summary = service.calculateSummary(rows, [], 7, 5000, 0, 0); + const sumNet = lineItems.reduce((s, li) => s + lineNet(li), 0); + expect(summary.gross_revenue).toBe(1070000); + expect(sumNet).toBe(1070000); + }); + + it('#8: CHANGE_COURSE dùng subtotal (đã net) → không double-count add_on', () => { + const rows = [ + row({ order_id: 9, order_category: 'CHANGE_COURSE', distance: '21KM', line_price: 620000, subtotal_price: 100000, total_add_on_price: 0 }), + ]; + const lineItems = service.buildLineItems(rows); + const summary = service.calculateSummary(rows, [], 7, 5000, 0, 0); + expect(lineNet(lineItems[0])).toBe(100000); + expect(summary.gross_revenue).toBe(100000); + }); + + it('reference sample T6: 2 đơn 650K+850K, 7% → GMV 1.5M, fee 105K, payout 1.395M', () => { + const rows = [ + row({ order_id: 11, distance: '10KM', type_name: 'EARLY BIRD', line_price: 650000, subtotal_price: 650000 }), + row({ order_id: 12, distance: '21KM', type_name: 'EARLY BIRD', line_price: 850000, subtotal_price: 850000 }), + ]; + const summary = service.calculateSummary(rows, [], 7, 5000, 0, 0); + expect(summary.gross_revenue).toBe(1500000); + expect(summary.fee_amount).toBe(105000); + expect(summary.payout_amount).toBe(1395000); + const lineItems = service.buildLineItems(rows); + expect(lineItems.reduce((s, li) => s + lineNet(li), 0)).toBe(1500000); + }); + + it('#11: participant_name ưu tiên first+last, KHÔNG dùng full_name (=mã đơn)', () => { + const out = service.buildManualOrders([ + { order_id: 1, type_name: 'X', full_name: '#5B200029811IB', first_name: 'Phạm Minh', last_name: 'Chiến', qty: 1, subtotal_price: 650000 }, + ]); + expect(out[0].participant_name).toBe('Phạm Minh Chiến'); + }); + + it('#11: tên thật trống → fallback full_name (mã đơn) để không mất dữ liệu', () => { + const out = service.buildManualOrders([ + { order_id: 2, type_name: 'X', full_name: '#5B200029812IB', first_name: '', last_name: '', qty: 1, subtotal_price: 850000 }, + ]); + expect(out[0].participant_name).toBe('#5B200029812IB'); + }); +}); diff --git a/backend/src/modules/reconciliation/services/reconciliation-calc.service.ts b/backend/src/modules/reconciliation/services/reconciliation-calc.service.ts index e2464948..0c9f6a22 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-calc.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-calc.service.ts @@ -140,7 +140,12 @@ export class ReconciliationCalcService { return manualOrders.map((r) => ({ order_id: Number(r.order_id), ticket_type_name: r.type_name ?? '', - participant_name: r.full_name ?? `${r.first_name ?? ''} ${r.last_name ?? ''}`.trim(), + // #11: full_name = o.name = MÃ ĐƠN HÀNG (#5B..IB), luôn truthy → fallback + // tên thật không bao giờ chạy. Ưu tiên first+last name, chỉ dùng full_name + // (mã đơn) khi tên thật trống. + participant_name: + `${r.first_name ?? ''} ${r.last_name ?? ''}`.trim() || + (typeof r.full_name === 'string' ? r.full_name : ''), quantity: Number(r.qty || 0), unit_price: Number(r.origin_price || r.line_price || 0), subtotal: Number(r.subtotal_price || 0), diff --git a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts index ad48217d..2bf51c6f 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts @@ -118,6 +118,10 @@ export class ReconciliationQueryService { LEFT JOIN discount_code dc ON o.discound_code_id = dc.id WHERE rc.race_id = ? AND o.internal_status = 'COMPLETE' + -- #2 defense-in-depth: chỉ đơn đã thanh toán. Verify PROD 2026-07-09: + -- 100% đơn COMPLETE đều financial_status='paid' (refund/void → CLOSE/ + -- CANCELLED, rời khỏi COMPLETE) → no-op hiện tại, chốt bất biến tương lai. + AND o.financial_status = 'paid' AND o.processed_on >= ? AND o.processed_on <= ? AND o.deleted = 0 diff --git a/backend/src/modules/reconciliation/services/reconciliation.cron.ts b/backend/src/modules/reconciliation/services/reconciliation.cron.ts index f2d027f2..63d19eab 100644 --- a/backend/src/modules/reconciliation/services/reconciliation.cron.ts +++ b/backend/src/modules/reconciliation/services/reconciliation.cron.ts @@ -132,9 +132,8 @@ export class ReconciliationCron { race_title, period_start, period_end, - fee_rate_applied: config.service_fee_rate ?? 5.5, - manual_fee_per_ticket: config.manual_fee_per_ticket ?? 5000, - fee_vat_rate: config.fee_vat_rate ?? 0, + // F-doi-soat P1 (#1) — create() tự cascade fee (event override + // > merchant > 5.5%). KHÔNG truyền để tránh bỏ qua event override. manual_adjustment: 0, adjustment_note: null, signed_date_str: null, diff --git a/backend/src/modules/reconciliation/services/xlsx.service.ts b/backend/src/modules/reconciliation/services/xlsx.service.ts index bdd6e295..db7e0223 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.ts @@ -228,16 +228,23 @@ export class XlsxService { styleCell(row.getCell(5), hasAddOn ? '—' : 0, { numFmt: VND_FMT }); styleCell(row.getCell(6), li.add_on_price, { numFmt: VND_FMT }); styleCell(row.getCell(7), li.discount_amount, { numFmt: VND_FMT }); - // Col 8 "Tổng cộng" per-line: [(1) × (2) + (5)] − (6) per header formula - // = unit_price × qty + add_on_price − discount = li.subtotal + li.add_on_price - styleCell(row.getCell(8), li.subtotal + li.add_on_price, { numFmt: VND_FMT }); + // #8 tie-out: Col 8 "Tổng cộng" per-line = giá trị đóng vào Section 1 GMV, + // theo đúng công thức header (7) = [(1)×(2)+(5)] − (6). + // ORDINARY: subtotal(gross) + add_on − discount = subtotal_price. + // CHANGE_COURSE: subtotal đã = subtotal_price (net) → dùng thẳng. + // Trước đây thiếu "− discount" → grand total > Section 1 khi có giảm giá. + const lineNet = + li.order_category === 'CHANGE_COURSE' + ? li.subtotal + : li.subtotal + li.add_on_price - li.discount_amount; + styleCell(row.getCell(8), lineNet, { numFmt: VND_FMT }); // Col 9: ticket type name (no border) styleCell(row.getCell(9), li.ticket_type_name, { border: false }); totalQty += li.quantity; totalDiscount += li.discount_amount; totalAddOnPrice += li.add_on_price; - grandTotal += li.subtotal + li.add_on_price; + grandTotal += lineNet; r++; } diff --git a/backend/src/modules/reconciliation/utils/fee-params.util.spec.ts b/backend/src/modules/reconciliation/utils/fee-params.util.spec.ts new file mode 100644 index 00000000..0060526b --- /dev/null +++ b/backend/src/modules/reconciliation/utils/fee-params.util.spec.ts @@ -0,0 +1,209 @@ +import { + resolveFeeParams, + pickEventOverride, + DEFAULT_SERVICE_FEE_RATE, + DEFAULT_MANUAL_FEE_PER_TICKET, + DEFAULT_FEE_VAT_RATE, + MerchantFeeConfigLike, +} from './fee-params.util'; + +const RACE = 1001; +const PERIOD_START = '2026-06-01'; + +function cfg(partial: Partial): MerchantFeeConfigLike { + return { event_fee_overrides: [], ...partial }; +} + +describe('resolveFeeParams — cascade rate/manual/vat (Pha 1 fix #1/#5/#6)', () => { + it('TIER 0: admin override thắng mọi tier', () => { + const r = resolveFeeParams({ + config: cfg({ + service_fee_rate: 5.5, + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-01-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + adminOverride: { fee_rate_applied: 9 }, + }); + expect(r.feeRate).toBe(9); + expect(r.feeSource).toBe('admin_preview_override'); + }); + + it('TIER 0: admin override = 0 (miễn phí có chủ đích) vẫn được tôn trọng, KHÔNG rơi fallback', () => { + const r = resolveFeeParams({ + config: cfg({ service_fee_rate: 7 }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + adminOverride: { fee_rate_applied: 0 }, + }); + expect(r.feeRate).toBe(0); + expect(r.feeSource).toBe('admin_preview_override'); + }); + + it('TIER 1: event override thắng merchant default — CHÍNH LÀ defect #1 (batchCreate/cron trước đây bỏ qua)', () => { + const r = resolveFeeParams({ + config: cfg({ + service_fee_rate: 5.5, // merchant default + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-01-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + expect(r.feeRate).toBe(7); + expect(r.feeSource).toBe('event_override'); + expect(r.eventOverride).toEqual({ effective_from: '2026-01-01', note: null }); + }); + + it('TIER 1: override cho race KHÁC không ảnh hưởng', () => { + const r = resolveFeeParams({ + config: cfg({ + service_fee_rate: 5.5, + event_fee_overrides: [ + { raceId: 9999, service_fee_rate: 7, effective_from: '2026-01-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + expect(r.feeRate).toBe(5.5); + expect(r.feeSource).toBe('merchant_default'); + }); + + it('TIER 1: override có effective_from > periodStart bị bỏ qua', () => { + const r = resolveFeeParams({ + config: cfg({ + service_fee_rate: 5.5, + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-07-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + expect(r.feeRate).toBe(5.5); + expect(r.feeSource).toBe('merchant_default'); + }); + + it('TIER 2: merchant default khi không có override áp dụng', () => { + const r = resolveFeeParams({ + config: cfg({ service_fee_rate: 6 }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + expect(r.feeRate).toBe(6); + expect(r.feeSource).toBe('merchant_default'); + }); + + it('TIER 3: fallback 5.5% khi service_fee_rate = null (defect #5/#6 — KHÔNG rơi 0%)', () => { + const r = resolveFeeParams({ + config: cfg({ service_fee_rate: null }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + expect(r.feeRate).toBe(DEFAULT_SERVICE_FEE_RATE); + expect(r.feeSource).toBe('default_fallback'); + }); + + it('TIER 3: config = null hoàn toàn → 5.5% / 5000 / 0', () => { + const r = resolveFeeParams({ + config: null, + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + expect(r.feeRate).toBe(DEFAULT_SERVICE_FEE_RATE); + expect(r.manualFeePerTicket).toBe(DEFAULT_MANUAL_FEE_PER_TICKET); + expect(r.feeVatRate).toBe(DEFAULT_FEE_VAT_RATE); + expect(r.feeSource).toBe('default_fallback'); + expect(r.eventOverride).toBeNull(); + }); + + it('manual_fee_per_ticket cascade: override > merchant > default; 0 hợp lệ', () => { + expect( + resolveFeeParams({ + config: cfg({ + manual_fee_per_ticket: 3000, + event_fee_overrides: [ + { raceId: RACE, manual_fee_per_ticket: 8000, effective_from: '2026-01-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }).manualFeePerTicket, + ).toBe(8000); + + expect( + resolveFeeParams({ + config: cfg({ manual_fee_per_ticket: 0 }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }).manualFeePerTicket, + ).toBe(0); + }); + + it('fee_vat_rate cascade: override > merchant > default 0', () => { + expect( + resolveFeeParams({ + config: cfg({ + fee_vat_rate: 8, + event_fee_overrides: [ + { raceId: RACE, fee_vat_rate: 10, effective_from: '2026-01-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }).feeVatRate, + ).toBe(10); + }); + + it('reference sample T6: event override 7% → feeRate 7 (tái tạo con số biên bản đã ký)', () => { + const r = resolveFeeParams({ + config: cfg({ + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-01-01' }, + ], + }), + mysqlRaceId: RACE, + periodStart: PERIOD_START, + }); + // GMV 1.500.000 × 7% = 105.000 (Math.round ở calc); ở đây chỉ assert rate. + expect(r.feeRate).toBe(7); + }); +}); + +describe('pickEventOverride — versioning BR-43-07 (lấy effective_from LỚN NHẤT <= periodStart)', () => { + it('nhiều version cùng race: chọn bản mới nhất đã hiệu lực', () => { + const config = cfg({ + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-01-01' }, + { raceId: RACE, service_fee_rate: 6, effective_from: '2026-05-01' }, + { raceId: RACE, service_fee_rate: 5, effective_from: '2026-08-01' }, // tương lai + ], + }); + const chosen = pickEventOverride(config, RACE, '2026-06-01'); + expect(chosen?.service_fee_rate).toBe(6); // 2026-05-01 là mới nhất <= 2026-06-01 + }); + + it('thứ tự mảng đảo lộn vẫn chọn đúng version mới nhất (fix .find() first-match)', () => { + const config = cfg({ + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 6, effective_from: '2026-05-01' }, + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-01-01' }, + ], + }); + const chosen = pickEventOverride(config, RACE, '2026-06-01'); + expect(chosen?.service_fee_rate).toBe(6); + }); + + it('không có version nào hiệu lực → null', () => { + const config = cfg({ + event_fee_overrides: [ + { raceId: RACE, service_fee_rate: 7, effective_from: '2026-09-01' }, + ], + }); + expect(pickEventOverride(config, RACE, '2026-06-01')).toBeNull(); + }); +}); diff --git a/backend/src/modules/reconciliation/utils/fee-params.util.ts b/backend/src/modules/reconciliation/utils/fee-params.util.ts new file mode 100644 index 00000000..0608edcb --- /dev/null +++ b/backend/src/modules/reconciliation/utils/fee-params.util.ts @@ -0,0 +1,137 @@ +/** + * Reconciliation fee-parameter resolver (Pha 1 accuracy fix — 2026-07-09). + * + * SINGLE SOURCE OF TRUTH cho cascade rate/manual/vat của module đối soát. + * Trước đây logic bị copy-paste lệch ở preview() / create() / batchCreate() / + * cron → biên bản tạo qua batch/cron BỎ QUA `event_fee_overrides` (defect #1), + * và create() thiếu rate → fee = 0 âm thầm (defect #6). Helper này thống nhất: + * + * fee_rate: + * TIER 0 adminOverride.fee_rate_applied (admin nhập tay ở preview / API) + * TIER 1 event_fee_overrides[raceId] có effective_from <= periodStart, LATEST + * TIER 2 MerchantConfig.service_fee_rate (merchant default) + * TIER 3 DEFAULT_SERVICE_FEE_RATE = 5.5% (Danny chốt 2026-07-09: fallback, + * KHÔNG để rơi 0% âm thầm — defect #5/#6) + * manual_fee_per_ticket / fee_vat_rate: cùng cascade, fallback 5000 / 0. + * + * Pure function (không DI, không mongoose) → dễ unit test. + */ + +export const DEFAULT_SERVICE_FEE_RATE = 5.5; +export const DEFAULT_MANUAL_FEE_PER_TICKET = 5000; +export const DEFAULT_FEE_VAT_RATE = 0; + +export type ReconFeeSource = + | 'admin_preview_override' + | 'event_override' + | 'merchant_default' + | 'default_fallback'; + +export interface EventFeeOverrideLike { + raceId: number; + service_fee_rate?: number | null; + manual_fee_per_ticket?: number | null; + fee_vat_rate?: number | null; + /** YYYY-MM-DD */ + effective_from: string; + note?: string | null; +} + +export interface MerchantFeeConfigLike { + service_fee_rate?: number | null; + manual_fee_per_ticket?: number | null; + fee_vat_rate?: number | null; + event_fee_overrides?: EventFeeOverrideLike[] | null; +} + +export interface AdminFeeOverride { + fee_rate_applied?: number | null; + manual_fee_per_ticket?: number | null; + fee_vat_rate?: number | null; +} + +export interface ResolvedFeeParams { + /** LUÔN là số (đã fallback 5.5%) — không bao giờ null. */ + feeRate: number; + manualFeePerTicket: number; + feeVatRate: number; + feeSource: ReconFeeSource; + eventOverride: { effective_from: string; note: string | null } | null; +} + +/** + * Chọn event override áp dụng: cùng raceId, `effective_from <= periodStart`, + * lấy bản có `effective_from` LỚN NHẤT (versioning BR-43-07). Fix so với + * `.find()` cũ (lấy first-match trong mảng → sai khi có nhiều version cho + * cùng 1 race). + */ +export function pickEventOverride( + config: MerchantFeeConfigLike | null | undefined, + mysqlRaceId: number, + periodStart: string, +): EventFeeOverrideLike | null { + const list = config?.event_fee_overrides ?? []; + let chosen: EventFeeOverrideLike | null = null; + for (const o of list) { + if (!o || o.raceId !== mysqlRaceId) continue; + if (typeof o.effective_from !== 'string' || o.effective_from > periodStart) { + continue; + } + if (!chosen || o.effective_from > chosen.effective_from) chosen = o; + } + return chosen; +} + +function firstNonNull(...vals: Array): number { + for (const v of vals) if (v != null) return Number(v); + return 0; +} + +export function resolveFeeParams(input: { + config: MerchantFeeConfigLike | null | undefined; + mysqlRaceId: number; + periodStart: string; + adminOverride?: AdminFeeOverride; +}): ResolvedFeeParams { + const { config, mysqlRaceId, periodStart, adminOverride } = input; + const override = pickEventOverride(config, mysqlRaceId, periodStart); + + let feeRate: number; + let feeSource: ReconFeeSource; + if (adminOverride?.fee_rate_applied != null) { + feeRate = Number(adminOverride.fee_rate_applied); + feeSource = 'admin_preview_override'; + } else if (override?.service_fee_rate != null) { + feeRate = Number(override.service_fee_rate); + feeSource = 'event_override'; + } else if (config?.service_fee_rate != null) { + feeRate = Number(config.service_fee_rate); + feeSource = 'merchant_default'; + } else { + feeRate = DEFAULT_SERVICE_FEE_RATE; + feeSource = 'default_fallback'; + } + + const manualFeePerTicket = firstNonNull( + adminOverride?.manual_fee_per_ticket, + override?.manual_fee_per_ticket, + config?.manual_fee_per_ticket, + DEFAULT_MANUAL_FEE_PER_TICKET, + ); + const feeVatRate = firstNonNull( + adminOverride?.fee_vat_rate, + override?.fee_vat_rate, + config?.fee_vat_rate, + DEFAULT_FEE_VAT_RATE, + ); + + return { + feeRate, + manualFeePerTicket, + feeVatRate, + feeSource, + eventOverride: override + ? { effective_from: override.effective_from, note: override.note ?? null } + : null, + }; +} From 1ada543a165f6de8d8dc09d05464c8376381bb57 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Thu, 9 Jul 2026 22:40:22 +0700 Subject: [PATCH 02/23] =?UTF-8?q?feat(recon):=20Pha=202-A=20=E2=80=94=20b?= =?UTF-8?q?=E1=BA=A3ng=20=C4=91=E1=BB=91i=20so=C3=A1t=20XLSX=20kh=E1=BB=9B?= =?UTF-8?q?p=20100%=20m=E1=BA=ABu=20T6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - xlsx.service: đúng 2 sheet ('Tổng quan' + 'raw'; bỏ Pivot + 3 sheet thừa). Tổng quan khớp mẫu cell-by-cell: A3 "Thời gian đối soát từ .. -> hết ngày .."; Section 2 "GIAO DỊCH THỦ CÔNG"; Section 3 header gộp "Add-on products"; cột add-on/giảm giá để trống khi 0; nhãn 'Tổng cộng'; footer Phí bán vé / Phí dịch vụ / Hoàn trả merchant (VAT chỉ hiện khi >0). Sửa typo formula mẫu. - reconciliation-query: SELECT thêm id/modified_on/create_by/name/ order_modified_on/code → sheet raw đủ 25 cột khớp mẫu. - Verify: generate từ fixture T6 khớp template cell-by-cell (1.5M/105K/1.395M). - +8 test xlsx (khớp mẫu + tie-out #8 + VAT conditional + raw 25 cột). 128 pass. Co-Authored-By: Claude Opus 4.8 --- .../services/reconciliation-query.service.ts | 13 +- .../services/xlsx.service.spec.ts | 237 +++++++++--------- .../reconciliation/services/xlsx.service.ts | 213 ++++++---------- 3 files changed, 208 insertions(+), 255 deletions(-) diff --git a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts index 2bf51c6f..0511d32e 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts @@ -104,13 +104,16 @@ export class ReconciliationQueryService { ): Promise { const sql = ` SELECT - oli.id as oli_id, oli.order_id, o.order_category, - oli.price AS line_price, - o.name as full_name, o.email, o.last_name, o.first_name, - o.phone_number, o.internal_status, o.payment_ref, o.processed_on, + oli.id as oli_id, oli.id AS id, oli.modified_on AS modified_on, + oli.order_id, o.order_category, + oli.price AS line_price, o.create_by AS create_by, + o.name as full_name, o.name AS name, o.email, o.last_name, o.first_name, + o.phone_number, o.internal_status, o.modified_on AS order_modified_on, + o.payment_ref, o.processed_on, rc.name AS distance, tt.type_name, tt.price AS origin_price, oli.quantity AS qty, o.total_discounts, o.total_add_on_price, - dc.code as discount_code, o.subtotal_price, o.total_price, o.vat_metadata + dc.code as discount_code, dc.code AS code, + o.subtotal_price, o.total_price, o.vat_metadata FROM order_line_item oli LEFT JOIN order_metadata o ON oli.order_id = o.id LEFT JOIN ticket_type tt ON oli.ticket_type_id = tt.id diff --git a/backend/src/modules/reconciliation/services/xlsx.service.spec.ts b/backend/src/modules/reconciliation/services/xlsx.service.spec.ts index 81d90710..230d3bb0 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.spec.ts @@ -1,134 +1,147 @@ /** - * FEATURE-030 — Smoke test XLSX render for add-on visual fix. + * XlsxService — Pha 2-A: bảng đối soát khớp 100% mẫu T6 + * (Standard Chartered Hanoi Marathon Heritage Race — kỳ tháng 6/2026). * - * Verify col 6 "Thành tiền áo" renders `li.add_on_price` (not hardcoded 0) - * and bottom Tổng row col 8 grandTotal includes add-on. - * - * Stub `src/config` env loader giống pattern reconciliation.service.spec.ts. + * Mẫu: đúng 2 sheet "Tổng quan" + "raw"; Section 1/2/3 với header "Add-on + * products"; footer Phí bán vé / Phí dịch vụ / Hoàn trả merchant; cột "Tổng + * cộng" (H) tie-out với Section-1 GMV (#8). */ -jest.mock( - 'src/config', - () => ({ - env: { - s3: { bucket: 'test', region: 'ap-southeast-1' }, - provider: { - companyName: 'CÔNG TY CỔ PHẦN 5BIB', - address: 'TEST ADDRESS', - taxCode: '0110398986', - phone: '0373398986', - representativeName: 'Nguyễn Bình Minh', - representativeTitle: 'Giám Đốc', - bankAccount: '110398986', - bankName: 'MB', - }, - }, - }), - { virtual: true }, -); - -jest.mock('@aws-sdk/client-s3', () => ({ - S3Client: jest.fn().mockImplementation(() => ({ send: jest.fn() })), - PutObjectCommand: jest.fn(), -})); - import { XlsxService } from './xlsx.service'; import * as ExcelJS from 'exceljs'; -describe('XlsxService — FEATURE-030 add-on visual render', () => { - const xlsxSvc = new XlsxService(); +const svc = new XlsxService(); - // Zaha fixture (simplified) — 1 line item có add-on 299K - const rec: any = { - tenant_id: 1, - tenant_name: 'CÔNG TY CỔ PHẦN ZAHA VIỆT NAM', - race_title: 'Hai Phong Legacy Marathon 2026', - period_start: '2026-04-01', - period_end: '2026-04-30', - fee_rate_applied: 5.5, - fee_vat_rate: 0, - manual_fee_per_ticket: 5000, - gross_revenue: 18422200, - total_discount: 0, - net_revenue: 18422200, - fee_amount: 1013221, - fee_vat_amount: 0, +function baseRec(overrides: any = {}) { + return { + race_title: 'Standard Chartered Hanoi Marathon Heritage Race', + signed_date_str: '2026-07-06', + period_start: '2026-06-01', + period_end: '2026-06-30', + net_revenue: 1500000, + fee_rate_applied: 7, + fee_amount: 105000, manual_ticket_count: 0, - manual_gross_revenue: 0, + manual_fee_per_ticket: 5000, manual_fee_amount: 0, - payout_amount: 17408979, - manual_adjustment: 0, - adjustment_note: null, - status: 'ready', - flags: [], - created_source: 'manual', - createdAt: new Date(), - updatedAt: new Date(), - signed_at: null, - raw_5bib_orders: [], - raw_manual_orders: [], - manual_orders: [], + fee_vat_rate: 0, + fee_vat_amount: 0, + payout_amount: 1395000, line_items: [ - // Row có add-on - { order_category: 'ORDINARY', ticket_type_name: 'Ưu đãi', distance_name: '21KM', unit_price: 533200, quantity: 1, discount_amount: 0, subtotal: 533200, add_on_price: 299000 }, - // Row KHÔNG có add-on - { order_category: 'ORDINARY', ticket_type_name: 'Regular', distance_name: '10KM', unit_price: 500000, quantity: 8, discount_amount: 0, subtotal: 4000000, add_on_price: 0 }, + { order_category: 'ORDINARY', ticket_type_name: 'EARLY BIRD', distance_name: '10KM', unit_price: 650000, quantity: 1, discount_amount: 0, subtotal: 650000, add_on_price: 0 }, + { order_category: 'ORDINARY', ticket_type_name: 'EARLY BIRD', distance_name: '21KM', unit_price: 850000, quantity: 1, discount_amount: 0, subtotal: 850000, add_on_price: 0 }, + ], + raw_5bib_orders: [ + { id: 3000037753, order_id: 200029811, order_category: 'ORDINARY', line_price: 650000, create_by: 34559, name: '#5B200029811IB', email: 'a@x.com', last_name: 'Chiến', first_name: 'Phạm Minh', phone_number: '087185660', internal_status: 'COMPLETE', payment_ref: 'PAYX-1', distance: '10KM', type_name: 'EARLY BIRD', origin_price: 650000, qty: 1, total_discounts: 0, total_add_on_price: 0, subtotal_price: 650000, total_price: 650000 }, ], - }; + raw_manual_orders: [], + ...overrides, + } as any; +} - it('TC-AO-06: XLSX Section 3 renders li.add_on_price in col 6 + grandTotal includes add-on', async () => { - const buf = await xlsxSvc.generate(rec); - expect(buf.length).toBeGreaterThan(1000); +async function load(rec: any): Promise { + const buf = await svc.generate(rec); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(buf); + return wb; +} - const wb = new ExcelJS.Workbook(); - await wb.xlsx.load(buf); - const ws = wb.worksheets[0]; +function findRow(ws: ExcelJS.Worksheet, col: number, label: string): number { + for (let r = 1; r <= ws.rowCount; r++) { + if (ws.getCell(r, col).value === label) return r; + } + return -1; +} - // Find row containing 'Tổng' label in col 1 (Section 3 bottom Tổng row) - let totalsRow = -1; - for (let r = 1; r <= 100; r++) { - if (ws.getCell(r, 1).value === 'Tổng') { - totalsRow = r; - break; - } - } - expect(totalsRow).toBeGreaterThan(0); +describe('XlsxService — khớp mẫu T6', () => { + it('đúng 2 sheet: "Tổng quan" + "raw"', async () => { + const wb = await load(baseRec()); + expect(wb.worksheets.map((w) => w.name)).toEqual(['Tổng quan', 'raw']); + }); + + it('header block + tiêu đề section khớp mẫu', async () => { + const ws = (await load(baseRec())).getWorksheet('Tổng quan')!; + expect(ws.getCell('A1').value).toBe('SỰ KIỆN: Standard Chartered Hanoi Marathon Heritage Race'); + expect(ws.getCell('A2').value).toBe('Ngày đối soát: 06/07/2026'); + expect(ws.getCell('A3').value).toBe('Thời gian đối soát từ 01/06/2026 -> hết ngày 30/06/2026'); + expect(ws.getCell('A5').value).toBe('1. GIAO DỊCH THANH TOÁN QUA 5BIB'); + expect(ws.getCell('A10').value).toBe('2. GIAO DỊCH THỦ CÔNG'); + expect(ws.getCell('A15').value).toBe('3. CHI TIẾT GIAO DỊCH'); + expect(ws.getCell('D15').value).toBe('Add-on products'); // header gộp add-on + expect(ws.getCell('C11').value).toBe('Phí dịch vụ'); // (không phải 'Phí/vé') + }); - // Col 6 (Thành tiền áo) bottom row = sum of add_on_price = 299000 - const totalAddOn = ws.getCell(totalsRow, 6).value; - expect(totalAddOn).toBe(299000); + it('Section 1 giá trị: tỷ lệ 7% + phí 105.000', async () => { + const ws = (await load(baseRec())).getWorksheet('Tổng quan')!; + expect(ws.getCell('B7').value).toBe(1500000); + expect(ws.getCell('C7').value).toBeCloseTo(0.07, 5); + expect(ws.getCell('D7').value).toBe(105000); + }); - // Col 8 (Tổng cộng) bottom row = sum(li.subtotal + li.add_on_price) - // = (533200 + 299000) + (4000000 + 0) = 4832200 - const grandTotal = ws.getCell(totalsRow, 8).value; - expect(grandTotal).toBe(4832200); + it('footer khớp mẫu: Phí bán vé / Phí dịch vụ / Hoàn trả merchant; KHÔNG dòng VAT khi vat=0', async () => { + const ws = (await load(baseRec())).getWorksheet('Tổng quan')!; + const feeRow = findRow(ws, 1, 'Phí bán vé'); + const svcRow = findRow(ws, 1, 'Phí dịch vụ'); + const payoutRow = findRow(ws, 1, 'Hoàn trả merchant'); + expect(feeRow).toBeGreaterThan(0); + expect(ws.getCell(feeRow, 8).value).toBe(105000); + expect(ws.getCell(svcRow, 8).value).toBe(0); + expect(ws.getCell(payoutRow, 8).value).toBe(1395000); + // vat=0 → không có dòng "Thuế GTGT" + expect(findRow(ws, 1, 'Thuế GTGT (0%)')).toBe(-1); + }); - // Per-line check: first data row (Ưu đãi 21KM) col 6 = 299000 - // Header rows: section title + blank + col headers + sub-labels = ~4 rows before data - let firstDataRow = -1; - for (let r = 1; r < totalsRow; r++) { - if (ws.getCell(r, 1).value === '21KM') { - firstDataRow = r; - break; - } + it('dòng VAT chỉ hiện khi fee_vat_amount > 0', async () => { + const ws = (await load(baseRec({ fee_vat_rate: 8, fee_vat_amount: 8400 }))).getWorksheet('Tổng quan')!; + const vatRow = findRow(ws, 1, 'Thuế GTGT (8%)'); + expect(vatRow).toBeGreaterThan(0); + expect(ws.getCell(vatRow, 8).value).toBe(8400); + }); + + it('tie-out (#8): cột "Tổng cộng" Section 3 (dòng Tổng cộng) = Section-1 GMV', async () => { + const ws = (await load(baseRec())).getWorksheet('Tổng quan')!; + const totRow = findRow(ws, 1, 'Tổng cộng'); // 'Tổng cộng' đầu tiên (Section 1) — cần dòng Section 3 + // Dòng "Tổng cộng" của Section 3 nằm dưới, có C = tổng qty = 2 + let s3Total = -1; + for (let r = 16; r <= ws.rowCount; r++) { + if (ws.getCell(r, 1).value === 'Tổng cộng' && ws.getCell(r, 3).value === 2) { s3Total = r; break; } } - expect(firstDataRow).toBeGreaterThan(0); - expect(ws.getCell(firstDataRow, 6).value).toBe(299000); - // Col 4/5 should be '—' (string) for row có add-on, since qty/unit unknown - expect(ws.getCell(firstDataRow, 4).value).toBe('—'); - expect(ws.getCell(firstDataRow, 5).value).toBe('—'); - // Col 8 Tổng cộng per-line = subtotal + add_on = 533200 + 299000 = 832200 - expect(ws.getCell(firstDataRow, 8).value).toBe(832200); + expect(s3Total).toBeGreaterThan(0); + expect(ws.getCell(s3Total, 8).value).toBe(1500000); // = net_revenue + expect(totRow).toBeGreaterThan(0); + }); - // Second row (10KM Regular, no add-on) - let secondDataRow = -1; - for (let r = 1; r < totalsRow; r++) { - if (ws.getCell(r, 1).value === '10KM') { - secondDataRow = r; - break; - } + it('có giảm giá: cột Giảm giá (G) hiện, cột Tổng cộng (H) = net, tie-out GMV', async () => { + const rec = baseRec({ + net_revenue: 1250000, + fee_amount: 87500, + payout_amount: 1162500, + line_items: [ + { order_category: 'ORDINARY', ticket_type_name: 'EARLY BIRD', distance_name: '10KM', unit_price: 500000, quantity: 1, discount_amount: 100000, subtotal: 500000, add_on_price: 0 }, + { order_category: 'ORDINARY', ticket_type_name: 'EARLY BIRD', distance_name: '21KM', unit_price: 850000, quantity: 1, discount_amount: 0, subtotal: 850000, add_on_price: 0 }, + ], + }); + const ws = (await load(rec)).getWorksheet('Tổng quan')!; + const r10 = findRow(ws, 1, '10KM'); + expect(ws.getCell(r10, 7).value).toBe(100000); // Giảm giá hiện + expect(ws.getCell(r10, 8).value).toBe(400000); // net = 500000 - 100000 + // tie-out tổng + let s3Total = -1; + for (let r = 16; r <= ws.rowCount; r++) { + if (ws.getCell(r, 1).value === 'Tổng cộng' && ws.getCell(r, 3).value === 2) { s3Total = r; break; } } - expect(secondDataRow).toBeGreaterThan(0); - expect(ws.getCell(secondDataRow, 6).value).toBe(0); - expect(ws.getCell(secondDataRow, 4).value).toBe(0); // no add-on → 0 (not '—') + expect(ws.getCell(s3Total, 8).value).toBe(1250000); + }); + + it('sheet raw: 25 cột + id/name/create_by/order_modified_on populated + dòng tổng W', async () => { + const ws = (await load(baseRec())).getWorksheet('raw')!; + const headers = ws.getRow(1).values as any[]; + expect(headers.slice(1)).toEqual([ + 'id','modified_on','order_id','order_category','line_price','create_by','name','email','last_name','first_name','phone_number','internal_status','order_modified_on','payment_ref','processed_on','distance','type_name','origin_price','qty','total_discounts','total_add_on_price','code','subtotal_price','total_price','vat_metadata', + ]); + expect(ws.getCell('A2').value).toBe(3000037753); + expect(ws.getCell('G2').value).toBe('#5B200029811IB'); + expect(ws.getCell('F2').value).toBe(34559); + // dòng tổng subtotal_price ở cột W (23) + expect(ws.getCell(3, 23).value).toBe(650000); }); }); diff --git a/backend/src/modules/reconciliation/services/xlsx.service.ts b/backend/src/modules/reconciliation/services/xlsx.service.ts index db7e0223..8ba25da7 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import * as ExcelJS from 'exceljs'; import { ReconciliationDocument } from '../schemas/reconciliation.schema'; -import { renderPeriodLabel } from './period-label.helper'; import { nowIctDateString } from '../../../common/utils/ict-date.util'; const FONT_BASE: Partial = { @@ -29,6 +28,11 @@ function formatDate(dateStr: string): string { return dateStr; } +/** Date → 'YYYY-MM-DD HH:mm:ss' (khớp format datetime của sheet raw mẫu T6). */ +function fmtSqlDatetime(d: Date): string { + return d.toISOString().slice(0, 19).replace('T', ' '); +} + /** Apply font, border, numFmt to a cell */ function styleCell( cell: ExcelJS.Cell, @@ -54,18 +58,12 @@ export class XlsxService { workbook.creator = '5BIB'; workbook.created = new Date(); + // Mẫu T6: đúng 2 sheet — "Tổng quan" + "raw". this.buildTongQuan(workbook, rec); - this.buildPivot(workbook, rec); - this.buildRawOrderSheet(workbook, 'Tổng quan đơn hàng', [ + this.buildRawOrderSheet(workbook, 'raw', [ ...rec.raw_5bib_orders, ...rec.raw_manual_orders, ]); - this.buildRawOrderSheet(workbook, 'Đơn hàng 5BIB', rec.raw_5bib_orders); - this.buildRawOrderSheet( - workbook, - 'Đơn hàng thủ công', - rec.raw_manual_orders, - ); const buffer = await workbook.xlsx.writeBuffer(); return Buffer.from(buffer); @@ -99,13 +97,14 @@ export class XlsxService { styleCell( ws.getRow(r).getCell(1), `Ngày đối soát: ${formatDate(rec.signed_date_str ?? nowIctDateString())}`, - { font: FONT_HEADER, border: false }, + { font: FONT_BASE, border: false }, ); r++; + // Mẫu T6: "Thời gian đối soát từ dd/mm/yyyy -> hết ngày dd/mm/yyyy" styleCell( ws.getRow(r).getCell(1), - `Kỳ đối soát: ${renderPeriodLabel(rec.period_start, rec.period_end)}`, - { font: FONT_HEADER, border: false }, + `Thời gian đối soát từ ${formatDate(rec.period_start)} -> hết ngày ${formatDate(rec.period_end)}`, + { font: FONT_BASE, border: false }, ); r += 2; // skip blank row @@ -140,23 +139,27 @@ export class XlsxService { }); r += 2; // skip blank - // === SECTION 2: PHÍ DỊCH VỤ === - styleCell(ws.getRow(r).getCell(1), '2. PHÍ DỊCH VỤ', { + // === SECTION 2: GIAO DỊCH THỦ CÔNG === + styleCell(ws.getRow(r).getCell(1), '2. GIAO DỊCH THỦ CÔNG', { font: FONT_BOLD, border: false, }); r++; - ['STT', 'Số lượng vé', 'Phí/vé', 'Phí dịch vụ'].forEach((txt, i) => + ['STT', 'Số lượng vé', 'Phí dịch vụ', 'Phí dịch vụ'].forEach((txt, i) => styleCell(ws.getRow(r).getCell(i + 1), txt, { font: FONT_BOLD }), ); r++; + // Mẫu T6: khi 0 giao dịch thủ công → B/C để trống, D = 0. + const manualCount = rec.manual_ticket_count || 0; styleCell(ws.getRow(r).getCell(1), 1); - styleCell(ws.getRow(r).getCell(2), rec.manual_ticket_count || 0); - styleCell(ws.getRow(r).getCell(3), rec.manual_fee_per_ticket || 0, { - numFmt: VND_FMT, - }); + styleCell(ws.getRow(r).getCell(2), manualCount > 0 ? manualCount : null); + styleCell( + ws.getRow(r).getCell(3), + manualCount > 0 ? rec.manual_fee_per_ticket || 0 : null, + { numFmt: VND_FMT }, + ); styleCell(ws.getRow(r).getCell(4), rec.manual_fee_amount || 0, { numFmt: VND_FMT, }); @@ -175,6 +178,12 @@ export class XlsxService { font: FONT_BOLD, border: false, }); + // Mẫu T6: header gộp "Add-on products" trên nhóm cột add-on (D:F), cùng hàng tiêu đề. + ws.mergeCells(`D${r}:F${r}`); + styleCell(ws.getRow(r).getCell(4), 'Add-on products', { + font: FONT_BOLD, + alignment: { horizontal: 'center' }, + }); r++; // Column headers @@ -182,9 +191,9 @@ export class XlsxService { 'Cự ly', 'Đơn giá', 'Số lượng', - 'Số lượng áo', + 'Số lượng', 'Đơn giá', - 'Thành tiền áo', + 'Thành tiền', 'Giảm giá', 'Tổng cộng', ]; @@ -220,14 +229,18 @@ export class XlsxService { styleCell(row.getCell(1), li.distance_name); styleCell(row.getCell(2), li.unit_price, { numFmt: VND_FMT }); styleCell(row.getCell(3), li.quantity, { numFmt: VND_FMT }); - // FEATURE-030: render add-on per-line. Backend chỉ track tổng - // `total_add_on_price` per order (Shopify schema) — KHÔNG track qty/unit - // riêng. Col 4/5 hiển thị '—' khi có add-on, 0 khi không. Col 6 = total value. - const hasAddOn = li.add_on_price > 0; - styleCell(row.getCell(4), hasAddOn ? '—' : 0, { numFmt: VND_FMT }); - styleCell(row.getCell(5), hasAddOn ? '—' : 0, { numFmt: VND_FMT }); - styleCell(row.getCell(6), li.add_on_price, { numFmt: VND_FMT }); - styleCell(row.getCell(7), li.discount_amount, { numFmt: VND_FMT }); + // Mẫu T6: cột add-on D(3)/E(4) để TRỐNG (backend không tách qty/đơn giá + // add-on); F(5) = thành tiền add-on, G(6) = giảm giá — để trống khi = 0. + styleCell(row.getCell(4), null, { numFmt: VND_FMT }); + styleCell(row.getCell(5), null, { numFmt: VND_FMT }); + styleCell(row.getCell(6), li.add_on_price > 0 ? li.add_on_price : null, { + numFmt: VND_FMT, + }); + styleCell( + row.getCell(7), + li.discount_amount > 0 ? li.discount_amount : null, + { numFmt: VND_FMT }, + ); // #8 tie-out: Col 8 "Tổng cộng" per-line = giá trị đóng vào Section 1 GMV, // theo đúng công thức header (7) = [(1)×(2)+(5)] − (6). // ORDINARY: subtotal(gross) + add_on − discount = subtotal_price. @@ -250,132 +263,44 @@ export class XlsxService { // Totals row const totRow = ws.getRow(r); - styleCell(totRow.getCell(1), 'Tổng', { font: FONT_BOLD }); + styleCell(totRow.getCell(1), 'Tổng cộng', { font: FONT_BOLD }); styleCell(totRow.getCell(2), '', { font: FONT_BOLD }); styleCell(totRow.getCell(3), totalQty, { font: FONT_BOLD, numFmt: VND_FMT }); styleCell(totRow.getCell(4), '', { font: FONT_BOLD }); styleCell(totRow.getCell(5), '', { font: FONT_BOLD }); - styleCell(totRow.getCell(6), totalAddOnPrice, { font: FONT_BOLD, numFmt: VND_FMT }); - styleCell(totRow.getCell(7), totalDiscount, { font: FONT_BOLD, numFmt: VND_FMT }); - styleCell(totRow.getCell(8), grandTotal, { font: FONT_BOLD, numFmt: VND_FMT }); - r++; - - // Summary footer rows (merged A:G) - - // Phí bán vé - ws.mergeCells(`A${r}:G${r}`); - styleCell(ws.getRow(r).getCell(1), 'Phí bán vé (chưa bao gồm VAT)', { - font: FONT_BOLD, - }); - styleCell(ws.getRow(r).getCell(8), rec.fee_amount, { + styleCell(totRow.getCell(6), totalAddOnPrice > 0 ? totalAddOnPrice : '', { font: FONT_BOLD, numFmt: VND_FMT, }); - r++; - - // Phí dịch vụ - ws.mergeCells(`A${r}:G${r}`); - styleCell( - ws.getRow(r).getCell(1), - 'Phí dịch vụ (chưa bao gồm VAT)', - { font: FONT_BOLD }, - ); - styleCell(ws.getRow(r).getCell(8), rec.manual_fee_amount || 0, { - font: FONT_BOLD, - numFmt: VND_FMT, - }); - r++; - - // VAT - styleCell( - ws.getRow(r).getCell(1), - `Giá trị thuế GTGT (${rec.fee_vat_rate}%)`, - { font: FONT_BOLD }, - ); - for (let c = 2; c <= 7; c++) { - ws.getRow(r).getCell(c).border = BORDER_THIN; - } - styleCell(ws.getRow(r).getCell(8), rec.fee_vat_amount, { + styleCell(totRow.getCell(7), totalDiscount > 0 ? totalDiscount : '', { font: FONT_BOLD, numFmt: VND_FMT, }); + styleCell(totRow.getCell(8), grandTotal, { font: FONT_BOLD, numFmt: VND_FMT }); r++; - // Hoàn trả merchant - ws.mergeCells(`A${r}:G${r}`); - styleCell( - ws.getRow(r).getCell(1), - 'Hoàn trả merchant (chưa bao gồm VAT)', - { font: FONT_BOLD }, - ); - styleCell(ws.getRow(r).getCell(8), rec.payout_amount, { - font: FONT_BOLD, - numFmt: VND_FMT, - }); - } - - /* ================================================================== */ - /* SHEET 2: Pivot */ - /* ================================================================== */ - private buildPivot(wb: ExcelJS.Workbook, rec: ReconciliationDocument) { - const ws = wb.addWorksheet('Pivot'); - - ws.getColumn(1).width = 41; - ws.getColumn(2).width = 39; - ws.getColumn(3).width = 13; - ws.getColumn(4).width = 13; - ws.getColumn(5).width = 10; - ws.getColumn(6).width = 21; - ws.getColumn(7).width = 20; - - // Headers at row 3 (reference has rows 1-2 empty) - const hdrRow = ws.getRow(3); - [ - 'order_category', - 'type_name', - 'distance', - 'origin_price', - 'Sum of qty', - 'Sum of total_discounts', - 'Sum of subtotal_price', - ].forEach((txt, i) => { - hdrRow.getCell(i + 1).value = txt; - hdrRow.getCell(i + 1).font = FONT_BOLD; - }); - - let r = 4; - let grandQty = 0; - let grandDiscount = 0; - let grandSubtotal = 0; - - for (const li of rec.line_items) { - const row = ws.getRow(r); - row.getCell(1).value = li.order_category; - row.getCell(2).value = li.ticket_type_name; - row.getCell(3).value = li.distance_name; - row.getCell(4).value = li.unit_price; - row.getCell(5).value = li.quantity; - row.getCell(6).value = li.discount_amount; - row.getCell(7).value = li.subtotal; + // Summary footer rows (merge A:G, giá trị ở H) — khớp mẫu T6. + const footer = (label: string, value: number) => { + ws.mergeCells(`A${r}:G${r}`); + styleCell(ws.getRow(r).getCell(1), label, { font: FONT_BOLD }); + styleCell(ws.getRow(r).getCell(8), value, { + font: FONT_BOLD, + numFmt: VND_FMT, + }); r++; - grandQty += li.quantity; - grandDiscount += li.discount_amount; - grandSubtotal += li.subtotal; + }; + footer('Phí bán vé', rec.fee_amount); + footer('Phí dịch vụ', rec.manual_fee_amount || 0); + // Mẫu SCB có fee_vat_rate = 0 → KHÔNG có dòng VAT. Chỉ thêm khi VAT > 0 + // (merchant có VAT trên phí) để không giấu khoản thuế. + if ((rec.fee_vat_amount || 0) > 0) { + footer(`Thuế GTGT (${rec.fee_vat_rate}%)`, rec.fee_vat_amount); } - - const gt = ws.getRow(r); - gt.getCell(1).value = 'Grand Total'; - gt.getCell(1).font = FONT_BOLD; - gt.getCell(5).value = grandQty; - gt.getCell(5).font = FONT_BOLD; - gt.getCell(6).value = grandDiscount; - gt.getCell(6).font = FONT_BOLD; - gt.getCell(7).value = grandSubtotal; - gt.getCell(7).font = FONT_BOLD; + footer('Hoàn trả merchant', rec.payout_amount); } /* ================================================================== */ - /* SHEET 3/4/5: Raw order data */ + /* SHEET 2: raw — 25-col nguồn đơn hàng (khớp mẫu T6) */ /* ================================================================== */ private buildRawOrderSheet( wb: ExcelJS.Workbook, @@ -431,12 +356,24 @@ export class XlsxService { headers.map((h) => { const v = o[h]; if (v === undefined || v === null) return ''; + if (v instanceof Date) return fmtSqlDatetime(v); if (typeof v === 'object') return JSON.stringify(v); return v; }), ); } + // Mẫu T6: dòng tổng subtotal_price ở cột W (23). + if (orders.length > 0) { + const totalSubtotal = orders.reduce( + (s, o) => s + Number(o.subtotal_price || 0), + 0, + ); + const totalRow = ws.addRow([]); + totalRow.getCell(23).value = totalSubtotal; + totalRow.getCell(23).font = FONT_BOLD; + } + colWidths.forEach((w, i) => { if (i < headers.length) ws.getColumn(i + 1).width = w; }); From 27ecba9b21cf969c88a1edb0e1824a163d40915b Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Thu, 9 Jul 2026 22:58:17 +0700 Subject: [PATCH 03/23] =?UTF-8?q?feat(recon):=20Pha=202-A=20=E2=80=94=20bi?= =?UTF-8?q?=C3=AAn=20b=E1=BA=A3n=20DOCX=20kh=E1=BB=9Bp=20100%=20m=E1=BA=AB?= =?UTF-8?q?u=20T6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docx.service viết lại theo mẫu T6: · Title "BIÊN BẢN ĐỐI SOÁT" (bỏ "QUYẾT TOÁN"); dòng kỳ "(từ .. đến ..)". · Preamble 'Biên Bản Quyết Toán này ... ("Ngày Hiệu Lực"), bởi và giữa:'. · Bên A label "Bên Sử Dụng"/"Đơn Vị Tổ Chức"; Bên B thêm "Tên tài khoản", bỏ "Điện thoại", label "Bên Cung cấp"/"5BIB". · Đánh số 1. NỘI DUNG NGHIỆM THU (a/b) + 2. KẾT LUẬN (a/b/c) đúng câu chữ. · Bảng doanh thu 6 cột mẫu (STT|Giai đoạn|Hạng vé|SL vé qua 5BIB|Giá niêm yết|Thành tiền); bỏ sub-header công thức + dòng add-on; Thành tiền = net (tie-out #8). 3 dòng tổng (1)/(2)/(3)=(1)-(2) + 3 dòng "(Bằng chữ)". · Giữ logo-less (F-037 v4) + viewer-compat (fixed layout/tblGrid/cantSplit) + MerchantConfig priority cho Bên A. - config: PROVIDER_BANK_* default → BIDV Mỹ Đình 2600398986 theo mẫu thật (env VPS vẫn override — cần verify khớp trước khi phát). - Verify: generate từ fixture → pandoc khớp mẫu (party/bảng/số/bằng chữ). - docx spec viết lại (khớp mẫu + regression MC/viewer). 129 test recon pass. Co-Authored-By: Claude Opus 4.8 --- backend/src/config/index.ts | 8 +- .../services/docx.service.spec.ts | 467 ++++++------------ .../reconciliation/services/docx.service.ts | 356 +++++-------- 3 files changed, 276 insertions(+), 555 deletions(-) diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3d3bb902..bace2a01 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -89,8 +89,12 @@ const envVarsSchema = Joi.object() PROVIDER_PHONE: Joi.string().default('0373398986'), PROVIDER_REPRESENTATIVE_NAME: Joi.string().default('Nguyễn Bình Minh'), PROVIDER_REPRESENTATIVE_TITLE: Joi.string().default('Giám Đốc'), - PROVIDER_BANK_ACCOUNT: Joi.string().default('110398986'), - PROVIDER_BANK_NAME: Joi.string().default('Ngân hàng Quân Đội MB'), + // 2026-07-09: cập nhật theo mẫu biên bản thật của 5BIB (BIDV Mỹ Đình). + // Env PROVIDER_BANK_* trên VPS vẫn override — verify khớp trước khi phát. + PROVIDER_BANK_ACCOUNT: Joi.string().default('2600398986'), + PROVIDER_BANK_NAME: Joi.string().default( + 'Ngân hàng TMCP Đầu tư và Phát triển Việt Nam (BIDV) - Chi nhánh: Mỹ Đình', + ), // FEATURE-076 — MISA Meinvoice daily reconcile + alert system. // All optional → module load conditionally (if MISA_USERNAME unset, skip // Layer 2 MISA cross-check; if TELEGRAM token unset, skip Telegram alerts diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index beeb9933..f3ff19a7 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -1,7 +1,7 @@ /** - * FEATURE-030 — Smoke test DOCX render for: - * - 5BIB provider info đọc từ env config (replace hardcoded legacy strings) - * - Bottom "Vật phẩm bổ sung" row hiển thị khi có add-on + * DocxService — Pha 2-A: biên bản đối soát khớp 100% mẫu T6 + + * regression các fix cũ (MerchantConfig priority, fixed layout viewer-compat, + * logo-less F-037 v4). * * Stub `src/config` env loader giống pattern reconciliation.service.spec.ts. */ @@ -12,13 +12,15 @@ jest.mock( s3: { bucket: 'test', region: 'ap-southeast-1' }, provider: { companyName: 'CÔNG TY CỔ PHẦN 5BIB', - address: 'Tầng 9, Tòa nhà Hồ Gươm Plaza (tòa văn phòng), Số 102 Phố Trần Phú, Phường Hà Đông, TP Hà Nội, Việt Nam', + address: + 'Tầng 9, Tòa nhà Hồ Gươm Plaza (tòa văn phòng), số 102 Phố Trần Phú, Phường Hà Đông, Thành Phố Hà Nội', taxCode: '0110398986', phone: '0373398986', representativeName: 'Nguyễn Bình Minh', representativeTitle: 'Giám Đốc', - bankAccount: '110398986', - bankName: 'Ngân hàng Quân Đội MB', + bankAccount: '2600398986', + bankName: + 'Ngân hàng TMCP Đầu tư và Phát triển Việt Nam (BIDV) - Chi nhánh: Mỹ Đình', }, }, }), @@ -36,24 +38,15 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -describe('DocxService — FEATURE-030 provider info from env + add-on row', () => { - /** - * Default mock: MerchantConfig findOne returns null → docx falls back to - * tenant_metadata (legacy F-030 behavior). Specific test cases override - * to assert MerchantConfig priority (BUG-FIX 2026-05-14). - */ +describe('DocxService — khớp mẫu T6 + regression', () => { const mockModel: any = { - findOne: jest.fn().mockReturnValue({ - lean: () => Promise.resolve(null), - }), + findOne: jest.fn().mockReturnValue({ lean: () => Promise.resolve(null) }), }; const docxSvc = new DocxService(mockModel); beforeEach(() => { mockModel.findOne.mockClear(); - mockModel.findOne.mockReturnValue({ - lean: () => Promise.resolve(null), - }); + mockModel.findOne.mockReturnValue({ lean: () => Promise.resolve(null) }); }); const rec: any = { @@ -62,26 +55,19 @@ describe('DocxService — FEATURE-030 provider info from env + add-on row', () = race_title: 'Hai Phong Legacy Marathon 2026', period_start: '2026-04-01', period_end: '2026-04-30', + signed_date_str: null, fee_rate_applied: 5.5, fee_vat_rate: 0, manual_fee_per_ticket: 5000, gross_revenue: 18422200, - total_discount: 0, net_revenue: 18422200, fee_amount: 1013221, fee_vat_amount: 0, manual_ticket_count: 0, - manual_gross_revenue: 0, manual_fee_amount: 0, payout_amount: 17408979, manual_adjustment: 0, - adjustment_note: null, status: 'ready', - flags: [], - created_source: 'manual', - createdAt: new Date(), - updatedAt: new Date(), - signed_at: null, raw_5bib_orders: [], raw_manual_orders: [], manual_orders: [], @@ -91,82 +77,143 @@ describe('DocxService — FEATURE-030 provider info from env + add-on row', () = ], }; - async function extractDocText(buf: Buffer): Promise { - // Use shell `unzip -p` to extract word/document.xml — avoid adding new dep - const tmp = path.join(os.tmpdir(), `docx-spec-${Date.now()}-${Math.random()}.docx`); + async function xml(buf: Buffer): Promise { + const tmp = path.join(os.tmpdir(), `docx-spec-${process.hrtime.bigint()}.docx`); fs.writeFileSync(tmp, buf); try { - const xml = execSync(`unzip -p "${tmp}" word/document.xml`, { + return execSync(`unzip -p "${tmp}" word/document.xml`, { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024, }); - return xml; } finally { fs.unlinkSync(tmp); } } - it('TC-AO-07: DOCX BÊN B info đọc từ env.provider.* (NOT hardcoded legacy)', async () => { - const buf = await docxSvc.generate(rec); - expect(buf.length).toBeGreaterThan(1000); - const text = await extractDocText(buf); + /* ── Khớp mẫu T6 ─────────────────────────────────────────────────────── */ + + it('tiêu đề + đánh số section khớp mẫu', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain('BIÊN BẢN ĐỐI SOÁT'); + expect(t).not.toContain('BIÊN BẢN QUYẾT TOÁN'); // title cũ + expect(t).toContain('DOANH THU BÁN VÉ SỰ KIỆN'); + expect(t).toContain('(từ 01/04/2026 đến 30/04/2026)'); + expect(t).toContain('1. NỘI DUNG NGHIỆM THU'); + expect(t).toContain('2. KẾT LUẬN'); + // Preamble mẫu + expect(t).toContain('được lập và ký kết vào'); + expect(t).toContain('Ngày Hiệu Lực'); // quote bị XML-escape nên bỏ dấu ngoặc kép khi assert + }); + + it('label 2 Bên khớp mẫu', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain('Bên Sử Dụng'); + expect(t).toContain('Đơn Vị Tổ Chức'); + expect(t).toContain('Bên Cung cấp'); + // label cũ KHÔNG còn + expect(t).not.toContain('Bên sử dụng dịch vụ'); + expect(t).not.toContain('Bên cung cấp dịch vụ'); + }); - // Verify env.provider.* values hiện trong document - expect(text).toContain('Hồ Gươm Plaza'); - expect(text).toContain('Hà Đông'); - expect(text).toContain('0110398986'); // tax code - expect(text).toContain('0373398986'); // phone - expect(text).toContain('Nguyễn Bình Minh'); - expect(text).toContain('110398986'); // bank account (substring of tax also — both present) - expect(text).toContain('Ngân hàng Quân Đội MB'); + it('bảng doanh thu 6 cột mẫu + 3 dòng tổng (1)/(2)/(3)', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain('Số lượng vé đã bán qua 5BIB'); + expect(t).toContain('Giá vé niêm yết (Đã bao gồm VAT)'); + expect(t).toContain('Hạng vé'); + expect(t).toContain('Tổng doanh thu bán vé (1)'); + expect(t).toContain('Phí bán vé (Đã bao gồm VAT) (2)'); + expect(t).toContain('Tổng tiền sau phí dịch vụ của 5BIB (3) = (1) - (2)'); + // cấu trúc cũ KHÔNG còn + expect(t).not.toContain('Hoàn trả merchant (4)'); + expect(t).not.toContain('Số lượng BIB'); + }); - // Verify hardcoded LEGACY strings KHÔNG còn xuất hiện - expect(text).not.toContain('Tôn Thất Thuyết'); - expect(text).not.toContain('Mỹ Đình 2'); - expect(text).not.toContain('Nam Từ Liêm'); - expect(text).not.toContain('1900 636 997'); - expect(text).not.toContain('34110001234567'); - expect(text).not.toContain('BIDV'); + it('3 dòng "(Bằng chữ:...)" cho (1)/(2)/(3)', async () => { + const t = await xml(await docxSvc.generate(rec)); + const bangChu = t.match(/Bằng chữ:/g) ?? []; + expect(bangChu.length).toBeGreaterThanOrEqual(3); }); - it('TC-AO-08: DOCX Section 3 thêm row "Vật phẩm bổ sung" khi có add-on', async () => { - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); + it('add-on gộp vào "Thành tiền" (net) — KHÔNG còn dòng "Vật phẩm bổ sung"', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).not.toContain('Vật phẩm bổ sung'); + // line 21KM: subtotal 533200 + add_on 299000 = 832200 → Thành tiền net + expect(t).toContain('832.200'); + }); - // Bottom row label hiện - expect(text).toContain('Vật phẩm bổ sung'); + it('KẾT LUẬN có mục a/b/c đúng câu chữ mẫu', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain('Số lượng vé bán ra và doanh thu ghi nhận giữa Các Bên đã khớp nhau'); + expect(t).toContain('02 (hai) bản gốc bằng tiếng Việt'); + expect(t).toContain('có hiệu lực kể từ Ngày Hiệu Lực'); }); - it('TC-AO-09: DOCX KHÔNG hiển thị "Vật phẩm bổ sung" row khi tất cả line items không có add-on', async () => { - const recNoAddon = { - ...rec, - line_items: rec.line_items.map((li: any) => ({ ...li, add_on_price: 0 })), - }; - const buf = await docxSvc.generate(recNoAddon); - const text = await extractDocText(buf); + /* ── Bên B từ env.provider (không phone, có Tên tài khoản) ────────────── */ + + it('Bên B đọc từ env.provider.*; có "Tên tài khoản", KHÔNG có Điện thoại', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain('CÔNG TY CỔ PHẦN 5BIB'); + expect(t).toContain('Hồ Gươm Plaza'); + expect(t).toContain('0110398986'); // tax + expect(t).toContain('2600398986'); // bank account + expect(t).toContain('BIDV'); + expect(t).toContain('Tên tài khoản'); + // Mẫu Bên B KHÔNG có dòng điện thoại (env.provider.phone không render Bên B) + expect(t).not.toContain('0373398986'); + // Legacy strings tuyệt đối không còn + expect(t).not.toContain('Tôn Thất Thuyết'); + expect(t).not.toContain('Nam Từ Liêm'); + }); + + it('signature block: tên đại diện uppercase', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain('NGUYỄN BÌNH MINH'); + expect(t).toContain('ĐẠI DIỆN BÊN SỬ DỤNG'); + expect(t).toContain('ĐẠI DIỆN BÊN CUNG CẤP'); + }); + + /* ── Regression: viewer-compat (F-037) ───────────────────────────────── */ + + it('F-037 v2: cả 4 table dùng tblLayout fixed (strict renderer respect tcW)', async () => { + const t = await xml(await docxSvc.generate(rec)); + const m = t.match(/w:tblLayout w:type="fixed"/g) ?? []; + expect(m.length).toBeGreaterThanOrEqual(4); + }); + + it('F-037 v4: KHÔNG có logo (drawing) trong doc — header text-only', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect((t.match(//g) ?? []).length).toBe(0); + expect(t).toContain('CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM'); + }); + + it('F-037 v3: gridCol widths đúng cho bảng doanh thu mới + party + signature; KHÔNG default 100', async () => { + const t = await xml(await docxSvc.generate(rec)); + // Party table + expect(t).toContain(''); + expect(t).toContain(''); + // Revenue table mới: 500/1500/1200/1600/1560/1500 + expect(t).toContain(''); + expect(t).toContain(''); + expect(t).toContain(''); + // Signature + expect(t).toContain(''); + expect((t.match(//g) ?? []).length).toBe(0); + }); - // Bottom row label KHÔNG hiện (clean render cho recon không có add-on) - expect(text).not.toContain('Vật phẩm bổ sung'); - // 5BIB info vẫn hiển thị (regression check) - expect(text).toContain('Hồ Gươm Plaza'); + it('F-037 v3: header có tblHeader + 3 dòng tổng có cantSplit (page-break safe)', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect((t.match(/ { - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - // representativeName.toUpperCase() = 'NGUYỄN BÌNH MINH' - expect(text).toContain('NGUYỄN BÌNH MINH'); + it('colspan cells dòng tổng có explicit tcW=6360 (colspan 5)', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect((t.match(/w:w="6360"/g) ?? []).length).toBeGreaterThanOrEqual(3); }); - /* ────────────────────────────────────────────────────────────────────────── - * BUG-FIX 2026-05-14 — Merchant info priority (MerchantConfig > tenant_metadata) - * - * Danny report screenshot: DOCX cho Zaha #46 dùng platform sync info - * thay vì "Công ty đối tác" admin đã nhập riêng → DOCX sai 8 field. - * Fix: priority MerchantConfig admin-entered > tenant_metadata > tenant_name. - * ────────────────────────────────────────────────────────────────────────── */ + /* ── Regression: MerchantConfig priority (BUG-FIX 2026-05-14) ─────────── */ - it('TC-MC-01: MerchantConfig admin-entered legal_name + tax_code thắng tenant_metadata + rec.tenant_name', async () => { + it('TC-MC-01: MerchantConfig admin-entered thắng tenant_metadata', async () => { mockModel.findOne.mockReturnValue({ lean: () => Promise.resolve({ @@ -180,254 +227,44 @@ describe('DocxService — FEATURE-030 provider info from env + add-on row', () = bank_name: 'Vietcombank Hà Nội', }), }); - - const recWithMeta = { - ...rec, - tenant_metadata: { - companyName: 'PLATFORM SYNC NAME WRONG', - vat: '0193762OLD', - address: 'Platform Address Wrong', - name: 'Platform Rep Wrong', - bankAccount: '111222333', - bankName: 'Platform Bank Wrong', - }, - }; - const buf = await docxSvc.generate(recWithMeta); - const text = await extractDocText(buf); - - // Admin-entered values WIN - expect(text).toContain('CÔNG TY CỔ PHẦN VIỆT NAM TÔI ĐÓ'); - expect(text).toContain('0193762555'); - expect(text).toContain('Hà Nội Admin Entered Address'); - expect(text).toContain('Trần Quang Hùng'); - expect(text).toContain('Chủ Tịch HĐQT'); - expect(text).toContain('999888777'); - expect(text).toContain('Vietcombank Hà Nội'); - - // Platform sync values KHÔNG còn hiện - expect(text).not.toContain('PLATFORM SYNC NAME WRONG'); - expect(text).not.toContain('0193762OLD'); - expect(text).not.toContain('Platform Address Wrong'); - expect(text).not.toContain('Platform Rep Wrong'); - expect(text).not.toContain('111222333'); - expect(text).not.toContain('Platform Bank Wrong'); - }); - - it('TC-MC-02: MerchantConfig KHÔNG tồn tại → fallback tenant_metadata (backward compat F-030)', async () => { - // Default mock returns null — no admin override - const recWithMeta = { - ...rec, - tenant_metadata: { - companyName: 'PLATFORM SYNC NAME', - vat: '01122334', - bankAccount: '123456789', - }, - }; - const buf = await docxSvc.generate(recWithMeta); - const text = await extractDocText(buf); - - expect(text).toContain('PLATFORM SYNC NAME'); - expect(text).toContain('01122334'); - expect(text).toContain('123456789'); + const t = await xml( + await docxSvc.generate({ + ...rec, + tenant_metadata: { companyName: 'PLATFORM SYNC NAME WRONG', vat: '0193762OLD' }, + }), + ); + expect(t).toContain('CÔNG TY CỔ PHẦN VIỆT NAM TÔI ĐÓ'); + expect(t).toContain('0193762555'); + expect(t).toContain('Trần Quang Hùng'); + expect(t).not.toContain('PLATFORM SYNC NAME WRONG'); + expect(t).not.toContain('0193762OLD'); }); - it('TC-MC-03: MerchantConfig partial fields (chỉ legal_name) → các field khác fallback tenant_metadata', async () => { - mockModel.findOne.mockReturnValue({ - lean: () => - Promise.resolve({ - tenantId: 1, - legal_name: 'ADMIN ENTERED COMPANY ONLY', - // tax_code, address, etc null - }), - }); - - const recWithMeta = { - ...rec, - tenant_metadata: { - companyName: 'PLATFORM NAME WRONG', - vat: '0199000111', - address: 'Platform Address Fallback', - name: 'Platform Rep Fallback', - bankAccount: '888777666', - bankName: 'Platform Bank Fallback', - }, - }; - const buf = await docxSvc.generate(recWithMeta); - const text = await extractDocText(buf); - - // Admin-entered legal_name WINS - expect(text).toContain('ADMIN ENTERED COMPANY ONLY'); - expect(text).not.toContain('PLATFORM NAME WRONG'); - - // Other fields fallback to tenant_metadata - expect(text).toContain('0199000111'); - expect(text).toContain('Platform Address Fallback'); - expect(text).toContain('Platform Rep Fallback'); - expect(text).toContain('888777666'); - expect(text).toContain('Platform Bank Fallback'); + it('TC-MC-02: MerchantConfig null → fallback tenant_metadata', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + tenant_metadata: { companyName: 'PLATFORM SYNC NAME', vat: '01122334' }, + }), + ); + expect(t).toContain('PLATFORM SYNC NAME'); + expect(t).toContain('01122334'); }); - it('TC-MC-04: MerchantConfig fetch query uses correct tenantId', async () => { + it('TC-MC-04: fetch dùng đúng tenantId', async () => { await docxSvc.generate(rec); expect(mockModel.findOne).toHaveBeenCalledWith({ tenantId: rec.tenant_id }); }); - /* ──────────────────────────────────────────────────────────────────────── - * FEATURE-037 — colspan cell explicit width (Danny 2026-05-15 bug report) - * - * Bug: DOCX preview Google Drive viewer / LibreOffice / Mac Preview render - * colspan cells thiếu width → text dài như "Hoàn trả merchant (4) = (1) - - * (2) - (3)" wrap mỗi ký tự thành 1 dòng vertical. MS Word auto-fit - * KHÔNG bị → người nhận bằng client khác complain. - * - * Fix: thêm explicit `` per colspan cell. - * ──────────────────────────────────────────────────────────────────────── */ - - it('TC-DOCX-COL-01: Tất cả colspan cells trong reconciliation table có explicit tcW (width DXA)', async () => { - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - // Verify cells "Phí bán vé" + "Thuế GTGT" + "Hoàn trả merchant" — mỗi - // text dài này PHẢI render cùng tcW preceding nó (= 5620 = sum colWidths[0..4]) - // Pattern check: trong XML, mỗi tcW="5620" appearance phải >= 3 (3 colspan-5 rows) - const tcWMatches5620 = text.match(/w:w="5620"/g) ?? []; - expect(tcWMatches5620.length).toBeGreaterThanOrEqual(3); - - // Verify cell "Tổng cộng (1)" colspan=3 → tcW="3930" (2300+750+880) - const tcWMatches3930 = text.match(/w:w="3930"/g) ?? []; - expect(tcWMatches3930.length).toBeGreaterThanOrEqual(1); - - // Verify text "Hoàn trả merchant" KHÔNG đứng cạnh cell missing width - // (tức: text này phải xuất hiện sau tcW="5620" definition) - expect(text).toContain('Hoàn trả merchant (4) = (1) - (2) - (3)'); - expect(text).toContain('Thuế GTGT'); - expect(text).toContain('Phí bán vé'); - }); - - it('TC-DOCX-COL-02: Sub-header BÊN A/B colspan=6 có tcW="9000" (full table width)', async () => { - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - // BÊN A "Bên sử dụng dịch vụ" + BÊN B "Bên cung cấp dịch vụ" + "Và" separator - // = 3 cells colspan=6 với width=9000 - const tcWMatches9000 = text.match(/w:w="9000"/g) ?? []; - expect(tcWMatches9000.length).toBeGreaterThanOrEqual(3); - - // Verify text "Sau đây gọi tắt là" KHÔNG bị wrap (presence check) - expect(text).toContain('Bên sử dụng dịch vụ'); - expect(text).toContain('Bên cung cấp dịch vụ'); - }); - - it('TC-DOCX-COL-04 (F-037 v2): TẤT CẢ 4 tables có `` — strict renderers respect tcW', async () => { - // Without `tblLayout type="fixed"`, strict renderers (Google Drive viewer, - // Mac Preview, LibreOffice) default to "autofit" → ignore tcW → wrap long - // text vertical mỗi ký tự. MS Word smart-fit OK → bug bị mask. - // - // Fix root cause discovered 2026-05-15 sau khi Danny gửi screenshot file - // local có tcW="5620"/"3930"/"9000" NHƯNG vẫn wrap → tblLayout missing. - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - const tblLayoutMatches = text.match(/w:tblLayout w:type="fixed"/g) ?? []; - // 4 tables: header logo, BÊN A/B info, reconciliation calc, signature - expect(tblLayoutMatches.length).toBeGreaterThanOrEqual(4); - }); - - it('TC-DOCX-COL-07 (F-037 v4): Logo BỎ khỏi header table — strict viewers render image distorted', async () => { - // Danny chốt 2026-05-15: logo image cell render distort qua Google Drive - // viewer / Mac Preview (shape placeholder rỗng thay vì logo). MS Word - // OK nhưng người nhận file complain. Decision: BỎ logo, header chỉ - // text "CỘNG HÒA..." centered full-width. - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - // KHÔNG được có trong document (drawing = image embed) - const drawingMatches = text.match(//g) ?? []; - expect(drawingMatches.length).toBe(0); - - // Text "CỘNG HÒA" vẫn render đúng (full-width centered) - expect(text).toContain('CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM'); - }); - - it('TC-DOCX-COL-06 (F-037 v3 fix B): Header + sub-header có `` + summary rows có `` cho page-break safe', async () => { - // Bug v3 partial: sau khi fix tblLayout + columnWidths, table render - // đúng widths NHƯNG dài quá → page break ngắt giữa Phí bán vé (2) và - // Thuế GTGT (3) → trang 2 chỉ thấy 2 summary rows + KHÔNG header → - // user thấy "table 2-col rời" misleading. - // - // Fix v3 B: tableHeader=true cho header/sub-header → lặp lại page 2. - // cantSplit=true cho summary rows → ngăn split mid-row. - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - // tableHeader (=tblHeader trong XML) cho header + sub-header - const tblHeaderMatches = text.match(/` với gridCol widths đúng — NOT default 100 DXA', async () => { - // Root cause v3 phát hiện 2026-05-15 sau v2 fix vẫn bug. XML inspect - // file Danny attached: `` - // → docx lib auto-gen default 100 DXA per col khi columnWidths missing. - // Trong tblLayout=fixed, renderer respect tblGrid > tcW → strict - // renderer ép tất cả cells xuống 0.07" → wrap vertical. - // - // Fix v3: pass `columnWidths` prop cho mỗi Table. - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - // Header table (logo removed F-037 v4) — single col 9000 full width - // expect(text).toContain(''); // tested below via 4500 grouping - - // BÊN A/B info table — labelW 1700 + colonW 200 + 4×1775 - expect(text).toContain(''); - expect(text).toContain(''); - expect(text).toContain(''); - - // Reconciliation calc table — colWidths [2300, 750, 880, 940, 750, 1240] - expect(text).toContain(''); - expect(text).toContain(''); - expect(text).toContain(''); - expect(text).toContain(''); - expect(text).toContain(''); - - // Signature 2×4500 - expect(text).toContain(''); - - // CRITICAL: KHÔNG được có default `100` gridCol (bug v1+v2 era) - const gridCol100 = text.match(//g) ?? []; - expect(gridCol100.length).toBe(0); - }); - - it('TC-DOCX-COL-03: addOnRow "Vật phẩm bổ sung" colspan=5 có tcW="5620"', async () => { - // rec đã có line_items[0].add_on_price = 299_000 → addOnRow render - const buf = await docxSvc.generate(rec); - const text = await extractDocText(buf); - - expect(text).toContain('Vật phẩm bổ sung'); - // 4 colspan-5 cells total (addOn + Phí + Thuế + Hoàn trả) - const tcWMatches5620 = text.match(/w:w="5620"/g) ?? []; - expect(tcWMatches5620.length).toBeGreaterThanOrEqual(4); - }); - - it('TC-MC-05: MerchantConfig fetch error → fail-soft fallback tenant_metadata (KHÔNG crash)', async () => { + it('TC-MC-05: fetch error → fail-soft fallback (KHÔNG crash)', async () => { mockModel.findOne.mockReturnValue({ lean: () => Promise.reject(new Error('mongo down')), }); - - const recWithMeta = { + const buf = await docxSvc.generate({ ...rec, - tenant_metadata: { - companyName: 'FALLBACK PLATFORM', - }, - }; - const buf = await docxSvc.generate(recWithMeta); + tenant_metadata: { companyName: 'FALLBACK PLATFORM' }, + }); expect(buf.length).toBeGreaterThan(1000); - const text = await extractDocText(buf); - expect(text).toContain('FALLBACK PLATFORM'); + expect(await xml(buf)).toContain('FALLBACK PLATFORM'); }); }); diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index 7d2a8ba3..b37c50cb 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -27,7 +27,6 @@ import { } from '../../merchant/schemas/merchant-config.schema'; import { env } from 'src/config'; import { renderPeriodLabel } from './period-label.helper'; -import { nowIctDateString } from '../../../common/utils/ict-date.util'; /* ------------------------------------------------------------------ */ /* Helpers */ @@ -234,11 +233,22 @@ export class DocxService { ); const periodLabel = renderPeriodLabel(rec.period_start, rec.period_end); - const signedDate = formatDate( - rec.signed_date_str ?? nowIctDateString(), - ); - const payoutWords = this.numToWords(Math.round(rec.payout_amount)); + // Biên bản khớp mẫu T6: 3 số (1)/(2)/(3) + số bằng chữ cho mỗi số. + // (2) = phí đã gồm VAT; (3) = (1) - (2) → tie-out nội bộ biên bản. + const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); + const payoutAfterFee = Math.round(rec.net_revenue - feeInclVat); + const netWords = this.numToWords(Math.round(rec.net_revenue)); + const feeWords = this.numToWords(feeInclVat); + const payoutWords = this.numToWords(payoutAfterFee); + // "Ngày Hiệu Lực": dùng signed_date_str nếu có, else để trống điền tay. + const periodYear = rec.period_end.slice(0, 4); + const sd = rec.signed_date_str + ? formatDate(rec.signed_date_str).split('/') + : null; + const effDate = sd + ? `ngày ${sd[0]} tháng ${sd[1]} năm ${sd[2]}` + : `ngày …… tháng …… năm ${periodYear}`; // Try to load logo let logoImage: ImageRun | null = null; @@ -262,13 +272,6 @@ export class DocxService { // logo not available — skip } - // Calculate totals for display - const totalQty = rec.line_items.reduce((s, li) => s + li.quantity, 0); - const totalDiscount = rec.line_items.reduce( - (s, li) => s + li.discount_amount, - 0, - ); - const doc = new Document({ styles: { default: { @@ -319,8 +322,8 @@ export class DocxService { // === 1. HEADER TABLE (logo + republic text) === this.buildHeaderTable(logoImage), - // === 2. TITLE BLOCK === - para('BIÊN BẢN QUYẾT TOÁN', { + // === 2. TITLE BLOCK (khớp mẫu T6) === + para('BIÊN BẢN ĐỐI SOÁT', { bold: true, size: SZ_18, align: AlignmentType.CENTER, @@ -336,16 +339,15 @@ export class DocxService { size: SZ_14, align: AlignmentType.CENTER, }), - para(`Kỳ đối soát: ${periodLabel}`, { - bold: true, - align: AlignmentType.CENTER, - spacingAfter: 120, - }), + para( + `(từ ${formatDate(rec.period_start)} đến ${formatDate(rec.period_end)})`, + { align: AlignmentType.CENTER, spacingAfter: 120 }, + ), // === 3. PREAMBLE === para( - `Hôm nay, ngày ${signedDate}, tại Hà Nội, chúng tôi gồm có:`, - { bold: true, spacingBefore: 120, spacingAfter: 120 }, + `Biên Bản Quyết Toán này (sau đây gọi tắt là "Biên Bản") được lập và ký kết vào ${effDate} ("Ngày Hiệu Lực"), bởi và giữa:`, + { spacingBefore: 120, spacingAfter: 120 }, ), // === 4. PARTY INFO TABLE === @@ -360,91 +362,82 @@ export class DocxService { bankName, ), - // === 5. TRANSITION PARAGRAPHS === para( - 'Hai bên cùng nhau xác nhận kết quả đối soát doanh thu bán vé sự kiện như sau:', - { bold: true, spacingBefore: 240, spacingAfter: 120 }, + 'Bên Sử Dụng và Bên Cung Cấp sau đây có thể gọi chung là "Các Bên" hoặc được gọi riêng là "Bên".', + { spacingBefore: 120, spacingAfter: 120 }, ), - para('NỘI DUNG NGHIỆM THU', { - bold: true, - spacingAfter: 60, - }), para( - 'Doanh thu bán vé, Phí Dịch vụ và Giá trị thanh toán thực tế.', - { bold: true, spacingAfter: 120 }, + 'Hai Bên thống nhất cùng nhau tiến hành Quyết toán và Đối soát với số liệu cụ thể như sau:', + { spacingAfter: 120 }, ), - // === 6. REVENUE DATA TABLE === - this.buildRevenueTable(rec, totalQty, totalDiscount), + // === 5. SECTION 1: NỘI DUNG NGHIỆM THU === + para('1. NỘI DUNG NGHIỆM THU', { bold: true, spacingAfter: 60 }), + para( + `Căn cứ theo Hợp Đồng, Các Bên thống nhất ký Biên Bản này để nghiệm thu doanh thu bán vé và các khoản công nợ Các Bên của Sự kiện "${rec.race_title}" – Kỳ đối soát ${periodLabel}. Nội dung cụ thể như sau:`, + { spacingAfter: 120 }, + ), + para( + 'a. Doanh thu bán vé, Phí Dịch vụ và Giá trị thanh toán thực tế.', + { bold: true, spacingAfter: 120 }, + ), - // === 7. DETAIL PARAGRAPHS === + this.buildRevenueTable(rec), para('', { spacingAfter: 120 }), + multiPara( [ - { - text: `Tổng doanh thu bán vé qua 5BIB: `, - bold: true, - }, - { - text: `${fmtVnd(rec.net_revenue)} đồng`, - bold: true, - }, - ], - { spacingAfter: 60 }, - ), - multiPara( - [ - { - text: `Phí bán vé (${rec.fee_rate_applied ?? 0}% doanh thu, chưa bao gồm thuế GTGT): `, - }, - { text: `${fmtVnd(rec.fee_amount)} đồng`, bold: true }, + { text: '- Tổng Doanh thu bán vé: ' }, + { text: `${fmtVnd(rec.net_revenue)} VNĐ (1)`, bold: true }, ], { spacingAfter: 60 }, ), + para(` (Bằng chữ: ${netWords})`, { spacingAfter: 120 }), + para('- Phí bán vé và Khoản bán vé:', { spacingAfter: 60 }), multiPara( [ { - text: `Thuế GTGT (${rec.fee_vat_rate}%): `, + text: `- Căn cứ theo quy định Hợp Đồng, 5BIB được hưởng khoản Phí bán vé là ${rec.fee_rate_applied ?? 0}% tính trên doanh thu bán vé thực tế tương ứng là: `, }, { - text: `${fmtVnd(rec.fee_vat_amount)} đồng`, + text: `${fmtVnd(feeInclVat)} VNĐ (2) = (1) × ${rec.fee_rate_applied ?? 0}%`, bold: true, }, ], { spacingAfter: 60 }, ), + para(` (Bằng chữ: ${feeWords})`, { spacingAfter: 120 }), multiPara( [ - { text: 'Giá trị thanh toán thực tế: ', bold: true }, { - text: `${fmtVnd(rec.payout_amount)} đồng`, - bold: true, + text: '- Khoản bán vé 5BIB sẽ thanh toán cho Bên A sau khi cấn trừ các khoản phí dịch vụ: ', }, + { text: `${fmtVnd(payoutAfterFee)} VNĐ (3)`, bold: true }, ], { spacingAfter: 60 }, ), - para(`(Bằng chữ: ${payoutWords})`, { - bold: true, - spacingAfter: 120, - }), + para(` (Bằng chữ: ${payoutWords})`, { spacingAfter: 120 }), para( - '* 5BIB sẽ xuất hóa đơn GTGT cho phí dịch vụ theo quy định.', + 'b. 5BIB sẽ thực hiện xuất hóa đơn phí dịch vụ cho Bên A ngay khi Biên Bản này được ký kết.', { spacingAfter: 240 }, ), - // === 8. CONCLUSION === - para('KẾT LUẬN', { bold: true, spacingAfter: 120 }), + // === 6. SECTION 2: KẾT LUẬN === + para('2. KẾT LUẬN', { bold: true, spacingAfter: 120 }), para( - 'Hai bên đã đối soát và thống nhất các số liệu nêu trên. Biên bản được lập thành 02 (hai) bản có giá trị pháp lý như nhau, mỗi bên giữ 01 (một) bản.', - { spacingAfter: 240 }, + 'a. Số lượng vé bán ra và doanh thu ghi nhận giữa Các Bên đã khớp nhau;', + { spacingAfter: 60 }, ), - - // === 9. SIGNATURE TABLE === - this.buildSignatureTable( - companyName, - representative, - repTitle, + para( + 'b. Biên Bản này được lập thành 02 (hai) bản gốc bằng tiếng Việt có giá trị pháp lý như nhau, mỗi Bên giữ 01 (một) bản để thực hiện.', + { spacingAfter: 60 }, ), + para('c. Biên bản này có hiệu lực kể từ Ngày Hiệu Lực.', { + spacingAfter: 240, + }), + + // === 7. SIGNATURE TABLE === + this.buildSignatureTable(companyName, representative, repTitle), ], }, ], @@ -566,7 +559,7 @@ export class DocxService { tCell( [ { - text: '(Sau đây gọi tắt là "Bên sử dụng dịch vụ")', + text: 'Sau đây được gọi là "Bên Sử Dụng" hoặc "Đơn Vị Tổ Chức"', bold: true, }, ], @@ -605,12 +598,13 @@ export class DocxService { }), infoRow('Địa chỉ', env.provider.address), infoRow('Mã số thuế', env.provider.taxCode), - infoRow('Điện thoại', env.provider.phone), infoRow( 'Người đại diện', `Ông ${env.provider.representativeName} Chức vụ: ${env.provider.representativeTitle}`, true, ), + // Mẫu T6: Bên B có dòng "Tên tài khoản", KHÔNG có "Điện thoại". + infoRow('Tên tài khoản', env.provider.companyName), infoRow('Tài khoản số', env.provider.bankAccount), infoRow('Mở tại NH', env.provider.bankName), new TableRow({ @@ -618,7 +612,7 @@ export class DocxService { tCell( [ { - text: '(Sau đây gọi tắt là "Bên cung cấp dịch vụ")', + text: 'Sau đây được gọi là "Bên Cung cấp" hoặc "5BIB"', bold: true, }, ], @@ -634,47 +628,41 @@ export class DocxService { /* ------------------------------------------------------------------ */ /* Revenue data table (matches reference: 6 cols, blue header) */ /* ------------------------------------------------------------------ */ - private buildRevenueTable( - rec: ReconciliationDocument, - totalQty: number, - totalDiscount: number, - ): Table { + private buildRevenueTable(rec: ReconciliationDocument): Table { const HEADER_BG = 'BDD7EE'; - const colWidths = [2300, 750, 880, 940, 750, 1240]; - - // Header row - // FEATURE-037 v3 fix B — `tableHeader: true` lặp lại header row khi - // table page-break. Without this, Thuế GTGT (3) + Hoàn trả (4) summary - // rows push xuống page 2 không có header → render trông như "table 2-col - // rời". Với tableHeader: true, header + sub-header lặp lại trên page 2 - // → user thấy full table context. + // 6 cột mẫu T6: STT | Giai đoạn | Hạng vé | Số lượng vé đã bán qua 5BIB | + // Giá vé niêm yết (Đã bao gồm VAT) | Thành tiền. Sum = 7860. + const colWidths = [500, 1500, 1200, 1600, 1560, 1500]; + const spanW = + colWidths[0] + colWidths[1] + colWidths[2] + colWidths[3] + colWidths[4]; // 6360 + const headerRow = new TableRow({ tableHeader: true, children: [ - tCell([{ text: 'Cự ly', bold: true }], { + tCell([{ text: 'STT', bold: true }], { width: colWidths[0], shading: HEADER_BG, + align: AlignmentType.CENTER, }), tCell([{ text: 'Giai đoạn', bold: true }], { width: colWidths[1], shading: HEADER_BG, }), - tCell([{ text: 'Đơn giá', bold: true }], { + tCell([{ text: 'Hạng vé', bold: true }], { width: colWidths[2], shading: HEADER_BG, - align: AlignmentType.RIGHT, }), - tCell([{ text: 'Số lượng BIB', bold: true }], { + tCell([{ text: 'Số lượng vé đã bán qua 5BIB', bold: true }], { width: colWidths[3], shading: HEADER_BG, - align: AlignmentType.RIGHT, + align: AlignmentType.CENTER, }), - tCell([{ text: 'Giảm giá', bold: true }], { + tCell([{ text: 'Giá vé niêm yết (Đã bao gồm VAT)', bold: true }], { width: colWidths[4], shading: HEADER_BG, align: AlignmentType.RIGHT, }), - tCell([{ text: 'Tổng cộng', bold: true }], { + tCell([{ text: 'Thành tiền', bold: true }], { width: colWidths[5], shading: HEADER_BG, align: AlignmentType.RIGHT, @@ -682,51 +670,36 @@ export class DocxService { ], }); - // Sub-header (formula labels) - const subHeaderRow = new TableRow({ - tableHeader: true, - children: [ - tCell([{ text: '' }], { width: colWidths[0] }), - tCell([{ text: '' }], { width: colWidths[1] }), - tCell([{ text: '(a)' }], { - width: colWidths[2], - align: AlignmentType.CENTER, - }), - tCell([{ text: '(b)' }], { - width: colWidths[3], - align: AlignmentType.CENTER, - }), - tCell([{ text: '(c)' }], { - width: colWidths[4], - align: AlignmentType.CENTER, - }), - tCell([{ text: '(d) = (a) x (b) - (c)' }], { - width: colWidths[5], - align: AlignmentType.CENTER, - }), - ], - }); + // Thành tiền = net đóng vào doanh thu (khớp tie-out #8 với xlsx Section 1). + const lineNet = (li: { + order_category: string; + subtotal: number; + add_on_price: number; + discount_amount: number; + }) => + li.order_category === 'CHANGE_COURSE' + ? li.subtotal + : li.subtotal + li.add_on_price - li.discount_amount; - // Data rows const dataRows: TableRow[] = rec.line_items.map( - (li) => + (li, i) => new TableRow({ children: [ - tCell([{ text: li.distance_name }], { width: colWidths[0] }), - tCell([{ text: li.ticket_type_name }], { width: colWidths[1] }), - tCell([{ text: fmtVnd(li.unit_price) }], { - width: colWidths[2], - align: AlignmentType.RIGHT, + tCell([{ text: String(i + 1) }], { + width: colWidths[0], + align: AlignmentType.CENTER, }), + tCell([{ text: li.ticket_type_name }], { width: colWidths[1] }), + tCell([{ text: li.distance_name }], { width: colWidths[2] }), tCell([{ text: String(li.quantity) }], { width: colWidths[3], - align: AlignmentType.RIGHT, + align: AlignmentType.CENTER, }), - tCell([{ text: fmtVnd(li.discount_amount) }], { + tCell([{ text: fmtVnd(li.unit_price) }], { width: colWidths[4], align: AlignmentType.RIGHT, }), - tCell([{ text: fmtVnd(li.subtotal) }], { + tCell([{ text: fmtVnd(lineNet(li)) }], { width: colWidths[5], align: AlignmentType.RIGHT, }), @@ -734,128 +707,35 @@ export class DocxService { }), ); - // FEATURE-030 — Bottom row "Vật phẩm bổ sung" hiển thị tổng add_on - // (áo, vật phẩm khác) — Option A trong Manager Plan. Render conditional - // chỉ khi có add-on, KHÔNG redesign table 6-col → 7-col (low-risk). - // Tổng cộng (1) = per-row subtotal + add_on = gross_revenue (Section 1). - const totalAddOnPrice = rec.line_items.reduce( - (s, li) => s + li.add_on_price, - 0, - ); - // FEATURE-037 fix — sum widths cho colspan cells. Strict renderers - // (Google Drive viewer / LibreOffice / Mac Preview) cần explicit width - // trên merged cells, nếu không sẽ inherit width col[0] (2300 DXA = - // 1.6") → text dài "Hoàn trả merchant (4) = (1) - (2) - (3)" wrap mỗi - // ký tự thành 1 dòng vertical. MS Word auto-fit nên bị mask, người - // nhận bằng client khác complain. - const colspan5Width = - colWidths[0] + colWidths[1] + colWidths[2] + colWidths[3] + colWidths[4]; // 5620 - const colspan3Width = colWidths[0] + colWidths[1] + colWidths[2]; // 3930 - const colspan6Width = colspan5Width + colWidths[5]; // 6860 — full table - - // FEATURE-037 v3 fix B — `cantSplit: true` per row prevent split - // mid-row khi cell có multi-line content. Combined với `tableHeader: true` - // trên header/sub-header → page break safe, không còn render "table 2-col - // rời" cho summary rows. - const addOnRow: TableRow | null = - totalAddOnPrice > 0 - ? new TableRow({ - cantSplit: true, - children: [ - tCell( - [{ text: 'Vật phẩm bổ sung (áo, ...)' }], - { colspan: 5, width: colspan5Width }, - ), - tCell([{ text: fmtVnd(totalAddOnPrice) }], { - width: colWidths[5], - align: AlignmentType.RIGHT, - }), - ], - }) - : null; - - // Summary rows - const summaryRows: TableRow[] = [ - ...(addOnRow ? [addOnRow] : []), - // Tổng cộng (1) — gross_revenue = sum(line subtotal) + sum(add_on) - new TableRow({ - cantSplit: true, - children: [ - tCell([{ text: 'Tổng cộng (1)', bold: true }], { - colspan: 3, - width: colspan3Width, - }), - tCell([{ text: String(totalQty), bold: true }], { - width: colWidths[3], - align: AlignmentType.RIGHT, - }), - tCell([{ text: fmtVnd(totalDiscount), bold: true }], { - width: colWidths[4], - align: AlignmentType.RIGHT, - }), - tCell([{ text: fmtVnd(rec.net_revenue), bold: true }], { - width: colWidths[5], - align: AlignmentType.RIGHT, - }), - ], - }), - // Phí bán vé (2) - new TableRow({ - cantSplit: true, - children: [ - tCell( - [ - { - text: 'Phí bán vé (chưa bao gồm thuế GTGT) (2)', - bold: true, - }, - ], - { colspan: 5, width: colspan5Width }, - ), - tCell([{ text: fmtVnd(rec.fee_amount), bold: true }], { - width: colWidths[5], - align: AlignmentType.RIGHT, - }), - ], - }), - // Thuế GTGT (3) - new TableRow({ - cantSplit: true, - children: [ - tCell( - [{ text: `Thuế GTGT (${rec.fee_vat_rate}%) (3)`, bold: true }], - { colspan: 5, width: colspan5Width }, - ), - tCell([{ text: fmtVnd(rec.fee_vat_amount), bold: true }], { - width: colWidths[5], - align: AlignmentType.RIGHT, - }), - ], - }), - // Hoàn trả merchant (4) + const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); + const payoutAfterFee = Math.round(rec.net_revenue - feeInclVat); + + const summaryRow = (label: string, value: number): TableRow => new TableRow({ cantSplit: true, children: [ - tCell( - [{ text: 'Hoàn trả merchant (4) = (1) - (2) - (3)', bold: true }], - { colspan: 5, width: colspan5Width }, - ), - tCell([{ text: fmtVnd(rec.payout_amount), bold: true }], { + tCell([{ text: label, bold: true }], { colspan: 5, width: spanW }), + tCell([{ text: fmtVnd(value), bold: true }], { width: colWidths[5], align: AlignmentType.RIGHT, }), ], - }), - ]; + }); return new Table({ - // FEATURE-037 v3 — table width PHẢI match sum(columnWidths) trong - // fixed layout, nếu không strict renderer hiển thị sai. colWidths - // sum = 2300+750+880+940+750+1240 = 6860 (≈4.76 inch = 12.1 cm). - width: { size: 6860, type: WidthType.DXA }, + width: { size: 7860, type: WidthType.DXA }, layout: TableLayoutType.FIXED, columnWidths: colWidths, - rows: [headerRow, subHeaderRow, ...dataRows, ...summaryRows], + rows: [ + headerRow, + ...dataRows, + summaryRow('Tổng doanh thu bán vé (1)', Math.round(rec.net_revenue)), + summaryRow('Phí bán vé (Đã bao gồm VAT) (2)', feeInclVat), + summaryRow( + 'Tổng tiền sau phí dịch vụ của 5BIB (3) = (1) - (2)', + payoutAfterFee, + ), + ], }); } From 6273cffdd4e5262ec65e53c3d705313c73908121 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Thu, 9 Jul 2026 23:14:13 +0700 Subject: [PATCH 04/23] =?UTF-8?q?feat(recon):=20Pha=202-A=20=E2=80=94=20ex?= =?UTF-8?q?port=20Danh=20s=C3=A1ch=20V=C4=90V=20(43=20c=E1=BB=99t=20kh?= =?UTF-8?q?=E1=BB=9Bp=20m=E1=BA=ABu=20T6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - athlete-list.service: mapper 43 cột + xlsx (sheet "Sheet1") khớp mẫu; parse customize_fields JSON cho sub-time/thành tích/giám hộ; dictionary VN cho giới tính/trạng thái vé/VĐV (không render raw enum). - reconciliation-query: queryAthletesForPeriod (athletes⋈codes⋈order⋈subinfo ⋈ticket_type⋈race_course), lọc COMPLETE/paid trong kỳ (cùng periodRangeUtc như queryOrders), DATE_FORMAT ngày trong SQL tránh lệch TZ. - endpoint GET /reconciliations/:id/download/athletes (admin-guard, PII không log) + register AthleteListService trong module. - Verify: query race 194 kỳ T6 → row VĐV khớp CHÍNH XÁC file mẫu (mã đơn/mã vé/DOB/giới tính/nhóm máu/trạng thái...). +5 unit test mapper. Co-Authored-By: Claude Opus 4.8 --- .../reconciliation.controller.ts | 24 +++ .../reconciliation/reconciliation.module.ts | 2 + .../services/athlete-list.service.spec.ts | 149 ++++++++++++++ .../services/athlete-list.service.ts | 186 ++++++++++++++++++ .../services/reconciliation-query.service.ts | 68 +++++++ 5 files changed, 429 insertions(+) create mode 100644 backend/src/modules/reconciliation/services/athlete-list.service.spec.ts create mode 100644 backend/src/modules/reconciliation/services/athlete-list.service.ts diff --git a/backend/src/modules/reconciliation/reconciliation.controller.ts b/backend/src/modules/reconciliation/reconciliation.controller.ts index 72730d29..c8db4d0c 100644 --- a/backend/src/modules/reconciliation/reconciliation.controller.ts +++ b/backend/src/modules/reconciliation/reconciliation.controller.ts @@ -22,6 +22,7 @@ import { ReconciliationPreflightService } from './services/reconciliation-prefli import { XlsxService } from './services/xlsx.service'; import { DocxService } from './services/docx.service'; import { BatchExportService } from './export/batch-export.service'; +import { AthleteListService } from './services/athlete-list.service'; import { PreviewReconciliationDto } from './dto/preview-reconciliation.dto'; import { CreateReconciliationDto } from './dto/create-reconciliation.dto'; import { UpdateReconciliationStatusDto } from './dto/update-reconciliation-status.dto'; @@ -61,6 +62,7 @@ export class ReconciliationController { private readonly xlsxService: XlsxService, private readonly docxService: DocxService, private readonly batchExportService: BatchExportService, + private readonly athleteListService: AthleteListService, ) {} @Get('preflight') @@ -221,6 +223,28 @@ export class ReconciliationController { res.send(buf); } + @Get(':id/download/athletes') + @ApiOperation({ + summary: 'Download Danh sách VĐV (43 cột) cho kỳ đối soát của biên bản', + }) + @ApiResponse({ status: 200, description: 'XLSX athlete-list stream' }) + async downloadAthletes(@Param('id') id: string, @Res() res: Response) { + const doc = await this.reconciliationService.findOne(id); + const rows = await this.queryService.queryAthletesForPeriod( + doc.mysql_race_id, + doc.period_start, + doc.period_end, + ); + const buf = await this.athleteListService.generate(rows); + const filename = `Danh sách VĐV - ${buildRecFilename(doc, 'xlsx')}`; + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }); + res.send(buf); + } + // ── Export ZIP endpoints ────────────────────────────────────────────── @Post('export/zip/by-ids') diff --git a/backend/src/modules/reconciliation/reconciliation.module.ts b/backend/src/modules/reconciliation/reconciliation.module.ts index de316001..bd6cb52a 100644 --- a/backend/src/modules/reconciliation/reconciliation.module.ts +++ b/backend/src/modules/reconciliation/reconciliation.module.ts @@ -22,6 +22,7 @@ import { ReconciliationCalcService } from './services/reconciliation-calc.servic import { ReconciliationPreflightService } from './services/reconciliation-preflight.service'; import { XlsxService } from './services/xlsx.service'; import { DocxService } from './services/docx.service'; +import { AthleteListService } from './services/athlete-list.service'; import { ReconciliationCron } from './services/reconciliation.cron'; import { BatchExportService } from './export/batch-export.service'; import { TongHopService } from './export/tong-hop.service'; @@ -44,6 +45,7 @@ import { TongHopService } from './export/tong-hop.service'; ReconciliationPreflightService, XlsxService, DocxService, + AthleteListService, ReconciliationCron, BatchExportService, TongHopService, diff --git a/backend/src/modules/reconciliation/services/athlete-list.service.spec.ts b/backend/src/modules/reconciliation/services/athlete-list.service.spec.ts new file mode 100644 index 00000000..218017dd --- /dev/null +++ b/backend/src/modules/reconciliation/services/athlete-list.service.spec.ts @@ -0,0 +1,149 @@ +/** + * AthleteListService — Pha 2-A: mapper 43 cột khớp mẫu "Danh sách VDV - T6". + * Fixture = 2 VĐV thật (race 194, athletes_id 111588/111589) đã sample từ + * platform DB → assert output khớp giá trị trong file mẫu của Danny. + */ +import { + mapAthleteRow, + parseCustomFields, + AthleteListService, +} from './athlete-list.service'; +import * as ExcelJS from 'exceljs'; + +// customize_fields thật của athlete 111589 (rút gọn) — có "Sub time" đã chọn. +const CF_111589 = { + fields: [ + { field_label: 'Sub time dự kiến đạt được', selected_value: 'Sau 2:15' }, + { field_label: 'Thành tích gần nhất đạt được theo (giờ)', selected_value: null }, + { field_label: 'Thành tích gần nhất đạt được theo (Phút)', selected_value: null }, + { field_label: 'Minh chứng thành tích gần nhất đạt được ', selected_value: null }, + ], + exception: null, +}; + +const ROW_111588: Record = { + athletes_id: 111588, + order_code: '#5B200029811IB', + code_value: 'STCM2610K-3067-GECTLX6K', + created_fmt: '30/06/2026', + type_name: 'EARLY BIRD', + course_name: '10KM', + a_name: 'HÀ NGỌC CHÂU CHÂU ', + s_fn: 'HÀ NGỌC CHÂU ', + s_ln: 'CHÂU ', + id_number: '014176003045', + a_email: 'vutamtcsl@gmail.com', + bib_number: null, + dob_fmt: '19/10/1976', + s_gender: 'FEMALE', + contact_phone: '0353199889', + address: 'Phường Tô Hiệu ,Tỉnh Sơn La ', + living_place: null, + city_province: 'Tỉnh Sơn La', + s_nat: 'Viet Nam', + a_nat: null, + sos_phone: '0968662787', + health_status: null, + current_medication: 'Không có ', + blood_type: 'B+', + name_on_bib: 'Hà Ngọc Châu ', + club: 'R26-Sơn Là runner', + racekit: 'L', + tshirt_size: null, + delegator_name: null, + code_status: 'ACTIVE', + last_status: 'ACTIVE', + external_order_ref: null, + note: null, + customize_fields: { fields: null, exception: null }, +}; + +const ROW_111589: Record = { + athletes_id: 111589, + order_code: '#5B200029812IB', + code_value: 'STCM2621K-3072-HXZI9ZMV', + created_fmt: '30/06/2026', + type_name: 'EARLY BIRD', + course_name: '21KM', + a_name: 'Hoàng Thị Lan Hương', + s_fn: 'Hoàng Thị', + s_ln: 'Lan Hương', + s_gender: 'FEMALE', + blood_type: 'O+', + name_on_bib: 'AN QUYÊN', + racekit: 'XL', + code_status: 'ACTIVE', + last_status: 'ACTIVE', + customize_fields: CF_111589, +}; + +// Index cột (1-based) theo thứ tự HEADERS. +const C = { + STT: 1, ID: 2, MA_DON: 3, MA_VE: 4, TG_DANGKY: 5, GIAI_DOAN: 6, CUNG_DUONG: 7, + HOTEN: 8, HO: 9, TEN: 10, CCCD: 11, EMAIL: 12, DOB: 14, GIOITINH: 15, + SDT_KHANCAP: 21, THUOC: 23, NHOMMAU: 24, TENBIB: 25, CLB: 26, SIZE: 27, + TT_VE: 36, TT_VDV: 37, SUBTIME: 40, +}; + +describe('AthleteListService — mapAthleteRow khớp mẫu T6', () => { + it('athlete 111588 → 43 cột đúng giá trị mẫu', () => { + const r = mapAthleteRow(ROW_111588, 1); + expect(r).toHaveLength(43); + expect(r[C.STT - 1]).toBe(1); + expect(r[C.ID - 1]).toBe(111588); + expect(r[C.MA_DON - 1]).toBe('#5B200029811IB'); + expect(r[C.MA_VE - 1]).toBe('STCM2610K-3067-GECTLX6K'); + expect(r[C.TG_DANGKY - 1]).toBe('30/06/2026'); + expect(r[C.GIAI_DOAN - 1]).toBe('EARLY BIRD'); + expect(r[C.CUNG_DUONG - 1]).toBe('10KM'); + expect(r[C.HO - 1]).toBe('HÀ NGỌC CHÂU'); // trim + expect(r[C.TEN - 1]).toBe('CHÂU'); + expect(r[C.CCCD - 1]).toBe('014176003045'); + expect(r[C.DOB - 1]).toBe('19/10/1976'); + expect(r[C.GIOITINH - 1]).toBe('Nữ'); // FEMALE → Nữ + expect(r[C.SDT_KHANCAP - 1]).toBe('0968662787'); + expect(r[C.THUOC - 1]).toBe('Không có'); + expect(r[C.NHOMMAU - 1]).toBe('B+'); + expect(r[C.SIZE - 1]).toBe('L'); + expect(r[C.TT_VE - 1]).toBe('Hoạt động'); // ACTIVE → Hoạt động + expect(r[C.TT_VDV - 1]).toBe('Đã ghi danh'); // ACTIVE → Đã ghi danh + expect(r[C.SUBTIME - 1]).toBe(''); // 111588 không có custom field + }); + + it('athlete 111589 → Sub time từ customize_fields', () => { + const r = mapAthleteRow(ROW_111589, 2); + expect(r[C.ID - 1]).toBe(111589); + expect(r[C.TENBIB - 1]).toBe('AN QUYÊN'); + expect(r[C.NHOMMAU - 1]).toBe('O+'); + expect(r[C.SUBTIME - 1]).toBe('Sau 2:15'); // từ customize_fields + }); + + it('parseCustomFields: map field_label(trim) → selected_value', () => { + const m = parseCustomFields(CF_111589); + expect(m['Sub time dự kiến đạt được']).toBe('Sau 2:15'); + // trailing space trong label được trim + expect(m['Minh chứng thành tích gần nhất đạt được']).toBe(''); + }); + + it('parseCustomFields: JSON string cũng parse được; input xấu → {}', () => { + expect(parseCustomFields(JSON.stringify(CF_111589))['Sub time dự kiến đạt được']).toBe('Sau 2:15'); + expect(parseCustomFields('not-json')).toEqual({}); + expect(parseCustomFields({ fields: null })).toEqual({}); + expect(parseCustomFields(null)).toEqual({}); + }); + + it('generate() → workbook 1 sheet "Sheet1", header 43 cột, 1 data row', async () => { + const buf = await new AthleteListService().generate([ROW_111588]); + const wb = new ExcelJS.Workbook(); + await wb.xlsx.load(buf); + expect(wb.worksheets).toHaveLength(1); + const ws = wb.worksheets[0]; + expect(ws.name).toBe('Sheet1'); + const headers = (ws.getRow(1).values as any[]).slice(1); + expect(headers).toHaveLength(43); + expect(headers[0]).toBe('STT'); + expect(headers[10]).toBe('CCCD/ Hộ chiếu'); // giữ đúng khoảng trắng mẫu + expect(ws.getCell('B2').value).toBe(111588); + expect(ws.getCell('C2').value).toBe('#5B200029811IB'); + }); +}); diff --git a/backend/src/modules/reconciliation/services/athlete-list.service.ts b/backend/src/modules/reconciliation/services/athlete-list.service.ts new file mode 100644 index 00000000..01cf7fb3 --- /dev/null +++ b/backend/src/modules/reconciliation/services/athlete-list.service.ts @@ -0,0 +1,186 @@ +import { Injectable } from '@nestjs/common'; +import * as ExcelJS from 'exceljs'; + +/** + * Danh sách VĐV export (Pha 2-A) — 43 cột khớp mẫu "Danh sách VDV - T6.xlsx". + * + * Nguồn: athletes ⋈ athlete_subinfo ⋈ codes ⋈ order_metadata (chỉ đơn + * COMPLETE/paid trong kỳ). PII (CCCD/DOB/SĐT/email/y tế) — endpoint PHẢI + * admin-guard, KHÔNG log giá trị PII (chỉ log count). + * + * ~4 cột cuối + tên liên hệ khẩn cấp + vài field giám hộ nằm trong + * `athlete_subinfo.customize_fields` JSON ({fields:[{field_label, + * selected_value}]}), map theo field_label. Trống khi race không thu field đó. + */ + +// Header khớp CHÍNH XÁC mẫu (kể cả khoảng trắng 'CCCD/ Hộ chiếu' + trailing +// space cột cuối) để file drop-in thay bản làm tay. +const HEADERS: string[] = [ + 'STT', + 'ID', + 'Mã đơn hàng', + 'Mã vé', + 'Thời gian đăng ký', + 'Tên giai đoạn', + 'Cung đường', + 'Họ và tên', + 'Họ', + 'Tên', + 'CCCD/ Hộ chiếu', + 'Email', + 'Số BIB', + 'Ngày sinh', + 'Giới tính', + 'Số điện thoại', + 'Địa chỉ', + 'Thành phố', + 'Quốc tịch', + 'Tên người liên hệ khẩn cấp', + 'Số điện thoại khẩn cấp', + 'Trạng thái sức khỏe', + 'Loại thuốc đang dùng', + 'Nhóm máu', + 'Tên trên BIB', + 'Câu lạc bộ', + 'Size áo', + 'Tên người giám hộ (nếu có)', + 'Email người giám hộ (nếu có)', + 'CCCD người giám hộ (nếu có)', + 'Ngày sinh người giám hộ (nếu có)', + 'SĐT người giám hộ (nếu có)', + 'Mối quan hệ với người giám hộ (nếu có)', + 'Tên trên BIB người giám hộ (nếu có)', + 'Rackit size người giám hộ (nếu có)', + 'Trạng thái vé', + 'Trạng thái VĐV', + 'Mã External Order', + 'Ghi chú', + 'Sub time dự kiến đạt được', + 'Thành tích gần nhất đạt được theo (giờ)', + 'Thành tích gần nhất đạt được theo (Phút)', + 'Minh chứng thành tích gần nhất đạt được ', +]; + +// Display dictionary (KHÔNG render raw enum cho user — CLAUDE.md convention). +const GENDER_VN: Record = { FEMALE: 'Nữ', MALE: 'Nam' }; +const TICKET_STATUS_VN: Record = { + ACTIVE: 'Hoạt động', + SENT: 'Đã gửi', + INACTIVE: 'Ngừng hoạt động', +}; +const ATHLETE_STATUS_VN: Record = { + ACTIVE: 'Đã ghi danh', + DELETED: 'Đã xoá', +}; + +function s(v: unknown): string { + return v == null ? '' : String(v).trim(); +} + +/** Parse athlete_subinfo.customize_fields → map field_label(trimmed) → selected_value. */ +export function parseCustomFields(cf: unknown): Record { + const out: Record = {}; + let obj: any = cf; + if (typeof cf === 'string') { + try { + obj = JSON.parse(cf); + } catch { + return out; + } + } + const fields = obj?.fields; + if (Array.isArray(fields)) { + for (const f of fields) { + const key = s(f?.field_label || f?.field_name); + if (key) out[key] = s(f?.selected_value); + } + } + return out; +} + +export function mapAthleteRow( + r: Record, + stt: number, +): (string | number)[] { + const cf = parseCustomFields(r.customize_fields); + const custom = (label: string) => cf[label.trim()] ?? ''; + return [ + stt, + r.athletes_id ?? '', + s(r.order_code), + s(r.code_value), + s(r.created_fmt), + s(r.type_name), + s(r.course_name), + s(r.a_name), + s(r.s_fn), + s(r.s_ln), + s(r.id_number), + s(r.a_email), + s(r.bib_number), + s(r.dob_fmt), + GENDER_VN[s(r.s_gender)] ?? '', + s(r.contact_phone), + s(r.address) || s(r.living_place), + s(r.city_province), + s(r.s_nat) || s(r.a_nat), + custom('Tên người liên hệ khẩn cấp'), + s(r.sos_phone), + s(r.health_status), + s(r.current_medication), + s(r.blood_type), + s(r.name_on_bib), + s(r.club), + s(r.racekit) || s(r.tshirt_size), + s(r.delegator_name), + s(r.delegator_email), + s(r.delegator_cccd), + custom('Ngày sinh người giám hộ'), + s(r.delegator_phone), + custom('Mối quan hệ với người giám hộ'), + custom('Tên trên BIB người giám hộ'), + custom('Rackit size người giám hộ'), + TICKET_STATUS_VN[s(r.code_status)] ?? s(r.code_status), + ATHLETE_STATUS_VN[s(r.last_status)] ?? s(r.last_status), + s(r.external_order_ref), + s(r.note), + custom('Sub time dự kiến đạt được'), + custom('Thành tích gần nhất đạt được theo (giờ)'), + custom('Thành tích gần nhất đạt được theo (Phút)'), + custom('Minh chứng thành tích gần nhất đạt được'), + ]; +} + +@Injectable() +export class AthleteListService { + async generate(rows: Array>): Promise { + const wb = new ExcelJS.Workbook(); + wb.creator = '5BIB'; + const ws = wb.addWorksheet('Sheet1'); + + const header = ws.addRow(HEADERS); + header.font = { bold: true }; + header.eachCell((c) => { + c.alignment = { vertical: 'middle', wrapText: true }; + }); + ws.views = [{ state: 'frozen', ySplit: 1 }]; + ws.autoFilter = { + from: 'A1', + to: { row: 1, column: HEADERS.length }, + }; + + rows.forEach((r, i) => ws.addRow(mapAthleteRow(r, i + 1))); + + // Column widths — cân đối đọc được (ID/STT hẹp, tên/địa chỉ rộng). + ws.columns.forEach((col, i) => { + col.width = i === 0 ? 6 : i === 16 ? 32 : 18; + }); + + const buf = await wb.xlsx.writeBuffer(); + return Buffer.from(buf); + } + + get headers(): string[] { + return HEADERS; + } +} diff --git a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts index 0511d32e..c7c7c506 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts @@ -144,6 +144,74 @@ export class ReconciliationQueryService { return this.categorize(rows, mysql_race_id, period_start, period_end); } + /** + * Pha 2-A — Danh sách VĐV: mọi athlete có vé COMPLETE/paid trong kỳ của race. + * Join qua `codes` (a.code_id → codes.order_id) để lấy order + ticket_type + + * course; cùng bộ lọc kỳ (`periodRangeUtc`) như queryOrders để danh sách VĐV + * khớp đúng đơn trong bảng đối soát. Ngày format ngay trong SQL (DATE_FORMAT) + * tránh lệch timezone. Trả raw rows — mapping 43 cột ở AthleteListService. + * + * PII: chỉ dùng bởi endpoint admin-guard; KHÔNG log giá trị. + */ + async queryAthletesForPeriod( + mysql_race_id: number, + period_start: string, + period_end: string, + ): Promise[]> { + const sql = ` + SELECT + a.athletes_id AS athletes_id, + o.name AS order_code, + c.value AS code_value, + DATE_FORMAT(a.created_on, '%d/%m/%Y') AS created_fmt, + tt.type_name AS type_name, + rc.name AS course_name, + a.name AS a_name, + s.first_name AS s_fn, s.last_name AS s_ln, + s.id_number AS id_number, + a.email AS a_email, + a.bib_number AS bib_number, + DATE_FORMAT(a.dob, '%d/%m/%Y') AS dob_fmt, + s.gender AS s_gender, + s.contact_phone AS contact_phone, + s.address AS address, s.living_place AS living_place, + s.city_province AS city_province, + s.nationality AS s_nat, a.nationality AS a_nat, + s.sos_phone AS sos_phone, + s.health_status AS health_status, + s.current_medication AS current_medication, + s.blood_type AS blood_type, + s.name_on_bib AS name_on_bib, + s.club AS club, + s.racekit AS racekit, s.tshirt_size AS tshirt_size, + s.delegator_name AS delegator_name, + s.delegator_email AS delegator_email, + s.delegator_cccd AS delegator_cccd, + s.delegator_phone AS delegator_phone, + c.status AS code_status, + a.last_status AS last_status, + c.external_order_ref AS external_order_ref, + s.note AS note, + s.customize_fields AS customize_fields + FROM athletes a + JOIN codes c ON c.id = a.code_id + JOIN order_metadata o ON o.id = c.order_id + LEFT JOIN athlete_subinfo s ON s.id = a.subinfo_id + LEFT JOIN ticket_type tt ON tt.id = c.ticket_type_id + LEFT JOIN race_course rc ON rc.id = c.course_id + WHERE a.race_id = ? + AND a.deleted = 0 + AND o.internal_status = 'COMPLETE' + AND o.financial_status = 'paid' + AND o.deleted = 0 + AND o.processed_on >= ? + AND o.processed_on <= ? + ORDER BY a.athletes_id ASC + `; + const { fromUtc, toUtc } = periodRangeUtc(period_start, period_end); + return this.tenantRepo.manager.query(sql, [mysql_race_id, fromUtc, toUtc]); + } + /** * FEATURE-016 BR-01..BR-04 — categorize order rows into 5BIB / manual / unknown. * From 0c5303d0ca296a09d62b05d1c609cc36d199cbd2 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Thu, 9 Jul 2026 23:17:33 +0700 Subject: [PATCH 05/23] =?UTF-8?q?feat(recon-admin):=20n=C3=BAt=20t?= =?UTF-8?q?=E1=BA=A3i=20"Danh=20s=C3=A1ch=20V=C4=90V"=20=E1=BB=9F=20trang?= =?UTF-8?q?=20chi=20ti=E1=BA=BFt=20=C4=91=E1=BB=91i=20so=C3=A1t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gọi GET /api/reconciliations/:id/download/athletes (cùng downloadWithAuth như XLSX/DOCX). next build xanh. Co-Authored-By: Claude Opus 4.8 --- .../src/app/(dashboard)/reconciliations/[id]/page.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx b/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx index 2568ad16..6e581e36 100644 --- a/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx @@ -557,6 +557,16 @@ export default function ReconciliationDetailPage() { DOCX + {/* Transition */} From 0475ead86ce41421aeb0ad5b8fa71bffe57ebf7d Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Thu, 9 Jul 2026 23:33:50 +0700 Subject: [PATCH 06/23] =?UTF-8?q?fix(recon):=20s=E1=BB=ADa=20bi=C3=AAn=20k?= =?UTF-8?q?=E1=BB=B3=20ICT=20=E2=80=94=20processed=5Fon=20l=C6=B0u=20ICT?= =?UTF-8?q?=20wall-clock,=20b=E1=BB=8F=20tr=E1=BB=AB=207h=20(F-082)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify DB thật (race 194): order_metadata.processed_on lưu GIỜ VN wall-clock (raw '2026-06-30 19:05:35'), KHÔNG phải UTC. F-082 endOfPeriodMs trừ 7h coi là UTC → biên trên kỳ = 16:59:59 → RỚT đơn đặt 17:00-23:59 tối ngày cuối kỳ sang kỳ SAU (đơn 850k @19:05 mất khỏi T6 → chỉ tính 1/2 VĐV). Sửa: endOfPeriodMs dùng calendar ICT thuần '23:59:59', so trực tiếp với cột. - Kỳ cũ (< T6/2026) vốn đã calendar thuần → số đã ký KHÔNG đổi; chỉ kỳ >= T6 được sửa đúng. Fix propagate qua periodRangeUtc → queryOrders + queryAthletesForPeriod + fee.service + preflight cùng lúc. - Verify e2e: race 194 kỳ T6 giờ trả 2 VĐV (khớp mẫu), trước fix chỉ 1. - Specs: ict-date.util + f082-boundary cập nhật + REGRESSION đơn 19:05 thuộc T6. 157 test pass. - DEFERRED Pha 2-B: ictDayRangeUtc (analytics daily) có CÙNG bug TZ. - CẦN audit recon đã sinh kỳ >= T6 trước khi re-issue biên bản đã ký (Danny chốt). Co-Authored-By: Claude Opus 4.8 --- .../src/common/utils/ict-date.util.spec.ts | 35 +++++++++---------- backend/src/common/utils/ict-date.util.ts | 18 ++++++---- .../__qc__/f082-period-boundary.spec.ts | 8 ++--- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/backend/src/common/utils/ict-date.util.spec.ts b/backend/src/common/utils/ict-date.util.spec.ts index 9622694f..8868fc9e 100644 --- a/backend/src/common/utils/ict-date.util.spec.ts +++ b/backend/src/common/utils/ict-date.util.spec.ts @@ -119,20 +119,17 @@ describe('periodRangeUtc (F-082 cutover)', () => { expect(r.toUtc).toBe('2026-05-31 23:59:59'); }); - it('kỳ CUTOVER T6/2026 — SEAM: from giữ continuity UTC, to theo ICT', () => { + it('kỳ T6/2026 — calendar ICT thuần (bug-fix: KHÔNG trừ 7h)', () => { const r = periodRangeUtc('2026-06-01', '2026-06-30'); - // from = end(T5 UTC) + 1s — KHÔNG phải ICT start 05-31 17:00 (tránh - // double-count đơn 17:00-23:59:59 UTC 31/5 đã thuộc kỳ T5 đã ký) expect(r.fromUtc).toBe('2026-06-01 00:00:00'); - // to = 30/6 23:59:59 ICT = 30/6 16:59:59 UTC - expect(r.toUtc).toBe('2026-06-30 16:59:59'); + // processed_on lưu ICT wall-clock → to = 30/6 23:59:59 (KHÔNG phải 16:59:59) + expect(r.toUtc).toBe('2026-06-30 23:59:59'); }); - it('kỳ T7/2026 — FULL ICT cả 2 đầu', () => { + it('kỳ T7/2026 — calendar ICT thuần cả 2 đầu', () => { const r = periodRangeUtc('2026-07-01', '2026-07-31'); - // from = end(T6 ICT) + 1s = 30/6 17:00:00 UTC = 1/7 00:00 ICT - expect(r.fromUtc).toBe('2026-06-30 17:00:00'); - expect(r.toUtc).toBe('2026-07-31 16:59:59'); + expect(r.fromUtc).toBe('2026-07-01 00:00:00'); + expect(r.toUtc).toBe('2026-07-31 23:59:59'); }); it('seam continuity: end(P) + 1s === start(P+1) cho chuỗi T4→T8', () => { @@ -162,25 +159,25 @@ describe('periodRangeUtc (F-082 cutover)', () => { expect(inT6).toBe(false); // KHÔNG double-count }); - it('đơn ICT sáng sớm 1/7 (30/6 17:00+ UTC) thuộc kỳ T7 (full ICT semantics)', () => { + it('REGRESSION bug-fix: đơn tối 30/6 19:05 ICT (như đơn 200029812) thuộc kỳ T6, KHÔNG rớt sang T7', () => { const t6 = periodRangeUtc('2026-06-01', '2026-06-30'); const t7 = periodRangeUtc('2026-07-01', '2026-07-31'); - const paid = '2026-06-30 21:00:00'; // = 1/7 04:00 ICT - expect(paid >= t6.fromUtc && paid <= t6.toUtc).toBe(false); - expect(paid >= t7.fromUtc && paid <= t7.toUtc).toBe(true); + const paid = '2026-06-30 19:05:35'; // processed_on ICT wall-clock thực tế + expect(paid >= t6.fromUtc && paid <= t6.toUtc).toBe(true); // ĐÚNG kỳ T6 + expect(paid >= t7.fromUtc && paid <= t7.toUtc).toBe(false); // KHÔNG sang T7 }); - it('multi-month range STRADDLE cutover T5→T7: from UTC, to ICT', () => { + it('multi-month range T5→T7: from theo tháng start, to theo tháng end', () => { const r = periodRangeUtc('2026-05-01', '2026-07-31'); - expect(r.fromUtc).toBe('2026-05-01 00:00:00'); // theo tháng start (T5 UTC) - expect(r.toUtc).toBe('2026-07-31 16:59:59'); // theo tháng end (T7 ICT) + expect(r.fromUtc).toBe('2026-05-01 00:00:00'); + expect(r.toUtc).toBe('2026-07-31 23:59:59'); }); - it('year boundary: T12/2026 → T1/2027 continuity (full ICT)', () => { + it('year boundary: T12/2026 → T1/2027 continuity (calendar ICT)', () => { const dec = periodRangeUtc('2026-12-01', '2026-12-31'); const jan = periodRangeUtc('2027-01-01', '2027-01-31'); - expect(dec.toUtc).toBe('2026-12-31 16:59:59'); - expect(jan.fromUtc).toBe('2026-12-31 17:00:00'); + expect(dec.toUtc).toBe('2026-12-31 23:59:59'); + expect(jan.fromUtc).toBe('2027-01-01 00:00:00'); }); it('year boundary kỳ cũ: T12/2025 → T1/2026 (full UTC legacy)', () => { diff --git a/backend/src/common/utils/ict-date.util.ts b/backend/src/common/utils/ict-date.util.ts index d4248181..18758134 100644 --- a/backend/src/common/utils/ict-date.util.ts +++ b/backend/src/common/utils/ict-date.util.ts @@ -106,16 +106,22 @@ function prevPeriod(period: string): string { return `${py}-${String(pm).padStart(2, '0')}`; } -/** Epoch ms của giây CUỐI kỳ `YYYY-MM` theo cutover rule. */ +/** + * Epoch ms của giây CUỐI kỳ `YYYY-MM` = chuỗi calendar '...23:59:59' thuần. + * + * BUG-FIX 2026-07-09 (verify DB thật): `order_metadata.processed_on` (và mọi + * datetime platform app ghi) lưu theo ICT WALL-CLOCK, KHÔNG phải UTC — raw + * '2026-06-30 19:05:35' là giờ VN thật. F-082 trước đây TRỪ 7h (coi là UTC) → + * biên trên = 16:59:59 → RỚT đơn đặt 17:00-23:59 tối ngày cuối kỳ sang kỳ SAU + * (verify: race 194 kỳ T6 mất đơn 850k @19:05). Kỳ = tháng lịch ICT nên bound + * là calendar ICT thuần, so trực tiếp với cột. Kỳ cũ trước cutover vốn đã dùng + * calendar thuần (UTC-calendar = ICT-calendar khi chỉ so chuỗi) → số đã ký + * KHÔNG đổi; chỉ kỳ >= T6/2026 được sửa lại đúng. + */ function endOfPeriodMs(period: string): number { const [y, m] = period.split('-').map(Number); const lastDay = new Date(Date.UTC(y, m, 0)).getUTCDate(); const lastDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; - if (period >= ICT_PERIOD_CUTOVER) { - // ICT end: 23:59:59 ICT = (23:59:59 UTC) - 7h - return Date.parse(lastDate + 'T23:59:59Z') - ICT_OFFSET_MS; - } - // Legacy UTC end (kỳ cũ giữ nguyên) return Date.parse(lastDate + 'T23:59:59Z'); } diff --git a/backend/src/modules/reconciliation/services/__qc__/f082-period-boundary.spec.ts b/backend/src/modules/reconciliation/services/__qc__/f082-period-boundary.spec.ts index cca3b6e4..ca734fa4 100644 --- a/backend/src/modules/reconciliation/services/__qc__/f082-period-boundary.spec.ts +++ b/backend/src/modules/reconciliation/services/__qc__/f082-period-boundary.spec.ts @@ -27,18 +27,18 @@ describe('F-082 QC — queryOrders period boundary params', () => { expect(params).toEqual([117, '2026-04-01 00:00:00', '2026-04-30 23:59:59']); }); - it('kỳ CUTOVER T6/2026 → from UTC seam-continuity, to ICT', async () => { + it('kỳ T6/2026 → calendar ICT thuần (bug-fix: to = 23:59:59, KHÔNG 16:59:59)', async () => { const { service, query } = makeService(); await service.queryOrders(220, '2026-06-01', '2026-06-30'); const params = query.mock.calls[0][1]; - expect(params).toEqual([220, '2026-06-01 00:00:00', '2026-06-30 16:59:59']); + expect(params).toEqual([220, '2026-06-01 00:00:00', '2026-06-30 23:59:59']); }); - it('kỳ T7/2026 → FULL ICT cả 2 đầu', async () => { + it('kỳ T7/2026 → calendar ICT thuần cả 2 đầu', async () => { const { service, query } = makeService(); await service.queryOrders(220, '2026-07-01', '2026-07-31'); const params = query.mock.calls[0][1]; - expect(params).toEqual([220, '2026-06-30 17:00:00', '2026-07-31 16:59:59']); + expect(params).toEqual([220, '2026-07-01 00:00:00', '2026-07-31 23:59:59']); }); it('preflight share path: re-create kỳ T5 SAU deploy vẫn ra UTC boundary (period-keyed, KHÔNG now-keyed)', async () => { From 49a4c049c00abc78d4a263931427cae12caf295b Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 17:56:25 +0700 Subject: [PATCH 07/23] =?UTF-8?q?feat(recon):=20Pha=203=20config=20email?= =?UTF-8?q?=20=C4=91=E1=BA=A7u=20m=E1=BB=91i=20+=20Pha=204=20g=E1=BB=ADi?= =?UTF-8?q?=20email=20=C4=91=E1=BB=91i=20so=C3=A1t=20(backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pha 3 — MerchantConfig thêm reconciliation_emails (To) + reconciliation_cc_emails (CC); admin nhập ở tab "Công ty đối tác" (chuỗi email cách dấu phẩy → mảng). DTO @IsEmail each, service whitelist + mergeFormat echo. Pha 4 — gửi email đối soát cho BTC: - MailService.sendReconciliation (nhiều attachment + CC pattern Mandrill). - reconciliation-email.helper: subject "[5BIB] Đối soát bán vé sự kiện " + HTML body (Dear team + 3 bảng tóm tắt khớp mẫu email T6). - reconciliation.service.sendReconciliationEmail: gom 3 file (biên bản DOCX + bảng XLSX + DS VĐV XLSX) on-the-fly, resolve người nhận từ MerchantConfig; chỉ mark status='sent' + metadata KHI gửi thật OK. Batch review-then-send + markMilestone (email_confirmed/paper_sent/signed/invoice_issued). - Endpoints POST /:id/send-email, POST /send-email/batch, PATCH /:id/milestone. - Schema recon thêm email_sent_at/by/recipients + confirmed/paper/invoice_at. - +11 test (email helper 4 + send flow 3 + config). 141 recon test pass. Co-Authored-By: Claude Opus 4.8 --- .../app/(dashboard)/merchants/[id]/page.tsx | 35 +++- .../dto/update-merchant-company.dto.ts | 23 ++- .../src/modules/merchant/merchant.service.ts | 5 + .../schemas/merchant-config.schema.ts | 9 + .../src/modules/notification/mail.service.ts | 51 ++++++ .../reconciliation/dto/recon-email.dto.ts | 24 +++ .../reconciliation.controller.ts | 35 ++++ .../reconciliation.service.spec.ts | 80 +++++++++ .../reconciliation/reconciliation.service.ts | 161 +++++++++++++++++- .../schemas/reconciliation.schema.ts | 23 +++ .../reconciliation-email.helper.spec.ts | 63 +++++++ .../services/reconciliation-email.helper.ts | 106 ++++++++++++ 12 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 backend/src/modules/reconciliation/dto/recon-email.dto.ts create mode 100644 backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts create mode 100644 backend/src/modules/reconciliation/services/reconciliation-email.helper.ts diff --git a/admin/src/app/(dashboard)/merchants/[id]/page.tsx b/admin/src/app/(dashboard)/merchants/[id]/page.tsx index 83ce9864..7c175b7b 100644 --- a/admin/src/app/(dashboard)/merchants/[id]/page.tsx +++ b/admin/src/app/(dashboard)/merchants/[id]/page.tsx @@ -85,6 +85,8 @@ interface Merchant { bank_name: string | null; bank_branch: string | null; admin_note: string | null; + reconciliation_emails: string[] | null; + reconciliation_cc_emails: string[] | null; created_on: string; } @@ -134,6 +136,8 @@ export default function MerchantDetailPage() { bank_name: "", bank_branch: "", admin_note: "", + recon_to: "", + recon_cc: "", }); // Fee update modal @@ -232,6 +236,8 @@ export default function MerchantDetailPage() { bank_name: merchant.bank_name ?? "", bank_branch: merchant.bank_branch ?? "", admin_note: merchant.admin_note ?? "", + recon_to: (merchant.reconciliation_emails ?? []).join(", "), + recon_cc: (merchant.reconciliation_cc_emails ?? []).join(", "), }); setCompanyEditing(true); } @@ -240,10 +246,19 @@ export default function MerchantDetailPage() { if (!token) return; setCompanyLoading(true); try { + // recon_to/recon_cc là chuỗi email ngăn cách dấu phẩy → tách thành mảng. + const { recon_to, recon_cc, ...rest } = companyForm; + const parseEmails = (s: string) => + s.split(",").map((e) => e.trim()).filter(Boolean); + const payload = { + ...rest, + reconciliation_emails: parseEmails(recon_to), + reconciliation_cc_emails: parseEmails(recon_cc), + }; const res = await fetch(`/api/merchants/${id}/company`, { method: "PATCH", headers: { ...authHeaders(token).headers, "Content-Type": "application/json" }, - body: JSON.stringify(companyForm), + body: JSON.stringify(payload), }); if (!res.ok) throw new Error(); toast.success("Đã cập nhật thông tin công ty"); @@ -433,6 +448,15 @@ export default function MerchantDetailPage() { setCompanyForm(p => ({ ...p, admin_note: e.target.value }))} placeholder="Ghi chú nội bộ..." /> + +
+ + setCompanyForm(p => ({ ...p, recon_to: e.target.value }))} placeholder="Nhiều email cách nhau dấu phẩy — VD: daumoi@btc.vn, sales@btc.vn" /> +
+
+ + setCompanyForm(p => ({ ...p, recon_cc: e.target.value }))} placeholder="VD: ketoan@5bib.com" /> +
) : (
@@ -467,6 +491,15 @@ export default function MerchantDetailPage() { {merchant.admin_note}
)} +
+
Email đối soát
+
+ To + {(merchant.reconciliation_emails ?? []).join(", ") || "— (chưa cấu hình)"} + CC + {(merchant.reconciliation_cc_emails ?? []).join(", ") || "—"} +
+
)} diff --git a/backend/src/modules/merchant/dto/update-merchant-company.dto.ts b/backend/src/modules/merchant/dto/update-merchant-company.dto.ts index de139ec0..31b6c3a4 100644 --- a/backend/src/modules/merchant/dto/update-merchant-company.dto.ts +++ b/backend/src/modules/merchant/dto/update-merchant-company.dto.ts @@ -1,4 +1,10 @@ -import { IsOptional, IsString } from 'class-validator'; +import { + ArrayUnique, + IsArray, + IsEmail, + IsOptional, + IsString, +} from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; export class UpdateMerchantCompanyDto { @@ -46,4 +52,19 @@ export class UpdateMerchantCompanyDto { @IsOptional() @IsString() admin_note?: string; + + // Pha 3 — email đối soát (đầu mối BTC + CC kế toán). + @ApiPropertyOptional({ type: [String], example: ['daumoi@btc.vn'] }) + @IsOptional() + @IsArray() + @ArrayUnique() + @IsEmail({}, { each: true, message: 'Email đầu mối không hợp lệ' }) + reconciliation_emails?: string[]; + + @ApiPropertyOptional({ type: [String], example: ['ketoan@5bib.com'] }) + @IsOptional() + @IsArray() + @ArrayUnique() + @IsEmail({}, { each: true, message: 'Email CC không hợp lệ' }) + reconciliation_cc_emails?: string[]; } diff --git a/backend/src/modules/merchant/merchant.service.ts b/backend/src/modules/merchant/merchant.service.ts index f534e449..c998b90a 100644 --- a/backend/src/modules/merchant/merchant.service.ts +++ b/backend/src/modules/merchant/merchant.service.ts @@ -374,6 +374,8 @@ export class MerchantService { 'bank_name', 'bank_branch', 'admin_note', + 'reconciliation_emails', + 'reconciliation_cc_emails', ] as const; for (const field of fields) { @@ -442,6 +444,9 @@ export class MerchantService { bank_name: config?.bank_name ?? null, bank_branch: config?.bank_branch ?? null, admin_note: config?.admin_note ?? null, + // Pha 3 — email đối soát (đầu mối BTC + CC kế toán) + reconciliation_emails: config?.reconciliation_emails ?? [], + reconciliation_cc_emails: config?.reconciliation_cc_emails ?? [], // Timestamps created_on: t.created_on, }; diff --git a/backend/src/modules/merchant/schemas/merchant-config.schema.ts b/backend/src/modules/merchant/schemas/merchant-config.schema.ts index 833e191f..8037088d 100644 --- a/backend/src/modules/merchant/schemas/merchant-config.schema.ts +++ b/backend/src/modules/merchant/schemas/merchant-config.schema.ts @@ -147,6 +147,15 @@ export class MerchantConfig { @Prop({ type: String, default: null }) admin_note: string | null; + // ── Email đối soát (Pha 3) — đầu mối BTC cung cấp khi ký HĐ ────────────── + /** Email đầu mối BTC nhận biên bản đối soát (To). */ + @Prop({ type: [String], default: [] }) + reconciliation_emails: string[]; + + /** Email CC (kế toán 5BIB / nội bộ) khi gửi đối soát. */ + @Prop({ type: [String], default: [] }) + reconciliation_cc_emails: string[]; + // ── F-043: Event-level fee overrides ──────────────────── /** * Array of fee overrides per race event. Empty default — existing 58 diff --git a/backend/src/modules/notification/mail.service.ts b/backend/src/modules/notification/mail.service.ts index e215ae2c..ee1472c0 100644 --- a/backend/src/modules/notification/mail.service.ts +++ b/backend/src/modules/notification/mail.service.ts @@ -512,6 +512,57 @@ export class MailService { } } + /** + * Pha 4 — gửi email đối soát cho BTC kèm nhiều file đính kèm (biên bản DOCX, + * bảng đối soát XLSX, danh sách VĐV XLSX). CC kế toán. Trả `false` khi + * Mailchimp chưa cấu hình (dev) hoặc lỗi (đã log, KHÔNG throw — batch gửi + * tiếp merchant khác). CC theo pattern Mandrill: thêm entry type:'cc' vào `to`. + */ + async sendReconciliation(args: { + to: string[]; + cc?: string[]; + subject: string; + html: string; + attachments: Array<{ filename: string; type: string; content: Buffer }>; + fromName?: string; + }): Promise { + if (!this.client) { + this.logger.warn( + `[DEV] sendReconciliation to=${args.to.join(',')} cc=${(args.cc ?? []).join(',')} subject="${args.subject}" attachments=${args.attachments.length} (mailchimp not configured)`, + ); + return false; + } + try { + const recipients = [ + ...args.to.map((email) => ({ email, type: 'to' })), + ...(args.cc ?? []).map((email) => ({ email, type: 'cc' })), + ]; + await this.client.messages.send({ + message: { + from_email: env.teamManagement.emailFrom, + from_name: args.fromName || 'CÔNG TY CỔ PHẦN 5BIB', + subject: args.subject, + html: args.html, + to: recipients, + attachments: args.attachments.map((a) => ({ + type: a.type, + name: a.filename, + content: a.content.toString('base64'), + })), + }, + }); + this.logger.log( + `Reconciliation email sent to=[${args.to.join(',')}] cc=[${(args.cc ?? []).join(',')}] files=${args.attachments.length}`, + ); + return true; + } catch (error) { + this.logger.error( + `sendReconciliation failed to=${args.to.join(',')}: ${(error as Error).message}`, + ); + return false; + } + } + async sendTeamAcceptanceSent(data: TeamAcceptanceSentData): Promise { if (!this.client) { this.logger.warn( diff --git a/backend/src/modules/reconciliation/dto/recon-email.dto.ts b/backend/src/modules/reconciliation/dto/recon-email.dto.ts new file mode 100644 index 00000000..6d8657e2 --- /dev/null +++ b/backend/src/modules/reconciliation/dto/recon-email.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { ArrayNotEmpty, IsArray, IsIn, IsMongoId } from 'class-validator'; + +export class SendReconEmailBatchDto { + @ApiProperty({ type: [String], description: 'Danh sách reconciliation _id' }) + @IsArray() + @ArrayNotEmpty() + @IsMongoId({ each: true }) + ids: string[]; +} + +export const RECON_MILESTONES = [ + 'email_confirmed', + 'paper_sent', + 'signed', + 'invoice_issued', +] as const; +export type ReconMilestone = (typeof RECON_MILESTONES)[number]; + +export class MarkMilestoneDto { + @ApiProperty({ enum: RECON_MILESTONES }) + @IsIn(RECON_MILESTONES) + milestone: ReconMilestone; +} diff --git a/backend/src/modules/reconciliation/reconciliation.controller.ts b/backend/src/modules/reconciliation/reconciliation.controller.ts index c8db4d0c..50b0f3e9 100644 --- a/backend/src/modules/reconciliation/reconciliation.controller.ts +++ b/backend/src/modules/reconciliation/reconciliation.controller.ts @@ -35,6 +35,10 @@ import { DeleteBatchDto, DeleteBatchResponseDto, } from './dto/delete-batch.dto'; +import { + SendReconEmailBatchDto, + MarkMilestoneDto, +} from './dto/recon-email.dto'; function fmtDate(s: string): string { if (!s) return ''; @@ -245,6 +249,37 @@ export class ReconciliationController { res.send(buf); } + // ── Pha 4: Gửi email đối soát + vòng đời ────────────────────────────── + + @Post('send-email/batch') + @ApiOperation({ + summary: 'Gửi email đối soát hàng loạt cho BTC (batch review-then-send)', + }) + @ApiResponse({ status: 201 }) + sendEmailBatch(@Body() dto: SendReconEmailBatchDto, @Request() req: any) { + return this.reconciliationService.sendReconciliationEmailBatch( + dto.ids, + req.user?.id, + ); + } + + @Post(':id/send-email') + @ApiOperation({ summary: 'Gửi email đối soát cho BTC kèm 3 file đính kèm' }) + @ApiResponse({ status: 201 }) + sendEmail(@Param('id') id: string, @Request() req: any) { + return this.reconciliationService.sendReconciliationEmail(id, req.user?.id); + } + + @Patch(':id/milestone') + @ApiOperation({ + summary: + 'Đánh dấu mốc vòng đời (email_confirmed | paper_sent | signed | invoice_issued)', + }) + @ApiResponse({ status: 200 }) + markMilestone(@Param('id') id: string, @Body() dto: MarkMilestoneDto) { + return this.reconciliationService.markMilestone(id, dto.milestone); + } + // ── Export ZIP endpoints ────────────────────────────────────────────── @Post('export/zip/by-ids') diff --git a/backend/src/modules/reconciliation/reconciliation.service.spec.ts b/backend/src/modules/reconciliation/reconciliation.service.spec.ts index 3acbdadb..88839753 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.spec.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.spec.ts @@ -33,6 +33,8 @@ import { ReconciliationCalcService } from './services/reconciliation-calc.servic import { ReconciliationPreflightService } from './services/reconciliation-preflight.service'; import { XlsxService } from './services/xlsx.service'; import { DocxService } from './services/docx.service'; +import { AthleteListService } from './services/athlete-list.service'; +import { MailService } from '../notification/mail.service'; describe('ReconciliationService — auditPeriodBoundary (BR-10)', () => { let service: ReconciliationService; @@ -52,6 +54,8 @@ describe('ReconciliationService — auditPeriodBoundary (BR-10)', () => { { provide: ReconciliationPreflightService, useValue: {} }, { provide: XlsxService, useValue: {} }, { provide: DocxService, useValue: {} }, + { provide: AthleteListService, useValue: {} }, + { provide: MailService, useValue: {} }, { provide: getModelToken(Reconciliation.name), useValue: mockReconciliationModel }, { provide: getModelToken(MerchantConfig.name), useValue: {} }, { provide: getModelToken(ReconciliationCronLog.name), useValue: {} }, @@ -187,6 +191,8 @@ describe('ReconciliationService.deleteMany — FEATURE-025 bulk delete', () => { { provide: ReconciliationPreflightService, useValue: {} }, { provide: XlsxService, useValue: {} }, { provide: DocxService, useValue: {} }, + { provide: AthleteListService, useValue: {} }, + { provide: MailService, useValue: {} }, { provide: getModelToken(Reconciliation.name), useValue: mockReconciliationModel }, { provide: getModelToken(MerchantConfig.name), useValue: {} }, { provide: getModelToken(ReconciliationCronLog.name), useValue: {} }, @@ -309,3 +315,77 @@ describe('ReconciliationService.deleteMany — FEATURE-025 bulk delete', () => { expect(JSON.stringify(payload)).not.toContain('69fdbab606b3935acf24ccf6'); }); }); + +describe('ReconciliationService.sendReconciliationEmail (Pha 4)', () => { + function fakeDoc(over: any = {}) { + return { + _id: 'r1', tenant_id: 1, mysql_race_id: 194, tenant_name: 'CTY', race_title: 'Race', + period_start: '2026-06-01', period_end: '2026-06-30', status: 'ready', + net_revenue: 1500000, fee_amount: 105000, fee_vat_amount: 0, manual_fee_amount: 0, + manual_ticket_count: 0, manual_fee_per_ticket: 5000, payout_amount: 1395000, line_items: [], + save: jest.fn().mockResolvedValue(true), + ...over, + }; + } + + async function build(configEmails: any) { + const mail = { sendReconciliation: jest.fn().mockResolvedValue(true) }; + const configModel = { + findOne: jest.fn().mockReturnValue({ lean: () => Promise.resolve(configEmails) }), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReconciliationService, + { provide: ReconciliationQueryService, useValue: { getTenant: jest.fn().mockResolvedValue({ metadata: {} }), queryAthletesForPeriod: jest.fn().mockResolvedValue([]) } }, + { provide: ReconciliationCalcService, useValue: {} }, + { provide: ReconciliationPreflightService, useValue: {} }, + { provide: XlsxService, useValue: { generate: jest.fn().mockResolvedValue(Buffer.from('x')) } }, + { provide: DocxService, useValue: { generate: jest.fn().mockResolvedValue(Buffer.from('d')) } }, + { provide: AthleteListService, useValue: { generate: jest.fn().mockResolvedValue(Buffer.from('a')) } }, + { provide: MailService, useValue: mail }, + { provide: getModelToken(Reconciliation.name), useValue: {} }, + { provide: getModelToken(MerchantConfig.name), useValue: configModel }, + { provide: getModelToken(ReconciliationCronLog.name), useValue: {} }, + { provide: getRepositoryToken(Tenant, 'platform'), useValue: {} }, + ], + }).compile(); + const service = module.get(ReconciliationService); + jest.spyOn(service as any, 'flushPnLCacheForRecon').mockResolvedValue(undefined); + return { service, mail }; + } + + it('chặn khi merchant chưa cấu hình email đầu mối', async () => { + const { service, mail } = await build({ reconciliation_emails: [], reconciliation_cc_emails: [] }); + jest.spyOn(service, 'findOne').mockResolvedValue(fakeDoc() as any); + await expect(service.sendReconciliationEmail('r1', 'admin1')).rejects.toThrow('chưa cấu hình email đầu mối'); + expect(mail.sendReconciliation).not.toHaveBeenCalled(); + }); + + it('gửi thành công → 3 file đính kèm + status sent + metadata', async () => { + const { service, mail } = await build({ reconciliation_emails: ['daumoi@btc.vn'], reconciliation_cc_emails: ['ketoan@5bib.com'] }); + const doc = fakeDoc(); + jest.spyOn(service, 'findOne').mockResolvedValue(doc as any); + const r = await service.sendReconciliationEmail('r1', 'admin1'); + expect(r.sent).toBe(true); + const arg = mail.sendReconciliation.mock.calls[0][0]; + expect(arg.to).toEqual(['daumoi@btc.vn']); + expect(arg.cc).toEqual(['ketoan@5bib.com']); + expect(arg.attachments).toHaveLength(3); + expect(arg.subject).toContain('[5BIB] Đối soát'); + expect(doc.status).toBe('sent'); + expect(doc.email_sent_by).toBe('admin1'); + expect(doc.email_recipients).toEqual(['daumoi@btc.vn', 'ketoan@5bib.com']); + expect(doc.save).toHaveBeenCalled(); + }); + + it('Mailchimp chưa cấu hình (dev) → sent=false, KHÔNG đổi status', async () => { + const { service, mail } = await build({ reconciliation_emails: ['a@x.com'], reconciliation_cc_emails: [] }); + mail.sendReconciliation.mockResolvedValue(false); + const doc = fakeDoc(); + jest.spyOn(service, 'findOne').mockResolvedValue(doc as any); + const r = await service.sendReconciliationEmail('r1'); + expect(r.sent).toBe(false); + expect(doc.status).toBe('ready'); + expect(doc.save).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/modules/reconciliation/reconciliation.service.ts b/backend/src/modules/reconciliation/reconciliation.service.ts index cc55240e..9725b4fa 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.ts @@ -1,4 +1,10 @@ -import { Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + Optional, +} from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { InjectRepository } from '@nestjs/typeorm'; import { InjectRedis } from '@nestjs-modules/ioredis'; @@ -31,6 +37,12 @@ import { UpdateReconciliationStatusDto } from './dto/update-reconciliation-statu import { BatchCreateReconciliationDto } from './dto/batch-create-reconciliation.dto'; import { DeleteBatchResponseDto } from './dto/delete-batch.dto'; import { resolveFeeParams } from './utils/fee-params.util'; +import { MailService } from '../notification/mail.service'; +import { AthleteListService } from './services/athlete-list.service'; +import { + buildReconEmailHtml, + buildReconEmailSubject, +} from './services/reconciliation-email.helper'; const BUCKET_NAME = env.s3.bucket; const REGION = env.s3.region; @@ -46,6 +58,8 @@ export class ReconciliationService { private preflightService: ReconciliationPreflightService, private xlsxService: XlsxService, private docxService: DocxService, + private athleteListService: AthleteListService, + private mailService: MailService, @InjectModel(Reconciliation.name) private reconciliationModel: Model, @InjectModel(MerchantConfig.name) @@ -487,6 +501,151 @@ export class ReconciliationService { return doc; } + /** + * Pha 4 — gửi email đối soát cho BTC kèm 3 file (biên bản DOCX + bảng XLSX + + * DS VĐV XLSX). Người nhận = MerchantConfig.reconciliation_emails (To) + + * reconciliation_cc_emails (CC). Chặn nếu chưa cấu hình email đầu mối. Chỉ + * đánh dấu 'sent' + metadata KHI gửi thật thành công (Mailchimp cấu hình). + */ + async sendReconciliationEmail( + id: string, + sentBy?: string, + ): Promise<{ sent: boolean; reason?: string; to: string[]; cc: string[] }> { + const doc = await this.findOne(id); + const config = await this.configModel + .findOne({ tenantId: doc.tenant_id }) + .lean(); + const to = (config?.reconciliation_emails ?? []).filter(Boolean); + const cc = (config?.reconciliation_cc_emails ?? []).filter(Boolean); + if (to.length === 0) { + throw new BadRequestException( + 'Merchant chưa cấu hình email đầu mối đối soát — vào Merchant > Công ty đối tác để nhập trước khi gửi.', + ); + } + + // tenant metadata cho Bên A của DOCX (như downloadDocx) + const tenant = await this.queryService.getTenant(doc.tenant_id); + (doc as any).tenant_metadata = tenant?.metadata ?? {}; + + const [xlsxBuf, docxBuf, athleteRows] = await Promise.all([ + this.xlsxService.generate(doc), + this.docxService.generate(doc), + this.queryService.queryAthletesForPeriod( + doc.mysql_race_id, + doc.period_start, + doc.period_end, + ), + ]); + const athleteBuf = await this.athleteListService.generate(athleteRows); + + const XLSX = + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + const DOCX = + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + const fd = (s: string) => (s || '').split('-').reverse().join('-'); + const base = `${doc.tenant_name || doc.tenant_id} - ${doc.race_title} - ${fd(doc.period_start)} đến ${fd(doc.period_end)}`; + + const sent = await this.mailService.sendReconciliation({ + to, + cc, + subject: buildReconEmailSubject(doc), + html: buildReconEmailHtml(doc), + attachments: [ + { filename: `Biên bản đối soát - ${base}.docx`, type: DOCX, content: docxBuf }, + { filename: `Bảng đối soát - ${base}.xlsx`, type: XLSX, content: xlsxBuf }, + { filename: `Danh sách VĐV - ${base}.xlsx`, type: XLSX, content: athleteBuf }, + ], + }); + + if (sent) { + doc.status = 'sent'; + doc.email_sent_at = new Date(); + doc.email_sent_by = sentBy ?? null; + doc.email_recipients = [...to, ...cc]; + await doc.save(); + await this.flushPnLCacheForRecon(doc.tenant_id, doc.mysql_race_id); + return { sent: true, to, cc }; + } + return { + sent: false, + reason: + 'Mailchimp chưa cấu hình (MAILCHIMP_API_KEY) — không gửi email thật.', + to, + cc, + }; + } + + /** Pha 4 — gửi hàng loạt theo danh sách id (batch review-then-send). */ + async sendReconciliationEmailBatch( + ids: string[], + sentBy?: string, + ): Promise<{ + sent: number; + failed: number; + results: Array<{ + id: string; + sent: boolean; + reason?: string; + to: string[]; + }>; + }> { + const results: Array<{ + id: string; + sent: boolean; + reason?: string; + to: string[]; + }> = []; + let sent = 0; + let failed = 0; + for (const id of ids) { + try { + const r = await this.sendReconciliationEmail(id, sentBy); + results.push({ id, sent: r.sent, reason: r.reason, to: r.to }); + if (r.sent) sent++; + else failed++; + } catch (err) { + results.push({ + id, + sent: false, + reason: (err as Error).message, + to: [], + }); + failed++; + } + } + return { sent, failed, results }; + } + + /** + * Pha 4 — đánh dấu mốc vòng đời sau khi gửi email: BTC xác nhận email → gửi + * bản in → nhận bản ký/đóng dấu → kế toán xuất HĐ. + */ + async markMilestone( + id: string, + milestone: 'email_confirmed' | 'paper_sent' | 'signed' | 'invoice_issued', + ): Promise { + const doc = await this.findOne(id); + const now = new Date(); + switch (milestone) { + case 'email_confirmed': + doc.email_confirmed_at = now; + break; + case 'paper_sent': + doc.paper_sent_at = now; + break; + case 'signed': + doc.signed_at = now; + doc.status = 'signed'; + break; + case 'invoice_issued': + doc.invoice_issued_at = now; + doc.status = 'completed'; + break; + } + await doc.save(); + return doc; + } + async batchCreate(dto: BatchCreateReconciliationDto): Promise<{ created: number; skipped: number; diff --git a/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts b/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts index f59e2c75..a79f72cf 100644 --- a/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts +++ b/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts @@ -173,6 +173,29 @@ export class Reconciliation { @Prop({ type: String, default: null }) signed_date_str: string | null; + // ── Pha 4 — vòng đời gửi email đối soát ──────────────────────────────── + @Prop({ type: Date, default: null }) + email_sent_at: Date | null; + + @Prop({ type: String, default: null }) + email_sent_by: string | null; + + /** Người nhận thực tế đã gửi (To + CC) — audit. */ + @Prop({ type: [String], default: [] }) + email_recipients: string[]; + + /** BTC xác nhận email (admin đánh dấu thủ công). */ + @Prop({ type: Date, default: null }) + email_confirmed_at: Date | null; + + /** Đã gửi bản in giấy có ký/đóng dấu cho khách. */ + @Prop({ type: Date, default: null }) + paper_sent_at: Date | null; + + /** Kế toán đã xuất hóa đơn. */ + @Prop({ type: Date, default: null }) + invoice_issued_at: Date | null; + // Embedded line items (5BIB orders grouped by ticket_type + distance) @Prop({ type: [LineItemSchema], default: [] }) line_items: LineItem[]; diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts new file mode 100644 index 00000000..761b00e4 --- /dev/null +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts @@ -0,0 +1,63 @@ +import { + buildReconEmailSubject, + buildReconEmailHtml, +} from './reconciliation-email.helper'; + +const rec: any = { + race_title: 'Standard Chartered Hanoi Marathon Heritage Race', + period_start: '2026-06-01', + period_end: '2026-06-30', + signed_date_str: '2026-07-06', + net_revenue: 1500000, + fee_rate_applied: 7, + fee_amount: 105000, + fee_vat_amount: 0, + manual_ticket_count: 0, + manual_fee_per_ticket: 5000, + manual_fee_amount: 0, + payout_amount: 1395000, + line_items: [ + { order_category: 'ORDINARY', ticket_type_name: 'EARLY BIRD', distance_name: '10KM', unit_price: 650000, quantity: 1, discount_amount: 0, subtotal: 650000, add_on_price: 0 }, + { order_category: 'ORDINARY', ticket_type_name: 'EARLY BIRD', distance_name: '21KM', unit_price: 850000, quantity: 1, discount_amount: 0, subtotal: 850000, add_on_price: 0 }, + ], +}; + +describe('reconciliation-email.helper', () => { + it('subject: [5BIB] + tên giải + kỳ', () => { + const s = buildReconEmailSubject(rec); + expect(s).toContain('[5BIB] Đối soát bán vé sự kiện'); + expect(s).toContain('Standard Chartered Hanoi Marathon Heritage Race'); + }); + + it('html: Dear team + header + số liệu + payout', () => { + const h = buildReconEmailHtml(rec); + expect(h).toContain('Dear team'); + expect(h).toContain('SỰ KIỆN'); + expect(h).toContain('Thời gian đối soát từ 01/06/2026 -> hết ngày 30/06/2026'); + expect(h).toContain('Ngày đối soát: 06/07/2026'); + expect(h).toContain('1.500.000'); // GMV + expect(h).toContain('105.000'); // phí + expect(h).toContain('1.395.000'); // hoàn trả + expect(h).toContain('7%'); + expect(h).toContain('10KM'); + expect(h).toContain('21KM'); + expect(h).toContain('Hoàn trả merchant'); + }); + + it('html: escape ký tự đặc biệt trong tên giải (chống HTML injection)', () => { + const h = buildReconEmailHtml({ ...rec, race_title: 'A & B ' }); + expect(h).toContain('A & B <x>'); + expect(h).not.toContain(''); + }); + + it('html: kỳ có giảm giá → Thành tiền = net (khớp tie-out)', () => { + const h = buildReconEmailHtml({ + ...rec, + net_revenue: 1250000, + line_items: [ + { order_category: 'ORDINARY', ticket_type_name: 'EB', distance_name: '10KM', unit_price: 500000, quantity: 1, discount_amount: 100000, subtotal: 500000, add_on_price: 0 }, + ], + }); + expect(h).toContain('400.000'); // 500000 - 100000 net + }); +}); diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts new file mode 100644 index 00000000..ef2c0f13 --- /dev/null +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts @@ -0,0 +1,106 @@ +import { ReconciliationDocument } from '../schemas/reconciliation.schema'; +import { renderPeriodLabel } from './period-label.helper'; + +/** + * Pha 4 — nội dung email đối soát gửi BTC (khớp mẫu email T6: "Dear team" + + * SỰ KIỆN/Ngày/Thời gian + 3 bảng tóm tắt). Số liệu chính thức nằm trong 3 file + * đính kèm (biên bản DOCX, bảng đối soát XLSX, danh sách VĐV XLSX); email chỉ + * tóm tắt cho dễ đọc. + */ + +function vnd(n: number | null | undefined): string { + return (n ?? 0).toLocaleString('vi-VN'); +} +function ddmmyyyy(s: string | null | undefined): string { + if (!s) return ''; + const p = s.split('-'); + return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : s; +} +function esc(s: unknown): string { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>'); +} +const lineNet = (li: { + order_category: string; + subtotal: number; + add_on_price: number; + discount_amount: number; +}) => + li.order_category === 'CHANGE_COURSE' + ? li.subtotal + : li.subtotal + li.add_on_price - li.discount_amount; + +const TABLE = + 'border-collapse:collapse;width:100%;max-width:660px;margin:0 0 8px;font-size:13px'; +const THS = + 'border:1px solid #9aa;background:#bdd7ee;padding:6px 8px;font-weight:bold;text-align:center'; +const TD = 'border:1px solid #9aa;padding:6px 8px'; +const TDC = TD + ';text-align:center'; +const TDR = TD + ';text-align:right'; +const TDB = TD + ';font-weight:bold'; +const TDRB = TDR + ';font-weight:bold'; +const TH = (t: string) => `${t}`; + +export function buildReconEmailSubject(rec: ReconciliationDocument): string { + return `[5BIB] Đối soát bán vé sự kiện ${rec.race_title} — ${renderPeriodLabel( + rec.period_start, + rec.period_end, + )}`; +} + +export function buildReconEmailHtml(rec: ReconciliationDocument): string { + const rate = rec.fee_rate_applied ?? 0; + const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); + const totalQty = (rec.line_items ?? []).reduce( + (s, li) => s + (li.quantity || 0), + 0, + ); + const detailRows = (rec.line_items ?? []) + .map( + (li) => ` + ${esc(li.distance_name)} + ${vnd(li.unit_price)} + ${li.quantity} + ${vnd(lineNet(li))} + ${esc(li.ticket_type_name)} + `, + ) + .join(''); + + return `
+

Dear team,

+

5BIB xin gửi Quý đối tác biên bản & bảng đối soát bán vé cho kỳ dưới đây. Chi tiết đầy đủ trong 03 file đính kèm: Biên bản đối soát, Bảng đối soát, và Danh sách vận động viên.

+

SỰ KIỆN: ${esc(rec.race_title)}

+

Ngày đối soát: ${ddmmyyyy(rec.signed_date_str)}

+

Thời gian đối soát từ ${ddmmyyyy(rec.period_start)} -> hết ngày ${ddmmyyyy(rec.period_end)}

+ +

1. GIAO DỊCH THANH TOÁN QUA 5BIB

+ + ${TH('STT')}${TH('Giá trị giao dịch')}${TH('Tỷ lệ phí')}${TH('Phí bán vé')} + + +
1${vnd(rec.net_revenue)}${rate}%${vnd(rec.fee_amount)}
Tổng cộng${vnd(rec.fee_amount)}
+ +

2. GIAO DỊCH THỦ CÔNG

+ + ${TH('STT')}${TH('Số lượng vé')}${TH('Phí dịch vụ')}${TH('Phí dịch vụ')} + + +
1${rec.manual_ticket_count || ''}${rec.manual_ticket_count ? vnd(rec.manual_fee_per_ticket) : ''}${vnd(rec.manual_fee_amount || 0)}
Tổng cộng${vnd(rec.manual_fee_amount || 0)}
+ +

3. CHI TIẾT GIAO DỊCH

+ + ${TH('Cự ly')}${TH('Đơn giá')}${TH('Số lượng')}${TH('Thành tiền')}${TH('Giai đoạn')} + ${detailRows} + + + + +
Tổng cộng${totalQty}${vnd(rec.net_revenue)}
Phí bán vé (đã gồm VAT)${vnd(feeInclVat)}
Phí dịch vụ${vnd(rec.manual_fee_amount || 0)}
Hoàn trả merchant${vnd(rec.payout_amount)}
+ +

Quý đối tác vui lòng phản hồi email này để xác nhận số liệu. Sau khi xác nhận, 5BIB sẽ gửi bản in biên bản có ký & đóng dấu và tiến hành thanh toán/xuất hóa đơn theo hợp đồng.

+

Trân trọng,
CÔNG TY CỔ PHẦN 5BIB

+
`; +} From d5f277ecf07755a346ec96a05bb03215572f680e Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 18:03:31 +0700 Subject: [PATCH 08/23] =?UTF-8?q?feat(recon-admin):=20Pha=204=20UI=20?= =?UTF-8?q?=E2=80=94=20g=E1=BB=ADi=20email=20=C4=91=E1=BB=91i=20so=C3=A1t?= =?UTF-8?q?=20(per-item=20+=20batch)=20+=20v=C3=B2ng=20=C4=91=E1=BB=9Di?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detail page: nút "Gửi email cho BTC" (dialog review-then-send) → POST /:id/send-email; hiển thị đã gửi + người nhận; nút vòng đời (BTC xác nhận / gửi bản in / nhận bản ký / xuất HĐ) → PATCH /:id/milestone. - List page: "Gửi email (N)" hàng loạt cho đối soát đã chọn (dialog duyệt + kết quả từng cái, per-item lỗi hiện lý do) → POST /send-email/batch. - next build xanh. Cần click-test live trên DEV sau deploy (cần Logto auth). Co-Authored-By: Claude Opus 4.8 --- .../(dashboard)/reconciliations/[id]/page.tsx | 114 ++++++++++++++++++ .../app/(dashboard)/reconciliations/page.tsx | 98 +++++++++++++++ 2 files changed, 212 insertions(+) diff --git a/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx b/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx index 6e581e36..b3e66e64 100644 --- a/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx @@ -41,6 +41,7 @@ import { RefreshCw, ArrowRight, Loader2, + Mail, AlertTriangle, Calendar, Building2, @@ -132,6 +133,12 @@ interface ReconciliationDetail { /** Same as `xlsx_url` — internal use only. Use backend download endpoint for UI. */ docx_url: string | null; signed_at: string | null; + email_sent_at?: string | null; + email_sent_by?: string | null; + email_recipients?: string[]; + email_confirmed_at?: string | null; + paper_sent_at?: string | null; + invoice_issued_at?: string | null; line_items: LineItem[]; manual_orders: ManualOrderRow[]; createdAt: string; @@ -241,6 +248,9 @@ export default function ReconciliationDetailPage() { const [signedAt, setSignedAt] = useState(new Date().toISOString().slice(0, 10)); const [regenLoading, setRegenLoading] = useState(false); const [approveLoading, setApproveLoading] = useState(false); + const [sendOpen, setSendOpen] = useState(false); + const [sendLoading, setSendLoading] = useState(false); + const [milestoneLoading, setMilestoneLoading] = useState(false); const fetchDetail = useCallback(async () => { if (!token || !id) return; @@ -321,6 +331,49 @@ export default function ReconciliationDetailPage() { } } + async function handleSendEmail() { + if (!token || !data) return; + setSendLoading(true); + try { + const res = await fetch(`/api/reconciliations/${id}/send-email`, { + method: "POST", + headers: { ...authHeaders(token).headers, "Content-Type": "application/json" }, + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json?.message || "Gửi email thất bại"); + if (json?.sent) { + toast.success(`Đã gửi email tới ${(json.to || []).join(", ")}`); + } else { + toast(json?.reason || "Chưa gửi được — Mailchimp chưa cấu hình (dev)"); + } + setSendOpen(false); + await fetchDetail(); + } catch (e) { + toast.error((e as Error).message || "Gửi email thất bại"); + } finally { + setSendLoading(false); + } + } + + async function handleMilestone(milestone: string, label: string) { + if (!token) return; + setMilestoneLoading(true); + try { + const res = await fetch(`/api/reconciliations/${id}/milestone`, { + method: "PATCH", + headers: { ...authHeaders(token).headers, "Content-Type": "application/json" }, + body: JSON.stringify({ milestone }), + }); + if (!res.ok) throw new Error(); + toast.success(`Đã đánh dấu: ${label}`); + await fetchDetail(); + } catch { + toast.error("Không thể cập nhật mốc"); + } finally { + setMilestoneLoading(false); + } + } + // F-029 BR-HD-30 — page-level RBAC gate. if (authLoading) return null; if (!isStaff) return ; @@ -569,6 +622,40 @@ export default function ReconciliationDetailPage() { + {/* Gửi email đối soát (Pha 4) */} + +
+

Gửi email đối soát

+ {data.email_sent_at ? ( +
+ ✓ Đã gửi {new Date(data.email_sent_at).toLocaleString("vi-VN")} + {data.email_recipients?.length ? ( +
Tới: {data.email_recipients.join(", ")}
+ ) : null} +
+ ) : null} + + {data.email_sent_at && ( +
+ + + + +
+ )} +
+ {/* Transition */} {canTransition && nextStatus && ( <> @@ -746,6 +833,33 @@ export default function ReconciliationDetailPage() { + + + + + Gửi email đối soát cho BTC + + Kèm 3 file: Biên bản đối soát (DOCX), Bảng đối soát (XLSX), Danh sách VĐV (XLSX). + + +
+
+ Tiêu đề: + [5BIB] Đối soát bán vé sự kiện {data.race_title} +
+
+ Người nhận (To/CC) lấy từ Merchant > Công ty đối tác. Chưa cấu hình email đầu mối → hệ thống sẽ báo lỗi. +
+
+ + + + +
+
); } diff --git a/admin/src/app/(dashboard)/reconciliations/page.tsx b/admin/src/app/(dashboard)/reconciliations/page.tsx index 366da362..adc63f06 100644 --- a/admin/src/app/(dashboard)/reconciliations/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/page.tsx @@ -29,6 +29,7 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter, } from "@/components/ui/dialog"; import { toast } from "sonner"; @@ -45,6 +46,7 @@ import { Archive, Download, Trash2, + Mail, } from "lucide-react"; import { Input } from "@/components/ui/input"; @@ -184,6 +186,13 @@ export default function ReconciliationsPage() { // FEATURE-025 — Bulk delete state const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false); + const [sendBatchOpen, setSendBatchOpen] = useState(false); + const [sendBatchLoading, setSendBatchLoading] = useState(false); + const [sendBatchResult, setSendBatchResult] = useState<{ + sent: number; + failed: number; + results: Array<{ id: string; sent: boolean; reason?: string; to: string[] }>; + } | null>(null); const [bulkDeleteLoading, setBulkDeleteLoading] = useState(false); // Export ZIP state @@ -517,6 +526,43 @@ export default function ReconciliationsPage() { } } + // Pha 4 — gửi email đối soát hàng loạt (batch review-then-send) + async function handleBatchSend() { + if (selectedIds.size === 0) return; + setSendBatchLoading(true); + setSendBatchResult(null); + try { + const ids = Array.from(selectedIds); + const res = await fetch(`/api/reconciliations/send-email/batch`, { + method: "POST", + headers: { + ...authHeaders(token!).headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`HTTP ${res.status}: ${body || "unknown"}`); + } + const result = (await res.json()) as { + sent: number; + failed: number; + results: Array<{ id: string; sent: boolean; reason?: string; to: string[] }>; + }; + setSendBatchResult(result); + toast.success(`Đã gửi ${result.sent}/${ids.length} email đối soát`); + if (result.failed > 0) { + toast.message(`${result.failed} email chưa gửi được — xem chi tiết trong hộp thoại`); + } + fetchItems(); + } catch (err: any) { + toast.error(`Gửi hàng loạt thất bại: ${err.message}`); + } finally { + setSendBatchLoading(false); + } + } + function toggleBatchSelect(tenantId: number) { setBatchSelected((prev) => { const next = new Set(prev); @@ -627,6 +673,14 @@ export default function ReconciliationsPage() { Xuất ZIP ({selectedIds.size}) + {/* Pha 4 — Gửi email đối soát hàng loạt */} + + {!sendBatchResult && ( + + )} + + + + {/* M3: Batch creation modal */} { if (!open) handleBatchClose(); }}> From b412bd52d74566ded6dc0562a6779cd5ee7ed869 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 21:48:54 +0700 Subject: [PATCH 09/23] =?UTF-8?q?fix(recon):=20preflight=20nh=E1=BA=ADn=20?= =?UTF-8?q?bi=E1=BA=BFt=20event=20override=20=E2=80=94=20b=E1=BB=8F=20ERRO?= =?UTF-8?q?R=20"ch=C6=B0a=20thi=E1=BA=BFt=20l=E1=BA=ADp=20ph=C3=AD"=20gi?= =?UTF-8?q?=E1=BA=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug phát hiện khi test DEV (E2E): preflight flag ERROR "Merchant chưa thiết lập service_fee_rate" → recon bị 'flagged'/"Có vấn đề" phải Approve anyway, NGAY CẢ khi race CÓ event override 7% — và trái quyết định Pha 1 (fallback 5.5%, KHÔNG hard-block). Fix: resolveFeeParams per-race (override → merchant → 5.5%); chỉ WARNING khi rơi mặc định 5.5%, có override/merchant rate → im. estimated_fee dùng rate đã resolve. Áp cả run() + runRange(). +3 test. 141 recon test pass. Co-Authored-By: Claude Opus 4.8 --- .../reconciliation-preflight.service.spec.ts | 50 +++++++++++++++++++ .../reconciliation-preflight.service.ts | 50 ++++++++++++------- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts index c166fdac..45147631 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts @@ -209,4 +209,54 @@ describe('ReconciliationPreflightService.runRange — BR-11 overlap detection', expect(result.can_create).toBe(true); }); }); + + // ============================================================ + // Fee cascade preflight (override-aware, fallback 5.5%) — bug-fix 2026-07-10 + // ============================================================ + describe('Fee cascade preflight (override-aware)', () => { + function withConfig(cfg: any) { + mockConfigModel.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue(cfg), + }); + mockReconciliationModel.lean.mockResolvedValue([]); + mockQueryService.queryOrders.mockResolvedValue({ + fiveBibOrders: [{ order_id: 1, subtotal_price: 1000000 }], + manualOrders: [], + missingPaymentRef: [], + }); + } + + it('race CÓ event override → KHÔNG cảnh báo phí (bug-fix: hết ERROR giả)', async () => { + withConfig({ + service_fee_rate: null, + event_fee_overrides: [ + { raceId: 148, service_fee_rate: 7, effective_from: '2026-01-01' }, + ], + }); + const r = await service.runRange(baseRequest); + expect( + r.warnings.some( + (w) => w.type === 'FEE_RATE_DEFAULT' || w.type === 'NO_FEE_RATE', + ), + ).toBe(false); + expect(r.summary.estimated_fee).toBe(70000); // 1.000.000 × 7% + }); + + it('merchant default rate → KHÔNG cảnh báo, estimated_fee theo default', async () => { + withConfig({ service_fee_rate: 5.5, event_fee_overrides: [] }); + const r = await service.runRange(baseRequest); + expect(r.warnings.some((w) => w.type === 'FEE_RATE_DEFAULT')).toBe(false); + expect(r.summary.estimated_fee).toBe(55000); + }); + + it('không cấu hình gì → WARNING (KHÔNG ERROR) + fallback 5.5%, vẫn create được', async () => { + withConfig({ service_fee_rate: null, event_fee_overrides: [] }); + const r = await service.runRange(baseRequest); + const w = r.warnings.find((x) => x.type === 'FEE_RATE_DEFAULT'); + expect(w).toBeDefined(); + expect(w?.severity).toBe('WARNING'); + expect(r.summary.estimated_fee).toBe(55000); + expect(r.can_create).toBe(true); + }); + }); }); diff --git a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts index e9efeee4..e780eff0 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts @@ -13,6 +13,7 @@ import { } from '../../merchant/schemas/merchant-config.schema'; // F-082 — period-keyed TZ cutover (kỳ >= 2026-06 ICT, kỳ cũ UTC). import { periodRangeUtc } from '../../../common/utils/ict-date.util'; +import { resolveFeeParams } from '../utils/fee-params.util'; export interface PreflightFlag { type: string; @@ -83,15 +84,10 @@ export class ReconciliationPreflightService { const racesWithOrders: PreflightRaceResult[] = []; const racesSkipped: PreflightRaceSkipped[] = []; - // --- Check: fee rate --- - if (!config?.service_fee_rate) { - warnings.push({ - type: 'NO_FEE_RATE', - severity: 'ERROR', - message: 'Merchant chưa thiết lập service_fee_rate', - count: null, - }); - } + // --- Fee rate: check PER-RACE qua cascade (event_fee_overrides → merchant + // default → fallback 5.5%). Chỉ WARNING khi rơi về mặc định 5.5% (Danny + // chốt fallback, KHÔNG hard-block) — có override hoặc merchant rate → OK. + let estimatedFeeAccum = 0; // --- Get races to check --- let racesToCheck: Array<{ race_id: string; title: string }> = []; @@ -134,6 +130,22 @@ export class ReconciliationPreflightService { ordinary_missing_payment_ref: missingPaymentRef.length, }); + // --- Fee per-race qua cascade (override → merchant → 5.5%) --- + const resolvedFee = resolveFeeParams({ + config, + mysqlRaceId: raceId, + periodStart: period_start, + }); + if (resolvedFee.feeSource === 'default_fallback') { + warnings.push({ + type: 'FEE_RATE_DEFAULT', + severity: 'WARNING', + message: `"${raceName}" chưa cấu hình phí (event override / merchant default) — dùng mặc định ${resolvedFee.feeRate}%`, + count: null, + }); + } + estimatedFeeAccum += Math.round((grossRevenue * resolvedFee.feeRate) / 100); + // --- Race-level warnings --- // F-061 BR-61-04 — severity downgrade ERROR → WARNING + message update. // Sau F-061, ORDINARY/CHANGE_COURSE/PERSONAL_GROUP/etc thiếu payment_ref @@ -197,8 +209,7 @@ export class ReconciliationPreflightService { const totalOrders = racesWithOrders.reduce((s, r) => s + r.order_count, 0); const totalGross = racesWithOrders.reduce((s, r) => s + r.gross_revenue, 0); - const feeRate = config?.service_fee_rate ?? null; - const estimatedFee = feeRate ? Math.round((totalGross * feeRate) / 100) : null; + const estimatedFee = racesWithOrders.length > 0 ? estimatedFeeAccum : null; const hasErrors = warnings.some((w) => w.severity === 'ERROR'); const hasOrders = racesWithOrders.length > 0; @@ -244,11 +255,17 @@ export class ReconciliationPreflightService { const racesWithOrders: PreflightRaceResult[] = []; const racesSkipped: PreflightRaceSkipped[] = []; - if (!config?.service_fee_rate) { + // Fee qua cascade (override → merchant → 5.5%). Chỉ WARNING khi rơi mặc định. + const resolvedFee = resolveFeeParams({ + config, + mysqlRaceId: mysql_race_id, + periodStart: period_start, + }); + if (resolvedFee.feeSource === 'default_fallback') { warnings.push({ - type: 'NO_FEE_RATE', - severity: 'ERROR', - message: 'Merchant chưa thiết lập service_fee_rate', + type: 'FEE_RATE_DEFAULT', + severity: 'WARNING', + message: `Race chưa cấu hình phí (event override / merchant default) — dùng mặc định ${resolvedFee.feeRate}%`, count: null, }); } @@ -354,8 +371,7 @@ export class ReconciliationPreflightService { } const totalGross = racesWithOrders.reduce((s, r) => s + r.gross_revenue, 0); - const feeRate = config?.service_fee_rate ?? null; - const estimatedFee = feeRate ? Math.round((totalGross * feeRate) / 100) : null; + const estimatedFee = Math.round((totalGross * resolvedFee.feeRate) / 100); const hasOrders = racesWithOrders.length > 0; return { From d8ca54bb76187dac2cd2efc55e2e34f5a885c441 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 22:07:27 +0700 Subject: [PATCH 10/23] =?UTF-8?q?fix(recon):=20QC=20fixes=20=E2=80=94=20bi?= =?UTF-8?q?=C3=AAn=20b=E1=BA=A3n=20DOCX=20kh=E1=BB=9Bp=20payout=20th?= =?UTF-8?q?=E1=BA=ADt=20(blocker)=20+=20status=20forward-only=20+=20audit?= =?UTF-8?q?=20actor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QC gatekeeper adversarial (12/13 confirmed). Fix: - [HIGH blocker] docx (3) chỉ = net - feeInclVat, QUÊN trừ phí thủ công + cộng adjustment → biên bản ký lệch payout thật khi có đơn thu hộ (manual). Fix: thêm dòng "Trừ phí giao dịch thủ công" + "Điều chỉnh" + "(4) Số tiền 5BIB thanh toán cho Bên A" = payout_amount (khớp email + XLSX + số thực chuyển). Case không manual giữ nguyên (3) khớp mẫu SCB. Verify e2e: 1.395.000 - 15.000 = 1.380.000. +2 test. - [MEDIUM] gửi LẠI email downgrade status signed/completed → sent: forward-only, chỉ nâng khi đang trước 'sent'. +1 test. - [MEDIUM] email_sent_by luôn null (req.user?.id sai field) → userId ?? sub (khớp create()). Debt còn lại (LOW, QC "nên-làm-sau"): idempotency lock gửi email, strip raw_* khỏi findOne response, findOneAndUpdate atomic lifecycle, response DTO + SDK gen. 147 recon test pass, build xanh. Co-Authored-By: Claude Opus 4.8 --- .../reconciliation.controller.ts | 7 ++- .../reconciliation.service.spec.ts | 11 ++++ .../reconciliation/reconciliation.service.ts | 6 ++- .../services/docx.service.spec.ts | 24 +++++++++ .../reconciliation/services/docx.service.ts | 52 ++++++++++++++----- 5 files changed, 83 insertions(+), 17 deletions(-) diff --git a/backend/src/modules/reconciliation/reconciliation.controller.ts b/backend/src/modules/reconciliation/reconciliation.controller.ts index 50b0f3e9..aad86798 100644 --- a/backend/src/modules/reconciliation/reconciliation.controller.ts +++ b/backend/src/modules/reconciliation/reconciliation.controller.ts @@ -259,7 +259,7 @@ export class ReconciliationController { sendEmailBatch(@Body() dto: SendReconEmailBatchDto, @Request() req: any) { return this.reconciliationService.sendReconciliationEmailBatch( dto.ids, - req.user?.id, + req.user?.userId ?? req.user?.sub, ); } @@ -267,7 +267,10 @@ export class ReconciliationController { @ApiOperation({ summary: 'Gửi email đối soát cho BTC kèm 3 file đính kèm' }) @ApiResponse({ status: 201 }) sendEmail(@Param('id') id: string, @Request() req: any) { - return this.reconciliationService.sendReconciliationEmail(id, req.user?.id); + return this.reconciliationService.sendReconciliationEmail( + id, + req.user?.userId ?? req.user?.sub, + ); } @Patch(':id/milestone') diff --git a/backend/src/modules/reconciliation/reconciliation.service.spec.ts b/backend/src/modules/reconciliation/reconciliation.service.spec.ts index 88839753..24fcded9 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.spec.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.spec.ts @@ -378,6 +378,17 @@ describe('ReconciliationService.sendReconciliationEmail (Pha 4)', () => { expect(doc.save).toHaveBeenCalled(); }); + it('QC#3 — gửi LẠI khi đã signed → status GIỮ signed (không downgrade về sent)', async () => { + const { service } = await build({ reconciliation_emails: ['a@x.com'], reconciliation_cc_emails: [] }); + const doc = fakeDoc({ status: 'signed' }); + jest.spyOn(service, 'findOne').mockResolvedValue(doc as any); + const r = await service.sendReconciliationEmail('r1', 'admin1'); + expect(r.sent).toBe(true); + expect(doc.status).toBe('signed'); // KHÔNG về 'sent' + expect(doc.email_sent_at).toBeInstanceOf(Date); // vẫn cập nhật timestamp + expect(doc.email_sent_by).toBe('admin1'); + }); + it('Mailchimp chưa cấu hình (dev) → sent=false, KHÔNG đổi status', async () => { const { service, mail } = await build({ reconciliation_emails: ['a@x.com'], reconciliation_cc_emails: [] }); mail.sendReconciliation.mockResolvedValue(false); diff --git a/backend/src/modules/reconciliation/reconciliation.service.ts b/backend/src/modules/reconciliation/reconciliation.service.ts index 9725b4fa..030ed637 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.ts @@ -558,7 +558,11 @@ export class ReconciliationService { }); if (sent) { - doc.status = 'sent'; + // QC #3 — forward-only: KHÔNG downgrade recon đã ký/hoàn tất về 'sent' + // khi gửi LẠI email. Chỉ nâng lên 'sent' nếu đang đứng trước 'sent'. + if (['draft', 'flagged', 'ready', 'approved'].includes(doc.status)) { + doc.status = 'sent'; + } doc.email_sent_at = new Date(); doc.email_sent_by = sentBy ?? null; doc.email_recipients = [...to, ...cc]; diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index f3ff19a7..f407fecd 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -128,6 +128,30 @@ describe('DocxService — khớp mẫu T6 + regression', () => { expect(t).not.toContain('Số lượng BIB'); }); + it('QC#1 — KHÔNG phí thủ công: giữ (3), KHÔNG dòng phụ (khớp mẫu SCB)', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).not.toContain('Trừ phí giao dịch thủ công'); + expect(t).not.toContain('Số tiền 5BIB thanh toán cho Bên A (4)'); + expect(t).toContain('Tổng tiền sau phí dịch vụ của 5BIB (3) = (1) - (2)'); + // (3) = payout thật (không manual) = 17.408.979 + expect(t).toContain('17.408.979'); + }); + + it('QC#1 — CÓ phí thủ công: thêm dòng trừ + (4) = payout_amount thật', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + manual_fee_amount: 2000000, + manual_adjustment: 0, + payout_amount: rec.payout_amount - 2000000, // 15.408.979 + }), + ); + expect(t).toContain('Trừ phí giao dịch thủ công'); + expect(t).toContain('Số tiền 5BIB thanh toán cho Bên A (4)'); + // Số 5BIB thanh toán THẬT (khớp email/XLSX), KHÔNG phải (3)=net-fee + expect(t).toContain('15.408.979'); + }); + it('3 dòng "(Bằng chữ:...)" cho (1)/(2)/(3)', async () => { const t = await xml(await docxSvc.generate(rec)); const bangChu = t.match(/Bằng chữ:/g) ?? []; diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index b37c50cb..6525e0c1 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -237,10 +237,14 @@ export class DocxService { // Biên bản khớp mẫu T6: 3 số (1)/(2)/(3) + số bằng chữ cho mỗi số. // (2) = phí đã gồm VAT; (3) = (1) - (2) → tie-out nội bộ biên bản. const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); - const payoutAfterFee = Math.round(rec.net_revenue - feeInclVat); + // QC #1 — số 5BIB thanh toán THẬT = payout_amount (đã trừ phí thủ công + + // cộng điều chỉnh), KHÔNG phải net - feeInclVat. Khớp email + XLSX. + const actualPayout = Math.round(rec.payout_amount); + const hasExtraDeductions = + (rec.manual_fee_amount || 0) > 0 || (rec.manual_adjustment || 0) !== 0; const netWords = this.numToWords(Math.round(rec.net_revenue)); const feeWords = this.numToWords(feeInclVat); - const payoutWords = this.numToWords(payoutAfterFee); + const payoutWords = this.numToWords(actualPayout); // "Ngày Hiệu Lực": dùng signed_date_str nếu có, else để trống điền tay. const periodYear = rec.period_end.slice(0, 4); const sd = rec.signed_date_str @@ -412,7 +416,10 @@ export class DocxService { { text: '- Khoản bán vé 5BIB sẽ thanh toán cho Bên A sau khi cấn trừ các khoản phí dịch vụ: ', }, - { text: `${fmtVnd(payoutAfterFee)} VNĐ (3)`, bold: true }, + { + text: `${fmtVnd(actualPayout)} VNĐ ${hasExtraDeductions ? '(4)' : '(3)'}`, + bold: true, + }, ], { spacingAfter: 60 }, ), @@ -708,7 +715,10 @@ export class DocxService { ); const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); - const payoutAfterFee = Math.round(rec.net_revenue - feeInclVat); + const payoutAfterFee = Math.round(rec.net_revenue - feeInclVat); // (3) = (1)-(2) + const manualFee = Math.round(rec.manual_fee_amount || 0); + const adjustment = Math.round(rec.manual_adjustment || 0); + const hasExtra = manualFee > 0 || adjustment !== 0; const summaryRow = (label: string, value: number): TableRow => new TableRow({ @@ -722,20 +732,34 @@ export class DocxService { ], }); + // QC #1 — khi có phí giao dịch thủ công / điều chỉnh, (3) chưa phải số 5BIB + // thanh toán thật. Thêm dòng trừ phí thủ công + điều chỉnh + dòng cuối (4) + // = rec.payout_amount để biên bản ký khớp email + XLSX + số thực chuyển. + const summaryRows: TableRow[] = [ + summaryRow('Tổng doanh thu bán vé (1)', Math.round(rec.net_revenue)), + summaryRow('Phí bán vé (Đã bao gồm VAT) (2)', feeInclVat), + summaryRow('Tổng tiền sau phí dịch vụ của 5BIB (3) = (1) - (2)', payoutAfterFee), + ]; + if (manualFee > 0) { + summaryRows.push(summaryRow('Trừ phí giao dịch thủ công', -manualFee)); + } + if (adjustment !== 0) { + summaryRows.push(summaryRow('Điều chỉnh', adjustment)); + } + if (hasExtra) { + summaryRows.push( + summaryRow( + 'Số tiền 5BIB thanh toán cho Bên A (4)', + Math.round(rec.payout_amount), + ), + ); + } + return new Table({ width: { size: 7860, type: WidthType.DXA }, layout: TableLayoutType.FIXED, columnWidths: colWidths, - rows: [ - headerRow, - ...dataRows, - summaryRow('Tổng doanh thu bán vé (1)', Math.round(rec.net_revenue)), - summaryRow('Phí bán vé (Đã bao gồm VAT) (2)', feeInclVat), - summaryRow( - 'Tổng tiền sau phí dịch vụ của 5BIB (3) = (1) - (2)', - payoutAfterFee, - ), - ], + rows: [headerRow, ...dataRows, ...summaryRows], }); } From 5006926fd426d93a2ac6473cdcac17f5bd521aa4 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 22:08:46 +0700 Subject: [PATCH 11/23] =?UTF-8?q?docs(recon):=2004-qc-report=20=E2=80=94?= =?UTF-8?q?=20QC=20gatekeeper=20verdict=20+=20fix=20status=20(blocker=20?= =?UTF-8?q?=C4=91=C3=B3ng,=20LOW=20debt=20tracked)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/recon-automation/04-qc-report.md | 46 +++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/recon-automation/04-qc-report.md diff --git a/docs/recon-automation/04-qc-report.md b/docs/recon-automation/04-qc-report.md new file mode 100644 index 00000000..41ce2b9b --- /dev/null +++ b/docs/recon-automation/04-qc-report.md @@ -0,0 +1,46 @@ +# 04 — QC Gatekeeper Report: Hệ thống Đối soát tự động (Pha 1–4) + +- **Branch:** `fix/recon-accuracy-pha1` · **QC ngày:** 2026-07-10 +- **Method:** Adversarial workflow — 6 lens × find → verify (phản biện đối kháng) → verdict. 13 findings thô, **12 confirmed**. +- **Verdict gốc:** ⚠️ APPROVED-WITH-CONDITIONS (1 HIGH blocker) +- **Verdict sau fix:** ✅ **APPROVED cho PROD** — blocker + 3 MEDIUM đã fix & verify; còn lại là LOW tech-debt không chặn. + +> Feature làm ad-hoc (không qua pipeline `/5bib-prd`→`/5bib-code`) nên không có `01-ba-prd.md`/`03-coder-implementation.md`. QC dựa trên **code thật** + `memory/project_recon_automation`. Unit test tồn tại + PASS (147 test recon). + +--- + +## Findings & trạng thái + +| Mức | Finding | File:line | Trạng thái | +|-----|---------|-----------|-----------| +| 🔴 HIGH | Biên bản DOCX (3) = net−fee, **quên** trừ phí thủ công + adjustment → số ký lệch payout thật khi có đơn manual | `docx.service.ts:240,710` | ✅ **FIXED** (`d8ca54b`) — thêm dòng "Trừ phí giao dịch thủ công"/"Điều chỉnh"/"(4) Số tiền 5BIB thanh toán"=`payout_amount`; case no-manual giữ (3) khớp mẫu SCB. Verify e2e: 1.395.000−15.000=1.380.000. +2 test | +| 🟠 MED | Gửi lại email downgrade status `signed`/`completed` → `sent` | `reconciliation.service.ts:561` | ✅ **FIXED** — forward-only, chỉ nâng khi đang trước 'sent'. +1 test | +| 🟠 MED | `email_sent_by` luôn null (`req.user?.id` sai field) | `reconciliation.controller.ts:262,270` | ✅ **FIXED** — `req.user?.userId ?? req.user?.sub` khớp `create()` | +| 🟠 MED | Email egress trước persist — `save()` fail sau Mandrill accept → gửi lại = trùng | `reconciliation.service.ts:548` | ⏳ DEBT (rủi ro thấp; mitigate bởi forward-only + audit) | +| 🟡 LOW | Không idempotency lock gửi email → batch re-select item đã 'sent' gửi trùng cho BTC | `reconciliation.service.ts:510` | ⏳ DEBT — giảm nhẹ: staff-tool + confirm dialog + resend cố ý + nút disable khi in-flight | +| 🟡 LOW | `findOne`/mutation response trả `raw_5bib_orders`/`raw_manual_orders` (PII người mua) | `reconciliation.service.ts:435` | ⏳ DEBT — over-fetch (staff-gated), nên strip cho data-minimization | +| 🟡 LOW | Lost-update `status`/`signed_at` (read-doc→mutate→save, no optimistic lock) | `reconciliation.service.ts:623` | ⏳ DEBT — rủi ro thấp (tool nội bộ, vòng đời chậm) | +| 🟡 LOW | 3 endpoint Pha 4 thiếu `@ApiResponse({type:Dto})` + response DTO | `reconciliation.controller.ts:258,268,278` | ⏳ DEBT — chưa break (admin raw fetch); cần DTO + gen SDK | +| 🟡 LOW | Admin recon + merchant page raw `fetch()` — vi phạm rule "dùng SDK" | `reconciliations/page.tsx`, `merchants/[id]/page.tsx` | ⏳ DEBT — nợ convention, không break runtime | + +REFUTED (1): khe hở sub-second biên kỳ 23:59:59.x — phản biện xác nhận biên kỳ inclusive đúng, không rớt đơn. + +--- + +## Điểm mạnh đã verify OK + +- **Bảo mật:** mọi endpoint recon sau `LogtoStaffGuard` thật (không no-op) → **không IDOR, không PII leak ra ngoài principal trái phép**. Người nhận email = `reconciliation_emails`/`cc` do merchant config (đúng recipient, đúng tenant). +- **Correctness:** `payout_amount` canonical (calc) = net−fee−vat−manual+adj; email HTML + XLSX bám đúng. Cascade phí (override→merchant→5.5%) đúng. Tie-out #8 đúng. +- **Biên kỳ ICT:** bug off-by-1-day (T6) đã fix `0475ead` (`processed_on` ICT wall-clock). Sub-second REFUTED. +- **43-cột DS VĐV** khớp mẫu T6; escape XLSX an toàn. + +--- + +## Điều kiện PROD + +- [x] **C1 (HIGH)** fixed + verify e2e (docx (4) = payout thật khớp email/XLSX). +- [x] 2 MEDIUM (status forward-only, audit actor) fixed + test. +- [ ] Trước khi bật gửi email thật PROD: verify env `MAILCHIMP_API_KEY` + `PROVIDER_BANK_*` (BIDV 2600398986). +- [ ] LOW debt: lên ticket riêng (idempotency lock, strip raw_*, atomic lifecycle, DTO+SDK). Không chặn PROD. + +**Kết luận:** Feature vững logic + bảo mật; blocker pháp lý (số biên bản ký) đã đóng. **Approve merge + đủ điều kiện PROD** sau khi verify env; LOW debt theo dõi ở known-issues. From ce840331557ef7f05961c07373afbb3832202097 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 22:35:37 +0700 Subject: [PATCH 12/23] =?UTF-8?q?fix(recon):=20audit=20gi=E1=BA=A5y=20t?= =?UTF-8?q?=E1=BB=9D=20=E2=80=94=20s=E1=BB=91=20b=E1=BA=B1ng=20ch=E1=BB=AF?= =?UTF-8?q?=20chu=E1=BA=A9n=20k=E1=BA=BF=20to=C3=A1n=20+=20ch=E1=BB=A9c=20?= =?UTF-8?q?v=E1=BB=A5/VAT/payout=20=C3=A2m/tie-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit đối kháng (19 confirmed) trên biên bản DOCX / bảng XLSX / email: - CRITICAL số bằng chữ: numToWords bỏ "không trăm"/"lẻ" cho nhóm không dẫn đầu có hàng trăm=0 → 1.000.005 in "Một triệu năm đồng" (nghe thành 1,5tr). Viết lại theo chuẩn kế toán VN: nhóm giữa/cuối trăm=0 → "không trăm [lẻ]". Fix luôn số âm viết hoa "M" giữa câu + Math.round đầu hàm. PASS 32/32 battery. - HIGH chức vụ: bỏ hardcode default "Tổng Giám đốc" (bịa chức danh pháp lý khi MerchantConfig/metadata không có). Để trống → điền tay. - HIGH VAT: bullet "(2) = (1) × X%" sai số học khi feeVatRate>0 (feeInclVat gồm VAT). Nay khi VAT>0 in "= (1) × rate% + VAT vat%" + dòng tách phí/VAT; VAT=0 giữ nguyên khớp mẫu SCB. - HIGH tie-out: XLSX + email thiếu dòng "Điều chỉnh" (manual_adjustment) → số không tự cộng-trừ ra payout, lệch DOCX. Thêm dòng khi ≠0. - MEDIUM payout âm: prose "5BIB thanh toán cho Bên A: -X" vô nghĩa → đảo chiều "Bên A thanh toán cho 5BIB" + trị tuyệt đối khi payout<0. - Thêm WARN docx_party_incomplete khi thiếu tên pháp nhân/MST/STK Bên A. +10 test (6 docx, 2 xlsx, 2 email). 157/157 recon pass, build xanh. Giữ nguyên nhãn trùng "Phí dịch vụ" (Section 2) + Bên B env.provider (đã có default BIDV thật) — bám mẫu T6, refuted trong audit. Co-Authored-By: Claude Opus 4.8 --- .../services/docx.service.spec.ts | 84 +++++++++ .../reconciliation/services/docx.service.ts | 166 +++++++++++++----- .../reconciliation-email.helper.spec.ts | 16 ++ .../services/reconciliation-email.helper.ts | 5 + .../services/xlsx.service.spec.ts | 24 +++ .../reconciliation/services/xlsx.service.ts | 6 + 6 files changed, 254 insertions(+), 47 deletions(-) diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index f407fecd..324e0c40 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -291,4 +291,88 @@ describe('DocxService — khớp mẫu T6 + regression', () => { expect(buf.length).toBeGreaterThan(1000); expect(await xml(buf)).toContain('FALLBACK PLATFORM'); }); + + /* ── Số bằng chữ: chuẩn kế toán VN "không trăm / lẻ" (audit 2026-07-10) ── */ + + it('AUDIT-NW-01: nhóm giữa/cuối trăm=0 có "không trăm lẻ" (KHÔNG đọc lệch bậc)', async () => { + // net = 5.005.000 → "Năm triệu không trăm lẻ năm nghìn đồng" + const t = await xml( + await docxSvc.generate({ + ...rec, + net_revenue: 5005000, + fee_amount: 0, + fee_rate_applied: 0, + payout_amount: 5005000, + }), + ); + expect(t).toContain('Năm triệu không trăm lẻ năm nghìn đồng'); + // Chuỗi SAI cũ (thiếu "không trăm lẻ") tuyệt đối không còn + expect(t).not.toContain('(Bằng chữ: Năm triệu năm nghìn đồng)'); + }); + + it('AUDIT-NW-02: 1.000.005 → "một triệu không trăm lẻ năm" (không nghe thành 1,5tr)', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + net_revenue: 1000005, + fee_amount: 0, + fee_rate_applied: 0, + payout_amount: 1000005, + }), + ); + expect(t).toContain('Một triệu không trăm lẻ năm đồng'); + expect(t).not.toContain('(Bằng chữ: Một triệu năm đồng)'); + }); + + it('AUDIT-NW-03: payout âm → in trị tuyệt đối, KHÔNG "Âm" viết hoa giữa câu', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + net_revenue: 1000000, + fee_amount: 55000, + fee_rate_applied: 5.5, + manual_fee_amount: 2000000, + payout_amount: -1055000, + }), + ); + // Đảo chiều thanh toán khi âm + expect(t).toContain('Bên A thanh toán cho 5BIB'); + // Bằng chữ dùng trị tuyệt đối (không "Âm ...") + expect(t).toContain('Một triệu không trăm năm mươi lăm nghìn đồng'); + expect(t).not.toContain('Âm '); + }); + + /* ── Phí có VAT: đẳng thức đúng số học (audit 2026-07-10) ─────────────── */ + + it('AUDIT-VAT-01: feeVat>0 → "(2) = (1) × rate% + VAT vat%" + dòng tách VAT', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + net_revenue: 100000000, + fee_rate_applied: 5.5, + fee_amount: 5500000, + fee_vat_rate: 8, + fee_vat_amount: 440000, + payout_amount: 94060000, + }), + ); + expect(t).toContain('(2) = (1) × 5.5% + VAT 8%'); + expect(t).toContain('Thuế GTGT 8% = 440.000 VNĐ'); + // KHÔNG còn đẳng thức trần "= (1) × 5.5%" đứng cạnh số đã gồm VAT + expect(t).not.toContain('6.050.000 VNĐ (2) = (1) × 5.5%'); + }); + + it('AUDIT-VAT-02: feeVat=0 (mẫu SCB) → giữ nguyên "(2) = (1) × rate%"', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).toContain(`(2) = (1) × ${rec.fee_rate_applied}%`); + expect(t).not.toContain('+ VAT'); + }); + + /* ── Chức vụ: KHÔNG bịa "Tổng Giám đốc" khi thiếu nguồn ───────────────── */ + + it('AUDIT-REP-01: không có representative_title → KHÔNG hardcode "Tổng Giám đốc"', async () => { + // base rec: mc null + không tenant_metadata.position + const t = await xml(await docxSvc.generate(rec)); + expect(t).not.toContain('Tổng Giám đốc'); + }); }); diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index 6525e0c1..cae3471f 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -221,8 +221,9 @@ export class DocxService { tenantMeta.name ?? tenantMeta.representative ?? ''; - const repTitle = - mc?.representative_title ?? tenantMeta.position ?? 'Tổng Giám đốc'; + // KHÔNG hardcode 'Tổng Giám đốc' — bịa chức vụ pháp lý cho người ký là sai + // toàn vẹn chứng từ. Để trống khi không có nguồn (điền tay / admin nhập). + const repTitle = mc?.representative_title ?? tenantMeta.position ?? ''; const bankAccount = mc?.bank_account ?? tenantMeta.bankAccount ?? ''; const bankName = mc?.bank_name ?? tenantMeta.bankName ?? ''; @@ -232,19 +233,45 @@ export class DocxService { `companyName=${mc?.legal_name ? 'mc' : tenantMeta.companyName ? 'meta' : 'tenant_name'}`, ); + // Cảnh báo khi biên bản THIẾU định danh pháp lý Bên A (căn cứ ký + xuất hóa + // đơn). Không chặn cứng (flow review-then-send có người duyệt), nhưng WARN để + // ops thấy trước khi gửi — tránh ship biên bản mang tên account/slug, thiếu + // MST, hoặc thiếu số tài khoản nhận tiền. + const nameFromTenant = + !mc?.legal_name && !tenantMeta.companyName && !tenantMeta.company_name; + const missing: string[] = []; + if (nameFromTenant) missing.push('tên pháp nhân (đang dùng tên account)'); + if (!taxCode) missing.push('mã số thuế'); + if (!bankAccount && rec.payout_amount > 0) + missing.push('số tài khoản nhận tiền'); + if (missing.length) { + this.logger.warn( + `docx_party_incomplete tenant=${rec.tenant_id} race="${rec.race_title}" ` + + `thiếu: ${missing.join(', ')} — cần admin nhập tab "Công ty đối tác" trước khi gửi`, + ); + } + const periodLabel = renderPeriodLabel(rec.period_start, rec.period_end); // Biên bản khớp mẫu T6: 3 số (1)/(2)/(3) + số bằng chữ cho mỗi số. // (2) = phí đã gồm VAT; (3) = (1) - (2) → tie-out nội bộ biên bản. - const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); + const feeAmountRaw = Math.round(rec.fee_amount); // phí CHƯA gồm VAT + const feeVatAmount = Math.round(rec.fee_vat_amount || 0); + const feeInclVat = feeAmountRaw + feeVatAmount; + const hasFeeVat = feeVatAmount > 0; // merchant bật VAT trên phí dịch vụ + const feeVatRate = rec.fee_vat_rate ?? 0; // QC #1 — số 5BIB thanh toán THẬT = payout_amount (đã trừ phí thủ công + // cộng điều chỉnh), KHÔNG phải net - feeInclVat. Khớp email + XLSX. const actualPayout = Math.round(rec.payout_amount); + // Payout có thể ÂM (ít đơn online + nhiều vé thủ công / điều chỉnh âm) → + // câu chữ đảo chiều "Bên A thanh toán cho 5BIB" + in trị tuyệt đối. + const payoutNeg = actualPayout < 0; + const payoutAbs = Math.abs(actualPayout); const hasExtraDeductions = (rec.manual_fee_amount || 0) > 0 || (rec.manual_adjustment || 0) !== 0; const netWords = this.numToWords(Math.round(rec.net_revenue)); const feeWords = this.numToWords(feeInclVat); - const payoutWords = this.numToWords(actualPayout); + const payoutWords = this.numToWords(payoutAbs); // "Ngày Hiệu Lực": dùng signed_date_str nếu có, else để trống điền tay. const periodYear = rec.period_end.slice(0, 4); const sd = rec.signed_date_str @@ -401,23 +428,43 @@ export class DocxService { multiPara( [ { - text: `- Căn cứ theo quy định Hợp Đồng, 5BIB được hưởng khoản Phí bán vé là ${rec.fee_rate_applied ?? 0}% tính trên doanh thu bán vé thực tế tương ứng là: `, + // Khi có VAT trên phí: nói rõ "% chưa gồm VAT" + cộng VAT, tránh + // câu chữ ngụ ý feeInclVat = (1) × rate% (sai số học). + text: hasFeeVat + ? `- Căn cứ theo quy định Hợp Đồng, 5BIB được hưởng khoản Phí bán vé là ${rec.fee_rate_applied ?? 0}% (chưa gồm VAT) trên doanh thu bán vé thực tế, cộng Thuế GTGT ${feeVatRate}%; tổng phí tương ứng là: ` + : `- Căn cứ theo quy định Hợp Đồng, 5BIB được hưởng khoản Phí bán vé là ${rec.fee_rate_applied ?? 0}% tính trên doanh thu bán vé thực tế tương ứng là: `, }, { - text: `${fmtVnd(feeInclVat)} VNĐ (2) = (1) × ${rec.fee_rate_applied ?? 0}%`, + // Đẳng thức khớp con số: khi VAT>0 in "= (1) × rate% + VAT vat%", + // khi VAT=0 giữ "= (1) × rate%" (khớp mẫu SCB). + text: hasFeeVat + ? `${fmtVnd(feeInclVat)} VNĐ (2) = (1) × ${rec.fee_rate_applied ?? 0}% + VAT ${feeVatRate}%` + : `${fmtVnd(feeInclVat)} VNĐ (2) = (1) × ${rec.fee_rate_applied ?? 0}%`, bold: true, }, ], { spacingAfter: 60 }, ), para(` (Bằng chữ: ${feeWords})`, { spacingAfter: 120 }), + // Diễn giải tách VAT khi feeVatRate > 0 (minh bạch căn cứ xuất hóa đơn). + ...(hasFeeVat + ? [ + para( + ` Trong đó: Phí dịch vụ = (1) × ${rec.fee_rate_applied ?? 0}% = ${fmtVnd(feeAmountRaw)} VNĐ; Thuế GTGT ${feeVatRate}% = ${fmtVnd(feeVatAmount)} VNĐ.`, + { spacingAfter: 120 }, + ), + ] + : []), multiPara( [ { - text: '- Khoản bán vé 5BIB sẽ thanh toán cho Bên A sau khi cấn trừ các khoản phí dịch vụ: ', + // Payout âm → đảo chiều thanh toán (Bên A trả 5BIB), in trị tuyệt đối. + text: payoutNeg + ? '- Sau khi cấn trừ các khoản phí dịch vụ, Bên A thanh toán cho 5BIB số tiền: ' + : '- Khoản bán vé 5BIB sẽ thanh toán cho Bên A sau khi cấn trừ các khoản phí dịch vụ: ', }, { - text: `${fmtVnd(actualPayout)} VNĐ ${hasExtraDeductions ? '(4)' : '(3)'}`, + text: `${fmtVnd(payoutAbs)} VNĐ ${hasExtraDeductions ? '(4)' : '(3)'}`, bold: true, }, ], @@ -860,8 +907,14 @@ export class DocxService { /* Vietnamese number to words */ /* ------------------------------------------------------------------ */ private numToWords(amount: number): string { + // Chuẩn hoá về số nguyên NGAY đầu hàm (chống carry 1000 khi input lẻ đồng). + amount = Math.round(amount); if (amount === 0) return 'Không đồng'; - if (amount < 0) return 'Âm ' + this.numToWords(-amount); + if (amount < 0) { + // Không viết hoa chữ cái đầu của phần đệ quy → 'Âm một triệu đồng'. + const w = this.numToWords(-amount); + return 'Âm ' + w.charAt(0).toLowerCase() + w.slice(1); + } const units = [ '', @@ -876,51 +929,70 @@ export class DocxService { 'chín', ]; - const convertThreeDigit = (n: number): string => { - const hundreds = Math.floor(n / 100); - const remainder = n % 100; - const tens = Math.floor(remainder / 10); - const ones = remainder % 10; - - let result = ''; - - if (hundreds > 0) { - result += units[hundreds] + ' trăm'; - if (remainder > 0) result += ' '; + /** + * Đọc 1 nhóm 3 chữ số. `full=true` (nhóm KHÔNG dẫn đầu) → bắt buộc thêm + * tiền tố "không trăm" khi hàng trăm = 0 (chuẩn đọc số tiền chứng từ kế + * toán VN: 1.000.005 = "một triệu KHÔNG TRĂM LẺ năm đồng", KHÔNG phải + * "một triệu năm đồng" — dễ nghe nhầm 1,5 triệu). Nhóm dẫn đầu (full=false) + * bỏ "không trăm" để đọc tự nhiên (5.000 = "năm nghìn", không "không trăm"). + */ + const readGroup = (n: number, full: boolean): string => { + const h = Math.floor(n / 100); + const t = Math.floor((n % 100) / 10); + const o = n % 10; + const parts: string[] = []; + let hasHigher = false; + if (h > 0) { + parts.push(units[h] + ' trăm'); + hasHigher = true; + } else if (full) { + parts.push('không trăm'); + hasHigher = true; } - - if (tens === 0 && ones === 0) { - // nothing - } else if (tens === 0 && hundreds > 0) { - result += 'lẻ ' + units[ones]; - } else if (tens === 1) { - result += 'mười'; - if (ones === 1) result += ' một'; - else if (ones === 5) result += ' lăm'; - else if (ones > 0) result += ' ' + units[ones]; - } else if (tens > 1) { - result += units[tens] + ' mươi'; - if (ones === 1) result += ' mốt'; - else if (ones === 5) result += ' lăm'; - else if (ones > 0) result += ' ' + units[ones]; + if (t === 0) { + if (o > 0) parts.push((hasHigher ? 'lẻ ' : '') + units[o]); + } else if (t === 1) { + let s = 'mười'; + if (o === 1) s += ' một'; + else if (o === 5) s += ' lăm'; + else if (o > 0) s += ' ' + units[o]; + parts.push(s); } else { - result += units[ones]; + let s = units[t] + ' mươi'; + if (o === 1) s += ' mốt'; + else if (o === 5) s += ' lăm'; + else if (o > 0) s += ' ' + units[o]; + parts.push(s); } - - return result.trim(); + return parts.join(' ').trim(); }; - const billion = Math.floor(amount / 1_000_000_000); - const million = Math.floor((amount % 1_000_000_000) / 1_000_000); - const thousand = Math.floor((amount % 1_000_000) / 1_000); - const remainder = Math.round(amount % 1_000); + const scales = [ + '', + ' nghìn', + ' triệu', + ' tỷ', + ' nghìn tỷ', + ' triệu tỷ', + ' tỷ tỷ', + ]; + const groups: number[] = []; + let x = amount; + while (x > 0) { + groups.push(x % 1000); + x = Math.floor(x / 1000); + } + + // Nhóm có nghĩa lớn nhất (dẫn đầu) → không thêm "không trăm". + let msi = groups.length - 1; + while (msi > 0 && groups[msi] === 0) msi--; let result = ''; - if (billion > 0) result += convertThreeDigit(billion) + ' tỷ '; - if (million > 0) result += convertThreeDigit(million) + ' triệu '; - if (thousand > 0) result += convertThreeDigit(thousand) + ' nghìn '; - if (remainder > 0) result += convertThreeDigit(remainder); - + for (let i = msi; i >= 0; i--) { + const g = groups[i]; + if (g === 0) continue; // bỏ nhóm toàn 0 (và bỏ luôn từ bậc của nó) + result += (result ? ' ' : '') + readGroup(g, i !== msi) + (scales[i] || ''); + } result = result.trim(); return result.charAt(0).toUpperCase() + result.slice(1) + ' đồng'; } diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts index 761b00e4..1cd15c1c 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts @@ -50,6 +50,22 @@ describe('reconciliation-email.helper', () => { expect(h).not.toContain(''); }); + it('AUDIT: manual_adjustment≠0 → có dòng "Điều chỉnh" (email tự tie-out)', () => { + const h = buildReconEmailHtml({ + ...rec, + manual_adjustment: -200000, + payout_amount: 1195000, + }); + expect(h).toContain('Điều chỉnh'); + expect(h).toContain('-200.000'); + expect(h).toContain('1.195.000'); // hoàn trả + }); + + it('AUDIT: manual_adjustment=0 → KHÔNG có dòng "Điều chỉnh"', () => { + const h = buildReconEmailHtml({ ...rec, manual_adjustment: 0 }); + expect(h).not.toContain('Điều chỉnh'); + }); + it('html: kỳ có giảm giá → Thành tiền = net (khớp tie-out)', () => { const h = buildReconEmailHtml({ ...rec, diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts index ef2c0f13..da761896 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts @@ -97,6 +97,11 @@ export function buildReconEmailHtml(rec: ReconciliationDocument): string { Tổng cộng${totalQty}${vnd(rec.net_revenue)} Phí bán vé (đã gồm VAT)${vnd(feeInclVat)} Phí dịch vụ${vnd(rec.manual_fee_amount || 0)} + ${ + (rec.manual_adjustment || 0) !== 0 + ? `Điều chỉnh${vnd(rec.manual_adjustment)}` + : '' + } Hoàn trả merchant${vnd(rec.payout_amount)} diff --git a/backend/src/modules/reconciliation/services/xlsx.service.spec.ts b/backend/src/modules/reconciliation/services/xlsx.service.spec.ts index 230d3bb0..e7e7952f 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.spec.ts @@ -97,6 +97,30 @@ describe('XlsxService — khớp mẫu T6', () => { expect(ws.getCell(vatRow, 8).value).toBe(8400); }); + it('AUDIT: dòng "Điều chỉnh" hiện khi manual_adjustment≠0 → footer tự cộng-trừ ra payout', async () => { + // net 1.500.000 − phí 105.000 − đ/chỉnh 200.000 = 1.195.000 + const ws = ( + await load( + baseRec({ manual_adjustment: -200000, payout_amount: 1195000 }), + ) + ).getWorksheet('Tổng quan')!; + const adjRow = findRow(ws, 1, 'Điều chỉnh'); + expect(adjRow).toBeGreaterThan(0); + expect(ws.getCell(adjRow, 8).value).toBe(-200000); + // tie-out: net − phí bán vé − phí dịch vụ − |điều chỉnh| = hoàn trả + const fee = ws.getCell(findRow(ws, 1, 'Phí bán vé'), 8).value as number; + const svcFee = ws.getCell(findRow(ws, 1, 'Phí dịch vụ'), 8).value as number; + const adj = ws.getCell(adjRow, 8).value as number; + const payout = ws.getCell(findRow(ws, 1, 'Hoàn trả merchant'), 8) + .value as number; + expect(1500000 - fee - svcFee + adj).toBe(payout); + }); + + it('AUDIT: manual_adjustment=0 → KHÔNG có dòng "Điều chỉnh" (khớp mẫu SCB)', async () => { + const ws = (await load(baseRec())).getWorksheet('Tổng quan')!; + expect(findRow(ws, 1, 'Điều chỉnh')).toBe(-1); + }); + it('tie-out (#8): cột "Tổng cộng" Section 3 (dòng Tổng cộng) = Section-1 GMV', async () => { const ws = (await load(baseRec())).getWorksheet('Tổng quan')!; const totRow = findRow(ws, 1, 'Tổng cộng'); // 'Tổng cộng' đầu tiên (Section 1) — cần dòng Section 3 diff --git a/backend/src/modules/reconciliation/services/xlsx.service.ts b/backend/src/modules/reconciliation/services/xlsx.service.ts index 8ba25da7..92480d74 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.ts @@ -296,6 +296,12 @@ export class XlsxService { if ((rec.fee_vat_amount || 0) > 0) { footer(`Thuế GTGT (${rec.fee_vat_rate}%)`, rec.fee_vat_amount); } + // Dòng "Điều chỉnh" (nếu có) — để bảng tự cộng-trừ ra "Hoàn trả merchant", + // khớp biên bản DOCX (buildRevenueTable). Thiếu dòng này → lệch im lặng + // đúng bằng khoản điều chỉnh. + if ((rec.manual_adjustment || 0) !== 0) { + footer('Điều chỉnh', rec.manual_adjustment); + } footer('Hoàn trả merchant', rec.payout_amount); } From 252fe9ad982a3a55c6110ab284961009414f4d9a Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 22:37:48 +0700 Subject: [PATCH 13/23] =?UTF-8?q?docs(recon):=2005-docs-audit=20=E2=80=94?= =?UTF-8?q?=20audit=20gi=E1=BA=A5y=20t=E1=BB=9D=20(19=20confirmed)=20+=20t?= =?UTF-8?q?r=C6=B0=E1=BB=9Bc/sau=20s=E1=BB=91=20b=E1=BA=B1ng=20ch=E1=BB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/recon-automation/05-docs-audit.md | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/recon-automation/05-docs-audit.md diff --git a/docs/recon-automation/05-docs-audit.md b/docs/recon-automation/05-docs-audit.md new file mode 100644 index 00000000..268abeee --- /dev/null +++ b/docs/recon-automation/05-docs-audit.md @@ -0,0 +1,39 @@ +# 05 — Audit giấy tờ đối soát (số bằng chữ / số tiền / thông tin đối tác) + +- **Branch:** `fix/recon-accuracy-pha1` · **Ngày:** 2026-07-10 · **Commit fix:** `ce84033` +- **Trigger:** Danny — "đợt trước k dám dùng vì nó sai tà lưa; check kỹ thông tin đối tác, số tiền, số tiền bằng chữ, số tiền trả". +- **Method:** Workflow đối kháng 5 lens (số bằng chữ / footing / thông tin đối tác / xlsx / tie-out chéo) × find → verify (phản biện) — 21 finding thô, **19 confirmed, 2 refuted**. + battery test `numToWords` 32 case. + +## Đã sửa (commit ce84033) + +| # | Mức | Lỗi | Fix | +|---|-----|-----|-----| +| 1 | 🔴 CRITICAL | **Số bằng chữ**: `numToWords` bỏ "không trăm"/"lẻ" cho nhóm không dẫn đầu có hàng trăm=0. `1.000.005` → in **"Một triệu năm đồng"** (nghe thành 1,5tr — lệch bậc tiền trên giấy ký) | Viết lại theo chuẩn kế toán VN. Nhóm giữa/cuối trăm=0 → "không trăm [lẻ]". PASS 32/32 battery | +| 2 | 🟠 HIGH | Số bằng chữ nhóm tỷ/triệu/nghìn 1-99 trăm=0 mất "không trăm" (`7.050.000` → "Bảy triệu năm mươi nghìn") | Cùng patch #1 | +| 3 | 🟠 HIGH | **Chức vụ bịa**: hardcode default `'Tổng Giám đốc'` cho người ký khi không có nguồn → khẳng định sai thẩm quyền đại diện | Bỏ default → để trống, điền tay | +| 4 | 🟠 HIGH | **VAT**: bullet `"(2) = (1) × X%"` sai số học khi `feeVatRate>0` (feeInclVat đã gồm VAT) | VAT>0 → in `"= (1) × rate% + VAT vat%"` + dòng tách phí/VAT; VAT=0 giữ mẫu SCB | +| 5 | 🟠 HIGH | **Tie-out**: XLSX + email thiếu dòng "Điều chỉnh" (`manual_adjustment`) → số không tự cộng-trừ ra payout, lệch DOCX | Thêm dòng "Điều chỉnh" khi ≠0 (cả xlsx + email) | +| 6 | 🟡 MEDIUM | **Payout âm**: prose "5BIB thanh toán cho Bên A: **-X**" vô nghĩa | payout<0 → đảo chiều "Bên A thanh toán cho 5BIB" + trị tuyệt đối | +| 7 | 🟡 LOW | Số âm viết hoa "M" giữa câu ("Âm Một triệu") | Lowercase chữ đầu phần đệ quy | +| + | — | Thiếu định danh Bên A (tên pháp nhân/MST/STK) ship âm thầm | Thêm WARN `docx_party_incomplete` để ops thấy trước khi gửi | + +### Trước/sau — số bằng chữ trên payout thực tế +``` + 1.380.000 | (giống — vốn đúng) | Một triệu ba trăm tám mươi nghìn đồng + 7.050.000 | Bảy triệu năm mươi nghìn đồng → Bảy triệu KHÔNG TRĂM năm mươi nghìn đồng + 30.050.000 | Ba mươi triệu năm mươi nghìn đồng → Ba mươi triệu KHÔNG TRĂM năm mươi nghìn đồng + 5.005.000 | Năm triệu năm nghìn đồng → Năm triệu KHÔNG TRĂM LẺ năm nghìn đồng + 1.000.005 | Một triệu năm đồng (nghe = 1,5tr!) → Một triệu KHÔNG TRĂM LẺ năm đồng + 1.050.000.000 | Một tỷ năm mươi triệu đồng → Một tỷ KHÔNG TRĂM năm mươi triệu đồng +``` + +## Refuted (không phải lỗi) +- **"bốn" vs "tư"**: cả hai hợp lệ trên chứng từ; "bốn" chuẩn, không nhầm nghĩa. +- **Bên B `env.provider.*` "trống trên PROD"**: đã có Joi default = **giá trị BIDV thật hiện hành** (MST `0110398986`, TK `2600398986`, cập nhật 2026-07-09). Không trống. → gỡ luôn lo ngại `PROVIDER_BANK_*` từ QC trước. + +## Còn lại — cần Danny quyết (chưa sửa) +1. **Nhãn trùng `'Phí dịch vụ'/'Phí dịch vụ'` (Section 2 email+xlsx)** + prose thiếu mốc (3) khi có phí thủ công: nghi **bám mẫu T6 cố ý** (test `xlsx C11='Phí dịch vụ'` lock nhãn này). Chỉ đổi nếu mẫu gốc T6 cho phép — cần anh xác nhận với file mẫu. +2. **Chặn cứng vs cảnh báo** khi Bên A thiếu MST/tên pháp nhân/STK: hiện chỉ WARN log + flow review-then-send có người duyệt. Nếu muốn **chặn phát hành** (fail-fast) khi thiếu MST → cần anh chốt (rủi ro: chặn cả kỳ hợp lệ mà admin chưa kịp nhập). +3. **Phí ex-VAT vs gồm-VAT hiển thị** giữa email Section 1 (ex-VAT) và docx (gồm VAT): chỉ lệch khi `feeVatRate>0` (mặc định 0 nên hiện tất cả merchant đều khớp). Payout (số thực trả) luôn khớp cả 3 file. + +**Trạng thái:** 157/157 test recon pass, build xanh. Các lỗi output-sai đã đóng; phần còn lại là quyết định nghiệp vụ + xác nhận mẫu. From bdb0db0e91051d52ddf52631b9d0d60f8c1ebaa3 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 22:44:58 +0700 Subject: [PATCH 14/23] =?UTF-8?q?fix(recon):=20nh=C3=A3n=20c=E1=BB=99t=20"?= =?UTF-8?q?=C4=90=C6=A1n=20gi=C3=A1=20ph=C3=AD/v=C3=A9"+"Th=C3=A0nh=20ti?= =?UTF-8?q?=E1=BB=81n"=20+=20di=E1=BB=85n=20gi=E1=BA=A3i=20m=E1=BB=91c=20(?= =?UTF-8?q?3)=20(Danny=20ch=E1=BB=91t=20s=E1=BB=ADa=20r=C3=B5=20ngh=C4=A9a?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mục 2 (email+xlsx): bỏ nhãn trùng "Phí dịch vụ"/"Phí dịch vụ" → "Đơn giá phí/vé" (cột 3) + "Thành tiền" (cột 4). Cập nhật test lock C11. - DOCX: khi có phí thủ công/điều chỉnh, diễn giải định nghĩa "(3)=(1)-(2)" + liệt kê "Trừ phí giao dịch thủ công"/"Điều chỉnh" trước mốc (4) → truy vết đầy đủ, không nhảy (2)→(4). - Danny chốt: thiếu định danh Bên A chỉ CẢNH BÁO (giữ WARN, không chặn cứng). +2 test. 159/159 recon pass, build xanh. Co-Authored-By: Claude Opus 4.8 --- .../services/docx.service.spec.ts | 19 ++++++++++ .../reconciliation/services/docx.service.ts | 38 ++++++++++++++++++- .../services/reconciliation-email.helper.ts | 2 +- .../services/xlsx.service.spec.ts | 4 +- .../reconciliation/services/xlsx.service.ts | 3 +- 5 files changed, 61 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index 324e0c40..493067b4 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -375,4 +375,23 @@ describe('DocxService — khớp mẫu T6 + regression', () => { const t = await xml(await docxSvc.generate(rec)); expect(t).not.toContain('Tổng Giám đốc'); }); + + it('AUDIT-P3-01: CÓ phí thủ công → diễn giải có mốc (3)=(1)-(2) + dòng trừ', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + manual_fee_amount: 2000000, + payout_amount: rec.payout_amount - 2000000, + }), + ); + // Mốc (3) được định nghĩa trong PROSE (không chỉ trong bảng) + expect(t).toContain('Số tiền sau phí bán vé:'); + expect(t).toContain('Trừ phí giao dịch thủ công: 2.000.000 VNĐ'); + }); + + it('AUDIT-P3-02: KHÔNG phí thủ công → prose KHÔNG chèn mốc (3) rời (giữ mẫu SCB)', async () => { + const t = await xml(await docxSvc.generate(rec)); + // Không có dòng prose "(3) = (1) - (2)" (chỉ có trong bảng doanh thu) + expect(t).not.toContain('Số tiền sau phí bán vé:'); + }); }); diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index cae3471f..76e1d21f 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -267,8 +267,11 @@ export class DocxService { // câu chữ đảo chiều "Bên A thanh toán cho 5BIB" + in trị tuyệt đối. const payoutNeg = actualPayout < 0; const payoutAbs = Math.abs(actualPayout); - const hasExtraDeductions = - (rec.manual_fee_amount || 0) > 0 || (rec.manual_adjustment || 0) !== 0; + const manualFeeAmt = Math.round(rec.manual_fee_amount || 0); + const adjustmentAmt = Math.round(rec.manual_adjustment || 0); + const hasExtraDeductions = manualFeeAmt > 0 || adjustmentAmt !== 0; + // (3) = (1) - (2): số sau phí bán vé, TRƯỚC khi trừ phí thủ công/điều chỉnh. + const payoutAfterFee = Math.round(rec.net_revenue) - feeInclVat; const netWords = this.numToWords(Math.round(rec.net_revenue)); const feeWords = this.numToWords(feeInclVat); const payoutWords = this.numToWords(payoutAbs); @@ -455,6 +458,37 @@ export class DocxService { ), ] : []), + // Khi có phí thủ công/điều chỉnh: định nghĩa (3)=(1)-(2) rồi liệt kê + // các khoản cấn trừ trong prose để mốc (4) có đường truy vết đầy đủ. + ...(hasExtraDeductions + ? [ + multiPara( + [ + { text: '- Số tiền sau phí bán vé: ' }, + { + text: `${fmtVnd(payoutAfterFee)} VNĐ (3) = (1) - (2)`, + bold: true, + }, + ], + { spacingAfter: 60 }, + ), + ...(manualFeeAmt > 0 + ? [ + para( + ` - Trừ phí giao dịch thủ công: ${fmtVnd(manualFeeAmt)} VNĐ`, + { spacingAfter: 60 }, + ), + ] + : []), + ...(adjustmentAmt !== 0 + ? [ + para(` - Điều chỉnh: ${fmtVnd(adjustmentAmt)} VNĐ`, { + spacingAfter: 60, + }), + ] + : []), + ] + : []), multiPara( [ { diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts index da761896..dde83c74 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts @@ -85,7 +85,7 @@ export function buildReconEmailHtml(rec: ReconciliationDocument): string {

2. GIAO DỊCH THỦ CÔNG

- ${TH('STT')}${TH('Số lượng vé')}${TH('Phí dịch vụ')}${TH('Phí dịch vụ')} + ${TH('STT')}${TH('Số lượng vé')}${TH('Đơn giá phí/vé')}${TH('Thành tiền')}
1${rec.manual_ticket_count || ''}${rec.manual_ticket_count ? vnd(rec.manual_fee_per_ticket) : ''}${vnd(rec.manual_fee_amount || 0)}
Tổng cộng${vnd(rec.manual_fee_amount || 0)}
diff --git a/backend/src/modules/reconciliation/services/xlsx.service.spec.ts b/backend/src/modules/reconciliation/services/xlsx.service.spec.ts index e7e7952f..f220a13e 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.spec.ts @@ -67,7 +67,9 @@ describe('XlsxService — khớp mẫu T6', () => { expect(ws.getCell('A10').value).toBe('2. GIAO DỊCH THỦ CÔNG'); expect(ws.getCell('A15').value).toBe('3. CHI TIẾT GIAO DỊCH'); expect(ws.getCell('D15').value).toBe('Add-on products'); // header gộp add-on - expect(ws.getCell('C11').value).toBe('Phí dịch vụ'); // (không phải 'Phí/vé') + // Mục 2: cột 3 = đơn giá phí/vé, cột 4 = thành tiền (bỏ nhãn trùng cũ) + expect(ws.getCell('C11').value).toBe('Đơn giá phí/vé'); + expect(ws.getCell('D11').value).toBe('Thành tiền'); }); it('Section 1 giá trị: tỷ lệ 7% + phí 105.000', async () => { diff --git a/backend/src/modules/reconciliation/services/xlsx.service.ts b/backend/src/modules/reconciliation/services/xlsx.service.ts index 92480d74..c82d1bcb 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.ts @@ -146,7 +146,8 @@ export class XlsxService { }); r++; - ['STT', 'Số lượng vé', 'Phí dịch vụ', 'Phí dịch vụ'].forEach((txt, i) => + // Cột 3 = đơn giá phí/vé, cột 4 = thành tiền (bỏ nhãn trùng 'Phí dịch vụ'). + ['STT', 'Số lượng vé', 'Đơn giá phí/vé', 'Thành tiền'].forEach((txt, i) => styleCell(ws.getRow(r).getCell(i + 1), txt, { font: FONT_BOLD }), ); r++; From c639f1147866834939df313b5df761ebd712353b Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Fri, 10 Jul 2026 22:45:36 +0700 Subject: [PATCH 15/23] =?UTF-8?q?docs(recon):=2005-docs-audit=20=E2=80=94?= =?UTF-8?q?=20c=E1=BA=ADp=20nh=E1=BA=ADt=202=20quy=E1=BA=BFt=20=C4=91?= =?UTF-8?q?=E1=BB=8Bnh=20Danny=20=C4=91=C3=A3=20=C3=A1p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/recon-automation/05-docs-audit.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/recon-automation/05-docs-audit.md b/docs/recon-automation/05-docs-audit.md index 268abeee..f35477f3 100644 --- a/docs/recon-automation/05-docs-audit.md +++ b/docs/recon-automation/05-docs-audit.md @@ -31,9 +31,12 @@ - **"bốn" vs "tư"**: cả hai hợp lệ trên chứng từ; "bốn" chuẩn, không nhầm nghĩa. - **Bên B `env.provider.*` "trống trên PROD"**: đã có Joi default = **giá trị BIDV thật hiện hành** (MST `0110398986`, TK `2600398986`, cập nhật 2026-07-09). Không trống. → gỡ luôn lo ngại `PROVIDER_BANK_*` từ QC trước. -## Còn lại — cần Danny quyết (chưa sửa) -1. **Nhãn trùng `'Phí dịch vụ'/'Phí dịch vụ'` (Section 2 email+xlsx)** + prose thiếu mốc (3) khi có phí thủ công: nghi **bám mẫu T6 cố ý** (test `xlsx C11='Phí dịch vụ'` lock nhãn này). Chỉ đổi nếu mẫu gốc T6 cho phép — cần anh xác nhận với file mẫu. -2. **Chặn cứng vs cảnh báo** khi Bên A thiếu MST/tên pháp nhân/STK: hiện chỉ WARN log + flow review-then-send có người duyệt. Nếu muốn **chặn phát hành** (fail-fast) khi thiếu MST → cần anh chốt (rủi ro: chặn cả kỳ hợp lệ mà admin chưa kịp nhập). -3. **Phí ex-VAT vs gồm-VAT hiển thị** giữa email Section 1 (ex-VAT) và docx (gồm VAT): chỉ lệch khi `feeVatRate>0` (mặc định 0 nên hiện tất cả merchant đều khớp). Payout (số thực trả) luôn khớp cả 3 file. +## Quyết định Danny (2026-07-10) — đã áp -**Trạng thái:** 157/157 test recon pass, build xanh. Các lỗi output-sai đã đóng; phần còn lại là quyết định nghiệp vụ + xác nhận mẫu. +1. **Nhãn trùng `'Phí dịch vụ'` (mục 2) + prose thiếu mốc (3)** → Danny chốt **"sửa cho rõ nghĩa"**. ✅ Đã đổi cột 3="Đơn giá phí/vé", cột 4="Thành tiền" (email+xlsx); DOCX thêm diễn giải "(3)=(1)-(2)" + dòng "Trừ phí giao dịch thủ công"/"Điều chỉnh" trước (4). Commit `bdb0db0`. +2. **Thiếu định danh Bên A (MST/tên/STK)** → Danny chốt **"chỉ cảnh báo"**. ✅ Giữ WARN `docx_party_incomplete` + flow review-then-send, KHÔNG chặn cứng. + +## Còn lại (thấp, không chặn) +3. **Phí ex-VAT (email Section 1) vs gồm-VAT (docx)**: chỉ lệch khi `feeVatRate>0` (mặc định 0 → hiện tất cả merchant đều khớp). Payout (số thực trả) luôn khớp cả 3 file. Docx đã in đúng "(2)=(1)×rate%+VAT vat%" khi có VAT — nếu sau này có merchant bật VAT phí thì cân nhắc đồng bộ luôn email Section 1. + +**Trạng thái:** 159/159 test recon pass, build xanh, đã push. Toàn bộ lỗi output-sai + 2 quyết định nghiệp vụ đã đóng. From 346887e6355c535f6e961c2f6774ff327f700d1c Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 10:46:21 +0700 Subject: [PATCH 16/23] =?UTF-8?q?fix(recon):=20s=E1=BB=91=20b=E1=BA=B1ng?= =?UTF-8?q?=20ch=E1=BB=AF=20d=C3=B9ng=20"linh"=20(house-style=205BIB)=20+?= =?UTF-8?q?=20b=C3=A1o=20c=C3=A1o=20r=C3=A0=20so=C3=A1t=2022=20k=E1=BB=B3?= =?UTF-8?q?=20=C4=91=E1=BB=91i=20so=C3=A1t=20th=E1=BA=ADt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit 45 file DOCX/XLSX hồ sơ đối soát tay T4/T5/T6 2026 (13 giải, 9 merchant): 86 lỗi con người (8 CRITICAL). Nổi bật: - Biên bản Hoi An Heritage T5+T6 đều mang nhầm tên "Danang Mountain Trail" - Danang T6: 76.945đ viết chữ thành "Bảy mươi sáu triệu..." (gấp 1000 lần) - Cao Bằng T5: số 0đ nhưng 3 dòng bằng chữ residue 61,7tr từ giải khác - Mẫu canonical SCB T5+T6 THIẾU "nghìn" (1.395.000 → "Một triệu ba trăm chín mươi lăm đồng") — hệ mới không replicate - Đường Đua Mới 4/4 kỳ DOCX bỏ sót phí thủ công (lệch XLSX 25K–105K) Fix áp ngay: numToWords "lẻ" → "linh" — 100% hồ sơ thật dùng "linh" (0 "lẻ"). 159/159 test pass. Báo cáo đầy đủ: docs/recon-automation/06-real-docs-review.md (registry 9 merchant seed MerchantConfig + gap analysis VTV LPBank/giảm giá/ change-course + 2 phiên bản bank Bên B). Co-Authored-By: Claude Opus 4.8 --- .../services/docx.service.spec.ts | 14 +- .../reconciliation/services/docx.service.ts | 6 +- docs/recon-automation/06-real-docs-review.md | 186 ++++++++++++++++++ 3 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 docs/recon-automation/06-real-docs-review.md diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index 493067b4..c216c1a6 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -292,10 +292,10 @@ describe('DocxService — khớp mẫu T6 + regression', () => { expect(await xml(buf)).toContain('FALLBACK PLATFORM'); }); - /* ── Số bằng chữ: chuẩn kế toán VN "không trăm / lẻ" (audit 2026-07-10) ── */ + /* ── Số bằng chữ: chuẩn kế toán VN "không trăm / linh" (audit 2026-07-10) ── */ - it('AUDIT-NW-01: nhóm giữa/cuối trăm=0 có "không trăm lẻ" (KHÔNG đọc lệch bậc)', async () => { - // net = 5.005.000 → "Năm triệu không trăm lẻ năm nghìn đồng" + it('AUDIT-NW-01: nhóm giữa/cuối trăm=0 có "không trăm linh" (KHÔNG đọc lệch bậc)', async () => { + // net = 5.005.000 → "Năm triệu không trăm linh năm nghìn đồng" const t = await xml( await docxSvc.generate({ ...rec, @@ -305,12 +305,12 @@ describe('DocxService — khớp mẫu T6 + regression', () => { payout_amount: 5005000, }), ); - expect(t).toContain('Năm triệu không trăm lẻ năm nghìn đồng'); - // Chuỗi SAI cũ (thiếu "không trăm lẻ") tuyệt đối không còn + expect(t).toContain('Năm triệu không trăm linh năm nghìn đồng'); + // Chuỗi SAI cũ (thiếu "không trăm linh") tuyệt đối không còn expect(t).not.toContain('(Bằng chữ: Năm triệu năm nghìn đồng)'); }); - it('AUDIT-NW-02: 1.000.005 → "một triệu không trăm lẻ năm" (không nghe thành 1,5tr)', async () => { + it('AUDIT-NW-02: 1.000.005 → "một triệu không trăm linh năm" (không nghe thành 1,5tr)', async () => { const t = await xml( await docxSvc.generate({ ...rec, @@ -320,7 +320,7 @@ describe('DocxService — khớp mẫu T6 + regression', () => { payout_amount: 1000005, }), ); - expect(t).toContain('Một triệu không trăm lẻ năm đồng'); + expect(t).toContain('Một triệu không trăm linh năm đồng'); expect(t).not.toContain('(Bằng chữ: Một triệu năm đồng)'); }); diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index 76e1d21f..6843cdf5 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -966,9 +966,11 @@ export class DocxService { /** * Đọc 1 nhóm 3 chữ số. `full=true` (nhóm KHÔNG dẫn đầu) → bắt buộc thêm * tiền tố "không trăm" khi hàng trăm = 0 (chuẩn đọc số tiền chứng từ kế - * toán VN: 1.000.005 = "một triệu KHÔNG TRĂM LẺ năm đồng", KHÔNG phải + * toán VN: 1.000.005 = "một triệu KHÔNG TRĂM LINH năm đồng", KHÔNG phải * "một triệu năm đồng" — dễ nghe nhầm 1,5 triệu). Nhóm dẫn đầu (full=false) * bỏ "không trăm" để đọc tự nhiên (5.000 = "năm nghìn", không "không trăm"). + * Dùng "linh" (không dùng "lẻ") — audit 2026-07-10 toàn bộ 22 biên bản + * thật T5+T6 của 5BIB đều dùng "linh" (house-style), 0 bản dùng "lẻ". */ const readGroup = (n: number, full: boolean): string => { const h = Math.floor(n / 100); @@ -984,7 +986,7 @@ export class DocxService { hasHigher = true; } if (t === 0) { - if (o > 0) parts.push((hasHigher ? 'lẻ ' : '') + units[o]); + if (o > 0) parts.push((hasHigher ? 'linh ' : '') + units[o]); } else if (t === 1) { let s = 'mười'; if (o === 1) s += ' một'; diff --git a/docs/recon-automation/06-real-docs-review.md b/docs/recon-automation/06-real-docs-review.md new file mode 100644 index 00000000..c0fe3482 --- /dev/null +++ b/docs/recon-automation/06-real-docs-review.md @@ -0,0 +1,186 @@ +# 06 — Rà soát hồ sơ đối soát THẬT T4/T5/T6 2026 (22 kỳ, 13 giải, 9 merchant) + +- **Nguồn:** `~/Downloads/Tháng 5.2026` + `Tháng 6.2026` (Danny cung cấp 2026-07-10 — "đủ các format 5BIB đã và đang làm"). +- **Method:** extract 45 file DOCX/XLSX → workflow 14 agent audit song song (số học / số bằng chữ / thông tin / format / registry) + synthesis. Các claim CRITICAL đã spot-check nguyên văn. +- **Kết quả:** 86 lỗi con người (8 CRITICAL / 13 HIGH / 12 MEDIUM / 53 LOW). + +## Hành động đã áp ngay vào hệ thống mới +- **"lẻ" → "linh"**: 100% hồ sơ thật dùng "linh" (5 lần), 0 bản dùng "lẻ" → đổi `numToWords` khớp house-style (commit này). +- Xác nhận hệ mới **KHÔNG replicate** 2 lỗi nằm trong chính quy trình cũ: (a) mẫu canonical SCB T5+T6 thiếu chữ "nghìn" ở dòng (3) bằng chữ; (b) DOCX bỏ sót phí thủ công (Đường Đua Mới 4/4 kỳ) — hệ mới có dòng "Trừ phí giao dịch thủ công"+(4). + +--- + +# BÁO CÁO TỔNG HỢP AUDIT HỒ SƠ ĐỐI SOÁT THẬT 5BIB — KỲ T4/T5/T6 2026 +**Phạm vi:** 14 nhóm hồ sơ (9 merchant, 14 giải/kỳ), nguồn `~/Downloads/Tháng 5.2026` + `~/Downloads/Tháng 6.2026` + +--- + +## 1. BẢNG REGISTRY MERCHANT (9 pháp nhân — đã gộp trùng) + +| # | Legal name | MST | Địa chỉ | Phone | Đại diện + chức vụ | Bank Bên A | Ghi chú T5 vs T6 / rủi ro seed | +|---|---|---|---|---|---|---|---| +| 1 | CÔNG TY TNHH BELOVE MOUNTAIN | 0402262453 | 101 Phạm Văn Tráng, P. Hoà Khánh Bắc, Q. Liên Chiểu, Đà Nẵng | 0935950111 | Ông LÃ MINH — Giám đốc | Techcombank, STK **169111** | Nhất quán T5=T6 cả 2 giải. ⚠️ STK chỉ 6 chữ số — phải xác minh (alias Techcombank hay bị cắt cụt) TRƯỚC khi seed. Folder ghi sai "Belove Moutain" (thiếu n) | +| 2 | CÔNG TY TNHH ĐỨC HƯƠNG ANH (DHA) | 0102563058 | Tầng 5, TTTM V+, 505 Minh Khai, P. Vĩnh Tuy, Hà Nội | 024 3824 2626 | Ông NGUYỄN TRÍ — Tổng Giám đốc | **KHÔNG có trong hồ sơ** (cả 2 giải, cả T5+T6) | Nhất quán 100% T5=T6. Phải lấy bank từ nguồn khác dù (3) là khoản 5BIB chuyển cho Bên A | +| 3 | CÔNG TY CỔ PHẦN ĐƯỜNG ĐUA MỚI (Newrace) | 0108087302 | C02-L14 An Vượng Villa, KĐT Dương Nội, P. Dương Nội, Hà Nội | 0902294242 | Ông ĐẶNG QUANG ĐỨC — Giám đốc | BIDV CN Mỹ Đình, STK 26010001193740 | Nhất quán qua 3 giải × 2-3 kỳ. Merchant duy nhất có phí GD thủ công 5.000đ/vé | +| 4 | CÔNG TY CỔ PHẦN TRUYỀN THÔNG NEXUS | 0311627650 | 18B/18 Nguyễn Thị Minh Khai, P. Đa Kao, Q.1, TP.HCM | (028) 62903065 | NGUYỄN TỬ NGỌC ANH — Giám đốc | Techcombank CN Tân Định, STK 88079079 | Chỉ có T5, không so được T5 vs T6 | +| 5 | CÔNG TY CỔ PHẦN NÓN XANH | 0310118467 | Lầu 6, Tòa nhà Anh Đăng, 215 Nam Kỳ Khởi Nghĩa, P. Xuân Hòa, TP.HCM | 02839326177 (XLSX ghi "028 39326177" — khác spacing) | Bà PHẠM THỊ MINH PHƯƠNG — Tổng Giám đốc | **KHÔNG có trong hồ sơ** | T5=T6 về pháp nhân; format file/sheet lệch giữa 2 kỳ | +| 6 | CÔNG TY CỔ PHẦN PHONG NHA WILD | 3101146201 | Tổ dân phố Xuân Tiến, xã Phong Nha, Quảng Trị | **KHÔNG có trong hồ sơ** | Ông NGUYỄN HÙNG CƯỜNG — Tổng Giám đốc | MB CN Lý Nam Đế, STK 8868336688 | Chỉ T5. Thiếu phone khi seed | +| 7 | CÔNG TY CỔ PHẦN THÀNH AN MEDIA | 0110446252 | TM01-22, Tòa W1, Vinhomes West Point, Đỗ Đức Dục, P. Từ Liêm, Hà Nội | 0985 737 168 | Bà VŨ PHAN ANH — Tổng Giám đốc | Techcombank CN Trần Duy Hưng, STK 883636666 | 1 kỳ duy nhất (khoảng ngày 18–20/05). Hồ sơ đặc thù nhất bộ (phí theo kênh, net settlement) | +| 8 | CÔNG TY CỔ PHẦN VŨ MEDIA | 0107249159 | Số 10A1, ngõ 376 Khương Đình, P. Khương Đình, Hà Nội | 0933286355 | Ông VŨ VĂN PHONG — Giám đốc | **KHÔNG có trong hồ sơ** | Nhất quán T5=T6; typo năm "2025" lặp cả 2 kỳ | +| 9 | CÔNG TY CỔ PHẦN ZAHA VIỆT NAM | 0110699415 | Số 124, ngõ 11 Thụy Khuê, P. Tây Hồ, Hà Nội | 08 45678 880 (format giãn cách lạ) | Ông TRẦN LƯU NGỌC ANH — Giám đốc | ACB CN Hà Thành, STK **123456788886** | Nhất quán qua 2 giải. ⚠️ STK dạng số tuần tự nghi placeholder — phải xác minh trước khi seed | + +**Tổng kết seed MerchantConfig:** 4/9 merchant thiếu bank Bên A (DHA, Nón Xanh, Vũ Media — không có; Phong Nha thiếu phone), 2/9 có STK nghi vấn (Belove, Zaha). Chức vụ đại diện có 2 biến thể (Giám đốc / Tổng Giám đốc) → field `rep_title` riêng là bắt buộc, không hardcode. + +--- + +## 2. CATALOGUE LỖI CON NGƯỜI (nhóm theo type, sort severity) + +### 2.1. `wrong-race-name` — nhầm tên giải (copy-paste chéo hồ sơ) +| Sev | File | Evidence | +|---|---|---| +| CRITICAL | `[Belove Moutain] Hoi An Heritage/040626_..._BIEN BAN DOI SOAT-T5.docx` | Title + mục Nghiệm thu ghi **"Danang Mountain Trail"** trong khi số liệu là của Hoi An Heritage (khớp 100% xlsx: 5.090.000/279.950/4.810.050) | +| CRITICAL | `[Belove Moutain] Hoi An Heritage/060726_...-T6.docx` | **Lặp lại y hệt T5** — lỗi template tồn tại 2 tháng liên tiếp không ai phát hiện | +| CRITICAL | `260526_VTV-LOCPHAT-1805-2005.xlsx` R1 | "SỰ KIỆN: **Hành Trình Vì An Ninh Tổ Quốc**" (giải Công An) — trong khi nội dung là VTV LPBank 2026 | +| HIGH | `260526_Hành Trình Vì An Ninh Tổ Quốc_BBDS-1805-2005.docx` (tên file) | Tên FILE mang tên giải Công An, nội dung là VTV LPBank | +| LOW | `040626_Cuc Phuong Jungle Path-T5.xlsx` + T6 | Tên file thiếu "s" (Jungle Path vs Paths) | + +### 2.2. `words-number` — số bằng chữ sai con số (rủi ro pháp lý cao nhất) +| Sev | File | Evidence | +|---|---|---| +| CRITICAL | `040626_Cao Bang Ultra Trail_BBDS-T5.docx` | **3 dòng liền** ghi số = 0 VNĐ nhưng bằng chữ đọc 61.770.000 / 3.397.350 / 58.372.650 — residue copy nguyên bộ từ biên bản giải khác | +| CRITICAL | `060726_Danang Mountain Trail_BIEN BAN DOI SOAT - T6.docx` | Phí 76.945đ → chữ "Bảy mươi sáu **triệu**..." = 76.945.000, **gấp 1000 lần** | +| CRITICAL | `040626_Hai Phong Legacy_BIEN BAN DOI SOAT - T5.docx` | (1)=3.898.000 → chữ đọc "ba trăm tám mươi **chín** nghìn" = 3.989.000, lệch 91.000 | +| HIGH | `040626_Standard Chartered..._BBDS_T5.docx` **và** T6 | (3)=1.395.000 → "Một triệu ba trăm chín mươi lăm đồng" — **thiếu "nghìn"**. ⚠️ Lỗi này nằm NGAY TRONG mẫu canonical T6 mà hệ mới bám theo — hệ mới KHÔNG được replicate | + +### 2.3. `inconsistency-docx-xlsx` — DOCX và XLSX cùng kỳ lệch nhau +| Sev | Hồ sơ | Evidence | +|---|---|---| +| HIGH | Cuc Phuong T5 | DOCX payout 7.998.480 — XLSX trừ thêm phí thủ công 35.000 (7 vé×5.000) → 7.963.480. **Lệch 35.000** | +| HIGH | Cuc Phuong T6 | DOCX (3)=0đ — XLSX có phí thủ công 15.000 bị công thức IF clamp về 0 → **công nợ 15.000 biến mất, không truy vết** | +| HIGH | Ha Giang T4 | DOCX payout 926.100 — XLSX trừ 105.000 (21 vé×5.000) → 821.100. **Lệch 105.000** | +| HIGH | Cao Bằng T5 | DOCX payout 0đ — XLSX Hoàn trả merchant = **−75.000** (payout ÂM, merchant nợ 5BIB) | +| HIGH | Cao Bằng T6 | DOCX 2.281.910 — XLSX 2.256.910 (quên trừ 25.000 phí thủ công) | +| HIGH | DHA Halong T5+T6 | Nhãn "(3) Tổng phí dịch vụ của 5BIB = (1)−(2)" sai nghĩa — (3) thực là khoản HOÀN TRẢ Bên A (đúng bug hệ mới đã fix ở commit bdb0db0) | +| HIGH | Trang An T5+T6 | Kỳ đối soát ghi "từ 01/05/**2025**" / "01/06/**2025**" (năm sai) — XLSX ghi đúng 2026. Lặp 2 kỳ | +| MEDIUM | Cao Bằng T6 | Bảng DOCX ghi giai đoạn "Early Bird" — raw/XLSX là "Noon" (giá 1.232.000 là giá Noon) | +| MEDIUM | VTV LPBank | 2 GD Payoo hoàn tất 20/05 (1.458.000đ gross, có cả loại vé 21KM Phát Lộc) nằm ngoài bảng đối soát dù trong phạm vi "đến hết 20/05" — không có ghi chú settle muộn | +| MEDIUM | Phong Nha T5 | XLSX ghi kỳ "01/04→31/05" — DOCX ghi đúng tháng 5 (01/05–31/05) | +| LOW | Cao Bằng T6, VTV LPBank | Làm tròn lệch giữa 2 file (132.809,6 vs 132.810; hoàn trả lệch 0,325đ) | +| LOW | Lamdong T6 | Bảng DOCX bỏ trống Giai đoạn 2/3 dòng dù raw đều là Early Bird | + +**Pattern hệ thống:** riêng merchant Đường Đua Mới, **cả 4/4 kỳ DOCX đều bỏ sót phí GD thủ công** — DOCX là văn bản ký pháp lý nhưng số cam kết lệch XLSX. Khi migrate phải chốt số nào là số pháp lý. + +### 2.4. `math` — lỗi số học/công thức +| Sev | Hồ sơ | Evidence | +|---|---|---| +| MEDIUM | Cuc Phuong T5+T6, Ha Giang T4 (DOCX) | Nhãn công thức tự tham chiếu "(3) = (1) − (2) − **(3)**" (giá trị vẫn đúng) | +| MEDIUM | VTV LPBank DOCX | Cộng dọc 6 dòng in = 317.527.992 ≠ tổng in 317.527.991 (làm tròn 0,5đ hai chiều mâu thuẫn — không tie-out); tương tự cột Giảm giá lệch 1đ | +| LOW | VTV LPBank | 2 dòng chi tiết không tự khớp công thức in trên chính biên bản (101.288.543 vs 542; 160.810.694 vs 693) | +| LOW | DHA T6, SC Hanoi T5+T6 (XLSX) | Float artifact `91000.00000000001` / `105000.00000000001` không round | +| LOW | Cuc Phuong + Ha Giang XLSX | `=SUM(G18:G18)` chỉ trỏ 1 ô khi bảng có 3 dòng — **bom nổ chậm** khi có giảm giá ≠ 0; Ha Giang còn xuất chuỗi formula dạng text chưa evaluate | + +### 2.5. `partner-info` +| Sev | Merchant | Evidence | +|---|---|---| +| MEDIUM | Belove Mountain | STK 169111 chỉ 6 chữ số — cả 2 kỳ, 2 giải | +| MEDIUM | Zaha | STK 123456788886 dạng tuần tự nghi placeholder — cả 2 giải | + +### 2.6. `other` — kỳ/nhãn/thuật ngữ +| Sev | Hồ sơ | Evidence | +|---|---|---| +| HIGH | VN Tôi Đó T5 | Thân bài ghi "Kỳ đối soát tháng **3**" trong khi kỳ là tháng 5 (T6 đã đúng) | +| MEDIUM | DHA, Cuc Phuong, Ha Giang, Cao Bằng, Da Lat (DOCX) | Title "BIÊN BẢN ĐỐI SOÁT" nhưng mở đầu vẫn "Biên Bản **Quyết Toán** này..." — tàn dư template cũ, kể cả trong mẫu canonical (cần Danny chốt giữ/sửa) | +| MEDIUM | Cuc Phuong T5+T6, Cao Bằng T6 | Header cột "Số lượng vé **thủ công**" trong khi bảng liệt kê GD QUA 5BIB — copy-paste sai ngữ nghĩa | +| LOW | Ha Giang | File `-T4` nằm trong folder "Tháng 5.2026" — kỳ thực là THÁNG 4 (chốt muộn 04/06). Seed phải lấy kỳ T4 | +| LOW | VTV LPBank | Title ghi khoảng ngày 18–20/05 nhưng thân bài ghi "Kỳ đối soát tháng 5" — 2 cách gọi kỳ mâu thuẫn trong 1 biên bản; footer "(4)=(1)−(2)−(3)" nhưng không định nghĩa (3) | +| LOW | Nhiều bộ | Tên file "BIEN BAN DOI SOAT" ≠ title nội dung "BIÊN BẢN QUYẾT TOÁN" | + +### 2.7. `spelling` / typography — LOW nhưng SYSTEMIC (lỗi template gốc lan mọi merchant) +- **`"(5) = (3 x (4)"` thiếu ngoặc đóng** — xuất hiện ở **gần như 100% XLSX** mọi merchant, mọi kỳ (sheet Tổng quan R17). Lỗi 1 chỗ ở file mẫu gốc. +- **`(BIDV))` thừa ngoặc đóng** — Belove ×2 giải, Hải Phòng, VN Tôi Đó (template khối Bên B). +- **`”5BIB"` ngoặc kép sai chiều** — Belove, DHA ×2, Cuc Phuong, Nón Xanh, VN Tôi Đó (template). +- Chức vụ hoa/thường không nhất quán trong cùng văn bản ("Tổng Giám đốc" vs "Tổng Giám Đốc") — DHA ×2, Nón Xanh. +- Đơn lẻ: sheet "Pivort" (DHA T5), "Giám giá" (Phong Nha), "Số lượng BiB" (Nón Xanh, VTV LPBank), "ULTRA TRAIL CAO BANG" không dấu, "Việt Nam Tôi Đó" vs "Tôi đó" hoa/thường lẫn lộn. + +--- + +## 3. BÊN B (5BIB) TRÊN CÁC BIÊN BẢN — 2 PHIÊN BẢN BANK + +| Phiên bản | Chi tiết | Xuất hiện ở | +|---|---|---| +| **A — BIDV Mỹ Đình** (khớp env hệ mới) | STK **2600398986**, TK "CONG TY CO PHAN 5BIB", MST 0110398986 | **12/14 hồ sơ**: Belove ×2, DHA ×2, Đường Đua Mới ×3, Nexus, Nón Xanh, Vũ Media, Zaha ×2 — nhất quán T5=T6 | +| **B — MB Thụy Khuê** | STK **110398986**, TK "CONG TY CO PHAN 5BIB", MST 0110398986 (đúng) | **2/14 hồ sơ**: Phong Nha Wild Trail (T5), VTV LPBank (Thành An Media). VTV LPBank có thêm dòng "Tên tài khoản" mà template mới không có | + +**Lỗi kèm theo phiên bản A:** chuỗi `(BIDV))` thừa ngoặc ở 4 bộ hồ sơ. +**Action:** cần Danny chốt bank nào là chuẩn hiện hành cho 2 merchant nhóm B (hoặc env-configurable per merchant/kỳ) — nếu generate lại bằng hệ mới sẽ tự đổi sang BIDV. +**MST 5BIB nhất quán 100%:** 0110398986 ở mọi biên bản. + +--- + +## 4. FEE RATE MAP + +| Giải | Merchant | Rate trong hồ sơ | Khớp folder? | +|---|---|---|---| +| Hoi An Heritage Half Marathon | Belove Mountain | 5,5% gộp VAT | ✅ (_5.5) | +| Danang Mountain Trail | Belove Mountain | 5,5% gộp VAT | ✅ | +| Halong International Heritage Marathon | DHA | 7% gộp VAT | ✅ (_7) | +| SC Hanoi Marathon Heritage Race | DHA | 7% gộp VAT | ✅ | +| Cuc Phuong Jungle Paths | Đường Đua Mới | 5,5% + **5.000đ/vé thủ công** (chỉ trong XLSX) | ✅ (_5.5) | +| Ha Giang Discovery Marathon (kỳ T4) | Đường Đua Mới | 5,5% + 5.000đ/vé thủ công | ✅ | +| Ultra Trail Cao Bằng | Đường Đua Mới | 5,5% + 5.000đ/vé thủ công | ✅ | +| Da Lat Music Night Run | Nexus | 5,5% | ✅ | +| Lamdong Trail | Nón Xanh | 5,5% | ✅ | +| Phong Nha Wild Trail | Phong Nha Wild | 6,5% gộp VAT | ✅ (_6.5) | +| **VTV LPBank Marathon** | Thành An Media | **7,5% kênh Payoo / 6,5% kênh khác** | ❌ **folder không có suffix rate — không đối chiếu được** | +| Trang An Marathon | Vũ Media | 5,5% (trên net sau giảm giá) | ✅ | +| Hải Phòng Legacy Marathon | Zaha | 5,5% (gồm cả add-on) | ✅ | +| VN Tôi Đó – My Vietnam | Zaha | 5,5% | ✅ | + +**Nhận xét:** 13/14 khớp folder. Mọi rate đều "Đã bao gồm VAT" gộp trong rate — **không hồ sơ nào tách dòng VAT riêng** (→ seed VAT=0 toàn bộ). Phí thủ công 5.000đ/vé chỉ có ở merchant Đường Đua Mới (3 giải). Duy nhất VTV LPBank dùng dual-rate theo kênh. + +--- + +## 5. GAP ANALYSIS — HỆ THỐNG MỚI CHƯA HỖ TRỢ (đã dedup, sort ưu tiên) + +### Blocker nghiệp vụ (không render được đúng hồ sơ thật) +1. **Phí theo KÊNH thanh toán** (Payoo 7,5% / khác 6,5%, 2 dòng phí riêng trước (2)) — cascade hiện tại chỉ 1 rate. *Ảnh hưởng: VTV LPBank.* +2. **Kỳ đối soát theo KHOẢNG NGÀY sự kiện** (18–20/05) — hệ mới chỉ có kỳ tháng ICT. *VTV LPBank.* +3. **Doanh thu ghi nhận theo NET settlement** (final_price = tiền Payoo thực chuyển sau ưu đãi CTKM + phí cổng, prorate per line sinh số lẻ 0,5đ) — hệ mới tính trên giá niêm yết. *VTV LPBank.* +4. **Ingest báo cáo gốc cổng thanh toán** (Báo cáo giao dịch Payoo.xlsx làm căn cứ đối soát kênh) — chưa có pipeline. *VTV LPBank.* +5. **Cột Giảm giá/Giảm trừ per-dòng trong bảng DOCX** — 7/14 hồ sơ dùng bảng 7 cột có cột giảm giá; bảng 6 cột hệ mới sẽ cho SL×Giá niêm yết ≠ Thành tiền mà không có cột giải thích. *Nặng nhất: Vũ Media (giảm 71–100%, mã TAM26-*), VTV LPBank.* +6. **Xử lý CHANGE_COURSE**: dòng đổi cự ly hiển thị như hạng vé riêng, đơn giá = PHÍ ĐỔI (100.000/236.000) chứ KHÔNG phải origin_price; cần group theo (cự ly × mức phí); chốt render dòng 0 đồng ẩn/hiện. Lấy Giá niêm yết từ origin_price sẽ ra Thành tiền SAI. *VTV LPBank, Trang An, VN Tôi Đó.* +7. **Carry-over công nợ khi payout âm** — hồ sơ tay clamp về 0 làm mất nợ 15.000 (Cuc Phuong T6); case payout âm thuần phí (−75.000, Cao Bằng T5) cần verify render đủ cả DOCX + XLSX; footer "Trừ phí thủ công" phải BẬT cho Đường Đua Mới (cả 4 kỳ đều phát sinh). + +### Cần chốt/confirm với Danny hoặc merchant +8. **Wording phí gộp VAT**: 100% hồ sơ ghi "Phí bán vé (Đã bao gồm VAT)" — khi VAT=0 hệ mới phải in kiểu gộp (không kèm "+VAT x%"), giữ được chú thích "(Đã bao gồm VAT)" trong nhãn (2). +9. **Cột Add-on products trong DOCX** (Hải Phòng Legacy: add-on 398.000 cộng thẳng vào Thành tiền) + nhóm cột add-on (3)(4)(5) + Giảm giá (6), công thức (7)=[(1)×(2)+(5)]−(6) + formula kiểm tra chéo =H20−B7 trong section 3 XLSX — có ở hầu hết hồ sơ (đa số trống) — confirm layout hệ mới đủ cột. +10. **Quy tắc làm tròn thống nhất** DOCX vs XLSX (chấm dứt float artifact 91000.00000000001 và lệch 0,325–1đ; tie-out cộng dọc phải khớp tổng in). +11. **Nhãn cột**: "Cự ly"/"Khoảng cách" vs "Hạng vé"; "Giá vé niêm yết (Đã bao gồm VAT)"/"Đơn giá (đã bao gồm VAT)" vs "Giá niêm yết"; "Số lượng vé đã bán qua 5BIB" vs "SL" — confirm merchant chấp nhận nhãn mới; map distance→Hạng vé không mất thông tin combo ("5KM KIDS TRAIL (1 NGƯỜI LỚN & 1 TRẺ EM)"). +12. **Bank Bên B 2 phiên bản** (mục 3) — env hardcode BIDV vs 2 hồ sơ MB Thụy Khuê + dòng "Tên tài khoản". +13. **Engine bằng chữ**: canonical dùng "**linh**" (không phải "lẻ") — nếu engine sinh "lẻ" sẽ lệch mặt chữ với mẫu chốt; và tuyệt đối KHÔNG replicate lỗi thiếu "nghìn" nằm trong chính mẫu canonical T6 (SC Hanoi). +14. **Câu mở đầu canonical** vẫn là "Biên Bản Quyết Toán này..." dù title đã đổi "BIÊN BẢN ĐỐI SOÁT" — cần Danny chốt giữ y hệt hay sửa. +15. **Số pháp lý khi DOCX ≠ XLSX** (Đường Đua Mới 4 kỳ) — quyết định số cam kết nào dùng khi migrate/đối chiếu lịch sử. + +### Nice-to-have (artifact quy trình tay, có thể bỏ) +16. Sheet phụ XLSX: Pivot/"Pivort", Query (kèm SQL), "ĐH thủ công"/"ĐH qua 5BIB", **"ĐH có thông tin xuất VAT"** (parse vat_metadata phục vụ kế toán xuất hóa đơn — Zaha T6, VTV LPBank có 6 đơn thật) — hệ mới chỉ 2 sheet Tổng quan + raw. +17. Khối header pháp lý trong sheet Tổng quan XLSX (Quốc hiệu, căn cứ HĐ, thông tin 2 bên — Nón Xanh) + dòng "Ngày đối soát" riêng. +18. Naming file: bản tay prefix ddmmyy ngày lập ("040626_", "060726_") vs convention hệ mới YYYY_MM. + +--- + +## 6. FORMAT VARIATIONS ĐÁNG CHÚ Ý GIỮA CÁC KỲ + +1. **Tên sheet raw đổi giữa T5→T6 ở MỌI merchant có 2 kỳ**: "Raw data" (T5) → "raw" (T6); "Pivot"→"pivot". Chuẩn hóa theo canonical = "raw". +2. **Số lượng sheet không ổn định trong cùng merchant**: DHA 3→2; Cuc Phuong **5→2**; Cao Bằng 3→4 (T6 lại thêm sheet tách kênh); Nón Xanh 2→3; Nexus có sheet Query chứa SQL. Chính người làm tay không giữ được format của mình. +3. **Naming file tự do**: có dấu/không dấu, space/viết liền, có/không năm ("Trang An Marathon 2026-T5" vs "TrangAnMarathon-T6"; "Standard Chartered Marathon Di sản Hà Nội-T5" dịch tiếng Việt vs "StandardCharter-T6" thiếu 'ed'). +4. **Title DOCX 3 biến thể cùng tồn tại**: "BIÊN BẢN QUYẾT TOÁN / DOANH THU BÁN VÉ SỰ KIỆN" (đa số legacy) → "BIÊN BẢN ĐỐI SOÁT" (DHA/Newrace, nhưng thân bài sót "Quyết Toán") → XLSX Nón Xanh dùng tên thứ 3 "BIÊN BẢN ĐỐI SOÁT HOA HỒNG". Tên file luôn ghi "BIEN BAN DOI SOAT" bất kể nội dung. +5. **Bảng doanh thu DOCX 4 biến thể cột**: 6 cột chuẩn (Belove, Nexus, SC Hanoi) / 7 cột +Giảm giá (DHA, Phong Nha, VN Tôi Đó) / 7 cột +Add-on (Hải Phòng) / 5-6 cột kiểu (a)(b)(c)(d) không STT (Nón Xanh, Vũ Media, VTV LPBank). +6. **Bằng chữ đổi convention giữa kỳ**: Nón Xanh T5 dùng "linh", T6 dùng "không trăm" — cả 2 đúng chuẩn nhưng không nhất quán. +7. **Change-course đổi chỗ ghi**: Trang An T5 ghi ở cột Giai đoạn ("Standard_Change Course"), T6 ghi ở cột Khoảng cách ("21km_Change course"). +8. **Section 3 đổi ngữ nghĩa**: Cao Bằng T5 liệt kê vé THỦ CÔNG, T6 liệt kê GD QUA 5BIB — cùng vị trí bảng, khác nội dung. +9. **Kỳ lệch folder**: Ha Giang kỳ T4 nằm trong folder Tháng 5 (chốt muộn) — folder ≠ kỳ, seed phải đọc kỳ từ nội dung. +10. **VTV LPBank là outlier toàn diện**: kỳ theo khoảng ngày sự kiện, raw 27 cột (thêm "Cổng thanh toán", final_price, discount_final so với chuẩn 25 cột), 6 sheet, kèm file thứ 3 báo cáo Payoo, dual-rate, dòng CHANGE_COURSE 100.000đ/lượt như hạng vé. + +--- + +**KẾT LUẬN NHANH:** Chất lượng số học hồ sơ tay khá tốt (đa số kỳ PASS 100% số học) nhưng **lớp diễn giải (bằng chữ, tên giải, nhãn công thức, năm/kỳ) là điểm gãy chính** — 5 lỗi CRITICAL đều thuộc nhóm copy-paste template (3 words-number, 2 wrong-race-name), đúng loại lỗi hệ thống tự động triệt tiêu được. Rủi ro tồn dư lớn nhất khi tự động hóa: (a) lỗi thiếu "nghìn" nằm trong chính mẫu canonical, (b) DOCX bỏ sót phí thủ công của Đường Đua Mới ở 100% kỳ, (c) VTV LPBank cần cả một mô hình phí/kỳ/settlement riêng chưa được hỗ trợ. \ No newline at end of file From 64d5835288a16bc19a415e37984437a94540b300 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 12:53:39 +0700 Subject: [PATCH 17/23] =?UTF-8?q?feat(recon):=20Pha=205-A=20k=E1=BB=B3=20?= =?UTF-8?q?=C4=91=E1=BB=91i=20so=C3=A1t=20theo=20GIAI=20=C4=90O=E1=BA=A0N?= =?UTF-8?q?=20b=C3=A1n=20v=C3=A9=20+=20B=C3=AAn=20B=20=C4=91=E1=BB=95i=20M?= =?UTF-8?q?BBANK=20(Danny=20ch=E1=BB=91t=202026-07-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Bên B (5BIB) trên biên bản: BIDV Mỹ Đình → MBBANK Thụy Khuê 110398986 (Danny chốt; 2 biên bản thật mới nhất đã dùng MB). Verify VPS dev+prod KHÔNG set env PROVIDER_BANK_* → Joi default mới tự hiệu lực khi deploy. 2. Kỳ đối soát theo giai đoạn bán vé (ngày mở → ngày đóng, vd VTV LPbank 18-20/05) — nhiều giải đối soát theo đợt chứ không theo tháng: - Validator: IsPeriodDate thay IsPeriodBoundaryDate (ngày bất kỳ, bắt ngày không tồn tại 2026-02-30, span ≤ 366 ngày) — preview + preflight/range. - flexPeriodRangeUtc: kỳ tròn-tháng giữ NGUYÊN periodRangeUtc (seam continuity F-082, số kỳ đã ký không đổi từng byte); kỳ giai đoạn dùng biên ICT thuần [start 00:00:00, end 23:59:59] khớp processed_on wall-clock. - renderPeriodLabel/filenamePeriodSegment: "từ 18/05/2026 đến hết 20/05/2026" + "2026_05_18_den_2026_05_20". - Preflight run() nhận rangeOverride: đếm đơn đúng khoảng ngày, bỏ check so-sánh-tháng-trước (vô nghĩa với khoảng tuỳ ý). - Admin form tạo đối soát: toggle "Theo tháng | Theo giai đoạn" + 2 date picker (mở/đóng giai đoạn). +13 test (flex range, label, preflight override, docx render giai đoạn). 235/235 recon+common pass; backend + admin build xanh. Co-Authored-By: Claude Opus 4.8 --- .../(dashboard)/reconciliations/new/page.tsx | 108 +++++++++++++++--- .../src/common/utils/ict-date.util.spec.ts | 42 +++++++ backend/src/common/utils/ict-date.util.ts | 43 ++++++- .../src/common/validators/period.validator.ts | 47 ++++++-- backend/src/config/index.ts | 9 +- .../reconciliation/dto/preflight-range.dto.ts | 12 +- .../dto/preview-reconciliation.dto.ts | 12 +- .../reconciliation/reconciliation.service.ts | 8 +- .../services/docx.service.spec.ts | 28 ++++- .../services/period-label.helper.spec.ts | 21 ++++ .../services/period-label.helper.ts | 31 ++++- .../reconciliation-preflight.service.spec.ts | 97 ++++++++++++++++ .../reconciliation-preflight.service.ts | 20 +++- .../services/reconciliation-query.service.ts | 8 +- docs/recon-automation/04-qc-report.md | 2 +- 15 files changed, 431 insertions(+), 57 deletions(-) diff --git a/admin/src/app/(dashboard)/reconciliations/new/page.tsx b/admin/src/app/(dashboard)/reconciliations/new/page.tsx index b2ae0e64..7c95fa25 100644 --- a/admin/src/app/(dashboard)/reconciliations/new/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/new/page.tsx @@ -202,12 +202,24 @@ export default function NewReconciliationPage() { const [raceTitle, setRaceTitle] = useState(""); // FEATURE-003 BR-05 — Month-Range-Picker state. Default: previous month (Từ = Đến). const [monthRange, setMonthRange] = useState(() => presetPreviousMonth()); - // Derive period_start/end from month range (always snaps to month-boundary). + // Pha 5-A — kỳ theo GIAI ĐOẠN bán vé (ngày mở → ngày đóng giai đoạn, vd + // VTV LPbank 18/05–20/05) song song với kỳ tròn-tháng. + const [periodMode, setPeriodMode] = useState<"month" | "phase">("month"); + const [phaseStart, setPhaseStart] = useState(""); + const [phaseEnd, setPhaseEnd] = useState(""); + // Derive period_start/end theo mode (month = snap về biên tháng). const periodRange = monthRangeToPeriod(monthRange.from, monthRange.to); - const periodStart = periodRange?.period_start ?? ""; - const periodEnd = periodRange?.period_end ?? ""; + const periodStart = periodMode === "month" ? periodRange?.period_start ?? "" : phaseStart; + const periodEnd = periodMode === "month" ? periodRange?.period_end ?? "" : phaseEnd; const monthCount = inclusiveMonthCount(monthRange.from, monthRange.to); - const periodInvalid = monthCount < 1 || monthCount > 12; + const phaseSpanDays = + phaseStart && phaseEnd + ? (Date.parse(phaseEnd + "T00:00:00Z") - Date.parse(phaseStart + "T00:00:00Z")) / 86_400_000 + : NaN; + const periodInvalid = + periodMode === "month" + ? monthCount < 1 || monthCount > 12 + : !phaseStart || !phaseEnd || Number.isNaN(phaseSpanDays) || phaseSpanDays < 0 || phaseSpanDays > 366; const [previewLoading, setPreviewLoading] = useState(false); // Preflight state @@ -275,7 +287,11 @@ export default function NewReconciliationPage() { return; } if (periodInvalid) { - toast.error("Kỳ đối soát không hợp lệ (max 12 tháng, Đến ≥ Từ)"); + toast.error( + periodMode === "month" + ? "Kỳ đối soát không hợp lệ (max 12 tháng, Đến ≥ Từ)" + : "Giai đoạn không hợp lệ (chọn đủ 2 ngày, Đến ≥ Từ, tối đa 366 ngày)", + ); return; } setPreflightLoading(true); @@ -519,18 +535,78 @@ export default function NewReconciliationPage() {

Để trống sẽ dùng tên giải từ hệ thống

- {/* Period — FEATURE-003 BR-05: Month-Range-Picker (replaces free-form date range). */} + {/* Period — FEATURE-003 BR-05 Month-Range-Picker + Pha 5-A kỳ giai đoạn bán vé. */}
- - { - setMonthRange(next); - setPreflight(null); - setErrorConfirmed(false); - }} - centerYear={currentVnYearMonth().year} - /> +
+ +
+ + +
+
+ {periodMode === "month" ? ( + { + setMonthRange(next); + setPreflight(null); + setErrorConfirmed(false); + }} + centerYear={currentVnYearMonth().year} + /> + ) : ( +
+
+
+ Từ ngày (mở giai đoạn) + { + setPhaseStart(e.target.value); + setPreflight(null); + setErrorConfirmed(false); + }} + /> +
+
+ Đến hết ngày (đóng giai đoạn) + { + setPhaseEnd(e.target.value); + setPreflight(null); + setErrorConfirmed(false); + }} + /> +
+
+

+ Đối soát theo giai đoạn bán vé (ngày mở → ngày đóng, tính hết ngày giờ Việt Nam) — dùng cho giải chốt theo đợt như VTV LPBank. Biên bản sẽ ghi “từ {phaseStart ? phaseStart.split("-").reverse().join("/") : "…"} đến hết {phaseEnd ? phaseEnd.split("-").reverse().join("/") : "…"}”. +

+
+ )}
{/* Preflight panel */} diff --git a/backend/src/common/utils/ict-date.util.spec.ts b/backend/src/common/utils/ict-date.util.spec.ts index 8868fc9e..c42c4d0a 100644 --- a/backend/src/common/utils/ict-date.util.spec.ts +++ b/backend/src/common/utils/ict-date.util.spec.ts @@ -13,6 +13,8 @@ import { ictDayRangeUtc, periodRangeUtc, ICT_PERIOD_CUTOVER, + flexPeriodRangeUtc, + isMonthAlignedRange, } from './ict-date.util'; describe('ict-date.util (F-081)', () => { @@ -187,3 +189,43 @@ describe('periodRangeUtc (F-082 cutover)', () => { expect(jan.fromUtc).toBe('2026-01-01 00:00:00'); }); }); + +// Pha 5-A (Danny chốt 2026-07-11) — kỳ đối soát theo GIAI ĐOẠN bán vé. +describe('flexPeriodRangeUtc — kỳ giai đoạn bán vé (Pha 5-A)', () => { + it('kỳ tròn-tháng → giữ NGUYÊN behavior periodRangeUtc (seam continuity)', () => { + expect(flexPeriodRangeUtc('2026-06-01', '2026-06-30')).toEqual( + periodRangeUtc('2026-06-01', '2026-06-30'), + ); + expect(flexPeriodRangeUtc('2026-05-01', '2026-07-31')).toEqual( + periodRangeUtc('2026-05-01', '2026-07-31'), + ); + }); + + it('kỳ giai đoạn (VTV LPbank 18-20/05) → biên ICT thuần [00:00:00, 23:59:59]', () => { + const r = flexPeriodRangeUtc('2026-05-18', '2026-05-20'); + expect(r.fromUtc).toBe('2026-05-18 00:00:00'); + expect(r.toUtc).toBe('2026-05-20 23:59:59'); + }); + + it('đơn processed_on ICT wall-clock tối ngày đóng giai đoạn VẪN thuộc kỳ', () => { + const r = flexPeriodRangeUtc('2026-05-18', '2026-05-20'); + expect('2026-05-20 23:59:00' >= r.fromUtc && '2026-05-20 23:59:00' <= r.toUtc).toBe(true); + expect('2026-05-21 00:00:01' <= r.toUtc).toBe(false); // sau đóng → rớt + expect('2026-05-17 23:59:59' >= r.fromUtc).toBe(false); // trước mở → rớt + }); + + it('giai đoạn 1 ngày duy nhất hợp lệ', () => { + const r = flexPeriodRangeUtc('2026-05-18', '2026-05-18'); + expect(r.fromUtc).toBe('2026-05-18 00:00:00'); + expect(r.toUtc).toBe('2026-05-18 23:59:59'); + }); + + it('isMonthAlignedRange nhận diện đúng biên tháng (kể cả tháng 2)', () => { + expect(isMonthAlignedRange('2026-06-01', '2026-06-30')).toBe(true); + expect(isMonthAlignedRange('2026-02-01', '2026-02-28')).toBe(true); // 2026 không nhuận + expect(isMonthAlignedRange('2024-02-01', '2024-02-29')).toBe(true); // nhuận + expect(isMonthAlignedRange('2026-05-18', '2026-05-20')).toBe(false); + expect(isMonthAlignedRange('2026-05-01', '2026-05-30')).toBe(false); // end thiếu ngày 31 + expect(isMonthAlignedRange('2026-05-02', '2026-05-31')).toBe(false); // start không phải mùng 1 + }); +}); diff --git a/backend/src/common/utils/ict-date.util.ts b/backend/src/common/utils/ict-date.util.ts index 18758134..752e955c 100644 --- a/backend/src/common/utils/ict-date.util.ts +++ b/backend/src/common/utils/ict-date.util.ts @@ -128,7 +128,8 @@ function endOfPeriodMs(period: string): number { /** * F-082 — UTC datetime range [fromUtc, toUtc] (inclusive, SQL `>= ? AND <= ?`) * cho dải kỳ calendar VN `[periodStartDate, periodEndDate]` (YYYY-MM-DD, - * luôn là ngày 1 và ngày cuối tháng per `IsPeriodBoundaryDate` validator). + * TRÒN THÁNG — ngày 1 và ngày cuối tháng). Kỳ giai đoạn (ngày bất kỳ) dùng + * `flexPeriodRangeUtc` bên dưới, KHÔNG gọi thẳng hàm này. * * SEAM CONTINUITY INVARIANT (chống double-count 7h tại cutover): * startOf(P) = endOf(prevPeriod(P)) + 1s @@ -155,3 +156,43 @@ export function periodRangeUtc( toUtc: toUtcSqlDatetime(new Date(toMs)), }; } + +/** + * Kỳ có tròn-tháng không: start = mùng 1 VÀ end = ngày cuối tháng của chính nó. + * Kỳ tròn-tháng đi qua `periodRangeUtc` (giữ seam-continuity cutover F-082); + * kỳ GIAI ĐOẠN (ngày bất kỳ) dùng biên ICT thuần. + */ +export function isMonthAlignedRange( + periodStartDate: string, + periodEndDate: string, +): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(periodStartDate)) return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(periodEndDate)) return false; + if (periodStartDate.slice(8) !== '01') return false; + const [ey, em, ed] = periodEndDate.split('-').map(Number); + const lastDay = new Date(Date.UTC(ey, em, 0)).getUTCDate(); + return ed === lastDay; +} + +/** + * Pha 5-A (Danny chốt 2026-07-11) — kỳ đối soát theo GIAI ĐOẠN BÁN VÉ: + * from/to là ngày bất kỳ (vd 18/05 → 20/05 của VTV LPbank), không chỉ tròn + * tháng. Dispatcher: + * - Kỳ tròn-tháng → `periodRangeUtc` (behavior cũ giữ NGUYÊN từng byte — + * seam continuity, số kỳ đã ký không đổi). + * - Kỳ giai đoạn → biên calendar ICT thuần [start 00:00:00, end 23:59:59] + * so trực tiếp với `processed_on` (ICT wall-clock) — khớp đúng cách hồ sơ + * tay vẫn làm ("Từ 18/05 đến hết 20/05" giờ VN). + */ +export function flexPeriodRangeUtc( + periodStartDate: string, + periodEndDate: string, +): { fromUtc: string; toUtc: string } { + if (isMonthAlignedRange(periodStartDate, periodEndDate)) { + return periodRangeUtc(periodStartDate, periodEndDate); + } + return { + fromUtc: `${periodStartDate} 00:00:00`, + toUtc: `${periodEndDate} 23:59:59`, + }; +} diff --git a/backend/src/common/validators/period.validator.ts b/backend/src/common/validators/period.validator.ts index 286f08a9..fe61333a 100644 --- a/backend/src/common/validators/period.validator.ts +++ b/backend/src/common/validators/period.validator.ts @@ -7,7 +7,8 @@ import { const PERIOD_STRING_REGEX = /^\d{4}-(0[1-9]|1[0-2])$/; const BOUNDARY_DATE_REGEX = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; const MIN_PERIOD_YEAR = 2020; -const MAX_RANGE_MONTHS = 12; +// Pha 5-A — kỳ giai đoạn bán vé đo span theo NGÀY (tương đương trần 12 tháng cũ). +const MAX_RANGE_DAYS = 366; export function lastDayOfMonthUTC(year: number, month1Indexed: number): number { return new Date(Date.UTC(year, month1Indexed, 0)).getUTCDate(); @@ -88,8 +89,37 @@ export function IsPeriodBoundaryDate( } /** - * Cross-field validator: validates that `period_end >= period_start`, - * span <= MAX_RANGE_MONTHS (12), and both fields are non-empty. + * Pha 5-A (Danny chốt 2026-07-11) — kỳ đối soát theo GIAI ĐOẠN BÁN VÉ: + * period_start/end là ngày bất kỳ (không bắt buộc mùng 1 / cuối tháng). + * Kiểm: đúng format YYYY-MM-DD, ngày lịch CÓ THẬT (bắt 2026-02-30), year >= 2020. + */ +export function IsPeriodDate(opts?: ValidationOptions): PropertyDecorator { + return function (object: object, propertyName: string | symbol): void { + registerDecorator({ + name: 'isPeriodDate', + target: object.constructor, + propertyName: propertyName as string, + options: opts, + validator: { + validate(value: unknown): boolean { + if (typeof value !== 'string') return false; + if (!BOUNDARY_DATE_REGEX.test(value)) return false; + const [year, month, day] = value.split('-').map(Number); + if (year < MIN_PERIOD_YEAR) return false; + return day <= lastDayOfMonthUTC(year, month); + }, + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a valid YYYY-MM-DD date (year >= ${MIN_PERIOD_YEAR})`; + }, + }, + }); + }; +} + +/** + * Cross-field validator: `period_end >= period_start`, span tối đa 366 ngày + * (đo theo NGÀY để hỗ trợ kỳ giai đoạn bán vé bất kỳ — Pha 5-A; tương đương + * trần 12 tháng cũ cho kỳ tròn-tháng). * * Apply on the `period_end` property; reads sibling `period_start` from the object. */ @@ -110,14 +140,15 @@ export function IsValidPeriodRange( if (typeof start !== 'string') return false; // Compare as dates (string compare works for YYYY-MM-DD) if (value < start) return false; - const [sy, sm] = start.split('-').map(Number); - const [ey, em] = value.split('-').map(Number); - const months = monthsBetweenInclusive(sy, sm, ey, em); - if (months < 1 || months > MAX_RANGE_MONTHS) return false; + const spanDays = + (Date.parse(value + 'T00:00:00Z') - + Date.parse(start + 'T00:00:00Z')) / + 86_400_000; + if (Number.isNaN(spanDays) || spanDays > MAX_RANGE_DAYS) return false; return true; }, defaultMessage(args: ValidationArguments): string { - return `${args.property} must be >= period_start and span at most ${MAX_RANGE_MONTHS} months`; + return `${args.property} must be >= period_start and span at most ${MAX_RANGE_DAYS} days`; }, }, }); diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index bace2a01..e7033d4b 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -89,11 +89,12 @@ const envVarsSchema = Joi.object() PROVIDER_PHONE: Joi.string().default('0373398986'), PROVIDER_REPRESENTATIVE_NAME: Joi.string().default('Nguyễn Bình Minh'), PROVIDER_REPRESENTATIVE_TITLE: Joi.string().default('Giám Đốc'), - // 2026-07-09: cập nhật theo mẫu biên bản thật của 5BIB (BIDV Mỹ Đình). - // Env PROVIDER_BANK_* trên VPS vẫn override — verify khớp trước khi phát. - PROVIDER_BANK_ACCOUNT: Joi.string().default('2600398986'), + // 2026-07-11: Danny chốt dùng MBBANK (rà soát 22 biên bản thật: 2 bản mới + // nhất dùng MB Thụy Khuê; BIDV Mỹ Đình là template cũ). Env PROVIDER_BANK_* + // trên VPS vẫn override — verify env KHÔNG set BIDV cũ trước khi phát. + PROVIDER_BANK_ACCOUNT: Joi.string().default('110398986'), PROVIDER_BANK_NAME: Joi.string().default( - 'Ngân hàng TMCP Đầu tư và Phát triển Việt Nam (BIDV) - Chi nhánh: Mỹ Đình', + 'Ngân hàng TMCP Quân Đội (MB) - Chi nhánh: Thụy Khuê', ), // FEATURE-076 — MISA Meinvoice daily reconcile + alert system. // All optional → module load conditionally (if MISA_USERNAME unset, skip diff --git a/backend/src/modules/reconciliation/dto/preflight-range.dto.ts b/backend/src/modules/reconciliation/dto/preflight-range.dto.ts index 6cab407d..8e39c823 100644 --- a/backend/src/modules/reconciliation/dto/preflight-range.dto.ts +++ b/backend/src/modules/reconciliation/dto/preflight-range.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsNumber } from 'class-validator'; import { - IsPeriodBoundaryDate, + IsPeriodDate, IsValidPeriodRange, } from '../../../common/validators/period.validator'; @@ -15,17 +15,19 @@ export class PreflightRangeDto { mysql_race_id: number; @ApiProperty({ - description: 'Period start YYYY-MM-DD (must be the 1st of a month)', + description: + 'Period start YYYY-MM-DD. Kỳ tròn-tháng (mùng 1) hoặc kỳ GIAI ĐOẠN bán vé (ngày bất kỳ — Pha 5-A)', example: '2026-01-01', }) - @IsPeriodBoundaryDate('start') + @IsPeriodDate() period_start: string; @ApiProperty({ - description: 'Period end YYYY-MM-DD (must be the last day of a month, span ≤ 12 months)', + description: + 'Period end YYYY-MM-DD (ngày cuối tháng cho kỳ tròn-tháng, hoặc ngày bất kỳ >= period_start; span ≤ 366 ngày)', example: '2026-03-31', }) - @IsPeriodBoundaryDate('end') + @IsPeriodDate() @IsValidPeriodRange() period_end: string; } diff --git a/backend/src/modules/reconciliation/dto/preview-reconciliation.dto.ts b/backend/src/modules/reconciliation/dto/preview-reconciliation.dto.ts index 6eaa0a83..02316ea4 100644 --- a/backend/src/modules/reconciliation/dto/preview-reconciliation.dto.ts +++ b/backend/src/modules/reconciliation/dto/preview-reconciliation.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsNumber, IsOptional, IsString } from 'class-validator'; import { - IsPeriodBoundaryDate, + IsPeriodDate, IsValidPeriodRange, } from '../../../common/validators/period.validator'; @@ -20,17 +20,19 @@ export class PreviewReconciliationDto { race_title?: string; @ApiProperty({ - description: 'Period start date YYYY-MM-DD (must be the 1st of a month)', + description: + 'Period start date YYYY-MM-DD. Kỳ tròn-tháng (mùng 1) hoặc kỳ GIAI ĐOẠN bán vé (ngày bất kỳ — Pha 5-A)', example: '2025-01-01', }) - @IsPeriodBoundaryDate('start') + @IsPeriodDate() period_start: string; @ApiProperty({ - description: 'Period end date YYYY-MM-DD (must be the last day of a month, span ≤ 12 months)', + description: + 'Period end date YYYY-MM-DD (ngày cuối tháng cho kỳ tròn-tháng, hoặc ngày bất kỳ >= period_start cho kỳ giai đoạn; span ≤ 366 ngày)', example: '2025-03-31', }) - @IsPeriodBoundaryDate('end') + @IsPeriodDate() @IsValidPeriodRange() period_end: string; diff --git a/backend/src/modules/reconciliation/reconciliation.service.ts b/backend/src/modules/reconciliation/reconciliation.service.ts index 030ed637..d8c3dc8b 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.ts @@ -37,6 +37,7 @@ import { UpdateReconciliationStatusDto } from './dto/update-reconciliation-statu import { BatchCreateReconciliationDto } from './dto/batch-create-reconciliation.dto'; import { DeleteBatchResponseDto } from './dto/delete-batch.dto'; import { resolveFeeParams } from './utils/fee-params.util'; +import { isMonthAlignedRange } from '../../common/utils/ict-date.util'; import { MailService } from '../notification/mail.service'; import { AthleteListService } from './services/athlete-list.service'; import { @@ -235,12 +236,17 @@ export class ReconciliationService { const lineItems = this.calcService.buildLineItems(fiveBibOrders); const manualOrderRows = this.calcService.buildManualOrders(manualOrders); - // Run pre-flight to determine flags and status + // Run pre-flight to determine flags and status. + // Pha 5-A: kỳ GIAI ĐOẠN bán vé (ngày bất kỳ) → truyền range thật để + // preflight đếm đơn/ước phí đúng khoảng ngày; kỳ tròn-tháng giữ path cũ. const period = dto.period_start.slice(0, 7); // YYYY-MM const preflight = await this.preflightService.run( dto.tenant_id, period, dto.mysql_race_id, + isMonthAlignedRange(dto.period_start, dto.period_end) + ? undefined + : { period_start: dto.period_start, period_end: dto.period_end }, ); const flags = this.preflightService.extractFlags(preflight); const status = this.preflightService.determineStatus(flags); diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index c216c1a6..fd500013 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -18,9 +18,8 @@ jest.mock( phone: '0373398986', representativeName: 'Nguyễn Bình Minh', representativeTitle: 'Giám Đốc', - bankAccount: '2600398986', - bankName: - 'Ngân hàng TMCP Đầu tư và Phát triển Việt Nam (BIDV) - Chi nhánh: Mỹ Đình', + bankAccount: '110398986', + bankName: 'Ngân hàng TMCP Quân Đội (MB) - Chi nhánh: Thụy Khuê', }, }, }), @@ -179,8 +178,10 @@ describe('DocxService — khớp mẫu T6 + regression', () => { expect(t).toContain('CÔNG TY CỔ PHẦN 5BIB'); expect(t).toContain('Hồ Gươm Plaza'); expect(t).toContain('0110398986'); // tax - expect(t).toContain('2600398986'); // bank account - expect(t).toContain('BIDV'); + // 2026-07-11 Danny chốt MBBANK (thay BIDV Mỹ Đình cũ) + expect(t).toContain('110398986'); // bank account + expect(t).toContain('Ngân hàng TMCP Quân Đội (MB)'); + expect(t).not.toContain('BIDV'); expect(t).toContain('Tên tài khoản'); // Mẫu Bên B KHÔNG có dòng điện thoại (env.provider.phone không render Bên B) expect(t).not.toContain('0373398986'); @@ -394,4 +395,21 @@ describe('DocxService — khớp mẫu T6 + regression', () => { // Không có dòng prose "(3) = (1) - (2)" (chỉ có trong bảng doanh thu) expect(t).not.toContain('Số tiền sau phí bán vé:'); }); + + /* ── Pha 5-A: kỳ đối soát theo GIAI ĐOẠN bán vé (Danny chốt 2026-07-11) ── */ + + it('PHASE-01: kỳ giai đoạn → header + prose ghi "từ dd/mm đến hết dd/mm" (khớp mẫu VTV)', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + period_start: '2026-05-18', + period_end: '2026-05-20', + }), + ); + // Header dưới tên giải + expect(t).toContain('(từ 18/05/2026 đến 20/05/2026)'); + // Prose Nghiệm thu dùng label giai đoạn, KHÔNG phải "Tháng 5 năm 2026" + expect(t).toContain('Kỳ đối soát từ 18/05/2026 đến hết 20/05/2026'); + expect(t).not.toContain('Kỳ đối soát Tháng 5 năm 2026'); + }); }); diff --git a/backend/src/modules/reconciliation/services/period-label.helper.spec.ts b/backend/src/modules/reconciliation/services/period-label.helper.spec.ts index c420970c..e1ed7bd4 100644 --- a/backend/src/modules/reconciliation/services/period-label.helper.spec.ts +++ b/backend/src/modules/reconciliation/services/period-label.helper.spec.ts @@ -48,3 +48,24 @@ describe('filenamePeriodSegment', () => { expect(filenamePeriodSegment('', '')).toBe('unknown'); }); }); + +// Pha 5-A — kỳ GIAI ĐOẠN bán vé (ngày bất kỳ). +describe('kỳ giai đoạn bán vé (Pha 5-A)', () => { + it('renderPeriodLabel: khoảng ngày → "từ dd/mm/yyyy đến hết dd/mm/yyyy"', () => { + expect(renderPeriodLabel('2026-05-18', '2026-05-20')).toBe( + 'từ 18/05/2026 đến hết 20/05/2026', + ); + }); + + it('renderPeriodLabel: end giữa tháng dù start mùng 1 → vẫn là giai đoạn', () => { + expect(renderPeriodLabel('2026-05-01', '2026-05-20')).toBe( + 'từ 01/05/2026 đến hết 20/05/2026', + ); + }); + + it('filenamePeriodSegment: giai đoạn → YYYY_MM_DD_den_YYYY_MM_DD', () => { + expect(filenamePeriodSegment('2026-05-18', '2026-05-20')).toBe( + '2026_05_18_den_2026_05_20', + ); + }); +}); diff --git a/backend/src/modules/reconciliation/services/period-label.helper.ts b/backend/src/modules/reconciliation/services/period-label.helper.ts index da2f1c7c..7ca27f56 100644 --- a/backend/src/modules/reconciliation/services/period-label.helper.ts +++ b/backend/src/modules/reconciliation/services/period-label.helper.ts @@ -13,9 +13,25 @@ function parseYearMonth(ymd: string): { year: number; month: number } | null { return { year, month }; } +/** Kỳ tròn-tháng: start = mùng 1 và end = ngày cuối tháng của chính nó. */ +function isMonthAligned(period_start: string, period_end: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(period_start)) return false; + if (!/^\d{4}-\d{2}-\d{2}$/.test(period_end)) return false; + if (period_start.slice(8) !== '01') return false; + const [ey, em, ed] = period_end.split('-').map(Number); + return ed === new Date(Date.UTC(ey, em, 0)).getUTCDate(); +} + +function ddmmyyyy(ymd: string): string { + const p = ymd.split('-'); + return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ymd; +} + /** - * Returns "Tháng N năm Y" (single) or "Tháng Ms năm Ys đến Tháng Me năm Ye" (range). - * Falls back to the legacy "(Từ start đến hết end)" with date strings for malformed input. + * Kỳ tròn-tháng: "Tháng N năm Y" (single) hoặc "Tháng Ms năm Ys đến Tháng Me năm Ye". + * Kỳ GIAI ĐOẠN bán vé (Pha 5-A — ngày bất kỳ): "từ dd/mm/yyyy đến hết dd/mm/yyyy" + * (khớp cách hồ sơ tay ghi, vd VTV LPbank "Từ 18/05/2026 đến hết 20/05/2026"). + * Falls back to the legacy "(Từ start đến hết end)" for malformed input. */ export function renderPeriodLabel(period_start: string, period_end: string): string { const start = parseYearMonth(period_start); @@ -23,6 +39,9 @@ export function renderPeriodLabel(period_start: string, period_end: string): str if (!start || !end) { return `(Từ ${period_start} đến hết ${period_end})`; } + if (!isMonthAligned(period_start, period_end)) { + return `từ ${ddmmyyyy(period_start)} đến hết ${ddmmyyyy(period_end)}`; + } if (start.year === end.year && start.month === end.month) { return `Tháng ${start.month} năm ${start.year}`; } @@ -31,13 +50,17 @@ export function renderPeriodLabel(period_start: string, period_end: string): str /** * Filename period segment for ZIP export. - * - N=1 → "YYYY_MM" (legacy) - * - N>1 → "YYYY_MMs_den_YYYY_MMe" + * - N=1 tròn-tháng → "YYYY_MM" (legacy) + * - N>1 tròn-tháng → "YYYY_MMs_den_YYYY_MMe" + * - Kỳ giai đoạn → "YYYY_MM_DD_den_YYYY_MM_DD" (Pha 5-A) */ export function filenamePeriodSegment(period_start: string, period_end: string): string { const start = parseYearMonth(period_start); const end = parseYearMonth(period_end); if (!start || !end) return 'unknown'; + if (!isMonthAligned(period_start, period_end)) { + return `${period_start.replace(/-/g, '_')}_den_${period_end.replace(/-/g, '_')}`; + } const startStr = `${start.year}_${String(start.month).padStart(2, '0')}`; const endStr = `${end.year}_${String(end.month).padStart(2, '0')}`; if (startStr === endStr) return startStr; diff --git a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts index 45147631..8be868ef 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.spec.ts @@ -260,3 +260,100 @@ describe('ReconciliationPreflightService.runRange — BR-11 overlap detection', }); }); }); + +// Pha 5-A — run() với rangeOverride (kỳ GIAI ĐOẠN bán vé, ngày bất kỳ). +describe('ReconciliationPreflightService.run — rangeOverride kỳ giai đoạn (Pha 5-A)', () => { + let service: ReconciliationPreflightService; + let mockQueryService: any; + let mockReconciliationModel: any; + + beforeEach(async () => { + // findOne phải vừa await trực tiếp (check ALREADY_EXISTS) vừa .lean() + // (checkRevenueAnomaly) → trả thenable kèm lean. + const fakeFindOne = (awaitVal: any, leanVal: any = null) => ({ + lean: () => Promise.resolve(leanVal), + then: (resolve: (v: any) => void) => Promise.resolve(awaitVal).then(resolve), + }); + mockReconciliationModel = { + findOne: jest.fn().mockImplementation(() => fakeFindOne(null)), + find: jest.fn().mockReturnThis(), + lean: jest.fn().mockResolvedValue([]), + _fakeFindOne: fakeFindOne, + }; + mockQueryService = { + getTenant: jest.fn().mockResolvedValue({ name: 'Thành An Media' }), + getRacesByTenant: jest + .fn() + .mockResolvedValue([{ race_id: '999', title: 'VTV LPBank Marathon' }]), + queryOrders: jest.fn().mockResolvedValue({ + fiveBibOrders: [ + { order_id: 1, subtotal_price: 368000, order_category: 'ORDINARY' }, + ], + manualOrders: [], + missingPaymentRef: [], + unknownCategoryCount: 0, + }), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ReconciliationPreflightService, + { provide: ReconciliationQueryService, useValue: mockQueryService }, + { provide: ReconciliationCalcService, useValue: {} }, + { provide: getModelToken(Reconciliation.name), useValue: mockReconciliationModel }, + { + provide: getModelToken(MerchantConfig.name), + useValue: { + findOne: jest.fn().mockReturnValue({ + lean: jest.fn().mockResolvedValue({ service_fee_rate: 7.5 }), + }), + }, + }, + ], + }).compile(); + service = module.get(ReconciliationPreflightService); + }); + + it('truyền rangeOverride → queryOrders dùng ĐÚNG khoảng ngày giai đoạn', async () => { + await service.run(88, '2026-05', 999, { + period_start: '2026-05-18', + period_end: '2026-05-20', + }); + expect(mockQueryService.queryOrders).toHaveBeenCalledWith( + 999, + '2026-05-18', + '2026-05-20', + ); + }); + + it('kỳ giai đoạn: BỎ QUA check so-sánh-tháng-trước (không REVENUE_ warning giả)', async () => { + const res = await service.run(88, '2026-05', 999, { + period_start: '2026-05-18', + period_end: '2026-05-20', + }); + const revenueWarnings = res.warnings.filter((w) => + w.type.startsWith('LARGE_REVENUE'), + ); + expect(revenueWarnings).toHaveLength(0); + }); + + it('ALREADY_EXISTS message hiện khoảng ngày giai đoạn thay vì YYYY-MM', async () => { + mockReconciliationModel.findOne.mockImplementation(() => + mockReconciliationModel._fakeFindOne({ _id: 'x' }), + ); + const res = await service.run(88, '2026-05', 999, { + period_start: '2026-05-18', + period_end: '2026-05-20', + }); + const dup = res.warnings.find((w) => w.type === 'ALREADY_EXISTS'); + expect(dup?.message).toContain('2026-05-18 → 2026-05-20'); + }); + + it('không rangeOverride → behavior cũ giữ nguyên (query tròn tháng)', async () => { + await service.run(88, '2026-05', 999); + expect(mockQueryService.queryOrders).toHaveBeenCalledWith( + 999, + '2026-05-01', + '2026-05-31', + ); + }); +}); diff --git a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts index e780eff0..bdccd01b 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-preflight.service.ts @@ -69,13 +69,21 @@ export class ReconciliationPreflightService { * Run pre-flight for a single merchant + period. * If mysql_race_id provided: check only that race. * Otherwise: check all races of the merchant. + * + * Pha 5-A — `rangeOverride`: kỳ GIAI ĐOẠN bán vé (ngày bất kỳ, vd 18-20/05). + * Khi có override, mọi query đơn dùng đúng range đó thay vì tròn tháng; + * check so-sánh-tháng-trước (revenue anomaly) bị bỏ qua vì vô nghĩa với + * khoảng ngày tuỳ ý. */ async run( tenant_id: number, period: string, // YYYY-MM mysql_race_id?: number, + rangeOverride?: { period_start: string; period_end: string }, ): Promise { - const { period_start, period_end } = this.parsePeriod(period); + const { period_start, period_end } = + rangeOverride ?? this.parsePeriod(period); + const isPhaseRange = !!rangeOverride; const tenant = await this.queryService.getTenant(tenant_id); const config = await this.configModel.findOne({ tenantId: tenant_id }).lean(); @@ -183,7 +191,11 @@ export class ReconciliationPreflightService { } // --- Revenue spike/drop vs. previous month --- - await this.checkRevenueAnomaly(tenant_id, raceId, period, grossRevenue + manualRevenue, warnings, raceName); + // Kỳ giai đoạn (Pha 5-A): bỏ qua — so sánh "tháng trước" vô nghĩa với + // khoảng ngày tuỳ ý (18-20/05 vs cả tháng 4 luôn lệch lớn → noise). + if (!isPhaseRange) { + await this.checkRevenueAnomaly(tenant_id, raceId, period, grossRevenue + manualRevenue, warnings, raceName); + } // --- Fee changed in period --- await this.checkFeeChanged(tenant_id, period_start, period_end, warnings); @@ -201,7 +213,9 @@ export class ReconciliationPreflightService { warnings.push({ type: 'ALREADY_EXISTS', severity: 'WARNING', - message: `Đã có bản đối soát cho race này trong kỳ ${period}`, + message: `Đã có bản đối soát cho race này trong kỳ ${ + isPhaseRange ? `${period_start} → ${period_end}` : period + }`, count: null, }); } diff --git a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts index c7c7c506..0f23e80f 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts @@ -15,7 +15,7 @@ import { isFreePromoOrder, } from '../../../common/constants/order-classification'; // F-082 — period-keyed TZ cutover (kỳ >= 2026-06 ICT, kỳ cũ UTC). -import { periodRangeUtc } from '../../../common/utils/ict-date.util'; +import { flexPeriodRangeUtc } from '../../../common/utils/ict-date.util'; /** * FEATURE-016 v1.6.5 — extend FIVE_BIB_CATEGORIES to 6 categories. @@ -135,7 +135,7 @@ export class ReconciliationQueryService { // kỳ cũ giữ UTC nguyên trạng (số chứng từ đã ký không đổi). Seam // continuity chống double-count 7h tại cutover. Preflight share method // này → count đơn nhất quán với create(). - const { fromUtc, toUtc } = periodRangeUtc(period_start, period_end); + const { fromUtc, toUtc } = flexPeriodRangeUtc(period_start, period_end); const rows: Record[] = await this.tenantRepo.manager.query( sql, [mysql_race_id, fromUtc, toUtc], @@ -147,7 +147,7 @@ export class ReconciliationQueryService { /** * Pha 2-A — Danh sách VĐV: mọi athlete có vé COMPLETE/paid trong kỳ của race. * Join qua `codes` (a.code_id → codes.order_id) để lấy order + ticket_type + - * course; cùng bộ lọc kỳ (`periodRangeUtc`) như queryOrders để danh sách VĐV + * course; cùng bộ lọc kỳ (`flexPeriodRangeUtc`) như queryOrders để danh sách VĐV * khớp đúng đơn trong bảng đối soát. Ngày format ngay trong SQL (DATE_FORMAT) * tránh lệch timezone. Trả raw rows — mapping 43 cột ở AthleteListService. * @@ -208,7 +208,7 @@ export class ReconciliationQueryService { AND o.processed_on <= ? ORDER BY a.athletes_id ASC `; - const { fromUtc, toUtc } = periodRangeUtc(period_start, period_end); + const { fromUtc, toUtc } = flexPeriodRangeUtc(period_start, period_end); return this.tenantRepo.manager.query(sql, [mysql_race_id, fromUtc, toUtc]); } diff --git a/docs/recon-automation/04-qc-report.md b/docs/recon-automation/04-qc-report.md index 41ce2b9b..4e855992 100644 --- a/docs/recon-automation/04-qc-report.md +++ b/docs/recon-automation/04-qc-report.md @@ -40,7 +40,7 @@ REFUTED (1): khe hở sub-second biên kỳ 23:59:59.x — phản biện xác nh - [x] **C1 (HIGH)** fixed + verify e2e (docx (4) = payout thật khớp email/XLSX). - [x] 2 MEDIUM (status forward-only, audit actor) fixed + test. -- [ ] Trước khi bật gửi email thật PROD: verify env `MAILCHIMP_API_KEY` + `PROVIDER_BANK_*` (BIDV 2600398986). +- [ ] Trước khi bật gửi email thật PROD: verify env `MAILCHIMP_API_KEY` + `PROVIDER_BANK_*` (**MBBANK Thụy Khuê 110398986** — Danny chốt 2026-07-11 thay BIDV; env VPS không được set đè BIDV cũ). - [ ] LOW debt: lên ticket riêng (idempotency lock, strip raw_*, atomic lifecycle, DTO+SDK). Không chặn PROD. **Kết luận:** Feature vững logic + bảo mật; blocker pháp lý (số biên bản ký) đã đóng. **Approve merge + đủ điều kiện PROD** sau khi verify env; LOW debt theo dõi ở known-issues. From 41c669b161b920902af158687942c6a02e00031f Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 15:31:27 +0700 Subject: [PATCH 18/23] =?UTF-8?q?feat(recon):=20c=E1=BB=99t=20"Gi=E1=BA=A3?= =?UTF-8?q?m=20gi=C3=A1"=20=C4=91i=E1=BB=81u=20ki=E1=BB=87n=20trong=20b?= =?UTF-8?q?=E1=BA=A3ng=20doanh=20thu=20DOCX=20(audit=207/14=20k=E1=BB=B3?= =?UTF-8?q?=20th=E1=BA=ADt=20c=C3=B3=20gi=E1=BA=A3m=20gi=C3=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kỳ có giảm giá → bảng 7 cột thêm "Giảm giá" trước "Thành tiền" để dòng tự cộng được trên giấy ký (SL × Giá niêm yết − Giảm = Thành tiền — nặng nhất Vu Media giảm 71-100% mã TAM26-*). Kỳ không giảm giá giữ NGUYÊN 6 cột khớp mẫu SCB T6. Dòng tổng colspan động theo số cột. Danny chốt 2026-07-11: phí theo kênh Payoo (VTV 7.5%/6.5%) KHÔNG làm — đặc thù 1 giải, phần phí kênh làm tay; kỳ giai đoạn (Pha 5-A) vẫn dùng được. +2 test. 237/237 recon+common pass, build xanh. Co-Authored-By: Claude Opus 4.8 --- .../services/docx.service.spec.ts | 25 +++++++++ .../reconciliation/services/docx.service.ts | 55 ++++++++++++++++--- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/backend/src/modules/reconciliation/services/docx.service.spec.ts b/backend/src/modules/reconciliation/services/docx.service.spec.ts index fd500013..48774eb3 100644 --- a/backend/src/modules/reconciliation/services/docx.service.spec.ts +++ b/backend/src/modules/reconciliation/services/docx.service.spec.ts @@ -396,6 +396,31 @@ describe('DocxService — khớp mẫu T6 + regression', () => { expect(t).not.toContain('Số tiền sau phí bán vé:'); }); + /* ── Cột "Giảm giá" điều kiện (audit hồ sơ thật — 7/14 kỳ có giảm giá) ── */ + + it('DISC-01: kỳ CÓ giảm giá → bảng thêm cột "Giảm giá", dòng tự cộng được', async () => { + const t = await xml( + await docxSvc.generate({ + ...rec, + net_revenue: 4400000, + fee_amount: 242000, + payout_amount: 4158000, + line_items: [ + // 500.000 × 10 − 600.000 = 4.400.000 + { order_category: 'ORDINARY', ticket_type_name: 'Standard', distance_name: '10KM', unit_price: 500000, quantity: 10, discount_amount: 600000, subtotal: 5000000, add_on_price: 0 }, + ], + }), + ); + expect(t).toContain('Giảm giá'); + expect(t).toContain('600.000'); // giá trị giảm hiện trên dòng + expect(t).toContain('4.400.000'); // thành tiền net + }); + + it('DISC-02: kỳ KHÔNG giảm giá → giữ nguyên 6 cột (khớp mẫu SCB, không cột Giảm giá)', async () => { + const t = await xml(await docxSvc.generate(rec)); + expect(t).not.toContain('Giảm giá'); + }); + /* ── Pha 5-A: kỳ đối soát theo GIAI ĐOẠN bán vé (Danny chốt 2026-07-11) ── */ it('PHASE-01: kỳ giai đoạn → header + prose ghi "từ dd/mm đến hết dd/mm" (khớp mẫu VTV)', async () => { diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index 6843cdf5..505a35b3 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -718,11 +718,22 @@ export class DocxService { /* ------------------------------------------------------------------ */ private buildRevenueTable(rec: ReconciliationDocument): Table { const HEADER_BG = 'BDD7EE'; + // Audit hồ sơ thật 2026-07-10: 7/14 kỳ có giảm giá dùng bảng CÓ cột + // "Giảm giá" (SL × Giá − Giảm = Thành tiền tự cộng được trên giấy ký). + // Cột chỉ hiện khi kỳ có giảm giá; kỳ không giảm giữ 6 cột khớp mẫu SCB. + const hasDiscount = rec.line_items.some( + (li) => + li.order_category !== 'CHANGE_COURSE' && (li.discount_amount || 0) > 0, + ); // 6 cột mẫu T6: STT | Giai đoạn | Hạng vé | Số lượng vé đã bán qua 5BIB | // Giá vé niêm yết (Đã bao gồm VAT) | Thành tiền. Sum = 7860. - const colWidths = [500, 1500, 1200, 1600, 1560, 1500]; - const spanW = - colWidths[0] + colWidths[1] + colWidths[2] + colWidths[3] + colWidths[4]; // 6360 + // 7 cột (kỳ có giảm giá): + "Giảm giá" trước "Thành tiền". Sum = 7860. + const colWidths = hasDiscount + ? [500, 1300, 1100, 1300, 1360, 1100, 1200] + : [500, 1500, 1200, 1600, 1560, 1500]; + const lastCol = colWidths.length - 1; + // Dòng tổng: gộp mọi cột trừ cột cuối. + const spanW = colWidths.slice(0, lastCol).reduce((a, b) => a + b, 0); const headerRow = new TableRow({ tableHeader: true, @@ -750,8 +761,17 @@ export class DocxService { shading: HEADER_BG, align: AlignmentType.RIGHT, }), + ...(hasDiscount + ? [ + tCell([{ text: 'Giảm giá', bold: true }], { + width: colWidths[5], + shading: HEADER_BG, + align: AlignmentType.RIGHT, + }), + ] + : []), tCell([{ text: 'Thành tiền', bold: true }], { - width: colWidths[5], + width: colWidths[lastCol], shading: HEADER_BG, align: AlignmentType.RIGHT, }), @@ -787,8 +807,24 @@ export class DocxService { width: colWidths[4], align: AlignmentType.RIGHT, }), + ...(hasDiscount + ? [ + tCell( + [ + { + text: + li.order_category !== 'CHANGE_COURSE' && + (li.discount_amount || 0) > 0 + ? fmtVnd(li.discount_amount) + : '', + }, + ], + { width: colWidths[5], align: AlignmentType.RIGHT }, + ), + ] + : []), tCell([{ text: fmtVnd(lineNet(li)) }], { - width: colWidths[5], + width: colWidths[lastCol], align: AlignmentType.RIGHT, }), ], @@ -805,9 +841,12 @@ export class DocxService { new TableRow({ cantSplit: true, children: [ - tCell([{ text: label, bold: true }], { colspan: 5, width: spanW }), + tCell([{ text: label, bold: true }], { + colspan: lastCol, + width: spanW, + }), tCell([{ text: fmtVnd(value), bold: true }], { - width: colWidths[5], + width: colWidths[lastCol], align: AlignmentType.RIGHT, }), ], @@ -839,7 +878,7 @@ export class DocxService { return new Table({ width: { size: 7860, type: WidthType.DXA }, layout: TableLayoutType.FIXED, - columnWidths: colWidths, + columnWidths: [...colWidths], rows: [headerRow, ...dataRows, ...summaryRows], }); } From 7784111071b3c692ce53853488ff0654c5ee3cac Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 15:32:05 +0700 Subject: [PATCH 19/23] =?UTF-8?q?docs(recon):=2006=20=E2=80=94=20c?= =?UTF-8?q?=E1=BA=ADp=20nh=E1=BA=ADt=204=20quy=E1=BA=BFt=20=C4=91=E1=BB=8B?= =?UTF-8?q?nh=20Danny=202026-07-11=20(MBBANK/giai=20=C4=91o=E1=BA=A1n/b?= =?UTF-8?q?=E1=BB=8F=20Payoo/c=E1=BB=99t=20Gi=E1=BA=A3m=20gi=C3=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/recon-automation/06-real-docs-review.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/recon-automation/06-real-docs-review.md b/docs/recon-automation/06-real-docs-review.md index c0fe3482..cc653a42 100644 --- a/docs/recon-automation/06-real-docs-review.md +++ b/docs/recon-automation/06-real-docs-review.md @@ -4,6 +4,12 @@ - **Method:** extract 45 file DOCX/XLSX → workflow 14 agent audit song song (số học / số bằng chữ / thông tin / format / registry) + synthesis. Các claim CRITICAL đã spot-check nguyên văn. - **Kết quả:** 86 lỗi con người (8 CRITICAL / 13 HIGH / 12 MEDIUM / 53 LOW). +## Quyết định Danny 2026-07-11 (sau báo cáo này) +- **Bên B = MBBANK Thụy Khuê 110398986** (thay BIDV) — đã áp, verify live DEV. +- **Kỳ đối soát theo GIAI ĐOẠN bán vé** — đã build (Pha 5-A, commit 64d5835), E2E live DEV pass (range cắt biên đúng từng ngày ICT). +- **Phí theo kênh Payoo (VTV 7,5%/6,5%) — KHÔNG làm** ("đặc thù quá"): phần phí kênh + NET settlement của VTV làm tay; kỳ giai đoạn vẫn dùng cho query đơn. +- **Cột "Giảm giá" bảng DOCX — đã làm** (commit 41c669b): kỳ có giảm giá → 7 cột tự cộng được; kỳ không giảm giữ 6 cột khớp mẫu SCB. + ## Hành động đã áp ngay vào hệ thống mới - **"lẻ" → "linh"**: 100% hồ sơ thật dùng "linh" (5 lần), 0 bản dùng "lẻ" → đổi `numToWords` khớp house-style (commit này). - Xác nhận hệ mới **KHÔNG replicate** 2 lỗi nằm trong chính quy trình cũ: (a) mẫu canonical SCB T5+T6 thiếu chữ "nghìn" ở dòng (3) bằng chữ; (b) DOCX bỏ sót phí thủ công (Đường Đua Mới 4/4 kỳ) — hệ mới có dòng "Trừ phí giao dịch thủ công"+(4). From 79ffd5836b41e49c279bb920f9c14657ff74a813 Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 16:25:45 +0700 Subject: [PATCH 20/23] =?UTF-8?q?fix(recon-admin):=20list=20hi=E1=BB=83n?= =?UTF-8?q?=20th=E1=BB=8B=20k=E1=BB=B3=20giai=20=C4=91o=E1=BA=A1n=20d?= =?UTF-8?q?=E1=BA=A1ng=20dd/mm/yyyy=20=E2=80=93=20dd/mm/yyyy=20(thay=20v?= =?UTF-8?q?=C3=AC=20'Th=C3=A1ng=20X')?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nit từ E2E live Pha 5-A: formatPeriod list + formatPeriodLabel helper chỉ nhìn tháng nên bản giai đoạn 30/06→30/06 hiển thị 'Tháng 6/2026' gây hiểu nhầm kỳ tròn tháng. Nay không tròn-tháng → in đủ ngày. Trang chi tiết vốn đã đúng. Co-Authored-By: Claude Opus 4.8 --- admin/src/app/(dashboard)/reconciliations/page.tsx | 13 +++++++++---- admin/src/lib/period-helpers.ts | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/admin/src/app/(dashboard)/reconciliations/page.tsx b/admin/src/app/(dashboard)/reconciliations/page.tsx index adc63f06..6feda6e1 100644 --- a/admin/src/app/(dashboard)/reconciliations/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/page.tsx @@ -140,15 +140,20 @@ function formatDate(iso: string) { } // FEATURE-003 BR-12 — render kỳ for list table: -// N=1 → "Tháng MM/YYYY" -// N>1 → "Tháng MMs/YYYYs → Tháng MMe/YYYYe" +// Tròn-tháng N=1 → "Tháng MM/YYYY"; N>1 → "Tháng MMs/YYYYs → Tháng MMe/YYYYe" +// Kỳ GIAI ĐOẠN bán vé (Pha 5-A, ngày bất kỳ) → "dd/mm/yyyy – dd/mm/yyyy" function formatPeriod(start: string, end: string) { if (!start || !end) return "—"; const s = start.split("-"); const e = end.split("-"); if (s.length !== 3 || e.length !== 3) return `${start} – ${end}`; - const [sy, sm] = s; - const [ey, em] = e; + const [sy, sm, sd] = s; + const [ey, em, ed] = e; + const lastDay = new Date(Date.UTC(Number(ey), Number(em), 0)).getUTCDate(); + const monthAligned = Number(sd) === 1 && Number(ed) === lastDay; + if (!monthAligned) { + return `${sd}/${sm}/${sy} – ${ed}/${em}/${ey}`; + } if (sy === ey && sm === em) { return `Tháng ${Number(sm)}/${sy}`; } diff --git a/admin/src/lib/period-helpers.ts b/admin/src/lib/period-helpers.ts index 04874a57..ed370515 100644 --- a/admin/src/lib/period-helpers.ts +++ b/admin/src/lib/period-helpers.ts @@ -126,8 +126,8 @@ export function presetPreviousQuarter(now: Date = new Date()): { /** * Format period range for display. - * - N=1 → "Tháng MM/YYYY" - * - N>1 → "Tháng MMs/YYYYs → Tháng MMe/YYYYe" + * - Tròn-tháng N=1 → "Tháng MM/YYYY"; N>1 → "Tháng MMs/YYYYs → Tháng MMe/YYYYe" + * - Kỳ GIAI ĐOẠN bán vé (Pha 5-A, ngày bất kỳ) → "dd/mm/yyyy – dd/mm/yyyy" */ export function formatPeriodLabel(period_start: string, period_end: string): string { if (!period_start || !period_end) return '—'; @@ -136,8 +136,13 @@ export function formatPeriodLabel(period_start: string, period_end: string): str if (sParts.length !== 3 || eParts.length !== 3) { return `${period_start} – ${period_end}`; } - const [sy, sm] = sParts; - const [ey, em] = eParts; + const [sy, sm, sd] = sParts; + const [ey, em, ed] = eParts; + const lastDay = new Date(Date.UTC(Number(ey), Number(em), 0)).getUTCDate(); + const monthAligned = Number(sd) === 1 && Number(ed) === lastDay; + if (!monthAligned) { + return `${sd}/${sm}/${sy} – ${ed}/${em}/${ey}`; + } if (sy === ey && sm === em) { return `Tháng ${Number(sm)}/${sy}`; } From 6b476cf2d9200b60be3708c7b68566a17fb132ee Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 18:49:50 +0700 Subject: [PATCH 21/23] =?UTF-8?q?feat(recon):=20email=20=C4=91=E1=BB=91i?= =?UTF-8?q?=20so=C3=A1t=20theo=204=20ch=E1=BB=91t=20Danny=202026-07-11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Người gửi "Đối soát 5BIB " (env RECON_FROM_EMAIL/NAME, Joi default) + LUÔN CC danny@5bib.com (RECON_ALWAYS_CC, dedupe). 2. Thân email thêm điều khoản: sau 05 (năm) ngày không phản hồi → số liệu mặc định được xác nhận và đồng ý. 3. Kỳ 1 ngày hết in "từ 30/06 -> hết ngày 30/06" → "ngày 30/06/2026" (email + subject + docx + xlsx + admin list + filename segment). 4. Notification nhắc kế toán xuất hóa đơn (RECON_ACCOUNTING_EMAIL = ketoan@5bib.com): (a) ngay sau khi gửi đối soát thành công — email "Đã gửi đối soát... theo dõi xuất hóa đơn" kèm phí căn cứ HĐ; (b) cron 08:30 ICT hằng ngày — recon đã gửi QUÁ 5 ngày chưa phản hồi + chưa xuất HĐ → email "Đến hạn xuất hóa đơn" (idempotent qua invoice_reminder_sent_at, schema field mới). +5 test. 243/243 recon+common pass, build backend + admin xanh. Co-Authored-By: Claude Opus 4.8 --- .../app/(dashboard)/reconciliations/page.tsx | 1 + admin/src/lib/period-helpers.ts | 1 + backend/src/config/index.ts | 13 +++++ .../src/modules/notification/mail.service.ts | 15 +++++- .../reconciliation/reconciliation.service.ts | 19 ++++++- .../schemas/reconciliation.schema.ts | 5 ++ .../reconciliation/services/docx.service.ts | 4 +- .../services/period-label.helper.spec.ts | 9 ++++ .../services/period-label.helper.ts | 4 ++ .../reconciliation-email.helper.spec.ts | 35 +++++++++++++ .../services/reconciliation-email.helper.ts | 52 ++++++++++++++++++- .../services/reconciliation.cron.ts | 48 +++++++++++++++++ .../reconciliation/services/xlsx.service.ts | 4 +- 13 files changed, 204 insertions(+), 6 deletions(-) diff --git a/admin/src/app/(dashboard)/reconciliations/page.tsx b/admin/src/app/(dashboard)/reconciliations/page.tsx index 6feda6e1..27df35ba 100644 --- a/admin/src/app/(dashboard)/reconciliations/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/page.tsx @@ -152,6 +152,7 @@ function formatPeriod(start: string, end: string) { const lastDay = new Date(Date.UTC(Number(ey), Number(em), 0)).getUTCDate(); const monthAligned = Number(sd) === 1 && Number(ed) === lastDay; if (!monthAligned) { + if (start === end) return `${sd}/${sm}/${sy}`; return `${sd}/${sm}/${sy} – ${ed}/${em}/${ey}`; } if (sy === ey && sm === em) { diff --git a/admin/src/lib/period-helpers.ts b/admin/src/lib/period-helpers.ts index ed370515..42d436e3 100644 --- a/admin/src/lib/period-helpers.ts +++ b/admin/src/lib/period-helpers.ts @@ -141,6 +141,7 @@ export function formatPeriodLabel(period_start: string, period_end: string): str const lastDay = new Date(Date.UTC(Number(ey), Number(em), 0)).getUTCDate(); const monthAligned = Number(sd) === 1 && Number(ed) === lastDay; if (!monthAligned) { + if (period_start === period_end) return `${sd}/${sm}/${sy}`; return `${sd}/${sm}/${sy} – ${ed}/${em}/${ey}`; } if (sy === ey && sm === em) { diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index e7033d4b..8d36565d 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -129,6 +129,13 @@ const envVarsSchema = Joi.object() BIB_PASS_SEND_ENABLED: Joi.boolean().default(false), BIB_PASS_BATCH_LIMIT: Joi.number().min(1).max(2000).default(200), BIB_PASS_SCAN_CRON: Joi.string().default('0 */2 * * *'), + // Recon automation (Danny chốt 2026-07-11) — email đối soát gửi BTC: + // from "Đối soát 5BIB ", luôn CC danny@5bib.com; + // notification nhắc xuất hóa đơn về hộp thư kế toán. + RECON_FROM_EMAIL: Joi.string().default('ketoan@5bib.com'), + RECON_FROM_NAME: Joi.string().default('Đối soát 5BIB'), + RECON_ALWAYS_CC: Joi.string().default('danny@5bib.com'), + RECON_ACCOUNTING_EMAIL: Joi.string().default('ketoan@5bib.com'), }) .unknown(); @@ -175,6 +182,12 @@ export const env = { user: envVars.VOLUNTEER_DB_USER as string | undefined, pass: envVars.VOLUNTEER_DB_PASS as string | undefined, }, + recon: { + fromEmail: envVars.RECON_FROM_EMAIL as string, + fromName: envVars.RECON_FROM_NAME as string, + alwaysCc: envVars.RECON_ALWAYS_CC as string, + accountingEmail: envVars.RECON_ACCOUNTING_EMAIL as string, + }, teamManagement: { magicTokenDays: envVars.MAGIC_TOKEN_EXPIRES_DAYS as number, s3Bucket: envVars.TEAM_S3_BUCKET as string, diff --git a/backend/src/modules/notification/mail.service.ts b/backend/src/modules/notification/mail.service.ts index ee1472c0..ca739170 100644 --- a/backend/src/modules/notification/mail.service.ts +++ b/backend/src/modules/notification/mail.service.ts @@ -533,14 +533,25 @@ export class MailService { return false; } try { + // Danny chốt 2026-07-11: luôn CC hộp thư giám sát (danny@5bib.com) trên + // MỌI email đối soát gửi BTC — dedupe nếu đã nằm trong To/CC. + const alwaysCc = (env.recon.alwaysCc || '') + .split(',') + .map((e) => e.trim()) + .filter(Boolean); + const seen = new Set( + [...args.to, ...(args.cc ?? [])].map((e) => e.toLowerCase()), + ); + const extraCc = alwaysCc.filter((e) => !seen.has(e.toLowerCase())); const recipients = [ ...args.to.map((email) => ({ email, type: 'to' })), ...(args.cc ?? []).map((email) => ({ email, type: 'cc' })), + ...extraCc.map((email) => ({ email, type: 'cc' })), ]; await this.client.messages.send({ message: { - from_email: env.teamManagement.emailFrom, - from_name: args.fromName || 'CÔNG TY CỔ PHẦN 5BIB', + from_email: env.recon.fromEmail, + from_name: args.fromName || env.recon.fromName, subject: args.subject, html: args.html, to: recipients, diff --git a/backend/src/modules/reconciliation/reconciliation.service.ts b/backend/src/modules/reconciliation/reconciliation.service.ts index d8c3dc8b..8006d472 100644 --- a/backend/src/modules/reconciliation/reconciliation.service.ts +++ b/backend/src/modules/reconciliation/reconciliation.service.ts @@ -41,8 +41,10 @@ import { isMonthAlignedRange } from '../../common/utils/ict-date.util'; import { MailService } from '../notification/mail.service'; import { AthleteListService } from './services/athlete-list.service'; import { - buildReconEmailHtml, buildReconEmailSubject, + buildReconEmailHtml, + buildInvoiceReminderSubject, + buildInvoiceReminderHtml, } from './services/reconciliation-email.helper'; const BUCKET_NAME = env.s3.bucket; @@ -574,6 +576,21 @@ export class ReconciliationService { doc.email_recipients = [...to, ...cc]; await doc.save(); await this.flushPnLCacheForRecon(doc.tenant_id, doc.mysql_race_id); + // Danny 2026-07-11 — notification nội bộ cho kế toán (ketoan@5bib.com) + // ngay sau khi gửi đối soát: theo dõi phản hồi + xuất hóa đơn. + // Fire-and-forget: lỗi notify KHÔNG làm fail flow gửi chính. + try { + await this.mailService.sendReconciliation({ + to: [env.recon.accountingEmail], + subject: buildInvoiceReminderSubject(doc, 'sent'), + html: buildInvoiceReminderHtml(doc, 'sent', to), + attachments: [], + }); + } catch (e) { + this.logger.warn( + `recon_accounting_notify_failed id=${id}: ${(e as Error).message}`, + ); + } return { sent: true, to, cc }; } return { diff --git a/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts b/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts index a79f72cf..be2053bc 100644 --- a/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts +++ b/backend/src/modules/reconciliation/schemas/reconciliation.schema.ts @@ -196,6 +196,11 @@ export class Reconciliation { @Prop({ type: Date, default: null }) invoice_issued_at: Date | null; + // Nhắc xuất hóa đơn (Danny 2026-07-11): cron đánh dấu đã gửi reminder + // "quá 05 ngày không phản hồi" cho kế toán — idempotent per recon. + @Prop({ type: Date, default: null }) + invoice_reminder_sent_at: Date | null; + // Embedded line items (5BIB orders grouped by ticket_type + distance) @Prop({ type: [LineItemSchema], default: [] }) line_items: LineItem[]; diff --git a/backend/src/modules/reconciliation/services/docx.service.ts b/backend/src/modules/reconciliation/services/docx.service.ts index 505a35b3..88cb357f 100644 --- a/backend/src/modules/reconciliation/services/docx.service.ts +++ b/backend/src/modules/reconciliation/services/docx.service.ts @@ -374,7 +374,9 @@ export class DocxService { align: AlignmentType.CENTER, }), para( - `(từ ${formatDate(rec.period_start)} đến ${formatDate(rec.period_end)})`, + rec.period_start === rec.period_end + ? `(ngày ${formatDate(rec.period_start)})` + : `(từ ${formatDate(rec.period_start)} đến ${formatDate(rec.period_end)})`, { align: AlignmentType.CENTER, spacingAfter: 120 }, ), diff --git a/backend/src/modules/reconciliation/services/period-label.helper.spec.ts b/backend/src/modules/reconciliation/services/period-label.helper.spec.ts index e1ed7bd4..867ae5b0 100644 --- a/backend/src/modules/reconciliation/services/period-label.helper.spec.ts +++ b/backend/src/modules/reconciliation/services/period-label.helper.spec.ts @@ -69,3 +69,12 @@ describe('kỳ giai đoạn bán vé (Pha 5-A)', () => { ); }); }); + +describe('kỳ 1 ngày (Danny 2026-07-11)', () => { + it('renderPeriodLabel: start === end → "ngày dd/mm/yyyy"', () => { + expect(renderPeriodLabel('2026-06-30', '2026-06-30')).toBe('ngày 30/06/2026'); + }); + it('filenamePeriodSegment: start === end → YYYY_MM_DD', () => { + expect(filenamePeriodSegment('2026-06-30', '2026-06-30')).toBe('2026_06_30'); + }); +}); diff --git a/backend/src/modules/reconciliation/services/period-label.helper.ts b/backend/src/modules/reconciliation/services/period-label.helper.ts index 7ca27f56..f678e747 100644 --- a/backend/src/modules/reconciliation/services/period-label.helper.ts +++ b/backend/src/modules/reconciliation/services/period-label.helper.ts @@ -40,6 +40,9 @@ export function renderPeriodLabel(period_start: string, period_end: string): str return `(Từ ${period_start} đến hết ${period_end})`; } if (!isMonthAligned(period_start, period_end)) { + if (period_start === period_end) { + return `ngày ${ddmmyyyy(period_start)}`; + } return `từ ${ddmmyyyy(period_start)} đến hết ${ddmmyyyy(period_end)}`; } if (start.year === end.year && start.month === end.month) { @@ -59,6 +62,7 @@ export function filenamePeriodSegment(period_start: string, period_end: string): const end = parseYearMonth(period_end); if (!start || !end) return 'unknown'; if (!isMonthAligned(period_start, period_end)) { + if (period_start === period_end) return period_start.replace(/-/g, '_'); return `${period_start.replace(/-/g, '_')}_den_${period_end.replace(/-/g, '_')}`; } const startStr = `${start.year}_${String(start.month).padStart(2, '0')}`; diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts index 1cd15c1c..52fe1b54 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.spec.ts @@ -1,6 +1,8 @@ import { buildReconEmailSubject, buildReconEmailHtml, + buildInvoiceReminderSubject, + buildInvoiceReminderHtml, } from './reconciliation-email.helper'; const rec: any = { @@ -76,4 +78,37 @@ describe('reconciliation-email.helper', () => { }); expect(h).toContain('400.000'); // 500000 - 100000 net }); + + it('DANNY-01: thân email có điều khoản 05 ngày mặc định đồng ý', () => { + const h = buildReconEmailHtml(rec); + expect(h).toContain('05 (năm) ngày'); + expect(h).toContain('đã xác nhận và đồng ý'); + }); + + it('DANNY-02: kỳ 1 ngày → "Thời gian đối soát: ngày 30/06/2026" (không "từ X đến X")', () => { + const h = buildReconEmailHtml({ + ...rec, + period_start: '2026-06-30', + period_end: '2026-06-30', + }); + expect(h).toContain('Thời gian đối soát: ngày 30/06/2026'); + expect(h).not.toContain('từ 30/06/2026'); + // subject cũng dùng label ngày + expect( + buildReconEmailSubject({ ...rec, period_start: '2026-06-30', period_end: '2026-06-30' }), + ).toContain('— ngày 30/06/2026'); + }); + + it('DANNY-03: notification kế toán — subject + nội dung căn cứ xuất HĐ', () => { + const doc: any = { ...rec, email_sent_at: new Date('2026-07-11T09:23:00Z'), tenant_name: 'DHA' }; + const sSent = buildInvoiceReminderSubject(doc, 'sent'); + expect(sSent).toContain('Đã gửi đối soát'); + expect(sSent).toContain('theo dõi xuất hóa đơn'); + const sDue = buildInvoiceReminderSubject(doc, 'due'); + expect(sDue).toContain('Đến hạn xuất hóa đơn'); + const h = buildInvoiceReminderHtml(doc, 'due', ['btc@x.com']); + expect(h).toContain('xuất hóa đơn phí dịch vụ'); + expect(h).toContain('105.000'); // phí căn cứ HĐ + expect(h).toContain('1.395.000'); // payout + }); }); diff --git a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts index dde83c74..01a77549 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-email.helper.ts @@ -74,7 +74,11 @@ export function buildReconEmailHtml(rec: ReconciliationDocument): string {

5BIB xin gửi Quý đối tác biên bản & bảng đối soát bán vé cho kỳ dưới đây. Chi tiết đầy đủ trong 03 file đính kèm: Biên bản đối soát, Bảng đối soát, và Danh sách vận động viên.

SỰ KIỆN: ${esc(rec.race_title)}

Ngày đối soát: ${ddmmyyyy(rec.signed_date_str)}

-

Thời gian đối soát từ ${ddmmyyyy(rec.period_start)} -> hết ngày ${ddmmyyyy(rec.period_end)}

+

${ + rec.period_start === rec.period_end + ? `Thời gian đối soát: ngày ${ddmmyyyy(rec.period_start)}` + : `Thời gian đối soát từ ${ddmmyyyy(rec.period_start)} -> hết ngày ${ddmmyyyy(rec.period_end)}` + }

1. GIAO DỊCH THANH TOÁN QUA 5BIB

@@ -106,6 +110,52 @@ export function buildReconEmailHtml(rec: ReconciliationDocument): string {

Quý đối tác vui lòng phản hồi email này để xác nhận số liệu. Sau khi xác nhận, 5BIB sẽ gửi bản in biên bản có ký & đóng dấu và tiến hành thanh toán/xuất hóa đơn theo hợp đồng.

+

Sau 05 (năm) ngày kể từ ngày gửi email này, nếu 5BIB không nhận được phản hồi, số liệu đối soát trên được xem là Quý đối tác đã xác nhận và đồng ý.

Trân trọng,
CÔNG TY CỔ PHẦN 5BIB

`; } + +/* ------------------------------------------------------------------ */ +/* Notification nội bộ — nhắc kế toán xuất hóa đơn (Danny 2026-07-11) */ +/* ------------------------------------------------------------------ */ + +export function buildInvoiceReminderSubject( + rec: ReconciliationDocument, + kind: 'sent' | 'due', +): string { + const label = renderPeriodLabel(rec.period_start, rec.period_end); + return kind === 'sent' + ? `[5BIB] Đã gửi đối soát — ${rec.race_title} — ${label} (theo dõi xuất hóa đơn)` + : `[5BIB] Đến hạn xuất hóa đơn — ${rec.race_title} — ${label} (quá 05 ngày không phản hồi)`; +} + +export function buildInvoiceReminderHtml( + rec: ReconciliationDocument, + kind: 'sent' | 'due', + recipients: string[], +): string { + const feeInclVat = Math.round(rec.fee_amount + (rec.fee_vat_amount || 0)); + const label = renderPeriodLabel(rec.period_start, rec.period_end); + const sentAt = rec.email_sent_at + ? new Date(rec.email_sent_at.getTime() + 7 * 3_600_000) + .toISOString() + .slice(0, 16) + .replace('T', ' ') + : ''; + const intro = + kind === 'sent' + ? `

Đối soát dưới đây vừa được gửi cho BTC (${recipients.map(esc).join(', ')}). Theo dõi phản hồi để xuất hóa đơn phí dịch vụ; nếu sau 05 ngày BTC không phản hồi thì số liệu mặc định được xác nhận.

` + : `

Đối soát dưới đây đã gửi BTC từ ${esc(sentAt)} (giờ VN) và quá 05 ngày chưa có phản hồi — theo điều khoản trong email, số liệu được xem là ĐÃ xác nhận. Đề nghị tiến hành xuất hóa đơn phí dịch vụ.

`; + return `
+ ${intro} + + + + + + + +
Sự kiện${esc(rec.race_title)}
Merchant${esc(rec.tenant_name || rec.tenant_id)}
Kỳ đối soát${esc(label)}
Doanh thu bán vé (1)${vnd(rec.net_revenue)} đ
Phí dịch vụ căn cứ xuất HĐ (đã gồm VAT)${vnd(feeInclVat)} đ
5BIB thanh toán merchant${vnd(rec.payout_amount)} đ
+

Email tự động từ hệ thống đối soát 5BIB.

+
`; +} diff --git a/backend/src/modules/reconciliation/services/reconciliation.cron.ts b/backend/src/modules/reconciliation/services/reconciliation.cron.ts index 63d19eab..38ec92f3 100644 --- a/backend/src/modules/reconciliation/services/reconciliation.cron.ts +++ b/backend/src/modules/reconciliation/services/reconciliation.cron.ts @@ -18,6 +18,12 @@ import { } from '../schemas/reconciliation-cron-log.schema'; // F-082 — ICT-safe date derive cho cron prev-month. import { nowIctDateString } from '../../../common/utils/ict-date.util'; +import { MailService } from '../../notification/mail.service'; +import { env } from 'src/config'; +import { + buildInvoiceReminderSubject, + buildInvoiceReminderHtml, +} from './reconciliation-email.helper'; const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || @@ -46,8 +52,50 @@ export class ReconciliationCron { private readonly configModel: Model, @InjectModel(ReconciliationCronLog.name) private readonly cronLogModel: Model, + private readonly mailService: MailService, ) {} + /** + * Danny 2026-07-11 — nhắc kế toán xuất hóa đơn: đối soát đã gửi BTC + * QUÁ 05 NGÀY mà chưa có phản hồi (email_confirmed_at null) và chưa xuất + * hóa đơn → theo điều khoản trong email, số liệu mặc định được xác nhận + * → gửi reminder tới hộp thư kế toán. Idempotent per recon qua + * invoice_reminder_sent_at (set TRƯỚC khi gửi — không spam nếu mail lỗi + * thì log để xử lý tay). + */ + @Cron('30 8 * * *', { timeZone: 'Asia/Ho_Chi_Minh' }) + async handleInvoiceDueReminders() { + const cutoff = new Date(Date.now() - 5 * 24 * 3_600_000); + const due = await this.reconciliationModel + .find({ + status: 'sent', + email_sent_at: { $ne: null, $lte: cutoff }, + email_confirmed_at: null, + invoice_issued_at: null, + invoice_reminder_sent_at: null, + }) + .limit(50); + if (!due.length) return; + this.logger.log(`invoice_due_reminders: ${due.length} recon quá 5 ngày chưa phản hồi`); + for (const doc of due) { + doc.invoice_reminder_sent_at = new Date(); + await doc.save(); + try { + const ok = await this.mailService.sendReconciliation({ + to: [env.recon.accountingEmail], + subject: buildInvoiceReminderSubject(doc, 'due'), + html: buildInvoiceReminderHtml(doc, 'due', doc.email_recipients ?? []), + attachments: [], + }); + if (!ok) this.logger.warn(`invoice_due_reminder_not_sent id=${doc._id}`); + } catch (e) { + this.logger.error( + `invoice_due_reminder_failed id=${doc._id}: ${(e as Error).message}`, + ); + } + } + } + // F-082 — Chạy 08:00 SÁNG VN ngày 1 hàng tháng (trước: 08:00 UTC = 15:00 ICT // + prev-month derive theo server TZ → label sai nếu fire trong cửa sổ lệch). @Cron('0 8 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' }) diff --git a/backend/src/modules/reconciliation/services/xlsx.service.ts b/backend/src/modules/reconciliation/services/xlsx.service.ts index c82d1bcb..ec34364c 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.ts @@ -103,7 +103,9 @@ export class XlsxService { // Mẫu T6: "Thời gian đối soát từ dd/mm/yyyy -> hết ngày dd/mm/yyyy" styleCell( ws.getRow(r).getCell(1), - `Thời gian đối soát từ ${formatDate(rec.period_start)} -> hết ngày ${formatDate(rec.period_end)}`, + rec.period_start === rec.period_end + ? `Thời gian đối soát: ngày ${formatDate(rec.period_start)}` + : `Thời gian đối soát từ ${formatDate(rec.period_start)} -> hết ngày ${formatDate(rec.period_end)}`, { font: FONT_BASE, border: false }, ); r += 2; // skip blank row From 28c9ae65524fc635c4723c91e7f15c3da1a77c0b Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 18:51:08 +0700 Subject: [PATCH 22/23] =?UTF-8?q?docs:=20CLAUDE.md=20env=20RECON=5F*=20(em?= =?UTF-8?q?ail=20=C4=91=E1=BB=91i=20so=C3=A1t=20from/CC/notification=20k?= =?UTF-8?q?=E1=BA=BF=20to=C3=A1n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3bcd79d8..5789ba8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,6 +136,12 @@ RESULT_PUBLIC_URL=https://result.5bib.com # Used to embed QR code + share links BIB_PASS_SEND_ENABLED=false # Kill-switch — false = cron/batch chỉ dry-run, KHÔNG gửi email thật (dev/staging default) BIB_PASS_BATCH_LIMIT=200 # Trần số email gửi mỗi lần quét/cron (throttle) BIB_PASS_SCAN_CRON=0 */2 * * * # Lịch cron quét VĐV mới xác nhận BIB (mặc định mỗi 2 giờ, TZ Asia/Ho_Chi_Minh) + +# Recon automation — email đối soát gửi BTC (Danny chốt 2026-07-11; Joi default sẵn, KHÔNG cần set env) +RECON_FROM_EMAIL=ketoan@5bib.com # From của email đối soát + notification kế toán +RECON_FROM_NAME=Đối soát 5BIB # Tên người gửi hiển thị +RECON_ALWAYS_CC=danny@5bib.com # LUÔN CC trên mọi email đối soát (dedupe nếu trùng To/CC) +RECON_ACCOUNTING_EMAIL=ketoan@5bib.com # Nhận notification nhắc xuất hóa đơn (ngay sau gửi + cron 08:30 ICT quá-5-ngày) ``` ### Frontend / Admin (runtime) From 2f8d5309a337ab9476221ef4bf19f488415e490d Mon Sep 17 00:00:00 2001 From: Danny Nguyen Date: Sat, 11 Jul 2026 19:01:23 +0700 Subject: [PATCH 23/23] =?UTF-8?q?feat(recon):=20raw=20data=20cross-check?= =?UTF-8?q?=20+=20role=20"=C4=90=E1=BB=91i=20so=C3=A1t"=20+=20finance=20ac?= =?UTF-8?q?cess=20(Danny=202026-07-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Raw data cross-check — nhân viên tự lấy không cần Danny: - queryRawData: NGUYÊN VĂN câu SQL Danny chạy tay hằng tháng (dump thô đơn COMPLETE trong kỳ, 25 cột, ORDER BY oli.id — KHÔNG filter financial_status, KHÔNG categorize) - GET /reconciliations/raw-data/download?mysql_race_id&period_start& period_end (+race_title) → XLSX 1 sheet raw - Admin UI: nút "Tải raw data (cross-check)" ở trang tạo đối soát (dùng picker merchant/race/kỳ sẵn có — cả kỳ giai đoạn) + nút "Raw data" ở trang chi tiết (dùng kỳ của bản đó) 2. RBAC module đối soát — LogtoReconciliationGuard (union reconciliation ∪ staff ∪ finance ∪ admin): - Role MỚI "reconciliation" (tạo trên Logto Dashboard): CHỈ vào được module đối soát — controller khác vẫn LogtoStaffGuard nên không với tới - Role "finance" (Tài chính kế toán) được THÊM quyền module đối soát - Frontend: isReconciliation flag + nav requireRole "reconciliation" + sidebar filter + page guard 4 trang (vá luôn gap: trang new trước đây KHÔNG có guard) +9 test guard. 251/251 pass, build backend + admin xanh. Co-Authored-By: Claude Opus 4.8 --- .../(dashboard)/reconciliations/[id]/page.tsx | 15 ++- .../reconciliations/audit/page.tsx | 4 +- .../(dashboard)/reconciliations/new/page.tsx | 49 +++++++++- .../app/(dashboard)/reconciliations/page.tsx | 4 +- admin/src/components/admin-shell/Sidebar.tsx | 5 +- admin/src/lib/auth-context.tsx | 10 ++ admin/src/lib/nav-groups.ts | 5 +- backend/src/modules/logto-auth/index.ts | 1 + .../logto-reconciliation.guard.spec.ts | 98 +++++++++++++++++++ .../logto-auth/logto-reconciliation.guard.ts | 69 +++++++++++++ .../reconciliation.controller.ts | 51 +++++++++- .../services/reconciliation-query.service.ts | 54 ++++++++++ .../reconciliation/services/xlsx.service.ts | 15 +++ 13 files changed, 368 insertions(+), 12 deletions(-) create mode 100644 backend/src/modules/logto-auth/logto-reconciliation.guard.spec.ts create mode 100644 backend/src/modules/logto-auth/logto-reconciliation.guard.ts diff --git a/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx b/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx index b3e66e64..e8da901a 100644 --- a/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/[id]/page.tsx @@ -238,7 +238,7 @@ function StatusStepper({ currentStatus }: { currentStatus: string }) { export default function ReconciliationDetailPage() { const { id } = useParams<{ id: string }>(); - const { token, isStaff, isLoading: authLoading } = useAuth(); + const { token, isStaff, isFinance, isReconciliation, isLoading: authLoading } = useAuth(); const router = useRouter(); const [data, setData] = useState(null); @@ -376,7 +376,7 @@ export default function ReconciliationDetailPage() { // F-029 BR-HD-30 — page-level RBAC gate. if (authLoading) return null; - if (!isStaff) return ; + if (!isStaff && !isFinance && !isReconciliation) return ; if (loading) { return ( @@ -620,6 +620,17 @@ export default function ReconciliationDetailPage() { Danh sách VĐV + {/* Gửi email đối soát (Pha 4) */} diff --git a/admin/src/app/(dashboard)/reconciliations/audit/page.tsx b/admin/src/app/(dashboard)/reconciliations/audit/page.tsx index b9769b10..98e4f4ca 100644 --- a/admin/src/app/(dashboard)/reconciliations/audit/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/audit/page.tsx @@ -43,7 +43,7 @@ type AuditResponse = { }; export default function ReconciliationAuditPage() { - const { token, isStaff, isLoading: authLoading } = useAuth(); + const { token, isStaff, isFinance, isReconciliation, isLoading: authLoading } = useAuth(); const [loading, setLoading] = useState(false); const [data, setData] = useState(null); const [hasRun, setHasRun] = useState(false); @@ -68,7 +68,7 @@ export default function ReconciliationAuditPage() { // F-029 BR-HD-30 — page-level RBAC gate. if (authLoading) return null; - if (!isStaff) return ; + if (!isStaff && !isFinance && !isReconciliation) return ; return (
diff --git a/admin/src/app/(dashboard)/reconciliations/new/page.tsx b/admin/src/app/(dashboard)/reconciliations/new/page.tsx index 7c95fa25..a3196ab4 100644 --- a/admin/src/app/(dashboard)/reconciliations/new/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/new/page.tsx @@ -14,6 +14,7 @@ import { monthRangeToPeriod, presetPreviousMonth, } from "@/lib/period-helpers"; +import { RestrictedAccess } from "@/components/admin-shell/restricted-access"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -187,7 +188,7 @@ function getTodayStr() { const STEPS = ["Chọn thông tin", "Xem xét & Điều chỉnh", "Tạo tài liệu"]; export default function NewReconciliationPage() { - const { token } = useAuth(); + const { token, isStaff, isFinance, isReconciliation, isLoading: authLoading } = useAuth(); const router = useRouter(); const [step, setStep] = useState(0); @@ -221,6 +222,7 @@ export default function NewReconciliationPage() { ? monthCount < 1 || monthCount > 12 : !phaseStart || !phaseEnd || Number.isNaN(phaseSpanDays) || phaseSpanDays < 0 || phaseSpanDays > 366; const [previewLoading, setPreviewLoading] = useState(false); + const [rawLoading, setRawLoading] = useState(false); // Preflight state const [preflight, setPreflight] = useState(null); @@ -318,6 +320,30 @@ export default function NewReconciliationPage() { } } + async function handleDownloadRawData() { + const effectiveRaceTitle = raceTitle || selectedRace?.title || ""; + if (!token || !selectedRaceId || !periodStart || !periodEnd || periodInvalid) { + toast.error("Chọn merchant, giải và kỳ hợp lệ trước khi tải raw data"); + return; + } + setRawLoading(true); + try { + const url = `/api/reconciliations/raw-data/download?mysql_race_id=${selectedRaceId}&period_start=${periodStart}&period_end=${periodEnd}&race_title=${encodeURIComponent(effectiveRaceTitle)}`; + const res = await fetch(url, { headers: authHeaders(token).headers }); + if (!res.ok) throw new Error("Tải raw data thất bại"); + const blob = await res.blob(); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `Raw data - ${effectiveRaceTitle} - ${periodStart.split("-").reverse().join("-")} đến ${periodEnd.split("-").reverse().join("-")}.xlsx`; + a.click(); + URL.revokeObjectURL(a.href); + } catch { + toast.error("Tải raw data thất bại"); + } finally { + setRawLoading(false); + } + } + async function handlePreview() { const effectiveRaceTitle = raceTitle || selectedRace?.title || ""; if (!token || !selectedMerchantId || !selectedRaceId || !effectiveRaceTitle || !periodStart || !periodEnd) { @@ -420,6 +446,11 @@ export default function NewReconciliationPage() { } } + // Danny 2026-07-11 — page guard (trước đây trang này KHÔNG có guard). + if (!authLoading && !isStaff && !isFinance && !isReconciliation) { + return ; + } + return (
{/* Stepper */} @@ -743,6 +774,22 @@ export default function NewReconciliationPage() { "Kiểm tra dữ liệu" )} + )} diff --git a/admin/src/app/(dashboard)/reconciliations/page.tsx b/admin/src/app/(dashboard)/reconciliations/page.tsx index 27df35ba..7fba2092 100644 --- a/admin/src/app/(dashboard)/reconciliations/page.tsx +++ b/admin/src/app/(dashboard)/reconciliations/page.tsx @@ -171,7 +171,7 @@ function isRecentPeriod(period: string): boolean { } export default function ReconciliationsPage() { - const { token, isStaff, isLoading: authLoading } = useAuth(); + const { token, isStaff, isFinance, isReconciliation, isLoading: authLoading } = useAuth(); const router = useRouter(); const [items, setItems] = useState([]); @@ -608,7 +608,7 @@ export default function ReconciliationsPage() { // F-029 BR-HD-30 — page-level RBAC gate. if (authLoading) return null; - if (!isStaff) return ; + if (!isStaff && !isFinance && !isReconciliation) return ; return (
diff --git a/admin/src/components/admin-shell/Sidebar.tsx b/admin/src/components/admin-shell/Sidebar.tsx index a3bb964b..7052b8ef 100644 --- a/admin/src/components/admin-shell/Sidebar.tsx +++ b/admin/src/components/admin-shell/Sidebar.tsx @@ -83,7 +83,7 @@ function NavLink({ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { const pathname = usePathname(); - const { isAdmin, isFinance, isStaff } = useAuth(); + const { isAdmin, isFinance, isStaff, isReconciliation } = useAuth(); // F-078 BR-78-11 + hotfix3 — RBAC filter 4 tier: // - requireRole="admin" → chỉ admin xem @@ -99,6 +99,9 @@ export function SidebarNav({ onNavigate }: { onNavigate?: () => void }) { if (item.requireRole === "admin") return isAdmin; if (item.requireRole === "finance") return isFinance || isAdmin; if (item.requireRole === "staff-or-finance") return isStaff || isFinance; + // Danny 2026-07-11 — module Đối soát: staff + finance + role reconciliation. + if (item.requireRole === "reconciliation") + return isStaff || isFinance || isReconciliation; // Default = staff trở lên (admin inherits isStaff). Finance KHÔNG thấy. return isStaff; }), diff --git a/admin/src/lib/auth-context.tsx b/admin/src/lib/auth-context.tsx index e2504e63..30b90d9c 100644 --- a/admin/src/lib/auth-context.tsx +++ b/admin/src/lib/auth-context.tsx @@ -50,6 +50,12 @@ interface AuthContextType { isFinance: boolean; /** True khi user là staff hoặc cao hơn (bao gồm admin / super_admin). */ isStaff: boolean; + /** + * Danny 2026-07-11 — role "Đối soát" (Logto role/scope `reconciliation`): + * CHỈ truy cập module đối soát. True khi có role/scope reconciliation hoặc + * admin inheritance. Mirror backend LogtoReconciliationGuard. + */ + isReconciliation: boolean; } const AuthContext = createContext(null); @@ -105,6 +111,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAdmin || hasScope("finance") || hasRole("finance"); const isStaff = isAdmin || hasScope("staff") || hasRole("staff"); + // Danny 2026-07-11 — role "Đối soát" (reconciliation-only). + const isReconciliation = + isAdmin || hasScope("reconciliation") || hasRole("reconciliation"); useEffect(() => { // Legacy LOCAL_STORAGE token cleanup — remove on first mount @@ -138,6 +147,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAdmin, isFinance, isStaff, + isReconciliation, }; return {children}; diff --git a/admin/src/lib/nav-groups.ts b/admin/src/lib/nav-groups.ts index a7f8b332..285cff6d 100644 --- a/admin/src/lib/nav-groups.ts +++ b/admin/src/lib/nav-groups.ts @@ -74,7 +74,7 @@ export type NavItem = { * internal nav = operational items, ko cho finance * thuan tuy nhin). Finance items phai tag explicit. */ - requireRole?: "admin" | "finance" | "staff-or-finance"; + requireRole?: "admin" | "finance" | "staff-or-finance" | "reconciliation"; }; export type NavGroup = { @@ -99,7 +99,8 @@ export const NAV_GROUPS: NavGroup[] = [ badge: "NEW", requireRole: "admin", }, - { id: "reconciliations", href: "/reconciliations", label: "Đối soát", icon: ReceiptText }, + // Danny 2026-07-11 — mở cho role "Đối soát" (reconciliation) + finance. + { id: "reconciliations", href: "/reconciliations", label: "Đối soát", icon: ReceiptText, requireRole: "reconciliation" }, { id: "analytics", href: "/analytics", label: "Analytics", icon: BarChart2, requireRole: "admin" }, { id: "team", href: "/team-management", label: "Quản lý nhân sự", icon: Users }, { id: "claims", href: "/claims", label: "Khiếu nại", icon: FileWarning, dot: true }, diff --git a/backend/src/modules/logto-auth/index.ts b/backend/src/modules/logto-auth/index.ts index c0b28d23..bbaca088 100644 --- a/backend/src/modules/logto-auth/index.ts +++ b/backend/src/modules/logto-auth/index.ts @@ -7,6 +7,7 @@ export { LogtoMerchantFinanceGuard } from './logto-merchant-finance.guard'; // F-078 — Internal Finance role guards (BR-78-04 + BR-78-05) export { LogtoFinanceGuard } from './logto-finance.guard'; export { LogtoStaffOrFinanceGuard } from './logto-staff-or-finance.guard'; +export { LogtoReconciliationGuard } from './logto-reconciliation.guard'; export { OptionalLogtoAuthGuard } from './optional-logto-auth.guard'; export { LogtoAuthModule } from './logto-auth.module'; export { LogtoService, type LogtoUserInfo } from './logto.service'; diff --git a/backend/src/modules/logto-auth/logto-reconciliation.guard.spec.ts b/backend/src/modules/logto-auth/logto-reconciliation.guard.spec.ts new file mode 100644 index 00000000..fa20a1d6 --- /dev/null +++ b/backend/src/modules/logto-auth/logto-reconciliation.guard.spec.ts @@ -0,0 +1,98 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { LogtoReconciliationGuard } from './logto-reconciliation.guard'; + +/** + * Danny 2026-07-11 — Unit tests cho LogtoReconciliationGuard (module Đối soát). + * + * Union: reconciliation ∪ staff ∪ finance ∪ admin/super_admin/all. + * - Role MỚI `reconciliation`: nhân viên đối soát chỉ vào được module này + * (các controller khác dùng LogtoStaffGuard — KHÔNG nhận reconciliation). + * - `finance` (Tài chính kế toán) được THÊM quyền đối soát. + * - Staff hiện hữu không regress. + * + * super.canActivate() (JWT verify) mock = true — chỉ test scope/role gating. + */ +describe('LogtoReconciliationGuard (Danny 2026-07-11)', () => { + let guard: LogtoReconciliationGuard; + + beforeEach(() => { + guard = new LogtoReconciliationGuard(); + jest + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(guard)), + 'canActivate', + ) + .mockResolvedValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function makeCtx( + scopes: string[] = [], + roles: string[] = [], + ): ExecutionContext { + const req = { logto: { scopes, roles } }; + return { + switchToHttp: () => ({ getRequest: () => req }), + } as unknown as ExecutionContext; + } + + it('PASS role "reconciliation" (nhân viên đối soát — role mới)', async () => { + await expect( + guard.canActivate(makeCtx([], ['reconciliation'])), + ).resolves.toBe(true); + }); + + it('PASS scope "reconciliation"', async () => { + await expect( + guard.canActivate(makeCtx(['reconciliation'], [])), + ).resolves.toBe(true); + }); + + it('PASS role "finance" (Tài chính kế toán — Danny chốt thêm quyền)', async () => { + await expect(guard.canActivate(makeCtx([], ['finance']))).resolves.toBe( + true, + ); + }); + + it('PASS role "staff" (không regress staff hiện hữu)', async () => { + await expect(guard.canActivate(makeCtx([], ['staff']))).resolves.toBe( + true, + ); + }); + + it('PASS admin inheritance (admin / super_admin / all)', async () => { + await expect(guard.canActivate(makeCtx([], ['admin']))).resolves.toBe(true); + await expect( + guard.canActivate(makeCtx([], ['super_admin'])), + ).resolves.toBe(true); + await expect(guard.canActivate(makeCtx(['all'], []))).resolves.toBe(true); + }); + + it('FORBID token không có role/scope liên quan', async () => { + await expect( + guard.canActivate(makeCtx(['merchant:read'], ['merchant_viewer'])), + ).rejects.toThrow(ForbiddenException); + }); + + it('FORBID token rỗng', async () => { + await expect(guard.canActivate(makeCtx())).rejects.toThrow( + ForbiddenException, + ); + }); + + it('anonymous (JWT verify fail) → false', async () => { + jest.restoreAllMocks(); + jest + .spyOn( + Object.getPrototypeOf(Object.getPrototypeOf(guard)), + 'canActivate', + ) + .mockResolvedValue(false); + await expect(guard.canActivate(makeCtx([], ['staff']))).resolves.toBe( + false, + ); + }); +}); diff --git a/backend/src/modules/logto-auth/logto-reconciliation.guard.ts b/backend/src/modules/logto-auth/logto-reconciliation.guard.ts new file mode 100644 index 00000000..a0d370b1 --- /dev/null +++ b/backend/src/modules/logto-auth/logto-reconciliation.guard.ts @@ -0,0 +1,69 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { LogtoAuthGuard } from './logto-auth.guard'; + +/** + * Danny chốt 2026-07-11 — guard riêng cho module Đối soát: + * + * Chấp nhận union: reconciliation OR staff OR finance OR admin OR super_admin OR all. + * + * Lý do: + * - Role MỚI `reconciliation` ("Đối soát" trên Logto Dashboard): nhân viên + * đối soát CHỈ truy cập được module đối soát — các controller khác vẫn + * dùng LogtoStaffGuard (không nhận `reconciliation`) nên role này không + * với tới contracts/master-data/awards/… được. + * - Role `finance` (Tài chính kế toán — kế toán Hiền): được THÊM quyền + * module đối soát (theo dõi xuất hóa đơn sau khi gửi đối soát). + * - Staff hiện hữu KHÔNG mất quyền (union, không replace). + * + * Phân biệt: + * - `LogtoStaffGuard`: staff/admin — KHÔNG finance, KHÔNG reconciliation + * - `LogtoStaffOrFinanceGuard`: staff/finance/admin (module Hợp đồng) + * - `LogtoReconciliationGuard`: reconciliation/staff/finance/admin (module Đối soát) + * + * Pattern reuse: F-078 dual-check (roles claim ∪ scope claim). + */ +@Injectable() +export class LogtoReconciliationGuard + extends LogtoAuthGuard + implements CanActivate +{ + async canActivate(ctx: ExecutionContext): Promise { + // Step 1: parent guard verify JWT signature + audience + issuer. + const ok = await super.canActivate(ctx); + if (!ok) return false; + + // Step 2: union check — reconciliation ∪ staff ∪ finance ∪ admin. + const req = ctx.switchToHttp().getRequest(); + const roles: string[] = req.logto?.roles ?? []; + const scopes: string[] = req.logto?.scopes ?? []; + + const hasPermission = + // Recon-only tier (NEW — nhân viên đối soát) + roles.includes('reconciliation') || + scopes.includes('reconciliation') || + // Staff tier (existing — không regress) + roles.includes('staff') || + scopes.includes('staff') || + // Finance tier (Tài chính kế toán — Danny chốt thêm quyền đối soát) + roles.includes('finance') || + scopes.includes('finance') || + // Admin tier (inheritance) + roles.includes('admin') || + roles.includes('super_admin') || + scopes.includes('admin') || + scopes.includes('admin:all') || + scopes.includes('all'); + + if (!hasPermission) { + throw new ForbiddenException( + 'Module Đối soát cần quyền reconciliation, staff, finance, hoặc admin. Liên hệ Danny để được cấp role phù hợp trên Logto Dashboard, sau đó đăng xuất + đăng nhập lại.', + ); + } + return true; + } +} diff --git a/backend/src/modules/reconciliation/reconciliation.controller.ts b/backend/src/modules/reconciliation/reconciliation.controller.ts index aad86798..1b389d72 100644 --- a/backend/src/modules/reconciliation/reconciliation.controller.ts +++ b/backend/src/modules/reconciliation/reconciliation.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -15,7 +16,7 @@ import { } from '@nestjs/common'; import { Response } from 'express'; import { ApiOperation, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger'; -import { LogtoStaffGuard } from '../logto-auth'; +import { LogtoReconciliationGuard } from '../logto-auth'; import { ReconciliationService } from './reconciliation.service'; import { ReconciliationQueryService } from './services/reconciliation-query.service'; import { ReconciliationPreflightService } from './services/reconciliation-preflight.service'; @@ -57,7 +58,7 @@ function buildRecFilename(doc: any, ext: string): string { @ApiTags('reconciliations') @Controller('reconciliations') -@UseGuards(LogtoStaffGuard) +@UseGuards(LogtoReconciliationGuard) export class ReconciliationController { constructor( private readonly reconciliationService: ReconciliationService, @@ -195,6 +196,52 @@ export class ReconciliationController { return this.queryService.getRacesByTenant(Number(tenantId)); } + @Get('raw-data/download') + @ApiOperation({ + summary: + 'Tải raw data (25 cột, dump thô đơn COMPLETE trong kỳ) để cross-check — không cần tạo bản đối soát', + }) + @ApiResponse({ status: 200, description: 'XLSX raw data stream' }) + @ApiQuery({ name: 'mysql_race_id', required: true, type: Number }) + @ApiQuery({ name: 'period_start', required: true, type: String, description: 'YYYY-MM-DD' }) + @ApiQuery({ name: 'period_end', required: true, type: String, description: 'YYYY-MM-DD' }) + @ApiQuery({ name: 'race_title', required: false, type: String }) + async downloadRawData( + @Query('mysql_race_id') mysqlRaceId: string, + @Query('period_start') periodStart: string, + @Query('period_end') periodEnd: string, + @Res() res: Response, + @Query('race_title') raceTitle?: string, + ) { + const raceId = Number(mysqlRaceId); + const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + if ( + !Number.isInteger(raceId) || + raceId <= 0 || + !DATE_RE.test(periodStart || '') || + !DATE_RE.test(periodEnd || '') || + periodEnd < periodStart + ) { + throw new BadRequestException( + 'Tham số không hợp lệ: cần mysql_race_id + period_start/period_end (YYYY-MM-DD, Đến >= Từ).', + ); + } + const rows = await this.queryService.queryRawData( + raceId, + periodStart, + periodEnd, + ); + const buf = await this.xlsxService.generateRawData(rows); + const fd = (x: string) => x.split('-').reverse().join('-'); + const filename = `Raw data - ${raceTitle || `race ${raceId}`} - ${fd(periodStart)} đến ${fd(periodEnd)}.xlsx`; + res.set({ + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(filename)}"`, + }); + res.send(buf); + } + @Get(':id/download/xlsx') @ApiOperation({ summary: 'Download XLSX file for a reconciliation' }) @ApiResponse({ status: 200, description: 'XLSX file stream' }) diff --git a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts index 0f23e80f..3d54c19d 100644 --- a/backend/src/modules/reconciliation/services/reconciliation-query.service.ts +++ b/backend/src/modules/reconciliation/services/reconciliation-query.service.ts @@ -144,6 +144,60 @@ export class ReconciliationQueryService { return this.categorize(rows, mysql_race_id, period_start, period_end); } + /** + * Raw data cross-check (Danny 2026-07-11) — NGUYÊN VĂN câu query Danny vẫn + * chạy tay hằng tháng để đưa nhân viên đối soát. KHÁC queryOrders ở chỗ: + * KHÔNG filter financial_status, KHÔNG categorize/drop — dump thô toàn bộ + * đơn COMPLETE trong kỳ, ORDER BY oli.id ASC. Chỉ dùng bởi endpoint + * admin-guard tải file; KHÔNG log giá trị (có PII người mua). + */ + async queryRawData( + mysql_race_id: number, + period_start: string, + period_end: string, + ): Promise[]> { + const sql = ` + SELECT oli.id, + oli.modified_on, + oli.order_id AS order_id, + o.order_category, + oli.price AS line_price, + o.create_by, + o.name, + o.email, + o.last_name, + o.first_name, + o.phone_number, + o.internal_status, + o.modified_on AS order_modified_on, + o.payment_ref, + o.processed_on, + rc.name AS distance, + tt.type_name, + tt.price AS origin_price, + oli.quantity AS qty, + o.total_discounts, + o.total_add_on_price, + dc.code, + o.subtotal_price, + o.total_price, + o.vat_metadata + FROM order_line_item oli + LEFT JOIN order_metadata o ON oli.order_id = o.id + LEFT JOIN ticket_type tt ON oli.ticket_type_id = tt.id + LEFT JOIN race_course rc ON tt.race_course_id = rc.id + LEFT JOIN discount_code dc ON o.discound_code_id = dc.id + WHERE rc.race_id = ? + AND o.internal_status = 'COMPLETE' + AND o.processed_on >= ? + AND o.processed_on <= ? + AND o.deleted = 0 + ORDER BY oli.id ASC + `; + const { fromUtc, toUtc } = flexPeriodRangeUtc(period_start, period_end); + return this.tenantRepo.manager.query(sql, [mysql_race_id, fromUtc, toUtc]); + } + /** * Pha 2-A — Danh sách VĐV: mọi athlete có vé COMPLETE/paid trong kỳ của race. * Join qua `codes` (a.code_id → codes.order_id) để lấy order + ticket_type + diff --git a/backend/src/modules/reconciliation/services/xlsx.service.ts b/backend/src/modules/reconciliation/services/xlsx.service.ts index ec34364c..79fedac9 100644 --- a/backend/src/modules/reconciliation/services/xlsx.service.ts +++ b/backend/src/modules/reconciliation/services/xlsx.service.ts @@ -69,6 +69,21 @@ export class XlsxService { return Buffer.from(buffer); } + /** + * Raw data cross-check (Danny 2026-07-11) — workbook 1 sheet 'raw' dump + * nguyên kết quả câu query tay (25 cột chuẩn), KHÔNG cần tạo bản đối soát. + */ + async generateRawData( + orders: Array>, + ): Promise { + const workbook = new ExcelJS.Workbook(); + workbook.creator = '5BIB'; + workbook.created = new Date(); + this.buildRawOrderSheet(workbook, 'raw', orders); + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); + } + /* ================================================================== */ /* SHEET 1: Tổng quan — matches reference XLSX layout */ /* ================================================================== */