# `export_pallet_data` Identifier Update Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Change `export_pallet_data` from inbound-order-number filtering to an explicit three-way choice of container numbers, bill-of-lading numbers, or overseas-warehouse inbound-time range, while preserving the 200-item limit, company isolation, async task contract, and pallet export output. **Architecture:** Keep the Gateway as a schema/forwarding boundary and keep all business resolution in fmsoperate. Gateway accepts `container_codes` and `bl_numbers` as separate arrays and rejects ambiguous or mixed selectors; fmsoperate resolves each selector through its own indexed database branch before reusing the existing inbound-record → order → pallet-batch pipeline. The old `inbound_numbers` field is rejected at both the public schema and PHP task boundary. **Tech Stack:** Python 3 standard-library MCP Gateway, ThinkPHP 6/PHP 7.1+, ThinkORM/MySQL, Redis queue, PHPUnit contract tests, Markdown project documentation. **Delivery boundary:** Do not execute registration SQL, restart Gateway or workers, or commit Git changes. Preserve all unrelated user changes already present in the three repositories. --- ## Files and responsibilities ### Gateway (`Y:/mcp`) - Modify `tools/export_pallet_data.py` for metadata, runtime validation, and forwarding. - Modify `app.py` for CLI selector flags and payload construction. - Modify `services/output_presenter.py` to map new parameter errors to Chinese labels. - Modify `tests/test_export_pallet_data_tool.py` for the new closed schema, forwarding, limits, and CLI contract. - Modify `tests/test_tool_description_boundaries.py` so this number-based export requires explicit type clarification. - Modify `tests/test_output_presenter.py` for new field-label error mapping. - Modify `README.md` and `CONTEXT.md` to remove the old selector contract from Gateway-facing material. ### Business backend (`Y:/fmsoperate`) - Modify `app/mcp/validate/McpToolValidate.php` for the new selector XOR rules and explicit legacy-field rejection. - Modify `app/mcp/logic/McpPalletDataExportLogic.php` for normalization, validation, task parameters, and model calls. - Modify `app/mcp/model/McpPalletDataExportModel.php` for separate container and bill-of-lading lookup branches. - Modify `app/mcp/logic/McpPalletDataExportService.php` for the new persisted task parameters. - Modify `app/mcp/controller/McpToolsController.php` so audit payload allowlisting records the new selector fields. - Modify `app/job/think/handle/inside/McpPalletDataExport.php` so queued-task validation accepts only the new selector contract. - Do not modify the route, queue action, task-reference source list, or export output generation. ### Contract tests (`Y:/settlement_tests`) - Modify `tests/Unit/FmsOperate/McpPalletDataExportTest.php` for validator, logic, model-shape, identity, and new selector behavior. - Modify `tests/Unit/FmsOperate/McpPalletDataExportJobTest.php` for worker payload validation and legacy-task rejection. - Modify `docs/manual/mcp-async-export-workbuddy-cases.md` with new pallet selector placeholders and manual cases. ### Central documentation (`Y:/all_project_docs`) - Modify `fmsoperate/requirements.md`, `fmsoperate/tech-specs.md`, `fmsoperate/user-structure.md`, and `fmsoperate/timeline.md`. - Modify `mcp/overview.md`, `mcp/requirements.md`, `mcp/tech-specs.md`, `mcp/user-structure.md`, and `mcp/timeline.md`. - Modify `fmsoperate/sql/mcp_add_pallet_data_export_tool.sql` description only; do not execute it. ## Task 1: Add failing Gateway contract tests **Files:** - Modify: `Y:/mcp/tests/test_export_pallet_data_tool.py` - Modify: `Y:/mcp/tests/test_tool_description_boundaries.py` - Modify: `Y:/mcp/tests/test_output_presenter.py` - [ ] **Step 1: Replace the old schema expectations.** Change the schema test to require exactly: ```python { 'container_codes', 'bl_numbers', 'inbound_time_start', 'inbound_time_end', } ``` Keep `additionalProperties=False`, assert both number fields have `maxItems == 200`, and assert that `inbound_numbers`, `page`, `limit`, `ids`, and `inbound_id` are absent. - [ ] **Step 2: Add forwarding tests for both number types.** Add separate tests that call the tool with trimmed duplicate values and assert the exact payload: ```python ExportPalletDataTool(client).call( container_codes=[' CONT-1 ', 'CONT-1', 'CONT-2'], ) assert client.calls[-1][2] == { 'container_codes': ['CONT-1', 'CONT-2'], } ExportPalletDataTool(client).call( bl_numbers=[' BL-1 ', 'BL-1'], ) assert client.calls[-1][2] == { 'bl_numbers': ['BL-1'], } ``` Retain the existing date-only and date-time boundary tests, changing only their surrounding selector assumptions. - [ ] **Step 3: Add the new validation boundary cases.** Cover all of these cases: ```python tool.call(container_codes=['CONT-1'], bl_numbers=['BL-1']) # raises ValueError tool.call(container_codes=['CONT-1'], inbound_time_start='2026-09-01', inbound_time_end='2026-09-02') # raises ValueError tool.call(bl_numbers=['BL-1'], inbound_time_start='2026-09-01', inbound_time_end='2026-09-02') # raises ValueError tool.call(container_codes=[]) # raises ValueError tool.call(bl_numbers=['BL-' + 'x' * 100]) # accepted at 100 chars tool.call(container_codes=['x' * 101]) # raises ValueError tool.call(bl_numbers=['BL-{0}'.format(i) for i in range(201)]) # raises ValueError tool.call() # raises ValueError ``` The metadata test must also prove that passing the old field is impossible through the closed schema; do not add `inbound_numbers` back to the Python call signature. - [ ] **Step 4: Update description-boundary tests.** Include `ExportPalletDataTool()` in the number-type clarification group and require its description to contain: - `号码类型不明确时必须先询问用户` - `不得根据号码格式猜测` - `不得跨字段或跨工具试查` Keep the existing asynchronous export boundary assertions. - [ ] **Step 5: Add Presenter field-label coverage.** Add a `MCP_1401` case for `container_codes` and one for `bl_numbers`, asserting the public message is respectively `柜号参数不正确` and `提单号参数不正确`, and that raw Python field names are absent from serialized output. - [ ] **Step 6: Run the Gateway tests and confirm they fail for the old implementation.** Run from `Y:/mcp`: ```powershell python -m unittest tests.test_export_pallet_data_tool tests.test_tool_description_boundaries tests.test_output_presenter ``` Expected result: failures identify the still-existing `inbound_numbers` schema/signature/CLI behavior and missing field labels. Do not change production code until these tests demonstrate the contract gap. ## Task 2: Implement the Gateway selector contract **Files:** - Modify: `Y:/mcp/tools/export_pallet_data.py` - Modify: `Y:/mcp/app.py` - Modify: `Y:/mcp/services/output_presenter.py` - [ ] **Step 1: Replace the metadata fields and descriptions.** Define `container_codes` and `bl_numbers` with the same array constraints as the former number field: ```python { 'type': 'array', 'minItems': 1, 'maxItems': 200, 'items': { 'type': 'string', 'minLength': 1, 'maxLength': 100, 'pattern': r'.*\S.*', }, } ``` Describe each field using its Chinese business label, say that it is only valid when the user explicitly identifies that type, and prohibit other number types and internal IDs. Update the tool description to say the selector is exactly “柜号 / 提单号 / 海外仓入库时间”三选一. Explicitly state that an unclear number type requires a user question before calling and that the tool must not guess or try another field. - [ ] **Step 2: Implement a single normalized array helper.** Use one helper with the following behavior for both fields: ```python def _clean_numbers(self, field_name, values): if not isinstance(values, list) or not values: raise ValueError('{0} must be a non-empty list'.format(field_name)) cleaned = [] for value in values: if not isinstance(value, str): raise ValueError('{0} items must be strings'.format(field_name)) item = value.strip() if not item or len(item) > 100: raise ValueError( '{0} items must be 1 to 100 chars'.format(field_name) ) if item not in cleaned: cleaned.append(item) if len(cleaned) > 200: raise ValueError('at most 200 {0}'.format(field_name)) return cleaned ``` - [ ] **Step 3: Change `call()` to enforce the three-way XOR.** The public signature must be: ```python def call( self, container_codes=None, bl_numbers=None, inbound_time_start=None, inbound_time_end=None, request_id='rq_export_pallet_data', ): ``` Treat a non-`None` number argument as supplied, even if it is an empty list, so malformed empty arrays cannot silently disappear. Reject both number arguments together, reject any number argument combined with a time range, require both time bounds when time is selected, and reject an entirely empty payload. Forward exactly one cleaned selector or the two original time-bound strings. - [ ] **Step 4: Update the CLI branch without affecting other tools.** `--container-codes` and `--bl-numbers` already exist as shared CLI options for other tools. Remove only the obsolete `--inbound-numbers` parser option and change the `export_pallet_data` branch to build: ```python if args.container_codes: tool_args['container_codes'] = parse_string_list(args.container_codes) if args.bl_numbers: tool_args['bl_numbers'] = parse_string_list(args.bl_numbers) if args.inbound_time_start: tool_args['inbound_time_start'] = args.inbound_time_start if args.inbound_time_end: tool_args['inbound_time_end'] = args.inbound_time_end tool_args.pop('page', None) tool_args.pop('limit', None) ``` - [ ] **Step 5: Add safe parameter labels.** Extend `OutputPresenter.FIELD_LABELS['export_pallet_data']` with: ```python 'container_codes': '柜号', 'bl_numbers': '提单号', 'inbound_time_start': '入库时间开始', 'inbound_time_end': '入库时间结束', ``` Do not expose raw field names in the public error text. - [ ] **Step 6: Re-run the focused Gateway tests.** Run: ```powershell python -m unittest tests.test_export_pallet_data_tool tests.test_tool_description_boundaries tests.test_output_presenter ``` Expected result: all focused tests pass, including the unchanged 26-tool registry and 25-safe-tool assertions. ## Task 3: Add failing fmsoperate contract tests **Files:** - Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportTest.php` - Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportJobTest.php` - [ ] **Step 1: Update Validate test inputs to the new selector names.** Change the accepted cases to: ```php $this->assertTrue($this->validator()->check([ 'container_codes' => ['CONT-1'], ])); $this->assertTrue($this->validator()->check([ 'bl_numbers' => ['BL-1'], ])); $this->assertTrue($this->validator()->check([ 'inbound_time_start' => '2026-09-01', 'inbound_time_end' => '2026-09-07 18:30:00', ])); ``` - [ ] **Step 2: Add PHP boundary assertions.** Assert false for: ```php [ 'container_codes' => ['CONT-1'], 'bl_numbers' => ['BL-1'], ] [ 'container_codes' => ['CONT-1'], 'inbound_time_start' => '2026-09-01', 'inbound_time_end' => '2026-09-02', ] [ 'inbound_numbers' => ['INB-1'], ] ``` Retain assertions for one-sided time, over-31-day time, 201 items, forbidden pagination, `ids`, and `inbound_id`. - [ ] **Step 3: Update trusted-identity submission coverage.** Use `container_codes` in the submission test and assert the submitted task parameters contain the normalized new field, trusted `company_id`, `operator_id`, `is_super`, and `mcp_source_tool`, while still excluding `ADMIN_ID`. Add a second submission assertion using `bl_numbers` so both code paths persist the correct selector. - [ ] **Step 4: Add model query-shape guards.** Assert the model source contains `fms_outbound`, `container_code`, `fms_booking_detail`, and `bl_number`, keeps `company_id`, does not contain `CONVERT_TZ` or `COUNT(`, and does not contain a combined `container_code|bl_number` OR expression. This freezes the performance requirement that the two number types use separate branches. - [ ] **Step 5: Update worker fixtures and add legacy-task rejection.** Change existing queued-task JSON fixtures from `inbound_numbers` to `container_codes`. Add a successful `bl_numbers` fixture. Add a test whose task JSON contains only `inbound_numbers`; assert the worker does not start the task, exporter is not called, and the task is marked failed. - [ ] **Step 6: Run the focused PHP tests and confirm they fail before implementation.** Run from `Y:/settlement_tests`: ```bat bin\test.bat fmsoperate --filter McpPalletDataExport ``` Expected result: failures identify the old validator field, old Logic parameter payload, missing model branches, and worker assumptions. Do not weaken the new assertions to accommodate the old contract. ## Task 4: Implement fmsoperate validation and orchestration **Files:** - Modify: `Y:/fmsoperate/app/mcp/validate/McpToolValidate.php` - Modify: `Y:/fmsoperate/app/mcp/logic/McpPalletDataExportLogic.php` - Modify: `Y:/fmsoperate/app/mcp/controller/McpToolsController.php` - [ ] **Step 1: Collapse the duplicate `exportPalletData` scene definition.** Keep one scene definition containing only: ```php 'container_codes', 'bl_numbers', 'inbound_time_start', 'inbound_time_end', 'page', 'limit', 'ids', 'inbound_id', 'inbound_numbers', ``` The last five legacy/forbidden keys must be attached to `palletExportForbidden`, with `inbound_numbers` included only to return `MCP_1401` when an old caller sends it. Do not expose it in the Gateway schema or accept it in Logic normalization. - [ ] **Step 2: Apply the same array and range rules to both new fields.** Keep the existing 1–200, string, non-empty, 100-character `palletNumberArray` behavior and attach it to both `container_codes` and `bl_numbers`. Update the XOR callback to evaluate: ```php $hasContainer = is_array($data['container_codes'] ?? null) && $data['container_codes'] !== []; $hasBl = is_array($data['bl_numbers'] ?? null) && $data['bl_numbers'] !== []; $hasStart = trim((string)($data['inbound_time_start'] ?? '')) !== ''; $hasEnd = trim((string)($data['inbound_time_end'] ?? '')) !== ''; return !($hasContainer && $hasBl) && !($hasContainer && ($hasStart || $hasEnd)) && !($hasBl && ($hasStart || $hasEnd)) && (($hasContainer || $hasBl) || ($hasStart && $hasEnd)); ``` Retain the existing date parser and maximum 31-calendar-day rule. - [ ] **Step 3: Normalize only the new fields in Logic.** Replace `inbound_numbers` normalization with two independent de-duplicating loops for `container_codes` and `bl_numbers`. The normalized result must always contain both arrays, even when one is empty: ```php [ 'container_codes' => $containerCodes, 'bl_numbers' => $blNumbers, 'inbound_time_start' => ..., 'inbound_time_end' => ..., 'request_id' => ..., ] ``` `buildExportParams()` must add only these normalized selectors plus trusted `company_id`, `timezone`, `operator`, `operator_id`, `is_super`, and `mcp_source_tool`; never copy caller-supplied identity fields. - [ ] **Step 4: Update Logic validation and model calls.** `validateFilters()` must enforce the same three-way XOR as Validate, including per-field 200/100 limits. `hasExportableData()` and `findPalletIds()` calls must receive both arrays and both time bounds. Keep the existing permission checks, no-data `MCP_1601`, async submission, task reference, and retry response unchanged. - [ ] **Step 5: Update audit allowlisting in the controller.** In `exportPalletData()`, replace the old log-field list with: ```php [ 'container_codes', 'bl_numbers', 'inbound_time_start', 'inbound_time_end', ] ``` The route, route code, permission checks, response envelope, and task-source mapping remain unchanged. - [ ] **Step 6: Run the focused PHP tests.** Run: ```bat bin\test.bat fmsoperate --filter McpPalletDataExport ``` Expected result: Validate and Logic tests pass; model and worker tests remain red until Tasks 5 and 6 are complete. ## Task 5: Implement separate model lookup branches and service persistence **Files:** - Modify: `Y:/fmsoperate/app/mcp/model/McpPalletDataExportModel.php` - Modify: `Y:/fmsoperate/app/mcp/logic/McpPalletDataExportService.php` - [ ] **Step 1: Change the model method signatures.** Use this argument order consistently: ```php public function hasExportableData( $companyId, array $containerCodes, array $blNumbers, $timeStart, $timeEnd ) public function findPalletIds( $companyId, array $containerCodes, array $blNumbers, $timeStart, $timeEnd ) ``` - [ ] **Step 2: Add the container-code branch.** Add `findInboundIdsByContainerCodes($companyId, array $codes)` using one query that: ```php Db::table('fms_destination_inbound')->alias('di') ->join(['fms_outbound' => 'o'], 'di.outbound_id = o.id') ->where('di.company_id', (int)$companyId) ->where('di.is_delete', 0) ->where('o.container_code', 'in', $codes) ->field('di.id') ->group('di.id') ``` Return integer inbound IDs. Do not loop over the 200 values. - [ ] **Step 3: Add the bill-of-lading branch.** Add `findInboundIdsByBlNumbers($companyId, array $numbers)` using: ```php Db::table('fms_destination_inbound')->alias('di') ->join(['fms_outbound' => 'o'], 'di.outbound_id = o.id') ->join(['fms_booking_detail' => 'bd'], 'o.booking_detail_id = bd.id') ->where('di.company_id', (int)$companyId) ->where('di.is_delete', 0) ->where('bd.bl_number', 'in', $numbers) ->field('di.id') ->group('di.id') ``` Return integer inbound IDs and keep the branch separate from the container query. Do not use `where('o.container_code|bd.bl_number', ...)`. - [ ] **Step 4: Select exactly one branch in `findPalletIds()`.** Use this branch order: ```php if ($containerCodes !== []) { $inboundIds = $this->findInboundIdsByContainerCodes($companyId, $containerCodes); } elseif ($blNumbers !== []) { $inboundIds = $this->findInboundIdsByBlNumbers($companyId, $blNumbers); } else { $inboundIds = $this->findInboundIdsByTime($companyId, $timeStart, $timeEnd); } ``` Keep the current inbound ID → order ID → pallet ID stages, company condition on pallet batches, and no `COUNT` behavior. - [ ] **Step 5: Pass new selectors from the async Service.** Replace the old Service call with: ```php $palletIds = $this->model->findPalletIds( $companyId, is_array($params['container_codes'] ?? null) ? $params['container_codes'] : [], is_array($params['bl_numbers'] ?? null) ? $params['bl_numbers'] : [], (string)($params['inbound_time_start'] ?? ''), (string)($params['inbound_time_end'] ?? '') ); ``` Do not change Excel headers, row grouping, attachment download behavior, zip layout, uploader behavior, or temporary-file cleanup. - [ ] **Step 6: Run PHP tests and syntax checks.** Run: ```bat bin\test.bat fmsoperate --filter McpPalletDataExport php -l Y:\fmsoperate\app\mcp\validate\McpToolValidate.php php -l Y:\fmsoperate\app\mcp\logic\McpPalletDataExportLogic.php php -l Y:\fmsoperate\app\mcp\model\McpPalletDataExportModel.php php -l Y:\fmsoperate\app\mcp\logic\McpPalletDataExportService.php php -l Y:\fmsoperate\app\mcp\controller\McpToolsController.php ``` Expected result: focused tests pass and every changed PHP file reports no syntax errors. ## Task 6: Tighten queued-task validation and consumer regression tests **Files:** - Modify: `Y:/fmsoperate/app/job/think/handle/inside/McpPalletDataExport.php` - Modify: `Y:/settlement_tests/tests/Unit/FmsOperate/McpPalletDataExportJobTest.php` - [ ] **Step 1: Validate the persisted selector contract before starting.** In `validatedParams()`, reject any task containing `inbound_numbers`. Accept exactly one of: ```php $containerCodes = $params['container_codes'] ?? []; $blNumbers = $params['bl_numbers'] ?? []; $hasContainer = is_array($containerCodes) && $containerCodes !== []; $hasBl = is_array($blNumbers) && $blNumbers !== []; $hasTime = trim((string)($params['inbound_time_start'] ?? '')) !== '' && trim((string)($params['inbound_time_end'] ?? '')) !== ''; if ( ($hasContainer && $hasBl) || (($hasContainer || $hasBl) && $hasTime) || (!$hasContainer && !$hasBl && !$hasTime) ) { return false; } ``` Also require each new array to contain strings with 1–100-character trimmed values and at most 200 entries, so a manually altered task cannot bypass the HTTP validator. - [ ] **Step 2: Preserve worker identity and terminal-state behavior.** Do not change message/company/operator matching, atomic queued-task start, exporter invocation, completed file mapping, failed-state update, or exception rethrow. Only the persisted selector validation changes. - [ ] **Step 3: Update and extend job tests.** Keep the existing success, identity mismatch, and exception tests, replacing old task JSON with `container_codes`. Add a bill-of-lading success fixture and a legacy `inbound_numbers` fixture that never reaches the exporter and ends in status `3`. - [ ] **Step 4: Run the job-focused test.** Run: ```bat bin\test.bat fmsoperate --filter McpPalletDataExportJob ``` Expected result: all worker identity, success, failure, and legacy-selector tests pass. ## Task 7: Update current documentation and SQL registration description **Files:** - Modify: `Y:/mcp/README.md` - Modify: `Y:/mcp/CONTEXT.md` - Modify: `Y:/all_project_docs/fmsoperate/requirements.md` - Modify: `Y:/all_project_docs/fmsoperate/tech-specs.md` - Modify: `Y:/all_project_docs/fmsoperate/user-structure.md` - Modify: `Y:/all_project_docs/fmsoperate/timeline.md` - Modify: `Y:/all_project_docs/fmsoperate/sql/mcp_add_pallet_data_export_tool.sql` - Modify: `Y:/all_project_docs/mcp/overview.md` - Modify: `Y:/all_project_docs/mcp/requirements.md` - Modify: `Y:/all_project_docs/mcp/tech-specs.md` - Modify: `Y:/all_project_docs/mcp/user-structure.md` - Modify: `Y:/all_project_docs/mcp/timeline.md` - Modify: `Y:/settlement_tests/docs/manual/mcp-async-export-workbuddy-cases.md` - [ ] **Step 1: Replace the fmsoperate business contract.** In the `export_pallet_data` sections, state that the selector is exactly one of: ```text container_codes(柜号数组,1–200) bl_numbers(提单号数组,1–200) inbound_time_start + inbound_time_end(最多31个日历日) ``` State that each number is at most 100 characters, container and bill-of-lading arrays cannot coexist, neither can mix with time, and `inbound_numbers`/internal IDs/order numbers/pallet-batch numbers are unsupported. Preserve the existing company isolation, permissions, async task, output, attachment, and `MCP_1601` behavior. - [ ] **Step 2: Replace Gateway user-facing guidance.** Update the Gateway README and central MCP documents to tell the caller: 1. Ask whether a supplied number is a cabinet/container number or a bill-of-lading number when the user has not said. 2. Do not infer from formatting or try both fields. 3. Use only one number type with up to 200 values, or use the time range. 4. After submission, query the task separately with `query_export_task`. Remove statements saying the tool accepts an inbound number. - [ ] **Step 3: Update current context and acceptance manual.** Replace stale terminology in `Y:/mcp/CONTEXT.md`. Add pallet export placeholders for `<有权柜号>` and `<有权提单号>` to the manual, then add cases for: - successful container-number submission; - successful bill-of-lading submission; - ambiguous number requiring clarification; - container and bill-of-lading arrays mixed and rejected; - 201 values rejected; - number mixed with time rejected; - async task follow-up unchanged. Do not add real credentials, URLs, task references, or customer data. - [ ] **Step 4: Update the SQL registration description only.** Change the `st_mcp_tool_registry.description` text in `mcp_add_pallet_data_export_tool.sql` from inbound-number/time wording to container-number/bill-of-lading/time wording. Keep status defaults and the idempotent `ON DUPLICATE KEY UPDATE` behavior unchanged. Do not run the SQL or change the execution status in `sql/README.md`. - [ ] **Step 5: Check document consistency.** Search all affected documentation for the old active contract: ```powershell rg -n "入仓单号数组|入仓单号或海外仓入库时间|inbound_numbers" Y:\mcp Y:\all_project_docs\fmsoperate Y:\all_project_docs\mcp Y:\settlement_tests\docs ``` Remaining matches must be limited to the design/implementation history explicitly saying that the old field is removed or rejected; no current usage instructions may tell callers to send it. ## Task 8: Full verification and handoff **Files:** all changed files from Tasks 1–7. - [ ] **Step 1: Run all Gateway unit tests.** From `Y:/mcp`: ```powershell python -m unittest discover -s tests -p "test_*.py" python -m coverage run -m unittest discover -s tests -p "test_*.py" python -m coverage report -m --fail-under=100 ``` Expected result: all tests pass and the strict coverage threshold is satisfied. Do not claim coverage success without the actual report. - [ ] **Step 2: Run fmsoperate contract tests.** From `Y:/settlement_tests`: ```bat bin\test.bat fmsoperate --filter McpPalletDataExport ``` Then run the full fmsoperate suite: ```bat bin\test.bat fmsoperate ``` Expected result: both the focused pallet-export tests and the full fmsoperate test configuration pass. - [ ] **Step 3: Run PHP syntax checks for every changed PHP file.** Run `php -l` for: ```text Y:/fmsoperate/app/mcp/validate/McpToolValidate.php Y:/fmsoperate/app/mcp/logic/McpPalletDataExportLogic.php Y:/fmsoperate/app/mcp/model/McpPalletDataExportModel.php Y:/fmsoperate/app/mcp/logic/McpPalletDataExportService.php Y:/fmsoperate/app/mcp/controller/McpToolsController.php Y:/fmsoperate/app/job/think/handle/inside/McpPalletDataExport.php ``` - [ ] **Step 4: Run diff and status checks without altering unrelated work.** Run from each repository: ```powershell git diff --check git status --short ``` Run the same checks from `Y:/all_project_docs`. Review that only the requested files and the approved design/plan are changed; do not reset, checkout, clean, commit, execute SQL, or restart services. - [ ] **Step 5: Review the final contract manually.** Confirm all of the following from code and tests: - Gateway metadata and PHP Validate both reject the old inbound-number selector. - Container and bill-of-lading selectors are separate and mutually exclusive. - Time remains a third mutually exclusive selector. - 200 values are handled in one batch without per-number queries. - Container lookup uses `fms_outbound.container_code`. - Bill-of-lading lookup uses `fms_booking_detail.bl_number`. - Company isolation, permission checks, task references, async queue, 15-column output, attachment behavior, and safe task status responses are unchanged.