JhHardwareWRS_BackPoint/app/routers/dashboard.py
2026-06-24 15:19:14 +08:00

383 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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 (
Personnel,
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
Role,
WorkSession,
)
from app.schemas import DashboardRow, PageResponse
from app.services.cleaning import is_cleaning_item, is_cleaning_product
from app.services.common import as_float, round1, round2
from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product
from app.services.excel_export import export_dashboard_rows
from app.services.metrics import calculate_report_metrics, shift_distribution_text
from app.services.misc_work import is_misc_item, is_misc_product
from app.services.multi_person import is_multi_person_item
from app.services.report_lifecycle import purge_expired_voided_reports
from app.services.serializers import report_correction_maps
from app.services.work_schedule import get_work_schedule_config
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _product_for_item(
db: Session,
cache: dict[tuple[str, str, str, str, str], Product | None],
item: ProductionReportItem,
) -> Product | None:
key = (
item.attendance_point_name,
item.project_no,
item.product_name,
item.process_name or "",
"",
)
if key not in cache:
with db.no_autoflush:
cache[key] = db.scalar(
select(Product).where(
Product.project_no == item.project_no,
Product.attendance_point_name == item.attendance_point_name,
Product.product_name == item.product_name,
Product.process_name == (item.process_name or ""),
)
)
return cache[key]
REPORT_CORRECTION_LABELS = {
"start_at": "上班时间",
"end_at": "下班时间",
"review_remark": "备注",
}
ITEM_CORRECTION_LABELS = {
"device_no": "设备号",
"project_no": "项目号",
"product_name": "产品名称",
"raw_material_batch_no": "材料库存批次号",
"process_name": "工序",
"stamping_method": "冲压方式",
"process_unit_price_yuan": "工序单价",
"changeover_count": "换料次数",
"remark": "杂活事项",
"good_qty": "成品数量",
"defect_qty": "不良数量",
"scrap_qty": "报废数量",
}
def _correction_value(value) -> str | float:
if value is None:
return "-"
if hasattr(value, "isoformat"):
return value.isoformat()
return value
def _append_correction_pair(group: dict, key: tuple, label: str, old_value, new_value) -> None:
if key in group["correction_pair_keys"]:
return
group["correction_pair_keys"].add(key)
group["correction_pairs"].append(
{
"key": "__".join(str(part) for part in key),
"label": label,
"old_value": _correction_value(old_value),
"new_value": _correction_value(new_value),
}
)
def _append_dashboard_corrections(
group: dict,
report: ProductionReport,
item: ProductionReportItem,
report_corrections: dict,
item_corrections: dict,
) -> None:
for field, old_value in report_corrections.items():
label = REPORT_CORRECTION_LABELS.get(field, field)
_append_correction_pair(group, (report.id, field), label, old_value, getattr(report, field))
for field, old_value in item_corrections.items():
label = ITEM_CORRECTION_LABELS.get(field, field)
_append_correction_pair(group, (report.id, item.id, field), label, old_value, getattr(item, field))
def _dashboard_rows(
db: Session,
start_date: date | None = None,
end_date: date | None = None,
include_voided: bool = False,
) -> list[DashboardRow]:
query = (
select(ProductionReport)
.options(
selectinload(ProductionReport.employee),
selectinload(ProductionReport.items),
selectinload(ProductionReport.audit_logs),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
)
.where(ProductionReport.status == ReportStatus.approved)
)
if not include_voided:
query = query.where(ProductionReport.is_voided.is_(False))
if start_date:
query = query.where(ProductionReport.report_date >= start_date)
if end_date:
query = query.where(ProductionReport.report_date <= end_date)
reports = db.scalars(query).all()
groups: dict[tuple, dict] = {}
product_cache: dict[tuple[str, str, str, str, str], Product | None] = {}
schedule_cache = {}
for report in reports:
if report.attendance_point_name not in schedule_cache:
schedule_cache[report.attendance_point_name] = get_work_schedule_config(db, report.attendance_point_name)
schedule = schedule_cache[report.attendance_point_name]
report_corrections, item_corrections, _device_corrections = report_correction_maps(report)
report_metrics = calculate_report_metrics(
report.start_at,
report.end_at,
list(report.items),
devices=report.session.devices if report.session else None,
schedule=schedule,
)
item_count = max(1, len(report.items))
fallback_minutes = as_float(report.effective_minutes) / item_count
employee_name = report.employee.name if report.employee else ""
worker_type = "临时工" if (report.employee and report.employee.is_temporary) else "正式工"
for item in report.items:
item_misc = is_misc_item(item)
process_name = (item.remark or item.process_name or "") if item_misc else (item.process_name or "")
product = _product_for_item(db, product_cache, item)
is_cleaning = is_cleaning_item(item) or is_cleaning_product(product)
is_misc = item_misc or is_misc_product(product)
is_continuous_die = is_continuous_die_item(item) or is_continuous_die_product(product)
standard_beat = 0 if (is_cleaning or is_misc) else as_float(product.standard_beat if product else item.standard_beat)
process_unit_price = as_float(item.process_unit_price_yuan)
stamping_method = item.stamping_method or (product.stamping_method if product else None)
key = (
report.attendance_point_name,
report.report_date,
employee_name,
report.employee_phone,
item.project_no,
item.product_name,
process_name,
stamping_method or "",
)
group = groups.setdefault(
key,
{
"id": "__".join(str(part) for part in key),
"attendance_point_name": report.attendance_point_name,
"report_date": report.report_date,
"employee_phone": report.employee_phone,
"employee_name": employee_name,
"worker_type": worker_type,
"project_no": item.project_no,
"product_name": item.product_name,
"process_name": process_name,
"report_ids": set(),
"effective_minutes": 0.0,
"shift_day_minutes": 0.0,
"shift_overtime_minutes": 0.0,
"shift_night_minutes": 0.0,
"total_good_qty": 0.0,
"total_defect_qty": 0.0,
"total_output_qty": 0.0,
"standard_beat": standard_beat,
"process_unit_price_yuan": process_unit_price,
"changeover_count": 0.0,
"reference_wage": 0.0,
"expected_workload": 0.0,
"profile_no": product.profile_no if product else None,
"material_code": product.material_code if product else item.material_code,
"material_name": product.material_name if product else item.material_name,
"raw_material_batch_numbers": set(),
"device_no": item.device_no,
"stamping_method": stamping_method,
"operator_count": as_float(product.operator_count) if product else as_float(item.operator_count),
"is_continuous_die": is_continuous_die,
"is_multi_person": is_multi_person_item(item),
"is_multi_person_assistant": False,
"is_misc": is_misc,
"is_system_auto_submitted": False,
"is_voided": False,
"review_remarks": [],
"review_remark_keys": set(),
"correction_pairs": [],
"correction_pair_keys": set(),
},
)
_append_dashboard_corrections(group, report, item, report_corrections, item_corrections.get(item.id, {}))
good_qty = as_float(item.good_qty)
defect_qty = as_float(item.defect_qty) + as_float(item.scrap_qty)
output_qty = good_qty + defect_qty
allocated_minutes = 0 if is_cleaning else (as_float(item.allocated_minutes) or fallback_minutes)
expected_workload = allocated_minutes * 60 / standard_beat if standard_beat > 0 else 0
group["report_ids"].add(report.id)
group["effective_minutes"] += allocated_minutes
group["expected_workload"] += expected_workload
group["shift_day_minutes"] += as_float(getattr(item, "_shift_day_minutes", 0))
group["shift_overtime_minutes"] += as_float(getattr(item, "_shift_overtime_minutes", 0))
group["shift_night_minutes"] += as_float(getattr(item, "_shift_night_minutes", 0))
group["total_good_qty"] += good_qty
group["reference_wage"] += good_qty * process_unit_price
group["changeover_count"] += as_float(item.changeover_count)
group["total_defect_qty"] += defect_qty
group["total_output_qty"] += output_qty
group["is_system_auto_submitted"] = (
group["is_system_auto_submitted"] or bool(report.is_system_auto_submitted)
)
group["is_voided"] = group["is_voided"] or bool(report.is_voided)
group["is_multi_person"] = group["is_multi_person"] or is_multi_person_item(item)
group["is_continuous_die"] = group["is_continuous_die"] or is_continuous_die
group["is_multi_person_assistant"] = (
group["is_multi_person_assistant"] or bool(report.is_multi_person_assistant)
)
if report.review_remark:
review_remark = str(report.review_remark).strip()
if review_remark and review_remark not in group["review_remark_keys"]:
group["review_remark_keys"].add(review_remark)
group["review_remarks"].append(review_remark)
if item.raw_material_batch_no:
group["raw_material_batch_numbers"].add(item.raw_material_batch_no)
rows: list[DashboardRow] = []
for group in groups.values():
actual_beat = (
group["effective_minutes"] * 60 / group["total_output_qty"]
if group["total_output_qty"] > 0
else 0
)
standard_beat = group["standard_beat"]
expected_workload = group["expected_workload"]
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
workload_rate = (
(group["total_good_qty"] - expected_workload) / expected_workload * 100
if expected_workload > 0
else 0
)
shift_minutes = {
"day": group["shift_day_minutes"],
"overtime": group["shift_overtime_minutes"],
"night": group["shift_night_minutes"],
}
rows.append(
DashboardRow(
id=group["id"],
attendance_point_name=group["attendance_point_name"],
report_date=group["report_date"],
employee_phone=group["employee_phone"],
employee_name=group["employee_name"],
worker_type=group["worker_type"],
project_no=group["project_no"],
product_name=group["product_name"],
process_name=group["process_name"],
report_count=len(group["report_ids"]),
effective_minutes=round2(group["effective_minutes"]),
shift_distribution_text=shift_distribution_text(shift_minutes),
shift_day_minutes=round2(group["shift_day_minutes"]),
shift_overtime_minutes=round2(group["shift_overtime_minutes"]),
shift_night_minutes=round2(group["shift_night_minutes"]),
shift_other_minutes=0,
total_good_qty=round2(group["total_good_qty"]),
total_defect_qty=round2(group["total_defect_qty"]),
total_output_qty=round2(group["total_output_qty"]),
actual_beat=round2(actual_beat),
standard_beat=round2(standard_beat),
process_unit_price_yuan=round(as_float(group["process_unit_price_yuan"]), 4),
changeover_count=round2(group["changeover_count"]),
reference_wage=round2(group["reference_wage"]),
expected_workload=round1(expected_workload),
pace_rate=round1(pace_rate),
workload_rate=round1(workload_rate),
pace_text=f"{'' if pace_rate > 0 else ''}{abs(round1(pace_rate))}%",
workload_text=f"{'' if workload_rate >= 0 else ''}{abs(round1(workload_rate))}%",
profile_no=group["profile_no"],
material_code=group["material_code"],
material_name=group["material_name"],
raw_material_batch_no="".join(sorted(group["raw_material_batch_numbers"])) or None,
device_no=group["device_no"],
stamping_method=group["stamping_method"],
operator_count=group["operator_count"],
is_continuous_die=group["is_continuous_die"],
is_multi_person=group["is_multi_person"],
is_multi_person_assistant=group["is_multi_person_assistant"],
is_misc=group["is_misc"],
is_system_auto_submitted=group["is_system_auto_submitted"],
is_voided=group["is_voided"],
review_remark="".join(group["review_remarks"]) or None,
correction_pairs=group["correction_pairs"],
)
)
rows.sort(
key=lambda item: (
item.report_date,
item.attendance_point_name,
item.employee_phone,
item.project_no,
item.product_name,
item.process_name or "",
),
reverse=True,
)
return rows
@router.get("/reports/export")
def export_dashboard_report_excel(
start_date: date | None = None,
end_date: date | None = None,
include_voided: bool = False,
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> Response:
purge_expired_voided_reports(db)
content = export_dashboard_rows(_dashboard_rows(db, start_date, end_date, include_voided))
return Response(
content=content,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="dashboard.xlsx"'},
)
@router.get("/reports", response_model=PageResponse)
def dashboard_reports(
start_date: date | None = None,
end_date: date | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
include_voided: bool = False,
_: Personnel = Depends(require_roles(Role.manager)),
db: Session = Depends(get_db),
) -> PageResponse:
purge_expired_voided_reports(db)
rows = _dashboard_rows(db, start_date, end_date, include_voided)
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=rows[start : start + safe_page_size],
)