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

54 KiB
Raw Blame History

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:

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:

cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py -q

Expected:

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:

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:

cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py -q

Expected:

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:

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:

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:

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:

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:

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:

from app.routers import usage_stats

Add router registration next to other routers:

app.include_router(usage_stats.router)
  • Step 6: Run backend tests for usage stats

Run:

cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
.venv/bin/python -m pytest tests/test_usage_stats.py -q

Expected:

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:

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:

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:

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:

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:

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:

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:

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:

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:

listUsageStats,
getUsageStatsDetail,
exportUsageStats,
  • Step 4: Run JS syntax check

Run:

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:

"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:

{ key: 'usageStats', icon: '统', title: '设备模具使用统计', desc: '按时间统计设备和模具使用', url: '/pages/usageStats/usageStats' },
  • Step 3: Create page JSON

Create /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.json:

{
  "navigationBarTitleText": "使用统计"
}
  • Step 4: Create summary page JS

Create /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStats/usageStats.js:

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:

<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:

.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:

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:

"pages/usageStatsDetail/usageStatsDetail",
  • Step 2: Create detail page JSON

Create /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.json:

{
  "navigationBarTitleText": "统计详情"
}
  • Step 3: Create detail page JS

Create /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/usageStatsDetail/usageStatsDetail.js:

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:

<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:

.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:

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:

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:

all selected tests pass
  • Step 2: Run Python compile checks

Run:

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:

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:

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:

device_no = 清洗机A、清洗机B
good_qty = 100

Expected equipment summary:

清洗机A 清洗数量 +50 报工次数 +1
清洗机B 清洗数量 +50 报工次数 +1

Expected mold summary:

对应清洗模具 清洗数量 +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.