From 9137ea06714f99a08d528607770770a16b9ea695 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:00:28 +0700 Subject: [PATCH 01/24] =?UTF-8?q?fix(deal):=20=E0=B8=82=E0=B8=B1=E0=B9=89?= =?UTF-8?q?=E0=B8=99=2011/12=20deal=20page=20=E2=80=94=20one=20IR=20name,?= =?UTF-8?q?=20owner,=20demoted=20manual=20move,=20tidier=20=E0=B8=88?= =?UTF-8?q?=E0=B8=B1=E0=B8=94=E0=B8=8B=E0=B8=B7=E0=B9=89=E0=B8=AD-?= =?UTF-8?q?=E0=B8=AA=E0=B9=88=E0=B8=87=E0=B8=A1=E0=B8=AD=E0=B8=9A=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rulings 2026-10-29 after a ui-revamp audit and a before/after mock of the live page: - One name and one word: the ขั้น 11 main button reads ออกใบขอซื้อ (IR); the fulfilment labels, header chip and both TicketService messages say ใบขอซื้อ. - Pipeline owner: ขั้น 11 stays ฝ่ายบัญชี, ขั้น 12 shows ฝ่ายขาย (display only). - W3-09: at ขั้น 12 the manual move to 13 is a secondary "ข้ามไปขั้น 13 ด้วยตนเอง". - จัดซื้อ-ส่งมอบ tab: before the first IR the IR box comes first and the import-progress chips are hidden (stock reservation and commission weight stay reachable below it); the required-by field folds behind a toggle; when no factory is left (new read-only hasUncoveredFactories on the IR list) the create controls give way to "ทุกโรงงานออกใบขอซื้อแล้ว" (or "ไม่มีรายการที่ต้องออกใบขอซื้อ" with nothing to import). - The order-email button and line are gone from the sales rep's deal-tab cards (CEO and the import pages keep it); no ✉ emoji. Co-Authored-By: Claude Opus 5.5 --- .../ImportRequestController.java | 5 +- .../importrequest/ImportRequestService.java | 27 ++++ .../th/co/glr/hr/ticket/TicketService.java | 4 +- ...portRequestPaymentGateIntegrationTest.java | 3 +- .../StoredImportRequestIntegrationTest.java | 54 +++++++ frontend/src/api/mockApi.js | 17 ++- .../fulfilment/ImportFulfilmentPage.test.jsx | 6 + .../features/importDeal/ImportDealPage.jsx | 1 + .../importDeal/ImportDealPage.test.jsx | 23 ++- .../importProgress/FactoryProgressBar.jsx | 2 +- .../features/tickets/DealFulfilmentPanel.jsx | 66 +++++++-- .../tickets/DealFulfilmentPanel.test.jsx | 140 +++++++++++++----- .../src/features/tickets/DealStagePanel.jsx | 10 +- .../features/tickets/DealStagePanel.test.jsx | 30 +++- .../src/features/tickets/DealStateHeader.jsx | 2 +- .../features/tickets/DealStateHeader.test.jsx | 9 ++ .../tickets/ImportRequestFactoryCard.jsx | 9 +- .../src/features/tickets/TicketDetailPage.jsx | 5 +- .../tickets/TicketDetailPage.test.jsx | 14 +- frontend/src/features/tickets/salesActions.js | 2 +- .../src/features/tickets/salesActions.test.js | 6 +- frontend/src/features/tickets/stageMeta.js | 16 ++ .../src/features/tickets/workState.test.js | 4 +- frontend/src/utils/format.js | 2 +- frontend/src/utils/format.test.js | 7 + 25 files changed, 388 insertions(+), 76 deletions(-) diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java index a13ca69e3..b851f4aba 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java @@ -98,9 +98,10 @@ Map> createDrafts(@PathVariable long ticketId, } @GetMapping("/tickets/{ticketId}/import-requests") - Map> listForTicket(@PathVariable long ticketId, HttpSession session) { + Map listForTicket(@PathVariable long ticketId, HttpSession session) { UserPrincipal user = sessions.requireUser(session); - return Map.of("importRequests", service.list(ticketId, user)); + return Map.of("importRequests", service.list(ticketId, user), + "hasUncoveredFactories", service.hasUncoveredFactories(ticketId, user)); } @GetMapping("/import-requests/{id}") diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java index 43fb3d169..3455a2993 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java @@ -203,6 +203,33 @@ public List list(long ticketId, UserPrincipal actor) { return withPageCounts(stored.findByTicket(ticketId)); } + /** + * Read-only twin of {@link #createDrafts}'s coverage check, for the list response: is there a factory + * on this deal that still needs an IR? Uses the same {@link ImportRequestQueryRepository + * #factoryResolutionCandidates} cascade, but creates no factory. A typed name that matches no + * factory (or a line with no factory at all) counts as uncovered; only a LIVE (DRAFT / ISSUED) row + * covers a factory — a SUPERSEDED one does not. A deal with nothing left to order is not uncovered. + */ + public boolean hasUncoveredFactories(long ticketId, UserPrincipal actor) { + requireTicket(ticketId); + requireRead(ticketId, actor); + Set covered = stored.findByTicket(ticketId).stream() + .filter(r -> ImportRequestStatus.isLive(r.status())) + .map(ImportRequestDto::factoryId) + .collect(Collectors.toSet()); + Map idByNormalizedName = factories.findAll().stream() + .collect(Collectors.toMap(f -> normalizeFactoryName(f.factoryName()), FactoryConfigDto::id, (x, y) -> x)); + for (LineFactoryCandidate c : repository.factoryResolutionCandidates(ticketId)) { + Long factoryId = c.directFactoryId() != null ? c.directFactoryId() + : c.candidateFactoryName() != null ? idByNormalizedName.get(normalizeFactoryName(c.candidateFactoryName())) + : null; + if (factoryId == null || !covered.contains(factoryId)) { + return true; + } + } + return false; + } + public ImportRequestDto get(long id, UserPrincipal actor) { ImportRequestDto row = requireStored(id); requireRead(row.ticketId(), actor); diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index 85967b796..9aabae917 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -1096,7 +1096,7 @@ public void requireImportRequestIssuable(TicketSummaryDto s, boolean allowAdvanc // advance it, and a deal whose deposit is in hand must be able to import. if (!depositReady) { throw new ApiException(HttpStatus.CONFLICT, - "ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น"); + "ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น"); } } @@ -1227,7 +1227,7 @@ public TicketDto markIrSent(long ticketId, UserPrincipal actor) { "ดีลนี้ติดตามการนำเข้าด้วยใบขอซื้อรายโรงงาน — ต้องบันทึกความคืบหน้าที่ใบขอซื้อของแต่ละโรงงาน"); } if (!FulfilmentStatus.IR_ISSUED.equals(s.fulfillmentStatus())) { - throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบขอนำเข้า (IR) ก่อนจึงจะทำเครื่องหมายว่าส่ง IR แล้วได้"); + throw new ApiException(HttpStatus.CONFLICT, "ต้องออกใบขอซื้อ (IR) ก่อนจึงจะทำเครื่องหมายว่าส่ง IR แล้วได้"); } tickets.updateFulfillmentStatus(ticketId, FulfilmentStatus.IR_SENT); tickets.addEvent(ticketId, actor.id(), actor.name(), diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java index 8bd1497aa..729c90304 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestPaymentGateIntegrationTest.java @@ -156,7 +156,8 @@ void createDrafts_paymentGateRefusesBeforeTheCountryAndLeadTimePrompts() { assertThatThrownBy(() -> service.createDrafts(ticketId, null, owner)) .isInstanceOfSatisfying(ApiException.class, e -> { assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); - assertThat(e.getMessage()).contains("รับชำระมัดจำ").doesNotContain("ประเทศ"); + assertThat(e.getMessage()).contains("ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว") + .doesNotContain("ใบขอนำเข้า").doesNotContain("ประเทศ"); }); assertThat(service.list(ticketId, owner)).isEmpty(); diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java index ac1ed1399..06460493f 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java @@ -8,6 +8,10 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import org.springframework.mock.web.MockHttpSession; +import th.co.glr.hr.auth.SessionContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; @@ -178,6 +182,56 @@ void createDrafts_issuesEveryFactoryInOneStep_andMovesTheDealToProcurement() { assertThat(summary.salesStage()).isEqualTo(DealStage.PROCUREMENT); } + // ── owner ruling 2026-10-29 (5c): GET /tickets/{id}/import-requests also says whether a factory is uncovered ── + + /** The flag as the frontend reads it: a top-level key of the list response, read-only, no factory is created. */ + private Object hasUncoveredFactoriesOverHttpShape(long forTicket) { + SessionContext sessions = mock(SessionContext.class); + MockHttpSession session = new MockHttpSession(); + when(sessions.requireUser(session)).thenReturn(owner); + Map body = new ImportRequestController(service, sessions).listForTicket(forTicket, session); + assertThat(body).containsKey("importRequests"); + return body.get("hasUncoveredFactories"); + } + + @Test + void listResponse_hasUncoveredFactories_trueBeforeAnyIr_falseOnceCreateCoversEveryFactory_trueAgainForANewFactoryLine() { + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + + service.createDrafts(ticketId, null, owner); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.FALSE); + + insertFactory("Late Added Flag Factory"); + insertItem(ticketId, "Late Added Flag Factory", "Late line", "60x60 cm", "5", "pcs", 3); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + } + + @Test + void listResponse_hasUncoveredFactories_countsAnUnresolvedFactoryNameAsUncovered_andCreatesNoFactory() { + service.createDrafts(ticketId, null, owner); + insertItem(ticketId, "Never Seen Flag Factory Co", "Mystery line", "60x60 cm", "5", "pcs", 3); + Long factoriesBefore = jdbc.queryForObject("SELECT count(*) FROM price_catalog.factories", Map.of(), Long.class); + + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + + assertThat(jdbc.queryForObject("SELECT count(*) FROM price_catalog.factories", Map.of(), Long.class)) + .isEqualTo(factoriesBefore); + } + + @Test + void listResponse_hasUncoveredFactories_aFactoryCoveredOnlyBySupersededRowsIsUncovered_aLiveRowCovers() { + service.createDrafts(ticketId, null, owner); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.FALSE); + + jdbc.update("UPDATE sales.import_request SET status = 'SUPERSEDED' WHERE ticket_id = :t AND factory_id = :f", + Map.of("t", ticketId, "f", leaFactoryId)); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.TRUE); + + jdbc.update("UPDATE sales.import_request SET status = 'ISSUED' WHERE ticket_id = :t AND factory_id = :f", + Map.of("t", ticketId, "f", leaFactoryId)); + assertThat(hasUncoveredFactoriesOverHttpShape(ticketId)).isEqualTo(Boolean.FALSE); + } + @Test void createDrafts_isAllOrNothing_whenOneNewFactoryHasNoLeadTime() { // country PL has no LeadTimeDefaults entry -> null lead time -> cannot be issued. diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 9370030fc..b95b8373d 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -9080,7 +9080,7 @@ export const api = { if (mockHasLiveImportRequests(ticket.id)) { fail('ดีลนี้ติดตามการนำเข้าด้วยใบขอซื้อรายโรงงาน — ต้องบันทึกความคืบหน้าที่ใบขอซื้อของแต่ละโรงงาน', 409); } - if (ticket.fulfillmentStatus !== 'IR_ISSUED') fail('ต้องออกใบขอนำเข้า (IR) ก่อนจึงจะทำเครื่องหมายว่าส่งคำขอนำเข้าแล้วได้', 409); + if (ticket.fulfillmentStatus !== 'IR_ISSUED') fail('ต้องออกใบขอซื้อ (IR) ก่อนจึงจะทำเครื่องหมายว่าส่งคำขอนำเข้าแล้วได้', 409); ticket.fulfillmentStatus = 'IR_SENT'; ticket.updatedAt = new Date().toISOString().slice(0, 10); pushEvent(ticket, user, 'IR_SENT', ticket.status, ticket.status, null); @@ -12370,7 +12370,7 @@ export const api = { // Payment gate FIRST (same predicate as issue() below), before any prompt-producing check. if (!(['DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID'].includes(ticket.paymentStatus) || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'))) { - fail('ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + fail('ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); } // Supplied per-factory lead times (10-29): 400 on a bad value, before anything is written. const suppliedLeadTimes = new Map(); @@ -12435,7 +12435,16 @@ export const api = { async listForTicket(ticketId) { const ticket = findTicketRaw(Number(ticketId)); requireStoredIrRead(ticket, requireSession()); - return delay({ importRequests: mockImportRequestsForTicket(ticket.id) }); + // Mirrors ImportRequestService#hasUncoveredFactories: read-only; an unresolved/unknown factory + // (the resolver refuses without a country for a new name) counts as uncovered; only a live row covers. + let hasUncoveredFactories = true; + try { + const live = new Set(mockImportRequests + .filter((r) => r.ticketId === ticket.id && (r.status === 'DRAFT' || r.status === 'ISSUED')) + .map((r) => r.factoryId)); + hasUncoveredFactories = mockResolveFactoryGroups(ticket, null).some((g) => !live.has(g.factoryId)); + } catch { /* unresolved factory -> uncovered */ } + return delay({ importRequests: mockImportRequestsForTicket(ticket.id), hasUncoveredFactories }); }, async get(id) { const row = findStoredImportRequestRaw(id); @@ -12521,7 +12530,7 @@ export const api = { || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'); // Gated on the deposit ALONE (mirrors TicketService#requireImportRequestIssuable, 2026-10-01). if (!depositReadyOrLater) { - fail('ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + fail('ออกใบขอซื้อ (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); } const override = request?.docNumber?.trim() || null; if (override && mockImportRequests.some((r) => r.docNumber === override)) { diff --git a/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx b/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx index e405376b1..0261917bd 100644 --- a/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx +++ b/frontend/src/features/fulfilment/ImportFulfilmentPage.test.jsx @@ -245,6 +245,12 @@ describe('ImportFulfilmentPage (per-factory, stored aggregate)', () => { expect(screen.getByText('ส่งอีเมลแล้ว')).not.toBeNull(); }); + it('the งานนำเข้า order-email button stays, labelled without the ✉ emoji', async () => { + renderPage(); + const card = await screen.findByTestId('fulfilment-deal'); + expect(within(card).getByTestId('order-email-1').textContent).toBe('อีเมลสั่งซื้อ'); + }); + it('opens the order-email modal and marks it sent — import/CEO can send it', async () => { renderPage(); const card = await screen.findByTestId('fulfilment-deal'); diff --git a/frontend/src/features/importDeal/ImportDealPage.jsx b/frontend/src/features/importDeal/ImportDealPage.jsx index 476051ca2..20b50c130 100644 --- a/frontend/src/features/importDeal/ImportDealPage.jsx +++ b/frontend/src/features/importDeal/ImportDealPage.jsx @@ -276,6 +276,7 @@ export function ImportDealPage({ user, showToast }) { canEmailWrite={canEmailWrite} showToast={showToast} showInlineDownload + showOrderEmail /> ))} diff --git a/frontend/src/features/importDeal/ImportDealPage.test.jsx b/frontend/src/features/importDeal/ImportDealPage.test.jsx index 4a4a1810a..363d7f1ff 100644 --- a/frontend/src/features/importDeal/ImportDealPage.test.jsx +++ b/frontend/src/features/importDeal/ImportDealPage.test.jsx @@ -166,7 +166,7 @@ describe('ImportDealPage', () => { it('shows delivery status read-only: the fulfilment label and delivered/ordered per item, no write control', async () => { renderPage(); const panel = await screen.findByTestId('import-deal-delivery'); - expect(within(panel).getByText('ออกคำขอนำเข้าแล้ว')).not.toBeNull(); + expect(within(panel).getByText('ออกใบขอซื้อแล้ว')).not.toBeNull(); expect(within(panel).getByTestId('import-deal-delivery-item-201').textContent).toMatch(/40\s*\/\s*120/); expect(within(panel).getByTestId('import-deal-delivery-item-202').textContent).toMatch(/0\s*\/\s*50/); // Delivery is Sales's — the section carries no button or input at all. @@ -238,6 +238,25 @@ describe('ImportDealPage', () => { expect(screen.getByTestId('ir-email-open-11')).not.toBeNull(); }); + it('the import-side card keeps the order-email button, labelled without the ✉ emoji', async () => { + renderPage({ role: 'import' }); + expect((await screen.findByTestId('ir-email-open-11')).textContent).toBe('อีเมลสั่งซื้อ'); + }); + + // Moved here from the sales deal tab, which no longer shows the order email: the save payload must + // use Java's field names (UpdateEmailDraftRequest), not to/subject/body (Jackson silently drops those). + it('saving the order-email draft sends emailTo/emailSubject/emailBody', async () => { + api.storedImportRequests.updateEmailDraft.mockResolvedValue({ importRequest: irRow() }); + renderPage({ role: 'import' }); + fireEvent.click(await screen.findByTestId('ir-email-open-11')); + const subject = await screen.findByDisplayValue('Purchase order IR26001 - GL&R'); + fireEvent.change(subject, { target: { value: 'Revised' } }); + fireEvent.click(screen.getByTestId('ir-email-save-11')); + await waitFor(() => expect(api.storedImportRequests.updateEmailDraft).toHaveBeenCalledWith(11, expect.objectContaining({ + emailSubject: 'Revised', emailBody: 'Dear Cotto Industry team,...', + }))); + }); + it('surfaces a per-factory ใบขอซื้อ PDF download on each issued card, and it downloads', async () => { api.storedImportRequests.download.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); renderPage({ role: 'import' }); @@ -478,7 +497,7 @@ describe('ImportDealPage', () => { expect(within(strip).getByTestId('import-deal-ir-rollup-chip').textContent).toMatch(/ถึงไทย 1\/2 โรงงาน/); // Item bars are 33% (40/120) and 0% (0/50): the strip shows their mean, 17%. expect(within(strip).getByTestId('import-deal-delivery-chip').textContent).toMatch(/ส่งมอบ 17%/); - expect(within(strip).getByText('ออกคำขอนำเข้าแล้ว')).not.toBeNull(); + expect(within(strip).getByText('ออกใบขอซื้อแล้ว')).not.toBeNull(); }); it('omits the factory chip when nothing is issued, and the delivery chip when there are no items', async () => { diff --git a/frontend/src/features/importProgress/FactoryProgressBar.jsx b/frontend/src/features/importProgress/FactoryProgressBar.jsx index 42f3b9365..b387cef91 100644 --- a/frontend/src/features/importProgress/FactoryProgressBar.jsx +++ b/frontend/src/features/importProgress/FactoryProgressBar.jsx @@ -98,7 +98,7 @@ export function FactoryProgressBar({
{onOpenEmail ? ( ) : null} {editable && ahead.length > 0 ? ( diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.jsx index 95973cb62..331ea0f07 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.jsx @@ -303,6 +303,15 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), enabled: !!ticketId && canReadStoredIr, }); + // Read-only server flag on the same list endpoint (a separate key: the rows query above is shared + // with other screens as a bare array). Only an explicit `false` hides the create controls — an older + // backend that omits it (undefined) keeps them. + const coverageQuery = useQuery({ + queryKey: [...queryKeys.storedImportRequests(ticketId), 'coverage'], + queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.hasUncoveredFactories), + enabled: !!ticketId && canReadStoredIr, + }); + const allFactoriesCovered = coverageQuery.data === false; const storedIrRows = storedIrQuery.data ?? []; const liveStoredIrRows = storedIrRows .filter((r) => r.status !== 'SUPERSEDED') @@ -469,6 +478,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ // any ticket DTO the frontend reads), so this is an uncontrolled note a rep types once rather than // an editable display of the current value. Sales (owner) or CEO, from DealStage.ORDER_RECEIVED. const [dealRequiredByDraft, setDealRequiredByDraft] = useState(''); + const [requiredByOpen, setRequiredByOpen] = useState(false); // Nit: best-effort READ-BACK — there is still no GET for the deal-level value itself (see above), // but once any factory row exists its OWN requiredByNote was snapshotted FROM that deal-level // value (ImportRequestService#insertDraft), so it is the closest available proxy for "what is @@ -478,7 +488,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ useEffect(() => { if (requiredByNoteSeeded || liveStoredIrRows.length === 0) return; const note = liveStoredIrRows.find((r) => r.requiredByNote)?.requiredByNote; - if (note) setDealRequiredByDraft(note); + if (note) { setDealRequiredByDraft(note); setRequiredByOpen(true); } setRequiredByNoteSeeded(true); }, [requiredByNoteSeeded, liveStoredIrRows]); const setDealRequiredByMutation = useMutation({ @@ -717,7 +727,9 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ return (
- {/* Step 1: นำเข้าสินค้า (Import Request → รับสินค้า, or from-stock) */} + {/* Step 1: นำเข้าสินค้า (Import Request → รับสินค้า, or from-stock). Owner ruling 2026-10-29: + not rendered before the first IR (fulfillmentStatus null) — the stored-IR box below leads. */} + {fs ? (
@@ -768,7 +780,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ รับสินค้าแล้ว ) : fs == null ? ( -

ยังไม่ออกคำขอนำเข้า

+

ยังไม่ออกใบขอซื้อ

) : null}
@@ -788,6 +800,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ) : null}
+ ) : null} {/* ใบขอซื้อรายโรงงาน (V184, PR-B) — the STORED aggregate. Visible to CEO/import/ sales_manager unrestricted, and to sales only for a deal they own — mirrors @@ -800,7 +813,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ใบขอซื้อรายโรงงาน (F-SM-001)
- {canFullWriteStoredIr ? ( + {canFullWriteStoredIr && !allFactoriesCovered ? ( ) : null}
- {canFullWriteStoredIr && !depositReady ? ( + {canFullWriteStoredIr && !allFactoriesCovered && !depositReady ? (

{DEPOSIT_HINT}

) : null} -

- หนึ่งใบต่อหนึ่งโรงงาน — สร้างอัตโนมัติจากรายการสินค้าที่ต้องสั่งนำเข้า (ไม่รวมส่วนที่จองจากสต็อก) -

- {canFullWriteStoredIr && factorylessLines.length > 0 ? ( + {allFactoriesCovered ? ( +

+ {liveStoredIrRows.length > 0 ? 'ทุกโรงงานออกใบขอซื้อแล้ว' : 'ไม่มีรายการที่ต้องออกใบขอซื้อ'} +

+ ) : ( +

+ หนึ่งใบต่อหนึ่งโรงงาน — สร้างอัตโนมัติจากรายการสินค้าที่ต้องสั่งนำเข้า (ไม่รวมส่วนที่จองจากสต็อก) +

+ )} + {canFullWriteStoredIr && !allFactoriesCovered && factorylessLines.length > 0 ? (

รายการต่อไปนี้ยังไม่มีโรงงาน — เลือกโรงงานก่อนออกใบขอซื้อ (IR) @@ -845,7 +864,8 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ))}

) : null} - {canFullWriteStoredIr ? ( + {canFullWriteStoredIr && !allFactoriesCovered ? ( + requiredByOpen || dealRequiredByDraft ? ( + ) : ( + + ) ) : null} {storedIrQuery.isLoading ? (

กำลังโหลดใบขอซื้อ…

@@ -882,6 +908,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ canFooterWrite={canFooterWriteStoredIr} canAdvance={canAdvanceStoredIr} canEmailWrite={canEmailWriteStoredIr} + showOrderEmail={role !== 'sales'} showToast={showToast} /> ))} @@ -890,6 +917,25 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ) : null} + {/* Before the first IR the นำเข้าสินค้า card above is not rendered, but the commission-weight + setter must stay reachable, so it sits just below the stored-IR box. */} + {!fs && (can.reserveStock || can.setItemWeight) ? ( +
+ {can.reserveStock ? ( + + ) : null} + {can.setItemWeight ? ( + + ) : null} +
+ ) : null} + {/* ใบขอซื้อ (F-SM-001) — legacy PREVIEW, one form per BRAND, not a step of its own, so no StepNumber. Import/CEO only: ImportRequestService.IR_ROLES is {import, ceo}, so rendering this for sales would offer a control that 403s. Hidden once the deal has any diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx index c6f74fa93..2db7648c9 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx @@ -81,9 +81,14 @@ const summary = { createdById: 42, status: 'quotation_issued', fulfillmentStatus // `rows` may be an Error, to render the panel with a failed ใบขอซื้อ fetch. function renderPanel(user, rows = [draftRow()], { summary: dealSummary = summary, availableActions = [], showToast = vi.fn(), items = [], ref = undefined, + hasUncoveredFactories = undefined, // undefined = an older backend that does not send the flag } = {}) { if (rows instanceof Error) api.storedImportRequests.listForTicket.mockRejectedValue(rows); - else api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: rows }); + else { + api.storedImportRequests.listForTicket.mockResolvedValue( + hasUncoveredFactories === undefined ? { importRequests: rows } : { importRequests: rows, hasUncoveredFactories }, + ); + } const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); return render( @@ -577,13 +582,17 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง }); describe('mark-sent', () => { - it('the owning rep can open the email modal and mark it sent', async () => { - api.storedImportRequests.markEmailSent.mockResolvedValue({ importRequest: issuedRow({ emailSentAt: '2026-09-19T00:00:00Z' }) }); + // Owner ruling 2026-10-29: the order email is import's job (งานนำเข้า / import deal page); the sales deal tab shows none of it. + it('the owning sales rep: the factory card has no order-email button and no "อีเมลสั่งซื้อ:" line', async () => { renderPanel(OWNER, [issuedRow()]); - fireEvent.click(await screen.findByTestId('ir-email-open-11')); - const sendButton = await screen.findByTestId('ir-email-mark-sent-11'); - fireEvent.click(sendButton); - await waitFor(() => expect(api.storedImportRequests.markEmailSent).toHaveBeenCalledWith(11)); + await screen.findByTestId('ir-factory-card-11'); + expect(screen.queryByTestId('ir-email-open-11')).toBeNull(); + expect(screen.queryByText(/อีเมลสั่งซื้อ/)).toBeNull(); + }); + + it('CEO keeps the order email on the deal tab (the ruling is for the sales rep only)', async () => { + renderPanel(CEO, [issuedRow()]); + expect((await screen.findByTestId('ir-email-open-11')).textContent).toBe('อีเมลสั่งซื้อ'); }); it('sales_manager (read-only) sees no email-edit control', async () => { @@ -658,33 +667,6 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง }); }); - // PR-B REVIEW ROUND 1, B1: the email-draft save must send Java's own field names - // (emailTo/emailSubject/emailBody — UpdateEmailDraftRequest), not to/subject/body, which Jackson - // silently drops (unknown JSON properties are ignored) and the update's own COALESCE keeps the - // OLD value with a 200 OK — a save that looked like it worked but never touched the row. - describe('order-email save payload (B1)', () => { - it('CLICKING save sends emailTo/emailSubject/emailBody, not to/subject/body', async () => { - api.storedImportRequests.updateEmailDraft.mockResolvedValue({ importRequest: issuedRow() }); - renderPanel(OWNER, [issuedRow()]); - fireEvent.click(await screen.findByTestId('ir-email-open-11')); - const subjectInput = await screen.findByDisplayValue('Purchase order IR26001 - GL&R'); - fireEvent.change(subjectInput, { target: { value: 'Purchase order IR26001 - GL&R (revised)' } }); - fireEvent.click(screen.getByTestId('ir-email-save-11')); - await waitFor(() => expect(api.storedImportRequests.updateEmailDraft).toHaveBeenCalledWith(11, { - emailTo: 'factory@example.com', - emailSubject: 'Purchase order IR26001 - GL&R (revised)', - emailBody: 'Dear...', - })); - }); - - it('hides "บันทึก" once the email has already been sent', async () => { - renderPanel(OWNER, [issuedRow({ emailSentAt: '2026-09-18T00:00:00Z', emailSentByName: 'สมหญิง' })]); - fireEvent.click(await screen.findByTestId('ir-email-open-11')); - await screen.findByText(/ส่งแล้ว/); - expect(screen.queryByTestId('ir-email-save-11')).toBeNull(); - }); - }); - // PR-B REVIEW ROUND 1, S7: the missing-country picker parses factory names out of a 409 // message string (there is no structured field). A retry that comes back with the SAME missing // set must not reopen the modal forever. @@ -786,6 +768,96 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง // PR-B REVIEW ROUND 1, S5: a failed storedIrQuery fetch must not read as "no IR yet" nor bring // back the legacy per-brand buttons. + // Owner rulings 2026-10-29 (deal page, ขั้น 12 tab). + describe('tab layout before the first IR (5a)', () => { + const precedes = (a, b) => Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); + + it('no issued IR yet: the stored-IR box is shown and the นำเข้าสินค้า progress card is not rendered', async () => { + renderPanel(OWNER, [], { summary: { ...summary, fulfillmentStatus: null } }); + await screen.findByTestId('deal-fulfilment-stored-ir'); + expect(screen.queryByText('นำเข้าสินค้า')).toBeNull(); + expect(screen.queryByText(/ยังไม่ออกคำขอนำเข้า/)).toBeNull(); + }); + + it('once fulfillmentStatus is set the progress card is back, ahead of the stored-IR box (as today)', async () => { + renderPanel(OWNER, [issuedRow()], { summary: { ...summary, fulfillmentStatus: 'IR_ISSUED' } }); + const stored = await screen.findByTestId('deal-fulfilment-stored-ir'); + const card = screen.getByText('นำเข้าสินค้า'); + expect(precedes(card, stored)).toBe(true); + }); + }); + + it('before the first IR the commission-weight button stays reachable, below the stored-IR box, and opens its modal; จองสินค้า stays beside it', async () => { + renderPanel(CEO, [], { + summary: { ...summary, fulfillmentStatus: null }, + availableActions: [{ action: 'SET_ITEM_WEIGHT_MULTIPLIER' }, { action: 'RESERVE_STOCK' }], + }); + const stored = await screen.findByTestId('deal-fulfilment-stored-ir'); + const weight = await screen.findByTestId('deal-fulfilment-set-item-weight'); + expect(Boolean(stored.compareDocumentPosition(weight) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + // จองสินค้า is still the only path to FROM_STOCK, so it stays reachable beside it. + expect(screen.getByTestId('deal-fulfilment-reserve-stock')).not.toBeNull(); + fireEvent.click(weight); + expect(await screen.findByTestId('deal-fulfilment-set-item-weight-submit')).not.toBeNull(); + }); + + describe('required-by field is folded behind a toggle (5b)', () => { + const TOGGLE = '+ กำหนดวันที่ต้องการของ (ไม่บังคับ)'; + + it('hidden until the toggle is clicked', async () => { + renderPanel(OWNER, []); + await screen.findByTestId('deal-fulfilment-create-ir-drafts'); + expect(screen.queryByTestId('deal-required-by-note')).toBeNull(); + fireEvent.click(screen.getByText(TOGGLE)); + expect(screen.getByTestId('deal-required-by-note')).not.toBeNull(); + expect(screen.getByTestId('deal-required-by-note-save')).not.toBeNull(); + }); + + it('once open, clearing all the text keeps the field open so the empty value can be saved', async () => { + renderPanel(OWNER, [draftRow({ requiredByNote: 'Within 21/5/26' })]); + const input = await screen.findByTestId('deal-required-by-note'); + await waitFor(() => expect(input.value).toBe('Within 21/5/26')); + fireEvent.change(input, { target: { value: '' } }); + expect(screen.getByTestId('deal-required-by-note').value).toBe(''); + expect(screen.getByTestId('deal-required-by-note-save')).not.toBeNull(); + }); + + it('a saved non-empty value shows it expanded, with no toggle needed', async () => { + renderPanel(OWNER, [draftRow({ requiredByNote: 'Within 21/5/26' })]); + const input = await screen.findByTestId('deal-required-by-note'); + await waitFor(() => expect(input.value).toBe('Within 21/5/26')); + }); + }); + + describe('every factory already covered (5c)', () => { + const noCreateControls = () => { + expect(screen.queryByTestId('deal-fulfilment-create-ir-drafts')).toBeNull(); + expect(screen.queryByTestId('deal-required-by-note')).toBeNull(); + expect(screen.queryByText(/กำหนดวันที่ต้องการของ/)).toBeNull(); + expect(screen.queryByText(/หนึ่งใบต่อหนึ่งโรงงาน/)).toBeNull(); + }; + + it('hasUncoveredFactories=false: create button, required-by and helper line are gone; ทุกโรงงานออกใบขอซื้อแล้ว shows', async () => { + renderPanel(OWNER, [issuedRow()], { hasUncoveredFactories: false }); + await screen.findByText('ทุกโรงงานออกใบขอซื้อแล้ว'); + noCreateControls(); + }); + + it('false with no live IR row at all (nothing to import) says ไม่มีรายการที่ต้องออกใบขอซื้อ, not that every factory has one', async () => { + renderPanel(OWNER, [], { hasUncoveredFactories: false }); + await screen.findByText('ไม่มีรายการที่ต้องออกใบขอซื้อ'); + expect(screen.queryByText('ทุกโรงงานออกใบขอซื้อแล้ว')).toBeNull(); + }); + + it.each([['true', true], ['absent (older backend)', undefined]])('hasUncoveredFactories %s: create controls stay, no all-covered text', async (_n, flag) => { + renderPanel(OWNER, [issuedRow()], { hasUncoveredFactories: flag }); + await screen.findByTestId('deal-fulfilment-create-ir-drafts'); + expect(screen.queryByText('ทุกโรงงานออกใบขอซื้อแล้ว')).toBeNull(); + expect(screen.getByText(/หนึ่งใบต่อหนึ่งโรงงาน/)).not.toBeNull(); + expect(screen.getByText('+ กำหนดวันที่ต้องการของ (ไม่บังคับ)')).not.toBeNull(); + }); + }); + describe('storedIrQuery failure (S5)', () => { it('shows an error, not "ยังไม่มีใบขอซื้อสำหรับดีลนี้", and does not resurrect the legacy IR block', async () => { // IMPORT (isFulfilment): the role the legacy per-brand block is gated on diff --git a/frontend/src/features/tickets/DealStagePanel.jsx b/frontend/src/features/tickets/DealStagePanel.jsx index 85375c46c..a9c3d1089 100644 --- a/frontend/src/features/tickets/DealStagePanel.jsx +++ b/frontend/src/features/tickets/DealStagePanel.jsx @@ -13,7 +13,7 @@ import { EMPTY_STAGE_CATALOG, findStage, nextOnRoute, routePath, routePosition, } from './stageCatalog.js'; import { - AUTO_STAGE_HINT, GATE_LABEL, routeName, STAGE_HEADLINE, + AUTO_STAGE_HINT, GATE_LABEL, manualMoveLabel, routeName, STAGE_HEADLINE, STAGE_OWNER_DISPLAY, } from './stageMeta.js'; import { UpdateStageModal } from './UpdateStageModal.jsx'; import { stageForQuotationRecipient } from '../quotations/quotationMeta.js'; @@ -318,9 +318,9 @@ export const DealStagePanel = forwardRef(function DealStagePanel({
{STAGE_HEADLINE[summary.salesStage] ?? label.label}
- {meta && GATE_LABEL[meta.gate] ? ( + {meta && GATE_LABEL[STAGE_OWNER_DISPLAY[meta.code] ?? meta.gate] ? (
- {`ผู้รับผิดชอบ: ${GATE_LABEL[meta.gate]}`} + {`ผู้รับผิดชอบ: ${GATE_LABEL[STAGE_OWNER_DISPLAY[meta.code] ?? meta.gate]}`}
) : null} {/* One text line naming the route and the honest position on it. Nothing at all when @@ -383,12 +383,12 @@ export const DealStagePanel = forwardRef(function DealStagePanel({ {showGuidedOpener ? ( ) : null}
diff --git a/frontend/src/features/tickets/DealStagePanel.test.jsx b/frontend/src/features/tickets/DealStagePanel.test.jsx index 50a21d0d0..23c056d96 100644 --- a/frontend/src/features/tickets/DealStagePanel.test.jsx +++ b/frontend/src/features/tickets/DealStagePanel.test.jsx @@ -349,7 +349,7 @@ describe('DealStagePanel headline names the counterparty', () => { ); // One concise line under the title: the department that owns the stage, read off the catalog gate. - it.each(['PRESENTATION', 'ORDER_RECEIVED', 'DEPOSIT_RECEIVED', 'PROCUREMENT', 'CLOSED_PAID'])( + it.each(['PRESENTATION', 'ORDER_RECEIVED', 'DEPOSIT_RECEIVED', 'CLOSED_PAID'])( 'shows "ผู้รับผิดชอบ: " under the title at %s', (salesStage) => { renderPanel({ summary: baseSummary({ salesStage }) }); @@ -358,6 +358,15 @@ describe('DealStagePanel headline names the counterparty', () => { }, ); + // Owner ruling 2026-10-29: the sales rep owns ขั้น 12 (they issue the IR); ขั้น 11 stays with accounting. + it.each([['DEPOSIT_RECEIVED', 'ฝ่ายบัญชี'], ['PROCUREMENT', 'ฝ่ายขาย']])( + 'the owner line at %s follows the 2026-10-29 ruling', + (salesStage, department) => { + renderPanel({ summary: baseSummary({ salesStage }) }); + expect(screen.getByTestId('deal-stage-owner').textContent).toBe(`ผู้รับผิดชอบ: ${department}`); + }, + ); + it('shows no owner line while the stage catalog has not loaded (nothing to read the gate from)', () => { renderPanel({ catalog: { stages: [], phases: [] } }); expect(screen.queryByTestId('deal-stage-owner')).toBeNull(); @@ -447,6 +456,25 @@ describe('DealStagePanel guided advance (readiness gate)', () => { : decision )); + // W3-09 (owner ruling 2026-10-29): at ขั้น 12 the deal advances by itself, so the manual move is a + // deliberate SECONDARY skip, not the filled primary เลื่อนไป. Every other stage keeps เลื่อนไป. + it('at PROCUREMENT the visible manual move is a secondary "ข้ามไปขั้น 13 ด้วยตนเอง"; at ขั้น 2 it stays a primary เลื่อนไป', () => { + const blocked = (stage) => allAllowed.map((d) => ( + d.stage === stage ? { ...d, allowed: false, blockedReason: STAGE_ADVANCE_GATE_MESSAGE } : d + )); + const { unmount } = renderPanel({ + summary: baseSummary({ id: 502, salesStage: 'PROCUREMENT' }), availableActions: [], stageDecisions: blocked('DELIVERY_SCHEDULING'), + }); + const skip = screen.getByTestId('guided-advance-open'); + expect(skip.textContent).toBe('ข้ามไปขั้น 13 ด้วยตนเอง'); + expect(skip.className).toContain('bg-surface'); + unmount(); + renderPanel({ summary: baseSummary({ id: 503 }), availableActions: [], stageDecisions: blocked('SPEC_APPROVED') }); + const normal = screen.getByTestId('guided-advance-open'); + expect(normal.textContent).toContain('เลื่อนไป: 3.'); + expect(normal.className).not.toContain('bg-surface'); + }); + it('replaces the dead hint with a guided button that fills the gate then advances', async () => { api.tickets.updateTracking.mockClear(); api.tickets.addActivity.mockClear(); diff --git a/frontend/src/features/tickets/DealStateHeader.jsx b/frontend/src/features/tickets/DealStateHeader.jsx index ffdb8441b..b35fe2eae 100644 --- a/frontend/src/features/tickets/DealStateHeader.jsx +++ b/frontend/src/features/tickets/DealStateHeader.jsx @@ -159,7 +159,7 @@ export function DealStateHeader({ { key: 'fulfilment', label: 'การนำเข้า', - value: fulfilment ? fulfilment.label : 'ยังไม่เริ่ม', + value: fulfilment ? fulfilment.label : 'ยังไม่ออกใบขอซื้อ', tone: fulfilment ? fulfilment.tone : 'neutral', }, sections.payment && { diff --git a/frontend/src/features/tickets/DealStateHeader.test.jsx b/frontend/src/features/tickets/DealStateHeader.test.jsx index d2ce3484c..b77c40f62 100644 --- a/frontend/src/features/tickets/DealStateHeader.test.jsx +++ b/frontend/src/features/tickets/DealStateHeader.test.jsx @@ -110,3 +110,12 @@ const stages = DEAL_STAGE_CATALOG.stages.map((s) => s.code).filter((c) => !CHANN } }); }); + +describe('DealStateHeader — การนำเข้า chip wording (owner ruling 2026-10-29)', () => { + const chipValue = () => screen.getByText('การนำเข้า').nextElementSibling.textContent; + + it('no fulfilment yet reads ยังไม่ออกใบขอซื้อ, not ยังไม่เริ่ม', () => { + renderHeader({ fulfillmentStatus: null }); + expect(chipValue()).toBe('ยังไม่ออกใบขอซื้อ'); + }); +}); diff --git a/frontend/src/features/tickets/ImportRequestFactoryCard.jsx b/frontend/src/features/tickets/ImportRequestFactoryCard.jsx index 0b2d0824a..9616fc894 100644 --- a/frontend/src/features/tickets/ImportRequestFactoryCard.jsx +++ b/frontend/src/features/tickets/ImportRequestFactoryCard.jsx @@ -45,12 +45,13 @@ function downloadBlob(blob, filename) { * - `canFooterWrite` (CEO only): Checked/Approved By+date, vessel ETA. * - `canAdvance` (import/CEO): advance the per-factory step, set lead time AFTER issue. * - `canEmailWrite` (owning rep, CEO, or import): edit/mark-sent the order-email draft. + * `showOrderEmail` (import's surfaces only): the order-email row; the sales deal tab omits it (owner ruling 2026-10-29). * Everyone who can reach this card at all may read it and download the PDF — the buttons above * are an affordance; the server is the authority, so a 403/409 surfaces its own Thai message. */ export function ImportRequestFactoryCard({ row, ticketId, canFullWrite, canFooterWrite, canAdvance, canEmailWrite, showToast, - showInlineDownload = false, + showInlineDownload = false, showOrderEmail = false, }) { const queryClient = useQueryClient(); const [leadTimeDraft, setLeadTimeDraft] = useState({ min: row.leadTimeMinDays ?? '', max: row.leadTimeMaxDays ?? '' }); @@ -343,7 +344,7 @@ export function ImportRequestFactoryCard({ with its → next-step advance, plus the order-email action on its own row right below it. Every document-heavy control (PDF downloads, ลบร่าง/ออกฉบับแก้ไข, the lead-time editor, the CEO footer) lives in the collapsed-by-default details block further down. */} - {isIssued ? ( + {isIssued && showOrderEmail ? (
อีเมลสั่งซื้อ: {row.emailSentAt ? ( @@ -355,7 +356,7 @@ export function ImportRequestFactoryCard({ ) : null}
@@ -401,7 +402,7 @@ export function ImportRequestFactoryCard({ {documentDetails} - {emailOpen ? ( + {emailOpen && showOrderEmail ? ( { irStageDeal(); renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); await waitFor(() => expect(primaryAction()).toBe('create_import_request')); - expect(primary().textContent).toContain('สร้างใบขอซื้อ (IR)'); + expect(primary().textContent).toContain('ออกใบขอซื้อ (IR)'); + expect(primary().textContent).not.toContain('สร้างใบขอซื้อ'); await waitFor(() => expect(primary().disabled).toBe(false)); fireEvent.click(primary()); @@ -4987,6 +4988,17 @@ describe('TicketDetailPage', () => { expect(item.textContent).toContain('เลื่อนไป: เจ้าของตกลงตามสเปคแล้ว'); expect(item.textContent).not.toContain('ผู้ออกแบบอนุมัติสเปค'); }); + + // W3-09 (owner ruling 2026-10-29): ขั้น 12 advances by itself when the IR is issued; the manual + // move is a deliberate skip, so it must not read like the normal next step. Other stages keep เลื่อนไป. + it('at PROCUREMENT the manual move reads ข้ามไปขั้น 13 ด้วยตนเอง and still sends DELIVERY_SCHEDULING', async () => { + const item = await openMenuItem({ channel: 'DESIGNER_LED', salesStage: 'PROCUREMENT' }); + expect(item.textContent).toContain('ข้ามไปขั้น 13 ด้วยตนเอง'); + expect(item.textContent).not.toContain('เลื่อนไป'); + + fireEvent.click(item); + await waitFor(() => expect(api.tickets.updateStage).toHaveBeenCalledWith(701, { stage: 'DELIVERY_SCHEDULING' })); + }); }); // The gate refuses an off-route stage with "… — แก้ช่องทางดีลก่อน", and the control that answered it diff --git a/frontend/src/features/tickets/salesActions.js b/frontend/src/features/tickets/salesActions.js index 483ea8b12..f16d57465 100644 --- a/frontend/src/features/tickets/salesActions.js +++ b/frontend/src/features/tickets/salesActions.js @@ -94,7 +94,7 @@ const ACTION_LABEL = { [SALES_ACTION.CONFIRM_ORDER]: 'ยืนยันคำสั่งซื้อ', [SALES_ACTION.CREATE_DEPOSIT_NOTICE]: 'สร้างใบแจ้งยอดเงินรับมัดจำ', [SALES_ACTION.CREATE_REMAINING_INVOICE]: 'สร้างใบแจ้งหนี้', - [SALES_ACTION.CREATE_IMPORT_REQUEST]: 'สร้างใบขอซื้อ (IR)', + [SALES_ACTION.CREATE_IMPORT_REQUEST]: 'ออกใบขอซื้อ (IR)', // "Record the quotation outcome" — deliberately not "ติดตามผล"/"ติดตามลูกค้า" (that's // FOLLOW_UP's own label, a nudge to go chase the customer): by the time this bucket fires, the // quotation is already out and canRecordCustomerQuotationOutcome's own gate is what the button diff --git a/frontend/src/features/tickets/salesActions.test.js b/frontend/src/features/tickets/salesActions.test.js index 51eb64021..d6b0ab131 100644 --- a/frontend/src/features/tickets/salesActions.test.js +++ b/frontend/src/features/tickets/salesActions.test.js @@ -732,15 +732,15 @@ describe('nextSalesAction — สร้างใบแจ้งยอดเง }); // QA-09: ขั้น 11 (DEPOSIT_RECEIVED) — the owning rep's one main button creates the import request. -describe('nextSalesAction — สร้างใบขอซื้อ (IR) at ขั้น 11 (owner rulings QA-09)', () => { +describe('nextSalesAction — ออกใบขอซื้อ (IR) at ขั้น 11 (owner rulings QA-09)', () => { const paidDeal = (overrides = {}) => baseDeal({ status: 'quotation_issued', paymentStatus: 'DEPOSIT_PAID', fulfillmentStatus: null, ...overrides }); - const CREATE_IR = { key: 'create_import_request', label: 'สร้างใบขอซื้อ (IR)' }; + const CREATE_IR = { key: 'create_import_request', label: 'ออกใบขอซื้อ (IR)' }; it('exposes the action key', () => { expect(SALES_ACTION.CREATE_IMPORT_REQUEST).toBe('create_import_request'); }); - it('DEPOSIT_PAID + no fulfilment yet -> สร้างใบขอซื้อ (IR); importRequests unknown (undefined) or empty -> same', () => { + it('DEPOSIT_PAID + no fulfilment yet -> ออกใบขอซื้อ (IR); importRequests unknown (undefined) or empty -> same', () => { expect(nextSalesAction(paidDeal(), [orderConfirmedPr()])).toEqual(CREATE_IR); expect(nextSalesAction(paidDeal(), [orderConfirmedPr()], { importRequests: [] })).toEqual(CREATE_IR); }); diff --git a/frontend/src/features/tickets/stageMeta.js b/frontend/src/features/tickets/stageMeta.js index e9bd01363..9391ec360 100644 --- a/frontend/src/features/tickets/stageMeta.js +++ b/frontend/src/features/tickets/stageMeta.js @@ -27,6 +27,22 @@ export const GATE_LABEL = { account: 'ฝ่ายบัญชี', }; +/** + * Display-only owner override (owner ruling 2026-10-29): the sales rep issues the IR, so the pipeline + * card names ฝ่ายขาย at ขั้น 12. The catalog's write gate (DealStage.gateOf) is a permission and is NOT changed. + */ +export const STAGE_OWNER_DISPLAY = { PROCUREMENT: 'sales' }; + +/** + * Label of the manual move to the next stage. ขั้น 12 advances by itself when the IR is issued, so moving + * on by hand there is a deliberate skip (secondary button), not the normal เลื่อนไป (W3-09). + */ +export function manualMoveLabel(salesStage, next, stageLabel) { + return salesStage === 'PROCUREMENT' + ? `ข้ามไปขั้น ${next.no} ด้วยตนเอง` + : `เลื่อนไป: ${next.no}. ${stageLabel}`; +} + /** * Thai wording for the auto-advanced stages — "where does this stage come from, if I cannot just * click into it?". Keyed by code; the *set* of auto stages is the catalog's `auto` flag, served by diff --git a/frontend/src/features/tickets/workState.test.js b/frontend/src/features/tickets/workState.test.js index 8f94e6714..74369f6c3 100644 --- a/frontend/src/features/tickets/workState.test.js +++ b/frontend/src/features/tickets/workState.test.js @@ -517,9 +517,9 @@ describe('resolveWorkState — forwards importRequests to nextSalesAction (QA-09 const deal = baseDeal({ status: 'quotation_issued', paymentStatus: 'DEPOSIT_PAID', fulfillmentStatus: null }); const prs = [{ id: 1, ticketId: 1, status: 'QUOTATION_ACCEPTED', orderConfirmedAt: '2026-10-01T00:00:00Z' }]; - it('sales: a DRAFT importRequests row reaches nextSalesAction (ออกเลขใบขอซื้อ (IR)); without it, สร้างใบขอซื้อ (IR)', () => { + it('sales: a DRAFT importRequests row reaches nextSalesAction (ออกเลขใบขอซื้อ (IR)); without it, ออกใบขอซื้อ (IR)', () => { expect(resolveWorkState({ role: 'sales' }, deal, prs).action) - .toMatchObject({ key: 'create_import_request', label: 'สร้างใบขอซื้อ (IR)' }); + .toMatchObject({ key: 'create_import_request', label: 'ออกใบขอซื้อ (IR)' }); expect(resolveWorkState({ role: 'sales' }, deal, prs, { importRequests: [{ id: 1, status: 'DRAFT' }] }).action) .toMatchObject({ key: 'create_import_request', label: 'ออกเลขใบขอซื้อ (IR)' }); }); diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 5e865fc02..0de55a49b 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -658,7 +658,7 @@ export function overdueBadgeLabel(overdue) { */ export function fulfilmentStatusLabel(value) { const map = { - IR_ISSUED: { label: 'ออกคำขอนำเข้าแล้ว', tone: 'info' }, + IR_ISSUED: { label: 'ออกใบขอซื้อแล้ว', tone: 'info' }, IR_SENT: { label: 'ส่งคำขอนำเข้าแล้ว', tone: 'info' }, SHIPPING: { label: 'สินค้าเดินทาง', tone: 'info' }, GOODS_RECEIVED: { label: 'สินค้าถึงโกดังแล้ว', tone: 'success' }, diff --git a/frontend/src/utils/format.test.js b/frontend/src/utils/format.test.js index 20effbc00..42dacb886 100644 --- a/frontend/src/utils/format.test.js +++ b/frontend/src/utils/format.test.js @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; import { + fulfilmentStatusLabel, addDaysIso, attendanceSourceLabel, bangkokMonthStartIso, @@ -505,3 +506,9 @@ describe('formatFileSize', () => { expect(formatFileSize(undefined)).toBe('-'); }); }); + +describe('fulfilmentStatusLabel — one word for the document: ใบขอซื้อ (owner ruling 2026-10-29)', () => { + it('IR_ISSUED reads ออกใบขอซื้อแล้ว, not ออกคำขอนำเข้าแล้ว', () => { + expect(fulfilmentStatusLabel('IR_ISSUED').label).toBe('ออกใบขอซื้อแล้ว'); + }); +}); From e134a0df327a82660488fe1cadb04733206bc726 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:12:57 +0700 Subject: [PATCH 02/24] fix(deals): status chips and labels follow the deal's real state (P2 batch 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rulings 2026-10-29 for UI-walk findings QA-16/19/22/25/26/27: - QA-25: new deal codes are DL-YYYY-NNNN (was PR-, clashing with PCR-). Existing codes are untouched. - QA-16: the payment chip reads ยังไม่ถึงขั้นชำระ before the order is confirmed, ไม่เก็บมัดจำ for a bypass/0% deposit deal before goods arrive, and รอชำระส่วนที่เหลือ once the track reaches AWAITING_FINAL_PAYMENT. Legacy deals past ORDER_RECEIVED keep the old derivation. - QA-22: the การนำเข้า chip follows the per-factory IR steps (slowest ISSUED factory, plus "· ถึงโกดัง X/Y" when there are several). Stored IRs load for every role ImportRequestService allows to read them. - QA-19: account only gets forward stage moves: no backward ADVANCE_STAGE, the update-stage form lists only stages ahead, and the server returns 409. - QA-26/27: the legacy ticket.status is no longer shown in the deal header or deal list; the "รอ CEO ตรวจสอบปิดงาน" banner ends when the deal is COMPLETED. - QA-26: demo migration V200.1 removes DEMO-TKT-02/03/04 (guarded; no-op where absent or worked on). Co-Authored-By: Claude Opus 5.5 --- .../co/glr/hr/finance/FinanceDealService.java | 8 +- .../th/co/glr/hr/ticket/PaymentStage.java | 7 +- .../th/co/glr/hr/ticket/TicketRepository.java | 24 ++++- .../th/co/glr/hr/ticket/TicketService.java | 5 + ....1__demo_remove_legacy_ticket_fixtures.sql | 34 +++++++ ...ealQuotationSlice2HttpIntegrationTest.java | 2 +- .../FinanceDealLockdownIntegrationTest.java | 13 +++ .../hr/finance/FinanceDealServiceTest.java | 56 ++++++++++- .../TicketRepositoryIntegrationTest.java | 99 ++++++++++++++++++- frontend/src/api/mockApi.js | 19 +++- .../src/features/finance/FinanceDealPage.jsx | 8 +- .../features/finance/FinanceDealPage.test.jsx | 18 ++++ .../features/importProgress/importSteps.js | 19 ++++ .../importProgress/importSteps.test.js | 58 +++++++++++ .../features/tickets/DealDocumentRegister.jsx | 9 +- .../src/features/tickets/DealStateHeader.jsx | 11 ++- .../features/tickets/DealStateHeader.test.jsx | 83 +++++++++++++++- .../src/features/tickets/TicketDetailPage.jsx | 11 ++- .../tickets/TicketDetailPage.test.jsx | 84 ++++++++++++++++ .../src/features/tickets/TicketListPage.jsx | 20 +--- .../features/tickets/TicketListPage.test.jsx | 13 ++- frontend/src/utils/format.js | 2 + frontend/src/utils/format.test.js | 35 +++++++ 23 files changed, 583 insertions(+), 55 deletions(-) create mode 100644 backend/src/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql create mode 100644 frontend/src/features/importProgress/importSteps.test.js diff --git a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java index 5fdbb5be1..94665d22c 100644 --- a/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java +++ b/backend/src/main/java/th/co/glr/hr/finance/FinanceDealService.java @@ -192,11 +192,15 @@ private FinanceDealDto build(long ticketId, UserPrincipal actor) { private List availableActions(long ticketId, UserPrincipal actor, TicketSummaryDto s) { // Scope was enforced up front (requireFinanceAccess); an authorised action may have moved the deal out of it. var all = ticketService.financeActionsAlreadyScoped(ticketId, actor).availableActions(); + // QA-19: a manual money-stage move only ever goes FORWARD (a closed deal is not offered "move to S11"). + int current = DealStage.indexOf(s.salesStage()); boolean anyMoneyStage = all.stream().anyMatch(a -> "ADVANCE_STAGE".equals(a.action()) - && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage())); + && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage()) + && DealStage.indexOf(a.targetStage()) > current); List actions = new java.util.ArrayList<>(all.stream() .filter(a -> MONEY_ACTIONS.contains(a.action()) - || ("ADVANCE_STAGE".equals(a.action()) && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage())) + || ("ADVANCE_STAGE".equals(a.action()) && DealStage.ACCOUNT_TARGET_STAGES.contains(a.targetStage()) + && DealStage.indexOf(a.targetStage()) > current) || ("UPDATE_STAGE".equals(a.action()) && anyMoneyStage)) .map(FinanceDealService::toAction) .toList()); diff --git a/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java b/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java index 9863f6096..f1786521e 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java @@ -18,6 +18,11 @@ private PaymentStage() {} /** Paid amount covers the derived payable amount. */ public static final String FULLY_PAID = "FULLY_PAID"; + /** The customer has not reached the payment step yet (no payment_status, nothing paid). */ + public static final String NOT_STARTED = "NOT_STARTED"; + /** A bypass deposit policy (e.g. a 0% quotation): no deposit is collected, the whole amount is the balance. */ + public static final String NO_DEPOSIT = "NO_DEPOSIT"; + public static final Set VALID = Set.of( - NOT_REQUIRED, DEPOSIT_PENDING, DEPOSIT_RECEIVED, PARTIALLY_PAID, BALANCE_PENDING, FULLY_PAID); + NOT_STARTED, NO_DEPOSIT, NOT_REQUIRED, DEPOSIT_PENDING, DEPOSIT_RECEIVED, PARTIALLY_PAID, BALANCE_PENDING, FULLY_PAID); } diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index 04dc571b0..685da603c 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java @@ -308,7 +308,7 @@ public boolean existsById(long ticketId) { public String nextTicketCode() { long seq = jdbc.queryForObject("SELECT nextval('sales.ticket_code_seq')", Map.of(), Long.class); - return "PR-" + Year.now() + "-" + String.format("%04d", seq); + return "DL-" + Year.now() + "-" + String.format("%04d", seq); } public String nextQuotationCode() { @@ -1352,6 +1352,13 @@ public void clearCloseConfirmation(long ticketId) { private String derivePaymentStage(TicketSummaryDto s, BigDecimal payable, BigDecimal paid, BigDecimal outstanding, boolean balanceReceipt) { + // QA-16 (owner ruling 2026-10-29): the chip follows the payment track. Before the order is confirmed + // (no payment_status, nothing paid, stage before ORDER_RECEIVED) there is no payment step to speak of; + // a legacy deal already past that with a null track keeps the derivation below. + if (s.paymentStatus() == null && paid.signum() <= 0 + && DealStage.indexOf(s.salesStage()) < DealStage.indexOf(DealStage.ORDER_RECEIVED)) { + return PaymentStage.NOT_STARTED; + } if (payable.signum() <= 0) { return PaymentStage.NOT_REQUIRED; } @@ -1359,11 +1366,18 @@ private String derivePaymentStage(TicketSummaryDto s, BigDecimal payable, BigDec return PaymentStage.FULLY_PAID; } if (paid.signum() > 0) { - return balanceReceipt ? PaymentStage.PARTIALLY_PAID : PaymentStage.DEPOSIT_RECEIVED; + if (balanceReceipt) { + return PaymentStage.PARTIALLY_PAID; + } + return "AWAITING_FINAL_PAYMENT".equals(s.paymentStatus()) + ? PaymentStage.BALANCE_PENDING : PaymentStage.DEPOSIT_RECEIVED; + } + if (DepositPolicy.bypassesDepositNotice(s.depositPolicy())) { + // A 0% quotation: nothing to collect up front, the whole amount is the balance. + return "CUSTOMER_CONFIRMED".equals(s.paymentStatus()) ? PaymentStage.NO_DEPOSIT + : outstanding.signum() > 0 ? PaymentStage.BALANCE_PENDING : PaymentStage.NOT_REQUIRED; } - if (!DepositPolicy.bypassesDepositNotice(s.depositPolicy()) - && ("CUSTOMER_CONFIRMED".equals(s.paymentStatus()) - || "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus()))) { + if ("CUSTOMER_CONFIRMED".equals(s.paymentStatus()) || "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus())) { return PaymentStage.DEPOSIT_PENDING; } return outstanding.signum() > 0 ? PaymentStage.BALANCE_PENDING : PaymentStage.NOT_REQUIRED; diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index 85967b796..be6c5e0e3 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -2043,6 +2043,11 @@ public TicketDto financeUpdateStage(long ticketId, String targetStage, String no throw new ApiException(HttpStatus.FORBIDDEN, "ไม่มีสิทธิ์เข้าถึงรายการนี้"); } requireAccountScope(ticketId, actor); + // QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD. + if ("account".equals(actor.role()) + && DealStage.indexOf(targetStage) < DealStage.indexOf(requireTicket(ticketId).summary().salesStage())) { + throw new ApiException(HttpStatus.CONFLICT, "ฝ่ายบัญชีเลื่อนขั้นได้เฉพาะไปข้างหน้า ย้อนขั้นไม่ได้"); + } return updateStageInternal(ticketId, targetStage, note, actor); } diff --git a/backend/src/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql b/backend/src/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql new file mode 100644 index 000000000..07566155c --- /dev/null +++ b/backend/src/main/resources/db/migration-demo/V200.1__demo_remove_legacy_ticket_fixtures.sql @@ -0,0 +1,34 @@ +-- Removes the legacy demo ticket fixtures DEMO-TKT-02/03/04 (QA-26, owner ruling 2026-10-29: "remove demo tkt"). +-- +-- db/migration-demo is loaded by the demo profile -- including the hosted showcase, which runs prod,demo +-- (render.yaml), so this DOES run on that live database. It matches those three ticket codes ONLY and is a +-- no-op wherever they do not exist. +-- +-- V21 seeded them with the legacy ticket.status values submitted / price_proposed / approved, which no queue +-- holds any more -- they sat in the deal list as stuck, unexplained deals. DEMO-TKT-01 and -05 stay: the +-- e2e-real smoke / route-coverage / commission specs depend on them. +-- +-- ticket_item and ticket_event cascade from the ticket; notification and deal_activity do not, so they go +-- first. A fixture someone has since worked on in the showcase (it now owns a quotation, pricing request, +-- deposit notice, payment, ...) is left whole -- ticket AND its notification / activity rows -- rather than +-- failing the migration and blocking boot. One guard, applied to all three deletes. + +CREATE TEMP TABLE demo_tkt_to_remove ON COMMIT DROP AS +SELECT t.ticket_id + FROM sales.ticket t + WHERE t.code IN ('DEMO-TKT-02', 'DEMO-TKT-03', 'DEMO-TKT-04') + AND NOT EXISTS (SELECT 1 FROM sales.quotation x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.pricing_request x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.pricing_request_event x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.deposit_notice x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.payment_receipt x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.delivery_record x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.factory_purchase_order x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.billing_note_line x WHERE x.ticket_id = t.ticket_id) + AND NOT EXISTS (SELECT 1 FROM sales.commission_record x WHERE x.source_ticket_id = t.ticket_id); + +DELETE FROM sales.notification WHERE ticket_id IN (SELECT ticket_id FROM demo_tkt_to_remove); + +DELETE FROM sales.deal_activity WHERE ticket_id IN (SELECT ticket_id FROM demo_tkt_to_remove); + +DELETE FROM sales.ticket WHERE ticket_id IN (SELECT ticket_id FROM demo_tkt_to_remove); diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java index cd9d84f27..a08fe2e2e 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationSlice2HttpIntegrationTest.java @@ -80,7 +80,7 @@ void create_withARecipient_is201_andTheBodyCarriesWhatTheEditorReads() throws Ex assertThat(q.get("origin").asText()).isEqualTo("DEAL_DIRECT"); assertThat(q.get("docStatus").asText()).isEqualTo("DRAFT"); assertThat(q.get("ticketId").asLong()).isEqualTo(ticketId); - assertThat(q.get("ticketCode").asText()).startsWith("PR-"); + assertThat(q.get("ticketCode").asText()).startsWith("DL-"); // ขั้น 4 over HTTP, in the CREATE response itself: addressing the document to the designer // is what moved the deal (owner ruling 2026-10-16, .design/quote-stage-gates D-2), and the // 201 body already reports the new stage — so the editor needs no second fetch to render diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java index 4bc48db6a..783392d31 100644 --- a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealLockdownIntegrationTest.java @@ -243,6 +243,19 @@ void stage_accountMovesAMoneyStage_andCannotMoveANonMoneyStage() throws Exceptio assertFinanceShape(deal, id); } + /** QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD; going back is refused. */ + @Test + void stage_accountCannotMoveABackwardMoneyStage_409_andTheDealStaysPut() throws Exception { + long id = createS15Deal("FULLY_PAID"); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'CLOSED_PAID', next_follow_up_at = CURRENT_DATE + 3 WHERE ticket_id = :id", + new MapSqlParameterSource("id", id)); + + expectStatus(financeMvc.perform(json(post("/api/finance/deals/{id}/stage", id).session(session(accountUser)), + "{\"stage\":\"DEPOSIT_RECEIVED\",\"note\":\"ย้อนกลับ\"}")), 409); + + assertThat(salesStageOf(id)).isEqualTo(DealStage.CLOSED_PAID); + } + @Test void comments_accountAndCeoCanPost_andItAppearsInTheFinanceDeal() throws Exception { long id = createS15Deal("DEPOSIT_PAID"); diff --git a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java index e1830ec67..ffbb899de 100644 --- a/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/finance/FinanceDealServiceTest.java @@ -16,7 +16,9 @@ import th.co.glr.hr.common.ApiException; import th.co.glr.hr.deposit.DepositNoticeRepository; import th.co.glr.hr.deposit.RemainingInvoiceRepository; +import th.co.glr.hr.ticket.DealStage; import th.co.glr.hr.ticket.TicketRepository; +import th.co.glr.hr.ticket.TicketResponses.TicketActionDto; import th.co.glr.hr.ticket.TicketResponses.TicketActionsResponse; import th.co.glr.hr.ticket.TicketService; import th.co.glr.hr.ticket.TicketSummaryDto; @@ -54,9 +56,61 @@ void anActionThatMovesTheDealOutOfScope_stillReturnsTheUpdatedView_notA403() { assertThat(dto.availableActions()).isEmpty(); } + // ── QA-19 (owner ruling 2026-10-29): a manual money-stage move is only ever FORWARD ────────── + + /** S20 CLOSED_PAID, account has confirmed close, waiting on the CEO: "move to S11" is a step backwards. */ + @Test + void availableActions_atClosedPaid_offerNoAdvanceOrUpdateStage_butKeepTheCloseActions() { + List actions = actionsAt(DealStage.CLOSED_PAID, + new TicketActionDto("ADVANCE_STAGE", "stage", "เลื่อนไปขั้น S11", DealStage.DEPOSIT_RECEIVED), + new TicketActionDto("UPDATE_STAGE", "stage", "อัปเดตขั้น"), + new TicketActionDto("REVOKE_CLOSE_CONFIRM", "money", "ถอนการยืนยันปิดงาน")); + + assertThat(actions).doesNotContain("ADVANCE_STAGE", "UPDATE_STAGE"); + assertThat(actions).contains("REVOKE_CLOSE_CONFIRM"); + } + + @Test + void availableActions_atOrderReceived_stillOffersAdvanceToDepositReceived_andUpdateStage() { + List actions = actionsAt(DealStage.ORDER_RECEIVED, + new TicketActionDto("ADVANCE_STAGE", "stage", "เลื่อนไปขั้น S11", DealStage.DEPOSIT_RECEIVED), + new TicketActionDto("UPDATE_STAGE", "stage", "อัปเดตขั้น")); + + assertThat(actions).containsExactly("ADVANCE_STAGE", "UPDATE_STAGE"); + } + + /** Edge: a target equal to the current stage is not "ahead"; the one still ahead survives alone. */ + @Test + void availableActions_atDepositReceived_dropsTheSameStageTarget_keepsTheLaterOne() { + List offered = List.of( + new TicketActionDto("ADVANCE_STAGE", "stage", "S11", DealStage.DEPOSIT_RECEIVED), + new TicketActionDto("ADVANCE_STAGE", "stage", "S20", DealStage.CLOSED_PAID), + new TicketActionDto("UPDATE_STAGE", "stage", "อัปเดตขั้น")); + when(tickets.findSummaryById(anyLong())).thenReturn(Optional.of(summary(DealStage.DEPOSIT_RECEIVED))); + when(tickets.isInAccountScope(anyLong())).thenReturn(true); + when(ticketService.financeActionsAlreadyScoped(anyLong(), any())) + .thenReturn(new TicketActionsResponse(null, offered, List.of())); + + assertThat(service.get(10L, account).availableActions()) + .extracting(FinanceDealDto.Action::targetStage) + .containsExactly(DealStage.CLOSED_PAID, null); + } + + private List actionsAt(String salesStage, TicketActionDto... offered) { + when(tickets.findSummaryById(anyLong())).thenReturn(Optional.of(summary(salesStage))); + when(tickets.isInAccountScope(anyLong())).thenReturn(true); + when(ticketService.financeActionsAlreadyScoped(anyLong(), any())) + .thenReturn(new TicketActionsResponse(null, List.of(offered), List.of())); + return service.get(10L, account).availableActions().stream().map(FinanceDealDto.Action::action).toList(); + } + private static TicketSummaryDto summary() { + return summary("NEGOTIATION"); + } + + private static TicketSummaryDto summary(String salesStage) { return new TicketSummaryDto(1L, "PR-T", null, "t", "draft", "NORMAL", 1L, null, null, null, "cust", null, - null, null, null, null, null, null, null, null, 0, false, null, null, "NEGOTIATION", null, null, null, + null, null, null, null, null, null, null, null, 0, false, null, null, salesStage, null, null, null, "ACTIVE", null, null, null, null); } } diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java index cf384bdd4..5b76705a8 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java @@ -52,7 +52,8 @@ void createsTicketWithBatchedItemsAndReadsThemBack() { TicketDto ticket = tickets.findById(ticketId).orElseThrow(); - assertThat(ticket.summary().code()).startsWith("PR-"); + // QA-25 (owner ruling 2026-10-29): new deals are numbered DL--<4 digits>; existing PR- codes keep theirs. + assertThat(ticket.summary().code()).matches("DL-\\d{4}-\\d{4}"); assertThat(ticket.summary().createdById()).isEqualTo(actorId); assertThat(ticket.items()).hasSize(2); assertThat(ticket.items()).extracting(TicketItemDto::brand) @@ -855,6 +856,102 @@ void syncDepositPolicyFromQuotation_keepsRequired_whileADepositNoticeIsIssued() assertThat(depositPolicyRow(ticket)).containsEntry("deposit_policy", DepositPolicy.REQUIRED); } + // ── QA-16 payment chip (owner ruling 2026-10-29): the derived paymentStage follows the payment + // track. Literals, not PaymentStage constants, so this pins the wire value the frontend maps. ── + + @Test + void paymentStage_notStarted_whenNoPaymentTrackAndNothingPaid_withOrWithoutAPayable() { + long noPayable = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + assertThat(paymentStage(noPayable)).isEqualTo("NOT_STARTED"); + + long quotedNotConfirmed = payableTicket(null, DepositPolicy.REQUIRED); + assertThat(paymentStage(quotedNotConfirmed)).isEqualTo("NOT_STARTED"); + } + + @Test + void paymentStage_noDeposit_forEveryBypassPolicy_onceTheCustomerConfirmedAndNothingIsPaid() { + for (String policy : List.of(DepositPolicy.NOT_REQUIRED, DepositPolicy.WAIVED, DepositPolicy.CREDIT_CUSTOMER)) { + assertThat(paymentStage(payableTicket("CUSTOMER_CONFIRMED", policy))).as(policy).isEqualTo("NO_DEPOSIT"); + } + } + + @Test + void paymentStage_balancePending_whenTheTrackIsAwaitingFinalPayment_evenWithADepositPaid() { + long ticket = payableTicket("AWAITING_FINAL_PAYMENT", DepositPolicy.REQUIRED); + pay(ticket, "DEPOSIT", "321.00"); + assertThat(paymentStage(ticket)).isEqualTo(PaymentStage.BALANCE_PENDING); + } + + /** NOT_STARTED means "order not confirmed yet": a legacy deal already at ORDER_RECEIVED or later with a null + * track keeps the old derivation. */ + @Test + void paymentStage_nullTrackAtOrAfterOrderReceived_keepsTheOldDerivation() { + long procurement = payableTicket(null, DepositPolicy.REQUIRED); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'PROCUREMENT' WHERE ticket_id = :id", Map.of("id", procurement)); + assertThat(paymentStage(procurement)).isEqualTo(PaymentStage.BALANCE_PENDING); + + long orderReceived = payableTicket(null, DepositPolicy.REQUIRED); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'ORDER_RECEIVED' WHERE ticket_id = :id", Map.of("id", orderReceived)); + assertThat(paymentStage(orderReceived)).isEqualTo(PaymentStage.BALANCE_PENDING); + + long nothingPayable = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); + jdbc.update("UPDATE sales.ticket SET sales_stage = 'PROCUREMENT' WHERE ticket_id = :id", Map.of("id", nothingPayable)); + assertThat(paymentStage(nothingPayable)).isEqualTo(PaymentStage.NOT_REQUIRED); + } + + /** The unchanged rows of the chip: the new values must not leak into the REQUIRED-deposit path. */ + @Test + void paymentStage_keepsDepositAndMoneyStates_forRequiredPolicyAndLegacyRows() { + assertThat(paymentStage(payableTicket("CUSTOMER_CONFIRMED", DepositPolicy.REQUIRED))) + .isEqualTo(PaymentStage.DEPOSIT_PENDING); + assertThat(paymentStage(payableTicket("DEPOSIT_NOTICE_ISSUED", DepositPolicy.REQUIRED))) + .isEqualTo(PaymentStage.DEPOSIT_PENDING); + + long depositPaid = payableTicket("DEPOSIT_PAID", DepositPolicy.REQUIRED); + pay(depositPaid, "DEPOSIT", "321.00"); + assertThat(paymentStage(depositPaid)).isEqualTo(PaymentStage.DEPOSIT_RECEIVED); + + long partial = payableTicket("AWAITING_FINAL_PAYMENT", DepositPolicy.REQUIRED); + pay(partial, "DEPOSIT", "321.00"); + pay(partial, "BALANCE", "100.00"); + assertThat(paymentStage(partial)).isEqualTo(PaymentStage.PARTIALLY_PAID); + + long full = payableTicket("FULLY_PAID", DepositPolicy.REQUIRED); + pay(full, "BALANCE", "1070.00"); + assertThat(paymentStage(full)).isEqualTo(PaymentStage.FULLY_PAID); + + // legacy rows: no payment_status but money on file still shows the money state, not NOT_STARTED + long legacyDeposit = payableTicket(null, DepositPolicy.REQUIRED); + pay(legacyDeposit, "DEPOSIT", "321.00"); + assertThat(paymentStage(legacyDeposit)).isEqualTo(PaymentStage.DEPOSIT_RECEIVED); + long legacyBalance = payableTicket(null, DepositPolicy.REQUIRED); + pay(legacyBalance, "BALANCE", "100.00"); + assertThat(paymentStage(legacyBalance)).isEqualTo(PaymentStage.PARTIALLY_PAID); + } + + /** A priced item makes payable 1,000 + 7% VAT = 1,070.00 (ticket-item fallback of payableAmount). */ + private long payableTicket(String paymentStatus, String depositPolicy) { + long ticketId = tickets.create(sampleTicket(item("Toyota", "Hilux", "White", "Matte", "L")), + tickets.nextTicketCode(), actorId, "พนักงานขาย"); + jdbc.update("UPDATE sales.ticket_item SET approved_price = 1000 WHERE ticket_id = :id", Map.of("id", ticketId)); + jdbc.update("UPDATE sales.ticket SET payment_status = :ps, deposit_policy = :dp WHERE ticket_id = :id", + new MapSqlParameterSource().addValue("ps", paymentStatus).addValue("dp", depositPolicy) + .addValue("id", ticketId)); + return ticketId; + } + + private void pay(long ticketId, String kind, String amount) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by, receipt_ref) + VALUES (:t, :k, :a, 'THB', now(), :by, :ref)""", new MapSqlParameterSource() + .addValue("t", ticketId).addValue("k", kind).addValue("a", new BigDecimal(amount)) + .addValue("by", actorId).addValue("ref", "PS-" + ticketId + "-" + kind + "-" + amount)); + } + + private String paymentStage(long ticketId) { + return tickets.findSummaryById(ticketId).orElseThrow().paymentStage(); + } + private long confirmedTicket() { long ticketId = tickets.create(sampleTicket(), tickets.nextTicketCode(), actorId, "พนักงานขาย"); jdbc.update("UPDATE sales.ticket SET payment_status = 'CUSTOMER_CONFIRMED' WHERE ticket_id = :id", diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 9370030fc..39f9a9847 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -3843,8 +3843,12 @@ function derivePaymentFields(ticket) { const outstanding = moneyValue(Math.max(0, payable - paid)); const hasBalance = receiptsForTicket(ticket.id).some((receipt) => receipt.kind === 'BALANCE'); let paymentStage = 'NOT_REQUIRED'; - if (payable > 0 && paid >= payable) paymentStage = 'FULLY_PAID'; - else if (payable > 0 && paid > 0) paymentStage = hasBalance ? 'PARTIALLY_PAID' : 'DEPOSIT_RECEIVED'; + // Mirrors TicketRepository.derivePaymentStage (QA-16): NOT_STARTED = order not confirmed yet. + if (!ticket.paymentStatus && paid <= 0 && dealStageIndex(ticket.salesStage) < dealStageIndex('ORDER_RECEIVED')) paymentStage = 'NOT_STARTED'; + else if (payable > 0 && paid >= payable) paymentStage = 'FULLY_PAID'; + else if (payable > 0 && paid > 0) { + paymentStage = hasBalance ? 'PARTIALLY_PAID' : ticket.paymentStatus === 'AWAITING_FINAL_PAYMENT' ? 'BALANCE_PENDING' : 'DEPOSIT_RECEIVED'; + } else if (payable > 0 && depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED') paymentStage = 'NO_DEPOSIT'; else if (payable > 0 && !depositBypassesNotice(ticket) && ['CUSTOMER_CONFIRMED', 'DEPOSIT_NOTICE_ISSUED'].includes(ticket.paymentStatus)) paymentStage = 'DEPOSIT_PENDING'; else if (payable > 0 && outstanding > 0) paymentStage = 'BALANCE_PENDING'; @@ -8068,7 +8072,9 @@ function buildTicketActions(user, ticket) { function mockFinanceAvailableActions(user, ticket) { const all = buildTicketActions(user, ticket).availableActions; const MONEY = ['DEPOSIT_PAID', 'RECORD_PAYMENT', 'FINAL_PAYMENT', 'CONFIRM_CLOSE', 'REVOKE_CLOSE_CONFIRM']; - const moneyStage = (a) => a.action === 'ADVANCE_STAGE' && ['DEPOSIT_RECEIVED', 'CLOSED_PAID'].includes(a.targetStage); + // Mirrors FinanceDealService.availableActions (QA-19): only money stages AHEAD of the deal. + const moneyStage = (a) => a.action === 'ADVANCE_STAGE' && ['DEPOSIT_RECEIVED', 'CLOSED_PAID'].includes(a.targetStage) + && dealStageIndex(a.targetStage) > dealStageIndex(ticket.salesStage); const anyMoneyStage = all.some(moneyStage); return all .filter((a) => MONEY.includes(a.action) || moneyStage(a) || (a.action === 'UPDATE_STAGE' && anyMoneyStage)) @@ -8712,7 +8718,7 @@ export const api = { } } const nextId = Math.max(...db.tickets.map((t) => t.id)) + 1; - const code = `PR-2026-${String(nextId).padStart(4, '0')}`; + const code = `DL-2026-${String(nextId).padStart(4, '0')}`; const now = new Date().toISOString(); // Every deal begins as a DRAFT at the lead stage, regardless of whether // products were attached at creation time — pricing no longer starts at @@ -13217,6 +13223,11 @@ export const api = { async updateStage(id, payload) { const { user, raw } = requireMockFinanceAccess(id); + // Mirrors TicketService.financeUpdateStage (QA-19): account only ever moves a money stage forward. + if (user.role === 'account' && dealStageIndex(payload.stage) >= 0 + && dealStageIndex(payload.stage) < dealStageIndex(raw.salesStage)) { + fail('ฝ่ายบัญชีเลื่อนขั้นได้เฉพาะไปข้างหน้า ย้อนขั้นไม่ได้', 409); + } await mockUpdateStage(id, payload, true); return delay({ deal: buildFinanceDeal(raw, user) }); }, diff --git a/frontend/src/features/finance/FinanceDealPage.jsx b/frontend/src/features/finance/FinanceDealPage.jsx index 35cdc14f2..ebfe8568a 100644 --- a/frontend/src/features/finance/FinanceDealPage.jsx +++ b/frontend/src/features/finance/FinanceDealPage.jsx @@ -14,7 +14,7 @@ import { cn } from '../../utils/cn.js'; import { dealStageLabel, formatBangkokTime, formatThaiDate } from '../../utils/format.js'; import { InvoiceFromDealForm } from '../commissions/InvoiceFromDealForm.jsx'; import { emptyInvoiceForm, prepareInvoiceAttachment, toCreateFromDealPayload } from '../commissions/invoiceFromDeal.js'; -import { findStage, useStageCatalog } from '../tickets/stageCatalog.js'; +import { findStage, stageIndexIn, useStageCatalog } from '../tickets/stageCatalog.js'; import { RecordPaymentModal, RevokeCloseModal, StageModal, } from './FinanceActionModals.jsx'; @@ -32,7 +32,8 @@ const ACTION_LABEL = { }; // The stages account may write (DealStage.ACCOUNT_TARGET_STAGES) — only used to label the choices in -// the UPDATE_STAGE form; the server decides whether the move is accepted. +// the UPDATE_STAGE form (only those AHEAD of the deal are offered — account never moves a stage back); +// the server decides whether the move is accepted. const MONEY_STAGE_OPTIONS = ['DEPOSIT_RECEIVED', 'CLOSED_PAID']; function Bone({ className }) { @@ -459,7 +460,8 @@ export function FinanceDealPage() { ) : null} {activeAction?.action === 'UPDATE_STAGE' ? ( ({ value: code, label: `${findStage(catalog, code)?.businessCode ?? ''} ${dealStageLabel(code).label}`.trim() }))} + // Catalog not loaded yet: list both and let the server refuse a backward move (409). + options={MONEY_STAGE_OPTIONS.filter((code) => !catalog?.stages || stageIndexIn(catalog, code) > stageIndexIn(catalog, deal.salesStage)).map((code) => ({ value: code, label: `${findStage(catalog, code)?.businessCode ?? ''} ${dealStageLabel(code).label}`.trim() }))} busy={busy} error={flowError} onSubmit={(body) => run(() => api.finance.updateStage(deal.id, body))} onClose={() => setFlow(null)} diff --git a/frontend/src/features/finance/FinanceDealPage.test.jsx b/frontend/src/features/finance/FinanceDealPage.test.jsx index a70904985..758e3d4ef 100644 --- a/frontend/src/features/finance/FinanceDealPage.test.jsx +++ b/frontend/src/features/finance/FinanceDealPage.test.jsx @@ -754,6 +754,24 @@ describe('FinanceDealPage', () => { await waitFor(() => expect(api.finance.revokeCloseConfirmation).toHaveBeenCalledWith(501, { note: 'ยอดไม่ตรง' })); }); + // QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD. + async function openStageModal(salesStage) { + const deal = makeFinanceDeal({ salesStage, availableActions: [action('UPDATE_STAGE', 'แก้ไขสถานะ')] }); + api.finance.getDeal.mockResolvedValue({ deal }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'แก้ไขสถานะ' })); + const dialog = await screen.findByRole('dialog'); + return Array.from(within(dialog).getByLabelText('สถานะที่ต้องการ').querySelectorAll('option')).map((o) => o.value); + } + + it('UPDATE_STAGE lists only the money stages AHEAD of the deal (a delivered deal cannot be sent back to deposit)', async () => { + expect(await openStageModal('DELIVERY_SCHEDULING')).toEqual(['CLOSED_PAID']); + }); + + it('UPDATE_STAGE lists both money stages while the deal is still before the deposit stage', async () => { + expect(await openStageModal('ORDER_RECEIVED')).toEqual(['DEPOSIT_RECEIVED', 'CLOSED_PAID']); + }); + it('ADVANCE_STAGE (money stage) confirms, then posts the action targetStage to api.finance.updateStage', async () => { const deal = makeFinanceDeal({ availableActions: [action('ADVANCE_STAGE', 'เลื่อนสถานะ', { targetStage: 'CLOSED_PAID' })] }); api.finance.getDeal.mockResolvedValue({ deal }); diff --git a/frontend/src/features/importProgress/importSteps.js b/frontend/src/features/importProgress/importSteps.js index fe0e31584..4cb4cb29d 100644 --- a/frontend/src/features/importProgress/importSteps.js +++ b/frontend/src/features/importProgress/importSteps.js @@ -45,3 +45,22 @@ export function stepsAhead(code) { const i = importStepIndex(code); return i < 0 ? IMPORT_STEPS : IMPORT_STEPS.slice(i + 1); } + +// Deal rollups at or past goods-received: the deal-level fulfilment label already says it. +const PAST_IMPORT = new Set(['GOODS_RECEIVED', 'PARTIALLY_DELIVERED', 'FULLY_DELIVERED', 'FROM_STOCK']); + +/** + * The deal header's การนำเข้า chip text from the per-factory IR steps (QA-22): the SLOWEST live tracked + * factory's step label, with " · ถึงโกดัง X/Y" appended when more than one factory is tracked. null means + * "keep the deal-level fulfilment label" — no live tracked IR, or the rollup is already past import. + */ +export function importChipLabel(importRequests, fulfillmentStatus) { + if (PAST_IMPORT.has(fulfillmentStatus)) return null; + const tracked = (importRequests ?? []).filter((r) => r.status === 'ISSUED' && importStepIndex(r.importStep) >= 0); + if (tracked.length === 0) return null; + const slowest = tracked.reduce((a, b) => (importStepIndex(b.importStep) < importStepIndex(a.importStep) ? b : a)); + const label = importStepMeta(slowest.importStep).label; + if (tracked.length === 1) return label; + const received = tracked.filter((r) => r.importStep === 'RECEIVED').length; + return received === tracked.length ? label : `${label} · ถึงโกดัง ${received}/${tracked.length}`; +} diff --git a/frontend/src/features/importProgress/importSteps.test.js b/frontend/src/features/importProgress/importSteps.test.js new file mode 100644 index 000000000..2f1ace271 --- /dev/null +++ b/frontend/src/features/importProgress/importSteps.test.js @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import * as importSteps from './importSteps.js'; + +// QA-22 (owner ruling 2026-10-29): the deal header's การนำเข้า chip follows the per-factory IR steps. +// Contract pinned here: importChipLabel(importRequests, fulfillmentStatus) -> string, or null when the +// caller should keep today's fulfilment label ("ยังไม่เริ่ม" / fulfilmentStatusLabel(fulfillmentStatus)). +// Looked up off the namespace so a missing export fails on the assertion, not on a TypeError. +const importChipLabel = importSteps.importChipLabel ?? (() => undefined); + +const ir = (importStep, status = 'ISSUED') => ({ status, importStep }); + +describe('importChipLabel', () => { + it('shows the slowest live tracked factory step, with arrivals when there are several factories', () => { + expect(importChipLabel([ir('IN_TRANSIT'), ir('RECEIVED')], 'IR_ISSUED')).toBe('กำลังเดินทาง · ถึงโกดัง 1/2'); + expect(importChipLabel([ir('RECEIVED'), ir('CONTACTED'), ir('IN_TRANSIT')], 'IR_ISSUED')) + .toBe('ส่ง IR ให้จัดซื้อ · ถึงโกดัง 1/3'); + }); + + it('one factory: the step label alone', () => { + expect(importChipLabel([ir('ORDERED')], 'IR_ISSUED')).toBe('สั่งซื้อผู้ผลิต'); + }); + + it('skips superseded and untracked (no importStep) rows', () => { + expect(importChipLabel([ir('IN_TRANSIT'), ir('ORDERED', 'SUPERSEDED'), ir(null, 'DRAFT')], 'IR_ISSUED')) + .toBe('กำลังเดินทาง'); + }); + + it.each(['IR_ISSUED', 'IR_SENT'])( + 'every tracked factory RECEIVED while the rollup is still %s: the step label alone, no counter', + (fulfillmentStatus) => { + expect(importChipLabel([ir('RECEIVED'), ir('RECEIVED')], fulfillmentStatus)).toBe('ถึงโกดัง'); + expect(importChipLabel([ir('RECEIVED')], fulfillmentStatus)).toBe('ถึงโกดัง'); + }, + ); + + it('a revised factory counts once: v1 SUPERSEDED at ORDERED is ignored, v2 ISSUED at RECEIVED is the factory', () => { + expect(importChipLabel([ir('ORDERED', 'SUPERSEDED'), ir('RECEIVED')], 'IR_ISSUED')).toBe('ถึงโกดัง'); + }); + + it('a DRAFT row with a step is not live either', () => { + expect(importChipLabel([ir('ORDERED', 'DRAFT')], 'IR_ISSUED')).toBeNull(); + }); + + it.each([undefined, null, []])('returns null with no rows (%j) so the caller keeps the fulfilment label', (rows) => { + expect(importChipLabel(rows, 'IR_ISSUED')).toBeNull(); + }); + + it('returns null when every row is superseded or untracked', () => { + expect(importChipLabel([ir('ORDERED', 'SUPERSEDED'), ir(null, 'DRAFT')], 'IR_ISSUED')).toBeNull(); + }); + + it.each(['GOODS_RECEIVED', 'PARTIALLY_DELIVERED', 'FULLY_DELIVERED', 'FROM_STOCK'])( + 'returns null once the deal rollup is past import (%s)', + (fulfillmentStatus) => { + expect(importChipLabel([ir('RECEIVED'), ir('IN_TRANSIT')], fulfillmentStatus)).toBeNull(); + }, + ); +}); diff --git a/frontend/src/features/tickets/DealDocumentRegister.jsx b/frontend/src/features/tickets/DealDocumentRegister.jsx index f03d2b87b..2d3e59412 100644 --- a/frontend/src/features/tickets/DealDocumentRegister.jsx +++ b/frontend/src/features/tickets/DealDocumentRegister.jsx @@ -24,6 +24,12 @@ import { groupDirectQuotations, groupLegacyQuotations, groupRemainingInvoices, sortGroupsNewestFirst, } from './dealDocumentVersions.js'; +/** Same read gate as ImportRequestService#requireRead: import / CEO / sales_manager, or the OWNING sales rep. */ +export function canReadStoredImportRequests(user, summary) { + return ['import', 'ceo', 'sales_manager'].includes(user?.role) + || (user?.role === 'sales' && summary?.createdById != null && summary.createdById === user?.id); +} + /** * Slice D ("the เอกสาร document register"): one read-only roll-up of every document a deal can * produce — customer quotations (the PricingRequest/CustomerQuotation chain, the legacy @@ -174,8 +180,7 @@ export function DealDocumentRegister({ // CR-1 (R6/R9): the stored ใบขอซื้อ rows — one IR per factory — in a 5th section. Same read gate as // ImportRequestService#requireRead (and DealFulfilmentPanel's canReadStoredIr): import / CEO / // sales_manager, or the OWNING sales rep. Same query key as that panel, so one fetch feeds both. - const canViewImportRequests = ['import', 'ceo', 'sales_manager'].includes(user?.role) - || (user?.role === 'sales' && summary?.createdById != null && summary.createdById === user?.id); + const canViewImportRequests = canReadStoredImportRequests(user, summary); const importRequestsQuery = useQuery({ queryKey: queryKeys.storedImportRequests(ticketId), queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), diff --git a/frontend/src/features/tickets/DealStateHeader.jsx b/frontend/src/features/tickets/DealStateHeader.jsx index ffdb8441b..cee8648bb 100644 --- a/frontend/src/features/tickets/DealStateHeader.jsx +++ b/frontend/src/features/tickets/DealStateHeader.jsx @@ -4,8 +4,9 @@ import { OverflowMenu } from '../../components/common/OverflowMenu.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { dealLifecycleLabel, dealStageLabel, formatMoney, formatThaiDate, fulfilmentStatusLabel, - paymentStageLabel, pricingRequestStatusLabel, ticketStatusLabel, + paymentStageLabel, pricingRequestStatusLabel, } from '../../utils/format.js'; +import { importChipLabel } from '../importProgress/importSteps.js'; import { activePricingRequestsSummary } from '../pricingRequests/pricingRequestMeta.js'; import { visibleSections } from './salesViewScope.js'; @@ -119,13 +120,14 @@ function lastChipSpanClass(count) { * trigger at all. */ export function DealStateHeader({ - summary, pricingRequests = [], role, primaryAction, bannerText, overflowItems, onRefresh, condensed = false, onMenuOpenChange, + summary, pricingRequests = [], importRequests, role, primaryAction, bannerText, overflowItems, onRefresh, condensed = false, onMenuOpenChange, }) { const lifecycle = dealLifecycleLabel(summary.lifecycle ?? 'ACTIVE'); - const status = ticketStatusLabel(summary.status); const stage = dealStageLabel(summary.salesStage, summary.entryChannel); const payment = paymentStageLabel(summary.paymentStage); const fulfilment = summary.fulfillmentStatus ? fulfilmentStatusLabel(summary.fulfillmentStatus) : null; + // QA-22: follows the per-factory IR steps while any factory is tracked (the deal rollup lags until ALL arrive). + const importChip = importChipLabel(importRequests, summary.fulfillmentStatus); const hasDealValue = Number(summary.amountPayable ?? 0) > 0; // Most-recently-created non-cancelled pricing request stands in for "PCR @@ -159,7 +161,7 @@ export function DealStateHeader({ { key: 'fulfilment', label: 'การนำเข้า', - value: fulfilment ? fulfilment.label : 'ยังไม่เริ่ม', + value: importChip ?? (fulfilment ? fulfilment.label : 'ยังไม่เริ่ม'), tone: fulfilment ? fulfilment.tone : 'neutral', }, sections.payment && { @@ -238,7 +240,6 @@ export function DealStateHeader({

{summary.projectName}

) : null}
- {status.label} {summary.hasEdits ? ✎ มีการแก้ไข : null} สร้างโดย {summary.createdByName || '-'} · {formatThaiDate(summary.createdAt)} {summary.assignedToName ? ( diff --git a/frontend/src/features/tickets/DealStateHeader.test.jsx b/frontend/src/features/tickets/DealStateHeader.test.jsx index d2ce3484c..46bf21ad5 100644 --- a/frontend/src/features/tickets/DealStateHeader.test.jsx +++ b/frontend/src/features/tickets/DealStateHeader.test.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { render, screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { DEAL_STAGE_CATALOG } from '../../data/dealStageCatalog.js'; -import { dealStageLabel } from '../../utils/format.js'; +import { dealStageLabel, fulfilmentStatusLabel, ticketStatusLabel } from '../../utils/format.js'; import { DealStateHeader } from './DealStateHeader.jsx'; globalThis.React = React; @@ -110,3 +110,84 @@ const stages = DEAL_STAGE_CATALOG.stages.map((s) => s.code).filter((c) => !CHANN } }); }); + +// QA-27 / QA-26 (owner ruling 2026-10-29): the legacy sales.ticket.status ("แบบร่าง", "ออกใบเสนอราคาแล้ว"…) +// stopped meaning anything once the deal stage + payment/fulfilment chips took over — it only misled +// (a DELIVERED deal read "ออกใบเสนอราคาแล้ว"). The header shows no badge for it at all. +describe('DealStateHeader — no legacy ticket.status badge', () => { + it.each(['draft', 'quotation_issued', 'submitted', 'price_proposed', 'closed'])( + 'does not render the label for legacy status %s', + (status) => { + renderHeader({ status, salesStage: 'DELIVERED' }); + expect(screen.queryByText(ticketStatusLabel(status).label)).toBeNull(); + }, + ); + + it('still renders the rest of the header (stage chip, updated-at line) — the check is not vacuous', () => { + renderHeader({ status: 'quotation_issued', salesStage: 'DELIVERED' }); + expect(stageChipText()).toBe(dealStageLabel('DELIVERED').label); + expect(screen.getByText(/อัปเดตล่าสุด/)).toBeTruthy(); + }); +}); + +// QA-22 (owner ruling 2026-10-29): summary.fulfillmentStatus stays IR_ISSUED until EVERY factory is +// RECEIVED, so the การนำเข้า chip follows the per-factory IR steps instead: the SLOWEST live tracked +// factory's step, plus " · ถึงโกดัง X/Y" when more than one factory is tracked. +describe('DealStateHeader — การนำเข้า chip follows the per-factory IR steps', () => { + const ir = (importStep, extra = {}) => ({ id: Math.floor(Math.random() * 1e6), status: 'ISSUED', importStep, ...extra }); + function importChipText() { + const dt = screen.getByText('การนำเข้า'); + return within(dt.parentElement).getByText(/./, { selector: '.status-badge' }).textContent; + } + const withIrs = (importRequests, summaryOverrides = { fulfillmentStatus: 'IR_ISSUED' }) => + renderHeader(summaryOverrides, { importRequests }); + + it('two factories, one IN_TRANSIT and one RECEIVED -> the slower step with the arrival count', () => { + withIrs([ir('IN_TRANSIT'), ir('RECEIVED')]); + expect(importChipText()).toBe('กำลังเดินทาง · ถึงโกดัง 1/2'); + }); + + it('one factory -> the step label alone, no counter', () => { + withIrs([ir('ORDERED')]); + expect(importChipText()).toBe('สั่งซื้อผู้ผลิต'); + }); + + it('the slowest wins whatever the row order', () => { + withIrs([ir('RECEIVED'), ir('CONTACTED'), ir('IN_TRANSIT')]); + expect(importChipText()).toBe('ส่ง IR ให้จัดซื้อ · ถึงโกดัง 1/3'); + }); + + it('every tracked factory RECEIVED while the deal rollup is still IR_ISSUED -> ถึงโกดัง alone', () => { + withIrs([ir('RECEIVED'), ir('RECEIVED')]); + expect(importChipText()).toBe('ถึงโกดัง'); + }); + + it('a revised factory counts once: v1 SUPERSEDED at ORDERED + v2 ISSUED at RECEIVED -> ถึงโกดัง', () => { + withIrs([ir('ORDERED', { status: 'SUPERSEDED' }), ir('RECEIVED')]); + expect(importChipText()).toBe('ถึงโกดัง'); + }); + + it('ignores superseded and not-yet-tracked (no importStep) rows when picking the step and counting', () => { + withIrs([ir('IN_TRANSIT'), ir('ORDERED', { status: 'SUPERSEDED' }), ir(null, { status: 'DRAFT' })]); + expect(importChipText()).toBe('กำลังเดินทาง'); + }); + + it.each([ + ['no importRequests prop and no fulfilment status', undefined, null, 'ยังไม่เริ่ม'], + ['an empty list and no fulfilment status', [], null, 'ยังไม่เริ่ม'], + ['an empty list and IR_ISSUED', [], 'IR_ISSUED', fulfilmentStatusLabel('IR_ISSUED').label], + ['only superseded / untracked rows and IR_ISSUED', [ir('ORDERED', { status: 'SUPERSEDED' }), ir(null, { status: 'DRAFT' })], + 'IR_ISSUED', fulfilmentStatusLabel('IR_ISSUED').label], + ])('no live tracked IR (%s) -> today\'s behaviour', (_name, importRequests, fulfillmentStatus, expected) => { + withIrs(importRequests, { fulfillmentStatus }); + expect(importChipText()).toBe(expected); + }); + + it.each(['GOODS_RECEIVED', 'PARTIALLY_DELIVERED', 'FULLY_DELIVERED', 'FROM_STOCK'])( + 'past import (%s) keeps the deal-level fulfilment label even with IR rows on file', + (fulfillmentStatus) => { + withIrs([ir('RECEIVED'), ir('IN_TRANSIT')], { fulfillmentStatus }); + expect(importChipText()).toBe(fulfilmentStatusLabel(fulfillmentStatus).label); + }, + ); +}); diff --git a/frontend/src/features/tickets/TicketDetailPage.jsx b/frontend/src/features/tickets/TicketDetailPage.jsx index 4863118f8..7b2ddf8f4 100644 --- a/frontend/src/features/tickets/TicketDetailPage.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.jsx @@ -46,7 +46,7 @@ import { DealAttachmentsPanel } from './DealAttachmentsPanel.jsx'; import { DealDepositPanel, depositNoticesQueryOptions, pickDepositNotice } from './DealDepositPanel.jsx'; import { createDepositNoticeDraft } from '../deposits/createDepositNoticeDraft.js'; import { DealDocumentPipeline } from './DealDocumentPipeline.jsx'; -import { DealDocumentRegister } from './DealDocumentRegister.jsx'; +import { DealDocumentRegister, canReadStoredImportRequests } from './DealDocumentRegister.jsx'; import { RemainingInvoiceDialog } from './RemainingInvoiceDialog.jsx'; import { DealFulfilmentPanel } from './DealFulfilmentPanel.jsx'; import { DealHistoryPanel } from './DealHistoryPanel.jsx'; @@ -611,11 +611,12 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { queryFn: () => api.storedRemainingInvoices.listForTicket(ticketId).then((r) => r.remainingInvoices ?? []), enabled: readsDocs, }); - // Same key/fn as DealFulfilmentPanel's own read, so the two share one cache entry (QA-09). + // Same key/fn as DealFulfilmentPanel's own read, so the two share one cache entry (QA-09). Read for every role + // the backend lets read stored IRs, since the header's การนำเข้า chip (QA-22) follows them on every tab. const storedIrQuery = useQuery({ queryKey: queryKeys.storedImportRequests(ticketId), queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), - enabled: readsDocs, + enabled: !!ticketId && canReadStoredImportRequests(user, ticket?.summary), }); const importRequests = storedIrQuery.data; const depositNotice = pickDepositNotice(depositNoticesQuery.data); @@ -1224,7 +1225,8 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { // recording/confirming a payment is account-only now (see isMoneyRecorder above), so the CEO no // longer clears either wait and must see the same read-only status a sales/import viewer does, // not silence. - const blocker = closeConfirmedAt && !can.verifyClose + // QA-27: once the CEO has verified (COMPLETED) there is nothing left to wait for. + const blocker = closeConfirmedAt && !can.verifyClose && summary?.lifecycle !== 'COMPLETED' ? 'รอ CEO ตรวจสอบปิดงาน' : ps === 'DEPOSIT_NOTICE_ISSUED' && !isMoneyRecorder ? 'รอชำระมัดจำ' : ps === 'AWAITING_FINAL_PAYMENT' && !isMoneyRecorder ? 'รอชำระส่วนที่เหลือ' @@ -1868,6 +1870,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { { }); }); + // QA-27 (owner ruling 2026-10-29): "รอ CEO ตรวจสอบปิดงาน" is a WAIT, so it stops once the CEO has + // verified (lifecycle COMPLETED) — closeConfirmedAt stays set forever, so it cannot be the only test. + describe('blocker line "รอ CEO ตรวจสอบปิดงาน" ends when the CEO has verified (QA-27)', () => { + const salesManagerUser = { id: 11, employeeId: 11, name: 'ผจก.ขาย', role: 'sales_manager' }; + function closeConfirmedDeal(lifecycle) { + api.tickets.get.mockResolvedValue({ + ticket: buildTicket({ + summary: { + lifecycle, salesStage: 'DELIVERED', status: 'quotation_issued', paymentStatus: 'FULLY_PAID', + closeConfirmedAt: '2026-07-20T09:00:00.000Z', + }, + }), + }); + api.tickets.actions.mockResolvedValue({ + currentState: { lifecycle, salesStage: 'DELIVERED', paymentStatus: 'FULLY_PAID', fulfillmentStatus: 'FULLY_DELIVERED', status: 'quotation_issued' }, + availableActions: [], + }); + } + + it('still shows to a non-CEO viewer while the deal is ACTIVE and awaiting verification', async () => { + closeConfirmedDeal('ACTIVE'); + renderTicketDetailPage(salesManagerUser); + expect((await screen.findAllByText('รอ CEO ตรวจสอบปิดงาน')).length).toBeGreaterThan(0); + }); + + it('does not show to the CEO once the deal is COMPLETED (the CEO has no verify action left)', async () => { + closeConfirmedDeal('COMPLETED'); + renderTicketDetailPage(ceoUser); + await screen.findByRole('heading', { level: 1, name: 'บริษัท ทดสอบ จำกัด' }); + expect(screen.queryByText('รอ CEO ตรวจสอบปิดงาน')).toBeNull(); + }); + + it('does not show to a non-CEO viewer once the deal is COMPLETED either', async () => { + closeConfirmedDeal('COMPLETED'); + renderTicketDetailPage(salesManagerUser); + await screen.findByRole('heading', { level: 1, name: 'บริษัท ทดสอบ จำกัด' }); + expect(screen.queryByText('รอ CEO ตรวจสอบปิดงาน')).toBeNull(); + }); + }); + // Deal tracking (V83, Slice B1/B2 "kill the weekly report" — handoff 103). // Slice C2b moved DealTrackingPanel from the old "activity" tab into "ดีล" // (the default tab — see ticketDetailTabs.js's own comment on that tab), @@ -3761,6 +3801,50 @@ describe('TicketDetailPage', () => { }); }); + // QA-22: the header's การนำเข้า chip follows the per-factory IR steps, so the page itself reads the stored + // IRs (not only the fulfilment tab) for every role the backend lets read them — and passes them down. + describe('การนำเข้า header chip follows the stored IRs (QA-22)', () => { + function dealWithTwoFactories() { + api.tickets.get.mockResolvedValue({ + ticket: buildTicket({ summary: { lifecycle: 'ACTIVE', salesStage: 'PROCUREMENT', fulfillmentStatus: 'IR_ISSUED', createdById: 1 } }), + }); + api.tickets.actions.mockResolvedValue({ + currentState: { lifecycle: 'ACTIVE', salesStage: 'PROCUREMENT', paymentStatus: null, fulfillmentStatus: 'IR_ISSUED', status: 'price_proposed' }, + availableActions: [], + }); + api.storedImportRequests.listForTicket.mockResolvedValue({ + importRequests: [ + { id: 31, status: 'ISSUED', ticketId: 701, importStep: 'IN_TRANSIT' }, + { id: 32, status: 'ISSUED', ticketId: 701, importStep: 'RECEIVED' }, + ], + }); + } + afterEach(() => { + api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: [] }); + }); + const chip = async () => { + const header = await screen.findByTestId('deal-state-header'); + return within(within(header).getByText('การนำเข้า').parentElement); + }; + + it.each([ + ['the owning sales rep', salesOwnerUser], + ['import (not the owner)', { id: 7, employeeId: 7, name: 'ฝ่ายนำเข้า', role: 'import' }], + ['sales_manager (not the owner)', { id: 11, employeeId: 11, name: 'ผจก.ขาย', role: 'sales_manager' }], + ])('shows the slowest factory step with the arrival count to %s', async (_name, viewer) => { + dealWithTwoFactories(); + renderTicketDetailPage(viewer); + expect(await (await chip()).findByText('กำลังเดินทาง · ถึงโกดัง 1/2')).not.toBeNull(); + }); + + it('a non-owner sales rep cannot read stored IRs: the chip keeps the deal-level label and no request is made', async () => { + dealWithTwoFactories(); + renderTicketDetailPage({ id: 2, employeeId: 2, name: 'พนักงานขายอีกคน', role: 'sales' }); + expect((await chip()).getByText('ออกคำขอนำเข้าแล้ว')).not.toBeNull(); + expect(api.storedImportRequests.listForTicket).not.toHaveBeenCalled(); + }); + }); + // Ticket-detail IA rebuild Phase 2 built role-projected tab VISIBILITY; // Slice C2b regrouped the seven tabs into six (ticketDetailTabs.js has its // own unit tests for the pure function — these prove the page actually diff --git a/frontend/src/features/tickets/TicketListPage.jsx b/frontend/src/features/tickets/TicketListPage.jsx index abbd51f70..0e84936e6 100644 --- a/frontend/src/features/tickets/TicketListPage.jsx +++ b/frontend/src/features/tickets/TicketListPage.jsx @@ -21,7 +21,6 @@ import { formatThaiDate, fulfilmentStatusLabel, overdueBadgeLabel, - ticketStatusLabel, } from '../../utils/format.js'; import { StageProgressBar } from './DealStageStepper.jsx'; import { dealHref } from '../finance/dealHref.js'; @@ -383,7 +382,6 @@ function DealStageCell({ deal, catalog }) { // null (render nothing) for UNSPECIFIED / unknown / absent: a route is never invented. const route = routeName(deal.entryChannel); const meta = findStage(catalog, deal.salesStage); - const operational = ticketStatusLabel(deal.status); // FIX F6 (review-remediation): used to badge only ON_HOLD/DORMANT, so a // CANCELLED or COMPLETED deal looked indistinguishable from a live one in // this column — and specifically in this column, because the 721–1040px @@ -394,15 +392,6 @@ function DealStageCell({ deal, catalog }) { // the two stay in sync. const showLifecycleBadge = PAUSED_OR_TERMINAL_LIFECYCLES.has(deal.lifecycle); const lifecycle = dealLifecycleLabel(deal.lifecycle); - // Since 03b5ba9 stopped ticket-level auto-submit, every newly created deal's - // legacy `status` is frozen at 'draft' forever — it no longer advances with - // real workflow (that now lives on the deal's PricingRequest(s) and - // salesStage instead). Showing "แบบร่าง" under the stage badge for those - // deals is not just uninformative, it actively misleads a reader into - // thinking nothing has happened. Older deals that already progressed past - // draft before that change still have a meaningful legacy status, so keep - // showing it for them. - const showOperational = deal.status !== 'draft'; return ( @@ -412,9 +401,6 @@ function DealStageCell({ deal, catalog }) { {showLifecycleBadge ? {lifecycle.label} : null} {route ? {route} : null} - {showOperational ? ( - {operational.label} - ) : null} ); } @@ -601,10 +587,8 @@ export function TicketListPage({ user, showToast }) { const queryClient = useQueryClient(); // Phase filter + search live in the URL so list → detail → back keeps them. // The old operational ?status= chips row was removed as redundant with the - // phase cards (user decision) — the stage cell's sublabel shows where the - // legacy ticket-status paperwork stands, for deals where that status still - // means something (see DealStageCell's showOperational: it is suppressed - // for deals frozen at 'draft'). + // phase cards (user decision), and the stage cell no longer shows the legacy + // ticket status either (QA-27, owner ruling 2026-10-29). const [searchParams, setSearchParams] = useSearchParams(); const phaseFilter = searchParams.get('phase') ?? ''; const lifecycleFilter = searchParams.get('life') ?? ''; diff --git a/frontend/src/features/tickets/TicketListPage.test.jsx b/frontend/src/features/tickets/TicketListPage.test.jsx index b709fcffe..ee17e472d 100644 --- a/frontend/src/features/tickets/TicketListPage.test.jsx +++ b/frontend/src/features/tickets/TicketListPage.test.jsx @@ -508,17 +508,16 @@ describe('TicketListPage', () => { await waitFor(() => expect(screen.getByTestId('location-display').textContent).toBe('/tickets/502')); }); - // Review-remediation plan Commit D: since 03b5ba9 stopped ticket-level - // auto-submit, a new deal's legacy `status` is permanently 'draft' and no - // longer reflects real workflow — showing it as a secondary label under the - // stage badge would read as if nothing had happened. Deals that already - // progressed under the old flow (`status: 'submitted'` etc.) still show it. - it('suppresses the legacy status sublabel for a deal frozen on draft, but keeps it for others', async () => { + // QA-27 / QA-26 (owner ruling 2026-10-29), superseding Review-remediation Commit D (which hid only + // 'draft'): the legacy `status` sublabel under the stage badge is gone for EVERY deal — the stage + // badge and the payment/fulfilment chips say where the deal is, the old status only misled. + it('shows no legacy status sublabel under the stage badge, for any status', async () => { renderTicketListPage(); expect(await screen.findByText('บริษัท ดีลใหม่ จำกัด')).not.toBeNull(); expect(screen.queryByText('แบบร่าง')).toBeNull(); - expect(screen.getByText('รอฝ่ายนำเข้ารับเรื่อง')).not.toBeNull(); + expect(screen.queryByText('รอฝ่ายนำเข้ารับเรื่อง')).toBeNull(); // fixture deal with status 'submitted' + expect(screen.queryByText('ออกใบเสนอราคาแล้ว')).toBeNull(); // fixture deals with status 'quotation_issued' }); it('filters by lifecycle, overdue, and partial delivery chips (after expanding "ตัวกรองเพิ่มเติม")', async () => { diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js index 5e865fc02..80501e9e5 100644 --- a/frontend/src/utils/format.js +++ b/frontend/src/utils/format.js @@ -631,6 +631,8 @@ export function depositPolicyLabel(value) { export function paymentStageLabel(value) { const map = { + NOT_STARTED: { label: 'ยังไม่ถึงขั้นชำระ', tone: 'neutral' }, + NO_DEPOSIT: { label: 'ไม่เก็บมัดจำ', tone: 'neutral' }, NOT_REQUIRED: { label: 'ไม่ต้องชำระ', tone: 'neutral' }, DEPOSIT_PENDING: { label: 'รอมัดจำ', tone: 'warning' }, DEPOSIT_RECEIVED: { label: 'รับมัดจำแล้ว', tone: 'success' }, diff --git a/frontend/src/utils/format.test.js b/frontend/src/utils/format.test.js index 20effbc00..8ed5e46fb 100644 --- a/frontend/src/utils/format.test.js +++ b/frontend/src/utils/format.test.js @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { addDaysIso, @@ -19,6 +21,7 @@ import { greetingName, overtimeStatusLabel, pricingCostingStatusLabel, + paymentStageLabel, pricingDecisionStatusLabel, quotationStatusLabel, specialMoneyStatusLabel, @@ -505,3 +508,35 @@ describe('formatFileSize', () => { expect(formatFileSize(undefined)).toBe('-'); }); }); + +// QA-16 (owner ruling 2026-10-29): two new neutral payment-chip values, derived by the backend. +describe('paymentStageLabel — QA-16 chip values', () => { + it('labels NOT_STARTED and NO_DEPOSIT, both neutral', () => { + expect(paymentStageLabel('NOT_STARTED')).toEqual({ label: 'ยังไม่ถึงขั้นชำระ', tone: 'neutral' }); + expect(paymentStageLabel('NO_DEPOSIT')).toEqual({ label: 'ไม่เก็บมัดจำ', tone: 'neutral' }); + }); + + it('keeps the existing labels (BALANCE_PENDING is what an awaiting-final-payment deal now shows)', () => { + expect(paymentStageLabel('BALANCE_PENDING')).toEqual({ label: 'รอชำระส่วนที่เหลือ', tone: 'warning' }); + expect(paymentStageLabel('DEPOSIT_PENDING').label).toBe('รอมัดจำ'); + expect(paymentStageLabel('FULLY_PAID').label).toBe('ชำระครบแล้ว'); + }); + + // statusCatalog.test.js does not cover PaymentStage, so this reads PaymentStage.java directly + // (the technique stageCatalog.test.js uses): every backend value must have its own label. + it('has a label for every value in PaymentStage.java (never the raw code)', () => { + let dir = process.cwd(); + let source = null; + for (let depth = 0; depth < 8 && source === null; depth += 1) { + const candidate = resolve(dir, 'backend/src/main/java/th/co/glr/hr/ticket/PaymentStage.java'); + if (existsSync(candidate)) source = readFileSync(candidate, 'utf8'); + dir = dirname(dir); + } + if (source === null) throw new Error('could not find PaymentStage.java above the working directory'); + const codes = [...source.matchAll(/public static final String \w+ = "([A-Z_]+)";/g)].map((m) => m[1]); + expect(codes.length).toBeGreaterThanOrEqual(6); + for (const code of codes) { + expect(paymentStageLabel(code).label, code).not.toBe(code); + } + }); +}); From fb52650dedd863f49a2111d3d40a37aaecc3b3d5 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:51:36 +0700 Subject: [PATCH 03/24] fix(deal): coverage flag on its own typed endpoint; drop dead fs-null branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of #1201: - GET /api/tickets/{id}/import-requests is back to rows only, so the import pages (one call per deal) no longer pay for the coverage check. - New read-only GET /api/tickets/{ticketId}/import-requests/coverage returns ImportRequestCoverageResponse(hasUncoveredFactories), same authz as the list; typed, so the API contract guards see it. Both digests regenerated (+1 op). - The panel asks the coverage endpoint once (same cache prefix, so IR changes still refresh it) instead of fetching the list twice. - Removed the unreachable 'ยังไม่ออกใบขอซื้อ' paragraph inside the นำเข้าสินค้า card. Co-Authored-By: Claude Opus 5.5 --- .../hr/importrequest/ImportRequestController.java | 13 ++++++++++--- .../co/glr/hr/importrequest/ImportRequestDtos.java | 3 +++ .../StoredImportRequestIntegrationTest.java | 8 ++++---- docs/api/api-surface.json | 3 ++- docs/api/ui-reachable.json | 3 ++- frontend/src/api/hrApi.js | 2 ++ frontend/src/api/mockApi.js | 7 ++++++- frontend/src/api/routes.js | 1 + .../src/features/tickets/DealFulfilmentPanel.jsx | 8 +++----- .../features/tickets/DealFulfilmentPanel.test.jsx | 8 +++++--- 10 files changed, 38 insertions(+), 18 deletions(-) diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java index b851f4aba..f4721a2a9 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestController.java @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController; import th.co.glr.hr.auth.SessionContext; import th.co.glr.hr.auth.UserPrincipal; +import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestCoverageResponse; import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestDto; import th.co.glr.hr.importrequest.ImportRequestRequests.AdvanceImportStepRequest; import th.co.glr.hr.importrequest.ImportRequestRequests.CreateImportRequestsRequest; @@ -98,10 +99,16 @@ Map> createDrafts(@PathVariable long ticketId, } @GetMapping("/tickets/{ticketId}/import-requests") - Map listForTicket(@PathVariable long ticketId, HttpSession session) { + Map> listForTicket(@PathVariable long ticketId, HttpSession session) { UserPrincipal user = sessions.requireUser(session); - return Map.of("importRequests", service.list(ticketId, user), - "hasUncoveredFactories", service.hasUncoveredFactories(ticketId, user)); + return Map.of("importRequests", service.list(ticketId, user)); + } + + /** Read-only: does any factory on the deal still need an IR? Its own route so the list stays cheap. */ + @GetMapping("/tickets/{ticketId}/import-requests/coverage") + ImportRequestCoverageResponse coverage(@PathVariable long ticketId, HttpSession session) { + UserPrincipal user = sessions.requireUser(session); + return new ImportRequestCoverageResponse(service.hasUncoveredFactories(ticketId, user)); } @GetMapping("/import-requests/{id}") diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java index 362dd8678..83b93d387 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestDtos.java @@ -113,6 +113,9 @@ public record ImportRequestDto( List items ) {} + /** GET /tickets/{id}/import-requests/coverage. */ + public record ImportRequestCoverageResponse(boolean hasUncoveredFactories) {} + public record ImportRequestItemDto( long id, long importRequestId, diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java index 06460493f..20f36b6d2 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java @@ -184,14 +184,14 @@ void createDrafts_issuesEveryFactoryInOneStep_andMovesTheDealToProcurement() { // ── owner ruling 2026-10-29 (5c): GET /tickets/{id}/import-requests also says whether a factory is uncovered ── - /** The flag as the frontend reads it: a top-level key of the list response, read-only, no factory is created. */ + /** The flag as the frontend reads it: its own read-only route, so the shared list stays rows-only. */ private Object hasUncoveredFactoriesOverHttpShape(long forTicket) { SessionContext sessions = mock(SessionContext.class); MockHttpSession session = new MockHttpSession(); when(sessions.requireUser(session)).thenReturn(owner); - Map body = new ImportRequestController(service, sessions).listForTicket(forTicket, session); - assertThat(body).containsKey("importRequests"); - return body.get("hasUncoveredFactories"); + ImportRequestController controller = new ImportRequestController(service, sessions); + assertThat(controller.listForTicket(forTicket, session)).containsOnlyKeys("importRequests"); + return controller.coverage(forTicket, session).hasUncoveredFactories(); } @Test diff --git a/docs/api/api-surface.json b/docs/api/api-surface.json index 0693affac..3483a14e4 100644 --- a/docs/api/api-surface.json +++ b/docs/api/api-surface.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ApiSurfaceContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ApiSurfaceContractTest -Dapi.surface.update=true test", "source" : "springdoc /v3/api-docs, generated from Spring's handler mappings", - "count" : 381, + "count" : 382, "deprecatedCount" : 3, "operations" : [ "DELETE /api/attachments/{id}", @@ -163,6 +163,7 @@ "GET /api/tickets/{ticketId}/import-request/brands", "GET /api/tickets/{ticketId}/import-request/pages", "GET /api/tickets/{ticketId}/import-requests", + "GET /api/tickets/{ticketId}/import-requests/coverage", "GET /api/tickets/{ticketId}/pricing-requests", "GET /api/tickets/{ticketId}/remaining-invoice/options", "GET /api/tickets/{ticketId}/remaining-invoices", diff --git a/docs/api/ui-reachable.json b/docs/api/ui-reachable.json index 48627e533..38f193f22 100644 --- a/docs/api/ui-reachable.json +++ b/docs/api/ui-reachable.json @@ -1,7 +1,7 @@ { "$comment": "GENERATED by src/api/serverContract.test.js — do not hand-edit. Regenerate: cd frontend && UI_REACHABLE_UPDATE=1 npx vitest run src/api/serverContract.test.js", "source": "endpoints reachable from a component call site (api..( outside src/api/ and outside tests), resolved through hrApi by driving every method", - "count": 318, + "count": 319, "operations": [ "DELETE /api/attachments/{id}", "DELETE /api/catalog/prices/{priceId}", @@ -136,6 +136,7 @@ "GET /api/tickets/{ticketId}/import-request/brands", "GET /api/tickets/{ticketId}/import-request/pages", "GET /api/tickets/{ticketId}/import-requests", + "GET /api/tickets/{ticketId}/import-requests/coverage", "GET /api/tickets/{ticketId}/pricing-requests", "GET /api/tickets/{ticketId}/remaining-invoice/options", "GET /api/tickets/{ticketId}/remaining-invoices", diff --git a/frontend/src/api/hrApi.js b/frontend/src/api/hrApi.js index 2a8ef2e78..933604d1e 100644 --- a/frontend/src/api/hrApi.js +++ b/frontend/src/api/hrApi.js @@ -580,6 +580,8 @@ export const api = { createDrafts: (ticketId, payload) => apiRequest(API_ROUTES.storedImportRequests.forTicket(ticketId), { method: 'POST', body: payload }), listForTicket: (ticketId) => apiRequest(API_ROUTES.storedImportRequests.forTicket(ticketId)), + // `{ hasUncoveredFactories }` — read-only; kept off the list so the import pages do not pay for it. + coverage: (ticketId) => apiRequest(API_ROUTES.storedImportRequests.coverage(ticketId)), get: (id) => apiRequest(API_ROUTES.storedImportRequests.get(id)), // PATCH: an absent field is left alone, never blanked — see ImportRequestRequests. // UpdateImportRequestRequest's own Javadoc. diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index b95b8373d..a122557d3 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -12433,6 +12433,11 @@ export const api = { return delay({ importRequests: mockImportRequestsForTicket(ticket.id) }); }, async listForTicket(ticketId) { + const ticket = findTicketRaw(Number(ticketId)); + requireStoredIrRead(ticket, requireSession()); + return delay({ importRequests: mockImportRequestsForTicket(ticket.id) }); + }, + async coverage(ticketId) { const ticket = findTicketRaw(Number(ticketId)); requireStoredIrRead(ticket, requireSession()); // Mirrors ImportRequestService#hasUncoveredFactories: read-only; an unresolved/unknown factory @@ -12444,7 +12449,7 @@ export const api = { .map((r) => r.factoryId)); hasUncoveredFactories = mockResolveFactoryGroups(ticket, null).some((g) => !live.has(g.factoryId)); } catch { /* unresolved factory -> uncovered */ } - return delay({ importRequests: mockImportRequestsForTicket(ticket.id), hasUncoveredFactories }); + return delay({ hasUncoveredFactories }); }, async get(id) { const row = findStoredImportRequestRaw(id); diff --git a/frontend/src/api/routes.js b/frontend/src/api/routes.js index 9a9cb2f43..d93c47284 100644 --- a/frontend/src/api/routes.js +++ b/frontend/src/api/routes.js @@ -209,6 +209,7 @@ export const API_ROUTES = { // `importRequests` above, which stays the legacy read-only preview-per-brand family. storedImportRequests: { forTicket: (ticketId) => `/api/tickets/${ticketId}/import-requests`, + coverage: (ticketId) => `/api/tickets/${ticketId}/import-requests/coverage`, get: (id) => `/api/import-requests/${id}`, issue: (id) => `/api/import-requests/${id}/issue`, revise: (id) => `/api/import-requests/${id}/revise`, diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.jsx index 331ea0f07..81f114b22 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.jsx @@ -303,12 +303,12 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), enabled: !!ticketId && canReadStoredIr, }); - // Read-only server flag on the same list endpoint (a separate key: the rows query above is shared - // with other screens as a bare array). Only an explicit `false` hides the create controls — an older + // Read-only server flag from its own endpoint (the list stays rows-only for the import pages). Under the + // rows key's prefix, so invalidating the rows refreshes it too. Only an explicit `false` hides the create controls — an older // backend that omits it (undefined) keeps them. const coverageQuery = useQuery({ queryKey: [...queryKeys.storedImportRequests(ticketId), 'coverage'], - queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.hasUncoveredFactories), + queryFn: () => api.storedImportRequests.coverage(ticketId).then((r) => r.hasUncoveredFactories), enabled: !!ticketId && canReadStoredIr, }); const allFactoriesCovered = coverageQuery.data === false; @@ -779,8 +779,6 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ onClick={() => markGoodsReceivedMutation.mutate()} data-testid="deal-fulfilment-mark-goods-received"> รับสินค้าแล้ว - ) : fs == null ? ( -

ยังไม่ออกใบขอซื้อ

) : null}
diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx index 2db7648c9..5db18a42e 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx @@ -20,6 +20,7 @@ vi.mock('../../api/index.js', async (importOriginal) => { importRequests: { brands: vi.fn().mockResolvedValue({ brands: [] }), pages: vi.fn(), download: vi.fn() }, storedImportRequests: { listForTicket: vi.fn(), + coverage: vi.fn(), createDrafts: vi.fn(), update: vi.fn(), issue: vi.fn(), @@ -85,10 +86,11 @@ function renderPanel(user, rows = [draftRow()], { } = {}) { if (rows instanceof Error) api.storedImportRequests.listForTicket.mockRejectedValue(rows); else { - api.storedImportRequests.listForTicket.mockResolvedValue( - hasUncoveredFactories === undefined ? { importRequests: rows } : { importRequests: rows, hasUncoveredFactories }, - ); + api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: rows }); } + // The flag has its own endpoint; undefined = an older backend (no such route -> the call fails). + if (hasUncoveredFactories === undefined) api.storedImportRequests.coverage.mockRejectedValue(new Error('404')); + else api.storedImportRequests.coverage.mockResolvedValue({ hasUncoveredFactories }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); return render( From 61c7ebee5deb80cea607fd50d1afeddc88d58bf5 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:41:46 +0700 Subject: [PATCH 04/24] fix(deals): address code review of #1202 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The "รอ CEO ตรวจสอบปิดงาน" blocker shows only on an ACTIVE deal: the CEO can only verify an ACTIVE deal, so a cancelled/lost/paused deal with a close confirmation no longer reads as waiting for the CEO. - The การนำเข้า chip's tone follows the per-factory label: every factory at the warehouse reads success, a factory still on the way reads info. - The account backward-stage 409 now sits in updateStageInternal next to its own summary read, instead of loading the whole ticket a second time. Co-Authored-By: Claude Opus 5.5 --- .../java/th/co/glr/hr/ticket/TicketService.java | 10 +++++----- frontend/src/features/tickets/DealStateHeader.jsx | 6 ++++-- .../src/features/tickets/DealStateHeader.test.jsx | 7 +++++++ .../src/features/tickets/TicketDetailPage.jsx | 5 +++-- .../features/tickets/TicketDetailPage.test.jsx | 15 +++++++++------ 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index 55740082e..1499987a7 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -2043,11 +2043,6 @@ public TicketDto financeUpdateStage(long ticketId, String targetStage, String no throw new ApiException(HttpStatus.FORBIDDEN, "ไม่มีสิทธิ์เข้าถึงรายการนี้"); } requireAccountScope(ticketId, actor); - // QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD. - if ("account".equals(actor.role()) - && DealStage.indexOf(targetStage) < DealStage.indexOf(requireTicket(ticketId).summary().salesStage())) { - throw new ApiException(HttpStatus.CONFLICT, "ฝ่ายบัญชีเลื่อนขั้นได้เฉพาะไปข้างหน้า ย้อนขั้นไม่ได้"); - } return updateStageInternal(ticketId, targetStage, note, actor); } @@ -2056,6 +2051,11 @@ private TicketDto updateStageInternal(long ticketId, String targetStage, String throw new ApiException(HttpStatus.BAD_REQUEST, "ไม่รองรับสถานะขั้นตอนการขาย '" + targetStage + "'"); } TicketSummaryDto s = requireTicket(ticketId).summary(); + // QA-19 (owner ruling 2026-10-29): account only ever moves a money stage FORWARD. Account reaches this + // method only through financeUpdateStage (updateStage refuses it). + if ("account".equals(actor.role()) && DealStage.indexOf(targetStage) < DealStage.indexOf(s.salesStage())) { + throw new ApiException(HttpStatus.CONFLICT, "ฝ่ายบัญชีเลื่อนขั้นได้เฉพาะไปข้างหน้า ย้อนขั้นไม่ได้"); + } requireStageMoveAllowed(s, targetStage, actor); // ONE decision, two messages. This used to be two independent rules — a backward check // with an isRoutineBackwardMove exception bolted on, and a raw `indexOf(target) - diff --git a/frontend/src/features/tickets/DealStateHeader.jsx b/frontend/src/features/tickets/DealStateHeader.jsx index 4752e8fd6..600be3f0d 100644 --- a/frontend/src/features/tickets/DealStateHeader.jsx +++ b/frontend/src/features/tickets/DealStateHeader.jsx @@ -6,7 +6,7 @@ import { dealLifecycleLabel, dealStageLabel, formatMoney, formatThaiDate, fulfilmentStatusLabel, paymentStageLabel, pricingRequestStatusLabel, } from '../../utils/format.js'; -import { importChipLabel } from '../importProgress/importSteps.js'; +import { importChipLabel, importStepMeta } from '../importProgress/importSteps.js'; import { activePricingRequestsSummary } from '../pricingRequests/pricingRequestMeta.js'; import { visibleSections } from './salesViewScope.js'; @@ -162,7 +162,9 @@ export function DealStateHeader({ key: 'fulfilment', label: 'การนำเข้า', value: importChip ?? (fulfilment ? fulfilment.label : 'ยังไม่ออกใบขอซื้อ'), - tone: fulfilment ? fulfilment.tone : 'neutral', + // Every factory at the warehouse reads as done, like the GOODS_RECEIVED rollup it is about to become. + tone: importChip === importStepMeta('RECEIVED').label ? 'success' : importChip ? 'info' + : fulfilment ? fulfilment.tone : 'neutral', }, sections.payment && { key: 'dealValue', diff --git a/frontend/src/features/tickets/DealStateHeader.test.jsx b/frontend/src/features/tickets/DealStateHeader.test.jsx index 409881bea..379ba8f94 100644 --- a/frontend/src/features/tickets/DealStateHeader.test.jsx +++ b/frontend/src/features/tickets/DealStateHeader.test.jsx @@ -160,6 +160,13 @@ describe('DealStateHeader — การนำเข้า chip follows the per-f it('every tracked factory RECEIVED while the deal rollup is still IR_ISSUED -> ถึงโกดัง alone', () => { withIrs([ir('RECEIVED'), ir('RECEIVED')]); expect(importChipText()).toBe('ถึงโกดัง'); + // Arrived reads as done (success), like the GOODS_RECEIVED rollup it is about to become — not in-progress. + expect(screen.getByText('ถึงโกดัง', { selector: '.status-badge' }).className).toContain('status-success'); + }); + + it('a factory still on the way keeps the in-progress (info) tone', () => { + withIrs([ir('IN_TRANSIT'), ir('RECEIVED')]); + expect(screen.getByText('กำลังเดินทาง · ถึงโกดัง 1/2', { selector: '.status-badge' }).className).toContain('status-info'); }); it('a revised factory counts once: v1 SUPERSEDED at ORDERED + v2 ISSUED at RECEIVED -> ถึงโกดัง', () => { diff --git a/frontend/src/features/tickets/TicketDetailPage.jsx b/frontend/src/features/tickets/TicketDetailPage.jsx index 5d443a072..d4492b695 100644 --- a/frontend/src/features/tickets/TicketDetailPage.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.jsx @@ -1226,8 +1226,9 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { // recording/confirming a payment is account-only now (see isMoneyRecorder above), so the CEO no // longer clears either wait and must see the same read-only status a sales/import viewer does, // not silence. - // QA-27: once the CEO has verified (COMPLETED) there is nothing left to wait for. - const blocker = closeConfirmedAt && !can.verifyClose && summary?.lifecycle !== 'COMPLETED' + // QA-27: the CEO can only verify an ACTIVE deal (canVerifyClose), so on any other lifecycle — COMPLETED + // after the verify, or cancelled/lost/paused — there is no CEO step left to wait for. + const blocker = closeConfirmedAt && !can.verifyClose && (summary?.lifecycle ?? 'ACTIVE') === 'ACTIVE' ? 'รอ CEO ตรวจสอบปิดงาน' : ps === 'DEPOSIT_NOTICE_ISSUED' && !isMoneyRecorder ? 'รอชำระมัดจำ' : ps === 'AWAITING_FINAL_PAYMENT' && !isMoneyRecorder ? 'รอชำระส่วนที่เหลือ' diff --git a/frontend/src/features/tickets/TicketDetailPage.test.jsx b/frontend/src/features/tickets/TicketDetailPage.test.jsx index 0d82b30df..446191b79 100644 --- a/frontend/src/features/tickets/TicketDetailPage.test.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.test.jsx @@ -2722,12 +2722,15 @@ describe('TicketDetailPage', () => { expect(screen.queryByText('รอ CEO ตรวจสอบปิดงาน')).toBeNull(); }); - it('does not show to a non-CEO viewer once the deal is COMPLETED either', async () => { - closeConfirmedDeal('COMPLETED'); - renderTicketDetailPage(salesManagerUser); - await screen.findByRole('heading', { level: 1, name: 'บริษัท ทดสอบ จำกัด' }); - expect(screen.queryByText('รอ CEO ตรวจสอบปิดงาน')).toBeNull(); - }); + it.each(['COMPLETED', 'CANCELLED', 'CLOSED_LOST'])( + 'does not show to a non-CEO viewer once the deal is %s (the CEO can only verify an ACTIVE deal)', + async (lifecycle) => { + closeConfirmedDeal(lifecycle); + renderTicketDetailPage(salesManagerUser); + await screen.findByRole('heading', { level: 1, name: 'บริษัท ทดสอบ จำกัด' }); + expect(screen.queryByText('รอ CEO ตรวจสอบปิดงาน')).toBeNull(); + }, + ); }); // Deal tracking (V83, Slice B1/B2 "kill the weekly report" — handoff 103). From 8461ae320e1e203315aff8dec7411b917fb382b5 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:48:31 +0700 Subject: [PATCH 05/24] =?UTF-8?q?fix(deal):=20skip=20route=20reads=20true?= =?UTF-8?q?=20=E2=80=94=20=E0=B9=80=E0=B8=AA=E0=B8=99=E0=B8=AD=E0=B8=A3?= =?UTF-8?q?=E0=B8=B2=E0=B8=84=E0=B8=B2=20=E2=96=BE=20trigger,=20=E0=B8=82?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=A1=20on=20never-visited=20stages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 batch 3 from the skip walk, owner rulings 2026-10-29: - SK-03: the route-choice menu (2+ parties left) has the neutral trigger เสนอราคา instead of the first party's name; dashboard label unchanged. - SK-05: the stage track marks ข้าม on on-route stages below the current one that the deal never sat on (STAGE_CHANGED toStatus); the first change's fromStatus and earlier count as done. Phase headers count skips. - SK-06: skipped owner stage stays final (server gate unchanged); the แก้ไขสถานะ modal now words stages by the deal's channel. Co-Authored-By: Claude Opus 5.5 --- .../src/features/tickets/DealStagePanel.jsx | 3 + .../src/features/tickets/DealStageStepper.jsx | 77 ++++++++++++++----- .../tickets/DealStageStepper.test.jsx | 60 +++++++++++++++ .../src/features/tickets/TicketDetailPage.jsx | 8 +- .../tickets/TicketDetailPage.test.jsx | 31 +++++++- .../src/features/tickets/UpdateStageModal.jsx | 4 +- .../tickets/UpdateStageModal.test.jsx | 14 ++++ 7 files changed, 169 insertions(+), 28 deletions(-) diff --git a/frontend/src/features/tickets/DealStagePanel.jsx b/frontend/src/features/tickets/DealStagePanel.jsx index a9c3d1089..c48524de3 100644 --- a/frontend/src/features/tickets/DealStagePanel.jsx +++ b/frontend/src/features/tickets/DealStagePanel.jsx @@ -83,6 +83,8 @@ export const DealStagePanel = forwardRef(function DealStagePanel({ advanceReady = true, advanceInHeader = false, // Owner ruling 2026-10-27: the parties the sticky เสนอราคา menu offers, in route order. quoteParties = null, + // The ticket's events, oldest first — the stepper reads which stages the deal skipped from them. + events = [], onUpdateStage, onMarkLost, onReopen, onHold, onDormant, onResume, onSetTenderRequirement, onSetEntryChannel, }, ref) { @@ -431,6 +433,7 @@ export const DealStagePanel = forwardRef(function DealStagePanel({ salesStage={summary.salesStage} lost={lost} stageDecisions={stageDecisions} + events={events} entryChannel={summary.entryChannel} entryChannelAction={entryChannelAction} onSetEntryChannel={onSetEntryChannel} diff --git a/frontend/src/features/tickets/DealStageStepper.jsx b/frontend/src/features/tickets/DealStageStepper.jsx index 0eb7f0034..a4d2c1381 100644 --- a/frontend/src/features/tickets/DealStageStepper.jsx +++ b/frontend/src/features/tickets/DealStageStepper.jsx @@ -43,7 +43,7 @@ function phaseName(phaseId) { * ("ขั้นที่ 4 จาก 11") is PhaseSummary's job. State is carried by shape AND a Thai word (screen- * reader text), never by colour alone: done = tick, current = filled, upcoming = hollow. */ -function RouteRibbon({ path, currentIdx, catalog, lost }) { +function RouteRibbon({ path, currentIdx, catalog, lost, skipped }) { if (path.length === 0) return null; return (
@@ -57,7 +57,8 @@ function RouteRibbon({ path, currentIdx, catalog, lost }) { // here the question is "how far along am I", so position wins. const ordinal = position + 1; const idx = stageIndexIn(catalog, step.code); - const done = idx < currentIdx; + const skip = skipped.has(step.code); + const done = idx < currentIdx && !skip; const current = idx === currentIdx && !lost; return (
  • - + - {`ขั้นที่ ${ordinal} `}{done ? 'เสร็จแล้ว' : current ? 'ขั้นปัจจุบัน' : 'ยังไม่ถึง'} + {`ขั้นที่ ${ordinal} `}{skip ? 'ข้าม' : done ? 'เสร็จแล้ว' : current ? 'ขั้นปัจจุบัน' : 'ยังไม่ถึง'}
  • ); @@ -85,6 +88,27 @@ function RouteRibbon({ path, currentIdx, catalog, lost }) { ); } +/** + * SK-05 (owner ruling 2026-10-29): the codes of the stages BEHIND the deal that it never sat on. + * "Sat on" = every STAGE_CHANGED's toStatus (events are oldest first, as the server sends them). + * The first change's fromStatus and everything before it are pre-history and count as done; no + * STAGE_CHANGED at all means nothing is skipped. + */ +function skippedStageCodes(catalog, currentIdx, events) { + const changes = (events ?? []).filter((event) => event.kind === 'STAGE_CHANGED'); + if (changes.length === 0) return new Set(); + const visited = new Set(changes.map((event) => event.toStatus)); + const firstIdx = stageIndexIn(catalog, changes[0].fromStatus); + return new Set( + catalog.stages + .filter((step) => { + const idx = stageIndexIn(catalog, step.code); + return idx < currentIdx && idx > firstIdx && !visited.has(step.code); + }) + .map((step) => step.code), + ); +} + /** * The stages this deal's route does NOT visit, each with the server's own reason. Follows * UpdateStageModal's blocked list: an off-route stage is explained, not hidden and not silently @@ -152,9 +176,10 @@ function OffRouteGroup({ */ export function DealStageStepper({ catalog = EMPTY_STAGE_CATALOG, salesStage, lost = false, stageDecisions = [], entryChannel, - entryChannelAction, onSetEntryChannel, actionLoading = false, + entryChannelAction, onSetEntryChannel, actionLoading = false, events = [], }) { const currentIdx = stageIndexIn(catalog, salesStage); + const skipped = skippedStageCodes(catalog, currentIdx, events); const currentPhase = catalog.stages[currentIdx]?.phase ?? 1; const [open, setOpen] = useState(() => ({ [currentPhase]: true })); const path = routePath(catalog, stageDecisions); @@ -168,7 +193,7 @@ export function DealStageStepper({ return (
    - + {catalog.phases.map((phaseId) => { const phaseStages = stagesInPhase(catalog, phaseId); if (phaseStages.length === 0) return null; @@ -180,7 +205,9 @@ export function DealStageStepper({ if (steps.length === 0) return null; const isDone = currentIdx > lastIdx; const isCurrent = currentIdx >= firstIdx && currentIdx <= lastIdx; - const doneCount = steps.filter((step) => stageIndexIn(catalog, step.code) <= currentIdx).length; + const skipCount = steps.filter((step) => skipped.has(step.code)).length; + const doneCount = steps.filter((step) => stageIndexIn(catalog, step.code) <= currentIdx).length - skipCount; + const skipNote = skipCount > 0 ? ` · ข้าม ${skipCount}` : ''; const isOpen = !!open[phaseId]; return (
    @@ -218,7 +245,7 @@ export function DealStageStepper({ เฟส {phaseId} · {phaseName(phaseId)} - {isDone ? 'เสร็จแล้ว' : isCurrent ? `${doneCount}/${steps.length} ขั้นตอน` : `${steps.length} ขั้นตอน`} + {isDone ? 'เสร็จแล้ว' : isCurrent ? `${doneCount}/${steps.length} ขั้นตอน` : `${steps.length} ขั้นตอน`}{skipNote} @@ -229,22 +256,25 @@ export function DealStageStepper({
    {steps.map((step) => { const idx = stageIndexIn(catalog, step.code); - const stepDone = idx < currentIdx; + const stepSkipped = skipped.has(step.code); + const stepDone = idx < currentIdx && !stepSkipped; const stepCurrent = idx === currentIdx; const label = dealStageLabel(step.code, entryChannel); return ( -
    +
    - {stepDone ? : step.no} + {stepSkipped ? '–' : stepDone ? : step.no}
    @@ -255,6 +285,11 @@ export function DealStageStepper({ {GATE_LABEL[step.gate]} + {stepSkipped ? ( + + ข้าม + + ) : null}
    ); diff --git a/frontend/src/features/tickets/DealStageStepper.test.jsx b/frontend/src/features/tickets/DealStageStepper.test.jsx index 2ef16220f..1f7c008f7 100644 --- a/frontend/src/features/tickets/DealStageStepper.test.jsx +++ b/frontend/src/features/tickets/DealStageStepper.test.jsx @@ -438,3 +438,63 @@ describe('StageProgressBar — per-phase segments count only the deal\'s on-rout )).not.toThrow(); }); }); + +// SK-05 (owner ruling 2026-10-29): a stage the deal never sat on reads ข้าม, not เสร็จแล้ว. "Sat on" +// comes from the deal's STAGE_CHANGED events (fromStatus → toStatus, oldest first, as the server +// sends them); stages before the first recorded change are pre-history and count as done. +describe('DealStageStepper marks the stages a deal skipped', () => { + const stageChange = (id, from, to) => ({ + id, kind: 'STAGE_CHANGED', fromStatus: from, toStatus: to, createdAt: `2026-10-0${id}T09:00:00.000Z`, + }); + const pipFor = (code) => screen.getAllByTestId('route-pip').find((pip) => pip.getAttribute('data-stage') === code); + const rowFor = (code) => screen.getAllByTestId('stage-row').find((row) => row.getAttribute('data-stage') === code); + + it('an owner skip (4 → 8) reads ข้าม on 5, 6 and 7 in the ribbon, the phase headers and the rows', () => { + const events = [ + { id: 9, kind: 'SUBMITTED', fromStatus: null, toStatus: 'OWNER_SIGNOFF', createdAt: '2026-09-30T09:00:00.000Z' }, + stageChange(1, 'LEAD_APPROACH', 'PRESENTATION'), + stageChange(2, 'PRESENTATION', 'SPEC_APPROVED'), + stageChange(3, 'SPEC_APPROVED', 'QUOTE_DESIGN_SIDE'), + stageChange(4, 'QUOTE_DESIGN_SIDE', 'QUOTE_BUYER'), + ]; + render(); + + expect(within(pipFor('LEAD_APPROACH')).getByText('ขั้นที่ 1 เสร็จแล้ว')).not.toBeNull(); + expect(within(pipFor('QUOTE_DESIGN_SIDE')).getByText('ขั้นที่ 4 เสร็จแล้ว')).not.toBeNull(); + expect(within(pipFor('QUOTE_OWNER')).getByText('ขั้นที่ 5 ข้าม')).not.toBeNull(); + expect(within(pipFor('OWNER_SIGNOFF')).getByText('ขั้นที่ 6 ข้าม')).not.toBeNull(); + expect(within(pipFor('AWAITING_BUYER')).getByText('ขั้นที่ 7 ข้าม')).not.toBeNull(); + expect(within(pipFor('QUOTE_BUYER')).getByText('ขั้นที่ 8 ขั้นปัจจุบัน')).not.toBeNull(); + + // Phase 2 (S3–S6) is behind the deal: done, with its two skipped stages counted. Phase 3 (S7–S9) + // is current: S8 is the one step reached, S7 is skipped. + expect(screen.getByText('เสร็จแล้ว · ข้าม 2')).not.toBeNull(); + expect(screen.getByText('1/3 ขั้นตอน · ข้าม 1')).not.toBeNull(); + expect(rowFor('AWAITING_BUYER').textContent).toContain('ข้าม'); + expect(rowFor('QUOTE_BUYER').textContent).not.toContain('ข้าม'); + + fireEvent.click(screen.getByRole('button', { name: /เฟส 2/ })); + expect(rowFor('QUOTE_OWNER').textContent).toContain('ข้าม'); + expect(rowFor('OWNER_SIGNOFF').textContent).toContain('ข้าม'); + expect(rowFor('QUOTE_DESIGN_SIDE').textContent).not.toContain('ข้าม'); + }); + + it('stages before the first recorded change are done, and a stage reached by a backward move is done', () => { + const events = [ + stageChange(1, 'SPEC_APPROVED', 'QUOTE_DESIGN_SIDE'), + stageChange(2, 'QUOTE_DESIGN_SIDE', 'QUOTE_BUYER'), + stageChange(3, 'QUOTE_BUYER', 'OWNER_SIGNOFF'), + stageChange(4, 'OWNER_SIGNOFF', 'QUOTE_BUYER'), + ]; + render(); + + expect(within(pipFor('LEAD_APPROACH')).getByText('ขั้นที่ 1 เสร็จแล้ว')).not.toBeNull(); + expect(within(pipFor('PRESENTATION')).getByText('ขั้นที่ 2 เสร็จแล้ว')).not.toBeNull(); + expect(within(pipFor('SPEC_APPROVED')).getByText('ขั้นที่ 3 เสร็จแล้ว')).not.toBeNull(); + expect(within(pipFor('QUOTE_OWNER')).getByText('ขั้นที่ 5 ข้าม')).not.toBeNull(); + expect(within(pipFor('OWNER_SIGNOFF')).getByText('ขั้นที่ 6 เสร็จแล้ว')).not.toBeNull(); + expect(within(pipFor('AWAITING_BUYER')).getByText('ขั้นที่ 7 ข้าม')).not.toBeNull(); + }); +}); diff --git a/frontend/src/features/tickets/TicketDetailPage.jsx b/frontend/src/features/tickets/TicketDetailPage.jsx index d4492b695..e8dcfbd5c 100644 --- a/frontend/src/features/tickets/TicketDetailPage.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.jsx @@ -1418,7 +1418,8 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { } else if (actionKey === 'quote_next_party') { // Owner ruling 2026-10-27: "เสนอราคา" once a designer/owner quotation is sent. More // than one party left: a menu, later parties reading "ข้ามไปเสนอราคา"; one left: a plain - // button. Each opens the price-request form with ผู้รับ pre-filled. + // button. Each opens the price-request form with ผู้รับ pre-filled. SK-03 (2026-10-29): the menu + // trigger reads the neutral "เสนอราคา" (it no longer names just the first party). const parties = workStateAction.parties; const openFor = (party) => runOnTab('items', () => pricingRequestPanelRef.current?.openCreate({ recipientType: party })); stickyPrimaryAction = parties.length > 1 ? ( @@ -1429,8 +1430,8 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { testId: `quote-party-${party}`, onSelect: () => openFor(party), }))} - label={workStateAction.label} - triggerLabel={<>{workStateAction.label}} + label="เสนอราคา" + triggerLabel={<>เสนอราคา} triggerIcon={null} triggerClassName={buttonVariants({ variant: 'primary' })} triggerProps={{ 'data-testid': 'ticket-primary-action', 'data-action': actionKey }} @@ -1920,6 +1921,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { availableActions={availableActions} stageDecisions={stageDecisions} catalog={stageCatalog} + events={ticket.events} quoteParties={workStateAction?.key === 'quote_next_party' ? workStateAction.parties : null} // primaryAction now lives solely in DealStateHeader above (Phase 2 Slice S2's // "one primary CTA" — see its own doc comment) — not passed here too, to avoid diff --git a/frontend/src/features/tickets/TicketDetailPage.test.jsx b/frontend/src/features/tickets/TicketDetailPage.test.jsx index 446191b79..c7a99653f 100644 --- a/frontend/src/features/tickets/TicketDetailPage.test.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.test.jsx @@ -2307,12 +2307,14 @@ describe('TicketDetailPage', () => { // Later describes in this file read listForTicket bare (a rejected query) — keep it that way. afterEach(() => { api.dealQuotations.listForTicket.mockReset(); }); - it('a sent designer quotation: the primary เสนอราคาเจ้าของโครงการ is a menu (owner, then ข้ามไปเสนอราคาผู้ซื้อ) beside "ใบเสนอราคาผู้ออกแบบส่งแล้ว…"', async () => { + // SK-03 (owner ruling 2026-10-29): the menu's trigger is the neutral เสนอราคา ▾, not the first party. + it('a sent designer quotation: the primary เสนอราคา is a menu (owner, then ข้ามไปเสนอราคาผู้ซื้อ) beside "ใบเสนอราคาผู้ออกแบบส่งแล้ว…"', async () => { deal(); renderTicketDetailPage(salesOwnerUser); const primary = await primaryWithAction('quote_next_party'); - expect(primary.textContent).toBe('เสนอราคาเจ้าของโครงการ'); + expect(primary.textContent).toBe('เสนอราคา'); + expect(primary.getAttribute('aria-label')).toBe('เสนอราคา'); expect(primary.getAttribute('aria-haspopup')).toBe('menu'); expect(actionBarOf(primary).textContent).toContain('ใบเสนอราคาผู้ออกแบบส่งแล้ว เลือกเสนอราคาเจ้าของโครงการ หรือข้ามไปผู้ซื้อ'); @@ -5088,6 +5090,31 @@ describe('TicketDetailPage', () => { }); }); + // SK-05: the page hands the deal's own events to the stage track, so an owner skip reads ข้าม. + it('ดูขั้นตอนทั้งหมด marks the owner stages ข้าม on a deal that skipped from 4 to 8', async () => { + api.tickets.get.mockResolvedValue({ + ticket: buildTicket({ + summary: { salesStage: 'QUOTE_BUYER', entryChannel: 'DESIGNER_LED', nextFollowUpAt: '2026-07-15' }, + events: [ + { id: 1, kind: 'STAGE_CHANGED', fromStatus: 'SPEC_APPROVED', toStatus: 'QUOTE_DESIGN_SIDE', createdAt: '2026-07-01T09:00:00.000Z' }, + { id: 2, kind: 'STAGE_CHANGED', fromStatus: 'QUOTE_DESIGN_SIDE', toStatus: 'QUOTE_BUYER', createdAt: '2026-07-02T09:00:00.000Z' }, + ], + }), + }); + api.tickets.actions.mockResolvedValue({ + currentState: { lifecycle: 'ACTIVE', salesStage: 'QUOTE_BUYER', paymentStatus: null, fulfillmentStatus: null, status: 'price_proposed' }, + availableActions: [{ action: 'UPDATE_STAGE' }], + stageDecisions: DEAL_STAGE_CATALOG.stages.map((stage) => ({ + stage: stage.code, no: stage.no, allowed: true, requiresReason: false, blockedReason: null, onRoute: true, + })), + }); + api.tickets.listActivities.mockResolvedValue({ items: [] }); + renderTicketDetailPage(ceoUser); + fireEvent.click(await screen.findByRole('button', { name: /ดูขั้นตอนทั้งหมด/ })); + const pip = (await screen.findAllByTestId('route-pip')).find((el) => el.getAttribute('data-stage') === 'QUOTE_OWNER'); + expect(within(pip).getByText('ขั้นที่ 5 ข้าม')).not.toBeNull(); + }); + // The gate refuses an off-route stage with "… — แก้ช่องทางดีลก่อน", and the control that answered it // (EntryChannelControl) was deleted by GLA-156. EntryChannelFix puts it back where the refusal is // read. Driven end to end here so the WIRING is pinned, not just the pieces: the advertisement diff --git a/frontend/src/features/tickets/UpdateStageModal.jsx b/frontend/src/features/tickets/UpdateStageModal.jsx index 6c8e79b77..d99040150 100644 --- a/frontend/src/features/tickets/UpdateStageModal.jsx +++ b/frontend/src/features/tickets/UpdateStageModal.jsx @@ -81,7 +81,7 @@ export function UpdateStageModal({ @@ -132,7 +132,7 @@ export function UpdateStageModal({ - {dealStageLabel(decision.stage).label} + {dealStageLabel(decision.stage, deal?.entryChannel).label} {/* The server's own refusal message, verbatim — so the tooltip a rep reads before clicking is exactly the error they would have got by clicking. */} diff --git a/frontend/src/features/tickets/UpdateStageModal.test.jsx b/frontend/src/features/tickets/UpdateStageModal.test.jsx index a69dfa5d9..8be3ec38e 100644 --- a/frontend/src/features/tickets/UpdateStageModal.test.jsx +++ b/frontend/src/features/tickets/UpdateStageModal.test.jsx @@ -116,6 +116,20 @@ describe('UpdateStageModal renders the backend decision', () => { renderModal(); expect(screen.getByRole('option', { name: /เสนอราคาเจ้าของโครงการ/ })).toBeTruthy(); }); + + // SK-06 walk: S1 read 'เข้าถึงเจ้าของ/ผู้ออกแบบโครงการ' here but 'เข้าถึงผู้ออกแบบโครงการ' on the deal page. + it('words the stages by the deal\'s channel, in the options and the blocked list alike', () => { + renderModal({ + deal: { salesStage: 'NEGOTIATION', entryChannel: 'OWNER_DIRECT' }, + stageDecisions: [ + decision('LEAD_APPROACH', 1), + decision('SPEC_APPROVED', 3, { allowed: false, blockedReason: 'ไม่มีสิทธิ์เข้าถึงรายการนี้' }), + ], + }); + expect(screen.getByRole('option', { name: '1. เข้าถึงเจ้าของโครงการ' })).toBeTruthy(); + fireEvent.click(screen.getByTestId('update-stage-blocked-toggle')); + expect(screen.getByText('เจ้าของตกลงตามสเปคแล้ว')).toBeTruthy(); + }); }); /** From 8871ac2a069a0fbe842d36a7df4711f109bff480 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 21:29:00 +0700 Subject: [PATCH 06/24] =?UTF-8?q?fix(deal):=20whole-skipped=20phase=20read?= =?UTF-8?q?s=20=E0=B8=82=E0=B9=89=E0=B8=B2=E0=B8=A1;=20unknown=20first=20f?= =?UTF-8?q?rom-stage=20skips=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #1206: a done phase whose on-route stages were all skipped no longer shows the done tick; a first STAGE_CHANGED whose fromStatus is outside the catalog counts the history as done; the skipped-stage filter uses its own index. Co-Authored-By: Claude Opus 5.5 --- .../src/features/tickets/DealStageStepper.jsx | 17 +++++++++-------- .../features/tickets/DealStageStepper.test.jsx | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/frontend/src/features/tickets/DealStageStepper.jsx b/frontend/src/features/tickets/DealStageStepper.jsx index a4d2c1381..ce9a10953 100644 --- a/frontend/src/features/tickets/DealStageStepper.jsx +++ b/frontend/src/features/tickets/DealStageStepper.jsx @@ -92,19 +92,17 @@ function RouteRibbon({ path, currentIdx, catalog, lost, skipped }) { * SK-05 (owner ruling 2026-10-29): the codes of the stages BEHIND the deal that it never sat on. * "Sat on" = every STAGE_CHANGED's toStatus (events are oldest first, as the server sends them). * The first change's fromStatus and everything before it are pre-history and count as done; no - * STAGE_CHANGED at all means nothing is skipped. + * STAGE_CHANGED at all, or a first fromStatus outside the catalog, means nothing is skipped. */ function skippedStageCodes(catalog, currentIdx, events) { const changes = (events ?? []).filter((event) => event.kind === 'STAGE_CHANGED'); if (changes.length === 0) return new Set(); const visited = new Set(changes.map((event) => event.toStatus)); const firstIdx = stageIndexIn(catalog, changes[0].fromStatus); + if (firstIdx < 0) return new Set(); return new Set( catalog.stages - .filter((step) => { - const idx = stageIndexIn(catalog, step.code); - return idx < currentIdx && idx > firstIdx && !visited.has(step.code); - }) + .filter((step, idx) => idx < currentIdx && idx > firstIdx && !visited.has(step.code)) .map((step) => step.code), ); } @@ -208,6 +206,7 @@ export function DealStageStepper({ const skipCount = steps.filter((step) => skipped.has(step.code)).length; const doneCount = steps.filter((step) => stageIndexIn(catalog, step.code) <= currentIdx).length - skipCount; const skipNote = skipCount > 0 ? ` · ข้าม ${skipCount}` : ''; + const allSkipped = isDone && skipCount === steps.length; const isOpen = !!open[phaseId]; return (
    @@ -219,14 +218,14 @@ export function DealStageStepper({ > - {isDone ? : phaseId} + {allSkipped ? '–' : isDone ? : phaseId} {/* WCAG AA fix (fix/ui-contrast-tokens): a PHASE_TEXT map used to apply @@ -245,7 +244,9 @@ export function DealStageStepper({ เฟส {phaseId} · {phaseName(phaseId)} - {isDone ? 'เสร็จแล้ว' : isCurrent ? `${doneCount}/${steps.length} ขั้นตอน` : `${steps.length} ขั้นตอน`}{skipNote} + {allSkipped + ? `ข้าม ${skipCount}` + : <>{isDone ? 'เสร็จแล้ว' : isCurrent ? `${doneCount}/${steps.length} ขั้นตอน` : `${steps.length} ขั้นตอน`}{skipNote}} diff --git a/frontend/src/features/tickets/DealStageStepper.test.jsx b/frontend/src/features/tickets/DealStageStepper.test.jsx index 1f7c008f7..a16f4ad21 100644 --- a/frontend/src/features/tickets/DealStageStepper.test.jsx +++ b/frontend/src/features/tickets/DealStageStepper.test.jsx @@ -497,4 +497,22 @@ describe('DealStageStepper marks the stages a deal skipped', () => { expect(within(pipFor('OWNER_SIGNOFF')).getByText('ขั้นที่ 6 เสร็จแล้ว')).not.toBeNull(); expect(within(pipFor('AWAITING_BUYER')).getByText('ขั้นที่ 7 ข้าม')).not.toBeNull(); }); + + it('a phase the deal skipped whole (4 → 10) reads ข้าม, not เสร็จแล้ว', () => { + const events = [stageChange(1, 'SPEC_APPROVED', 'QUOTE_DESIGN_SIDE'), stageChange(2, 'QUOTE_DESIGN_SIDE', 'ORDER_RECEIVED')]; + render(); + const phase3 = screen.getByRole('button', { name: /เฟส 3/ }); + expect(phase3.textContent).toContain('ข้าม 3'); + expect(phase3.textContent).not.toContain('เสร็จแล้ว'); + expect(screen.getByRole('button', { name: /เฟส 2/ }).textContent).toContain('เสร็จแล้ว · ข้าม 2'); + }); + + it('a first change with no known from-stage marks nothing ข้าม (history unknown = done)', () => { + const events = [stageChange(1, null, 'QUOTE_DESIGN_SIDE'), stageChange(2, 'QUOTE_DESIGN_SIDE', 'QUOTE_BUYER')]; + render(); + expect(within(pipFor('LEAD_APPROACH')).getByText('ขั้นที่ 1 เสร็จแล้ว')).not.toBeNull(); + expect(screen.queryByText(/ข้าม/)).toBeNull(); + }); }); From 6ac0094391d5fe496df5b1d91188757edd0de5cb Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:50:22 +0700 Subject: [PATCH 07/24] fix(dashboard): money tiles match finance (QA-17, QA-18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rulings 2026-10-29: - Account รอรับมัดจำ = deposit due (latest ISSUED deposit notice total_payable, new TicketSummaryDto.depositDue, hidden from import). - ค้างรับ = /finance คงค้างรวม on account, CEO and SM (shared dashboardMoney.js). - Account รอชำระส่วนที่เหลือ counts the deals the to-do offers that step for. - CEO + SM show เงินรับแล้ว (เดือนนี้) (receipts in the Bangkok month, new cashReceivedThisMonth on the dashboard summary for account/ceo/sales_manager), ค้างรับ and ยอดคาดการณ์ (ACTIVE deals before ขั้น 10). Old month-sales, closed-count and pipeline tiles removed. - CEO ยอดตามทีม/ฝ่าย = this month's cash per rep (cashReceivedByRep). - Paid-sum sign rule shared via TicketRepository.SIGNED_RECEIPT_SUM. Co-Authored-By: Claude Opus 5.5 --- .../th/co/glr/hr/dashboard/CashByRepDto.java | 6 + .../glr/hr/dashboard/DashboardRepository.java | 38 ++++++ .../co/glr/hr/dashboard/DashboardService.java | 11 +- .../glr/hr/dashboard/DashboardSummaryDto.java | 49 +++++++- .../th/co/glr/hr/ticket/TicketRepository.java | 25 +++- .../th/co/glr/hr/ticket/TicketService.java | 2 +- .../th/co/glr/hr/ticket/TicketSummaryDto.java | 46 +++++++- .../DashboardRepositoryIntegrationTest.java | 83 ++++++++++++++ .../hr/dashboard/DashboardServiceTest.java | 49 ++++++++ .../TicketDepositDueIntegrationTest.java | 108 ++++++++++++++++++ docs/api/response-fields.json | 7 +- frontend/src/App.jsx | 4 +- frontend/src/api/mockApi.js | 44 ++++++- .../features/dashboard/AccountOverview.jsx | 36 ++---- .../dashboard/AccountOverview.test.jsx | 81 ++++++++++++- .../src/features/dashboard/CeoOverview.jsx | 68 ++++------- .../features/dashboard/CeoOverview.test.jsx | 73 +++++++++--- .../features/dashboard/ManagerOverview.jsx | 35 +++--- .../dashboard/ManagerOverview.test.jsx | 32 ++++-- .../src/features/dashboard/dashboardMoney.js | 31 +++++ .../features/dashboard/dashboardMoney.test.js | 78 +++++++++++++ .../src/features/tickets/accountActions.js | 9 +- frontend/src/test/fixtures/dashboardMoney.js | 64 +++++++++++ 23 files changed, 845 insertions(+), 134 deletions(-) create mode 100644 backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java create mode 100644 backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java create mode 100644 frontend/src/features/dashboard/dashboardMoney.js create mode 100644 frontend/src/features/dashboard/dashboardMoney.test.js create mode 100644 frontend/src/test/fixtures/dashboardMoney.js diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java b/backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java new file mode 100644 index 000000000..de86656d5 --- /dev/null +++ b/backend/src/main/java/th/co/glr/hr/dashboard/CashByRepDto.java @@ -0,0 +1,6 @@ +package th.co.glr.hr.dashboard; + +import java.math.BigDecimal; + +/** One sales rep's net cash received this Bangkok month. */ +public record CashByRepDto(String repName, BigDecimal amount) {} diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java index 36e2e5ffe..e44f3830b 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java @@ -186,6 +186,44 @@ WHERE status NOT IN ('closed','cancelled','draft') return summary == null ? TicketSummaryDto.empty(scope.label()) : summary; } + private static final java.time.ZoneId BANGKOK = java.time.ZoneId.of("Asia/Bangkok"); + /** The Bangkok month window on receipts, shared by every "cash received this month" figure. */ + private static final String RECEIVED_IN_MONTH = "received_at >= :from AND received_at < :to"; + + private static MapSqlParameterSource monthWindow(LocalDate monthStart) { + return new MapSqlParameterSource() + .addValue("from", monthStart.atStartOfDay(BANGKOK).toOffsetDateTime()) + .addValue("to", monthStart.plusMonths(1).atStartOfDay(BANGKOK).toOffsetDateTime()); + } + + /** + * Cash received in the Bangkok calendar month starting {@code monthStart}: payment receipts with + * {@code received_at} in [monthStart, next month start), signed by {@link TicketRepository#SIGNED_RECEIPT_SUM}. + */ + public java.math.BigDecimal cashReceived(LocalDate monthStart) { + return jdbc.queryForObject("SELECT " + TicketRepository.SIGNED_RECEIPT_SUM + + " FROM sales.payment_receipt WHERE " + RECEIVED_IN_MONTH, + monthWindow(monthStart), java.math.BigDecimal.class); + } + + /** + * {@link #cashReceived}, split by the creator of the deal each receipt belongs to (same window, same + * sign rule), so the rows always add up to it. Grouped by creator id, labelled with the creator's name + * ({@code ไม่ระบุผู้ขาย} when blank), so two reps with the same name stay separate. + */ + public java.util.List cashReceivedByRep(LocalDate monthStart) { + return jdbc.query( + "SELECT COALESCE(NULLIF(TRIM(CONCAT_WS(' ', e.first_name_th, e.last_name_th)), ''), 'ไม่ระบุผู้ขาย') AS rep_name, " + + "SUM(x.amount) AS amount " + + "FROM (SELECT ticket_id, " + TicketRepository.SIGNED_RECEIPT_SUM + " AS amount " + + "FROM sales.payment_receipt WHERE " + RECEIVED_IN_MONTH + " GROUP BY ticket_id) x " + + "JOIN sales.ticket t ON t.ticket_id = x.ticket_id " + + "LEFT JOIN hr.employee e ON e.employee_id = t.created_by " + + "GROUP BY t.created_by, e.first_name_th, e.last_name_th", + monthWindow(monthStart), + (rs, n) -> new CashByRepDto(rs.getString("rep_name"), rs.getBigDecimal("amount"))); + } + public NotificationSummaryDto notifications(long employeeId) { NotificationSummaryDto summary = jdbc.queryForObject(""" SELECT COUNT(*) FILTER (WHERE is_read = FALSE) AS unread, diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java index a251dc95a..a9472046c 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java @@ -1,9 +1,11 @@ package th.co.glr.hr.dashboard; +import java.math.BigDecimal; import java.time.Clock; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.util.List; import java.util.Set; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -17,6 +19,7 @@ public class DashboardService { private static final Set COMMISSION_APPROVER_ROLES = Set.of("sales_manager", "ceo"); private static final Set TICKET_VIEW_ALL_ROLES = Set.of("import", "ceo"); private static final Set TICKET_OWN_ROLES = Set.of("sales"); + private static final Set CASH_RECEIVED_ROLES = Set.of("account", "ceo", "sales_manager"); private final DashboardRepository dashboardRepository; private final Clock clock; @@ -53,6 +56,10 @@ public DashboardSummaryDto summary(UserPrincipal user) { ? null : dashboardRepository.latestPayrollPeriodId(user.employeeId()).orElse(null); + boolean seesCash = CASH_RECEIVED_ROLES.contains(user.role()); + BigDecimal cashReceived = seesCash ? dashboardRepository.cashReceived(monthStart) : null; + List cashByRep = seesCash ? dashboardRepository.cashReceivedByRep(monthStart) : null; + return DashboardSummaryDto.of( user.role(), user.employeeId(), @@ -64,7 +71,9 @@ public DashboardSummaryDto summary(UserPrincipal user) { dashboardRepository.attendance(attendanceScope, today, monthStart), latestPayrollPeriodId, tickets, - notifications + notifications, + cashReceived, + cashByRep ); } diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java index 73e151a89..90cdf3f63 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java @@ -1,7 +1,9 @@ package th.co.glr.hr.dashboard; import com.fasterxml.jackson.annotation.JsonFormat; +import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.List; public record DashboardSummaryDto( String role, @@ -28,7 +30,11 @@ public record DashboardSummaryDto( long onHold, long dormant, long paymentOverdue, - long partiallyDelivered + long partiallyDelivered, + /** Payment receipts dated this Bangkok month (deposit + balance - adjustment); null unless account/ceo/sales_manager. */ + BigDecimal cashReceivedThisMonth, + /** This month's net cash split per deal creator (sums to cashReceivedThisMonth); null unless account/ceo/sales_manager. */ + List cashReceivedByRep ) { public static DashboardSummaryDto of( String role, @@ -42,6 +48,43 @@ public static DashboardSummaryDto of( Long latestPayrollPeriodId, TicketSummaryDto tickets, NotificationSummaryDto notifications + ) { + return of(role, employeeId, divisionId, manager, generatedAt, headcount, pendingApprovals, + attendance, latestPayrollPeriodId, tickets, notifications, null, null); + } + + public static DashboardSummaryDto of( + String role, + Long employeeId, + Long divisionId, + boolean manager, + OffsetDateTime generatedAt, + HeadcountSummaryDto headcount, + PendingApprovalsSummaryDto pendingApprovals, + AttendanceSummaryDto attendance, + Long latestPayrollPeriodId, + TicketSummaryDto tickets, + NotificationSummaryDto notifications, + BigDecimal cashReceivedThisMonth + ) { + return of(role, employeeId, divisionId, manager, generatedAt, headcount, pendingApprovals, + attendance, latestPayrollPeriodId, tickets, notifications, cashReceivedThisMonth, null); + } + + public static DashboardSummaryDto of( + String role, + Long employeeId, + Long divisionId, + boolean manager, + OffsetDateTime generatedAt, + HeadcountSummaryDto headcount, + PendingApprovalsSummaryDto pendingApprovals, + AttendanceSummaryDto attendance, + Long latestPayrollPeriodId, + TicketSummaryDto tickets, + NotificationSummaryDto notifications, + BigDecimal cashReceivedThisMonth, + List cashReceivedByRep ) { return new DashboardSummaryDto( role, @@ -67,7 +110,9 @@ public static DashboardSummaryDto of( tickets.onHold(), tickets.dormant(), tickets.paymentOverdue(), - tickets.partiallyDelivered() + tickets.partiallyDelivered(), + cashReceivedThisMonth, + cashReceivedByRep ); } } diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index 685da603c..cdb789a07 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java @@ -1247,7 +1247,8 @@ private TicketSummaryDto enrichSummary(TicketSummaryDto s) { facts.commissionRecorded(), s.reopenedAt(), s.reopenCount(), s.quotationOnly(), due == null ? null : due.date(), due == null ? null : due.basis(), due == null ? null : due.creditDays(), - facts.liveDirectQuotation(), facts.quotedRecipients()); + facts.liveDirectQuotation(), facts.quotedRecipients(), + "DEPOSIT_NOTICE_ISSUED".equals(s.paymentStatus()) ? facts.depositDue() : null); } /** @@ -1286,7 +1287,8 @@ AND status NOT IN ('VOID', 'REJECTED') private record CommissionAndLiveQuotation(boolean commissionRecorded, LiveDirectQuotationDto liveDirectQuotation, - Set quotedRecipients) {} + Set quotedRecipients, + BigDecimal depositDue) {} /** A Postgres {@code text[]} as a {@link Set}; empty for SQL NULL, which is what * {@code array_agg} returns over no rows. */ @@ -1316,7 +1318,10 @@ private CommissionAndLiveQuotation recordedCommissionAndLiveDirectQuotation(long (SELECT array_agg(DISTINCT r.recipient_type) FROM sales.quotation r WHERE r.ticket_id = :ticketId - AND r.doc_status <> 'CANCELLED') AS quoted_recipients + AND r.doc_status <> 'CANCELLED') AS quoted_recipients, + (SELECT d.total_payable FROM sales.deposit_notice d + WHERE d.ticket_id = :ticketId AND d.status = 'ISSUED' + ORDER BY d.version DESC, d.deposit_notice_id DESC LIMIT 1) AS deposit_due FROM (SELECT 1) AS one LEFT JOIN LATERAL ( """ + LIVE_DIRECT_QUOTATION_SELECT + """ @@ -1328,7 +1333,7 @@ LEFT JOIN LATERAL ( : new LiveDirectQuotationDto(quotationId, rs.getString("number"), rs.getString("doc_status"), rs.getString("recipient_type")); return new CommissionAndLiveQuotation(rs.getBoolean("commission_recorded"), live, - readTextArray(rs, "quoted_recipients")); + readTextArray(rs, "quoted_recipients"), rs.getBigDecimal("deposit_due")); }); } @@ -1908,12 +1913,20 @@ public Optional paymentDue(long ticketId) { /** Full "amount paid" SELECT statement — see {@link #payableAmountSelect} for the correlation contract. */ private static String paidAmountSelect(String ticketIdRef) { return """ - SELECT COALESCE(SUM(CASE WHEN kind = 'ADJUSTMENT' THEN -amount ELSE amount END), 0) + SELECT %2$s FROM sales.payment_receipt WHERE ticket_id = %1$s - """.formatted(ticketIdRef); + """.formatted(ticketIdRef, SIGNED_RECEIPT_SUM); } + /** + * The one sign rule for money received: DEPOSIT and BALANCE add, ADJUSTMENT (refund / credit note) + * subtracts. Shared by the per-deal paid amount and the dashboard's company-wide cash received so + * the two can never count a receipt differently. + */ + public static final String SIGNED_RECEIPT_SUM = + "COALESCE(SUM(CASE WHEN kind = 'ADJUSTMENT' THEN -amount ELSE amount END), 0)"; + public record DepositNoticePaymentInfo(long id, BigDecimal depositAmount, BigDecimal totalPayable) {} public Optional latestIssuedDepositNotice(long ticketId) { diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index 1499987a7..0b73210ce 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -753,7 +753,7 @@ private TicketSummaryDto withCustomerAndProject(TicketSummaryDto s, String custo s.winProbabilityOverride(), s.designerName(), s.ownerName(), s.buyerName(), s.stale(), s.commissionRecorded(), s.reopenedAt(), s.reopenCount(), s.quotationOnly(), s.paymentDueDate(), s.paymentDueBasis(), s.paymentDueCreditDays(), - s.liveDirectQuotation(), s.quotedRecipients()); + s.liveDirectQuotation(), s.quotedRecipients(), s.depositDue()); } /** diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java index b186debde..db25108e3 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketSummaryDto.java @@ -154,8 +154,50 @@ public record TicketSummaryDto( * in {@code mockApi.js}'s ticket summary at the same time. */ @JsonIgnore - Set quotedRecipients + Set quotedRecipients, + /** + * What the customer is asked to pay NOW as the deposit: the VAT-inclusive {@code total_payable} + * of the newest ISSUED deposit notice, while the deal is waiting on it + * ({@code paymentStatus = DEPOSIT_NOTICE_ISSUED}); {@code null} otherwise. Account's รอรับมัดจำ + * tile shows this rather than the whole-deal {@link #amountOutstanding}. Filled by + * {@code TicketRepository#enrichSummary} in the same statement as {@link #commissionRecorded}; + * nulled by {@link #withoutMoney()} (it is customer pricing). + */ + BigDecimal depositDue ) { + /** The shape before {@link #depositDue}: it defaults to {@code null}. */ + public TicketSummaryDto( + long id, String code, String type, String title, String status, String priority, + long createdById, String createdByName, Long assignedToId, String assignedToName, + String customerName, Long customerId, Long projectId, String projectName, + Long contactId, String contactName, String note, + Instant createdAt, Instant updatedAt, Instant closedAt, int itemCount, boolean hasEdits, + String paymentStatus, String fulfillmentStatus, + String salesStage, String lostReason, Instant lostAt, Instant stageUpdatedAt, + String lifecycle, String tenderRequirement, String depositPolicy, String depositPolicyReason, + String entryChannel, LocalDate billingDate, LocalDate dueDate, Integer creditTermDays, + LocalDate lastFollowUpAt, LocalDate nextFollowUpAt, String paymentStage, + BigDecimal amountPayable, BigDecimal amountPaid, BigDecimal amountOutstanding, boolean overdue, + Instant closeConfirmedAt, String closeConfirmedByName, boolean invoiceOnFile, + String cancelReason, Instant cancelledAt, + Integer winProbabilityOverride, String designerName, String ownerName, String buyerName, + boolean stale, boolean commissionRecorded, Instant reopenedAt, int reopenCount, + boolean quotationOnly, LocalDate paymentDueDate, String paymentDueBasis, + Integer paymentDueCreditDays, LiveDirectQuotationDto liveDirectQuotation, + Set quotedRecipients + ) { + this(id, code, type, title, status, priority, createdById, createdByName, assignedToId, + assignedToName, customerName, customerId, projectId, projectName, contactId, contactName, + note, createdAt, updatedAt, closedAt, itemCount, hasEdits, paymentStatus, fulfillmentStatus, + salesStage, lostReason, lostAt, stageUpdatedAt, lifecycle, tenderRequirement, depositPolicy, + depositPolicyReason, entryChannel, billingDate, dueDate, creditTermDays, lastFollowUpAt, + nextFollowUpAt, paymentStage, amountPayable, amountPaid, amountOutstanding, overdue, + closeConfirmedAt, closeConfirmedByName, invoiceOnFile, cancelReason, cancelledAt, + winProbabilityOverride, designerName, ownerName, buyerName, stale, commissionRecorded, + reopenedAt, reopenCount, quotationOnly, paymentDueDate, paymentDueBasis, + paymentDueCreditDays, liveDirectQuotation, quotedRecipients, null); + } + /** The shape before the derived payment-due fields existed: they default to "no structured term". */ public TicketSummaryDto( long id, String code, String type, String title, String status, String priority, @@ -318,7 +360,7 @@ public TicketSummaryDto withCustomerAndProject(String frozenCustomerName, String amountOutstanding, overdue, closeConfirmedAt, closeConfirmedByName, invoiceOnFile, cancelReason, cancelledAt, winProbabilityOverride, designerName, ownerName, buyerName, stale, commissionRecorded, reopenedAt, reopenCount, quotationOnly, paymentDueDate, paymentDueBasis, - paymentDueCreditDays, liveDirectQuotation, quotedRecipients); + paymentDueCreditDays, liveDirectQuotation, quotedRecipients, depositDue); } /** diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java index 5baa20e40..8a7c7ded9 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java @@ -5,6 +5,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -132,6 +133,78 @@ void aggregatesDashboardSectionsWithScopes() { assertThat(repository.headcount(DashboardQueryScope.self(salesEmployee)).active()).isNull(); } + @Test + void cashReceivedCountsTheBangkokMonthsDepositsAndBalancesLessAdjustments() { + long division = insertDivision("SA", "Sales"); + long rep = insertEmployee("EMP-001", division, true); + insertTicket("PR-1", "quotation_issued", rep, LocalDate.of(2026, 6, 1)); + insertTicket("PR-2", "quotation_issued", rep, LocalDate.of(2026, 6, 1)); + + insertReceipt("PR-1", "DEPOSIT", "85418.10", "2026-07-02T10:00:00+07:00", rep); + insertReceipt("PR-1", "BALANCE", "40000.40", "2026-07-15T10:00:00+07:00", rep); + insertReceipt("PR-1", "ADJUSTMENT", "1000.00", "2026-07-20T10:00:00+07:00", rep); + insertReceipt("PR-2", "DEPOSIT", "10000.00", "2026-07-03T09:00:00+07:00", rep); + // 00:30 on 1 July in Bangkok although still 30 June in UTC: this month's. + insertReceipt("PR-2", "BALANCE", "2000.00", "2026-06-30T17:30:00Z", rep); + // Last Bangkok minute of June, and the first of August: neither is July's. + insertReceipt("PR-2", "BALANCE", "5000.00", "2026-06-30T23:59:59+07:00", rep); + insertReceipt("PR-2", "BALANCE", "700.00", "2026-08-01T00:00:00+07:00", rep); + + assertThat(repository.cashReceived(LocalDate.of(2026, 7, 1))).isEqualByComparingTo("136418.50"); + assertThat(repository.cashReceived(LocalDate.of(2026, 5, 1))).isEqualByComparingTo("0"); + } + + @Test + void cashReceivedByRepSplitsTheSameMonthsCashByTheDealsCreator() { + long division = insertDivision("SA", "Sales"); + long repA = insertEmployee("EMP-001", division, true); + long repB = insertEmployee("EMP-002", division, true); + long unnamed = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-003', '', :d, TRUE) RETURNING employee_id + """, Map.of("d", division), Number.class).longValue(); + insertTicket("PR-1", "quotation_issued", repA, LocalDate.of(2026, 6, 1)); + insertTicket("PR-2", "quotation_issued", repA, LocalDate.of(2026, 6, 1)); + insertTicket("PR-3", "quotation_issued", repB, LocalDate.of(2026, 6, 1)); + insertTicket("PR-4", "quotation_issued", unnamed, LocalDate.of(2026, 6, 1)); + // a different rep who happens to share EMP-001's name: kept as its own row + long namesake = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-004', 'EMP-001', :d, TRUE) RETURNING employee_id + """, Map.of("d", division), Number.class).longValue(); + insertTicket("PR-5", "quotation_issued", namesake, LocalDate.of(2026, 6, 1)); + insertReceipt("PR-5", "DEPOSIT", "50.00", "2026-07-05T09:00:00+07:00", repA); + + insertReceipt("PR-1", "DEPOSIT", "85418.10", "2026-07-02T10:00:00+07:00", repA); + insertReceipt("PR-1", "ADJUSTMENT", "1000.00", "2026-07-20T10:00:00+07:00", repA); + // two of repA's deals add up; 00:30 on 1 July in Bangkok is still July's + insertReceipt("PR-2", "BALANCE", "2000.00", "2026-06-30T17:30:00Z", repA); + insertReceipt("PR-3", "DEPOSIT", "10000.00", "2026-07-03T09:00:00+07:00", repB); + insertReceipt("PR-4", "BALANCE", "700.00", "2026-07-04T09:00:00+07:00", repB); + // outside the Bangkok month: last minute of June, first minute of August + insertReceipt("PR-3", "BALANCE", "5000.00", "2026-06-30T23:59:59+07:00", repB); + insertReceipt("PR-1", "BALANCE", "300.00", "2026-08-01T00:00:00+07:00", repA); + + List rows = repository.cashReceivedByRep(LocalDate.of(2026, 7, 1)); + + assertThat(rows).extracting(CashByRepDto::repName) + .containsExactlyInAnyOrder("EMP-001", "EMP-001", "EMP-002", "ไม่ระบุผู้ขาย"); + assertThat(rows.stream().filter(r -> r.repName().equals("EMP-001")).map(CashByRepDto::amount)) + .usingComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .containsExactlyInAnyOrder(new BigDecimal("86418.10"), new BigDecimal("50.00")); + assertThat(amountOf(rows, "EMP-002")).isEqualByComparingTo("10000.00"); + assertThat(amountOf(rows, "ไม่ระบุผู้ขาย")).isEqualByComparingTo("700.00"); + // the rows are the company figure, split: they always add up to cashReceived + assertThat(rows.stream().map(CashByRepDto::amount).reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo(repository.cashReceived(LocalDate.of(2026, 7, 1))) + .isEqualByComparingTo("97168.10"); + assertThat(repository.cashReceivedByRep(LocalDate.of(2026, 5, 1))).isEmpty(); + } + + private static BigDecimal amountOf(List rows, String repName) { + return rows.stream().filter(r -> r.repName().equals(repName)).findFirst().orElseThrow().amount(); + } + private long insertDivision(String code, String name) { Number id = jdbc.queryForObject(""" INSERT INTO hr.division (source_code, name_th) @@ -218,6 +291,16 @@ private void insertAttendance( .addValue("absent", absent)); } + private void insertReceipt(String ticketCode, String kind, String amount, String receivedAt, long recordedBy) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by) + VALUES ((SELECT ticket_id FROM sales.ticket WHERE code = :code), :kind, :amount, 'THB', + :receivedAt, :by) + """, new MapSqlParameterSource() + .addValue("code", ticketCode).addValue("kind", kind).addValue("amount", new BigDecimal(amount)) + .addValue("receivedAt", OffsetDateTime.parse(receivedAt)).addValue("by", recordedBy)); + } + private void insertTicket(String code, String status, long createdBy, LocalDate createdDate) { jdbc.update(""" INSERT INTO sales.ticket ( diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java index 6ff05f2e5..e122cab71 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java @@ -5,16 +5,21 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.math.BigDecimal; import java.time.Clock; import java.time.Instant; import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.ZoneId; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import th.co.glr.hr.auth.UserPrincipal; @@ -186,6 +191,50 @@ void salesSummaryKeepsTicketsScopedToOwnEmployee() { assertThat(ticketScope.getValue().employeeId()).isEqualTo(30L); } + @ParameterizedTest + @ValueSource(strings = {"account", "ceo", "sales_manager"}) + void cashReceivedThisMonthIsServedToTheMoneyRoles(String role) { + when(repository.cashReceived(LocalDate.of(2026, 7, 1))).thenReturn(new BigDecimal("125000.50")); + when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))) + .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50")))); + + DashboardSummaryDto summary = service.summary(user(role, 40L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("125000.50"); + assertThat(summary.cashReceivedByRep()) + .containsExactly(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50"))); + } + + @ParameterizedTest + @ValueSource(strings = {"hr", "import", "sales", "employee"}) + void cashReceivedThisMonthIsNullForEveryOtherRoleAndNeverQueried(String role) { + when(repository.cashReceived(any())).thenReturn(new BigDecimal("125000.50")); + when(repository.cashReceivedByRep(any())) + .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50")))); + + DashboardSummaryDto summary = service.summary(user(role, 41L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isNull(); + assertThat(summary.cashReceivedByRep()).isNull(); + verify(repository, never()).cashReceived(any()); + verify(repository, never()).cashReceivedByRep(any()); + } + + @Test + void cashReceivedThisMonthUsesTheBangkokMonthEvenWhenUtcIsStillLastMonth() { + // 2026-06-30T20:30Z is already 2026-07-01 03:30 in Bangkok. + DashboardService boundary = new DashboardService( + repository, Clock.fixed(Instant.parse("2026-06-30T20:30:00Z"), BANGKOK)); + when(repository.cashReceived(LocalDate.of(2026, 7, 1))).thenReturn(new BigDecimal("1.00")); + when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))) + .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("1.00")))); + + DashboardSummaryDto summary = boundary.summary(user("account", 42L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("1.00"); + assertThat(summary.cashReceivedByRep()).hasSize(1); + } + private UserPrincipal user(String role, Long employeeId, Long divisionId, boolean manager) { return new UserPrincipal( employeeId == null ? 99L : employeeId, diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java new file mode 100644 index 000000000..6916afff8 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketDepositDueIntegrationTest.java @@ -0,0 +1,108 @@ +package th.co.glr.hr.ticket; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; + +/** + * {@code TicketSummaryDto.depositDue}: the deposit-notice total the customer is asked to pay now + * (VAT-inclusive {@code total_payable} of the newest ISSUED notice) while the deal is waiting on + * that deposit ({@code DEPOSIT_NOTICE_ISSUED}); {@code null} otherwise and for the import role. + * Account's รอรับมัดจำ dashboard tile shows this, not the whole-deal outstanding. + */ +class TicketDepositDueIntegrationTest extends AbstractPostgresIntegrationTest { + private TicketRepository tickets; + private long repId; + + @BeforeEach + void seed() { + tickets = new TicketRepository(jdbc); + long divisionId = jdbc.queryForObject(""" + INSERT INTO hr.division (source_code, name_th) VALUES ('SA-DD', 'Sales DD') RETURNING division_id + """, Map.of(), Number.class).longValue(); + repId = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-DD', 'EMP-DD', :divisionId, TRUE) RETURNING employee_id + """, Map.of("divisionId", divisionId), Number.class).longValue(); + } + + @Test + void waitingOnTheDeposit_serves_theIssuedNoticeTotalPayable_onTheListRowAndTheSingleRead() { + long id = deal("DEPOSIT_NOTICE_ISSUED"); + notice(id, 1, "ISSUED", "85418.10"); + + assertThat(listRow(id).depositDue()).isEqualByComparingTo("85418.10"); + assertThat(tickets.findSummaryById(id).orElseThrow().depositDue()).isEqualByComparingTo("85418.10"); + } + + @Test + void severalNotices_serveTheNewestIssuedOne_notADraftOrSupersededOne() { + long id = deal("DEPOSIT_NOTICE_ISSUED"); + notice(id, 1, "SUPERSEDED", "90000.00"); + notice(id, 2, "ISSUED", "85418.10"); + notice(id, 3, "DRAFT", "70000.00"); + + assertThat(listRow(id).depositDue()).isEqualByComparingTo("85418.10"); + } + + @Test + void isNull_whenThereIsNoIssuedNotice() { + long onlyDraft = deal("DEPOSIT_NOTICE_ISSUED"); + notice(onlyDraft, 1, "DRAFT", "85418.10"); + long none = deal("DEPOSIT_NOTICE_ISSUED"); + + assertThat(listRow(onlyDraft).depositDue()).isNull(); + assertThat(listRow(none).depositDue()).isNull(); + } + + @Test + void isNull_onceTheDealIsPastTheDepositStep_evenWithAnIssuedNoticeOnFile() { + for (String status : new String[] {"DEPOSIT_PAID", "AWAITING_FINAL_PAYMENT", "FULLY_PAID", "CUSTOMER_CONFIRMED"}) { + long id = deal(status); + notice(id, 1, "ISSUED", "85418.10"); + + assertThat(listRow(id).depositDue()).as(status).isNull(); + assertThat(tickets.findSummaryById(id).orElseThrow().depositDue()).as(status).isNull(); + } + } + + @Test + void importRoleSummary_hasNoDepositDue_butTheSourceRowDid() { + long id = deal("DEPOSIT_NOTICE_ISSUED"); + notice(id, 1, "ISSUED", "85418.10"); + TicketSummaryDto row = listRow(id); + + assertThat(row.depositDue()).isNotNull(); + assertThat(row.withoutMoney().depositDue()).isNull(); + } + + // ── helpers ───────────────────────────────────────────────────────────────────────────── + + private long deal(String paymentStatus) { + long id = tickets.create( + new CreateTicketRequest("ดีลทดสอบ", "NORMAL", "ลูกค้าทดสอบ", null, null, null, null, null, List.of()), + tickets.nextTicketCode(), repId, "EMP-DD"); + tickets.updatePaymentStatusUnchecked(id, paymentStatus); + return id; + } + + /** 30% deposit on a 213,545.25 deal would be 64,063.58; the figure that matters is the stored total_payable. */ + private void notice(long ticketId, int version, String status, String totalPayable) { + jdbc.update(""" + INSERT INTO sales.deposit_notice (ticket_id, version, status, customer_name, deposit_percent, + subtotal, deposit_amount, vat_percent, vat_amount, total_payable) + VALUES (:id, :v, :s, 'x', 0.3, 100000.00, 30000.00, 0.07, 2100.00, :total)""", + new MapSqlParameterSource("id", ticketId).addValue("v", version).addValue("s", status) + .addValue("total", new BigDecimal(totalPayable))); + } + + private TicketSummaryDto listRow(long ticketId) { + return tickets.findSummaries(null, null).stream().filter(r -> r.id() == ticketId).findFirst().orElseThrow(); + } +} diff --git a/docs/api/response-fields.json b/docs/api/response-fields.json index d79ad22d7..67c2cab4f 100644 --- a/docs/api/response-fields.json +++ b/docs/api/response-fields.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ResponseFieldContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ResponseFieldContractTest -Dresponse.fields.update=true test", "source" : "Schema.field pairs from springdoc /v3/api-docs response schemas, kept where the field name is read in frontend/src (excluding mockApi, hrApi, routes and tests)", - "count" : 1463, + "count" : 1466, "fields" : [ "Action.requiredFields", "Action.targetStage", @@ -129,6 +129,7 @@ "BillingNoteLineDto.sourceId", "BillingNoteLineDto.sourceType", "BillingNoteLineDto.ticketId", + "CashByRepDto.repName", "CatalogDto.sqmPerPiece", "Comment.authorName", "Comment.createdAt", @@ -239,7 +240,8 @@ "CustomerQuotationItemDto.requestedQuantity", "CustomerQuotationItemDto.requestedUnitBasis", "CustomerQuotationItemDto.salesDiscount", - "DashboardSummaryDto.closedThisMonth", + "DashboardSummaryDto.cashReceivedByRep", + "DashboardSummaryDto.cashReceivedThisMonth", "DashboardSummaryDto.divisionId", "DashboardSummaryDto.employeeId", "DashboardSummaryDto.latestPayrollPeriodId", @@ -1101,6 +1103,7 @@ "PricingRequestSummaryDto.itemCount", "PricingRequestSummaryDto.omitContactHonorific", "PricingRequestSummaryDto.orderConfirmedAt", + "PricingRequestSummaryDto.parentPricingRequestId", "PricingRequestSummaryDto.paymentTermMode", "PricingRequestSummaryDto.printedByDisplayId", "PricingRequestSummaryDto.projectName", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e60731fc4..29f7c062a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -334,7 +334,7 @@ export function App() { dashboardSummary={dashboardSummary} /> ) : user.role === 'sales_manager' && SALES_ENABLED ? ( - + ) : user.role === 'sales' && SALES_ENABLED ? ( ) : user?.role === 'ceo' && SALES_ENABLED ? ( @@ -346,7 +346,7 @@ export function App() { ) : user.role === 'import' && SALES_ENABLED ? ( ) : user.role === 'account' && SALES_ENABLED ? ( - + ) : isDivisionManager(user) ? ( new Date(a.receivedAt) - new Date(b.receivedAt) || a.receiptId - b.receiptId); } +// DEPOSIT/BALANCE add, ADJUSTMENT (refund) subtracts — TicketRepository.SIGNED_RECEIPT_SUM's twin. +const signedReceiptSum = (sum, receipt) => + sum + (receipt.kind === 'ADJUSTMENT' ? -Number(receipt.amount) : Number(receipt.amount)); + function sumPaid(ticketId) { - return moneyValue(receiptsForTicket(ticketId).reduce((sum, receipt) => - sum + (receipt.kind === 'ADJUSTMENT' ? -Number(receipt.amount) : Number(receipt.amount)), 0)); + return moneyValue(receiptsForTicket(ticketId).reduce(signedReceiptSum, 0)); } function derivePaymentFields(ticket) { @@ -3866,9 +3869,38 @@ function derivePaymentFields(ticket) { amountPaid: paid, amountOutstanding: outstanding, overdue, + // Mirrors TicketSummaryDto#depositDue: newest ISSUED deposit notice's VAT-inclusive total while the + // deal waits on that deposit; null otherwise. + depositDue: ticket.paymentStatus === 'DEPOSIT_NOTICE_ISSUED' + ? (latestIssuedDepositNotice(ticket.id)?.totalPayable ?? null) + : null, }; } +function mockReceiptsThisMonth() { + const bangkokMonth = (d) => new Date(d).toLocaleDateString('sv-SE', { timeZone: 'Asia/Bangkok' }).slice(0, 7); + const thisMonth = bangkokMonth(new Date()); + return (db.paymentReceipts ?? []).filter((r) => bangkokMonth(r.receivedAt) === thisMonth); +} + +function mockCashReceivedThisMonth() { + return moneyValue(mockReceiptsThisMonth().reduce(signedReceiptSum, 0)); +} + +// Mirrors DashboardRepository#cashReceivedByRep: this month's receipts grouped by the deal creator's id, +// labelled with the creator's name (blank -> ไม่ระบุผู้ขาย). +function mockCashReceivedByRep() { + const byCreator = new Map(); + mockReceiptsThisMonth().forEach((r) => { + const ticket = db.tickets.find((t) => t.id === r.ticketId); + if (!ticket) return; + const row = byCreator.get(ticket.createdById) ?? { repName: ticket.createdByName?.trim() || 'ไม่ระบุผู้ขาย', amount: 0 }; + row.amount = signedReceiptSum(row.amount, r); + byCreator.set(ticket.createdById, row); + }); + return [...byCreator.values()].map((row) => ({ ...row, amount: moneyValue(row.amount) })); +} + function latestIssuedDepositNotice(ticketId) { return [...mockDepositNotices] .filter((d) => d.ticketId === Number(ticketId) && d.status === 'ISSUED') @@ -12243,6 +12275,14 @@ export const api = { dormant: tickets.dormant, paymentOverdue: tickets.paymentOverdue, partiallyDelivered: tickets.partiallyDelivered, + // Mirrors DashboardSummaryDto#cashReceivedThisMonth: receipts in the current Bangkok month + // (deposit + balance - adjustment), only for the roles that see company money. + cashReceivedThisMonth: ['account', 'ceo', 'sales_manager'].includes(user.role) + ? mockCashReceivedThisMonth() + : null, + cashReceivedByRep: ['account', 'ceo', 'sales_manager'].includes(user.role) + ? mockCashReceivedByRep() + : null, }, }); }, diff --git a/frontend/src/features/dashboard/AccountOverview.jsx b/frontend/src/features/dashboard/AccountOverview.jsx index adee72d76..3248a31ba 100644 --- a/frontend/src/features/dashboard/AccountOverview.jsx +++ b/frontend/src/features/dashboard/AccountOverview.jsx @@ -15,6 +15,7 @@ import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { cn } from '../../utils/cn.js'; import { formatMoney, formatThaiDate, greetingName, ticketStatusLabel } from '../../utils/format.js'; import { accountMoneyBucket, nextAccountAction } from '../tickets/accountActions.js'; +import { financeOutstanding } from './dashboardMoney.js'; // Money-pulse bucket meta (Bento Grid). `span` encodes IMPORTANCE, not data: overdue is the 2x2 lead // tile, the deposit step is double-wide, the commission step is the slim closing strip. @@ -27,7 +28,7 @@ import { accountMoneyBucket, nextAccountAction } from '../tickets/accountActions // amountOutstanding is 0 by definition. const MONEY_BUCKETS = [ { key: 'overdue', label: 'เกินกำหนด', icon: 'triangleAlert', tone: 'rose', amountField: 'amountOutstanding', span: 'col-span-2 row-span-2 nav-drawer:row-span-1' }, - { key: 'depositPending', label: 'รอรับมัดจำ', icon: 'clock', tone: 'amber', amountField: 'amountOutstanding', span: 'col-span-2 nav-drawer:col-span-1' }, + { key: 'depositPending', label: 'รอรับมัดจำ', icon: 'clock', tone: 'amber', amountField: 'depositDue', span: 'col-span-2 nav-drawer:col-span-1' }, { key: 'finalPaymentPending', label: 'รอชำระส่วนที่เหลือ', icon: 'badgeDollar', tone: 'teal', amountField: 'amountOutstanding', live: true }, { key: 'closeReady', label: 'รอปิดงาน', icon: 'check', tone: 'indigo', amountField: 'amountPayable', span: 'nav-drawer:col-span-2' }, { key: 'commissionPending', label: 'บันทึกใบกำกับ', icon: 'fileText', tone: 'blue', amountField: 'amountPayable', span: 'col-span-4 nav-drawer:col-span-2' }, @@ -54,11 +55,6 @@ function rowAmount(ticket) { return outstanding > 0 ? outstanding : Number(ticket.amountPayable ?? 0); } -function startOfMonth() { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), 1); -} - /** * Account (ฝ่ายบัญชี) role-scoped landing: money pulse + "สิ่งที่ต้องทำ" * worklist, mirrors ImportOverview's conveyor-pulse pattern but keyed on @@ -81,7 +77,7 @@ function startOfMonth() { * is ever widened. Flagged in the branch's handoff as a follow-up decision * for the plan owner, not fixed here. */ -export function AccountOverview({ user, employee, showToast }) { +export function AccountOverview({ user, employee, dashboardSummary, showToast }) { const navigate = useNavigate(); const scopedQuery = useQuery({ @@ -134,24 +130,12 @@ export function AccountOverview({ user, employee, showToast }) { }); }, [allTickets, user?.role]); - const monthSummary = useMemo(() => { - const monthStart = startOfMonth(); - let receivedThisMonth = 0; - let outstandingTotal = 0; - let overdueTotal = 0; - allTickets.forEach((ticket) => { - const outstanding = Number(ticket.amountOutstanding ?? 0); - outstandingTotal += outstanding; - if (ticket.overdue) overdueTotal += outstanding; - // Best-effort "received this month": amountPaid on deals last updated - // within the current calendar month — the closest signal available - // without a per-ticket payment-receipt fetch for every visible deal. - if (ticket.updatedAt && new Date(ticket.updatedAt) >= monthStart) { - receivedThisMonth += Number(ticket.amountPaid ?? 0); - } - }); - return { receivedThisMonth, outstandingTotal, overdueTotal }; - }, [allTickets]); + // ค้างรับ is /finance's คงค้างรวม (shared helper); รับแล้ว is the server's receipts for this Bangkok month. + const monthSummary = useMemo(() => ({ + receivedThisMonth: dashboardSummary?.cashReceivedThisMonth ?? null, + outstandingTotal: financeOutstanding(allTickets), + overdueTotal: allTickets.reduce((sum, t) => sum + (t.overdue ? Number(t.amountOutstanding ?? 0) : 0), 0), + }), [allTickets, dashboardSummary?.cashReceivedThisMonth]); const firstName = employee?.nickName || employee?.nameTh || user?.name || ''; const today = formatThaiDate(new Date().toISOString()); @@ -276,7 +260,7 @@ export function AccountOverview({ user, employee, showToast }) { ].map(([label, value, tone]) => (
    {label}
    -
    {formatMoney(value)}
    +
    {value == null ? '—' : formatMoney(value)}
    ))} diff --git a/frontend/src/features/dashboard/AccountOverview.test.jsx b/frontend/src/features/dashboard/AccountOverview.test.jsx index 5fb64f620..c50bfbe3a 100644 --- a/frontend/src/features/dashboard/AccountOverview.test.jsx +++ b/frontend/src/features/dashboard/AccountOverview.test.jsx @@ -1,10 +1,13 @@ import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { MemoryRouter } from 'react-router-dom'; -import { render, screen, within } from '@testing-library/react'; +import { cleanup, render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AccountOverview } from './AccountOverview.jsx'; import { api } from '../../api/index.js'; +import { AccountFinancePage } from '../finance/AccountFinancePage.jsx'; +import { DEAL_STAGE_CATALOG } from '../../data/dealStageCatalog.js'; +import { closedDemoDeal } from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -14,6 +17,7 @@ vi.mock('../../api/index.js', async (importOriginal) => { ...actual, api: { tickets: { list: vi.fn() }, + meta: { dealStages: vi.fn() }, }, }; }); @@ -52,14 +56,14 @@ const noActionTicket = { overdue: false, dueDate: null, updatedAt: '2026-07-05T00:00:00.000Z', }; -function renderOverview() { +function renderOverview(dashboardSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return render( - + , ); @@ -68,6 +72,7 @@ function renderOverview() { describe('AccountOverview', () => { beforeEach(() => { vi.clearAllMocks(); + api.meta.dealStages.mockResolvedValue(DEAL_STAGE_CATALOG); api.tickets.list.mockImplementation((params = {}) => { if (params.salesStage === 'CLOSED_PAID') return Promise.resolve({ tickets: [] }); return Promise.resolve({ tickets: [overdueTicket, depositTicket, noActionTicket] }); @@ -114,4 +119,74 @@ describe('AccountOverview', () => { expect(screen.queryByLabelText('งานขาย (Sales)')).toBeNull(); expect(screen.queryByText('ดีลทั้งหมด')).toBeNull(); }); + // Serve `tickets` from the scoped list and `closedPaid` from the CLOSED_PAID query. + function serve(tickets, closedPaid = []) { + api.tickets.list.mockImplementation((params = {}) => Promise.resolve( + { tickets: params.salesStage === 'CLOSED_PAID' ? closedPaid : tickets }, + )); + } + const tile = (label) => screen.getByText(label).closest('.rounded-md').textContent; + const summaryRow = (label) => screen.getByText(label).nextSibling.textContent; + + it('รอรับมัดจำ shows the issued deposit notice total, not the whole-deal outstanding', async () => { + serve([{ ...depositTicket, amountPayable: 213545.25, amountOutstanding: 213545.25, depositDue: 85418.10 }]); + renderOverview(); + await screen.findByText('รอปิดงาน'); + expect(tile('รอรับมัดจำ')).toContain('฿85,418.10'); + expect(tile('รอรับมัดจำ')).toContain('1 ดีล'); + expect(tile('รอรับมัดจำ')).not.toContain('213,545.25'); + }); + + it('รอชำระส่วนที่เหลือ counts a deposit-paid deal the to-do offers รับชำระส่วนที่เหลือ for', async () => { + const depositPaid = { + ...depositTicket, id: 104, code: 'PR-2026-0104', customerName: 'บริษัท ดี จำกัด', + paymentStatus: 'DEPOSIT_PAID', salesStage: 'DELIVERY_SCHEDULING', amountOutstanding: 40000, + }; + // Deposit-bypassed deals are offered the same step. + const bypassed = { + ...noActionTicket, id: 105, code: 'PR-2026-0105', customerName: 'บริษัท อี จำกัด', + depositPolicy: 'CREDIT_CUSTOMER', paymentStatus: null, amountOutstanding: 25000, + }; + serve([depositPaid, bypassed]); + renderOverview(); + await screen.findByText('รอปิดงาน'); + const todo = screen.getByText('สิ่งที่ต้องทำ').closest('section'); + const offered = within(todo).getAllByText('รับชำระส่วนที่เหลือ').length; + expect(offered).toBe(2); + expect(tile('รอชำระส่วนที่เหลือ')).toContain(`${offered} ดีล`); + }); + + it('สรุปเดือนนี้ รับแล้ว (เดือนนี้) is the summary cashReceivedThisMonth, not a guess from updatedAt', async () => { + // amountPaid on a deal updated this month is what the old guess would have summed. + serve([{ ...overdueTicket, updatedAt: new Date().toISOString() }]); + renderOverview({ cashReceivedThisMonth: 321000.5 }); + await screen.findByText('สรุปเดือนนี้'); + expect(summaryRow('รับแล้ว (เดือนนี้)')).toBe('฿321,000.50'); + }); + + it('ค้างรับ leaves out deals with no account to-do, including the closed DEMO-TKT-05 shape', async () => { + serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); + renderOverview(); + await screen.findByText('สรุปเดือนนี้'); + expect(summaryRow('ค้างรับ')).toBe('฿150,000.00'); + }); + + it('ค้างรับ equals the คงค้างรวม on AccountFinancePage for the same tickets', async () => { + serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); + renderOverview(); + await screen.findByText('สรุปเดือนนี้'); + const dashboardValue = summaryRow('ค้างรับ'); + cleanup(); + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + const subtitle = await screen.findByText(/คงค้างรวม ฿/); + expect(subtitle.textContent).toContain(`คงค้างรวม ${dashboardValue}`); + }); }); diff --git a/frontend/src/features/dashboard/CeoOverview.jsx b/frontend/src/features/dashboard/CeoOverview.jsx index 39e929f9f..09c3307ab 100644 --- a/frontend/src/features/dashboard/CeoOverview.jsx +++ b/frontend/src/features/dashboard/CeoOverview.jsx @@ -10,7 +10,8 @@ import { PageStack, Panel, StatGrid } from '../../components/common/Layout.jsx'; import { SkeletonCard } from '../../components/common/Skeleton.jsx'; import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; -import { formatMoney, formatThaiDate, bangkokMonthStartIso, greetingName } from '../../utils/format.js'; +import { formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; +import { financeOutstanding, forecastValue } from './dashboardMoney.js'; /** * CEO exec-cockpit landing (plan: docs/agent-handoffs/valiant-watching-map.md @@ -98,7 +99,6 @@ function WorklistRow({ row }) { export function CeoOverview({ user, employee, dashboardSummary }) { const navigate = useNavigate(); - const monthStart = bangkokMonthStartIso(); // Shares the same cache entries PricingRequestQueuePage // already populate for a CEO session — one company-wide fetch each, no @@ -190,49 +190,31 @@ export function CeoOverview({ user, employee, dashboardSummary }) { // for user.role === 'ceo' (see App.jsx), so there is no other-role case to guard against. ───── const leaveRows = leaveSubmitted; - // ── ผลบริษัทเดือนนี้: no ฿ figure exists on /api/dashboard/summary (it only - // returns ticket-status counts), so every ฿ number here is derived from the + // ── ผลบริษัทเดือนนี้: เงินรับแล้ว comes from /api/dashboard/summary (cashReceivedThisMonth); + // every other ฿ number here is derived from the // scoped tickets.list() the CEO already gets in full — amountPayable/ // amountPaid/amountOutstanding/overdue are the same computed fields // TicketDetailPage's money views already trust (derivePaymentFields). ──── - const closedThisMonthTickets = useMemo( - () => tickets.filter((t) => t.status === 'closed' && t.closedAt && t.closedAt >= monthStart), - [tickets, monthStart], - ); - const salesClosedThisMonth = useMemo( - () => moneySum(closedThisMonthTickets, (t) => t.amountPaid ?? t.amountPayable), - [closedThisMonthTickets], - ); - const companyPipelineValue = useMemo( - () => moneySum(tickets.filter((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' && t.status !== 'closed'), (t) => t.amountPayable), - [tickets], - ); + const outstanding = useMemo(() => financeOutstanding(tickets), [tickets]); + const forecast = useMemo(() => forecastValue(tickets), [tickets]); + const cashReceived = dashboardSummary?.cashReceivedThisMonth; const overdueReceivables = useMemo( () => moneySum(tickets.filter((t) => t.overdue), (t) => t.amountOutstanding), [tickets], ); - const closedThisMonthCount = dashboardSummary?.tickets?.closedThisMonth ?? closedThisMonthTickets.length; const headcountActive = dashboardSummary?.headcount?.active ?? null; const lateToday = dashboardSummary?.attendance?.lateToday ?? null; - // ── ยอดตามทีม/ฝ่าย: tickets carry no divisionId (and employees.list() is - // hr-only — CEO has no server route to a division breakdown here), so this - // groups by the deal's owning sales rep (createdByName) instead, the - // closest real grouping the CEO's existing endpoints expose. No target/ - // quota field exists anywhere in this data, so bars are sized relative to - // this month's top performer, never against an invented quota — see - // docs/role-scoped-views.md "CEO" section for this stated gap. - const repBreakdown = useMemo(() => { - const byRep = new Map(); - for (const ticket of closedThisMonthTickets) { - const key = ticket.createdByName || 'ไม่ระบุผู้ขาย'; - const amount = Number(ticket.amountPaid ?? ticket.amountPayable) || 0; - byRep.set(key, (byRep.get(key) ?? 0) + amount); - } - const rows = [...byRep.entries()].map(([name, amount]) => ({ name, amount })); - rows.sort((a, b) => b.amount - a.amount); - return rows.slice(0, 8); - }, [closedThisMonthTickets]); + // ── ยอดตามทีม/ฝ่าย: this month's cash received split per sales rep, served by the dashboard + // summary (cashReceivedByRep; its rows add up to เงินรับแล้ว). Tickets carry no divisionId and + // employees.list() is hr-only, so a division breakdown is not derivable for the CEO. No target/ + // quota field exists, so bars are sized relative to this month's top rep, never against a quota. + const repBreakdown = useMemo( + () => [...(dashboardSummary?.cashReceivedByRep ?? [])] + .map((row) => ({ name: row.repName, amount: Number(row.amount) || 0 })) + .sort((a, b) => b.amount - a.amount), + [dashboardSummary?.cashReceivedByRep], + ); const repBreakdownMax = repBreakdown.reduce((max, row) => Math.max(max, row.amount), 0); // ── the cross-domain worklist, in the exec-pulse's own order ───────────── @@ -307,7 +289,6 @@ export function CeoOverview({ user, employee, dashboardSummary }) { -
    ) : ( @@ -347,13 +328,6 @@ export function CeoOverview({ user, employee, dashboardSummary }) { ? () => navigate(otRows.length >= leaveRows.length ? '/employee-requests?tab=ot' : '/leave') : undefined} /> - @@ -379,7 +353,7 @@ export function CeoOverview({ user, employee, dashboardSummary }) { employees.list() is hr-only, so a real division breakdown is not derivable from any endpoint the CEO can call. */} {repBreakdown.length === 0 ? ( -

    ยังไม่มีดีลที่ปิดในเดือนนี้

    +

    ยังไม่มีเงินรับเข้าในเดือนนี้

    ) : (
    {repBreakdown.map((row) => ( @@ -403,9 +377,9 @@ export function CeoOverview({ user, employee, dashboardSummary }) {
    {[ - ['ยอดขายปิดแล้ว', formatMoney(salesClosedThisMonth)], - ['ปิดงานแล้ว', `${closedThisMonthCount} ดีล`], - ['Pipeline บริษัท', formatMoney(companyPipelineValue)], + ['เงินรับแล้ว (เดือนนี้)', cashReceived == null ? '—' : formatMoney(cashReceived)], + ['ค้างรับ', formatMoney(outstanding)], + ['ยอดคาดการณ์', formatMoney(forecast)], ['ลูกหนี้ค้าง/เกินกำหนด', formatMoney(overdueReceivables)], ['กำลังพล', headcountActive != null ? `${headcountActive} คน` : '-'], ['มาสายวันนี้', lateToday != null ? `${lateToday} คน` : '-'], diff --git a/frontend/src/features/dashboard/CeoOverview.test.jsx b/frontend/src/features/dashboard/CeoOverview.test.jsx index d2221ea6d..b6d64a597 100644 --- a/frontend/src/features/dashboard/CeoOverview.test.jsx +++ b/frontend/src/features/dashboard/CeoOverview.test.jsx @@ -6,6 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CeoOverview } from './CeoOverview.jsx'; import { api } from '../../api/index.js'; import { bangkokMonthStartIso } from '../../utils/format.js'; +import { + CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, +} from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -36,6 +39,12 @@ const dashboardSummary = { tickets: { closedThisMonth: 4 }, headcount: { active: 42 }, attendance: { lateToday: 3 }, + cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, + // This month's cash split per sales rep: the two add up to CASH_RECEIVED_THIS_MONTH (125,000.50). + cashReceivedByRep: [ + { repName: 'สมชาย ขายดี', amount: 90000.5 }, + { repName: 'สมหญิง ขายเก่ง', amount: 35000 }, + ], }; function mockAllData() { @@ -125,7 +134,7 @@ function LocationProbe() { return
    {location.pathname}{location.search}
    ; } -function renderOverview() { +function renderOverview(summary = dashboardSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); @@ -137,7 +146,7 @@ function renderOverview() { + )} /> } /> @@ -184,8 +193,6 @@ describe('CeoOverview', () => { // OT (MANAGER_APPROVED + manager-less-direct-to-CEO) + ลา (ALL of it, CEO leave-approval reach, // 2026-09-01) = 2 + 2. expect(statCardValue('OT·ลา รออนุมัติ')).toBe('4'); - // ยอดขายเดือนนี้: sum of amountPaid for tickets closed this month (only TK-702). - expect(statCardValue('ยอดขายเดือนนี้')).toBe('฿50,000.00'); expect(api.pricingRequests.queue).toHaveBeenCalledWith({ activeOnly: true }); expect(api.tickets.list).toHaveBeenCalledWith({}); @@ -281,25 +288,61 @@ describe('CeoOverview', () => { await screen.findByText('ผลบริษัทเดือนนี้'); - expect(screen.getByText('ยอดขายปิดแล้ว').nextSibling.textContent).toBe('฿50,000.00'); - // Prefers the real dashboardSummary.tickets.closedThisMonth (4) over the - // client-derived count (1) when the summary provides it. - expect(screen.getByText('ปิดงานแล้ว').nextSibling.textContent).toBe('4 ดีล'); - // Pipeline: active, not-yet-closed tickets only (701 + 703 = 120,000). - expect(screen.getByText('Pipeline บริษัท').nextSibling.textContent).toBe('฿120,000.00'); // Overdue receivables: only the overdue ticket's outstanding balance (703). expect(screen.getByText('ลูกหนี้ค้าง/เกินกำหนด').nextSibling.textContent).toBe('฿15,000.00'); expect(screen.getByText('กำลังพล').nextSibling.textContent).toBe('42 คน'); expect(screen.getByText('มาสายวันนี้').nextSibling.textContent).toBe('3 คน'); }); - it('renders a per-rep breakdown for deals closed this month, with no invented target/quota', async () => { + it('splits this month\'s cash received per sales rep, adding up to เงินรับแล้ว, with no invented target/quota', async () => { renderOverview(); - await screen.findByText('ยอดตามทีม/ฝ่าย'); - // Only สมหญิง's deal (TK-702) closed this month. - expect(screen.getByText('สมหญิง ขายเก่ง')).not.toBeNull(); + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + expect(within(panel).getByText('สมชาย ขายดี').nextSibling.textContent).toBe('฿90,000.50'); + expect(within(panel).getByText('สมหญิง ขายเก่ง').nextSibling.textContent).toBe('฿35,000.00'); + // The bars add up to the เงินรับแล้ว (เดือนนี้) figure shown beside them. + const barTotal = [...panel.querySelectorAll('code')] + .reduce((sum, el) => sum + Number(el.textContent.replace(/[฿,]/g, '')), 0); + expect(barTotal).toBeCloseTo(CASH_RECEIVED_THIS_MONTH, 2); + expectFigure('เงินรับแล้ว (เดือนนี้)', '฿125,000.50'); + // The old basis -- deals the CEO closed this month at amountPaid (TK-702, ฿50,000) -- is gone. + expect(within(panel).queryByText('฿50,000.00')).toBeNull(); // No percentage/quota text is rendered anywhere in that panel. - expect(screen.queryByText(/%/)).toBeNull(); + expect(within(panel).queryByText(/%/)).toBeNull(); + }); + + it('lists every rep so the bars still add up to เงินรับแล้ว with more than eight reps', async () => { + const reps = Array.from({ length: 9 }, (_, i) => ({ repName: `เซลส์ ${i + 1}`, amount: 1000 })); + renderOverview({ ...dashboardSummary, cashReceivedThisMonth: 9000, cashReceivedByRep: reps }); + + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + expect(panel.querySelectorAll('code')).toHaveLength(9); + }); + + it('keeps the empty-state text when no cash was received per rep', async () => { + renderOverview({ ...dashboardSummary, cashReceivedByRep: [] }); + + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + expect(within(panel).getByText('ยังไม่มีเงินรับเข้าในเดือนนี้')).not.toBeNull(); + }); + it('shows the same three money figures as /finance and the sales-manager dashboard', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview(); + await screen.findByText('อนุมัติราคา'); + + expectFigure('เงินรับแล้ว (เดือนนี้)', EXPECTED.cash); + expectFigure('ค้างรับ', EXPECTED.outstanding); + expectFigure('ยอดคาดการณ์', EXPECTED.forecast); + }); + + it('no longer shows the closed-sales and pipeline figures', async () => { + renderOverview(); + await screen.findByText('ผลบริษัทเดือนนี้'); + + ['ยอดขายเดือนนี้', 'ยอดขายปิดแล้ว', 'ปิดงานแล้ว', 'Pipeline บริษัท'].forEach((label) => { + expect(figureRows(label), label).toEqual([]); + }); + // The other company-result rows stay. + expect(screen.getByText('ลูกหนี้ค้าง/เกินกำหนด')).not.toBeNull(); }); }); diff --git a/frontend/src/features/dashboard/ManagerOverview.jsx b/frontend/src/features/dashboard/ManagerOverview.jsx index 8c314e316..748dddfd0 100644 --- a/frontend/src/features/dashboard/ManagerOverview.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.jsx @@ -12,6 +12,7 @@ import { SkeletonCard, SkeletonText } from '../../components/common/Skeleton.jsx import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; +import { financeOutstanding, forecastValue } from './dashboardMoney.js'; // No server-side status filter exists on GET /api/commissions (mirrors // CommissionController#list — payrollMonth is the only query param it @@ -86,7 +87,7 @@ function dealAmount(ticket) { * be fabricating business data (CLAUDE.md). Flagged as a follow-up decision * for the plan owner, not fabricated here. */ -export function ManagerOverview({ user, employee, showToast }) { +export function ManagerOverview({ user, employee, dashboardSummary, showToast }) { const navigate = useNavigate(); const ticketsQuery = useQuery({ @@ -123,16 +124,10 @@ export function ManagerOverview({ user, employee, showToast }) { () => tickets.filter((t) => isDealWon(t) && t.stageUpdatedAt && new Date(t.stageUpdatedAt) >= monthStart), [tickets, monthStart], ); - const teamMonthValue = useMemo( - () => closedThisMonth.reduce((sum, t) => sum + dealAmount(t), 0), - [closedThisMonth], - ); - const openTickets = useMemo(() => tickets.filter(isDealOpen), [tickets]); - const openPipelineValue = useMemo( - () => openTickets.reduce((sum, t) => sum + dealAmount(t), 0), - [openTickets], - ); + const outstanding = useMemo(() => financeOutstanding(tickets), [tickets]); + const forecast = useMemo(() => forecastValue(tickets), [tickets]); + const cashReceived = dashboardSummary?.cashReceivedThisMonth; // "ดีลต้องดูแล": an open deal with no activity in 7 days (`ticket.stale`, // mirrors TicketRepository.enrichSummary's own staleness window — see @@ -189,6 +184,7 @@ export function ManagerOverview({ user, employee, showToast }) { + @@ -200,11 +196,18 @@ export function ManagerOverview({ user, employee, showToast }) { + navigate('/tickets')} /> diff --git a/frontend/src/features/dashboard/ManagerOverview.test.jsx b/frontend/src/features/dashboard/ManagerOverview.test.jsx index a125acd05..e3e952662 100644 --- a/frontend/src/features/dashboard/ManagerOverview.test.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.test.jsx @@ -5,6 +5,9 @@ import { render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ManagerOverview } from './ManagerOverview.jsx'; import { api } from '../../api/index.js'; +import { + CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, +} from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -65,14 +68,14 @@ const managerApprovedCommission = { invoiceDetails: { invoiceNumber: 'INV-2026-0302' }, }; -function renderOverview() { +function renderOverview(dashboardSummary) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return render( - + , ); @@ -87,12 +90,9 @@ describe('ManagerOverview', () => { it('renders the team pulse derived from the team ticket list', async () => { renderOverview(); - await screen.findByText('ยอดทีมเดือนนี้'); - // ฿300,000.00 also appears in the leaderboard row for the same rep/deal — - // both are correct, so assert at least one, not exactly one. + await screen.findByText('ดีลต้องดูแล'); + // The leaderboard row for the same rep/deal carries the won deal's ฿300,000.00. expect(screen.getAllByText('฿300,000.00').length).toBeGreaterThan(0); - expect(screen.getByText('pipeline ทีม')).not.toBeNull(); - expect(screen.getByText('ดีลต้องดูแล')).not.toBeNull(); }); it('renders the ค่าคอมรออนุมัติ worklist with a review/approve CTA, SUBMITTED only', async () => { @@ -124,4 +124,22 @@ describe('ManagerOverview', () => { expect(within(panel).getByText('ไม่มีความเคลื่อนไหว 7 วัน')).not.toBeNull(); expect(within(panel).queryByText('บริษัท ทรี จำกัด')).toBeNull(); }); + it('shows the same three money figures as /finance and the CEO dashboard', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH }); + await screen.findByText('ดีลต้องดูแล'); + + expectFigure('เงินรับแล้ว (เดือนนี้)', EXPECTED.cash); + expectFigure('ค้างรับ', EXPECTED.outstanding); + expectFigure('ยอดคาดการณ์', EXPECTED.forecast); + }); + + it('no longer shows the team-sales and team-pipeline tiles', async () => { + renderOverview(); + await screen.findByText('ดีลต้องดูแล'); + + ['ยอดทีมเดือนนี้', 'pipeline ทีม'].forEach((label) => { + expect(figureRows(label), label).toEqual([]); + }); + }); }); diff --git a/frontend/src/features/dashboard/dashboardMoney.js b/frontend/src/features/dashboard/dashboardMoney.js new file mode 100644 index 000000000..a18321895 --- /dev/null +++ b/frontend/src/features/dashboard/dashboardMoney.js @@ -0,0 +1,31 @@ +// The money figures the account, CEO and sales-manager dashboards share, so each reads the same +// number /finance does. Presentation only: the server stays the authority on every figure. +import { nextAccountAction } from '../tickets/accountActions.js'; +import { moneyMilestoneOf } from '../finance/moneyMilestone.js'; + +const ACCOUNT_PENDING_PAYMENT_STATUSES = new Set(['DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT']); + +const amount = (value) => Number(value ?? 0); + +// Mirrors TicketRepository.appendRoleScope("account"): the deals account's list can contain. +function inAccountScope(ticket) { + const live = ticket.lifecycle !== 'CLOSED_LOST' && ticket.lifecycle !== 'CANCELLED' + && ticket.status !== 'cancelled' && moneyMilestoneOf(ticket.salesStage) != null; + return live || ACCOUNT_PENDING_PAYMENT_STATUSES.has(ticket.paymentStatus) || ticket.overdue === true; +} + +/** /finance's คงค้างรวม: outstanding on deals that are in account's scope and have an account to-do. */ +export function financeOutstanding(tickets) { + return (tickets ?? []) + .filter((t) => inAccountScope(t) && nextAccountAction(t) != null) + .reduce((sum, t) => sum + amount(t.amountOutstanding), 0); +} + +/** ยอดคาดการณ์: payable on live deals that have not reached ขั้น 10 yet. */ +export function forecastValue(tickets) { + return (tickets ?? []) + .filter((t) => moneyMilestoneOf(t.salesStage) == null + && (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' + && t.status !== 'closed' && t.status !== 'cancelled') + .reduce((sum, t) => sum + amount(t.amountPayable), 0); +} diff --git a/frontend/src/features/dashboard/dashboardMoney.test.js b/frontend/src/features/dashboard/dashboardMoney.test.js new file mode 100644 index 000000000..39a089b5a --- /dev/null +++ b/frontend/src/features/dashboard/dashboardMoney.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { financeOutstanding, forecastValue } from './dashboardMoney.js'; +import { nextAccountAction } from '../tickets/accountActions.js'; +import { + closedDemoDeal, depositPendingDeal, finalPaymentDeal, lostPreOrderDeal, moneyFixtureTickets, + preOrderBypassDeal, preOrderPlainDeal, +} from '../../test/fixtures/dashboardMoney.js'; + +// financeOutstanding is /finance's คงค้างรวม: Σ amountOutstanding over deals that have an account +// to-do AND sit inside account's list scope (TicketRepository.appendRoleScope("account")). +describe('financeOutstanding', () => { + it('counts a deposit-pending deal for its whole outstanding, not the deposit', () => { + expect(financeOutstanding([depositPendingDeal])).toBeCloseTo(213545.25, 2); + }); + + it('counts a deposit-paid deal awaiting the final payment', () => { + expect(financeOutstanding([finalPaymentDeal])).toBe(80000); + }); + + it('does not count a closed deal that still carries an outstanding figure (DEMO-TKT-05)', () => { + expect(closedDemoDeal.amountOutstanding).toBe(196880); + expect(financeOutstanding([closedDemoDeal])).toBe(0); + }); + + it('does not count a pre-ขั้น 10 deal even though nextAccountAction offers it a step', () => { + // Guard: the fixture really is one the to-do helper would offer, so 0 below is the scope rule. + expect(nextAccountAction(preOrderBypassDeal)).not.toBeNull(); + expect(financeOutstanding([preOrderBypassDeal])).toBe(0); + }); + + it('does not count an in-scope deal that has no account to-do', () => { + const waiting = { ...depositPendingDeal, paymentStatus: 'CUSTOMER_CONFIRMED' }; + expect(nextAccountAction(waiting)).toBeNull(); + expect(financeOutstanding([waiting])).toBe(0); + }); + + it('counts an overdue pre-ขั้น 10 deal (overdue is its own scope branch)', () => { + const overdue = { ...preOrderPlainDeal, overdue: true, amountOutstanding: 15000 }; + expect(financeOutstanding([overdue])).toBe(15000); + }); + + it('does not count a cancelled deal whose payment status is not a pending one', () => { + const cancelled = { ...finalPaymentDeal, lifecycle: 'CANCELLED' }; + expect(nextAccountAction(cancelled)).not.toBeNull(); + expect(financeOutstanding([cancelled])).toBe(0); + }); + + it('sums the fixture and tolerates an empty or missing list', () => { + expect(financeOutstanding(moneyFixtureTickets)).toBeCloseTo(293545.25, 2); + expect(financeOutstanding([])).toBe(0); + expect(financeOutstanding(undefined)).toBe(0); + }); +}); + +// forecastValue is ยอดคาดการณ์: Σ amountPayable over deals before ขั้น 10 that are still live. +describe('forecastValue', () => { + it('sums amountPayable of active deals before ขั้น 10', () => { + expect(forecastValue([preOrderBypassDeal, preOrderPlainDeal])).toBeCloseTo(80000.25, 2); + }); + + it('leaves out deals at or past ขั้น 10, lost/cancelled lifecycles and closed/cancelled status', () => { + expect(forecastValue([depositPendingDeal, finalPaymentDeal, closedDemoDeal, lostPreOrderDeal])).toBe(0); + expect(forecastValue([{ ...preOrderPlainDeal, status: 'closed' }])).toBe(0); + expect(forecastValue([{ ...preOrderPlainDeal, status: 'cancelled' }])).toBe(0); + expect(forecastValue([{ ...preOrderPlainDeal, lifecycle: 'CANCELLED' }])).toBe(0); + }); + + it('treats a null lifecycle as active and a null amount as zero', () => { + expect(forecastValue([{ ...preOrderPlainDeal, lifecycle: null }])).toBeCloseTo(30000.25, 2); + expect(forecastValue([{ ...preOrderPlainDeal, amountPayable: null }])).toBe(0); + }); + + it('sums the fixture and tolerates an empty or missing list', () => { + expect(forecastValue(moneyFixtureTickets)).toBeCloseTo(80000.25, 2); + expect(forecastValue([])).toBe(0); + expect(forecastValue(null)).toBe(0); + }); +}); diff --git a/frontend/src/features/tickets/accountActions.js b/frontend/src/features/tickets/accountActions.js index 318fcd620..558f06f73 100644 --- a/frontend/src/features/tickets/accountActions.js +++ b/frontend/src/features/tickets/accountActions.js @@ -35,6 +35,11 @@ function finalPaymentDue(ticket) { || depositBypassed(ticket); } +// The one predicate behind the รับชำระส่วนที่เหลือ to-do step; accountMoneyBucket reuses it. +function offersFinalPayment(ticket) { + return ticket.status === 'quotation_issued' && finalPaymentDue(ticket); +} + // Mirrors TicketService#requireClosePrerequisites (the predicate behind // closeReady()/canConfirmClose): dual-track deals need FULLY_PAID + a fully // delivered fulfilment status + no outstanding balance + the invoice on @@ -117,7 +122,7 @@ export function nextAccountAction(ticket, viewerRole) { if (canRecordPayments && ticket.status === 'quotation_issued' && ticket.paymentStatus === 'DEPOSIT_NOTICE_ISSUED') { return { key: 'confirmDeposit', label: 'ยืนยันรับมัดจำ', to: dealPath, urgent: false }; } - if (canRecordPayments && ticket.status === 'quotation_issued' && finalPaymentDue(ticket)) { + if (canRecordPayments && offersFinalPayment(ticket)) { return { key: 'confirmFinalPayment', label: 'รับชำระส่วนที่เหลือ', to: dealPath, urgent: false }; } if (closeReady(ticket)) { @@ -140,7 +145,7 @@ export function accountMoneyBucket(ticket) { const outstanding = ticket.amountOutstanding != null && Number(ticket.amountOutstanding) > 0; if (ticket.overdue && outstanding) return 'overdue'; if (ticket.paymentStatus === 'DEPOSIT_NOTICE_ISSUED') return 'depositPending'; - if (ticket.paymentStatus === 'AWAITING_FINAL_PAYMENT') return 'finalPaymentPending'; + if (offersFinalPayment(ticket)) return 'finalPaymentPending'; if (closeReady(ticket)) return 'closeReady'; if (ticket.salesStage === 'CLOSED_PAID' && !ticket.commissionRecorded) return 'commissionPending'; return null; diff --git a/frontend/src/test/fixtures/dashboardMoney.js b/frontend/src/test/fixtures/dashboardMoney.js new file mode 100644 index 000000000..20391ce78 --- /dev/null +++ b/frontend/src/test/fixtures/dashboardMoney.js @@ -0,0 +1,64 @@ +// One ticket list + the figures it must produce, shared by the CEO and sales-manager dashboard +// tests so both are pinned to the SAME three numbers (เงินรับแล้ว / ค้างรับ / ยอดคาดการณ์). +import { expect } from 'vitest'; +import { screen } from '@testing-library/react'; + +const base = { + lifecycle: 'ACTIVE', status: 'quotation_issued', paymentStatus: null, fulfillmentStatus: null, + depositPolicy: 'REQUIRED', closeConfirmedAt: null, invoiceOnFile: false, commissionRecorded: false, + overdue: false, amountPaid: 0, +}; + +// Deposit notice issued (ขั้น 10): the whole deal is outstanding. +export const depositPendingDeal = { + ...base, id: 1, code: 'DL-1', customerName: 'ดีลรอมัดจำ', salesStage: 'ORDER_RECEIVED', + paymentStatus: 'DEPOSIT_NOTICE_ISSUED', amountPayable: 213545.25, amountOutstanding: 213545.25, + depositDue: 85418.10, +}; +// Deposit paid, final payment next (ขั้น 18). +export const finalPaymentDeal = { + ...base, id: 2, code: 'DL-2', customerName: 'ดีลรอชำระส่วนที่เหลือ', salesStage: 'DELIVERY_SCHEDULING', + paymentStatus: 'DEPOSIT_PAID', amountPayable: 120000, amountPaid: 40000, amountOutstanding: 80000, +}; +// DEMO-TKT-05 shape: finished deal that still carries an outstanding figure. Not account's work. +export const closedDemoDeal = { + ...base, id: 3, code: 'DEMO-TKT-05', customerName: 'ดีลปิดแล้ว', status: 'closed', + salesStage: 'CLOSED_PAID', commissionRecorded: true, amountPayable: 196880, amountOutstanding: 196880, +}; +// Before ขั้น 10, deposit not required: nextAccountAction offers a step, but account's list never has it. +export const preOrderBypassDeal = { + ...base, id: 4, code: 'DL-4', customerName: 'ดีลก่อนสั่งซื้อ-ไม่เก็บมัดจำ', salesStage: 'QUOTE_BUYER', + depositPolicy: 'NOT_REQUIRED', amountPayable: 50000, amountOutstanding: 50000, +}; +// Before ขั้น 10, no account step at all. +export const preOrderPlainDeal = { + ...base, id: 5, code: 'DL-5', customerName: 'ดีลก่อนสั่งซื้อ', salesStage: 'QUOTE_BUYER', + amountPayable: 30000.25, amountOutstanding: 30000.25, +}; +// Before ขั้น 10 but lost: not in the forecast. +export const lostPreOrderDeal = { + ...base, id: 6, code: 'DL-6', customerName: 'ดีลที่แพ้', lifecycle: 'CLOSED_LOST', salesStage: 'QUOTE_BUYER', + amountPayable: 77777, amountOutstanding: 77777, +}; + +export const moneyFixtureTickets = [ + depositPendingDeal, finalPaymentDeal, closedDemoDeal, preOrderBypassDeal, preOrderPlainDeal, lostPreOrderDeal, +]; + +export const CASH_RECEIVED_THIS_MONTH = 125000.5; +export const EXPECTED = { + cash: '฿125,000.50', + outstanding: '฿293,545.25', // 213,545.25 + 80,000 — not the closed, pre-order or lost deals + forecast: '฿80,000.25', // 50,000 + 30,000.25 — pre-ขั้น 10, ACTIVE, not closed +}; + +/** Text of every row/tile carrying `label`: a StatCard, or a
    /
    pair in a
    . */ +export function figureRows(label) { + return screen.queryAllByText(label).map((el) => el.closest('.stat-card, dl > div')?.textContent ?? ''); +} + +export function expectFigure(label, value) { + const rows = figureRows(label); + expect(rows.length, `"${label}" is shown`).toBeGreaterThan(0); + rows.forEach((text) => expect(text).toContain(value)); +} From 2b7f3bb5b74388428e152164f6e7bc8f106a41b5 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:57:47 +0700 Subject: [PATCH 08/24] fix(dashboard): unique keys for same-name rep rows Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/dashboard/CeoOverview.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/dashboard/CeoOverview.jsx b/frontend/src/features/dashboard/CeoOverview.jsx index 09c3307ab..120c140b4 100644 --- a/frontend/src/features/dashboard/CeoOverview.jsx +++ b/frontend/src/features/dashboard/CeoOverview.jsx @@ -356,8 +356,8 @@ export function CeoOverview({ user, employee, dashboardSummary }) {

    ยังไม่มีเงินรับเข้าในเดือนนี้

    ) : (
    - {repBreakdown.map((row) => ( -
    + {repBreakdown.map((row, index) => ( +
    {row.name} {formatMoney(row.amount)} From d78642d6b15ab91de461c421c974cfa839d56371 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 19:58:10 +0700 Subject: [PATCH 09/24] fix(dashboard): code-review fixes for #1205 - SM leaderboard = this month's cash per rep (same rows as the CEO panel) - cashReceivedThisMonth = sum of the per-rep rows (one query) - pin the rep panel's largest-first order; drop unused DashboardSummaryDto.of overload; fix stale comments Co-Authored-By: Claude Opus 5.5 --- .../glr/hr/dashboard/DashboardRepository.java | 15 ++----- .../co/glr/hr/dashboard/DashboardService.java | 3 +- .../glr/hr/dashboard/DashboardSummaryDto.java | 18 --------- .../DashboardRepositoryIntegrationTest.java | 24 +----------- .../hr/dashboard/DashboardServiceTest.java | 30 ++++++++------ frontend/src/api/mockApi.js | 14 ++----- .../features/dashboard/AccountOverview.jsx | 9 +++-- .../features/dashboard/CeoOverview.test.jsx | 15 ++++--- .../features/dashboard/ManagerOverview.jsx | 39 +++++-------------- .../dashboard/ManagerOverview.test.jsx | 27 ++++++++++--- frontend/src/test/fixtures/dashboardMoney.js | 5 +++ 11 files changed, 79 insertions(+), 120 deletions(-) diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java index e44f3830b..925421381 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java @@ -197,18 +197,9 @@ private static MapSqlParameterSource monthWindow(LocalDate monthStart) { } /** - * Cash received in the Bangkok calendar month starting {@code monthStart}: payment receipts with - * {@code received_at} in [monthStart, next month start), signed by {@link TicketRepository#SIGNED_RECEIPT_SUM}. - */ - public java.math.BigDecimal cashReceived(LocalDate monthStart) { - return jdbc.queryForObject("SELECT " + TicketRepository.SIGNED_RECEIPT_SUM - + " FROM sales.payment_receipt WHERE " + RECEIVED_IN_MONTH, - monthWindow(monthStart), java.math.BigDecimal.class); - } - - /** - * {@link #cashReceived}, split by the creator of the deal each receipt belongs to (same window, same - * sign rule), so the rows always add up to it. Grouped by creator id, labelled with the creator's name + * Cash received in the Bangkok calendar month starting {@code monthStart} ([start, next month start) on + * {@code received_at}, signed by {@link TicketRepository#SIGNED_RECEIPT_SUM}), split by the creator of the + * deal each receipt belongs to. The service totals the rows for the month figure. Grouped by creator id, labelled with the creator's name * ({@code ไม่ระบุผู้ขาย} when blank), so two reps with the same name stay separate. */ public java.util.List cashReceivedByRep(LocalDate monthStart) { diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java index a9472046c..f9bd507ba 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java @@ -57,8 +57,9 @@ public DashboardSummaryDto summary(UserPrincipal user) { : dashboardRepository.latestPayrollPeriodId(user.employeeId()).orElse(null); boolean seesCash = CASH_RECEIVED_ROLES.contains(user.role()); - BigDecimal cashReceived = seesCash ? dashboardRepository.cashReceived(monthStart) : null; List cashByRep = seesCash ? dashboardRepository.cashReceivedByRep(monthStart) : null; + BigDecimal cashReceived = cashByRep == null ? null + : cashByRep.stream().map(CashByRepDto::amount).reduce(BigDecimal.ZERO, BigDecimal::add); return DashboardSummaryDto.of( user.role(), diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java index 90cdf3f63..4ae916a9f 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java @@ -53,24 +53,6 @@ public static DashboardSummaryDto of( attendance, latestPayrollPeriodId, tickets, notifications, null, null); } - public static DashboardSummaryDto of( - String role, - Long employeeId, - Long divisionId, - boolean manager, - OffsetDateTime generatedAt, - HeadcountSummaryDto headcount, - PendingApprovalsSummaryDto pendingApprovals, - AttendanceSummaryDto attendance, - Long latestPayrollPeriodId, - TicketSummaryDto tickets, - NotificationSummaryDto notifications, - BigDecimal cashReceivedThisMonth - ) { - return of(role, employeeId, divisionId, manager, generatedAt, headcount, pendingApprovals, - attendance, latestPayrollPeriodId, tickets, notifications, cashReceivedThisMonth, null); - } - public static DashboardSummaryDto of( String role, Long employeeId, diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java index 8a7c7ded9..3292bfc0d 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardRepositoryIntegrationTest.java @@ -133,27 +133,6 @@ void aggregatesDashboardSectionsWithScopes() { assertThat(repository.headcount(DashboardQueryScope.self(salesEmployee)).active()).isNull(); } - @Test - void cashReceivedCountsTheBangkokMonthsDepositsAndBalancesLessAdjustments() { - long division = insertDivision("SA", "Sales"); - long rep = insertEmployee("EMP-001", division, true); - insertTicket("PR-1", "quotation_issued", rep, LocalDate.of(2026, 6, 1)); - insertTicket("PR-2", "quotation_issued", rep, LocalDate.of(2026, 6, 1)); - - insertReceipt("PR-1", "DEPOSIT", "85418.10", "2026-07-02T10:00:00+07:00", rep); - insertReceipt("PR-1", "BALANCE", "40000.40", "2026-07-15T10:00:00+07:00", rep); - insertReceipt("PR-1", "ADJUSTMENT", "1000.00", "2026-07-20T10:00:00+07:00", rep); - insertReceipt("PR-2", "DEPOSIT", "10000.00", "2026-07-03T09:00:00+07:00", rep); - // 00:30 on 1 July in Bangkok although still 30 June in UTC: this month's. - insertReceipt("PR-2", "BALANCE", "2000.00", "2026-06-30T17:30:00Z", rep); - // Last Bangkok minute of June, and the first of August: neither is July's. - insertReceipt("PR-2", "BALANCE", "5000.00", "2026-06-30T23:59:59+07:00", rep); - insertReceipt("PR-2", "BALANCE", "700.00", "2026-08-01T00:00:00+07:00", rep); - - assertThat(repository.cashReceived(LocalDate.of(2026, 7, 1))).isEqualByComparingTo("136418.50"); - assertThat(repository.cashReceived(LocalDate.of(2026, 5, 1))).isEqualByComparingTo("0"); - } - @Test void cashReceivedByRepSplitsTheSameMonthsCashByTheDealsCreator() { long division = insertDivision("SA", "Sales"); @@ -194,9 +173,8 @@ void cashReceivedByRepSplitsTheSameMonthsCashByTheDealsCreator() { .containsExactlyInAnyOrder(new BigDecimal("86418.10"), new BigDecimal("50.00")); assertThat(amountOf(rows, "EMP-002")).isEqualByComparingTo("10000.00"); assertThat(amountOf(rows, "ไม่ระบุผู้ขาย")).isEqualByComparingTo("700.00"); - // the rows are the company figure, split: they always add up to cashReceived + // the rows are the whole month's cash (the service totals them for cashReceivedThisMonth) assertThat(rows.stream().map(CashByRepDto::amount).reduce(BigDecimal.ZERO, BigDecimal::add)) - .isEqualByComparingTo(repository.cashReceived(LocalDate.of(2026, 7, 1))) .isEqualByComparingTo("97168.10"); assertThat(repository.cashReceivedByRep(LocalDate.of(2026, 5, 1))).isEmpty(); } diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java index e122cab71..026bfc93c 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java @@ -193,22 +193,31 @@ void salesSummaryKeepsTicketsScopedToOwnEmployee() { @ParameterizedTest @ValueSource(strings = {"account", "ceo", "sales_manager"}) - void cashReceivedThisMonthIsServedToTheMoneyRoles(String role) { - when(repository.cashReceived(LocalDate.of(2026, 7, 1))).thenReturn(new BigDecimal("125000.50")); - when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))) - .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50")))); + void cashReceivedThisMonthIsTheSumOfThePerRepRowsForTheMoneyRoles(String role) { + when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))).thenReturn(List.of( + new CashByRepDto("สมชาย ขายดี", new BigDecimal("90000.50")), + new CashByRepDto("สมหญิง ขายเก่ง", new BigDecimal("35000.00")))); DashboardSummaryDto summary = service.summary(user(role, 40L, null, false)); assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("125000.50"); - assertThat(summary.cashReceivedByRep()) - .containsExactly(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50"))); + assertThat(summary.cashReceivedByRep()).hasSize(2); + } + + @ParameterizedTest + @ValueSource(strings = {"account", "ceo", "sales_manager"}) + void aMonthWithNoReceiptsIsZeroNotNull(String role) { + when(repository.cashReceivedByRep(any())).thenReturn(List.of()); + + DashboardSummaryDto summary = service.summary(user(role, 40L, null, false)); + + assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("0"); + assertThat(summary.cashReceivedByRep()).isEmpty(); } @ParameterizedTest @ValueSource(strings = {"hr", "import", "sales", "employee"}) - void cashReceivedThisMonthIsNullForEveryOtherRoleAndNeverQueried(String role) { - when(repository.cashReceived(any())).thenReturn(new BigDecimal("125000.50")); + void cashReceivedIsNullForEveryOtherRoleAndNeverQueried(String role) { when(repository.cashReceivedByRep(any())) .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("125000.50")))); @@ -216,23 +225,20 @@ void cashReceivedThisMonthIsNullForEveryOtherRoleAndNeverQueried(String role) { assertThat(summary.cashReceivedThisMonth()).isNull(); assertThat(summary.cashReceivedByRep()).isNull(); - verify(repository, never()).cashReceived(any()); verify(repository, never()).cashReceivedByRep(any()); } @Test - void cashReceivedThisMonthUsesTheBangkokMonthEvenWhenUtcIsStillLastMonth() { + void cashReceivedUsesTheBangkokMonthEvenWhenUtcIsStillLastMonth() { // 2026-06-30T20:30Z is already 2026-07-01 03:30 in Bangkok. DashboardService boundary = new DashboardService( repository, Clock.fixed(Instant.parse("2026-06-30T20:30:00Z"), BANGKOK)); - when(repository.cashReceived(LocalDate.of(2026, 7, 1))).thenReturn(new BigDecimal("1.00")); when(repository.cashReceivedByRep(LocalDate.of(2026, 7, 1))) .thenReturn(List.of(new CashByRepDto("สมชาย ขายดี", new BigDecimal("1.00")))); DashboardSummaryDto summary = boundary.summary(user("account", 42L, null, false)); assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("1.00"); - assertThat(summary.cashReceivedByRep()).hasSize(1); } private UserPrincipal user(String role, Long employeeId, Long divisionId, boolean manager) { diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 4c4edaaac..61eb37690 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -3883,10 +3883,6 @@ function mockReceiptsThisMonth() { return (db.paymentReceipts ?? []).filter((r) => bangkokMonth(r.receivedAt) === thisMonth); } -function mockCashReceivedThisMonth() { - return moneyValue(mockReceiptsThisMonth().reduce(signedReceiptSum, 0)); -} - // Mirrors DashboardRepository#cashReceivedByRep: this month's receipts grouped by the deal creator's id, // labelled with the creator's name (blank -> ไม่ระบุผู้ขาย). function mockCashReceivedByRep() { @@ -12249,6 +12245,7 @@ export const api = { const tickets = dashboardTickets(user); const pendingApprovals = dashboardPending(user, tickets); const notifications = dashboardNotifications(user); + const cashByRep = ['account', 'ceo', 'sales_manager'].includes(user.role) ? mockCashReceivedByRep() : null; return delay({ summary: { role: user.role, @@ -12277,12 +12274,9 @@ export const api = { partiallyDelivered: tickets.partiallyDelivered, // Mirrors DashboardSummaryDto#cashReceivedThisMonth: receipts in the current Bangkok month // (deposit + balance - adjustment), only for the roles that see company money. - cashReceivedThisMonth: ['account', 'ceo', 'sales_manager'].includes(user.role) - ? mockCashReceivedThisMonth() - : null, - cashReceivedByRep: ['account', 'ceo', 'sales_manager'].includes(user.role) - ? mockCashReceivedByRep() - : null, + // The month figure is the sum of the per-rep rows, as DashboardService totals them. + cashReceivedThisMonth: cashByRep ? moneyValue(cashByRep.reduce((sum, row) => sum + row.amount, 0)) : null, + cashReceivedByRep: cashByRep, }, }); }, diff --git a/frontend/src/features/dashboard/AccountOverview.jsx b/frontend/src/features/dashboard/AccountOverview.jsx index 3248a31ba..73eeb1cb9 100644 --- a/frontend/src/features/dashboard/AccountOverview.jsx +++ b/frontend/src/features/dashboard/AccountOverview.jsx @@ -22,10 +22,11 @@ import { financeOutstanding } from './dashboardMoney.js'; // Order matches the plan's original layout: // overdue leads (most urgent), then the two payment-confirmation steps, then // close-ready, then the folded commission step. `amountField` picks which -// ticket money field the bucket sums: outstanding balance for the three -// pending-money buckets, deal value (amountPayable) for the two "money is -// already secured, just needs admin follow-through" buckets where -// amountOutstanding is 0 by definition. +// ticket money field the bucket sums: the deposit notice total (depositDue) +// for รอรับมัดจำ, outstanding balance for เกินกำหนด and รอชำระส่วนที่เหลือ, +// deal value (amountPayable) for the two "money is already secured, just +// needs admin follow-through" buckets where amountOutstanding is 0 by +// definition. const MONEY_BUCKETS = [ { key: 'overdue', label: 'เกินกำหนด', icon: 'triangleAlert', tone: 'rose', amountField: 'amountOutstanding', span: 'col-span-2 row-span-2 nav-drawer:row-span-1' }, { key: 'depositPending', label: 'รอรับมัดจำ', icon: 'clock', tone: 'amber', amountField: 'depositDue', span: 'col-span-2 nav-drawer:col-span-1' }, diff --git a/frontend/src/features/dashboard/CeoOverview.test.jsx b/frontend/src/features/dashboard/CeoOverview.test.jsx index b6d64a597..ced04996a 100644 --- a/frontend/src/features/dashboard/CeoOverview.test.jsx +++ b/frontend/src/features/dashboard/CeoOverview.test.jsx @@ -7,7 +7,7 @@ import { CeoOverview } from './CeoOverview.jsx'; import { api } from '../../api/index.js'; import { bangkokMonthStartIso } from '../../utils/format.js'; import { - CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, + CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, } from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -41,10 +41,7 @@ const dashboardSummary = { attendance: { lateToday: 3 }, cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, // This month's cash split per sales rep: the two add up to CASH_RECEIVED_THIS_MONTH (125,000.50). - cashReceivedByRep: [ - { repName: 'สมชาย ขายดี', amount: 90000.5 }, - { repName: 'สมหญิง ขายเก่ง', amount: 35000 }, - ], + cashReceivedByRep: CASH_BY_REP, }; function mockAllData() { @@ -311,6 +308,14 @@ describe('CeoOverview', () => { expect(within(panel).queryByText(/%/)).toBeNull(); }); + it('sorts the rep rows by amount, largest first, whatever order the server sent', async () => { + renderOverview(); + + const panel = (await screen.findByText('ยอดตามทีม/ฝ่าย')).closest('section'); + const names = [...panel.querySelectorAll('span.truncate')].map((el) => el.textContent); + expect(names).toEqual(['สมชาย ขายดี', 'สมหญิง ขายเก่ง']); + }); + it('lists every rep so the bars still add up to เงินรับแล้ว with more than eight reps', async () => { const reps = Array.from({ length: 9 }, (_, i) => ({ repName: `เซลส์ ${i + 1}`, amount: 1000 })); renderOverview({ ...dashboardSummary, cashReceivedThisMonth: 9000, cashReceivedByRep: reps }); diff --git a/frontend/src/features/dashboard/ManagerOverview.jsx b/frontend/src/features/dashboard/ManagerOverview.jsx index 748dddfd0..b0e0e0181 100644 --- a/frontend/src/features/dashboard/ManagerOverview.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.jsx @@ -21,11 +21,6 @@ import { financeOutstanding, forecastValue } from './dashboardMoney.js'; // exactly like CommissionPage's own `totals.submitted` does. const TEAM_COMMISSIONS_QUERY_KEY = ['commissions', 'list', 'submitted']; -function startOfMonth() { - const now = new Date(); - return new Date(now.getFullYear(), now.getMonth(), 1); -} - // A deal is "won" once it reaches the terminal pipeline stage, "lost" once // its lifecycle/legacy status marks it lost or cancelled, else "open" — // mirrors the same three-way split features/tickets/salesViewScope.js's @@ -42,10 +37,6 @@ function isDealOpen(ticket) { return !isDealWon(ticket) && !isDealLost(ticket) && ticket.status !== 'closed'; } -function dealAmount(ticket) { - return Number(ticket.amountPayable ?? 0); -} - /** * Sales Manager role-scoped landing (team cockpit): pulse (team ยอด this * month / commissions awaiting the manager's own review / deals needing @@ -119,11 +110,6 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast }) const ticketsById = useMemo(() => new Map(tickets.map((t) => [t.id, t])), [tickets]); - const monthStart = useMemo(() => startOfMonth(), []); - const closedThisMonth = useMemo( - () => tickets.filter((t) => isDealWon(t) && t.stageUpdatedAt && new Date(t.stageUpdatedAt) >= monthStart), - [tickets, monthStart], - ); const openTickets = useMemo(() => tickets.filter(isDealOpen), [tickets]); const outstanding = useMemo(() => financeOutstanding(tickets), [tickets]); const forecast = useMemo(() => forecastValue(tickets), [tickets]); @@ -135,8 +121,7 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast }) // overdue (`ticket.overdue`), or a follow-up date already in the past. const attentionTickets = useMemo(() => { // `new Date()` (not `Date.now()`, which the react-hooks purity rule - // flags as a well-known impure call inside render) — same pattern this - // file's own `startOfMonth()` already uses above. + // flags as a well-known impure call inside render). const now = new Date(); return openTickets .filter((t) => t.stale || t.overdue || (t.nextFollowUpAt && new Date(t.nextFollowUpAt) < now)) @@ -153,17 +138,13 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast }) const lostCount = useMemo(() => tickets.filter(isDealLost).length, [tickets]); const closeRate = wonCount + lostCount > 0 ? (wonCount / (wonCount + lostCount)) * 100 : null; - const leaderboard = useMemo(() => { - const byRep = new Map(); - closedThisMonth.forEach((t) => { - const key = t.createdById ?? t.createdByName ?? 'unknown'; - const current = byRep.get(key) || { id: key, name: t.createdByName || 'ไม่ระบุ', amount: 0, count: 0 }; - current.amount += dealAmount(t); - current.count += 1; - byRep.set(key, current); - }); - return [...byRep.values()].sort((a, b) => b.amount - a.amount).slice(0, 5); - }, [closedThisMonth]); + // Same rows as the CEO's ยอดตามทีม/ฝ่าย panel: this month's cash received per rep, from the summary. + const leaderboard = useMemo( + () => [...(dashboardSummary?.cashReceivedByRep ?? [])] + .map((row) => ({ name: row.repName, amount: Number(row.amount) || 0 })) + .sort((a, b) => b.amount - a.amount), + [dashboardSummary?.cashReceivedByRep], + ); const leaderboardMax = leaderboard[0]?.amount || 0; const firstName = employee?.nickName || employee?.nameTh || user?.name || ''; @@ -329,11 +310,11 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast })
    {leaderboard.length === 0 ? ( -

    ยังไม่มีดีลปิดในเดือนนี้

    +

    ยังไม่มีเงินรับเข้าในเดือนนี้

    ) : (
    {leaderboard.map((rep, index) => ( -
    +
    {index + 1}. {rep.name} {formatMoney(rep.amount)} diff --git a/frontend/src/features/dashboard/ManagerOverview.test.jsx b/frontend/src/features/dashboard/ManagerOverview.test.jsx index e3e952662..ca524bfd6 100644 --- a/frontend/src/features/dashboard/ManagerOverview.test.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.test.jsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ManagerOverview } from './ManagerOverview.jsx'; import { api } from '../../api/index.js'; import { - CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, + CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, } from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -91,8 +91,6 @@ describe('ManagerOverview', () => { it('renders the team pulse derived from the team ticket list', async () => { renderOverview(); await screen.findByText('ดีลต้องดูแล'); - // The leaderboard row for the same rep/deal carries the won deal's ฿300,000.00. - expect(screen.getAllByText('฿300,000.00').length).toBeGreaterThan(0); }); it('renders the ค่าคอมรออนุมัติ worklist with a review/approve CTA, SUBMITTED only', async () => { @@ -109,11 +107,28 @@ describe('ManagerOverview', () => { expect(within(panel).queryByText(/INV-2026-0302/)).toBeNull(); }); - it('renders the leaderboard and close-rate in the right rail', async () => { - renderOverview(); + it('ranks the leaderboard by this month\'s cash per rep, the same rows as the CEO panel', async () => { + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, cashReceivedByRep: CASH_BY_REP }); const heading = await screen.findByRole('heading', { name: 'อันดับทีม (เดือนนี้)' }); const panel = heading.closest('section'); - expect(within(panel).getByText(/เซลส์ เอ/)).not.toBeNull(); + + const rows = [...panel.querySelectorAll('strong')].map((el) => el.textContent); + expect(rows).toEqual(['฿90,000.50', '฿35,000.00']); + expect(within(panel).getByText('1. สมชาย ขายดี')).not.toBeNull(); + expect(within(panel).getByText('2. สมหญิง ขายเก่ง')).not.toBeNull(); + // The old basis -- the won deal's amountPayable (฿300,000.00) -- is gone. + expect(within(panel).queryByText('฿300,000.00')).toBeNull(); + }); + + it('shows the leaderboard empty state when no cash came in', async () => { + renderOverview({ cashReceivedThisMonth: 0, cashReceivedByRep: [] }); + const heading = await screen.findByRole('heading', { name: 'อันดับทีม (เดือนนี้)' }); + expect(within(heading.closest('section')).getByText('ยังไม่มีเงินรับเข้าในเดือนนี้')).not.toBeNull(); + }); + + it('renders the close-rate in the right rail', async () => { + renderOverview(); + await screen.findByRole('heading', { name: 'อันดับทีม (เดือนนี้)' }); expect(screen.getByText('อัตราปิดงาน')).not.toBeNull(); }); diff --git a/frontend/src/test/fixtures/dashboardMoney.js b/frontend/src/test/fixtures/dashboardMoney.js index 20391ce78..790e60981 100644 --- a/frontend/src/test/fixtures/dashboardMoney.js +++ b/frontend/src/test/fixtures/dashboardMoney.js @@ -46,6 +46,11 @@ export const moneyFixtureTickets = [ ]; export const CASH_RECEIVED_THIS_MONTH = 125000.5; +// Per-rep split of CASH_RECEIVED_THIS_MONTH, served ascending on purpose: the panels must sort it themselves. +export const CASH_BY_REP = [ + { repName: 'สมหญิง ขายเก่ง', amount: 35000 }, + { repName: 'สมชาย ขายดี', amount: 90000.5 }, +]; export const EXPECTED = { cash: '฿125,000.50', outstanding: '฿293,545.25', // 213,545.25 + 80,000 — not the closed, pre-order or lost deals From 8937ea437a0a382c99c2d34c8985afbbf5af9dd0 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:05:17 +0700 Subject: [PATCH 10/24] =?UTF-8?q?fix(dashboard):=20=E0=B8=84=E0=B9=89?= =?UTF-8?q?=E0=B8=B2=E0=B8=87=E0=B8=A3=E0=B8=B1=E0=B8=9A=20and=20=E0=B8=A2?= =?UTF-8?q?=E0=B8=AD=E0=B8=94=E0=B8=84=E0=B8=B2=E0=B8=94=E0=B8=81=E0=B8=B2?= =?UTF-8?q?=E0=B8=A3=E0=B8=93=E0=B9=8C=20as=20server=20totals=20(no=20500-?= =?UTF-8?q?row=20cap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit financeOutstanding / forecastValue on /api/dashboard/summary for account/ceo/sales_manager, one SQL reusing payableAmountSelect, paidAmountSelect, paymentOverduePredicate, the account list scope and the commission-recorded fragment. Account, CEO, SM and the /finance header read the same figure (CEO /finance hero relabelled ค้างรับทั้งหมด). Shared case table money-cases.json pins the SQL against nextAccountAction. Co-Authored-By: Claude Opus 5.5 --- .../glr/hr/dashboard/DashboardRepository.java | 33 ++ .../co/glr/hr/dashboard/DashboardService.java | 4 +- .../glr/hr/dashboard/DashboardSummaryDto.java | 16 +- .../th/co/glr/hr/ticket/TicketRepository.java | 46 ++- .../DashboardMoneyTotalsIntegrationTest.java | 173 ++++++++++ .../hr/dashboard/DashboardServiceTest.java | 26 ++ .../test/resources/dashboard/money-cases.json | 305 ++++++++++++++++++ docs/api/response-fields.json | 6 +- frontend/src/api/mockApi.js | 21 ++ .../features/dashboard/AccountOverview.jsx | 7 +- .../dashboard/AccountOverview.test.jsx | 25 +- .../src/features/dashboard/CeoOverview.jsx | 9 +- .../features/dashboard/CeoOverview.test.jsx | 12 +- .../features/dashboard/ManagerOverview.jsx | 9 +- .../dashboard/ManagerOverview.test.jsx | 13 +- .../src/features/dashboard/dashboardMoney.js | 31 -- .../features/dashboard/dashboardMoney.test.js | 78 ----- .../src/features/dashboard/moneyCases.test.js | 45 +++ .../features/finance/AccountFinancePage.jsx | 19 +- .../finance/AccountFinancePage.test.jsx | 17 +- frontend/src/test/fixtures/dashboardMoney.js | 7 +- 21 files changed, 734 insertions(+), 168 deletions(-) create mode 100644 backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java create mode 100644 backend/src/test/resources/dashboard/money-cases.json delete mode 100644 frontend/src/features/dashboard/dashboardMoney.js delete mode 100644 frontend/src/features/dashboard/dashboardMoney.test.js create mode 100644 frontend/src/features/dashboard/moneyCases.test.js diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java index 925421381..d89955d69 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardRepository.java @@ -215,6 +215,39 @@ public java.util.List cashReceivedByRep(LocalDate monthStart) { (rs, n) -> new CashByRepDto(rs.getString("rep_name"), rs.getBigDecimal("amount"))); } + /** + * ฝ่ายบัญชี has something to do on the deal: the SQL twin of {@code nextAccountAction} for an account + * viewer (accountActions.js) -- overdue; a deposit or final payment to take; or CLOSED_PAID with no + * commission recorded yet. Close-ready deals owe nothing, so they add nothing to an outstanding total. + * {@code dashboard/money-cases.json} pins this against the JS rule. + */ + private static final String ACCOUNT_HAS_TODO = "(" + + "(t.status = 'quotation_issued' AND (" + + "t.payment_status IN ('DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT', 'DEPOSIT_PAID') " + + "OR (t.deposit_policy IN (:bypassPolicies) AND (t.payment_status IS NULL OR t.payment_status = 'CUSTOMER_CONFIRMED')))) " + + "OR (t.sales_stage = 'CLOSED_PAID' AND NOT " + TicketRepository.hasRecordedCommission("t.ticket_id") + ") " + + "OR " + TicketRepository.paymentOverduePredicate("t.ticket_id") + ")"; + + /** /finance's คงค้างรวม: outstanding on every deal in account's list scope that has an account to-do. */ + public java.math.BigDecimal financeOutstanding() { + MapSqlParameterSource params = new MapSqlParameterSource() + .addValue("bypassPolicies", th.co.glr.hr.ticket.DepositPolicy.NON_REQUIRED); + TicketRepository.addAccountScopeParams(params); + return jdbc.queryForObject("SELECT COALESCE(SUM(GREATEST((" + TicketRepository.payableAmountSelect("t.ticket_id") + + ") - (" + TicketRepository.paidAmountSelect("t.ticket_id") + "), 0)), 0) FROM sales.ticket t WHERE " + + TicketRepository.accountScopePredicate() + " AND " + ACCOUNT_HAS_TODO, params, java.math.BigDecimal.class); + } + + /** ยอดคาดการณ์: payable of live deals that have not reached ORDER_RECEIVED. */ + public java.math.BigDecimal forecastValue() { + MapSqlParameterSource params = new MapSqlParameterSource().addValue("beforeOrder", + th.co.glr.hr.ticket.DealStage.ORDER.subList(0, + th.co.glr.hr.ticket.DealStage.indexOf(th.co.glr.hr.ticket.DealStage.ORDER_RECEIVED))); + return jdbc.queryForObject("SELECT COALESCE(SUM(" + "(" + TicketRepository.payableAmountSelect("t.ticket_id") + ")" + + "), 0) FROM sales.ticket t WHERE t.lifecycle = 'ACTIVE' AND t.sales_stage IN (:beforeOrder) " + + "AND t.status NOT IN ('closed', 'cancelled')", params, java.math.BigDecimal.class); + } + public NotificationSummaryDto notifications(long employeeId) { NotificationSummaryDto summary = jdbc.queryForObject(""" SELECT COUNT(*) FILTER (WHERE is_read = FALSE) AS unread, diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java index f9bd507ba..008501aa3 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardService.java @@ -74,7 +74,9 @@ public DashboardSummaryDto summary(UserPrincipal user) { tickets, notifications, cashReceived, - cashByRep + cashByRep, + seesCash ? dashboardRepository.financeOutstanding() : null, + seesCash ? dashboardRepository.forecastValue() : null ); } diff --git a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java index 4ae916a9f..a8f42b053 100644 --- a/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java +++ b/backend/src/main/java/th/co/glr/hr/dashboard/DashboardSummaryDto.java @@ -34,7 +34,11 @@ public record DashboardSummaryDto( /** Payment receipts dated this Bangkok month (deposit + balance - adjustment); null unless account/ceo/sales_manager. */ BigDecimal cashReceivedThisMonth, /** This month's net cash split per deal creator (sums to cashReceivedThisMonth); null unless account/ceo/sales_manager. */ - List cashReceivedByRep + List cashReceivedByRep, + /** /finance's คงค้างรวม as a server total (account-scope deals with an account to-do); null unless account/ceo/sales_manager. */ + BigDecimal financeOutstanding, + /** Payable of ACTIVE deals before ORDER_RECEIVED; null unless account/ceo/sales_manager. */ + BigDecimal forecastValue ) { public static DashboardSummaryDto of( String role, @@ -50,7 +54,7 @@ public static DashboardSummaryDto of( NotificationSummaryDto notifications ) { return of(role, employeeId, divisionId, manager, generatedAt, headcount, pendingApprovals, - attendance, latestPayrollPeriodId, tickets, notifications, null, null); + attendance, latestPayrollPeriodId, tickets, notifications, null, null, null, null); } public static DashboardSummaryDto of( @@ -66,7 +70,9 @@ public static DashboardSummaryDto of( TicketSummaryDto tickets, NotificationSummaryDto notifications, BigDecimal cashReceivedThisMonth, - List cashReceivedByRep + List cashReceivedByRep, + BigDecimal financeOutstanding, + BigDecimal forecastValue ) { return new DashboardSummaryDto( role, @@ -94,7 +100,9 @@ public static DashboardSummaryDto of( tickets.paymentOverdue(), tickets.partiallyDelivered(), cashReceivedThisMonth, - cashReceivedByRep + cashReceivedByRep, + financeOutstanding, + forecastValue ); } } diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index cdb789a07..74b1542a5 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java @@ -226,20 +226,30 @@ OR t.sales_stage IN (:importStages) params.addValue("prTerminalStatuses", PRICING_REQUEST_TERMINAL_STATUSES); params.addValue("importStages", IMPORT_STAGE_SCOPE); } else if ("account".equals(actorRole)) { - sql.append(""" - AND ( - (t.lifecycle NOT IN ('CLOSED_LOST', 'CANCELLED') - AND t.status <> 'cancelled' - AND t.sales_stage IN (:accountOrderStages)) - OR t.payment_status IN (:accountPendingStatuses) - OR (""") - .append(paymentOverduePredicate("t.ticket_id")) - .append(")) "); - params.addValue("accountOrderStages", ACCOUNT_ORDER_STAGE_SCOPE); - params.addValue("accountPendingStatuses", ACCOUNT_PENDING_PAYMENT_STATUSES); + sql.append(" AND ").append(accountScopePredicate()).append(' '); + addAccountScopeParams(params); } } + /** + * Account's list scope as a parenthesised predicate over {@code t} ({@code sales.ticket}): the one text + * both the account deal list and the dashboard totals run. Needs {@link #addAccountScopeParams}. + */ + public static String accountScopePredicate() { + return """ + ( + (t.lifecycle NOT IN ('CLOSED_LOST', 'CANCELLED') + AND t.status <> 'cancelled' + AND t.sales_stage IN (:accountOrderStages)) + OR t.payment_status IN (:accountPendingStatuses) + OR (""" + paymentOverduePredicate("t.ticket_id") + "))\n"; + } + + public static void addAccountScopeParams(MapSqlParameterSource params) { + params.addValue("accountOrderStages", ACCOUNT_ORDER_STAGE_SCOPE); + params.addValue("accountPendingStatuses", ACCOUNT_PENDING_PAYMENT_STATUSES); + } + /** * Is this deal inside the account role's list scope? The single-deal twin of the list * predicate: it runs the SAME {@link #appendRoleScope} fragment (and, like the list since the @@ -1276,14 +1286,18 @@ SELECT COUNT(*) FROM sales.attachment * .hasActiveCommissionForTicket} always agree. If you change one predicate you MUST change the * other, and that test is what catches it if you forget. */ - private static final String HAS_RECORDED_COMMISSION = """ + public static String hasRecordedCommission(String ticketIdRef) { + return """ EXISTS( SELECT 1 FROM sales.commission_record - WHERE source_ticket_id = :ticketId + WHERE source_ticket_id = %s AND kind = 'SALE' AND status NOT IN ('VOID', 'REJECTED') ) - """; + """.formatted(ticketIdRef); + } + + private static final String HAS_RECORDED_COMMISSION = hasRecordedCommission(":ticketId"); private record CommissionAndLiveQuotation(boolean commissionRecorded, LiveDirectQuotationDto liveDirectQuotation, @@ -1742,7 +1756,7 @@ private static String issuedDocumentsPayable(String ticketIdRef) { * bigger SQL statement — see {@link #appendRoleScope}). Single source of truth so the two * contexts can never drift apart; this is the exact SQL {@link #payableAmount} always ran. */ - private static String payableAmountSelect(String ticketIdRef) { + public static String payableAmountSelect(String ticketIdRef) { return payableAmountSelect(ticketIdRef, true); } @@ -1911,7 +1925,7 @@ public Optional paymentDue(long ticketId) { } /** Full "amount paid" SELECT statement — see {@link #payableAmountSelect} for the correlation contract. */ - private static String paidAmountSelect(String ticketIdRef) { + public static String paidAmountSelect(String ticketIdRef) { return """ SELECT %2$s FROM sales.payment_receipt diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java new file mode 100644 index 000000000..e3b50d4f9 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardMoneyTotalsIntegrationTest.java @@ -0,0 +1,173 @@ +package th.co.glr.hr.dashboard; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.InputStream; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.support.AbstractPostgresIntegrationTest; +import th.co.glr.hr.ticket.CreateTicketRequest; +import th.co.glr.hr.ticket.TicketRepository; + +/** + * The server totals behind the money tiles and /finance's คงค้างรวม: {@code financeOutstanding} (outstanding + * on deals in account's list scope that have an account to-do) and {@code forecastValue} (payable of live + * deals before ORDER_RECEIVED), summed in SQL over EVERY deal -- not over the first page of /api/tickets. + * + *

    The deal shapes come from {@code dashboard/money-cases.json}, the same table + * {@code moneyCases.test.js} checks the frontend rule (account scope + nextAccountAction) against, so the + * SQL and the JS cannot drift apart unnoticed. Each case is built, the totals are read before and after, + * and the delta must be its outstanding / payable or nothing. + */ +class DashboardMoneyTotalsIntegrationTest extends AbstractPostgresIntegrationTest { + private static final ZoneId BKK = ZoneId.of("Asia/Bangkok"); + + private DashboardRepository dashboard; + private TicketRepository tickets; + private long repId; + private int seq; + + @BeforeEach + void seed() { + dashboard = new DashboardRepository(jdbc); + tickets = new TicketRepository(jdbc); + long divisionId = jdbc.queryForObject(""" + INSERT INTO hr.division (source_code, name_th) VALUES ('SA-MT', 'Sales MT') RETURNING division_id + """, Map.of(), Number.class).longValue(); + repId = jdbc.queryForObject(""" + INSERT INTO hr.employee (employee_code, first_name_th, division_id, is_active) + VALUES ('EMP-MT', 'EMP-MT', :divisionId, TRUE) RETURNING employee_id + """, Map.of("divisionId", divisionId), Number.class).longValue(); + } + + @Test + void everyCaseInTheSharedTableMovesTheTotalsExactlyAsExpected() throws Exception { + JsonNode table; + try (InputStream in = getClass().getResourceAsStream("/dashboard/money-cases.json")) { + table = new ObjectMapper().readTree(in); + } + BigDecimal payable = table.get("payable").decimalValue(); + + for (JsonNode c : table.get("cases")) { + BigDecimal outstandingBefore = dashboard.financeOutstanding(); + BigDecimal forecastBefore = dashboard.forecastValue(); + + BigDecimal paid = c.get("paid").decimalValue(); + buildDeal(c, payable); + BigDecimal expectedOutstanding = c.get("countsInOutstanding").asBoolean() + ? payable.subtract(paid).max(BigDecimal.ZERO) : BigDecimal.ZERO; + BigDecimal expectedForecast = c.get("countsInForecast").asBoolean() ? payable : BigDecimal.ZERO; + assertThat(dashboard.financeOutstanding().subtract(outstandingBefore)) + .as("financeOutstanding delta: %s", c.get("name").asText()).isEqualByComparingTo(expectedOutstanding); + assertThat(dashboard.forecastValue().subtract(forecastBefore)) + .as("forecastValue delta: %s", c.get("name").asText()).isEqualByComparingTo(expectedForecast); + } + } + + /** Guards the builder: each fixture is the shape its case names, as /finance's own row sees it. */ + @Test + void theBuiltDealsAreTheShapesTheCasesName() throws Exception { + JsonNode table; + try (InputStream in = getClass().getResourceAsStream("/dashboard/money-cases.json")) { + table = new ObjectMapper().readTree(in); + } + BigDecimal payable = table.get("payable").decimalValue(); + for (JsonNode c : table.get("cases")) { + long id = buildDeal(c, payable); + var row = tickets.findSummaryById(id).orElseThrow(); + assertThat(row.amountPayable()).as("payable: %s", c.get("name").asText()).isEqualByComparingTo(payable); + assertThat(row.overdue()).as("overdue: %s", c.get("name").asText()).isEqualTo(c.get("overdue").asBoolean()); + assertThat(row.commissionRecorded()).as("commission: %s", c.get("name").asText()) + .isEqualTo(c.get("commission").asBoolean()); + } + } + + @Test + void totalsIncludeEveryDeal_notJustTheFirstFiveHundred() { + int deals = 505; // /api/tickets pages at 500 + BigDecimal payable = new BigDecimal("107000.00"); + for (int i = 0; i < deals; i++) { + long id = newDeal(); + acceptedQuotation(id); + // a deposit notice is out, before ORDER_RECEIVED: counted by both totals + jdbc.update(""" + UPDATE sales.ticket SET status = 'quotation_issued', payment_status = 'DEPOSIT_NOTICE_ISSUED', sales_stage = 'QUOTE_BUYER' + WHERE ticket_id = :id""", Map.of("id", id)); + } + + assertThat(dashboard.financeOutstanding()).isEqualByComparingTo(payable.multiply(BigDecimal.valueOf(deals))); + assertThat(dashboard.forecastValue()).isEqualByComparingTo(payable.multiply(BigDecimal.valueOf(deals))); + } + + // ── builders ──────────────────────────────────────────────────────────────────────────── + + private long newDeal() { + seq++; + return tickets.create( + new CreateTicketRequest("ดีลทดสอบ " + seq, "NORMAL", "ลูกค้าทดสอบ", null, null, null, null, null, List.of()), + tickets.nextTicketCode(), repId, "EMP-MT"); + } + + /** A quotation whose subtotal is 100,000, so the VAT-inclusive payable is 107,000. */ + private void acceptedQuotation(long ticketId) { + long q = tickets.createQuotation(ticketId, "QT-MT-" + ticketId, repId, new BigDecimal("100000.00")).id(); + jdbc.update("UPDATE sales.quotation SET doc_status = 'ACCEPTED' WHERE quotation_id = :q", Map.of("q", q)); + } + + private long buildDeal(JsonNode c, BigDecimal payable) { + long id = newDeal(); + acceptedQuotation(id); + MapSqlParameterSource p = new MapSqlParameterSource("id", id) + .addValue("lifecycle", c.get("lifecycle").asText()) + .addValue("status", c.get("status").asText()) + .addValue("stage", c.get("salesStage").asText()) + .addValue("payment", c.get("paymentStatus").isNull() ? null : c.get("paymentStatus").asText()) + .addValue("policy", c.get("depositPolicy").asText()); + jdbc.update(""" + UPDATE sales.ticket SET lifecycle = :lifecycle, status = :status, sales_stage = :stage, + payment_status = :payment, deposit_policy = :policy + WHERE ticket_id = :id""", p); + BigDecimal paid = c.get("paid").decimalValue(); + if (paid.signum() > 0) { + jdbc.update(""" + INSERT INTO sales.payment_receipt (ticket_id, kind, amount, currency, received_at, recorded_by, receipt_ref) + VALUES (:id, 'DEPOSIT', :a, 'THB', now(), :by, :ref)""", new MapSqlParameterSource("id", id) + .addValue("a", paid).addValue("by", repId).addValue("ref", "MT-" + id)); + } + if (c.get("overdue").asBoolean()) { + // credit 30 days from a delivery 31 days ago -> the balance is a day late + jdbc.update("UPDATE sales.quotation SET remainder_mode = 'CREDIT', credit_days = 30 WHERE ticket_id = :id", + Map.of("id", id)); + jdbc.update("UPDATE sales.ticket SET fulfillment_status = 'FULLY_DELIVERED' WHERE ticket_id = :id", + Map.of("id", id)); + jdbc.update(""" + INSERT INTO sales.delivery_record (ticket_id, source, delivered_at, delivered_by) + VALUES (:id, 'WAREHOUSE', :at, :by)""", new MapSqlParameterSource("id", id) + .addValue("at", Timestamp.from(LocalDate.now(BKK).minusDays(31).atTime(12, 0).atZone(BKK).toInstant())) + .addValue("by", repId)); + } + if (c.get("commission").asBoolean()) { + long invoice = jdbc.queryForObject(""" + INSERT INTO sales.invoice_details (invoice_number, invoice_date, gross_amount, evidence_provenance) + VALUES (:n, CURRENT_DATE, 1000.00, 'PRE_ERP_PAPER_APPROVAL') RETURNING invoice_id""", + Map.of("n", "MT-INV-" + id), Long.class); + jdbc.update(""" + INSERT INTO sales.commission_record + (invoice_id, source_ticket_id, sales_rep_id, submitted_by_id, kind, status, + payroll_month, actual_received, commissionable_base) + VALUES (:inv, :id, :rep, :rep, 'SALE', 'SUBMITTED', + date_trunc('month', CURRENT_DATE)::date, 1000.00, 1000.00)""", + new MapSqlParameterSource("inv", invoice).addValue("id", id).addValue("rep", repId)); + } + return id; + } +} diff --git a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java index 026bfc93c..bc6cdbf3a 100644 --- a/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/dashboard/DashboardServiceTest.java @@ -241,6 +241,32 @@ void cashReceivedUsesTheBangkokMonthEvenWhenUtcIsStillLastMonth() { assertThat(summary.cashReceivedThisMonth()).isEqualByComparingTo("1.00"); } + @ParameterizedTest + @ValueSource(strings = {"account", "ceo", "sales_manager"}) + void financeOutstandingAndForecastAreServerTotalsForTheMoneyRoles(String role) { + when(repository.financeOutstanding()).thenReturn(new BigDecimal("293545.25")); + when(repository.forecastValue()).thenReturn(new BigDecimal("80000.25")); + + DashboardSummaryDto summary = service.summary(user(role, 43L, null, false)); + + assertThat(summary.financeOutstanding()).isEqualByComparingTo("293545.25"); + assertThat(summary.forecastValue()).isEqualByComparingTo("80000.25"); + } + + @ParameterizedTest + @ValueSource(strings = {"hr", "import", "sales", "employee"}) + void financeOutstandingAndForecastAreNullForOtherRolesAndNeverQueried(String role) { + when(repository.financeOutstanding()).thenReturn(new BigDecimal("1.00")); + when(repository.forecastValue()).thenReturn(new BigDecimal("1.00")); + + DashboardSummaryDto summary = service.summary(user(role, 44L, null, false)); + + assertThat(summary.financeOutstanding()).isNull(); + assertThat(summary.forecastValue()).isNull(); + verify(repository, never()).financeOutstanding(); + verify(repository, never()).forecastValue(); + } + private UserPrincipal user(String role, Long employeeId, Long divisionId, boolean manager) { return new UserPrincipal( employeeId == null ? 99L : employeeId, diff --git a/backend/src/test/resources/dashboard/money-cases.json b/backend/src/test/resources/dashboard/money-cases.json new file mode 100644 index 000000000..e73206e6c --- /dev/null +++ b/backend/src/test/resources/dashboard/money-cases.json @@ -0,0 +1,305 @@ +{ + "comment": "Shared by dashboardMoneyCases.test.js (JS rule) and DashboardMoneyTotalsIntegrationTest (SQL). Every deal has payable 107000 (quotation subtotal 100000 + 7% VAT); outstanding = max(107000 - paid, 0).", + "payable": 107000, + "cases": [ + { + "name": "deposit-pending: whole outstanding", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": "DEPOSIT_NOTICE_ISSUED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit-notice issued before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": "DEPOSIT_NOTICE_ISSUED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": true + }, + { + "name": "deposit paid", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "DELIVERY_SCHEDULING", + "paymentStatus": "DEPOSIT_PAID", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 40000, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "awaiting final payment", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "DELIVERED", + "paymentStatus": "AWAITING_FINAL_PAYMENT", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 50000, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit bypassed (NOT_REQUIRED) at ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit waived, customer confirmed, at PROCUREMENT", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "PROCUREMENT", + "paymentStatus": "CUSTOMER_CONFIRMED", + "depositPolicy": "WAIVED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "credit customer at ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": null, + "depositPolicy": "CREDIT_CUSTOMER", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "deposit bypassed before ORDER_RECEIVED: out of account scope", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": true + }, + { + "name": "overdue before ORDER_RECEIVED: overdue is its own scope branch", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": "CUSTOMER_CONFIRMED", + "depositPolicy": "REQUIRED", + "overdue": true, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": true + }, + { + "name": "DEMO-TKT-05 shape: CLOSED_PAID with commission recorded", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "CLOSED_PAID", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": true, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "CLOSED_PAID without a commission and money outstanding", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "CLOSED_PAID", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": true, + "countsInForecast": false + }, + { + "name": "cancelled deal that still has a to-do", + "lifecycle": "CANCELLED", + "status": "quotation_issued", + "salesStage": "DELIVERY_SCHEDULING", + "paymentStatus": "DEPOSIT_PAID", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 40000, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "lost before ORDER_RECEIVED", + "lifecycle": "CLOSED_LOST", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "in scope but no account to-do", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": "CUSTOMER_CONFIRMED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "plain active deal before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": true + }, + { + "name": "on hold before ORDER_RECEIVED", + "lifecycle": "ON_HOLD", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "dormant before ORDER_RECEIVED", + "lifecycle": "DORMANT", + "status": "quotation_issued", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "closed status before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "cancelled status before ORDER_RECEIVED", + "lifecycle": "ACTIVE", + "status": "cancelled", + "salesStage": "QUOTE_BUYER", + "paymentStatus": null, + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "awaiting final payment but status document_issued: no to-do", + "lifecycle": "ACTIVE", + "status": "document_issued", + "salesStage": "DELIVERED", + "paymentStatus": "AWAITING_FINAL_PAYMENT", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 50000, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "deposit notice issued but status closed: no to-do", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": "DEPOSIT_NOTICE_ISSUED", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "deposit paid but status closed: no to-do", + "lifecycle": "ACTIVE", + "status": "closed", + "salesStage": "PROCUREMENT", + "paymentStatus": "DEPOSIT_PAID", + "depositPolicy": "REQUIRED", + "overdue": false, + "commission": false, + "paid": 40000, + "countsInOutstanding": false, + "countsInForecast": false + }, + { + "name": "deposit bypassed but status document_issued: no to-do", + "lifecycle": "ACTIVE", + "status": "document_issued", + "salesStage": "ORDER_RECEIVED", + "paymentStatus": null, + "depositPolicy": "NOT_REQUIRED", + "overdue": false, + "commission": false, + "paid": 0, + "countsInOutstanding": false, + "countsInForecast": false + } + ] +} \ No newline at end of file diff --git a/docs/api/response-fields.json b/docs/api/response-fields.json index 67c2cab4f..9dc45e43c 100644 --- a/docs/api/response-fields.json +++ b/docs/api/response-fields.json @@ -1,7 +1,7 @@ { "$comment" : "GENERATED by ResponseFieldContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ResponseFieldContractTest -Dresponse.fields.update=true test", "source" : "Schema.field pairs from springdoc /v3/api-docs response schemas, kept where the field name is read in frontend/src (excluding mockApi, hrApi, routes and tests)", - "count" : 1466, + "count" : 1468, "fields" : [ "Action.requiredFields", "Action.targetStage", @@ -244,6 +244,8 @@ "DashboardSummaryDto.cashReceivedThisMonth", "DashboardSummaryDto.divisionId", "DashboardSummaryDto.employeeId", + "DashboardSummaryDto.financeOutstanding", + "DashboardSummaryDto.forecastValue", "DashboardSummaryDto.latestPayrollPeriodId", "DashboardSummaryDto.pendingApprovals", "DashboardSummaryDto.totalOpen", @@ -558,6 +560,7 @@ "ImportDealDto.salesStage", "ImportDealItemDto.qtyDelivered", "ImportDealItemDto.qtySqm", + "ImportRequestCoverageResponse.hasUncoveredFactories", "ImportRequestDto.approvedByName", "ImportRequestDto.approvedDate", "ImportRequestDto.checkedByName", @@ -1393,7 +1396,6 @@ "TicketSummaryDto.buyerName", "TicketSummaryDto.closeConfirmedAt", "TicketSummaryDto.closeConfirmedByName", - "TicketSummaryDto.closedAt", "TicketSummaryDto.commissionRecorded", "TicketSummaryDto.contactId", "TicketSummaryDto.contactName", diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 61eb37690..59044a345 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -1,5 +1,6 @@ import thaiLocations from '../data/thai-locations.json'; import { formatThaiAddress } from '../features/locations/thaiAddress.js'; +import { nextAccountAction } from '../features/tickets/accountActions.js'; // Mock backend for VITE_USE_MOCKS=true — the default verification surface for the // `frontend-mock` launch config that devs, QA and coding agents drive. // @@ -3877,6 +3878,24 @@ function derivePaymentFields(ticket) { }; } +// Mirrors DashboardRepository#financeOutstanding: outstanding on deals in account's list scope that have an +// account to-do (nextAccountAction as the account viewer). +function mockFinanceOutstanding() { + return moneyValue(db.tickets + .filter(accountListScopeIncludes) + .map((t) => ({ ...t, ...derivePaymentFields(t), commissionRecorded: hasRecordedCommission(t) })) + .filter((t) => nextAccountAction(t) != null) + .reduce((sum, t) => sum + Number(t.amountOutstanding ?? 0), 0)); +} + +// Mirrors DashboardRepository#forecastValue: payable of ACTIVE deals before ORDER_RECEIVED. +function mockForecastValue() { + return moneyValue(db.tickets + .filter((t) => (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' && dealStageIndex(t.salesStage) < ACCOUNT_ORDER_STAGE_SCOPE_START + && t.status !== 'closed' && t.status !== 'cancelled') + .reduce((sum, t) => sum + Number(derivePaymentFields(t).amountPayable ?? 0), 0)); +} + function mockReceiptsThisMonth() { const bangkokMonth = (d) => new Date(d).toLocaleDateString('sv-SE', { timeZone: 'Asia/Bangkok' }).slice(0, 7); const thisMonth = bangkokMonth(new Date()); @@ -12277,6 +12296,8 @@ export const api = { // The month figure is the sum of the per-rep rows, as DashboardService totals them. cashReceivedThisMonth: cashByRep ? moneyValue(cashByRep.reduce((sum, row) => sum + row.amount, 0)) : null, cashReceivedByRep: cashByRep, + financeOutstanding: cashByRep ? mockFinanceOutstanding() : null, + forecastValue: cashByRep ? mockForecastValue() : null, }, }); }, diff --git a/frontend/src/features/dashboard/AccountOverview.jsx b/frontend/src/features/dashboard/AccountOverview.jsx index 73eeb1cb9..824742ede 100644 --- a/frontend/src/features/dashboard/AccountOverview.jsx +++ b/frontend/src/features/dashboard/AccountOverview.jsx @@ -15,7 +15,6 @@ import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { cn } from '../../utils/cn.js'; import { formatMoney, formatThaiDate, greetingName, ticketStatusLabel } from '../../utils/format.js'; import { accountMoneyBucket, nextAccountAction } from '../tickets/accountActions.js'; -import { financeOutstanding } from './dashboardMoney.js'; // Money-pulse bucket meta (Bento Grid). `span` encodes IMPORTANCE, not data: overdue is the 2x2 lead // tile, the deposit step is double-wide, the commission step is the slim closing strip. @@ -131,12 +130,12 @@ export function AccountOverview({ user, employee, dashboardSummary, showToast }) }); }, [allTickets, user?.role]); - // ค้างรับ is /finance's คงค้างรวม (shared helper); รับแล้ว is the server's receipts for this Bangkok month. + // ค้างรับ is /finance's คงค้างรวม (the summary's server total); รับแล้ว is the server's receipts for this Bangkok month. const monthSummary = useMemo(() => ({ receivedThisMonth: dashboardSummary?.cashReceivedThisMonth ?? null, - outstandingTotal: financeOutstanding(allTickets), + outstandingTotal: dashboardSummary?.financeOutstanding ?? null, overdueTotal: allTickets.reduce((sum, t) => sum + (t.overdue ? Number(t.amountOutstanding ?? 0) : 0), 0), - }), [allTickets, dashboardSummary?.cashReceivedThisMonth]); + }), [allTickets, dashboardSummary?.cashReceivedThisMonth, dashboardSummary?.financeOutstanding]); const firstName = employee?.nickName || employee?.nameTh || user?.name || ''; const today = formatThaiDate(new Date().toISOString()); diff --git a/frontend/src/features/dashboard/AccountOverview.test.jsx b/frontend/src/features/dashboard/AccountOverview.test.jsx index c50bfbe3a..723fddc2a 100644 --- a/frontend/src/features/dashboard/AccountOverview.test.jsx +++ b/frontend/src/features/dashboard/AccountOverview.test.jsx @@ -18,6 +18,7 @@ vi.mock('../../api/index.js', async (importOriginal) => { api: { tickets: { list: vi.fn() }, meta: { dealStages: vi.fn() }, + dashboard: { summary: vi.fn() }, }, }; }); @@ -164,18 +165,28 @@ describe('AccountOverview', () => { expect(summaryRow('รับแล้ว (เดือนนี้)')).toBe('฿321,000.50'); }); - it('ค้างรับ leaves out deals with no account to-do, including the closed DEMO-TKT-05 shape', async () => { + it('ค้างรับ is the summary\'s server total, not a sum of the loaded rows', async () => { + // rows would add up to ฿150,000.00 (the closed DEMO-TKT-05 deal and the no-to-do deal are not counted either way) serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); - renderOverview(); + renderOverview({ financeOutstanding: 777000.5 }); await screen.findByText('สรุปเดือนนี้'); - expect(summaryRow('ค้างรับ')).toBe('฿150,000.00'); + expect(summaryRow('ค้างรับ')).toBe('฿777,000.50'); }); - it('ค้างรับ equals the คงค้างรวม on AccountFinancePage for the same tickets', async () => { + it('ค้างรับ shows an em dash while the summary has no total', async () => { serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); - renderOverview(); + renderOverview({}); + await screen.findByText('สรุปเดือนนี้'); + expect(summaryRow('ค้างรับ')).toBe('—'); + }); + + it('ค้างรับ equals the ค้างรับทั้งหมด on AccountFinancePage: both read the summary\'s total', async () => { + serve([overdueTicket, depositTicket, noActionTicket], [closedDemoDeal]); + api.dashboard.summary.mockResolvedValue({ summary: { financeOutstanding: 777000.5 } }); + renderOverview({ financeOutstanding: 777000.5 }); await screen.findByText('สรุปเดือนนี้'); const dashboardValue = summaryRow('ค้างรับ'); + expect(dashboardValue).toBe('฿777,000.50'); cleanup(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -186,7 +197,7 @@ describe('AccountOverview', () => { , ); - const subtitle = await screen.findByText(/คงค้างรวม ฿/); - expect(subtitle.textContent).toContain(`คงค้างรวม ${dashboardValue}`); + const subtitle = await screen.findByText(/ค้างรับทั้งหมด ฿/); + expect(subtitle.textContent).toContain(`ค้างรับทั้งหมด ${dashboardValue}`); }); }); diff --git a/frontend/src/features/dashboard/CeoOverview.jsx b/frontend/src/features/dashboard/CeoOverview.jsx index 120c140b4..8cae82bba 100644 --- a/frontend/src/features/dashboard/CeoOverview.jsx +++ b/frontend/src/features/dashboard/CeoOverview.jsx @@ -11,7 +11,6 @@ import { SkeletonCard } from '../../components/common/Skeleton.jsx'; import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; -import { financeOutstanding, forecastValue } from './dashboardMoney.js'; /** * CEO exec-cockpit landing (plan: docs/agent-handoffs/valiant-watching-map.md @@ -195,8 +194,8 @@ export function CeoOverview({ user, employee, dashboardSummary }) { // scoped tickets.list() the CEO already gets in full — amountPayable/ // amountPaid/amountOutstanding/overdue are the same computed fields // TicketDetailPage's money views already trust (derivePaymentFields). ──── - const outstanding = useMemo(() => financeOutstanding(tickets), [tickets]); - const forecast = useMemo(() => forecastValue(tickets), [tickets]); + const outstanding = dashboardSummary?.financeOutstanding; + const forecast = dashboardSummary?.forecastValue; const cashReceived = dashboardSummary?.cashReceivedThisMonth; const overdueReceivables = useMemo( () => moneySum(tickets.filter((t) => t.overdue), (t) => t.amountOutstanding), @@ -378,8 +377,8 @@ export function CeoOverview({ user, employee, dashboardSummary }) {

    {[ ['เงินรับแล้ว (เดือนนี้)', cashReceived == null ? '—' : formatMoney(cashReceived)], - ['ค้างรับ', formatMoney(outstanding)], - ['ยอดคาดการณ์', formatMoney(forecast)], + ['ค้างรับ', outstanding == null ? '—' : formatMoney(outstanding)], + ['ยอดคาดการณ์', forecast == null ? '—' : formatMoney(forecast)], ['ลูกหนี้ค้าง/เกินกำหนด', formatMoney(overdueReceivables)], ['กำลังพล', headcountActive != null ? `${headcountActive} คน` : '-'], ['มาสายวันนี้', lateToday != null ? `${lateToday} คน` : '-'], diff --git a/frontend/src/features/dashboard/CeoOverview.test.jsx b/frontend/src/features/dashboard/CeoOverview.test.jsx index ced04996a..a3dd90ffb 100644 --- a/frontend/src/features/dashboard/CeoOverview.test.jsx +++ b/frontend/src/features/dashboard/CeoOverview.test.jsx @@ -7,7 +7,7 @@ import { CeoOverview } from './CeoOverview.jsx'; import { api } from '../../api/index.js'; import { bangkokMonthStartIso } from '../../utils/format.js'; import { - CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, + CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, SERVER_TOTALS, expectFigure, figureRows, moneyFixtureTickets, } from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -42,6 +42,7 @@ const dashboardSummary = { cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, // This month's cash split per sales rep: the two add up to CASH_RECEIVED_THIS_MONTH (125,000.50). cashReceivedByRep: CASH_BY_REP, + ...SERVER_TOTALS, }; function mockAllData() { @@ -340,6 +341,15 @@ describe('CeoOverview', () => { expectFigure('ยอดคาดการณ์', EXPECTED.forecast); }); + it('shows an em dash for ค้างรับ and ยอดคาดการณ์ when the summary has no totals, never a sum of rows', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview({ ...dashboardSummary, financeOutstanding: undefined, forecastValue: undefined }); + await screen.findByText('อนุมัติราคา'); + + expectFigure('ค้างรับ', '—'); + expectFigure('ยอดคาดการณ์', '—'); + }); + it('no longer shows the closed-sales and pipeline figures', async () => { renderOverview(); await screen.findByText('ผลบริษัทเดือนนี้'); diff --git a/frontend/src/features/dashboard/ManagerOverview.jsx b/frontend/src/features/dashboard/ManagerOverview.jsx index b0e0e0181..a582e1cd0 100644 --- a/frontend/src/features/dashboard/ManagerOverview.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.jsx @@ -12,7 +12,6 @@ import { SkeletonCard, SkeletonText } from '../../components/common/Skeleton.jsx import { StatCard } from '../../components/common/StatCard.jsx'; import { StatusBadge } from '../../components/common/StatusBadge.jsx'; import { formatMoney, formatThaiDate, greetingName } from '../../utils/format.js'; -import { financeOutstanding, forecastValue } from './dashboardMoney.js'; // No server-side status filter exists on GET /api/commissions (mirrors // CommissionController#list — payrollMonth is the only query param it @@ -111,8 +110,8 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast }) const ticketsById = useMemo(() => new Map(tickets.map((t) => [t.id, t])), [tickets]); const openTickets = useMemo(() => tickets.filter(isDealOpen), [tickets]); - const outstanding = useMemo(() => financeOutstanding(tickets), [tickets]); - const forecast = useMemo(() => forecastValue(tickets), [tickets]); + const outstanding = dashboardSummary?.financeOutstanding; + const forecast = dashboardSummary?.forecastValue; const cashReceived = dashboardSummary?.cashReceivedThisMonth; // "ดีลต้องดูแล": an open deal with no activity in 7 days (`ticket.stale`, @@ -185,7 +184,7 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast }) @@ -208,7 +207,7 @@ export function ManagerOverview({ user, employee, dashboardSummary, showToast }) navigate('/tickets')} diff --git a/frontend/src/features/dashboard/ManagerOverview.test.jsx b/frontend/src/features/dashboard/ManagerOverview.test.jsx index ca524bfd6..8e8993dee 100644 --- a/frontend/src/features/dashboard/ManagerOverview.test.jsx +++ b/frontend/src/features/dashboard/ManagerOverview.test.jsx @@ -6,7 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ManagerOverview } from './ManagerOverview.jsx'; import { api } from '../../api/index.js'; import { - CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, expectFigure, figureRows, moneyFixtureTickets, + CASH_BY_REP, CASH_RECEIVED_THIS_MONTH, EXPECTED, SERVER_TOTALS, expectFigure, figureRows, moneyFixtureTickets, } from '../../test/fixtures/dashboardMoney.js'; globalThis.React = React; @@ -141,7 +141,7 @@ describe('ManagerOverview', () => { }); it('shows the same three money figures as /finance and the CEO dashboard', async () => { api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); - renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH }); + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH, ...SERVER_TOTALS }); await screen.findByText('ดีลต้องดูแล'); expectFigure('เงินรับแล้ว (เดือนนี้)', EXPECTED.cash); @@ -149,6 +149,15 @@ describe('ManagerOverview', () => { expectFigure('ยอดคาดการณ์', EXPECTED.forecast); }); + it('shows an em dash for ค้างรับ and ยอดคาดการณ์ when the summary has no totals, never a sum of rows', async () => { + api.tickets.list.mockResolvedValue({ tickets: moneyFixtureTickets }); + renderOverview({ cashReceivedThisMonth: CASH_RECEIVED_THIS_MONTH }); + await screen.findByText('ดีลต้องดูแล'); + + expectFigure('ค้างรับ', '—'); + expectFigure('ยอดคาดการณ์', '—'); + }); + it('no longer shows the team-sales and team-pipeline tiles', async () => { renderOverview(); await screen.findByText('ดีลต้องดูแล'); diff --git a/frontend/src/features/dashboard/dashboardMoney.js b/frontend/src/features/dashboard/dashboardMoney.js deleted file mode 100644 index a18321895..000000000 --- a/frontend/src/features/dashboard/dashboardMoney.js +++ /dev/null @@ -1,31 +0,0 @@ -// The money figures the account, CEO and sales-manager dashboards share, so each reads the same -// number /finance does. Presentation only: the server stays the authority on every figure. -import { nextAccountAction } from '../tickets/accountActions.js'; -import { moneyMilestoneOf } from '../finance/moneyMilestone.js'; - -const ACCOUNT_PENDING_PAYMENT_STATUSES = new Set(['DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT']); - -const amount = (value) => Number(value ?? 0); - -// Mirrors TicketRepository.appendRoleScope("account"): the deals account's list can contain. -function inAccountScope(ticket) { - const live = ticket.lifecycle !== 'CLOSED_LOST' && ticket.lifecycle !== 'CANCELLED' - && ticket.status !== 'cancelled' && moneyMilestoneOf(ticket.salesStage) != null; - return live || ACCOUNT_PENDING_PAYMENT_STATUSES.has(ticket.paymentStatus) || ticket.overdue === true; -} - -/** /finance's คงค้างรวม: outstanding on deals that are in account's scope and have an account to-do. */ -export function financeOutstanding(tickets) { - return (tickets ?? []) - .filter((t) => inAccountScope(t) && nextAccountAction(t) != null) - .reduce((sum, t) => sum + amount(t.amountOutstanding), 0); -} - -/** ยอดคาดการณ์: payable on live deals that have not reached ขั้น 10 yet. */ -export function forecastValue(tickets) { - return (tickets ?? []) - .filter((t) => moneyMilestoneOf(t.salesStage) == null - && (t.lifecycle ?? 'ACTIVE') === 'ACTIVE' - && t.status !== 'closed' && t.status !== 'cancelled') - .reduce((sum, t) => sum + amount(t.amountPayable), 0); -} diff --git a/frontend/src/features/dashboard/dashboardMoney.test.js b/frontend/src/features/dashboard/dashboardMoney.test.js deleted file mode 100644 index 39a089b5a..000000000 --- a/frontend/src/features/dashboard/dashboardMoney.test.js +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { financeOutstanding, forecastValue } from './dashboardMoney.js'; -import { nextAccountAction } from '../tickets/accountActions.js'; -import { - closedDemoDeal, depositPendingDeal, finalPaymentDeal, lostPreOrderDeal, moneyFixtureTickets, - preOrderBypassDeal, preOrderPlainDeal, -} from '../../test/fixtures/dashboardMoney.js'; - -// financeOutstanding is /finance's คงค้างรวม: Σ amountOutstanding over deals that have an account -// to-do AND sit inside account's list scope (TicketRepository.appendRoleScope("account")). -describe('financeOutstanding', () => { - it('counts a deposit-pending deal for its whole outstanding, not the deposit', () => { - expect(financeOutstanding([depositPendingDeal])).toBeCloseTo(213545.25, 2); - }); - - it('counts a deposit-paid deal awaiting the final payment', () => { - expect(financeOutstanding([finalPaymentDeal])).toBe(80000); - }); - - it('does not count a closed deal that still carries an outstanding figure (DEMO-TKT-05)', () => { - expect(closedDemoDeal.amountOutstanding).toBe(196880); - expect(financeOutstanding([closedDemoDeal])).toBe(0); - }); - - it('does not count a pre-ขั้น 10 deal even though nextAccountAction offers it a step', () => { - // Guard: the fixture really is one the to-do helper would offer, so 0 below is the scope rule. - expect(nextAccountAction(preOrderBypassDeal)).not.toBeNull(); - expect(financeOutstanding([preOrderBypassDeal])).toBe(0); - }); - - it('does not count an in-scope deal that has no account to-do', () => { - const waiting = { ...depositPendingDeal, paymentStatus: 'CUSTOMER_CONFIRMED' }; - expect(nextAccountAction(waiting)).toBeNull(); - expect(financeOutstanding([waiting])).toBe(0); - }); - - it('counts an overdue pre-ขั้น 10 deal (overdue is its own scope branch)', () => { - const overdue = { ...preOrderPlainDeal, overdue: true, amountOutstanding: 15000 }; - expect(financeOutstanding([overdue])).toBe(15000); - }); - - it('does not count a cancelled deal whose payment status is not a pending one', () => { - const cancelled = { ...finalPaymentDeal, lifecycle: 'CANCELLED' }; - expect(nextAccountAction(cancelled)).not.toBeNull(); - expect(financeOutstanding([cancelled])).toBe(0); - }); - - it('sums the fixture and tolerates an empty or missing list', () => { - expect(financeOutstanding(moneyFixtureTickets)).toBeCloseTo(293545.25, 2); - expect(financeOutstanding([])).toBe(0); - expect(financeOutstanding(undefined)).toBe(0); - }); -}); - -// forecastValue is ยอดคาดการณ์: Σ amountPayable over deals before ขั้น 10 that are still live. -describe('forecastValue', () => { - it('sums amountPayable of active deals before ขั้น 10', () => { - expect(forecastValue([preOrderBypassDeal, preOrderPlainDeal])).toBeCloseTo(80000.25, 2); - }); - - it('leaves out deals at or past ขั้น 10, lost/cancelled lifecycles and closed/cancelled status', () => { - expect(forecastValue([depositPendingDeal, finalPaymentDeal, closedDemoDeal, lostPreOrderDeal])).toBe(0); - expect(forecastValue([{ ...preOrderPlainDeal, status: 'closed' }])).toBe(0); - expect(forecastValue([{ ...preOrderPlainDeal, status: 'cancelled' }])).toBe(0); - expect(forecastValue([{ ...preOrderPlainDeal, lifecycle: 'CANCELLED' }])).toBe(0); - }); - - it('treats a null lifecycle as active and a null amount as zero', () => { - expect(forecastValue([{ ...preOrderPlainDeal, lifecycle: null }])).toBeCloseTo(30000.25, 2); - expect(forecastValue([{ ...preOrderPlainDeal, amountPayable: null }])).toBe(0); - }); - - it('sums the fixture and tolerates an empty or missing list', () => { - expect(forecastValue(moneyFixtureTickets)).toBeCloseTo(80000.25, 2); - expect(forecastValue([])).toBe(0); - expect(forecastValue(null)).toBe(0); - }); -}); diff --git a/frontend/src/features/dashboard/moneyCases.test.js b/frontend/src/features/dashboard/moneyCases.test.js new file mode 100644 index 000000000..fedce8324 --- /dev/null +++ b/frontend/src/features/dashboard/moneyCases.test.js @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { nextAccountAction } from '../tickets/accountActions.js'; +import { moneyMilestoneOf } from '../finance/moneyMilestone.js'; + +// Anti-drift: the same deal shapes drive DashboardMoneyTotalsIntegrationTest (the SQL behind +// /api/dashboard/summary). The JS rule here is what /finance's rows use -- account's list scope +// (TicketRepository.appendRoleScope("account")) plus nextAccountAction -- so if the two sides ever +// disagree about a shape, one of the two tests goes red. +// vitest runs from frontend/ (see the repo's vitest note), so the backend resource is one level up. +const table = JSON.parse(readFileSync( + resolve(process.cwd(), '../backend/src/test/resources/dashboard/money-cases.json'), 'utf8')); + +const PENDING = new Set(['DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT']); + +function ticketOf(c) { + const outstanding = Math.max(table.payable - c.paid, 0); + return { + id: 1, lifecycle: c.lifecycle, status: c.status, salesStage: c.salesStage, paymentStatus: c.paymentStatus, + depositPolicy: c.depositPolicy, overdue: c.overdue, commissionRecorded: c.commission, + closeConfirmedAt: null, invoiceOnFile: false, fulfillmentStatus: null, + amountPayable: table.payable, amountPaid: c.paid, amountOutstanding: outstanding, + }; +} + +function inAccountScope(t) { + const live = t.lifecycle !== 'CLOSED_LOST' && t.lifecycle !== 'CANCELLED' && t.status !== 'cancelled' + && moneyMilestoneOf(t.salesStage) != null; + return live || PENDING.has(t.paymentStatus) || t.overdue === true; +} +const countsInOutstanding = (t) => inAccountScope(t) && nextAccountAction(t) != null; + +describe('dashboard money case table (JS side)', () => { + it('has the cases the SQL test builds', () => { + expect(table.cases.length).toBeGreaterThanOrEqual(23); + }); + + it.each(table.cases.map((c) => [c.name, c]))('%s', (_name, c) => { + const t = ticketOf(c); + // Outstanding only: the JS side of this rule is production code (nextAccountAction). The forecast rule has + // no production JS twin any more (the server sums it), so DashboardMoneyTotalsIntegrationTest alone pins it. + expect(countsInOutstanding(t), 'countsInOutstanding').toBe(c.countsInOutstanding); + }); +}); diff --git a/frontend/src/features/finance/AccountFinancePage.jsx b/frontend/src/features/finance/AccountFinancePage.jsx index 7262d36b0..93de090a2 100644 --- a/frontend/src/features/finance/AccountFinancePage.jsx +++ b/frontend/src/features/finance/AccountFinancePage.jsx @@ -123,6 +123,16 @@ export function AccountFinancePage({ user, showToast }) { if (error) showToast?.('error', error.message || 'โหลดข้อมูลไม่สำเร็จ'); }, [scopedQuery.error, closedPaidQuery.error, showToast]); + // The same query (key + shape) as useHrData's, so one cache entry serves the dashboard and this header, + // and FinanceDealPage's invalidation of it keeps คงค้างรวม fresh. + const summaryQuery = useQuery({ + queryKey: queryKeys.dashboardSummary(), + queryFn: () => api.dashboard.summary() + .then((response) => response?.summary ?? response ?? null) + .catch(() => null), + }); + const serverOutstanding = summaryQuery.data?.financeOutstanding ?? null; + const loading = scopedQuery.isLoading || closedPaidQuery.isLoading; const loadError = scopedQuery.error || closedPaidQuery.error; @@ -151,15 +161,14 @@ export function AccountFinancePage({ user, showToast }) { // The summary always describes the actionable set, whichever view is showing. const stats = useMemo(() => { - const total = actionable.reduce((sum, r) => sum + Number(r.ticket.amountOutstanding ?? 0), 0); const overdueCount = actionable.filter((r) => r.ticket.overdue).length; const dueTimes = actionable .map((r) => (r.ticket.paymentDueDate ? new Date(r.ticket.paymentDueDate).getTime() : null)) .filter((t) => t != null && !Number.isNaN(t)); const nextDue = dueTimes.length ? new Date(Math.min(...dueTimes)).toISOString() : null; - return { total, overdueCount, nextDue }; + return { overdueCount, nextDue }; }, [actionable]); - const summary = `ต้องดำเนินการ ${actionable.length} ดีล · คงค้างรวม ${actionable.length === 0 ? '—' : formatMoney(stats.total)}`; + const summary = `ต้องดำเนินการ ${actionable.length} ดีล · ค้างรับทั้งหมด ${serverOutstanding == null ? '—' : formatMoney(serverOutstanding)}`; const rows = useMemo(() => { const base = view === 'all' ? allRows : actionable; @@ -199,9 +208,9 @@ export function AccountFinancePage({ user, showToast }) { {/* The screen-reader sentence; the visible block below shows the same figures as a lead stat. */}

    {summary}

    -

    ยอดคงค้างของดีลที่ต้องดำเนินการ

    +

    ค้างรับทั้งหมด

    - {actionable.length === 0 ? '—' : formatMoney(stats.total)} + {serverOutstanding == null ? '—' : formatMoney(serverOutstanding)}

    diff --git a/frontend/src/features/finance/AccountFinancePage.test.jsx b/frontend/src/features/finance/AccountFinancePage.test.jsx index 77e0238ec..b5a2cb050 100644 --- a/frontend/src/features/finance/AccountFinancePage.test.jsx +++ b/frontend/src/features/finance/AccountFinancePage.test.jsx @@ -16,6 +16,7 @@ vi.mock('../../api/index.js', async (importOriginal) => { api: { tickets: { list: vi.fn() }, meta: { dealStages: vi.fn().mockResolvedValue(DEAL_STAGE_CATALOG) }, + dashboard: { summary: vi.fn() }, }, }; }); @@ -86,6 +87,7 @@ const rowLink = (name) => screen.getByRole('link', { name: new RegExp(name) }); describe('AccountFinancePage (index-first worklist)', () => { beforeEach(() => { vi.clearAllMocks(); + api.dashboard.summary.mockResolvedValue({ summary: { financeOutstanding: 424242.42 } }); api.tickets.list.mockImplementation((params = {}) => { if (params.salesStage === 'CLOSED_PAID') return Promise.resolve({ tickets: [closedPaidTicket] }); return Promise.resolve({ tickets: [depositTicket, finalPaymentTicket, idleTicket] }); @@ -201,18 +203,23 @@ describe('AccountFinancePage (index-first worklist)', () => { expect(within(link).getByText('ติดตามชำระ')).not.toBeNull(); }); - it('the summary line is computed from the data: count of actionable deals and total outstanding', async () => { + it('the summary line counts the actionable rows but takes ค้างรับทั้งหมด from the server total', async () => { renderPage(); - // deposit 80,000 + final 60,000 + closed-paid 0 = 140,000 across 3 actionable deals + // 3 actionable rows; their outstanding adds up to 140,000, but the figure is the summary's (rows are capped at 500) expect(await screen.findByText(/ต้องดำเนินการ 3 ดีล/)).not.toBeNull(); - expect(screen.getByText(/คงค้างรวม ฿140,000.00/)).not.toBeNull(); + expect(await screen.findByText('ต้องดำเนินการ 3 ดีล · ค้างรับทั้งหมด ฿424,242.42')).not.toBeNull(); + // the hero is labelled as the whole receivable, not as the listed deals' sum + expect(screen.getByText('ค้างรับทั้งหมด')).not.toBeNull(); + expect(screen.queryByText('ยอดคงค้างของดีลที่ต้องดำเนินการ')).toBeNull(); + expect(screen.queryByText(/฿140,000.00/)).toBeNull(); }); - it('with nothing actionable the summary says so and shows an em dash, not a fabricated zero', async () => { + it('without a server total the summary shows an em dash, not a fabricated sum or zero', async () => { + api.dashboard.summary.mockResolvedValue({ summary: {} }); api.tickets.list.mockResolvedValue({ tickets: [idleTicket] }); renderPage(); expect(await screen.findByText(/ต้องดำเนินการ 0 ดีล/)).not.toBeNull(); - expect(screen.getByText(/คงค้างรวม —/)).not.toBeNull(); + expect(screen.getByText(/ค้างรับทั้งหมด —/)).not.toBeNull(); expect(screen.queryByText(/฿0.00/)).toBeNull(); }); diff --git a/frontend/src/test/fixtures/dashboardMoney.js b/frontend/src/test/fixtures/dashboardMoney.js index 790e60981..adfc4cb3c 100644 --- a/frontend/src/test/fixtures/dashboardMoney.js +++ b/frontend/src/test/fixtures/dashboardMoney.js @@ -51,10 +51,13 @@ export const CASH_BY_REP = [ { repName: 'สมหญิง ขายเก่ง', amount: 35000 }, { repName: 'สมชาย ขายดี', amount: 90000.5 }, ]; +// The server totals on /api/dashboard/summary. Deliberately NOT what the fixture rows add up to +// (293,545.25 and 80,000.25): /api/tickets is capped at 500 rows, so a screen that sums rows is wrong. +export const SERVER_TOTALS = { financeOutstanding: 411111.11, forecastValue: 222222.22 }; export const EXPECTED = { cash: '฿125,000.50', - outstanding: '฿293,545.25', // 213,545.25 + 80,000 — not the closed, pre-order or lost deals - forecast: '฿80,000.25', // 50,000 + 30,000.25 — pre-ขั้น 10, ACTIVE, not closed + outstanding: '฿411,111.11', + forecast: '฿222,222.22', }; /** Text of every row/tile carrying `label`: a StatCard, or a
    /
    pair in a
    . */ From b98d69be5b0790469d3f8d53699c25a02826d4dd Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:26:06 +0700 Subject: [PATCH 11/24] refactor(dashboard): one summary fetch for the shared dashboardSummary cache key Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/finance/AccountFinancePage.jsx | 7 +++---- frontend/src/hooks/useHrData.js | 10 +++++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/finance/AccountFinancePage.jsx b/frontend/src/features/finance/AccountFinancePage.jsx index 93de090a2..ec675923e 100644 --- a/frontend/src/features/finance/AccountFinancePage.jsx +++ b/frontend/src/features/finance/AccountFinancePage.jsx @@ -11,6 +11,7 @@ import { Icon } from '../../components/common/Icon.jsx'; import { FilterBar, FilterField, PageStack } from '../../components/common/Layout.jsx'; import { PageHeader } from '../../components/common/PageHeader.jsx'; import { TabPanel, Tabs } from '../../components/common/Tabs.jsx'; +import { fetchDashboardSummary } from '../../hooks/useHrData.js'; import { cn } from '../../utils/cn.js'; import { dealStageLabel, formatMoney, formatThaiDate } from '../../utils/format.js'; import { nextAccountAction } from '../tickets/accountActions.js'; @@ -123,13 +124,11 @@ export function AccountFinancePage({ user, showToast }) { if (error) showToast?.('error', error.message || 'โหลดข้อมูลไม่สำเร็จ'); }, [scopedQuery.error, closedPaidQuery.error, showToast]); - // The same query (key + shape) as useHrData's, so one cache entry serves the dashboard and this header, + // The same query (key + fetch) as useHrData's, so one cache entry serves the dashboard and this header, // and FinanceDealPage's invalidation of it keeps คงค้างรวม fresh. const summaryQuery = useQuery({ queryKey: queryKeys.dashboardSummary(), - queryFn: () => api.dashboard.summary() - .then((response) => response?.summary ?? response ?? null) - .catch(() => null), + queryFn: fetchDashboardSummary, }); const serverOutstanding = summaryQuery.data?.financeOutstanding ?? null; diff --git a/frontend/src/hooks/useHrData.js b/frontend/src/hooks/useHrData.js index 0e12f7c93..4ab90859e 100644 --- a/frontend/src/hooks/useHrData.js +++ b/frontend/src/hooks/useHrData.js @@ -6,6 +6,12 @@ import { hasPermission } from '../app/permissions.js'; import { declaredAllowanceTotal } from '../features/taxAllowance/taxAllowanceSchema.js'; import { selectCurrentDeclaration, taxAllowanceStatusInfo } from '../features/taxAllowance/taxAllowanceStatus.js'; +// One fetch for the queryKeys.dashboardSummary() cache entry; every reader of that key uses it so the +// cached shape can't depend on which screen mounted first. +export const fetchDashboardSummary = () => api.dashboard.summary() + .then((response) => response?.summary ?? response ?? null) + .catch(() => null); + export function useHrData({ user, showToast }) { const queryClient = useQueryClient(); @@ -44,9 +50,7 @@ export function useHrData({ user, showToast }) { const dashboardSummaryQuery = useQuery({ queryKey: queryKeys.dashboardSummary(), - queryFn: () => api.dashboard.summary() - .then((response) => response?.summary ?? response ?? null) - .catch(() => null), + queryFn: fetchDashboardSummary, enabled: !!user && !!api.dashboard?.summary, }); const dashboardSummary = dashboardSummaryQuery.data ?? null; From b77627467f6e7f12469b7508f236aff35e9774b3 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:26:31 +0700 Subject: [PATCH 12/24] =?UTF-8?q?fix(stock):=20stock=20lines=20never=20go?= =?UTF-8?q?=20to=20a=20factory;=20all-stock=20deals=20go=20to=20=E0=B8=82?= =?UTF-8?q?=E0=B8=B1=E0=B9=89=E0=B8=99=2013=20(walk=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rulings 2026-10-29 (no reserve feature): - confirmOrder writes ticket_item.qty_from_stock = qty for IN_THAILAND PCR lines (0 otherwise), so IR candidates, the all-stock 409, commission's stock share and STOCK delivery read it unchanged. issue() refuses a draft with any stock-covered line. - All-stock deals route to DELIVERY_SCHEDULING/FROM_STOCK on deposit confirm, 0% confirmation, or waiver. STOCK delivery waits for a settled deposit. - RESERVE_STOCK is no longer advertised; the จองสินค้าจากสต็อก UI is removed (endpoint kept). - W4-01: stock lines store no origin/lead time and print no ระยะเวลานำเข้า; จากสต็อก badge; import not prompted for stock lines. - W4-03: CEO can start a new review after a returned decision. - W4-04: no PCR create/revise/submit once the order is confirmed (409), UI hidden; ⋯ ขอแก้ไข hidden after the order. New summary field ticketPaymentStatus. - W4-05: 0%-deposit deal gets the IR main button; ถัดไป skips ขั้น 11. - W4-06: ตีกลับ hidden on an all-stock request. - W4-09: sales-typed stock price removed (stored NULL); deal shows CEO price. - IN_TRANSIT refused (400); dead in-transit service code deleted. Co-Authored-By: Claude Opus 5.5 --- .../DealQuotationRenderAdapter.java | 10 +- .../ImportRequestRepository.java | 10 + .../importrequest/ImportRequestService.java | 6 + .../OrderConfirmationService.java | 6 +- .../hr/pricingrequest/PricingRequestDtos.java | 12 +- .../PricingRequestRepository.java | 49 +- .../pricingrequest/PricingRequestService.java | 159 +------ .../th/co/glr/hr/ticket/TicketRepository.java | 52 +-- .../th/co/glr/hr/ticket/TicketService.java | 162 +++---- .../DealQuotationEnglishFormTest.java | 26 ++ .../DealQuotationRenderAdapterV3Test.java | 23 +- ...emainingInvoiceServiceIntegrationTest.java | 39 +- .../StoredImportRequestIntegrationTest.java | 30 ++ .../OrderConfirmationIntegrationTest.java | 6 +- .../AbstractStockLineIntegrationTest.java | 3 +- .../StockLineFulfilmentIntegrationTest.java | 432 ++++++++++++++++++ .../StockLineInTransitIntegrationTest.java | 330 ------------- .../StockLineRoutingIntegrationTest.java | 207 ++++----- .../ProcurementServiceIntegrationTest.java | 18 +- .../ticket/DeliveryAuthzIntegrationTest.java | 3 + .../ticket/StageFactGateIntegrationTest.java | 16 +- .../TicketRepositoryIntegrationTest.java | 23 +- .../co/glr/hr/ticket/TicketServiceTest.java | 135 +++--- docs/api/ui-reachable.json | 3 +- frontend/src/api/mockApi.js | 109 +++-- frontend/src/api/serverContract.test.js | 3 + .../PricingRequestDetailPage.jsx | 38 +- .../PricingRequestDetailPage.test.jsx | 109 +++++ .../PricingRequestPanel.test.jsx | 26 ++ .../pricingRequests/pricingRequestMeta.js | 10 +- .../pricingRequestMeta.test.js | 9 + .../features/tickets/DealFulfilmentPanel.jsx | 107 +---- .../tickets/DealFulfilmentPanel.test.jsx | 22 +- .../src/features/tickets/DealStagePanel.jsx | 7 +- .../features/tickets/DealStagePanel.test.jsx | 21 + .../features/tickets/TicketCreateModal.jsx | 97 +--- .../tickets/TicketCreateModal.test.jsx | 172 ++----- .../src/features/tickets/TicketDetailPage.jsx | 134 +----- .../tickets/TicketDetailPage.test.jsx | 219 +++------ .../src/features/tickets/dealMoneyStatus.js | 10 + .../src/features/tickets/importActions.js | 4 + .../features/tickets/importActions.test.js | 11 +- frontend/src/features/tickets/salesActions.js | 6 +- .../src/features/tickets/salesActions.test.js | 45 +- .../src/features/tickets/ticketItemFields.jsx | 39 -- .../tickets/ticketItemFields.test.jsx | 49 -- 46 files changed, 1379 insertions(+), 1628 deletions(-) create mode 100644 backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java delete mode 100644 backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java diff --git a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java index 03c6bc36a..79c7c523a 100644 --- a/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java +++ b/backend/src/main/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapter.java @@ -731,7 +731,7 @@ private static List leadTimeGroups(List items, boo for (DealQuotationItemDto item : ordered) { Integer min = item.leadTimeMinDays(); Integer max = item.leadTimeMaxDays(); - boolean hasLeadTime = min != null && max != null; + boolean hasLeadTime = hasImportLeadTime(item); boolean continuesGroup = hasLeadTime && groupMin != null && min.equals(groupMin) && max.equals(groupMax) && item.seq() == groupLastSeq + 1; if (continuesGroup) { @@ -767,7 +767,13 @@ private static List leadTimeGroups(List items, boo * PLAIN rows (no lead-time fields at all) or a DRAFT preview before any TILE row's lead time * has been filled in both answer {@code false} here. */ private static boolean hasAnyLeadTime(List items) { - return items.stream().anyMatch(i -> i.leadTimeMinDays() != null && i.leadTimeMaxDays() != null); + return items.stream().anyMatch(DealQuotationRenderAdapter::hasImportLeadTime); + } + + /** A stock line (IN_THAILAND) is not imported, so its lead time (if any is stored) is never printed. */ + private static boolean hasImportLeadTime(DealQuotationItemDto item) { + return item.leadTimeMinDays() != null && item.leadTimeMaxDays() != null + && !"IN_THAILAND".equals(item.stockSource()); } // Matches the leading "N." numbering convention every OTHER remark line (LINE4/5/6/7/8 and diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java index 2b91612ad..9e4215fa3 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRepository.java @@ -510,6 +510,16 @@ public String nextDocNumber(int yearTh) { return String.format("IR%02d%03d", yearTh % 100, seq); } + /** True when ANY line of the form is already covered from stock (qty_from_stock >= qty, qty > 0): never ordered. */ + public boolean anyItemCoveredFromStock(long importRequestId) { + Boolean covered = jdbc.queryForObject(""" + SELECT EXISTS(SELECT 1 FROM sales.import_request_item iri + JOIN sales.ticket_item ti ON ti.item_id = iri.ticket_item_id + WHERE iri.import_request_id = :id AND ti.qty > 0 AND ti.qty_from_stock >= ti.qty) + """, Map.of("id", importRequestId), Boolean.class); + return Boolean.TRUE.equals(covered); + } + public boolean docNumberExists(String docNumber) { Boolean found = jdbc.queryForObject( "SELECT EXISTS(SELECT 1 FROM sales.import_request WHERE doc_number = :n)", diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java index 3455a2993..888efa22b 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestService.java @@ -586,6 +586,12 @@ public ImportRequestDto issue(long id, IssueImportRequestRequest request, UserPr if (draft.items().isEmpty()) { throw new ApiException(HttpStatus.CONFLICT, "ใบขอซื้อยังไม่มีรายการสินค้า"); } + // A draft made before the order was confirmed can name a line that is now covered from stock; issuing it + // would order stock the customer already has from the factory. + if (stored.anyItemCoveredFromStock(id)) { + throw new ApiException(HttpStatus.CONFLICT, + "ใบขอซื้อนี้มีรายการที่ครอบคลุมจากสต็อกแล้ว — ไม่ต้องสั่งนำเข้า กรุณาลบฉบับร่างนี้แล้วสร้างใบขอซื้อใหม่"); + } // Owner decision 09-18 #2: "อื่นๆ/unknown must be typed by sales" — a form with no estimate // at all may not be issued. Both-or-neither is already the DB invariant // (chk_import_request_lead_time), so checking one implies the other. diff --git a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java index c8b6b8e7a..3ee1ff995 100644 --- a/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java +++ b/backend/src/main/java/th/co/glr/hr/orderconfirmation/OrderConfirmationService.java @@ -356,11 +356,13 @@ private void reconcileTicketItems(long pricingRequestId, long ticketId, UserPrin ? th.co.glr.hr.dealquotation.WastageCalculator.sqmQuantityFromPieces( reconciledQty.intValueExact(), accepted.sqmPerPiece()) : item.requestedQtySqm(); + // A stock line (IN_THAILAND) is covered in full and never ordered from a factory; any other line 0. + BigDecimal qtyFromStock = "IN_THAILAND".equals(item.stockSource()) ? reconciledQty : BigDecimal.ZERO; if (item.sourceTicketItemId() != null) { boolean changed; try { changed = tickets.reconcileItemQty( - ticketId, item.sourceTicketItemId(), reconciledQty, reconciledQtySqm); + ticketId, item.sourceTicketItemId(), reconciledQty, reconciledQtySqm, qtyFromStock); } catch (DataIntegrityViolationException e) { throw new ApiException(HttpStatus.CONFLICT, "ไม่สามารถปรับจำนวนสินค้า (item " + item.sourceTicketItemId() @@ -373,7 +375,7 @@ private void reconcileTicketItems(long pricingRequestId, long ticketId, UserPrin // to reserveStock/completeDelivery. tickets.insertReconciledItem(ticketId, resolveBrand(item), item.model(), item.color(), item.texture(), item.size(), item.factory(), reconciledQty, reconciledQtySqm, - unitBasis); + unitBasis, qtyFromStock); anyChange = true; } } diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java index 126a69f10..9ca388d5a 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java +++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestDtos.java @@ -74,7 +74,11 @@ public record PricingRequestSummaryDto( // The deal ticket's own legacy status (sales.ticket.status). The frontend gates // "ยืนยันคำสั่งซื้อ" on it (OrderConfirmationService.isBridgeableTicketStatus) so a deal the // bridge cannot handle never offers a button that can only 409. - String ticketStatus + String ticketStatus, + // The deal ticket's payment track (sales.ticket.payment_status): null until the customer's order + // is confirmed. Once set no new pricing round (customer-change revision / submit) can start, so the + // frontend hides the create/submit/สร้างรอบแก้ไข controls on it. + String ticketPaymentStatus ) { /** The pre-GLA-125 shape — kept so every existing construction site (tests, mostly) * compiles unchanged. Defaults every new field to null/false, exactly what a pre-GLA-125 @@ -93,7 +97,7 @@ public PricingRequestSummaryDto( requestedByName, assignedImportId, assignedImportName, requiredDate, customerTargetPrice, targetCurrency, note, itemCount, revisionNo, parentPricingRequestId, submittedAt, pickedUpAt, cancelledAt, createdAt, updatedAt, - orderConfirmedAt, null, null, null, null, null, null, null, false, null, null); + orderConfirmedAt, null, null, null, null, null, null, null, false, null, null, null); } /** The pre-customerId (but post-GLA-125-header-terms) shape — the 36-field constructor @@ -115,7 +119,7 @@ public PricingRequestSummaryDto( customerTargetPrice, targetCurrency, note, itemCount, revisionNo, parentPricingRequestId, submittedAt, pickedUpAt, cancelledAt, createdAt, updatedAt, orderConfirmedAt, paymentTermMode, creditDays, validityDays, printedByDisplayId, - salesRepDisplayId, deptCode, unitCode, omitContactHonorific, null, null); + salesRepDisplayId, deptCode, unitCode, omitContactHonorific, null, null, null); } /** The pre-ticketStatus (37-field) shape. Defaults ticketStatus to null. */ @@ -137,7 +141,7 @@ public PricingRequestSummaryDto( customerTargetPrice, targetCurrency, note, itemCount, revisionNo, parentPricingRequestId, submittedAt, pickedUpAt, cancelledAt, createdAt, updatedAt, orderConfirmedAt, paymentTermMode, creditDays, validityDays, printedByDisplayId, - salesRepDisplayId, deptCode, unitCode, omitContactHonorific, customerId, null); + salesRepDisplayId, deptCode, unitCode, omitContactHonorific, customerId, null, null); } } diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java index 743ebec29..14fc83ad4 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java +++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestRepository.java @@ -43,7 +43,7 @@ public class PricingRequestRepository { private static final String SUMMARY_SELECT = """ SELECT pr.*, t.code AS ticket_code, t.title AS ticket_title, t.created_by AS ticket_created_by, - p.name AS project_name, t.customer_name, t.customer_id, t.status AS ticket_status, + p.name AS project_name, t.customer_name, t.customer_id, t.status AS ticket_status, t.payment_status AS ticket_payment_status, er.first_name_th AS requested_by_first_name_th, er.last_name_th AS requested_by_last_name_th, ei.first_name_th AS assigned_import_first_name_th, ei.last_name_th AS assigned_import_last_name_th, (SELECT COUNT(*) FROM sales.pricing_request_item i @@ -723,50 +723,6 @@ AND COALESCE(NULLIF(BTRIM(resolved_factory_name), ''), NULLIF(BTRIM(factory), '' .addValue("factoryId", factoryId)); } - /** - * Stock lines (V194): records the วันที่คาดว่าจะถึง of ONE in-transit line, and who/when. The - * {@code stock_source = 'IN_TRANSIT'} predicate is in the WHERE so a line that stopped being in - * transit between the caller's read and this write matches zero rows (compare-and-set). - */ - public int setItemExpectedArrival(long pricingRequestId, long pricingRequestItemId, - LocalDate expectedArrivalDate, long setById) { - return jdbc.update(""" - UPDATE sales.pricing_request_item - SET expected_arrival_date = :eta, - expected_arrival_set_by_id = :by, - expected_arrival_set_at = now() - WHERE pricing_request_item_id = :id - AND pricing_request_id = :pricingRequestId - AND stock_source = 'IN_TRANSIT' - """, - new MapSqlParameterSource() - .addValue("id", pricingRequestItemId) - .addValue("pricingRequestId", pricingRequestId) - .addValue("eta", expectedArrivalDate) - .addValue("by", setById)); - } - - /** - * Stock lines (V194, R13): turns ONE in-transit line back into a normal import line. Source, - * ETA and the ETA's who/when are cleared in a SINGLE statement - chk_pricing_request_item_eta_only_in_transit - * forbids an ETA on a non-transit line, so clearing them in two updates would trip it. - */ - public int convertItemInTransitToImport(long pricingRequestId, long pricingRequestItemId) { - return jdbc.update(""" - UPDATE sales.pricing_request_item - SET stock_source = NULL, - expected_arrival_date = NULL, - expected_arrival_set_by_id = NULL, - expected_arrival_set_at = NULL - WHERE pricing_request_item_id = :id - AND pricing_request_id = :pricingRequestId - AND stock_source = 'IN_TRANSIT' - """, - new MapSqlParameterSource() - .addValue("id", pricingRequestItemId) - .addValue("pricingRequestId", pricingRequestId)); - } - /** * True if {@code factoryName} already has a CURRENT {@code sales.factory_quote} on this * pricing request whose status has moved past {@code DRAFT} — i.e. an RFQ that a human has @@ -1316,7 +1272,8 @@ private PricingRequestSummaryDto mapSummary(ResultSet rs) throws SQLException { rs.getString("unit_code"), rs.getBoolean("omit_contact_honorific"), nullableLong(rs, "customer_id"), - rs.getString("ticket_status") + rs.getString("ticket_status"), + rs.getString("ticket_payment_status") ); } diff --git a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java index 6b84e7694..b91e97b0f 100644 --- a/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java +++ b/backend/src/main/java/th/co/glr/hr/pricingrequest/PricingRequestService.java @@ -59,21 +59,8 @@ public class PricingRequestService { // two lists in sync by inspection, not by sharing a mutable reference. private static final Set SALES_ROLES = Set.of("sales"); private static final Set IMPORT_ROLES = Set.of("import"); - // Stock lines (V194): who may confirm an in-transit line's ETA (import; the CEO may too). - // Converting an in-transit line to สั่งนำเข้า is IMPORT ONLY - the CEO is refused (IA 1.7). - private static final Set ETA_ROLES = Set.of("import", "ceo"); /** Line sources (V194). null = สั่งนำเข้า. */ static final String STOCK_IN_THAILAND = "IN_THAILAND"; - static final String STOCK_IN_TRANSIT = "IN_TRANSIT"; - /** - * Statuses in which an in-transit line's ETA may be saved / the line converted to import. - * READY_FOR_CEO_REVIEW is included on purpose: import must be able to correct an ETA (or switch - * the line) after the request has already gone to the CEO - convert then pulls it back. - */ - private static final Set STOCK_LINE_EDIT_STATUSES = Set.of( - PricingRequestStatus.IMPORT_REVIEWING, - PricingRequestStatus.AWAITING_FACTORY_RESPONSE, - PricingRequestStatus.READY_FOR_CEO_REVIEW); // Mirrors TicketService.VIEWER_ROLES: who may read a pricing request at all. // sales_manager stays read-only oversight here too — never add it to // SALES_ROLES/IMPORT_ROLES. @@ -140,6 +127,7 @@ public PricingRequestDetailDto createDraft(long ticketId, CreatePricingRequestRe return detail(requireSameTicket(existing, ticketId).id()); } requireActive(ticket); + requireOrderNotConfirmed(ticket); // Quotation <-> deal linking slice 1 (IA §7, 2026-09-30): a deal with a LIVE direct // (DEAL_DIRECT) quotation is priced by hand on that quotation; a pricing request on top would // make it the mixed-origin deal nobody has ever produced. Keyed on the live quotation, not @@ -299,6 +287,7 @@ public PricingRequestDetailDto submit(long id, UserPrincipal actor) { } TicketSummaryDto ticket = requireTicket(summary.ticketId()); requireActive(ticket); + requireOrderNotConfirmed(ticket); List items = requests.findItems(id); if (items.isEmpty()) { @@ -577,101 +566,12 @@ public PricingRequestDetailDto setItemFactory(long id, long itemId, SetItemFacto return detail(id); } - /** - * Stock lines (V194, IA 1.2, R12): import (or the CEO) confirms the วันที่คาดว่าจะถึง of ONE - * {@code IN_TRANSIT} line, then re-checks the single readiness predicate so the request advances - * the moment the last missing ETA lands. - * - *

    Authz (IA 1.7): import + ceo only, 403 for everyone else. Refusals: 400 null date; 409 when - * the request is outside IMPORT_REVIEWING / AWAITING_FACTORY_RESPONSE / READY_FOR_CEO_REVIEW or the - * line is not in transit. Changing an ETA that is already set is allowed (and keeps a - * READY_FOR_CEO_REVIEW request there). - */ - @Transactional - public PricingRequestDetailDto setInTransitArrival(long id, long itemId, LocalDate expectedArrivalDate, - UserPrincipal actor) { - requireRole(actor, ETA_ROLES); - if (expectedArrivalDate == null) { - throw new ApiException(HttpStatus.BAD_REQUEST, "ต้องระบุวันที่คาดว่าจะถึง"); - } - requests.lockPricingRequest(id); - PricingRequestSummaryDto summary = requireViewable(id, actor); - requireStockLineEditableStatus(summary); - requireActive(requireTicket(summary.ticketId())); - PricingRequestItemDto item = requireItem(id, itemId); - if (!STOCK_IN_TRANSIT.equals(item.stockSource())) { + /** Once the customer's order is confirmed (payment track started) no new pricing round may start on the deal. */ + private static void requireOrderNotConfirmed(TicketSummaryDto ticket) { + if (ticket.paymentStatus() != null) { throw new ApiException(HttpStatus.CONFLICT, - "ระบุวันที่คาดว่าจะถึงได้เฉพาะรายการสต็อกกำลังเดินทางเท่านั้น"); + "ยืนยันคำสั่งซื้อของดีลนี้แล้ว จึงเริ่มรอบราคาใหม่ไม่ได้ — การเปลี่ยนแปลงหลังยืนยันต้องทำเป็นการแก้ไขคำสั่งซื้อ"); } - int rows = requests.setItemExpectedArrival(id, itemId, expectedArrivalDate, actor.id()); - if (rows == 0) { - throw new ApiException(HttpStatus.CONFLICT, - "คำขอราคาถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"); - } - requests.addEvent(id, summary.ticketId(), actor.id(), actor.name(), - PricingRequestEventKind.PRICING_REQUEST_ITEM_ETA_SET, summary.status(), summary.status(), - "ระบุวันที่คาดว่าจะถึง " + expectedArrivalDate + " ให้รายการ " + item.displayName(), null); - advanceIfReadyForCeo(summary, actor); - return detail(id); - } - - /** - * Stock lines (V194, IA 1.2, R13): import switches an {@code IN_TRANSIT} line to สั่งนำเข้า - * because the shipment cannot cover it - clears source + ETA (+ who/when) in ONE update, notifies - * the owning rep only, and pulls a READY_FOR_CEO_REVIEW request back to - * AWAITING_FACTORY_RESPONSE (the converted line has no factory quote yet). - * - *

    Authz (IA 1.7): import ONLY - the CEO is refused too. Same status window as - * {@link #setInTransitArrival}; 409 on a line that is not in transit. - */ - @Transactional - public PricingRequestDetailDto convertInTransitToImport(long id, long itemId, UserPrincipal actor) { - requireRole(actor, IMPORT_ROLES); - requests.lockPricingRequest(id); - PricingRequestSummaryDto summary = requireViewable(id, actor); - requireStockLineEditableStatus(summary); - requireActive(requireTicket(summary.ticketId())); - PricingRequestItemDto item = requireItem(id, itemId); - if (!STOCK_IN_TRANSIT.equals(item.stockSource())) { - throw new ApiException(HttpStatus.CONFLICT, - "เปลี่ยนเป็นสั่งนำเข้าได้เฉพาะรายการสต็อกกำลังเดินทางเท่านั้น"); - } - int rows = requests.convertItemInTransitToImport(id, itemId); - if (rows == 0) { - throw new ApiException(HttpStatus.CONFLICT, - "คำขอราคาถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง"); - } - String toStatus = summary.status(); - if (PricingRequestStatus.READY_FOR_CEO_REVIEW.equals(summary.status())) { - int pulledBack = requests.transition(id, PricingRequestStatus.READY_FOR_CEO_REVIEW, - PricingRequestStatus.AWAITING_FACTORY_RESPONSE, null, null); - if (pulledBack == 1) { - toStatus = PricingRequestStatus.AWAITING_FACTORY_RESPONSE; - } - } - requests.addEvent(id, summary.ticketId(), actor.id(), actor.name(), - PricingRequestEventKind.PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT, summary.status(), toStatus, - "เปลี่ยนรายการ " + item.displayName() + " จากสต็อกกำลังเดินทางเป็นสั่งนำเข้า", null); - notifications.notifyEmployeeForPricingRequest(summary.requestedById(), summary.id(), - "PRICING_REQUEST_ITEM_CONVERTED_TO_IMPORT", - "คำขอราคา " + summary.requestCode() + ": รายการ " + item.displayName() - + " เปลี่ยนจากสต็อกกำลังเดินทางเป็นสั่งนำเข้า (สต็อกระหว่างทางไม่พอ)"); - return detail(id); - } - - private void requireStockLineEditableStatus(PricingRequestSummaryDto summary) { - if (!STOCK_LINE_EDIT_STATUSES.contains(summary.status())) { - throw new ApiException(HttpStatus.CONFLICT, - "แก้ไขรายการสต็อกได้เฉพาะคำขอราคาที่อยู่ระหว่างการดำเนินการของฝ่ายนำเข้าหรือรอ CEO เท่านั้น (สถานะปัจจุบัน: '" - + summary.status() + "')"); - } - } - - private PricingRequestItemDto requireItem(long id, long itemId) { - return requests.findItems(id).stream() - .filter(candidate -> candidate.id() == itemId) - .findFirst() - .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "ไม่พบรายการสินค้านี้ในคำขอราคานี้")); } /** True when the request is non-empty and EVERY line is สต็อกในไทย (R4). */ @@ -679,37 +579,6 @@ private static boolean allInThailand(List items) { return !items.isEmpty() && items.stream().allMatch(i -> STOCK_IN_THAILAND.equals(i.stockSource())); } - /** - * Re-checks the ONE readiness predicate after an ETA save (the markReadyForCosting side lives in - * FactoryQuoteService) and advances via the right edge: AWAITING_FACTORY_RESPONSE -> - * READY_FOR_CEO_REVIEW, or - a request with NO import line, so no factory quote ever moves it - * off IMPORT_REVIEWING - IMPORT_REVIEWING -> READY_FOR_CEO_REVIEW (the V194 edge). A request - * that is already READY_FOR_CEO_REVIEW simply stays there. - */ - private void advanceIfReadyForCeo(PricingRequestSummaryDto before, UserPrincipal actor) { - PricingRequestSummaryDto summary = requests.findSummary(before.id()).orElse(null); - if (summary == null) { - return; - } - String from = summary.status(); - boolean noImportLines = requests.findItems(summary.id()).stream().allMatch(i -> i.stockSource() != null); - boolean advanceable = PricingRequestStatus.AWAITING_FACTORY_RESPONSE.equals(from) - || (PricingRequestStatus.IMPORT_REVIEWING.equals(from) && noImportLines); - if (!advanceable || !factoryQuoteCarryForward.isReadyForCeoReview(summary)) { - return; - } - int transitioned = requests.transition(summary.id(), from, - PricingRequestStatus.READY_FOR_CEO_REVIEW, null, null); - if (transitioned == 1) { - requests.addEvent(summary.id(), summary.ticketId(), actor.id(), actor.name(), - PricingRequestEventKind.PRICING_COSTING_SUBMITTED, from, - PricingRequestStatus.READY_FOR_CEO_REVIEW, - "ราคาโรงงานและวันที่คาดว่าจะถึงครบแล้ว - ส่งต่อให้ CEO", null); - notifyCeo(summary, PricingRequestEventKind.PRICING_COSTING_SUBMITTED, - "คำขอราคา " + summary.requestCode() + " พร้อมให้ CEO พิจารณาราคาแล้ว"); - } - } - @Transactional public PricingRequestDetailDto cancel(long id, CancelPricingRequestRequest request, UserPrincipal actor) { PricingRequestSummaryDto summary = requireViewable(id, actor); @@ -783,6 +652,7 @@ public PricingRequestDetailDto createCustomerChangeRevision(long id, CustomerCha } TicketSummaryDto ticket = requireTicket(parent.ticketId()); requireActive(ticket); + requireOrderNotConfirmed(ticket); validateClientRequestId(request.clientRequestId()); PricingRequestSummaryDto existing = existingForClientRequest(actor.id(), request.clientRequestId()); if (existing != null) { @@ -1420,7 +1290,7 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR // Stock lines (V194): an unknown source is a caller-fixable 400, not a CHECK-constraint 500. // A blank source normalizes to null (สั่งนำเข้า). String stockSource = hasText(rawItem.stockSource()) ? rawItem.stockSource().trim() : null; - if (stockSource != null && !STOCK_IN_THAILAND.equals(stockSource) && !STOCK_IN_TRANSIT.equals(stockSource)) { + if (stockSource != null && !STOCK_IN_THAILAND.equals(stockSource)) { throw new ApiException(HttpStatus.BAD_REQUEST, "รายการที่ " + (rowNumber + 1) + ": ไม่รองรับแหล่งที่มาของสินค้า '" + rawItem.stockSource() + "'"); } @@ -1464,6 +1334,15 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR // country it was not actually typed for. String originCountryOther = ORIGIN_COUNTRY_OTHER.equals(item.originCountry()) ? item.originCountryOther() : null; + // A stock line is never imported (walk 4): whatever import-only inputs a client sent are not stored, + // or the quotation would print an import lead time for goods already in Thailand. + boolean stockLine = stockSource != null; + String originCountry = stockLine ? null : item.originCountry(); + Integer leadTimeMinDays = stockLine ? null : item.leadTimeMinDays(); + Integer leadTimeMaxDays = stockLine ? null : item.leadTimeMaxDays(); + if (stockLine) { + originCountryOther = null; + } return new PricingRequestItemRequest( item.sourceTicketItemId(), item.productId(), item.variantId(), item.brand(), item.model(), item.productDescription(), item.color(), item.texture(), item.size(), item.factory(), @@ -1471,8 +1350,8 @@ private PricingRequestItemRequest resolveItem(int rowNumber, PricingRequestItemR item.targetDeliveryDate(), item.deliveryLocation(), item.specialRequirement(), item.productCode(), item.thicknessMm(), item.sqmPerPiece(), quantityMode, item.areaSqm(), item.piecesInput(), item.wastageMode(), item.wastageValue(), item.piecesPerBox(), - item.sqmPerBox(), roundToFullBox, item.originCountry(), item.leadTimeMinDays(), - item.leadTimeMaxDays(), result.piecesBeforeWastage(), result.piecesAfterWastage(), + item.sqmPerBox(), roundToFullBox, originCountry, leadTimeMinDays, + leadTimeMaxDays, result.piecesBeforeWastage(), result.piecesAfterWastage(), result.boxes(), originCountryOther, stockSource, requestedCurrency(rowNumber, item.requestedCurrency()), requestedPriceUnitBasis(item.requestedPriceUnitBasis())); diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java index 74b1542a5..d56298770 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketRepository.java @@ -523,12 +523,10 @@ public void replaceItemsPreservingPricing(long ticketId, List ite // method just persists whatever it was handed, exactly like every other // pricing-adjacent field above (approvedPrice/calcedCost/manualPrice/...). .addValue("weightMultiplier", item.weightMultiplier()) - // V183: unlike weightMultiplier (guarded, "prior always wins"), sourcedFromStock/ - // stockSalePrice are sales-writable -- TicketService.mergeEditedItemsPreservingPricing - // resolves them from the incoming request with a null-safe fallback to the prior - // row (so an edit that says nothing about them does not silently clear a flag set - // on a previous save), and validates the pairing before this method ever sees the - // TicketItemDto. This method just persists whatever it was handed. + // V183: unlike weightMultiplier (guarded, "prior always wins"), sourcedFromStock is + // sales-writable -- TicketService.mergeEditedItemsPreservingPricing resolves it from the + // request with a null-safe fallback to the prior row. It also resolves stockSalePrice to + // NULL (walk 4); this method just persists whatever it was handed. .addValue("sourcedFromStock", item.sourcedFromStock()) .addValue("stockSalePrice", item.stockSalePrice()); } @@ -1110,25 +1108,10 @@ private void insertItems(long ticketId, List items) { .addValue("sortOrder", i) .addValue("catalogPriceId", item.catalogPriceId()) .addValue("catalogProductCode", item.catalogProductCode()) - // V183: sourcedFromStock is a Boolean (nullable) on the request record -- a - // request that never mentions it (every call site before this feature) means - // "not from stock", same as an explicit false. This method persists what it is - // given without validating the sourcedFromStock/stockSalePrice pairing -- that - // invariant is enforced in TicketService.create before any DB write is attempted - // (see its item-validation loop) and backstopped by chk_ticket_item_stock_sale_price. + // sourcedFromStock is a nullable Boolean: a request that never mentions it means "not from + // stock", same as false. The sales-typed price is gone (walk 4): the column always stays NULL. .addValue("sourcedFromStock", Boolean.TRUE.equals(item.sourcedFromStock())) - // Force-null when the flag is false, exactly as - // TicketService.mergeEditedItemsPreservingPricing does on the edit path (and as - // mockApi.js and TicketCreateModal's submit() already do client-side): never let a - // stray price ride along on a line that is not flagged stock-sourced. - // TicketService.create validates "flag true => positive price", but it deliberately - // does NOT reject the inverse, so without this a malformed request carrying - // {sourcedFromStock:false, stockSalePrice:500} reached chk_ticket_item_stock_sale_price - // and came back as a 500 (DataIntegrityViolationException -> handleDataAccess) with the - // raw driver message, instead of being silently normalised the way every other layer - // already normalises it. - .addValue("stockSalePrice", - Boolean.TRUE.equals(item.sourcedFromStock()) ? item.stockSalePrice() : null); + .addValue("stockSalePrice", null); } jdbc.batchUpdate(""" INSERT INTO sales.ticket_item @@ -2497,18 +2480,22 @@ public void setHasEdits(long ticketId, boolean value) { * that FK does not exist (source_ticket_item_id is a plain nullable BIGINT, not an FK * — see V59's own column definition), so this IS defensive, not merely decorative). */ - public boolean reconcileItemQty(long ticketId, long itemId, BigDecimal qty, BigDecimal qtySqm) { + public boolean reconcileItemQty(long ticketId, long itemId, BigDecimal qty, BigDecimal qtySqm, + BigDecimal qtyFromStock) { + // qty_from_stock moves in the same statement as qty so chk_ticket_item_qty_from_stock never + // sees a half-written row: the pricing request's stock lines are the only writer (no reserve step). return jdbc.update(""" UPDATE sales.ticket_item - SET qty = :qty, qty_sqm = :qtySqm + SET qty = :qty, qty_sqm = :qtySqm, qty_from_stock = :qtyFromStock WHERE ticket_id = :ticketId AND item_id = :itemId - AND qty IS DISTINCT FROM :qty + AND (qty IS DISTINCT FROM :qty OR qty_from_stock IS DISTINCT FROM :qtyFromStock) """, new MapSqlParameterSource() .addValue("ticketId", ticketId) .addValue("itemId", itemId) .addValue("qty", qty) - .addValue("qtySqm", qtySqm)) > 0; + .addValue("qtySqm", qtySqm) + .addValue("qtyFromStock", qtyFromStock)) > 0; } /** @@ -2582,13 +2569,13 @@ AND NOT EXISTS ( */ public long insertReconciledItem(long ticketId, String brand, String model, String color, String texture, String size, String factory, BigDecimal qty, BigDecimal qtySqm, - String unitBasis) { + String unitBasis, BigDecimal qtyFromStock) { return jdbc.queryForObject(""" INSERT INTO sales.ticket_item (ticket_id, brand, model, color, texture, size, factory, qty, qty_sqm, unit_basis, - currency, sort_order) + qty_from_stock, currency, sort_order) VALUES (:ticketId, :brand, :model, :color, :texture, :size, :factory, :qty, :qtySqm, - :unitBasis, 'THB', + :unitBasis, :qtyFromStock, 'THB', (SELECT COALESCE(MAX(sort_order), -1) + 1 FROM sales.ticket_item WHERE ticket_id = :ticketId)) RETURNING item_id """, @@ -2602,7 +2589,8 @@ public long insertReconciledItem(long ticketId, String brand, String model, Stri .addValue("factory", factory) .addValue("qty", qty) .addValue("qtySqm", qtySqm) - .addValue("unitBasis", unitBasis), + .addValue("unitBasis", unitBasis) + .addValue("qtyFromStock", qtyFromStock), Long.class); } diff --git a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java index 0b73210ce..db3e89957 100644 --- a/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java +++ b/backend/src/main/java/th/co/glr/hr/ticket/TicketService.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDate; import java.math.BigDecimal; -import java.math.RoundingMode; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -202,7 +201,7 @@ public TicketDto get(long id, UserPrincipal actor) { } TicketDto ticket = requireViewAccess(id, actor); // B5 fix: display-only fallback, scoped to THIS single read endpoint on purpose. Every - // other reader of ticket.items() (actions()'s canReserveStock/canRecordDelivery/ + // other reader of ticket.items() (actions()'s canRecordDelivery/ // hasRemainingDelivery, editItems' merge-by-position, commission/delivery calculations, // ...) must keep seeing the real (possibly empty) sales.ticket_item rows -- those all // read qtyDelivered/qtyFromStock and stage/action gates that a borrowed quotation line @@ -354,21 +353,6 @@ public TicketDto create(CreateTicketRequest request, UserPrincipal actor) { throw new ApiException(HttpStatus.BAD_REQUEST, "ไม่รองรับระดับความสำคัญ '" + request.priority() + "'"); } - // V183 (stock-sourced deal-line pricing): tickets.create(...) below calls - // TicketRepository#insertItems directly off the raw TicketItemRequest list — there is no - // DTO merge step (like editItems' mergeEditedItemsPreservingPricing) to hook validation - // into before persistence, so the invariant chk_ticket_item_stock_sale_price enforces at - // the DB layer is checked here instead, before any DB write, same style as the - // priority/entryChannel guards immediately above. - if (request.items() != null) { - for (TicketItemRequest item : request.items()) { - if (Boolean.TRUE.equals(item.sourcedFromStock()) - && !isStorablePositivePrice(item.stockSalePrice())) { - throw new ApiException(HttpStatus.BAD_REQUEST, - "ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก"); - } - } - } String code = tickets.nextTicketCode(); long id = tickets.create(request, code, actor.id(), actor.name()); // The readiness gate has TWO halves — next_follow_up_at (collected on the request now) and @@ -945,6 +929,11 @@ private TicketDto applyCustomerConfirmation(long ticketId, TicketSummaryDto s, S } // Deal pipeline (V50): a confirmed PO advances the deal — guarded no-op inside. autoAdvanceStage(s, DealStage.ORDER_RECEIVED, actor); + // No deposit to wait for (0% quotation, or a policy already waived): an all-stock deal goes + // straight to delivery. `s` is stale here (the policy may have just changed), so re-read. + if (DepositPolicy.bypassesDepositNotice(requireTicket(ticketId).summary().depositPolicy())) { + routeAllStockDealToDelivery(ticketId, actor); + } return requireTicket(ticketId); } @@ -1463,18 +1452,7 @@ public TicketDto reserveStock(long ticketId, StockReservationRequest request, Us tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.STOCK_RESERVED, s.status(), s.status(), "qty_from_stock=" + totalDeclared.stripTrailingZeros().toPlainString()); - boolean allCovered = !ticket.items().isEmpty() - && ticket.items().stream() - .allMatch(item -> nullToZero(mergedStock.get(item.id())).compareTo(nullToZero(item.qty())) >= 0); - boolean leavesTheImportAxis = allCovered - && (s.fulfillmentStatus() == null || FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus())); - if (leavesTheImportAxis) { - tickets.updateFulfillmentStatus(ticketId, FulfilmentStatus.FROM_STOCK); - // Fully covered from stock — no import journey, so the goods are ready - // now. Advance straight to DELIVERY_SCHEDULING (S18) rather than - // PROCUREMENT (an import step this deal never performs). - autoAdvanceStage(s, DealStage.DELIVERY_SCHEDULING, actor); - } + boolean leavesTheImportAxis = routeAllStockDealToDelivery(s, ticket.items(), mergedStock, actor); notifySalesManagerOfRepDeclaration(s, actor, mergedStock, ticket.items(), leavesTheImportAxis); return requireTicket(ticketId); } @@ -1602,7 +1580,7 @@ public TicketDto setItemWeightMultipliers(long ticketId, ItemWeightMultiplierReq * Who may set THIS deal's per-item commission weight -- the single source of truth for {@link * #setItemWeightMultipliers}'s gate and for whether {@link #actions} advertises {@code * SET_ITEM_WEIGHT_MULTIPLIER}, exactly the same "one predicate, never two copies" discipline - * {@link #canReserveStock} documents for {@code RESERVE_STOCK}. No ownership/stage nuance: + * {@link #canWriteDelivery} documents. No ownership/stage nuance: * {@link #ITEM_WEIGHT_ROLES} are both blanket oversight roles here, the same shape {@code * CommissionService#updateDeductions}'s {@code requireManagerOrCeo} already has at the record * level. @@ -1660,6 +1638,9 @@ private TicketDto recordDeliveryInternal(TicketDto ticket, RecordDeliveryRequest if (!DELIVERY_SOURCES.contains(source)) { throw new ApiException(HttpStatus.BAD_REQUEST, "source ต้องเป็น WAREHOUSE หรือ STOCK"); } + if ("STOCK".equals(source) && !depositSettled(s)) { + throw new ApiException(HttpStatus.CONFLICT, "ส่งสินค้าจากสต็อกได้หลังรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น"); + } Map itemsById = itemMap(ticket.items()); Map combined = new LinkedHashMap<>(); for (RecordDeliveryRequest.Line line : request.lines()) { @@ -1828,6 +1809,11 @@ private TicketDto recordPaymentInternal(long ticketId, RecordPaymentRequest requ + (note != null ? " — " + note : ""), RelatedDocumentType.PAYMENT_RECEIPT, receiptId); reconcilePaymentStatus(ticketId, actor); + // Deposit confirmed (the payment track moved past CUSTOMER_CONFIRMED): an all-stock deal is ready to deliver. + String paymentAfter = requireTicket(ticketId).summary().paymentStatus(); + if (paymentAfter != null && !"CUSTOMER_CONFIRMED".equals(paymentAfter)) { + routeAllStockDealToDelivery(ticketId, actor); + } return requireTicket(ticketId); } @@ -2575,6 +2561,36 @@ static String quotationRecipientForStage(String stage) { return null; } + /** + * A deal whose every line is covered from stock (non-empty, covered >= qty) has no import journey: + * fulfilment becomes FROM_STOCK and the stage goes straight to DELIVERY_SCHEDULING (S18), not + * PROCUREMENT. Shared by reserveStock, the deposit confirmation and the 0%-deposit order + * confirmation. {@code coveredByItemId} is the per-line stock cover to judge. + * + * @return true when the deal is all-stock and not already on the import axis + */ + private boolean routeAllStockDealToDelivery(TicketSummaryDto s, List items, + Map coveredByItemId, UserPrincipal actor) { + boolean allCovered = !items.isEmpty() + && items.stream() + .allMatch(item -> nullToZero(coveredByItemId.get(item.id())).compareTo(nullToZero(item.qty())) >= 0); + boolean leavesTheImportAxis = allCovered + && (s.fulfillmentStatus() == null || FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus())); + if (leavesTheImportAxis) { + tickets.updateFulfillmentStatus(s.id(), FulfilmentStatus.FROM_STOCK); + autoAdvanceStage(s, DealStage.DELIVERY_SCHEDULING, actor); + } + return leavesTheImportAxis; + } + + /** {@link #routeAllStockDealToDelivery(TicketSummaryDto, List, Map, UserPrincipal)} on a fresh read of the deal. */ + private void routeAllStockDealToDelivery(long ticketId, UserPrincipal actor) { + TicketDto ticket = requireTicket(ticketId); + Map covered = new LinkedHashMap<>(); + ticket.items().forEach(item -> covered.put(item.id(), nullToZero(item.qtyFromStock()))); + routeAllStockDealToDelivery(ticket.summary(), ticket.items(), covered, actor); + } + /** * Same-transaction stage advance from the deal's own operational transitions — and, since the * 2026-10-16 ruling, from a quotation being addressed to a recipient. No-throw by construction: @@ -2732,6 +2748,10 @@ public TicketDto waiveDeposit(long ticketId, String policy, String reason, UserP tickets.addEvent(ticketId, actor.id(), actor.name(), TicketEventKind.POLICY_CHANGED, s.salesStage(), s.salesStage(), "deposit_policy → " + policy + " — " + reason.trim()); + // Nothing left to wait for: an all-stock deal at CUSTOMER_CONFIRMED goes straight to delivery. + if (PaymentTrack.CUSTOMER_CONFIRMED.equals(s.paymentStatus())) { + routeAllStockDealToDelivery(ticketId, actor); + } return requireTicket(ticketId); } @@ -2891,24 +2911,6 @@ public TicketDto editItems(long ticketId, EditItemsRequest request, UserPrincipa return requireTicket(ticketId); } - /** - * V183: is this a stock sale price that will still be positive once Postgres has STORED it? - * - *

    {@code sales.ticket_item.stock_sale_price} is {@code numeric(14,2)}, and Postgres rounds - * a value to the column's scale BEFORE {@code chk_ticket_item_stock_sale_price} sees it. A - * bare {@code signum() > 0} therefore does NOT match the CHECK: {@code 0.004} is positive in - * Java, is stored as {@code 0.00}, and the constraint then rejects the row — surfacing as a - * {@link org.springframework.dao.DataIntegrityViolationException}, which - * {@code ApiExceptionHandler.handleDataAccess} reports as a 500 (with the raw driver message), - * not the clean Thai 400 this validation exists to give. Rounding HALF_UP at scale 2 is - * exactly Postgres' own numeric rounding, so this predicate agrees with the CHECK for every - * input. Verified against real Postgres 17: {@code 0.004} violates the constraint, - * {@code 0.005} stores as {@code 0.01}. - */ - private static boolean isStorablePositivePrice(BigDecimal price) { - return price != null && price.setScale(2, RoundingMode.HALF_UP).signum() > 0; - } - private List mergeEditedItemsPreservingPricing( long ticketId, List existingItems, List requestItems) { List merged = new ArrayList<>(requestItems.size()); @@ -2927,33 +2929,14 @@ private List mergeEditedItemsPreservingPricing( String currency = prior != null ? prior.currency() : ((r.currency() != null && !r.currency().isBlank()) ? r.currency() : "THB"); - // V183: unlike proposedPrice/approvedPrice/calcedCost/manualPrice (guarded, "prior - // always wins" because only import/CEO may set them), sourcedFromStock/stockSalePrice - // are a sales decision made at deal-entry/edit time -- the request WINS, mirroring - // unitBasis's own null-safe-fallback treatment just above: a request that says - // nothing about these fields (r.sourcedFromStock() == null) inherits the prior row's - // value rather than silently resetting the flag to "not from stock" on every - // unrelated edit. + // sourcedFromStock is a sales decision made at deal-entry/edit time -- the request WINS, with + // the same null-safe fallback as unitBasis: a request that says nothing about it inherits the + // prior row's flag. The sales-typed stockSalePrice is gone (walk 4): the deal shows the + // CEO-approved price, so whatever a client sends is dropped and the column stays NULL. boolean sourcedFromStock = r.sourcedFromStock() != null ? r.sourcedFromStock() : (prior != null && prior.sourcedFromStock()); - BigDecimal stockSalePrice = r.sourcedFromStock() != null - ? r.stockSalePrice() - : (prior != null ? prior.stockSalePrice() : null); - // Same invariant chk_ticket_item_stock_sale_price enforces at the DB layer -- - // checked here too so a bad request 400s with a clear Thai message instead of - // reaching replaceItemsPreservingPricing and failing as an opaque 500 from a - // constraint violation. - if (sourcedFromStock) { - if (!isStorablePositivePrice(stockSalePrice)) { - throw new ApiException(HttpStatus.BAD_REQUEST, - "ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก"); - } - } else { - // Force-null regardless of what the request sent — never trust a stray price - // through when the item isn't flagged as stock-sourced. - stockSalePrice = null; - } + BigDecimal stockSalePrice = null; merged.add(new TicketItemDto( prior != null ? prior.id() : 0L, ticketId, @@ -3170,10 +3153,6 @@ private void addOperationalActions(List actions, TicketDto tick && FulfilmentStatus.SHIPPING.equals(s.fulfillmentStatus())) { actions.add(new TicketActionDto("GOODS_RECEIVED", "fulfillment", "รับสินค้า")); } - if (canReserveStock(ticket, actor)) { - actions.add(new TicketActionDto("RESERVE_STOCK", "fulfillment", "จองสินค้าจากสต็อก", - List.of("lines"))); - } if (canSetItemWeightMultiplier(s, actor)) { actions.add(new TicketActionDto("SET_ITEM_WEIGHT_MULTIPLIER", "fulfillment", "ตั้งน้ำหนักคอมมิชชั่นต่อรายการ", List.of("lines"))); @@ -3367,12 +3346,8 @@ private void requireDeliveryAccess(TicketSummaryDto s, UserPrincipal actor) { } /** - * The stage floor a stock-coverage declaration must clear, and the single source of truth for - * it — {@link #reserveStock}'s 409 and {@link #canReserveStock} (which decides whether {@link - * #actions} advertises RESERVE_STOCK) both read this one predicate, exactly as they both read - * {@link #canDeclareStockCoverage} for the authorisation half. Applying the floor to only one - * of them would put the capability back in the state the widening was careful to avoid: live - * but invisible, or advertised and then refused on click. + * The stage floor a stock-coverage declaration must clear: {@link #reserveStock}'s 409 reads it. + * (Walk 4: {@code RESERVE_STOCK} is no longer advertised in {@link #actions}; the endpoint stays.) * *

    Why ORDER_RECEIVED (S10), and why a floor is needed at all. The * declaration is uncorroborated (see {@link #reserveStock}), yet a full-coverage one reroutes @@ -3399,14 +3374,6 @@ private static boolean stockCoverageStageReached(TicketSummaryDto s) { return DealStage.indexOf(s.salesStage()) >= DealStage.indexOf(DealStage.ORDER_RECEIVED); } - private boolean canReserveStock(TicketDto ticket, UserPrincipal actor) { - return canDeclareStockCoverage(ticket.summary(), actor) - && stockCoverageStageReached(ticket.summary()) - && !ticket.items().isEmpty() - && hasRemainingDelivery(ticket) - && !FulfilmentStatus.FULLY_DELIVERED.equals(ticket.summary().fulfillmentStatus()); - } - private boolean canRecordDelivery(TicketDto ticket, UserPrincipal actor) { if (!canWriteDelivery(ticket.summary(), actor) || !hasRemainingDelivery(ticket)) { return false; @@ -3415,7 +3382,20 @@ private boolean canRecordDelivery(TicketDto ticket, UserPrincipal actor) { boolean stockAvailable = ticket.items().stream() .anyMatch(item -> nullToZero(item.qtyFromStock()).compareTo(nullToZero(item.qtyDelivered())) > 0); boolean warehouseAvailable = warehouseDeliveryAvailable(s, s.id()); - return FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus()) || stockAvailable || warehouseAvailable; + return ((FulfilmentStatus.FROM_STOCK.equals(s.fulfillmentStatus()) || stockAvailable) && depositSettled(s)) + || warehouseAvailable; + } + + /** + * The deposit is in hand: paid (or later), or a no-deposit policy that has reached CUSTOMER_CONFIRMED. + * A deposit NOTICE alone is not money. Stock goods leave the warehouse only after this (walk 4: stock + * lines are covered from order confirmation, so without it a deal could deliver before paying). + */ + private static boolean depositSettled(TicketSummaryDto s) { + String ps = s.paymentStatus(); + return PaymentTrack.DEPOSIT_PAID.equals(ps) || PaymentTrack.AWAITING_FINAL_PAYMENT.equals(ps) + || PaymentTrack.FULLY_PAID.equals(ps) + || (DepositPolicy.bypassesDepositNotice(s.depositPolicy()) && PaymentTrack.CUSTOMER_CONFIRMED.equals(ps)); } /** Prerequisites only — the role checks live on each action below. */ diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java index e8cf5c110..e1e510b70 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationEnglishFormTest.java @@ -313,6 +313,22 @@ void remarks_areEnglishAndCarryTheComputedDepositLeadTimeAndValidity() throws Ex assertThat(remarks).noneMatch(line -> line.contains("บริษัทฯ")); } + /** Walk 4 (W4-01), English twin: an all-stock quotation prints no "Delivery" import line, even when its + * lead-time columns carry a value; a stock line beside an import line is left out of the group. */ + @Test + void remarks_aStockLineIsLeftOutOfTheEnglishLeadTime_andAnAllStockQuotationHasNoDeliveryLine() { + DealQuotationItemDto stock = tile().withStockSource("IN_THAILAND"); + QuotationRenderModel allStock = DealQuotationRenderAdapter.toRenderModel( + withItems(englishQuotation(), List.of(stock)), null, null, List.of()); + assertThat(allStock.remarkLines()).hasSize(7).noneMatch(l -> l.contains("Delivery")); + + DealQuotationItemDto importLine = tile(); + DealQuotationItemDto stockSecond = tileAtSeq(2).withStockSource("IN_THAILAND"); + QuotationRenderModel mixed = DealQuotationRenderAdapter.toRenderModel( + withItems(englishQuotation(), List.of(importLine, stockSecond)), null, null, List.of()); + assertThat(mixed.remarkLines().get(2)).isEqualTo("3.Delivery : item 1 approximately 30-45 days"); + } + /** * ⚠️ The remark date is CE too. The Thai block prints 01/09/2569 for the same offer * date; getting this one wrong is invisible next to a correct header date, because it is @@ -1468,6 +1484,16 @@ private DealQuotationItemDto tile() { WastageCalculator.LINE_TYPE_TILE, BigDecimal.TEN, "pcs.", null, null, null, null, null); } + /** {@link #tile()} at another seq (same 30-45 day lead time), for a second line. */ + private DealQuotationItemDto tileAtSeq(int seq) { + return new DealQuotationItemDto((long) seq, seq, null, null, null, null, "A", null, null, "60x60", + new BigDecimal("2"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_MODE_PIECES, null, 10, + WastageCalculator.WASTAGE_MODE_NONE, null, 1, new BigDecimal("100.00"), null, null, 30, 45, null, + new BigDecimal("2.78"), 10, 10, 10, 10, new BigDecimal("100.00"), new BigDecimal("1000.00"), + "Tile Model A", "Size 60x60x2 cm.", "(10 pcs.)", + WastageCalculator.LINE_TYPE_TILE, BigDecimal.TEN, "pcs.", null, null, null, null, null); + } + /** V178 fixture — a DIRECT_NET tile whose net (85.00) genuinely differs from its list price * (100.00): {@link DealQuotationRenderAdapter#hasSpecialPricing} rule (b). */ private DealQuotationItemDto discountedDirectNetTile() { diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java index b887783a0..52ef1347a 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationRenderAdapterV3Test.java @@ -500,6 +500,26 @@ void leadTime_differentRangesStaySeparateGroups() { "3.ระยะเวลานำเข้า : รายการที่ 1 ประมาณ 10-20 วัน รายการที่ 2 ประมาณ 40-50 วัน"); } + /** + * Walk 4 (W4-01): a stock line (stockSource IN_THAILAND) is not imported, so even when its lead + * time columns carry a value it is left out of the ระยะเวลานำเข้า groups - the import line alone + * is printed as "รายการที่ 1", not "รายการที่ 1-2". + */ + @Test + void leadTime_aStockLineIsLeftOutOfTheImportGroups() { + QuotationRenderModel m = model(WastageCalculator.PRICE_MODE_NET, List.of( + tileWithLeadTime(30, 45), tileWithLeadTime(30, 45).withStockSource("IN_THAILAND"))); + assertThat(m.remarkLines().get(2)).isEqualTo("3.ระยะเวลานำเข้า : รายการที่ 1 ประมาณ 30-45 วัน"); + } + + /** Every line is stock: nothing is imported, so the ระยะเวลานำเข้า remark is dropped and the rest renumber. */ + @Test + void leadTime_anAllStockQuotation_printsNoImportLeadTimeLine() { + QuotationRenderModel m = model(WastageCalculator.PRICE_MODE_NET, List.of( + tileWithLeadTime(30, 45).withStockSource("IN_THAILAND"))); + assertThat(m.remarkLines()).hasSize(7).noneMatch(l -> l.contains("ระยะเวลานำเข้า")); + } + /** No item carries a lead time at all -- owner feedback (2026-09-14) supersedes the earlier * "print a visible blank" behaviour: the line is now dropped ENTIRELY (not left blank), and * every remark after it renumbers down by one -- exactly 7 lines, old line 4 becomes "3.", @@ -696,7 +716,8 @@ private DealQuotationItemDto reseq(DealQuotationItemDto src, int seq) { src.piecesPerSqm(), src.piecesBeforeWastage(), src.piecesAfterWastage(), src.piecesFinal(), src.boxes(), src.netUnitPrice(), src.lineAmount(), src.descriptionLine(), src.sizeLine(), src.calculationLine(), src.lineType(), src.quantity(), src.unit(), src.specialPriceSqm(), - src.adjustmentPct(), src.adjustmentDeadline(), src.specialPriceLine(), src.adjustmentAmount()); + src.adjustmentPct(), src.adjustmentDeadline(), src.specialPriceLine(), src.adjustmentAmount()) + .withStockSource(src.stockSource()); } private DealQuotationItemDto plain() { diff --git a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java index 7a1d3612e..b03314776 100644 --- a/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/deposit/RemainingInvoiceServiceIntegrationTest.java @@ -414,7 +414,7 @@ void createDraft_negativeNetAfterDeduction_isRefusedWithConflict() { @Test void issue_supersedesAnyOtherIssuedRemainingInvoiceOnTheSameDeal_evenADifferentChain() { - DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiChainA"); + DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiChainA", "MultiChainB"); RemainingInvoiceDocumentDto issuedA = remainingInvoiceService.issue( remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor).id(), salesActor); assertThat(issuedA.status()).isEqualTo("ISSUED"); @@ -422,7 +422,7 @@ void issue_supersedesAnyOtherIssuedRemainingInvoiceOnTheSameDeal_evenADifferentC // A wholly SEPARATE pricing-request chain on the SAME ticket, driven to its own accepted // quotation + issued deposit notice — a real shape (a later revision/re-quote on the same // deal), not merely a synthetic id swap. - buildSecondAcceptedQuotationChain(fixtureA.ticketId(), salesActor, "MultiChainB"); + acceptPreparedSecondChain(fixtureA.ticketId(), salesActor); RemainingInvoiceDocumentDto draftB = remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor); // The newest qualifying quotation is chain B's — never chain A's (which already has its // own ISSUED remaining invoice), proving this draft is genuinely a different chain. @@ -448,11 +448,11 @@ void issue_supersedesAnyOtherIssuedRemainingInvoiceOnTheSameDeal_evenADifferentC @Test void createDraft_refusesASecondDraftOnTheDealWhileOneAlreadyExists_evenADifferentChain() { - DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiDraftA"); + DealFixture fixtureA = buildAcceptedDeal(salesActor, "MultiDraftA", "MultiDraftB"); RemainingInvoiceDocumentDto draftA = remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor); assertThat(draftA.status()).isEqualTo("DRAFT"); - buildSecondAcceptedQuotationChain(fixtureA.ticketId(), salesActor, "MultiDraftB"); + acceptPreparedSecondChain(fixtureA.ticketId(), salesActor); // A second DRAFT on the SAME deal is refused even though it would be for a DIFFERENT // quotation chain than draftA's — O2: at most one live (DRAFT or ISSUED) remaining @@ -575,11 +575,11 @@ void createDraft_repositoryRaceIsMappedToConflict_neverAnUnmapped500() { * not per chain — enforceable as a real DB invariant since P1). */ @Test void revise_refusesWhenADraftForADifferentChainAlreadyExistsOnTheDeal() { - DealFixture fixtureA = buildAcceptedDeal(salesActor, "ReviseCrossChainA"); + DealFixture fixtureA = buildAcceptedDeal(salesActor, "ReviseCrossChainA", "ReviseCrossChainB"); RemainingInvoiceDocumentDto issuedA = remainingInvoiceService.issue( remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor).id(), salesActor); - buildSecondAcceptedQuotationChain(fixtureA.ticketId(), salesActor, "ReviseCrossChainB"); + acceptPreparedSecondChain(fixtureA.ticketId(), salesActor); RemainingInvoiceDocumentDto draftB = remainingInvoiceService.createDraft(fixtureA.ticketId(), null, salesActor); assertThat(draftB.customerQuotationId()).isNotEqualTo(issuedA.customerQuotationId()); @@ -891,6 +891,16 @@ void revise_repositoryRaceIsMappedToConflict_neverAnUnmapped500() { private record DealFixture(long ticketId, long pricingRequestId, long depositNoticeId) {} private DealFixture buildAcceptedDeal(UserPrincipal owner, String label) { + return buildAcceptedDeal(owner, label, null); + } + + /** Chain B's request and ISSUED quotation, prepared BEFORE chain A's order is confirmed (walk 4, W4-04: a + * confirmed deal can start no new pricing round); {@link #acceptPreparedSecondChain} accepts and confirms it. */ + private long preparedSecondPricingRequestId; + private long preparedSecondIssuedQuotationId; + + /** {@code secondChainLabel} non-null: a second chain is prepared (driven to ISSUED) before A is confirmed. */ + private DealFixture buildAcceptedDeal(UserPrincipal owner, String label, String secondChainLabel) { long catalogProductId = insertCatalogProduct(FACTORY, "IT", "TEST-RI-" + label + "-" + UUID.randomUUID().toString().substring(0, 8), new BigDecimal("100.00"), "THB", "per_piece"); @@ -926,6 +936,9 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M long pricingRequestId = pricingRequestService.createDraft(ticketId, request, owner).summary().id(); driveDraftPricingRequestToQuotationAccepted(pricingRequestId, quantity, owner); + if (secondChainLabel != null) { + prepareSecondQuotationChain(ticketId, owner, secondChainLabel); + } orderConfirmation.confirmOrder(pricingRequestId, new ConfirmOrderRequest(UUID.randomUUID().toString()), owner); @@ -941,8 +954,9 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M * driven to ACCEPTED (no second deposit notice: one ISSUED per deal), exactly like {@link #buildAcceptedDeal} * except it reuses the given ticket (and its first ticket item) instead of creating a new one. * Used by the O2 (one live remaining invoice per deal) tests to prove superseding/blocking - * reaches a genuinely different chain, not merely a different id on the same fixture. */ - private void buildSecondAcceptedQuotationChain(long ticketId, UserPrincipal owner, String label) { + * reaches a genuinely different chain, not merely a different id on the same fixture. This half builds it + * up to an ISSUED quotation; {@link #acceptPreparedSecondChain} finishes it. */ + private void prepareSecondQuotationChain(long ticketId, UserPrincipal owner, String label) { long catalogProductId = insertCatalogProduct(FACTORY, "IT", "TEST-RI-" + label + "-" + UUID.randomUUID().toString().substring(0, 8), new BigDecimal("100.00"), "THB", "per_piece"); @@ -983,8 +997,15 @@ null, new BigDecimal("10"), new BigDecimal("0.36"), WastageCalculator.QUANTITY_M // tests below, which does the identical "go around the service with raw SQL" move for the // identical reason). Chain A's own row is left untouched — genuinely still ACCEPTED. CustomerQuotationDto issuedB = driveDraftPricingRequestToQuotationIssued(pricingRequestId, quantity, owner); + preparedSecondPricingRequestId = pricingRequestId; + preparedSecondIssuedQuotationId = issuedB.id(); + } + + /** Accepts (raw SQL, see the MAJOR 5 note above) and confirms the chain {@link #prepareSecondQuotationChain} built. */ + private void acceptPreparedSecondChain(long ticketId, UserPrincipal owner) { + long pricingRequestId = preparedSecondPricingRequestId; jdbc.update("UPDATE sales.quotation SET doc_status = 'ACCEPTED', accepted_at = now() WHERE quotation_id = :id", - Map.of("id", issuedB.id())); + Map.of("id", preparedSecondIssuedQuotationId)); // recordOutcome ALSO transitions the pricing request's own status (QUOTATION_ISSUED -> // QUOTATION_ACCEPTED) — the confirmOrder call right below gates on THIS column, not // sales.quotation.doc_status, so the raw-SQL bypass above must carry that write too. diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java index 20f36b6d2..93810b66b 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/StoredImportRequestIntegrationTest.java @@ -269,6 +269,36 @@ void issue_mintsTheNumberAndFreezesTheDate_andSetsTheFirstStepToContacted() { e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); } + /** + * Walk 4 (owner ruling 2026-10-29: a stock line is never ordered from a factory). A DRAFT form + * left over from before the order was confirmed can name a line that is now covered from stock + * ({@code qty_from_stock >= qty}, written by order confirmation): issuing it would order stock + * the customer already has. ANY covered line refuses the issue (all covered, or one among several - + * a legacy mixed draft); nothing is numbered and the form stays a DRAFT. + */ + @Test + void issue_ofADraftWithAnyLineNowCoveredFromStock_isRefused409_andBurnsNoNumber() { + List drafts = DraftSeed.seed(jdbc, ticketId, owner); + long leaDraft = byFactory(drafts, leaFactoryId).id(); + long padanaDraft = byFactory(drafts, padanaFactoryId).id(); + // LEA's only line fully covered; Padana's FIRST of two lines covered (a mixed draft). + jdbc.update("UPDATE sales.ticket_item SET qty_from_stock = qty WHERE ticket_id = :t AND factory = 'LEA Stored Test'", + Map.of("t", ticketId)); + jdbc.update("UPDATE sales.ticket_item SET qty_from_stock = qty WHERE ticket_id = :t AND model = 'Lithos Nero Nat'", + Map.of("t", ticketId)); + int seqBefore = sequenceValue(); + + for (long draft : new long[] {leaDraft, padanaDraft}) { + assertThatThrownBy(() -> service.issue(draft, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains("สต็อก"); + }); + assertThat(service.get(draft, owner).status()).isEqualTo(ImportRequestStatus.DRAFT); + } + assertThat(sequenceValue()).isEqualTo(seqBefore); + } + @Test void issue_withAnOverride_doesNotBurnASequenceValue() { List drafts = DraftSeed.seed(jdbc, ticketId, owner); diff --git a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java index 76d4f4b66..2dc6f0aae 100644 --- a/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/orderconfirmation/OrderConfirmationIntegrationTest.java @@ -407,8 +407,9 @@ void createDraft_picksTheNewerPricingRequestsAcceptedQuotation_notTheOneWithTheH assertThat(pr1Accepted.quotationRevisionNo()).isEqualTo(2); // the HIGHER revision_no assertThat(pricingRequestService.get(pr1Id, salesActor).summary().status()) .isEqualTo(PricingRequestStatus.QUOTATION_ACCEPTED); - // confirmOrder gates on QUOTATION_ACCEPTED, so it must run AFTER the outcome above. - orderConfirmation.confirmOrder(pr1Id, new OrderConfirmationRequests.ConfirmOrderRequest(null), salesActor); + // confirmOrder gates on QUOTATION_ACCEPTED, so it runs AFTER the outcome above - but only after PR#2 + // below is driven too: once PR#1's order is confirmed (walk 4, W4-04) the deal can start no new + // pricing round, so a second request can no longer be submitted. // PR#2 (created SECOND, raw factory price 250.00 — deliberately different so its line // item is distinguishable from PR#1's): its own first-ever quotation, revision 1 — a @@ -440,6 +441,7 @@ void createDraft_picksTheNewerPricingRequestsAcceptedQuotation_notTheOneWithTheH Map.of("id", pr2Id)); assertThat(pricingRequestService.get(pr2Id, salesActor).summary().status()) .isEqualTo(PricingRequestStatus.QUOTATION_ACCEPTED); + orderConfirmation.confirmOrder(pr1Id, new OrderConfirmationRequests.ConfirmOrderRequest(null), salesActor); orderConfirmation.confirmOrder(pr2Id, new OrderConfirmationRequests.ConfirmOrderRequest(null), salesActor); CustomerQuotationDto pr2Accepted = quotationRepository.findByPricingRequest(pr2Id).stream() .filter(q -> QuotationStatus.ACCEPTED.equals(q.docStatus())).findFirst().orElseThrow(); diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java index dfe92d55f..6a2832fcf 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/AbstractStockLineIntegrationTest.java @@ -93,6 +93,7 @@ public abstract class AbstractStockLineIntegrationTest extends AbstractPostgresI protected PricingDecisionService decisionService; protected PricingCostingRepository costingRepository; protected NotificationRepository notifications; + protected TicketService ticketService; protected long salesRepId; protected long otherSalesRepId; @@ -137,7 +138,7 @@ protected void wireStockLineFixture() { costingRepository = new PricingCostingRepository(jdbc); decisionService = new PricingDecisionService(decisionRepository, pricingRequests, costingRepository, tickets, fxRates, notifications, landedCost, formulaEngine); - TicketService ticketService = new TicketService(tickets, notifications, objectMapper, customers, + ticketService = new TicketService(tickets, notifications, objectMapper, customers, new QuotationRenderer(), pricingRequestService, new EmployeeAuthRepository(jdbc), new th.co.glr.hr.customer.ContactRepository(jdbc), new th.co.glr.hr.customer.ProjectRepository(jdbc), new th.co.glr.hr.dealquotation.DealQuotationRepository(jdbc, new th.co.glr.hr.catalog.CatalogRepository(jdbc))); diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java new file mode 100644 index 000000000..040e3e9d7 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineFulfilmentIntegrationTest.java @@ -0,0 +1,432 @@ +package th.co.glr.hr.pricingstock; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import th.co.glr.hr.common.ApiException; +import th.co.glr.hr.commission.CommissionRepository; +import th.co.glr.hr.commission.PendingCommissionItemDto; +import th.co.glr.hr.customer.CustomerRepository; +import th.co.glr.hr.customerquotation.CustomerQuotationRepository; +import th.co.glr.hr.dealquotation.DealQuotationRepository; +import th.co.glr.hr.catalog.CatalogRepository; +import th.co.glr.hr.deposit.DepositNoticePdfRenderer; +import th.co.glr.hr.deposit.DepositNoticeRepository; +import th.co.glr.hr.deposit.DepositNoticeService; +import th.co.glr.hr.factory.FactoryConfigRepository; +import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestDto; +import th.co.glr.hr.importrequest.ImportRequestQueryRepository; +import th.co.glr.hr.importrequest.ImportRequestRenderer; +import th.co.glr.hr.importrequest.ImportRequestRepository; +import th.co.glr.hr.importrequest.ImportRequestService; +import th.co.glr.hr.orderconfirmation.OrderConfirmationRequests.ConfirmOrderRequest; +import th.co.glr.hr.orderconfirmation.OrderConfirmationService; +import th.co.glr.hr.pricingrequest.PricingRequestRecipient; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.CustomerChangeRevisionRequest; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest; +import th.co.glr.hr.pricingrequest.PricingRequestStatus; +import th.co.glr.hr.ticket.DealStage; +import th.co.glr.hr.ticket.FulfilmentStatus; +import th.co.glr.hr.ticket.RecordDeliveryRequest; +import th.co.glr.hr.ticket.RecordPaymentRequest; +import th.co.glr.hr.ticket.TicketSummaryDto; + +/** + * Walk 4 (owner rulings, Ploy 2026-10-29): there is NO reserve-from-stock feature. A stock line + * (pricing_request_item.stock_source = IN_THAILAND) ends when the CEO confirms the price and then + * waits for delivery; it is never ordered from a factory. Real services, real repositories, real + * Postgres, the real {@code OrderConfirmationService#confirmOrder} - no {@code reserveStock} call + * anywhere in this class. + * + *

    The ONE writer under test: {@code confirmOrder} must write {@code ticket_item.qty_from_stock} + * from the confirmed pricing request's {@code stock_source}. Every reader below (IR candidates, + * the "all covered from stock" 409, STOCK delivery, the commission x2/x3 share) already reads that + * column and is expected to work unchanged once it is written. + * + *

    Fixture note: the abstract fixture's seed ticket line is deleted so "every line of the deal is + * stock" is decided by the lines this class creates (a stray uncovered seed line would make an + * all-stock deal look mixed). A stock line's ticket_item carries FACTORY_B on purpose: if the writer + * is missing, that line shows up as an IR candidate for factory B and the IR assertions go red. + */ +class StockLineFulfilmentIntegrationTest extends AbstractStockLineIntegrationTest { + + private OrderConfirmationService orderConfirmation; + private ImportRequestService importRequests; + private CommissionRepository commissionRepository; + private int quotationSeq; + + @BeforeEach + void wireOrderConfirmationAndImportRequests() { + CustomerRepository customers = new CustomerRepository(jdbc); + CustomerQuotationRepository legacyQuotations = new CustomerQuotationRepository(jdbc); + DepositNoticeService depositNotices = new DepositNoticeService(new DepositNoticeRepository(jdbc), tickets, + notifications, new DepositNoticePdfRenderer(), customers, legacyQuotations); + orderConfirmation = new OrderConfirmationService( + pricingRequests, tickets, ticketService, legacyQuotations, depositNotices, notifications); + // Same wiring the app has (setter-injected): without it the accepted quotation's deposit + // percent is never read and the 0% path could not be exercised. + orderConfirmation.wireDealQuotationRepository(new DealQuotationRepository(jdbc, new CatalogRepository(jdbc))); + importRequests = new ImportRequestService(new ImportRequestQueryRepository(jdbc), new ImportRequestRenderer(), + new ImportRequestRepository(jdbc), new FactoryConfigRepository(jdbc), tickets, ticketService); + commissionRepository = new CommissionRepository(jdbc); + jdbc.update("DELETE FROM sales.ticket_item WHERE ticket_id = :t", Map.of("t", ticketId)); + } + + // ── B1: the one writer ─────────────────────────────────────────────────────────────── + + @Test + void b1_confirmOrder_writesQtyFromStock_forTheStockLineOnly_andTheCommissionReaderSeesIt() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(qtyFromStock("Stock A")).isEqualByComparingTo("10"); + assertThat(qtyFromStock("Imp A")).isEqualByComparingTo("0"); + List seenByCommission = commissionRepository.findPendingItems(ticketId); + assertThat(seenByCommission).filteredOn(i -> i.description().contains("Stock A")) + .singleElement().satisfies(i -> assertThat(i.qtyFromStock()).isEqualByComparingTo("10")); + assertThat(seenByCommission).filteredOn(i -> i.description().contains("Imp A")) + .singleElement().satisfies(i -> assertThat(i.qtyFromStock()).isEqualByComparingTo("0")); + } + + @Test + void b1_aStockLineThatArrivesAsANewTicketLine_isInsertedFullyStockCovered() { + // "Stock New" has no source ticket item (a line a revision added): reconcile INSERTS its + // ticket_item, and that insert must carry the stock cover too. + long pcr = acceptedPcr(Set.of("Stock New"), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock New", IN_THAILAND, 6)); + acceptQuotation(pcr, 30); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(qtyFromStock("Stock New")).isEqualByComparingTo("6"); + assertThat(qtyFromStock("Imp A")).isEqualByComparingTo("0"); + } + + // ── B2 / B3 / B5: the import side and the auto-advance ─────────────────────────────── + + @Test + void b2_mixedDeal_afterTheDeposit_createDraftsOrdersOnlyTheImportLinesFactory() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + recordDeposit(); + + List irs = importRequests.createDrafts(ticketId, null, salesActor); + + assertThat(irs).extracting(ImportRequestDto::factoryName) + .as("the stock line's factory (B) must never get an import request") + .containsExactly(FACTORY_A); + assertThat(irs.get(0).items()).hasSize(1); + } + + @Test + void b3_stockOnlyDeal_depositRequired_staysAtOrderReceivedUntilTheDeposit_thenGoesToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + + recordDeposit(); + + assertThat(summary().salesStage()).isEqualTo(DealStage.DELIVERY_SCHEDULING); + assertThat(summary().fulfillmentStatus()).isEqualTo(FulfilmentStatus.FROM_STOCK); + } + + @Test + void b3_stockOnlyDeal_afterTheDeposit_createDraftsIsRefused409_coveredFromStock() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + recordDeposit(); + + assertThatThrownBy(() -> importRequests.createDrafts(ticketId, null, salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains("ครอบคลุมจากสต็อกทั้งหมด"); + }); + assertThat(importRequests.list(ticketId, salesActor)).isEmpty(); + } + + @Test + void b4_stockOnlyDeal_zeroPercentDeposit_goesToDeliverySchedulingAtOrderConfirmation() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 0); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + TicketSummaryDto s = summary(); + assertThat(s.depositPolicy()).as("fixture: the 0% quotation put the deal on the no-deposit path") + .isEqualTo("NOT_REQUIRED"); + assertThat(s.salesStage()).isEqualTo(DealStage.DELIVERY_SCHEDULING); + assertThat(s.fulfillmentStatus()).isEqualTo(FulfilmentStatus.FROM_STOCK); + } + + @Test + void b4_mixedDeal_zeroPercentDeposit_doesNotJumpToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 0); + + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + @Test + void b5_mixedDeal_afterTheDeposit_isDepositReceivedOnly_noJump() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + recordDeposit(); + + assertThat(summary().salesStage()).isEqualTo(DealStage.DEPOSIT_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // ── B6: STOCK delivery without any reserve ─────────────────────────────────────────── + + @Test + void b6_stockOnlyDeal_atStep13_aStockDeliveryOfTheFullQty_succeedsWithoutReserveStock() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + recordDeposit(); + long itemId = ticketItemId("Stock A"); + + var after = ticketService.recordPartialDelivery(ticketId, new RecordDeliveryRequest("STOCK", null, + List.of(new RecordDeliveryRequest.Line(itemId, new BigDecimal("10"))), null), salesActor); + + assertThat(after.items()).singleElement() + .satisfies(i -> assertThat(i.qtyDelivered()).isEqualByComparingTo("10")); + assertThat(after.summary().fulfillmentStatus()).isEqualTo(FulfilmentStatus.FULLY_DELIVERED); + } + + // Review fix 1: the writer sets qty_from_stock at confirmation, so STOCK delivery must wait for the deposit + // (paid, or a no-deposit policy) - otherwise a REQUIRED stock-only deal could deliver and close with nothing paid. + @Test + void b6_requiredStockOnlyDeal_beforeTheDeposit_stockDeliveryIsNotAdvertised_andRefused409() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + long itemId = ticketItemId("Stock A"); + RecordDeliveryRequest full = new RecordDeliveryRequest("STOCK", null, + List.of(new RecordDeliveryRequest.Line(itemId, new BigDecimal("10"))), null); + + assertThat(actionCodes()).doesNotContain("RECORD_PARTIAL_DELIVERY", "COMPLETE_DELIVERY"); + assertThatThrownBy(() -> ticketService.recordPartialDelivery(ticketId, full, salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThatThrownBy(() -> ticketService.completeDelivery(ticketId, + new th.co.glr.hr.ticket.CompleteDeliveryRequest(null, null), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(summary().fulfillmentStatus()).isNull(); + + recordDeposit(); + + assertThat(actionCodes()).contains("RECORD_PARTIAL_DELIVERY"); + } + + @Test + void b6_mixedDeal_beforeTheDeposit_aPartialStockDeliveryIsRefused409_andNothingIsRecorded() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + assertThatThrownBy(() -> ticketService.recordPartialDelivery(ticketId, new RecordDeliveryRequest("STOCK", null, + List.of(new RecordDeliveryRequest.Line(ticketItemId("Stock A"), new BigDecimal("4"))), null), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // Review fix 2: waiving the deposit on an all-stock deal at CUSTOMER_CONFIRMED leaves nothing to wait for. + @Test + void b4_stockOnlyDeal_atCustomerConfirmed_waivingTheDeposit_goesToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + + ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าเครดิต", salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.DELIVERY_SCHEDULING); + assertThat(summary().fulfillmentStatus()).isEqualTo(FulfilmentStatus.FROM_STOCK); + } + + @Test + void b4_mixedDeal_waivingTheDeposit_doesNotJumpToDeliveryScheduling() { + long pcr = acceptedPcr(Set.of(), + importLine("Imp A", catalogProductFactoryA, FACTORY_A, 20), + stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(pcr, 30); + orderConfirmation.confirmOrder(pcr, confirmRequest(), salesActor); + + ticketService.waiveDeposit(ticketId, "WAIVED", "ลูกค้าเครดิต", salesActor); + + assertThat(summary().salesStage()).isEqualTo(DealStage.ORDER_RECEIVED); + assertThat(summary().fulfillmentStatus()).isNull(); + } + + // Review fix 3: no new request on a confirmed deal either (create, like submit and revise). + @Test + void b12_onceTheOrderIsConfirmed_creatingANewPricingRequestIsRefused409() { + long accepted = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(accepted, 30); + // Control: before the order is confirmed the same create is legal. + pricingRequestService.createDraft(ticketId, requestOf(importLine("New 1", catalogProductFactoryA, FACTORY_A, 5)), salesActor); + + orderConfirmation.confirmOrder(accepted, confirmRequest(), salesActor); + + assertThatThrownBy(() -> pricingRequestService.createDraft(ticketId, + requestOf(importLine("New 2", catalogProductFactoryA, FACTORY_A, 5)), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + } + + // ── B12 (W4-04): no new pricing round once the order is confirmed ──────────────────── + + @Test + void b12_onceTheOrderIsConfirmed_aCustomerChangeRevisionOfAnotherRequestIsRefused409() { + long accepted = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(accepted, 30); + long siblingBefore = persistDraft(importLine("Sib 1", catalogProductFactoryA, FACTORY_A, 5)); + forceStatus(siblingBefore, PricingRequestStatus.QUOTATION_ISSUED); + long siblingAfter = persistDraft(importLine("Sib 2", catalogProductFactoryA, FACTORY_A, 5)); + forceStatus(siblingAfter, PricingRequestStatus.QUOTATION_ISSUED); + + // Control: before the order is confirmed the same call is legal (so the 409 below is the + // confirmation, not a malformed request). + assertThat(pricingRequestService.createCustomerChangeRevision(siblingBefore, revisionOf("Sib 1 rev"), + salesActor).summary().status()).isEqualTo(PricingRequestStatus.DRAFT); + + orderConfirmation.confirmOrder(accepted, confirmRequest(), salesActor); + + assertThatThrownBy(() -> pricingRequestService.createCustomerChangeRevision(siblingAfter, + revisionOf("Sib 2 rev"), salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(status(siblingAfter)).as("refused: the request is untouched") + .isEqualTo(PricingRequestStatus.QUOTATION_ISSUED); + } + + @Test + void b12_onceTheOrderIsConfirmed_aDraftRequestCannotBeSubmitted_409() { + long accepted = acceptedPcr(Set.of(), stockLine("Stock A", IN_THAILAND, 10)); + acceptQuotation(accepted, 30); + long draftBefore = persistDraft(importLine("Late 1", catalogProductFactoryA, FACTORY_A, 5)); + long draftAfter = persistDraft(importLine("Late 2", catalogProductFactoryA, FACTORY_A, 5)); + + // Control: before the order is confirmed a DRAFT submits fine. + pricingRequestService.submit(draftBefore, salesActor); + assertThat(status(draftBefore)).isEqualTo(PricingRequestStatus.SUBMITTED); + + orderConfirmation.confirmOrder(accepted, confirmRequest(), salesActor); + + assertThatThrownBy(() -> pricingRequestService.submit(draftAfter, salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(status(draftAfter)).isEqualTo(PricingRequestStatus.DRAFT); + } + + // ── fixtures ───────────────────────────────────────────────────────────────────────── + + private ConfirmOrderRequest confirmRequest() { + return new ConfirmOrderRequest(UUID.randomUUID().toString()); + } + + private CustomerChangeRevisionRequest revisionOf(String model) { + return new CustomerChangeRevisionRequest("ลูกค้าเปลี่ยน", UUID.randomUUID().toString(), + PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14), + null, "THB", null, List.of(importLine(model, catalogProductFactoryA, FACTORY_A, 5))); + } + + private List actionCodes() { + return ticketService.actions(ticketId, salesActor).availableActions().stream() + .map(a -> a.action()).toList(); + } + + private TicketSummaryDto summary() { + return tickets.findById(ticketId).orElseThrow().summary(); + } + + /** + * A pricing request at QUOTATION_ACCEPTED whose lines are linked to freshly created ticket + * lines (qty stub 1, like a deal's first save) - except the models in {@code unlinked}, which + * have no source ticket line (so reconcile must INSERT them). A stock line's ticket line names + * FACTORY_B; an import line's names its own factory. + */ + private long acceptedPcr(Set unlinked, PricingRequestItemRequest... items) { + long id = persistDraft(items); + int sort = 0; + for (PricingRequestItemRequest item : items) { + if (unlinked.contains(item.model())) { + continue; + } + long ticketItem = jdbc.queryForObject(""" + INSERT INTO sales.ticket_item (ticket_id, brand, model, size, qty, unit, sort_order, factory) + VALUES (:t, 'SCG', :model, '60x60', 1, 'pcs', :sort, :factory) + RETURNING item_id + """, new MapSqlParameterSource().addValue("t", ticketId).addValue("model", item.model()) + .addValue("sort", sort++) + .addValue("factory", item.factory() != null ? item.factory() : FACTORY_B), Long.class); + jdbc.update(""" + UPDATE sales.pricing_request_item SET source_ticket_item_id = :ti + WHERE pricing_request_id = :id AND model = :model + """, Map.of("ti", ticketItem, "id", id, "model", item.model())); + } + forceStatus(id, PricingRequestStatus.QUOTATION_ACCEPTED); + return id; + } + + /** The customer-accepted new-engine quotation: only its number, total and deposit percent matter here. */ + private void acceptQuotation(long pricingRequestId, int depositPercent) { + jdbc.update(""" + INSERT INTO sales.quotation (ticket_id, number, issued_by, origin, doc_status, pricing_request_id, + deposit_percent, total_amount) + VALUES (:t, :number, :by, 'PRICING_REQUEST', 'ACCEPTED', :pr, :pct, 1000.00) + """, new MapSqlParameterSource().addValue("t", ticketId) + .addValue("number", "QT-WALK4-" + (++quotationSeq)).addValue("by", salesRepId) + .addValue("pr", pricingRequestId).addValue("pct", depositPercent)); + } + + /** Account confirms the deposit money (the real {@code recordPayment}); payable is 1,070 with VAT. */ + private void recordDeposit() { + ticketService.recordPayment(ticketId, new RecordPaymentRequest( + "DEPOSIT", new BigDecimal("321.00"), null, "มัดจำ", null, null, false), accountActor); + } + + private BigDecimal qtyFromStock(String model) { + return jdbc.queryForObject(""" + SELECT qty_from_stock FROM sales.ticket_item WHERE ticket_id = :t AND model = :m + """, Map.of("t", ticketId, "m", model), BigDecimal.class); + } + + private long ticketItemId(String model) { + return jdbc.queryForObject("SELECT item_id FROM sales.ticket_item WHERE ticket_id = :t AND model = :m", + Map.of("t", ticketId, "m", model), Long.class); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java deleted file mode 100644 index eebdf7402..000000000 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineInTransitIntegrationTest.java +++ /dev/null @@ -1,330 +0,0 @@ -package th.co.glr.hr.pricingstock; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.time.LocalDate; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.http.HttpStatus; -import th.co.glr.hr.auth.UserPrincipal; -import th.co.glr.hr.common.ApiException; -import th.co.glr.hr.factoryquote.FactoryQuoteDtos.FactoryQuoteDto; -import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestItemDto; -import th.co.glr.hr.pricingrequest.PricingRequestRequests.SetItemFactoryRequest; -import th.co.glr.hr.pricingrequest.PricingRequestStatus; - -/** - * Slice 1, stock lines: the two NEW write paths ({@code setInTransitArrival}, - * {@code convertInTransitToImport}), the {@code setItemFactory} refusal for stock lines, and their - * authorization - written the wrong way round (who must NOT reach it, and the row is unchanged - * afterwards), per CLAUDE.md "Permission changes must ship evidence". - * - *

    Authz evidence here is REAL: real service, real repository, real Postgres, no mock. It is not - * a mutation check - that is the implementer's/reviewer's step (drop the role gate, expect exactly - * the matching case below to go red). - * - *

    Tests marked [PIN] pass before the feature exists on purpose. - * - *

    Contract choices this file makes that the design left open (each is stated so a reviewer can - * overrule it): a wrong-KIND line or a wrong-STATUS request is a 409; a null date is a 400; a role - * outside the allowed set is a 403; convert is ALSO allowed from READY_FOR_CEO_REVIEW (with a - * pull-back), because IA 1.2 both lists the status guard as {IMPORT_REVIEWING, - * AWAITING_FACTORY_RESPONSE} and specifies the READY_FOR_CEO_REVIEW pull-back - the pull-back would - * be dead code otherwise, so the guard is read as including READY_FOR_CEO_REVIEW. - */ -class StockLineInTransitIntegrationTest extends AbstractStockLineIntegrationTest { - - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 8 - setItemFactory on a stock line - // ───────────────────────────────────────────────────────────────────────────────────── - - @ParameterizedTest - @ValueSource(strings = {IN_THAILAND, IN_TRANSIT}) - void setItemFactory_onAStockLine_isRefusedWith409_andTheLineKeepsNoFactory(String source) { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Stock", source, 5)); - long stockItemId = itemId(id, "Stock"); - - assertThatThrownBy(() -> pricingRequestService.setItemFactory(id, stockItemId, - new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - PricingRequestItemDto after = itemByModel(id, "Stock"); - assertThat(after.factory()).isNull(); - assertThat(after.resolvedFactoryId()).isNull(); - assertThat(after.stockSource()).isEqualTo(source); - } - - @Test - void setItemFactory_onAnImportLineWithoutAFactory_stillWorks_PIN() { - long id = persistSubmitAndPickUp( - importLineWithoutFactory("Free text", 5), - stockLine("Stock TH", IN_THAILAND, 3)); - - pricingRequestService.setItemFactory(id, itemId(id, "Free text"), - new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor); - - assertThat(itemByModel(id, "Free text").factory()).isEqualTo(FACTORY_B); - } - - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 6 (tail) + case 10 - setInTransitArrival - // ───────────────────────────────────────────────────────────────────────────────────── - - @Test - void setInTransitArrival_byImport_persistsTheDate_andWhoSetIt() { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - assertThat(etaSetBy(id, "Transit")).isEqualTo(importUserId); - assertThat(etaSetAtIsSet(id, "Transit")).isTrue(); - } - - @Test - void setInTransitArrival_byTheCeo_isAllowed() { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), ceoActor); - - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - assertThat(etaSetBy(id, "Transit")).isEqualTo(ceoUserId); - } - - @ParameterizedTest - @ValueSource(strings = {"salesOwner", "otherSales", "salesManager", "account"}) - void setInTransitArrival_byAnyoneButImportAndCeo_isRefusedWith403_andNothingChanges(String who) { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit"), eta(), actorFor(who))) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); - - assertThat(snapshot(id, "Transit")).as("the refused write must leave no trace").isEqualTo(before); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isNull(); - } - - @ParameterizedTest - @ValueSource(strings = {IN_THAILAND, "IMPORT"}) - void setInTransitArrival_onALineThatIsNotInTransit_isRefusedWith409_andNoEtaIsWritten(String kind) { - long id = persistSubmitAndPickUp( - IN_THAILAND.equals(kind) ? stockLine("Wrong kind", IN_THAILAND, 4) - : importLine("Wrong kind", catalogProductFactoryA, FACTORY_A, 4), - stockLine("Transit", IN_TRANSIT, 4)); - Snapshot before = snapshot(id, "Wrong kind"); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Wrong kind"), eta(), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Wrong kind")).isEqualTo(before); - assertThat(itemByModel(id, "Wrong kind").expectedArrivalDate()).isNull(); - } - - @Test - void setInTransitArrival_withANullDate_isRefusedWith400() { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit"), null, importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isNull(); - } - - @ParameterizedTest - @ValueSource(strings = {PricingRequestStatus.SUBMITTED, PricingRequestStatus.CEO_REVIEWING, - PricingRequestStatus.APPROVED_FOR_QUOTATION, PricingRequestStatus.CANCELLED}) - void setInTransitArrival_outsideImportReviewingAwaitingFactoryOrReady_isRefusedWith409(String forced) { - long id = persistAndSubmit(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - if (!PricingRequestStatus.SUBMITTED.equals(forced)) { - forceStatus(id, forced); - } - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit"), eta(), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Transit")).isEqualTo(before); - assertThat(status(id)).isEqualTo(forced); - } - - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 9 - convertInTransitToImport - // ───────────────────────────────────────────────────────────────────────────────────── - - @Test - void convert_byImport_clearsSourceAndEta_notifiesTheOwningRep_andTheLineBecomesAnImportLine() { - long id = persistSubmitAndPickUp( - stockLine("Transit", IN_TRANSIT, 4), - stockLine("Stock TH", IN_THAILAND, 3)); - seedEta(id, "Transit", eta()); - long repBefore = notificationCount(salesRepId, id); - long otherRepBefore = notificationCount(otherSalesRepId, id); - long transitItemId = itemId(id, "Transit"); - - pricingRequestService.convertInTransitToImport(id, transitItemId, importActor); - - PricingRequestItemDto converted = itemByModel(id, "Transit"); - assertThat(converted.stockSource()).as("R13: now สั่งนำเข้า").isNull(); - assertThat(converted.expectedArrivalDate()).isNull(); - assertThat(etaSetBy(id, "Transit")).isNull(); - assertThat(etaSetAtIsSet(id, "Transit")).isFalse(); - assertThat(itemByModel(id, "Stock TH").stockSource()).as("other lines untouched").isEqualTo(IN_THAILAND); - assertThat(notificationCount(salesRepId, id)).as("the OWNING rep is told").isEqualTo(repBefore + 1); - assertThat(notificationCount(otherSalesRepId, id)).as("nobody else is").isEqualTo(otherRepBefore); - assertThat(status(id)).isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); - - // ...and it is now a NORMAL import line: import can give it a factory, and the next - // factory-quote generation includes it (and still never the in-Thailand line). - pricingRequestService.setItemFactory(id, transitItemId, new SetItemFactoryRequest(factoryId(FACTORY_B)), - importActor); - List drafts = factoryQuoteService.generateDrafts(id, importActor); - assertThat(drafts).flatExtracting(FactoryQuoteDto::items) - .extracting(item -> item.pricingRequestItemId()) - .containsExactly(transitItemId); - } - - @Test - void convert_whileReadyForCeoReview_pullsTheRequestBackToAwaitingFactoryResponse() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - quoteEveryImportLineReady(id); - seedEta(id, "Transit", eta()); - forceStatus(id, PricingRequestStatus.READY_FOR_CEO_REVIEW); // precondition, see class Javadoc - - pricingRequestService.convertInTransitToImport(id, itemId(id, "Transit"), importActor); - - assertThat(status(id)) - .as("the converted line has no factory quote yet: the request is no longer ready") - .isEqualTo(PricingRequestStatus.AWAITING_FACTORY_RESPONSE); - assertThat(itemByModel(id, "Transit").stockSource()).isNull(); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isNull(); - } - - @Test - void convert_thenTheLineFollowsTheNormalImportFlow_andTheRequestIsReadyAgainOnceItIsQuoted() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - quoteEveryImportLineReady(id); - seedEta(id, "Transit", eta()); - forceStatus(id, PricingRequestStatus.READY_FOR_CEO_REVIEW); - pricingRequestService.convertInTransitToImport(id, itemId(id, "Transit"), importActor); - pricingRequestService.setItemFactory(id, itemId(id, "Transit"), - new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor); - - quoteEveryImportLineReady(id); - - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); - } - - @ParameterizedTest - @ValueSource(strings = {IN_THAILAND, "IMPORT"}) - void convert_onALineThatIsNotInTransit_isRefusedWith409_andNothingChanges(String kind) { - long id = persistSubmitAndPickUp( - IN_THAILAND.equals(kind) ? stockLine("Wrong kind", IN_THAILAND, 4) - : importLine("Wrong kind", catalogProductFactoryA, FACTORY_A, 4), - stockLine("Transit", IN_TRANSIT, 4)); - Snapshot before = snapshot(id, "Wrong kind"); - - assertThatThrownBy(() -> pricingRequestService.convertInTransitToImport( - id, itemId(id, "Wrong kind"), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Wrong kind")).isEqualTo(before); - } - - @ParameterizedTest - @ValueSource(strings = {PricingRequestStatus.SUBMITTED, PricingRequestStatus.CEO_REVIEWING, - PricingRequestStatus.APPROVED_FOR_QUOTATION, PricingRequestStatus.CANCELLED}) - void convert_outsideImportReviewingAwaitingFactoryOrReady_isRefusedWith409_andTheLineIsUnchanged(String forced) { - long id = persistAndSubmit(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - seedEta(id, "Transit", eta()); - if (!PricingRequestStatus.SUBMITTED.equals(forced)) { - forceStatus(id, forced); - } - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.convertInTransitToImport( - id, itemId(id, "Transit"), importActor)) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - - assertThat(snapshot(id, "Transit")).isEqualTo(before); - assertThat(itemByModel(id, "Transit").stockSource()).isEqualTo(IN_TRANSIT); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - } - - // ── case 10, convert half: IMPORT ONLY - the CEO is refused too ───────────────────────── - - @ParameterizedTest - @ValueSource(strings = {"salesOwner", "otherSales", "salesManager", "account", "ceo"}) - void convert_byAnyoneButImport_isRefusedWith403_theCeoIncluded_andTheLineIsUnchanged(String who) { - long id = persistSubmitAndPickUp(stockLine("Transit", IN_TRANSIT, 4), importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10)); - seedEta(id, "Transit", eta()); - Snapshot before = snapshot(id, "Transit"); - - assertThatThrownBy(() -> pricingRequestService.convertInTransitToImport( - id, itemId(id, "Transit"), actorFor(who))) - .isInstanceOfSatisfying(ApiException.class, - e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); - - assertThat(snapshot(id, "Transit")).as("the refused conversion must leave no trace").isEqualTo(before); - assertThat(itemByModel(id, "Transit").stockSource()).isEqualTo(IN_TRANSIT); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta()); - } - - // ── helpers ────────────────────────────────────────────────────────────────────────── - - private UserPrincipal actorFor(String who) { - return switch (who) { - case "salesOwner" -> salesActor; - case "otherSales" -> otherSalesActor; - case "salesManager" -> salesManagerActor; - case "account" -> accountActor; - case "ceo" -> ceoActor; - case "import" -> importActor; - default -> throw new IllegalArgumentException(who); - }; - } - - /** Everything a refused write could have touched, so "refused" means "no trace at all". */ - private record Snapshot(String stockSource, LocalDate eta, Long etaSetBy, String status, long events, - long repNotifications, long ceoNotifications, long importNotifications) {} - - private Snapshot snapshot(long id, String model) { - PricingRequestItemDto item = itemByModel(id, model); - return new Snapshot(item.stockSource(), item.expectedArrivalDate(), etaSetBy(id, model), status(id), - eventCount(id), notificationCount(salesRepId, id), notificationCount(ceoUserId, id), - notificationCount(importUserId, id)); - } - - private Long etaSetBy(long id, String model) { - return jdbc.queryForObject(""" - SELECT expected_arrival_set_by_id FROM sales.pricing_request_item - WHERE pricing_request_id = :id AND model = :model - """, Map.of("id", id, "model", model), Long.class); - } - - private boolean etaSetAtIsSet(long id, String model) { - return Boolean.TRUE.equals(jdbc.queryForObject(""" - SELECT expected_arrival_set_at IS NOT NULL FROM sales.pricing_request_item - WHERE pricing_request_id = :id AND model = :model - """, Map.of("id", id, "model", model), Boolean.class)); - } -} diff --git a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java index 13f0e9064..e5bc863a8 100644 --- a/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/pricingstock/StockLineRoutingIntegrationTest.java @@ -15,6 +15,8 @@ import th.co.glr.hr.pricingrequest.PricingRequestDtos.PricingRequestItemDto; import th.co.glr.hr.pricingrequest.PricingRequestRecipient; import th.co.glr.hr.pricingrequest.PricingRequestRequests.CustomerChangeRevisionRequest; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest; +import th.co.glr.hr.pricingrequest.PricingRequestRequests.SetItemFactoryRequest; import th.co.glr.hr.pricingrequest.PricingRequestRequests.UpdatePricingRequestRequest; import th.co.glr.hr.pricingrequest.PricingRequestStatus; @@ -100,10 +102,9 @@ void importPlusInThailand_submitGoesToImport_importNotified_notStraightToCeo_PIN void factoryQuoteDrafts_containOnlyImportLines_evenWhenStockLinesResolveToAFactory() { long id = persistSubmitAndPickUp( importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - // both stock kinds point at a real catalog product of factory B: ONLY the source may - // keep them out of factory B's quote, not the absence of a factory. - stockLineWithCatalog("Stock TH", IN_THAILAND, catalogProductFactoryB, FACTORY_B, 5), - stockLineWithCatalog("Stock TR", IN_TRANSIT, catalogProductFactoryB, FACTORY_B, 4)); + // the stock line points at a real catalog product of factory B: ONLY the source may + // keep it out of factory B's quote, not the absence of a factory. + stockLineWithCatalog("Stock TH", IN_THAILAND, catalogProductFactoryB, FACTORY_B, 5)); List drafts = factoryQuoteService.generateDrafts(id, importActor); @@ -126,130 +127,117 @@ void importQuotesReady_withAnInThailandLinePresent_autoAdvancesToCeoReview() { } // ───────────────────────────────────────────────────────────────────────────────────── - // Case 3 - สต็อกกำลังเดินทาง with NO import lines: the new IMPORT_REVIEWING -> READY edge + // Case 5 - submit validation // ───────────────────────────────────────────────────────────────────────────────────── @Test - void inTransitLines_evenWithInThailand_submitGoesToImport_notStraightToCeo_PIN() { - long id = persistDraft( - stockLine("Transit 1", IN_TRANSIT, 10), - stockLine("Stock TH", IN_THAILAND, 3)); - long importBefore = notificationCount(importUserId, id); - - pricingRequestService.submit(id, salesActor); + void createDraft_aStockLine_isAcceptedWithoutFactoryOriginCountryOrLeadTime() { + PricingRequestDetailDto created = pricingRequestService.createDraft(ticketId, + requestOf(stockLine("Stock TH", IN_THAILAND, 10)), salesActor); - assertThat(status(id)).isEqualTo(PricingRequestStatus.SUBMITTED); - assertThat(notificationCount(importUserId, id)).isGreaterThan(importBefore); + assertThat(created.items()).singleElement().satisfies(item -> { + assertThat(item.factory()).isNull(); + assertThat(item.originCountry()).isNull(); + assertThat(item.leadTimeMinDays()).isNull(); + assertThat(item.leadTimeMaxDays()).isNull(); + }); } + /** + * Walk 4 (W4-01): the import-only inputs are cleared for a stock line even if a client sends + * them (the form hides them, but an old draft or a hand-built request can still carry them): + * they would otherwise print on the quotation as an import lead time. Read from the stored + * columns, not the response, so a response that merely hides them cannot pass. + */ @Test - void inTransitOnly_oneEtaStillMissing_staysWithImport_butTheSavedEtaIsPersisted() { - long id = persistSubmitAndPickUp( - stockLine("Transit 1", IN_TRANSIT, 10), - stockLine("Transit 2", IN_TRANSIT, 4), - stockLine("Stock TH", IN_THAILAND, 3)); - assertThat(status(id)).isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit 1"), eta(), importActor); - - assertThat(itemByModel(id, "Transit 1").expectedArrivalDate()).isEqualTo(eta()); - assertThat(itemByModel(id, "Transit 2").expectedArrivalDate()).isNull(); - assertThat(status(id)) - .as("Transit 2 still has no ETA: the request must NOT reach the CEO yet") - .isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); + void b10_createDraft_anInThailandLineSentWithImportOnlyInputs_storesThemNull() { + PricingRequestItemRequest full = withOriginAndLead( + lineWithFullImportFields("Plumb", IN_THAILAND, 10), "อื่นๆ", 3, 7, "Zanzibar"); + + long id = pricingRequestService.createDraft(ticketId, requestOf(full), salesActor).summary().id(); + + Map row = jdbc.queryForMap(""" + SELECT stock_source, origin_country, origin_country_other, lead_time_min_days, lead_time_max_days + FROM sales.pricing_request_item WHERE pricing_request_id = :id + """, Map.of("id", id)); + assertThat(row.get("stock_source")).isEqualTo(IN_THAILAND); + assertThat(row.get("origin_country")).isNull(); + assertThat(row.get("origin_country_other")).isNull(); + assertThat(row.get("lead_time_min_days")).isNull(); + assertThat(row.get("lead_time_max_days")).isNull(); } @Test - void inTransitOnly_lastMissingEta_advancesImportReviewingToReadyForCeoReview_andNotifiesCeo() { - long id = persistSubmitAndPickUp( - stockLine("Transit 1", IN_TRANSIT, 10), - stockLine("Transit 2", IN_TRANSIT, 4), - stockLine("Stock TH", IN_THAILAND, 3)); - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit 1"), eta(), importActor); - long ceoBefore = notificationCount(ceoUserId, id); + void b10_updateDraft_switchingAnImportLineToStock_clearsItsStoredImportInputs() { + long id = pricingRequestService.createDraft(ticketId, + requestOf(importLine("Switch", null, null, 10)), salesActor).summary().id(); + assertThat(itemByModel(id, "Switch").leadTimeMinDays()).as("fixture: the import line carries a lead time") + .isNotNull(); - PricingRequestDetailDto detail = pricingRequestService.setInTransitArrival( - id, itemId(id, "Transit 2"), eta().plusDays(3), importActor); + pricingRequestService.updateDraft(id, + draftUpdate(lineWithFullImportFields("Switch", IN_THAILAND, 10)), salesActor); - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); - assertThat(detail.summary().status()).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); - assertThat(notificationCount(ceoUserId, id)).isGreaterThan(ceoBefore); - assertThat(detail.events()).extracting(e -> e.toStatus()) - .as("the IMPORT_REVIEWING -> READY_FOR_CEO_REVIEW edge is on the audit trail") - .contains(PricingRequestStatus.READY_FOR_CEO_REVIEW); + assertThat(jdbc.queryForObject(""" + SELECT lead_time_min_days IS NULL AND lead_time_max_days IS NULL AND origin_country IS NULL + FROM sales.pricing_request_item WHERE pricing_request_id = :id + """, Map.of("id", id), Boolean.class)).isTrue(); } - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 4 - import + สต็อกกำลังเดินทาง, in both orders - // ───────────────────────────────────────────────────────────────────────────────────── - + /** Walk 4 (owner ruling): the สต็อกกำลังเดินทาง option is removed - a new line cannot be created as one. */ @Test - void importAndInTransit_quotesReadyButEtaMissing_isNotReady_thenSavingTheEtaAdvances() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - - quoteEveryImportLineReady(id); - - assertThat(status(id)) - .as("every quote is ready but the in-transit line has no ETA: NOT ready for the CEO") - .isEqualTo(PricingRequestStatus.AWAITING_FACTORY_RESPONSE); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + void b11_createDraft_anInTransitLine_isRefused400WithAThaiMessage() { + assertThatThrownBy(() -> pricingRequestService.createDraft(ticketId, + requestOf(stockLine("Transit", IN_TRANSIT, 4)), salesActor)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + // The generic unknown-source 400: IN_TRANSIT is just not a source any more (no dedicated branch). + assertThat(e.getMessage()).contains("รายการที่ 1").contains("ไม่รองรับแหล่งที่มาของสินค้า 'IN_TRANSIT'"); + }); } @Test - void importAndInTransit_etaSavedFirst_thenMarkReadyForCostingAdvances() { - long id = persistSubmitAndPickUp( - importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - - assertThat(status(id)) - .as("ETA saved but the import line is not quoted yet: still with import") - .isEqualTo(PricingRequestStatus.IMPORT_REVIEWING); - - quoteEveryImportLineReady(id); + void b11_updateDraft_switchingALineToInTransit_isRefused400() { + long id = pricingRequestService.createDraft(ticketId, + requestOf(importLine("Switch", null, null, 10)), salesActor).summary().id(); - assertThat(status(id)) - .as("the LAST of {quotes ready, ETAs set} to arrive must advance the request") - .isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + assertThatThrownBy(() -> pricingRequestService.updateDraft(id, + draftUpdate(stockLine("Switch", IN_TRANSIT, 10)), salesActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); + assertThat(storedStockSource(id)).as("the refused update changed nothing").isNull(); } + // setItemFactory on a stock line - moved here from the deleted StockLineInTransitIntegrationTest + // (the in-transit write paths went with the option; this refusal stayed). + @Test - void importAndInTransit_changingAnEtaWhileReadyForCeoReview_keepsItThere() { + void setItemFactory_onAStockLine_isRefusedWith409_andTheLineKeepsNoFactory() { long id = persistSubmitAndPickUp( importLine("Imp A", catalogProductFactoryA, FACTORY_A, 10), - stockLine("Transit", IN_TRANSIT, 4)); - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta(), importActor); - quoteEveryImportLineReady(id); - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + stockLine("Stock", IN_THAILAND, 5)); + long stockItemId = itemId(id, "Stock"); - pricingRequestService.setInTransitArrival(id, itemId(id, "Transit"), eta().plusDays(10), importActor); + assertThatThrownBy(() -> pricingRequestService.setItemFactory(id, stockItemId, + new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); - assertThat(itemByModel(id, "Transit").expectedArrivalDate()).isEqualTo(eta().plusDays(10)); - assertThat(status(id)).isEqualTo(PricingRequestStatus.READY_FOR_CEO_REVIEW); + PricingRequestItemDto after = itemByModel(id, "Stock"); + assertThat(after.factory()).isNull(); + assertThat(after.resolvedFactoryId()).isNull(); + assertThat(after.stockSource()).isEqualTo(IN_THAILAND); } - // ───────────────────────────────────────────────────────────────────────────────────── - // Case 5 - submit validation - // ───────────────────────────────────────────────────────────────────────────────────── - @Test - void createDraft_stockLinesOfBothKinds_acceptedWithoutFactoryOriginCountryOrLeadTime() { - PricingRequestDetailDto created = pricingRequestService.createDraft(ticketId, - requestOf(stockLine("Stock TH", IN_THAILAND, 10), stockLine("Stock TR", IN_TRANSIT, 4)), salesActor); + void setItemFactory_onAnImportLineWithoutAFactory_stillWorks_PIN() { + long id = persistSubmitAndPickUp( + importLineWithoutFactory("Free text", 5), + stockLine("Stock TH", IN_THAILAND, 3)); - assertThat(created.items()).hasSize(2); - for (PricingRequestItemDto item : created.items()) { - assertThat(item.factory()).isNull(); - assertThat(item.originCountry()).isNull(); - assertThat(item.leadTimeMinDays()).isNull(); - assertThat(item.leadTimeMaxDays()).isNull(); - } + pricingRequestService.setItemFactory(id, itemId(id, "Free text"), + new SetItemFactoryRequest(factoryId(FACTORY_B)), importActor); + + assertThat(itemByModel(id, "Free text").factory()).isEqualTo(FACTORY_B); } @Test @@ -326,16 +314,10 @@ void createDraft_aStockSourceOnAnOtherwiseCompleteLine_isStored_notSilentlyDropp @Test void stockSource_roundTripsThroughCreateUpdateAndRead_inDraft() { PricingRequestDetailDto created = pricingRequestService.createDraft(ticketId, - requestOf(stockLine("RT", IN_TRANSIT, 5)), salesActor); + requestOf(stockLine("RT", IN_THAILAND, 5)), salesActor); long id = created.summary().id(); - assertThat(created.items()).singleElement().satisfies(i -> { - assertThat(i.stockSource()).isEqualTo(IN_TRANSIT); - assertThat(i.expectedArrivalDate()).as("no ETA can exist in DRAFT").isNull(); - }); - assertThat(pricingRequestService.get(id, salesActor).items().get(0).stockSource()).isEqualTo(IN_TRANSIT); - assertThat(storedStockSource(id)).isEqualTo(IN_TRANSIT); - - pricingRequestService.updateDraft(id, draftUpdate(stockLine("RT", IN_THAILAND, 5)), salesActor); + assertThat(created.items()).singleElement() + .satisfies(i -> assertThat(i.stockSource()).isEqualTo(IN_THAILAND)); assertThat(pricingRequestService.get(id, salesActor).items().get(0).stockSource()).isEqualTo(IN_THAILAND); assertThat(storedStockSource(id)).isEqualTo(IN_THAILAND); @@ -353,16 +335,13 @@ void stockSource_isCarriedIntoACustomerChangeRevision() { new CustomerChangeRevisionRequest("ลูกค้าเปลี่ยน", UUID.randomUUID().toString(), PricingRequestRecipient.DESIGNER, null, "Designer Co.", LocalDate.now().plusDays(14), null, "THB", null, - List.of(stockLine("Rev TH", IN_THAILAND, 5), stockLine("Rev TR", IN_TRANSIT, 4), + List.of(stockLine("Rev TH", IN_THAILAND, 5), importLine("Rev Imp", catalogProductFactoryA, FACTORY_A, 10))), salesActor); long childId = revision.summary().id(); assertThat(itemByModel(childId, "Rev TH").stockSource()).isEqualTo(IN_THAILAND); - assertThat(itemByModel(childId, "Rev TR").stockSource()).isEqualTo(IN_TRANSIT); assertThat(itemByModel(childId, "Rev Imp").stockSource()).isNull(); - assertThat(itemByModel(childId, "Rev TR").expectedArrivalDate()) - .as("a new revision starts without an ETA: import confirms it again").isNull(); } // ───────────────────────────────────────────────────────────────────────────────────── @@ -394,12 +373,12 @@ private th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemReq String model, int pieces ) { var full = importLine(model, null, null, pieces); - return withOriginAndLead(full, null, 3, 7); + return withOriginAndLead(full, null, 3, 7, null); } private th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest withOriginAndLead( th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest i, - String originCountry, Integer leadMin, Integer leadMax + String originCountry, Integer leadMin, Integer leadMax, String originCountryOther ) { return new th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemRequest( i.sourceTicketItemId(), i.productId(), i.variantId(), i.brand(), i.model(), i.productDescription(), @@ -408,7 +387,7 @@ private th.co.glr.hr.pricingrequest.PricingRequestRequests.PricingRequestItemReq i.deliveryLocation(), i.specialRequirement(), i.productCode(), i.thicknessMm(), i.sqmPerPiece(), i.quantityMode(), i.areaSqm(), i.piecesInput(), i.wastageMode(), i.wastageValue(), i.piecesPerBox(), i.sqmPerBox(), i.roundToFullBox(), originCountry, leadMin, leadMax, - i.piecesBeforeWastage(), i.piecesAfterWastage(), i.boxes(), i.originCountryOther(), i.stockSource()); + i.piecesBeforeWastage(), i.piecesAfterWastage(), i.boxes(), originCountryOther, i.stockSource()); } private String storedStockSource(long pricingRequestId) { diff --git a/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java index 825166ab7..879304a7f 100644 --- a/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/procurement/ProcurementServiceIntegrationTest.java @@ -426,12 +426,12 @@ SELECT COUNT(*) FROM sales.factory_purchase_order WHERE pricing_request_id = :id */ @Test void createPurchaseOrders_ticketAtProcurementButThisPricingRequestNotAccepted_isRejected() { - DealFixture deal = driveToProcurement(true); + DealFixture deal = driveToProcurement(true, true); // The second pricing request is driven all the way to QUOTATION_ISSUED — it DOES have its // own APPROVED decision and its own real pricing_costing_item rows, so // findApprovedPricingCostingId succeeds and cannot be the thing that blocks this call. // Only status != QUOTATION_ACCEPTED (the customer has not yet accepted THIS quotation) can. - long otherPricingRequestId = addSecondPricingRequestDrivenToQuotationIssued(deal.ticketId); + long otherPricingRequestId = siblingPricingRequestId; assertThat(pricingRequestService.get(otherPricingRequestId, importActor).summary().status()) .isEqualTo(PricingRequestStatus.QUOTATION_ISSUED); @@ -552,7 +552,21 @@ private record DealFixture(long ticketId, long pricingRequestId) {} * factories, so downstream tests can assert "2 factories -> 2 POs" without a second * fixture. */ private DealFixture driveToProcurement(boolean issueImportRequest) { + return driveToProcurement(issueImportRequest, false); + } + + /** The id of the second request {@code driveToProcurement(.., true)} drove to QUOTATION_ISSUED. */ + private long siblingPricingRequestId; + + /** + * {@code withSiblingRequest}: a SECOND request on the ticket is driven to QUOTATION_ISSUED BEFORE the + * first one's order is confirmed (walk 4, W4-04: a confirmed deal starts no new pricing round). + */ + private DealFixture driveToProcurement(boolean issueImportRequest, boolean withSiblingRequest) { DealFixture deal = driveToQuotationIssuedNotYetAccepted(FACTORY_A, FACTORY_B); + if (withSiblingRequest) { + siblingPricingRequestId = addSecondPricingRequestDrivenToQuotationIssued(deal.ticketId); + } CustomerQuotationDto issued = quotationRepository.findByPricingRequest(deal.pricingRequestId).stream() .filter(q -> QuotationStatus.ISSUED.equals(q.docStatus())) .findFirst().orElseThrow(); diff --git a/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java index 0e492b10d..04baa8ed5 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/DeliveryAuthzIntegrationTest.java @@ -309,6 +309,9 @@ private long deliverableDeal() { new BigDecimal("10.00"), null, "PIECE", null, null, null, null, "THB"))); long ticketId = tickets.create(request, tickets.nextTicketCode(), ownerId, "เจ้าของดีล ส่งมอบ"); tickets.updateSalesStage(ticketId, DealStage.ORDER_RECEIVED); + // Stock goods leave only after the deposit (walk 4): the fixture records it, since this class is about WHO may deliver. + jdbc.update("UPDATE sales.ticket SET payment_status = 'DEPOSIT_PAID' WHERE ticket_id = :id", + java.util.Map.of("id", ticketId)); long itemId = onlyItemId(ticketId); ticketService.reserveStock(ticketId, new StockReservationRequest(List.of( diff --git a/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java index 4d9e5d0f4..fe0500678 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/StageFactGateIntegrationTest.java @@ -742,28 +742,24 @@ void aStockDeclarationAtOrAboveTheFloor_isAcceptedAndStillRoutesTheDeal() { } /** - * The floor must reach BOTH entry points. {@code reserveStock}'s own gate and - * {@code canReserveStock} (which decides whether {@code actions} advertises RESERVE_STOCK) were - * unified on one predicate by the 2026-08-13 widening; applying the floor to only one of them - * would put the capability back to advertised-then-refused, or live-but-invisible. + * Walk 4 (owner ruling 2026-10-29): RESERVE_STOCK is not advertised at any stage, but the + * {@code reserveStock} endpoint keeps its stage floor (fixtures and old clients still call it): + * refused below ORDER_RECEIVED, accepted at it. */ @Test - void actionsDoesNotAdvertiseReserveStockBelowTheFloor_andDoesAboveIt() { + void actionsNeverAdvertiseReserveStock_andTheEndpointKeepsItsFloor() { long below = createDealWithOneItem(); tickets.updateSalesStage(below, DealStage.NEGOTIATION); assertThat(actionCodes(below, ownerRep)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(below, ceo)).doesNotContain("RESERVE_STOCK"); - // …and the advertiser agrees with the gate on the very same deal. assertThatThrownBy(() -> ticketService.reserveStock(below, declare(onlyItemId(below), "100.00"), ownerRep)) .isInstanceOf(ApiException.class); long above = createDealWithOneItem(); tickets.updateSalesStage(above, DealStage.ORDER_RECEIVED); - assertThat(actionCodes(above, ownerRep)).contains("RESERVE_STOCK"); - assertThat(actionCodes(above, ceo)).contains("RESERVE_STOCK"); - // import lost RESERVE_STOCK entirely on 2026-09-28 (S18 is Sales's) — not offered it even - // above the floor. + assertThat(actionCodes(above, ownerRep)).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(above, ceo)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(above, importActor)).doesNotContain("RESERVE_STOCK"); assertThatCode(() -> ticketService.reserveStock(above, declare(onlyItemId(above), "100.00"), ownerRep)) diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java index 5b76705a8..f2d3fa276 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketRepositoryIntegrationTest.java @@ -180,14 +180,27 @@ void editItems_clearingTheLinkPersistsAsNull() { // would happily "pass" this while the real CHECK constraint / columns did something else. @Test - void createTicket_persistsStockSourcedPriceAndReadsItBack() { + void createTicket_stockSourcedLineWithNoPrice_persistsTheFlagAndANullPrice() { + // Walk 4 (W4-09): chk_ticket_item_stock_sale_price is relaxed to + // `sourced_from_stock OR stock_sale_price IS NULL`, so a flagged line needs no price. + long ticketId = tickets.create(sampleTicket( + itemFromStock("Cotto", "Stone Series", "White", "Matte", "60x60", null)), + tickets.nextTicketCode(), actorId, "พนักงานขาย"); + + TicketItemDto persisted = tickets.findById(ticketId).orElseThrow().items().get(0); + assertThat(persisted.sourcedFromStock()).isTrue(); + assertThat(persisted.stockSalePrice()).isNull(); + } + + @Test + void createTicket_aStockSalePriceSentByAnOldClient_isStoredNull() { long ticketId = tickets.create(sampleTicket( itemFromStock("Cotto", "Stone Series", "White", "Matte", "60x60", new BigDecimal("555.00"))), tickets.nextTicketCode(), actorId, "พนักงานขาย"); TicketItemDto persisted = tickets.findById(ticketId).orElseThrow().items().get(0); assertThat(persisted.sourcedFromStock()).isTrue(); - assertThat(persisted.stockSalePrice()).isEqualByComparingTo("555.00"); + assertThat(persisted.stockSalePrice()).isNull(); } @Test @@ -201,7 +214,7 @@ void createTicket_notFromStockHasNullStockSalePrice() { } @Test - void editItems_roundTripsStockSourcedPriceThroughReplaceItemsPreservingPricing() { + void editItems_roundTripsTheStockFlagWithNoPriceThroughReplaceItemsPreservingPricing() { long ticketId = tickets.create(sampleTicket( item("Toyota", "Hilux", "White", "Matte", "L")), tickets.nextTicketCode(), actorId, "พนักงานขาย"); TicketItemDto existing = tickets.findById(ticketId).orElseThrow().items().get(0); @@ -217,13 +230,13 @@ void editItems_roundTripsStockSourcedPriceThroughReplaceItemsPreservingPricing() existing.qtyDelivered(), existing.qtyFromStock(), existing.stockNote(), existing.catalogPriceId(), existing.catalogProductCode(), null, null, null, null, null, 1, - true, new BigDecimal("620.00")); + true, null); tickets.replaceItemsPreservingPricing(ticketId, List.of(edited)); TicketItemDto persisted = tickets.findById(ticketId).orElseThrow().items().get(0); assertThat(persisted.sourcedFromStock()).isTrue(); - assertThat(persisted.stockSalePrice()).isEqualByComparingTo("620.00"); + assertThat(persisted.stockSalePrice()).isNull(); } @Test diff --git a/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java b/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java index 1522af01e..f5d3b4bca 100644 --- a/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java +++ b/backend/src/test/java/th/co/glr/hr/ticket/TicketServiceTest.java @@ -1310,27 +1310,23 @@ void reserveStock_fromOrderReceivedOnward_isAccepted() { } /** - * The floor must reach BOTH entry points or the capability goes live-but-invisible (or - * advertised-then-refused), which is the failure #706 was careful to avoid when it unified - * the gate and the advertiser on canDeclareStockCoverage. Same deal, same roles, one stage - * apart. + * Walk 4 (owner ruling 2026-10-29): there is no reserve-from-stock feature, so {@code + * RESERVE_STOCK} is advertised to nobody at any stage - not the owning rep, not the CEO, not + * import - even where it used to be offered (ORDER_RECEIVED and later). The endpoint and + * {@code reserveStock} itself stay (suites use them as a fixture); only the advert goes. */ @Test - void actions_doesNotAdvertiseReserveStockBelowOrderReceived() { + void actions_neverAdvertisesReserveStock_atAnyStage() { TicketItemDto item = deliveryItem(1L, "100.00", "0.00", "0.00"); - stubDeal(41L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), null, null, - DealStage.NEGOTIATION, null); - // The floor is checked through the two roles that CAN declare (owning rep + CEO); import is - // no longer offered RESERVE_STOCK at all (owner decision 2026-09-28), asserted below the loop. - assertThat(actionCodes(41L, salesActor)).doesNotContain("RESERVE_STOCK"); - assertThat(actionCodes(41L, ceoActor)).doesNotContain("RESERVE_STOCK"); - - stubDeal(42L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), null, null, - DealStage.ORDER_RECEIVED, null); - assertThat(actionCodes(42L, salesActor)).contains("RESERVE_STOCK"); - assertThat(actionCodes(42L, ceoActor)).contains("RESERVE_STOCK"); - // import is above the floor here yet still not offered it — the S18 narrowing, at the advertiser. - assertThat(actionCodes(42L, importActor)).doesNotContain("RESERVE_STOCK"); + long id = 41L; + for (String stage : List.of(DealStage.NEGOTIATION, DealStage.ORDER_RECEIVED, DealStage.DEPOSIT_RECEIVED, + DealStage.PROCUREMENT, DealStage.DELIVERY_SCHEDULING)) { + stubDeal(id, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), null, null, stage, null); + assertThat(actionCodes(id, salesActor)).as(stage).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(id, ceoActor)).as(stage).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(id, importActor)).as(stage).doesNotContain("RESERVE_STOCK"); + id++; + } } @Test @@ -1734,38 +1730,30 @@ private void assertCreateAcceptsEntryChannel(String channel) { verify(ticketRepo).create(request, "PR-2026-0001", 1L, "sales"); } - // ── create: V183 stock-sourced deal-line pricing ──────────────────────── - // tickets.create(...) calls TicketRepository#insertItems directly off the raw - // TicketItemRequest list -- no DTO merge step exists on this path, so the - // stockSalePrice required+positive-iff-sourcedFromStock invariant is validated up front, - // before any DB write is attempted (same style as create_rejectsInvalidPriority above). + // ── create: stock-sourced deal lines ───────────────────────────────────── + // Walk 4 (W4-09, owner ruling 2026-10-29): the deal line no longer carries a sales-typed price + // for stock goods - the deal shows the CEO-approved price. Ticking "from stock" is just a flag; + // the price is not required and a price that is sent is stored as NULL (the NULL itself is proved + // against real Postgres in TicketRepositoryIntegrationTest). @Test - void create_rejectsStockSourcedItemWithoutPositivePrice() { + void create_allowsStockSourcedItemWithoutAPrice() { TicketItemRequest missingPrice = new TicketItemRequest( "Brand", "Model", "White", "Matte", "60x60", null, BigDecimal.ONE, null, "PIECE", null, null, null, null, "THB", null, null, true, null); var request = createRequest(77L, List.of(missingPrice)); + when(ticketRepo.nextTicketCode()).thenReturn("PR-2026-0001"); + when(ticketRepo.create(request, "PR-2026-0001", 1L, "sales")).thenReturn(10L); + stubTicket(10L, 1L, TicketStatus.DRAFT); - assertBadRequest(() -> service.create(request, salesActor)); - verify(ticketRepo, never()).create(any(), anyString(), org.mockito.ArgumentMatchers.anyLong(), anyString()); - } - - @Test - void create_rejectsStockSourcedItemWithZeroOrNegativePrice() { - TicketItemRequest zeroPrice = new TicketItemRequest( - "Brand", "Model", "White", "Matte", "60x60", null, - BigDecimal.ONE, null, "PIECE", null, null, null, null, "THB", - null, null, true, BigDecimal.ZERO); - var request = createRequest(77L, List.of(zeroPrice)); + service.create(request, salesActor); // must not throw - assertBadRequest(() -> service.create(request, salesActor)); - verify(ticketRepo, never()).create(any(), anyString(), org.mockito.ArgumentMatchers.anyLong(), anyString()); + verify(ticketRepo).create(request, "PR-2026-0001", 1L, "sales"); } @Test - void create_allowsStockSourcedItemWithValidPrice() { + void create_allowsStockSourcedItemEvenIfAnOldClientStillSendsAPrice() { TicketItemRequest fromStock = new TicketItemRequest( "Brand", "Model", "White", "Matte", "60x60", null, BigDecimal.ONE, null, "PIECE", null, null, null, null, "THB", @@ -2310,25 +2298,20 @@ void actions_neverOffersSubmit() { } /** - * canReserveStock (the actions() advertiser) and reserveStock's own gate must stay in step — - * both now read canDeclareStockCoverage, so the API can never hide an action the caller may - * actually perform, nor offer one that would 403 on click (the inverse of the "never advertise - * a dead action" rule canIssueImportRequest documents). Without this test the advertiser's - * half of the 2026-08-13 widening would be an unmutatable line. + * Walk 4: {@code RESERVE_STOCK} is no longer advertised to the deal owner or the CEO (it was the + * "never advertise a dead action" pairing with reserveStock's own gate; the owner removed the + * จองสินค้าจากสต็อก UI), and still not to anyone else. {@code reserveStock} itself keeps its gate - + * see StageFactGateIntegrationTest for the endpoint staying callable. */ @Test - void actions_offersReserveStockToTheDealOwner_butNotToAnotherRep() { + void actions_neverOffersReserveStock_toAnyRole() { TicketItemDto item = deliveryItem(1L, "100.00", "0.00", "0.00"); stubDeal(40L, 1L, TicketStatus.QUOTATION_ISSUED, List.of(item), "DEPOSIT_PAID", null, DealStage.ORDER_RECEIVED, null); - assertThat(actionCodes(40L, salesActor)).contains("RESERVE_STOCK"); - // CEO keeps it as oversight/override; import lost it on 2026-09-28 (S18 is Sales's). - assertThat(actionCodes(40L, ceoActor)).contains("RESERVE_STOCK"); + assertThat(actionCodes(40L, salesActor)).doesNotContain("RESERVE_STOCK"); + assertThat(actionCodes(40L, ceoActor)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(40L, importActor)).doesNotContain("RESERVE_STOCK"); - // otherSales cannot even read this deal (requireViewAccess is owner-scoped for sales), - // so the advertiser is exercised through the roles that CAN see it and still must not - // be offered it. assertThat(actionCodes(40L, accountActor)).doesNotContain("RESERVE_STOCK"); assertThat(actionCodes(40L, salesManagerActor)).doesNotContain("RESERVE_STOCK"); } @@ -2346,7 +2329,7 @@ private List actionCodes(long ticketId, UserPrincipal actor) { * Rule 4 — {@link TicketService#waiveDeposit} 409s past that point (see * {@code depositPolicy_cannotChangeAfterNoticeIssued_evenForTheOwner} in * {@code DepositPolicyAuthzIntegrationTest}), so advertising it here would offer a button that - * dies on click, the same discipline {@link #actions_offersReserveStockToTheDealOwner_butNotToAnotherRep} + * dies on click, the same discipline {@link #actions_neverOffersReserveStock_toAnyRole} * documents for RESERVE_STOCK. This used to be named * actions_offersWaiveDepositToOwnerOnly_andDepositPaidToAccountOnly and asserted the OPPOSITE * for {@code salesActor} — that assertion was wrong; see canSetDepositPolicy's own Javadoc for @@ -2761,16 +2744,16 @@ void editItems_rejectsNonOwnerSales() { assertForbidden(() -> service.editItems(10L, new EditItemsRequest(List.of(req), null), otherSales)); } - // ── editItems: V183 stock-sourced deal-line pricing ───────────────────── - // sourcedFromStock/stockSalePrice are LEGITIMATELY sales-writable (unlike proposedPrice/ - // approvedPrice/calcedCost/manualPrice, which stay guarded to `prior`) — see - // mergeEditedItemsPreservingPricing's own comment. These tests cover: (b) an invalid - // price is rejected with 400, (c) sourcedFromStock=false forces stockSalePrice to null - // even if the request sent one, and (d) a request that omits both fields preserves the - // prior row's value instead of resetting it. + // ── editItems: stock-sourced deal lines ───────────────────────────────── + // sourcedFromStock is LEGITIMATELY sales-writable (unlike proposedPrice/approvedPrice/calcedCost/ + // manualPrice, which stay guarded to `prior`) - see mergeEditedItemsPreservingPricing's own + // comment. Walk 4 (W4-09): stockSalePrice is no longer sales-writable at all - the merged row never + // carries one, whatever the request (or the prior row) said. Cases: (a) a flagged line with no price + // is accepted, (b) a sent price is dropped, (c) sourcedFromStock=false still clears it, (d) a request + // that omits the flag preserves the prior row's FLAG (but not a price). @Test - void editItems_rejectsStockSourcedItemWithoutPositivePrice() { + void editItems_acceptsStockSourcedItemWithoutAPrice_andKeepsTheFlag() { TicketItemDto existing = new TicketItemDto( 101L, 10L, "Brand", "Model", null, null, null, "Cotto", new BigDecimal("5"), new BigDecimal("10"), null, null, null, @@ -2782,8 +2765,33 @@ void editItems_rejectsStockSourcedItemWithoutPositivePrice() { new BigDecimal("5"), new BigDecimal("10"), "PIECE", null, null, null, null, "THB", null, null, true, null); - assertBadRequest(() -> - service.editItems(10L, new EditItemsRequest(List.of(edited), null), salesActor)); + service.editItems(10L, new EditItemsRequest(List.of(edited), null), salesActor); // must not throw + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(ticketRepo).replaceItemsPreservingPricing(eq(10L), captor.capture()); + assertThat(captor.getValue().get(0).sourcedFromStock()).isTrue(); + assertThat(captor.getValue().get(0).stockSalePrice()).isNull(); + } + + @Test + void editItems_aSentStockSalePrice_isNeverStored() { + TicketItemDto existing = new TicketItemDto( + 101L, 10L, "Brand", "Model", null, null, null, "Cotto", + new BigDecimal("5"), new BigDecimal("10"), null, null, null, + null, null, "THB", 0, null, null, null, "PIECE", null, null); + stubTicketWithItems(10L, 1L, TicketStatus.IN_REVIEW, List.of(existing)); + + TicketItemRequest edited = new TicketItemRequest( + "Brand", "Model", null, null, null, "Cotto", + new BigDecimal("5"), new BigDecimal("10"), "PIECE", + null, null, null, null, "THB", null, null, true, new BigDecimal("999.00")); + + service.editItems(10L, new EditItemsRequest(List.of(edited), null), salesActor); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(ticketRepo).replaceItemsPreservingPricing(eq(10L), captor.capture()); + assertThat(captor.getValue().get(0).sourcedFromStock()).isTrue(); + assertThat(captor.getValue().get(0).stockSalePrice()).isNull(); } @Test @@ -2814,7 +2822,7 @@ void editItems_sourcedFromStockFalseForcesStockSalePriceNull() { } @Test - void editItems_omittingStockFieldsPreservesPriorValue() { + void editItems_omittingStockFieldsPreservesThePriorFlag_butNotAPrice() { // A request that says nothing about sourcedFromStock (null, the "not specified" value // for this Boolean field, as opposed to an explicit false) must not silently reset a // flag a previous save set — same null-safe-fallback treatment unitBasis already gets. @@ -2844,7 +2852,8 @@ void editItems_omittingStockFieldsPreservesPriorValue() { TicketItemDto merged = captor.getValue().get(0); assertThat(merged.brand()).isEqualTo("NewBrand"); // descriptive field still follows the request assertThat(merged.sourcedFromStock()).isTrue(); - assertThat(merged.stockSalePrice()).isEqualByComparingTo("500.00"); + // Walk 4: the flag is preserved, a prior sales-typed price is not carried forward any more. + assertThat(merged.stockSalePrice()).isNull(); } // ── overrideItemPrice ──────────────────────────────────────────────── diff --git a/docs/api/ui-reachable.json b/docs/api/ui-reachable.json index 38f193f22..24c24b82c 100644 --- a/docs/api/ui-reachable.json +++ b/docs/api/ui-reachable.json @@ -1,7 +1,7 @@ { "$comment": "GENERATED by src/api/serverContract.test.js — do not hand-edit. Regenerate: cd frontend && UI_REACHABLE_UPDATE=1 npx vitest run src/api/serverContract.test.js", "source": "endpoints reachable from a component call site (api..( outside src/api/ and outside tests), resolved through hrApi by driving every method", - "count": 319, + "count": 318, "operations": [ "DELETE /api/attachments/{id}", "DELETE /api/catalog/prices/{priceId}", @@ -284,7 +284,6 @@ "POST /api/tickets/{id}/item-weight-multipliers", "POST /api/tickets/{id}/lost", "POST /api/tickets/{id}/reopen", - "POST /api/tickets/{id}/reserve-stock", "POST /api/tickets/{id}/resume", "POST /api/tickets/{id}/shipping", "POST /api/tickets/{id}/stage", diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 59044a345..c9b6bf728 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -81,20 +81,6 @@ import { const db = createDemoDatabase(); -// V183 (stock-sourced deal-line pricing): mirrors TicketService.isStorablePositivePrice exactly. -// sales.ticket_item.stock_sale_price is numeric(14,2), and Postgres rounds HALF_UP to that scale -// BEFORE chk_ticket_item_stock_sale_price sees the value -- 0.004 stores as 0.00 (the real CHECK -// rejects it), 0.005 stores as 0.01 (accepted). A plain `Number(price) > 0` would let 0.004 pass -// here and then have the real backend 400/500 it -- see ticketItemFields.jsx's -// isStockSalePriceMissing, which this duplicates rather than imports (mockApi.js does not import -// frontend feature modules). -function isMockStockSalePriceMissing(sourcedFromStock, stockSalePrice) { - if (!sourcedFromStock) return false; - if (stockSalePrice === '' || stockSalePrice == null) return true; - const n = Number(stockSalePrice); - return !(Number.isFinite(n) && Math.round(n * 100) > 0); -} - function normalizeQuotation(q, ticket, index = 0) { return { ...q, @@ -1626,8 +1612,7 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { const ticketItem = ticket.items.find((ti) => ti.id === item.sourceTicketItemId); if (!ticketItem) continue; const qtyDelivered = Number(ticketItem.qtyDelivered ?? 0); - const qtyFromStock = Number(ticketItem.qtyFromStock ?? 0); - if (newQty < qtyDelivered || newQty < qtyFromStock) { + if (newQty < qtyDelivered) { // Mirrors the DB's chk_ticket_item_qty_delivered/chk_ticket_item_qty_from_stock CHECK // constraints (V54) that back the real reconcileItemQty call — a downward reconciliation // that would drop qty below an already-recorded delivered/reserved amount is refused, not @@ -1635,9 +1620,12 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { fail(`ไม่สามารถปรับจำนวนสินค้า (item ${item.sourceTicketItemId}) ให้ตรงกับคำขอราคาที่ยืนยันคำสั่งซื้อได้ ` + 'เนื่องจากมีการส่งมอบหรือจองสต็อกไปแล้วเกินจำนวนใหม่', 409); } - if (Number(ticketItem.qty) !== newQty) { + // A stock line (IN_THAILAND) is covered in full - the one writer of qtyFromStock (no reserve step). + const coveredFromStock = item.stockSource === 'IN_THAILAND' ? newQty : 0; + if (Number(ticketItem.qty) !== newQty || Number(ticketItem.qtyFromStock ?? 0) !== coveredFromStock) { ticketItem.qty = newQty; ticketItem.qtySqm = item.requestedQtySqm ?? null; + ticketItem.qtyFromStock = coveredFromStock; anyChange = true; } } else { @@ -1652,7 +1640,7 @@ function reconcileTicketItemsFromPricingRequest(ticket, pr, user) { qty: newQty, qtySqm: item.requestedQtySqm ?? null, proposedPrice: null, approvedPrice: null, currency: 'THB', sortOrder: ticket.items.length, - qtyDelivered: 0, qtyFromStock: 0, + qtyDelivered: 0, qtyFromStock: item.stockSource === 'IN_THAILAND' ? newQty : 0, unitBasis: item.requestedUnitBasis === 'PER_SQM' ? 'SQM' : 'PIECE', }); anyChange = true; @@ -1981,9 +1969,18 @@ function buildPricingRequestSummary(pr) { unitCode: pr.unitCode ?? null, omitContactHonorific: pr.omitContactHonorific ?? false, customerId: ticket?.customerId ?? null, + // null until the customer's order is confirmed; once set no new pricing round can start. + ticketPaymentStatus: ticket?.paymentStatus ?? null, }; } +// Mirrors PricingRequestService.requireOrderNotConfirmed. +function requireMockOrderNotConfirmed(ticket) { + if (ticket?.paymentStatus != null) { + fail('ยืนยันคำสั่งซื้อของดีลนี้แล้ว จึงเริ่มรอบราคาใหม่ไม่ได้ — การเปลี่ยนแปลงหลังยืนยันต้องทำเป็นการแก้ไขคำสั่งซื้อ', 409); + } +} + function buildPricingRequestDetail(pr) { return { summary: buildPricingRequestSummary(pr), items: pr.items, events: pr.events }; } @@ -3930,6 +3927,7 @@ function reconcilePaymentStatus(ticket, user) { ticket.paymentStatus = 'FULLY_PAID'; pushEvent(ticket, user, 'FULLY_PAID', ticket.status, ticket.status, null); maybeAdvanceClosedPaid(ticket, user); + routeAllStockDealToDelivery(ticket, user); } return; } @@ -3946,6 +3944,7 @@ function reconcilePaymentStatus(ticket, user) { ticket.paymentStatus = 'AWAITING_FINAL_PAYMENT'; pushEvent(ticket, user, 'AWAITING_FINAL_PAYMENT', ticket.status, ticket.status, null); } + routeAllStockDealToDelivery(ticket, user); // deposit confirmed: an all-stock deal is ready to deliver } } @@ -4031,9 +4030,16 @@ function warehouseAvailableFor(ticket) { return ticket.fulfillmentStatus === 'GOODS_RECEIVED' || hasReceivedGoods(ticket.id); } +// Mirrors TicketService.depositSettled: paid (or later), or a no-deposit policy at CUSTOMER_CONFIRMED. Stock goods +// are delivered only after this (a deposit NOTICE alone is not money). +function depositSettled(ticket) { + return ['DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID'].includes(ticket.paymentStatus) + || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'); +} + function deliveryAvailable(ticket) { const stockAvailable = (ticket.items ?? []).some((item) => Number(item.qtyFromStock ?? 0) > Number(item.qtyDelivered ?? 0)); - return ticket.fulfillmentStatus === 'FROM_STOCK' || stockAvailable || warehouseAvailableFor(ticket); + return ((ticket.fulfillmentStatus === 'FROM_STOCK' || stockAvailable) && depositSettled(ticket)) || warehouseAvailableFor(ticket); } function reserveStockForTicket(ticket, user, payload = {}) { @@ -4050,12 +4056,16 @@ function reserveStockForTicket(ticket, user, payload = {}) { total += qty; } pushEvent(ticket, user, 'STOCK_RESERVED', ticket.status, ticket.status, `qty_from_stock=${total}`); + routeAllStockDealToDelivery(ticket, user); +} + +// Mirrors TicketService.routeAllStockDealToDelivery: a deal whose every line is covered from stock (non-empty) +// has no import journey - FROM_STOCK, straight to DELIVERY_SCHEDULING (S18) rather than PROCUREMENT. +function routeAllStockDealToDelivery(ticket, user) { const allCovered = (ticket.items ?? []).length > 0 && ticket.items.every((item) => Number(item.qtyFromStock ?? 0) >= Number(item.qty ?? 0)); if (allCovered && (ticket.fulfillmentStatus == null || ticket.fulfillmentStatus === 'FROM_STOCK')) { ticket.fulfillmentStatus = 'FROM_STOCK'; - // Full stock coverage has no import journey — goods are ready now, so advance - // straight to DELIVERY_SCHEDULING (S18) rather than PROCUREMENT. autoAdvanceStage(ticket, 'DELIVERY_SCHEDULING', user); } } @@ -4080,6 +4090,9 @@ function updateItemWeightMultipliersForTicket(ticket, user, payload = {}) { function recordDeliveryForTicket(ticket, user, payload = {}, completing = false) { const source = String(payload.source ?? '').trim().toUpperCase(); if (!['WAREHOUSE', 'STOCK'].includes(source)) fail('source ต้องเป็น WAREHOUSE หรือ STOCK', 400); + if (source === 'STOCK' && !depositSettled(ticket)) { + fail('ส่งสินค้าจากสต็อกได้หลังรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + } const lines = payload.lines ?? []; if (!lines.length) fail('ต้องระบุรายการส่งสินค้า', 400); const combined = new Map(); @@ -8007,14 +8020,6 @@ function buildTicketActions(user, ticket) { if (['import', 'ceo'].includes(user.role) && !irTracked && ticket.fulfillmentStatus === 'IR_ISSUED') add('IR_SENT', 'fulfillment', 'ส่งคำขอนำเข้าแล้ว'); if (['import', 'ceo'].includes(user.role) && !irTracked && ticket.fulfillmentStatus === 'IR_SENT') add('SHIPPING', 'fulfillment', 'สินค้าเดินทาง'); if (['import', 'ceo'].includes(user.role) && !irTracked && ticket.fulfillmentStatus === 'SHIPPING') add('GOODS_RECEIVED', 'fulfillment', 'รับสินค้า'); - // S18 owner decision 2026-09-28: CEO or the deal's owning sales rep -- NOT import (dropped - // from `['import', 'ceo']`), mirroring TicketService#canReserveStock -> canDeclareStockCoverage - // and the reserveStock mutation gate above. `owner` is the same limb the delivery - // advertisement uses just below; advertising it to import would offer a button that 403s. - if ((user.role === 'ceo' || owner) && (ticket.items ?? []).length > 0 && hasRemainingDelivery(ticket) - && ticket.fulfillmentStatus !== 'FULLY_DELIVERED') { - add('RESERVE_STOCK', 'fulfillment', 'จองสินค้าจากสต็อก', { requiredFields: ['lines'] }); - } // V148 (per-item stock-commission weighting): sales_manager/ceo only -- mirrors // TicketService#canSetItemWeightMultiplier exactly (ITEM_WEIGHT_ROLES, no ownership/stage // nuance beyond the ticket being ACTIVE, which the enclosing `if (active)` already is). @@ -8757,13 +8762,6 @@ export const api = { if (!['DESIGNER_LED', 'OWNER_DIRECT', 'BUYER_DIRECT'].includes(payload.entryChannel)) { fail(`ไม่รองรับช่องทางรับงาน '${payload.entryChannel}'`, 400); } - // Mirrors TicketService.create's V183 item-validation loop: a stock-sourced line must - // carry a real, positive selling price, checked before any write. - for (const item of payload.items || []) { - if (isMockStockSalePriceMissing(item.sourcedFromStock, item.stockSalePrice)) { - fail('ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก', 400); - } - } const nextId = Math.max(...db.tickets.map((t) => t.id)) + 1; const code = `DL-2026-${String(nextId).padStart(4, '0')}`; const now = new Date().toISOString(); @@ -8815,9 +8813,9 @@ export const api = { // emptyItemFromTicketItem can seed productId/catalogProductCode without re-searching. catalogPriceId: item.catalogPriceId ?? null, catalogProductCode: item.catalogProductCode ?? null, - // Stock-sourced pricing (V183) — see this method's own validation above. + // The sales-typed stock price is gone (walk 4): only the flag is stored, never a price. sourcedFromStock: Boolean(item.sourcedFromStock), - stockSalePrice: item.sourcedFromStock ? (item.stockSalePrice ?? null) : null, + stockSalePrice: null, })), events: [{ id: nextId * 1000, ticketId: nextId, actorId: user.id, actorName: user.name, kind: 'CREATED', fromStatus: null, toStatus: 'draft', message: null, createdAt: now }], quotation: null, @@ -8855,30 +8853,14 @@ export const api = { const importCanEdit = user.role === 'import' && ['in_review', 'price_proposed'].includes(st); if (!salesCanEdit && !importCanEdit) fail('ไม่มีสิทธิ์แก้ไขรายการสินค้าในสถานะนี้', 403); - // V183 (stock-sourced deal-line pricing): unlike the pricing fields below (guarded to the - // prior row) and unlike catalogPriceId/catalogProductCode (request wins outright, even when - // omitted), sourcedFromStock/stockSalePrice get a NULL-SAFE FALLBACK to the prior row — - // mirrors TicketService.mergeEditedItemsPreservingPricing exactly: a request that omits the - // field (undefined/null) inherits the prior row's value instead of resetting the flag. - (payload.items || []).forEach((item, i) => { - const resolvedSourcedFromStock = item.sourcedFromStock ?? ticket.items[i]?.sourcedFromStock ?? false; - const resolvedStockSalePrice = resolvedSourcedFromStock - ? (item.sourcedFromStock != null ? item.stockSalePrice : ticket.items[i]?.stockSalePrice) ?? null - : null; - if (isMockStockSalePriceMissing(resolvedSourcedFromStock, resolvedStockSalePrice)) { - fail('ต้องระบุราคาขายเมื่อเลือกสินค้าจากสต็อก', 400); - } - }); // Mirrors TicketService.editItems: sales/import editing descriptive fields must // never silently overwrite import's proposed price or CEO's approved/manual price — // pricing fields always come from the existing item at this position, never the // request (2026-07-16 pricing-integrity audit, finding #4). Only proposePrice is // allowed to replace proposedPrice wholesale. ticket.items = (payload.items || []).map((item, i) => { + // sourcedFromStock: a request that omits it inherits the prior row's flag; the price is never stored. const resolvedSourcedFromStock = item.sourcedFromStock ?? ticket.items[i]?.sourcedFromStock ?? false; - const resolvedStockSalePrice = resolvedSourcedFromStock - ? (item.sourcedFromStock != null ? item.stockSalePrice : ticket.items[i]?.stockSalePrice) ?? null - : null; return { ...ticket.items[i], brand: item.brand, model: item.model, @@ -8900,9 +8882,8 @@ export const api = { // because you only discover it in prod. catalogPriceId: item.catalogPriceId ?? null, catalogProductCode: item.catalogProductCode ?? null, - // Stock-sourced pricing (V183) — see this method's own validation above. sourcedFromStock: resolvedSourcedFromStock, - stockSalePrice: resolvedStockSalePrice, + stockSalePrice: null, }; }); ticket.hasEdits = true; @@ -9304,6 +9285,8 @@ export const api = { ticket.updatedAt = new Date().toISOString().slice(0, 10); pushEvent(ticket, user, 'POLICY_CHANGED', ticket.salesStage, ticket.salesStage, `deposit_policy → ${payload.policy} — ${payload.reason.trim()}`); + // Nothing left to wait for: an all-stock deal at CUSTOMER_CONFIRMED goes straight to delivery. + if (ticket.paymentStatus === 'CUSTOMER_CONFIRMED') routeAllStockDealToDelivery(ticket, user); return delay({ ticket: buildTicketDetail(ticket) }); }, @@ -12578,6 +12561,13 @@ export const api = { requireActive(ticket); if (row.status !== 'DRAFT') fail('ใบขอซื้อฉบับนี้ออกเลขไปแล้ว', 409); if (row.items.length === 0) fail('ใบขอซื้อยังไม่มีรายการสินค้า', 409); + // Mirrors ImportRequestService.issue: a draft naming a line that is now covered from stock is refused. + if (row.items.some((it) => { + const ti = (ticket.items ?? []).find((i) => Number(i.id) === Number(it.ticketItemId)); + return ti && Number(ti.qty) > 0 && Number(ti.qtyFromStock ?? 0) >= Number(ti.qty); + })) { + fail('ใบขอซื้อนี้มีรายการที่ครอบคลุมจากสต็อกแล้ว — ไม่ต้องสั่งนำเข้า กรุณาลบฉบับร่างนี้แล้วสร้างใบขอซื้อใหม่', 409); + } if (row.leadTimeMinDays == null || row.leadTimeMaxDays == null) { fail(`กรุณาระบุระยะเวลานำเข้า (วัน) ของโรงงาน ${row.factoryName}`, 409); } @@ -14605,6 +14595,7 @@ export const api = { const ticket = db.tickets.find((t) => t.id === Number(ticketId)); if (!ticket) fail('ไม่พบดีลนี้', 404); requirePricingRequestDealActive(ticket); + requireMockOrderNotConfirmed(ticket); if (ticket.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); if (!payload.clientRequestId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(payload.clientRequestId)) { fail('clientRequestId ต้องเป็น UUID', 400); @@ -16415,6 +16406,8 @@ export const api = { followQuotationDepositPolicy(ticket, mockDealQuotations.find( (q) => q.pricingRequestId === pr.id && q.origin === 'PRICING_REQUEST' && q.docStatus === 'ACCEPTED'), user); autoAdvanceStage(ticket, 'ORDER_RECEIVED', user); + // No deposit to wait for (0% quotation / waived): an all-stock deal goes straight to delivery. + if (depositBypassesNotice(ticket)) routeAllStockDealToDelivery(ticket, user); } pushPricingRequestEvent(pr, user, 'ORDER_CONFIRMED', pr.status, pr.status, 'ยืนยันคำสั่งซื้อแล้ว'); @@ -16506,6 +16499,7 @@ export const api = { if (ticket?.createdById !== user.id) fail('ไม่มีสิทธิ์เข้าถึงรายการนี้', 403); if (pr.status !== 'DRAFT') fail(`ต้องเป็นคำขอราคาที่อยู่ในสถานะ 'DRAFT' เท่านั้น (สถานะปัจจุบัน: '${pr.status}')`, 409); requirePricingRequestDealActive(ticket); + requireMockOrderNotConfirmed(ticket); if (pr.items.length === 0) fail('ต้องมีรายการสินค้าอย่างน้อย 1 รายการก่อนส่งคำขอราคา', 400); if (pr.recipientContactId == null && !pr.recipientLabel?.trim()) fail('ต้องระบุผู้รับคำขอราคา', 400); if (pr.requiredDate && new Date(pr.requiredDate) < new Date(new Date().toDateString())) { @@ -16570,6 +16564,7 @@ export const api = { if (['DRAFT', 'CANCELLED', 'SUPERSEDED'].includes(parent.status)) { fail('สร้างรอบแก้ไขจากการเปลี่ยนแปลงของลูกค้าได้เฉพาะจากคำขอราคาที่ยื่นและยังดำเนินการอยู่เท่านั้น', 409); } + requireMockOrderNotConfirmed(ticket); if (!payload.revisionReason?.trim()) fail('revisionReason ต้องไม่เว้นว่าง', 400); if (!payload.clientRequestId) fail('clientRequestId ต้องเป็น UUID', 400); // GLA-102: mirrors PricingRequestService.createCustomerChangeRevision's diff --git a/frontend/src/api/serverContract.test.js b/frontend/src/api/serverContract.test.js index 0660f70fc..7b068d76a 100644 --- a/frontend/src/api/serverContract.test.js +++ b/frontend/src/api/serverContract.test.js @@ -540,6 +540,9 @@ describe('controller surface / hrApi.js contract', () => { * stale-entry test below deletes the entry for you. */ const UNREACHABLE_FROM_UI = new Set([ + // Walk 4 (owner ruling 2026-10-29): there is no reserve-from-stock feature, so the จองสินค้าจากสต็อก UI is + // gone. The endpoint and hrApi method stay (backend test fixtures and old clients still call it). + 'POST /api/tickets/{}/reserve-stock', // W3-04 (owner ruling 2026-10-29): the one-click legacy "issue IR" is closed — the endpoint now // answers 409 for every deal and no screen calls it. Retire the endpoint and hrApi method together // in a follow-up. diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx index 56d6da011..7f72ea394 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.jsx @@ -1755,7 +1755,7 @@ export function PricingRequestDetailPage({ user, showToast }) { const missingFactoryItems = useMemo( () => (request?.items ?? []) .map((item, index) => ({ item, position: index + 1 })) - .filter((entry) => !itemFactoryName(entry.item)), + .filter((entry) => entry.item.stockSource == null && !itemFactoryName(entry.item)), [request], ); // Import owns this field, and only while the request is in its hands. NOT an authorization @@ -1816,7 +1816,7 @@ export function PricingRequestDetailPage({ user, showToast }) { const pricePreviewGroups = useMemo(() => { const map = new Map(); (request?.items ?? []) - .filter((item) => !coveredItemIds.has(item.id)) + .filter((item) => !coveredItemIds.has(item.id) && item.stockSource == null) // stock lines are never quoted by a factory .forEach((item) => { const name = itemFactoryName(item) || 'ยังไม่ได้ระบุโรงงาน'; if (!map.has(name)) map.set(name, { key: name, factoryName: name, items: [] }); @@ -1876,6 +1876,8 @@ export function PricingRequestDetailPage({ user, showToast }) { // mirrors PricingDecisionService's own DRAFT + CEO_REVIEWING + canActOnPricingDecision guard. const decisionEditable = Boolean(currentDecision) && currentDecision.status === 'DRAFT' && canActOnPricingDecision(user, summary); + // Returned to import and back READY: the RETURNED decision is history, the CEO starts a fresh one. + const needsFreshReview = !currentDecision || currentDecision.status === 'RETURNED'; // V141 ("CEO owns costing"): the bound costing's items, keyed by their OWN id — a decision // item's pricingCostingItemId is a FK to that id, never to pricingRequestItemId (the two are // easy to conflate since a costing item also carries pricingRequestItemId as its own FK). Every @@ -1933,6 +1935,7 @@ export function PricingRequestDetailPage({ user, showToast }) { // SUPERSEDED edge and the predicate is already false for it. const canCreateCustomerRevision = isSales(user) && summary?.ticketCreatedById === user?.employeeId + && summary?.ticketPaymentStatus == null // order confirmed: no new pricing round (the backend 409s it) && canTransition(summary?.status, 'SUPERSEDED'); const pricingRequestAttachments = attachmentsQuery.data ?? []; // Mirrors PricingRequestService.ATTACHMENT_EDITABLE_STATUSES: Sales may only upload/delete its @@ -2742,6 +2745,8 @@ export function PricingRequestDetailPage({ user, showToast }) { function renderCeoDecisionActions({ decision, editable, missingBeforeApprove, staleOverrideItems, newForm, missingCeoPrice, missingCost, missingStockListPrice }) { if (!editable) return null; + // No import line means no import cost to fix, so there is nothing to send back (the server refuses too). + const allLinesAreStock = (request.items ?? []).length > 0 && request.items.every((item) => item.stockSource != null); return ( <>

    @@ -2758,14 +2763,16 @@ export function PricingRequestDetailPage({ user, showToast }) { > อนุมัติราคาขาย - + {allLinesAreStock ? null : ( + + )}
    {missingBeforeApprove.length > 0 ? ( ทุกรายการต้องมีอัตรากำไรก่อนอนุมัติ (หรือปรับราคาเอง) @@ -2842,7 +2849,7 @@ export function PricingRequestDetailPage({ user, showToast }) { function renderCeoStartReview() { return ( <> - {!currentDecision && canStartCeoReview(user, summary) ? ( + {needsFreshReview && canStartCeoReview(user, summary) ? (
    - {canFullWriteStoredIr && !allFactoriesCovered && !depositReady ? ( + {canFullWriteStoredIr && !allFactoriesCovered && !allLinesFromStock && !depositReady ? (

    {DEPOSIT_HINT}

    ) : null} {allFactoriesCovered ? ( @@ -832,7 +793,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({

    ) : (

    - หนึ่งใบต่อหนึ่งโรงงาน — สร้างอัตโนมัติจากรายการสินค้าที่ต้องสั่งนำเข้า (ไม่รวมส่วนที่จองจากสต็อก) + หนึ่งใบต่อหนึ่งโรงงาน — สร้างอัตโนมัติจากรายการสินค้าที่ต้องสั่งนำเข้า (ไม่รวมรายการจากสต็อก)

    )} {canFullWriteStoredIr && !allFactoriesCovered && factorylessLines.length > 0 ? ( @@ -917,14 +878,8 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ {/* Before the first IR the นำเข้าสินค้า card above is not rendered, but the commission-weight setter must stay reachable, so it sits just below the stored-IR box. */} - {!fs && (can.reserveStock || can.setItemWeight) ? ( + {!fs && can.setItemWeight ? (
    - {can.reserveStock ? ( - - ) : null} {can.setItemWeight ? ( - - - )} - > -
    - {items.map((item) => ( - - ))} -