feat: 报工提交和审核刷新超额快照
This commit is contained in:
parent
1ed48314be
commit
b203b782de
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user