from datetime import datetime from sqlalchemy import select from sqlalchemy.orm import Session from app.models import ( AttendancePoint, AuditAction, Equipment, PersonRole, Personnel, Product, ProductionReport, ProductionReportItem, WorkSession, WorkSessionDevice, ) from app.schemas import ( AttendancePointOut, ClockSessionOut, EquipmentOption, EquipmentOut, PersonnelOut, ProductOption, ProductOut, ReportAllocationOut, ReportDeviceSegmentOut, ReportItemOut, ReportMetrics, ReportOut, ) from app.services.cleaning import is_cleaning_item, is_cleaning_product from app.services.common import REPORT_STATUS_NAMES, ROLE_NAMES, as_float from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product from app.services.display_names import mold_process_display_name from app.services.metrics import build_result_text, calculate_report_metrics from app.services.misc_work import is_misc_item, is_misc_product from app.services.multi_person import is_multi_person_item, is_multi_person_product from app.services.process_names import export_process_name from app.services.report_allocations import allocation_summary_text from app.services.report_lifecycle import can_edit_report, can_unvoid_report from app.services.work_schedule import WorkScheduleConfig from app.timezone import now def temporary_expired(person: Personnel) -> bool: return bool( person.is_temporary and (person.temporary_expires_at is None or person.temporary_expires_at <= now()) ) def attendance_point_out(point: AttendancePoint) -> AttendancePointOut: return AttendancePointOut( name=point.name, latitude=None if point.latitude is None else float(point.latitude), longitude=None if point.longitude is None else float(point.longitude), radius_meters=int(point.radius_meters or 500), day_start=point.day_start, day_end=point.day_end, lunch_start=point.lunch_start, lunch_end=point.lunch_end, dinner_start=point.dinner_start, dinner_end=point.dinner_end, overtime_start=point.overtime_start, overtime_end=point.overtime_end, night_start=point.night_start, night_end=point.night_end, remark=point.remark, is_active=bool(point.is_active), created_at=point.created_at, updated_at=point.updated_at, ) def personnel_out(person: Personnel, role=None) -> PersonnelOut: selected_role = role if role is not None else getattr(person, "role", None) roles = sorted([item.role for item in person.roles], key=lambda item: str(item)) points = sorted( [item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None], key=lambda item: item.name, ) return PersonnelOut( phone=person.phone, name=person.name, role=selected_role, role_name=ROLE_NAMES.get(str(selected_role), str(selected_role)), roles=roles, role_names=[ROLE_NAMES.get(str(item), str(item)) for item in roles], attendance_point_names=[point.name for point in points], attendance_points=[attendance_point_out(point) for point in points], is_temporary=bool(person.is_temporary), temporary_expires_at=person.temporary_expires_at, temporary_expired=temporary_expired(person), ) def person_role_out(person_role: PersonRole) -> PersonnelOut: return personnel_out(person_role.person, person_role.role) def personnel_summary_out(person: Personnel) -> dict: roles = sorted([str(item.role) for item in person.roles]) points = sorted( [item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None], key=lambda item: item.name, ) return { "phone": person.phone, "name": person.name, "roles": roles, "role_names": [ROLE_NAMES.get(role, role) for role in roles], "role": roles[0] if roles else "", "role_name": "、".join(ROLE_NAMES.get(role, role) for role in roles), "attendance_point_names": [point.name for point in points], "attendance_points": [attendance_point_out(point) for point in points], "is_temporary": bool(person.is_temporary), "temporary_expires_at": person.temporary_expires_at, "temporary_expired": temporary_expired(person), } def product_out(product: Product) -> ProductOut: optional_float = lambda value: None if value is None else as_float(value) return ProductOut( attendance_point_name=product.attendance_point_name, project_no=product.project_no, product_name=product.product_name, profile_no=product.profile_no, material_code=product.material_code, material_name=product.material_name, supplier=product.supplier, product_net_weight_kg=optional_float(product.product_net_weight_kg), product_gross_weight_kg=optional_float(product.product_gross_weight_kg), scrap_loss_rate=optional_float(product.scrap_loss_rate), waste_price_yuan_per_kg=optional_float(product.waste_price_yuan_per_kg), device_no=product.device_no, process_name=product.process_name, stamping_method=product.stamping_method, operator_count=as_float(product.operator_count), process_unit_price_yuan=as_float(product.process_unit_price_yuan), standard_beat=as_float(product.standard_beat), standard_workload=as_float(product.standard_workload), display_name=f"{product.project_no} / {mold_process_display_name(product.product_name, product.process_name, product.stamping_method)}", ) def product_option(product: Product) -> ProductOption: data = product_out(product).model_dump() data["is_cleaning"] = is_cleaning_product(product) data["is_misc"] = is_misc_product(product) data["is_continuous_die"] = is_continuous_die_product(product) data["is_multi_person"] = is_multi_person_product(product) return ProductOption(**data) def equipment_out(equipment: Equipment) -> EquipmentOut: return EquipmentOut( attendance_point_name=equipment.attendance_point_name, device_no=equipment.device_no, device_type=equipment.device_type or "冲压设备", remark=equipment.remark, created_at=equipment.created_at, updated_at=equipment.updated_at, ) def equipment_option(equipment: Equipment) -> EquipmentOption: display_name = equipment.device_no if equipment.remark: display_name = f"{equipment.device_no} / {equipment.remark}" return EquipmentOption( attendance_point_name=equipment.attendance_point_name, device_no=equipment.device_no, device_type=equipment.device_type or "冲压设备", remark=equipment.remark, display_name=display_name, ) def _session_device_stamping_method( db: Session | None, session: WorkSession, device: WorkSessionDevice, ) -> str | None: if db is None: return None point_name = device.attendance_point_name or session.attendance_point_name product = db.scalar( select(Product) .where( Product.attendance_point_name == point_name, Product.product_name == device.device_no, Product.process_name == (device.process_name or ""), Product.device_no == "", ) .order_by(Product.project_no.asc()) ) return product.stamping_method if product else None def session_out(session: WorkSession, db: Session | None = None) -> ClockSessionOut: return ClockSessionOut( id=session.id, attendance_point_name=session.attendance_point_name, employee_phone=session.employee_phone, start_at=session.start_at, end_at=session.end_at, status=str(session.status), devices=[ mold_process_display_name( device.device_no, device.process_name, _session_device_stamping_method(db, session, device), ) for device in session.devices ], ) def _changed(old_value, new_value) -> bool: return str(old_value or "") != str(new_value or "") def _parse_datetime(value) -> datetime | None: if isinstance(value, datetime): return value if not value: return None try: return datetime.fromisoformat(str(value)) except ValueError: return None def _datetime_changed(old_value, new_value) -> bool: old_dt = _parse_datetime(old_value) new_dt = _parse_datetime(new_value) if old_dt is None or new_dt is None: return _changed(old_value, new_value) if old_dt.replace(second=0, microsecond=0) == new_dt.replace(second=0, microsecond=0): return False return old_dt != new_dt def _numeric_changed(old_value, new_value) -> bool: return round(as_float(old_value), 4) != round(as_float(new_value), 4) def _process_name_changed(old_value, new_value) -> bool: return export_process_name(old_value) != export_process_name(new_value) def _ordered_session_devices(session: WorkSession | None) -> list[WorkSessionDevice]: return sorted( getattr(session, "devices", []) or [], key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)), ) def report_correction_maps(report: ProductionReport) -> tuple[dict, dict[int, dict], dict[int, dict]]: logs = [ log for log in getattr(report, "audit_logs", []) if log.action == AuditAction.approve_with_correction and log.before_json ] if not logs: return {}, {}, {} log = sorted(logs, key=lambda item: item.created_at)[-1] before = log.before_json or {} report_corrections = {} if _datetime_changed(before.get("start_at"), report.start_at.isoformat()): report_corrections["start_at"] = before.get("start_at") if _datetime_changed(before.get("end_at"), report.end_at.isoformat()): report_corrections["end_at"] = before.get("end_at") if _changed(before.get("review_remark"), report.review_remark): report_corrections["review_remark"] = before.get("review_remark") before_items = { item.get("id"): item for item in before.get("items", []) if item.get("id") is not None } item_corrections: dict[int, dict] = {} text_fields = [ "device_no", "project_no", "product_name", "raw_material_batch_no", "process_name", "stamping_method", "remark", ] numeric_fields = ["process_unit_price_yuan", "changeover_count", "good_qty", "defect_qty", "scrap_qty"] for item in report.items: old = before_items.get(item.id) if not old: continue changes = {} for field in text_fields: if field == "process_name": if _process_name_changed(old.get(field), getattr(item, field)): changes[field] = export_process_name(old.get(field)) elif _changed(old.get(field), getattr(item, field)): changes[field] = old.get(field) for field in numeric_fields: if _numeric_changed(old.get(field), getattr(item, field)): changes[field] = as_float(old.get(field)) if changes: item_corrections[item.id] = changes before_devices = { device.get("id"): device for device in before.get("devices", []) if device.get("id") is not None } device_corrections: dict[int, dict] = {} for device in _ordered_session_devices(report.session if report.session else None): old = before_devices.get(device.id) if old and _datetime_changed(old.get("scanned_at"), device.scanned_at.isoformat()): device_corrections[device.id] = {"scanned_at": old.get("scanned_at")} return report_corrections, item_corrections, device_corrections def report_device_segment_out( device: WorkSessionDevice, corrections: dict | None = None, stamping_method: str | None = None, ) -> ReportDeviceSegmentOut: process_name = device.process_name or "" display_name = mold_process_display_name(device.device_no, process_name, stamping_method) return ReportDeviceSegmentOut( id=device.id, attendance_point_name=device.attendance_point_name, device_no=device.device_no, mold_name=device.device_no, process_name=process_name, stamping_method=stamping_method, display_name=display_name, scanned_at=device.scanned_at, released_at=device.released_at, sort_order=int(device.sort_order or 0), corrections=corrections or {}, ) def report_allocation_out(row) -> ReportAllocationOut: return ReportAllocationOut( allocation_date=row.allocation_date, day_minutes=as_float(row.day_minutes), overtime_minutes=as_float(row.overtime_minutes), night_minutes=as_float(row.night_minutes), effective_minutes=as_float(row.effective_minutes), good_qty=as_float(row.good_qty), defect_qty=as_float(row.defect_qty), scrap_qty=as_float(row.scrap_qty), changeover_count=as_float(row.changeover_count), reference_wage=as_float(row.reference_wage), ) def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut: return ReportItemOut( id=item.id, attendance_point_name=item.attendance_point_name, device_no=item.device_no, project_no=item.project_no, product_name=item.product_name, material_code=item.material_code, material_name=item.material_name, raw_material_batch_no=item.raw_material_batch_no, process_name=item.process_name, stamping_method=item.stamping_method, operator_count=as_float(item.operator_count), process_unit_price_yuan=as_float(item.process_unit_price_yuan), changeover_count=as_float(item.changeover_count), standard_beat=as_float(item.standard_beat), standard_workload=as_float(item.standard_workload), good_qty=as_float(item.good_qty), defect_qty=as_float(item.defect_qty), scrap_qty=as_float(item.scrap_qty), allocated_minutes=as_float(item.allocated_minutes), started_at=item.started_at, is_misc=is_misc_item(item), is_continuous_die=is_continuous_die_item(item), is_multi_person=is_multi_person_item(item), remark=item.remark, allocations=[report_allocation_out(row) for row in getattr(item, "allocations", []) or []], corrections=corrections or {}, ) def _report_is_cleaning(report: ProductionReport) -> bool: return bool(report.items) and all(is_cleaning_item(item) for item in report.items) def _report_is_misc_only(report: ProductionReport) -> bool: return bool(report.items) and all(is_misc_item(item) for item in report.items) def _cleaning_report_metrics(report: ProductionReport) -> dict: total_good_qty = sum(as_float(item.good_qty) for item in report.items) total_output_qty = sum( as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) for item in report.items ) return { "duration_minutes": 0, "effective_minutes": 0, "shift_distribution_text": "清洗报工不计工时", "shift_day_minutes": 0, "shift_overtime_minutes": 0, "shift_night_minutes": 0, "shift_other_minutes": 0, "shift_meal_break_minutes": 0, "total_good_qty": total_good_qty, "total_output_qty": total_output_qty, "actual_beat": 0, "standard_beat": 0, "expected_workload": 0, "pace_rate": 0, "workload_rate": 0, } def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut: is_cleaning_report = _report_is_cleaning(report) calculated_metrics = ( _cleaning_report_metrics(report) if is_cleaning_report else calculate_report_metrics( report.start_at, report.end_at, list(report.items), devices=report.session.devices if report.session else None, schedule=schedule, ) ) metrics = { "effective_minutes": as_float(calculated_metrics["effective_minutes"]), "shift_distribution_text": calculated_metrics["shift_distribution_text"], "shift_day_minutes": as_float(calculated_metrics["shift_day_minutes"]), "shift_overtime_minutes": as_float(calculated_metrics["shift_overtime_minutes"]), "shift_night_minutes": as_float(calculated_metrics["shift_night_minutes"]), "shift_other_minutes": as_float(calculated_metrics["shift_other_minutes"]), "shift_meal_break_minutes": as_float(calculated_metrics["shift_meal_break_minutes"]), "total_good_qty": as_float(calculated_metrics["total_good_qty"]), "total_output_qty": as_float(calculated_metrics["total_output_qty"]), "actual_beat": as_float(calculated_metrics["actual_beat"]), "standard_beat": as_float(calculated_metrics["standard_beat"]), "expected_workload": as_float(calculated_metrics["expected_workload"]), "pace_rate": as_float(calculated_metrics["pace_rate"]), "workload_rate": as_float(calculated_metrics["workload_rate"]), } report_corrections, item_corrections, device_corrections = report_correction_maps(report) is_modified = bool(report_corrections or item_corrections or device_corrections) stamping_by_mold = { (item.product_name, item.process_name or ""): item.stamping_method for item in report.items } return ReportOut( id=report.id, attendance_point_name=report.attendance_point_name, session_id=report.session_id, employee_phone=report.employee_phone, employee_name=report.employee.name if report.employee else "", report_date=report.report_date, start_at=report.start_at, end_at=report.end_at, duration_minutes=as_float(calculated_metrics["duration_minutes"]), break_minutes=as_float(report.break_minutes), status=report.status, status_name=REPORT_STATUS_NAMES.get(str(report.status), str(report.status)), reviewer_phone=report.reviewer_phone, reviewer_name=report.reviewer.name if report.reviewer else None, reviewed_at=report.reviewed_at, reject_reason=report.reject_reason, review_remark=report.review_remark, submitted_at=report.submitted_at, is_system_auto_submitted=bool(report.is_system_auto_submitted), auto_submit_reason=report.auto_submit_reason, is_multi_person_assistant=bool(report.is_multi_person_assistant), multi_person_source_report_id=report.multi_person_source_report_id, is_voided=bool(report.is_voided), voided_at=report.voided_at, voided_by=report.voided_by, voided_by_name=report.voider.name if report.voider else None, unvoid_deadline_at=report.unvoid_deadline_at, can_void=not bool(report.is_voided), can_unvoid=can_unvoid_report(report), can_edit=can_edit_report(report), is_modified=is_modified, device_segments=[ report_device_segment_out( device, device_corrections.get(device.id, {}), stamping_by_mold.get((device.device_no, device.process_name or "")), ) for device in _ordered_session_devices(report.session if report.session else None) ], items=[report_item_out(item, item_corrections.get(item.id, {})) for item in report.items], metrics=ReportMetrics(**metrics), result_text=( "清洗报工已提交,等待管理员审核" if is_cleaning_report else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics)) ), allocation_summary_text=allocation_summary_text(list(getattr(report, "allocations", []) or [])), corrections=report_corrections, )