352 lines
12 KiB
Python
352 lines
12 KiB
Python
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.metrics import calculate_report_metrics
|
||
from app.services.report_allocations import (
|
||
allocation_summary_text,
|
||
build_report_allocation_drafts,
|
||
refresh_report_allocations,
|
||
)
|
||
from app.services.work_schedule import WorkScheduleConfig
|
||
|
||
|
||
@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,
|
||
device_no: str = "23#",
|
||
product_name: str | None = None,
|
||
process_name: str | None = "冲压",
|
||
):
|
||
return SimpleNamespace(
|
||
id=id,
|
||
device_no=device_no,
|
||
product_name=product_name or device_no,
|
||
process_name=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 _device(scanned_at: datetime):
|
||
return SimpleNamespace(
|
||
device_no="23#",
|
||
process_name="冲压",
|
||
scanned_at=scanned_at,
|
||
released_at=None,
|
||
release_reason=None,
|
||
sort_order=0,
|
||
)
|
||
|
||
|
||
def _report(
|
||
start_at: datetime,
|
||
end_at: datetime,
|
||
item,
|
||
*,
|
||
id: int = 1,
|
||
report_date: date | None = None,
|
||
devices: list | 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 if isinstance(item, list) else [item],
|
||
session=SimpleNamespace(devices=devices or []),
|
||
)
|
||
|
||
|
||
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_no_device_shared_full_report_splits_effective_minutes_between_items():
|
||
first_item = _item(id=1, good_qty=10)
|
||
second_item = _item(id=2, good_qty=20)
|
||
report = _report(
|
||
datetime(2026, 7, 25, 8, 0),
|
||
datetime(2026, 7, 25, 11, 0),
|
||
[first_item, second_item],
|
||
)
|
||
|
||
rows = build_report_allocation_drafts(report)
|
||
|
||
assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [
|
||
(1, 90, 90),
|
||
(2, 90, 90),
|
||
]
|
||
|
||
|
||
def test_items_sharing_same_started_at_split_the_segment_minutes():
|
||
started_at = datetime(2026, 7, 25, 8, 0)
|
||
first_item = _item(id=1, good_qty=10, started_at=started_at)
|
||
second_item = _item(id=2, good_qty=20, started_at=started_at)
|
||
report = _report(
|
||
started_at,
|
||
datetime(2026, 7, 25, 11, 0),
|
||
[first_item, second_item],
|
||
devices=[_device(started_at)],
|
||
)
|
||
|
||
rows = build_report_allocation_drafts(report)
|
||
|
||
assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [
|
||
(1, 90, 90),
|
||
(2, 90, 90),
|
||
]
|
||
|
||
|
||
def test_device_matched_and_unmatched_items_match_metrics_remaining_minutes():
|
||
start_at = datetime(2026, 7, 25, 8, 0)
|
||
end_at = datetime(2026, 7, 25, 11, 0)
|
||
matching_item = _item(id=1, good_qty=10, device_no="23#")
|
||
unmatched_item = _item(id=2, good_qty=20, device_no="99#")
|
||
report = _report(start_at, end_at, [matching_item, unmatched_item], devices=[_device(start_at)])
|
||
|
||
calculate_report_metrics(start_at, end_at, [matching_item, unmatched_item], devices=[_device(start_at)])
|
||
rows = build_report_allocation_drafts(report)
|
||
|
||
assert {matching_item.id: matching_item.allocated_minutes, unmatched_item.id: unmatched_item.allocated_minutes} == {
|
||
1: 180,
|
||
2: 0,
|
||
}
|
||
assert {row.report_item_id: row.effective_minutes for row in rows} == {
|
||
1: 180,
|
||
2: 0,
|
||
}
|
||
|
||
|
||
def test_night_allocation_uses_meal_deducted_minutes_with_previous_date_rules():
|
||
schedule = WorkScheduleConfig(
|
||
day_start="08:00",
|
||
day_end="17:20",
|
||
lunch_start="11:40",
|
||
lunch_end="12:40",
|
||
dinner_start="21:00",
|
||
dinner_end="21:30",
|
||
overtime_start="18:00",
|
||
overtime_end="20:00",
|
||
night_start="20:00",
|
||
night_end="06:00",
|
||
)
|
||
start_at = datetime(2026, 7, 25, 20, 0)
|
||
end_at = datetime(2026, 7, 25, 22, 0)
|
||
item = _item(id=1, good_qty=90)
|
||
report = _report(start_at, end_at, item)
|
||
|
||
metrics = calculate_report_metrics(start_at, end_at, [item], schedule=schedule)
|
||
rows = build_report_allocation_drafts(report, schedule=schedule)
|
||
|
||
assert metrics["effective_minutes"] == 90
|
||
assert metrics["shift_night_minutes"] == 90
|
||
assert [(row.allocation_date, row.night_minutes, row.effective_minutes) for row in rows] == [
|
||
(date(2026, 7, 25), 90, 90),
|
||
]
|
||
|
||
|
||
def test_blank_overtime_meal_deduction_is_clipped_like_metrics():
|
||
start_at = datetime(2026, 7, 25, 6, 0)
|
||
end_at = datetime(2026, 7, 25, 19, 0)
|
||
item = _item(id=1, good_qty=680)
|
||
report = _report(start_at, end_at, item)
|
||
|
||
metrics = calculate_report_metrics(start_at, end_at, [item])
|
||
rows = build_report_allocation_drafts(report)
|
||
|
||
assert metrics["effective_minutes"] == 680
|
||
assert metrics["shift_day_minutes"] == 500
|
||
assert metrics["shift_overtime_minutes"] == 180
|
||
assert [(row.effective_minutes, row.day_minutes, row.overtime_minutes, row.night_minutes) for row in rows] == [
|
||
(680, 500, 180, 0),
|
||
]
|
||
|
||
|
||
def test_same_device_item_split_inside_meal_is_rescaled_to_metrics_effective_minutes():
|
||
start_at = datetime(2026, 7, 25, 8, 0)
|
||
second_started_at = datetime(2026, 7, 25, 12, 0)
|
||
end_at = datetime(2026, 7, 25, 13, 0)
|
||
first_item = _item(id=1, good_qty=192, started_at=start_at)
|
||
second_item = _item(id=2, good_qty=48, started_at=second_started_at)
|
||
report = _report(start_at, end_at, [first_item, second_item], devices=[_device(start_at)])
|
||
|
||
metrics = calculate_report_metrics(start_at, end_at, [first_item, second_item], devices=[_device(start_at)])
|
||
rows = build_report_allocation_drafts(report)
|
||
|
||
assert metrics["effective_minutes"] == 240
|
||
assert metrics["shift_day_minutes"] == 240
|
||
assert {first_item.id: first_item.allocated_minutes, second_item.id: second_item.allocated_minutes} == {
|
||
1: 192,
|
||
2: 48,
|
||
}
|
||
assert [(row.report_item_id, row.effective_minutes, row.day_minutes) for row in rows] == [
|
||
(1, 192, 192),
|
||
(2, 48, 48),
|
||
]
|
||
assert sum(row.effective_minutes for row in rows) == 240
|
||
|
||
|
||
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()
|