diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index d723133..32d8b0d 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -12,6 +12,7 @@ from app.models import ( Personnel, Product, ProductionReport, + ProductionReportAllocation, ProductionReportItem, ReportStatus, Role, @@ -22,12 +23,11 @@ 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.metrics import 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"]) @@ -123,141 +123,194 @@ def _dashboard_rows( include_voided: bool = False, ) -> list[DashboardRow]: query = ( - select(ProductionReport) + select(ProductionReportAllocation) + .join(ProductionReportAllocation.report) + .join(ProductionReportAllocation.item) .options( - selectinload(ProductionReport.employee), - selectinload(ProductionReport.items), - selectinload(ProductionReport.audit_logs), - selectinload(ProductionReport.session).selectinload(WorkSession.devices), + selectinload(ProductionReportAllocation.report).selectinload(ProductionReport.employee), + selectinload(ProductionReportAllocation.report).selectinload(ProductionReport.items), + selectinload(ProductionReportAllocation.report).selectinload( + ProductionReport.audit_logs + ), + selectinload(ProductionReportAllocation.report) + .selectinload(ProductionReport.session) + .selectinload(WorkSession.devices), + selectinload(ProductionReportAllocation.item), ) .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) + query = query.where(ProductionReportAllocation.allocation_date >= start_date) if end_date: - query = query.where(ProductionReport.report_date <= end_date) - reports = db.scalars(query).all() + query = query.where(ProductionReportAllocation.allocation_date <= end_date) + allocations = 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 + for allocation in allocations: + item = allocation.item + if item is not None: + setattr(item, "_dashboard_product", _product_for_item(db, product_cache, item)) + return _dashboard_rows_from_allocations(allocations) + + +def _dashboard_rows_from_allocations(allocations: list) -> list[DashboardRow]: + groups: dict[tuple, dict] = {} + correction_cache: dict[int, tuple[dict, dict[int, dict], dict[int, dict]]] = {} + for allocation in allocations: + report = getattr(allocation, "report", None) + item = getattr(allocation, "item", None) + if report is None or item is None: + continue + report_id = getattr(report, "id", None) + report_key = report_id if report_id is not None else id(report) + if report_key not in correction_cache: + correction_cache[report_key] = report_correction_maps(report) + report_corrections, item_corrections, _device_corrections = correction_cache[report_key] 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) + worker_type = ( + "临时工" if (report.employee and report.employee.is_temporary) else "正式工" + ) + item_misc = is_misc_item(item) + process_name = ( + (item.remark or item.process_name or "") if item_misc else (item.process_name or "") + ) + product = getattr(item, "_dashboard_product", None) + 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) + item_standard_beat = getattr(item, "standard_beat", 0) + 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(getattr(item, "process_unit_price_yuan", 0)) + stamping_method = getattr(item, "stamping_method", None) or ( + product.stamping_method if product else None + ) + allocation_date = getattr(allocation, "allocation_date") + attendance_point_name = getattr( + report, "attendance_point_name", getattr(allocation, "attendance_point_name", "") + ) + employee_phone = getattr( + report, "employee_phone", getattr(allocation, "employee_phone", "") + ) + key = ( + attendance_point_name, + allocation_date, + employee_name, + 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": attendance_point_name, + "report_date": allocation_date, + "employee_phone": 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": ( + getattr(product, "profile_no", getattr(item, "profile_no", None)) + if product + else getattr(item, "profile_no", None) + ), + "material_code": ( + getattr(product, "material_code", getattr(item, "material_code", None)) + if product + else getattr(item, "material_code", None) + ), + "material_name": ( + getattr(product, "material_name", getattr(item, "material_name", None)) + if product + else getattr(item, "material_name", None) + ), + "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(getattr(item, "operator_count", 1)) + ), + "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(getattr(item, "id", None), {}), + ) + good_qty = as_float(getattr(allocation, "good_qty", 0)) + defect_qty = as_float(getattr(allocation, "defect_qty", 0)) + as_float( + getattr(allocation, "scrap_qty", 0) + ) + output_qty = good_qty + defect_qty + effective_minutes = as_float(getattr(allocation, "effective_minutes", 0)) + expected_workload = effective_minutes * 60 / standard_beat if standard_beat > 0 else 0 + group["report_ids"].add(report_key) + group["source_report_dates"].add(report.report_date) + group["effective_minutes"] += effective_minutes + group["expected_workload"] += expected_workload + group["shift_day_minutes"] += as_float(getattr(allocation, "day_minutes", 0)) + group["shift_overtime_minutes"] += as_float(getattr(allocation, "overtime_minutes", 0)) + group["shift_night_minutes"] += as_float(getattr(allocation, "night_minutes", 0)) + group["total_good_qty"] += good_qty + group["reference_wage"] += as_float(getattr(allocation, "reference_wage", 0)) + group["changeover_count"] += as_float(getattr(allocation, "changeover_count", 0)) + 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) + raw_material_batch_no = getattr(item, "raw_material_batch_no", None) + if raw_material_batch_no: + group["raw_material_batch_numbers"].add(raw_material_batch_no) rows: list[DashboardRow] = [] for group in groups.values(): diff --git a/app/services/excel_export.py b/app/services/excel_export.py index 51f55dc..6ea21ae 100644 --- a/app/services/excel_export.py +++ b/app/services/excel_export.py @@ -108,7 +108,8 @@ def export_dashboard_rows(rows: list) -> bytes: void_fill = PatternFill(fill_type="solid", fgColor="FFD9D9D9") sheet.append([ "考勤点", - "报工日期", + "统计归属日期", + "原始报工日期", "员工姓名", "员工电话", "工种", @@ -138,6 +139,13 @@ def export_dashboard_rows(rows: list) -> bytes: ]) wage_summary: dict[tuple[str, str, str], dict[str, float | str]] = {} for row_index, row in enumerate(rows, start=2): + source_dates = getattr(row, "source_report_dates", []) or [ + getattr(row, "source_report_date", None) or row.report_date + ] + source_dates = sorted({item for item in source_dates if item}) + source_date_text = "、".join( + item.isoformat() for item in source_dates + ) report_flags = [] if getattr(row, "is_voided", False): report_flags.append("作废") @@ -152,6 +160,7 @@ def export_dashboard_rows(rows: list) -> bytes: sheet.append([ row.attendance_point_name, row.report_date.isoformat(), + source_date_text, row.employee_name, row.employee_phone, row.worker_type, @@ -199,9 +208,11 @@ def export_dashboard_rows(rows: list) -> bytes: total_good_qty = as_float(row.total_good_qty) expected_workload = as_float(row.expected_workload) if standard_beat > 0 and (actual_beat > standard_beat or actual_beat < standard_beat * 0.7): - sheet.cell(row=row_index, column=14).fill = warn_fill - if expected_workload > 0 and (total_good_qty < expected_workload or total_good_qty > expected_workload * 1.3): - sheet.cell(row=row_index, column=11).fill = warn_fill + sheet.cell(row=row_index, column=15).fill = warn_fill + if expected_workload > 0 and ( + total_good_qty < expected_workload or total_good_qty > expected_workload * 1.3 + ): + sheet.cell(row=row_index, column=12).fill = warn_fill if getattr(row, "is_voided", False): for cell in sheet[row_index]: cell.fill = void_fill diff --git a/tests/test_dashboard_allocations.py b/tests/test_dashboard_allocations.py new file mode 100644 index 0000000..6d22f2c --- /dev/null +++ b/tests/test_dashboard_allocations.py @@ -0,0 +1,158 @@ +from datetime import date +from io import BytesIO +from types import SimpleNamespace + +from openpyxl import load_workbook + +from app.routers import dashboard +from app.services.excel_export import export_dashboard_rows + + +def test_dashboard_rows_from_allocations_groups_by_allocation_date_desc(): + report_date = date(2026, 7, 25) + report = SimpleNamespace( + id=1, + attendance_point_name="总厂", + report_date=report_date, + employee_phone="13800000000", + employee=SimpleNamespace(name="张三", is_temporary=False), + items=[], + audit_logs=[], + session=None, + is_system_auto_submitted=False, + is_voided=False, + is_multi_person_assistant=False, + review_remark="复核通过", + ) + item = SimpleNamespace( + id=10, + attendance_point_name="总厂", + device_no="A-01", + project_no="P1", + product_name="23#", + process_name="冲压", + remark=None, + raw_material_batch_no="BATCH-1", + material_code="M-1", + material_name="铝材", + profile_no="PF-1", + stamping_method="单冲", + operator_count=1, + process_unit_price_yuan=2, + standard_beat=1, + good_qty=650, + defect_qty=0, + scrap_qty=0, + changeover_count=2, + allocated_minutes=650, + ) + report.items = [item] + allocations = [ + SimpleNamespace( + report=report, + item=item, + allocation_date=date(2026, 7, 24), + night_minutes=350, + overtime_minutes=0, + day_minutes=0, + effective_minutes=350, + good_qty=350, + defect_qty=0, + scrap_qty=0, + changeover_count=1, + reference_wage=350, + ), + SimpleNamespace( + report=report, + item=item, + allocation_date=date(2026, 7, 25), + night_minutes=0, + overtime_minutes=60, + day_minutes=240, + effective_minutes=300, + good_qty=300, + defect_qty=0, + scrap_qty=0, + changeover_count=1, + reference_wage=300, + ), + ] + + rows = dashboard._dashboard_rows_from_allocations(allocations) + + assert [row.report_date for row in rows] == [date(2026, 7, 25), date(2026, 7, 24)] + assert [row.source_report_dates for row in rows] == [[report_date], [report_date]] + assert [row.source_report_date for row in rows] == [report_date, report_date] + assert [row.report_count for row in rows] == [1, 1] + + current, previous = rows + assert current.effective_minutes == 300 + assert current.shift_day_minutes == 240 + assert current.shift_overtime_minutes == 60 + assert current.shift_night_minutes == 0 + assert current.total_good_qty == 300 + assert current.total_defect_qty == 0 + assert current.total_output_qty == 300 + assert current.changeover_count == 1 + assert current.reference_wage == 300 + + assert previous.effective_minutes == 350 + assert previous.shift_day_minutes == 0 + assert previous.shift_overtime_minutes == 0 + assert previous.shift_night_minutes == 350 + assert previous.total_good_qty == 350 + assert previous.total_defect_qty == 0 + assert previous.total_output_qty == 350 + assert previous.changeover_count == 1 + assert previous.reference_wage == 350 + + +def test_export_dashboard_rows_writes_allocation_and_source_dates_with_warning_columns(): + row = SimpleNamespace( + attendance_point_name="总厂", + report_date=date(2026, 7, 24), + source_report_dates=[date(2026, 7, 25), date(2026, 7, 23)], + source_report_date=None, + employee_name="张三", + employee_phone="13800000000", + worker_type="正式工", + project_no="P1", + product_name="23#", + process_name="冲压", + effective_minutes=60, + shift_distribution_text="白班1小时", + total_good_qty=50, + total_defect_qty=2, + expected_workload=100, + actual_beat=20, + standard_beat=10, + profile_no="PF-1", + material_code="M-1", + material_name="铝材", + raw_material_batch_no="BATCH-1", + device_no="A-01", + stamping_method="单冲", + changeover_count=1, + operator_count=1, + process_unit_price_yuan=2, + reference_wage=100, + is_voided=False, + is_multi_person=False, + is_continuous_die=False, + correction_pairs=[], + review_remark="复核通过", + ) + + workbook = load_workbook(BytesIO(export_dashboard_rows([row]))) + sheet = workbook["报工看板"] + + assert [sheet.cell(row=1, column=index).value for index in range(1, 5)] == [ + "考勤点", + "统计归属日期", + "原始报工日期", + "员工姓名", + ] + assert sheet.cell(row=2, column=2).value == "2026-07-24" + assert sheet.cell(row=2, column=3).value == "2026-07-23、2026-07-25" + assert sheet.cell(row=2, column=12).fill.fgColor.rgb == "FFFF6666" + assert sheet.cell(row=2, column=15).fill.fgColor.rgb == "FFFF6666" diff --git a/tests/test_dashboard_source_dates.py b/tests/test_dashboard_source_dates.py index 8be861f..2c65232 100644 --- a/tests/test_dashboard_source_dates.py +++ b/tests/test_dashboard_source_dates.py @@ -10,6 +10,7 @@ from app.models import ( Personnel, Product, ProductionReport, + ProductionReportAllocation, ProductionReportItem, ReportStatus, SessionStatus, @@ -92,7 +93,24 @@ def test_dashboard_rows_populate_source_report_dates_from_original_reports(): scrap_qty=0, allocated_minutes=60, ) - db.add_all([session, report, item]) + allocation = ProductionReportAllocation( + id=index, + report_id=report.id, + report_item_id=item.id, + attendance_point_name=point_name, + employee_phone=person.phone, + allocation_date=report_date, + day_minutes=60, + overtime_minutes=0, + night_minutes=0, + effective_minutes=60, + good_qty=10, + defect_qty=0, + scrap_qty=0, + changeover_count=0, + reference_wage=20, + ) + db.add_all([session, report, item, allocation]) db.commit() rows = _dashboard_rows(db, report_date, report_date)