from datetime import date, datetime import re from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, text from sqlalchemy.orm import Session, selectinload from app.database import get_db from app.deps import current_user, require_roles from app.models import ( Personnel, Equipment, Product, ProductionReport, ProductionReportItem, ReportStatus, Role, SessionStatus, WorkSession, WorkSessionDevice, ) from app.schemas import ( CleaningReportSubmitItem, CleaningReportSubmitRequest, PageResponse, ReportDraftBlock, ReportDraftItem, ReportDraftOut, ReportSubmitRequest, ReportOut, ) from app.services.attendance_points import require_attendance_point_access, validate_attendance_point_location from app.services.attendance_points import accessible_point_names from app.services.auto_submit import auto_submit_overdue_sessions from app.services.cleaning import is_cleaning_item, is_cleaning_product from app.services.common import as_float, normalize_device_no, paginate, round2 from app.services.continuous_die import is_continuous_die_item, is_continuous_die_product from app.services.display_names import mold_process_display_name 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.report_lifecycle import purge_expired_voided_reports 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.work_schedule import get_work_schedule_config from app.timezone import now, today router = APIRouter(prefix="/api/reports", tags=["reports"]) CLEANING_DEVICE_LABEL = "清洗设备" def _split_device_nos(value: str | None) -> list[str]: return [ normalize_device_no(part) for part in re.split(r"[、,,\s]+", str(value or "").strip()) if normalize_device_no(part) ] def _cleaning_device_nos(item: CleaningReportSubmitItem) -> list[str]: values: list[str] = [] for device_no in item.device_nos or []: normalized = normalize_device_no(device_no) if normalized and normalized not in values: values.append(normalized) for device_no in _split_device_nos(item.device_no): if device_no not in values: values.append(device_no) return values def _report_query(): return select(ProductionReport).options( selectinload(ProductionReport.items), selectinload(ProductionReport.audit_logs), selectinload(ProductionReport.employee), selectinload(ProductionReport.reviewer), selectinload(ProductionReport.voider), selectinload(ProductionReport.session).selectinload(WorkSession.devices), ) def _get_report_or_404(db: Session, report_id: int) -> ProductionReport: report = db.scalar(_report_query().where(ProductionReport.id == report_id)) if report is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工记录不存在") return report def _ordered_session_devices(session: WorkSession): 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 _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str | None) -> Product | None: return db.scalar( select(Product) .where( Product.attendance_point_name == point_name, Product.product_name == str(mold_name or "").strip(), Product.process_name == str(process_name or "").strip(), Product.device_no == "", ) .order_by(Product.project_no.asc()) ) def _miniapp_work_order_short_code(work_order_no: str | None) -> str | None: normalized = str(work_order_no or "").strip() current_year = datetime.now().strftime("%Y") if len(normalized) <= 8 or not normalized.startswith(current_year) or normalized[8] != "-": return None sequence = normalized[4:8] if not sequence.isdigit(): return None return f"JH{int(sequence):04d}" def _raw_material_batch_options( db: Session, *, project_no: str | None, product_name: str | None, process_name: str | None, ) -> list[str]: normalized_project_no = str(project_no or "").strip() normalized_product_name = str(product_name or "").strip() normalized_process_name = str(process_name or "").strip() if not normalized_project_no and not normalized_product_name: return [] rows = db.execute( text( """ select distinct ledger.material_lot_no from pp_production_batch_ledger ledger join md_item product on product.id = ledger.product_item_id where ledger.material_lot_no is not null and trim(ledger.material_lot_no) <> '' and coalesce(ledger.miniapp_selectable, 1) = 1 and trim(coalesce(ledger.status, '')) = '在生产' and ( (:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no) or (:product_name <> '' and :project_no = '' and product.item_name = :product_name) or (:product_name = '' and :project_no <> '' and product.item_code = :project_no) ) order by ledger.material_lot_no asc """ ), { "project_no": normalized_project_no, "product_name": normalized_product_name, "process_name": normalized_process_name, }, ).all() options: list[str] = [] for row in rows: lot_no = str(row[0] or "").strip() if lot_no and lot_no not in options: options.append(lot_no) return options def _cleaning_metrics(items: list[ProductionReportItem]) -> dict[str, float | str]: total_good_qty = sum(as_float(item.good_qty) for item in items) total_output_qty = sum( as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) for item in items ) return { "duration_minutes": 0, "effective_minutes": 0, "shift_day_minutes": 0, "shift_overtime_minutes": 0, "shift_night_minutes": 0, "shift_other_minutes": 0, "shift_meal_break_minutes": 0, "shift_distribution_text": "清洗报工不计工时", "total_good_qty": round2(total_good_qty), "total_output_qty": round2(total_output_qty), "actual_beat": 0, "standard_beat": 0, "expected_workload": 0, "pace_rate": 0, "workload_rate": 0, } def _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime, float]]: if session.end_at is None: return [] ordered_devices = _ordered_session_devices(session) ranges_by_mold: dict[tuple[str, str], dict[str, datetime | float]] = {} 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, "duration_minutes": minutes_between(segment_start, 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) ranges_by_mold[mold_key]["duration_minutes"] = as_float(ranges_by_mold[mold_key]["duration_minutes"]) + minutes_between(segment_start, segment_end) return [ (mold_name, process_name, value["start_at"], value["end_at"], round2(value["duration_minutes"])) for (mold_name, process_name), value in ranges_by_mold.items() ] @router.get("/draft", response_model=ReportDraftOut) def get_report_draft( session_id: int, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ReportDraftOut: session = db.scalar( select(WorkSession) .options(selectinload(WorkSession.devices), selectinload(WorkSession.employee)) .where(WorkSession.id == session_id) ) if session is None or session.employee_phone != user.phone: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在") if session.status != SessionStatus.reporting or session.end_at is None: raise HTTPException(status_code=400, detail="该流程还不能提交报工") if is_reporting_expired(session): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL) blocks: list[ReportDraftBlock] = [] equipment_options = [ equipment_option(equipment) for equipment in db.scalars( select(Equipment) .where( Equipment.attendance_point_name == session.attendance_point_name, Equipment.device_type == "冲压设备", ) .order_by(Equipment.device_no.asc()) ).all() ] for mold_name, process_name, block_start_at, block_end_at, block_duration_minutes in _draft_mold_ranges(session): product_query = select(Product).where( Product.attendance_point_name == session.attendance_point_name, Product.product_name == mold_name, ) if process_name: product_query = product_query.where(Product.process_name == process_name) products = db.scalars(product_query.order_by(Product.project_no, Product.process_name)).all() options = [product_option(product) for product in products] first = options[0] if options else None block_is_misc = any(option.is_misc for option in options) block_is_continuous_die = any(option.is_continuous_die for option in options) raw_material_batch_options = ( _raw_material_batch_options( db, project_no=first.project_no, product_name=first.product_name, process_name=first.process_name, ) if first else [] ) blocks.append( ReportDraftBlock( attendance_point_name=session.attendance_point_name, device_no=mold_name, mold_name=mold_name, process_name=process_name, stamping_method=first.stamping_method if first else None, display_name=mold_process_display_name(mold_name, process_name, first.stamping_method if first else None), start_at=block_start_at, end_at=block_end_at, duration_minutes=block_duration_minutes, options=options, equipment_options=equipment_options, is_misc=block_is_misc, is_continuous_die=block_is_continuous_die, items=[ ReportDraftItem( device_no=mold_name if block_is_misc else "", attendance_point_name=session.attendance_point_name, project_no=first.project_no if first else "", product_name=first.product_name if first else "", material_code=first.material_code if first else None, material_name=first.material_name if first else None, raw_material_batch_no=None, raw_material_batch_options=raw_material_batch_options, process_name=first.process_name if first else None, stamping_method=first.stamping_method if first else None, operator_count=first.operator_count if first else 1, process_unit_price_yuan=first.process_unit_price_yuan if first else 0, changeover_count=0, standard_beat=first.standard_beat if first else 0, standard_workload=first.standard_workload if first else 0, good_qty=0, defect_qty=0, is_misc=block_is_misc, is_continuous_die=first.is_continuous_die if first else False, ) ], ) ) return ReportDraftOut( session_id=session.id, attendance_point_name=session.attendance_point_name, employee_phone=session.employee_phone, employee_name=session.employee.name, start_at=session.start_at, end_at=session.end_at, duration_minutes=round2(minutes_between(session.start_at, session.end_at)), device_blocks=blocks, ) @router.post("", response_model=ReportOut) def submit_report( payload: ReportSubmitRequest, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ReportOut: session = db.scalar( select(WorkSession) .options(selectinload(WorkSession.devices)) .where(WorkSession.id == payload.session_id) ) if session is None or session.employee_phone != user.phone: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报工流程不存在") if session.status != SessionStatus.reporting or session.end_at is None: raise HTTPException(status_code=400, detail="该流程还不能提交报工") if is_reporting_expired(session): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=REPORTING_EXPIRED_DETAIL) valid_molds = {_mold_key(device.device_no, device.process_name) for device in session.devices} valid_equipment = { equipment.device_no for equipment in db.scalars(select(Equipment).where(Equipment.device_type == "冲压设备")).all() if equipment.attendance_point_name == session.attendance_point_name } items: list[ProductionReportItem] = [] for block in payload.device_blocks: mold_name = str(block.device_no or "").strip() process_name = str(block.process_name or "").strip() if _mold_key(mold_name, process_name) not in valid_molds: product = _product_for_mold(db, session.attendance_point_name, mold_name, process_name) mold_text = mold_process_display_name(mold_name, process_name, product.stamping_method if product else None) raise HTTPException(status_code=400, detail=f"模具 {mold_text} 不在本次流程中") for item in block.items: product = db.scalar( select(Product).where( Product.attendance_point_name == session.attendance_point_name, Product.project_no == item.project_no, Product.product_name == item.product_name, Product.process_name == (item.process_name or ""), Product.device_no == "", ) ) item_is_misc = is_misc_product(product) or item.is_misc if not item_is_misc and item.good_qty + item.defect_qty + item.scrap_qty <= 0: continue device_no = normalize_device_no(item.device_no) if item_is_misc: device_no = mold_name elif not device_no: raise HTTPException(status_code=400, detail=f"{mold_name} 请选择设备号") if not item_is_misc and valid_equipment and device_no not in valid_equipment: raise HTTPException(status_code=400, detail=f"设备 {device_no} 不在设备清单中") stamping_method = product.stamping_method if product else item.stamping_method item_is_continuous_die = is_continuous_die_product(product) or is_continuous_die_item(item) changeover_count = as_float(item.changeover_count) if not item_is_continuous_die: changeover_count = 0 if changeover_count < 0: raise HTTPException(status_code=400, detail="换料次数不能为负数") items.append( ProductionReportItem( attendance_point_name=session.attendance_point_name, device_no=device_no, project_no=product.project_no if product else item.project_no, product_name=product.product_name if product else item.product_name, material_code=product.material_code if product else item.material_code, material_name=product.material_name if product else item.material_name, raw_material_batch_no=item.raw_material_batch_no, process_name=product.process_name if product else item.process_name, stamping_method=stamping_method, operator_count=product.operator_count if product else item.operator_count, process_unit_price_yuan=product.process_unit_price_yuan if product else item.process_unit_price_yuan, changeover_count=changeover_count, standard_beat=0 if item_is_misc else item.standard_beat, standard_workload=0, good_qty=0 if item_is_misc else item.good_qty, defect_qty=0 if item_is_misc else item.defect_qty, scrap_qty=0 if item_is_misc else item.scrap_qty, started_at=item.started_at, remark=item.remark, ) ) if not items: raise HTTPException(status_code=400, detail="请至少填写一项产出数量") schedule = get_work_schedule_config(db, session.attendance_point_name) metrics = ( _cleaning_metrics(items) if all(is_cleaning_item(item) for item in items) else calculate_report_metrics( session.start_at, session.end_at, items, devices=session.devices, schedule=schedule, ) ) submitted_at = now() report = ProductionReport( attendance_point_name=session.attendance_point_name, session_id=session.id, employee_phone=user.phone, report_date=today(), start_at=session.start_at, end_at=session.end_at, duration_minutes=metrics["duration_minutes"], 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=submitted_at, items=items, ) release_session_locks(session, user.phone, "报工提交释放占用", submitted_at) session.status = SessionStatus.submitted db.add(report) db.flush() refresh_report_allocations(db, report, schedule=schedule, commit=False) db.commit() saved = _get_report_or_404(db, report.id) return report_out(saved, schedule) @router.post("/cleaning", response_model=ReportOut) def submit_cleaning_report( payload: CleaningReportSubmitRequest, user: Personnel = Depends(require_roles(Role.worker)), db: Session = Depends(get_db), ) -> ReportOut: if not payload.items: raise HTTPException(status_code=400, detail="请至少添加一个清洗产品") requested_point = next((str(item.attendance_point_name or "").strip() for item in payload.items if item.attendance_point_name), "") try: point_name = require_attendance_point_access(db, user, requested_point) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc try: validate_attendance_point_location(db, point_name, payload.latitude, payload.longitude, action_name="清洗报工") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc valid_cleaning_equipment = { normalize_device_no(equipment.device_no) for equipment in db.scalars( select(Equipment).where( Equipment.attendance_point_name == point_name, Equipment.device_type == "清洗设备", ) ).all() } grouped: dict[tuple[str, str, str, str], float] = {} products_by_key: dict[tuple[str, str, str, str], Product] = {} for item in payload.items: item_point_name = str(item.attendance_point_name or point_name).strip() if item_point_name != point_name: raise HTTPException(status_code=400, detail="清洗报工只能提交同一考勤点的产品") device_nos = _cleaning_device_nos(item) if not device_nos: raise HTTPException(status_code=400, detail="请选择清洗设备号") invalid_device_nos = [device_no for device_no in device_nos if device_no not in valid_cleaning_equipment] if invalid_device_nos: raise HTTPException(status_code=400, detail=f"设备 {'、'.join(invalid_device_nos)} 不是清洗设备") product_name = str(item.product_name or "").strip() process_name = str(item.process_name or "").strip() product = db.scalar( select(Product) .where( Product.attendance_point_name == point_name, Product.product_name == product_name, Product.process_name == process_name, Product.device_no == "", ) .order_by(Product.project_no.asc()) ) if product is None: raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, item.stamping_method)} 不在产品清单中") if not is_cleaning_product(product): raise HTTPException(status_code=400, detail=f"{mold_process_display_name(product_name, process_name, product.stamping_method)} 的冲压方式不是清洗") device_no_text = "、".join(device_nos) key = (device_no_text, product.project_no, product.product_name, product.process_name) products_by_key[key] = product grouped[key] = ( grouped.get(key, 0.0) + as_float(item.quantity) ) report_items: list[ProductionReportItem] = [] for key, quantity in grouped.items(): if quantity <= 0: continue device_no, _project_no, _product_name, _process_name = key product = products_by_key[key] report_items.append( ProductionReportItem( attendance_point_name=point_name, device_no=device_no, project_no=product.project_no, product_name=product.product_name, material_code=product.material_code, material_name=product.material_name, raw_material_batch_no=None, process_name=product.process_name, stamping_method=product.stamping_method, operator_count=product.operator_count, process_unit_price_yuan=product.process_unit_price_yuan, changeover_count=0, standard_beat=0, standard_workload=0, good_qty=quantity, defect_qty=0, scrap_qty=0, allocated_minutes=0, ) ) if not report_items: raise HTTPException(status_code=400, detail="请填写清洗数量") timestamp = now() session = WorkSession( attendance_point_name=point_name, employee_phone=user.phone, start_at=timestamp, end_at=timestamp, status=SessionStatus.submitted, ) for index, item in enumerate(report_items): session.devices.append( WorkSessionDevice( attendance_point_name=point_name, device_no=item.product_name, process_name=item.process_name or "", scanned_at=timestamp, sort_order=index, ) ) metrics = _cleaning_metrics(report_items) report = ProductionReport( attendance_point_name=point_name, session=session, employee_phone=user.phone, report_date=today(), start_at=timestamp, end_at=timestamp, duration_minutes=0, break_minutes=0, effective_minutes=0, total_good_qty=metrics["total_good_qty"], total_output_qty=metrics["total_output_qty"], actual_beat=0, standard_beat=0, expected_workload=0, pace_rate=0, workload_rate=0, status=ReportStatus.pending, submitted_at=timestamp, items=report_items, ) db.add(report) schedule = get_work_schedule_config(db, point_name) db.flush() refresh_report_allocations(db, report, schedule=schedule, commit=False) db.commit() saved = _get_report_or_404(db, report.id) return report_out(saved, schedule) @router.get("/mine", response_model=PageResponse) def list_my_reports( start_date: date | None = None, end_date: date | None = None, status_filter: ReportStatus | None = Query(None, alias="status"), page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), include_voided: bool = False, user: Personnel = Depends(current_user), db: Session = Depends(get_db), ) -> PageResponse: purge_expired_voided_reports(db) if user.role == Role.worker: auto_submit_overdue_sessions(db, employee_phone=user.phone) query = _report_query().where(ProductionReport.employee_phone == user.phone) if not include_voided: query = query.where(ProductionReport.is_voided.is_(False)) if start_date: query = query.where(ProductionReport.report_date >= start_date) if end_date: query = query.where(ProductionReport.report_date <= end_date) if status_filter: query = query.where(ProductionReport.status == status_filter) query = query.order_by(ProductionReport.report_date.desc(), ProductionReport.submitted_at.desc()) result = paginate(db, query, page, page_size) return PageResponse(**{ **result, "rows": [report_out(row, get_work_schedule_config(db, row.attendance_point_name)) for row in result["rows"]], }) @router.get("/{report_id}", response_model=ReportOut) def get_report( report_id: int, user: Personnel = Depends(current_user), db: Session = Depends(get_db), ) -> ReportOut: purge_expired_voided_reports(db) if user.role == Role.worker: auto_submit_overdue_sessions(db, employee_phone=user.phone) report = _get_report_or_404(db, report_id) if user.role == Role.worker and report.employee_phone != user.phone: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限") if user.role == Role.admin and report.attendance_point_name not in accessible_point_names(db, user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点权限") return report_out(report, get_work_schedule_config(db, report.attendance_point_name))