# SwapShiftPlacement

## Permission Scope

shiftSchedule

## Overview

swapShiftPlacement replaces who staffs a slot in one atomic step: the outgoing placement becomes `SUPERSEDED` and points at the incoming one, which is created `ACTIVE` on the same Shift. A substitution ("Y, cover for X tonight") is a change of plan, not an actuals event — nobody has worked yet, so the shift schedule is the only place that can hold it.

## Business Rules

- The outgoing placement must exist and be `ACTIVE`; a `SUPERSEDED` or `CANCELLED` placement rejects this command
- The parent Shift must not be cancelled (`cancelledAt` null)
- The outgoing row is never deleted: it becomes `SUPERSEDED`, gets `releasedAt` stamped, and `supersededById` points at the replacement — this is the audit trail that X was originally planned on this slot
- The incoming placement is created with `provenance = MANUAL` even when it replaces a `GENERATED` one, so a regeneration pass does not overwrite a human decision
- Both halves happen in one transaction: a release without a replacement would silently reopen the slot
- The incoming Assignment must be effective on the Shift's date — a hurried substitution is exactly where an ineligible person would otherwise be placed
- The incoming Assignment must not already hold an ACTIVE placement on the same Shift; swapping onto a slot someone already staffs would leave them counted twice
- Doing this is what keeps predicted-vs-actual correct — variance reads `ACTIVE` placements only, so after a swap X is no longer reported as a no-show and Y's actuals appear against the slot
- Scope line: X's reason for dropping out (病欠 etc.) is **not** recorded here. "Unfilled" is a derived scheduling signal; 欠勤 / 有給 is a leave-management fact with payroll consequences, and the two must not be conflated

## Process Flow

```mermaid
flowchart TD
    A[Receive placement id and incoming assignmentId] --> B{Both supplied?}
    B -->|No| BX[Return error: MISSING_REQUIRED_FIELD]
    B -->|Yes| C{Placement exists?}
    C -->|No| CX[Return error: SHIFT_PLACEMENT_NOT_FOUND]
    C -->|Yes| D{status = ACTIVE?}
    D -->|No| DX[Return error: SHIFT_PLACEMENT_NOT_ACTIVE]
    D -->|Yes| E{Parent shift cancelled?}
    E -->|Yes| EX[Return error: SHIFT_CANCELLED]
    E -->|No| F[Insert incoming placement ACTIVE with provenance MANUAL]
    F --> G[Set outgoing to SUPERSEDED with supersededById and releasedAt]
    G --> H[Return both placements]
```

## External Dependencies

- [shiftSchedule::ShiftPlacement](../model/ShiftPlacement.md) model - the stateful placement this command supersedes and replaces
- [shiftSchedule::Shift](../model/Shift.md) model - the slot both placements staff

## Error Scenarios

- **MISSING_REQUIRED_FIELD**: a required field is missing
- **SHIFT_PLACEMENT_NOT_FOUND**: no ShiftPlacement exists for the given id
- **SHIFT_PLACEMENT_NOT_ACTIVE**: the placement is not ACTIVE
- **SHIFT_NOT_FOUND**: no Shift exists for the given id
- **SHIFT_CANCELLED**: the target Shift is CANCELLED
- **ASSIGNMENT_NOT_EFFECTIVE**: the referenced Assignment does not exist or is not effective on the relevant date
- **SHIFT_PLACEMENT_ALREADY_EXISTS**: an ACTIVE ShiftPlacement already places this Assignment on this Shift

## Test Cases

- throws SHIFT_PLACEMENT_ALREADY_EXISTS when the incoming Assignment already has an ACTIVE placement on the Shift

- creates the incoming placement ACTIVE on the same shift
- supersedes the outgoing placement and links it to the replacement
- marks the replacement MANUAL even when it supersedes a GENERATED placement
- leaves the slot staffed: one ACTIVE placement in, one SUPERSEDED out
- throws SHIFT_PLACEMENT_NOT_ACTIVE when the placement was already superseded
- throws SHIFT_CANCELLED when the parent shift has been withdrawn
- throws SHIFT_PLACEMENT_NOT_FOUND when the placement does not exist
- throws MISSING_REQUIRED_FIELD when the incoming assignmentId is absent
