feat: 报工提交和审核刷新超额快照

This commit is contained in:
souplearn 2026-07-25 20:29:33 +08:00
parent 1ed48314be
commit b203b782de
4 changed files with 356 additions and 5 deletions

View File

@ -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.mold_locks import release_session_locks
from app.services.report_allocations import refresh_report_allocations from app.services.report_allocations import refresh_report_allocations
from app.services.report_lifecycle import purge_expired_voided_reports 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.reporting_window import REPORTING_EXPIRED_DETAIL, is_reporting_expired
from app.services.serializers import equipment_option, product_option, report_out from app.services.serializers import equipment_option, product_option, report_out
from app.services.work_schedule import get_work_schedule_config from app.services.work_schedule import get_work_schedule_config
@ -460,6 +461,7 @@ def submit_report(
db.add(report) db.add(report)
db.flush() db.flush()
refresh_report_allocations(db, report, schedule=schedule, commit=False) 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() db.commit()
saved = _get_report_or_404(db, report.id) saved = _get_report_or_404(db, report.id)

View File

@ -36,6 +36,7 @@ from app.services.report_lifecycle import (
purge_expired_voided_reports, purge_expired_voided_reports,
restore_voided_report, 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.serializers import report_out
from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config
from app.timezone import now from app.timezone import now
@ -532,6 +533,7 @@ def approve_report(
_apply_metrics(report, schedule) _apply_metrics(report, schedule)
refresh_report_allocations(db, report, schedule=schedule, commit=False) refresh_report_allocations(db, report, schedule=schedule, commit=False)
reviewed_at = now() reviewed_at = now()
refresh_report_over_limit_snapshot(db, report, source=CHECK_SOURCE_REVIEW, checked_at=reviewed_at)
was_pending = report.status == ReportStatus.pending was_pending = report.status == ReportStatus.pending
report.status = ReportStatus.approved report.status = ReportStatus.approved
report.reviewer_phone = user.phone report.reviewer_phone = user.phone

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import logging import logging
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from sqlalchemy import func, select, text 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.models import Product, ProductionReport, ProductionReportItem, ReportStatus
from app.services.cleaning import is_cleaning_item from app.services.cleaning import is_cleaning_item
from app.services.misc_work import is_misc_item from app.services.misc_work import is_misc_item
from app.timezone import now
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -288,9 +290,7 @@ def evaluate_report_item_over_limit(
current_order = parse_process_order(getattr(item, "process_name", None)) current_order = parse_process_order(getattr(item, "process_name", None))
if current_order is None: if current_order is None:
return OverLimitResult( return OverLimitResult(
status=OVER_LIMIT_STATUS_UNCHECKABLE, status=OVER_LIMIT_STATUS_NOT_CHECKED,
over_limit_type=OVER_LIMIT_TYPE_UNCHECKABLE,
reason="非数字工序,无法校验超额",
batch_no=batch_no, batch_no=batch_no,
current_report_qty=report_qty, current_report_qty=report_qty,
) )
@ -383,3 +383,55 @@ def evaluate_report_item_over_limit(
current_cumulative_qty=current_cumulative_qty, current_cumulative_qty=current_cumulative_qty,
current_report_qty=report_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()

View File

@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from types import SimpleNamespace from types import SimpleNamespace
@ -7,7 +7,16 @@ from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from app.database import Base 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 ( from app.services.report_over_limit import (
OVER_LIMIT_STATUS_EXCEEDED, OVER_LIMIT_STATUS_EXCEEDED,
OVER_LIMIT_STATUS_NOT_CHECKED, OVER_LIMIT_STATUS_NOT_CHECKED,
@ -15,7 +24,9 @@ from app.services.report_over_limit import (
current_report_qty, current_report_qty,
evaluate_report_item_over_limit, evaluate_report_item_over_limit,
parse_process_order, parse_process_order,
refresh_report_over_limit_snapshot,
) )
from app.timezone import now
@compiles(BigInteger, "sqlite") @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): def test_first_process_exceeds_material_limit_with_submit_snapshot(monkeypatch):
db = _sqlite_db() db = _sqlite_db()
try: try:
@ -515,3 +564,249 @@ def test_history_process_names_are_compared_after_trim(monkeypatch):
assert result.status == OVER_LIMIT_STATUS_EXCEEDED assert result.status == OVER_LIMIT_STATUS_EXCEEDED
finally: finally:
db.close() 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()