JhHardwareWRS_BackPoint/app/services/metrics.py
2026-06-24 15:19:14 +08:00

507 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from collections import defaultdict
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
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 _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)
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 _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}"