from collections import defaultdict from dataclasses import dataclass from datetime import date, datetime, time, timedelta from typing import Protocol from app.services.common import as_float, normalize_device_no, round1, round2 from app.services.misc_work import is_misc_item from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig class ReportItemLike(Protocol): device_no: str product_name: str process_name: str | None started_at: datetime | None standard_beat: float standard_workload: float stamping_method: str | None good_qty: float defect_qty: float scrap_qty: float allocated_minutes: float class ReportDeviceLike(Protocol): device_no: str process_name: str | None scanned_at: datetime released_at: datetime | None sort_order: int @dataclass(frozen=True) class ItemTimeRange: item: ReportItemLike start_at: datetime end_at: datetime allocation_weight: float = 1.0 SHIFT_BUCKETS = ("day", "overtime", "night") SHIFT_LABELS = { "day": "白班", "overtime": "加班", "night": "夜班", } MEAL_END_GRACE_MINUTES = 5 def minutes_between(start_at: datetime, end_at: datetime) -> float: seconds = max(0.0, (end_at - start_at).total_seconds()) return seconds / 60 def _combine(day: date, value: time) -> datetime: return datetime.combine(day, value) def _parse_schedule_time(value: str) -> time: hour, minute = [int(part) for part in str(value).split(":", 1)] return time(hour, minute) def _schedule_interval(base_date: date, start_text: str, end_text: str) -> tuple[datetime, datetime]: start = _combine(base_date, _parse_schedule_time(start_text)) end = _combine(base_date, _parse_schedule_time(end_text)) if end <= start: end += timedelta(days=1) return start, end def _overlap_minutes( start_at: datetime, end_at: datetime, interval_start: datetime, interval_end: datetime, ) -> float: start = max(start_at, interval_start) end = min(end_at, interval_end) return minutes_between(start, end) if end > start else 0.0 def _clip_interval( interval_start: datetime, interval_end: datetime, boundary_start: datetime, boundary_end: datetime, ) -> tuple[datetime, datetime] | None: start = max(interval_start, boundary_start) end = min(interval_end, boundary_end) if end <= start: return None return start, end def _merge_intervals(intervals: list[tuple[datetime, datetime]]) -> list[tuple[datetime, datetime]]: ordered = sorted(intervals, key=lambda item: item[0]) merged: list[tuple[datetime, datetime]] = [] for start, end in ordered: if not merged or start > merged[-1][1]: merged.append((start, end)) elif end > merged[-1][1]: merged[-1] = (merged[-1][0], end) return merged def _subtract_intervals( base_start: datetime, base_end: datetime, occupied: list[tuple[datetime, datetime]], ) -> list[tuple[datetime, datetime]]: gaps: list[tuple[datetime, datetime]] = [] cursor = base_start for start, end in _merge_intervals(occupied): if start > cursor: gaps.append((cursor, start)) if end > cursor: cursor = end if cursor < base_end: gaps.append((cursor, base_end)) return gaps def _calendar_dates_between(start_at: datetime, end_at: datetime): current = start_at.date() last = end_at.date() while current <= last: yield current current += timedelta(days=1) def _blank_overtime_intervals( current_date: date, schedule: WorkScheduleConfig, ) -> list[tuple[datetime, datetime]]: day_start = datetime.combine(current_date, time.min) day_end = day_start + timedelta(days=1) occupied: list[tuple[datetime, datetime]] = [] raw_intervals = [ _schedule_interval(current_date, schedule.day_start, schedule.day_end), _schedule_interval(current_date, schedule.overtime_start, schedule.overtime_end), _schedule_interval(current_date, schedule.night_start, schedule.night_end), _schedule_interval(current_date - timedelta(days=1), schedule.night_start, schedule.night_end), ] for interval_start, interval_end in raw_intervals: clipped = _clip_interval(interval_start, interval_end, day_start, day_end) if clipped is not None: occupied.append(clipped) return _subtract_intervals(day_start, day_end, occupied) def _covered_meal_overlap_minutes( start_at: datetime, end_at: datetime, meal_start: datetime, meal_end: datetime, ) -> float: if end_at <= meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES): return 0.0 if start_at <= meal_start and end_at >= meal_end: return _overlap_minutes(start_at, end_at, meal_start, meal_end) return 0.0 def _iter_shift_base_dates(start_at: datetime, end_at: datetime): current = start_at.date() - timedelta(days=1) last = end_at.date() while current <= last: yield current current += timedelta(days=1) def _raw_shift_minutes( start_at: datetime, end_at: datetime, schedule: WorkScheduleConfig | None = None, ) -> dict[str, float]: active_schedule = schedule or DEFAULT_WORK_SCHEDULE_CONFIG minutes = { "day": 0.0, "overtime": 0.0, "night": 0.0, "meal_break": 0.0, } if end_at <= start_at: return minutes 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 meal_start, meal_end in meal_intervals: minutes["meal_break"] += _covered_meal_overlap_minutes( start_at, end_at, meal_start, meal_end, ) for bucket, intervals in shift_intervals.items(): for interval_start, interval_end in intervals: raw_minutes = _overlap_minutes(start_at, end_at, interval_start, interval_end) shift_start = max(start_at, interval_start) shift_end = min(end_at, interval_end) meal_minutes = sum( _overlap_minutes(shift_start, shift_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=MEAL_END_GRACE_MINUTES) ) ) minutes[bucket] += max(0.0, raw_minutes - meal_minutes) for blank_start, blank_end in _blank_overtime_intervals(current_date, active_schedule): raw_minutes = _overlap_minutes(start_at, end_at, blank_start, blank_end) blank_start_at = max(start_at, blank_start) blank_end_at = min(end_at, blank_end) meal_minutes = sum( _overlap_minutes(blank_start_at, blank_end_at, 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=MEAL_END_GRACE_MINUTES) ) ) minutes["overtime"] += max(0.0, raw_minutes - meal_minutes) return minutes def shift_minutes_between( start_at: datetime, end_at: datetime, break_minutes: float = 0, schedule: WorkScheduleConfig | None = None, ) -> dict[str, float]: return _raw_shift_minutes(start_at, end_at, schedule) def effective_work_minutes_between( start_at: datetime, end_at: datetime, break_minutes: float = 0, schedule: WorkScheduleConfig | None = None, ) -> float: shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule) return sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS) def format_hours(minutes: float) -> str: text = f"{round(as_float(minutes) / 60, 2):.2f}".rstrip("0").rstrip(".") return text or "0" def shift_distribution_text(shift_minutes: dict[str, float]) -> str: effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS) parts = [ f"{SHIFT_LABELS[key]}{format_hours(shift_minutes.get(key, 0.0))}小时" for key in SHIFT_BUCKETS if round(as_float(shift_minutes.get(key, 0.0)), 2) > 0 ] detail = f"({'、'.join(parts)})" if parts else "" return f"工时 {format_hours(effective_minutes)}小时{detail}" def _set_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None: for key in SHIFT_BUCKETS: setattr(item, f"_shift_{key}_minutes", as_float(shift_minutes.get(key, 0.0))) def _add_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None: for key in SHIFT_BUCKETS: setattr( item, f"_shift_{key}_minutes", _get_item_shift_minutes(item, key) + as_float(shift_minutes.get(key, 0.0)), ) def _get_item_shift_minutes(item: ReportItemLike, key: str) -> float: return as_float(getattr(item, f"_shift_{key}_minutes", 0.0)) def _scale_item_shift_minutes(item: ReportItemLike, scale: float) -> None: for key in SHIFT_BUCKETS: setattr(item, f"_shift_{key}_minutes", _get_item_shift_minutes(item, key) * scale) def _mold_key(product_name: str | None, process_name: str | None = "") -> str: return f"{normalize_device_no(product_name)}\0{str(process_name or '').strip()}" WORK_TIME_IGNORED_RELEASE_REASONS = { "换模具释放占用", "报工提交释放占用", "超时-系统自动提交", } def release_at_caps_work_time(device: ReportDeviceLike) -> bool: if getattr(device, "released_at", None) is None: return False reason = str(getattr(device, "release_reason", "") or "").strip() return reason not in WORK_TIME_IGNORED_RELEASE_REASONS def _device_segment_ranges( start_at: datetime, end_at: datetime, devices: list[ReportDeviceLike] | None, ) -> dict[str, list[tuple[datetime, datetime]]]: ordered_devices = sorted( devices or [], key=lambda device: (int(getattr(device, "sort_order", 0) or 0), device.scanned_at), ) if not ordered_devices: return {} segment_ranges: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list) for index, device in enumerate(ordered_devices): segment_start = start_at if index == 0 else max(start_at, device.scanned_at) segment_end = end_at if index + 1 < len(ordered_devices): segment_end = min(end_at, ordered_devices[index + 1].scanned_at) released_at = getattr(device, "released_at", None) if release_at_caps_work_time(device) and released_at is not None: segment_end = min(segment_end, released_at) if segment_end > segment_start: segment_ranges[_mold_key(device.device_no, getattr(device, "process_name", ""))].append( (segment_start, segment_end) ) return segment_ranges def _segment_ranges_shift_minutes( segment_ranges: dict[str, list[tuple[datetime, datetime]]], schedule: WorkScheduleConfig | None = None, ) -> dict[str, float]: minutes = {key: 0.0 for key in SHIFT_BUCKETS} for ranges in segment_ranges.values(): for segment_start, segment_end in ranges: shift_minutes = shift_minutes_between(segment_start, segment_end, schedule=schedule) for key in SHIFT_BUCKETS: minutes[key] += shift_minutes.get(key, 0.0) return minutes def _clamp_datetime(value: datetime, start_at: datetime, end_at: datetime) -> datetime: if value < start_at: return start_at if value > end_at: return end_at return value def _allocate_device_item_minutes( device_items: list[ReportItemLike], segment_start: datetime, segment_end: datetime, schedule: WorkScheduleConfig | None = None, ) -> None: if not device_items: return grouped_items: dict[datetime, list[ReportItemLike]] = defaultdict(list) for item in device_items: item_start = getattr(item, "started_at", None) or segment_start grouped_items[_clamp_datetime(item_start, segment_start, segment_end)].append(item) first_item_start = min(grouped_items) if first_item_start > segment_start: grouped_items[segment_start].extend(grouped_items.pop(first_item_start)) ordered_starts = sorted(grouped_items) for index, item_start in enumerate(ordered_starts): item_end = segment_end if index + 1 < len(ordered_starts): item_end = ordered_starts[index + 1] shift_minutes = shift_minutes_between(item_start, item_end, schedule=schedule) minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS) items_at_start = grouped_items[item_start] minutes_per_item = minutes / len(items_at_start) for item in items_at_start: item.allocated_minutes = as_float(item.allocated_minutes) + minutes_per_item _add_item_shift_minutes( item, {key: shift_minutes.get(key, 0.0) / len(items_at_start) for key in SHIFT_BUCKETS}, ) def _device_item_time_ranges( device_items: list[ReportItemLike], segment_start: datetime, segment_end: datetime, ) -> list[ItemTimeRange]: if not device_items: return [] grouped_items: dict[datetime, list[ReportItemLike]] = defaultdict(list) for item in device_items: item_start = getattr(item, "started_at", None) or segment_start grouped_items[_clamp_datetime(item_start, segment_start, segment_end)].append(item) first_item_start = min(grouped_items) if first_item_start > segment_start: grouped_items[segment_start].extend(grouped_items.pop(first_item_start)) ranges: list[ItemTimeRange] = [] ordered_starts = sorted(grouped_items) for index, item_start in enumerate(ordered_starts): item_end = segment_end if index + 1 < len(ordered_starts): item_end = ordered_starts[index + 1] items_at_start = grouped_items[item_start] allocation_weight = 1 / len(items_at_start) for item in items_at_start: ranges.append( ItemTimeRange( item=item, start_at=item_start, end_at=item_end, allocation_weight=allocation_weight, ) ) return ranges def item_time_ranges( start_at: datetime, end_at: datetime, items: list[ReportItemLike], devices: list[ReportDeviceLike] | None = None, ) -> list[ItemTimeRange]: if not items or end_at <= start_at: return [] segment_ranges = _device_segment_ranges(start_at, end_at, devices) if not segment_ranges: allocation_weight = 1 / len(items) return [ ItemTimeRange(item=item, start_at=start_at, end_at=end_at, allocation_weight=allocation_weight) for item in items ] items_by_device: dict[str, list[ReportItemLike]] = defaultdict(list) for item in items: mold_key = _mold_key( getattr(item, "product_name", "") or item.device_no, getattr(item, "process_name", ""), ) items_by_device[mold_key].append(item) ranges: list[ItemTimeRange] = [] allocated_item_ids: set[int] = set() for device_no, device_ranges in segment_ranges.items(): device_items = items_by_device.get(device_no, []) if not device_items: continue for segment_start, segment_end in device_ranges: ranges.extend(_device_item_time_ranges(device_items, segment_start, segment_end)) for item in device_items: allocated_item_ids.add(id(item)) unmatched_items = [item for item in items if id(item) not in allocated_item_ids] if unmatched_items: ranges.extend(ItemTimeRange(item=item, start_at=start_at, end_at=end_at) for item in unmatched_items) return ranges def _allocate_item_minutes( start_at: datetime, end_at: datetime, items: list[ReportItemLike], effective_minutes: float, devices: list[ReportDeviceLike] | None, schedule: WorkScheduleConfig | None = None, ) -> None: if not items: return for item in items: item.allocated_minutes = 0.0 _set_item_shift_minutes(item, {}) segment_ranges = _device_segment_ranges(start_at, end_at, devices) if not segment_ranges: shift_minutes = shift_minutes_between(start_at, end_at, schedule=schedule) minutes_per_item = effective_minutes / len(items) for item in items: item.allocated_minutes = minutes_per_item _set_item_shift_minutes( item, {key: shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS}, ) return covered_shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule) target_effective_minutes = min(effective_minutes, sum(covered_shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)) target_shift_minutes = covered_shift_minutes items_by_device: dict[str, list[ReportItemLike]] = defaultdict(list) for item in items: mold_key = _mold_key( getattr(item, "product_name", "") or item.device_no, getattr(item, "process_name", ""), ) items_by_device[mold_key].append(item) allocated_item_ids: set[int] = set() for device_no, ranges in segment_ranges.items(): device_items = items_by_device.get(device_no, []) if not device_items: continue for segment_range in ranges: _allocate_device_item_minutes(device_items, segment_range[0], segment_range[1], schedule) for item in device_items: allocated_item_ids.add(id(item)) unmatched_items = [item for item in items if id(item) not in allocated_item_ids] if unmatched_items: allocated_minutes = sum(as_float(item.allocated_minutes) for item in items) remaining_minutes = max(0.0, target_effective_minutes - allocated_minutes) minutes_per_item = remaining_minutes / len(unmatched_items) allocated_shift_minutes = { key: sum(_get_item_shift_minutes(item, key) for item in items) for key in SHIFT_BUCKETS } remaining_shift_minutes = { key: max(0.0, target_shift_minutes.get(key, 0.0) - allocated_shift_minutes[key]) for key in SHIFT_BUCKETS } for item in unmatched_items: item.allocated_minutes = minutes_per_item _set_item_shift_minutes( item, {key: remaining_shift_minutes[key] / len(unmatched_items) for key in SHIFT_BUCKETS}, ) allocated_minutes = sum(as_float(item.allocated_minutes) for item in items) if target_effective_minutes <= 0: for item in items: item.allocated_minutes = 0.0 _set_item_shift_minutes(item, {}) elif allocated_minutes <= 0: minutes_per_item = target_effective_minutes / len(items) for item in items: item.allocated_minutes = minutes_per_item _set_item_shift_minutes( item, {key: target_shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS}, ) elif abs(allocated_minutes - target_effective_minutes) > 0.000001: scale = target_effective_minutes / allocated_minutes for item in items: item.allocated_minutes = as_float(item.allocated_minutes) * scale _scale_item_shift_minutes(item, scale) def calculate_report_metrics( start_at: datetime, end_at: datetime, items: list[ReportItemLike], break_minutes: float = 0, devices: list[ReportDeviceLike] | None = None, schedule: WorkScheduleConfig | None = None, ) -> dict[str, float]: segment_ranges = _device_segment_ranges(start_at, end_at, devices) if segment_ranges: duration_minutes = sum( minutes_between(segment_start, segment_end) for ranges in segment_ranges.values() for segment_start, segment_end in ranges ) shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule) shift_minutes["meal_break"] = sum( shift_minutes_between(segment_start, segment_end, break_minutes, schedule).get("meal_break", 0.0) for ranges in segment_ranges.values() for segment_start, segment_end in ranges ) else: duration_minutes = minutes_between(start_at, end_at) shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule) effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS) _allocate_item_minutes(start_at, end_at, items, effective_minutes, devices, schedule) total_good_qty = 0.0 total_output_qty = 0.0 weighted_standard_beat = 0.0 standard_weight = 0.0 expected_workload = 0.0 productive_minutes = 0.0 for item in items: if is_misc_item(item): try: item.standard_workload = 0 except AttributeError: pass continue good_qty = as_float(item.good_qty) defect_qty = as_float(item.defect_qty) scrap_qty = as_float(item.scrap_qty) output_qty = good_qty + defect_qty + scrap_qty beat = as_float(item.standard_beat) workload = as_float(item.allocated_minutes) * 60 / beat if beat > 0 else 0 weight = max(1.0, output_qty) try: item.standard_workload = round1(workload) except AttributeError: pass total_good_qty += good_qty total_output_qty += output_qty weighted_standard_beat += beat * weight standard_weight += weight expected_workload += workload productive_minutes += as_float(item.allocated_minutes) actual_beat = productive_minutes * 60 / total_output_qty if total_output_qty > 0 else 0 standard_beat = weighted_standard_beat / standard_weight if standard_weight > 0 else 0 pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0 workload_rate = ( (total_good_qty - expected_workload) / expected_workload * 100 if expected_workload > 0 else 0 ) return { "duration_minutes": round2(duration_minutes), "effective_minutes": round2(effective_minutes), "shift_day_minutes": round2(shift_minutes["day"]), "shift_overtime_minutes": round2(shift_minutes["overtime"]), "shift_night_minutes": round2(shift_minutes["night"]), "shift_other_minutes": 0, "shift_meal_break_minutes": round2(shift_minutes["meal_break"]), "shift_distribution_text": shift_distribution_text(shift_minutes), "total_good_qty": round2(total_good_qty), "total_output_qty": round2(total_output_qty), "actual_beat": round2(actual_beat), "standard_beat": round2(standard_beat), "expected_workload": round1(expected_workload), "pace_rate": round1(pace_rate), "workload_rate": round1(workload_rate), } def build_result_text(metrics: dict[str, float]) -> str: pace_rate = as_float(metrics.get("pace_rate")) workload_rate = as_float(metrics.get("workload_rate")) fast_abnormal = pace_rate < -30 large_abnormal = workload_rate > 30 pace_text = ( f"今日节拍慢于标准节拍{abs(pace_rate)}%" if pace_rate > 0 else f"今日节拍快于标准节拍{abs(pace_rate)}%" ) workload_text = ( f"今日工作量大于标准工作量{abs(workload_rate)}%" if workload_rate >= 0 else f"今日工作量小于标准工作量{abs(workload_rate)}%" ) if fast_abnormal or large_abnormal: suffix = "数据明显超出标准,请自我核对一下是否报工填错。" elif pace_rate <= 0 and workload_rate >= 0: suffix = "今天真的很棒呢!" else: suffix = "后续需要加油喽!" return f"{pace_text},{workload_text},{suffix}"