231 lines
8.6 KiB
Python
231 lines
8.6 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.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)
|