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
16 changes: 16 additions & 0 deletions backend/src/main/java/th/co/glr/hr/catalog/CatalogRepository.java
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,22 @@ WHERE pp.price_id IN (:priceIds)
return result;
}

/** price_id -> price_catalog.factories.name, for the ids that resolve to a factory (absent otherwise). */
public Map<Long, String> findFactoryNames(Collection<Long> priceIds) {
if (priceIds == null || priceIds.isEmpty()) {
return Map.of();
}
Map<Long, String> names = new HashMap<>();
jdbc.query("""
SELECT pp.price_id, f.name
FROM price_catalog.product_prices pp
JOIN price_catalog.factories f ON f.factory_id = pp.factory_id
WHERE pp.price_id IN (:priceIds)
""", new MapSqlParameterSource().addValue("priceIds", new ArrayList<>(new LinkedHashSet<>(priceIds))),
rs -> { names.put(rs.getLong("price_id"), rs.getString("name")); });
return names;
}

/** One price row's batched pricing-lookup keys (P1b.2) — a null field means "did not
* resolve", mirroring the deleted single-row {@code findThicknessMm}/{@code
* findOriginCountryCode}'s {@code Optional.empty()} exactly (see {@link #findPricingKeys}). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2216,6 +2216,10 @@ private PromoteToDealResultDto promotionResult(long ticketId, long quotationId)
*/
private List<TicketItemRequest> ticketItemsFromQuotation(DealQuotationDto quotation) {
List<TicketItemRequest> rows = new ArrayList<>();
// W3-03: carry the catalogue link and its factory across, as the pricing-request route does, so
// import can raise the per-factory IR. A free-text line has neither and keeps both null.
Map<Long, String> factoryByPriceId = catalog.findFactoryNames(quotation.items().stream()
.map(DealQuotationItemDto::catalogPriceId).filter(java.util.Objects::nonNull).toList());
for (DealQuotationItemDto item : quotation.items()) {
if (WastageCalculator.LINE_TYPE_ADJUSTMENT.equals(item.lineType())) {
continue;
Expand All @@ -2235,12 +2239,14 @@ private List<TicketItemRequest> ticketItemsFromQuotation(DealQuotationDto quotat
clip(item.color(), TICKET_ITEM_TEXT_MAX),
clip(item.texture(), TICKET_ITEM_TEXT_MAX),
clip(item.sizeText(), TICKET_ITEM_TEXT_MAX),
null, // factory — a direct quotation line carries none
clip(item.catalogPriceId() == null ? null : factoryByPriceId.get(item.catalogPriceId()),
TICKET_ITEM_TEXT_MAX),
qty, qtySqm, "PIECE",
null, null, // rawPrice/rawCurrency — not a costing input, see Javadoc
clip(item.unit(), TICKET_ITEM_RAW_UNIT_MAX),
null, // proposedPrice — see Javadoc
"THB"));
"THB",
item.catalogPriceId(), null));
}
return rows;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ 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" };

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

private final FactoryConfigRepository repo;
private final SessionContext sessions;

Expand All @@ -41,4 +47,12 @@ Map<String, List<FactoryConfigDto>> list(HttpSession session) {
sessions.requireAnyRole(user, READ_ROLES);
return Map.of("factories", repo.findAll());
}

@GetMapping("/names")
Map<String, List<FactoryNameDto>> names(HttpSession session) {
UserPrincipal user = sessions.requireUser(session);
sessions.requireAnyRole(user, NAME_READ_ROLES);
return Map.of("factories", repo.findAll().stream()
.map(f -> new FactoryNameDto(f.id(), f.factoryName())).toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,20 @@ public record QuotationLeadTimeRow(Long ticketItemId, Long factoryId, int minDay
* column, not a real factory selection. Deriving a factory from brand would therefore invent a
* relationship this codebase's own data does not assert.
*/
public boolean ticketHasItem(long ticketId, long ticketItemId) {
return Boolean.TRUE.equals(jdbc.queryForObject(
"SELECT EXISTS(SELECT 1 FROM sales.ticket_item WHERE item_id = :item AND ticket_id = :ticket)",
java.util.Map.of("item", ticketItemId, "ticket", ticketId), Boolean.class));
}

/** Records the factory sales chose for a line that had none; never overwrites an existing name. */
public void setItemFactoryIfBlank(long ticketId, long ticketItemId, String factoryName) {
jdbc.update("""
UPDATE sales.ticket_item SET factory = :name
WHERE item_id = :item AND ticket_id = :ticket AND (factory IS NULL OR BTRIM(factory) = '')
""", java.util.Map.of("name", factoryName, "item", ticketItemId, "ticket", ticketId));
}

public List<LineFactoryCandidate> factoryResolutionCandidates(long ticketId) {
return jdbc.query("""
SELECT ti.item_id, ti.brand, ti.model, ti.size, ti.unit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,25 @@ private ImportRequestRequests() {}
*/
public record CreateImportRequestsRequest(
String clientRequestId,
@Valid List<NewFactoryCountryInput> newFactoryCountries
) {}
@Valid List<NewFactoryCountryInput> newFactoryCountries,
@Valid List<ItemFactoryInput> itemFactories
) {
/** Pre-picker shape: no per-line factory choices. */
public CreateImportRequestsRequest(String clientRequestId, List<NewFactoryCountryInput> newFactoryCountries) {
this(clientRequestId, newFactoryCountries, null);
}
}

/**
* 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
* no factory at all; a line that already has one (catalogue link, PCR factory or typed name) keeps
* it and the choice is ignored.
*
* @param ticketItemId a {@code sales.ticket_item} of THIS deal
* @param factoryId an existing {@code price_catalog.factories} row
*/
public record ItemFactoryInput(@NotNull Long ticketItemId, @NotNull Long factoryId) {}

/**
* One factory NAME's caller-supplied country, for {@code createDrafts}'s auto-create path
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ public List<ImportRequestDto> createDrafts(long ticketId, CreateImportRequestsRe
"สร้างใบขอซื้อได้หลังจากยืนยันคำสั่งซื้อแล้ว (ORDER_RECEIVED) เท่านั้น");
}

applyItemFactoryChoices(ticketId, request);
List<FactoryGroup> groups = resolveFactoryGroups(ticketId, actor.id(), request);
if (groups.isEmpty()) {
throw new ApiException(HttpStatus.CONFLICT,
Expand Down Expand Up @@ -1264,6 +1265,37 @@ private static LeadTimeDefaults.Range leadTimeFromQuotation(
return min == null ? null : new LeadTimeDefaults.Range(min, max);
}

/**
* Sales' factory pick for a deal line that has none (free-text line on a direct quotation, owner
* ruling 2026-10-29). Validated first (unknown factory / a line outside this deal -> 400), applied
* only to a line that resolves to no factory at all — a catalogue, PCR or typed-name line keeps
* its own. The name is written to {@code ticket_item.factory} so the normal cascade resolves it to
* the existing factory; a line still without one afterwards hits today's 409.
*/
private void applyItemFactoryChoices(long ticketId, CreateImportRequestsRequest request) {
if (request == null || request.itemFactories() == null || request.itemFactories().isEmpty()) {
return;
}
Map<Long, LineFactoryCandidate> candidates = repository.factoryResolutionCandidates(ticketId).stream()
.collect(Collectors.toMap(LineFactoryCandidate::ticketItemId, c -> c, (a, b) -> a));
for (ImportRequestRequests.ItemFactoryInput choice : request.itemFactories()) {
FactoryConfigDto factory = freshFactoryLookup(choice.factoryId()).orElseThrow(() ->
new ApiException(HttpStatus.BAD_REQUEST, "ไม่พบโรงงานที่เลือก"));
LineFactoryCandidate line = candidates.get(choice.ticketItemId());
if (line == null) {
// Not an import candidate (e.g. fully reserved from stock): nothing to order, so the pick is
// ignored. A line of ANOTHER deal is still a bad request.
if (!repository.ticketHasItem(ticketId, choice.ticketItemId())) {
throw new ApiException(HttpStatus.BAD_REQUEST, "รายการที่เลือกไม่ได้อยู่ในดีลนี้");
}
continue;
}
if (line.directFactoryId() == null && line.candidateFactoryName() == null) {
repository.setItemFactoryIfBlank(ticketId, line.ticketItemId(), factory.factoryName());
}
}
}

private Optional<FactoryConfigDto> freshFactoryLookup(long factoryId) {
return factories.findAll().stream().filter(f -> f.id() == factoryId).findFirst();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1441,16 +1441,17 @@ public boolean hasLivePurchaseOrders(long ticketId) {
}

/**
* Whether this deal has at least one ISSUED row in the per-factory {@code sales.import_request}
* aggregate (V184) — the same refuse-don't-delegate guard {@link #hasLivePurchaseOrders} provides
* Whether this deal has at least one live (DRAFT or ISSUED, not SUPERSEDED) row in the per-factory
* {@code sales.import_request} aggregate (V184; widened from ISSUED-only by W3-04, owner ruling
* 2026-10-29) — the same refuse-don't-delegate guard {@link #hasLivePurchaseOrders} provides
* for the (dormant) PO path, used by {@code TicketService#markIrSent}/{@code #markShipping}/
* {@code #markGoodsReceived} to refuse a ticket-level click that cannot say which factory it
* concerns once the deal is actually tracked per-factory.
*/
public boolean hasLiveImportRequests(long ticketId) {
Boolean value = jdbc.queryForObject("""
SELECT EXISTS (
SELECT 1 FROM sales.import_request WHERE ticket_id = :ticketId AND status = 'ISSUED'
SELECT 1 FROM sales.import_request WHERE ticket_id = :ticketId AND status <> 'SUPERSEDED'
)
""", Map.of("ticketId", ticketId), Boolean.class);
return Boolean.TRUE.equals(value);
Expand Down
Loading
Loading