377 lines
16 KiB
Python
377 lines
16 KiB
Python
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,
|
||
_device_item_time_ranges,
|
||
_device_segment_ranges,
|
||
_iter_shift_base_dates,
|
||
_mold_key,
|
||
_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(
|
||
_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)
|
||
_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 _minutes_total(rows: dict[date, dict[str, float]]) -> float:
|
||
return sum(sum(minutes.get(key, 0.0) for key in SHIFT_BUCKETS) for minutes in rows.values())
|
||
|
||
|
||
def _empty_shift_minutes() -> dict[str, float]:
|
||
return {key: 0.0 for key in SHIFT_BUCKETS}
|
||
|
||
|
||
def _add_date_bucket_minutes(
|
||
target: dict[date, dict[str, float]],
|
||
source: dict[date, dict[str, float]],
|
||
*,
|
||
scale: float = 1.0,
|
||
) -> None:
|
||
for allocation_date, minutes in source.items():
|
||
row = target.setdefault(allocation_date, _empty_shift_minutes())
|
||
for key in SHIFT_BUCKETS:
|
||
row[key] += minutes.get(key, 0.0) * scale
|
||
|
||
|
||
def _bucket_totals(rows_by_date: dict[date, dict[str, float]]) -> dict[str, float]:
|
||
return {
|
||
key: sum(minutes.get(key, 0.0) for minutes in rows_by_date.values())
|
||
for key in SHIFT_BUCKETS
|
||
}
|
||
|
||
|
||
def _item_bucket_totals(minutes_by_item: dict[int, dict[date, dict[str, float]]]) -> dict[str, float]:
|
||
totals = _empty_shift_minutes()
|
||
for rows_by_date in minutes_by_item.values():
|
||
for key, value in _bucket_totals(rows_by_date).items():
|
||
totals[key] += value
|
||
return totals
|
||
|
||
|
||
def _scale_item_minutes_to_bucket_targets(
|
||
minutes_by_item: dict[int, dict[date, dict[str, float]]],
|
||
target_buckets: dict[str, float],
|
||
) -> None:
|
||
raw_buckets = _item_bucket_totals(minutes_by_item)
|
||
for key in SHIFT_BUCKETS:
|
||
raw_value = raw_buckets.get(key, 0.0)
|
||
target_value = target_buckets.get(key, 0.0)
|
||
if raw_value <= 0 or abs(raw_value - target_value) <= 0.000001:
|
||
continue
|
||
scale = target_value / raw_value
|
||
for rows_by_date in minutes_by_item.values():
|
||
for minutes in rows_by_date.values():
|
||
minutes[key] = minutes.get(key, 0.0) * scale
|
||
|
||
|
||
def _items_by_device(items: list) -> dict[str, list]:
|
||
grouped: dict[str, list] = defaultdict(list)
|
||
for item in items:
|
||
mold_key = _mold_key(
|
||
getattr(item, "product_name", "") or item.device_no,
|
||
getattr(item, "process_name", ""),
|
||
)
|
||
grouped[mold_key].append(item)
|
||
return grouped
|
||
|
||
|
||
def _device_minutes_by_item(report, items: list, schedule: WorkScheduleConfig | None = None):
|
||
start_at = getattr(report, "start_at")
|
||
end_at = getattr(report, "end_at")
|
||
segment_ranges = _device_segment_ranges(start_at, end_at, _report_devices(report))
|
||
if not segment_ranges:
|
||
return None
|
||
|
||
items_by_device = _items_by_device(items)
|
||
minutes_by_item: dict[int, dict[date, dict[str, float]]] = {}
|
||
target_minutes: dict[date, dict[str, float]] = defaultdict(_empty_shift_minutes)
|
||
unassigned_minutes: dict[date, dict[str, float]] = defaultdict(_empty_shift_minutes)
|
||
allocated_item_ids: set[int] = set()
|
||
|
||
for device_no, ranges in segment_ranges.items():
|
||
device_items = items_by_device.get(device_no, [])
|
||
for segment_start, segment_end in ranges:
|
||
segment_minutes = _range_allocation_minutes(segment_start, segment_end, schedule)
|
||
_add_date_bucket_minutes(target_minutes, segment_minutes)
|
||
if not device_items:
|
||
_add_date_bucket_minutes(unassigned_minutes, segment_minutes)
|
||
continue
|
||
for item_range in _device_item_time_ranges(device_items, segment_start, segment_end):
|
||
item_minutes = minutes_by_item.setdefault(id(item_range.item), defaultdict(_empty_shift_minutes))
|
||
range_minutes = _range_allocation_minutes(item_range.start_at, item_range.end_at, schedule)
|
||
_add_date_bucket_minutes(
|
||
item_minutes,
|
||
range_minutes,
|
||
scale=as_float(getattr(item_range, "allocation_weight", 1.0)),
|
||
)
|
||
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 and _minutes_total(unassigned_minutes) > 0:
|
||
target_buckets = _bucket_totals(target_minutes)
|
||
matched_buckets = _item_bucket_totals(minutes_by_item)
|
||
unassigned_buckets = _bucket_totals(unassigned_minutes)
|
||
for unmatched_item in unmatched_items:
|
||
item_minutes = minutes_by_item.setdefault(id(unmatched_item), defaultdict(_empty_shift_minutes))
|
||
for allocation_date, minutes in unassigned_minutes.items():
|
||
row = item_minutes.setdefault(allocation_date, _empty_shift_minutes())
|
||
for key in SHIFT_BUCKETS:
|
||
remaining = max(0.0, target_buckets.get(key, 0.0) - matched_buckets.get(key, 0.0))
|
||
raw_unassigned = unassigned_buckets.get(key, 0.0)
|
||
if remaining <= 0 or raw_unassigned <= 0:
|
||
continue
|
||
row[key] += minutes.get(key, 0.0) * (remaining / raw_unassigned) / len(unmatched_items)
|
||
return minutes_by_item
|
||
|
||
_scale_item_minutes_to_bucket_targets(minutes_by_item, _bucket_totals(target_minutes))
|
||
return minutes_by_item
|
||
|
||
|
||
def _range_minutes_by_item(report, items: list, schedule: WorkScheduleConfig | None = None):
|
||
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(_empty_shift_minutes))
|
||
for allocation_date, minutes in _range_allocation_minutes(item_range.start_at, item_range.end_at, schedule).items():
|
||
row = target.setdefault(allocation_date, _empty_shift_minutes())
|
||
for key in SHIFT_BUCKETS:
|
||
row[key] += minutes.get(key, 0.0) * as_float(
|
||
getattr(item_range, "allocation_weight", 1.0)
|
||
)
|
||
return minutes_by_item
|
||
|
||
|
||
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 []
|
||
|
||
minutes_by_item = _device_minutes_by_item(report, items, schedule)
|
||
if minutes_by_item is None:
|
||
minutes_by_item = _range_minutes_by_item(report, items, schedule)
|
||
|
||
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
|