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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,31 @@ private ImportRequestRequests() {}
public record CreateImportRequestsRequest(
String clientRequestId,
@Valid List<NewFactoryCountryInput> newFactoryCountries,
@Valid List<ItemFactoryInput> itemFactories
@Valid List<ItemFactoryInput> itemFactories,
@Valid List<NewFactoryLeadTimeInput> leadTimes
) {
/** Pre-picker shape: no per-line factory choices. */
public CreateImportRequestsRequest(String clientRequestId, List<NewFactoryCountryInput> newFactoryCountries) {
this(clientRequestId, newFactoryCountries, null);
this(clientRequestId, newFactoryCountries, null, null);
}

public CreateImportRequestsRequest(String clientRequestId, List<NewFactoryCountryInput> newFactoryCountries,
List<ItemFactoryInput> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}).
*
* <p>Idempotent in the way that matters: a factory with a DRAFT or an ISSUED form is skipped
* <p>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
Expand All @@ -233,6 +237,13 @@ public List<ImportRequestDto> 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<String, ImportRequestRequests.NewFactoryLeadTimeInput> suppliedLeadTimes =
validatedLeadTimes(request);
applyItemFactoryChoices(ticketId, request);
List<FactoryGroup> groups = resolveFactoryGroups(ticketId, actor.id(), request);
if (groups.isEmpty()) {
Expand All @@ -248,32 +259,54 @@ public List<ImportRequestDto> 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<ImportRequestQueryRepository.QuotationLeadTimeRow> 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<NewRow> newRows = new ArrayList<>();
List<String> 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,
"ทุกโรงงานในดีลนี้มีใบขอซื้อที่ยังใช้งานอยู่แล้ว");
}
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));
}
Expand Down Expand Up @@ -546,6 +579,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.
Expand Down Expand Up @@ -1105,6 +1149,29 @@ public void setRequiredByNote(long ticketId, SetRequiredByNoteRequest request, U
private record FactoryGroup(long factoryId, String factoryName, String countryCode, String brandLabel,
List<ImportRequestItemInput> 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<String, ImportRequestRequests.NewFactoryLeadTimeInput> validatedLeadTimes(
CreateImportRequestsRequest request) {
Map<String, ImportRequestRequests.NewFactoryLeadTimeInput> 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) {}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,8 +347,8 @@ void importCannotWriteDelivery() {
// ── fixtures ──────────────────────────────────────────────────────────────────────────────────

private ImportRequestDto issueOneImportRequest() {
List<ImportRequestDto> 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() {
Expand Down
53 changes: 53 additions & 0 deletions backend/src/test/java/th/co/glr/hr/importrequest/DraftSeed.java
Original file line number Diff line number Diff line change
@@ -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<ImportRequestDto> seed(NamedParameterJdbcTemplate jdbc, long ticketId, UserPrincipal actor) {
ImportRequestRepository stored = new ImportRequestRepository(jdbc);
var snapshot = new ImportRequestQueryRepository(jdbc).loadTicketSnapshot(ticketId).orElseThrow();
List<Map<String, Object>> 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<String, Object> 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);
}
}
Loading
Loading