669 lines
23 KiB
Markdown
669 lines
23 KiB
Markdown
# 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`:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```python
|
||
from datetime import datetime
|
||
```
|
||
|
||
The file already imports `datetime`; keep one import only.
|
||
|
||
Add these constants near existing config constants:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
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:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
MiniAppOperationReportSyncRead,
|
||
```
|
||
|
||
Extend the system config imports to include:
|
||
|
||
```python
|
||
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`:
|
||
|
||
```python
|
||
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:
|
||
|
||
```python
|
||
@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:
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```javascript
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```javascript
|
||
import { fetchResource } from "../services/api";
|
||
```
|
||
|
||
to:
|
||
|
||
```javascript
|
||
import { fetchResource, postResource } from "../services/api";
|
||
```
|
||
|
||
Add after `const reports = ref([]);`:
|
||
|
||
```javascript
|
||
const syncingReports = ref(false);
|
||
const lastSyncedAt = ref("");
|
||
```
|
||
|
||
- [ ] **Step 4: Make list refresh read-only**
|
||
|
||
In `loadReports()`, change:
|
||
|
||
```javascript
|
||
const params = new URLSearchParams({
|
||
limit: "800",
|
||
sync_to_erp: "true"
|
||
});
|
||
```
|
||
|
||
to:
|
||
|
||
```javascript
|
||
const params = new URLSearchParams({
|
||
limit: "800",
|
||
sync_to_erp: "false"
|
||
});
|
||
```
|
||
|
||
Change success text from:
|
||
|
||
```javascript
|
||
feedbackMessage.value = "小程序报工数据已刷新并同步 ERP 生产核算";
|
||
```
|
||
|
||
to:
|
||
|
||
```javascript
|
||
feedbackMessage.value = "小程序报工列表已刷新";
|
||
```
|
||
|
||
- [ ] **Step 5: Add explicit sync action**
|
||
|
||
Add after `loadReports()`:
|
||
|
||
```javascript
|
||
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:
|
||
|
||
```vue
|
||
<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:
|
||
|
||
```javascript
|
||
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:
|
||
|
||
```javascript
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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`.
|