ForgeFlow-ERP/docs/superpowers/plans/2026-06-11-miniapp-operation-report-sync.md
2026-06-12 16:00:56 +08:00

23 KiB
Raw Blame History

Miniapp Operation Report Sync 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: Make 工序报工 page refresh fast by separating read-only list refresh from explicit miniapp-to-ERP synchronization, with sync range fixed to automatic catch-up from the last successful sync time.

Architecture: Keep the existing list endpoint for display, but make the frontend call it with sync_to_erp=false. Add a dedicated backend sync endpoint that reads and locks MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT in sys_system_config, syncs all miniapp report items changed since that time, and only updates the timestamp after the transaction succeeds. The frontend adds a disabled loading state for the sync button and refreshes the list after sync.

Tech Stack: FastAPI, SQLAlchemy, Pydantic, MySQL-compatible row locking, Vue 3 Composition API, Vite, Python unittest, Node static UI checks.


File Structure

  • Modify: backend/app/services/system_config.py
    • Add constants and helpers for MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT.
    • Add parse/format helpers for sync timestamps.
    • Add helpers to initialize and lock the sync config row, so first sync also has a database lock target.
  • Modify: backend/app/schemas/operations.py
    • Add MiniAppOperationReportSyncRead.
  • Modify: backend/app/api/routes/production.py
    • Add helper query for miniapp rows changed since the last successful sync.
    • Add POST /production/miniapp-operation-reports/sync.
    • Keep list endpoint behavior backward-compatible, but frontend will call it as read-only.
  • Modify: backend/tests/test_miniapp_operation_report_date_filter.py
    • Add tests for read-only list refresh and explicit automatic catch-up sync.
  • Modify: frontend/src/views/OperationReportView.vue
    • Change list refresh to sync_to_erp=false.
    • Add sync button, loading state, last-sync display, and result feedback.
  • Modify: frontend/scripts/test-operation-report-date-filter.mjs
    • Update static checks for read-only refresh.
  • Create: frontend/scripts/test-operation-report-sync.mjs
    • Static checks for sync button, dedicated endpoint, disabled loading state, and auto-refresh after sync.

Task 1: Backend Sync Timestamp Helpers

Files:

  • Modify: backend/app/services/system_config.py

  • Test: backend/tests/test_miniapp_operation_report_date_filter.py

  • Step 1: Add failing helper tests

Append these imports in backend/tests/test_miniapp_operation_report_date_filter.py:

from app.services.system_config import (  # noqa: E402
    MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
    format_miniapp_operation_report_last_sync,
    get_miniapp_operation_report_last_sync,
    parse_miniapp_operation_report_last_sync,
    upsert_miniapp_operation_report_last_sync,
)
from app.models.org import SystemConfig  # noqa: E402
from sqlalchemy import select  # noqa: E402

Add this test method to MiniAppOperationReportDateFilterTest:

    def test_miniapp_operation_report_last_sync_config_round_trips_datetime(self) -> None:
        timestamp = datetime(2026, 6, 11, 10, 30, 15)

        row = upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1)
        self.db.commit()

        persisted = self.db.scalar(
            select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE)
        )
        self.assertIsNotNone(persisted)
        self.assertEqual(row.config_value, "2026-06-11 10:30:15")
        self.assertEqual(format_miniapp_operation_report_last_sync(timestamp), "2026-06-11 10:30:15")
        self.assertEqual(parse_miniapp_operation_report_last_sync("2026-06-11 10:30:15"), timestamp)
        self.assertEqual(get_miniapp_operation_report_last_sync(self.db), timestamp)
  • Step 2: Run the failing backend test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py::MiniAppOperationReportDateFilterTest::test_miniapp_operation_report_last_sync_config_round_trips_datetime -q

Expected: FAIL because the sync config helper functions do not exist.

  • Step 3: Implement timestamp helpers

In backend/app/services/system_config.py, add the import:

from datetime import datetime

The file already imports datetime; keep one import only.

Add these constants near existing config constants:

MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE = "MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT"
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME = "小程序报工上次同步成功时间"
MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT = "%Y-%m-%d %H:%M:%S"

Add these helpers after get_smart_operation_report_enabled:

def format_miniapp_operation_report_last_sync(value: datetime) -> str:
    return value.strftime(MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)


def parse_miniapp_operation_report_last_sync(value: str | None) -> datetime | None:
    normalized = str(value or "").strip()
    if not normalized:
        return None
    return datetime.strptime(normalized, MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)


def get_miniapp_operation_report_last_sync(db: Session) -> datetime | None:
    value = get_system_config_value(db, MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, "")
    return parse_miniapp_operation_report_last_sync(value)


def upsert_miniapp_operation_report_last_sync(
    db: Session,
    timestamp: datetime,
    *,
    updated_by: int | None = None,
) -> SystemConfig:
    return upsert_system_config(
        db,
        config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
        config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
        config_value=format_miniapp_operation_report_last_sync(timestamp),
        remark="工序报工小程序同步 ERP 的上次成功截止时间",
        updated_by=updated_by,
        now=timestamp,
    )


def ensure_miniapp_operation_report_last_sync_config(
    db: Session,
    *,
    updated_by: int | None = None,
) -> SystemConfig:
    row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE))
    if row:
        return row
    timestamp = datetime.now()
    row = SystemConfig(
        config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
        config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
        config_value="",
        remark="工序报工小程序同步 ERP 的上次成功截止时间",
        status="ACTIVE",
        created_by=updated_by,
        updated_by=updated_by,
        created_at=timestamp,
        updated_at=timestamp,
    )
    db.add(row)
    db.flush()
    return row


def lock_miniapp_operation_report_last_sync(db: Session) -> SystemConfig | None:
    ensure_miniapp_operation_report_last_sync_config(db)
    return db.scalar(
        select(SystemConfig)
        .where(
            SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
            SystemConfig.status == "ACTIVE",
        )
        .with_for_update()
    )
  • Step 4: Run the helper test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py::MiniAppOperationReportDateFilterTest::test_miniapp_operation_report_last_sync_config_round_trips_datetime -q

Expected: PASS.


Task 2: Backend Read-Only List And Explicit Sync Endpoint

Files:

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/api/routes/production.py

  • Modify: backend/tests/test_miniapp_operation_report_date_filter.py

  • Step 1: Add failing tests for read-only refresh and automatic catch-up sync

Add imports in backend/tests/test_miniapp_operation_report_date_filter.py:

from app.api.routes import production as production_routes  # noqa: E402
from app.api.routes.production import sync_miniapp_operation_reports  # noqa: E402
from app.models.operations import OperationReport  # noqa: E402

Add this test method:

    def test_list_reports_with_sync_disabled_does_not_call_sync(self) -> None:
        calls: list[int] = []
        original_sync = production_routes._sync_miniapp_item_to_erp

        def fake_sync(db, source_item_id, **kwargs):
            calls.append(source_item_id)
            return None

        production_routes._sync_miniapp_item_to_erp = fake_sync
        try:
            rows = list_miniapp_operation_reports(
                limit=100,
                sync_to_erp=False,
                start_date=date(2026, 5, 18),
                end_date=date(2026, 6, 1),
                db=self.db,
            )
        finally:
            production_routes._sync_miniapp_item_to_erp = original_sync

        self.assertEqual(len(rows), 2)
        self.assertEqual(calls, [])
        self.assertEqual(self.db.query(OperationReport).count(), 0)

Add this test method:

    def test_sync_reports_uses_last_success_time_not_page_filter(self) -> None:
        upsert_miniapp_operation_report_last_sync(self.db, datetime(2026, 5, 19, 0, 0, 0), updated_by=1)
        self.db.commit()
        calls: list[int] = []
        original_sync = production_routes._sync_miniapp_item_to_erp
        original_now = production_routes.datetime

        class FixedDateTime(datetime):
            @classmethod
            def now(cls, tz=None):
                return cls(2026, 6, 11, 12, 0, 0)

        def fake_sync(db, source_item_id, **kwargs):
            calls.append(source_item_id)
            return None

        production_routes._sync_miniapp_item_to_erp = fake_sync
        production_routes.datetime = FixedDateTime
        try:
            result = sync_miniapp_operation_reports(db=self.db)
        finally:
            production_routes._sync_miniapp_item_to_erp = original_sync
            production_routes.datetime = original_now

        self.assertEqual(calls, [2, 3])
        self.assertEqual(result.total, 2)
        self.assertEqual(result.sync_from, datetime(2026, 5, 19, 0, 0, 0))
        self.assertEqual(result.sync_to, datetime(2026, 6, 11, 12, 0, 0))
        self.assertEqual(get_miniapp_operation_report_last_sync(self.db), datetime(2026, 6, 11, 12, 0, 0))
  • Step 2: Run the failing tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py -q

Expected: FAIL because sync_miniapp_operation_reports and response schema do not exist yet.

  • Step 3: Add sync response schema

In backend/app/schemas/operations.py, add after MiniAppOperationReportRead:

class MiniAppOperationReportSyncRead(BaseModel):
    sync_from: datetime | None = None
    sync_to: datetime
    last_synced_at: datetime
    total: int = 0
    synced: int = 0
    skipped: int = 0
    failed: int = 0
  • Step 4: Import sync helpers and schema

In backend/app/api/routes/production.py, extend the operations schema import to include:

MiniAppOperationReportSyncRead,

Extend the system config imports to include:

get_miniapp_operation_report_last_sync,
lock_miniapp_operation_report_last_sync,
upsert_miniapp_operation_report_last_sync,
  • Step 5: Add changed-since miniapp row helper

In backend/app/api/routes/production.py, add this helper near _miniapp_report_item_rows:

def _miniapp_report_item_rows_changed_since(
    db: Session,
    *,
    sync_from: datetime | None,
    sync_to: datetime,
    limit: int = 800,
) -> list[dict]:
    stmt = (
        select(
            MiniAppProductionReport.id.label("source_report_id"),
            MiniAppProductionReportItem.id.label("source_item_id"),
            func.coalesce(
                MiniAppProductionReportItem.attendance_point_name,
                MiniAppProductionReport.attendance_point_name,
            ).label("attendance_point_name"),
            MiniAppProductionReport.employee_phone.label("employee_phone"),
            MiniAppPersonnel.name.label("employee_name"),
            MiniAppProductionReport.report_date.label("report_date"),
            MiniAppProductionReport.start_at.label("start_time"),
            MiniAppProductionReport.end_at.label("end_time"),
            MiniAppProductionReport.duration_minutes.label("duration_minutes"),
            MiniAppProductionReport.effective_minutes.label("effective_minutes"),
            MiniAppProductionReport.status.label("status"),
            MiniAppProductionReport.reviewer_phone.label("reviewer_phone"),
            MiniAppProductionReport.reviewed_at.label("reviewed_at"),
            MiniAppProductionReport.reject_reason.label("reject_reason"),
            MiniAppProductionReportItem.device_no.label("device_no"),
            MiniAppProductionReportItem.project_no.label("project_no"),
            MiniAppProductionReportItem.product_name.label("product_name"),
            MiniAppProductionReportItem.material_code.label("material_code"),
            MiniAppProductionReportItem.material_name.label("material_name"),
            MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"),
            MiniAppProductionReportItem.process_name.label("process_name"),
            MiniAppProductionReportItem.stamping_method.label("stamping_method"),
            MiniAppProductionReportItem.standard_beat.label("standard_beat"),
            MiniAppProductionReportItem.standard_workload.label("standard_workload"),
            MiniAppProductionReportItem.good_qty.label("good_qty"),
            MiniAppProductionReportItem.defect_qty.label("defect_qty"),
            MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"),
            MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"),
            MiniAppProductionReportItem.started_at.label("started_at"),
        )
        .join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id)
        .outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone)
        .where(MiniAppProductionReport.updated_at <= sync_to)
        .order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc())
        .limit(limit)
    )
    if sync_from is not None:
        stmt = stmt.where(MiniAppProductionReport.updated_at > sync_from)
    rows = db.execute(stmt).mappings().all()
    return [dict(row) for row in rows]
  • Step 6: Add explicit sync endpoint

In backend/app/api/routes/production.py, add before the existing @router.get("/miniapp-operation-reports" route:

@router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead)
def sync_miniapp_operation_reports(
    limit: int = Query(default=800, ge=1, le=800),
    db: Session = Depends(get_db),
) -> MiniAppOperationReportSyncRead:
    lock_miniapp_operation_report_last_sync(db)
    sync_from = get_miniapp_operation_report_last_sync(db)
    sync_to = datetime.now()
    rows = _miniapp_report_item_rows_changed_since(db, sync_from=sync_from, sync_to=sync_to, limit=limit)
    cache = _new_miniapp_resolve_cache()
    report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows]
    if report_nos:
        existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(report_nos))).all()
        cache["report"].update({report.report_no: report for report in existing_reports})

    touched_work_order_ids: set[int] = set()
    synced = 0
    skipped = 0
    for row in rows:
        synced_report = _sync_miniapp_item_to_erp(
            db,
            int(row["source_item_id"]),
            required=False,
            row=row,
            cache=cache,
            sync_status=False,
        )
        if synced_report:
            synced += 1
            if synced_report.work_order_id:
                touched_work_order_ids.add(int(synced_report.work_order_id))
        else:
            skipped += 1

    for work_order_id in touched_work_order_ids:
        sync_work_order_status(db, work_order_id)
    row = upsert_miniapp_operation_report_last_sync(db, sync_to)
    db.commit()
    db.refresh(row)
    return MiniAppOperationReportSyncRead(
        sync_from=sync_from,
        sync_to=sync_to,
        last_synced_at=sync_to,
        total=len(rows),
        synced=synced,
        skipped=skipped,
        failed=0,
    )
  • Step 7: Run backend tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py -q

Expected: PASS.


Task 3: Frontend Read-Only Refresh And Sync Button

Files:

  • Modify: frontend/src/views/OperationReportView.vue

  • Modify: frontend/scripts/test-operation-report-date-filter.mjs

  • Create: frontend/scripts/test-operation-report-sync.mjs

  • Step 1: Add failing frontend static test

Create frontend/scripts/test-operation-report-sync.mjs:

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";

const root = process.cwd();
const source = readFileSync(path.join(root, "src/views/OperationReportView.vue"), "utf8");

assert.match(source, /syncReportsToErp/, "operation report page should define an explicit ERP sync action");
assert.match(source, /postResource\("\/production\/miniapp-operation-reports\/sync"/, "sync action should call the dedicated sync endpoint");
assert.match(source, /syncingReports\.value\s*=\s*true/, "sync action should enable loading state");
assert.match(source, /:disabled="syncingReports"/, "sync button should be disabled while syncing");
assert.match(source, /同步中/, "sync button should show a syncing label");
assert.match(source, /lastSyncedAt/, "page should display the last successful sync time");
assert.match(source, /await loadReports\(\)/, "page should refresh the list after sync succeeds");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "list refresh should not sync ERP implicitly");
assert.match(source, /sync_to_erp:\s*"false"/, "list refresh should explicitly request read-only data");

console.log("operation report sync checks passed");
  • Step 2: Run failing frontend test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-operation-report-sync.mjs

Expected: FAIL because the sync action and button do not exist.

  • Step 3: Update imports and state

In frontend/src/views/OperationReportView.vue, change:

import { fetchResource } from "../services/api";

to:

import { fetchResource, postResource } from "../services/api";

Add after const reports = ref([]);:

const syncingReports = ref(false);
const lastSyncedAt = ref("");
  • Step 4: Make list refresh read-only

In loadReports(), change:

const params = new URLSearchParams({
  limit: "800",
  sync_to_erp: "true"
});

to:

const params = new URLSearchParams({
  limit: "800",
  sync_to_erp: "false"
});

Change success text from:

feedbackMessage.value = "小程序报工数据已刷新并同步 ERP 生产核算";

to:

feedbackMessage.value = "小程序报工列表已刷新";
  • Step 5: Add explicit sync action

Add after loadReports():

async function syncReportsToErp() {
  if (syncingReports.value) {
    return;
  }
  feedbackMessage.value = "";
  errorMessage.value = "";
  syncingReports.value = true;
  try {
    const result = await postResource("/production/miniapp-operation-reports/sync", {});
    lastSyncedAt.value = result.last_synced_at || result.sync_to || "";
    feedbackMessage.value = [
      `同步完成:共 ${result.total || 0} 条`,
      `成功 ${result.synced || 0} 条`,
      `跳过 ${result.skipped || 0} 条`,
      `失败 ${result.failed || 0} 条`,
      `同步范围:${result.sync_from || "首次同步"}${result.sync_to || "-"}`
    ].join("");
    await loadReports();
  } catch (error) {
    errorMessage.value = error.message || "同步小程序报工到 ERP 失败,上次同步成功时间未更新,请稍后重试";
  } finally {
    syncingReports.value = false;
  }
}
  • Step 6: Add sync UI

In the template, inside .operation-filter-ribbon after the refresh button, add:

<button class="primary-button" type="button" :disabled="syncingReports" @click="syncReportsToErp">
  {{ syncingReports ? "同步中..." : "同步到 ERP" }}
</button>
<span class="operation-sync-hint">上次同步{{ lastSyncedAt || "暂无" }}</span>
  • Step 7: Update existing date-filter static test

In frontend/scripts/test-operation-report-date-filter.mjs, replace the old negative check:

assert.doesNotMatch(
  source,
  /fetchResource\("\/production\/miniapp-operation-reports\?limit=800&sync_to_erp=true"/,
  "operation report page must not use the old all-row request"
);

with:

assert.match(source, /sync_to_erp:\s*"false"/, "operation report refresh should be read-only by default");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "operation report refresh must not sync ERP implicitly");
  • Step 8: Run frontend static tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-operation-report-date-filter.mjs
node scripts/test-operation-report-sync.mjs

Expected: both PASS.


Task 4: Final Verification

Files:

  • Backend and frontend files from prior tasks.

  • Step 1: Run backend focused tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py -q

Expected: PASS.

  • Step 2: Run frontend static checks

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-operation-report-date-filter.mjs
node scripts/test-operation-report-sync.mjs

Expected: PASS.

  • Step 3: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: Vite build succeeds. Chunk-size warning is acceptable if it matches the existing project warning.

  • Step 4: Manual browser verification

Open http://127.0.0.1:5173/operation-reports and verify:

  • Page loads the list without waiting for ERP sync.
  • 刷新 only refreshes the current visible list.
  • 同步到 ERP changes to 同步中... and cannot be clicked again while syncing.
  • Sync success message shows total, success, skipped, failed, and sync range.
  • The list refreshes after sync completes.
  • Page date filters do not affect the backend sync range.

Self-Review

  • Spec coverage: The plan covers read-only refresh, automatic catch-up sync, no user sync date range, loading button, last-sync persistence, idempotent existing sync reuse, and verification.
  • Placeholder scan: No unfinished placeholders or vague “add tests” instructions remain.
  • Type consistency: The plan consistently uses MiniAppOperationReportSyncRead, sync_miniapp_operation_reports, MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT, syncingReports, and lastSyncedAt.