JhHardwareWRS/docs/superpowers/plans/2026-06-23-usage-stats.md
2026-06-24 15:19:14 +08:00

1690 lines
54 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Equipment And Mold Usage Statistics Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an admin/manager workbench module that ranks equipment and mold usage with horizontal bar charts, date filters, detail drill-down, and Excel export.
**Architecture:** Build usage statistics as read-only derived data from approved, non-voided production reports. Backend aggregation lives in a focused service and exposes summary, detail, and export endpoints; frontend adds one summary page and one detail page following the existing mini-program card/pager/date-filter patterns. No database migration is required for this first version.
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, openpyxl, WeChat Mini Program WXML/WXSS/JS.
---
## Confirmed Product Decisions
- Module name: `设备模具使用统计`.
- Entry points: admin workbench and manager workbench.
- Permissions:
- Admin sees only data for their accessible attendance points.
- Manager sees all attendance points.
- Default dataset: only `ProductionReport.status == approved` and `ProductionReport.is_voided == false`.
- Layout: A + C.
- Summary page: `设备 / 模具` segmented switch, filters, sort switch, horizontal bar ranking.
- Detail page: daily trend bars and related report summaries.
- Equipment metrics:
- Press equipment: usage minutes + report count.
- Cleaning equipment: cleaning quantity + report count.
- Cleaning multi-equipment rule:
- Current system stores one cleaning item with `device_no = "清洗机A、清洗机B"` and one total `good_qty`.
- First version splits that quantity evenly across selected cleaning equipment.
- Example: `清洗机A、清洗机B` with `good_qty=100` contributes `50` to each device and `+1` report count to each device.
- Mold dimension:
- `attendance_point_name + product_name + process_name + stamping_method`.
- Normal/continuous/multi-person mold: usage minutes + report count.
- Cleaning mold: cleaning quantity + report count.
- Misc work is excluded from mold statistics.
- Sorting:
- Default `sort_by=value`: press/normal rows sort by minutes; cleaning rows sort by quantity.
- Alternate `sort_by=count`: sorts by report count.
- Export:
- Excel with two sheets: `设备统计`, `模具统计`.
## File Map
Backend:
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/main.py`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats_export.py`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`
Frontend:
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/index/index.js`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxml`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxss`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxml`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxss`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json`
Version-control note:
- The current workspace may not be a git repository. If `git status` works in the execution environment, commit after each task. If `git status` fails with “not a git repository”, skip commit steps and report that the workspace has no git metadata.
---
### Task 1: Backend Usage Statistics Aggregation Service
**Files:**
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`
- [ ] **Step 1: Write failing tests for aggregation rules**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py` with these tests:
```python
from datetime import date
from types import SimpleNamespace
from app.services.usage_stats import (
UsageStatAccumulator,
build_usage_stats,
split_cleaning_device_nos,
)
def _report(
*,
report_id=1,
report_date=date(2026, 6, 23),
point="嘉恒",
employee_name="张三",
employee_phone="13900000000",
items=None,
):
return SimpleNamespace(
id=report_id,
attendance_point_name=point,
report_date=report_date,
employee_phone=employee_phone,
employee=SimpleNamespace(name=employee_name),
items=items or [],
)
def _item(
*,
item_id=1,
point="嘉恒",
device_no="28#",
project_no="P001",
product_name="测试产品",
process_name="1",
stamping_method="普通",
allocated_minutes=60,
good_qty=10,
operator_count=1,
):
return SimpleNamespace(
id=item_id,
attendance_point_name=point,
device_no=device_no,
project_no=project_no,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
allocated_minutes=allocated_minutes,
good_qty=good_qty,
operator_count=operator_count,
)
def test_split_cleaning_device_nos_supports_cn_and_ascii_separators():
assert split_cleaning_device_nos("清洗机A、清洗机B, 清洗机C") == ["清洗机A", "清洗机B", "清洗机C"]
assert split_cleaning_device_nos("") == []
assert split_cleaning_device_nos(None) == []
def test_press_equipment_stats_use_allocated_minutes_and_report_count():
item = _item(device_no="28#", allocated_minutes=75, good_qty=12)
rows = build_usage_stats(
reports=[_report(items=[item])],
category="device",
equipment_type_by_key={("嘉恒", "28#"): "冲压设备"},
)
assert len(rows) == 1
row = rows[0]
assert row.category == "device"
assert row.object_type == "冲压设备"
assert row.name == "28#"
assert row.metric_kind == "minutes"
assert row.value == 75
assert row.report_count == 1
def test_cleaning_equipment_stats_split_quantity_across_selected_devices():
item = _item(device_no="清洗机A、清洗机B", stamping_method="清洗", allocated_minutes=0, good_qty=100)
rows = build_usage_stats(
reports=[_report(items=[item])],
category="device",
equipment_type_by_key={
("嘉恒", "清洗机A"): "清洗设备",
("嘉恒", "清洗机B"): "清洗设备",
},
)
assert [(row.name, row.value, row.report_count, row.metric_kind) for row in rows] == [
("清洗机A", 50, 1, "quantity"),
("清洗机B", 50, 1, "quantity"),
]
def test_mold_stats_group_by_attendance_product_process_and_stamping_method():
first = _item(item_id=1, product_name="测试", process_name="1", stamping_method="连续模", allocated_minutes=30)
second = _item(item_id=2, product_name="测试", process_name="1", stamping_method="连续模", allocated_minutes=45)
rows = build_usage_stats(
reports=[_report(items=[first, second])],
category="mold",
equipment_type_by_key={},
)
assert len(rows) == 1
row = rows[0]
assert row.category == "mold"
assert row.attendance_point_name == "嘉恒"
assert row.product_name == "测试"
assert row.process_name == "1"
assert row.stamping_method == "连续模"
assert row.metric_kind == "minutes"
assert row.value == 75
assert row.report_count == 2
assert "连续模" in row.tags
def test_cleaning_mold_stats_use_cleaning_quantity_not_minutes():
item = _item(product_name="清洗产品", process_name="1", stamping_method="清洗", allocated_minutes=0, good_qty=88)
rows = build_usage_stats(
reports=[_report(items=[item])],
category="mold",
equipment_type_by_key={},
)
assert len(rows) == 1
assert rows[0].metric_kind == "quantity"
assert rows[0].value == 88
assert rows[0].report_count == 1
```
- [ ] **Step 2: Run tests and verify they fail because service does not exist**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py -q
```
Expected:
```text
ModuleNotFoundError: No module named 'app.services.usage_stats'
```
- [ ] **Step 3: Implement the usage stats service**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats.py`:
```python
from dataclasses import dataclass, field
from datetime import date
import re
from app.services.cleaning import is_cleaning_item
from app.services.common import as_float, round2
from app.services.continuous_die import is_continuous_die_item
from app.services.misc_work import is_misc_item
from app.services.multi_person import is_multi_person_item
def split_cleaning_device_nos(value: str | None) -> list[str]:
result: list[str] = []
for part in re.split(r"[、,\s]+", str(value or "").strip()):
text = part.strip()
if text and text not in result:
result.append(text)
return result
@dataclass
class UsageStatRow:
id: str
category: str
attendance_point_name: str
name: str
object_type: str
metric_kind: str
value: float = 0
report_count: int = 0
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
tags: list[str] = field(default_factory=list)
class UsageStatAccumulator:
def __init__(
self,
*,
category: str,
attendance_point_name: str,
name: str,
object_type: str,
metric_kind: str,
product_name: str = "",
process_name: str = "",
stamping_method: str = "",
tags: list[str] | None = None,
) -> None:
identity_parts = [
category,
attendance_point_name,
name,
product_name,
process_name,
stamping_method,
metric_kind,
]
self.row = UsageStatRow(
id="||".join(str(part or "") for part in identity_parts),
category=category,
attendance_point_name=attendance_point_name,
name=name,
object_type=object_type,
metric_kind=metric_kind,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
tags=tags or [],
)
def add(self, value: float) -> None:
self.row.value += as_float(value)
self.row.report_count += 1
def _item_tags(item) -> list[str]:
tags: list[str] = []
if is_cleaning_item(item):
tags.append("清洗")
if is_continuous_die_item(item):
tags.append("连续模")
if is_multi_person_item(item):
tags.append("多人协作")
return tags
def _mold_object_type(item) -> str:
if is_cleaning_item(item):
return "清洗模具"
if is_continuous_die_item(item):
return "连续模"
if is_multi_person_item(item):
return "多人工序"
return "普通模具"
def build_usage_stats(
*,
reports: list,
category: str,
equipment_type_by_key: dict[tuple[str, str], str],
) -> list[UsageStatRow]:
groups: dict[tuple, UsageStatAccumulator] = {}
for report in reports:
for item in report.items:
if is_misc_item(item):
continue
point_name = str(item.attendance_point_name or report.attendance_point_name or "").strip()
cleaning = is_cleaning_item(item)
if category == "device":
if cleaning:
device_nos = split_cleaning_device_nos(item.device_no)
if not device_nos:
continue
split_qty = as_float(item.good_qty) / len(device_nos)
for device_no in device_nos:
object_type = equipment_type_by_key.get((point_name, device_no), "清洗设备")
key = ("device", point_name, device_no, "quantity")
groups.setdefault(
key,
UsageStatAccumulator(
category="device",
attendance_point_name=point_name,
name=device_no,
object_type=object_type,
metric_kind="quantity",
tags=["清洗设备"],
),
).add(split_qty)
else:
device_no = str(item.device_no or "").strip()
if not device_no:
continue
object_type = equipment_type_by_key.get((point_name, device_no), "冲压设备")
key = ("device", point_name, device_no, "minutes")
groups.setdefault(
key,
UsageStatAccumulator(
category="device",
attendance_point_name=point_name,
name=device_no,
object_type=object_type,
metric_kind="minutes",
tags=[object_type],
),
).add(as_float(item.allocated_minutes))
continue
if category == "mold":
product_name = str(item.product_name or "").strip()
process_name = str(item.process_name or "").strip()
stamping_method = str(item.stamping_method or "").strip()
if not product_name or not process_name:
continue
metric_kind = "quantity" if cleaning else "minutes"
key = ("mold", point_name, product_name, process_name, stamping_method, metric_kind)
groups.setdefault(
key,
UsageStatAccumulator(
category="mold",
attendance_point_name=point_name,
name=f"{product_name} / {process_name} / {stamping_method or '-'}",
object_type=_mold_object_type(item),
metric_kind=metric_kind,
product_name=product_name,
process_name=process_name,
stamping_method=stamping_method,
tags=_item_tags(item),
),
).add(as_float(item.good_qty) if cleaning else as_float(item.allocated_minutes))
rows = [item.row for item in groups.values()]
for row in rows:
row.value = round2(row.value)
return rows
```
- [ ] **Step 4: Run aggregation tests and verify they pass**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py -q
```
Expected:
```text
5 passed
```
---
### Task 2: Backend API Schemas And Router
**Files:**
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/main.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`
- [ ] **Step 1: Extend tests for endpoint helper behavior**
Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`:
```python
from app.routers.usage_stats import _sort_usage_rows
def test_usage_rows_sort_by_value_then_count_then_name():
rows = [
UsageStatRow(id="b", category="device", attendance_point_name="嘉恒", name="B", object_type="冲压设备", metric_kind="minutes", value=20, report_count=9),
UsageStatRow(id="a", category="device", attendance_point_name="嘉恒", name="A", object_type="冲压设备", metric_kind="minutes", value=30, report_count=1),
]
assert [row.name for row in _sort_usage_rows(rows, "value")] == ["A", "B"]
assert [row.name for row in _sort_usage_rows(rows, "count")] == ["B", "A"]
```
- [ ] **Step 2: Run test and verify it fails because router does not exist**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py::test_usage_rows_sort_by_value_then_count_then_name -q
```
Expected:
```text
ModuleNotFoundError: No module named 'app.routers.usage_stats'
```
- [ ] **Step 3: Add usage stats schemas**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/schemas.py`, add these classes near `DashboardRow`:
```python
class UsageStatsRow(BaseModel):
id: str
category: str
attendance_point_name: str = ""
name: str
object_type: str
metric_kind: str
value: float = 0
report_count: int = 0
product_name: str = ""
process_name: str = ""
stamping_method: str = ""
tags: list[str] = Field(default_factory=list)
class UsageStatsDailyRow(BaseModel):
report_date: date
value: float = 0
report_count: int = 0
class UsageStatsReportRow(BaseModel):
report_id: int
report_date: date
employee_phone: str = ""
employee_name: str = ""
display_name: str = ""
value: float = 0
report_count: int = 1
class UsageStatsDetailOut(BaseModel):
category: str
attendance_point_name: str = ""
name: str
object_type: str = ""
metric_kind: str
value: float = 0
report_count: int = 0
daily_rows: list[UsageStatsDailyRow] = Field(default_factory=list)
report_rows: list[UsageStatsReportRow] = Field(default_factory=list)
```
- [ ] **Step 4: Create usage stats router**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`:
```python
from datetime import date
from math import ceil
from fastapi import APIRouter, Depends, Query
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import get_db
from app.deps import require_roles
from app.models import Equipment, Personnel, ProductionReport, ReportStatus, Role
from app.schemas import PageResponse, UsageStatsDetailOut, UsageStatsDailyRow, UsageStatsReportRow, UsageStatsRow
from app.services.attendance_points import accessible_point_names, require_attendance_point_access
from app.services.common import round2
from app.services.report_lifecycle import purge_expired_voided_reports
from app.services.usage_stats import UsageStatRow, build_usage_stats
from app.services.usage_stats_export import export_usage_stats_rows
router = APIRouter(prefix="/api/usage-stats", tags=["usage-stats"])
def _equipment_type_map(db: Session, point_names: list[str]) -> dict[tuple[str, str], str]:
rows = db.scalars(select(Equipment).where(Equipment.attendance_point_name.in_(point_names))).all()
return {(row.attendance_point_name, row.device_no): row.device_type for row in rows}
def _approved_reports_query(point_names: list[str], start_date: date | None, end_date: date | None):
query = (
select(ProductionReport)
.options(selectinload(ProductionReport.employee), selectinload(ProductionReport.items))
.where(
ProductionReport.status == ReportStatus.approved,
ProductionReport.is_voided.is_(False),
ProductionReport.attendance_point_name.in_(point_names),
)
)
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
return query
def _sort_usage_rows(rows: list[UsageStatRow], sort_by: str) -> list[UsageStatRow]:
if sort_by == "count":
return sorted(rows, key=lambda row: (row.report_count, row.value, row.name), reverse=True)
return sorted(rows, key=lambda row: (row.value, row.report_count, row.name), reverse=True)
def _to_schema_row(row: UsageStatRow) -> UsageStatsRow:
return UsageStatsRow(
id=row.id,
category=row.category,
attendance_point_name=row.attendance_point_name,
name=row.name,
object_type=row.object_type,
metric_kind=row.metric_kind,
value=row.value,
report_count=row.report_count,
product_name=row.product_name,
process_name=row.process_name,
stamping_method=row.stamping_method,
tags=row.tags,
)
def _requested_points(db: Session, user: Personnel, attendance_point_name: str = "") -> list[str]:
if attendance_point_name:
return [require_attendance_point_access(db, user, attendance_point_name)]
return accessible_point_names(db, user)
@router.get("/summary", response_model=PageResponse)
def usage_stats_summary(
category: str = Query("device", pattern="^(device|mold)$"),
start_date: date | None = None,
end_date: date | None = None,
attendance_point_name: str = "",
sort_by: str = Query("value", pattern="^(value|count)$"),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
point_names = _requested_points(db, user, attendance_point_name)
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
rows = _sort_usage_rows(
build_usage_stats(
reports=reports,
category=category,
equipment_type_by_key=_equipment_type_map(db, point_names),
),
sort_by,
)
safe_page_size = min(100, max(1, page_size))
total = len(rows)
total_pages = max(1, ceil(total / safe_page_size))
start = (page - 1) * safe_page_size
return PageResponse(
page=page,
page_size=safe_page_size,
total=total,
total_pages=total_pages,
rows=[_to_schema_row(row) for row in rows[start:start + safe_page_size]],
)
@router.get("/detail", response_model=UsageStatsDetailOut)
def usage_stats_detail(
category: str = Query("device", pattern="^(device|mold)$"),
start_date: date | None = None,
end_date: date | None = None,
attendance_point_name: str = "",
name: str = "",
product_name: str = "",
process_name: str = "",
stamping_method: str = "",
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> UsageStatsDetailOut:
purge_expired_voided_reports(db)
point_names = _requested_points(db, user, attendance_point_name)
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
rows = build_usage_stats(
reports=reports,
category=category,
equipment_type_by_key=_equipment_type_map(db, point_names),
)
target = next(
(
row for row in rows
if row.attendance_point_name == attendance_point_name
and (row.name == name or not name)
and (category == "device" or (
row.product_name == product_name
and row.process_name == process_name
and row.stamping_method == stamping_method
))
),
None,
)
if target is None:
return UsageStatsDetailOut(category=category, attendance_point_name=attendance_point_name, name=name, metric_kind="minutes")
daily: dict[date, UsageStatsDailyRow] = {}
report_rows: list[UsageStatsReportRow] = []
for report in reports:
partial = build_usage_stats(
reports=[report],
category=category,
equipment_type_by_key=_equipment_type_map(db, point_names),
)
match = next((row for row in partial if row.id == target.id), None)
if match is None:
continue
day = daily.setdefault(report.report_date, UsageStatsDailyRow(report_date=report.report_date))
day.value = round2(day.value + match.value)
day.report_count += match.report_count
report_rows.append(
UsageStatsReportRow(
report_id=report.id,
report_date=report.report_date,
employee_phone=report.employee_phone,
employee_name=report.employee.name if report.employee else "",
display_name=target.name,
value=match.value,
report_count=match.report_count,
)
)
return UsageStatsDetailOut(
category=target.category,
attendance_point_name=target.attendance_point_name,
name=target.name,
object_type=target.object_type,
metric_kind=target.metric_kind,
value=target.value,
report_count=target.report_count,
daily_rows=sorted(daily.values(), key=lambda row: row.report_date),
report_rows=sorted(report_rows, key=lambda row: (row.report_date, row.report_id), reverse=True),
)
@router.get("/export")
def usage_stats_export(
start_date: date | None = None,
end_date: date | None = None,
attendance_point_name: str = "",
user: Personnel = Depends(require_roles(Role.admin, Role.manager)),
db: Session = Depends(get_db),
) -> Response:
purge_expired_voided_reports(db)
point_names = _requested_points(db, user, attendance_point_name)
reports = db.scalars(_approved_reports_query(point_names, start_date, end_date)).all()
equipment_type_by_key = _equipment_type_map(db, point_names)
device_rows = _sort_usage_rows(build_usage_stats(reports=reports, category="device", equipment_type_by_key=equipment_type_by_key), "value")
mold_rows = _sort_usage_rows(build_usage_stats(reports=reports, category="mold", equipment_type_by_key=equipment_type_by_key), "value")
content = export_usage_stats_rows(device_rows, mold_rows)
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="usage-stats.xlsx"'},
)
```
- [ ] **Step 5: Register router in main app**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/main.py`:
Add import:
```python
from app.routers import usage_stats
```
Add router registration next to other routers:
```python
app.include_router(usage_stats.router)
```
- [ ] **Step 6: Run backend tests for usage stats**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py -q
```
Expected:
```text
6 passed
```
---
### Task 3: Excel Export
**Files:**
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats_export.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/usage_stats.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`
- [ ] **Step 1: Add export test**
Append to `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_usage_stats.py`:
```python
from io import BytesIO
from openpyxl import load_workbook
from app.services.usage_stats_export import export_usage_stats_rows
def test_export_usage_stats_rows_contains_device_and_mold_sheets():
device_rows = [
UsageStatRow(id="d1", category="device", attendance_point_name="嘉恒", name="28#", object_type="冲压设备", metric_kind="minutes", value=120, report_count=3)
]
mold_rows = [
UsageStatRow(
id="m1",
category="mold",
attendance_point_name="嘉恒",
name="测试 / 1 / 连续模",
object_type="连续模",
metric_kind="minutes",
value=80,
report_count=2,
product_name="测试",
process_name="1",
stamping_method="连续模",
)
]
workbook = load_workbook(BytesIO(export_usage_stats_rows(device_rows, mold_rows)))
assert workbook.sheetnames == ["设备统计", "模具统计"]
assert [cell.value for cell in workbook["设备统计"][1]] == ["考勤点", "设备号", "设备类型", "使用时长", "清洗数量", "报工次数"]
assert [cell.value for cell in workbook["设备统计"][2]] == ["嘉恒", "28#", "冲压设备", 120, 0, 3]
assert [cell.value for cell in workbook["模具统计"][1]] == ["考勤点", "产品名称", "工序", "冲压方式", "使用时长", "清洗数量", "报工次数"]
assert [cell.value for cell in workbook["模具统计"][2]] == ["嘉恒", "测试", "1", "连续模", 80, 0, 2]
```
- [ ] **Step 2: Run export test and verify it fails**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py::test_export_usage_stats_rows_contains_device_and_mold_sheets -q
```
Expected:
```text
ModuleNotFoundError: No module named 'app.services.usage_stats_export'
```
- [ ] **Step 3: Implement export helper**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/services/usage_stats_export.py`:
```python
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
from openpyxl.utils import get_column_letter
HEADER_FILL = PatternFill("solid", fgColor="DCEBFF")
HEADER_FONT = Font(bold=True, color="1F2A44")
def _style_sheet(sheet) -> None:
for cell in sheet[1]:
cell.fill = HEADER_FILL
cell.font = HEADER_FONT
for column in range(1, sheet.max_column + 1):
sheet.column_dimensions[get_column_letter(column)].width = 18
def _usage_minutes(row) -> float:
return row.value if row.metric_kind == "minutes" else 0
def _cleaning_quantity(row) -> float:
return row.value if row.metric_kind == "quantity" else 0
def export_usage_stats_rows(device_rows: list, mold_rows: list) -> bytes:
workbook = Workbook()
device_sheet = workbook.active
device_sheet.title = "设备统计"
device_sheet.append(["考勤点", "设备号", "设备类型", "使用时长", "清洗数量", "报工次数"])
for row in device_rows:
device_sheet.append([
row.attendance_point_name,
row.name,
row.object_type,
_usage_minutes(row),
_cleaning_quantity(row),
row.report_count,
])
_style_sheet(device_sheet)
mold_sheet = workbook.create_sheet("模具统计")
mold_sheet.append(["考勤点", "产品名称", "工序", "冲压方式", "使用时长", "清洗数量", "报工次数"])
for row in mold_rows:
mold_sheet.append([
row.attendance_point_name,
row.product_name,
row.process_name,
row.stamping_method,
_usage_minutes(row),
_cleaning_quantity(row),
row.report_count,
])
_style_sheet(mold_sheet)
output = BytesIO()
workbook.save(output)
return output.getvalue()
```
- [ ] **Step 4: Run export test and verify it passes**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py::test_export_usage_stats_rows_contains_device_and_mold_sheets -q
```
Expected:
```text
1 passed
```
---
### Task 4: Frontend API Mapping
**Files:**
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`
- [ ] **Step 1: Add mapping helpers**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add near dashboard mapping helpers:
```javascript
const toCamelUsageStatsRow = row => ({
id: row.id,
category: row.category,
attendancePointName: row.attendance_point_name || '',
name: row.name || '',
objectType: row.object_type || '',
metricKind: row.metric_kind || 'minutes',
value: Number(row.value || 0),
reportCount: Number(row.report_count || 0),
productName: row.product_name || '',
processName: row.process_name || '',
stampingMethod: row.stamping_method || '',
tags: row.tags || [],
})
const toCamelUsageStatsDetail = data => ({
category: data.category,
attendancePointName: data.attendance_point_name || '',
name: data.name || '',
objectType: data.object_type || '',
metricKind: data.metric_kind || 'minutes',
value: Number(data.value || 0),
reportCount: Number(data.report_count || 0),
dailyRows: (data.daily_rows || []).map(row => ({
reportDate: row.report_date,
value: Number(row.value || 0),
reportCount: Number(row.report_count || 0),
})),
reportRows: (data.report_rows || []).map(row => ({
reportId: row.report_id,
reportDate: row.report_date,
employeePhone: row.employee_phone || '',
employeeName: row.employee_name || '',
displayName: row.display_name || '',
value: Number(row.value || 0),
reportCount: Number(row.report_count || 0),
})),
})
```
- [ ] **Step 2: Add API calls**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add:
```javascript
const listUsageStats = async (filters = {}) => {
const data = await requestClient.request({
url: '/api/usage-stats/summary',
params: {
category: filters.category || 'device',
start_date: filters.startDate,
end_date: filters.endDate,
attendance_point_name: filters.attendancePointName,
sort_by: filters.sortBy || 'value',
page: filters.page,
page_size: filters.pageSize,
},
})
return toCamelPage(data, toCamelUsageStatsRow)
}
const getUsageStatsDetail = async (filters = {}) => toCamelUsageStatsDetail(await requestClient.request({
url: '/api/usage-stats/detail',
params: {
category: filters.category || 'device',
start_date: filters.startDate,
end_date: filters.endDate,
attendance_point_name: filters.attendancePointName,
name: filters.name,
product_name: filters.productName,
process_name: filters.processName,
stamping_method: filters.stampingMethod,
},
}))
const exportUsageStats = filters => requestClient.download({
url: '/api/usage-stats/export',
params: {
start_date: filters && filters.startDate,
end_date: filters && filters.endDate,
attendance_point_name: filters && filters.attendancePointName,
},
})
```
- [ ] **Step 3: Export API functions**
At the bottom `module.exports` object in `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, add:
```javascript
listUsageStats,
getUsageStatsDetail,
exportUsageStats,
```
- [ ] **Step 4: Run JS syntax check**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS
node --check utils/api.js
```
Expected: exit code `0`.
---
### Task 5: Frontend Summary Page
**Files:**
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/index/index.js`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxml`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxss`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json`
- [ ] **Step 1: Register page**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`, add this page before `pages/dashboard/dashboard`:
```json
"pages/usageStats/usageStats",
```
- [ ] **Step 2: Add workbench entries**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/index/index.js`, add this action to both admin and manager action arrays:
```javascript
{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
```
- [ ] **Step 3: Create page JSON**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json`:
```json
{
"navigationBarTitleText": "使用统计"
}
```
- [ ] **Step 4: Create summary page JS**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js`:
```javascript
const api = require('../../utils/api')
const todayDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const monthStartDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}-${month}-01`
}
Page({
data: {
category: 'device',
sortBy: 'value',
startDate: monthStartDate(),
endDate: todayDate(),
attendancePoints: [],
attendancePointLabels: ['全部考勤点'],
attendancePointIndex: 0,
attendancePointName: '',
rows: [],
page: 1,
pageInput: '1',
totalPages: 1,
refreshing: false,
},
onLoad() {
this.init()
},
async init() {
await this.loadAttendancePoints()
await this.load(1)
},
async loadAttendancePoints() {
const points = await api.listAccessibleAttendancePoints()
this.setData({
attendancePoints: points || [],
attendancePointLabels: ['全部考勤点'].concat((points || []).map(item => item.name)),
})
},
async load(page = this.data.page) {
const result = await api.listUsageStats({
category: this.data.category,
sortBy: this.data.sortBy,
startDate: this.data.startDate,
endDate: this.data.endDate,
attendancePointName: this.data.attendancePointName,
page,
pageSize: 10,
})
const maxValue = Math.max(1, ...(result.rows || []).map(item => Number(item.value || 0)))
this.setData({
rows: (result.rows || []).map(item => ({
...item,
barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxValue * 100)),
valueText: item.metricKind === 'quantity' ? `${item.value} 件` : `${item.value} 分钟`,
primarySortLabel: item.metricKind === 'quantity' ? '按清洗数量' : '按使用时长',
})),
page: result.page,
pageInput: String(result.page || 1),
totalPages: result.totalPages,
})
},
async onPullDownRefresh() {
this.setData({ refreshing: true })
try {
await this.load(1)
} finally {
this.setData({ refreshing: false })
if (wx.stopPullDownRefresh) {
wx.stopPullDownRefresh()
}
}
},
chooseCategory(e) {
this.setData({ category: e.currentTarget.dataset.category, page: 1 })
this.load(1)
},
chooseSort(e) {
this.setData({ sortBy: e.currentTarget.dataset.sort, page: 1 })
this.load(1)
},
onStartChange(e) {
this.setData({ startDate: e.detail.value, page: 1 })
this.load(1)
},
onEndChange(e) {
this.setData({ endDate: e.detail.value, page: 1 })
this.load(1)
},
onAttendancePointChange(e) {
const index = Number(e.detail.value)
const point = index > 0 ? this.data.attendancePoints[index - 1] : null
this.setData({
attendancePointIndex: index,
attendancePointName: point ? point.name : '',
page: 1,
})
this.load(1)
},
openDetail(e) {
const row = this.data.rows[Number(e.currentTarget.dataset.index)]
if (!row) {
return
}
const params = [
`category=${encodeURIComponent(row.category)}`,
`startDate=${encodeURIComponent(this.data.startDate)}`,
`endDate=${encodeURIComponent(this.data.endDate)}`,
`attendancePointName=${encodeURIComponent(row.attendancePointName)}`,
`name=${encodeURIComponent(row.name)}`,
`productName=${encodeURIComponent(row.productName || '')}`,
`processName=${encodeURIComponent(row.processName || '')}`,
`stampingMethod=${encodeURIComponent(row.stampingMethod || '')}`,
].join('&')
wx.navigateTo({ url: `/pages/usageStatsDetail/usageStatsDetail?${params}` })
},
async exportFile() {
await api.exportUsageStats({
startDate: this.data.startDate,
endDate: this.data.endDate,
attendancePointName: this.data.attendancePointName,
})
},
onPageInput(e) {
this.setData({ pageInput: e.detail.value })
},
jumpToPage() {
const next = Math.min(this.data.totalPages, Math.max(1, Number(this.data.pageInput || 1)))
this.load(next)
},
prevPage() {
if (this.data.page > 1) {
this.load(this.data.page - 1)
}
},
nextPage() {
if (this.data.page < this.data.totalPages) {
this.load(this.data.page + 1)
}
},
})
```
- [ ] **Step 5: Create summary WXML**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxml`:
```xml
<scroll-view class="page safe-bottom" scroll-y type="list" refresher-enabled refresher-triggered="{{refreshing}}" bindrefresherrefresh="onPullDownRefresh">
<view class="header">
<text class="title">设备模具使用统计</text>
<text class="subtitle">默认统计审核通过且未作废的报工数据</text>
</view>
<view class="filter-bar date-filter-bar">
<picker mode="date" value="{{startDate}}" bindchange="onStartChange">
<view class="picker">{{startDate || '开始日期'}}</view>
</picker>
<picker mode="date" value="{{endDate}}" bindchange="onEndChange">
<view class="picker">{{endDate || '结束日期'}}</view>
</picker>
</view>
<picker range="{{attendancePointLabels}}" value="{{attendancePointIndex}}" bindchange="onAttendancePointChange">
<view class="picker usage-point-picker">{{attendancePointLabels[attendancePointIndex] || '全部考勤点'}}</view>
</picker>
<view class="usage-switch-row">
<button class="usage-switch {{category === 'device' ? 'active' : ''}}" data-category="device" bindtap="chooseCategory">设备</button>
<button class="usage-switch {{category === 'mold' ? 'active' : ''}}" data-category="mold" bindtap="chooseCategory">模具</button>
</view>
<view class="usage-switch-row sort-row">
<button class="usage-switch {{sortBy === 'value' ? 'active' : ''}}" data-sort="value" bindtap="chooseSort">按时长/数量</button>
<button class="usage-switch {{sortBy === 'count' ? 'active' : ''}}" data-sort="count" bindtap="chooseSort">按报工次数</button>
</view>
<button class="btn primary full export-btn" bindtap="exportFile">导出统计</button>
<view wx:if="{{!rows.length}}" class="list-empty">暂无统计数据</view>
<view wx:for="{{rows}}" wx:key="id" wx:for-index="index" class="card usage-card" data-index="{{index}}" bindtap="openDetail">
<view class="row">
<view class="row-left">
<text class="value">{{item.name}}</text>
<text class="subtitle">考勤点 {{item.attendancePointName || '-'}}</text>
<text class="subtitle">{{item.objectType}} · 报工 {{item.reportCount}} 次</text>
</view>
<text class="pill primary">{{item.metricKind === 'quantity' ? '数量' : '时长'}}</text>
</view>
<view class="usage-bar-track">
<view class="usage-bar-fill {{item.metricKind === 'quantity' ? 'quantity' : 'minutes'}}" style="width: {{item.barPercent}}%;"></view>
</view>
<view class="row usage-value-row">
<text class="label">{{item.metricKind === 'quantity' ? '清洗数量' : '使用时长'}}</text>
<text class="value">{{item.valueText}}</text>
</view>
</view>
<view class="button-row pager-row" wx:if="{{rows.length}}">
<button class="btn secondary pager-nav" bindtap="prevPage" disabled="{{page <= 1}}">上一页</button>
<view class="page-jump-control">
<input class="page-jump-input" type="number" confirm-type="done" value="{{pageInput}}" bindinput="onPageInput" bindconfirm="jumpToPage" />
<text class="page-total-label">/ {{totalPages}}</text>
<text class="page-jump-action" bindtap="jumpToPage"></text>
</view>
<button class="btn secondary pager-nav" bindtap="nextPage" disabled="{{page >= totalPages}}">下一页</button>
</view>
</scroll-view>
```
- [ ] **Step 6: Create summary WXSS**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.wxss`:
```css
.usage-point-picker {
margin-bottom: 18rpx;
}
.usage-switch-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12rpx;
margin-bottom: 16rpx;
padding: 6rpx;
border-radius: 16rpx;
background: #e8eef7;
}
.usage-switch {
height: 66rpx;
line-height: 66rpx;
margin: 0;
padding: 0;
border-radius: 14rpx;
color: #475467;
background: transparent;
font-size: 25rpx;
font-weight: 800;
}
.usage-switch::after {
border: 0;
}
.usage-switch.active {
color: #ffffff;
background: linear-gradient(180deg, #2876ff 0%, #1456c2 100%);
box-shadow: 0 7rpx 0 #0d3f93;
}
.sort-row {
margin-bottom: 18rpx;
}
.export-btn {
margin-bottom: 18rpx;
}
.usage-card {
position: relative;
overflow: hidden;
}
.usage-bar-track {
overflow: hidden;
height: 22rpx;
margin-top: 18rpx;
border-radius: 999rpx;
background: #dbe4f0;
box-shadow: inset 0 2rpx 5rpx rgba(22, 34, 50, 0.12);
}
.usage-bar-fill {
height: 100%;
min-width: 10rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #1463ff 0%, #12a375 100%);
}
.usage-bar-fill.quantity {
background: linear-gradient(90deg, #12a375 0%, #56c596 100%);
}
.usage-value-row {
margin-top: 14rpx;
}
```
- [ ] **Step 7: Run frontend JS syntax check**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS
node --check pages/usageStats/usageStats.js
```
Expected: exit code `0`.
---
### Task 6: Frontend Detail Page
**Files:**
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxml`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxss`
- Create: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json`
- [ ] **Step 1: Register detail page**
In `/Users/souplearn/Gitlab/app/JhHardwareWRS/app.json`, add this page after `pages/usageStats/usageStats`:
```json
"pages/usageStatsDetail/usageStatsDetail",
```
- [ ] **Step 2: Create detail page JSON**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json`:
```json
{
"navigationBarTitleText": "统计详情"
}
```
- [ ] **Step 3: Create detail page JS**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js`:
```javascript
const api = require('../../utils/api')
const decode = value => decodeURIComponent(String(value || ''))
Page({
data: {
filters: {},
detail: null,
maxDailyValue: 1,
},
onLoad(options) {
const filters = {
category: decode(options.category || 'device'),
startDate: decode(options.startDate || ''),
endDate: decode(options.endDate || ''),
attendancePointName: decode(options.attendancePointName || ''),
name: decode(options.name || ''),
productName: decode(options.productName || ''),
processName: decode(options.processName || ''),
stampingMethod: decode(options.stampingMethod || ''),
}
this.setData({ filters })
this.load()
},
async load() {
try {
const detail = await api.getUsageStatsDetail(this.data.filters)
const maxDailyValue = Math.max(1, ...(detail.dailyRows || []).map(item => Number(item.value || 0)))
this.setData({
detail: {
...detail,
valueText: detail.metricKind === 'quantity' ? `${detail.value} 件` : `${detail.value} 分钟`,
dailyRows: (detail.dailyRows || []).map(item => ({
...item,
barPercent: Math.max(4, Math.round(Number(item.value || 0) / maxDailyValue * 100)),
valueText: detail.metricKind === 'quantity' ? `${item.value} 件` : `${item.value} 分钟`,
})),
reportRows: (detail.reportRows || []).map(item => ({
...item,
valueText: detail.metricKind === 'quantity' ? `${item.value} 件` : `${item.value} 分钟`,
})),
},
maxDailyValue,
})
} catch (error) {
wx.showToast({ title: error.message || '加载统计详情失败', icon: 'none' })
}
},
})
```
- [ ] **Step 4: Create detail WXML**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxml`:
```xml
<scroll-view class="page safe-bottom" scroll-y type="list">
<view class="header">
<text class="title">{{detail.name || '统计详情'}}</text>
<text class="subtitle">考勤点 {{detail.attendancePointName || '-'}}</text>
</view>
<view wx:if="{{detail}}" class="card">
<view class="row">
<view>
<text class="label">{{detail.metricKind === 'quantity' ? '清洗数量' : '使用时长'}}</text>
<text class="value">{{detail.valueText}}</text>
</view>
<view class="text-right">
<text class="label">报工次数</text>
<text class="value">{{detail.reportCount}}</text>
</view>
</view>
<view class="divider"></view>
<text class="subtitle">{{detail.objectType}}</text>
</view>
<view wx:if="{{detail}}" class="card">
<text class="section-title">每日趋势</text>
<view wx:if="{{!detail.dailyRows.length}}" class="list-empty">暂无每日数据</view>
<view wx:for="{{detail.dailyRows}}" wx:key="reportDate" class="daily-row">
<view class="row">
<text class="label">{{item.reportDate}}</text>
<text class="value">{{item.valueText}} · {{item.reportCount}} 次</text>
</view>
<view class="detail-bar-track">
<view class="detail-bar-fill {{detail.metricKind === 'quantity' ? 'quantity' : 'minutes'}}" style="width: {{item.barPercent}}%;"></view>
</view>
</view>
</view>
<view wx:if="{{detail}}" class="card">
<text class="section-title">关联报工</text>
<view wx:if="{{!detail.reportRows.length}}" class="list-empty">暂无关联报工</view>
<view wx:for="{{detail.reportRows}}" wx:key="reportId" class="report-row">
<text class="value">{{item.employeeName || item.employeePhone}} · {{item.reportDate}}</text>
<text class="subtitle">{{item.displayName}}</text>
<text class="subtitle">{{item.valueText}} · {{item.reportCount}} 次</text>
</view>
</view>
</scroll-view>
```
- [ ] **Step 5: Create detail WXSS**
Create `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.wxss`:
```css
.section-title {
display: block;
margin-bottom: 18rpx;
color: #162232;
font-size: 28rpx;
font-weight: 900;
}
.daily-row {
padding: 16rpx 0;
border-bottom: 1rpx solid #e4ebf5;
}
.daily-row:last-child {
border-bottom: 0;
}
.detail-bar-track {
overflow: hidden;
height: 20rpx;
margin-top: 12rpx;
border-radius: 999rpx;
background: #dbe4f0;
}
.detail-bar-fill {
height: 100%;
min-width: 10rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #1463ff 0%, #12a375 100%);
}
.detail-bar-fill.quantity {
background: linear-gradient(90deg, #12a375 0%, #56c596 100%);
}
.report-row {
padding: 18rpx 0;
border-bottom: 1rpx solid #e4ebf5;
}
.report-row:last-child {
border-bottom: 0;
}
```
- [ ] **Step 6: Run detail page JS syntax check**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS
node --check pages/usageStatsDetail/usageStatsDetail.js
```
Expected: exit code `0`.
---
### Task 7: Verification
**Files:**
- Verify all files changed above.
- [ ] **Step 1: Run backend tests**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py tests/test_product_delete.py tests/test_continuous_die.py tests/test_metrics.py tests/test_review_corrections.py -q
```
Expected:
```text
all selected tests pass
```
- [ ] **Step 2: Run Python compile checks**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m compileall app/services/usage_stats.py app/services/usage_stats_export.py app/routers/usage_stats.py app/schemas.py app/main.py
```
Expected: exit code `0`.
- [ ] **Step 3: Run frontend JS checks**
Run:
```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS
node --check utils/api.js
node --check pages/index/index.js
node --check pages/usageStats/usageStats.js
node --check pages/usageStatsDetail/usageStatsDetail.js
```
Expected: every command exits `0`.
- [ ] **Step 4: Manual mini-program verification**
In WeChat DevTools:
```text
1. Log in as admin.
2. Open 工作台 -> 设备模具使用统计.
3. Confirm default date range is this month.
4. Confirm admin only sees accessible attendance-point data.
5. Switch 设备 / 模具.
6. Switch 按时长/数量 / 按报工次数.
7. Click one row and confirm detail page opens.
8. Confirm detail daily bars and report summaries show.
9. Click 导出统计 and confirm Excel downloads.
10. Log in as manager and confirm all attendance points are available.
```
- [ ] **Step 5: Manual data spot check for cleaning split**
Use one approved cleaning report with a multi-device item:
```text
device_no = 清洗机A、清洗机B
good_qty = 100
```
Expected equipment summary:
```text
清洗机A 清洗数量 +50 报工次数 +1
清洗机B 清洗数量 +50 报工次数 +1
```
Expected mold summary:
```text
对应清洗模具 清洗数量 +100 报工次数 +1
```
If no multi-device cleaning report exists, create one in test data first; do not change production history solely for this check.
---
## Self-Review
Spec coverage:
- Admin/manager workbench entry: Task 5.
- Default approved + not voided: Task 2 `_approved_reports_query`.
- Date filter, attendance-point filter, category switch, sort switch: Task 5.
- Equipment statistics for press and cleaning equipment: Task 1 and Task 2.
- Cleaning multi-device even split: Task 1 and Task 7.
- Mold statistics by attendance point + product + process + stamping method: Task 1 and Task 2.
- Misc work excluded: Task 1.
- Detail drill-down with daily bars and report summaries: Task 2 and Task 6.
- Excel export with two sheets: Task 3 and Task 5.
- No database migration: preserved by architecture and file map.
Placeholder scan:
- No `TBD`, `TODO`, or open-ended implementation steps remain.
- Every code-producing task names exact files and provides concrete code.
Type consistency:
- Backend category values: `device`, `mold`.
- Backend metric values: `minutes`, `quantity`.
- Frontend fields: `attendancePointName`, `objectType`, `metricKind`, `reportCount`.
- API params use snake_case only at HTTP boundary.