diff --git a/frontend/src/features/deposits/DepositNoticePage.test.jsx b/frontend/src/features/deposits/DepositNoticePage.test.jsx index 040901277..a1dacfc40 100644 --- a/frontend/src/features/deposits/DepositNoticePage.test.jsx +++ b/frontend/src/features/deposits/DepositNoticePage.test.jsx @@ -386,19 +386,18 @@ describe('DepositNoticePage', () => { expect(api.pricingRequests.createDepositNoticeFromQuotation).not.toHaveBeenCalled(); }); - // A failure anywhere in the resolution step (network error, permission edge case) must - // degrade to the fallback path, never break the button or surface an unhandled rejection. - it('falls back to the ticket-level route when resolving pricing requests fails', async () => { + // A failed pricing-request lookup must NOT silently route the deal to the ticket-level engine + // (it could create the wrong kind of deposit notice): the error surfaces and nothing is created. + it('surfaces the error and creates nothing when resolving pricing requests fails', async () => { api.pricingRequests.listForTicket.mockRejectedValue(new Error('network error')); - renderDepositNoticePage(); + const showToast = vi.fn(); + renderDepositNoticePage({ showToast }); const createButton = await screen.findByRole('button', { name: /สร้างเอกสารฉบับร่าง/ }); fireEvent.click(createButton); - await waitFor(() => expect(api.depositNotices.createDraft).toHaveBeenCalledTimes(1)); - expect(api.depositNotices.createDraft).toHaveBeenCalledWith('701', { - notes: ['หมายเหตุมาตรฐาน'], - }); + await waitFor(() => expect(showToast).toHaveBeenCalledWith('error', 'network error')); + expect(api.depositNotices.createDraft).not.toHaveBeenCalled(); expect(api.pricingRequests.createDepositNoticeFromQuotation).not.toHaveBeenCalled(); }); diff --git a/frontend/src/features/deposits/createDepositNoticeDraft.js b/frontend/src/features/deposits/createDepositNoticeDraft.js index 04585029d..aba042775 100644 --- a/frontend/src/features/deposits/createDepositNoticeDraft.js +++ b/frontend/src/features/deposits/createDepositNoticeDraft.js @@ -5,21 +5,16 @@ import { api } from '../../api/index.js'; // // Prefer the pricing-request-chain endpoint (createDepositNoticeFromQuotation) when this deal has a // pricing request whose customer quotation was accepted — it sources items from the accepted -// quotation and autofills the header from the customer master + ticket project. A failure ANYWHERE -// in that lookup (network error, permission edge case, no pricing requests at all — a direct-quotation -// deal) degrades to the ticket-level route; it must never break the button. +// quotation and autofills the header from the customer master + ticket project. No pricing requests at +// all (a direct-quotation deal) takes the ticket-level route; a FAILED lookup rejects instead of guessing +// the route (release review 2026-10-29) — the caller's onError shows it and nothing is created. // depositPercent is not sent on either branch: the backend picks the new draft's percent // (DepositNoticeService.createDraftFromQuotation; findGoverningQuotation, #1170). // `noteTemplates` (already loaded) are optional. export async function createDepositNoticeDraft(ticketId, noteTemplates) { - let acceptedPricingRequestId = null; - try { - const { items: pricingRequests } = await api.pricingRequests.listForTicket(ticketId); - acceptedPricingRequestId = (pricingRequests ?? []) - .find((pr) => pr.status === 'QUOTATION_ACCEPTED')?.id ?? null; - } catch { - acceptedPricingRequestId = null; - } + const { items: pricingRequests } = await api.pricingRequests.listForTicket(ticketId); + const acceptedPricingRequestId = (pricingRequests ?? []) + .find((pr) => pr.status === 'QUOTATION_ACCEPTED')?.id ?? null; if (acceptedPricingRequestId != null) { return api.pricingRequests.createDepositNoticeFromQuotation(acceptedPricingRequestId, {}); } diff --git a/frontend/src/features/tickets/DealFulfilmentPanel.jsx b/frontend/src/features/tickets/DealFulfilmentPanel.jsx index 2a84241d8..c36186cec 100644 --- a/frontend/src/features/tickets/DealFulfilmentPanel.jsx +++ b/frontend/src/features/tickets/DealFulfilmentPanel.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api, ROLE_PERMISSIONS } from '../../api/index.js'; import { queryKeys } from '../../api/queryKeys.js'; @@ -184,9 +184,9 @@ function SubstepChips({ currentCode, fromStock = null }) { * gate for this slice; the `can.*` predicates below are moved out of * TicketDetailPage byte-for-byte. */ -export function DealFulfilmentPanel({ +export const DealFulfilmentPanel = forwardRef(function DealFulfilmentPanel({ user, ticketId, summary, items = [], availableActions = [], showToast, -}) { +}, ref) { const queryClient = useQueryClient(); const role = user?.role; const isImport = ROLE_PERMISSIONS.canPickupTickets.includes(role); @@ -583,6 +583,13 @@ export function DealFulfilmentPanel({ setItemWeightMutation.mutate({ lines }); } + // QA-09: the deal page's sticky ขั้น 11 button drives this panel's own create (modal + PDF download stay here). + const showIrSection = () => document.getElementById('deal-fulfilment-stored-ir')?.scrollIntoView?.({ behavior: 'smooth', block: 'start' }); + useImperativeHandle(ref, () => ({ + createIrDrafts: () => { showIrSection(); createDraftsMutation.mutate(undefined); }, + showIrSection, + })); + return (
@@ -668,7 +675,7 @@ export function DealFulfilmentPanel({ ImportRequestService#requireRead exactly (canReadStoredIr above). */} {canReadStoredIr ? (
+ id="deal-fulfilment-stored-ir" data-testid="deal-fulfilment-stored-ir">
ใบขอซื้อรายโรงงาน (F-SM-001) @@ -1043,4 +1050,4 @@ export function DealFulfilmentPanel({ ) : null} ); -} +}); diff --git a/frontend/src/features/tickets/TicketDetailPage.jsx b/frontend/src/features/tickets/TicketDetailPage.jsx index 07732b665..992fa8199 100644 --- a/frontend/src/features/tickets/TicketDetailPage.jsx +++ b/frontend/src/features/tickets/TicketDetailPage.jsx @@ -352,6 +352,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { // panel's confirm directly instead of scrolling to a second copy of the // button — same convention as the two refs above. const dealQuotationPanelRef = useRef(null); + const fulfilmentPanelRef = useRef(null); // Edit-items mode const [editMode, setEditMode] = useState(false); @@ -610,6 +611,13 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { queryFn: () => api.storedRemainingInvoices.listForTicket(ticketId).then((r) => r.remainingInvoices ?? []), enabled: readsDocs, }); + // Same key/fn as DealFulfilmentPanel's own read, so the two share one cache entry (QA-09). + const storedIrQuery = useQuery({ + queryKey: queryKeys.storedImportRequests(ticketId), + queryFn: () => api.storedImportRequests.listForTicket(ticketId).then((r) => r.importRequests ?? []), + enabled: readsDocs, + }); + const importRequests = storedIrQuery.data; const depositNotice = pickDepositNotice(depositNoticesQuery.data); // undefined until the list is read: nextSalesAction then never offers the invoice. const remainingInvoice = remainingInvoicesQuery.data === undefined ? undefined : pickRemainingInvoice(remainingInvoicesQuery.data); @@ -1297,14 +1305,14 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { // treated like an unknown route (no route choice; ⋯ still offers its outcome). // (Read once is enough: a failed refetch keeps the rows it read.) const offRouteParties = dealQuotationsQuery.data !== undefined ? offRoutePartiesFor(stageCatalog, summary.entryChannel) : null; - const workState = resolveWorkState(user, summary, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice }); + const workState = resolveWorkState(user, summary, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice, importRequests }); // PR A: at the owner/buyer quote stages revise is an ADDITIONAL action (header "⋯" menu), never a // replacement for the primary บันทึกผลใบเสนอราคา — the client may simply accept the original. - const secondaryActions = secondaryWorkActions(user, summary, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice }); + const secondaryActions = secondaryWorkActions(user, summary, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice, importRequests }); const outcomeSecondary = secondaryActions.find((action) => action.key === 'record_quotation_outcome') ?? null; const reviseSecondary = secondaryActions.find((action) => action.key === 'revise_quotation') ?? null; // The backend refuses a non-owner for both documents (2026-10-28): no main button for them. - const OWNER_ONLY_ACTIONS = ['create_deposit_notice', 'create_remaining_invoice']; + const OWNER_ONLY_ACTIONS = ['create_deposit_notice', 'create_remaining_invoice', 'create_import_request']; const workStateAction = OWNER_ONLY_ACTIONS.includes(workState.action?.key) && !isOwner ? null : workState.action; const lost = summary.lifecycle === 'CLOSED_LOST'; const isActiveLifecycle = (summary.lifecycle ?? 'ACTIVE') === 'ACTIVE'; @@ -1466,6 +1474,17 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) { ); stickyPrimaryLabel = workStateAction.label; + } else if (actionKey === 'create_import_request') { + // A DRAFT IR already exists (label says so): just show it; else run the panel's own create. + const hasDraft = importRequests?.some((r) => r.status === 'DRAFT'); + stickyPrimaryAction = ( + + ); + stickyPrimaryLabel = workStateAction.label; } else if (actionKey === 'confirm_order') { // DealQuotationPanel now lives inside the "เอกสาร" tab (see comment above). stickyPrimaryAction = ( @@ -2730,6 +2749,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) {
{sections.delivery ? ( { }); }); + // QA-09 (owner rulings): at ขั้น 11 (DEPOSIT_RECEIVED, deposit paid, nothing ordered yet) the + // owning rep's ONE main button creates the import request through DealFulfilmentPanel's own flow. + describe('sticky primary — สร้างใบขอซื้อ (IR) (ขั้น 11), QA-09', () => { + const acceptedPr = { + id: 501, requestCode: 'PCR-2026-0501', ticketId: 701, ticketCreatedById: 1, + status: 'QUOTATION_ACCEPTED', recipientType: 'BUYER', recipientLabel: null, orderConfirmedAt: '2026-10-20T09:00:00.000Z', + }; + const nonOwnerSales = { id: 2, employeeId: 2, name: 'เซลส์อื่น', role: 'sales' }; + const primary = () => screen.queryByTestId('ticket-primary-action'); + const primaryAction = () => primary()?.getAttribute('data-action') ?? null; + const location = () => screen.getByTestId('location-probe').textContent; + // jsdom has no scrollIntoView: record which element the page asked to scroll to. + let scrolledIds = []; + beforeEach(() => { + scrolledIds = []; + Element.prototype.scrollIntoView = function scrollIntoView() { scrolledIds.push(this.id); }; + }); + const scrolledToIrSection = () => scrolledIds.includes('deal-fulfilment-stored-ir'); + + function irStageDeal({ rows = [] } = {}) { + const state = { + lifecycle: 'ACTIVE', salesStage: 'DEPOSIT_RECEIVED', paymentStatus: 'DEPOSIT_PAID', fulfillmentStatus: null, status: 'quotation_issued', + }; + api.tickets.get.mockResolvedValue({ ticket: buildTicket({ summary: { ...state, createdById: 1 } }) }); + api.tickets.actions.mockResolvedValue({ currentState: state, availableActions: [] }); + api.pricingRequests.listForTicket.mockResolvedValue({ items: [acceptedPr] }); + api.dealQuotations.listForTicket.mockResolvedValue({ items: [] }); + api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: rows }); + api.storedImportRequests.createDrafts.mockResolvedValue({ importRequests: [{ id: 31, status: 'DRAFT', ticketId: 701 }] }); + } + + afterEach(() => { + api.dealQuotations.listForTicket.mockReset(); + api.storedImportRequests.listForTicket.mockResolvedValue({ importRequests: [] }); + delete Element.prototype.scrollIntoView; + }); + + it('owner rep at DEPOSIT_PAID: the main button is สร้างใบขอซื้อ (IR); clicking creates the drafts once and lands on the จัดซื้อ-ส่งมอบ tab', async () => { + irStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_import_request')); + expect(primary().textContent).toContain('สร้างใบขอซื้อ (IR)'); + await waitFor(() => expect(primary().disabled).toBe(false)); + + fireEvent.click(primary()); + + await waitFor(() => expect(api.storedImportRequests.createDrafts).toHaveBeenCalledTimes(1)); + expect(api.storedImportRequests.createDrafts).toHaveBeenCalledWith(701, undefined); + await waitFor(() => expect(location()).toContain('tab=fulfilment')); + await waitFor(() => expect(scrolledToIrSection()).toBe(true)); + }); + + it('a DRAFT IR is listed: the button reads ออกเลขใบขอซื้อ (IR) and only switches tab — createDrafts is not called', async () => { + irStageDeal({ rows: [{ id: 31, status: 'DRAFT', ticketId: 701, docNumber: null, factoryName: 'Factory A', version: 1, items: [] }] }); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_import_request')); + await waitFor(() => expect(primary().textContent).toContain('ออกเลขใบขอซื้อ (IR)')); + + fireEvent.click(primary()); + + await waitFor(() => expect(location()).toContain('tab=fulfilment')); + expect(api.storedImportRequests.createDrafts).not.toHaveBeenCalled(); + await waitFor(() => expect(scrolledToIrSection()).toBe(true)); + }); + + it('a sales rep who does NOT own the deal gets no create_import_request main button', async () => { + irStageDeal(); + renderTicketDetailPageAtRoute(['/tickets/701'], nonOwnerSales); + await stickyInputsSettled(); + await screen.findByRole('heading', { level: 1 }); + expect(primaryAction()).not.toBe('create_import_request'); + }); + + it('the button is disabled until the IR list is read (a click must not create beside an unseen DRAFT)', async () => { + irStageDeal(); + api.storedImportRequests.listForTicket.mockReturnValue(new Promise(() => {})); + renderTicketDetailPageAtRoute(['/tickets/701'], salesOwnerUser); + await waitFor(() => expect(primaryAction()).toBe('create_import_request')); + expect(primary().disabled).toBe(true); + }); + }); + // Slice 2 — flow B + the direct-quotation CTAs (SLICE-2-FLOW-A.md §D/§E). The ใบเสนอราคา panel // offers the two routes as sibling buttons — never a dialog (DESIGN.md §16) — and the sticky CTA // follows a live direct quotation (TicketSummaryDto.liveDirectQuotation, S2-B4). diff --git a/frontend/src/features/tickets/salesActions.js b/frontend/src/features/tickets/salesActions.js index 2118adb62..cba57fb70 100644 --- a/frontend/src/features/tickets/salesActions.js +++ b/frontend/src/features/tickets/salesActions.js @@ -73,6 +73,8 @@ export const SALES_ACTION = { // Owner rulings 2026-10-28: the deal page's main button makes the ขั้น 10 deposit notice and the ขั้น 13 invoice. CREATE_DEPOSIT_NOTICE: 'create_deposit_notice', CREATE_REMAINING_INVOICE: 'create_remaining_invoice', + // Owner rulings 2026-10-29 (QA-09): ขั้น 11, deposit paid -> the owning rep creates the import request (IR). + CREATE_IMPORT_REQUEST: 'create_import_request', RECORD_QUOTATION_OUTCOME: 'record_quotation_outcome', RECORD_DELIVERY: 'record_delivery', FOLLOW_UP: 'follow_up', @@ -92,6 +94,7 @@ const ACTION_LABEL = { [SALES_ACTION.CONFIRM_ORDER]: 'ยืนยันคำสั่งซื้อ', [SALES_ACTION.CREATE_DEPOSIT_NOTICE]: 'สร้างใบแจ้งยอดเงินรับมัดจำ', [SALES_ACTION.CREATE_REMAINING_INVOICE]: 'สร้างใบแจ้งหนี้', + [SALES_ACTION.CREATE_IMPORT_REQUEST]: 'สร้างใบขอซื้อ (IR)', // "Record the quotation outcome" — deliberately not "ติดตามผล"/"ติดตามลูกค้า" (that's // FOLLOW_UP's own label, a nudge to go chase the customer): by the time this bucket fires, the // quotation is already out and canRecordCustomerQuotationOutcome's own gate is what the button @@ -129,6 +132,7 @@ const ACTION_RANK = { [SALES_ACTION.CONFIRM_ORDER_DIRECT]: 1, [SALES_ACTION.CREATE_DEPOSIT_NOTICE]: 1, [SALES_ACTION.CREATE_REMAINING_INVOICE]: 1, + [SALES_ACTION.CREATE_IMPORT_REQUEST]: 1, [SALES_ACTION.ISSUE_QUOTATION]: 2, [SALES_ACTION.SUBMIT_DIRECT_QUOTATION]: 2, [SALES_ACTION.RECORD_QUOTATION_OUTCOME]: 3, @@ -297,7 +301,7 @@ function routeChoiceAction(ownPrs, quotationOf, offRouteParties) { * while the route is unknown. `quotationByRequest`: a Map, pricingRequestId -> the newest * PRICING_REQUEST-origin quotation `{ id, number, docStatus }` — only the deal page has it. */ -export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice } = {}) { +export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice, importRequests } = {}) { if (!deal || deal.lifecycle !== 'ACTIVE') return null; // 0. Slice 2 — flow A (SLICE-2-FLOW-A.md §E, IA §4): a LIVE direct quotation. Checked BEFORE @@ -319,7 +323,7 @@ export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = nul // Owner rulings 2026-10-28: a confirmed flow-A deal keeps its direct quotation APPROVED + live, so // the deposit notice must outrank this revise. const depositAction = depositNoticeAction(deal, depositNotice); - if (confirmRefused && reviseTarget) return depositAction ?? reviseAction(reviseTarget); + if (confirmRefused && reviseTarget) return depositAction ?? importRequestAction(deal, importRequests) ?? reviseAction(reviseTarget); // Owner ruling 2026-10-15: ขั้น 1 → 2 → 3 are moved by hand, and the price request starts at ขั้น 3. // So on ขั้น 1–2 the one action is moving the stage — nothing below (a due follow-up, an older @@ -408,6 +412,10 @@ export function nextSalesAction(deal, pricingRequests = [], { reviseTarget = nul // Owner rulings 2026-10-28: ขั้น 10, the customer confirmed the order -> make the deposit notice. if (depositAction) return depositAction; + // Owner rulings 2026-10-29 (QA-09): ขั้น 11, deposit paid and nothing ordered yet -> create the IR. + const importAction = importRequestAction(deal, importRequests); + if (importAction) return importAction; + // A live request's quotation awaiting ผจก.ขาย/CEO: the rep waits. const pending = pendingRequestQuotation(deal, ownPrs, quotationByRequest); if (pending) { @@ -526,6 +534,15 @@ function depositNoticeAction(deal, depositNotice) { }; } +/** `importRequests` undefined = unknown (still the create label). A DRAFT row -> the issue label. */ +function importRequestAction(deal, importRequests) { + if (deal.paymentStatus !== 'DEPOSIT_PAID' || deal.fulfillmentStatus != null) return null; + return { + key: SALES_ACTION.CREATE_IMPORT_REQUEST, + label: importRequests?.some((r) => r.status === 'DRAFT') ? 'ออกเลขใบขอซื้อ (IR)' : ACTION_LABEL[SALES_ACTION.CREATE_IMPORT_REQUEST], + }; +} + export const REVISE_STAGES = new Set(['QUOTE_OWNER', 'QUOTE_BUYER']); /** `target` = { quotationId, number, openDraftId? }. With an open revision draft the action points diff --git a/frontend/src/features/tickets/salesActions.test.js b/frontend/src/features/tickets/salesActions.test.js index 15633ad62..51eb64021 100644 --- a/frontend/src/features/tickets/salesActions.test.js +++ b/frontend/src/features/tickets/salesActions.test.js @@ -730,3 +730,47 @@ describe('nextSalesAction — สร้างใบแจ้งยอดเง .not.toBe('create_remaining_invoice'); }); }); + +// QA-09: ขั้น 11 (DEPOSIT_RECEIVED) — the owning rep's one main button creates the import request. +describe('nextSalesAction — สร้างใบขอซื้อ (IR) at ขั้น 11 (owner rulings QA-09)', () => { + const paidDeal = (overrides = {}) => baseDeal({ status: 'quotation_issued', paymentStatus: 'DEPOSIT_PAID', fulfillmentStatus: null, ...overrides }); + const CREATE_IR = { key: 'create_import_request', label: 'สร้างใบขอซื้อ (IR)' }; + + it('exposes the action key', () => { + expect(SALES_ACTION.CREATE_IMPORT_REQUEST).toBe('create_import_request'); + }); + + it('DEPOSIT_PAID + no fulfilment yet -> สร้างใบขอซื้อ (IR); importRequests unknown (undefined) or empty -> same', () => { + expect(nextSalesAction(paidDeal(), [orderConfirmedPr()])).toEqual(CREATE_IR); + expect(nextSalesAction(paidDeal(), [orderConfirmedPr()], { importRequests: [] })).toEqual(CREATE_IR); + }); + + it('a DRAFT IR exists -> ออกเลขใบขอซื้อ (IR) (same key); SUPERSEDED rows are ignored', () => { + expect(nextSalesAction(paidDeal(), [orderConfirmedPr()], { importRequests: [{ id: 1, status: 'DRAFT' }] })) + .toEqual({ key: 'create_import_request', label: 'ออกเลขใบขอซื้อ (IR)' }); + expect(nextSalesAction(paidDeal(), [orderConfirmedPr()], { importRequests: [{ id: 1, status: 'SUPERSEDED' }, { id: 2, status: 'DRAFT' }] })) + .toEqual({ key: 'create_import_request', label: 'ออกเลขใบขอซื้อ (IR)' }); + expect(nextSalesAction(paidDeal(), [orderConfirmedPr()], { importRequests: [{ id: 1, status: 'SUPERSEDED' }] })) + .toEqual(CREATE_IR); + }); + + it.each(['IR_ISSUED', 'FROM_STOCK', 'SHIPPING'])('fulfillmentStatus %s -> not offered', (fulfillmentStatus) => { + expect(nextSalesAction(paidDeal({ fulfillmentStatus }), [orderConfirmedPr()], { importRequests: [] })?.key) + .not.toBe('create_import_request'); + }); + + it('a confirmed direct-quotation deal with a reviseTarget still gets the IR, not แก้ใบเสนอราคา', () => { + const deal = paidDeal({ liveDirectQuotation: { id: 90, number: 'QT-2026-0090-1', docStatus: 'APPROVED', recipientType: 'BUYER' } }); + expect(nextSalesAction(deal, [], { reviseTarget: { quotationId: 90, number: 'QT-2026-0090-1' }, importRequests: [] })).toEqual(CREATE_IR); + }); + + it.each(['CUSTOMER_CONFIRMED', 'DEPOSIT_NOTICE_ISSUED', 'AWAITING_FINAL_PAYMENT', null])('paymentStatus %s -> not offered', (paymentStatus) => { + expect(nextSalesAction(paidDeal({ paymentStatus }), [orderConfirmedPr()], { importRequests: [] })?.key) + .not.toBe('create_import_request'); + }); + + it('CUSTOMER_CONFIRMED: the deposit-notice action still wins (the IR button only follows it)', () => { + expect(nextSalesAction(paidDeal({ paymentStatus: 'CUSTOMER_CONFIRMED' }), [orderConfirmedPr()], { importRequests: [] })) + .toMatchObject({ key: 'create_deposit_notice' }); + }); +}); diff --git a/frontend/src/features/tickets/stageMeta.js b/frontend/src/features/tickets/stageMeta.js index 173293963..e9bd01363 100644 --- a/frontend/src/features/tickets/stageMeta.js +++ b/frontend/src/features/tickets/stageMeta.js @@ -36,7 +36,7 @@ export const AUTO_STAGE_HINT = { // Operational — a transition on the deal itself. ORDER_RECEIVED: 'อัปเดตอัตโนมัติเมื่อฝ่ายขายยืนยันใบสั่งซื้อของดีลนี้', DEPOSIT_RECEIVED: 'อัปเดตอัตโนมัติเมื่อฝ่ายบัญชียืนยันรับมัดจำของดีลนี้', - PROCUREMENT: 'อัปเดตอัตโนมัติเมื่อฝ่ายนำเข้าออกใบขอซื้อ (IR) ของดีลนี้', + PROCUREMENT: 'อัปเดตอัตโนมัติเมื่อฝ่ายขายออกเลขใบขอซื้อ (IR) ของดีลนี้', CLOSED_PAID: 'อัปเดตอัตโนมัติเมื่อฝ่ายบัญชียืนยันรับเงินครบของดีลนี้', // Document-driven (owner ruling 2026-10-16) — a quotation addressed to that party. Each names // the DOCUMENT rather than a department, because unlike the four above the way in is something diff --git a/frontend/src/features/tickets/stageMeta.test.js b/frontend/src/features/tickets/stageMeta.test.js index 059ae397f..07bb40099 100644 --- a/frontend/src/features/tickets/stageMeta.test.js +++ b/frontend/src/features/tickets/stageMeta.test.js @@ -47,6 +47,8 @@ describe('stageMeta holds labels, and only labels', () => { ['CLOSED_PAID', 'DEPOSIT_RECEIVED', 'ORDER_RECEIVED', 'PROCUREMENT', 'QUOTE_BUYER', 'QUOTE_DESIGN_SIDE', 'QUOTE_OWNER'], ); + // QA-09: the IR is now issued by the owning sales rep (ขั้น 11 button), not by import. + expect(AUTO_STAGE_HINT.PROCUREMENT).toBe('อัปเดตอัตโนมัติเมื่อฝ่ายขายออกเลขใบขอซื้อ (IR) ของดีลนี้'); for (const hint of Object.values(AUTO_STAGE_HINT)) { expect(hint).toContain('อัตโนมัติ'); } diff --git a/frontend/src/features/tickets/workState.js b/frontend/src/features/tickets/workState.js index bc34dc8bf..d457c3c22 100644 --- a/frontend/src/features/tickets/workState.js +++ b/frontend/src/features/tickets/workState.js @@ -71,7 +71,7 @@ export const DIRECT_APPROVAL_ACTION_KEY = 'approve_direct_quotation'; * The same two apply to a PRICING_REQUEST-origin quotation awaiting approval (owner ruling * 2026-10-27), read from `quotationByRequest` (see nextSalesAction). */ -export function resolveWorkState(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice } = {}) { +export function resolveWorkState(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice, importRequests } = {}) { const role = user?.role; if (!deal || deal.lifecycle !== 'ACTIVE') return { action: null }; @@ -97,7 +97,7 @@ export function resolveWorkState(user, deal, pricingRequests = [], { reviseTarge } } - const action = role === 'sales' ? nextSalesAction(deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice }) + const action = role === 'sales' ? nextSalesAction(deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice, importRequests }) : role === 'import' ? nextImportAction(deal, pricingRequests) : role === 'account' ? nextAccountAction(deal) : null; @@ -138,11 +138,11 @@ function quoteNextPartyBanner({ anchorParty, anchorDeclined, parties }) { * is resolved by the caller through quotationMeta.canReviseDealQuotation (owner + status live * there). Skipped when revise is already the primary (approved direct quotation past draft). */ -export function secondaryWorkActions(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice } = {}) { +export function secondaryWorkActions(user, deal, pricingRequests = [], { reviseTarget = null, offRouteParties = null, quotationByRequest = null, depositNotice = null, remainingInvoice, importRequests } = {}) { if (user?.role !== 'sales' || !deal || deal.lifecycle !== 'ACTIVE') return []; const outcome = outcomeSecondaryAction(deal, pricingRequests, quotationByRequest); const revise = reviseTarget && REVISE_STAGES.has(deal.salesStage) - && resolveWorkState(user, deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice }).action?.key !== SALES_ACTION.REVISE_QUOTATION + && resolveWorkState(user, deal, pricingRequests, { reviseTarget, offRouteParties, quotationByRequest, depositNotice, remainingInvoice, importRequests }).action?.key !== SALES_ACTION.REVISE_QUOTATION ? reviseAction(reviseTarget) : null; return [outcome, revise].filter(Boolean); } diff --git a/frontend/src/features/tickets/workState.test.js b/frontend/src/features/tickets/workState.test.js index a48470f99..03c04e858 100644 --- a/frontend/src/features/tickets/workState.test.js +++ b/frontend/src/features/tickets/workState.test.js @@ -492,3 +492,15 @@ describe('resolveWorkState — forwards depositNotice / remainingInvoice to next .not.toBe('create_remaining_invoice'); }); }); + +describe('resolveWorkState — forwards importRequests to nextSalesAction (QA-09)', () => { + const deal = baseDeal({ status: 'quotation_issued', paymentStatus: 'DEPOSIT_PAID', fulfillmentStatus: null }); + const prs = [{ id: 1, ticketId: 1, status: 'QUOTATION_ACCEPTED', orderConfirmedAt: '2026-10-01T00:00:00Z' }]; + + it('sales: a DRAFT importRequests row reaches nextSalesAction (ออกเลขใบขอซื้อ (IR)); without it, สร้างใบขอซื้อ (IR)', () => { + expect(resolveWorkState({ role: 'sales' }, deal, prs).action) + .toMatchObject({ key: 'create_import_request', label: 'สร้างใบขอซื้อ (IR)' }); + expect(resolveWorkState({ role: 'sales' }, deal, prs, { importRequests: [{ id: 1, status: 'DRAFT' }] }).action) + .toMatchObject({ key: 'create_import_request', label: 'ออกเลขใบขอซื้อ (IR)' }); + }); +});