from collections import defaultdict from dataclasses import dataclass from datetime import date, datetime, timedelta from sqlalchemy import delete from sqlalchemy.orm import Session from app.models import ProductionReportAllocation from app.services.common import as_float, round2 from app.services.metrics import ( SHIFT_BUCKETS, SHIFT_LABELS, _blank_overtime_intervals, _covered_meal_overlap_minutes, _iter_shift_base_dates, _overlap_minutes, _schedule_interval, format_hours, item_time_ranges, minutes_between, ) from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig @dataclass(frozen=True) class ReportAllocationDraft: report_id: int | None report_item_id: int | None attendance_point_name: str employee_phone: str allocation_date: date day_minutes: float = 0 overtime_minutes: float = 0 night_minutes: float = 0 effective_minutes: float = 0 good_qty: float = 0 defect_qty: float = 0 scrap_qty: float = 0 changeover_count: float = 0 reference_wage: float = 0 def _add_minutes( rows: dict[date, dict[str, float]], allocation_date: date, bucket: str, minutes: float, ) -> None: if minutes <= 0: return rows[allocation_date][bucket] += minutes def _range_allocation_minutes( start_at: datetime, end_at: datetime, schedule: WorkScheduleConfig | None = None, ) -> dict[date, dict[str, float]]: active_schedule = schedule or DEFAULT_WORK_SCHEDULE_CONFIG rows: dict[date, dict[str, float]] = defaultdict(lambda: {key: 0.0 for key in SHIFT_BUCKETS}) if end_at <= start_at: return rows for current_date in _iter_shift_base_dates(start_at, end_at): meal_intervals = [ _schedule_interval(current_date, active_schedule.lunch_start, active_schedule.lunch_end), _schedule_interval(current_date, active_schedule.dinner_start, active_schedule.dinner_end), ] shift_intervals = { "day": [_schedule_interval(current_date, active_schedule.day_start, active_schedule.day_end)], "overtime": [ _schedule_interval(current_date, active_schedule.overtime_start, active_schedule.overtime_end) ], "night": [_schedule_interval(current_date, active_schedule.night_start, active_schedule.night_end)], } for bucket, intervals in shift_intervals.items(): for interval_start, interval_end in intervals: overlap_start = max(start_at, interval_start) overlap_end = min(end_at, interval_end) if overlap_end <= overlap_start: continue meal_minutes = sum( _overlap_minutes(overlap_start, overlap_end, meal_start, meal_end) for meal_start, meal_end in meal_intervals if ( start_at <= meal_start and end_at >= meal_end and end_at > meal_end + timedelta(minutes=5) ) ) minutes = max(0.0, minutes_between(overlap_start, overlap_end) - meal_minutes) if bucket == "night": _add_minutes(rows, current_date, "night", minutes) else: _add_minutes(rows, overlap_start.date(), bucket, minutes) for blank_start, blank_end in _blank_overtime_intervals(current_date, active_schedule): overlap_start = max(start_at, blank_start) overlap_end = min(end_at, blank_end) if overlap_end <= overlap_start: continue meal_minutes = sum( _covered_meal_overlap_minutes(start_at, end_at, meal_start, meal_end) for meal_start, meal_end in meal_intervals ) minutes = max(0.0, minutes_between(overlap_start, overlap_end) - meal_minutes) _add_minutes(rows, overlap_start.date(), "overtime", minutes) return rows def _report_devices(report) -> list | None: session = getattr(report, "session", None) devices = getattr(session, "devices", None) if devices is not None: return list(devices) devices = getattr(report, "devices", None) return list(devices) if devices is not None else None def _item_reference_wage(item) -> float: return as_float(getattr(item, "good_qty", 0)) * as_float(getattr(item, "process_unit_price_yuan", 0)) def _fallback_row(report, item) -> ReportAllocationDraft: return ReportAllocationDraft( report_id=getattr(report, "id", None), report_item_id=getattr(item, "id", None), attendance_point_name=str(getattr(report, "attendance_point_name", "") or ""), employee_phone=str(getattr(report, "employee_phone", "") or ""), allocation_date=getattr(report, "report_date"), good_qty=round2(getattr(item, "good_qty", 0)), defect_qty=round2(getattr(item, "defect_qty", 0)), scrap_qty=round2(getattr(item, "scrap_qty", 0)), changeover_count=round2(getattr(item, "changeover_count", 0)), reference_wage=round2(_item_reference_wage(item)), ) def _draft_from_minutes(report, item, allocation_date: date, minutes: dict[str, float], ratio: float) -> ReportAllocationDraft: reference_wage = _item_reference_wage(item) day_minutes = round2(minutes.get("day", 0)) overtime_minutes = round2(minutes.get("overtime", 0)) night_minutes = round2(minutes.get("night", 0)) return ReportAllocationDraft( report_id=getattr(report, "id", None), report_item_id=getattr(item, "id", None), attendance_point_name=str(getattr(report, "attendance_point_name", "") or ""), employee_phone=str(getattr(report, "employee_phone", "") or ""), allocation_date=allocation_date, day_minutes=day_minutes, overtime_minutes=overtime_minutes, night_minutes=night_minutes, effective_minutes=round2(day_minutes + overtime_minutes + night_minutes), good_qty=round2(as_float(getattr(item, "good_qty", 0)) * ratio), defect_qty=round2(as_float(getattr(item, "defect_qty", 0)) * ratio), scrap_qty=round2(as_float(getattr(item, "scrap_qty", 0)) * ratio), changeover_count=round2(as_float(getattr(item, "changeover_count", 0)) * ratio), reference_wage=round2(reference_wage * ratio), ) def build_report_allocation_drafts(report, schedule: WorkScheduleConfig | None = None) -> list[ReportAllocationDraft]: items = list(getattr(report, "items", []) or []) if not items: return [] ranges = item_time_ranges(getattr(report, "start_at"), getattr(report, "end_at"), items, _report_devices(report)) minutes_by_item: dict[int, dict[date, dict[str, float]]] = {} for item_range in ranges: item_key = id(item_range.item) target = minutes_by_item.setdefault(item_key, defaultdict(lambda: {key: 0.0 for key in SHIFT_BUCKETS})) for allocation_date, minutes in _range_allocation_minutes(item_range.start_at, item_range.end_at, schedule).items(): for key in SHIFT_BUCKETS: target[allocation_date][key] += minutes.get(key, 0.0) * as_float( getattr(item_range, "allocation_weight", 1.0) ) rows: list[ReportAllocationDraft] = [] for item in items: item_minutes = minutes_by_item.get(id(item), {}) total_effective = sum(sum(minutes.get(key, 0.0) for key in SHIFT_BUCKETS) for minutes in item_minutes.values()) if total_effective <= 0: rows.append(_fallback_row(report, item)) continue for allocation_date in sorted(item_minutes): minutes = item_minutes[allocation_date] effective = sum(minutes.get(key, 0.0) for key in SHIFT_BUCKETS) if effective <= 0: continue rows.append(_draft_from_minutes(report, item, allocation_date, minutes, effective / total_effective)) return rows def allocation_summary_text(rows) -> str: grouped: dict[date, dict[str, float]] = defaultdict(lambda: {key: 0.0 for key in SHIFT_BUCKETS}) for row in rows: allocation_date = getattr(row, "allocation_date") grouped[allocation_date]["day"] += as_float(getattr(row, "day_minutes", 0)) grouped[allocation_date]["overtime"] += as_float(getattr(row, "overtime_minutes", 0)) grouped[allocation_date]["night"] += as_float(getattr(row, "night_minutes", 0)) parts: list[str] = [] for allocation_date in sorted(grouped): shift_text = "、".join( f"{SHIFT_LABELS[key]}{format_hours(grouped[allocation_date].get(key, 0))}小时" for key in SHIFT_BUCKETS if round2(grouped[allocation_date].get(key, 0)) > 0 ) if shift_text: parts.append(f"{allocation_date.isoformat()} {shift_text}") return ";".join(parts) def refresh_report_allocations( db: Session, report, schedule: WorkScheduleConfig | None = None, *, commit: bool = False, ) -> list[ProductionReportAllocation]: db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == getattr(report, "id"))) rows = [ ProductionReportAllocation( report_id=draft.report_id, report_item_id=draft.report_item_id, attendance_point_name=draft.attendance_point_name, employee_phone=draft.employee_phone, allocation_date=draft.allocation_date, day_minutes=draft.day_minutes, overtime_minutes=draft.overtime_minutes, night_minutes=draft.night_minutes, effective_minutes=draft.effective_minutes, good_qty=draft.good_qty, defect_qty=draft.defect_qty, scrap_qty=draft.scrap_qty, changeover_count=draft.changeover_count, reference_wage=draft.reference_wage, ) for draft in build_report_allocation_drafts(report, schedule) ] db.add_all(rows) db.flush() if commit: db.commit() return rows