diff --git a/docs/superpowers/plans/2026-07-25-report-time-allocation.md b/docs/superpowers/plans/2026-07-25-report-time-allocation.md
new file mode 100644
index 0000000..3550f1d
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-25-report-time-allocation.md
@@ -0,0 +1,1836 @@
+# 报工跨日期工时归属拆分 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:** 将跨夜班、跨自然日的报工从“整条记录只有一个报工日期”改为“原始报工一条,统计归属按有效工时拆到多个日期”。
+
+**Architecture:** 后端新增 `ProductionReportAllocation` 归属明细表和 `report_allocations` 服务,报工提交、自动提交、管理员审核更改后统一刷新归属明细。统计类接口按 `allocation_date` 查询和聚合;原始记录、审核、作废、更改水印继续基于 `ProductionReport`。
+
+**Tech Stack:** FastAPI, SQLAlchemy 2, MySQL, Pytest, openpyxl, 微信小程序原生 WXML/WXSS/JS。
+
+---
+
+## Scope
+
+本计划实现以下内容:
+
+- 空白作息区间算加班。
+- 普通报工按工序明细有效工时拆分归属日期。
+- 数量、换料次数、参考工资按有效工时比例拆分。
+- 清洗等零工时报工使用原始 `report_date` 兜底,不做工时比例拆分。
+- 经理看板、导出、对账小账本、设备/模具使用统计切换到归属明细口径。
+- 管理员审核页和经理侧展示归属摘要。
+- 提供 MySQL 表结构迁移和历史归属明细回填脚本。
+
+本计划不做:
+
+- 不拆分原始 `ProductionReport`。
+- 不让管理员手工编辑 `report_date`。
+- 不批量修改历史 `ProductionReport.report_date`。
+
+## File Map
+
+Backend repo root: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint`
+
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py`
+ - 新增 `ProductionReportAllocation` 模型和关系。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`
+ - 新增归属输出 schema,并在 `ReportItemOut`、`ReportOut`、`DashboardRow` 中追加兼容字段。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/metrics.py`
+ - 空白作息区间计入加班。
+ - 暴露可复用的 item 时间片/班种拆分辅助函数。
+- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_allocations.py`
+ - 归属明细计算、摘要格式化、数据库刷新。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py`
+ - 输出归属摘要和 item 级归属明细。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
+ - 员工提交和清洗提交后刷新归属明细。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/auto_submit.py`
+ - 系统自动提交后刷新归属明细。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`
+ - 管理员审核/更改保存后刷新归属明细。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_lifecycle.py`
+ - 彻底删除作废记录时同步删除归属明细。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py`
+ - 经理看板按归属明细聚合。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py`
+ - 经理导出增加“原始报工日期”和“统计归属日期”口径。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reconciliation.py`
+ - 对账小账本按归属明细月份汇总最后工序成品数量。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`
+ - 设备/模具统计按归属明细日期过滤。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`
+ - 支持从归属明细构造使用统计行,报工次数按原始报工 ID 去重。
+- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py`
+ - 创建表并回填历史归属明细。
+- Test `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_metrics.py`
+ - 增加空白区间算加班测试。
+- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_allocations.py`
+ - 覆盖跨日期拆分、比例拆分、零工时兜底。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`
+ - 覆盖归属明细使用统计和报工次数去重。
+- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_dashboard_allocations.py`
+ - 覆盖经理看板按 `allocation_date` 聚合。
+- Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_reconciliation_allocations.py`
+ - 覆盖对账小账本按归属月份取最后工序成品。
+
+Frontend repo root: `/Users/souplearn/Gitlab/app/JhHardwareWRS`
+
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`
+ - 映射 `allocation_summary_text` 和 `allocations`。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`
+ - 管理员审核页展示归属摘要。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss`
+ - 增加归属摘要样式。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`
+ - 经理看板展示统计归属日期说明。
+- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxss`
+ - 增加归属说明样式。
+
+---
+
+### Task 1: Lock Current Metrics Behavior And Add Blank-Interval Overtime Tests
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_metrics.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/metrics.py`
+
+- [ ] **Step 1: Add failing tests for blank intervals**
+
+Append these tests to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_metrics.py`:
+
+```python
+def test_morning_blank_interval_counts_as_overtime():
+ start_at = datetime(2026, 7, 25, 6, 0, 0)
+ end_at = datetime(2026, 7, 25, 8, 0, 0)
+ item = Item(good_qty=10)
+
+ metrics = calculate_report_metrics(start_at, end_at, [item])
+
+ assert metrics["duration_minutes"] == 120
+ assert metrics["effective_minutes"] == 120
+ assert metrics["shift_day_minutes"] == 0
+ assert metrics["shift_overtime_minutes"] == 120
+ assert metrics["shift_night_minutes"] == 0
+ assert item.allocated_minutes == 120
+
+
+def test_dinner_blank_interval_counts_as_overtime():
+ start_at = datetime(2026, 7, 25, 17, 20, 0)
+ end_at = datetime(2026, 7, 25, 18, 0, 0)
+ item = Item(good_qty=10)
+
+ metrics = calculate_report_metrics(start_at, end_at, [item])
+
+ assert metrics["duration_minutes"] == 40
+ assert metrics["effective_minutes"] == 40
+ assert metrics["shift_day_minutes"] == 0
+ assert metrics["shift_overtime_minutes"] == 40
+ assert metrics["shift_night_minutes"] == 0
+ assert item.allocated_minutes == 40
+```
+
+- [ ] **Step 2: Run the failing tests**
+
+Run:
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_metrics.py::test_morning_blank_interval_counts_as_overtime tests/test_metrics.py::test_dinner_blank_interval_counts_as_overtime -v
+```
+
+Expected before implementation: both tests fail because blank intervals currently do not contribute to `shift_overtime_minutes`.
+
+- [ ] **Step 3: Implement blank interval overtime in metrics**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/metrics.py`, add helpers near `_overlap_minutes`:
+
+```python
+def _clip_interval(
+ interval_start: datetime,
+ interval_end: datetime,
+ boundary_start: datetime,
+ boundary_end: datetime,
+) -> tuple[datetime, datetime] | None:
+ start = max(interval_start, boundary_start)
+ end = min(interval_end, boundary_end)
+ if end <= start:
+ return None
+ return start, end
+
+
+def _merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]:
+ ordered = sorted(intervals, key=lambda item: item[0])
+ merged: list[tuple[datetime, datetime]] = []
+ for start, end in ordered:
+ if not merged or start > merged[-1][1]:
+ merged.append((start, end))
+ elif end > merged[-1][1]:
+ merged[-1] = (merged[-1][0], end)
+ return merged
+
+
+def _subtract_intervals(
+ base_start: datetime,
+ base_end: datetime,
+ occupied: list[tuple[datetime, datetime]],
+) -> list[tuple[datetime, datetime]]:
+ gaps: list[tuple[datetime, datetime]] = []
+ cursor = base_start
+ for start, end in _merge_intervals(occupied):
+ if start > cursor:
+ gaps.append((cursor, start))
+ if end > cursor:
+ cursor = end
+ if cursor < base_end:
+ gaps.append((cursor, base_end))
+ return gaps
+
+
+def _calendar_dates_between(start_at: datetime, end_at: datetime):
+ current = start_at.date()
+ last = end_at.date()
+ while current <= last:
+ yield current
+ current += timedelta(days=1)
+
+
+def _blank_overtime_intervals(
+ current_date: date,
+ schedule: WorkScheduleConfig,
+) -> list[tuple[datetime, datetime]]:
+ day_start = datetime.combine(current_date, time.min)
+ day_end = day_start + timedelta(days=1)
+ occupied: list[tuple[datetime, datetime]] = []
+ raw_intervals = [
+ _schedule_interval(current_date, schedule.day_start, schedule.day_end),
+ _schedule_interval(current_date, schedule.overtime_start, schedule.overtime_end),
+ _schedule_interval(current_date, schedule.night_start, schedule.night_end),
+ _schedule_interval(current_date - timedelta(days=1), schedule.night_start, schedule.night_end),
+ ]
+ for interval_start, interval_end in raw_intervals:
+ clipped = _clip_interval(interval_start, interval_end, day_start, day_end)
+ if clipped is not None:
+ occupied.append(clipped)
+ return _subtract_intervals(day_start, day_end, occupied)
+```
+
+Then inside `_raw_shift_minutes`, after the existing `for bucket, intervals in shift_intervals.items()` block, add:
+
+```python
+ for blank_start, blank_end in _blank_overtime_intervals(current_date, active_schedule):
+ minutes["overtime"] += _overlap_minutes(start_at, end_at, blank_start, blank_end)
+```
+
+- [ ] **Step 4: Run targeted metrics tests**
+
+Run:
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_metrics.py -v
+```
+
+Expected: all `test_metrics.py` tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/services/metrics.py tests/test_metrics.py
+git commit -m "feat: 空白作息区间计入加班工时"
+```
+
+---
+
+### Task 2: Add Allocation Model And MySQL Migration
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py`
+- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py`
+
+- [ ] **Step 1: Add SQLAlchemy model**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/models.py`, add this model after `ProductionReportItem`:
+
+```python
+class ProductionReportAllocation(Base):
+ __tablename__ = "production_report_allocations"
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
+ report_id: Mapped[int] = mapped_column(ForeignKey("production_reports.id"), nullable=False)
+ report_item_id: Mapped[int | None] = mapped_column(ForeignKey("production_report_items.id", ondelete="CASCADE"))
+ attendance_point_name: Mapped[str] = mapped_column(String(128), nullable=False, default="")
+ employee_phone: Mapped[str] = mapped_column(String(20), nullable=False, default="")
+ allocation_date: Mapped[date] = mapped_column(Date, nullable=False)
+ day_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
+ overtime_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
+ night_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
+ effective_minutes: Mapped[float] = mapped_column(Numeric(10, 2), nullable=False, default=0)
+ good_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
+ defect_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
+ scrap_qty: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
+ changeover_count: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
+ reference_wage: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, default=0)
+ created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
+ updated_at: Mapped[datetime] = mapped_column(DateTime, default=now, onupdate=now, nullable=False)
+
+ report: Mapped[ProductionReport] = relationship(back_populates="allocations")
+ item: Mapped[ProductionReportItem | None] = relationship(back_populates="allocations")
+
+ __table_args__ = (
+ Index("idx_report_allocations_report", "report_id"),
+ Index("idx_report_allocations_item", "report_item_id"),
+ Index("idx_report_allocations_date", "allocation_date"),
+ Index("idx_report_allocations_point_date", "attendance_point_name", "allocation_date"),
+ Index("idx_report_allocations_employee_date", "employee_phone", "allocation_date"),
+ )
+```
+
+Add relationships:
+
+```python
+# inside ProductionReport
+ allocations: Mapped[list["ProductionReportAllocation"]] = relationship(
+ back_populates="report",
+ cascade="all, delete-orphan",
+ )
+
+# inside ProductionReportItem
+ allocations: Mapped[list["ProductionReportAllocation"]] = relationship(
+ back_populates="item",
+ cascade="all, delete-orphan",
+ )
+```
+
+- [ ] **Step 2: Add migration script**
+
+Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py`:
+
+```python
+from pathlib import Path
+import sys
+
+from sqlalchemy import text
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+from app.database import engine # noqa: E402
+
+
+def _table_exists(conn, table_name: str) -> bool:
+ return bool(conn.execute(
+ text("""
+ SELECT COUNT(1)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = :table_name
+ """),
+ {"table_name": table_name},
+ ).scalar())
+
+
+def _index_exists(conn, table_name: str, index_name: str) -> bool:
+ return bool(conn.execute(
+ text("""
+ SELECT COUNT(1)
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = :table_name
+ AND index_name = :index_name
+ """),
+ {"table_name": table_name, "index_name": index_name},
+ ).scalar())
+
+
+def _ensure_schema() -> None:
+ with engine.begin() as conn:
+ if not _table_exists(conn, "production_report_allocations"):
+ conn.execute(text("""
+ CREATE TABLE production_report_allocations (
+ id BIGINT NOT NULL AUTO_INCREMENT,
+ report_id BIGINT NOT NULL,
+ report_item_id BIGINT NULL,
+ attendance_point_name VARCHAR(128) NOT NULL DEFAULT '',
+ employee_phone VARCHAR(20) NOT NULL DEFAULT '',
+ allocation_date DATE NOT NULL,
+ day_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
+ overtime_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
+ night_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
+ effective_minutes DECIMAL(10,2) NOT NULL DEFAULT 0,
+ good_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
+ defect_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
+ scrap_qty DECIMAL(12,2) NOT NULL DEFAULT 0,
+ changeover_count DECIMAL(12,2) NOT NULL DEFAULT 0,
+ reference_wage DECIMAL(12,2) NOT NULL DEFAULT 0,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ PRIMARY KEY (id),
+ CONSTRAINT fk_report_allocations_report
+ FOREIGN KEY (report_id) REFERENCES production_reports(id)
+ ON DELETE CASCADE,
+ CONSTRAINT fk_report_allocations_item
+ FOREIGN KEY (report_item_id) REFERENCES production_report_items(id)
+ ON DELETE CASCADE
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ """))
+ indexes = {
+ "idx_report_allocations_report": "CREATE INDEX idx_report_allocations_report ON production_report_allocations (report_id)",
+ "idx_report_allocations_item": "CREATE INDEX idx_report_allocations_item ON production_report_allocations (report_item_id)",
+ "idx_report_allocations_date": "CREATE INDEX idx_report_allocations_date ON production_report_allocations (allocation_date)",
+ "idx_report_allocations_point_date": "CREATE INDEX idx_report_allocations_point_date ON production_report_allocations (attendance_point_name, allocation_date)",
+ "idx_report_allocations_employee_date": "CREATE INDEX idx_report_allocations_employee_date ON production_report_allocations (employee_phone, allocation_date)",
+ }
+ for index_name, sql in indexes.items():
+ if not _index_exists(conn, "production_report_allocations", index_name):
+ conn.execute(text(sql))
+
+
+def main() -> None:
+ _ensure_schema()
+ print("report allocations schema migrated")
+
+
+if __name__ == "__main__":
+ main()
+```
+
+- [ ] **Step 3: Verify model import**
+
+Run:
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+python - <<'PY'
+from app.models import ProductionReportAllocation
+print(ProductionReportAllocation.__tablename__)
+PY
+```
+
+Expected:
+
+```text
+production_report_allocations
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/models.py scripts/migrate_report_allocations.py
+git commit -m "feat: 增加报工归属明细表"
+```
+
+---
+
+### Task 3: Build Allocation Calculation Service
+
+**Files:**
+- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_allocations.py`
+- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_allocations.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/metrics.py`
+
+- [ ] **Step 1: Add failing allocation tests**
+
+Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_allocations.py`:
+
+```python
+from datetime import date, datetime
+from types import SimpleNamespace
+
+from app.services.report_allocations import build_report_allocation_drafts, allocation_summary_text
+from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG
+
+
+def _item(**overrides):
+ values = {
+ "id": 10,
+ "attendance_point_name": "嘉恒",
+ "device_no": "28#",
+ "project_no": "P1",
+ "product_name": "产品A",
+ "process_name": "1",
+ "stamping_method": "普通",
+ "process_unit_price_yuan": 1,
+ "good_qty": 650,
+ "defect_qty": 65,
+ "scrap_qty": 6.5,
+ "changeover_count": 13,
+ "allocated_minutes": 650,
+ "started_at": None,
+ }
+ values.update(overrides)
+ return SimpleNamespace(**values)
+
+
+def _report(start_at, end_at, *items, report_date=date(2026, 7, 25)):
+ return SimpleNamespace(
+ id=1,
+ attendance_point_name="嘉恒",
+ employee_phone="13800000000",
+ report_date=report_date,
+ start_at=start_at,
+ end_at=end_at,
+ is_voided=False,
+ items=list(items),
+ session=SimpleNamespace(devices=[]),
+ )
+
+
+def test_split_midnight_report_to_previous_night_and_current_day():
+ item = _item()
+ report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
+
+ drafts = build_report_allocation_drafts(report, DEFAULT_WORK_SCHEDULE_CONFIG)
+
+ assert [(row.report_item_id, row.allocation_date) for row in drafts] == [
+ (10, date(2026, 7, 24)),
+ (10, date(2026, 7, 25)),
+ ]
+ previous, current = drafts
+ assert previous.night_minutes == 350
+ assert previous.effective_minutes == 350
+ assert previous.good_qty == 350
+ assert previous.defect_qty == 35
+ assert previous.scrap_qty == 3.5
+ assert previous.changeover_count == 7
+ assert previous.reference_wage == 350
+ assert current.day_minutes == 180
+ assert current.overtime_minutes == 120
+ assert current.effective_minutes == 300
+ assert current.good_qty == 300
+ assert current.defect_qty == 30
+ assert current.scrap_qty == 3
+ assert current.changeover_count == 6
+ assert current.reference_wage == 300
+
+
+def test_one_minute_night_tail_only_allocates_one_minute_to_previous_day():
+ item = _item(good_qty=301, defect_qty=0, scrap_qty=0, changeover_count=0, process_unit_price_yuan=2)
+ report = _report(datetime(2026, 7, 25, 5, 59), datetime(2026, 7, 25, 11, 0), item)
+
+ drafts = build_report_allocation_drafts(report, DEFAULT_WORK_SCHEDULE_CONFIG)
+
+ previous, current = drafts
+ assert previous.allocation_date == date(2026, 7, 24)
+ assert previous.night_minutes == 1
+ assert previous.good_qty == 1
+ assert previous.reference_wage == 2
+ assert current.allocation_date == date(2026, 7, 25)
+ assert current.overtime_minutes == 120
+ assert current.day_minutes == 180
+ assert current.good_qty == 300
+ assert current.reference_wage == 600
+
+
+def test_zero_effective_minutes_falls_back_to_original_report_date():
+ item = _item(
+ id=20,
+ stamping_method="清洗",
+ allocated_minutes=0,
+ good_qty=100,
+ defect_qty=0,
+ scrap_qty=0,
+ process_unit_price_yuan=0,
+ )
+ report = _report(
+ datetime(2026, 7, 25, 9, 0),
+ datetime(2026, 7, 25, 9, 0),
+ item,
+ report_date=date(2026, 7, 25),
+ )
+
+ drafts = build_report_allocation_drafts(report, DEFAULT_WORK_SCHEDULE_CONFIG)
+
+ assert len(drafts) == 1
+ assert drafts[0].allocation_date == date(2026, 7, 25)
+ assert drafts[0].effective_minutes == 0
+ assert drafts[0].good_qty == 100
+
+
+def test_allocation_summary_text_groups_dates_and_shift_kinds():
+ item = _item()
+ report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
+ drafts = build_report_allocation_drafts(report, DEFAULT_WORK_SCHEDULE_CONFIG)
+
+ assert allocation_summary_text(drafts) == "2026-07-24 夜班5.83小时;2026-07-25 白班3小时、加班2小时"
+```
+
+- [ ] **Step 2: Run failing allocation tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_report_allocations.py -v
+```
+
+Expected before implementation: import failure for `app.services.report_allocations`.
+
+- [ ] **Step 3: Expose item time range helpers from metrics**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/metrics.py`, add a small dataclass near the Protocol classes:
+
+```python
+@dataclass(frozen=True)
+class ItemTimeRange:
+ item: ReportItemLike
+ start_at: datetime
+ end_at: datetime
+```
+
+Add `from dataclasses import dataclass` at the top.
+
+Then add this public helper below `_allocate_item_minutes`:
+
+```python
+def item_time_ranges(
+ start_at: datetime,
+ end_at: datetime,
+ items: list[ReportItemLike],
+ devices: list[ReportDeviceLike] | None = None,
+) -> list[ItemTimeRange]:
+ segment_ranges = _device_segment_ranges(start_at, end_at, devices)
+ if not items:
+ return []
+ if not segment_ranges:
+ return [ItemTimeRange(item=item, start_at=start_at, end_at=end_at) for item in items if end_at > start_at]
+
+ items_by_device: dict[str, list[ReportItemLike]] = defaultdict(list)
+ for item in items:
+ mold_key = _mold_key(
+ getattr(item, "product_name", "") or item.device_no,
+ getattr(item, "process_name", ""),
+ )
+ items_by_device[mold_key].append(item)
+
+ ranges: list[ItemTimeRange] = []
+ allocated_item_ids: set[int] = set()
+ for device_no, device_ranges in segment_ranges.items():
+ device_items = items_by_device.get(device_no, [])
+ if not device_items:
+ continue
+ for segment_start, segment_end in device_ranges:
+ grouped_items: dict[datetime, list[ReportItemLike]] = defaultdict(list)
+ for item in device_items:
+ item_start = getattr(item, "started_at", None) or segment_start
+ grouped_items[_clamp_datetime(item_start, segment_start, segment_end)].append(item)
+ first_item_start = min(grouped_items)
+ if first_item_start > segment_start:
+ grouped_items[segment_start].extend(grouped_items.pop(first_item_start))
+ ordered_starts = sorted(grouped_items)
+ for index, item_start in enumerate(ordered_starts):
+ item_end = segment_end
+ if index + 1 < len(ordered_starts):
+ item_end = ordered_starts[index + 1]
+ if item_end <= item_start:
+ continue
+ for item in grouped_items[item_start]:
+ ranges.append(ItemTimeRange(item=item, start_at=item_start, end_at=item_end))
+ allocated_item_ids.add(id(item))
+
+ unmatched_items = [item for item in items if id(item) not in allocated_item_ids]
+ for item in unmatched_items:
+ ranges.append(ItemTimeRange(item=item, start_at=start_at, end_at=end_at))
+ return ranges
+```
+
+- [ ] **Step 4: Implement report allocation service**
+
+Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_allocations.py` with these public APIs:
+
+```python
+from collections import defaultdict
+from dataclasses import dataclass
+from datetime import date, datetime, timedelta
+
+from sqlalchemy import delete
+from sqlalchemy.orm import Session
+
+from app.models import ProductionReport, ProductionReportAllocation
+from app.services.common import as_float, round2
+from app.services.metrics import (
+ SHIFT_BUCKETS,
+ format_hours,
+ item_time_ranges,
+ shift_minutes_between,
+)
+from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig
+from app.timezone import now
+
+
+@dataclass(frozen=True)
+class ReportAllocationDraft:
+ report_id: int
+ report_item_id: int | None
+ attendance_point_name: str
+ employee_phone: str
+ allocation_date: date
+ day_minutes: float = 0
+ overtime_minutes: float = 0
+ night_minutes: float = 0
+ effective_minutes: float = 0
+ good_qty: float = 0
+ defect_qty: float = 0
+ scrap_qty: float = 0
+ changeover_count: float = 0
+ reference_wage: float = 0
+
+
+def _night_allocation_date(segment_start: datetime, schedule: WorkScheduleConfig) -> date:
+ night_end = _parse_time(schedule.night_end)
+ if segment_start.time() < night_end:
+ return segment_start.date() - timedelta(days=1)
+ return segment_start.date()
+
+
+def _parse_time(value: str):
+ hour, minute = [int(part) for part in str(value).split(":", 1)]
+ return datetime.min.time().replace(hour=hour, minute=minute)
+
+
+def _date_for_bucket(bucket: str, segment_start: datetime, schedule: WorkScheduleConfig) -> date:
+ if bucket == "night":
+ return _night_allocation_date(segment_start, schedule)
+ return segment_start.date()
+
+
+def _split_range_by_hour_boundaries(start_at: datetime, end_at: datetime) -> list[tuple[datetime, datetime]]:
+ boundaries = {start_at, end_at}
+ cursor = start_at.replace(minute=0, second=0, microsecond=0)
+ while cursor <= end_at:
+ boundaries.add(cursor)
+ cursor += timedelta(hours=1)
+ return [
+ (start, end)
+ for start, end in zip(sorted(boundaries), sorted(boundaries)[1:])
+ if end > start
+ ]
+
+
+def _empty_bucket_minutes() -> dict[str, float]:
+ return {key: 0.0 for key in SHIFT_BUCKETS}
+
+
+def _item_reference_wage(item) -> float:
+ return as_float(getattr(item, "good_qty", 0)) * as_float(getattr(item, "process_unit_price_yuan", 0))
+
+
+def _zero_effective_fallback(report: ProductionReport, item) -> ReportAllocationDraft:
+ return ReportAllocationDraft(
+ report_id=report.id,
+ report_item_id=getattr(item, "id", None),
+ attendance_point_name=str(getattr(report, "attendance_point_name", "") or ""),
+ employee_phone=str(getattr(report, "employee_phone", "") or ""),
+ allocation_date=report.report_date,
+ good_qty=round2(getattr(item, "good_qty", 0)),
+ defect_qty=round2(getattr(item, "defect_qty", 0)),
+ scrap_qty=round2(getattr(item, "scrap_qty", 0)),
+ changeover_count=round2(getattr(item, "changeover_count", 0)),
+ reference_wage=round2(_item_reference_wage(item)),
+ )
+
+
+def build_report_allocation_drafts(
+ report: ProductionReport,
+ schedule: WorkScheduleConfig | None = None,
+) -> list[ReportAllocationDraft]:
+ active_schedule = schedule or DEFAULT_WORK_SCHEDULE_CONFIG
+ items = list(getattr(report, "items", []) or [])
+ if not items:
+ return []
+ devices = getattr(getattr(report, "session", None), "devices", None)
+ ranges = item_time_ranges(report.start_at, report.end_at, items, devices)
+ grouped_minutes: dict[tuple[int | None, date], dict[str, float]] = defaultdict(_empty_bucket_minutes)
+
+ for item_range in ranges:
+ for slice_start, slice_end in _split_range_by_hour_boundaries(item_range.start_at, item_range.end_at):
+ shift_minutes = shift_minutes_between(slice_start, slice_end, schedule=active_schedule)
+ for bucket in SHIFT_BUCKETS:
+ minutes = as_float(shift_minutes.get(bucket, 0))
+ if minutes <= 0:
+ continue
+ allocation_date = _date_for_bucket(bucket, slice_start, active_schedule)
+ item_id = getattr(item_range.item, "id", None)
+ grouped_minutes[(item_id, allocation_date)][bucket] += minutes
+
+ drafts: list[ReportAllocationDraft] = []
+ for item in items:
+ item_id = getattr(item, "id", None)
+ item_rows = [
+ (allocation_date, minutes)
+ for (row_item_id, allocation_date), minutes in grouped_minutes.items()
+ if row_item_id == item_id
+ ]
+ total_effective = sum(sum(as_float(minutes.get(key, 0)) for key in SHIFT_BUCKETS) for _date, minutes in item_rows)
+ if total_effective <= 0:
+ drafts.append(_zero_effective_fallback(report, item))
+ continue
+ for allocation_date, minutes in sorted(item_rows, key=lambda row: row[0]):
+ effective = sum(as_float(minutes.get(key, 0)) for key in SHIFT_BUCKETS)
+ if effective <= 0:
+ continue
+ ratio = effective / total_effective
+ drafts.append(
+ ReportAllocationDraft(
+ report_id=report.id,
+ report_item_id=item_id,
+ attendance_point_name=str(getattr(report, "attendance_point_name", "") or ""),
+ employee_phone=str(getattr(report, "employee_phone", "") or ""),
+ allocation_date=allocation_date,
+ day_minutes=round2(minutes.get("day", 0)),
+ overtime_minutes=round2(minutes.get("overtime", 0)),
+ night_minutes=round2(minutes.get("night", 0)),
+ effective_minutes=round2(effective),
+ good_qty=round2(as_float(getattr(item, "good_qty", 0)) * ratio),
+ defect_qty=round2(as_float(getattr(item, "defect_qty", 0)) * ratio),
+ scrap_qty=round2(as_float(getattr(item, "scrap_qty", 0)) * ratio),
+ changeover_count=round2(as_float(getattr(item, "changeover_count", 0)) * ratio),
+ reference_wage=round2(_item_reference_wage(item) * ratio),
+ )
+ )
+ return sorted(drafts, key=lambda row: (row.report_item_id or 0, row.allocation_date))
+
+
+def allocation_summary_text(rows: list[ReportAllocationDraft | ProductionReportAllocation]) -> str:
+ grouped: dict[date, dict[str, float]] = defaultdict(_empty_bucket_minutes)
+ for row in rows:
+ grouped[row.allocation_date]["day"] += as_float(row.day_minutes)
+ grouped[row.allocation_date]["overtime"] += as_float(row.overtime_minutes)
+ grouped[row.allocation_date]["night"] += as_float(row.night_minutes)
+ parts: list[str] = []
+ labels = {"day": "白班", "overtime": "加班", "night": "夜班"}
+ for allocation_date, minutes in sorted(grouped.items()):
+ shift_parts = [
+ f"{labels[key]}{format_hours(minutes.get(key, 0))}小时"
+ for key in ("day", "overtime", "night")
+ if round2(minutes.get(key, 0)) > 0
+ ]
+ if shift_parts:
+ parts.append(f"{allocation_date.isoformat()} {'、'.join(shift_parts)}")
+ return ";".join(parts)
+
+
+def refresh_report_allocations(
+ db: Session,
+ report: ProductionReport,
+ schedule: WorkScheduleConfig | None = None,
+ *,
+ commit: bool = False,
+) -> list[ProductionReportAllocation]:
+ db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == report.id))
+ timestamp = now()
+ rows = [
+ ProductionReportAllocation(
+ report_id=draft.report_id,
+ report_item_id=draft.report_item_id,
+ attendance_point_name=draft.attendance_point_name,
+ employee_phone=draft.employee_phone,
+ allocation_date=draft.allocation_date,
+ day_minutes=draft.day_minutes,
+ overtime_minutes=draft.overtime_minutes,
+ night_minutes=draft.night_minutes,
+ effective_minutes=draft.effective_minutes,
+ good_qty=draft.good_qty,
+ defect_qty=draft.defect_qty,
+ scrap_qty=draft.scrap_qty,
+ changeover_count=draft.changeover_count,
+ reference_wage=draft.reference_wage,
+ created_at=timestamp,
+ updated_at=timestamp,
+ )
+ for draft in build_report_allocation_drafts(report, schedule)
+ ]
+ db.add_all(rows)
+ if commit:
+ db.commit()
+ return rows
+```
+
+During implementation, prefer extracting helpers from this block rather than copying logic into routers.
+
+- [ ] **Step 5: Run allocation tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_report_allocations.py tests/test_metrics.py -v
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/services/metrics.py app/services/report_allocations.py tests/test_report_allocations.py tests/test_metrics.py
+git commit -m "feat: 增加报工工时归属拆分计算"
+```
+
+---
+
+### Task 4: Refresh Allocations In Report Lifecycle
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/auto_submit.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_lifecycle.py`
+- Test: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_allocations.py`
+
+- [ ] **Step 1: Add lifecycle test for refresh**
+
+Append this test to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_report_allocations.py`:
+
+```python
+def test_refresh_report_allocations_replaces_existing_rows():
+ from sqlalchemy import create_engine, select
+ from sqlalchemy.orm import sessionmaker
+
+ from app.models import Base, ProductionReportAllocation
+ from app.services.report_allocations import refresh_report_allocations
+
+ engine = create_engine("sqlite:///:memory:")
+ Base.metadata.create_all(engine)
+ Session = sessionmaker(bind=engine)
+ db = Session()
+ item = _item(id=10)
+ report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
+
+ refresh_report_allocations(db, report, DEFAULT_WORK_SCHEDULE_CONFIG, commit=True)
+ refresh_report_allocations(db, report, DEFAULT_WORK_SCHEDULE_CONFIG, commit=True)
+
+ rows = db.scalars(select(ProductionReportAllocation).order_by(ProductionReportAllocation.allocation_date)).all()
+ assert len(rows) == 2
+ assert [row.allocation_date for row in rows] == [date(2026, 7, 24), date(2026, 7, 25)]
+```
+
+- [ ] **Step 2: Wire normal report submission**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`, import:
+
+```python
+from app.services.report_allocations import refresh_report_allocations
+```
+
+In `submit_report`, after `db.add(report)` and before `db.commit()`, add:
+
+```python
+ db.flush()
+ refresh_report_allocations(db, report, schedule=schedule, commit=False)
+```
+
+- [ ] **Step 3: Wire cleaning submission**
+
+In `submit_cleaning_report`, after `db.add(report)` and before `db.commit()`, add:
+
+```python
+ db.flush()
+ refresh_report_allocations(db, report, schedule=get_work_schedule_config(db, point_name), commit=False)
+```
+
+Expected behavior: cleaning has zero effective minutes, so allocation rows use original `report_date` and carry quantity.
+
+- [ ] **Step 4: Wire auto submit**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/auto_submit.py`, import:
+
+```python
+from app.services.report_allocations import refresh_report_allocations
+```
+
+In `auto_submit_session`, after `db.add(report)`, add:
+
+```python
+ db.flush()
+ refresh_report_allocations(db, report, schedule=schedule, commit=False)
+```
+
+- [ ] **Step 5: Wire admin approve/edit**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`, import:
+
+```python
+from app.services.report_allocations import refresh_report_allocations
+```
+
+In `approve_report`, after `_recalculate_report_metrics(report, schedule)` and before final `db.commit()`, add:
+
+```python
+ refresh_report_allocations(db, report, schedule=schedule, commit=False)
+```
+
+- [ ] **Step 6: Delete allocations when purging expired voided reports**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/report_lifecycle.py`, import `ProductionReportAllocation` and add this before deleting `ProductionReportItem`:
+
+```python
+ db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == report.id))
+```
+
+- [ ] **Step 7: Run lifecycle-adjacent tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_report_allocations.py tests/test_review_corrections.py -v
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/routers/reports.py app/services/auto_submit.py app/routers/reviews.py app/services/report_lifecycle.py tests/test_report_allocations.py
+git commit -m "feat: 报工保存后刷新归属明细"
+```
+
+---
+
+### Task 5: Expose Allocation Summary In API Responses
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`
+
+- [ ] **Step 1: Add schema fields**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`, add:
+
+```python
+class ReportAllocationOut(BaseModel):
+ allocation_date: date
+ day_minutes: float = 0
+ overtime_minutes: float = 0
+ night_minutes: float = 0
+ effective_minutes: float = 0
+ good_qty: float = 0
+ defect_qty: float = 0
+ scrap_qty: float = 0
+ changeover_count: float = 0
+ reference_wage: float = 0
+```
+
+Add to `ReportItemOut`:
+
+```python
+ allocations: list[ReportAllocationOut] = Field(default_factory=list)
+```
+
+Add to `ReportOut`:
+
+```python
+ allocation_summary_text: str = ""
+```
+
+Add to `DashboardRow`:
+
+```python
+ source_report_date: date | None = None
+ source_report_dates: list[date] = Field(default_factory=list)
+```
+
+- [ ] **Step 2: Serialize allocations**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/serializers.py`, import:
+
+```python
+from app.schemas import ReportAllocationOut
+from app.services.report_allocations import allocation_summary_text
+```
+
+Add helper:
+
+```python
+def report_allocation_out(row) -> ReportAllocationOut:
+ return ReportAllocationOut(
+ allocation_date=row.allocation_date,
+ day_minutes=as_float(row.day_minutes),
+ overtime_minutes=as_float(row.overtime_minutes),
+ night_minutes=as_float(row.night_minutes),
+ effective_minutes=as_float(row.effective_minutes),
+ good_qty=as_float(row.good_qty),
+ defect_qty=as_float(row.defect_qty),
+ scrap_qty=as_float(row.scrap_qty),
+ changeover_count=as_float(row.changeover_count),
+ reference_wage=as_float(row.reference_wage),
+ )
+```
+
+Update `report_item_out` to set:
+
+```python
+ allocations=[report_allocation_out(row) for row in getattr(item, "allocations", [])],
+```
+
+Update `report_out` to set:
+
+```python
+ allocation_summary_text=allocation_summary_text(list(getattr(report, "allocations", []) or [])),
+```
+
+- [ ] **Step 3: Eager load allocations in report queries**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` and `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reviews.py`, update report queries that already use `selectinload(ProductionReport.items)` to also load:
+
+```python
+selectinload(ProductionReport.allocations)
+selectinload(ProductionReport.items).selectinload(ProductionReportItem.allocations)
+```
+
+Add `ProductionReportItem` import where needed.
+
+- [ ] **Step 4: Smoke test response serialization**
+
+Run:
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_report_allocations.py tests/test_review_corrections.py -v
+```
+
+Expected: tests pass and import errors do not occur.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/schemas.py app/services/serializers.py app/routers/reports.py app/routers/reviews.py
+git commit -m "feat: 输出报工归属摘要"
+```
+
+---
+
+### Task 6: Switch Manager Dashboard And Excel Export To Allocations
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py`
+- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_dashboard_allocations.py`
+
+- [ ] **Step 1: Add failing dashboard aggregation test**
+
+Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_dashboard_allocations.py` with a focused helper-level test around a new function `_dashboard_rows_from_allocations`.
+
+```python
+from datetime import date
+from types import SimpleNamespace
+
+from app.routers.dashboard import _dashboard_rows_from_allocations
+
+
+def test_dashboard_groups_by_allocation_date_not_original_report_date():
+ employee = SimpleNamespace(name="张三", is_temporary=False)
+ report = SimpleNamespace(
+ id=1,
+ attendance_point_name="嘉恒",
+ report_date=date(2026, 7, 25),
+ employee_phone="13800000000",
+ employee=employee,
+ is_system_auto_submitted=False,
+ is_voided=False,
+ is_multi_person_assistant=False,
+ review_remark="",
+ audit_logs=[],
+ )
+ item = SimpleNamespace(
+ id=10,
+ report=report,
+ attendance_point_name="嘉恒",
+ project_no="P1",
+ product_name="产品A",
+ process_name="1",
+ stamping_method="普通",
+ material_code="M1",
+ material_name="料A",
+ raw_material_batch_no="B1",
+ device_no="28#",
+ operator_count=1,
+ process_unit_price_yuan=2,
+ standard_beat=60,
+ good_qty=650,
+ defect_qty=0,
+ scrap_qty=0,
+ )
+ allocations = [
+ SimpleNamespace(
+ report=report,
+ item=item,
+ report_id=1,
+ report_item_id=10,
+ allocation_date=date(2026, 7, 24),
+ day_minutes=0,
+ overtime_minutes=0,
+ night_minutes=350,
+ effective_minutes=350,
+ good_qty=350,
+ defect_qty=0,
+ scrap_qty=0,
+ changeover_count=0,
+ reference_wage=700,
+ ),
+ SimpleNamespace(
+ report=report,
+ item=item,
+ report_id=1,
+ report_item_id=10,
+ allocation_date=date(2026, 7, 25),
+ day_minutes=180,
+ overtime_minutes=120,
+ night_minutes=0,
+ effective_minutes=300,
+ good_qty=300,
+ defect_qty=0,
+ scrap_qty=0,
+ changeover_count=0,
+ reference_wage=600,
+ ),
+ ]
+
+ rows = _dashboard_rows_from_allocations(allocations)
+
+ assert [row.report_date for row in rows] == [date(2026, 7, 25), date(2026, 7, 24)]
+ assert [row.total_good_qty for row in rows] == [300, 350]
+ assert [row.reference_wage for row in rows] == [600, 700]
+ assert all(row.report_count == 1 for row in rows)
+```
+
+- [ ] **Step 2: Implement allocation query in dashboard router**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/dashboard.py`, import `ProductionReportAllocation` and replace date filtering in `_dashboard_rows`:
+
+```python
+query = (
+ select(ProductionReportAllocation)
+ .join(ProductionReport, ProductionReport.id == ProductionReportAllocation.report_id)
+ .join(ProductionReportItem, ProductionReportItem.id == ProductionReportAllocation.report_item_id)
+ .options(
+ selectinload(ProductionReportAllocation.report).selectinload(ProductionReport.employee),
+ selectinload(ProductionReportAllocation.report).selectinload(ProductionReport.audit_logs),
+ selectinload(ProductionReportAllocation.item),
+ )
+ .where(ProductionReport.status == ReportStatus.approved)
+)
+if not include_voided:
+ query = query.where(ProductionReport.is_voided.is_(False))
+if start_date:
+ query = query.where(ProductionReportAllocation.allocation_date >= start_date)
+if end_date:
+ query = query.where(ProductionReportAllocation.allocation_date <= end_date)
+allocations = db.scalars(query).all()
+return _dashboard_rows_from_allocations(allocations)
+```
+
+Implement `_dashboard_rows_from_allocations(allocations: list) -> list[DashboardRow]` by moving the existing grouping logic from `_dashboard_rows`, with these substitutions:
+
+- group key uses `allocation.allocation_date` instead of `report.report_date`
+- `effective_minutes` uses `allocation.effective_minutes`
+- shift minutes use `allocation.day_minutes`, `allocation.overtime_minutes`, `allocation.night_minutes`
+- quantities use `allocation.good_qty`, `allocation.defect_qty + allocation.scrap_qty`
+- `changeover_count` uses `allocation.changeover_count`
+- `reference_wage` uses `allocation.reference_wage`
+- `report_count` uses `len(group["report_ids"])`
+- `source_report_dates` collects `report.report_date`
+
+- [ ] **Step 3: Update Excel headers**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/excel_export.py`, change dashboard headers from:
+
+```python
+"报工日期",
+```
+
+to:
+
+```python
+"统计归属日期",
+"原始报工日期",
+```
+
+When appending rows, write:
+
+```python
+row.report_date.isoformat(),
+"、".join(sorted(item.isoformat() for item in getattr(row, "source_report_dates", []) or [row.source_report_date or row.report_date])),
+```
+
+Ensure all later column indexes used for red fills shift by one column.
+
+- [ ] **Step 4: Run dashboard/export tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_dashboard_allocations.py tests/test_usage_stats.py -v
+```
+
+Expected: dashboard allocation test passes; existing usage stats tests still pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/routers/dashboard.py app/services/excel_export.py tests/test_dashboard_allocations.py
+git commit -m "feat: 经理看板按归属日期汇总"
+```
+
+---
+
+### Task 7: Switch Reconciliation Ledger To Allocations
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reconciliation.py`
+- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_reconciliation_allocations.py`
+
+- [ ] **Step 1: Add query helper for reported quantities**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reconciliation.py`, change `_reported_good_quantities` to query `ProductionReportAllocation` joined to `ProductionReportItem` and `ProductionReport`.
+
+The selected month must be:
+
+```python
+extract("month", ProductionReportAllocation.allocation_date).label("month")
+```
+
+The selected quantity must be:
+
+```python
+func.sum(ProductionReportAllocation.good_qty).label("good_qty")
+```
+
+The year filter must be:
+
+```python
+extract("year", ProductionReportAllocation.allocation_date) == year
+```
+
+- [ ] **Step 2: Update reconciliation years**
+
+In `list_reconciliation_years`, read years from `ProductionReportAllocation.allocation_date` joined to approved, unvoided reports:
+
+```python
+select(extract("year", ProductionReportAllocation.allocation_date))
+```
+
+- [ ] **Step 3: Add focused reconciliation test**
+
+Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_reconciliation_allocations.py` with a pure helper test. Extract the final row folding into a helper named `_fold_reported_good_quantity_rows` so the test does not depend on a database fixture.
+
+Expected scenario:
+
+```text
+产品A最后工序为2序;
+同一原始报工拆到2026-07和2026-08;
+7月对账小账本只显示7月 allocation.good_qty;
+8月只显示8月 allocation.good_qty。
+```
+
+Use exact expected values:
+
+```python
+assert totals[("嘉恒", "产品A", 7)] == 350
+assert totals[("嘉恒", "产品A", 8)] == 300
+```
+
+- [ ] **Step 4: Run reconciliation tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_reconciliation_allocations.py -v
+```
+
+Expected: tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/routers/reconciliation.py tests/test_reconciliation_allocations.py
+git commit -m "feat: 对账小账本按归属日期统计"
+```
+
+---
+
+### Task 8: Switch Usage Stats To Allocations
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`
+
+- [ ] **Step 1: Update accumulator to dedupe report count**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`, add `report_ids` to `UsageStatRow`:
+
+```python
+ report_ids: set[int] = field(default_factory=set, repr=False)
+```
+
+Change `UsageStatAccumulator.add` signature:
+
+```python
+ report_id: int | None = None,
+```
+
+Replace:
+
+```python
+ row.report_count += 1
+```
+
+with:
+
+```python
+ if report_id is None:
+ row.report_count += 1
+ elif report_id not in row.report_ids:
+ row.report_ids.add(report_id)
+ row.report_count += 1
+```
+
+Pass `report_id` from both report-item and allocation paths.
+
+- [ ] **Step 2: Add allocation-aware builder**
+
+Add a function:
+
+```python
+def build_usage_stats_from_allocations(
+ *,
+ allocations: list,
+ category: str,
+ equipment_type_by_key: dict[tuple[str, str], str],
+) -> list[UsageStatRow]:
+ reports = []
+ for allocation in allocations:
+ item = allocation.item
+ proxy = SimpleNamespace(
+ attendance_point_name=item.attendance_point_name,
+ product_name=item.product_name,
+ process_name=item.process_name,
+ stamping_method=item.stamping_method,
+ operator_count=item.operator_count,
+ device_no=item.device_no,
+ allocated_minutes=allocation.effective_minutes,
+ good_qty=allocation.good_qty,
+ report_id=allocation.report_id,
+ )
+ reports.append(SimpleNamespace(items=[proxy]))
+ return build_usage_stats(
+ reports=reports,
+ category=category,
+ equipment_type_by_key=equipment_type_by_key,
+ )
+```
+
+Add `from types import SimpleNamespace` at the top.
+
+When implementing, also modify `_iter_report_items` or the `acc.add` calls so `report_id` reaches `UsageStatAccumulator.add`.
+
+- [ ] **Step 3: Update usage stats router query**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`, import `ProductionReportAllocation` and query allocations joined to approved, unvoided reports.
+
+Date filters must use:
+
+```python
+ProductionReportAllocation.allocation_date >= start_date
+ProductionReportAllocation.allocation_date <= end_date
+```
+
+Summary/export/detail should call `build_usage_stats_from_allocations`.
+
+Daily detail rows should use `allocation.allocation_date`.
+
+Report detail rows should show `allocation.allocation_date` as `report_date` so the detail page follows the selected statistics date.
+
+- [ ] **Step 4: Add usage stats tests**
+
+Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`:
+
+```python
+def test_usage_stats_dedupes_report_count_for_split_allocations():
+ allocation_a = SimpleNamespace(
+ report_id=1,
+ effective_minutes=350,
+ good_qty=350,
+ item=_item(device_no="28#", allocated_minutes=0, good_qty=0),
+ )
+ allocation_b = SimpleNamespace(
+ report_id=1,
+ effective_minutes=300,
+ good_qty=300,
+ item=_item(device_no="28#", allocated_minutes=0, good_qty=0),
+ )
+
+ rows = build_usage_stats_from_allocations(
+ allocations=[allocation_a, allocation_b],
+ category="device",
+ equipment_type_by_key={("嘉恒", "28#"): "冲压设备"},
+ )
+
+ assert len(rows) == 1
+ assert rows[0].value == 650
+ assert rows[0].report_count == 1
+```
+
+- [ ] **Step 5: Run usage stats tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest tests/test_usage_stats.py -v
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add app/routers/usage_stats.py app/services/usage_stats.py tests/test_usage_stats.py
+git commit -m "feat: 设备模具统计按归属日期汇总"
+```
+
+---
+
+### Task 9: Frontend Display For Allocation Summary
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxss`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxss`
+
+- [ ] **Step 1: Map new fields in API**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add:
+
+```javascript
+const toCamelAllocation = row => ({
+ allocationDate: row.allocation_date || '',
+ dayMinutes: Number(row.day_minutes || 0),
+ overtimeMinutes: Number(row.overtime_minutes || 0),
+ nightMinutes: Number(row.night_minutes || 0),
+ effectiveMinutes: Number(row.effective_minutes || 0),
+ goodQty: Number(row.good_qty || 0),
+ defectQty: Number(row.defect_qty || 0),
+ scrapQty: Number(row.scrap_qty || 0),
+ changeoverCount: Number(row.changeover_count || 0),
+ referenceWage: Number(row.reference_wage || 0),
+})
+```
+
+In `toCamelReportItem`, add:
+
+```javascript
+ allocations: (row.allocations || []).map(toCamelAllocation),
+```
+
+In `toCamelReport`, add:
+
+```javascript
+ allocationSummaryText: row.allocation_summary_text || '',
+```
+
+In dashboard row mapper, map:
+
+```javascript
+ sourceReportDate: row.source_report_date || '',
+ sourceReportDates: row.source_report_dates || [],
+```
+
+- [ ] **Step 2: Show summary in review page**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`, below `{{item.metrics.shiftDistributionText || ''}}`, add:
+
+```xml
+归属拆分:{{item.allocationSummaryText}}
+```
+
+- [ ] **Step 3: Show source date in dashboard**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`, below the row title date, add:
+
+```xml
+原始报工日期 {{item.sourceReportDates.join('、')}}
+```
+
+- [ ] **Step 4: Add compact styles**
+
+In both WXSS files, add:
+
+```css
+.allocation-summary {
+ display: block;
+ margin-top: 6rpx;
+ color: #4b5563;
+ font-size: 24rpx;
+ line-height: 1.45;
+}
+```
+
+- [ ] **Step 5: Frontend smoke check**
+
+Open WeChat DevTools and verify:
+
+```text
+管理员审核页:跨日期记录显示“归属拆分:...”
+经理看板:统计日期仍在主标题;跨日期聚合行额外显示原始报工日期
+旧记录没有 allocation_summary_text 时页面不显示空行
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS
+git add utils/api.js pages/review/review.wxml pages/review/review.wxss pages/dashboard/dashboard.wxml pages/dashboard/dashboard.wxss
+git commit -m "feat: 展示报工归属拆分摘要"
+```
+
+---
+
+### Task 10: Run Migration And Backfill Verification
+
+**Files:**
+- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py`
+- Run: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py`
+
+- [ ] **Step 1: Add backfill logic to migration script**
+
+In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/scripts/migrate_report_allocations.py`, add imports:
+
+```python
+from sqlalchemy.orm import selectinload
+
+from app.database import SessionLocal, engine # noqa: E402
+from app.models import ProductionReport, WorkSession # noqa: E402
+from app.services.report_allocations import refresh_report_allocations # noqa: E402
+from app.services.work_schedule import get_work_schedule_config # noqa: E402
+```
+
+Replace the existing `from app.database import engine` import with the import above.
+
+Add:
+
+```python
+def _backfill() -> tuple[int, int]:
+ processed = 0
+ skipped = 0
+ with SessionLocal() as db:
+ reports = db.scalars(
+ select(ProductionReport)
+ .options(
+ selectinload(ProductionReport.items),
+ selectinload(ProductionReport.session).selectinload(WorkSession.devices),
+ )
+ .order_by(ProductionReport.id.asc())
+ ).all()
+ for report in reports:
+ if report is None:
+ skipped += 1
+ continue
+ schedule = get_work_schedule_config(db, report.attendance_point_name)
+ refresh_report_allocations(db, report, schedule=schedule, commit=False)
+ processed += 1
+ if processed % 200 == 0:
+ db.commit()
+ db.commit()
+ return processed, skipped
+```
+
+Add `select` to the SQLAlchemy import:
+
+```python
+from sqlalchemy import select, text
+```
+
+Update `main`:
+
+```python
+def main() -> None:
+ _ensure_schema()
+ processed, skipped = _backfill()
+ print(f"report allocations migrated, processed={processed}, skipped={skipped}")
+```
+
+- [ ] **Step 2: Run migration on local dev DB**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+python scripts/migrate_report_allocations.py
+```
+
+Expected:
+
+```text
+report allocations migrated, processed=, skipped=0
+```
+
+- [ ] **Step 3: Verify table and indexes**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+python - <<'PY'
+from sqlalchemy import text
+from app.database import engine
+
+with engine.connect() as conn:
+ table_count = conn.execute(text("""
+ SELECT COUNT(1)
+ FROM information_schema.tables
+ WHERE table_schema = DATABASE()
+ AND table_name = 'production_report_allocations'
+ """)).scalar()
+ row_count = conn.execute(text("SELECT COUNT(1) FROM production_report_allocations")).scalar()
+ indexes = conn.execute(text("""
+ SELECT index_name
+ FROM information_schema.statistics
+ WHERE table_schema = DATABASE()
+ AND table_name = 'production_report_allocations'
+ ORDER BY index_name
+ """)).all()
+print({"table_count": table_count, "row_count": row_count, "indexes": [row[0] for row in indexes]})
+PY
+```
+
+Expected:
+
+```text
+table_count is 1
+row_count is greater than or equal to the number of production report items that can be allocated
+indexes include idx_report_allocations_point_date
+```
+
+- [ ] **Step 4: Verify sample cross-date data**
+
+If local DB has no cross-date sample, create one through the app flow or by controlled test data. Then run:
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+python - <<'PY'
+from sqlalchemy import text
+from app.database import engine
+
+with engine.connect() as conn:
+ rows = conn.execute(text("""
+ SELECT report_id, report_item_id, allocation_date,
+ day_minutes, overtime_minutes, night_minutes,
+ effective_minutes, good_qty, reference_wage
+ FROM production_report_allocations
+ WHERE report_id = :report_id
+ ORDER BY allocation_date, report_item_id
+ """), {"report_id": 1}).mappings().all()
+for row in rows:
+ print(dict(row))
+PY
+```
+
+Expected for a `00:10-11:00` style report:
+
+```text
+one row for previous date with night_minutes > 0
+one row for current date with day_minutes and overtime_minutes > 0
+sum(good_qty) equals original item good_qty within rounding tolerance
+sum(reference_wage) equals original item reference wage within rounding tolerance
+```
+
+- [ ] **Step 5: Commit migration backfill update**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git add scripts/migrate_report_allocations.py
+git commit -m "feat: 回填历史报工归属明细"
+```
+
+---
+
+### Task 11: Full Verification
+
+**Files:**
+- Verify backend repo `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint`
+- Verify frontend repo `/Users/souplearn/Gitlab/app/JhHardwareWRS`
+
+- [ ] **Step 1: Run backend tests**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+pytest -v
+```
+
+Expected: all backend tests pass.
+
+- [ ] **Step 2: Run targeted import smoke**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+python - <<'PY'
+from app.models import ProductionReportAllocation
+from app.services.report_allocations import build_report_allocation_drafts
+from app.routers.dashboard import _dashboard_rows
+print("ok")
+PY
+```
+
+Expected:
+
+```text
+ok
+```
+
+- [ ] **Step 3: Manual app verification**
+
+Use local backend and WeChat DevTools:
+
+```text
+1. 创建或找一条 00:10-11:00 的测试报工。
+2. 管理员审核通过。
+3. 管理员审核页显示归属拆分摘要。
+4. 经理看板按前一天和当天拆成统计行。
+5. 经理导出 Excel 有“统计归属日期”和“原始报工日期”。
+6. 对账小账本按统计归属月份显示最后工序成品数量。
+7. 设备/模具统计按统计归属日期过滤,报工次数不重复放大。
+8. 作废该报工后,看板、对账、使用统计不再计入。
+9. 撤销作废后,看板、对账、使用统计恢复。
+```
+
+- [ ] **Step 4: Final git status**
+
+```bash
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
+git status --short
+cd /Users/souplearn/Gitlab/app/JhHardwareWRS
+git status --short
+```
+
+Expected:
+
+```text
+only intentional commits remain; no unrelated user files are staged
+```
+
+---
+
+## Self-Review Checklist
+
+- Spec coverage:
+ - 原始报工不拆:Tasks 2-6 keep `ProductionReport` unchanged as source record.
+ - 归属明细表:Task 2.
+ - 空白区间算加班:Task 1.
+ - 数量和工资按工时比例拆:Task 3.
+ - 清洗零工时兜底:Task 3.
+ - 提交、自动提交、管理员审核刷新归属:Task 4.
+ - 经理看板和导出:Task 6.
+ - 对账小账本:Task 7.
+ - 设备/模具统计:Task 8.
+ - 前端展示:Task 9.
+ - 历史回填:Task 10.
+
+- Placeholder scan:
+ - No unresolved placeholder markers.
+ - No unresolved business decision.
+ - Report count is fixed as original report ID dedupe.
+
+- Type consistency:
+ - Model name: `ProductionReportAllocation`.
+ - Service functions: `build_report_allocation_drafts`, `allocation_summary_text`, `refresh_report_allocations`.
+ - API fields: `allocation_summary_text`, `allocations`, `source_report_date`, `source_report_dates`.