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(), "source_report_dates": 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["source_report_dates"].add(report.report_date) 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"], } source_report_dates = sorted(group["source_report_dates"]) rows.append( DashboardRow( id=group["id"], attendance_point_name=group["attendance_point_name"], report_date=group["report_date"], source_report_date=source_report_dates[-1] if source_report_dates else group["report_date"], source_report_dates=source_report_dates, 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], )