feat: 增加报工工时归属拆分计算

This commit is contained in:
souplearn 2026-07-25 03:00:07 +08:00
parent 66777fa174
commit b0be660999
4 changed files with 548 additions and 0 deletions

View File

@ -1,4 +1,5 @@
from collections import defaultdict
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta
from typing import Protocol
@ -29,6 +30,13 @@ class ReportDeviceLike(Protocol):
sort_order: int
@dataclass(frozen=True)
class ItemTimeRange:
item: ReportItemLike
start_at: datetime
end_at: datetime
SHIFT_BUCKETS = ("day", "overtime", "night")
SHIFT_LABELS = {
"day": "白班",
@ -389,6 +397,71 @@ def _allocate_device_item_minutes(
)
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]
for item in grouped_items[item_start]:
ranges.append(ItemTimeRange(item=item, start_at=item_start, end_at=item_end))
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:
return [ItemTimeRange(item=item, start_at=start_at, end_at=end_at) 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,

View File

@ -0,0 +1,260 @@
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)
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

View File

@ -310,3 +310,16 @@ def test_dinner_blank_interval_counts_as_overtime():
assert metrics["shift_overtime_minutes"] == 40
assert metrics["shift_night_minutes"] == 0
assert item.allocated_minutes == 40
def test_midnight_to_night_end_remains_night_not_blank_overtime():
start_at = datetime(2026, 7, 25, 0, 0, 0)
end_at = datetime(2026, 7, 25, 6, 0, 0)
item = Item(good_qty=10)
metrics = calculate_report_metrics(start_at, end_at, [item])
assert metrics["effective_minutes"] == 360
assert metrics["shift_night_minutes"] == 360
assert metrics["shift_overtime_minutes"] == 0
assert metrics["shift_day_minutes"] == 0

View File

@ -0,0 +1,202 @@
from datetime import date, datetime
from types import SimpleNamespace
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import ProductionReport, ProductionReportAllocation, ProductionReportItem
from app.services.report_allocations import (
allocation_summary_text,
build_report_allocation_drafts,
refresh_report_allocations,
)
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw):
return "INTEGER"
def _item(
*,
id: int = 1,
good_qty: float = 0,
defect_qty: float = 0,
scrap_qty: float = 0,
changeover_count: float = 0,
process_unit_price_yuan: float = 0,
started_at: datetime | None = None,
):
return SimpleNamespace(
id=id,
device_no="23#",
product_name="23#",
process_name="冲压",
started_at=started_at,
standard_beat=1,
standard_workload=0,
stamping_method=None,
good_qty=good_qty,
defect_qty=defect_qty,
scrap_qty=scrap_qty,
changeover_count=changeover_count,
process_unit_price_yuan=process_unit_price_yuan,
allocated_minutes=0,
)
def _report(start_at: datetime, end_at: datetime, item, *, id: int = 1, report_date: date | None = None):
return SimpleNamespace(
id=id,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=report_date or start_at.date(),
start_at=start_at,
end_at=end_at,
items=[item],
session=SimpleNamespace(devices=[]),
)
def test_cross_day_after_midnight_allocates_previous_night_and_current_overtime_day():
item = _item(
good_qty=650,
defect_qty=65,
scrap_qty=6.5,
changeover_count=13,
process_unit_price_yuan=1,
)
report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert len(rows) == 2
previous, current = rows
assert previous.allocation_date == date(2026, 7, 24)
assert previous.night_minutes == 350
assert previous.day_minutes == 0
assert previous.overtime_minutes == 0
assert previous.effective_minutes == 350
assert previous.good_qty == 350
assert previous.defect_qty == 35
assert previous.scrap_qty == 3.5
assert previous.changeover_count == 7
assert previous.reference_wage == 350
assert current.allocation_date == date(2026, 7, 25)
assert current.night_minutes == 0
assert current.overtime_minutes == 120
assert current.day_minutes == 180
assert current.effective_minutes == 300
assert current.good_qty == 300
assert current.defect_qty == 30
assert current.scrap_qty == 3
assert current.changeover_count == 6
assert current.reference_wage == 300
def test_one_minute_before_night_end_allocates_to_previous_date():
item = _item(good_qty=301, process_unit_price_yuan=2)
report = _report(datetime(2026, 7, 25, 5, 59), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert [(row.allocation_date, row.night_minutes, row.overtime_minutes, row.day_minutes) for row in rows] == [
(date(2026, 7, 24), 1, 0, 0),
(date(2026, 7, 25), 0, 120, 180),
]
assert rows[0].good_qty == 1
assert rows[0].reference_wage == 2
assert rows[1].good_qty == 300
assert rows[1].reference_wage == 600
def test_zero_effective_minutes_falls_back_to_original_report_date():
item = _item(good_qty=5, defect_qty=1, scrap_qty=0.5, changeover_count=2, process_unit_price_yuan=3)
report = _report(
datetime(2026, 7, 25, 9, 0),
datetime(2026, 7, 25, 9, 0),
item,
report_date=date(2026, 7, 25),
)
rows = build_report_allocation_drafts(report)
assert len(rows) == 1
row = rows[0]
assert row.allocation_date == date(2026, 7, 25)
assert row.effective_minutes == 0
assert row.day_minutes == 0
assert row.overtime_minutes == 0
assert row.night_minutes == 0
assert row.good_qty == 5
assert row.defect_qty == 1
assert row.scrap_qty == 0.5
assert row.changeover_count == 2
assert row.reference_wage == 15
def test_allocation_summary_text_groups_dates_and_shift_kinds():
item = _item(good_qty=650, process_unit_price_yuan=1)
report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert allocation_summary_text(rows) == "2026-07-24 夜班5.83小时2026-07-25 白班3小时、加班2小时"
def test_refresh_report_allocations_replaces_existing_rows_and_flushes():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, future=True)
db = SessionLocal()
try:
report = ProductionReport(
id=1,
session_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
report_date=date(2026, 7, 25),
start_at=datetime(2026, 7, 25, 5, 59),
end_at=datetime(2026, 7, 25, 11, 0),
)
item = ProductionReportItem(
id=1,
report_id=1,
attendance_point_name="总厂",
device_no="23#",
project_no="P1",
product_name="23#",
process_name="冲压",
process_unit_price_yuan=2,
good_qty=301,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
standard_beat=1,
standard_workload=0,
)
stale = ProductionReportAllocation(
id=99,
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 20),
effective_minutes=1,
)
report.items.append(item)
db.add_all([report, stale])
db.flush()
rows = refresh_report_allocations(db, report)
assert len(rows) == 2
assert all(row.id is not None for row in rows)
stored = db.scalars(select(ProductionReportAllocation).order_by(ProductionReportAllocation.allocation_date)).all()
assert [row.allocation_date for row in stored] == [date(2026, 7, 24), date(2026, 7, 25)]
assert [row.good_qty for row in stored] == [1, 300]
finally:
db.close()