diff --git a/app/routers/reconciliation.py b/app/routers/reconciliation.py index af41bf5..9fc378c 100644 --- a/app/routers/reconciliation.py +++ b/app/routers/reconciliation.py @@ -1,7 +1,7 @@ from datetime import date from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import extract, func, select +from sqlalchemy import and_, extract, func, select from sqlalchemy.orm import Session from app.database import get_db @@ -101,7 +101,13 @@ def _reported_good_quantities( func.sum(ProductionReportAllocation.good_qty).label("good_qty"), ) .select_from(ProductionReportAllocation) - .join(ProductionReportItem, ProductionReportItem.id == ProductionReportAllocation.report_item_id) + .join( + ProductionReportItem, + and_( + ProductionReportItem.id == ProductionReportAllocation.report_item_id, + ProductionReportItem.report_id == ProductionReportAllocation.report_id, + ), + ) .join(ProductionReport, ProductionReport.id == ProductionReportAllocation.report_id) .where( ProductionReport.status == ReportStatus.approved, diff --git a/tests/test_reconciliation_allocations.py b/tests/test_reconciliation_allocations.py index 6f7deb4..0feb2e9 100644 --- a/tests/test_reconciliation_allocations.py +++ b/tests/test_reconciliation_allocations.py @@ -1,6 +1,113 @@ +from datetime import date, datetime + +from sqlalchemy import BigInteger, create_engine +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.orm import sessionmaker + +from app.database import Base +from app.models import ( + ProductionReport, + ProductionReportAllocation, + ProductionReportItem, + ReportStatus, +) from app.routers import reconciliation +@compiles(BigInteger, "sqlite") +def _compile_big_integer_for_sqlite(type_, compiler, **kw): + return "INTEGER" + + +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 _report( + *, + id: int, + status: ReportStatus = ReportStatus.approved, + is_voided: bool = False, +): + return ProductionReport( + id=id, + session_id=id, + attendance_point_name="嘉恒", + employee_phone="13800000000", + report_date=date(2026, 7, 31), + start_at=datetime(2026, 7, 31, 20, 0), + end_at=datetime(2026, 8, 1, 2, 0), + duration_minutes=360, + break_minutes=0, + effective_minutes=360, + total_good_qty=650, + total_output_qty=650, + actual_beat=1, + standard_beat=1, + expected_workload=0, + pace_rate=0, + workload_rate=0, + status=status, + is_voided=is_voided, + submitted_at=datetime(2026, 8, 1, 2, 1), + ) + + +def _item( + *, + id: int, + report_id: int, + process_name: str = "2序", +): + return ProductionReportItem( + id=id, + report_id=report_id, + attendance_point_name="嘉恒", + device_no="A-01", + project_no="P1", + product_name="产品A", + process_name=process_name, + operator_count=1, + process_unit_price_yuan=1, + standard_beat=1, + standard_workload=0, + good_qty=650, + defect_qty=0, + scrap_qty=0, + allocated_minutes=360, + ) + + +def _allocation( + *, + id: int, + report_id: int, + report_item_id: int, + allocation_date: date, + good_qty: float, +): + return ProductionReportAllocation( + id=id, + report_id=report_id, + report_item_id=report_item_id, + attendance_point_name="嘉恒", + employee_phone="13800000000", + allocation_date=allocation_date, + day_minutes=0, + overtime_minutes=0, + night_minutes=0, + effective_minutes=0, + good_qty=good_qty, + defect_qty=0, + scrap_qty=0, + changeover_count=0, + reference_wage=0, + ) + + def test_fold_reported_good_quantity_rows_uses_allocation_month_for_latest_process_only(): rows = [ ("嘉恒", "产品A", "1序", 7, 999), @@ -13,3 +120,104 @@ def test_fold_reported_good_quantity_rows_uses_allocation_month_for_latest_proce assert totals[("嘉恒", "产品A", 7)] == 350 assert totals[("嘉恒", "产品A", 8)] == 300 + + +def test_reported_good_quantities_groups_by_allocation_month_for_approved_unvoided_latest_process(): + db = _sqlite_db() + try: + approved = _report(id=1) + pending = _report(id=2, status=ReportStatus.pending) + voided = _report(id=3, is_voided=True) + latest_item = _item(id=1, report_id=approved.id, process_name="2序") + earlier_item = _item(id=2, report_id=approved.id, process_name="1序") + pending_item = _item(id=3, report_id=pending.id, process_name="2序") + voided_item = _item(id=4, report_id=voided.id, process_name="2序") + db.add_all([ + approved, + pending, + voided, + latest_item, + earlier_item, + pending_item, + voided_item, + _allocation( + id=1, + report_id=approved.id, + report_item_id=latest_item.id, + allocation_date=date(2026, 7, 31), + good_qty=350, + ), + _allocation( + id=2, + report_id=approved.id, + report_item_id=latest_item.id, + allocation_date=date(2026, 8, 1), + good_qty=300, + ), + _allocation( + id=3, + report_id=approved.id, + report_item_id=earlier_item.id, + allocation_date=date(2026, 7, 31), + good_qty=999, + ), + _allocation( + id=4, + report_id=pending.id, + report_item_id=pending_item.id, + allocation_date=date(2026, 7, 31), + good_qty=111, + ), + _allocation( + id=5, + report_id=voided.id, + report_item_id=voided_item.id, + allocation_date=date(2026, 8, 1), + good_qty=222, + ), + ]) + db.commit() + + totals = reconciliation._reported_good_quantities( + db, + year=2026, + product_keys=[("嘉恒", "产品A")], + latest_processes={("嘉恒", "产品A"): {"2序"}}, + ) + + assert totals[("嘉恒", "产品A", 7)] == 350 + assert totals[("嘉恒", "产品A", 8)] == 300 + finally: + db.close() + + +def test_reported_good_quantities_ignores_mismatched_allocation_report_item_rows(): + db = _sqlite_db() + try: + approved = _report(id=1) + other_report = _report(id=2, status=ReportStatus.pending) + other_item = _item(id=2, report_id=other_report.id, process_name="2序") + db.add_all([ + approved, + other_report, + other_item, + _allocation( + id=1, + report_id=approved.id, + report_item_id=other_item.id, + allocation_date=date(2026, 7, 31), + good_qty=123, + ), + ]) + db.commit() + + totals = reconciliation._reported_good_quantities( + db, + year=2026, + product_keys=[("嘉恒", "产品A")], + latest_processes={("嘉恒", "产品A"): {"2序"}}, + ) + + assert totals == {} + finally: + db.close()