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 @@ -835,11 +835,16 @@ public record DealQuotationItemDto(
* Non-null ONLY on a TILE row of a document whose {@code priceMode} is {@code MIXED}; on
* every other row it is null and the document's mode applies
* ({@code WastageCalculator#rowPriceMode} is the one rule). */
String priceMode
String priceMode,
/** V194 — "IN_THAILAND" | "IN_TRANSIT" when this row came from a stock-flagged pricing-request
* line (owner ruling 2026-10-28: stock lines skip import, so they carry no import lead time);
* null = สั่งนำเข้า, and null on every DEAL_DIRECT row. Read-only: set at insert from the
* pricing-request item, never accepted from the client. */
String stockSource
) {
/** Ruling 3 (2026-09-30): the account view -- net unit price and line amount stay; every list-price / discount / CEO pricing field is emptied. */
public DealQuotationItemDto withoutPriceInternals() {
return new DealQuotationItemDto(id, seq, locationLabel, null, productCode, brand, model, color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, piecesPerBox, null, null, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, null, lineType, quantity, unit, null, null, adjustmentDeadline, null, null, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, false, null, null, null, null, null, null, directNetPriceSqm, null);
return new DealQuotationItemDto(id, seq, locationLabel, null, productCode, brand, model, color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue, piecesPerBox, null, null, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, null, lineType, quantity, unit, null, null, adjustmentDeadline, null, null, hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, false, null, null, null, null, null, null, directNetPriceSqm, null, stockSource);
}

/** This item carrying {@code value} as its own {@link #priceMode} — the same appended-field
Expand All @@ -854,7 +859,49 @@ public DealQuotationItemDto withPriceMode(String value) {
unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox,
priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice,
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, value);
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, value, stockSource);
}

/** The pre-stockSource canonical shape (58 components) -- stockSource null (import). */
public DealQuotationItemDto(
long id, int seq, String locationLabel, Long catalogPriceId, String productCode,
String brand, String model, String color, String texture, String sizeText,
BigDecimal thicknessMm, BigDecimal sqmPerPiece, String quantityMode, BigDecimal areaSqm,
Integer piecesInput, String wastageMode, BigDecimal wastageValue, Integer piecesPerBox,
BigDecimal unitPrice, BigDecimal discountPct, String originCountry,
Integer leadTimeMinDays, Integer leadTimeMaxDays, String itemNotes,
BigDecimal piecesPerSqm, int piecesBeforeWastage, int piecesAfterWastage, int piecesFinal,
Integer boxes, BigDecimal netUnitPrice, BigDecimal lineAmount,
String descriptionLine, String sizeLine, String calculationLine,
String lineType, BigDecimal quantity, String unit, BigDecimal specialPriceSqm,
BigDecimal adjustmentPct, LocalDate adjustmentDeadline, String specialPriceLine,
BigDecimal adjustmentAmount, boolean hasPicture, String picturePlacement, String pictureUrl,
BigDecimal sqmPerBox, boolean roundToFullBox, boolean priceChangedFromCeo,
BigDecimal ceoListUnitPrice, BigDecimal ceoDiscountPct, BigDecimal ceoSpecialPriceSqm,
BigDecimal ceoDirectNetPrice, BigDecimal ceoNetUnitPrice, BigDecimal listPriceSqm,
BigDecimal directNetPriceSqm, String priceMode) {
this(id, seq, locationLabel, catalogPriceId, productCode, brand, model, color, texture, sizeText,
thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput, wastageMode, wastageValue,
piecesPerBox, unitPrice, discountPct, originCountry, leadTimeMinDays, leadTimeMaxDays, itemNotes,
piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal, boxes, netUnitPrice,
lineAmount, descriptionLine, sizeLine, calculationLine, lineType, quantity, unit,
specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox, priceChangedFromCeo,
ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice, ceoNetUnitPrice,
listPriceSqm, directNetPriceSqm, priceMode, null);
}

/** This item carrying {@code value} as its {@link #stockSource}. */
public DealQuotationItemDto withStockSource(String value) {
return new DealQuotationItemDto(id, seq, locationLabel, catalogPriceId, productCode, brand, model,
color, texture, sizeText, thicknessMm, sqmPerPiece, quantityMode, areaSqm, piecesInput,
wastageMode, wastageValue, piecesPerBox, unitPrice, discountPct, originCountry, leadTimeMinDays,
leadTimeMaxDays, itemNotes, piecesPerSqm, piecesBeforeWastage, piecesAfterWastage, piecesFinal,
boxes, netUnitPrice, lineAmount, descriptionLine, sizeLine, calculationLine, lineType, quantity,
unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox,
priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice,
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, value);
}

/** The pre-GLA-123 canonical shape (no CEO-original/changed-flag fields) — kept so every
Expand Down Expand Up @@ -943,7 +990,7 @@ public DealQuotationItemDto withSqmPerBox(BigDecimal value) {
unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, value, roundToFullBox,
priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice,
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode);
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource);
}

/** This item carrying {@code value} as its {@link #roundToFullBox} — same device as
Expand All @@ -958,7 +1005,7 @@ public DealQuotationItemDto withRoundToFullBox(boolean value) {
unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, sqmPerBox, value,
priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice,
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode);
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource);
}

/** This item carrying the GLA-123 CEO-comparison fields — same device as
Expand All @@ -974,7 +1021,7 @@ public DealQuotationItemDto withCeoComparison(boolean changed, BigDecimal listUn
unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox,
changed, listUnitPrice, discountPct2, specialPriceSqm2, directNetPrice, netUnitPrice2,
listPriceSqm, directNetPriceSqm, priceMode);
listPriceSqm, directNetPriceSqm, priceMode, stockSource);
}

/** This item carrying the per-ตร.ม. prices as the rep typed them (V192.2) — the same
Expand All @@ -990,7 +1037,7 @@ public DealQuotationItemDto withPerSqmPrices(BigDecimal list, BigDecimal directN
unit, specialPriceSqm, adjustmentPct, adjustmentDeadline, specialPriceLine, adjustmentAmount,
hasPicture, picturePlacement, pictureUrl, sqmPerBox, roundToFullBox,
priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice,
ceoNetUnitPrice, list, directNet, priceMode);
ceoNetUnitPrice, list, directNet, priceMode, stockSource);
}

/** This item with its picture fields set from the stored link (repository read path). */
Expand All @@ -1006,7 +1053,7 @@ public DealQuotationItemDto withPicture(long quotationId, String placement) {
has ? "/api/deal-quotations/" + quotationId + "/items/" + id + "/picture" : null, sqmPerBox,
roundToFullBox,
priceChangedFromCeo, ceoListUnitPrice, ceoDiscountPct, ceoSpecialPriceSqm, ceoDirectNetPrice,
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode);
ceoNetUnitPrice, listPriceSqm, directNetPriceSqm, priceMode, stockSource);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1839,7 +1839,7 @@ private List<DealQuotationItemDto> findItems(long quotationId) {
final_unit_price, amount,
raw_unit, description, line_type, qi.special_price_sqm, adjustment_pct, adjustment_deadline,
picture_placement, sqm_per_box, round_to_full_box,
qi.list_price_sqm, qi.direct_net_price_sqm,
qi.list_price_sqm, qi.direct_net_price_sqm, qi.stock_source,
-- V192.5: the ROW's own mode. Aliased because "price_mode" below is already the
-- DOCUMENT's (the subselect), and #mapItemColumns needs both to tell them apart.
qi.price_mode AS item_price_mode,
Expand Down Expand Up @@ -1890,7 +1890,7 @@ private Map<Long, List<DealQuotationItemDto>> findItemsForQuotations(List<Long>
final_unit_price, amount,
raw_unit, description, line_type, qi.special_price_sqm, adjustment_pct, adjustment_deadline,
picture_placement, sqm_per_box, round_to_full_box,
qi.list_price_sqm, qi.direct_net_price_sqm,
qi.list_price_sqm, qi.direct_net_price_sqm, qi.stock_source,
-- V192.5: the ROW's own mode. Aliased because "price_mode" below is already the
-- DOCUMENT's (the subselect), and #mapItemColumns needs both to tell them apart.
qi.price_mode AS item_price_mode,
Expand Down Expand Up @@ -2266,7 +2266,7 @@ private boolean ceoPriceModeChanged(ResultSet rs) throws SQLException {
* the bytes are served separately by {@link #findItemPicture}. An item without a picture
* keeps the legacy constructor's {@code hasPicture=false} shape exactly. */
private DealQuotationItemDto mapItem(ResultSet rs, Map<Long, CatalogSqmBasis> basisByPriceId) throws SQLException {
DealQuotationItemDto item = mapItemColumns(rs, basisByPriceId);
DealQuotationItemDto item = mapItemColumns(rs, basisByPriceId).withStockSource(rs.getString("stock_source"));
String placement = rs.getString("picture_placement");
return placement == null ? item : item.withPicture(rs.getLong("quotation_id"), placement);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4068,7 +4068,8 @@ private void requireEveryTileItemHasALeadTime(List<DealQuotationItemDto> items)
List<String> missingSeqs = new ArrayList<>();
for (DealQuotationItemDto item : items) {
boolean tile = item.lineType() == null || WastageCalculator.LINE_TYPE_TILE.equals(item.lineType());
if (tile && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) {
// In-Thailand stock lines skip import (owner ruling 2026-10-28); IN_TRANSIT still needs a lead time (2026-10-29).
if (tile && !"IN_THAILAND".equals(item.stockSource()) && (item.leadTimeMinDays() == null || item.leadTimeMaxDays() == null)) {
missingSeqs.add(String.valueOf(item.seq()));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2031,6 +2031,42 @@ void submit_reChecksStoredItems_missingLeadTimeIsBadRequest() {
.hasMessageContaining("กรุณาระบุระยะเวลานำเข้า");
}

/** VW-01 (owner ruling 2026-10-28: stock lines skip import). A TILE row that came from a
* stock-flagged pricing-request line (quotation_item.stock_source set, V194) has no import
* lead time, so the submit gate must not demand one -- while the import row in the same
* document (stock_source NULL, still no lead time) is still refused, and only IT is named. */
@Test
void submit_inThailandStockLineNeedsNoLeadTime_butInTransitAndImportStillDo() {
DealQuotationDto created = quotationService.create(ticketId,
upsertRequest(List.of(sampleItem("100.00", 10), sampleItem("100.00", 10))), salesActor);
jdbc.update("""
UPDATE sales.quotation_item SET lead_time_min_days = NULL, lead_time_max_days = NULL
WHERE quotation_id = :id
""", java.util.Map.of("id", created.id()));
jdbc.update("""
UPDATE sales.quotation_item SET stock_source = 'IN_THAILAND'
WHERE quotation_id = :id AND seq = 1
""", java.util.Map.of("id", created.id()));
// Owner ruling 2026-10-29: only IN_THAILAND skips import; an IN_TRANSIT line still needs a lead time.
jdbc.update("""
UPDATE sales.quotation_item SET stock_source = 'IN_TRANSIT'
WHERE quotation_id = :id AND seq = 2
""", java.util.Map.of("id", created.id()));

assertThatThrownBy(() -> quotationService.submit(created.id(), salesActor))
.isInstanceOf(ApiException.class)
.hasFieldOrPropertyWithValue("status", HttpStatus.BAD_REQUEST)
.hasMessageContaining("กรุณาระบุระยะเวลานำเข้า (วัน) ของรายการที่ 2")
.satisfies(e -> assertThat(((ApiException) e).getMessage()).doesNotContain("1"));

jdbc.update("""
UPDATE sales.quotation_item SET stock_source = 'IN_TRANSIT', lead_time_min_days = 30, lead_time_max_days = 45
WHERE quotation_id = :id AND seq = 2
""", java.util.Map.of("id", created.id()));
assertThat(quotationService.submit(created.id(), salesActor).docStatus())
.isEqualTo(QuotationStatus.PENDING_APPROVAL);
}

// ── Wording-scan fix 3 (2026-09-17): an ENTERED lead-time range must be min>=1, max>=min ────

/** {@code ประมาณ 0-3 วัน} saved on 2 approved quotations, and {@code 90-75}/negatives would too
Expand Down
3 changes: 2 additions & 1 deletion docs/api/response-fields.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$comment" : "GENERATED by ResponseFieldContractTest — do not hand-edit. Regenerate: cd backend && ./mvnw -Dtest=ResponseFieldContractTest -Dresponse.fields.update=true test",
"source" : "Schema.field pairs from springdoc /v3/api-docs response schemas, kept where the field name is read in frontend/src (excluding mockApi, hrApi, routes and tests)",
"count" : 1462,
"count" : 1463,
"fields" : [
"Action.requiredFields",
"Action.targetStage",
Expand Down Expand Up @@ -368,6 +368,7 @@
"DealQuotationItemDto.specialPriceSqm",
"DealQuotationItemDto.sqmPerBox",
"DealQuotationItemDto.sqmPerPiece",
"DealQuotationItemDto.stockSource",
"DealQuotationItemDto.thicknessMm",
"DealQuotationItemDto.unitPrice",
"DealQuotationItemDto.wastageMode",
Expand Down
2 changes: 1 addition & 1 deletion docs/api/ui-reachable.json
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@
"POST /api/deal-quotations/{id}/approve",
"POST /api/deal-quotations/{id}/attachments",
"POST /api/deal-quotations/{id}/cancel",
"POST /api/deal-quotations/{id}/confirm-order",
"POST /api/deal-quotations/{id}/items/{pricingDecisionItemId}/restore",
"POST /api/deal-quotations/{id}/outcome",
"POST /api/deal-quotations/{id}/promote-to-deal",
"POST /api/deal-quotations/{id}/reject",
"POST /api/deal-quotations/{id}/revisions",
"POST /api/deal-quotations/{id}/submit",
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ export const API_ROUTES = {
// GLA-136 (owner ruling 2026-09-30) -- "สร้างดีลจากใบเสนอราคา": promote an APPROVED direct
// quotation's quotation-only container ticket into the pipeline at ORDER_RECEIVED. Mirrors
// DealQuotationController#promoteToDeal.
promoteToDeal: (id) => `/api/deal-quotations/${id}/promote-to-deal`,
promoteToDeal: (id) => `/api/deal-quotations/${id}/confirm-order`,
cancel: (id) => `/api/deal-quotations/${id}/cancel`,
file: (id, format) => `/api/deal-quotations/${id}/file?format=${format}`,
// M4(d) fix (Opus review, 2026-09-20) — "คืนรายการ": re-adds a CEO-linked line a prior save
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/api/serverContract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ const SERVER_ONLY = {
'PATCH /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.',
'DELETE /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.',
'GET /api/deal-quotations/{}/items/{}/picture': 'GLA-75 quotation item pictures (V170), built BACKEND-FIRST: the per-item picture endpoints landed before the quotation editor UI that calls them (a separate frontend branch). Gated like editing / viewing the quotation and covered by DealQuotationPictureIntegrationTest. LIVE, not dead — delete this entry when hrApi.js calls it.',
'POST /api/deal-quotations/{}/confirm-order': 'Quotation ↔ deal linking slice 1 (IA §7, 2026-09-30), built BACKEND-FIRST: the real name of GLA-136\'s promote-to-deal, which DealQuotationController maps to the SAME handler as a one-release alias. hrApi.js still calls …/promote-to-deal (identical behaviour) until the slice-1 frontend switches it over. Covered by DealQuotationConfirmOrderIntegrationTest and DealQuotationControllerTest. LIVE, not dead — delete this entry when hrApi.js calls it.',
'POST /api/deal-quotations/{}/promote-to-deal': 'GLA-136 legacy alias of …/confirm-order (same handler), kept for ONE release so a still-cached frontend bundle keeps working; the frontend now calls …/confirm-order. Pinned by DealQuotationControllerTest. Remove with the controller alias.',


// ── Formerly dormant, now GONE: ProcurementController ─────────────────────
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/features/quotations/QuotationEditorPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3533,7 +3533,7 @@ export function QuotationEditorPage({ user, showToast }) {
<Button
variant="primary"
loading={promoteMutation.isPending}
onClick={() => promoteMutation.mutate()}
onClick={() => { setPromoteConfirmOpen(false); promoteMutation.mutate(); }}
data-testid="quotation-promote-to-deal-confirm"
>
ยืนยันคำสั่งซื้อ
Expand Down
Loading
Loading