Skip to content

release: develop → main 2026-10-28 (backend image v2026-10-28, V200) - #1179

Merged
waritctd merged 118 commits into
mainfrom
release/2026-10-28-develop-to-main
Oct 28, 2026
Merged

waritctd merged 118 commits into
mainfrom
release/2026-10-28-develop-to-main

Conversation

@waritctd

Copy link
Copy Markdown
Owner

Release 2026-10-28: develop → main

Owner ruling (Ploy, 2026-10-28): ship all of develop to main as one release, backend image first, V200 included; code-review main..develop before merging.

Carries (merged into develop since the 10-22 release, #1169): #1168 (deal-page next-party CTA, new-engine quotations), #1170 (deposit notice bills the latest answered quotation; blank new-engine % = 30), #1173 (0% quotation → no-deposit path), #1171 (owner check on the ticket-route deposit-notice draft), #1174, #1177, #1162 (sticky chrome condense), #1164 (CEO per-line margin), #1165 (import profiles batch, V200), #1150, #1127, #1151. Plus render.yaml → v2026-10-28.

Dependencies inside the release: #1168 relies on #1170 (otherwise a blank deposit % bills 50% against a 30% PDF), and #1170 must not ship without #1173. #1166 (backend for new-engine deposit notices) is already on main and prod since 10-22.

Deploy order

  1. Build and push ghcr.io/waritctd/glr-hr-backend:v2026-10-28 from this branch (scripts/build-push-backend-image.sh).
  2. Render → Existing Image v2026-10-28; Flyway applies V200 (prod history checked read-only first).
  3. Merge this PR (merge = Vercel frontend deploy). Merging before step 2 ships a frontend that calls the new backend.

Not merging until the code review is reported and the owner says go.

🤖 Generated with Claude Code

waritctd and others added 30 commits October 22, 2026 13:51
…ance, and opens new-engine quotations

Owner rulings 2026-10-22. After a designer or owner accepts a quotation the
deal page used to offer only ยืนยันคำสั่งซื้อ, which confirmed the customer and
jumped the deal from ขั้น 4 to ขั้น 10 (then 11 on the deposit). Now:

- A. After a non-last-party acceptance the sticky primary is
  เสนอราคา<next party> with a menu of the remaining on-route parties
  (เสนอราคาเจ้าของโครงการ / ข้ามไปเสนอราคาผู้ซื้อ), an info line in the action
  bar, and the pipeline card's ถัดไป line naming both choices. Picking one
  opens the price-request form with ผู้รับ pre-filled; the deal moves when the
  quotation is created, as before. ยืนยันคำสั่งซื้อ only when no on-route party
  remains. A later party's live request means neither is offered.
- B. When the CEO has settled the price, เสนอใบราคา<party> creates the
  quotation draft in the new engine and opens /quotations/:id (UAT ruling R2
  of 2026-10-18). The deal page's old-engine one-click create+issue path is
  removed.
- C. Quotation rows in the deal's เอกสาร tab link to their quotation page.
- บันทึกผลใบเสนอราคา opens the price-request page, where the outcome is
  recorded for both engines, instead of a panel that knew only the old one.

Frontend only. Tests revised first and shown red; 5 tests pinning the removed
old-engine path deleted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- confirmOrderGatePasses takes the deal's own requests, already filtered.
- DealQuotationPanel reads PARTY_LABEL instead of keeping its own copy.
- TicketDetailPage derives off-route parties from stageCatalog's offRouteCodes
  (now exported) instead of re-filtering stageDecisions.
- The banner ternary loses a redundant branch.
- OverflowMenu spreads triggerProps first, so a caller can never override the
  menu's own handlers or ARIA.

No behaviour change; clean-review pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GHSA-qxg6-89mg-rqv8 and GHSA-xp26-3454-mjgp on vitest / @vitest/mocker
(moderate) fail `npm run audit`; develop's own Frontend CI fails the same
job at 9ff4283. 4.1.12 is the fix release. Tickets, pricing-request and
dashboard suites: 1578/1578 on the new version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test drives three saves with per-row waits and takes 5–17 s on a loaded
CI runner (it timed out at 5699 ms on #1168's first run, and passes alone in
under 5 s). It is slow, not hung.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
POST /api/tickets/{id}/deposit-notice/draft reaches
DepositNoticeService.createDraftFromQuotation, which checks role and ticket
status but not ownership, unlike update/issue. These tests pin the refusal
(403, no deposit_notice row) and the owner positive control, against real
Postgres and in the mock. Red until the guard lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createDraftFromQuotation now calls requireTicketOwner after
requireApprovedTicket, the same owner-only guard update/issue use. Any
sales user could previously draft on another rep's deal, read its
quotation lines, prices, percent and customer, and plant the owner's
working draft. The pricing-request route already ran requireOwner before
delegating, so it behaves the same. The mock's depositNotices.createDraft
mirrors requireRole(sales) + owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eal status; mock pins the sales-only role gate

Review notes on #1171: a non-owning rep must get 403 on an unapproved deal too (not the
409 that leaks its status), and the mock's new role gate had no test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Review note on #1171: with the status check first, a non-owning rep learned whether
another rep's deal was approved (409) before being refused (403). Owner first, like
the pricing-request route and RemainingInvoiceService. Mock: drop the optional chaining
that could never fire (findTicketRaw already 404s).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-owner-check

fix(sales): owner check on the ticket-route deposit-notice draft
No behaviour change. The helper is about to map account's stale deal links
too, so the import-only name would lie.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…als under the lock

Under the self-service lock canAccessPath refuses account every /tickets path,
so RequireAccess renders the access-denied page before TicketDetailRoute's own
account redirect can mount. The deposit-notice notification sent to account
links /tickets/:id. Red until the guard redirects account.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ce lock

Under the lock canAccessPath refuses account every /tickets path, so
RequireAccess showed the access-denied page and TicketDetailRoute's account
redirect never mounted. The deposit-notice notification (bell + e-mail) links
/tickets/:id, so account dead-ended instead of reaching ยืนยันรับมัดจำ.

dealRedirectFor now maps a role to its per-deal page: import keeps
/import/deals/:id for the bare path only; account goes to /finance/deals/:id
from /tickets/:id and its sub-paths (/tickets/:id/deposit). /tickets stays
refused. RELEASED_PATHS_BY_ROLE is unchanged. UI routing only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…-under-lock

fix(shell): account's deal links land on การเงิน under the self-service lock
…ries

The audit-gate (threshold=moderate, prod+dev) started failing on every
open PR after two advisories were newly published — unrelated to any PR's
code; develop's own audit is green only because it ran before they landed.

- zod ^4.4.3 -> ^4.6.7 (GHSA-mrhx-c7xc-m3w5; minor, same major)
- overrides.undici ^7.30.1 (GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472,
  GHSA-xjj6-f33q-929c; undici is a jsdom/test transitive, patch bump)

`npm run audit` now reports 0 advisories at/above moderate; the zod-form
suites pass unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chore(deps): clear high-severity undici/zod advisories blocking CI audit
…LEA, Panaria, REFIN, Vives)

The owner sent re-issued 2026/2027 price lists for 5 factories. Each already carries an import
profile seeded by V40 (Aug 2026), but (a) the new workbooks' column headers changed and (b) every
seed predates ImportProfile's now-REQUIRED size_unit/thickness_unit (added by the 2026-09
catalogue-accuracy work), so ImportEngine#parse refuses them until set. V200 upserts a complete,
corrected config per factory (ON CONFLICT uq_profile_factory), so uploading the new file just works.

Per factory (validated header-by-header against the actual workbook; size_unit cross-checked
m²/box = w×h×pcs/box, never guessed):
- CDE (Cotto d'Este): columns unchanged; adds size_unit/thickness_unit = mm.
- LEA: Thickness (was SPESSORE), Pieces/box (was PZ_SCA), "m² per box" (was MQ_SCA), kg/box; mm/mm.
- Panaria: Thickness, Pieces/box (header still row 2; MQ_SCA/KG_SCA unchanged); mm/mm.
- REFIN: price → "NEW PRICE 2027" (added to aliases); size_unit cm ("Size (cm)"), thickness mm;
  Trim-Tiles/accessories keep their missing columns, handled by allow_missing_code + aliases.
- Vives: price list re-issued with ENGLISH headers (Model/Name/Size/Pieces-Box/m²-Box/Weight-Box/
  EAN). Price = Price/Piece OR Price/m² (first_non_empty; text cells, comma decimals → eu). Size is
  apostrophe-decimal (15'8X31'6 = 15.8×31.6 cm), size_unit cm; no thickness on the main sheet
  (thickness_unit none), joined from the SEPARATE workbook via Model↔Ref (value "Thickness (mm)").

Verified end to end by running the real ImportEngine against the actual files (throwaway local
harness, not committed): CDE 1640 rows / LEA 1626 / Panaria 1419 / REFIN 1756 / Vives 4617 parse
with 0–2 errors (the few are genuine no-price source rows the engine drops); Vives apostrophe sizes
→ 158×316 mm, text "1,78" → 1.78, and 1964 models matched a sidecar Ref for thickness. Migration
applied cleanly to a local glr_dev and the stored configs read back correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he JSON brace as a placeholder

V200 quoted each import-profile JSON with the NAMED tag $json$, producing
`$json${` at the opening brace. Flyway runs placeholder replacement by
default and does not skip named-tag dollar quotes, so it read `${...}` as
a placeholder and every migration run failed with "No value provided for
placeholder" — breaking all backend integration tests (resetSchema) and
the e2e backend boot.

Switch to the anonymous `$$` tag, exactly as V40 does for the same
JSON-in-dollar-quote pattern (the replacer skips anonymous quotes, so
`$${` is safe). Delimiters only — the JSON bodies are byte-identical and
all five still parse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rate

Owner request 2026-10-21: on the CEO selling-price decision (PricingRequestDetailPage, step 3)
the CEO could only type one "อัตรากำไรเริ่มต้น" applied to every line, so margin was effectively
one combined rate. Each product line can now carry its OWN margin%, and its price recomputes as
cost × (1 + its own margin) × buffer.

Frontend-only — the backend/API/mock already support per-item margin: PUT /pricing-decisions/{id}
takes items:[{pricingDecisionItemId, marginPct}] and applyItemUpdates recomputes that line's
selling price (the per-line price/cost overrides on this page already use this exact endpoint).
The per-item margin grid had been dropped in an earlier "Phase 1 UI simplification"; this re-adds
it as a focused per-line input.

- New CeoLineMarginField: a plain per-line margin input (no mandatory reason, unlike the
  cost/price-override modals — a margin is the ordinary formula input), seeded from the line's
  proposedMarginPct and re-synced on refetch. Own state so renderCeoDecisionItem (called in a loop)
  holds no hook.
- saveLineMargin mutation: single-item marginPct payload to the existing update endpoint; clear*
  flags omitted (the request record is wire-tolerant, they default to false), leaving every other
  item column unchanged.
- The single "อัตรากำไรเริ่มต้น" stays as the creation-time seed; per-line inputs override it.
- Shown only on editable, formula-driven legacy lines: hidden once a "ปรับราคาเอง" override is
  active (the formula no longer drives the line), once the decision is read-only, and on the
  NEW-FORM (V187) path (which prices via the mode picker, not margin) — those keep the read-only
  อัตรากำไร/ราคาขั้นต่ำ summary. Client bound mirrors requireValidMargin (> -1).

Tests (Vitest + mock, TDD): seed-from-proposedMarginPct + single-item save payload; two lines carry
DIFFERENT margins, each save targets only its own line; unchanged margin does not call the API; no
input on a price-overridden line or a new-form line. 281/281 in the file pass; ESLint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…out (฿/%) on every mode

Owner feedback (2026-10-21): the business's deals are new-form (price_mode NET), where the
per-line margin input (which only rendered on the legacy formula path) never showed — so "config
margin per product" was unreachable. Also wanted the margin shown clearly in baht and % on every
mode.

- NET mode now renders the per-line อัตรากำไร input (reusing CeoLineMarginField + the existing
  marginPct PUT). Under NET the ราคาตั้ง starts from ต้นทุน × (1+กำไร) × ตัวคูณ, so a per-line margin
  drives THIS line's ราคาตั้ง; ส่วนลด/สุทธิ then apply on top. Backend applyItemUpdates already
  recomputes list→net from marginPct (computeSellingPrice → deriveListAndNet), so it works end to
  end. Shown only on a cost-bearing line the formula still drives (not stock, not ปรับราคาเอง);
  SPECIAL_SQM/DIRECT_NET set the price directly, so no margin input there.
- LineMarginReadout: a derived, read-only line shown on EVERY mode (new-form + legacy) —
  กำไร/หน่วย (฿) = NET price − landed cost (FX-adjusted to THB), the % of cost, and รวม × qty.
  Dashes out when the line has no cost or no price yet. Pure lineProfitThb helper, unit-tested.

Not a change to how any price computes — the readout is derived from existing fields. Tests: NET
input saves marginPct; SPECIAL_SQM has no input; readout shows ฿/% on NET + legacy and dashes with
no cost. 284/284 in the file pass; ESLint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… separate save button)

Owner feedback (2026-10-21): the margin should recompute in real time like ส่วนลด, with no
dedicated "บันทึกกำไร" button. Reworked the NET per-line margin from the standalone
CeoLineMarginField (its own button) into a draft-backed input: typing it drives THIS line's ราคาตั้ง
live (ต้นทุน × (1+กำไร) × ตัวคูณ, ÷fx for non-THB — mirrors PricingDecisionService#computeSellingPrice
so the preview equals what the server stores), the net/total and the กำไร readout update with it,
and it is saved by the shared "บันทึกราคา" as marginPct (no override, no reason — mutually exclusive
with a manual ราคาตั้ง override). While a margin is set, ราคาตั้ง shows the derived value read-only.
Legacy formula lines keep their own CeoLineMarginField. 284/284 pass; ESLint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…uperseding #1117

Folds #1117's capability into #1164 so one PR covers it. The per-line อัตรากำไร input now shows on
NET and SPECIAL_SQM (cost-bearing, formula-driven lines) and recomputes ONE formula price —
formulaFromMargin = ต้นทุน × (1+กำไร) × ตัวคูณ (÷fx for non-THB, mirrors
PricingDecisionService#computeSellingPrice) — surfaced via formulaPerPiece everywhere: ราคาตั้ง
(NET), the ราคาพิเศษ prefill (SPECIAL_SQM), and the สูตร lines. Saved by the shared บันทึกราคา as
marginPct. The realized-margin readout (฿ + % of cost) stays on every mode. DIRECT_NET sets the
price outright, so no margin input there.

Supersedes #1117 (feat/ceo-margin-recalc-price), which did the same NET+SPECIAL_SQM margin recalc
but without the ฿/% readout. 284/284 in the file pass; ESLint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- NORMAL: hide the per-line margin input under SPECIAL_SQM/DIRECT_NET —
  margin drives the price under NET only. Under those modes the price is
  set directly, so a margin would be a no-op on the saved net; marginPct
  is no longer sent from those modes on save (saveCeoItemPrice).
- Share one margin validator (MAX_MARGIN_PCT / marginOutOfRange /
  MARGIN_RANGE_ERROR) across the legacy per-line field and the new-form
  NET input so bound and message cannot drift; mirror the server's full
  bound (-1 < m <= 999.999999).
- Drop the sellingBuffer `|| 1` fallback: while the formula config is
  unloaded, show no live preview rather than one ~buffer% low; round the
  intermediate SP before the fx divide to match the server.
- LineMarginReadout distinguishes "no cost" from "net derived on save".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot scrollTop)

The deal/ticket page jittered while scrolling — bouncing up/down, sometimes unable to
scroll down, snapping back to the top. Root cause: the sticky chrome condensed on a single
scrollTop>8 threshold (added in 3b269d1 "repair ticket workspace sticky chrome"), and
condensing shrinks the whole page. On a page not much taller than the viewport, condensing
dropped scrollTop back under the threshold → expand → page tall again → scroll → condense →
… an oscillation with no hysteresis.

Fix (root cause): drive the condense off a 0-size top-of-content sentinel + IntersectionObserver
instead of scrollTop. The sentinel's visibility does not depend on the chrome's own height, so
condensing can never move it back into view — no feedback loop. rootMargin '-8px 0px 0px 0px'
keeps the same trigger point as the old >8 check; mobile stays expanded (chrome is static there).

The `--deal-header-h` ResizeObserver height-writer is KEPT unchanged — it is NOT dead
(AppShell's `scroll-pt-[calc(var(--deal-header-h,18rem) + …)]` reads it to offset scrollIntoView
below the sticky header). Writing a scroll-padding custom property never moves
scrollTop/scrollHeight, so it does not feed the condense loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nimatable DOM)

Follow-up to the sentinel fix: the collapse still flickered on slow scroll (a single
threshold dithering) and snapped ~150px in one frame.

- Two sentinels give a dead-band — condense past the LOWER sentinel, expand only back at
  the very top — so slow scrolling between them can't flip-flop the collapse.
- Gate A: a page only slightly taller than the viewport never condenses, because condensing
  it would shrink it below the condense line and clamp scrollTop back to the top → re-expand.
  Only genuinely tall pages condense, where it is stable.
- DealStateHeader is now ONE DOM for both states (was a condensed/expanded render swap that
  could not animate): the meta line + 5-chip stat grid collapse via a grid-template-rows
  1fr→0fr transition, so the height change animates instead of jumping. The expanded action
  bar + mobile fixed bar stay OUTSIDE the clipped collapsible region so the "⋯" OverflowMenu
  is never clipped; mobile is never condensed.

Tests: DealStateHeader condensed-variant assertions are scoped to the visible bar
(deal-state-header-bar), since the collapsed stat grid is now kept mounted (aria-hidden) for
the animation and would otherwise make a plain getByText ambiguous; a new TicketDetailPage
test pins the sentinel + hysteresis wiring. jsdom has no real layout/IntersectionObserver, so
the no-oscillation/smoothness itself is verified in a browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Re-derive the condense state on content growth / viewport resize, not
  only on sentinel intersection. The IntersectionObserver never re-fires
  when the content below grows or shrinks while the sentinels stay on the
  same side, which left the state stale (a page that became tall enough
  stayed expanded; a condensed page that shrank below the condense line
  stayed condensed). A ResizeObserver on the content root + scroll box now
  re-evaluates synchronously with the same dead-band; it no-ops where the
  scroll box can't be measured (unit tests), so the IO path still drives.
- Coalesce the `--deal-header-h` writes: measure+write at most once per
  frame (rAF) and skip when the rounded height is unchanged, instead of
  one setProperty per ResizeObserver callback during the height animation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CEO's costing

Sales enters a product's thickness on the pricing-request item (V185), but the CEO's
landed-cost calc read thickness ONLY from the catalogue (price_catalog.product_prices),
so a free-text line or a catalogue row with a blank thickness showed "no thickness" and
could not be costed — even though Sales had typed one. This links the two.

- PricingRequestRepository#fillCatalogThicknessFromItems: at submit (right after
  snapshotCatalogSelections), a Sales thickness on a catalog-LINKED item is written back
  to price_catalog.product_prices.thickness_mm, but ONLY when the catalogue row's own
  thickness IS NULL — "catalog wins", an existing catalogue thickness is never overwritten
  by a per-deal entry. So the CEO and every other reader then see it on the product.
- LandedCostCalculator#resolveThicknessMm: catalog-then-request fallback — when the
  catalogue carries no thickness, use the Sales-entered value on the item. Covers a
  free-text line (no catalogue row to write back to) and an in-flight/legacy deal
  submitted before the write-back existed, so the CEO can cost it without a re-submit.
  Catalog still read first (catalog wins). Mirrors resolveSqmPerPiece's fallback shape.

Owner ruling 2026-10-15: new product -> write thickness to the catalogue; an existing one
should already carry it, but if not, Sales's value fills the gap; catalog wins on conflict.
A pure free-text line is NOT auto-promoted into the catalogue (a later import-confirms
feature); its thickness lives on the item and is honoured by the costing fallback.

Test-first (integration, CI-only — no local Postgres/Docker):
- PricingRequestItemDirectDealFieldsIntegrationTest: write-back fills a null catalogue
  thickness; an existing catalogue thickness is NOT overwritten (catalog wins).
- LandedCostCalculatorFormulaIntegrationTest: a catalogue-null-thickness line with a Sales
  thickness is costable (fallback), not set aside as uncostable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ll-thickness

This PR's own change (resolveThicknessMm falls back to the Sales-entered
thickness) means a null CATALOG thickness no longer makes a line
uncostable — the required Sales value costs it. Five pre-existing tests
built their "starts uncostable" item that way, so they now reached
startReview costed and failed their isStillBlocked/isStillRefused gates.

Rebuild those fixtures on the surviving set-aside path: a FREE-TEXT line
(productId == null). resolveOriginCountryCode reads the catalogue only
(no request fallback) and a real factory's country is NOT NULL (V151), so
only a free-text line has no resolvable origin — which sets it aside
(frozenLandedCost == null) before the freight lookup, exactly the state
the override/approve gates exercise. The invariants under test are
unchanged; only the mechanism that makes the item uncostable moved.

- PricingDecisionCeoPriceModeIntegrationTest: freeTextUncostableItem
  replaces the catalog-null-thickness product (now removed).
- AbstractStockLineIntegrationTest: importLineUncostable builder;
  the dead catalogProductUncostable seed removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… routes to

The previous commit made the uncostable fixtures free-text but removed the
factory seed as "dead" — it was not: costShipment runs
factoryConfigs.findByName (price_catalog.factories) as a per-shipment
existence check BEFORE the per-item set-aside, so an unknown factory 422s
"ไม่พบ factory config สำหรับโรงงาน" before the line can be set aside.

Re-seed just the factory row (its catalog product is never linked, so the
line stays free-text and uncostable for lack of a resolvable origin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A domain-level map of the repo under docs/repo-map/: one note per vertical slice
(backend package(s) ↔ frontend feature(s) ↔ DB schema/migrations), linked with
[[wikilinks]] so Obsidian's Graph view shows how features relate. Built to cut the time
it takes to find where a bug lives across the backend/frontend split, and to let a coding
agent orient from INDEX.md + one domain note instead of scanning the whole tree.

- _tools/generate.py: ONE script that is both the generator and the re-sync tool — a
  curated DOMAINS model (grouping, relations, gotchas) plus a live tree-walker that lists
  each domain's current files. Stamps every note with the develop sha it synced to.
- INDEX.md: entry point, domains grouped by layer (Sales/CRM · HR · Platform).
- domains/*.md: 17 notes covering every backend package and frontend feature.
- README.md: how to open as an Obsidian vault + how to re-sync.

Docs only; no product code. Generated notes are reproducible from the script
(re-run after big merges). Gotchas are pointers only — CLAUDE.md/AGENTS.md stay authoritative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opening the vault makes Obsidian write .obsidian/ (workspace.json, graph.json,
app state) — per-user editor state, not shared content. Ignore it so it never
shows up as untracked noise or gets committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
waritctd and others added 14 commits October 28, 2026 01:08
… stored lines and their amount (RED)

Today a null or empty items list saves subtotal 0 and deposit 0 while the item rows stay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…nes and recomputes the amount

DepositNoticeRepository.update replaces item rows only when items are sent, but computed the
subtotal from the request alone, so a null or empty items list saved subtotal 0 and deposit 0
beside intact rows. It now sums the stored rows in that case. Null and [] both mean keep: rows
were never deleted on [], so the safer reading leaves them and makes the money match them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…ets a Thai message, and a cleared field bills 0 (RED)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
… message; the page rounds what sales types

DepositNoticeService.update refuses a request percent that is not a whole number of percent (400),
because the document prints it rounded: 40.5% showed as 41% while billing 40.5%. The @DecimalMax
on DepositNoticeDraftRequest.depositPercent carries a Thai message instead of the English default.
DepositNoticePage rounds the typed value so the field, label and amount agree. The mock refuses
over 100% and fractional percents the same way.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…ice request; the repository update takes the stored lines it was handed

Inert plumbing for the next commits: DepositNoticeDraftRequest gains a nullable
expectedUpdatedAt (an 8-argument constructor keeps every existing caller compiling),
read by nothing yet. DepositNoticeRepository#update now sums the lines the service
already read (doc.items()) instead of re-reading them; same rows, one query fewer.
No behaviour change: DepositNoticeServiceTest 50, DepositNoticeControllerTest 9,
DepositNoticeIssueGuardIntegrationTest 5, all green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…sued-row refusals, legacy % echo, Thai % floor, sales-only update (RED)

RED before the fix (each an assertion on the missing behaviour):
- issue refuses a stored 0% / 0.3330 / 1.5 draft and a 0-baht draft, nothing numbered:
  DepositNoticeIssueGuardIntegrationTest:221 "Expecting code to raise a throwable"
- a save with a stale updatedAt stamp is 409 and the first save stands:
  DepositNoticeIssueGuardIntegrationTest:301 "Expecting code to raise a throwable"
- DepositNoticeRepository#update on an ISSUED row refuses before touching the lines:
  DepositNoticeIssueGuardIntegrationTest:334 "Expecting code to raise a throwable"
- a stored 0.3330 sent back unchanged saves: DepositNoticeServiceTest:1084
  ApiException "เปอร์เซ็นต์มัดจำต้องเป็นจำนวนเต็ม"
- a sales_manager that owns the deal cannot update: DepositNoticeServiceTest:820
  "Expecting code to raise a throwable"
- -0.05 and 0 get a Thai message: DepositNoticeControllerTest
  expected "depositPercent เปอร์เซ็นต์มัดจำต้องมากกว่า 0" but was "must be greater than or equal to 0"
Guards (green before): the stamp binds through the canonical record constructor; the
stored-lines fixture now has two fractional-qty lines with net below unit, so net x qty
(75.02), unit x qty (90.00), the rounded amounts (75.03) and nets alone (43.34) differ.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…issue save is refused before the lines; update is sales-only

- DepositNoticeService#issue: a stored percent outside a whole 1-100 (0%, 0.3330, 1.5) is 409
  before numbering; a 0-baht draft is 409 (same lines as #1173, so the two merge clean).
- DepositNoticeRepository#update: honours expectedUpdatedAt (AND updated_at = :expected, null
  skips it) and checks the header row count; 0 rows -> 409 "ถูกแก้ไขโดยผู้ใช้อื่น" while still a
  draft, else requireDraft's message, both before the item DELETE/INSERT. Dead null -> 0.50
  fallback removed (the service always sets the percent).
- DepositNoticeService#update: requireRole(SALES_ROLES) first, like create/issue; the
  whole-number rule skips a stored percent sent back unchanged.
- DepositNoticeDraftRequest: @DecimalMin exclusive with a Thai message.

GREEN: DepositNoticeServiceTest 50, DepositNoticeControllerTest 10,
DepositNoticeIssueGuardIntegrationTest 6. Mutations, each red on its named test: % re-check
narrowed to signum, amount check off, stamp predicate dropped, row count ignored, legacy-echo
skip removed, role gate removed, @DecimalMin back to inclusive English, stored-lines sum
switched to unitPrice x qty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…d the folded ส่วนลดพิเศษ row (RED)

Owner rulings 2026-10-23 (items 2 and 4 of the #1176 review):
- while a notice is ISSUED, the ticket route and the pricing-request route both refuse a new
  draft with 409 naming that notice, and no row is written:
  DepositNoticeIssueGuardIntegrationTest:259 "Expecting code to raise a throwable"
- a DEAL_DIRECT quotation's ADJUSTMENT row is folded into the goods lines by amount: 2 lines,
  nets 95.00 / 475.00, subtotal 1,900, and the page-shaped PUT validates and saves:
  DealQuotationConfirmOrderIntegrationTest:262 expected ("10","100.00","95.00",...) but the draft
  held ("10","100.00","100.00"), ("2","500.00","500.00"), ("-1","100.00","100.00")
Revised for the new rule: PaymentTrackIntegrationTest's revision walk now shows the create
route refusing (PaymentTrackIntegrationTest:288, RED) and writes the revision draft through the
repository, as the separate re-version change will; RemainingInvoiceServiceIntegrationTest's
second-chain fixture does the same (green before and after, 28 tests).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…he ส่วนลดพิเศษ row into the goods lines

- DepositNoticeService#createDraftFromQuotation (both the ticket route and
  OrderConfirmationService#createDepositNoticeFromQuotation funnel here): while a notice is
  ISSUED, 409 "ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ <no.> แล้ว จึงสร้างฉบับร่างใหม่ไม่ได้".
  issue()'s DEPOSIT_NOTICE_ISSUED self-loop stays for the separate re-version change.
- DepositNoticeRepository#findDealQuotationItems: a DEAL_DIRECT ADJUSTMENT row (qty -1) is no
  longer copied; foldAdjustments spreads its amount over the goods lines by amount (HALF_UP per
  share, remainder on the last line with an amount), keeps the descriptions and relabels the
  per-unit discount. Subtotal unchanged for the review's fixture (1,900); a qty that does not
  divide a line's new amount can move it by under qty x 0.005 (ponytail comment).

GREEN: DepositNoticeIssueGuardIntegrationTest 7, PaymentTrackIntegrationTest 14,
DealQuotationConfirmOrderIntegrationTest 31, DepositNoticeServiceTest 51.
Added with the fix (proved by mutation, not RED-first): DepositNoticeServiceTest
#foldAdjustments_putsTheRoundingRemainderOnTheLastLineWithAnAmount, since the IT's 50/50
split cannot tell a remainder from a plain proportional share.
Mutations, each red on its named test: guard filter -> false; fold skipped; remainder off.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…hecks, one notice per deal in the UI, and a confirm before issuing from the deal (RED)

RED before the fix:
- DepositNoticePage: a stored 0.333 saves as 0.33; a net-0 line adds 0 and is sent at 0
  (Unable to find "100.10 บาท": the line fell back to its unit price); 100.10 at 33% shows
  35.34; a blank เพิ่มรายการ row is dropped; the stamp travels; a cleared % sends null
- DepositNoticePage: a 409 save reloads the draft and toasts the server message (payload had
  no expectedUpdatedAt)
- DepositNoticePage: % 0 and 150 are blocked on the % field before the dialog (Unable to find
  "กรุณากรอก % มัดจำเป็นจำนวนเต็ม 1–100")
- mock depositNotices: @Valid-shaped messages before the 404/409 lookups ("ไม่พบใบแจ้งรับมัดจำนี้"
  instead of 400), the reread straight after the three rejects, the stale stamp, the owner/role
  checks on update and issue, no second draft on either route ("promise resolved instead of
  rejecting")
- mock promoteToDeal: the deposit draft folds a flat ส่วนลดพิเศษ (got [10, 850, 850, null] and a
  qty -1 row)
- canCreateDepositNoticeFromQuotation refuses once an ISSUED notice exists (expected true to be false)
- PricingRequestDetailPage step 6 points at the ISSUED notice (Unable to find link /GLRD69001/)
- DealDepositPanel (TicketDetailPage): ออกเอกสาร opens a dialog with %, deposit, VAT, total
  (Unable to find role="dialog"); an ISSUED notice says the deal already has its notice

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…s; one notice per deal in the UI; the deal page confirms before issuing

DepositNoticePage: a net-0 line counts as 0 (?? not ||); the stored % is rounded to the whole %
the field shows; money rounds per step (round2, now exported from quotationMeta); a blank
เพิ่มรายการ row is left out of the save and the issue checks; a cleared % sends null; the
draft's updatedAt goes as expectedUpdatedAt and a 409 reloads the draft and toasts the server
message; the issue gate has its own whole 1–100 % rule focused on the % field; the confirm
body is DepositIssueSummary, shared with DealDepositPanel.
DealDepositPanel: ออกเอกสาร opens a ConfirmDialog with the stored %, deposit, VAT and total; an
ISSUED notice says the deal already has its notice. canCreateDepositNoticeFromQuotation takes
the deal's notices and refuses once one is ISSUED; PricingRequestDetailPage step 6 reads them
(existing listByTicket) and links the ISSUED number instead of offering the create.
Mock: @Valid-shaped % checks before the lookups, sales-only + owner on update and issue, the
stored-% floor, the legacy echo, the stale stamp (updatedAt strictly increases), issue's %
and amount checks (amount lines as in #1173), no new draft on either route while one is
ISSUED, and the ส่วนลดพิเศษ fold for a flat adjustment.

GREEN: DepositNoticePage 15, mockApi.depositNotices 13, mockApi.promoteToDeal 11,
pricingRequestMeta 75, PricingRequestDetailPage 277, TicketDetailPage 169, contract +
serverContract + quotationMeta. ESLint clean. Mutations, each red on its named test: ?? -> ||,
stamp dropped, 409 reseed dropped, cleared % -> 0, blank-row filter off, % rule off,
populateForm rounding off, all per-step rounding off, mock owner check off (update and issue),
mock validation moved after the lookups, mock stamp off, mock reject leaks the %, mock
one-notice guard off, mock fold off, predicate ignores notices, PR page ignores notices, panel
line removed, panel issues without the dialog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…from #1170/#1173's hunks

No behaviour change: requireNoIssuedDepositNotice now sits above buildMockDoc (it was adjacent
to #1170's rewrite of mockPickTicketQuotationForDepositNotice), and the new predicate and
step-6 tests open their describe blocks instead of sharing #1173's insertion points.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…import does not collide with #1170's

No behaviour change (DepositNoticeIssueGuardIntegrationTest 7, DepositNoticeServiceTest 51,
DepositNoticeControllerTest 10 green with clean).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UXmTJDVga17Rn5az4YBjZ2
…in release)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
demo-glr Ready Ready Preview Oct 28, 2026 12:05am UTC

Request Review

waritctd and others added 5 commits October 28, 2026 02:10
…al is issued; 2-decimal lines; the quotation's own discount wording kept

Owner rulings 2026-10-28 on the #1176 review:
- Item 1(a): DepositNoticeService.issue refuses while another notice on the deal is ISSUED
  ("ดีลนี้ออกใบแจ้งรับมัดจำเลขที่ … แล้ว จึงออกฉบับร่างนี้ไม่ได้"), so a second draft can no longer
  replace the document the customer already has. Mock mirrors it.
- Item 2 ("use 2 decimals for all"): foldAdjustments rounds each share and each new net price to 2
  decimals with no remainder pushed onto a last line; the notice total is the sum of its own 2-dp
  lines and may differ from the quotation by a few satang (documented as the ruled outcome).
- A line keeps the quotation's own discount wording and gains "+ ส่วนลดพิเศษ … ต่อหน่วย" only when
  the fold lowered its price.
- The rebase onto develop left a duplicate requireNoIssuedDepositNotice in mockApi.js; one removed.

Tests: issue_secondDraftOnSameTicket_…StillRefusedWhileAnotherNoticeIsIssued (IT; red with the guard
disabled, green with it), depositNoticeSecondIssue_isRefused_… (PaymentTrack IT),
foldAdjustments_keepsEachLinesOwnLabel_…TwoDpLines (unit), mock deposit-notice tests. Green:
DepositNoticeServiceTest 50, DepositNoticeIssueGuardIntegrationTest 7, PaymentTrackIntegrationTest
14 (local Postgres), mockApi.depositNotices + deposits 36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e locks the deal and takes an optional stamp, page refuses cleared rows and keeps stored %

- DealDepositPanel: notices were landing in the accepted-quotation slot; pass the deal's quotation (0% hides create) and notices separately.
- DepositNoticeService.issue: lock the ticket row before the one-ISSUED guard; optional expectedUpdatedAt (409 if the draft moved on). Dead SUPERSEDE update removed.
- DepositNoticePage: save/preview refused when every row is cleared; a stored non-whole % is no longer rewritten; issue sends the stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de and remainder rounding

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on-draft

feat(sales): sales may change the deposit % on a draft notice; the amount follows
…-to-main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ase/2026-10-28-develop-to-main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
waritctd and others added 4 commits October 28, 2026 06:42
… PER_SQM basis is set aside, not a 422; keep deposit policy while a notice is issued

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ount (409)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fix(pricing,deposit): release 2026-10-28 review findings — no catalog write from typed thickness, missing sqm set aside, no 0-THB manual cost, deposit policy kept while a notice is issued
…se/2026-10-28-develop-to-main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@waritctd
waritctd merged commit 22f3c66 into main Oct 28, 2026
7 checks passed

This branch was successfully deployed

1 active deployment
Preview — 30240cfe Deployed Oct 28, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants