1690 lines
54 KiB
Markdown
1690 lines
54 KiB
Markdown
# 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.
|