Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
117bc03
test(sales): pin that sales may change the deposit % on a draft notic…
waritctd Oct 23, 2026
9b415d5
feat(sales): sales may change the deposit % on a draft notice; the am…
waritctd Oct 23, 2026
119bf02
test(sales): pin that a deposit-notice update without items keeps the…
waritctd Oct 23, 2026
137f703
fix(sales): a deposit-notice update without items keeps the stored li…
waritctd Oct 23, 2026
180c29a
test(sales): pin that a deposit % must be a whole number, over 100% g…
waritctd Oct 23, 2026
1dafe7b
fix(sales): a deposit % must be a whole number; over 100% gets a Thai…
waritctd Oct 23, 2026
538b5f3
refactor(sales): carry an optional updatedAt stamp on the deposit-not…
waritctd Oct 23, 2026
112e89e
test(sales): pin the deposit-notice issue re-check, stale-save and is…
waritctd Oct 23, 2026
a3669ca
fix(sales): issue re-checks the stored % and amount; a stale or post-…
waritctd Oct 23, 2026
ac95268
test(sales): pin one deposit notice per deal on both create routes an…
waritctd Oct 23, 2026
d7fb0c6
fix(sales): one deposit notice per deal on both create routes; fold t…
waritctd Oct 23, 2026
6832ee1
test(sales): pin the deposit editor's payload and money, the mock's c…
waritctd Oct 23, 2026
561ff39
fix(sales): the deposit editor saves and shows what the backend store…
waritctd Oct 23, 2026
f28cbe4
chore(sales): move the one-notice mock helper and two new tests away …
waritctd Oct 23, 2026
9e0dfdf
chore(sales): qualify ApiException in DepositNoticeRepository so its …
waritctd Oct 23, 2026
4d92a57
fix(sales): issuing is refused while another deposit notice on the de…
waritctd Oct 27, 2026
9512462
fix(deposit): panel passes quotation+notices to the create gate, issu…
waritctd Oct 27, 2026
a8512a6
test(deposit): revise tests pinned to the pre-ruling re-issue superse…
waritctd Oct 27, 2026
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 @@ -98,9 +98,11 @@ ResponseEntity<byte[]> preview(@PathVariable long docId, HttpSession session) {

// Issue (assign doc number + transition ticket)
@PostMapping("/deposit-notices/{docId}/issue")
Map<String, DepositNoticeDto> issue(@PathVariable long docId, HttpSession session) {
Map<String, DepositNoticeDto> issue(@PathVariable long docId,
@RequestBody(required = false) DepositNoticeIssueRequest body,
HttpSession session) {
UserPrincipal user = sessions.requireUser(session);
return Map.of("depositNotice", service.issue(docId, user));
return Map.of("depositNotice", service.issue(docId, body == null ? null : body.expectedUpdatedAt(), user));
}

// File download
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;

public record DepositNoticeDraftRequest(
Expand All @@ -13,7 +14,17 @@ public record DepositNoticeDraftRequest(
@Size(max = 255) String customerAddress,
@Size(max = 255) String projectName,
@Size(max = 255) String reference,
@DecimalMin("0") @DecimalMax("1") BigDecimal depositPercent,
@DecimalMin(value = "0", inclusive = false, message = "เปอร์เซ็นต์มัดจำต้องมากกว่า 0")
@DecimalMax(value = "1", message = "เปอร์เซ็นต์มัดจำต้องไม่เกิน 100") BigDecimal depositPercent,
List<String> notes,
@Valid List<DepositNoticeItemRequest> items
) {}
@Valid List<DepositNoticeItemRequest> items,
// The draft's updatedAt as this client last read it. null skips the stale-save check (older clients).
OffsetDateTime expectedUpdatedAt
) {
/** Without a stamp: creation and the server-side callers. */
public DepositNoticeDraftRequest(String customerName, String customerTaxId, String customerAddress,
String projectName, String reference, BigDecimal depositPercent,
List<String> notes, List<DepositNoticeItemRequest> items) {
this(customerName, customerTaxId, customerAddress, projectName, reference, depositPercent, notes, items, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package th.co.glr.hr.deposit;

import java.time.OffsetDateTime;

/** Optional body of the issue call: the draft's updatedAt as the caller last read it (null skips the check). */
public record DepositNoticeIssueRequest(OffsetDateTime expectedUpdatedAt) {}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
Expand All @@ -23,6 +24,9 @@

@Repository
public class DepositNoticeRepository {
static final String NOT_DRAFT = "ใบแจ้งรับมัดจำนี้ไม่ได้อยู่ในสถานะร่าง (DRAFT)";
static final String EDITED_ELSEWHERE = "ใบแจ้งรับมัดจำนี้ถูกแก้ไขโดยผู้ใช้อื่น กรุณาโหลดข้อมูลใหม่แล้วลองอีกครั้ง";

private final NamedParameterJdbcTemplate jdbc;

public DepositNoticeRepository(NamedParameterJdbcTemplate jdbc) {
Expand Down Expand Up @@ -199,14 +203,22 @@ public long createDraft(long ticketId, DepositNoticeDraftRequest req, List<Depos
return docId;
}

/**
* Saves a DRAFT. Refuses with 409 before touching the lines when the row is no longer DRAFT
* (issued after the service read it) or, given {@code req.expectedUpdatedAt()}, someone else
* saved it since that stamp. {@code req.depositPercent()} is always set by the service.
*/
@Transactional
public void update(long docId, DepositNoticeDraftRequest req) {
BigDecimal depositPct = req.depositPercent() != null ? req.depositPercent() : new BigDecimal("0.50");
public void update(long docId, DepositNoticeDraftRequest req, List<DepositNoticeItemDto> storedItems) {
BigDecimal depositPct = req.depositPercent();
BigDecimal vatPct = new BigDecimal("0.07");
List<DepositNoticeItemRequest> items = req.items() != null ? req.items() : List.of();

BigDecimal subtotal = items.stream()
.map(it -> it.netUnitPrice().multiply(it.qty()))
// No items in the request (null or empty) keeps the stored lines (they are only replaced
// below when items are sent), so the subtotal must come from those same stored lines.
BigDecimal subtotal = (items.isEmpty()
? storedItems.stream().map(it -> it.netUnitPrice().multiply(it.qty()))
: items.stream().map(it -> it.netUnitPrice().multiply(it.qty())))
.reduce(BigDecimal.ZERO, BigDecimal::add)
.setScale(2, RoundingMode.HALF_UP);
BigDecimal deposit = subtotal.multiply(depositPct).setScale(2, RoundingMode.HALF_UP);
Expand All @@ -215,7 +227,7 @@ public void update(long docId, DepositNoticeDraftRequest req) {

String[] notesArr = req.notes() != null ? req.notes().toArray(String[]::new) : new String[0];

jdbc.update("""
int rows = jdbc.update("""
UPDATE sales.deposit_notice SET
customer_name = :customerName,
customer_tax_id = :customerTaxId,
Expand All @@ -230,9 +242,11 @@ public void update(long docId, DepositNoticeDraftRequest req) {
notes = :notes,
updated_at = now()
WHERE deposit_notice_id = :id AND status = 'DRAFT'
AND (CAST(:expectedUpdatedAt AS timestamptz) IS NULL OR updated_at = :expectedUpdatedAt)
""",
new MapSqlParameterSource()
.addValue("id", docId)
.addValue("expectedUpdatedAt", req.expectedUpdatedAt())
.addValue("customerName", req.customerName())
.addValue("customerTaxId", req.customerTaxId())
.addValue("customerAddress", req.customerAddress())
Expand All @@ -245,6 +259,12 @@ public void update(long docId, DepositNoticeDraftRequest req) {
.addValue("totalPayable", total)
.addValue("notes", notesArr)
);
if (rows == 0) {
boolean stillDraft = req.expectedUpdatedAt() != null && !jdbc.queryForList(
"SELECT 1 FROM sales.deposit_notice WHERE deposit_notice_id = :id AND status = 'DRAFT'",
Map.of("id", docId), Integer.class).isEmpty();
throw new th.co.glr.hr.common.ApiException(HttpStatus.CONFLICT, stillDraft ? EDITED_ELSEWHERE : NOT_DRAFT);
}
if (!items.isEmpty()) {
jdbc.update("DELETE FROM sales.deposit_notice_item WHERE deposit_notice_id = :id", Map.of("id", docId));
insertItems(docId, items);
Expand All @@ -270,8 +290,12 @@ public void update(long docId, DepositNoticeDraftRequest req) {
*
* @return the new doc number, or {@link Optional#empty()} if the row was not DRAFT.
*/
@Transactional
public Optional<String> issue(long docId, long actorId, String actorName) {
return issue(docId, actorId, actorName, null);
}

@Transactional
public Optional<String> issue(long docId, long actorId, String actorName, java.time.OffsetDateTime expectedUpdatedAt) {
int thaiYear = Year.now().getValue() + 543;
String docNumber = nextDocNumber("DEPOSIT_NOTICE", thaiYear);

Expand All @@ -285,21 +309,23 @@ public Optional<String> issue(long docId, long actorId, String actorName) {
updated_at = now()
WHERE deposit_notice_id = :id
AND status = 'DRAFT'
AND (CAST(:expectedUpdatedAt AS timestamptz) IS NULL OR updated_at = :expectedUpdatedAt)
""",
Map.of("num", docNumber, "actorId", actorId, "actorName", actorName, "id", docId));
new MapSqlParameterSource()
.addValue("num", docNumber).addValue("actorId", actorId).addValue("actorName", actorName)
.addValue("id", docId).addValue("expectedUpdatedAt", expectedUpdatedAt));

if (rows == 0) {
// Still a draft with a stamp given => it was saved after the caller read it.
boolean editedSince = expectedUpdatedAt != null && !jdbc.queryForList(
"SELECT 1 FROM sales.deposit_notice WHERE deposit_notice_id = :id AND status = 'DRAFT'",
Map.of("id", docId), Integer.class).isEmpty();
if (editedSince) {
throw new th.co.glr.hr.common.ApiException(HttpStatus.CONFLICT, EDITED_ELSEWHERE);
}
return Optional.empty();
}

// Supersede all older versions for same ticket
jdbc.update("""
UPDATE sales.deposit_notice SET status = 'SUPERSEDED', updated_at = now()
WHERE ticket_id = (SELECT ticket_id FROM sales.deposit_notice WHERE deposit_notice_id = :id)
AND deposit_notice_id <> :id
AND status = 'ISSUED'
""", Map.of("id", docId));

return Optional.of(docNumber);
}

Expand Down Expand Up @@ -365,14 +391,23 @@ private void insertItems(long docId, List<DepositNoticeItemRequest> items) {
* items. Reads the RESOLVED display columns the quotation itself prints
* ({@code qty}/{@code raw_unit}/{@code unit_price}/{@code final_unit_price}), NOT the legacy
* {@code requested_*}/{@code approved_unit_price} columns the V2 chain leaves null. The
* discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. */
* discount-label wording matches {@link DepositNoticeService#itemsFromQuotation}. An ADJUSTMENT
* row is folded into the goods lines, see {@link #foldAdjustments}. */
public List<DepositNoticeItemRequest> findDealQuotationItems(long quotationId) {
return jdbc.query("""
SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price
List<DepositNoticeItemRequest> goods = new java.util.ArrayList<>();
BigDecimal[] adjustment = {BigDecimal.ZERO};
jdbc.query("""
SELECT seq, description, qty, raw_unit, unit_price, sales_discount, final_unit_price, line_type
FROM sales.quotation_item
WHERE quotation_id = :quotationId
ORDER BY seq
""", Map.of("quotationId", quotationId), (rs, i) -> {
""", Map.of("quotationId", quotationId), rs -> {
if ("ADJUSTMENT".equals(rs.getString("line_type"))) {
BigDecimal net = rs.getBigDecimal("final_unit_price");
BigDecimal qty = rs.getBigDecimal("qty");
if (net != null && qty != null) adjustment[0] = adjustment[0].add(net.multiply(qty));
return;
}
String description = rs.getString("description");
if (description == null || description.isBlank()) {
description = "รายการสินค้า";
Expand All @@ -385,10 +420,49 @@ public List<DepositNoticeItemRequest> findDealQuotationItems(long quotationId) {
String discountLabel = discount != null && discount.signum() > 0
? "ส่วนลด " + discount.stripTrailingZeros().toPlainString() + " ต่อหน่วย"
: null;
return new DepositNoticeItemRequest(
goods.add(new DepositNoticeItemRequest(
rs.getInt("seq"), description, rs.getBigDecimal("qty"), unit,
rs.getBigDecimal("unit_price"), discountLabel, rs.getBigDecimal("final_unit_price"));
rs.getBigDecimal("unit_price"), discountLabel, rs.getBigDecimal("final_unit_price")));
});
return foldAdjustments(goods, adjustment[0]);
}

/**
* Owner rulings 2026-10-23: a DEAL_DIRECT ส่วนลดพิเศษ row (ADJUSTMENT, qty -1) cannot be a
* deposit line (the editor's PUT requires qty &gt;= 0), so its amount is spread over the goods
* lines in proportion to their amount. "2 decimals for all": each share is HALF_UP to 2 dp and
* each line's new net price is 2 dp, with no remainder pushed onto a last line. The notice
* totals are the sum of its own stored 2 dp lines ({@code amount = net x qty}), so they can
* differ from the quotation's total by under qty x 0.005 baht per line; that is the ruled
* outcome, not a bug. A line keeps the quotation's own discount wording, and gains the folded
* discount after it only when the fold lowered its price. A free line stays as it is.
*/
static List<DepositNoticeItemRequest> foldAdjustments(List<DepositNoticeItemRequest> goods,
BigDecimal adjustment) {
BigDecimal goodsTotal = goods.stream().map(it -> it.netUnitPrice().multiply(it.qty()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (adjustment.signum() == 0 || goodsTotal.signum() == 0) {
return goods;
}
List<DepositNoticeItemRequest> folded = new java.util.ArrayList<>();
for (DepositNoticeItemRequest it : goods) {
BigDecimal amount = it.netUnitPrice().multiply(it.qty());
if (amount.signum() == 0) {
folded.add(it);
continue;
}
BigDecimal share = adjustment.multiply(amount).divide(goodsTotal, 2, RoundingMode.HALF_UP);
BigDecimal net = amount.add(share).divide(it.qty(), 2, RoundingMode.HALF_UP);
BigDecimal foldedPerUnit = it.netUnitPrice().subtract(net);
String label = it.discountLabel();
if (foldedPerUnit.signum() > 0) {
String extra = "ส่วนลดพิเศษ " + foldedPerUnit.stripTrailingZeros().toPlainString() + " ต่อหน่วย";
label = label == null || label.isBlank() ? extra : label + " + " + extra;
}
folded.add(new DepositNoticeItemRequest(it.seq(), it.description(), it.qty(), it.unit(), it.unitPrice(),
label, net));
}
return folded;
}

private List<DepositNoticeItemDto> findItems(long docId) {
Expand Down
Loading
Loading