Repository navigation
release: develop → main 2026-10-28 (backend image v2026-10-28, V200) - #1179
Merged
Merged
Conversation
…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>
… 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…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>
… 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>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ghcr.io/waritctd/glr-hr-backend:v2026-10-28from this branch (scripts/build-push-backend-image.sh).v2026-10-28; Flyway applies V200 (prod history checked read-only first).Not merging until the code review is reported and the owner says go.
🤖 Generated with Claude Code