# Raw Material Return Outbound 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:** Add 原材料库“退货出库” so already-inbound raw material lots can be returned to suppliers with logistics evidence, inventory deduction, purchase-order warning status, and full traceability.

**Architecture:** Implement a dedicated purchase-return business record plus inventory outbound transactions. The return operation selects a concrete raw-material stock lot, links it back to a purchase order item through receipt history, deducts weight from that lot, and exposes “存在退货” as a warning overlay while preserving the purchase order lifecycle status.

**Tech Stack:** FastAPI, SQLAlchemy ORM, Pydantic schemas, MySQL patch SQL, SQLite unit tests, Vue 3 single-file components, Vite build.

---

## Scope And Decisions

- Add this only under `嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库`.
- This is for material that already entered the warehouse and was later found quality/performance-unqualified.
- This is not the same as purchase quality inspection rejection. Existing `PENDING_QC` / `REJECTED` quality flow stays unchanged.
- Raw material remains weight-only. Do not introduce raw-material quantity fields.
- Logistics rule for this feature: require either `运单号` or `订单/运单照片`. `运费` is optional for this feature.
- Purchase order status display must preserve lifecycle status and add a warning overlay. Example: `已收货 · 存在退货`.
- If a lot has receipt history from exactly one purchase order item, auto-bind it. If multiple purchase order items contributed to the same merged lot, require the user to choose which purchase order item receives this return.

## File Structure

- Modify `backend/app/models/operations.py`
  - Add `PurchaseReturn` and `PurchaseReturnItem` ORM models.
- Create `backend/sql/add_purchase_return_outbound_patch.sql`
  - Add `po_purchase_return` and `po_purchase_return_item` tables.
- Modify `backend/app/schemas/operations.py`
  - Add return-out request/read schemas.
  - Add stock-lot purchase-link read schema.
  - Add purchase return overlay fields to purchase order read schemas.
- Modify `backend/app/services/logistics.py`
  - Allow a logistics-required operation to require waybill/photo without requiring freight.
- Modify `backend/app/services/operations.py`
  - Add purchase-link query helper for stock lots.
  - Add return aggregation to purchase order query helpers.
  - Add return transaction labels where query output needs them.
- Modify `backend/app/api/routes/inventory.py`
  - Add stock-lot purchase-link endpoint.
  - Add raw-material return-out endpoint.
  - Add `PURCHASE_RETURN_OUT` to outbound labels/permission maps if the generic maps need the type.
- Modify `frontend/src/utils/dictionaries.js`
  - Add labels for `PURCHASE_RETURN`, `PURCHASE_RETURN_OUT`, and `HAS_RETURN`.
- Modify `frontend/src/utils/statusBadges.js`
  - Add warning tone for `存在退货` and `HAS_RETURN`.
- Modify `frontend/src/views/InventoryLedgerView.vue`
  - Add the `退货出库` raw outbound button.
  - Add a dedicated drawer for stock-lot purchase return.
  - Submit to the new backend endpoint and refresh inventory.
- Modify `frontend/src/views/PurchaseOrderView.vue`
  - Show purchase lifecycle status plus warning chip when a PO or PO line has returns.
- Create `backend/tests/test_raw_material_return_outbound.py`
  - Cover backend return-out, validation, lot deduction, purchase order warning overlay, and optional freight.
- Modify or create `frontend/scripts/test-inventory-ledger-raw-return.mjs`
  - Static verification that the raw return operation and labels exist.

---

### Task 1: Add Purchase Return Tables And ORM Models

**Files:**
- Modify: `backend/app/models/operations.py`
- Create: `backend/sql/add_purchase_return_outbound_patch.sql`
- Test: `backend/tests/test_raw_material_return_outbound.py`

- [ ] **Step 1: Write the failing ORM model test**

Create `backend/tests/test_raw_material_return_outbound.py` with this initial content:

```python
from __future__ import annotations

import unittest
from datetime import UTC, date, datetime
from decimal import Decimal

from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker


@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
    _ = type_, compiler, kw
    return "INTEGER"


import app.models.master_data  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.master_data import Item, StockBalance, Warehouse  # noqa: E402
from app.models.operations import (  # noqa: E402
    InventoryTxn,
    PurchaseOrder,
    PurchaseOrderItem,
    PurchaseReceipt,
    PurchaseReceiptItem,
    PurchaseReturn,
    PurchaseReturnItem,
    StockLot,
    Supplier,
    WarehouseLocation,
)


class User:
    id = 1


class Context:
    user = User()


class RawMaterialReturnOutboundTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()
        self._seed()

    def tearDown(self) -> None:
        self.db.close()

    def _seed(self) -> None:
        now = datetime.now(UTC)
        self.supplier = Supplier(
            id=1,
            supplier_code="SUP-001",
            supplier_name="宁波测试供应商",
            status="ACTIVE",
            lead_time_days=0,
            default_tax_rate=Decimal("0"),
            created_at=now,
            updated_at=now,
        )
        self.raw_warehouse = Warehouse(
            id=1,
            warehouse_code="WH-RM-01",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.raw_location = WarehouseLocation(
            id=1,
            warehouse_id=1,
            location_code="RM-A-01",
            location_name="原料主库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.raw_item = Item(
            id=1,
            item_code="原材料00001",
            item_name="冷轧钢板",
            item_type="RAW_MATERIAL",
            unit_weight_kg=Decimal("1"),
            safety_stock_weight_kg=Decimal("0"),
            scrap_sale_price=Decimal("1"),
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.purchase_order = PurchaseOrder(
            id=1,
            po_no="采购2026-00001",
            supplier_id=1,
            order_date=date(2026, 5, 31),
            expected_date=None,
            purchaser_employee_id=None,
            target_warehouse_type="RAW",
            tax_rate=Decimal("0"),
            total_amount=Decimal("2500"),
            status="RECEIVED",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.purchase_order_item = PurchaseOrderItem(
            id=1,
            purchase_order_id=1,
            line_no=1,
            material_item_id=1,
            source_demand_id=None,
            order_qty=Decimal("0"),
            order_weight_kg=Decimal("500"),
            received_qty=Decimal("0"),
            received_weight_kg=Decimal("500"),
            unit_price=Decimal("5"),
            line_amount=Decimal("2500"),
            status="CLOSED",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.receipt = PurchaseReceipt(
            id=1,
            receipt_no="入库2026-00001",
            purchase_order_id=1,
            warehouse_id=1,
            receipt_date=now,
            receiver_employee_id=None,
            supplier_delivery_no="SUP-DN-001",
            status="PASSED",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.receipt_item = PurchaseReceiptItem(
            id=1,
            receipt_id=1,
            line_no=1,
            purchase_order_item_id=1,
            material_item_id=1,
            location_id=1,
            lot_no="JH_原材料00001_0001",
            merge_to_lot_id=None,
            received_qty=Decimal("0"),
            received_weight_kg=Decimal("500"),
            accepted_qty=Decimal("0"),
            accepted_weight_kg=Decimal("500"),
            unit_cost=Decimal("5"),
            status="PASSED",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.lot = StockLot(
            id=1,
            lot_no="JH_原材料00001_0001",
            parent_lot_id=None,
            lot_role="INBOUND_RAW",
            material_sub_batch_no=None,
            item_id=1,
            warehouse_id=1,
            location_id=1,
            source_doc_type="PURCHASE_RECEIPT",
            source_doc_id=1,
            source_line_id=1,
            source_material_lot_id=None,
            source_material_sub_batch_no=None,
            source_material_summary=None,
            logistics_waybill_no=None,
            logistics_freight_amount=None,
            logistics_photo_url=None,
            inbound_qty=Decimal("0"),
            inbound_weight_kg=Decimal("500"),
            remaining_qty=Decimal("0"),
            remaining_weight_kg=Decimal("500"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("5"),
            production_date=None,
            expire_date=None,
            quality_status="PASS",
            status="AVAILABLE",
            remark="质检放行",
            created_at=now,
            updated_at=now,
        )
        self.balance = StockBalance(
            id=1,
            item_id=1,
            warehouse_id=1,
            location_id=1,
            qty_on_hand=Decimal("0"),
            weight_on_hand_kg=Decimal("500"),
            qty_available=Decimal("0"),
            weight_available_kg=Decimal("500"),
            qty_allocated=Decimal("0"),
            weight_allocated_kg=Decimal("0"),
            avg_unit_cost=Decimal("5"),
            updated_at=now,
        )
        self.db.add_all(
            [
                self.supplier,
                self.raw_warehouse,
                self.raw_location,
                self.raw_item,
                self.purchase_order,
                self.purchase_order_item,
                self.receipt,
                self.receipt_item,
                self.lot,
                self.balance,
            ]
        )
        self.db.commit()

    def test_purchase_return_tables_exist(self) -> None:
        now = datetime.now(UTC)
        purchase_return = PurchaseReturn(
            return_no="退货2026-00001",
            purchase_order_id=1,
            warehouse_id=1,
            return_date=now,
            operator_user_id=1,
            logistics_waybill_no="YT123",
            logistics_freight_amount=None,
            logistics_photo_url=None,
            reason="性能不合格",
            status="CONFIRMED",
            remark="测试退货",
            created_at=now,
            updated_at=now,
        )
        self.db.add(purchase_return)
        self.db.flush()
        return_item = PurchaseReturnItem(
            purchase_return_id=purchase_return.id,
            line_no=1,
            purchase_order_item_id=1,
            material_item_id=1,
            lot_id=1,
            return_weight_kg=Decimal("20"),
            unit_cost=Decimal("5"),
            amount=Decimal("100"),
            reason="性能不合格",
            status="CONFIRMED",
            remark=None,
            created_at=now,
            updated_at=now,
        )
        self.db.add(return_item)
        self.db.commit()

        saved = self.db.scalar(select(PurchaseReturn).where(PurchaseReturn.return_no == "退货2026-00001"))
        saved_item = self.db.scalar(select(PurchaseReturnItem).where(PurchaseReturnItem.purchase_return_id == saved.id))

        self.assertEqual(saved.status, "CONFIRMED")
        self.assertEqual(float(saved_item.return_weight_kg), 20)
```

- [ ] **Step 2: Run the model test and verify it fails**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_return_tables_exist -q
```

Expected: FAIL with an import error for `PurchaseReturn` or `PurchaseReturnItem`.

- [ ] **Step 3: Add ORM models**

Modify `backend/app/models/operations.py` after `PurchaseReceiptItem` and before `StockLot`:

```python
class PurchaseReturn(Base):
    __tablename__ = "po_purchase_return"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    return_no: Mapped[str] = mapped_column(String(50))
    purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id"))
    warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
    return_date: Mapped[object] = mapped_column(DateTime)
    operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
    logistics_waybill_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
    logistics_freight_amount: Mapped[float | None] = mapped_column(DECIMAL(18, 2), nullable=True)
    logistics_photo_url: Mapped[str | None] = mapped_column(String(255), nullable=True)
    reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
    status: Mapped[str] = mapped_column(String(32))
    remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
    created_at: Mapped[object] = mapped_column(DateTime)
    updated_at: Mapped[object] = mapped_column(DateTime)


class PurchaseReturnItem(Base):
    __tablename__ = "po_purchase_return_item"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    purchase_return_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_return.id"))
    line_no: Mapped[int] = mapped_column(Integer)
    purchase_order_item_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order_item.id"))
    material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
    lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
    return_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
    amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
    reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
    status: Mapped[str] = mapped_column(String(32))
    remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
    created_at: Mapped[object] = mapped_column(DateTime)
    updated_at: Mapped[object] = mapped_column(DateTime)
```

- [ ] **Step 4: Add MySQL patch SQL**

Create `backend/sql/add_purchase_return_outbound_patch.sql`:

```sql
CREATE TABLE IF NOT EXISTS po_purchase_return (
  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  return_no VARCHAR(50) NOT NULL,
  purchase_order_id BIGINT NOT NULL,
  warehouse_id BIGINT NOT NULL,
  return_date DATETIME NOT NULL,
  operator_user_id BIGINT NULL,
  logistics_waybill_no VARCHAR(100) NULL,
  logistics_freight_amount DECIMAL(18, 2) NULL,
  logistics_photo_url VARCHAR(255) NULL,
  reason VARCHAR(255) NULL,
  status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED',
  remark VARCHAR(255) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_po_purchase_return_no (return_no),
  KEY idx_po_purchase_return_order (purchase_order_id),
  KEY idx_po_purchase_return_warehouse (warehouse_id),
  CONSTRAINT fk_po_purchase_return_order FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order (id),
  CONSTRAINT fk_po_purchase_return_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购退货出库单';

CREATE TABLE IF NOT EXISTS po_purchase_return_item (
  id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
  purchase_return_id BIGINT NOT NULL,
  line_no INT NOT NULL,
  purchase_order_item_id BIGINT NOT NULL,
  material_item_id BIGINT NOT NULL,
  lot_id BIGINT NOT NULL,
  return_weight_kg DECIMAL(18, 6) NOT NULL DEFAULT 0,
  unit_cost DECIMAL(18, 4) NOT NULL DEFAULT 0,
  amount DECIMAL(18, 2) NOT NULL DEFAULT 0,
  reason VARCHAR(255) NULL,
  status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED',
  remark VARCHAR(255) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  KEY idx_po_purchase_return_item_return (purchase_return_id),
  KEY idx_po_purchase_return_item_po_item (purchase_order_item_id),
  KEY idx_po_purchase_return_item_lot (lot_id),
  CONSTRAINT fk_po_purchase_return_item_return FOREIGN KEY (purchase_return_id) REFERENCES po_purchase_return (id),
  CONSTRAINT fk_po_purchase_return_item_po_item FOREIGN KEY (purchase_order_item_id) REFERENCES po_purchase_order_item (id),
  CONSTRAINT fk_po_purchase_return_item_material FOREIGN KEY (material_item_id) REFERENCES md_item (id),
  CONSTRAINT fk_po_purchase_return_item_lot FOREIGN KEY (lot_id) REFERENCES wh_stock_lot (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购退货出库明细';
```

- [ ] **Step 5: Run the model test and verify it passes**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_return_tables_exist -q
```

Expected: PASS.

- [ ] **Step 6: Commit this task if the workspace is a git repository**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/models/operations.py backend/sql/add_purchase_return_outbound_patch.sql backend/tests/test_raw_material_return_outbound.py
git commit -m "feat: add purchase return outbound tables"
```

Expected: commit succeeds when the workspace has `.git`. If `git status` says this is not a git repository, record that in the final implementation notes and continue without committing.

---

### Task 2: Add Schemas, Logistics Rule, And Stock-Lot Purchase Links

**Files:**
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/services/logistics.py`
- Modify: `backend/app/services/operations.py`
- Test: `backend/tests/test_raw_material_return_outbound.py`

- [ ] **Step 1: Add failing tests for stock-lot purchase links and logistics optional freight**

Append these tests to `RawMaterialReturnOutboundTest`:

```python
    def test_stock_lot_purchase_links_returns_receipt_purchase_order_item(self) -> None:
        from app.services.operations import get_stock_lot_purchase_links

        links = get_stock_lot_purchase_links(self.db, self.lot.id)

        self.assertEqual(len(links), 1)
        self.assertEqual(links[0]["purchase_order_id"], self.purchase_order.id)
        self.assertEqual(links[0]["purchase_order_item_id"], self.purchase_order_item.id)
        self.assertEqual(links[0]["po_no"], "采购2026-00001")
        self.assertEqual(float(links[0]["accepted_weight_kg"]), 500)
        self.assertEqual(float(links[0]["returned_weight_kg"]), 0)
        self.assertEqual(float(links[0]["returnable_weight_kg"]), 500)

    def test_logistics_can_require_waybill_or_photo_without_freight(self) -> None:
        from app.services.logistics import normalize_logistics_fields

        waybill, freight, photo_url = normalize_logistics_fields(
            None,
            None,
            required=True,
            freight_required=False,
            order_photo_url="/inventory/logistics-photos/test.jpg",
        )

        self.assertIsNone(waybill)
        self.assertIsNone(freight)
        self.assertEqual(photo_url, "/inventory/logistics-photos/test.jpg")
```

- [ ] **Step 2: Run the new tests and verify they fail**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_stock_lot_purchase_links_returns_receipt_purchase_order_item \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_logistics_can_require_waybill_or_photo_without_freight \
  -q
```

Expected: FAIL because `get_stock_lot_purchase_links` and `freight_required` do not exist.

- [ ] **Step 3: Add schemas**

Modify `backend/app/schemas/operations.py` after `PurchaseReceiptItemRead`:

```python
class StockLotPurchaseLinkRead(BaseModel):
    purchase_order_id: int
    po_no: str
    purchase_order_item_id: int
    material_item_id: int
    material_code: str
    material_name: str
    lot_id: int
    lot_no: str
    accepted_weight_kg: float
    returned_weight_kg: float
    returnable_weight_kg: float
    unit_cost: float


class RawMaterialReturnOutboundItemCreate(BaseModel):
    lot_id: int
    purchase_order_item_id: int
    return_weight_kg: float
    reason: str | None = None
    remark: str | None = None


class RawMaterialReturnOutboundCreate(BaseModel):
    warehouse_id: int
    return_date: datetime | None = None
    waybill_no: str | None = None
    order_photo_url: str | None = None
    freight_amount: float | None = None
    reason: str | None = None
    remark: str | None = None
    items: list[RawMaterialReturnOutboundItemCreate]


class PurchaseReturnItemRead(BaseModel):
    purchase_return_item_id: int
    purchase_return_id: int
    line_no: int
    purchase_order_item_id: int
    material_item_id: int
    material_code: str
    material_name: str
    lot_id: int
    lot_no: str
    return_weight_kg: float
    unit_cost: float
    amount: float
    reason: str | None = None
    status: str
    remark: str | None = None


class PurchaseReturnRead(BaseModel):
    purchase_return_id: int
    return_no: str
    purchase_order_id: int
    po_no: str
    warehouse_id: int
    warehouse_name: str
    return_date: datetime
    logistics_waybill_no: str | None = None
    logistics_freight_amount: float | None = None
    logistics_photo_url: str | None = None
    reason: str | None = None
    status: str
    remark: str | None = None
    total_return_weight_kg: float
    total_amount: float
    items: list[PurchaseReturnItemRead] = Field(default_factory=list)
```

Modify `PurchaseOrderRead`:

```python
    has_return: bool = False
    total_return_weight_kg: float = 0
```

Modify `PurchaseOrderItemRead`:

```python
    returned_weight_kg: float = 0
    net_received_weight_kg: float = 0
    has_return: bool = False
```

- [ ] **Step 4: Extend logistics normalization without breaking existing callers**

Modify `backend/app/services/logistics.py`:

```python
def normalize_logistics_fields(
    waybill_no: str | None,
    freight_amount: Any,
    *,
    required: bool,
    order_photo_url: str | None = None,
    freight_required: bool | None = None,
) -> tuple[str | None, Decimal | None, str | None]:
    clean_waybill = str(waybill_no or "").strip()
    clean_photo_url = str(order_photo_url or "").strip()
    freight_missing = freight_amount is None or str(freight_amount).strip() == ""
    must_have_freight = required if freight_required is None else freight_required

    if required and (not clean_waybill and not clean_photo_url):
        raise HTTPException(status_code=400, detail="该出入库业务必须填写运单号或上传订单照片")
    if must_have_freight and freight_missing:
        raise HTTPException(status_code=400, detail="该出入库业务必须填写运费")
    if freight_missing:
        return clean_waybill or None, None, clean_photo_url or None

    freight = to_decimal(freight_amount, "0.01")
    if freight < 0:
        raise HTTPException(status_code=400, detail="运费不能小于0")
    return clean_waybill or None, freight, clean_photo_url or None
```

- [ ] **Step 5: Add stock-lot purchase-link helper**

Modify imports in `backend/app/services/operations.py` to include the new return models:

```python
    PurchaseReturn,
    PurchaseReturnItem,
```

Add this helper near `get_stock_lot_options_query`:

```python
def get_stock_lot_purchase_links(db: Session, lot_id: int) -> list[dict[str, object]]:
    lot = db.get(StockLot, lot_id)
    if not lot:
        raise HTTPException(status_code=404, detail="库存批次不存在")

    returned_subquery = (
        select(
            PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"),
            PurchaseReturnItem.lot_id.label("lot_id"),
            func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"),
        )
        .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id)
        .where(PurchaseReturn.status != "CANCELED")
        .group_by(PurchaseReturnItem.purchase_order_item_id, PurchaseReturnItem.lot_id)
        .subquery()
    )

    accepted_weight = func.coalesce(func.sum(PurchaseReceiptItem.accepted_weight_kg), 0)
    returned_weight = func.coalesce(returned_subquery.c.returned_weight_kg, 0)
    stmt = (
        select(
            PurchaseOrder.id.label("purchase_order_id"),
            PurchaseOrder.po_no.label("po_no"),
            PurchaseOrderItem.id.label("purchase_order_item_id"),
            PurchaseOrderItem.material_item_id.label("material_item_id"),
            Item.item_code.label("material_code"),
            Item.item_name.label("material_name"),
            StockLot.id.label("lot_id"),
            StockLot.lot_no.label("lot_no"),
            accepted_weight.label("accepted_weight_kg"),
            returned_weight.label("returned_weight_kg"),
            case(
                (accepted_weight - returned_weight > StockLot.remaining_weight_kg, StockLot.remaining_weight_kg),
                else_=accepted_weight - returned_weight,
            ).label("returnable_weight_kg"),
            PurchaseReceiptItem.unit_cost.label("unit_cost"),
        )
        .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id)
        .join(PurchaseOrder, PurchaseOrder.id == PurchaseReceipt.purchase_order_id)
        .join(PurchaseOrderItem, PurchaseOrderItem.id == PurchaseReceiptItem.purchase_order_item_id)
        .join(Item, Item.id == PurchaseReceiptItem.material_item_id)
        .join(StockLot, StockLot.id == lot_id)
        .outerjoin(
            returned_subquery,
            (returned_subquery.c.purchase_order_item_id == PurchaseOrderItem.id)
            & (returned_subquery.c.lot_id == StockLot.id),
        )
        .where(
            PurchaseReceiptItem.material_item_id == lot.item_id,
            PurchaseReceiptItem.lot_no == lot.lot_no,
            PurchaseReceiptItem.status == "PASSED",
        )
        .group_by(
            PurchaseOrder.id,
            PurchaseOrder.po_no,
            PurchaseOrderItem.id,
            PurchaseOrderItem.material_item_id,
            Item.item_code,
            Item.item_name,
            StockLot.id,
            StockLot.lot_no,
            returned_subquery.c.returned_weight_kg,
            PurchaseReceiptItem.unit_cost,
            StockLot.remaining_weight_kg,
        )
        .order_by(PurchaseOrder.id.asc(), PurchaseOrderItem.line_no.asc())
    )
    return [dict(row) for row in db.execute(stmt).mappings().all()]
```

- [ ] **Step 6: Run tests and verify they pass**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_stock_lot_purchase_links_returns_receipt_purchase_order_item \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_logistics_can_require_waybill_or_photo_without_freight \
  -q
```

Expected: PASS.

- [ ] **Step 7: Commit this task if possible**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/schemas/operations.py backend/app/services/logistics.py backend/app/services/operations.py backend/tests/test_raw_material_return_outbound.py
git commit -m "feat: add raw material return link schemas"
```

Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing.

---

### Task 3: Implement Raw Material Return Outbound Backend Flow

**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/services/operations.py`
- Test: `backend/tests/test_raw_material_return_outbound.py`

- [ ] **Step 1: Add failing tests for return outbound**

Append these tests to `RawMaterialReturnOutboundTest`:

```python
    def test_raw_material_return_outbound_deducts_lot_balance_and_creates_records(self) -> None:
        from app.api.routes.inventory import create_raw_material_return_outbound
        from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate

        result = create_raw_material_return_outbound(
            RawMaterialReturnOutboundCreate(
                warehouse_id=self.raw_warehouse.id,
                waybill_no="YT123456",
                freight_amount=None,
                reason="性能不合格",
                remark="退回供应商",
                items=[
                    RawMaterialReturnOutboundItemCreate(
                        lot_id=self.lot.id,
                        purchase_order_item_id=self.purchase_order_item.id,
                        return_weight_kg=80,
                        reason="硬度不达标",
                    )
                ],
            ),
            context=Context(),
            db=self.db,
        )

        self.db.refresh(self.lot)
        self.db.refresh(self.balance)
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_RETURN_OUT"))
        saved_return = self.db.scalar(select(PurchaseReturn).where(PurchaseReturn.return_no == result.return_no))
        saved_item = self.db.scalar(select(PurchaseReturnItem).where(PurchaseReturnItem.purchase_return_id == saved_return.id))

        self.assertEqual(result.status, "CONFIRMED")
        self.assertEqual(result.logistics_waybill_no, "YT123456")
        self.assertEqual(float(result.total_return_weight_kg), 80)
        self.assertEqual(float(self.lot.remaining_weight_kg), 420)
        self.assertEqual(float(self.balance.weight_on_hand_kg), 420)
        self.assertEqual(float(self.balance.weight_available_kg), 420)
        self.assertIsNotNone(txn)
        self.assertEqual(txn.source_doc_type, "PURCHASE_RETURN")
        self.assertEqual(txn.source_doc_id, saved_return.id)
        self.assertEqual(txn.source_line_id, saved_item.id)
        self.assertEqual(float(txn.weight_change_kg), -80)
        self.assertEqual(txn.logistics_waybill_no, "YT123456")

    def test_raw_material_return_requires_waybill_or_photo(self) -> None:
        from fastapi import HTTPException
        from app.api.routes.inventory import create_raw_material_return_outbound
        from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate

        with self.assertRaises(HTTPException) as exc:
            create_raw_material_return_outbound(
                RawMaterialReturnOutboundCreate(
                    warehouse_id=self.raw_warehouse.id,
                    reason="性能不合格",
                    items=[
                        RawMaterialReturnOutboundItemCreate(
                            lot_id=self.lot.id,
                            purchase_order_item_id=self.purchase_order_item.id,
                            return_weight_kg=80,
                        )
                    ],
                ),
                context=Context(),
                db=self.db,
            )

        self.assertEqual(exc.exception.status_code, 400)
        self.assertIn("运单号或上传订单照片", exc.exception.detail)

    def test_raw_material_return_rejects_weight_above_remaining(self) -> None:
        from fastapi import HTTPException
        from app.api.routes.inventory import create_raw_material_return_outbound
        from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate

        with self.assertRaises(HTTPException) as exc:
            create_raw_material_return_outbound(
                RawMaterialReturnOutboundCreate(
                    warehouse_id=self.raw_warehouse.id,
                    order_photo_url="/inventory/logistics-photos/return.jpg",
                    reason="性能不合格",
                    items=[
                        RawMaterialReturnOutboundItemCreate(
                            lot_id=self.lot.id,
                            purchase_order_item_id=self.purchase_order_item.id,
                            return_weight_kg=501,
                        )
                    ],
                ),
                context=Context(),
                db=self.db,
            )

        self.assertEqual(exc.exception.status_code, 400)
        self.assertIn("退货重量不能超过批次可用重量", exc.exception.detail)
```

- [ ] **Step 2: Run tests and verify they fail**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_raw_material_return_outbound_deducts_lot_balance_and_creates_records \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_raw_material_return_requires_waybill_or_photo \
  tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_raw_material_return_rejects_weight_above_remaining \
  -q
```

Expected: FAIL because `create_raw_material_return_outbound` does not exist.

- [ ] **Step 3: Add return number builder and read helper**

Modify imports in `backend/app/api/routes/inventory.py`:

```python
from app.models.operations import (
    PurchaseOrder,
    PurchaseOrderItem,
    PurchaseReceipt,
    PurchaseReceiptItem,
    PurchaseReturn,
    PurchaseReturnItem,
    StockLot,
    Stocktake,
    StocktakeLine,
    StocktakeWarehouse,
    WarehouseLocation,
)
```

Add schema imports:

```python
    PurchaseReturnRead,
    RawMaterialReturnOutboundCreate,
    StockLotPurchaseLinkRead,
```

Add service import:

```python
    get_stock_lot_purchase_links,
```

Add helpers near `_build_customer_supplied_lot_no`:

```python
def _build_purchase_return_no(db: Session) -> str:
    year = datetime.now().strftime("%Y")
    prefix = f"退货{year}-"
    rows = db.scalars(select(PurchaseReturn.return_no).where(PurchaseReturn.return_no.like(f"{prefix}%"))).all()
    max_no = 0
    for value in rows:
        tail = str(value or "").replace(prefix, "", 1)
        if tail.isdigit():
            max_no = max(max_no, int(tail))
    return f"{prefix}{max_no + 1:05d}"


def _purchase_return_read(db: Session, purchase_return_id: int) -> PurchaseReturnRead:
    rows = db.execute(
        select(
            PurchaseReturn.id.label("purchase_return_id"),
            PurchaseReturn.return_no.label("return_no"),
            PurchaseReturn.purchase_order_id.label("purchase_order_id"),
            PurchaseOrder.po_no.label("po_no"),
            PurchaseReturn.warehouse_id.label("warehouse_id"),
            Warehouse.warehouse_name.label("warehouse_name"),
            PurchaseReturn.return_date.label("return_date"),
            PurchaseReturn.logistics_waybill_no.label("logistics_waybill_no"),
            PurchaseReturn.logistics_freight_amount.label("logistics_freight_amount"),
            PurchaseReturn.logistics_photo_url.label("logistics_photo_url"),
            PurchaseReturn.reason.label("reason"),
            PurchaseReturn.status.label("status"),
            PurchaseReturn.remark.label("remark"),
            PurchaseReturnItem.id.label("purchase_return_item_id"),
            PurchaseReturnItem.line_no.label("line_no"),
            PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"),
            PurchaseReturnItem.material_item_id.label("material_item_id"),
            Item.item_code.label("material_code"),
            Item.item_name.label("material_name"),
            PurchaseReturnItem.lot_id.label("lot_id"),
            StockLot.lot_no.label("lot_no"),
            PurchaseReturnItem.return_weight_kg.label("return_weight_kg"),
            PurchaseReturnItem.unit_cost.label("unit_cost"),
            PurchaseReturnItem.amount.label("amount"),
            PurchaseReturnItem.reason.label("item_reason"),
            PurchaseReturnItem.status.label("item_status"),
            PurchaseReturnItem.remark.label("item_remark"),
        )
        .join(PurchaseOrder, PurchaseOrder.id == PurchaseReturn.purchase_order_id)
        .join(Warehouse, Warehouse.id == PurchaseReturn.warehouse_id)
        .join(PurchaseReturnItem, PurchaseReturnItem.purchase_return_id == PurchaseReturn.id)
        .join(Item, Item.id == PurchaseReturnItem.material_item_id)
        .join(StockLot, StockLot.id == PurchaseReturnItem.lot_id)
        .where(PurchaseReturn.id == purchase_return_id)
        .order_by(PurchaseReturnItem.line_no.asc())
    ).mappings().all()
    if not rows:
        raise HTTPException(status_code=500, detail="采购退货出库单读取失败")
    first = rows[0]
    items = [
        {
            "purchase_return_item_id": row["purchase_return_item_id"],
            "purchase_return_id": row["purchase_return_id"],
            "line_no": row["line_no"],
            "purchase_order_item_id": row["purchase_order_item_id"],
            "material_item_id": row["material_item_id"],
            "material_code": row["material_code"],
            "material_name": row["material_name"],
            "lot_id": row["lot_id"],
            "lot_no": row["lot_no"],
            "return_weight_kg": row["return_weight_kg"],
            "unit_cost": row["unit_cost"],
            "amount": row["amount"],
            "reason": row["item_reason"],
            "status": row["item_status"],
            "remark": row["item_remark"],
        }
        for row in rows
    ]
    return PurchaseReturnRead.model_validate(
        {
            "purchase_return_id": first["purchase_return_id"],
            "return_no": first["return_no"],
            "purchase_order_id": first["purchase_order_id"],
            "po_no": first["po_no"],
            "warehouse_id": first["warehouse_id"],
            "warehouse_name": first["warehouse_name"],
            "return_date": first["return_date"],
            "logistics_waybill_no": first["logistics_waybill_no"],
            "logistics_freight_amount": first["logistics_freight_amount"],
            "logistics_photo_url": first["logistics_photo_url"],
            "reason": first["reason"],
            "status": first["status"],
            "remark": first["remark"],
            "total_return_weight_kg": sum(to_decimal(row["return_weight_kg"]) for row in rows),
            "total_amount": sum(to_decimal(row["amount"], "0.01") for row in rows),
            "items": items,
        }
    )
```

- [ ] **Step 4: Add endpoints**

Add to `backend/app/api/routes/inventory.py` near the stock-lot routes:

```python
@router.get("/stock-lots/{lot_id}/purchase-links", response_model=list[StockLotPurchaseLinkRead])
def list_stock_lot_purchase_links(lot_id: int, db: Session = Depends(get_db)) -> list[StockLotPurchaseLinkRead]:
    return [StockLotPurchaseLinkRead.model_validate(row) for row in get_stock_lot_purchase_links(db, lot_id)]
```

Add after `create_warehouse_outbound`:

```python
@router.post("/raw-material-return-outbound", response_model=PurchaseReturnRead)
def create_raw_material_return_outbound(
    payload: RawMaterialReturnOutboundCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> PurchaseReturnRead:
    if not payload.items:
        raise HTTPException(status_code=400, detail="退货出库至少需要一条明细")

    warehouse = db.get(Warehouse, payload.warehouse_id)
    if not warehouse:
        raise HTTPException(status_code=404, detail="仓库不存在")
    if str(warehouse.warehouse_type or "").upper() != "RAW":
        raise HTTPException(status_code=400, detail="退货出库只能在原材料库执行")
    ensure_warehouses_unlocked(db, [warehouse.id], "原材料退货出库")

    logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields(
        payload.waybill_no,
        payload.freight_amount,
        required=True,
        freight_required=False,
        order_photo_url=payload.order_photo_url,
    )

    purchase_order_ids: set[int] = set()
    now = datetime.now()
    return_rows: list[tuple[object, StockLot, PurchaseOrderItem, Decimal]] = []
    seen_lot_ids: set[int] = set()
    for item_payload in payload.items:
        if item_payload.lot_id in seen_lot_ids:
            raise HTTPException(status_code=400, detail="同一库存批次不能在一张退货单中重复选择")
        seen_lot_ids.add(item_payload.lot_id)

        lot = db.get(StockLot, item_payload.lot_id)
        if not lot:
            raise HTTPException(status_code=404, detail="库存批次不存在")
        if int(lot.warehouse_id) != int(warehouse.id):
            raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不属于当前原材料库")
        if lot.status != "AVAILABLE" or lot.quality_status != "PASS":
            raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 当前状态不能退货出库")

        po_item = db.get(PurchaseOrderItem, item_payload.purchase_order_item_id)
        if not po_item:
            raise HTTPException(status_code=404, detail="采购订单明细不存在")
        if int(po_item.material_item_id) != int(lot.item_id):
            raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 与采购订单明细物料不一致")

        links = get_stock_lot_purchase_links(db, lot.id)
        matched_link = next(
            (link for link in links if int(link["purchase_order_item_id"]) == int(po_item.id)),
            None,
        )
        if not matched_link:
            raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 未关联所选采购订单明细")

        return_weight = to_decimal(item_payload.return_weight_kg)
        if return_weight <= 0:
            raise HTTPException(status_code=400, detail="退货重量必须大于0")
        usable_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)
        if return_weight > usable_weight:
            raise HTTPException(status_code=400, detail=f"退货重量不能超过批次可用重量：{lot.lot_no}")
        if return_weight > to_decimal(matched_link["returnable_weight_kg"]):
            raise HTTPException(status_code=400, detail=f"退货重量不能超过该采购明细可退重量：{lot.lot_no}")

        purchase_order_ids.add(int(po_item.purchase_order_id))
        return_rows.append((item_payload, lot, po_item, return_weight))

    if len(purchase_order_ids) != 1:
        raise HTTPException(status_code=400, detail="一张退货出库单只能对应一个采购订单，请分开登记")
    purchase_order_id = next(iter(purchase_order_ids))

    purchase_return = PurchaseReturn(
        return_no=_build_purchase_return_no(db),
        purchase_order_id=purchase_order_id,
        warehouse_id=warehouse.id,
        return_date=payload.return_date or now,
        operator_user_id=context.user.id,
        logistics_waybill_no=logistics_waybill_no,
        logistics_freight_amount=logistics_freight_amount,
        logistics_photo_url=logistics_photo_url,
        reason=payload.reason,
        status="CONFIRMED",
        remark=payload.remark,
        created_at=now,
        updated_at=now,
    )
    db.add(purchase_return)
    db.flush()

    for line_no, (item_payload, lot, po_item, return_weight) in enumerate(return_rows, start=1):
        unit_cost = to_decimal(lot.unit_cost, "0.0001")
        amount = to_decimal(return_weight * unit_cost, "0.01")
        return_item = PurchaseReturnItem(
            purchase_return_id=purchase_return.id,
            line_no=line_no,
            purchase_order_item_id=po_item.id,
            material_item_id=lot.item_id,
            lot_id=lot.id,
            return_weight_kg=return_weight,
            unit_cost=unit_cost,
            amount=amount,
            reason=item_payload.reason or payload.reason,
            status="CONFIRMED",
            remark=item_payload.remark,
            created_at=now,
            updated_at=now,
        )
        db.add(return_item)
        db.flush()

        lot.remaining_qty = Decimal("0")
        lot.remaining_weight_kg = to_decimal(lot.remaining_weight_kg) - return_weight
        if to_decimal(lot.remaining_weight_kg) <= 0:
            lot.status = "DEPLETED"
        db.add(lot)

        upsert_stock_balance(
            db,
            item_id=lot.item_id,
            warehouse_id=lot.warehouse_id,
            location_id=lot.location_id,
            qty_delta=Decimal("0"),
            weight_delta=-return_weight,
            available_qty_delta=Decimal("0"),
            available_weight_delta=-return_weight,
            unit_cost=unit_cost,
        )
        create_inventory_txn(
            db,
            txn_type="PURCHASE_RETURN_OUT",
            item_id=lot.item_id,
            warehouse_id=lot.warehouse_id,
            location_id=lot.location_id,
            lot_id=lot.id,
            qty_change=Decimal("0"),
            weight_change=-return_weight,
            unit_cost=unit_cost,
            source_doc_type="PURCHASE_RETURN",
            source_doc_id=purchase_return.id,
            source_line_id=return_item.id,
            biz_time=purchase_return.return_date,
            operator_user_id=context.user.id,
            remark=item_payload.reason or payload.reason or "原材料退货出库",
            amount_basis="WEIGHT",
            logistics_waybill_no=logistics_waybill_no,
            logistics_freight_amount=logistics_freight_amount,
            logistics_photo_url=logistics_photo_url,
        )

    db.commit()
    return _purchase_return_read(db, purchase_return.id)
```

- [ ] **Step 5: Add labels to backend maps**

In `backend/app/api/routes/inventory.py`, add:

```python
_OUTBOUND_BIZ_MAP = {
    "PRODUCTION_OUT":     ("PRODUCTION_OUT",       "PRODUCTION_OUT"),
    "OUTSOURCING_OUT":    ("OUTSOURCING_OUT",      "OUTSOURCING_OUT"),
    "SCRAP_OUT":          ("SCRAP_OUT",            "SCRAP_OUT"),
    "PURCHASE_RETURN_OUT":("PURCHASE_RETURN_OUT",  "PURCHASE_RETURN"),
    "SCRAP_SALE_OUT":     ("SCRAP_SALE_OUT",       "SCRAP_SALE_OUT"),
    "WIP_OUT":            ("WIP_OUT",              "WIP_OUT"),
    "SALES_OUT":          ("SALES_OUT",            "SALES_OUT"),
}
```

Add the raw permission if the generic endpoint is ever called with this type:

```python
_ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE = {
    "RAW": {"OUTSOURCING_OUT", "SCRAP_OUT", "PURCHASE_RETURN_OUT"},
    "SEMI": {"WIP_OUT", "OUTSOURCING_OUT"},
    "FINISHED": {"SALES_OUT"},
    "AUX": {"PRODUCTION_OUT"},
    "SCRAP": {"SCRAP_SALE_OUT"},
}
```

Do not route the frontend through generic `/inventory/outbound`; use the dedicated endpoint so the purchase order link is enforced.

- [ ] **Step 6: Run backend return tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py -q
```

Expected: all tests in the file PASS.

- [ ] **Step 7: Commit this task if possible**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/api/routes/inventory.py backend/app/services/operations.py backend/tests/test_raw_material_return_outbound.py
git commit -m "feat: implement raw material return outbound"
```

Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing.

---

### Task 4: Add Purchase Order Return Warning Overlay

**Files:**
- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/schemas/operations.py`
- Modify: `frontend/src/utils/dictionaries.js`
- Modify: `frontend/src/utils/statusBadges.js`
- Modify: `frontend/src/views/PurchaseOrderView.vue`
- Test: `backend/tests/test_raw_material_return_outbound.py`

- [ ] **Step 1: Add failing backend test for purchase order overlay**

Append this test to `RawMaterialReturnOutboundTest`:

```python
    def test_purchase_order_query_preserves_status_and_marks_has_return(self) -> None:
        from app.api.routes.inventory import create_raw_material_return_outbound
        from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
        from app.services.operations import get_purchase_order_items_query, get_purchase_order_query

        create_raw_material_return_outbound(
            RawMaterialReturnOutboundCreate(
                warehouse_id=self.raw_warehouse.id,
                waybill_no="YT123456",
                reason="性能不合格",
                items=[
                    RawMaterialReturnOutboundItemCreate(
                        lot_id=self.lot.id,
                        purchase_order_item_id=self.purchase_order_item.id,
                        return_weight_kg=80,
                    )
                ],
            ),
            context=Context(),
            db=self.db,
        )

        order_row = self.db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == self.purchase_order.id)).mappings().first()
        item_row = self.db.execute(
            get_purchase_order_items_query(limit=1, purchase_order_id=self.purchase_order.id)
        ).mappings().first()

        self.assertEqual(order_row["display_status"], "RECEIVED")
        self.assertTrue(order_row["has_return"])
        self.assertEqual(float(order_row["total_return_weight_kg"]), 80)
        self.assertTrue(item_row["has_return"])
        self.assertEqual(float(item_row["returned_weight_kg"]), 80)
        self.assertEqual(float(item_row["net_received_weight_kg"]), 420)
```

- [ ] **Step 2: Run the overlay test and verify it fails**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_order_query_preserves_status_and_marks_has_return -q
```

Expected: FAIL because the query does not return `has_return`, `total_return_weight_kg`, `returned_weight_kg`, or `net_received_weight_kg`.

- [ ] **Step 3: Add return aggregation to purchase order query**

Modify `get_purchase_order_query` in `backend/app/services/operations.py`. Add this subquery before `line_subquery`:

```python
    return_item_subquery = (
        select(
            PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"),
            func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"),
            func.count(PurchaseReturnItem.id).label("return_count"),
        )
        .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id)
        .where(PurchaseReturn.status != "CANCELED")
        .group_by(PurchaseReturnItem.purchase_order_item_id)
        .subquery()
    )
```

Update `line_subquery` to outer join this subquery:

```python
        .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id)
```

Add these selected fields to `line_subquery`:

```python
            func.coalesce(func.sum(return_item_subquery.c.returned_weight_kg), 0).label("total_return_weight_kg"),
            func.coalesce(func.sum(return_item_subquery.c.return_count), 0).label("return_count"),
```

Add expressions after `rejected_count`:

```python
    total_return_weight = func.coalesce(line_subquery.c.total_return_weight_kg, 0)
    return_count = func.coalesce(line_subquery.c.return_count, 0)
```

Add selected fields in the final select:

```python
            (return_count > 0).label("has_return"),
            total_return_weight.label("total_return_weight_kg"),
```

- [ ] **Step 4: Add return aggregation to purchase order item query**

Modify `get_purchase_order_items_query` in `backend/app/services/operations.py`. Add this subquery near `receipt_item_subquery`:

```python
    return_item_subquery = (
        select(
            PurchaseReturnItem.purchase_order_item_id.label("purchase_order_item_id"),
            func.coalesce(func.sum(PurchaseReturnItem.return_weight_kg), 0).label("returned_weight_kg"),
            func.count(PurchaseReturnItem.id).label("return_count"),
        )
        .join(PurchaseReturn, PurchaseReturn.id == PurchaseReturnItem.purchase_return_id)
        .where(PurchaseReturn.status != "CANCELED")
        .group_by(PurchaseReturnItem.purchase_order_item_id)
        .subquery()
    )
```

Add expressions:

```python
    returned_weight = func.coalesce(return_item_subquery.c.returned_weight_kg, 0)
    net_received_weight = case((accepted_weight - returned_weight > 0, accepted_weight - returned_weight), else_=0)
```

Add selected fields:

```python
            returned_weight.label("returned_weight_kg"),
            net_received_weight.label("net_received_weight_kg"),
            (func.coalesce(return_item_subquery.c.return_count, 0) > 0).label("has_return"),
```

Add the outer join:

```python
        .outerjoin(return_item_subquery, return_item_subquery.c.purchase_order_item_id == PurchaseOrderItem.id)
```

- [ ] **Step 5: Run backend overlay tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py::RawMaterialReturnOutboundTest::test_purchase_order_query_preserves_status_and_marks_has_return -q
```

Expected: PASS.

- [ ] **Step 6: Add frontend labels and warning tone**

Modify `frontend/src/utils/dictionaries.js`:

```javascript
export const STATUS_LABELS = {
  ACTIVE: "启用",
  INACTIVE: "停用",
  OPEN: "未开始",
  PARTIAL: "部分完成",
  PARTIAL_RECEIVED: "部分收货",
  CLOSED: "已关闭",
  ORDERED: "已下单",
  RECEIVED: "已收货",
  RELEASED: "已下达",
  IN_PROGRESS: "进行中",
  READY_TO_RECEIPT: "待成品入库",
  COMPLETED: "已完工",
  DONE: "已完成",
  PENDING: "待处理",
  PENDING_QC: "待质检",
  PASSED: "已放行",
  REJECTED: "已拒收",
  POSTED: "已过账",
  AVAILABLE: "可用",
  LOCKED: "已锁单",
  DEPLETED: "已耗尽",
  CONFIRMED: "已确认",
  OVERRIDDEN: "已覆盖",
  CREATED: "已创建",
  IDLE: "空闲",
  SUPERSEDED: "已替代",
  HAS_RETURN: "存在退货"
};
```

Add inventory labels:

```javascript
export const INVENTORY_TXN_TYPE_LABELS = {
  PURCHASE_IN: "采购入库",
  PURCHASE_RETURN_OUT: "采购退货出库",
  CUSTOMER_SUPPLIED_IN: "客供料入库",
  MATERIAL_ISSUE: "生产领料",
  FG_IN: "成品入库",
  SALES_OUT: "发货出库",
  SCRAP_OUT: "报废出库",
  PRODUCTION_SCRAP_IN: "生产废料入库",
  OUTSOURCING_SCRAP_IN: "委外废料入库",
  SCRAP_SALE_OUT: "废料售卖出库"
};

export const SOURCE_DOC_TYPE_LABELS = {
  PURCHASE_RECEIPT: "采购到货",
  PURCHASE_RETURN: "采购退货",
  CUSTOMER_SUPPLIED_IN: "客供料入库",
  WORK_ORDER: "生产工单",
  FG_RECEIPT: "成品入库",
  DELIVERY: "发货单",
  SCRAP_OUT: "报废出库",
  PRODUCTION_SCRAP_IN: "生产废料入库",
  OUTSOURCING_SCRAP_IN: "委外废料入库",
  SCRAP_SALE_OUT: "废料售卖出库"
};
```

Modify `frontend/src/utils/statusBadges.js` warning values:

```javascript
      "存在退货",
      "HAS_RETURN",
```

- [ ] **Step 7: Update purchase order status cell UI**

In `frontend/src/views/PurchaseOrderView.vue`, replace the status cell:

```vue
<td>
  <span class="table-chip">{{ formatStatusLabel(order.display_status || order.status) }}</span>
  <span v-if="order.has_return" class="table-chip status-tone-warning">存在退货</span>
</td>
```

In the purchase order item table, add return fields near received weight:

```vue
<th>退货重量(kg)</th>
<th>净入库重量(kg)</th>
```

Add corresponding cells:

```vue
<td>{{ formatWeight(item.returned_weight_kg) }}</td>
<td>
  {{ formatWeight(item.net_received_weight_kg) }}
  <span v-if="item.has_return" class="table-chip status-tone-warning">存在退货</span>
</td>
```

- [ ] **Step 8: Run backend tests and frontend build**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_raw_material_return_outbound.py -q
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```

Expected: backend tests PASS. Frontend build succeeds; existing Vite chunk-size warning is acceptable if no new build error appears.

- [ ] **Step 9: Commit this task if possible**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend/app/services/operations.py backend/app/schemas/operations.py frontend/src/utils/dictionaries.js frontend/src/utils/statusBadges.js frontend/src/views/PurchaseOrderView.vue backend/tests/test_raw_material_return_outbound.py
git commit -m "feat: show purchase return warning status"
```

Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing.

---

### Task 5: Add Raw Material Return Outbound UI

**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Create: `frontend/scripts/test-inventory-ledger-raw-return.mjs`

- [ ] **Step 1: Add static frontend test**

Create `frontend/scripts/test-inventory-ledger-raw-return.mjs`:

```javascript
import fs from "node:fs";
import path from "node:path";

const root = process.cwd();
const viewPath = path.join(root, "src/views/InventoryLedgerView.vue");
const dictionariesPath = path.join(root, "src/utils/dictionaries.js");

const view = fs.readFileSync(viewPath, "utf8");
const dictionaries = fs.readFileSync(dictionariesPath, "utf8");

const checks = [
  ["raw return operation button", view.includes("raw-return-out") && view.includes("退货出库")],
  ["dedicated raw return drawer", view.includes("rawReturnDrawerOpen") && view.includes("原材料退货出库")],
  ["purchase links endpoint", view.includes("/inventory/stock-lots/") && view.includes("/purchase-links")],
  ["return outbound endpoint", view.includes("/inventory/raw-material-return-outbound")],
  ["purchase return label", dictionaries.includes("PURCHASE_RETURN_OUT") && dictionaries.includes("采购退货出库")],
];

const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
  console.error("Raw return UI checks failed:");
  failed.forEach(([name]) => console.error(`- ${name}`));
  process.exit(1);
}

console.log("Raw return UI checks passed");
```

- [ ] **Step 2: Run the static test and verify it fails**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-raw-return.mjs
```

Expected: FAIL because the UI has no return operation yet.

- [ ] **Step 3: Add state and form model**

In `frontend/src/views/InventoryLedgerView.vue`, add refs near other drawer refs:

```javascript
const rawReturnDrawerOpen = ref(false);
const rawReturnPurchaseLinks = ref([]);
const rawReturnLinkLoading = ref(false);
```

Add form after `const deliveryForm = reactive(buildEmptyDeliveryForm());`:

```javascript
const rawReturnForm = reactive(buildEmptyRawReturnForm());
```

Add builder near other builders:

```javascript
function buildEmptyRawReturnForm() {
  return {
    warehouse_id: "",
    lot_id: "",
    purchase_order_item_id: "",
    return_weight_kg: 0,
    waybill_no: "",
    order_photo_url: "",
    freight_amount: "",
    reason: "",
    remark: ""
  };
}
```

Add reset function:

```javascript
function resetRawReturnForm() {
  Object.assign(rawReturnForm, buildEmptyRawReturnForm());
  rawReturnForm.warehouse_id = activeWarehouses.value[0]?.id || "";
  rawReturnPurchaseLinks.value = [];
}
```

- [ ] **Step 4: Add raw return operation**

In `operationMatrix.raw.outbound`, add:

```javascript
makeOperation("raw-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn")
```

Keep the raw outbound order:

```javascript
outbound: [
  makeOperation("raw-production-out", "out", "PRODUCTION_OUT", "生产出库", "选择原材料库存批次出库，后续小程序报工按库存批次号推进。", "production"),
  makeOperation("raw-outsourcing-out", "out", "OUTSOURCING_OUT", "委外出库", "原材料出库给第三方加工。"),
  makeOperation("raw-scrap-out", "out", "SCRAP_OUT", "报废出库", "原材料报废出库。"),
  makeOperation("raw-return-out", "out", "PURCHASE_RETURN_OUT", "退货出库", "入仓后发现质量或性能不合格的原材料退回供应商。", "rawReturn")
]
```

Update `openOperationDrawer`:

```javascript
  if (operation.drawerType === "rawReturn") {
    resetRawReturnForm();
    rawReturnDrawerOpen.value = true;
    return;
  }
```

Add close function:

```javascript
function closeRawReturnDrawer() {
  rawReturnDrawerOpen.value = false;
}
```

- [ ] **Step 5: Add computed lot options**

Add:

```javascript
const rawReturnLotOptions = computed(() =>
  lots.value
    .filter(
      (lot) =>
        normalizeWarehouseType(lot) === "RAW" &&
        lot.status === "AVAILABLE" &&
        lot.quality_status === "PASS" &&
        Number(lot.remaining_weight_kg || 0) > 0
    )
    .sort((left, right) => String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true }))
);

const selectedRawReturnLot = computed(() =>
  rawReturnLotOptions.value.find((lot) => Number(lot.lot_id) === Number(rawReturnForm.lot_id)) || null
);

const selectedRawReturnLink = computed(() =>
  rawReturnPurchaseLinks.value.find((link) => Number(link.purchase_order_item_id) === Number(rawReturnForm.purchase_order_item_id)) || null
);
```

Add lot change loader:

```javascript
async function handleRawReturnLotChange() {
  rawReturnForm.purchase_order_item_id = "";
  rawReturnForm.return_weight_kg = 0;
  rawReturnPurchaseLinks.value = [];
  if (!rawReturnForm.lot_id) {
    return;
  }
  rawReturnLinkLoading.value = true;
  try {
    const links = await fetchResource(`/inventory/stock-lots/${rawReturnForm.lot_id}/purchase-links`, []);
    rawReturnPurchaseLinks.value = links;
    if (links.length === 1) {
      rawReturnForm.purchase_order_item_id = links[0].purchase_order_item_id;
      rawReturnForm.return_weight_kg = Number(links[0].returnable_weight_kg || selectedRawReturnLot.value?.remaining_weight_kg || 0);
    }
  } catch (error) {
    errorMessage.value = error.message || "采购来源读取失败";
  } finally {
    rawReturnLinkLoading.value = false;
  }
}
```

- [ ] **Step 6: Add drawer template**

In `frontend/src/views/InventoryLedgerView.vue`, add this `FormDrawer` near the generic drawer:

```vue
<FormDrawer
  :open="rawReturnDrawerOpen"
  eyebrow="原材料库 · 退货出库"
  title="原材料退货出库"
  tag="采购退货"
  wide
  @close="closeRawReturnDrawer"
>
  <form class="stack-form" @submit.prevent="submitRawReturnOutbound">
    <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
    <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>

    <div class="double-field">
      <label class="form-field">
        <span>仓库</span>
        <select v-model.number="rawReturnForm.warehouse_id" required>
          <option disabled value="">请选择仓库</option>
          <option v-for="warehouse in activeWarehouses" :key="warehouse.id" :value="warehouse.id">
            {{ warehouse.warehouse_name }}
          </option>
        </select>
      </label>
      <label class="form-field">
        <span>库存批次号</span>
        <select v-model.number="rawReturnForm.lot_id" required @change="handleRawReturnLotChange">
          <option disabled value="">请选择库存批次</option>
          <option v-for="lot in rawReturnLotOptions" :key="lot.lot_id" :value="lot.lot_id">
            {{ lot.lot_no }} · {{ lot.item_code }} · {{ lot.item_name }} · 可退 {{ formatWeight(lot.remaining_weight_kg) }}
          </option>
        </select>
      </label>
    </div>

    <div class="double-field">
      <label class="form-field">
        <span>采购来源</span>
        <select v-model.number="rawReturnForm.purchase_order_item_id" required :disabled="rawReturnLinkLoading || !rawReturnPurchaseLinks.length">
          <option disabled value="">{{ rawReturnLinkLoading ? "读取中..." : "请选择采购订单来源" }}</option>
          <option v-for="link in rawReturnPurchaseLinks" :key="link.purchase_order_item_id" :value="link.purchase_order_item_id">
            {{ link.po_no }} · {{ link.material_code }} · {{ link.material_name }} · 可退 {{ formatWeight(link.returnable_weight_kg) }}
          </option>
        </select>
      </label>
      <label class="form-field">
        <span>退货重量(kg)</span>
        <input v-model.number="rawReturnForm.return_weight_kg" type="number" min="0.001" step="0.001" required />
      </label>
    </div>

    <div class="triple-field">
      <label class="form-field">
        <span>运单号</span>
        <input v-model.trim="rawReturnForm.waybill_no" type="text" placeholder="可填运单号，或上传订单照片" />
      </label>
      <label class="form-field">
        <span>运费</span>
        <input v-model.number="rawReturnForm.freight_amount" type="number" min="0" step="0.01" placeholder="可不填" />
      </label>
      <label class="form-field">
        <span>订单照片</span>
        <input type="file" accept="image/*" @change="handleLogisticsPhotoUpload(rawReturnForm, $event)" />
        <small v-if="rawReturnForm.order_photo_url">已上传，可代替运单号</small>
      </label>
    </div>

    <label class="form-field">
      <span>退货原因</span>
      <input v-model.trim="rawReturnForm.reason" type="text" placeholder="例如质量不合格、性能不达标、规格不符" />
    </label>

    <label class="form-field">
      <span>备注</span>
      <textarea v-model.trim="rawReturnForm.remark" rows="3"></textarea>
    </label>

    <div class="drawer-actions">
      <button class="primary-button" type="submit" :disabled="submittingOperation || logisticsPhotoUploading">
        {{ submittingOperation ? "出库中..." : "确认退货出库" }}
      </button>
    </div>
  </form>
</FormDrawer>
```

- [ ] **Step 7: Add submit function**

Add:

```javascript
function validateRawReturnForm() {
  if (!rawReturnForm.lot_id) {
    return "请选择库存批次号";
  }
  if (!rawReturnForm.purchase_order_item_id) {
    return "请选择采购来源";
  }
  if (Number(rawReturnForm.return_weight_kg || 0) <= 0) {
    return "退货重量必须大于0";
  }
  const lotWeight = Number(selectedRawReturnLot.value?.remaining_weight_kg || 0);
  if (Number(rawReturnForm.return_weight_kg || 0) > lotWeight) {
    return `退货重量不能超过批次剩余重量 ${formatWeight(lotWeight)}`;
  }
  const linkWeight = Number(selectedRawReturnLink.value?.returnable_weight_kg || 0);
  if (selectedRawReturnLink.value && Number(rawReturnForm.return_weight_kg || 0) > linkWeight) {
    return `退货重量不能超过采购来源可退重量 ${formatWeight(linkWeight)}`;
  }
  if (!String(rawReturnForm.waybill_no || "").trim() && !String(rawReturnForm.order_photo_url || "").trim()) {
    return "请填写运单号或上传订单照片";
  }
  if (!isFreightBlank(rawReturnForm.freight_amount) && Number(rawReturnForm.freight_amount) < 0) {
    return "运费不能小于0";
  }
  return "";
}

async function submitRawReturnOutbound() {
  resetFeedback();
  const validationError = validateRawReturnForm();
  if (validationError) {
    errorMessage.value = validationError;
    return;
  }
  submittingOperation.value = true;
  try {
    const result = await postResource("/inventory/raw-material-return-outbound", {
      warehouse_id: Number(rawReturnForm.warehouse_id),
      waybill_no: rawReturnForm.waybill_no || null,
      order_photo_url: rawReturnForm.order_photo_url || null,
      freight_amount: isFreightBlank(rawReturnForm.freight_amount) ? null : Number(rawReturnForm.freight_amount),
      reason: rawReturnForm.reason || null,
      remark: rawReturnForm.remark || null,
      items: [
        {
          lot_id: Number(rawReturnForm.lot_id),
          purchase_order_item_id: Number(rawReturnForm.purchase_order_item_id),
          return_weight_kg: Number(rawReturnForm.return_weight_kg || 0),
          reason: rawReturnForm.reason || null,
          remark: rawReturnForm.remark || null
        }
      ]
    });
    feedbackMessage.value = `退货出库 ${result.return_no} 已登记`;
    rawReturnDrawerOpen.value = false;
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || "退货出库失败";
  } finally {
    submittingOperation.value = false;
  }
}
```

- [ ] **Step 8: Run static UI test and build**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-raw-return.mjs
npm run build
```

Expected: static test prints `Raw return UI checks passed`; build succeeds.

- [ ] **Step 9: Commit this task if possible**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add frontend/src/views/InventoryLedgerView.vue frontend/scripts/test-inventory-ledger-raw-return.mjs frontend/src/utils/dictionaries.js frontend/src/utils/statusBadges.js
git commit -m "feat: add raw material return outbound UI"
```

Expected: commit succeeds when the workspace has `.git`; otherwise continue without committing.

---

### Task 6: Final Integration Verification

**Files:**
- Verify: `backend/tests/test_raw_material_return_outbound.py`
- Verify: `backend/tests/test_raw_material_weight_only_flow.py`
- Verify: `backend/tests/test_inventory_lot_numbering.py`
- Verify: `backend/tests/test_sales_order_purchase_lock_flow.py`
- Verify: `frontend/scripts/test-inventory-ledger-raw-return.mjs`
- Verify: `frontend/src/views/InventoryLedgerView.vue`
- Verify: `frontend/src/views/PurchaseOrderView.vue`

- [ ] **Step 1: Run targeted backend tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_raw_material_return_outbound.py \
  tests/test_raw_material_weight_only_flow.py \
  tests/test_inventory_lot_numbering.py \
  tests/test_sales_order_purchase_lock_flow.py \
  -q
```

Expected: PASS.

- [ ] **Step 2: Run all backend tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest -q
```

Expected: PASS.

- [ ] **Step 3: Run frontend static test and build**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-raw-return.mjs
npm run build
```

Expected: static test PASS; build succeeds. Existing Vite chunk-size warnings are acceptable when there are no build errors.

- [ ] **Step 4: Run terminology and weight-only scans**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "采购退货出库|PURCHASE_RETURN_OUT|存在退货" backend frontend
rg -n "原材料.*数量|数量.*原材料|return_qty|returned_qty" backend/app frontend/src
```

Expected: first command finds the new feature in planned files. Second command must not reveal new raw-material quantity fields introduced by this work; existing legacy model fields unrelated to the new UI are acceptable only if no new usage was added for raw return.

- [ ] **Step 5: Manual browser verification**

Run the backend and frontend using the project’s existing local start commands. If the commands are already running, reuse them. Then verify:

```text
1. Open 嘉恒仓库.
2. Select 原材料库.
3. Confirm 出库 row shows 生产出库、委外出库、报废出库、退货出库.
4. Click 退货出库.
5. Select a stock lot created from purchase receipt.
6. Confirm purchase source auto-selects when only one purchase source exists.
7. Enter return weight lower than remaining weight.
8. Upload an order photo without freight.
9. Submit and confirm success toast.
10. Reopen stock lot detail and confirm remaining weight decreased.
11. Open inventory transaction drawer and confirm 采购退货出库 appears with the same lot.
12. Open 采购订单 list and confirm original lifecycle status remains visible and the warning chip 存在退货 appears.
```

Expected: all steps behave as written.

- [ ] **Step 6: Final commit if possible**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short
git add backend frontend docs/superpowers/plans/2026-05-31-raw-material-return-out.md
git commit -m "feat: complete raw material return outbound"
```

Expected: commit succeeds when the workspace has `.git`; otherwise final notes state that commits were skipped because the workspace is not a git repository.

---

## Self-Review

- Spec coverage: The plan covers adding the raw warehouse operation, logistics proof requirement, lot-level inventory deduction, purchase-order warning overlay, status color labels, and backend/frontend tests.
- Placeholder scan: The plan contains concrete file paths, test commands, code snippets, and expected outcomes. It does not rely on vague implementation instructions.
- Type consistency: The plan consistently uses `PurchaseReturn`, `PurchaseReturnItem`, `RawMaterialReturnOutboundCreate`, `StockLotPurchaseLinkRead`, `PURCHASE_RETURN_OUT`, `PURCHASE_RETURN`, `has_return`, and `total_return_weight_kg`.
