diff --git a/app/routers/reports.py b/app/routers/reports.py index aa3dc03..d61e294 100644 --- a/app/routers/reports.py +++ b/app/routers/reports.py @@ -41,6 +41,7 @@ from app.services.misc_work import is_misc_product from app.services.mold_locks import release_session_locks from app.services.report_allocations import refresh_report_allocations from app.services.report_lifecycle import purge_expired_voided_reports +from app.services.report_over_limit import CHECK_SOURCE_SUBMIT, refresh_report_over_limit_snapshot from app.services.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired from app.services.serializers import equipment_option, product_option, report_out from app.services.work_schedule import get_work_schedule_config @@ -460,6 +461,7 @@ def submit_report( db.add(report) db.flush() refresh_report_allocations(db, report, schedule=schedule, commit=False) + refresh_report_over_limit_snapshot(db, report, source=CHECK_SOURCE_SUBMIT, checked_at=submitted_at) db.commit() saved = _get_report_or_404(db, report.id) diff --git a/app/routers/reviews.py b/app/routers/reviews.py index 0a615e5..725fce3 100644 --- a/app/routers/reviews.py +++ b/app/routers/reviews.py @@ -36,6 +36,7 @@ from app.services.report_lifecycle import ( purge_expired_voided_reports, restore_voided_report, ) +from app.services.report_over_limit import CHECK_SOURCE_REVIEW, refresh_report_over_limit_snapshot from app.services.serializers import report_out from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config from app.timezone import now @@ -532,6 +533,7 @@ def approve_report( _apply_metrics(report, schedule) refresh_report_allocations(db, report, schedule=schedule, commit=False) reviewed_at = now() + refresh_report_over_limit_snapshot(db, report, source=CHECK_SOURCE_REVIEW, checked_at=reviewed_at) was_pending = report.status == ReportStatus.pending report.status = ReportStatus.approved report.reviewer_phone = user.phone diff --git a/app/services/report_over_limit.py b/app/services/report_over_limit.py index cd9ebcf..07a8d1f 100644 --- a/app/services/report_over_limit.py +++ b/app/services/report_over_limit.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import re from dataclasses import dataclass +from datetime import datetime from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from sqlalchemy import func, select, text @@ -12,6 +13,7 @@ from sqlalchemy.orm import Session from app.models import Product, ProductionReport, ProductionReportItem, ReportStatus from app.services.cleaning import is_cleaning_item from app.services.misc_work import is_misc_item +from app.timezone import now logger = logging.getLogger(__name__) @@ -288,9 +290,7 @@ def evaluate_report_item_over_limit( current_order = parse_process_order(getattr(item, "process_name", None)) if current_order is None: return OverLimitResult( - status=OVER_LIMIT_STATUS_UNCHECKABLE, - over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE, - reason="非数字工序,无法校验超额", + status=OVER_LIMIT_STATUS_NOT_CHECKED, batch_no=batch_no, current_report_qty=report_qty, ) @@ -383,3 +383,55 @@ def evaluate_report_item_over_limit( current_cumulative_qty=current_cumulative_qty, current_report_qty=report_qty, ) + + +def _apply_result_to_item( + item: ProductionReportItem, + result: OverLimitResult, + *, + source: str, + checked_at: datetime, +) -> None: + item.over_limit_status = result.status + item.over_limit_type = result.over_limit_type + item.over_limit_reason = result.reason + item.over_limit_checked_at = checked_at + item.over_limit_check_source = source + item.over_limit_batch_no = result.batch_no + item.over_limit_product_gross_weight_kg = result.product_gross_weight_kg + item.over_limit_issued_weight_kg = result.issued_weight_kg + item.over_limit_max_reportable_qty = result.max_reportable_qty + item.over_limit_previous_good_qty = result.previous_good_qty + item.over_limit_current_cumulative_qty = result.current_cumulative_qty + item.over_limit_current_report_qty = result.current_report_qty + + +def refresh_report_over_limit_snapshot( + db: Session, + report: ProductionReport, + source: str, + *, + checked_at: datetime | None = None, +) -> None: + timestamp = checked_at or now() + exceeded_count = 0 + uncheckable_count = 0 + + for item in list(getattr(report, "items", []) or []): + result = evaluate_report_item_over_limit(db, report, item) + _apply_result_to_item(item, result, source=source, checked_at=timestamp) + if result.status == OVER_LIMIT_STATUS_EXCEEDED: + exceeded_count += 1 + elif result.status == OVER_LIMIT_STATUS_UNCHECKABLE: + uncheckable_count += 1 + + report.has_over_limit = exceeded_count > 0 + if exceeded_count and uncheckable_count: + report.over_limit_summary = f"{exceeded_count}项超额,{uncheckable_count}项无法校验" + elif exceeded_count: + report.over_limit_summary = f"{exceeded_count}项超额" + elif uncheckable_count: + report.over_limit_summary = f"{uncheckable_count}项无法校验" + else: + report.over_limit_summary = None + db.flush() diff --git a/tests/test_report_over_limit.py b/tests/test_report_over_limit.py index cb24585..89cf1f5 100644 --- a/tests/test_report_over_limit.py +++ b/tests/test_report_over_limit.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime, timedelta from decimal import Decimal from types import SimpleNamespace @@ -7,7 +7,16 @@ from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker from app.database import Base -from app.models import Product, ProductionReport, ProductionReportItem, ReportStatus +from app.models import Equipment, Product, ProductionReport, 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, + ReportItemCorrection, + ReportSubmitBlock, + ReportSubmitItem, + ReportSubmitRequest, +) from app.services.report_over_limit import ( OVER_LIMIT_STATUS_EXCEEDED, OVER_LIMIT_STATUS_NOT_CHECKED, @@ -15,7 +24,9 @@ from app.services.report_over_limit import ( current_report_qty, evaluate_report_item_over_limit, parse_process_order, + refresh_report_over_limit_snapshot, ) +from app.timezone import now @compiles(BigInteger, "sqlite") @@ -109,6 +120,44 @@ def _report(report_id: int, status: ReportStatus = ReportStatus.pending) -> Prod ) +def _numeric_product(*, gross_weight: Decimal = Decimal("2")) -> Product: + return Product( + attendance_point_name="总厂", + project_no="P1", + product_name="产品A", + device_no="", + process_name="1", + product_gross_weight_kg=gross_weight, + standard_beat=1, + standard_workload=0, + ) + + +def _press_equipment() -> Equipment: + return Equipment(attendance_point_name="总厂", device_no="E1", device_type="冲压设备") + + +def _numeric_session(*, status: SessionStatus = SessionStatus.reporting) -> WorkSession: + end_at = now() - timedelta(minutes=1) + start_at = end_at - timedelta(hours=2) + return WorkSession( + attendance_point_name="总厂", + employee_phone="13800000000", + start_at=start_at, + end_at=end_at, + status=status, + devices=[ + WorkSessionDevice( + attendance_point_name="总厂", + device_no="产品A", + process_name="1", + scanned_at=start_at, + sort_order=0, + ) + ], + ) + + def test_first_process_exceeds_material_limit_with_submit_snapshot(monkeypatch): db = _sqlite_db() try: @@ -515,3 +564,249 @@ def test_history_process_names_are_compared_after_trim(monkeypatch): assert result.status == OVER_LIMIT_STATUS_EXCEEDED finally: db.close() + + +def test_refresh_report_over_limit_snapshot_writes_item_and_report_summary(monkeypatch): + db = _sqlite_db() + try: + product = Product( + attendance_point_name="总厂", + project_no="P1", + product_name="产品A", + device_no="", + process_name="1", + product_gross_weight_kg=Decimal("2"), + standard_beat=1, + ) + report = _report(1) + item = ProductionReportItem( + id=1, + report=report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no=" LOT-1 ", + process_name="1", + good_qty=60, + defect_qty=0, + scrap_qty=0, + standard_beat=1, + ) + db.add_all([product, report, item]) + db.flush() + monkeypatch.setattr( + "app.services.report_over_limit.read_batch_issued_weight_kg", + lambda db, batch_no, project_no, product_name: Decimal("100"), + ) + + refresh_report_over_limit_snapshot( + db, + report, + source="submit", + checked_at=datetime(2026, 7, 25, 10), + ) + + assert report.has_over_limit is True + assert report.over_limit_summary == "1项超额" + assert item.over_limit_status == OVER_LIMIT_STATUS_EXCEEDED + assert item.over_limit_check_source == "submit" + assert item.over_limit_checked_at == datetime(2026, 7, 25, 10) + assert item.over_limit_batch_no == "LOT-1" + assert item.over_limit_max_reportable_qty == Decimal("50.00") + finally: + db.close() + + +def test_refresh_report_over_limit_snapshot_skips_non_numeric_process(monkeypatch): + db = _sqlite_db() + try: + product = Product( + attendance_point_name="总厂", + project_no="P1", + product_name="产品A", + device_no="", + process_name="冲压", + product_gross_weight_kg=Decimal("2"), + standard_beat=1, + ) + report = _report(1) + item = ProductionReportItem( + id=1, + report=report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="冲压", + good_qty=60, + defect_qty=0, + scrap_qty=0, + standard_beat=1, + ) + db.add_all([product, report, item]) + db.flush() + monkeypatch.setattr( + "app.services.report_over_limit.read_batch_issued_weight_kg", + lambda db, batch_no, project_no, product_name: Decimal("100"), + ) + + refresh_report_over_limit_snapshot( + db, + report, + source="submit", + checked_at=datetime(2026, 7, 25, 10), + ) + + assert report.has_over_limit is False + assert report.over_limit_summary is None + assert item.over_limit_status == OVER_LIMIT_STATUS_NOT_CHECKED + assert item.over_limit_type is None + assert item.over_limit_reason is None + finally: + db.close() + + +def test_refresh_report_over_limit_snapshot_accepts_source_positional_argument(monkeypatch): + db = _sqlite_db() + try: + report = _report(1) + item = ProductionReportItem( + id=1, + report=report, + attendance_point_name="总厂", + device_no="A", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="冲压", + good_qty=60, + defect_qty=0, + scrap_qty=0, + standard_beat=1, + ) + db.add_all([report, item]) + db.flush() + + refresh_report_over_limit_snapshot(db, report, "submit", checked_at=datetime(2026, 7, 25, 10)) + + assert item.over_limit_check_source == "submit" + finally: + db.close() + + +def test_submit_report_refreshes_over_limit_snapshot(monkeypatch): + db = _sqlite_db() + try: + user = SimpleNamespace(phone="13800000000") + session = _numeric_session() + db.add_all([_press_equipment(), _numeric_product(), session]) + db.commit() + monkeypatch.setattr(reports_router, "report_out", lambda report, schedule=None: report) + monkeypatch.setattr( + "app.services.report_over_limit.read_batch_issued_weight_kg", + lambda db, batch_no, project_no, product_name: Decimal("100"), + ) + + report = reports_router.submit_report( + ReportSubmitRequest( + session_id=session.id, + device_blocks=[ + ReportSubmitBlock( + device_no="产品A", + process_name="1", + items=[ + ReportSubmitItem( + device_no="E1", + project_no="P1", + product_name="产品A", + process_name="1", + standard_beat=1, + raw_material_batch_no="LOT-1", + good_qty=60, + ) + ], + ) + ], + ), + user=user, + db=db, + ) + + assert report.has_over_limit is True + assert report.over_limit_summary == "1项超额" + assert report.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED + assert report.items[0].over_limit_check_source == "submit" + assert report.items[0].over_limit_current_report_qty == Decimal("60.00") + finally: + db.close() + + +def test_approve_report_refreshes_over_limit_snapshot_after_correction(monkeypatch): + db = _sqlite_db() + try: + user = SimpleNamespace(phone="13900000000", role=Role.admin) + session = _numeric_session(status=SessionStatus.submitted) + report = ProductionReport( + attendance_point_name="总厂", + session=session, + employee_phone="13800000000", + report_date=date(2026, 7, 25), + start_at=session.start_at, + end_at=session.end_at, + duration_minutes=120, + effective_minutes=120, + total_good_qty=40, + total_output_qty=40, + actual_beat=180, + standard_beat=1, + expected_workload=0, + pace_rate=0, + workload_rate=0, + status=ReportStatus.pending, + submitted_at=session.end_at, + items=[ + ProductionReportItem( + attendance_point_name="总厂", + device_no="E1", + project_no="P1", + product_name="产品A", + raw_material_batch_no="LOT-1", + process_name="1", + standard_beat=1, + standard_workload=0, + good_qty=40, + defect_qty=0, + scrap_qty=0, + ) + ], + ) + db.add_all([_press_equipment(), _numeric_product(), report]) + db.commit() + monkeypatch.setattr(reviews_router, "accessible_point_names", lambda _db, _user: ["总厂"]) + monkeypatch.setattr(reviews_router, "report_out", lambda report, schedule=None: report) + monkeypatch.setattr( + "app.services.report_over_limit.read_batch_issued_weight_kg", + lambda db, batch_no, project_no, product_name: Decimal("100"), + ) + + saved = reviews_router.approve_report( + report.id, + ApproveReportRequest( + item_corrections=[ + ReportItemCorrection(id=report.items[0].id, good_qty=60) + ] + ), + user=user, + db=db, + ) + + assert saved.has_over_limit is True + assert saved.over_limit_summary == "1项超额" + assert saved.items[0].good_qty == 60 + assert saved.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED + assert saved.items[0].over_limit_check_source == "review" + assert saved.items[0].over_limit_current_report_qty == Decimal("60.00") + finally: + db.close()