JhHardwareWRS_BackPoint/tests/test_report_over_limit.py
2026-07-25 20:44:22 +08:00

813 lines
27 KiB
Python

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.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()