JhHardwareWRS_BackPoint/app/services/auto_submit.py
2026-07-25 04:16:08 +08:00

234 lines
8.8 KiB
Python

import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.database import SessionLocal
from app.models import (
Product,
ProductionReport,
ProductionReportItem,
ReportStatus,
SessionStatus,
WorkSession,
WorkSessionDevice,
)
from app.services.metrics import calculate_report_metrics, minutes_between, release_at_caps_work_time
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.work_schedule import DEFAULT_AUTO_SUBMIT_HOURS, get_work_schedule, get_work_schedule_config
from app.timezone import now
AUTO_SUBMIT_INTERVAL_SECONDS = 300
AUTO_SUBMIT_REASON = "超时-系统自动提交"
logger = logging.getLogger(__name__)
def _ordered_session_devices(session: WorkSession) -> list[WorkSessionDevice]:
return sorted(
session.devices,
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
)
def _mold_key(product_name: str, process_name: str | None) -> tuple[str, str]:
return (str(product_name or "").strip(), str(process_name or "").strip())
def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
ranges_by_mold: dict[tuple[str, str], dict[str, datetime]] = {}
for index, device in enumerate(ordered_devices):
segment_start = session.start_at if index == 0 else max(session.start_at, device.scanned_at)
segment_end = session.end_at
if index + 1 < len(ordered_devices):
segment_end = min(session.end_at, ordered_devices[index + 1].scanned_at)
if release_at_caps_work_time(device) and device.released_at is not None:
segment_end = min(segment_end, device.released_at)
if segment_end < segment_start:
segment_end = segment_start
mold_key = _mold_key(device.device_no, device.process_name)
if mold_key not in ranges_by_mold:
ranges_by_mold[mold_key] = {
"start_at": segment_start,
"end_at": segment_end,
}
continue
ranges_by_mold[mold_key]["start_at"] = min(ranges_by_mold[mold_key]["start_at"], segment_start)
ranges_by_mold[mold_key]["end_at"] = max(ranges_by_mold[mold_key]["end_at"], segment_end)
return [
(mold_name, process_name, value["start_at"], value["end_at"])
for (mold_name, process_name), value in ranges_by_mold.items()
]
def _product_for_mold(db: Session, session: WorkSession, mold_name: str, process_name: str) -> Product | None:
return db.scalar(
select(Product)
.where(
Product.attendance_point_name == session.attendance_point_name,
Product.product_name == mold_name,
Product.process_name == process_name,
Product.device_no == "",
)
.order_by(Product.project_no.asc())
)
def _fallback_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
if session.end_at is None:
return []
ordered_devices = _ordered_session_devices(session)
if not ordered_devices:
return []
first = ordered_devices[0]
return [(first.device_no, first.process_name or "", session.start_at, session.end_at)]
def _build_auto_report_items(db: Session, session: WorkSession) -> list[ProductionReportItem]:
report_items: list[ProductionReportItem] = []
mold_ranges = _draft_mold_ranges(session) or _fallback_mold_ranges(session)
for mold_name, process_name, block_start_at, _block_end_at in mold_ranges:
product = _product_for_mold(db, session, mold_name, process_name)
item_is_misc = is_misc_product(product)
report_items.append(
ProductionReportItem(
attendance_point_name=session.attendance_point_name,
device_no=mold_name if item_is_misc else "",
project_no=product.project_no if product else "",
product_name=product.product_name if product else mold_name,
material_code=product.material_code if product else None,
material_name=product.material_name if product else None,
raw_material_batch_no=None,
process_name=product.process_name if product else process_name,
stamping_method=product.stamping_method if product else None,
operator_count=product.operator_count if product else 1,
process_unit_price_yuan=product.process_unit_price_yuan if product else 0,
standard_beat=0 if item_is_misc else (product.standard_beat if product else 0),
standard_workload=0,
good_qty=0,
defect_qty=0,
scrap_qty=0,
started_at=block_start_at,
remark=None,
)
)
return report_items
def auto_submit_session(db: Session, session: WorkSession, submitted_at: datetime | None = None) -> ProductionReport | None:
existing_report = db.scalar(select(ProductionReport).where(ProductionReport.session_id == session.id))
if existing_report is not None:
session.status = SessionStatus.submitted
return None
timestamp = submitted_at or now()
session.end_at = timestamp
session.status = SessionStatus.submitted
release_session_locks(session, None, AUTO_SUBMIT_REASON, timestamp)
items = _build_auto_report_items(db, session)
schedule = get_work_schedule_config(db, session.attendance_point_name)
metrics = calculate_report_metrics(
session.start_at,
session.end_at,
items,
devices=session.devices,
schedule=schedule,
)
report = ProductionReport(
attendance_point_name=session.attendance_point_name,
session_id=session.id,
employee_phone=session.employee_phone,
report_date=timestamp.date(),
start_at=session.start_at,
end_at=session.end_at,
duration_minutes=metrics["duration_minutes"] if items else minutes_between(session.start_at, session.end_at),
break_minutes=0,
effective_minutes=metrics["effective_minutes"],
total_good_qty=metrics["total_good_qty"],
total_output_qty=metrics["total_output_qty"],
actual_beat=metrics["actual_beat"],
standard_beat=metrics["standard_beat"],
expected_workload=metrics["expected_workload"],
pace_rate=metrics["pace_rate"],
workload_rate=metrics["workload_rate"],
status=ReportStatus.pending,
submitted_at=timestamp,
is_system_auto_submitted=True,
auto_submit_reason=AUTO_SUBMIT_REASON,
items=items,
)
db.add(report)
db.flush()
refresh_report_allocations(db, report, schedule=schedule, commit=False)
return report
def auto_submit_overdue_sessions(
db: Session,
current_time: datetime | None = None,
employee_phone: str | None = None,
limit: int = 100,
) -> int:
timestamp = current_time or now()
try:
auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or DEFAULT_AUTO_SUBMIT_HOURS)
except Exception:
db.rollback()
auto_submit_hours = DEFAULT_AUTO_SUBMIT_HOURS
cutoff = timestamp - timedelta(hours=auto_submit_hours)
query = (
select(WorkSession)
.options(selectinload(WorkSession.devices))
.where(
WorkSession.status == SessionStatus.active,
WorkSession.start_at <= cutoff,
)
.order_by(WorkSession.start_at.asc())
)
if employee_phone:
query = query.where(WorkSession.employee_phone == employee_phone)
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
query = query.with_for_update(skip_locked=True)
query = query.limit(limit)
sessions = db.scalars(query).all()
submitted_count = 0
for session in sessions:
if auto_submit_session(db, session, timestamp) is not None:
submitted_count += 1
if sessions:
db.commit()
return submitted_count
async def auto_submit_overdue_loop(interval_seconds: int = AUTO_SUBMIT_INTERVAL_SECONDS) -> None:
while True:
try:
submitted_count = await asyncio.to_thread(_run_auto_submit_once)
if submitted_count:
logger.info("auto submitted overdue work sessions: %s", submitted_count)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("auto submit overdue work sessions failed")
await asyncio.sleep(interval_seconds)
def _run_auto_submit_once() -> int:
with SessionLocal() as db:
return auto_submit_overdue_sessions(db)