383 lines
16 KiB
Python
383 lines
16 KiB
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 (
|
||
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],
|
||
)
|