from collections import defaultdict from dataclasses import dataclass from datetime import date, datetime, time, 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 _midnight(value: date) -> datetime: return datetime.combine(value, time.min) 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 _add_night_minutes( rows: dict[date, dict[str, float]], start_at: datetime, end_at: datetime, base_date: date, ) -> None: midnight = _midnight(base_date + timedelta(days=1)) before_midnight = _overlap_minutes(start_at, end_at, _midnight(base_date), midnight) after_midnight = _overlap_minutes(start_at, end_at, midnight, midnight + timedelta(days=1)) _add_minutes(rows, base_date, "night", before_midnight + after_midnight) 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_night_minutes(rows, overlap_start, overlap_end, current_date) 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