From d0441240ee1de1ffe18508418c9aa7cc60ede6b6 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:17:42 +0700 Subject: [PATCH 1/2] fix(import): creating the IR issues one per factory at once; lead-time popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner rulings 2026-10-29: no DRAFT step for a new import request. สร้างใบขอซื้อ (IR) now creates AND issues one IR per factory in a single transaction, so the deal moves to ขั้น 12 and every factory shows on the import fulfilment page right away, in sync with the deal's จัดซื้อ-ส่งมอบ tab. - createDrafts checks every new factory before any write (items, lead time, deposit gate) and issues each through the extracted issueStoredDraft; a failure writes nothing. - A factory with no lead time from the quotation or country default gets a popup (like the new-factory country popup) asking min-max days; the 409 lists only those factories and the retry carries the values (1-365, min<=max). - Revisions keep their edit DRAFT; leftover DRAFT rows still issue as before. - Button and toast wording now says ออกใบขอซื้อ (IR). Co-Authored-By: Claude Opus 5.5 --- .../importrequest/ImportRequestRequests.java | 21 ++- .../importrequest/ImportRequestService.java | 98 +++++++++-- ...lQuotationConfirmOrderIntegrationTest.java | 6 +- .../ImportDealAuthzIntegrationTest.java | 4 +- .../th/co/glr/hr/importrequest/DraftSeed.java | 53 ++++++ ...mportRequestEmailDraftIntegrationTest.java | 46 ++--- ...questFactoryResolutionIntegrationTest.java | 28 ++-- ...TimeFromPricingRequestIntegrationTest.java | 1 - .../ImportRequestLeadTimeIntegrationTest.java | 158 ++++++++++++++---- ...portRequestPaymentGateIntegrationTest.java | 28 +++- .../StoredImportRequestIntegrationTest.java | 100 +++++++---- frontend/e2e-real/helpers/sales.js | 4 +- frontend/e2e-real/uat-sales-journeys.spec.js | 11 +- frontend/src/api/mockApi.js | 38 ++++- .../mockApi.storedImportRequestRollup.test.js | 30 ++-- .../PricingRequestDetailPage.test.jsx | 2 +- .../features/tickets/DealFulfilmentPanel.jsx | 123 ++++++++++++-- .../tickets/DealFulfilmentPanel.test.jsx | 134 +++++++++++++-- 18 files changed, 696 insertions(+), 189 deletions(-) create mode 100644 backend/src/test/java/th/co/glr/hr/importrequest/DraftSeed.java diff --git a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRequests.java b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRequests.java index 3d6e3b0ad..27bcc725d 100644 --- a/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRequests.java +++ b/backend/src/main/java/th/co/glr/hr/importrequest/ImportRequestRequests.java @@ -32,14 +32,31 @@ private ImportRequestRequests() {} public record CreateImportRequestsRequest( String clientRequestId, @Valid List newFactoryCountries, - @Valid List itemFactories + @Valid List itemFactories, + @Valid List leadTimes ) { /** Pre-picker shape: no per-line factory choices. */ public CreateImportRequestsRequest(String clientRequestId, List newFactoryCountries) { - this(clientRequestId, newFactoryCountries, null); + this(clientRequestId, newFactoryCountries, null, null); + } + + public CreateImportRequestsRequest(String clientRequestId, List newFactoryCountries, + List itemFactories) { + this(clientRequestId, newFactoryCountries, itemFactories, null); } } + /** + * One factory NAME's caller-supplied import lead time, for a new factory whose lead time can be + * derived from neither the quotation nor the country default (owner ruling 10-29). Same bounds as + * {@link SetLeadTimeRequest}; min must not exceed max. + */ + public record NewFactoryLeadTimeInput( + @NotBlank String factoryName, + @NotNull @Min(1) @Max(365) Integer minDays, + @NotNull @Min(1) @Max(365) Integer maxDays + ) {} + /** * Sales' factory choice for ONE deal line that has no factory (a free-text line on a direct * quotation — W3-03 follow-up, owner ruling 2026-10-29). Applied only to a line that resolves to 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 dbb67e01b..cb3f51659 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 @@ -210,13 +210,17 @@ public ImportRequestDto get(long id, UserPrincipal actor) { } /** - * Raises one DRAFT per FACTORY on the deal that does not already have a live one, resolving each + * Raises AND ISSUES one ใบขอซื้อ per FACTORY on the deal that does not already have a live one (owner + * ruling 10-29: no DRAFT is left behind; each row comes back ISSUED with a doc number and step + * CONTACTED, and the first one moves the deal to PROCUREMENT). All-or-nothing: a missing lead time, + * an empty item list or the payment gate refuses the whole call with a 409 before anything is + * written. Resolving each * line's factory per {@link ImportRequestQueryRepository#factoryResolutionCandidates}'s cascade — * see that method's Javadoc for the order and {@link #resolveFactoryGroups} for the match-or-create * step (owner decision 2, revised 09-18: auto-create now requires {@code request}'s {@code * newFactoryCountries}). * - *

Idempotent in the way that matters: a factory with a DRAFT or an ISSUED form is skipped + *

Idempotent in the way that matters: a factory with a (leftover) DRAFT or an ISSUED form is skipped * rather than duplicated, so calling this twice does not produce two forms for the same factory — * and the two partial unique indexes in V184 refuse it in the database even if this check were * wrong. Refuses outright if every factory is already covered, rather than returning "created @@ -233,6 +237,9 @@ public List createDrafts(long ticketId, CreateImportRequestsRe "สร้างใบขอซื้อได้หลังจากยืนยันคำสั่งซื้อแล้ว (ORDER_RECEIVED) เท่านั้น"); } + // 400 before anything is written (the picker and auto-create below both write). + Map suppliedLeadTimes = + validatedLeadTimes(request); applyItemFactoryChoices(ticketId, request); List groups = resolveFactoryGroups(ticketId, actor.id(), request); if (groups.isEmpty()) { @@ -248,32 +255,57 @@ public List createDrafts(long ticketId, CreateImportRequestsRe // CR-1 (D, B-R4/B-R5): each factory's IR takes the lead time on the deal's current QUOTATION items. List quotationLeadTimes = repository.currentQuotationLeadTimes(ticketId); - int created = 0; + + // ALL-OR-NOTHING (owner ruling 10-29): create also ISSUES, so every issue check runs for every + // NEW factory BEFORE the first write. (Not left to the @Transactional rollback alone: it is + // inert in the hand-wired integration suite, and the checks are cheap.) + record NewRow(FactoryGroup group, LeadTimeDefaults.Range derived, LeadTimeDefaults.Range leadTime) { } + List newRows = new ArrayList<>(); + List missingLeadTime = new ArrayList<>(); for (FactoryGroup group : groups) { if (alreadyCovered.contains(group.factoryId())) { continue; } LeadTimeDefaults.Range derived = leadTimeFromQuotation(quotationLeadTimes, group.factoryId(), group.items()); - LeadTimeDefaults.Range leadTime = derived; + LeadTimeDefaults.Range leadTime = derived != null ? derived : LeadTimeDefaults.forCountry(group.countryCode()); + if (group.items().isEmpty()) { + throw new ApiException(HttpStatus.CONFLICT, "ใบขอซื้อยังไม่มีรายการสินค้า"); + } if (leadTime == null) { - leadTime = LeadTimeDefaults.forCountry(group.countryCode()); + // Owner ruling 10-29: a caller-supplied lead time (keyed by factory name) fills it in. + var supplied = suppliedLeadTimes.get(normalizeFactoryName(group.factoryName())); + if (supplied == null) { + missingLeadTime.add(group.factoryName()); + continue; + } + leadTime = new LeadTimeDefaults.Range(supplied.minDays(), supplied.maxDays()); } + newRows.add(new NewRow(group, derived, leadTime)); + } + if (!missingLeadTime.isEmpty()) { + // Parseable by the panel (like the missing-country prefix); lists ONLY the factories still lacking one. + throw new ApiException(HttpStatus.CONFLICT, + MISSING_LEAD_TIME_PREFIX + String.join(", ", missingLeadTime)); + } + if (newRows.isEmpty()) { + throw new ApiException(HttpStatus.CONFLICT, + "ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว"); + } + // The payment gate applies at create now (widened gate, same as issue()). + ticketService.requireImportRequestIssuable(requireTicketSummary(ticketId), true); + + for (NewRow n : newRows) { + FactoryGroup group = n.group(); long id = stored.insertDraft(ticketId, group.factoryId(), group.factoryName(), group.brandLabel(), stored.highestVersion(ticketId, group.factoryId()) + 1, - snapshot, leadTime == null ? null : leadTime.minDays(), - leadTime == null ? null : leadTime.maxDays(), null, // S-E: nothing to carry, fresh draft - null, // REVIEW ROUND 3, item 2: nothing to carry either -- a fresh draft's own - // requiredByNote comes from the deal snapshot only, same as before. + snapshot, n.leadTime().minDays(), n.leadTime().maxDays(), null, // S-E: nothing to carry, fresh row + null, // REVIEW ROUND 3, item 2: nothing to carry either -- requiredByNote comes from the deal snapshot only. actor.id(), actor.name()); stored.replaceItems(id, group.items()); - if (derived != null) { - stored.setDerivedLeadTime(id, derived.minDays(), derived.maxDays()); + if (n.derived() != null) { + stored.setDerivedLeadTime(id, n.derived().minDays(), n.derived().maxDays()); } - created++; - } - if (created == 0) { - throw new ApiException(HttpStatus.CONFLICT, - "ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว"); + issueStoredDraft(requireStored(id), null, snapshot, actor); } return withPageCounts(stored.findByTicket(ticketId)); } @@ -546,6 +578,17 @@ public ImportRequestDto issue(long id, IssueImportRequestRequest request, UserPr String override = request == null || request.docNumber() == null || request.docNumber().isBlank() ? null : request.docNumber().strip(); + return issueStoredDraft(draft, override, dealSnapshot, actor); + } + + /** + * The write half of {@link #issue}: everything AFTER the issuability checks (status, items, lead + * time, payment gate). Shared with {@link #createDrafts}, which pre-checks every new factory and + * then issues each freshly inserted row through here, so the two paths cannot drift. + */ + private ImportRequestDto issueStoredDraft(ImportRequestDto draft, String override, + TicketSnapshot dealSnapshot, UserPrincipal actor) { + long id = draft.id(); // An override does NOT advance the sequence. A number typed ahead of the counter therefore // cannot be minted a second time later — ux_import_request_doc_number refuses the duplicate — // and this pre-check turns that race into a clear message instead of a constraint violation. @@ -1105,6 +1148,29 @@ public void setRequiredByNote(long ticketId, SetRequiredByNoteRequest request, U private record FactoryGroup(long factoryId, String factoryName, String countryCode, String brandLabel, List items) {} + /** 409 prefix the panel parses to open its lead-time popup (mirrors the missing-country prefix). */ + public static final String MISSING_LEAD_TIME_PREFIX = "กรุณาระบุระยะเวลานำเข้าให้กับโรงงาน: "; + + /** Supplied per-factory lead times keyed by normalized name; same bounds as setLeadTime, else 400. */ + private static Map validatedLeadTimes( + CreateImportRequestsRequest request) { + Map out = new LinkedHashMap<>(); + if (request == null || request.leadTimes() == null) { + return out; + } + for (var in : request.leadTimes()) { + if (in == null || in.factoryName() == null || in.factoryName().isBlank() + || in.minDays() == null || in.maxDays() == null + || in.minDays() < 1 || in.maxDays() > 365 || in.minDays() > in.maxDays()) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "ระยะเวลานำเข้าต้องเป็น 1–365 วัน และขั้นต่ำต้องไม่เกินขั้นสูง" + + (in == null || in.factoryName() == null ? "" : " (โรงงาน " + in.factoryName() + ")")); + } + out.put(normalizeFactoryName(in.factoryName()), in); + } + return out; + } + /** A caller-supplied country for a factory about to be auto-created, already validated. */ private record ValidatedCountry(String code, String other) {} diff --git a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java index a3024f289..1cb8e3979 100644 --- a/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/dealquotation/DealQuotationConfirmOrderIntegrationTest.java @@ -882,11 +882,14 @@ void confirmOrder_onCatalogueLines_copiesCatalogPriceIdAndFactoryToEveryTicketIt } @Test - void confirmOrder_onCatalogueLines_letsImportCreateThePerFactoryIrDraft_insteadOf409() { + void confirmOrder_onCatalogueLines_letsSalesCreateAndIssueThePerFactoryIr_insteadOf409() { long priceB = insertCatalogProduct("W3 Factory B", "IT", "W3-B", new BigDecimal("10"), "EUR", "per_sqm"); long ticketId = pipelineTicket(salesActor); DealQuotationDto approved = approvedQuotation(ticketId, catalogueLineDraft(priceB, "Line B")); confirmer.confirmOrderFromDirectQuotation(approved.id(), salesActor); + // create also issues now, so the payment gate applies: the deposit has been received. + jdbc.update("UPDATE sales.ticket SET payment_status = 'DEPOSIT_PAID' WHERE ticket_id = :id", + Map.of("id", ticketId)); th.co.glr.hr.importrequest.ImportRequestService importRequests = new th.co.glr.hr.importrequest.ImportRequestService( new th.co.glr.hr.importrequest.ImportRequestQueryRepository(jdbc), @@ -899,6 +902,7 @@ void confirmOrder_onCatalogueLines_letsImportCreateThePerFactoryIrDraft_insteadO assertThat(drafts).hasSize(1); assertThat(drafts.get(0).factoryName()).isEqualTo("W3 Factory B"); + assertThat(drafts.get(0).status()).isEqualTo("ISSUED"); } /** Guard (green before and after): a free-text line has no catalogue item, so it keeps factory NULL. */ diff --git a/backend/src/test/java/th/co/glr/hr/importdeal/ImportDealAuthzIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importdeal/ImportDealAuthzIntegrationTest.java index af1e736c4..880d1440a 100644 --- a/backend/src/test/java/th/co/glr/hr/importdeal/ImportDealAuthzIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importdeal/ImportDealAuthzIntegrationTest.java @@ -347,8 +347,8 @@ void importCannotWriteDelivery() { // ── fixtures ────────────────────────────────────────────────────────────────────────────────── private ImportRequestDto issueOneImportRequest() { - List drafts = importRequestService.createDrafts(ticketId, null, ceoUser); - return importRequestService.issue(drafts.get(0).id(), null, ceoUser); + // createDrafts creates AND issues (10-29), so the created row is already ISSUED. + return importRequestService.createDrafts(ticketId, null, ceoUser).get(0); } private long firstItemId() { diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/DraftSeed.java b/backend/src/test/java/th/co/glr/hr/importrequest/DraftSeed.java new file mode 100644 index 000000000..76fa6cbc6 --- /dev/null +++ b/backend/src/test/java/th/co/glr/hr/importrequest/DraftSeed.java @@ -0,0 +1,53 @@ +package th.co.glr.hr.importrequest; + +import java.util.List; +import java.util.Map; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import th.co.glr.hr.auth.UserPrincipal; +import th.co.glr.hr.importrequest.ImportRequestDtos.ImportRequestDto; + +/** + * Test-only seed for leftover DRAFT rows. Since the 10-29 ruling {@code createDrafts} creates AND + * ISSUES, so tests of the DRAFT lifecycle (edit, delete, first-issue checks) need a DRAFT that the + * service no longer produces. This writes one per factory of the deal's lines (matched by + * {@code ticket_item.factory} name) straight through the repository, with the country lead-time + * default, exactly as the old create did. Skips factories that already have a live form. + */ +final class DraftSeed { + + private DraftSeed() { } + + static List seed(NamedParameterJdbcTemplate jdbc, long ticketId, UserPrincipal actor) { + ImportRequestRepository stored = new ImportRequestRepository(jdbc); + var snapshot = new ImportRequestQueryRepository(jdbc).loadTicketSnapshot(ticketId).orElseThrow(); + List> factories = jdbc.queryForList(""" + SELECT f.factory_id, f.name, f.country, + string_agg(DISTINCT ti.brand, ', ') AS brands, min(ti.sort_order) AS first_sort + FROM sales.ticket_item ti + JOIN price_catalog.factories f ON f.name = ti.factory + WHERE ti.ticket_id = :t + GROUP BY f.factory_id, f.name, f.country + ORDER BY min(ti.sort_order) + """, Map.of("t", ticketId)); + for (Map f : factories) { + long factoryId = ((Number) f.get("factory_id")).longValue(); + if (stored.findByTicket(ticketId).stream().anyMatch(r -> r.factoryId() == factoryId + && ImportRequestStatus.isLive(r.status()))) { + continue; + } + var lead = LeadTimeDefaults.forCountry((String) f.get("country")); + long id = stored.insertDraft(ticketId, factoryId, (String) f.get("name"), (String) f.get("brands"), + stored.highestVersion(ticketId, factoryId) + 1, snapshot, + lead == null ? null : lead.minDays(), lead == null ? null : lead.maxDays(), null, null, + actor.id(), actor.name()); + jdbc.update(""" + INSERT INTO sales.import_request_item + (import_request_id, ticket_item_id, seq, code, size, qty, unit, brand, model) + SELECT :id, ti.item_id, row_number() OVER (ORDER BY ti.sort_order), ti.model, ti.size, + ti.qty, ti.unit, ti.brand, ti.model + FROM sales.ticket_item ti WHERE ti.ticket_id = :t AND ti.factory = :name + """, Map.of("id", id, "t", ticketId, "name", f.get("name"))); + } + return stored.findByTicket(ticketId); + } +} diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java index dc2813c0b..7fe00496c 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestEmailDraftIntegrationTest.java @@ -90,7 +90,7 @@ void issue_generatesAnEmailDraft_toTheFactorysAddress_withNoPricingContent() { insertFactory("Email Factory", "IT", "sales@emailfactory.example"); long ticketId = insertTicket("EM-ISSUE"); insertItem(ticketId, "Email Factory", "Lithos Nero", "60x60 cm", "100", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issued = service.issue(id, null, owner); @@ -141,9 +141,9 @@ void issue_bodyUsesTheNumberedBlockLayout_skippingBlankFieldsAndNeverPrintingNul "60x60 cm", "308.000", "pcs", 0, "Grigio", "Matte"); insertItemWithBrandModelColor(ticketId, "Terrazzo Co", "Block Layout Factory", "Terrazzo White", "30x60 cm", "10", "pcs", 1, null, null); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); - - ImportRequestDto issued = service.issue(id, null, owner); + // Real create (it now issues): these two pin the line prefill (colour/surface, catalog code) + // that only the service's own resolution produces, which a seeded row would not carry. + ImportRequestDto issued = service.createDrafts(ticketId, null, owner).get(0); String body = issued.emailBody(); assertThat(body).contains("1. Living Ceramics Lithos Nero") @@ -177,7 +177,7 @@ void issue_bodyKeepsABusinessTypedNote_whenItDiffersFromTheAutoDerivedOne() { // Business hand-edits the auto-derived note to something else before drafts are raised is not // reachable pre-createDrafts in this flow (the note is only ever set AT creation), so this // exercises the draft-stage item edit via update() instead. - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto draft = service.get(id, owner); java.util.List edited = draft.items().stream() .map(it -> new ImportRequestRequests.ImportRequestItemInput(it.ticketItemId(), it.code(), @@ -209,9 +209,9 @@ void issue_bodyShowsTheCodeSuffix_whenItGenuinelyDiffersFromTheModel() { .addValue("model", "Lithos Nero Hand-Typed").addValue("size", "60x60 cm") .addValue("qty", new BigDecimal("10")).addValue("unit", "pcs") .addValue("catalogPriceId", catalogPriceId)); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); - - ImportRequestDto issued = service.issue(id, null, owner); + // Real create (it now issues): these two pin the line prefill (colour/surface, catalog code) + // that only the service's own resolution produces, which a seeded row would not carry. + ImportRequestDto issued = service.createDrafts(ticketId, null, owner).get(0); assertThat(issued.emailBody()) .contains("1. Living Ceramics Lithos Nero Hand-Typed (Code: CAT-CODE-777)"); } @@ -227,7 +227,7 @@ void issue_attachedFilename_matchesTheDownloadRoutesOwnFilenameHelper() { insertFactory("Filename Tie Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-FILENAME-TIE"); insertItem(ticketId, "Filename Tie Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issued = service.issue(id, null, owner); String expectedFilename = ImportRequestService.downloadFileName(issued.docNumber(), issued.id(), @@ -249,7 +249,7 @@ void issue_emailRequiredByLine_matchesWhatActuallyGetsSnapshottedOntoTheForm() { insertItem(ticketId, "Required By Consistency Factory", "Line", "60x60", "10", "pcs", 0); // The draft is raised BEFORE the deal-level value is set, so the draft's OWN requiredByNote // starts (and stays) blank until #issue's re-snapshot fallback fires. - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThat(service.get(id, owner).requiredByNote()).isNull(); service.setRequiredByNote(ticketId, new SetRequiredByNoteRequest("Within 30 days"), owner); @@ -270,7 +270,7 @@ void issue_emailSubjectAndBody_neverNameTheCustomerOrTheProject() { insertFactory("Privacy Factory", "IT", "sales@privacyfactory.example"); long ticketId = insertTicket("EM-PRIVACY"); insertItem(ticketId, "Privacy Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issued = service.issue(id, null, owner); @@ -289,7 +289,7 @@ void issue_withNoFactoryEmailOnFile_leavesEmailToNull_butStillDraftsTheBody() { insertFactory("No Email Factory", "IT", null); long ticketId = insertTicket("EM-NOEMAIL"); insertItem(ticketId, "No Email Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issued = service.issue(id, null, owner); @@ -303,7 +303,7 @@ void revision_regeneratesTheEmailDraft() { insertFactory("Revision Email Factory", "IT", "old@example.com"); long ticketId = insertTicket("EM-REVISE"); insertItem(ticketId, "Revision Email Factory", "Line A", "60x60", "10", "pcs", 0); - long v1 = service.createDrafts(ticketId, null, owner).get(0).id(); + long v1 = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issuedV1 = service.issue(v1, null, owner); assertThat(issuedV1.emailBody()).contains("Line A"); @@ -328,7 +328,7 @@ void setLeadTime_neverClobbersAHandEditedUnsentEmailBody() { insertFactory("Hand Edit Preserve Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-HANDEDIT-PRESERVE"); insertItem(ticketId, "Hand Edit Preserve Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); ImportRequestDto handEdited = service.updateEmailDraft(id, new UpdateEmailDraftRequest( @@ -352,7 +352,7 @@ void setLeadTime_regeneratesAnUneditedEmail_butAlwaysSignsAsTheOriginalIssuer_no insertFactory("Original Signer Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-ORIG-SIGNER"); insertItem(ticketId, "Original Signer Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issued = service.issue(id, null, owner); assertThat(issued.emailBody()).contains(owner.email()); @@ -374,7 +374,7 @@ void emailDraft_isEditableByOwningRepCeoOrImport_notByANonOwningRep() { insertFactory("Edit Email Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-EDIT"); insertItem(ticketId, "Edit Email Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); ImportRequestDto afterOwner = service.updateEmailDraft(id, @@ -406,7 +406,7 @@ void emailDraft_isNotEditableOnceMarkedSent() { insertFactory("Sent Email Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-SENT-EDIT"); insertItem(ticketId, "Sent Email Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); service.markEmailSent(id, importUser); @@ -431,7 +431,7 @@ void updateEmailDraftSql_refusesADraftRow_directlyAtTheRepository() { insertFactory("Repo Guard Draft Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-REPO-DRAFT"); insertItem(ticketId, "Repo Guard Draft Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); // never issued + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); // never issued ImportRequestRepository repo = new ImportRequestRepository(jdbc); assertThat(repo.updateEmailDraft(id, "sneaky@example.com", null, null)).isZero(); @@ -445,7 +445,7 @@ void updateEmailDraftSql_refusesAnAlreadySentRow_directlyAtTheRepository() { insertFactory("Repo Guard Sent Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-REPO-SENT"); insertItem(ticketId, "Repo Guard Sent Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); service.markEmailSent(id, importUser); @@ -461,7 +461,7 @@ void markEmailSentSql_refusesADraftRow_directlyAtTheRepository() { insertFactory("Repo Guard MarkSent Draft Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-REPO-MARK-DRAFT"); insertItem(ticketId, "Repo Guard MarkSent Draft Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); // never issued + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); // never issued ImportRequestRepository repo = new ImportRequestRepository(jdbc); assertThat(repo.markEmailSent(id, importUser.id(), importUser.name())).isZero(); @@ -479,7 +479,7 @@ void markEmailSent_isAllowedToImportCeoAndOwningRep_notToANonOwningRepOrSalesMan insertItem(ticketId, "Mark Sent Factory", "Line", "60x60", "10", "pcs", 0); // Non-owning rep and sales_manager: refused. - long draftForOther = service.createDrafts(ticketId, null, owner).get(0).id(); + long draftForOther = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(draftForOther, null, owner); assertThatThrownBy(() -> service.markEmailSent(draftForOther, otherSalesRep)) .isInstanceOfSatisfying(ApiException.class, @@ -505,7 +505,7 @@ void markEmailSent_allowedToTheOwningRep_andToCeo() { insertFactory("Mark Sent Factory 2", "IT", "a@example.com"); long ticketId = insertTicket("EM-MARK2"); insertItem(ticketId, "Mark Sent Factory 2", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); ImportRequestDto afterOwner = service.markEmailSent(id, owner); @@ -517,7 +517,7 @@ void markEmailSent_recordsATicketEvent() { insertFactory("Event Factory", "IT", "a@example.com"); long ticketId = insertTicket("EM-EVENT"); insertItem(ticketId, "Event Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); service.markEmailSent(id, importUser); diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java index 61a16d49d..ab22a0105 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestFactoryResolutionIntegrationTest.java @@ -250,14 +250,21 @@ void aNewFactoryWithZzAndCountryOther_createsARowWithCountryOtherSet() { long ticketId = insertTicket("RESOLV-3-ZZ-TEXT"); insertItem(ticketId, "AnyBrand", "ZZ With Text Line", "60x60", "10", "pcs", 0, "โรงงานลึกลับ2"); - List drafts = service.createDrafts(ticketId, + // 10-29: create now ISSUES, and 'ZZ' has no lead-time default, so the call is refused (409, + // nothing created). The factory row is still auto-created before that refusal; in production the + // @Transactional rolls it back, but this hand-wired service has no tx proxy, so it is visible + // here and is what pins country/country_other on the auto-create path. + assertThatThrownBy(() -> service.createDrafts(ticketId, new CreateImportRequestsRequest(null, List.of(new NewFactoryCountryInput("โรงงานลึกลับ2", "ZZ", "Farawaystan"))), - owner); + owner)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + assertThat(service.list(ticketId, owner)).isEmpty(); Map row = jdbc.queryForMap(""" - SELECT country, country_other FROM price_catalog.factories WHERE factory_id = :id - """, Map.of("id", drafts.get(0).factoryId())); + SELECT country, country_other FROM price_catalog.factories WHERE name = :n + """, Map.of("n", "โรงงานลึกลับ2")); assertThat(row.get("country")).isEqualTo("ZZ"); assertThat(row.get("country_other")).isEqualTo("Farawaystan"); } @@ -325,10 +332,7 @@ void twoFactoriesSharingABrand_bothIssueIndependently() { .containsExactlyInAnyOrder(factoryA, factoryB); assertThat(drafts).extracting(ImportRequestDto::brand) .containsExactly("SharedBrand", "SharedBrand"); - - for (ImportRequestDto d : drafts) { - assertThat(service.issue(d.id(), null, owner).status()).isEqualTo(ImportRequestStatus.ISSUED); - } + assertThat(drafts).allSatisfy(d -> assertThat(d.status()).isEqualTo(ImportRequestStatus.ISSUED)); } // ── F-SM-001 line prefill (owner decision 09-18 #3 §A) ────────────────────────────────────── @@ -406,9 +410,6 @@ void rollup_firesOnlyOnceEveryFactoryIsReceived_notBefore() { insertItem(ticketId, "Brand", "Line B", "60x60", "10", "pcs", 1, "Rollup Factory B"); List drafts = service.createDrafts(ticketId, null, owner); - for (ImportRequestDto d : drafts) { - service.issue(d.id(), null, owner); - } assertThat(fulfillmentStatus(ticketId)).isEqualTo(FulfilmentStatus.IR_ISSUED); ImportRequestDto a = drafts.stream().filter(d -> "Rollup Factory A".equals(d.factoryName())) @@ -439,7 +440,6 @@ void rollup_onAMixedPartiallyDeliveredDeal_writesOnlyTheEvent_neverTouchesStatus insertItem(ticketId, "Brand", "Import Line", "60x60", "10", "pcs", 0, "Mixed Deal Factory"); long irId = service.createDrafts(ticketId, null, owner).get(0).id(); - service.issue(irId, null, owner); assertThat(fulfillmentStatus(ticketId)).isEqualTo(FulfilmentStatus.IR_ISSUED); // Simulate: while this factory's goods were still in transit, the deal's stock-sourced lines @@ -463,7 +463,7 @@ void legacyMarkIrSent_isRefusedOnceTheDealIsTrackedPerFactory() { insertFactory("Legacy Refusal Factory", "IT", null); long ticketId = insertTicket("RESOLV-7"); insertItem(ticketId, "Brand", "Line", "60x60", "10", "pcs", 0, "Legacy Refusal Factory"); - service.issue(service.createDrafts(ticketId, null, owner).get(0).id(), null, owner); + service.createDrafts(ticketId, null, owner); assertThat(tickets.hasLiveImportRequests(ticketId)).isTrue(); assertThatThrownBy(() -> ticketService.markIrSent(ticketId, importUser)) @@ -478,7 +478,7 @@ void legacyIssueImportRequest_isRefusedOnceTheDealIsTrackedPerFactory() { insertFactory("Legacy Issue Refusal Factory", "IT", null); long ticketId = insertTicket("RESOLV-8"); insertItem(ticketId, "Brand", "Line", "60x60", "10", "pcs", 0, "Legacy Issue Refusal Factory"); - service.issue(service.createDrafts(ticketId, null, owner).get(0).id(), null, owner); + service.createDrafts(ticketId, null, owner); // The legacy one-shot action itself 409s once fulfillment_status is already IR_ISSUED — // recordFirstImportRequestIssued sets exactly that, so the deal cannot tell which path diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeFromPricingRequestIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeFromPricingRequestIntegrationTest.java index a169a23cb..58abdc213 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeFromPricingRequestIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeFromPricingRequestIntegrationTest.java @@ -413,7 +413,6 @@ private long[] issuedPrRouteIr(String tag, Integer min, Integer max) { if (min != null) { assertThat(v1.leadTimeMinDays()).isEqualTo(min); } - service.issue(v1.id(), null, owner); return new long[] {v1.id(), pr, q}; } diff --git a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeIntegrationTest.java b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeIntegrationTest.java index 467b1c468..23f740531 100644 --- a/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeIntegrationTest.java +++ b/backend/src/test/java/th/co/glr/hr/importrequest/ImportRequestLeadTimeIntegrationTest.java @@ -97,25 +97,123 @@ void draftAutofillsLeadTimeFromTheFactorysCountry_th() { assertAutofill("TH", 3, 7); } - /** 'ZZ' (อื่นๆ) and any other unmapped code get NO default — the rep must type it in. */ + /** + * 'ZZ' (อื่นๆ) and any other unmapped code get NO default. Create now ISSUES, so with no lead time + * it is refused outright (409 naming the factory) and writes nothing — the rep must type it in on + * the factory first / the deal's quotation. + */ + @Test + void createDrafts_isRefusedNamingTheFactory_forZzAndUnmappedCodes() { + insertFactory("ZZ Factory", "ZZ", "Farawaystan"); + insertFactory("Unmapped Factory", "PL", null); + + for (String name : new String[] {"ZZ Factory", "Unmapped Factory"}) { + long ticket = insertTicket("LT-NULL-" + name.charAt(0)); + insertItem(ticket, name, "Line", 0); + assertThatThrownBy(() -> service.createDrafts(ticket, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains(name); + }); + assertThat(service.list(ticket, owner)).isEmpty(); + } + } + + // ── 10-29 ruling B: lead-time popup contract (missing lead time is askable, not a dead end) ── + + private static final String MISSING_LEAD_TIME_PREFIX = "กรุณาระบุระยะเวลานำเข้าให้กับโรงงาน: "; + + private long dealWithThreeFactories() { + insertFactory("LT Good Factory", "IT", null); + insertFactory("LT Missing PL", "PL", null); + insertFactory("LT Missing ZZ", "ZZ", "Farawaystan"); + long ticket = insertTicket("LT-B-" + System.nanoTime()); + insertItem(ticket, "LT Good Factory", "Line G", 0); + insertItem(ticket, "LT Missing PL", "Line P", 1); + insertItem(ticket, "LT Missing ZZ", "Line Z", 2); + return ticket; + } + + private static ImportRequestRequests.CreateImportRequestsRequest withLeadTimes( + ImportRequestRequests.NewFactoryLeadTimeInput... inputs) { + return new ImportRequestRequests.CreateImportRequestsRequest(null, null, null, List.of(inputs)); + } + + @Test + void createDrafts_409ListsOnlyTheFactoriesWithNoLeadTime_andWritesNothing() { + long ticket = dealWithThreeFactories(); + + assertThatThrownBy(() -> service.createDrafts(ticket, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).startsWith(MISSING_LEAD_TIME_PREFIX) + .contains("LT Missing PL").contains("LT Missing ZZ").doesNotContain("LT Good Factory"); + }); + assertThat(service.list(ticket, owner)).isEmpty(); + } + @Test - void draftLeavesLeadTimeNull_forZzAndUnmappedCodes() { - long zzFactory = insertFactory("ZZ Factory", "ZZ", "Farawaystan"); - long unmappedFactory = insertFactory("Unmapped Factory", "PL", null); - - long ticketZz = insertTicket("LT-ZZ"); - insertItem(ticketZz, "ZZ Factory", "Line", 0); - ImportRequestDto zzDraft = service.createDrafts(ticketZz, null, owner).get(0); - assertThat(zzDraft.leadTimeMinDays()).isNull(); - assertThat(zzDraft.leadTimeMaxDays()).isNull(); - - long ticketOther = insertTicket("LT-PL"); - insertItem(ticketOther, "Unmapped Factory", "Line", 0); - ImportRequestDto otherDraft = service.createDrafts(ticketOther, null, owner).get(0); - assertThat(otherDraft.leadTimeMinDays()).isNull(); - assertThat(otherDraft.leadTimeMaxDays()).isNull(); - - assertThat(zzFactory).isNotEqualTo(unmappedFactory); // sanity: two distinct rows used + void createDrafts_withOnlySomeLeadTimesSupplied_409ListsTheRest_andWritesNothing() { + long ticket = dealWithThreeFactories(); + + assertThatThrownBy(() -> service.createDrafts(ticket, + withLeadTimes(new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", 20, 30)), owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).startsWith(MISSING_LEAD_TIME_PREFIX) + .contains("LT Missing ZZ").doesNotContain("LT Missing PL").doesNotContain("LT Good Factory"); + }); + assertThat(service.list(ticket, owner)).isEmpty(); + } + + @Test + void createDrafts_withASuppliedLeadTime_issuesEveryRow_usingIt_andKeepsTheDefaultsElsewhere() { + long ticket = dealWithThreeFactories(); + + var holder = new java.util.concurrent.atomic.AtomicReference>(List.of()); + org.assertj.core.api.Assertions.assertThatCode(() -> holder.set(service.createDrafts(ticket, + withLeadTimes(new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", 20, 30), + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing ZZ", 5, 9)), owner))) + .doesNotThrowAnyException(); + + assertThat(holder.get()).hasSize(3).allSatisfy(r -> assertThat(r.status()).isEqualTo(ImportRequestStatus.ISSUED)); + assertThat(byName(holder.get(), "LT Missing PL").leadTimeMinDays()).isEqualTo(20); + assertThat(byName(holder.get(), "LT Missing PL").leadTimeMaxDays()).isEqualTo(30); + assertThat(byName(holder.get(), "LT Missing ZZ").leadTimeMinDays()).isEqualTo(5); + assertThat(byName(holder.get(), "LT Good Factory").leadTimeMinDays()).isEqualTo(75); // IT default + } + + @Test + void createDrafts_withABadSuppliedLeadTime_is400_andWritesNothing() { + long ticket = dealWithThreeFactories(); + var ok = new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing ZZ", 5, 9); + for (var bad : List.of( + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", 0, 30), // min < 1 + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", 40, 30), // min > max + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", 10, 366), // max > 365 + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", null, 30))) { // absent + assertThatThrownBy(() -> service.createDrafts(ticket, withLeadTimes(bad, ok), owner)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST)); + assertThat(service.list(ticket, owner)).isEmpty(); + } + } + + @Test + void createDrafts_ignoresASuppliedLeadTime_forAFactoryThatAlreadyHasADefault() { + long ticket = dealWithThreeFactories(); + + List created = service.createDrafts(ticket, + withLeadTimes(new ImportRequestRequests.NewFactoryLeadTimeInput("LT Good Factory", 1, 2), + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing PL", 20, 30), + new ImportRequestRequests.NewFactoryLeadTimeInput("LT Missing ZZ", 5, 9)), owner); + + assertThat(byName(created, "LT Good Factory").leadTimeMinDays()).isEqualTo(75); + assertThat(byName(created, "LT Good Factory").leadTimeMaxDays()).isEqualTo(90); + } + + private static ImportRequestDto byName(List rows, String name) { + return rows.stream().filter(r -> name.equals(r.factoryName())).findFirst().orElseThrow(); } private void assertAutofill(String isoCode, int expectedMin, int expectedMax) { @@ -135,7 +233,7 @@ void issue_isRefused_whileLeadTimeIsUnset() { insertFactory("No Default Factory", "PL", null); long ticketId = insertTicket("LT-ISSUE-NULL"); insertItem(ticketId, "No Default Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThatThrownBy(() -> service.issue(id, null, owner)) .isInstanceOfSatisfying(ApiException.class, @@ -154,7 +252,7 @@ void draftLeadTime_isEditableByTheOwningRepOrCeo_notByANonOwningRepOrImport() { insertFactory("Editable Draft Factory", "PL", null); long ticketId = insertTicket("LT-DRAFT-EDIT"); insertItem(ticketId, "Editable Draft Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); // Owning rep: allowed. ImportRequestDto afterOwner = service.update(id, new UpdateImportRequestRequest( @@ -183,7 +281,7 @@ void issuedLeadTime_isEditableByImportOrCeo_notBySales() { insertFactory("Editable Issued Factory", "IT", null); // autofilled 75-90, satisfies issue gate long ticketId = insertTicket("LT-ISSUED-EDIT"); insertItem(ticketId, "Editable Issued Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); // Import: allowed. @@ -209,7 +307,7 @@ void setLeadTime_refusesAMinGreaterThanMax() { insertFactory("Bad Range Factory", "IT", null); long ticketId = insertTicket("LT-BADRANGE"); insertItem(ticketId, "Bad Range Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); assertThatThrownBy(() -> service.setLeadTime(id, new SetLeadTimeRequest(50, 10), importUser)) @@ -222,7 +320,7 @@ void draftUpdate_refusesOneFieldWithoutTheOther() { insertFactory("Partial Field Factory", "IT", null); long ticketId = insertTicket("LT-PARTIAL"); insertItem(ticketId, "Partial Field Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThatThrownBy(() -> service.update(id, new UpdateImportRequestRequest( null, null, null, null, null, null, null, null, null, 30, null, null), owner)) @@ -237,7 +335,7 @@ void revision_carriesLeadTimeForward_notReautofilled() { insertFactory("Revision Factory", "IT", null); // would autofill 75-90 long ticketId = insertTicket("LT-REVISE"); insertItem(ticketId, "Revision Factory", "Line", 0); - long v1 = service.createDrafts(ticketId, null, owner).get(0).id(); + long v1 = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); // Import/CEO hand-refine the estimate AFTER issue. service.issue(v1, null, owner); service.setLeadTime(v1, new SetLeadTimeRequest(60, 65), importUser); @@ -261,7 +359,7 @@ void setLeadTime_reDerivesTheAutoVesselEtaNote_andRegeneratesTheUnsentEmailBody( insertFactory("ETA Redrive Factory", "IT", null); // autofills 75-90 long ticketId = insertTicket("LT-ETA-REDERIVE"); insertItem(ticketId, "ETA Redrive Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issued = service.issue(id, null, owner); String autoNoteFor75to90 = issued.vesselEtaNote(); assertThat(autoNoteFor75to90).isNotBlank(); @@ -283,7 +381,7 @@ void setLeadTime_neverOverwritesACeoTypedCustomVesselEtaNote() { insertFactory("ETA Custom Factory", "IT", null); long ticketId = insertTicket("LT-ETA-CUSTOM"); insertItem(ticketId, "ETA Custom Factory", "Line", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(id, null, owner); ImportRequestDto ceoTyped = service.update(id, new UpdateImportRequestRequest( null, null, null, "Vessel confirmed by phone, mid-December", null, null, null, null, @@ -305,7 +403,7 @@ void revision_carriesVesselEtaNoteForward_andReDerivesOnlyIfStillAutoDerived() { insertFactory("ETA Revise Factory", "IT", null); // autofills 75-90 long ticketId = insertTicket("LT-ETA-REVISE"); insertItem(ticketId, "ETA Revise Factory", "Line", 0); - long v1 = service.createDrafts(ticketId, null, owner).get(0).id(); + long v1 = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issuedV1 = service.issue(v1, null, owner); assertThat(issuedV1.vesselEtaNote()).isNotBlank(); @@ -333,7 +431,7 @@ void revision_thenEditingLeadTime_thenIssue_showsTheNewRange_notThePredecessors( insertFactory("ETA Revise Then Edit Factory", "IT", null); // autofills 75-90 long ticketId = insertTicket("LT-ETA-REVISE-EDIT"); insertItem(ticketId, "ETA Revise Then Edit Factory", "Line", 0); - long v1 = service.createDrafts(ticketId, null, owner).get(0).id(); + long v1 = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); ImportRequestDto issuedV1 = service.issue(v1, null, owner); assertThat(issuedV1.vesselEtaNote()).contains("75").contains("90"); @@ -363,7 +461,7 @@ void revision_carriesACeoTypedCustomVesselEtaNoteForward_untouched() { insertFactory("ETA Revise Custom Factory", "IT", null); long ticketId = insertTicket("LT-ETA-REVISE-CUSTOM"); insertItem(ticketId, "ETA Revise Custom Factory", "Line", 0); - long v1 = service.createDrafts(ticketId, null, owner).get(0).id(); + long v1 = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); service.issue(v1, null, owner); service.update(v1, new UpdateImportRequestRequest( null, null, null, "Confirmed by phone, ETA mid-Jan", null, null, null, null, @@ -381,7 +479,7 @@ void expectedArrival_isDerivedFromIssueDatePlusLeadTime_andNullUntilIssued() { insertFactory("Arrival Factory", "IT", null); long ticketId = insertTicket("LT-ARRIVAL"); insertItem(ticketId, "Arrival Factory", "Line", 0); - ImportRequestDto draft = service.createDrafts(ticketId, null, owner).get(0); + ImportRequestDto draft = DraftSeed.seed(jdbc, ticketId, owner).get(0); // Null while DRAFT — no issueDate yet even though lead time is already autofilled. assertThat(draft.expectedArrivalFrom()).isNull(); 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 e1ed5c503..9632bc23a 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 @@ -82,7 +82,7 @@ void secondFactorysFirstIssue_isAllowed_afterTheDealHasAdvancedToFullyPaid() { insertItem(ticketId, "Pay Gate Factory A", "Line A", "60x60", "10", "pcs", 0); insertItem(ticketId, "Pay Gate Factory B", "Line B", "60x60", "10", "pcs", 1); - List drafts = service.createDrafts(ticketId, null, owner); + List drafts = DraftSeed.seed(jdbc, ticketId, owner); long aId = byFactory(drafts, factoryA).id(); long bId = byFactory(drafts, factoryB).id(); assertThat(service.issue(aId, null, owner).status()).isEqualTo(ImportRequestStatus.ISSUED); @@ -99,7 +99,7 @@ void aRevisionsIssue_isAllowed_afterTheDealHasAdvancedToAwaitingFinalPayment() { insertFactory("Pay Gate Revise Factory"); long ticketId = insertTicket("GATE-AWAITING", "DEPOSIT_PAID"); insertItem(ticketId, "Pay Gate Revise Factory", "Line", "60x60", "10", "pcs", 0); - long v1 = service.createDrafts(ticketId, null, owner).get(0).id(); + long v1 = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThat(service.issue(v1, null, owner).status()).isEqualTo(ImportRequestStatus.ISSUED); setPaymentStatus(ticketId, "AWAITING_FINAL_PAYMENT"); @@ -117,13 +117,29 @@ void issue_isStillRefused_onADealWithNoDepositAtAll() { insertFactory("Pay Gate No Deposit Factory"); long ticketId = insertTicket("GATE-NODEPOSIT", null); insertItem(ticketId, "Pay Gate No Deposit Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThatThrownBy(() -> service.issue(id, null, owner)) .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); } + /** 10-29: the payment gate now applies at CREATE (create issues). Nothing is written when refused. */ + @Test + void createDrafts_isRefused409_andWritesNothing_onADealWithNoDepositYet() { + insertFactory("Pay Gate Create Factory"); + long ticketId = insertTicket("GATE-CREATE-NODEP", null); + insertItem(ticketId, "Pay Gate Create Factory", "Line", "60x60", "10", "pcs", 0); + + assertThatThrownBy(() -> service.createDrafts(ticketId, null, owner)) + .isInstanceOfSatisfying(ApiException.class, + e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)); + + assertThat(service.list(ticketId, owner)).isEmpty(); + assertThat(new TicketRepository(jdbc).findById(ticketId).orElseThrow().summary() + .fulfillmentStatus()).isNull(); + } + /** * Cheap nit (REVIEW ROUND 3): {@code CUSTOMER_CONFIRMED} on a deposit-REQUIRED policy (the * default — {@code insertTicket} never overrides {@code deposit_policy}, so this ticket carries @@ -139,7 +155,7 @@ void issue_isStillRefused_onCustomerConfirmed_whenDepositPolicyIsRequired() { insertFactory("Pay Gate Customer Confirmed Factory"); long ticketId = insertTicket("GATE-CUSTCONF", "CUSTOMER_CONFIRMED"); insertItem(ticketId, "Pay Gate Customer Confirmed Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThatThrownBy(() -> service.issue(id, null, owner)) .isInstanceOfSatisfying(ApiException.class, @@ -157,7 +173,7 @@ void issue_isAllowed_onADraftTicket_whenTheDepositIsPaid() { long ticketId = insertTicket("GATE-DRAFT-PAID", "DEPOSIT_PAID"); setTicketStatus(ticketId, "draft"); insertItem(ticketId, "Pay Gate Draft Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThat(service.issue(id, null, owner).status()).isEqualTo(ImportRequestStatus.ISSUED); } @@ -179,7 +195,7 @@ void issue_isStillRefused_onADraftTicket_withNoDeposit_orOnlyCustomerConfirmed() long ticketId = insertTicket("GATE-DR-" + (paymentStatus == null ? "NULL" : "CC"), paymentStatus); setTicketStatus(ticketId, "draft"); insertItem(ticketId, "Pay Gate Draft Refused Factory", "Line", "60x60", "10", "pcs", 0); - long id = service.createDrafts(ticketId, null, owner).get(0).id(); + long id = DraftSeed.seed(jdbc, ticketId, owner).get(0).id(); assertThatThrownBy(() -> service.issue(id, null, owner)) .isInstanceOfSatisfying(ApiException.class, 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 57e685fe8..ac1ed1399 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 @@ -147,13 +147,6 @@ void list_ordersByFactoryName_notByTheNullableBrandSnapshot() { void createDrafts_isOnePerFactory_andSkipsFactoriesAlreadyCovered() { List first = service.createDrafts(ticketId, null, owner); assertThat(first).hasSize(2); - assertThat(first).allSatisfy(r -> { - assertThat(r.status()).isEqualTo(ImportRequestStatus.DRAFT); - assertThat(r.docNumber()).isNull(); - assertThat(r.issueDate()).isNull(); - // DRAFT carries no progress step yet (chk_import_request_step_pairing). - assertThat(r.importStep()).isNull(); - }); assertThat(first).extracting(ImportRequestDto::factoryId) .containsExactlyInAnyOrder(padanaFactoryId, leaFactoryId); assertThat(byFactory(first, padanaFactoryId).items()).extracting(i -> i.code()) @@ -166,9 +159,46 @@ void createDrafts_isOnePerFactory_andSkipsFactoriesAlreadyCovered() { assertThat(service.list(ticketId, owner)).hasSize(2); } + // ── 10-29 ruling: createDrafts creates AND ISSUES, all-or-nothing ──────────────────────────── + + @Test + void createDrafts_issuesEveryFactoryInOneStep_andMovesTheDealToProcurement() { + List created = service.createDrafts(ticketId, null, owner); + + assertThat(created).hasSize(2); + assertThat(created).allSatisfy(r -> { + assertThat(r.status()).isEqualTo(ImportRequestStatus.ISSUED); + assertThat(r.docNumber()).matches("IR\\d{2}\\d{3}"); + assertThat(r.importStep()).isEqualTo(ImportRequestStep.CONTACTED); + }); + assertThat(created).extracting(ImportRequestDto::docNumber).doesNotHaveDuplicates(); + // The tab and ImportFulfilmentPage read this: the ticket must now carry a fulfilment status. + var summary = new TicketRepository(jdbc).findById(ticketId).orElseThrow().summary(); + assertThat(summary.fulfillmentStatus()).isEqualTo("IR_ISSUED"); + assertThat(summary.salesStage()).isEqualTo(DealStage.PROCUREMENT); + } + + @Test + void createDrafts_isAllOrNothing_whenOneNewFactoryHasNoLeadTime() { + // country PL has no LeadTimeDefaults entry -> null lead time -> cannot be issued. + jdbc.update("INSERT INTO price_catalog.factories (name, country, default_currency) " + + "VALUES ('No Lead Time Factory', 'PL', 'EUR')", Map.of()); + insertItem(ticketId, "No Lead Time Factory", "Line", "60x60 cm", "5", "pcs", 3); + + assertThatThrownBy(() -> service.createDrafts(ticketId, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains("No Lead Time Factory"); + }); + + assertThat(service.list(ticketId, owner)).isEmpty(); + assertThat(new TicketRepository(jdbc).findById(ticketId).orElseThrow().summary() + .fulfillmentStatus()).isNull(); + } + @Test void issue_mintsTheNumberAndFreezesTheDate_andSetsTheFirstStepToContacted() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); ImportRequestDto issued = service.issue(id, null, owner); @@ -187,7 +217,7 @@ void issue_mintsTheNumberAndFreezesTheDate_andSetsTheFirstStepToContacted() { @Test void issue_withAnOverride_doesNotBurnASequenceValue() { - List drafts = service.createDrafts(ticketId, null, owner); + List drafts = DraftSeed.seed(jdbc, ticketId, owner); int seqBefore = sequenceValue(); service.issue(byFactory(drafts, padanaFactoryId).id(), @@ -201,7 +231,7 @@ void issue_withAnOverride_doesNotBurnASequenceValue() { @Test void issue_withAnAlreadyUsedOverride_isRefused() { - List drafts = service.createDrafts(ticketId, null, owner); + List drafts = DraftSeed.seed(jdbc, ticketId, owner); service.issue(byFactory(drafts, padanaFactoryId).id(), new IssueImportRequestRequest("IR69068"), owner); @@ -218,7 +248,7 @@ void issue_withAnAlreadyUsedOverride_isRefused() { */ @Test void revise_leavesThePreviousVersionIssuedUntilTheReplacementIsIssued_andCarriesTheStepForward() { - long v1 = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long v1 = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(v1, null, owner); service.advanceStep(v1, new AdvanceImportStepRequest(ImportRequestStep.ORDERED, null, null), importUser); @@ -242,7 +272,7 @@ void revise_leavesThePreviousVersionIssuedUntilTheReplacementIsIssued_andCarries /** A revision cannot even be prepared once the predecessor has reached RECEIVED. */ @Test void revise_isRefusedOnceThePredecessorHasReceivedTheGoods() { - long v1 = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long v1 = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(v1, null, owner); for (String step : List.of(ImportRequestStep.ORDERED, ImportRequestStep.PICKED_UP, ImportRequestStep.IN_TRANSIT, ImportRequestStep.AWAITING_CUSTOMS, ImportRequestStep.RECEIVED)) { @@ -256,7 +286,7 @@ void revise_isRefusedOnceThePredecessorHasReceivedTheGoods() { @Test void issuedForm_bodyIsFrozenEvenToTheOwningRep() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, null, owner); // Body: refused to import outright — FORBIDDEN, not CONFLICT, because import is not even in @@ -285,7 +315,7 @@ void issuedForm_bodyIsFrozenEvenToTheOwningRep() { */ @Test void issuedFormFooter_isWritableByCeoOnly_ownerRepImportAndSalesManagerAllRefused() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); ImportRequestDto issued = service.issue(id, null, owner); // vesselEtaNote is auto-prefilled by the SYSTEM at issue (owner decision 09-18 #3 §A) — a // service-internal write, unaffected by 09-19's actor-role gate. Captured here so the @@ -320,7 +350,7 @@ void issuedFormFooter_isWritableByCeoOnly_ownerRepImportAndSalesManagerAllRefuse @Test void deleteDraft_removesADraftButNeverAnIssuedForm() { - List drafts = service.createDrafts(ticketId, null, owner); + List drafts = DraftSeed.seed(jdbc, ticketId, owner); long lea = byFactory(drafts, leaFactoryId).id(); long padana = byFactory(drafts, padanaFactoryId).id(); service.issue(padana, null, owner); @@ -336,7 +366,7 @@ void deleteDraft_removesADraftButNeverAnIssuedForm() { @Test void aStoredFormRendersFromItsOwnSnapshot_notFromTheDeal() throws Exception { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, new IssueImportRequestRequest("IR69068"), owner); jdbc.update("UPDATE sales.ticket SET customer_name = 'ลูกค้าใหม่' WHERE ticket_id = :id", @@ -354,7 +384,7 @@ void aStoredFormRendersFromItsOwnSnapshot_notFromTheDeal() throws Exception { */ @Test void aStoredFormsPrintedHeader_namesItsOwnFactory() throws Exception { - long padanaId = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long padanaId = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(padanaId, new IssueImportRequestRequest("IR-S-B-1"), owner); String text = textOf(service.renderStored(padanaId, importUser)); @@ -370,7 +400,7 @@ void aStoredFormsPrintedHeader_namesItsOwnFactory() throws Exception { */ @Test void factoryCopy_omitsCustomerNameAndDepositDate_internalCopyKeepsBoth() throws Exception { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, new IssueImportRequestRequest("IR-OWNER-0919-2"), owner); String internal = textOf(service.renderStored(id, importUser, false)); @@ -394,7 +424,7 @@ void factoryCopy_omitsCustomerNameAndDepositDate_internalCopyKeepsBoth() throws */ @Test void uniquePartialIndex_refusesASecondIssuedRowForTheSameTicketAndFactory_evenBypassingTheService() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, new IssueImportRequestRequest("IR-UNIQ-1"), owner); assertThatThrownBy(() -> jdbc.update(""" @@ -412,7 +442,7 @@ void uniquePartialIndex_refusesASecondIssuedRowForTheSameTicketAndFactory_evenBy @Test void advanceStep_forwardSkipsAllowed_butNeverBackwards() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, null, owner); // Skip ORDERED and PICKED_UP entirely — forward skips are allowed by owner decision. @@ -434,7 +464,7 @@ void advanceStep_forwardSkipsAllowed_butNeverBackwards() { @Test void advanceStep_isRefusedToSalesAndSalesManager_butAllowedToCeo() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, null, owner); assertThatThrownBy(() -> service.advanceStep(id, @@ -461,7 +491,7 @@ void nonOwningSalesRepCannotCreateIssueOrRevise() { e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); assertThat(stored.findByTicket(ticketId)).isEmpty(); - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); assertThatThrownBy(() -> service.issue(id, null, otherSalesRep)) .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); @@ -480,7 +510,7 @@ void importCannotCreateIssueOrRevise() { e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); assertThat(stored.findByTicket(ticketId)).isEmpty(); - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); assertThatThrownBy(() -> service.issue(id, null, importUser)) .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); @@ -509,7 +539,7 @@ void salesManagerIsReadOnly() { .isInstanceOfSatisfying(ApiException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, null, owner); assertThatThrownBy(() -> service.update(id, new UpdateImportRequestRequest( @@ -528,7 +558,7 @@ void salesManagerIsReadOnly() { @Test void ceoCanRaiseAndIssue() { - long id = byFactory(service.createDrafts(ticketId, null, ceoUser), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, ceoUser), padanaFactoryId).id(); assertThat(service.issue(id, null, ceoUser).status()).isEqualTo(ImportRequestStatus.ISSUED); } @@ -546,7 +576,7 @@ void ceoCanRaiseAndIssue() { void requiredByNote_isSetByTheOwningRep_andSnapshottedOntoTheFormAtCreation() { service.setRequiredByNote(ticketId, new SetRequiredByNoteRequest("Within 21/5/26"), owner); - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); assertThat(service.get(id, owner).requiredByNote()).isEqualTo("Within 21/5/26"); } @@ -557,7 +587,7 @@ void requiredByNote_isSetByTheOwningRep_andSnapshottedOntoTheFormAtCreation() { */ @Test void requiredByNote_isEditableOnTheDraftByOwningRepOrCeo_notByANonOwningRepOrImport() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); ImportRequestDto afterOwner = service.update(id, new UpdateImportRequestRequest( null, null, null, null, null, null, null, null, null, null, null, "Within 10 days"), owner); @@ -589,7 +619,7 @@ void requiredByNote_isReSnapshottedAtIssue_onlyWhenStillBlankOnTheDraft() { // ONE createDrafts call raises BOTH factories' drafts at once (setUp's ticket already has // items for both) — capture both ids from it rather than calling createDrafts a second // time, which would find every factory already covered and 409. - List drafts = service.createDrafts(ticketId, null, owner); + List drafts = DraftSeed.seed(jdbc, ticketId, owner); long blankId = byFactory(drafts, padanaFactoryId).id(); long typedId = byFactory(drafts, leaFactoryId).id(); assertThat(service.get(blankId, owner).requiredByNote()).isNull(); @@ -618,7 +648,7 @@ void requiredByNote_isReSnapshottedAtIssue_onlyWhenStillBlankOnTheDraft() { */ @Test void requiredByNote_typedOnTheFormBeforeItsFirstIssue_survivesARevision() { - long v1 = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long v1 = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.update(v1, new UpdateImportRequestRequest( null, null, null, null, null, null, null, null, null, null, null, "Before Songkran"), owner); @@ -642,7 +672,7 @@ void requiredByNote_typedOnTheFormBeforeItsFirstIssue_survivesARevision() { */ @Test void nonOwningSalesRepCannotReadListOrDownloadAnotherRepsIr() throws Exception { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, new IssueImportRequestRequest("IR-S3-READ"), owner); assertThatThrownBy(() -> service.get(id, otherSalesRep)) @@ -671,7 +701,7 @@ void nonOwningSalesRepCannotReadListOrDownloadAnotherRepsIr() throws Exception { */ @Test void importAndSalesManagerCannotDeleteDraft() { - long lea = byFactory(service.createDrafts(ticketId, null, owner), leaFactoryId).id(); + long lea = byFactory(DraftSeed.seed(jdbc, ticketId, owner), leaFactoryId).id(); assertThatThrownBy(() -> service.deleteDraft(lea, importUser)) .isInstanceOfSatisfying(ApiException.class, @@ -694,7 +724,7 @@ void importAndSalesManagerCannotDeleteDraft() { */ @Test void everyMutatingMethod_isRefusedOnceTheDealIsInactive() { - long padana = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long padana = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(padana, null, owner); long lea = byFactory(service.list(ticketId, owner), leaFactoryId).id(); // A THIRD, not-yet-covered factory, added before markLost — so the post-lost createDrafts @@ -760,7 +790,7 @@ void everyMutatingMethod_isRefusedOnceTheDealIsInactive() { */ @Test void revision_doesNotMoveTheArrivalAnchor() { - long v1 = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long v1 = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.update(v1, new UpdateImportRequestRequest( null, null, null, null, null, null, null, null, null, 10, 20, null), owner); ImportRequestDto issuedV1 = service.issue(v1, null, owner); @@ -782,7 +812,7 @@ void revision_doesNotMoveTheArrivalAnchor() { @Test void advanceStep_refusesAFutureEventDate_andADateBeforeTheFirstIssue() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); ImportRequestDto issued = service.issue(id, null, owner); assertThatThrownBy(() -> service.advanceStep(id, new AdvanceImportStepRequest( @@ -802,7 +832,7 @@ void advanceStep_refusesAFutureEventDate_andADateBeforeTheFirstIssue() { * not the raw uppercase code. */ @Test void advanceStep_eventMessageUsesThaiStepLabels() { - long id = byFactory(service.createDrafts(ticketId, null, owner), padanaFactoryId).id(); + long id = byFactory(DraftSeed.seed(jdbc, ticketId, owner), padanaFactoryId).id(); service.issue(id, null, owner); service.advanceStep(id, new AdvanceImportStepRequest(ImportRequestStep.ORDERED, null, "ทดสอบ"), importUser); diff --git a/frontend/e2e-real/helpers/sales.js b/frontend/e2e-real/helpers/sales.js index cd922f4df..3269a3e43 100644 --- a/frontend/e2e-real/helpers/sales.js +++ b/frontend/e2e-real/helpers/sales.js @@ -977,8 +977,8 @@ export async function waiveDeposit(sessions, ticketId, { policy = 'NOT_REQUIRED' } /** - * POST /api/tickets/{id}/import-requests — ImportRequestController.createDrafts: one DRAFT ใบขอซื้อ - * per FACTORY (V184). This is how every deal starts its import now: the one-click legacy + * POST /api/tickets/{id}/import-requests — ImportRequestController.createDrafts: one ใบขอซื้อ, + * created AND ISSUED in the same call (owner ruling 2026-10-29), per FACTORY (V184). This is how every deal starts its import now: the one-click legacy * `POST /api/tickets/{id}/import-request` (issueImportRequest) answers 409 for every deal (W3-04, * owner ruling 2026-10-29). Needs the deal's OWNING sales rep (or ceo) and DealStage >= ORDER_RECEIVED; * each line must resolve to a factory (PCR item factory, catalogue link or typed name), else 409 diff --git a/frontend/e2e-real/uat-sales-journeys.spec.js b/frontend/e2e-real/uat-sales-journeys.spec.js index 6ed4324c7..8b738f011 100644 --- a/frontend/e2e-real/uat-sales-journeys.spec.js +++ b/frontend/e2e-real/uat-sales-journeys.spec.js @@ -17,7 +17,6 @@ import { declareStockCoverage, expectNeverEntered, expectRoute, - issueImportRequestDraft, logActivity, readDeal, resolveFactory, @@ -171,12 +170,10 @@ test.describe('@uat-sales pipeline journeys — Cases A-D', () => { // it is written against the controllers (ImportRequestController) and has not been executed. let importRequests; await test.step('S10 -> S12 (-> PROCUREMENT): auto, the first per-factory ใบขอซื้อ is issued — S11 (DEPOSIT_RECEIVED) is a real, structural skip here, not an oversight', async () => { - const drafts = await createImportRequestDrafts(sessions, deal.id); - expect(drafts.length, 'one DRAFT per factory').toBeGreaterThan(0); - importRequests = []; - for (const draft of drafts) { - importRequests.push(await issueImportRequestDraft(sessions, draft.id)); - } + // Create ISSUES one ใบขอซื้อ per factory in the same call (10-29): nothing left to issue. + importRequests = await createImportRequestDrafts(sessions, deal.id); + expect(importRequests.length, 'one ISSUED ใบขอซื้อ per factory').toBeGreaterThan(0); + for (const ir of importRequests) expect(ir.status).toBe('ISSUED'); const ticket = await readDeal(sessions, 'sales', deal.id); expect(ticket.summary.salesStage).toBe('PROCUREMENT'); expect(ticket.summary.fulfillmentStatus).toBe('IR_ISSUED'); diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index c34b50361..40b938bca 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -12367,6 +12367,16 @@ export const api = { if (dealStageIndex(ticket.salesStage) < dealStageIndex('ORDER_RECEIVED')) { fail('สร้างใบขอซื้อได้หลังจากยืนยันคำสั่งซื้อแล้ว (ORDER_RECEIVED) เท่านั้น', 409); } + // Supplied per-factory lead times (10-29): 400 on a bad value, before anything is written. + const suppliedLeadTimes = new Map(); + for (const lt of request?.leadTimes ?? []) { + const okInt = (v) => Number.isInteger(v) && v >= 1 && v <= 365; + if (!lt?.factoryName?.trim() || !okInt(lt.minDays) || !okInt(lt.maxDays) || lt.minDays > lt.maxDays) { + fail('ระยะเวลานำเข้าต้องเป็น 1–365 วัน และขั้นต่ำต้องไม่เกินขั้นสูง', 400); + } + suppliedLeadTimes.set(lt.factoryName.trim().replace(/\s+/g, ' ').toLowerCase(), lt); + } + const suppliedFor = (g) => suppliedLeadTimes.get(String(g.factoryName).trim().replace(/\s+/g, ' ').toLowerCase()); mockApplyItemFactoryChoices(ticket, request); const groups = mockResolveFactoryGroups(ticket, request); if (groups.length === 0) { @@ -12375,11 +12385,26 @@ export const api = { const alreadyCovered = new Set(mockImportRequests .filter((r) => r.ticketId === ticket.id && (r.status === 'DRAFT' || r.status === 'ISSUED')) .map((r) => r.factoryId)); - let created = 0; const now = new Date().toISOString(); - for (const group of groups) { - if (alreadyCovered.has(group.factoryId)) continue; - const leadTime = mockLeadTimeDefaults(group.countryCode); + // 10-29: create also ISSUES, all-or-nothing -- check every NEW factory before writing any row + // (the payment gate itself is re-checked per row by issue() below, same predicate). + const newGroups = groups.filter((g) => !alreadyCovered.has(g.factoryId)); + const missingLeadTime = newGroups + .filter((g) => !mockLeadTimeDefaults(g.countryCode) && !suppliedFor(g)) + .map((g) => g.factoryName); + if (missingLeadTime.length > 0) { + fail(`กรุณาระบุระยะเวลานำเข้าให้กับโรงงาน: ${missingLeadTime.join(', ')}`, 409); + } + if (newGroups.length === 0) fail('ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว', 409); + if (!(['DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID'].includes(ticket.paymentStatus) + || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'))) { + fail('ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); + } + const newIds = []; + for (const group of newGroups) { + const supplied = suppliedFor(group); + const leadTime = mockLeadTimeDefaults(group.countryCode) + ?? (supplied ? { min: supplied.minDays, max: supplied.maxDays } : null); const id = mockImportRequestSeq++; mockImportRequests.push({ id, ticketId: ticket.id, ticketCode: ticket.code, @@ -12400,9 +12425,10 @@ export const api = { id: `${id}-${i + 1}`, importRequestId: id, ticketItemId: it.ticketItemId, seq: i + 1, ...it, })), }); - created++; + newIds.push(id); } - if (created === 0) fail('ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว', 409); + // Reuse the mock's issue(): doc number, step CONTACTED, IR_ISSUED + PROCUREMENT on the first. + for (const id of newIds) await api.storedImportRequests.issue(id); return delay({ importRequests: mockImportRequestsForTicket(ticket.id) }); }, async listForTicket(ticketId) { diff --git a/frontend/src/api/mockApi.storedImportRequestRollup.test.js b/frontend/src/api/mockApi.storedImportRequestRollup.test.js index b44af51a6..c26872e45 100644 --- a/frontend/src/api/mockApi.storedImportRequestRollup.test.js +++ b/frontend/src/api/mockApi.storedImportRequestRollup.test.js @@ -146,7 +146,7 @@ async function driveTwoFactoryTicketToOrderReceived() { describe('mockApi storedImportRequests — S3: rollup requires EVERY required factory issued+received', () => { it( - 'does NOT roll the deal up to GOODS_RECEIVED while a second required factory is still only DRAFT', + 'does NOT roll the deal up to GOODS_RECEIVED while a second required factory is issued but not yet received', async () => { const { ticketId } = await driveTwoFactoryTicketToOrderReceived(); @@ -159,15 +159,15 @@ describe('mockApi storedImportRequests — S3: rollup requires EVERY required fa // given role), so this used to switch to 'ceo' for the call and is no longer allowed to. await api.tickets.setDepositPolicy(ticketId, { policy: 'NOT_REQUIRED', reason: 'ทดสอบ S3' }); - const { importRequests: drafts } = await api.storedImportRequests.createDrafts(ticketId, {}); - expect(drafts).toHaveLength(2); // Panaria SpA + REFIN. - const panariaDraft = drafts.find((r) => r.factoryName === 'Panaria SpA'); - const refinDraft = drafts.find((r) => r.factoryName === 'REFIN'); - expect(panariaDraft).toBeTruthy(); - expect(refinDraft).toBeTruthy(); + // 10-29: create also ISSUES, so both rows come back ISSUED. + const { importRequests: created } = await api.storedImportRequests.createDrafts(ticketId, {}); + expect(created).toHaveLength(2); // Panaria SpA + REFIN. + const panariaIssued = created.find((r) => r.factoryName === 'Panaria SpA'); + const refinIssued = created.find((r) => r.factoryName === 'REFIN'); + expect(panariaIssued?.status).toBe('ISSUED'); + expect(refinIssued?.status).toBe('ISSUED'); - // Issue and fully advance ONLY Panaria SpA — REFIN stays a DRAFT, never issued. - const { importRequest: panariaIssued } = await api.storedImportRequests.issue(panariaDraft.id, {}); + // Fully advance ONLY Panaria SpA — REFIN stays issued but not received. await api.auth.login({ role: 'import' }); for (const target of ['ORDERED', 'PICKED_UP', 'IN_TRANSIT', 'AWAITING_CUSTOMS']) { await api.storedImportRequests.advanceStep(panariaIssued.id, { targetStep: target }); @@ -185,9 +185,7 @@ describe('mockApi storedImportRequests — S3: rollup requires EVERY required fa expect(afterPanaria.fulfillmentStatus).not.toBe('GOODS_RECEIVED'); expect(afterPanaria.fulfillmentStatus).toBe('IR_ISSUED'); - // Now issue and receive REFIN too — every required factory has an ISSUED row, all RECEIVED. - await api.auth.login({ role: 'sales' }); - const { importRequest: refinIssued } = await api.storedImportRequests.issue(refinDraft.id, {}); + // Now receive REFIN too — every required factory has an ISSUED row, all RECEIVED. await api.auth.login({ role: 'import' }); for (const target of ['ORDERED', 'PICKED_UP', 'IN_TRANSIT', 'AWAITING_CUSTOMS', 'RECEIVED']) { await api.storedImportRequests.advanceStep(refinIssued.id, { targetStep: target }); @@ -201,7 +199,7 @@ describe('mockApi storedImportRequests — S3: rollup requires EVERY required fa }); describe('mockApi storedImportRequests — S4: bypass issue gate requires EXACTLY CUSTOMER_CONFIRMED', () => { - it('issue() succeeds once a bypass-policy deal is genuinely CUSTOMER_CONFIRMED', async () => { + it('create (which now issues) succeeds once a bypass-policy deal is genuinely CUSTOMER_CONFIRMED', async () => { const { ticketId } = await driveTwoFactoryTicketToOrderReceived(); // driveTwoFactoryTicketToOrderReceived's own confirmOrder already leaves paymentStatus at // CUSTOMER_CONFIRMED — setting a bypass policy on TOP of that is exactly the "bypass-policy @@ -212,9 +210,9 @@ describe('mockApi storedImportRequests — S4: bypass issue gate requires EXACTL await api.tickets.setDepositPolicy(ticketId, { policy: 'WAIVED', reason: 'ทดสอบ S4' }); const { importRequests: drafts } = await api.storedImportRequests.createDrafts(ticketId, {}); - const draft = drafts[0]; - const { importRequest: issued } = await api.storedImportRequests.issue(draft.id, {}); - expect(issued.status).toBe('ISSUED'); + // 10-29: the payment gate now applies at create, which also issues. + expect(drafts.length).toBeGreaterThan(0); + expect(drafts[0].status).toBe('ISSUED'); }); }); diff --git a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx index f7d85c998..cc33b3b05 100644 --- a/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx +++ b/frontend/src/features/pricingRequests/PricingRequestDetailPage.test.jsx @@ -4535,7 +4535,7 @@ describe('CR-1 factory card — the factory\'s IR row, read-only for import (R9) await waitForLoaded(); const row = await screen.findByTestId('pcr-ir-91'); expect(row.textContent).toContain('ฉบับร่าง'); - expect(screen.queryByRole('button', { name: /สร้างใบ IR|ออกเลข|ออกฉบับแก้ไข/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /สร้างใบ IR|ออกใบขอซื้อ|ออกเลข|ออกฉบับแก้ไข/ })).toBeNull(); }); it('matches the factory by id when both carry one, and ignores SUPERSEDED rows and other factories', async () => { diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.jsx index d0ccb4eb9..7967f7c84 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.jsx @@ -16,6 +16,15 @@ import { ImportRequestFactoryCard } from './ImportRequestFactoryCard.jsx'; import { procurementPath } from './stageMeta.js'; const MISSING_COUNTRY_PREFIX = 'กรุณาระบุประเทศให้กับโรงงานใหม่: '; +// 409 prefix of ImportRequestService#MISSING_LEAD_TIME_PREFIX: the factories listed after it have no lead +// time from the quotation or the country default (owner ruling 10-29). +const MISSING_LEAD_TIME_PREFIX = 'กรุณาระบุระยะเวลานำเข้าให้กับโรงงาน: '; + +function parseFactoryNames(message, prefix) { + return message.slice(prefix.length).split(',') + .map((s) => s.trim().replace(/\s+/g, ' ')) + .filter(Boolean); +} // One factory NAME's country pick, for the createDrafts auto-create path (owner decision 09-18): // a real country, or 'ZZ' อื่นๆ with a required typed name — same pairing rule @@ -42,7 +51,7 @@ function NewFactoryCountryModal({ factoryNames, countries, countriesError, onClo countryOther: entries[name].countryCode === 'ZZ' ? entries[name].countryOther.trim() : null, })))} data-testid="new-factory-country-submit"> - สร้างใบ IR + ออกใบขอซื้อ (IR) )} @@ -89,6 +98,59 @@ function NewFactoryCountryModal({ factoryNames, countries, countriesError, onClo ); } +// One factory NAME's import lead time (min-max days), for a new factory whose lead time comes from +// neither the quotation nor the country default. Same bounds as the server: whole days 1-365, min <= max. +function FactoryLeadTimeModal({ factoryNames, onClose, onSubmit, submitting }) { + const [entries, setEntries] = useState(() => Object.fromEntries( + factoryNames.map((name) => [name, { min: '', max: '' }]), + )); + const valid = (v) => /^\d+$/.test(v) && Number(v) >= 1 && Number(v) <= 365; + const ready = factoryNames.every((name) => { + const e = entries[name]; + return e && valid(e.min) && valid(e.max) && Number(e.min) <= Number(e.max); + }); + const set = (name, key, value) => setEntries((d) => ({ ...d, [name]: { ...d[name], [key]: value } })); + return ( + + + + + )} + > +

+ {factoryNames.map((name) => ( +
+ {name} +
+ + set(name, 'min', e.target.value)} /> + + + set(name, 'max', e.target.value)} /> + +
+
+ ))} +
+ + ); +} + // One frame + one title style for every block on the tab (①, the per-factory tracker, the legacy // preview, ②): 16px padding (12px on a phone), 12px between rows, radius-md. The tracker block adds // `border-border-strong` on top of this so it is the one that leads. @@ -273,12 +335,17 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ // parse ever drift — it surfaces a plain error instead and lets the rep press "สร้างใบขอซื้อ" // again deliberately. const [missingFactoryRetryKey, setMissingFactoryRetryKey] = useState(null); + // Same loop guard for the lead-time popup (10-29). `leadTimeCountries` remembers the country picks + // already made, so the lead-time retry still carries them when country came first. + const [leadTimeNames, setLeadTimeNames] = useState(null); // string[] | null + const [leadTimeRetryKey, setLeadTimeRetryKey] = useState(null); + const [leadTimeCountries, setLeadTimeCountries] = useState(null); const countriesQuery = useQuery({ queryKey: ['priceImport', 'countries'], queryFn: () => api.priceImport.countries(), enabled: newFactoryNames != null, }); - // CR-1 (R6 / R9): sales presses สร้างใบ IR, one IR per factory is created, and each PDF downloads + // CR-1 (R6 / R9): sales presses ออกใบขอซื้อ (IR), one IR per factory is created, and each PDF downloads // straight away so sales can hand it over (import downloads the same IR from /fulfilment). A failed // download never undoes the create — the IRs exist, the toast says so, the cards below still carry // their own PDF buttons. @@ -289,7 +356,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ downloadBlob(blob, `IR-${row.docNumber ?? `draft-${row.id}`}-${row.factoryName}`, 'pdf'); })); if (outcomes.some((o) => o.status === 'rejected')) { - showToast?.('error', 'สร้างใบ IR แล้ว แต่ดาวน์โหลด PDF ไม่สำเร็จ — ดาวน์โหลดได้จากรายการด้านล่าง'); + showToast?.('error', 'ออกใบขอซื้อ (IR) แล้ว แต่ดาวน์โหลด PDF ไม่สำเร็จ — ดาวน์โหลดได้จากรายการด้านล่าง'); } } // Factory-less lines (W3-03 follow-up, owner ruling 2026-10-29): a free-text line on a direct- @@ -320,13 +387,13 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ } return Object.keys(body).length > 0 ? body : undefined; } - // The ONE entry point for creating IR drafts: the panel's สร้างใบ IR button calls it, and so must + // The ONE entry point for creating IR drafts: the panel's ออกใบขอซื้อ (IR) button calls it, and so must // any external caller (PR #1189's imperative handle: createIrDrafts() => requestCreateIrDrafts()). // Returns false (and says why) while a factory-less line still has no factory, so the picker can // never be bypassed. function requestCreateIrDrafts() { if (!allLinesPicked) { - showToast?.('error', 'เลือกโรงงานให้ครบทุกรายการก่อนสร้างใบ IR'); + showToast?.('error', 'เลือกโรงงานให้ครบทุกรายการก่อนออกใบขอซื้อ (IR)'); document.querySelector('[data-testid="deal-fulfilment-stored-ir"]')?.scrollIntoView?.({ block: 'start' }); return false; } @@ -342,25 +409,43 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ return { result, knownIds }; }, onSuccess: ({ result, knownIds }) => { - showToast?.('success', 'สร้างใบ IR แล้ว'); + showToast?.('success', 'ออกใบขอซื้อ (IR) แล้ว'); downloadCreatedIrs((result?.importRequests ?? []).filter((r) => !knownIds.has(r.id) && r.status !== 'SUPERSEDED')); setNewFactoryNames(null); setMissingFactoryRetryKey(null); + setLeadTimeNames(null); + setLeadTimeRetryKey(null); + setLeadTimeCountries(null); queryClient.invalidateQueries({ queryKey: queryKeys.storedImportRequests(ticketId) }); queryClient.invalidateQueries({ queryKey: queryKeys.importDeal(ticketId) }); invalidateAfterFulfilmentChange(); }, onError: (err, variables) => { + if (typeof err.message === 'string' && err.message.startsWith(MISSING_LEAD_TIME_PREFIX)) { + const names = parseFactoryNames(err.message, MISSING_LEAD_TIME_PREFIX); + const key = [...names].map((n) => n.toLowerCase()).sort().join('|'); + setNewFactoryNames(null); + setMissingFactoryRetryKey(null); + if (variables?.leadTimes?.length && key === leadTimeRetryKey) { + setLeadTimeNames(null); + setLeadTimeRetryKey(null); + setLeadTimeCountries(null); + onError(new Error('ยังระบุระยะเวลานำเข้าไม่ครบ — กรุณากดออกใบขอซื้อ (IR) อีกครั้งแล้วลองใหม่')); + return; + } + setLeadTimeCountries(variables?.newFactoryCountries ?? null); + setLeadTimeRetryKey(key); + setLeadTimeNames(names); + return; + } if (typeof err.message === 'string' && err.message.startsWith(MISSING_COUNTRY_PREFIX)) { - const names = err.message.slice(MISSING_COUNTRY_PREFIX.length).split(',') - .map((s) => s.trim().replace(/\s+/g, ' ')) - .filter(Boolean); + const names = parseFactoryNames(err.message, MISSING_COUNTRY_PREFIX); const key = [...names].map((n) => n.toLowerCase()).sort().join('|'); const wasARetry = Boolean(variables?.newFactoryCountries?.length); if (wasARetry && key === missingFactoryRetryKey) { setNewFactoryNames(null); setMissingFactoryRetryKey(null); - onError(new Error('ยังระบุโรงงานไม่ครบ — กรุณากดสร้างใบ IR อีกครั้งแล้วลองใหม่')); + onError(new Error('ยังระบุโรงงานไม่ครบ — กรุณากดออกใบขอซื้อ (IR) อีกครั้งแล้วลองใหม่')); return; } setMissingFactoryRetryKey(key); @@ -711,12 +796,12 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ {canFullWriteStoredIr ? ( ) : null} @@ -726,7 +811,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ {canFullWriteStoredIr && factorylessLines.length > 0 ? (

- รายการต่อไปนี้ยังไม่มีโรงงาน — เลือกโรงงานก่อนสร้างใบ IR + รายการต่อไปนี้ยังไม่มีโรงงาน — เลือกโรงงานก่อนออกใบขอซื้อ (IR)

{factoryNamesQuery.isError ? (

@@ -1085,6 +1170,18 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ ) : null} + {leadTimeNames ? ( + setLeadTimeNames(null)} + onSubmit={(leadTimes) => createDraftsMutation.mutate(buildCreatePayload({ + ...(leadTimeCountries ? { newFactoryCountries: leadTimeCountries } : {}), leadTimes, + }))} + /> + ) : null} + {newFactoryNames ? ( { + it('keeps ออกใบขอซื้อ (IR) disabled until every factory-less line has a factory, and sends the choice', async () => { api.storedImportRequests.createDrafts.mockResolvedValue({ importRequests: [draftRow()] }); renderPanel(OWNER, [], { summary: directSummary, items: lines }); const button = await screen.findByTestId('deal-fulfilment-create-ir-drafts'); @@ -346,6 +346,96 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง }); }); + // 10-29 ruling B: a new factory with no lead time (quotation or country default) is askable, not a + // dead end. The 409 carries a parseable prefix; the popup collects min-max per listed factory. + describe('lead-time popup (10-29)', () => { + beforeEach(() => api.storedImportRequests.createDrafts.mockReset()); + const LT = (names) => Object.assign(new Error(`กรุณาระบุระยะเวลานำเข้าให้กับโรงงาน: ${names}`), { status: 409 }); + const CTRY = (names) => Object.assign(new Error(`กรุณาระบุประเทศให้กับโรงงานใหม่: ${names}`), { status: 409 }); + const fill = (name, min, max) => { + fireEvent.change(screen.getByTestId(`factory-lead-time-min-${name}`), { target: { value: String(min) } }); + fireEvent.change(screen.getByTestId(`factory-lead-time-max-${name}`), { target: { value: String(max) } }); + }; + + it('a missing-lead-time 409 opens the popup, and submitting retries with leadTimes', async () => { + api.storedImportRequests.createDrafts + .mockRejectedValueOnce(LT('Rex Ceramics, Cotto')) + .mockResolvedValueOnce({ importRequests: [issuedRow({ factoryName: 'Rex Ceramics' })] }); + renderPanel(OWNER, []); + fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); + + const submit = await screen.findByTestId('factory-lead-time-submit'); + expect(submit.disabled).toBe(true); + fill('Rex Ceramics', 20, 30); + expect(submit.disabled).toBe(true); // Cotto still blank + fill('Cotto', 5, 9); + await waitFor(() => expect(submit.disabled).toBe(false)); + fireEvent.click(submit); + + await waitFor(() => expect(api.storedImportRequests.createDrafts).toHaveBeenLastCalledWith(1, { + leadTimes: [ + { factoryName: 'Rex Ceramics', minDays: 20, maxDays: 30 }, + { factoryName: 'Cotto', minDays: 5, maxDays: 9 }, + ], + })); + }); + + it('does not enable submit while min is greater than max', async () => { + api.storedImportRequests.createDrafts.mockRejectedValueOnce(LT('Rex Ceramics')); + renderPanel(OWNER, []); + fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); + const submit = await screen.findByTestId('factory-lead-time-submit'); + fill('Rex Ceramics', 40, 30); + expect(submit.disabled).toBe(true); + }); + + it('cancel creates nothing: no retry, popup gone', async () => { + api.storedImportRequests.createDrafts.mockRejectedValueOnce(LT('Rex Ceramics')); + renderPanel(OWNER, []); + fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); + fireEvent.click(await screen.findByTestId('factory-lead-time-cancel')); + + await waitFor(() => expect(screen.queryByTestId('factory-lead-time-submit')).toBeNull()); + expect(api.storedImportRequests.createDrafts).toHaveBeenCalledTimes(1); + }); + + it('country first, then lead time: the second retry carries both', async () => { + api.storedImportRequests.createDrafts + .mockRejectedValueOnce(CTRY('Rex Ceramics')) + .mockRejectedValueOnce(LT('Rex Ceramics')) + .mockResolvedValueOnce({ importRequests: [issuedRow({ factoryName: 'Rex Ceramics' })] }); + renderPanel(OWNER, []); + fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); + + fireEvent.change(await screen.findByLabelText(/^ประเทศ/), { target: { value: 'CN' } }); + fireEvent.click(await screen.findByTestId('new-factory-country-submit')); + await screen.findByTestId('factory-lead-time-submit'); + expect(screen.queryByTestId('new-factory-country-submit')).toBeNull(); + fill('Rex Ceramics', 20, 30); + fireEvent.click(screen.getByTestId('factory-lead-time-submit')); + + await waitFor(() => expect(api.storedImportRequests.createDrafts).toHaveBeenLastCalledWith(1, { + newFactoryCountries: [{ factoryName: 'Rex Ceramics', countryCode: 'CN', countryOther: null }], + leadTimes: [{ factoryName: 'Rex Ceramics', minDays: 20, maxDays: 30 }], + })); + }); + + it('retry guard: the same factory asked for again after a submit does not loop the popup', async () => { + const onToast = vi.fn(); + api.storedImportRequests.createDrafts + .mockRejectedValueOnce(LT('Rex Ceramics')) + .mockRejectedValueOnce(LT('Rex Ceramics')); + renderPanel(OWNER, [], { showToast: onToast }); + fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); + await screen.findByTestId('factory-lead-time-submit'); + fill('Rex Ceramics', 20, 30); + fireEvent.click(screen.getByTestId('factory-lead-time-submit')); + + await waitFor(() => expect(screen.queryByTestId('factory-lead-time-submit')).toBeNull()); + expect(api.storedImportRequests.createDrafts).toHaveBeenCalledTimes(2); + }); + }); + describe('country-required create flow', () => { it('a missing-country 409 opens the picker, and submitting retries with newFactoryCountries', async () => { api.storedImportRequests.createDrafts @@ -634,10 +724,10 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง // PR-B REVIEW ROUND 1, nit: the create button relabels once the deal already has rows. describe('create-button relabel (nit)', () => { - it('reads "สร้างใบ IR" with no rows yet, and "สร้างใบ IR โรงงานที่ยังไม่มี" once rows exist (CR-1 rename)', async () => { + it('reads "ออกใบขอซื้อ (IR)" with no rows yet, and "ออกใบขอซื้อ (IR) โรงงานที่ยังไม่มี" once rows exist (CR-1 rename)', async () => { const { rerender } = renderPanel(OWNER, []); const initialButton = await screen.findByTestId('deal-fulfilment-create-ir-drafts'); - expect(initialButton.textContent).toBe('สร้างใบ IR'); + expect(initialButton.textContent).toBe('ออกใบขอซื้อ (IR)'); api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: [draftRow()] }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); @@ -646,7 +736,7 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง , ); - expect(await screen.findByText('สร้างใบ IR โรงงานที่ยังไม่มี')).not.toBeNull(); + expect(await screen.findByText('ออกใบขอซื้อ (IR) โรงงานที่ยังไม่มี')).not.toBeNull(); }); }); @@ -670,10 +760,10 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง }); }); -// CR-1 (GLA-167) R6 / R9: sales presses สร้างใบ IR once the quotation is approved; one IR per +// CR-1 (GLA-167) R6 / R9: sales presses ออกใบขอซื้อ (IR) once the quotation is approved; one IR per // factory is created and each PDF downloads straight away so sales can hand it over; import then // downloads the same IR from /fulfilment. -describe('DealFulfilmentPanel — สร้างใบ IR downloads every created PDF (CR-1)', () => { +describe('DealFulfilmentPanel — ออกใบขอซื้อ (IR) downloads every created PDF (CR-1)', () => { beforeEach(() => { vi.clearAllMocks(); globalThis.URL.createObjectURL = vi.fn(() => 'blob:ir'); @@ -681,13 +771,13 @@ describe('DealFulfilmentPanel — สร้างใบ IR downloads every creat api.storedImportRequests.download.mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' })); }); - it('relabels the button สร้างใบ IR for the owning rep and CEO, and import still does not get it', async () => { + it('relabels the button ออกใบขอซื้อ (IR) for the owning rep and CEO, and import still does not get it', async () => { renderPanel(OWNER, []); - expect((await screen.findByTestId('deal-fulfilment-create-ir-drafts')).textContent).toBe('สร้างใบ IR'); + expect((await screen.findByTestId('deal-fulfilment-create-ir-drafts')).textContent).toBe('ออกใบขอซื้อ (IR)'); }); it('after a successful create, downloads the PDF of each IR it just created (one per factory)', async () => { - const created = [draftRow({ id: 21, factoryName: 'Cotto Industry' }), draftRow({ id: 22, factoryId: 2, factoryName: 'Panaria' })]; + const created = [issuedRow({ id: 21, factoryName: 'Cotto Industry' }), issuedRow({ id: 22, factoryId: 2, factoryName: 'Panaria' })]; api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: created }); renderPanel(OWNER, []); fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); @@ -696,9 +786,25 @@ describe('DealFulfilmentPanel — สร้างใบ IR downloads every creat expect(api.storedImportRequests.download.mock.calls.map((c) => c[0]).sort()).toEqual([21, 22]); }); + it('10-29: create ends with the rows ISSUED (no ออกเลข button) and the toast ออกใบขอซื้อ (IR) แล้ว', async () => { + const showToast = vi.fn(); + const issued = [issuedRow({ id: 21, importStep: 'CONTACTED' }), issuedRow({ id: 22, factoryId: 2, factoryName: 'Panaria', docNumber: 'IR26002', importStep: 'CONTACTED' })]; + api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: issued }); + renderPanel(OWNER, [], { showToast }); + // after the create the refetch returns the server's rows, which are already ISSUED + api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: issued }); + fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); + + await waitFor(() => expect(showToast).toHaveBeenCalledWith('success', 'ออกใบขอซื้อ (IR) แล้ว')); + await screen.findByText('Panaria'); + expect(screen.queryByTestId('ir-issue-21')).toBeNull(); + expect(screen.queryByTestId('ir-issue-22')).toBeNull(); + expect(screen.queryByTestId('ir-draft-hint-21')).toBeNull(); + }); + it('downloads only the NEW IRs when some factories already had one', async () => { const existing = draftRow({ id: 10, factoryName: 'Cotto Industry' }); - const fresh = draftRow({ id: 23, factoryId: 3, factoryName: 'Rex Ceramics' }); + const fresh = issuedRow({ id: 23, factoryId: 3, factoryName: 'Rex Ceramics' }); api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: [existing, fresh] }); renderPanel(OWNER, [existing]); await screen.findByText('Cotto Industry'); @@ -710,7 +816,7 @@ describe('DealFulfilmentPanel — สร้างใบ IR downloads every creat it('a failed PDF download does not undo the create: the toast says the IRs exist but the PDF failed', async () => { const showToast = vi.fn(); - api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: [draftRow({ id: 21 })] }); + api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: [issuedRow({ id: 21 })] }); api.storedImportRequests.download.mockRejectedValueOnce(new Error('ดาวน์โหลดใบขอซื้อไม่สำเร็จ')); api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: [] }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); @@ -720,12 +826,12 @@ describe('DealFulfilmentPanel — สร้างใบ IR downloads every creat , ); fireEvent.click(await screen.findByTestId('deal-fulfilment-create-ir-drafts')); - await waitFor(() => expect(showToast).toHaveBeenCalledWith('success', expect.stringContaining('สร้างใบ IR'))); - await waitFor(() => expect(showToast).toHaveBeenCalledWith('error', expect.stringContaining('ดาวน์โหลด'))); + await waitFor(() => expect(showToast).toHaveBeenCalledWith('success', expect.stringContaining('ออกใบขอซื้อ (IR)'))); + await waitFor(() => expect(showToast).toHaveBeenCalledWith('error', expect.stringContaining('ออกใบขอซื้อ (IR) แล้ว แต่ดาวน์โหลด'))); }); it('refreshes the stored IRs, the import deal and the fulfilment worklist after creating (F)', async () => { - api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: [draftRow({ id: 21 })] }); + api.storedImportRequests.createDrafts.mockResolvedValueOnce({ importRequests: [issuedRow({ id: 21 })] }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); const invalidate = vi.spyOn(queryClient, 'invalidateQueries'); api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: [] }); From c148bee88a9ee4d0626f90db0381dcae2717f373 Mon Sep 17 00:00:00 2001 From: waritctd <148909369+waritctd@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:49:15 +0700 Subject: [PATCH 2/2] fix(import): deposit gate refuses IR create first; tab button waits for the deposit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of #1199: - createDrafts checks requireImportRequestIssuable right after the stage check, so an unpaid deal is refused at the first press instead of after the country and lead-time popups. - The deal tab's ออกใบขอซื้อ (IR) button is disabled with the hint "ออกใบขอซื้อ (IR) ได้หลังรับชำระมัดจำแล้ว" until the deal is deposit-ready (depositReadyForIr mirrors TicketService#requireImportRequestIssuable); the imperative entry point refuses early with the same message. Co-Authored-By: Claude Opus 5.5 --- .../importrequest/ImportRequestService.java | 7 +-- ...portRequestPaymentGateIntegrationTest.java | 24 ++++++++++ frontend/src/api/mockApi.js | 9 ++-- .../features/tickets/DealFulfilmentPanel.jsx | 12 ++++- .../tickets/DealFulfilmentPanel.test.jsx | 46 ++++++++++++++++++- .../src/features/tickets/dealMoneyStatus.js | 12 +++++ 6 files changed, 101 insertions(+), 9 deletions(-) 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 cb3f51659..43fb3d169 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 @@ -237,6 +237,10 @@ public List createDrafts(long ticketId, CreateImportRequestsRe "สร้างใบขอซื้อได้หลังจากยืนยันคำสั่งซื้อแล้ว (ORDER_RECEIVED) เท่านั้น"); } + // The payment gate applies at create now (widened gate, same as issue()) and refuses FIRST, so a + // deal with no paid deposit is never walked through the country / lead-time prompts. + ticketService.requireImportRequestIssuable(requireTicketSummary(ticketId), true); + // 400 before anything is written (the picker and auto-create below both write). Map suppliedLeadTimes = validatedLeadTimes(request); @@ -291,9 +295,6 @@ record NewRow(FactoryGroup group, LeadTimeDefaults.Range derived, LeadTimeDefaul throw new ApiException(HttpStatus.CONFLICT, "ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว"); } - // The payment gate applies at create now (widened gate, same as issue()). - ticketService.requireImportRequestIssuable(requireTicketSummary(ticketId), true); - for (NewRow n : newRows) { FactoryGroup group = n.group(); long id = stored.insertDraft(ticketId, group.factoryId(), group.factoryName(), 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 9632bc23a..8bd1497aa 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 @@ -140,6 +140,30 @@ void createDrafts_isRefused409_andWritesNothing_onADealWithNoDepositYet() { .fulfillmentStatus()).isNull(); } + /** + * Review fix: the payment gate refuses FIRST. A deal with no paid deposit whose line also names a + * brand-new factory (no country, no lead time) must get the gate's 409, not the missing-country + * 409 that would walk sales through both popups before refusing — and nothing is written (no + * auto-created factory either). + */ + @Test + void createDrafts_paymentGateRefusesBeforeTheCountryAndLeadTimePrompts() { + long ticketId = insertTicket("GATE-FIRST", null); + insertItem(ticketId, "Brand New Gate Factory Co", "Line", "60x60", "10", "pcs", 0); + Long factoriesBefore = jdbc.queryForObject( + "SELECT count(*) FROM price_catalog.factories", Map.of(), Long.class); + + assertThatThrownBy(() -> service.createDrafts(ticketId, null, owner)) + .isInstanceOfSatisfying(ApiException.class, e -> { + assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT); + assertThat(e.getMessage()).contains("รับชำระมัดจำ").doesNotContain("ประเทศ"); + }); + + assertThat(service.list(ticketId, owner)).isEmpty(); + assertThat(jdbc.queryForObject("SELECT count(*) FROM price_catalog.factories", Map.of(), Long.class)) + .isEqualTo(factoriesBefore); + } + /** * Cheap nit (REVIEW ROUND 3): {@code CUSTOMER_CONFIRMED} on a deposit-REQUIRED policy (the * default — {@code insertTicket} never overrides {@code deposit_policy}, so this ticket carries diff --git a/frontend/src/api/mockApi.js b/frontend/src/api/mockApi.js index 40b938bca..9370030fc 100644 --- a/frontend/src/api/mockApi.js +++ b/frontend/src/api/mockApi.js @@ -12367,6 +12367,11 @@ export const api = { if (dealStageIndex(ticket.salesStage) < dealStageIndex('ORDER_RECEIVED')) { fail('สร้างใบขอซื้อได้หลังจากยืนยันคำสั่งซื้อแล้ว (ORDER_RECEIVED) เท่านั้น', 409); } + // 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); + } // Supplied per-factory lead times (10-29): 400 on a bad value, before anything is written. const suppliedLeadTimes = new Map(); for (const lt of request?.leadTimes ?? []) { @@ -12396,10 +12401,6 @@ export const api = { fail(`กรุณาระบุระยะเวลานำเข้าให้กับโรงงาน: ${missingLeadTime.join(', ')}`, 409); } if (newGroups.length === 0) fail('ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว', 409); - if (!(['DEPOSIT_NOTICE_ISSUED', 'DEPOSIT_PAID', 'AWAITING_FINAL_PAYMENT', 'FULLY_PAID'].includes(ticket.paymentStatus) - || (depositBypassesNotice(ticket) && ticket.paymentStatus === 'CUSTOMER_CONFIRMED'))) { - fail('ออกใบขอนำเข้า (IR) ได้เฉพาะเมื่อรับชำระมัดจำแล้ว (หรือได้รับการยกเว้นมัดจำ) เท่านั้น', 409); - } const newIds = []; for (const group of newGroups) { const supplied = suppliedFor(group); diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.jsx index 7967f7c84..95973cb62 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.jsx @@ -9,6 +9,7 @@ import { Modal } from '../../components/common/Modal.jsx'; import { cn } from '../../utils/cn.js'; import { downloadBlob } from '../../utils/download.js'; import { formatThaiDate, fulfilmentStatusLabel } from '../../utils/format.js'; +import { depositReadyForIr } from './dealMoneyStatus.js'; import { ImportStatusStrip } from '../importProgress/ImportStatusStrip.jsx'; import { importStepIndex } from '../importProgress/importSteps.js'; import { nextFulfilmentActionCode } from './importActions.js'; @@ -391,7 +392,13 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ // any external caller (PR #1189's imperative handle: createIrDrafts() => requestCreateIrDrafts()). // Returns false (and says why) while a factory-less line still has no factory, so the picker can // never be bypassed. + const depositReady = depositReadyForIr(summary); + const DEPOSIT_HINT = 'ออกใบขอซื้อ (IR) ได้หลังรับชำระมัดจำแล้ว'; function requestCreateIrDrafts() { + if (!depositReady) { + showToast?.('error', DEPOSIT_HINT); + return false; + } if (!allLinesPicked) { showToast?.('error', 'เลือกโรงงานให้ครบทุกรายการก่อนออกใบขอซื้อ (IR)'); document.querySelector('[data-testid="deal-fulfilment-stored-ir"]')?.scrollIntoView?.({ block: 'start' }); @@ -794,7 +801,7 @@ export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({

{canFullWriteStoredIr ? ( - ) : null} + {canFullWriteStoredIr && !depositReady ? ( +

{DEPOSIT_HINT}

+ ) : null}

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

diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx index 13137f662..c6f74fa93 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.test.jsx @@ -76,7 +76,7 @@ function issuedRow(over = {}) { }; } -const summary = { createdById: 42, status: 'quotation_issued', fulfillmentStatus: null }; +const summary = { createdById: 42, status: 'quotation_issued', fulfillmentStatus: null, paymentStatus: 'DEPOSIT_PAID' }; // `rows` may be an Error, to render the panel with a failed ใบขอซื้อ fetch. function renderPanel(user, rows = [draftRow()], { @@ -436,6 +436,50 @@ describe('DealFulfilmentPanel — ใบขอซื้อรายโรงง }); }); + // Review fix: the create button mirrors TicketService#requireImportRequestIssuable(summary, true) + // (DEPOSIT_NOTICE_ISSUED / DEPOSIT_PAID / AWAITING_FINAL_PAYMENT / FULLY_PAID, or a not-required + // deposit policy that has reached CUSTOMER_CONFIRMED) instead of offering a click that can only 409. + describe('deposit-ready gate on the create button', () => { + const HINT = 'ออกใบขอซื้อ (IR) ได้หลังรับชำระมัดจำแล้ว'; + const dealAt = (paymentStatus, depositPolicy = 'REQUIRED') => ({ ...summary, paymentStatus, depositPolicy }); + + it.each([['CUSTOMER_CONFIRMED'], [null]])('is disabled with the hint at paymentStatus %s', async (paymentStatus) => { + renderPanel(OWNER, [], { summary: dealAt(paymentStatus) }); + const button = await screen.findByTestId('deal-fulfilment-create-ir-drafts'); + expect(button.disabled).toBe(true); + expect(screen.getByTestId('ir-deposit-hint').textContent).toContain(HINT); + }); + + it.each([['DEPOSIT_NOTICE_ISSUED'], ['DEPOSIT_PAID'], ['AWAITING_FINAL_PAYMENT'], ['FULLY_PAID']])( + 'is enabled, no hint, at paymentStatus %s (same set as the server gate)', async (paymentStatus) => { + renderPanel(OWNER, [], { summary: dealAt(paymentStatus) }); + const button = await screen.findByTestId('deal-fulfilment-create-ir-drafts'); + expect(button.disabled).toBe(false); + expect(screen.queryByTestId('ir-deposit-hint')).toBeNull(); + }); + + it.each([['WAIVED'], ['NOT_REQUIRED'], ['CREDIT_CUSTOMER']])( + 'a %s deposit policy is enabled once CUSTOMER_CONFIRMED, and still disabled before it', async (policy) => { + const ok = renderPanel(OWNER, [], { summary: dealAt('CUSTOMER_CONFIRMED', policy) }); + expect((await screen.findByTestId('deal-fulfilment-create-ir-drafts')).disabled).toBe(false); + ok.unmount(); + renderPanel(OWNER, [], { summary: dealAt(null, policy) }); + expect((await screen.findByTestId('deal-fulfilment-create-ir-drafts')).disabled).toBe(true); + }); + + it('the imperative entry point (ขั้น 11 sticky button) refuses early with the same message', async () => { + const showToast = vi.fn(); + const ref = React.createRef(); + renderPanel(OWNER, [], { summary: dealAt('CUSTOMER_CONFIRMED'), showToast, ref }); + await screen.findByTestId('deal-fulfilment-create-ir-drafts'); + let started; + started = ref.current.createIrDrafts(); + expect(started).toBe(false); + expect(showToast).toHaveBeenCalledWith('error', HINT); + expect(api.storedImportRequests.createDrafts).not.toHaveBeenCalled(); + }); + }); + describe('country-required create flow', () => { it('a missing-country 409 opens the picker, and submitting retries with newFactoryCountries', async () => { api.storedImportRequests.createDrafts diff --git a/frontend/src/features/tickets/dealMoneyStatus.js b/frontend/src/features/tickets/dealMoneyStatus.js index 3ae971ffb..019aff33a 100644 --- a/frontend/src/features/tickets/dealMoneyStatus.js +++ b/frontend/src/features/tickets/dealMoneyStatus.js @@ -16,6 +16,18 @@ export function depositBypassed(policy) { return ['NOT_REQUIRED', 'WAIVED', 'CREDIT_CUSTOMER'].includes(policy); } +/** + * May an import request (ใบขอซื้อ) be issued yet? Mirrors TicketService#requireImportRequestIssuable + * (summary, allowAdvancedPayment = true) exactly: notice issued, deposit paid or later, or a + * not-required deposit policy that has reached CUSTOMER_CONFIRMED. + */ +export function depositReadyForIr(summary) { + const { paymentStatus, depositPolicy } = summary ?? {}; + return paymentStatus === 'DEPOSIT_NOTICE_ISSUED' + || DEPOSIT_PAID_STATUSES.includes(paymentStatus) + || (depositBypassed(depositPolicy) && paymentStatus === 'CUSTOMER_CONFIRMED'); +} + /** `{ bypassed, paid, noticeIssued }` — the three deposit-status facts both callers key off. */ export function depositStatus(summary) { const bypassed = depositBypassed(summary.depositPolicy);