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