Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ public class FactoryConfigController {
private static final String[] READ_ROLES = { "ceo", "import" };

/** Names only (W3-03 follow-up, owner ruling 2026-10-29): the owning sales rep and sales_manager pick a
* factory for a factory-less line when creating the IR. No email/currency/unit leaves this route. */
private static final String[] NAME_READ_ROLES = { "ceo", "import", "sales", "sales_manager" };
* factory for a factory-less line when creating the IR; account opens /catalog, whose factory filter
* reads this. No email/currency/unit leaves this route. */
private static final String[] NAME_READ_ROLES = { "ceo", "import", "sales", "sales_manager", "account" };

public record FactoryNameDto(long id, String factoryName) {}

Expand Down
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
12 changes: 9 additions & 3 deletions backend/src/main/java/th/co/glr/hr/pricing/FxRateController.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ public class FxRateController {

/**
* Read gate, owner ruling 2026-08-02 — widened from issue #388's {@code ceo}/{@code import}
* gate by exactly one role, to {@code sales}. NOT opened to every authenticated session: the
* owner's words were "should only be to sale", and this set is the whole of that ruling.
* gate by exactly one role, to {@code sales}, and to {@code sales_manager} by the 2026-10-29
* ruling below. NOT opened to every authenticated session: the owner's words were "should
* only be to sale", and this set is the whole of those two rulings.
*
* <p>The trigger was the deal-create modal's "ราคาตั้ง (ประมาณการ)" estimate (V112): a rep who
* picks a catalog item needs the FX rate to convert its price to THB, and every catalog row is
Expand All @@ -45,6 +46,11 @@ public class FxRateController {
* number already visible to this caller, so it survives its trigger. Re-narrowing it is a
* separate owner decision, not a tidy-up that follows from #748.
*
* <p><b>Widened again, owner ruling 2026-10-29 (role check RC-01):</b> {@code sales_manager}
* joins the set — the deal page converts SM's foreign-currency prices to THB, which needs this
* rate. The "sales_manager cannot create a deal" argument above no longer decides the gate.
* Nothing else changes; writes stay CEO-only.
*
* <p>The reasoning is deliberately narrower than #388's: an FX rate only converts a number that
* is ALREADY visible to this caller — {@code /api/catalog/prices} is open to any authenticated
* user per the 2026-08-01 ruling that closed #388 — and on its own reveals nothing about landed
Expand All @@ -55,7 +61,7 @@ public class FxRateController {
* <p>If a test pinning this set fails, someone has re-narrowed or re-widened the gate: reopen
* the ruling with the owner rather than editing the test to match the code.
*/
private static final Set<String> READ_ROLES = Set.of("ceo", "import", "sales");
private static final Set<String> READ_ROLES = Set.of("ceo", "import", "sales", "sales_manager");

private final FxRateRepository fxRates;
private final SessionContext sessions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,14 @@
* (2026-07-16).</li>
* <li>{@code GET /api/catalog/prices} → <b>open</b> to any authenticated user, per the owner's
* 2026-08-01 ruling closing #388: #205's "browsable" covers the supplier purchase price.</li>
* <li>{@code GET /api/fx-rates} → {@code ceo}/{@code import}/<b>{@code sales}</b>, per the
* owner's 2026-08-02 ruling (see {@code FxRateController.READ_ROLES}): an FX rate only
* <li>{@code GET /api/fx-rates} → {@code ceo}/{@code import}/<b>{@code sales}</b>/
* <b>{@code sales_manager}</b>, per the owner's 2026-08-02 ruling (sales) and 2026-10-29
* ruling RC-01 (sales_manager) — see {@code FxRateController.READ_ROLES}: an FX rate only
* converts a number already visible via the open catalog-prices read, and reveals nothing
* about landed cost or margin on its own — but the ruling was "should only be to sale", so
* this is one role wider than #388, NOT open to every authenticated session.</li>
* about landed cost or margin on its own. NOT open to every authenticated session.</li>
* <li>{@code GET /api/price-calc-configs}, {@code GET /api/factory-configs} → ceo/import
* ({@code RAW_COSTING_ROLES}), unchanged. The FX widening is deliberately narrower than
* #388's catalog ruling: it does not extend to the margin policy or the supplier
* directory.</li>
* ({@code RAW_COSTING_ROLES}), unchanged. The FX widening does not extend to the margin
* policy or this supplier-directory endpoint.</li>
* </ul>
*/
class CatalogPricingReadAuthzIntegrationTest extends AbstractPostgresIntegrationTest {
Expand Down Expand Up @@ -211,20 +210,29 @@ void salesCanReadFxRatesPerOwnerRuling20260802() throws Exception {
.andExpect(jsonPath("$.fxRates[?(@.currency == 'XTS')].rateToThb").exists());
}

/**
* Owner ruling 2026-10-29 (role check RC-01): the deal page converts SM's foreign-currency
* prices to THB, so the FX read widens to {@code sales_manager}. Writes stay CEO-only.
*/
@Test
void salesManagerCanReadFxRatesPerOwnerRuling20261029() throws Exception {
fxMvc.perform(get("/api/fx-rates").session(sessionFor("sales_manager")))
.andExpect(status().isOk())
.andExpect(jsonPath("$.fxRates[?(@.currency == 'XTS')].rateToThb").exists());
}

/**
* THE OTHER HALF OF THE SAME RULING, and the half that actually catches a regression. The
* ruling was "should only be to sale" — it did NOT open the rate to every authenticated
* session. Without this case the suite stays green with the gate wide open, which is precisely
* the failure mode a read-gate widening has: you find out in production.
*
* <p>{@code sales_manager} is in this list on purpose. It browses the deal pipeline but cannot
* create a deal ({@code canCreateTickets} is {@code ['sales']}, and {@code TicketService.create}
* gates on {@code Set.of("sales")}), so it never reaches the modal that needs the rate. Adding
* it here would be widening beyond what the owner authorised.
* <p>{@code sales_manager} left this list by the owner's ruling of 2026-10-29 (role check
* RC-01) and is pinned by {@link #salesManagerCanReadFxRatesPerOwnerRuling20261029}.
*/
@Test
void everyRoleOutsideTheRulingIsStillRefusedTheFxRead() throws Exception {
for (String role : List.of("employee", "warehouse", "qc", "hr", "sales_manager", "account")) {
for (String role : List.of("employee", "warehouse", "qc", "hr", "account")) {
fxMvc.perform(get("/api/fx-rates").session(sessionFor(role)))
.andExpect(status().isForbidden());
}
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
Loading
Loading