Files
JhHardwareWRS_BackPoint/tests/test_report_allocations.py

1110 lines
37 KiB
Python

from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
import pytest
from sqlalchemy import BigInteger, create_engine, inspect, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import raiseload, selectinload, sessionmaker
from app.database import Base
from app.models import (
AttendancePoint,
Equipment,
Personnel,
Product,
ProductionReport,
ProductionReportAllocation,
ProductionReportItem,
ReportStatus,
Role,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.routers import reports as reports_router
from app.routers import reviews as reviews_router
from app.schemas import (
ApproveReportRequest,
CleaningReportSubmitItem,
CleaningReportSubmitRequest,
ReportItemCorrection,
ReportSubmitBlock,
ReportSubmitItem,
ReportSubmitRequest,
)
from app.services import auto_submit as auto_submit_service
from app.services import reporting_window as reporting_window_service
from app.services.report_lifecycle import purge_expired_voided_reports
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.serializers import report_out
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, *, device_no: str = "23#", process_name: str = "冲压", sort_order: int = 0):
return SimpleNamespace(
device_no=device_no,
process_name=process_name,
scanned_at=scanned_at,
released_at=None,
release_reason=None,
sort_order=sort_order,
)
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_short_shared_report_rounding_residual_keeps_aggregate_night_total():
start_at = datetime(2026, 5, 3, 1, 4, 50)
end_at = datetime(2026, 5, 3, 1, 4, 53)
first_item = _item(id=1, good_qty=1)
second_item = _item(id=2, good_qty=1)
report = _report(start_at, end_at, [first_item, second_item])
metrics = calculate_report_metrics(start_at, end_at, [first_item, second_item])
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 0.05
assert metrics["shift_night_minutes"] == 0.05
assert [row.allocation_date for row in rows] == [date(2026, 5, 2), date(2026, 5, 2)]
assert [row.effective_minutes for row in rows] == [0.03, 0.02]
assert round(sum(row.effective_minutes for row in rows), 2) == 0.05
assert round(sum(row.night_minutes for row in rows), 2) == 0.05
assert round(sum(row.day_minutes for row in rows), 2) == 0
assert round(sum(row.overtime_minutes for row in rows), 2) == 0
assert all(row.effective_minutes == round(row.day_minutes + row.overtime_minutes + row.night_minutes, 2) for row in rows)
def test_item_split_fields_keep_original_totals_after_rounding_residual():
item = _item(
good_qty=1,
defect_qty=1,
scrap_qty=1,
changeover_count=1,
process_unit_price_yuan=1,
)
report = _report(datetime(2026, 7, 25, 6, 0), datetime(2026, 7, 28, 6, 0), item)
rows = build_report_allocation_drafts(report)
assert [row.allocation_date for row in rows] == [
date(2026, 7, 25),
date(2026, 7, 26),
date(2026, 7, 27),
]
assert [row.effective_minutes for row in rows] == [1340, 1340, 1340]
assert round(sum(row.good_qty for row in rows), 2) == 1
assert round(sum(row.defect_qty for row in rows), 2) == 1
assert round(sum(row.scrap_qty for row in rows), 2) == 1
assert round(sum(row.changeover_count for row in rows), 2) == 1
assert round(sum(row.reference_wage for row in rows), 2) == 1
def test_reference_wage_uses_half_up_rounding_for_allocation_totals():
item = _item(good_qty=115, process_unit_price_yuan=Decimal("0.867"))
report = _report(datetime(2026, 7, 25, 8, 0), datetime(2026, 7, 25, 10, 0), item)
rows = build_report_allocation_drafts(report)
assert len(rows) == 1
assert rows[0].reference_wage == 99.71
def test_split_reference_wage_residual_keeps_half_up_item_total():
item = _item(good_qty=115, process_unit_price_yuan=Decimal("0.867"))
report = _report(datetime(2026, 7, 25, 0, 10), datetime(2026, 7, 25, 11, 0), item)
rows = build_report_allocation_drafts(report)
assert [row.allocation_date for row in rows] == [date(2026, 7, 24), date(2026, 7, 25)]
assert round(sum(row.reference_wage for row in rows), 2) == 99.71
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_unassigned_device_segment_is_allocated_when_all_items_already_matched():
start_at = datetime(2026, 7, 25, 0, 0)
switch_at = datetime(2026, 7, 25, 8, 0)
end_at = datetime(2026, 7, 25, 10, 0)
devices = [
_device(start_at, device_no="23#", sort_order=0),
_device(switch_at, device_no="99#", sort_order=1),
]
item = _item(id=1, good_qty=600, device_no="23#", started_at=start_at)
report = _report(start_at, end_at, item, devices=devices)
metrics = calculate_report_metrics(start_at, end_at, [item], devices=devices)
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 600
assert metrics["shift_day_minutes"] == 120
assert metrics["shift_overtime_minutes"] == 120
assert metrics["shift_night_minutes"] == 360
assert round(sum(row.effective_minutes for row in rows), 2) == metrics["effective_minutes"]
assert {
"day": round(sum(row.day_minutes for row in rows), 2),
"overtime": round(sum(row.overtime_minutes for row in rows), 2),
"night": round(sum(row.night_minutes for row in rows), 2),
} == {
"day": metrics["shift_day_minutes"],
"overtime": metrics["shift_overtime_minutes"],
"night": metrics["shift_night_minutes"],
}
assert all(row.effective_minutes == round(row.day_minutes + row.overtime_minutes + row.night_minutes, 2) for row in rows)
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_matched_meal_split_and_unmatched_device_range_follow_metrics_order():
start_at = datetime(2026, 7, 25, 8, 0)
second_started_at = datetime(2026, 7, 25, 12, 0)
switch_at = datetime(2026, 7, 25, 13, 0)
end_at = datetime(2026, 7, 25, 20, 0)
devices = [
_device(start_at, device_no="23#", sort_order=0),
_device(switch_at, device_no="77#", sort_order=1),
]
first_item = _item(id=1, good_qty=240, device_no="23#", started_at=start_at)
second_item = _item(id=2, good_qty=60, device_no="23#", started_at=second_started_at)
unmatched_item = _item(id=3, good_qty=320, device_no="99#")
items = [first_item, second_item, unmatched_item]
report = _report(start_at, end_at, items, devices=devices)
metrics = calculate_report_metrics(start_at, end_at, items, devices=devices)
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 620
assert {item.id: item.allocated_minutes for item in items} == {
1: 240,
2: 60,
3: 320,
}
assert {
item_id: round(sum(row.effective_minutes for row in rows if row.report_item_id == item_id), 2)
for item_id in [1, 2, 3]
} == {
1: 240,
2: 60,
3: 320,
}
assert round(sum(row.effective_minutes for row in rows), 2) == metrics["effective_minutes"]
def test_item_totals_anchor_to_metrics_when_unmatched_remaining_buckets_exceed_item_target():
start_at = datetime(2026, 7, 25, 8, 0)
second_started_at = datetime(2026, 7, 25, 12, 0)
switch_at = datetime(2026, 7, 25, 18, 0)
end_at = datetime(2026, 7, 25, 20, 0)
devices = [
_device(start_at, device_no="23#", sort_order=0),
_device(switch_at, device_no="77#", sort_order=1),
]
first_item = _item(id=1, good_qty=240, device_no="23#", started_at=start_at)
second_item = _item(id=2, good_qty=360, device_no="23#", started_at=second_started_at)
unmatched_item = _item(id=3, good_qty=60, device_no="99#")
items = [first_item, second_item, unmatched_item]
report = _report(start_at, end_at, items, devices=devices)
metrics = calculate_report_metrics(start_at, end_at, items, devices=devices)
rows = build_report_allocation_drafts(report)
assert metrics["effective_minutes"] == 660
assert {item.id: item.allocated_minutes for item in items} == {
1: 240,
2: 360,
3: 60,
}
assert {
item_id: round(sum(row.effective_minutes for row in rows if row.report_item_id == item_id), 2)
for item_id in [1, 2, 3]
} == {
1: 240,
2: 360,
3: 60,
}
assert {
"day": round(sum(row.day_minutes for row in rows), 2),
"overtime": round(sum(row.overtime_minutes for row in rows), 2),
"night": round(sum(row.night_minutes for row in rows), 2),
} == {
"day": metrics["shift_day_minutes"],
"overtime": metrics["shift_overtime_minutes"],
"night": metrics["shift_night_minutes"],
}
assert round(sum(row.effective_minutes for row in rows), 2) == 660
assert all(row.effective_minutes == row.day_minutes + row.overtime_minutes + row.night_minutes for row in rows)
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_report_out_exposes_allocation_summary_and_item_allocations():
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, 8, 0),
end_at=datetime(2026, 7, 25, 18, 30),
break_minutes=60,
status=ReportStatus.pending,
submitted_at=datetime(2026, 7, 25, 18, 31),
)
report.employee = Personnel(phone="13800000000", name="张三")
report.session = WorkSession(
id=1,
employee_phone="13800000000",
attendance_point_name="总厂",
start_at=datetime(2026, 7, 25, 8, 0),
status=SessionStatus.submitted,
)
report.session.devices = []
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=Decimal("2.50"),
good_qty=Decimal("100.50"),
defect_qty=Decimal("2.25"),
scrap_qty=Decimal("1.25"),
changeover_count=Decimal("1.50"),
standard_beat=Decimal("3.00"),
standard_workload=Decimal("0.00"),
)
allocation = ProductionReportAllocation(
id=1,
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 25),
day_minutes=Decimal("120.50"),
overtime_minutes=Decimal("30.25"),
night_minutes=Decimal("0.00"),
effective_minutes=Decimal("150.75"),
good_qty=Decimal("100.50"),
defect_qty=Decimal("2.25"),
scrap_qty=Decimal("1.25"),
changeover_count=Decimal("1.50"),
reference_wage=Decimal("251.25"),
)
previous_allocation = ProductionReportAllocation(
id=2,
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 24),
day_minutes=Decimal("0.00"),
overtime_minutes=Decimal("0.00"),
night_minutes=Decimal("60.00"),
effective_minutes=Decimal("60.00"),
good_qty=Decimal("40.00"),
defect_qty=Decimal("0.00"),
scrap_qty=Decimal("0.00"),
changeover_count=Decimal("0.00"),
reference_wage=Decimal("100.00"),
)
item.allocations = [allocation, previous_allocation]
report.items = [item]
report.allocations = [allocation, previous_allocation]
report.audit_logs = []
output = report_out(report)
assert output.allocation_summary_text == "2026-07-24 夜班1小时;2026-07-25 白班2.01小时、加班0.5小时"
assert [row.allocation_date for row in output.items[0].allocations] == [date(2026, 7, 24), date(2026, 7, 25)]
item_allocation = output.items[0].allocations[1]
assert item_allocation.allocation_date == date(2026, 7, 25)
assert item_allocation.day_minutes == 120.5
assert item_allocation.overtime_minutes == 30.25
assert item_allocation.effective_minutes == 150.75
assert item_allocation.good_qty == 100.5
assert item_allocation.defect_qty == 2.25
assert item_allocation.scrap_qty == 1.25
assert item_allocation.changeover_count == 1.5
assert item_allocation.reference_wage == 251.25
def test_report_out_handles_empty_allocations():
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, 8, 0),
end_at=datetime(2026, 7, 25, 9, 0),
break_minutes=0,
status=ReportStatus.pending,
submitted_at=datetime(2026, 7, 25, 9, 1),
)
report.employee = Personnel(phone="13800000000", name="张三")
report.session = WorkSession(
id=1,
employee_phone="13800000000",
attendance_point_name="总厂",
start_at=datetime(2026, 7, 25, 8, 0),
status=SessionStatus.submitted,
)
report.session.devices = []
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=0,
good_qty=0,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
standard_beat=1,
standard_workload=0,
)
item.allocations = []
report.items = [item]
report.allocations = []
report.audit_logs = []
output = report_out(report)
assert output.allocation_summary_text == ""
assert output.items[0].allocations == []
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()
def _sqlite_db():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine, future=True)
return SessionLocal()
def test_report_allocation_schema_has_unique_report_item_date_key():
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
inspector = inspect(engine)
unique_column_sets = [
tuple(constraint["column_names"])
for constraint in inspector.get_unique_constraints("production_report_allocations")
]
unique_column_sets.extend(
tuple(index["column_names"])
for index in inspector.get_indexes("production_report_allocations")
if index.get("unique")
)
assert ("report_id", "report_item_id", "allocation_date") in unique_column_sets
def test_report_allocation_rejects_duplicate_report_item_date_key():
db = _sqlite_db()
try:
first = ProductionReportAllocation(
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 25),
)
duplicate = ProductionReportAllocation(
report_id=1,
report_item_id=1,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 25),
)
db.add(first)
db.flush()
db.add(duplicate)
with pytest.raises(IntegrityError):
db.flush()
finally:
db.close()
def test_report_out_skips_unloaded_allocations_without_lazy_load():
db = _sqlite_db()
try:
person = Personnel(phone="13800000000", name="张三")
session = WorkSession(
id=1,
attendance_point_name="总厂",
employee_phone=person.phone,
start_at=datetime(2026, 7, 25, 8, 0),
end_at=datetime(2026, 7, 25, 10, 0),
status=SessionStatus.submitted,
)
report = ProductionReport(
id=1,
session_id=session.id,
attendance_point_name="总厂",
employee_phone=person.phone,
report_date=date(2026, 7, 25),
start_at=datetime(2026, 7, 25, 8, 0),
end_at=datetime(2026, 7, 25, 10, 0),
break_minutes=0,
status=ReportStatus.pending,
submitted_at=datetime(2026, 7, 25, 10, 1),
)
item = ProductionReportItem(
id=1,
report_id=report.id,
attendance_point_name="总厂",
device_no="23#",
project_no="P1",
product_name="23#",
process_name="冲压",
process_unit_price_yuan=1,
good_qty=100,
defect_qty=0,
scrap_qty=0,
changeover_count=0,
standard_beat=1,
standard_workload=0,
)
allocation = ProductionReportAllocation(
id=1,
report_id=report.id,
report_item_id=item.id,
attendance_point_name="总厂",
employee_phone=person.phone,
allocation_date=date(2026, 7, 25),
day_minutes=120,
effective_minutes=120,
good_qty=100,
reference_wage=100,
)
db.add_all([person, session, report, item, allocation])
db.commit()
loaded_report = db.scalar(
select(ProductionReport)
.options(
selectinload(ProductionReport.employee),
selectinload(ProductionReport.items).raiseload(ProductionReportItem.allocations),
selectinload(ProductionReport.audit_logs),
selectinload(ProductionReport.session).selectinload(WorkSession.devices),
raiseload(ProductionReport.allocations),
)
.where(ProductionReport.id == report.id)
)
output = report_out(loaded_report)
assert output.allocation_summary_text == ""
assert output.items[0].allocations == []
finally:
db.close()
def _product(
*,
point_name: str = "总厂",
project_no: str = "P1",
product_name: str = "23#",
process_name: str = "冲压",
stamping_method: str | None = None,
):
return Product(
attendance_point_name=point_name,
project_no=project_no,
product_name=product_name,
device_no="",
process_name=process_name,
stamping_method=stamping_method,
operator_count=1,
process_unit_price_yuan=2,
standard_beat=1,
standard_workload=0,
)
def _press_equipment(point_name: str = "总厂", device_no: str = "23#"):
return Equipment(
attendance_point_name=point_name,
device_no=device_no,
device_type="冲压设备",
)
def _session_with_device(
*,
point_name: str = "总厂",
employee_phone: str = "13800000000",
start_at: datetime = datetime(2026, 7, 25, 8, 0),
end_at: datetime = datetime(2026, 7, 25, 11, 0),
):
return WorkSession(
attendance_point_name=point_name,
employee_phone=employee_phone,
start_at=start_at,
end_at=end_at,
status=SessionStatus.reporting,
devices=[
WorkSessionDevice(
attendance_point_name=point_name,
device_no="23#",
process_name="冲压",
scanned_at=start_at,
sort_order=0,
)
],
)
def _stored_allocations(db, report_id: int):
return db.scalars(
select(ProductionReportAllocation).where(ProductionReportAllocation.report_id == report_id)
).all()
def test_submit_report_creates_allocations(monkeypatch):
db = _sqlite_db()
try:
user = SimpleNamespace(phone="13800000000")
session = _session_with_device(employee_phone=user.phone)
db.add_all([session, _press_equipment(), _product()])
db.commit()
monkeypatch.setattr(reports_router, "report_out", lambda report, schedule=None: report)
monkeypatch.setattr(reporting_window_service, "now", lambda: datetime(2026, 7, 25, 11, 5))
report = reports_router.submit_report(
ReportSubmitRequest(
session_id=session.id,
device_blocks=[
ReportSubmitBlock(
device_no="23#",
process_name="冲压",
items=[
ReportSubmitItem(
device_no="23#",
project_no="P1",
product_name="23#",
process_name="冲压",
standard_beat=1,
good_qty=180,
)
],
)
],
),
user=user,
db=db,
)
allocations = _stored_allocations(db, report.id)
assert len(allocations) == 1
assert allocations[0].report_item_id == report.items[0].id
assert allocations[0].effective_minutes == 180
assert allocations[0].good_qty == 180
finally:
db.close()
def test_submit_cleaning_report_creates_zero_minute_allocation(monkeypatch):
db = _sqlite_db()
try:
point_name = "总厂"
user = SimpleNamespace(phone="13800000000", role=Role.manager)
db.add_all(
[
AttendancePoint(name=point_name, latitude=0, longitude=0, radius_meters=500, is_active=True),
Equipment(attendance_point_name=point_name, device_no="C1", device_type="清洗设备"),
_product(
point_name=point_name,
project_no="CLEAN1",
product_name="清洗产品",
process_name="清洗",
stamping_method="清洗",
),
]
)
db.commit()
monkeypatch.setattr(reports_router, "report_out", lambda report, schedule=None: report)
report = reports_router.submit_cleaning_report(
CleaningReportSubmitRequest(
latitude=0,
longitude=0,
items=[
CleaningReportSubmitItem(
attendance_point_name=point_name,
device_no="C1",
product_name="清洗产品",
process_name="清洗",
quantity=25,
)
],
),
user=user,
db=db,
)
allocations = _stored_allocations(db, report.id)
assert len(allocations) == 1
assert allocations[0].effective_minutes == 0
assert allocations[0].good_qty == 25
finally:
db.close()
def test_auto_submit_session_creates_allocations():
db = _sqlite_db()
try:
session = _session_with_device(
start_at=datetime(2026, 7, 25, 8, 0),
end_at=None,
)
session.status = SessionStatus.active
db.add_all([session, _product()])
db.commit()
report = auto_submit_service.auto_submit_session(
db,
session,
submitted_at=datetime(2026, 7, 25, 11, 0),
)
db.flush()
allocations = _stored_allocations(db, report.id)
assert len(allocations) == 1
assert allocations[0].report_item_id == report.items[0].id
assert allocations[0].effective_minutes == 180
finally:
db.close()
def test_approve_report_refreshes_allocations_after_edit_save(monkeypatch):
db = _sqlite_db()
try:
user = SimpleNamespace(phone="13900000000", role=Role.admin)
session = _session_with_device()
session.status = SessionStatus.submitted
report = ProductionReport(
attendance_point_name="总厂",
session=session,
employee_phone="13800000000",
report_date=date(2026, 7, 25),
start_at=datetime(2026, 7, 25, 8, 0),
end_at=datetime(2026, 7, 25, 11, 0),
duration_minutes=180,
effective_minutes=180,
total_good_qty=100,
total_output_qty=100,
actual_beat=1.8,
standard_beat=1,
expected_workload=100,
pace_rate=100,
workload_rate=100,
status=ReportStatus.pending,
submitted_at=datetime(2026, 7, 25, 11, 1),
items=[
ProductionReportItem(
attendance_point_name="总厂",
device_no="23#",
project_no="P1",
product_name="23#",
process_name="冲压",
process_unit_price_yuan=2,
standard_beat=1,
standard_workload=0,
good_qty=100,
defect_qty=0,
scrap_qty=0,
)
],
)
db.add_all([_press_equipment(), _product(), report])
db.flush()
db.add(
ProductionReportAllocation(
report_id=report.id,
report_item_id=report.items[0].id,
attendance_point_name="总厂",
employee_phone="13800000000",
allocation_date=date(2026, 7, 20),
effective_minutes=1,
good_qty=1,
)
)
db.commit()
monkeypatch.setattr(reviews_router, "accessible_point_names", lambda _db, _user: ["总厂"])
monkeypatch.setattr(reviews_router, "report_out", lambda report, schedule=None: report)
saved = reviews_router.approve_report(
report.id,
ApproveReportRequest(
item_corrections=[
ReportItemCorrection(id=report.items[0].id, good_qty=200)
]
),
user=user,
db=db,
)
allocations = _stored_allocations(db, saved.id)
assert len(allocations) == 1
assert allocations[0].allocation_date == date(2026, 7, 25)
assert allocations[0].good_qty == 200
assert allocations[0].reference_wage == 400
finally:
db.close()
def test_purge_expired_voided_reports_deletes_allocations_before_report_items():
report = SimpleNamespace(id=1, session=None)
operations: list[str] = []
class _Rows:
def all(self):
return [report]
class _FakeDb:
def scalars(self, _statement):
return _Rows()
def execute(self, statement):
operations.append(statement.table.name)
def delete(self, _obj):
operations.append("delete-report")
def commit(self):
operations.append("commit")
purged_count = purge_expired_voided_reports(_FakeDb())
assert purged_count == 1
assert operations.index("production_report_allocations") < operations.index("production_report_items")