Commit 9442c9bc970 for woocommerce

commit 9442c9bc970b123b63d1adabb3c559af4c086820
Author: Abdalsalaam Halawa <abdalsalaamnafez@gmail.com>
Date:   Fri Nov 13 09:07:52 2026 +0200

    [Order Fulfillments] Add the fulfillments importer client data layer and state (#69357)

    * Add the fulfillments importer client data layer and state

    Types, the REST client for the prepare and run endpoints, the CSV helpers for the failed-rows export, and the wizard reducer. The webpack entry is a placeholder until the orders-screen bootstrap lands at the top of the stack.

    * Use first nonblank record as CSV header

    The failed-rows export took its header from the first
    parsed record. A leading blank line, a comma-only row,
    or a whitespace-only row then replaced the real header.

    Blank records stay in the parse so error row numbers
    remain the physical record numbers, including blanks
    between data rows. CR-only line endings are unchanged.

    * Keep the import error in its own column for short and wide rows

    A row with fewer cells than the header put the reason under a data
    column, and a wider row pushed it past the header. Every record is now
    padded to the widest one. Also stop claiming parity with
    WC_CSV_Exporter::escape_data(), which does prefix negative numbers.

    * Clear the ended session on a new file and list replayed rows once

    PREPARE_OK and SET_FILE now reset sessionEnded, and PREPARE_OK drops the
    previous summary, so a fresh session is not treated as unrecoverable.
    CHUNK_OK keys row results by row number, so a chunk the import loop sends
    again after a lost response does not list the same rows twice. The last
    step is renamed from 'done' to 'summary' to match its heading.

diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/components/steps/types.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/components/steps/types.ts
new file mode 100644
index 00000000000..38f4b2d6be1
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/components/steps/types.ts
@@ -0,0 +1,18 @@
+/**
+ * External dependencies
+ */
+import type { Dispatch } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type {
+	ImporterAction,
+	ImporterState,
+} from '../../hooks/use-importer-state';
+
+export interface StepComponentProps {
+	state: ImporterState;
+	dispatch: Dispatch< ImporterAction >;
+	onClose: () => void;
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/api.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/api.ts
new file mode 100644
index 00000000000..e9cdbb760c5
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/api.ts
@@ -0,0 +1,86 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import type { ColumnMapping, PrepareResponse, RunChunkResponse } from './types';
+
+const DEFAULT_BASE = '/wc/v3/fulfillments/import';
+
+/**
+ * Use the REST base localized by PHP when present so the route stays in sync
+ * with FulfillmentsCsvImporterController. Falls back to the default if the
+ * settings object is not on the page.
+ */
+function getBase(): string {
+	const route = window.wcFulfillmentsImporterSettings?.importRoute;
+	return typeof route === 'string' && route.length > 0 ? route : DEFAULT_BASE;
+}
+
+export interface PrepareArgs {
+	file: File;
+	delimiter: string;
+	notifyCustomer: boolean;
+	updateExisting: boolean;
+	signal?: AbortSignal;
+}
+
+export interface RunArgs {
+	token: string;
+	offset: number;
+	limit: number;
+	mapping: ColumnMapping;
+	notifyCustomer: boolean;
+	updateExisting: boolean;
+	signal?: AbortSignal;
+}
+
+/**
+ * Multipart wrapper for the prepare step: stages the upload, parses headers, opens an ImportSession.
+ */
+export async function prepare( args: PrepareArgs ): Promise< PrepareResponse > {
+	const body = new FormData();
+	body.append( 'file', args.file );
+	body.append( 'delimiter', args.delimiter || ',' );
+	body.append( 'notify_customer', args.notifyCustomer ? '1' : '0' );
+	body.append( 'update_existing', args.updateExisting ? '1' : '0' );
+
+	return apiFetch< PrepareResponse >( {
+		path: `${ getBase() }/prepare`,
+		method: 'POST',
+		body,
+		signal: args.signal,
+	} );
+}
+
+/**
+ * Process a single chunk against an open session. Throws on transport or REST error.
+ */
+export async function runChunk( args: RunArgs ): Promise< RunChunkResponse > {
+	const mappingForWire: Record< string, string > = {};
+	Object.entries( args.mapping ).forEach( ( [ col, key ] ) => {
+		// Unassigned ('') and "Do not import" ('skip') columns stay off the wire.
+		if ( key && key !== 'skip' ) {
+			mappingForWire[ col ] = key;
+		}
+	} );
+
+	return apiFetch< RunChunkResponse >( {
+		path: `${ getBase() }/run`,
+		method: 'POST',
+		data: {
+			token: args.token,
+			offset: args.offset,
+			limit: args.limit,
+			mapping: mappingForWire,
+			options: {
+				notify_customer: args.notifyCustomer,
+				update_existing: args.updateExisting,
+			},
+		},
+		signal: args.signal,
+	} );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/test/api.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/test/api.test.ts
new file mode 100644
index 00000000000..9a609c29741
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/test/api.test.ts
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+/**
+ * Internal dependencies
+ */
+import { prepare, runChunk } from '../api';
+
+const mockedApiFetch = apiFetch as jest.MockedFunction< typeof apiFetch >;
+
+describe( 'runChunk', () => {
+	beforeEach( () => {
+		mockedApiFetch.mockReset();
+		mockedApiFetch.mockResolvedValue( {} );
+	} );
+
+	it( 'keeps unassigned and "Do not import" columns off the wire', async () => {
+		await runChunk( {
+			token: 'tok',
+			offset: 0,
+			limit: 100,
+			mapping: {
+				0: 'order_number',
+				1: 'skip',
+				2: '',
+				3: 'tracking_number',
+			},
+			notifyCustomer: false,
+			updateExisting: true,
+		} );
+
+		expect( mockedApiFetch ).toHaveBeenCalledTimes( 1 );
+		const request = mockedApiFetch.mock.calls[ 0 ][ 0 ] as {
+			data: { mapping: Record< string, string > };
+		};
+		expect( request.data.mapping ).toEqual( {
+			0: 'order_number',
+			3: 'tracking_number',
+		} );
+	} );
+
+	it( 'sends the token, offset, limit and options through', async () => {
+		await runChunk( {
+			token: 'tok',
+			offset: 200,
+			limit: 100,
+			mapping: { 0: 'order_number' },
+			notifyCustomer: true,
+			updateExisting: false,
+		} );
+
+		const request = mockedApiFetch.mock.calls[ 0 ][ 0 ] as {
+			data: Record< string, unknown >;
+		};
+		expect( request.data.token ).toBe( 'tok' );
+		expect( request.data.offset ).toBe( 200 );
+		expect( request.data.limit ).toBe( 100 );
+		expect( request.data.options ).toEqual( {
+			notify_customer: true,
+			update_existing: false,
+		} );
+	} );
+} );
+
+describe( 'prepare', () => {
+	beforeEach( () => {
+		mockedApiFetch.mockReset();
+		mockedApiFetch.mockResolvedValue( {} );
+	} );
+
+	it( 'passes the abort signal through to apiFetch', async () => {
+		const controller = new AbortController();
+
+		await prepare( {
+			file: new File( [ 'a,b' ], 'a.csv', { type: 'text/csv' } ),
+			delimiter: ',',
+			notifyCustomer: false,
+			updateExisting: true,
+			signal: controller.signal,
+		} );
+
+		expect( mockedApiFetch ).toHaveBeenCalledTimes( 1 );
+		const request = mockedApiFetch.mock.calls[ 0 ][ 0 ] as {
+			path: string;
+			method: string;
+			signal?: AbortSignal;
+		};
+		expect( request.path ).toBe( '/wc/v3/fulfillments/import/prepare' );
+		expect( request.method ).toBe( 'POST' );
+		expect( request.signal ).toBe( controller.signal );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/types.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/types.ts
new file mode 100644
index 00000000000..63066f840f2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/data/types.ts
@@ -0,0 +1,97 @@
+export interface ImporterRowResult {
+	row: number;
+	status: 'created' | 'updated' | 'skipped' | 'failed';
+	message: string;
+	/**
+	 * Raw order number value from the CSV, so failed rows can still name the
+	 * order the file referred to. Current servers send it for every row;
+	 * optional for rows from before the field existed.
+	 */
+	order_number?: string;
+	/** Stable failure code, present on failed rows only. */
+	code?: string;
+	order_id?: number;
+	/** HPOS-aware admin edit URL, present when the order was resolved. */
+	order_edit_url?: string;
+	fulfillment_id?: number;
+	notified?: boolean;
+}
+
+export interface ImporterSummary {
+	created: number;
+	updated: number;
+	skipped: number;
+	failed: number;
+	notified: number;
+	rows: ImporterRowResult[];
+}
+
+/**
+ * Values arrive through wp_localize_script, which casts scalars to strings,
+ * so numeric fields must be coerced before use. chunkSize and maxRows are
+ * optional until the server localizes them; the client falls back to defaults.
+ */
+export interface ImporterSettings {
+	importRoute: string;
+	chunkSize?: number | string;
+	maxRows?: number | string;
+	autoOpen?: boolean | string;
+	providers: Array< { key: string; label: string } >;
+}
+
+export type CanonicalColumnKey =
+	| 'order_number'
+	| 'tracking_number'
+	| 'shipment_provider'
+	| 'tracking_url'
+	| 'items'
+	| '';
+
+/**
+ * What a CSV column can be mapped to in the UI. The empty string means the
+ * column is still unassigned; 'skip' means the merchant chose "Do not import".
+ * The distinction lets the mapping screen flag only genuinely unassigned
+ * columns when a required field is missing. Neither value is sent on the wire.
+ */
+export type MappingChoice = CanonicalColumnKey | 'skip';
+
+/**
+ * CSV column index => mapping choice.
+ */
+export type ColumnMapping = Record< number, MappingChoice >;
+
+export interface PrepareResponse {
+	token: string;
+	headers: string[];
+	sample: string[];
+	total: number;
+	/**
+	 * The server returns string-keyed indices ("0", "1", ...) per the REST contract.
+	 */
+	detected_mapping: Record< string, CanonicalColumnKey >;
+	delimiter: string;
+}
+
+export interface RunChunkError {
+	row: number;
+	code: string;
+	message: string;
+}
+
+export interface RunChunkCounts {
+	created: number;
+	updated: number;
+	skipped: number;
+	failed: number;
+	notified: number;
+}
+
+export interface RunChunkResponse {
+	processed: number;
+	total: number;
+	done: boolean;
+	counts: RunChunkCounts;
+	rows: ImporterRowResult[];
+	errors: RunChunkError[];
+	summary?: ImporterSummary;
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/global.d.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/global.d.ts
new file mode 100644
index 00000000000..48e51337eb7
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/global.d.ts
@@ -0,0 +1,9 @@
+import type { ImporterSettings } from './data/types';
+
+declare global {
+	interface Window {
+		wcFulfillmentsImporterSettings?: ImporterSettings;
+	}
+}
+
+export {};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/test/use-importer-state.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/test/use-importer-state.test.ts
new file mode 100644
index 00000000000..8133471f104
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/test/use-importer-state.test.ts
@@ -0,0 +1,382 @@
+/**
+ * Internal dependencies
+ */
+import {
+	createInitialState,
+	hasAllRequiredColumns,
+	importerReducer,
+} from '../use-importer-state';
+import type {
+	ImporterSummary,
+	PrepareResponse,
+	RunChunkResponse,
+} from '../../data/types';
+
+const prepareResponse: PrepareResponse = {
+	token: 'abc',
+	headers: [ 'Order ID', 'Tracking', 'Carrier' ],
+	sample: [ '12345', '1Z999', 'UPS' ],
+	total: 25,
+	detected_mapping: {
+		'0': 'order_number',
+		'1': 'tracking_number',
+		'2': 'shipment_provider',
+	},
+	delimiter: ',',
+};
+
+const chunkResponse: RunChunkResponse = {
+	processed: 10,
+	total: 25,
+	done: false,
+	counts: { created: 8, updated: 1, skipped: 1, failed: 0, notified: 0 },
+	rows: [],
+	errors: [],
+};
+
+const summary: ImporterSummary = {
+	created: 22,
+	updated: 1,
+	skipped: 1,
+	failed: 1,
+	notified: 0,
+	rows: [],
+};
+
+describe( 'importerReducer', () => {
+	it( 'transitions upload → mapping → import → summary on the happy path', () => {
+		let state = createInitialState();
+		expect( state.step ).toBe( 'upload' );
+
+		state = importerReducer( state, {
+			type: 'SET_FILE',
+			file: new File( [ 'a,b' ], 'a.csv' ),
+		} );
+		expect( state.file?.name ).toBe( 'a.csv' );
+
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+		expect( state.step ).toBe( 'mapping' );
+		expect( state.token ).toBe( 'abc' );
+		expect( state.mapping[ 0 ] ).toBe( 'order_number' );
+
+		state = importerReducer( state, { type: 'GO_IMPORT' } );
+		expect( state.step ).toBe( 'import' );
+
+		state = importerReducer( state, {
+			type: 'CHUNK_OK',
+			payload: chunkResponse,
+		} );
+		expect( state.processed ).toBe( 10 );
+		expect( state.counts.created ).toBe( 8 );
+
+		state = importerReducer( state, { type: 'FINISH', summary } );
+		expect( state.step ).toBe( 'summary' );
+		expect( state.summary?.created ).toBe( 22 );
+	} );
+
+	it( 'refuses to leave mapping when required columns are not mapped', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+		state = importerReducer( state, {
+			type: 'SET_MAPPING_FOR_COL',
+			col: 2,
+			value: '',
+		} );
+
+		const before = state.step;
+		state = importerReducer( state, { type: 'GO_IMPORT' } );
+		expect( state.step ).toBe( before );
+		expect( state.step ).toBe( 'mapping' );
+	} );
+
+	it( 'ERROR clears busy and stores the message; RESET wipes everything', () => {
+		let state = createInitialState();
+		state = importerReducer( state, { type: 'SET_BUSY', value: true } );
+		state = importerReducer( state, {
+			type: 'ERROR',
+			message: 'Upload failed',
+		} );
+
+		expect( state.isBusy ).toBe( false );
+		expect( state.error ).toBe( 'Upload failed' );
+
+		state = importerReducer( state, { type: 'RESET' } );
+		expect( state ).toEqual( { ...createInitialState(), generation: 1 } );
+	} );
+
+	it( 'RESET and BACK_TO_UPLOAD start a new generation', () => {
+		let state = createInitialState();
+		expect( state.generation ).toBe( 0 );
+
+		state = importerReducer( state, { type: 'RESET' } );
+		expect( state.generation ).toBe( 1 );
+
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+		expect( state.generation ).toBe( 1 );
+
+		state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+		expect( state.generation ).toBe( 2 );
+	} );
+
+	it( 'a new file and a new session clear sessionEnded and the old summary', () => {
+		let state = importerReducer( createInitialState(), {
+			type: 'FINISH',
+			summary,
+		} );
+		state = importerReducer( state, {
+			type: 'ERROR',
+			message: 'The import session expired.',
+			sessionEnded: true,
+		} );
+
+		state = importerReducer( state, {
+			type: 'SET_FILE',
+			file: new File( [ 'a,b' ], 'a.csv' ),
+		} );
+		expect( state.sessionEnded ).toBe( false );
+
+		state = importerReducer( state, {
+			type: 'ERROR',
+			message: 'The import session expired.',
+			sessionEnded: true,
+		} );
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+		expect( state.step ).toBe( 'mapping' );
+		expect( state.error ).toBeNull();
+		expect( state.sessionEnded ).toBe( false );
+		expect( state.summary ).toBeNull();
+	} );
+
+	it( 'CHUNK_OK lists a row only once when a chunk is sent again', () => {
+		const failedRow = {
+			row: 2,
+			status: 'failed' as const,
+			code: 'order_not_found',
+			message: 'Order not found.',
+		};
+		const createdRow = {
+			row: 3,
+			status: 'created' as const,
+			message: '',
+		};
+		const chunk: RunChunkResponse = {
+			...chunkResponse,
+			processed: 2,
+			total: 2,
+			counts: {
+				created: 1,
+				updated: 0,
+				skipped: 0,
+				failed: 1,
+				notified: 0,
+			},
+			rows: [ failedRow, createdRow ],
+		};
+
+		let state = importerReducer( createInitialState(), {
+			type: 'CHUNK_OK',
+			payload: chunk,
+		} );
+		state = importerReducer( state, { type: 'CHUNK_OK', payload: chunk } );
+		expect( state.rows ).toEqual( [ failedRow, createdRow ] );
+
+		state = importerReducer( state, {
+			type: 'FINISH',
+			summary: { ...summary, created: 1, failed: 1 },
+		} );
+		expect( state.summary?.rows ).toHaveLength( 2 );
+	} );
+
+	it( 'SET_FILE ends the busy state of a preparation in flight', () => {
+		let state = createInitialState();
+		state = importerReducer( state, { type: 'SET_BUSY', value: true } );
+		state = importerReducer( state, {
+			type: 'SET_FILE',
+			file: new File( [ '' ], 'b.csv', { type: 'text/csv' } ),
+		} );
+
+		expect( state.isBusy ).toBe( false );
+	} );
+
+	it( 'SET_MAPPING_FOR_COL clears any other column mapped to the same field', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+
+		state = importerReducer( state, {
+			type: 'SET_MAPPING_FOR_COL',
+			col: 2,
+			value: 'tracking_number',
+		} );
+
+		expect( state.mapping[ 2 ] ).toBe( 'tracking_number' );
+		expect( state.mapping[ 1 ] ).toBe( '' );
+		expect( state.mapping[ 0 ] ).toBe( 'order_number' );
+	} );
+
+	it( 'SET_MAPPING_FOR_COL treats unknown values as "do not import"', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+
+		state = importerReducer( state, {
+			type: 'SET_MAPPING_FOR_COL',
+			col: 1,
+			value: 'not_a_column' as never,
+		} );
+
+		expect( state.mapping[ 1 ] ).toBe( '' );
+	} );
+
+	it( 'SET_MAPPING_FOR_COL allows several columns set to "Do not import"', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+
+		state = importerReducer( state, {
+			type: 'SET_MAPPING_FOR_COL',
+			col: 1,
+			value: 'skip',
+		} );
+		state = importerReducer( state, {
+			type: 'SET_MAPPING_FOR_COL',
+			col: 2,
+			value: 'skip',
+		} );
+
+		expect( state.mapping[ 1 ] ).toBe( 'skip' );
+		expect( state.mapping[ 2 ] ).toBe( 'skip' );
+	} );
+
+	it( 'SET_FILE clears the cached file text', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'SET_FILE_TEXT',
+			text: 'a,b\n1,2\n',
+		} );
+		expect( state.fileText ).toBe( 'a,b\n1,2\n' );
+
+		// A newly chosen file must never export rows from the old one.
+		state = importerReducer( state, {
+			type: 'SET_FILE',
+			file: new File( [ 'x,y' ], 'other.csv' ),
+		} );
+		expect( state.fileText ).toBeNull();
+	} );
+
+	it( 'PREPARE_OK stores the delimiter the server parsed with', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: { ...prepareResponse, delimiter: ';' },
+		} );
+		expect( state.delimiter ).toBe( ';' );
+
+		// An empty delimiter in the response keeps the current one.
+		state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: { ...prepareResponse, delimiter: '' },
+		} );
+		expect( state.delimiter ).toBe( ';' );
+	} );
+
+	it( 'BACK_TO_UPLOAD returns to the upload step and keeps the mapping', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+		state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+
+		expect( state.step ).toBe( 'upload' );
+		expect( state.mapping[ 0 ] ).toBe( 'order_number' );
+	} );
+
+	it( 'PREPARE_OK keeps manual mapping edits when the headers are unchanged', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+		state = importerReducer( state, {
+			type: 'SET_MAPPING_FOR_COL',
+			col: 2,
+			value: 'skip',
+		} );
+		state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: prepareResponse,
+		} );
+
+		expect( state.mapping[ 2 ] ).toBe( 'skip' );
+
+		// A file with different headers resets to the fresh detection.
+		state = importerReducer( state, { type: 'BACK_TO_UPLOAD' } );
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: {
+				...prepareResponse,
+				headers: [ 'Other', 'Headers', 'Here' ],
+			},
+		} );
+		expect( state.mapping[ 2 ] ).toBe( 'shipment_provider' );
+	} );
+
+	it( 'PREPARE_OK drops duplicate detected fields, first column wins', () => {
+		let state = createInitialState();
+		state = importerReducer( state, {
+			type: 'PREPARE_OK',
+			payload: {
+				...prepareResponse,
+				detected_mapping: {
+					'0': 'order_number',
+					'1': 'tracking_number',
+					'2': 'tracking_number',
+				},
+			},
+		} );
+
+		expect( state.mapping ).toEqual( {
+			0: 'order_number',
+			1: 'tracking_number',
+			2: '',
+		} );
+	} );
+
+	it( 'hasAllRequiredColumns is true only when every required column is mapped', () => {
+		expect( hasAllRequiredColumns( {} ) ).toBe( false );
+		expect(
+			hasAllRequiredColumns( {
+				0: 'order_number',
+				1: 'tracking_number',
+			} )
+		).toBe( false );
+		expect(
+			hasAllRequiredColumns( {
+				0: 'order_number',
+				1: 'tracking_number',
+				2: 'shipment_provider',
+			} )
+		).toBe( true );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/use-importer-state.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/use-importer-state.ts
new file mode 100644
index 00000000000..42c7604488d
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/hooks/use-importer-state.ts
@@ -0,0 +1,357 @@
+/**
+ * External dependencies
+ */
+import { useReducer } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import type {
+	CanonicalColumnKey,
+	ColumnMapping,
+	ImporterRowResult,
+	ImporterSummary,
+	MappingChoice,
+	PrepareResponse,
+	RunChunkResponse,
+} from '../data/types';
+
+export type ImporterStep = 'upload' | 'mapping' | 'import' | 'summary';
+
+export interface ImporterState {
+	step: ImporterStep;
+	// Upload step.
+	file: File | null;
+	// Content of the staged file, read at upload time. The File handle only
+	// references the on-disk file, so reading it later fails if the file was
+	// moved or edited; the failed-rows export works from this copy instead.
+	fileText: string | null;
+	delimiter: string;
+	notifyCustomer: boolean;
+	updateExisting: boolean;
+	// Mapping step.
+	token: string | null;
+	headers: string[];
+	sample: string[];
+	total: number;
+	mapping: ColumnMapping;
+	// Import step.
+	processed: number;
+	counts: {
+		created: number;
+		updated: number;
+		skipped: number;
+		failed: number;
+		notified: number;
+	};
+	rows: ImporterRowResult[];
+	// Summary step.
+	summary: ImporterSummary | null;
+	// Ambient.
+	error: string | null;
+	// True when the error ended the server-side session, so retrying cannot succeed.
+	sessionEnded: boolean;
+	isBusy: boolean;
+	// Bumped whenever the wizard restarts, so an async result started under an
+	// earlier session can tell it no longer belongs to the current state.
+	generation: number;
+}
+
+export type ImporterAction =
+	| { type: 'SET_FILE'; file: File | null }
+	| { type: 'SET_FILE_TEXT'; text: string | null }
+	| { type: 'SET_DELIMITER'; delimiter: string }
+	| { type: 'SET_NOTIFY'; value: boolean }
+	| { type: 'SET_UPDATE_EXISTING'; value: boolean }
+	| { type: 'SET_BUSY'; value: boolean }
+	| { type: 'PREPARE_OK'; payload: PrepareResponse }
+	| { type: 'SET_MAPPING_FOR_COL'; col: number; value: MappingChoice }
+	| { type: 'BACK_TO_UPLOAD' }
+	| { type: 'GO_IMPORT' }
+	| { type: 'CHUNK_OK'; payload: RunChunkResponse }
+	| { type: 'FINISH'; summary: ImporterSummary }
+	| { type: 'ERROR'; message: string; sessionEnded?: boolean }
+	| { type: 'CLEAR_ERROR' }
+	| { type: 'RESET' };
+
+export const REQUIRED_COLUMNS: CanonicalColumnKey[] = [
+	'order_number',
+	'tracking_number',
+	'shipment_provider',
+];
+
+const CANONICAL_COLUMN_KEYS: ReadonlySet< CanonicalColumnKey > = new Set( [
+	'',
+	'order_number',
+	'tracking_number',
+	'shipment_provider',
+	'tracking_url',
+	'items',
+] );
+
+const MAPPING_CHOICES: ReadonlySet< MappingChoice > = new Set( [
+	...CANONICAL_COLUMN_KEYS,
+	'skip',
+] );
+
+// Values that do not map a column to a field, so any number of columns can hold them.
+function isNonField( value: MappingChoice ): boolean {
+	return value === '' || value === 'skip';
+}
+
+export function createInitialState(): ImporterState {
+	return {
+		step: 'upload',
+		file: null,
+		fileText: null,
+		delimiter: ',',
+		notifyCustomer: false,
+		updateExisting: true,
+		token: null,
+		headers: [],
+		sample: [],
+		total: 0,
+		mapping: {},
+		processed: 0,
+		counts: {
+			created: 0,
+			updated: 0,
+			skipped: 0,
+			failed: 0,
+			notified: 0,
+		},
+		rows: [],
+		summary: null,
+		error: null,
+		sessionEnded: false,
+		isBusy: false,
+		generation: 0,
+	};
+}
+
+/**
+ * Drop repeated canonical fields from a detected mapping, keeping the first
+ * column (lowest index) that maps to each field.
+ */
+function uniqueMapping( mapping: ColumnMapping ): ColumnMapping {
+	const taken = new Set< MappingChoice >();
+	const next: ColumnMapping = {};
+	Object.keys( mapping )
+		.map( Number )
+		.sort( ( a, b ) => a - b )
+		.forEach( ( col ) => {
+			const value = mapping[ col ];
+			if ( ! isNonField( value ) && taken.has( value ) ) {
+				next[ col ] = '';
+				return;
+			}
+			taken.add( value );
+			next[ col ] = value;
+		} );
+	return next;
+}
+
+function normalizeMapping(
+	wireMapping: PrepareResponse[ 'detected_mapping' ]
+): ColumnMapping {
+	const out: ColumnMapping = {};
+	Object.entries( wireMapping || {} ).forEach( ( [ col, key ] ) => {
+		const candidate = ( key || '' ) as CanonicalColumnKey;
+		out[ Number( col ) ] = CANONICAL_COLUMN_KEYS.has( candidate )
+			? candidate
+			: '';
+	} );
+	return uniqueMapping( out );
+}
+
+export function hasAllRequiredColumns( mapping: ColumnMapping ): boolean {
+	const present = new Set( Object.values( mapping ).filter( Boolean ) );
+	return REQUIRED_COLUMNS.every( ( required ) => present.has( required ) );
+}
+
+/**
+ * Assign a field to one CSV column, keeping field mappings one-to-one: any
+ * other column currently mapped to the same field is cleared, and values
+ * outside the known set are treated as unassigned.
+ */
+function assignMappingForCol(
+	mapping: ColumnMapping,
+	col: number,
+	value: MappingChoice
+): ColumnMapping {
+	const safeValue = MAPPING_CHOICES.has( value ) ? value : '';
+	const next: ColumnMapping = {};
+	Object.entries( mapping ).forEach( ( [ key, mapped ] ) => {
+		const index = Number( key );
+		next[ index ] =
+			! isNonField( safeValue ) && mapped === safeValue && index !== col
+				? ''
+				: mapped;
+	} );
+	next[ col ] = safeValue;
+	return next;
+}
+
+/**
+ * Add a chunk's row results, keyed by physical row number. A chunk the
+ * import loop sends again after a lost response reports the same rows, so a
+ * repeated row replaces the earlier entry instead of being listed twice.
+ */
+function mergeRowResults(
+	existing: ImporterRowResult[],
+	incoming: ImporterRowResult[]
+): ImporterRowResult[] {
+	const byRow = new Map< number, ImporterRowResult >();
+	existing.forEach( ( result ) => byRow.set( result.row, result ) );
+	incoming.forEach( ( result ) => byRow.set( result.row, result ) );
+	return Array.from( byRow.values() );
+}
+
+export function importerReducer(
+	state: ImporterState,
+	action: ImporterAction
+): ImporterState {
+	switch ( action.type ) {
+		case 'SET_FILE':
+			// Picking a file aborts any preparation in flight, so busy ends here.
+			return {
+				...state,
+				file: action.file,
+				fileText: null,
+				error: null,
+				sessionEnded: false,
+				isBusy: false,
+			};
+		case 'SET_FILE_TEXT':
+			return { ...state, fileText: action.text };
+		case 'SET_DELIMITER':
+			return { ...state, delimiter: action.delimiter };
+		case 'SET_NOTIFY':
+			return { ...state, notifyCustomer: action.value };
+		case 'SET_UPDATE_EXISTING':
+			return { ...state, updateExisting: action.value };
+		case 'SET_BUSY':
+			return { ...state, isBusy: action.value };
+		case 'PREPARE_OK': {
+			const detected = normalizeMapping(
+				action.payload.detected_mapping
+			);
+			// Keep the merchant's manual mapping when they went back to the
+			// upload step and continued with a file of the same shape.
+			const sameHeaders =
+				state.headers.length > 0 &&
+				state.headers.length === action.payload.headers.length &&
+				state.headers.every(
+					( header, index ) =>
+						header === action.payload.headers[ index ]
+				);
+			return {
+				...state,
+				step: 'mapping',
+				token: action.payload.token,
+				// Keep the delimiter the server actually parsed with, so the
+				// failed-rows export splits the file the same way.
+				delimiter: action.payload.delimiter || state.delimiter,
+				headers: action.payload.headers,
+				sample: action.payload.sample,
+				total: action.payload.total,
+				mapping: sameHeaders ? state.mapping : detected,
+				processed: 0,
+				counts: {
+					created: 0,
+					updated: 0,
+					skipped: 0,
+					failed: 0,
+					notified: 0,
+				},
+				rows: [],
+				summary: null,
+				error: null,
+				sessionEnded: false,
+				isBusy: false,
+			};
+		}
+		case 'SET_MAPPING_FOR_COL':
+			return {
+				...state,
+				mapping: assignMappingForCol(
+					state.mapping,
+					action.col,
+					action.value
+				),
+			};
+		case 'BACK_TO_UPLOAD':
+			// The staged file, delimiter and mapping are all kept so the
+			// merchant can swap the file or a setting and continue.
+			return {
+				...state,
+				step: 'upload',
+				error: null,
+				sessionEnded: false,
+				generation: state.generation + 1,
+			};
+		case 'GO_IMPORT':
+			if ( ! hasAllRequiredColumns( state.mapping ) ) {
+				return state;
+			}
+			return {
+				...state,
+				step: 'import',
+				processed: 0,
+				counts: {
+					created: 0,
+					updated: 0,
+					skipped: 0,
+					failed: 0,
+					notified: 0,
+				},
+				rows: [],
+				error: null,
+			};
+		case 'CHUNK_OK':
+			return {
+				...state,
+				processed: action.payload.processed,
+				counts: {
+					created: action.payload.counts.created,
+					updated: action.payload.counts.updated,
+					skipped: action.payload.counts.skipped,
+					failed: action.payload.counts.failed,
+					notified: action.payload.counts.notified,
+				},
+				rows: mergeRowResults( state.rows, action.payload.rows ?? [] ),
+				error: null,
+			};
+		case 'FINISH':
+			return {
+				...state,
+				step: 'summary',
+				summary: {
+					...action.summary,
+					rows: state.rows,
+				},
+				isBusy: false,
+				error: null,
+			};
+		case 'ERROR':
+			return {
+				...state,
+				error: action.message,
+				sessionEnded: action.sessionEnded === true,
+				isBusy: false,
+			};
+		case 'CLEAR_ERROR':
+			return { ...state, error: null, sessionEnded: false };
+		case 'RESET':
+			return {
+				...createInitialState(),
+				generation: state.generation + 1,
+			};
+		default:
+			return state;
+	}
+}
+
+export function useImporterState() {
+	return useReducer( importerReducer, undefined, createInitialState );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/index.tsx
new file mode 100644
index 00000000000..1e64440c5ab
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/index.tsx
@@ -0,0 +1,8 @@
+/**
+ * Entry point of the fulfillments importer bundle.
+ *
+ * Every folder under wp-admin-scripts is a webpack entry, so this file has to
+ * exist from the first PR of the stack. The orders-screen bootstrap (trigger
+ * button, auto-open handling and the wizard mount) replaces it in the last PR.
+ */
+export {};
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/csv.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/csv.ts
new file mode 100644
index 00000000000..45bc1b07792
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/csv.ts
@@ -0,0 +1,166 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import type { ImporterRowResult } from '../data/types';
+
+/**
+ * Trigger a browser download for a CSV text.
+ */
+export function downloadCsv( filename: string, content: string ): void {
+	const blob = new Blob( [ content ], { type: 'text/csv' } );
+	const url = URL.createObjectURL( blob );
+	const link = document.createElement( 'a' );
+	link.href = url;
+	link.download = filename;
+	document.body.appendChild( link );
+	link.click();
+	document.body.removeChild( link );
+	// Revoking in the same tick can cancel the download in Safari and Firefox.
+	setTimeout( () => URL.revokeObjectURL( url ), 1000 );
+}
+
+/**
+ * Split CSV text into records, honoring quoted fields (including embedded
+ * delimiters, escaped quotes and newlines). Indexes match the importer's
+ * physical record numbers, so blank lines stay in the result.
+ */
+export function parseCsvRecords( text: string, delimiter: string ): string[][] {
+	// A quote delimiter would collide with the quoting rules below.
+	if ( delimiter === '"' ) {
+		delimiter = ',';
+	}
+	// Strip a UTF-8 BOM so the first header cell round-trips clean.
+	if ( text.charCodeAt( 0 ) === 0xfeff ) {
+		text = text.slice( 1 );
+	}
+
+	const records: string[][] = [];
+	let record: string[] = [];
+	let field = '';
+	let inQuotes = false;
+
+	for ( let i = 0; i < text.length; i++ ) {
+		const char = text[ i ];
+		if ( inQuotes ) {
+			if ( char === '"' ) {
+				if ( text[ i + 1 ] === '"' ) {
+					field += '"';
+					i++;
+				} else {
+					inQuotes = false;
+				}
+			} else {
+				field += char;
+			}
+		} else if ( char === '"' && field === '' ) {
+			inQuotes = true;
+		} else if ( char === delimiter ) {
+			record.push( field );
+			field = '';
+		} else if ( char === '\n' || char === '\r' ) {
+			if ( char === '\r' && text[ i + 1 ] === '\n' ) {
+				i++;
+			}
+			record.push( field );
+			records.push( record );
+			record = [];
+			field = '';
+		} else {
+			field += char;
+		}
+	}
+	if ( field !== '' || record.length > 0 ) {
+		record.push( field );
+		records.push( record );
+	}
+	return records;
+}
+
+// A leading trigger character can run as a formula when the file is opened in
+// a spreadsheet. Unlike WC_CSV_Exporter::escape_data(), numeric text such as
+// `-12.5` is left alone, so negative numbers stay numbers.
+const ACTIVE_CONTENT_TRIGGERS = [ '=', '+', '-', '@', '\t', '\r' ];
+
+function escapeCsvField( value: string, delimiter: string ): string {
+	if (
+		ACTIVE_CONTENT_TRIGGERS.includes( value[ 0 ] ) &&
+		Number.isNaN( Number( value ) )
+	) {
+		value = `'${ value }`;
+	}
+	if (
+		value.includes( '"' ) ||
+		value.includes( delimiter ) ||
+		value.includes( '\n' ) ||
+		value.includes( '\r' )
+	) {
+		return `"${ value.replace( /"/g, '""' ) }"`;
+	}
+	return value;
+}
+
+/**
+ * A record is blank when every cell is empty once trimmed, including a
+ * line of commas (`,,,`) or whitespace. Matches the server reader.
+ */
+function isBlankRecord( record: string[] ): boolean {
+	return record.every( ( cell ) => cell.trim() === '' );
+}
+
+/**
+ * Build a CSV of the failed rows in their original columns plus a reason
+ * column, ready to send back to whoever produced the file. `row` is the
+ * 1-based physical record number, including blank records. The header is
+ * the first record that is not blank.
+ */
+export function buildFailedRowsCsv(
+	fileText: string,
+	delimiter: string,
+	failedRows: ImporterRowResult[]
+): string {
+	const effectiveDelimiter = delimiter || ',';
+	const records = parseCsvRecords( fileText, effectiveDelimiter );
+	const header =
+		records.find( ( record ) => ! isBlankRecord( record ) ) ?? [];
+
+	// Physical index, including blank records. A missing index means the
+	// report names a row the file does not have.
+	const failedRecords = failedRows.map( ( row ) => ( {
+		record: records[ row.row - 1 ] ?? [
+			__( '(original row unavailable)', 'woocommerce' ),
+		],
+		message: row.message,
+	} ) );
+
+	// Pad every record to the widest one so the reason always lands in the
+	// "Import error" column, also for rows shorter or wider than the header.
+	const width = Math.max(
+		header.length,
+		...failedRecords.map( ( { record } ) => record.length )
+	);
+	const pad = ( record: string[] ): string[] => [
+		...record,
+		...Array< string >( width - record.length ).fill( '' ),
+	];
+
+	const out: string[][] = [
+		[ ...pad( header ), __( 'Import error', 'woocommerce' ) ],
+		...failedRecords.map( ( { record, message } ) => [
+			...pad( record ),
+			message,
+		] ),
+	];
+
+	return out
+		.map( ( record ) =>
+			record
+				.map( ( field ) => escapeCsvField( field, effectiveDelimiter ) )
+				.join( effectiveDelimiter )
+		)
+		.join( '\r\n' );
+}
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/test/csv.test.ts b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/test/csv.test.ts
new file mode 100644
index 00000000000..02d9893b2fe
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/fulfillments-importer/utils/test/csv.test.ts
@@ -0,0 +1,256 @@
+/**
+ * Internal dependencies
+ */
+import { buildFailedRowsCsv, parseCsvRecords } from '../csv';
+import type { ImporterRowResult } from '../../data/types';
+
+function failedRow(
+	row: number,
+	message = 'Order not found for order number "9".'
+): ImporterRowResult {
+	return {
+		row,
+		status: 'failed',
+		code: 'order_not_found',
+		message,
+		order_number: '9',
+	};
+}
+
+describe( 'parseCsvRecords', () => {
+	it( 'splits simple rows on the delimiter', () => {
+		expect( parseCsvRecords( 'a,b,c\n1,2,3', ',' ) ).toEqual( [
+			[ 'a', 'b', 'c' ],
+			[ '1', '2', '3' ],
+		] );
+	} );
+
+	it( 'returns no records for empty input', () => {
+		expect( parseCsvRecords( '', ',' ) ).toEqual( [] );
+	} );
+
+	it( 'does not emit an empty record for a trailing newline', () => {
+		expect( parseCsvRecords( 'a,b\n1,2\n', ',' ) ).toEqual( [
+			[ 'a', 'b' ],
+			[ '1', '2' ],
+		] );
+	} );
+
+	it( 'honors quoted fields with embedded delimiters, quotes and newlines', () => {
+		const text = 'a,b\n"1,5","he said ""hi""\nsecond line"';
+		expect( parseCsvRecords( text, ',' ) ).toEqual( [
+			[ 'a', 'b' ],
+			[ '1,5', 'he said "hi"\nsecond line' ],
+		] );
+	} );
+
+	it( 'falls back to a comma when the delimiter is the quote character', () => {
+		expect( parseCsvRecords( 'a,b\n"x,y",2', '"' ) ).toEqual( [
+			[ 'a', 'b' ],
+			[ 'x,y', '2' ],
+		] );
+	} );
+
+	it( 'strips a UTF-8 BOM and handles CRLF line endings', () => {
+		expect( parseCsvRecords( 'a;b\r\n1;2\r\n', ';' ) ).toEqual( [
+			[ 'a', 'b' ],
+			[ '1', '2' ],
+		] );
+	} );
+
+	it( 'treats a lone CR as a line ending', () => {
+		expect( parseCsvRecords( 'a,b\r1,2\r3,4', ',' ) ).toEqual( [
+			[ 'a', 'b' ],
+			[ '1', '2' ],
+			[ '3', '4' ],
+		] );
+	} );
+
+	it( 'keeps blank, comma-only and whitespace-only records at their physical indexes', () => {
+		expect(
+			parseCsvRecords(
+				'\n,,,\norder,tracking\n   \n" \t ", \n1,2\n',
+				','
+			)
+		).toEqual( [
+			[ '' ],
+			[ '', '', '', '' ],
+			[ 'order', 'tracking' ],
+			[ '   ' ],
+			[ ' \t ', ' ' ],
+			[ '1', '2' ],
+		] );
+	} );
+} );
+
+describe( 'buildFailedRowsCsv', () => {
+	const failed: ImporterRowResult[] = [
+		{
+			row: 3,
+			status: 'failed',
+			code: 'order_not_found',
+			message: 'Order not found for order number "9".',
+			order_number: '9',
+		},
+	];
+
+	it( 'keeps the original columns and appends the reason', () => {
+		const file = 'order,tracking\n1,T-1\n9,T-2\n';
+		const csv = buildFailedRowsCsv( file, ',', failed );
+		const lines = csv.split( '\r\n' );
+
+		expect( lines[ 0 ] ).toBe( 'order,tracking,Import error' );
+		// Row 3 of the file (header is row 1) is the failed one.
+		expect( lines[ 1 ] ).toBe(
+			'9,T-2,"Order not found for order number ""9""."'
+		);
+		expect( lines ).toHaveLength( 2 );
+	} );
+
+	it( 'quotes a reason that contains the semicolon delimiter', () => {
+		const semicolonFailed: ImporterRowResult[] = [
+			{
+				row: 3,
+				status: 'failed',
+				code: 'order_not_found',
+				message: 'Order not found; check the number.',
+				order_number: '9',
+			},
+		];
+		const csv = buildFailedRowsCsv(
+			'order;tracking\n1;T-1\n9;T-2\n',
+			';',
+			semicolonFailed
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order;tracking;Import error',
+			'9;T-2;"Order not found; check the number."',
+		] );
+	} );
+
+	it( 'neutralizes spreadsheet formula triggers but not plain numbers', () => {
+		const file = 'order,note\n1,x\n9,=HYPERLINK("http://evil")\n';
+		const csv = buildFailedRowsCsv( file, ',', failed );
+
+		expect( csv ).toContain( `"'=HYPERLINK(""http://evil"")"` );
+		// Negative numbers stay untouched. WC_CSV_Exporter::escape_data() would
+		// prefix this one, since fgetcsv() hands it a string.
+		const numeric = buildFailedRowsCsv(
+			'order,amount\n1,x\n9,-12.5\n',
+			',',
+			failed
+		);
+		expect( numeric ).toContain( '9,-12.5,' );
+	} );
+
+	it( 'pads a row shorter than the header so the reason stays in its column', () => {
+		const shortFailed: ImporterRowResult[] = [
+			{
+				row: 3,
+				status: 'failed',
+				code: 'missing_provider',
+				message: 'Shipment provider is required',
+				order_number: '1002',
+			},
+		];
+		const csv = buildFailedRowsCsv(
+			'order_number,tracking_number,shipment_provider,tracking_url\n1001,TRK-1,ups,\n1002,TRK-2\n',
+			',',
+			shortFailed
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order_number,tracking_number,shipment_provider,tracking_url,Import error',
+			'1002,TRK-2,,,Shipment provider is required',
+		] );
+	} );
+
+	it( 'widens the header when a row has more cells than the header', () => {
+		const csv = buildFailedRowsCsv(
+			'order,tracking\n1,T-1\n9,T-2,extra\n',
+			',',
+			failed
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order,tracking,,Import error',
+			'9,T-2,extra,"Order not found for order number ""9""."',
+		] );
+	} );
+
+	it( 'marks rows whose original record cannot be found', () => {
+		const csv = buildFailedRowsCsv( 'order,tracking\n', ',', failed );
+		expect( csv.split( '\r\n' )[ 1 ] ).toContain(
+			'(original row unavailable)'
+		);
+	} );
+
+	it( 'uses the first nonblank record as the header after a leading blank line', () => {
+		const csv = buildFailedRowsCsv(
+			'\norder,tracking\n1,T-1\n9,T-2\n',
+			',',
+			[ failedRow( 4 ) ]
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order,tracking,Import error',
+			'9,T-2,"Order not found for order number ""9""."',
+		] );
+	} );
+
+	it( 'treats a leading comma-only row as blank', () => {
+		const csv = buildFailedRowsCsv(
+			',,,\norder,tracking\n1,T-1\n9,T-2\n',
+			',',
+			[ failedRow( 4 ) ]
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order,tracking,Import error',
+			'9,T-2,"Order not found for order number ""9""."',
+		] );
+	} );
+
+	it( 'treats whitespace-only rows as blank and does not trim real cells', () => {
+		// Physical rows: spaces, quoted tab, header, comma-separated
+		// whitespace, then the failed row with its own surrounding spaces.
+		const csv = buildFailedRowsCsv(
+			'   \n" \t "\norder,tracking\n , \t , \n 9 , FAIL \n',
+			',',
+			[ failedRow( 5, 'Missing order' ) ]
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order,tracking,Import error',
+			' 9 , FAIL ,Missing order',
+		] );
+	} );
+
+	it( 'keeps the physical row when blank rows sit between data', () => {
+		const csv = buildFailedRowsCsv(
+			'order,tracking\n1,T-1\n\n9,T-2\n2,T-3\n',
+			',',
+			[ failedRow( 4 ) ]
+		);
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order,tracking,Import error',
+			'9,T-2,"Order not found for order number ""9""."',
+		] );
+	} );
+
+	it( 'numbers failed rows by physical record, including leading and in-file blanks', () => {
+		const file = '\n,,,\norder,tracking\n1,T-1\n\n   \n9,T-2\n2,T-3\n';
+		const csv = buildFailedRowsCsv( file, ',', [
+			failedRow( 7, 'First failure' ),
+			failedRow( 8, 'Second failure' ),
+		] );
+
+		expect( csv.split( '\r\n' ) ).toEqual( [
+			'order,tracking,Import error',
+			'9,T-2,First failure',
+			'2,T-3,Second failure',
+		] );
+	} );
+} );