# Accessory Module Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add an independent accessory registration module to CargoTrace, allowing warehouse workers to manually register, shelve, and box order accessories that arrive without execution cards. **Architecture:** New `finished_goods_accessory` table + `accessory_type` preset table in PostgreSQL. New FastAPI service layer (`accessory_service.py`) and API routes. Flutter gets a new `AccessoryPage` on the home screen. Existing boxing module is extended to show and box accessories via a new `item_type` column on `finished_goods_box_item`. **Tech Stack:** FastAPI + SQLAlchemy 2.0 (sync) + Pydantic v2 / Flutter 3.x + Dart / PostgreSQL **Branching:** Create a `dev` branch from `master` in both `services/fastapi` and `apps/pad_scanner` submodules. --- ## Task 1: Create dev branches in both submodules **Step 1: Create dev branch in fastapi submodule** ```bash cd D:/FileLib/Projects/CargoTrace/services/fastapi git checkout -b dev git push -u origin dev ``` **Step 2: Create dev branch in pad_scanner submodule** ```bash cd D:/FileLib/Projects/CargoTrace/apps/pad_scanner git checkout -b dev git push -u origin dev ``` **Step 3: Commit plan document in fastapi** The PRD `2026-05-24-accessory-module.md` is already in the working tree. ```bash cd D:/FileLib/Projects/CargoTrace/services/fastapi git add docs/plans/2026-05-24-accessory-module.md docs/plans/2026-05-24-accessory-impl.md git commit -m "docs: add accessory module PRD and implementation plan" ``` --- ## Task 2: Database migration — new tables + ALTER existing table **Files:** - Create: `services/fastapi/app/models/accessory.py` - Modify: `services/fastapi/app/models/finished_goods.py` (add `item_type` column to `FinishedGoodsBoxItem`) - Modify: `services/fastapi/app/models/__init__.py` (register new models for auto-import) **Step 1: Create the accessory model file** ```python # app/models/accessory.py from datetime import datetime from sqlalchemy import BigInteger, DateTime, Index, Integer, String, func from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base SCHEMA = "CargoTrace" class FinishedGoodsAccessory(Base): __tablename__ = "finished_goods_accessory" __table_args__ = ( Index("idx_fga_paichan_no", "paichan_no"), Index("idx_fga_zongpai_no", "zongpai_no"), Index("idx_fga_location_code", "location_code"), {"schema": SCHEMA}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) paichan_no: Mapped[str] = mapped_column(String(64), nullable=False) zongpai_no: Mapped[str] = mapped_column(String(64), nullable=False) accessory_type: Mapped[str] = mapped_column(String(64), nullable=False) quantity: Mapped[int] = mapped_column(Integer, nullable=False) location_code: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime, nullable=False, default=func.current_timestamp(), server_default=func.current_timestamp(), ) class AccessoryType(Base): __tablename__ = "accessory_type" __table_args__ = ( Index("uk_accessory_type_name", "name", unique=True), {"schema": SCHEMA}, ) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) name: Mapped[str] = mapped_column(String(64), nullable=False) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) ``` **Step 2: Add `item_type` column to `FinishedGoodsBoxItem`** In `app/models/finished_goods.py`, add to the `FinishedGoodsBoxItem` class: ```python item_type: Mapped[str] = mapped_column( String(16), nullable=False, default="product", server_default="product" ) ``` **Step 3: Run the SQL migration manually** Execute against the PostgreSQL database: ```sql -- 1. 附件记录表 CREATE TABLE IF NOT EXISTS "CargoTrace".finished_goods_accessory ( id SERIAL PRIMARY KEY, paichan_no VARCHAR(64) NOT NULL, zongpai_no VARCHAR(64) NOT NULL, accessory_type VARCHAR(64) NOT NULL, quantity INT NOT NULL, location_code VARCHAR(64) NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_fga_paichan_no ON "CargoTrace".finished_goods_accessory (paichan_no); CREATE INDEX IF NOT EXISTS idx_fga_zongpai_no ON "CargoTrace".finished_goods_accessory (zongpai_no); CREATE INDEX IF NOT EXISTS idx_fga_location_code ON "CargoTrace".finished_goods_accessory (location_code); -- 2. 附件类型预设表 CREATE TABLE IF NOT EXISTS "CargoTrace".accessory_type ( id SERIAL PRIMARY KEY, name VARCHAR(64) NOT NULL, sort_order INT NOT NULL DEFAULT 0, CONSTRAINT uk_accessory_type_name UNIQUE (name) ); -- 3. 装箱明细表新增 item_type 字段 ALTER TABLE "CargoTrace".finished_goods_box_item ADD COLUMN IF NOT EXISTS item_type VARCHAR(16) NOT NULL DEFAULT 'product'; ``` **Step 4: Verify tables exist** ```bash cd D:/FileLib/Projects/CargoTrace/services/fastapi # Activate venv and run a quick check ``` **Step 5: Commit** ```bash git add app/models/accessory.py app/models/finished_goods.py git commit -m "feat: add accessory models and box_item item_type column" ``` --- ## Task 3: Pydantic schemas for accessory endpoints **Files:** - Create: `services/fastapi/app/schemas/accessory.py` **Step 1: Write the schema file** ```python # app/schemas/accessory.py import re from pydantic import BaseModel, Field, field_validator PAICHAN_PATTERN = re.compile(r"^[A-Z]{1,2}\d{5}(-J)?$") LOCATION_NORMAL_PATTERN = re.compile(r"^[A-Z]+\d+-\d+-\d+$") LOCATION_TRANS_PATTERN = re.compile(r"^TRANS-\d+") # ── Work order query ── class WorkOrderGroup(BaseModel): work_order_no: str zongpai_nos: list[str] class WorkOrderQueryResponse(BaseModel): paichan_no: str work_orders: list[WorkOrderGroup] # ── Accessory CRUD ── class AccessoryCreateRequest(BaseModel): paichan_no: str zongpai_no: str accessory_type: str quantity: int location_code: str | None = None @field_validator("paichan_no") @classmethod def validate_paichan_no(cls, v: str) -> str: v = v.strip().upper() if not PAICHAN_PATTERN.match(v): raise ValueError("INVALID_PAICHAN") return v @field_validator("zongpai_no") @classmethod def validate_zongpai_no(cls, v: str) -> str: v = v.strip().upper() if not re.match(r"^(\d{2}(B|C|T)\d+|\d{2}(BW|CW)\d{4})$", v): raise ValueError("INVALID_ZONGPAI") return v @field_validator("quantity") @classmethod def validate_quantity(cls, v: int) -> int: if v <= 0: raise ValueError("INVALID_QUANTITY") return v @field_validator("location_code") @classmethod def validate_location_code(cls, v: str | None) -> str | None: if v is None: return v v = v.strip().upper() if not LOCATION_NORMAL_PATTERN.match(v) and not LOCATION_TRANS_PATTERN.match(v): raise ValueError("INVALID_LOCATION") return v class AccessoryResponse(BaseModel): id: int paichan_no: str zongpai_no: str accessory_type: str quantity: int location_code: str | None = None is_boxed: bool = False created_at: str class AccessoryListResponse(BaseModel): paichan_no: str items: list[AccessoryResponse] class AccessoryUpdateRequest(BaseModel): accessory_type: str | None = None quantity: int | None = None location_code: str | None = None @field_validator("quantity") @classmethod def validate_quantity(cls, v: int | None) -> int | None: if v is not None and v <= 0: raise ValueError("INVALID_QUANTITY") return v @field_validator("location_code") @classmethod def validate_location_code(cls, v: str | None) -> str | None: if v is None: return v v = v.strip().upper() if not LOCATION_NORMAL_PATTERN.match(v) and not LOCATION_TRANS_PATTERN.match(v): raise ValueError("INVALID_LOCATION") return v # ── Accessory type management ── class AccessoryTypeItem(BaseModel): id: int name: str sort_order: int class AccessoryTypeListResponse(BaseModel): types: list[AccessoryTypeItem] class AccessoryTypeCreateRequest(BaseModel): name: str sort_order: int = 0 @field_validator("name") @classmethod def validate_name(cls, v: str) -> str: v = v.strip() if not v: raise ValueError("INVALID_TYPE_NAME") return v class AccessoryTypeUpdateRequest(BaseModel): name: str | None = None sort_order: int | None = None ``` **Step 2: Commit** ```bash git add app/schemas/accessory.py git commit -m "feat: add accessory Pydantic schemas" ``` --- ## Task 4: Accessory service layer — business logic **Files:** - Create: `services/fastapi/app/services/accessory_service.py` This service handles: work order query, accessory CRUD, shelving/off-shelf logic, and type management. **Step 1: Write the service** Key functions to implement: 1. `query_work_orders(db, paichan_no)` — Query ERP for all work orders under a paichan_no, group by work_order_no, return `WorkOrderQueryResponse`. 2. `list_accessories(db, paichan_no)` — List all accessories for a paichan_no, check `is_boxed` via `finished_goods_box_item`. 3. `create_accessory(db, req)` — Create accessory record with shelving logic (same off-shelf rules as location_service). 4. `update_accessory(db, accessory_id, req)` — Partial update of accessory_type/quantity/location_code. 5. `delete_accessory(db, accessory_id)` — Delete if not boxed. 6. `list_accessory_types(db)` / `create_accessory_type(db, req)` / `update_accessory_type(db, type_id, req)` / `delete_accessory_type(db, type_id)` **Error classes to define:** ```python class InvalidPaichanError(Exception): pass class PaichanNotFoundError(Exception): pass class AccessoryNotFoundError(Exception): pass class AccessoryAlreadyBoxedError(Exception): pass class AccessoryDuplicateLocationError(Exception): def __init__(self, location_code: str, registered_at: str): self.location_code = location_code self.registered_at = registered_at class AccessoryAlreadyOffShelfError(Exception): def __init__(self, location_code: str, registered_at: str): self.location_code = location_code self.registered_at = registered_at ``` **Shelving logic** (in `create_accessory` when `location_code` is provided): - If the accessory row already has a `location_code`: - If existing starts with `TRANS-` → raise `AccessoryAlreadyOffShelfError` - If new target starts with `TRANS-` → update record (off-shelf / down-shelf) - If both are normal locations → raise `AccessoryDuplicateLocationError` **is_boxed check**: Query `finished_goods_box_item` where `zongpai_no` matches and `item_type = 'accessory'` and there exists a `finished_goods_box` with `paichan_no` matching. This checks if the accessory has been packed into a box. **Step 2: Commit** ```bash git add app/services/accessory_service.py git commit -m "feat: add accessory service layer with CRUD and shelving logic" ``` --- ## Task 5: Accessory API routes **Files:** - Create: `services/fastapi/app/api/v1/accessory.py` - Modify: `services/fastapi/app/main.py` — register new router + exception handlers **Step 1: Create the accessory router** ```python # app/api/v1/accessory.py from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.core.database import get_db from app.schemas.accessory import ( AccessoryCreateRequest, AccessoryListResponse, AccessoryResponse, AccessoryTypeCreateRequest, AccessoryTypeItem, AccessoryTypeListResponse, AccessoryTypeUpdateRequest, AccessoryUpdateRequest, WorkOrderQueryResponse, ) from app.services.accessory_service import ( create_accessory, create_accessory_type, delete_accessory, delete_accessory_type, list_accessories, list_accessory_types, update_accessory, update_accessory_type, query_work_orders, ) router = APIRouter(tags=["accessory"]) ``` Endpoints: | Method | Path | Handler | |---|---|---| | GET | `/accessory/work-orders` | `query_work_orders` | | GET | `/accessory` | `list_accessories` | | POST | `/accessory` | `create_accessory` | | PATCH | `/accessory/{id}` | `update_accessory` | | DELETE | `/accessory/{id}` | `delete_accessory` | | GET | `/accessory-type` | `list_accessory_types` | | POST | `/accessory-type` | `create_accessory_type` | | PATCH | `/accessory-type/{id}` | `update_accessory_type` | | DELETE | `/accessory-type/{id}` | `delete_accessory_type` | **Step 2: Register router + exception handlers in `main.py`** Add to `app/main.py`: ```python from app.api.v1.accessory import router as accessory_router from app.services.accessory_service import ( AccessoryAlreadyBoxedError, AccessoryAlreadyOffShelfError, AccessoryDuplicateLocationError, AccessoryNotFoundError, InvalidPaichanError, PaichanNotFoundError, ) app.include_router(accessory_router, prefix="/CargoTrace") ``` Add exception handlers mirroring the existing pattern (400 for `InvalidPaichanError`, 404 for `PaichanNotFoundError`/`AccessoryNotFoundError`, 409 for duplicate/off-shelf/boxed errors). **Step 3: Commit** ```bash git add app/api/v1/accessory.py app/main.py git commit -m "feat: add accessory API routes and exception handlers" ``` --- ## Task 6: Extend boxing module to support accessories **Files:** - Modify: `services/fastapi/app/services/box_service.py` — `get_box_info` returns `pending_accessories`; `save_box_record` handles `item_type=accessory` - Modify: `services/fastapi/app/schemas/box.py` — add `PendingAccessory`, `BoxSaveRequest.item_type`, `BoxSaveRequest.accessory_id` - Modify: `services/fastapi/app/api/v1/box.py` — no structural change, just forward new fields **Step 1: Extend box schemas** In `app/schemas/box.py`, add: ```python class PendingAccessory(BaseModel): accessory_id: int accessory_type: str quantity: int location_code: str | None = None ``` Add to `BoxInfoResponse`: ```python pending_accessories: list[PendingAccessory] = Field(default_factory=list) ``` Add to `BoxSaveRequest`: ```python item_type: str = "product" accessory_id: int | None = None ``` **Step 2: Extend `get_box_info` in `box_service.py`** After building the existing response dict, query `finished_goods_accessory` for the same `zongpai_no` where `location_code IS NOT NULL` (shelved but not yet boxed). Add as `pending_accessories`. **Step 3: Extend `save_box_record` in `box_service.py`** When `item_type == "accessory"`: - Skip the ERP quantity validation (accessories don't have ERP quantities) - Use the `zongpai_no` and `paichan_no` from the `accessory_id` record - Create `FinishedGoodsBoxItem` with `item_type="accessory"` **Step 4: Commit** ```bash git add app/schemas/box.py app/services/box_service.py git commit -m "feat: extend boxing module to support accessory items" ``` --- ## Task 7: FastAPI — integration test **Files:** - Create: `services/fastapi/tests/test_accessory.py` **Step 1: Write integration tests** Test the following scenarios against the running API: 1. `GET /CargoTrace/accessory/work-orders?paichan_no=W00009` — returns work order groups 2. `POST /CargoTrace/accessory` — create accessory with location 3. `GET /CargoTrace/accessory?paichan_no=W00009` — list accessories 4. `PATCH /CargoTrace/accessory/{id}` — update quantity 5. `DELETE /CargoTrace/accessory/{id}` — delete 6. `GET /CargoTrace/accessory-type` — list types 7. `POST /CargoTrace/accessory-type` — create type 8. Off-shelf scenario: create with normal location, then update to TRANS- location 9. Error scenario: duplicate location, already off-shelf, invalid paichan **Step 2: Run tests** ```bash cd D:/FileLib/Projects/CargoTrace/services/fastapi source .venv/Scripts/activate pytest tests/test_accessory.py -v ``` **Step 3: Commit** ```bash git add tests/test_accessory.py git commit -m "test: add accessory module integration tests" ``` --- ## Task 8: Flutter — API service extension **Files:** - Modify: `apps/pad_scanner/lib/services/api_service.dart` — add accessory API methods **Step 1: Add accessory API methods to `ApiService`** Methods to add: ```dart // Work order query Future queryWorkOrders(String paichanNo) // Accessory CRUD Future createAccessory(AccessoryCreateRequest req) Future listAccessories(String paichanNo) Future updateAccessory(int id, AccessoryUpdateRequest req) Future deleteAccessory(int id) // Accessory types Future> listAccessoryTypes() Future createAccessoryType(String name, {int sortOrder = 0}) Future deleteAccessoryType(int id) ``` Define corresponding result/model classes. **Step 2: Commit** ```bash git add lib/services/api_service.dart git commit -m "feat: add accessory API methods to Flutter API service" ``` --- ## Task 9: Flutter — Accessory registration page **Files:** - Create: `apps/pad_scanner/lib/pages/accessory_page.dart` - Create: `apps/pad_scanner/lib/pages/accessory/` directory with part files for separation **Step 1: Create the main page structure** `accessory_page.dart` as StatefulWidget, following the same pattern as `registration_page.dart`: - Part files for scan handling, submit logic, status display, list management - State machine: `idle` → `paichanQueried` → `workOrderSelected` → `accessoryTypeSelected` → `readyToSubmit` **Step 2: Implement the step-by-step form** - Paichan_no input field + query button (hardware Enter key triggers query) - Work order dropdown (populated after query) - Zongpai_no auto-fill or dropdown (based on selected work order) - Accessory type selector (dropdown from presets + manual input option) - Quantity input field - Location code field (supports scanner input) - Submit button **Step 3: Implement the registered accessories list** Bottom half of the page, showing all accessories for the current paichan_no with inline edit/delete. **Step 4: Commit** ```bash git add lib/pages/accessory_page.dart lib/pages/accessory/ git commit -m "feat: add accessory registration page" ``` --- ## Task 10: Flutter — Update home page with accessory card **Files:** - Modify: `apps/pad_scanner/lib/pages/home_page.dart` **Step 1: Add accessory module card** Add a third card between "装箱编号" and "货架查询": ```dart ModuleCard( icon: Icons.build, title: '附件登记', description: '手动登记订单附件上架与装箱', status: ModuleStatus.developing, onTap: () => Navigator.pushNamed(context, '/accessory'), ) ``` **Step 2: Register route in `main.dart`** ```dart '/accessory': (context) => const AccessoryPage(), ``` **Step 3: Commit** ```bash git add lib/pages/home_page.dart lib/main.dart git commit -m "feat: add accessory module card to home page" ``` --- ## Task 11: Flutter — Extend boxing page for accessories **Files:** - Modify: `apps/pad_scanner/lib/pages/boxing_page.dart` or relevant part files - Modify: `apps/pad_scanner/lib/pages/boxing/boxing_models.dart` — add `PendingAccessory` model **Step 1: Parse `pending_accessories` from box info API response** When `get_box_info` returns data, extract `pending_accessories` list. **Step 2: Add "待装箱附件" section in boxing UI** Below the existing assigned items list, show pending accessories with "加入本箱" buttons. **Step 3: Handle accessory boxing submission** When boxing an accessory, send `item_type: 'accessory'` and `accessory_id` in the request. **Step 4: Show accessory items in boxing detail page** In `boxing_detail_page.dart`, distinguish accessories from products (e.g., different row color or icon). **Step 5: Commit** ```bash git add lib/pages/boxing_page.dart lib/pages/boxing/ lib/pages/boxing_detail_page.dart git commit -m "feat: extend boxing module to display and box accessories" ``` --- ## Task 12: Manual testing & polish **Step 1: Start FastAPI dev server** ```bash cd D:/FileLib/Projects/CargoTrace/services/fastapi source .venv/Scripts/activate uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ``` **Step 2: Build and run Flutter app** ```bash cd D:/FileLib/Projects/CargoTrace/apps/pad_scanner flutter run ``` **Step 3: Test the complete flow** 1. Home page → 附件登记 card visible, tap to enter 2. Input paichan_no → work orders load in dropdown 3. Select work order → zongpai_no auto-fills 4. Select accessory type → input quantity 5. Scan location code → submit 6. Verify accessory appears in registered list 7. Go to boxing page → scan a zongpai_no that has accessories 8. Verify "待装箱附件" section shows accessories 9. Box an accessory → verify in detail page **Step 4: Commit any fixes** ```bash git add -A git commit -m "fix: address issues found during manual testing" ``` --- ## Task 13: Final commit and merge to master **Step 1: In fastapi submodule** ```bash cd D:/FileLib/Projects/CargoTrace/services/fastapi # Ensure all changes committed on dev git log --oneline master..dev # Merge dev into master git checkout master git merge dev git push ``` **Step 2: In pad_scanner submodule** ```bash cd D:/FileLib/Projects/CargoTrace/apps/pad_scanner git log --oneline master..dev git checkout master git merge dev git push ``` **Step 3: Update parent repo submodules** ```bash cd D:/FileLib/Projects/CargoTrace git add apps/pad_scanner services/fastapi git commit -m "feat: add accessory registration module" git push ```