from datetime import date, datetime, timedelta from decimal import Decimal from types import SimpleNamespace from sqlalchemy import BigInteger, create_engine, inspect from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import sessionmaker from app.database import Base 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, OVER_LIMIT_TYPE_UNCHECKABLE, current_report_qty, evaluate_report_item_over_limit, parse_process_order, refresh_report_over_limit_snapshot, ) from app.services.serializers import report_out from app.timezone import now @compiles(BigInteger, "sqlite") def _compile_big_integer_for_sqlite(type_, compiler, **kw): return "INTEGER" def test_report_over_limit_columns_exist_on_sqlite_metadata(): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) inspector = inspect(engine) report_column_rows = inspector.get_columns("production_reports") item_column_rows = inspector.get_columns("production_report_items") report_columns = {column["name"] for column in report_column_rows} item_columns = {column["name"] for column in item_column_rows} report_column_by_name = {column["name"]: column for column in report_column_rows} item_column_by_name = {column["name"]: column for column in item_column_rows} report_indexes = {index["name"]: tuple(index["column_names"]) for index in inspector.get_indexes("production_reports")} item_indexes = { index["name"]: tuple(index["column_names"]) for index in inspector.get_indexes("production_report_items") } assert {"has_over_limit", "over_limit_summary"}.issubset(report_columns) assert { "over_limit_status", "over_limit_type", "over_limit_reason", "over_limit_checked_at", "over_limit_check_source", "over_limit_batch_no", "over_limit_product_gross_weight_kg", "over_limit_issued_weight_kg", "over_limit_max_reportable_qty", "over_limit_previous_good_qty", "over_limit_current_cumulative_qty", "over_limit_current_report_qty", }.issubset(item_columns) assert report_indexes["idx_reports_over_limit"] == ("has_over_limit", "submitted_at") assert item_indexes["idx_report_items_over_limit_status"] == ("over_limit_status",) assert item_indexes["idx_report_items_process_batch"] == ( "attendance_point_name", "project_no", "product_name", "raw_material_batch_no", "process_name", ) assert report_column_by_name["has_over_limit"]["nullable"] is False assert str(report_column_by_name["has_over_limit"]["default"]).strip("'\"") in {"0", "false", "FALSE"} assert item_column_by_name["over_limit_status"]["nullable"] is False assert str(item_column_by_name["over_limit_status"]["default"]).strip("'\"") == "not_checked" 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_parse_process_order_accepts_numeric_process_names(): assert parse_process_order("1") == 1 assert parse_process_order("1序") == 1 assert parse_process_order(" 02 序 ") == 2 def test_parse_process_order_rejects_special_and_text_process_names(): assert parse_process_order("杂活") is None assert parse_process_order("清洗") is None assert parse_process_order("第一序") is None def test_current_report_qty_uses_good_defect_and_scrap(): item = SimpleNamespace(good_qty=Decimal("90"), defect_qty=Decimal("5"), scrap_qty=Decimal("5")) assert current_report_qty(item) == Decimal("100.00") def _report(report_id: int, status: ReportStatus = ReportStatus.pending) -> ProductionReport: return ProductionReport( id=report_id, session_id=report_id, attendance_point_name="总厂", employee_phone="13800000000", report_date=datetime(2026, 7, 25).date(), start_at=datetime(2026, 7, 25, 8), end_at=datetime(2026, 7, 25, 10), status=status, ) 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: 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"), ) result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=report.id) assert result.status == OVER_LIMIT_STATUS_EXCEEDED assert result.max_reportable_qty == Decimal("50.00") assert result.current_report_qty == Decimal("60.00") assert result.current_cumulative_qty == Decimal("60.00") assert "1序累计报工数量超过本批次材料可支撑数量" in result.reason finally: db.close() def test_evaluate_defaults_to_excluding_current_report(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=50, 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"), ) result = evaluate_report_item_over_limit(db, report, item) assert result.current_cumulative_qty == Decimal("50.00") assert result.status != OVER_LIMIT_STATUS_EXCEEDED finally: db.close() def test_current_report_sibling_items_contribute_to_current_cumulative(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) first_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=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) second_item = ProductionReportItem( id=2, report=report, attendance_point_name="总厂", device_no="B", project_no="P1", product_name="产品A", raw_material_batch_no=" LOT-1 ", process_name="1", good_qty=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) db.add_all([product, report, first_item, second_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"), ) result = evaluate_report_item_over_limit(db, report, first_item) assert result.current_report_qty == Decimal("30.00") assert result.current_cumulative_qty == Decimal("60.00") assert result.status == OVER_LIMIT_STATUS_EXCEEDED finally: db.close() def test_history_batch_numbers_are_compared_after_trim(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, ) previous_report = _report(1, ReportStatus.approved) previous_item = ProductionReportItem( id=1, report=previous_report, attendance_point_name="总厂", device_no="A", project_no="P1", product_name="产品A", raw_material_batch_no=" LOT-1 ", process_name="1", good_qty=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) report = _report(2) item = ProductionReportItem( id=2, report=report, attendance_point_name="总厂", device_no="B", project_no="P1", product_name="产品A", raw_material_batch_no="LOT-1", process_name="1", good_qty=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) db.add_all([product, previous_report, previous_item, 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"), ) result = evaluate_report_item_over_limit(db, report, item) assert result.current_cumulative_qty == Decimal("60.00") assert result.status == OVER_LIMIT_STATUS_EXCEEDED finally: db.close() def test_erp_weight_lookup_sql_error_has_distinct_uncheckable_reason(): 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=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) db.add_all([product, report, item]) db.flush() result = evaluate_report_item_over_limit(db, report, item) assert result.over_limit_type == OVER_LIMIT_TYPE_UNCHECKABLE assert result.reason == "读取批次投入重量失败,无法校验超额" finally: db.close() def test_evaluate_report_item_over_limit_does_not_write_snapshot_fields(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"), ) result = evaluate_report_item_over_limit(db, report, item) assert result.status == OVER_LIMIT_STATUS_EXCEEDED 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_later_process_exceeds_previous_good_quantity(): db = _sqlite_db() try: db.add_all( [ Product( attendance_point_name="总厂", project_no="P1", product_name="产品A", device_no="", process_name="1", standard_beat=1, ), Product( attendance_point_name="总厂", project_no="P1", product_name="产品A", device_no="", process_name="3", standard_beat=1, ), ] ) previous_report = _report(1, ReportStatus.approved) previous_item = ProductionReportItem( id=1, report=previous_report, attendance_point_name="总厂", device_no="A", project_no="P1", product_name="产品A", raw_material_batch_no="LOT-1", process_name="1", good_qty=100, defect_qty=10, scrap_qty=5, standard_beat=1, ) report = _report(2) report.employee_phone = "13800000001" item = ProductionReportItem( id=2, report=report, attendance_point_name="总厂", device_no="B", project_no="P1", product_name="产品A", raw_material_batch_no="LOT-1", process_name="3", good_qty=98, defect_qty=3, scrap_qty=2, standard_beat=1, ) db.add_all([previous_report, previous_item, report, item]) db.flush() result = evaluate_report_item_over_limit(db, report, item, exclude_report_id=report.id) assert result.status == OVER_LIMIT_STATUS_EXCEEDED assert result.previous_good_qty == Decimal("100.00") assert result.current_report_qty == Decimal("103.00") assert result.current_cumulative_qty == Decimal("103.00") assert "3序累计报工数量超过1序累计成品数量" in result.reason finally: db.close() def test_history_process_names_are_compared_after_trim(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, ) previous_report = _report(1, ReportStatus.approved) previous_item = ProductionReportItem( id=1, report=previous_report, attendance_point_name="总厂", device_no="A", project_no="P1", product_name="产品A", raw_material_batch_no="LOT-1", process_name=" 01序 ", good_qty=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) report = _report(2) item = ProductionReportItem( id=2, report=report, attendance_point_name="总厂", device_no="B", project_no="P1", product_name="产品A", raw_material_batch_no="LOT-1", process_name="1", good_qty=30, defect_qty=0, scrap_qty=0, standard_beat=1, ) db.add_all([product, previous_report, previous_item, 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"), ) result = evaluate_report_item_over_limit(db, report, item) assert result.current_cumulative_qty == Decimal("60.00") 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() def test_approve_report_without_over_limit_input_changes_preserves_submit_snapshot(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=60, total_output_qty=60, actual_beat=120, standard_beat=1, expected_workload=0, pace_rate=0, workload_rate=0, status=ReportStatus.pending, submitted_at=session.end_at, has_over_limit=True, over_limit_summary="1项超额", 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=60, defect_qty=0, scrap_qty=0, over_limit_status=OVER_LIMIT_STATUS_EXCEEDED, over_limit_type="first_process_material", over_limit_reason="提交时已超额", over_limit_checked_at=datetime(2026, 7, 25, 10), over_limit_check_source="submit", over_limit_batch_no="LOT-1", over_limit_product_gross_weight_kg=Decimal("2.0000"), over_limit_issued_weight_kg=Decimal("100.000000"), over_limit_max_reportable_qty=Decimal("50.00"), over_limit_current_cumulative_qty=Decimal("60.00"), over_limit_current_report_qty=Decimal("60.00"), ) ], ) 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("200"), ) saved = reviews_router.approve_report( report.id, ApproveReportRequest(), user=user, db=db, ) assert saved.has_over_limit is True assert saved.over_limit_summary == "1项超额" assert saved.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED assert saved.items[0].over_limit_check_source == "submit" assert saved.items[0].over_limit_reason == "提交时已超额" assert saved.items[0].over_limit_issued_weight_kg == Decimal("100.000000") assert saved.items[0].over_limit_max_reportable_qty == Decimal("50.00") finally: db.close() def test_report_out_exposes_over_limit_snapshot_fields(): report = ProductionReport( id=1, session_id=1, attendance_point_name="总厂", employee_phone="13800000000", report_date=datetime(2026, 7, 25).date(), start_at=datetime(2026, 7, 25, 8), end_at=datetime(2026, 7, 25, 10), status=ReportStatus.pending, submitted_at=datetime(2026, 7, 25, 10), has_over_limit=True, over_limit_summary="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, over_limit_status=OVER_LIMIT_STATUS_EXCEEDED, over_limit_type="first_process_material", over_limit_reason="1序累计报工数量超过本批次材料可支撑数量", over_limit_checked_at=datetime(2026, 7, 25, 10), over_limit_check_source="submit", over_limit_batch_no="LOT-1", over_limit_product_gross_weight_kg=Decimal("2.0000"), over_limit_issued_weight_kg=Decimal("100.000000"), over_limit_max_reportable_qty=Decimal("50.00"), over_limit_previous_good_qty=None, over_limit_current_cumulative_qty=Decimal("60.00"), over_limit_current_report_qty=Decimal("60.00"), ) report.items = [item] report.allocations = [] report.audit_logs = [] output = report_out(report) assert output.has_over_limit is True assert output.over_limit_summary == "1项超额" assert output.items[0].over_limit_status == OVER_LIMIT_STATUS_EXCEEDED assert output.items[0].over_limit_reason == "1序累计报工数量超过本批次材料可支撑数量" assert output.items[0].over_limit_max_reportable_qty == 50