JhHardwareWRS_BackPoint/app/services/report_allocations.py
2026-07-25 06:54:10 +08:00

641 lines
25 KiB
Python
Raw 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 dataclasses import dataclass, replace
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.models import ProductionReport, 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,
calculate_report_metrics,
format_hours,
item_time_ranges,
minutes_between,
)
from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig
SPLIT_QUANT = Decimal("0.01")
@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 _decimal_value(value: Any) -> Decimal:
if value is None:
return Decimal("0")
if isinstance(value, Decimal):
return value
try:
return Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return Decimal("0")
def _round_split_value(value: Any) -> float:
return float(_decimal_value(value).quantize(SPLIT_QUANT, rounding=ROUND_HALF_UP))
def _split_cents(value: Any) -> int:
rounded = _decimal_value(value).quantize(SPLIT_QUANT, rounding=ROUND_HALF_UP)
return int(rounded * 100)
def _scaled_split_value(value: Any, ratio: float) -> float:
return _round_split_value(_decimal_value(value) * _decimal_value(ratio))
def _item_reference_wage(item) -> Decimal:
return _decimal_value(getattr(item, "good_qty", 0)) * _decimal_value(
getattr(item, "process_unit_price_yuan", 0)
)
def _allocation_targets(report, items: list, schedule: WorkScheduleConfig | None = None):
metrics = calculate_report_metrics(
getattr(report, "start_at"),
getattr(report, "end_at"),
items,
break_minutes=as_float(getattr(report, "break_minutes", 0)),
devices=_report_devices(report),
schedule=schedule,
)
return (
{id(item): as_float(getattr(item, "allocated_minutes", 0)) for item in items},
{
"day": as_float(metrics.get("shift_day_minutes", 0)),
"overtime": as_float(metrics.get("shift_overtime_minutes", 0)),
"night": as_float(metrics.get("shift_night_minutes", 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 _allocate_unassigned_minutes_to_existing_items(
minutes_by_item: dict[int, dict[date, dict[str, float]]],
unassigned_minutes: dict[date, dict[str, float]],
) -> None:
unassigned_total = _minutes_total(unassigned_minutes)
if unassigned_total <= 0 or not minutes_by_item:
return
raw_item_totals = {
item_id: _minutes_total(rows_by_date)
for item_id, rows_by_date in minutes_by_item.items()
}
raw_total = sum(raw_item_totals.values())
item_count = len(minutes_by_item)
for item_id, rows_by_date in minutes_by_item.items():
weight = raw_item_totals[item_id] / raw_total if raw_total > 0 else 1 / item_count
_add_date_bucket_minutes(rows_by_date, unassigned_minutes, scale=weight)
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 _scale_bucket_minutes(
minutes_by_item: dict[int, dict[date, dict[str, float]]],
bucket: str,
scale: float,
) -> None:
for rows_by_date in minutes_by_item.values():
for minutes in rows_by_date.values():
minutes[bucket] = minutes.get(bucket, 0.0) * scale
def _balance_minutes_to_targets(
minutes_by_item: dict[int, dict[date, dict[str, float]]],
target_minutes_by_item: dict[int, float],
target_buckets: dict[str, float],
) -> None:
for _ in range(100):
for item_id, rows_by_date in minutes_by_item.items():
raw_total = _minutes_total(rows_by_date)
target_total = target_minutes_by_item.get(item_id, raw_total)
if raw_total <= 0:
continue
scale = target_total / raw_total
if abs(scale - 1) <= 0.000000001:
continue
for minutes in rows_by_date.values():
for key in SHIFT_BUCKETS:
minutes[key] = minutes.get(key, 0.0) * scale
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:
continue
scale = target_value / raw_value
if abs(scale - 1) <= 0.000000001:
continue
_scale_bucket_minutes(minutes_by_item, key, scale)
def _round_bucket_cells_to_targets(
minutes_by_item: dict[int, dict[date, dict[str, float]]],
target_buckets: dict[str, float],
item_order: dict[int, int],
) -> None:
for bucket in SHIFT_BUCKETS:
cells: list[tuple[float, int, int, date]] = []
for item_id, rows_by_date in minutes_by_item.items():
for allocation_date, minutes in rows_by_date.items():
value = minutes.get(bucket, 0.0)
if value > 0:
cents_value = max(0.0, value * 100)
floor_cents = int(cents_value + 0.000000001)
cells.append((cents_value - floor_cents, floor_cents, item_order.get(item_id, 0), allocation_date))
minutes[bucket] = floor_cents / 100
else:
minutes[bucket] = 0.0
target_cents = int(round(round2(target_buckets.get(bucket, 0.0)) * 100))
floor_total = sum(cell[1] for cell in cells)
residual = target_cents - floor_total
if not cells or residual == 0:
continue
ordered = sorted(
cells,
key=lambda cell: (-cell[0], cell[2], cell[3].isoformat()),
)
if residual < 0:
ordered = sorted(
cells,
key=lambda cell: (cell[0], cell[2], cell[3].isoformat()),
)
cents_by_cell: dict[tuple[int, date], int] = {
(cell[2], cell[3]): cell[1] for cell in cells
}
steps = abs(residual)
for index in range(steps):
_, _, item_index, allocation_date = ordered[index % len(ordered)]
key = (item_index, allocation_date)
if residual > 0:
cents_by_cell[key] += 1
elif cents_by_cell[key] > 0:
cents_by_cell[key] -= 1
item_id_by_order = {order: item_id for item_id, order in item_order.items()}
for (item_index, allocation_date), cents in cents_by_cell.items():
item_id = item_id_by_order[item_index]
minutes_by_item[item_id][allocation_date][bucket] = cents / 100
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
_allocate_unassigned_minutes_to_existing_items(minutes_by_item, unassigned_minutes)
_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=_round_split_value(getattr(item, "good_qty", 0)),
defect_qty=_round_split_value(getattr(item, "defect_qty", 0)),
scrap_qty=_round_split_value(getattr(item, "scrap_qty", 0)),
changeover_count=_round_split_value(getattr(item, "changeover_count", 0)),
reference_wage=_round_split_value(_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=_scaled_split_value(getattr(item, "good_qty", 0), ratio),
defect_qty=_scaled_split_value(getattr(item, "defect_qty", 0), ratio),
scrap_qty=_scaled_split_value(getattr(item, "scrap_qty", 0), ratio),
changeover_count=_scaled_split_value(getattr(item, "changeover_count", 0), ratio),
reference_wage=_round_split_value(reference_wage * _decimal_value(ratio)),
)
SPLIT_RESIDUAL_FIELDS = (
"good_qty",
"defect_qty",
"scrap_qty",
"changeover_count",
"reference_wage",
)
def _item_split_targets(item) -> dict[str, float]:
return {
"good_qty": _round_split_value(getattr(item, "good_qty", 0)),
"defect_qty": _round_split_value(getattr(item, "defect_qty", 0)),
"scrap_qty": _round_split_value(getattr(item, "scrap_qty", 0)),
"changeover_count": _round_split_value(getattr(item, "changeover_count", 0)),
"reference_wage": _round_split_value(_item_reference_wage(item)),
}
def _balance_item_split_residuals(
drafts: list[ReportAllocationDraft],
targets: dict[str, float],
) -> list[ReportAllocationDraft]:
if not drafts:
return drafts
mutable_values = [
{field: _split_cents(getattr(draft, field)) for field in SPLIT_RESIDUAL_FIELDS}
for draft in drafts
]
for field in SPLIT_RESIDUAL_FIELDS:
target_cents = _split_cents(targets[field])
current_cents = sum(values[field] for values in mutable_values)
residual = target_cents - current_cents
if residual == 0:
continue
if residual > 0:
ordered_indexes = sorted(
range(len(drafts)),
key=lambda index: (
-as_float(drafts[index].effective_minutes),
drafts[index].allocation_date.isoformat(),
index,
),
)
else:
ordered_indexes = sorted(
range(len(drafts)),
key=lambda index: (
-mutable_values[index][field],
-as_float(drafts[index].effective_minutes),
drafts[index].allocation_date.isoformat(),
index,
),
)
step = 1 if residual > 0 else -1
remaining = abs(residual)
while remaining > 0:
changed = False
for index in ordered_indexes:
if step < 0 and mutable_values[index][field] <= 0:
continue
mutable_values[index][field] += step
remaining -= 1
changed = True
if remaining == 0:
break
if not changed:
break
return [
replace(
draft,
**{
field: mutable_values[index][field] / 100
for field in SPLIT_RESIDUAL_FIELDS
},
)
for index, draft in enumerate(drafts)
]
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)
item_targets, bucket_targets = _allocation_targets(report, items, schedule)
_balance_minutes_to_targets(minutes_by_item, item_targets, bucket_targets)
_round_bucket_cells_to_targets(
minutes_by_item,
bucket_targets,
{id(item): index for index, item in enumerate(items)},
)
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
item_rows: list[ReportAllocationDraft] = []
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
item_rows.append(_draft_from_minutes(report, item, allocation_date, minutes, effective / total_effective))
rows.extend(_balance_item_split_residuals(item_rows, _item_split_targets(item)))
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]:
"""Rebuild allocation rows with metrics semantics, updating item allocation-derived fields."""
report_id = getattr(report, "id")
if isinstance(report_id, int):
db.execute(
select(ProductionReport.id)
.where(ProductionReport.id == report_id)
.with_for_update()
).scalar_one_or_none()
db.execute(delete(ProductionReportAllocation).where(ProductionReportAllocation.report_id == 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