Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions frontend/src/features/deposits/DepositNoticePage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
17 changes: 6 additions & 11 deletions frontend/src/features/deposits/createDepositNoticeDraft.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {});
}
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/features/tickets/DealFulfilmentPanel.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<Panel flush title="การส่งมอบ / นำเข้า" data-testid="deal-fulfilment-panel">
<div className="flex flex-col gap-4 p-4 mobile:p-3">
Expand Down Expand Up @@ -668,7 +675,7 @@ export function DealFulfilmentPanel({
ImportRequestService#requireRead exactly (canReadStoredIr above). */}
{canReadStoredIr ? (
<div className={cn(STEP_BOX, 'border-border-strong')}
data-testid="deal-fulfilment-stored-ir">
id="deal-fulfilment-stored-ir" data-testid="deal-fulfilment-stored-ir">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<strong className={STEP_TITLE}>ใบขอซื้อรายโรงงาน (F-SM-001)</strong>
Expand Down Expand Up @@ -1043,4 +1050,4 @@ export function DealFulfilmentPanel({
) : null}
</Panel>
);
}
});
26 changes: 23 additions & 3 deletions frontend/src/features/tickets/TicketDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -1466,6 +1474,17 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) {
</Button>
);
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 = (
<Button type="button" variant="primary" data-testid="ticket-primary-action" data-action={actionKey}
disabled={importRequests === undefined}
onClick={() => runOnTab('fulfilment', () => (hasDraft ? fulfilmentPanelRef.current?.showIrSection() : fulfilmentPanelRef.current?.createIrDrafts()))}>
{workStateAction.label}
</Button>
);
stickyPrimaryLabel = workStateAction.label;
} else if (actionKey === 'confirm_order') {
// DealQuotationPanel now lives inside the "เอกสาร" tab (see comment above).
stickyPrimaryAction = (
Expand Down Expand Up @@ -2730,6 +2749,7 @@ export function TicketDetailPage({ user, ticketId, onBack, showToast }) {
<div id="deal-fulfilment-panel" tabIndex={-1} className="scroll-mt-[300px] mobile:scroll-mt-[420px] outline-none">
{sections.delivery ? (
<DealFulfilmentPanel
ref={fulfilmentPanelRef}
ticketId={ticketId}
user={user}
summary={summary}
Expand Down
82 changes: 82 additions & 0 deletions frontend/src/features/tickets/TicketDetailPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4328,6 +4328,88 @@ describe('TicketDetailPage', () => {
});
});

// 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).
Expand Down
Loading
Loading