from datetime import date, datetime import re from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.orm import Session, selectinload from app.database import get_db from app.deps import require_roles from app.models import ( AuditAction, Equipment, Personnel, Product, ProductionReport, ProductionReportItem, ReportAuditLog, ReportStatus, Role, WorkSession, WorkSessionDevice, ) from app.schemas import ApproveReportRequest, PageResponse, RejectReportRequest, ReportOut 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 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 from app.services.misc_work import is_misc_item, is_misc_product from app.services.report_allocations import refresh_report_allocations from app.services.report_lifecycle import ( can_edit_report, mark_report_voided, purge_expired_voided_reports, restore_voided_report, ) from app.services.serializers import report_out from app.services.work_schedule import WorkScheduleConfig, get_work_schedule_config from app.timezone import now router = APIRouter(prefix="/api/reviews", tags=["reviews"]) def _minute_picker_noise(left: datetime | None, right: datetime | None) -> bool: if left is None or right is None: return left is right return left.replace(second=0, microsecond=0) == right.replace(second=0, microsecond=0) def _effective_review_time(payload_value: datetime | None, current_value: datetime) -> datetime: if payload_value is None: return current_value return current_value if _minute_picker_noise(payload_value, current_value) else payload_value def _split_device_nos(value: str | None) -> list[str]: values: list[str] = [] for part in re.split(r"[、,,\s]+", str(value or "").strip()): normalized = normalize_device_no(part) if normalized and normalized not in values: values.append(normalized) return values def _validate_cleaning_report_devices(db: Session, report: ProductionReport) -> None: valid_cleaning_equipment = { normalize_device_no(equipment.device_no) for equipment in db.scalars( select(Equipment).where( Equipment.attendance_point_name == report.attendance_point_name, Equipment.device_type == "清洗设备", ) ).all() } if not valid_cleaning_equipment: raise HTTPException(status_code=400, detail="该考勤点暂无清洗设备,请先在设备管理中维护") for item in report.items: device_nos = _split_device_nos(item.device_no) 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)} 不是该考勤点的清洗设备") def _validate_normal_report_devices(db: Session, report: ProductionReport) -> None: normal_items = [ item for item in report.items if not is_cleaning_item(item) and not is_misc_item(item) ] if not normal_items: return valid_press_equipment = { normalize_device_no(equipment.device_no) for equipment in db.scalars( select(Equipment).where( Equipment.attendance_point_name == report.attendance_point_name, Equipment.device_type == "冲压设备", ) ).all() } if not valid_press_equipment: raise HTTPException(status_code=400, detail="该考勤点暂无冲压设备,请先在设备管理中维护") for item in normal_items: device_no = normalize_device_no(item.device_no) if not device_no: raise HTTPException( status_code=400, detail=f"{mold_process_display_name(item.product_name, item.process_name, item.stamping_method)} 请填写设备号", ) if device_no not in valid_press_equipment: raise HTTPException(status_code=400, detail=f"设备 {device_no} 不是该考勤点的冲压设备") 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 _snapshot(report: ProductionReport) -> dict: return { "id": report.id, "status": str(report.status), "start_at": report.start_at.isoformat(), "end_at": report.end_at.isoformat(), "break_minutes": float(report.break_minutes or 0), "effective_minutes": float(report.effective_minutes or 0), "total_good_qty": float(report.total_good_qty or 0), "actual_beat": float(report.actual_beat or 0), "review_remark": report.review_remark, "is_multi_person_assistant": bool(report.is_multi_person_assistant), "multi_person_source_report_id": report.multi_person_source_report_id, "devices": [ { "id": device.id, "device_no": device.device_no, "process_name": device.process_name, "scanned_at": device.scanned_at.isoformat(), "released_at": device.released_at.isoformat() if device.released_at else None, "sort_order": int(device.sort_order or 0), } for device in _ordered_session_devices(report.session) ], "items": [ { "id": item.id, "device_no": item.device_no, "project_no": item.project_no, "product_name": item.product_name, "raw_material_batch_no": item.raw_material_batch_no, "process_name": item.process_name, "stamping_method": item.stamping_method, "operator_count": float(item.operator_count or 1), "process_unit_price_yuan": float(item.process_unit_price_yuan or 0), "changeover_count": float(item.changeover_count or 0), "remark": item.remark, "good_qty": float(item.good_qty or 0), "defect_qty": float(item.defect_qty or 0), "scrap_qty": float(item.scrap_qty or 0), } for item in report.items ], } def _ordered_session_devices(session: WorkSession | None) -> list[WorkSessionDevice]: return sorted( getattr(session, "devices", []) or [], key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)), ) def _device_display_name(db: Session, report: ProductionReport, device: WorkSessionDevice) -> str: with db.no_autoflush: product = db.scalar( select(Product) .where( Product.attendance_point_name == report.attendance_point_name, Product.product_name == device.device_no, Product.process_name == (device.process_name or ""), Product.device_no == "", ) .order_by(Product.project_no.asc()) ) return mold_process_display_name( device.device_no, device.process_name, product.stamping_method if product else None, ) def _apply_device_corrections( db: Session, report: ProductionReport, payload: ApproveReportRequest, next_start_at: datetime, next_end_at: datetime, ) -> tuple[bool, datetime]: if not payload.device_corrections and report.session is None: return False, next_start_at if report.session is None: raise HTTPException(status_code=400, detail="该报工缺少扫码模具记录,不能校正换模具时间") ordered_devices = _ordered_session_devices(report.session) if not ordered_devices: return False, next_start_at first_device = ordered_devices[0] corrected_start_at = next_start_at pending_scanned_at: dict[int, datetime] = {} has_correction = False if not payload.device_corrections: pending_scanned_at[first_device.id] = corrected_start_at else: device_map = {device.id: device for device in ordered_devices} seen_ids: set[int] = set() for correction in payload.device_corrections: if correction.id in seen_ids: raise HTTPException(status_code=400, detail="同一换模具时间不能重复校正") seen_ids.add(correction.id) device = device_map.get(correction.id) if device is None: raise HTTPException(status_code=400, detail=f"换模具记录 {correction.id} 不存在") next_scanned_at = _effective_review_time(correction.scanned_at, device.scanned_at) if device.id == first_device.id: corrected_start_at = next_scanned_at elif next_scanned_at < corrected_start_at or next_scanned_at > next_end_at: raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内") pending_scanned_at[device.id] = next_scanned_at pending_scanned_at.setdefault(first_device.id, corrected_start_at) if corrected_start_at >= next_end_at: raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间") for device_id, next_scanned_at in pending_scanned_at.items(): if next_scanned_at > next_end_at: raise HTTPException(status_code=400, detail="换模具时间必须在上下班时间范围内") device = next(device for device in ordered_devices if device.id == device_id) if next_scanned_at == device.scanned_at: continue device.scanned_at = next_scanned_at has_correction = True ordered_devices = _ordered_session_devices(report.session) for index in range(1, len(ordered_devices)): previous = ordered_devices[index - 1] current = ordered_devices[index] if current.scanned_at <= previous.scanned_at: previous_name = _device_display_name(db, report, previous) current_name = _device_display_name(db, report, current) raise HTTPException( status_code=400, detail=f"换模具时间顺序不正确:{current_name} 必须晚于 {previous_name}", ) return has_correction, corrected_start_at def _is_cleaning_report(report: ProductionReport) -> bool: return bool(report.items) and all(is_cleaning_item(item) for item in report.items) def _apply_metrics(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> None: if _is_cleaning_report(report): total_good_qty = sum(as_float(item.good_qty) for item in report.items) total_output_qty = sum( as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) for item in report.items ) report.duration_minutes = 0 report.effective_minutes = 0 report.total_good_qty = total_good_qty report.total_output_qty = total_output_qty report.actual_beat = 0 report.standard_beat = 0 report.expected_workload = 0 report.pace_rate = 0 report.workload_rate = 0 for item in report.items: item.allocated_minutes = 0 return metrics = calculate_report_metrics( report.start_at, report.end_at, report.items, devices=report.session.devices if report.session else None, schedule=schedule, ) report.duration_minutes = metrics["duration_minutes"] report.effective_minutes = metrics["effective_minutes"] report.total_good_qty = metrics["total_good_qty"] report.total_output_qty = metrics["total_output_qty"] report.actual_beat = metrics["actual_beat"] report.standard_beat = metrics["standard_beat"] report.expected_workload = metrics["expected_workload"] report.pace_rate = metrics["pace_rate"] report.workload_rate = metrics["workload_rate"] @router.get("/pending", response_model=PageResponse) def list_pending_reports( start_date: date | None = None, end_date: date | None = None, page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100), include_voided: bool = False, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> PageResponse: purge_expired_voided_reports(db) auto_submit_overdue_sessions(db) query = ( _report_query() .where( ProductionReport.status == ReportStatus.pending, ProductionReport.attendance_point_name.in_(accessible_point_names(db, user)), ) ) 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) query = query.order_by(ProductionReport.submitted_at.asc()) 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("/mine", response_model=PageResponse) def list_my_review_records( 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(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> PageResponse: purge_expired_voided_reports(db) query = _report_query().where(ProductionReport.attendance_point_name.in_(accessible_point_names(db, user))) 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) else: query = query.where(ProductionReport.status != ReportStatus.pending) query = query.order_by(ProductionReport.reviewed_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.post("/{report_id}/approve", response_model=ReportOut) def approve_report( report_id: int, payload: ApproveReportRequest, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> ReportOut: report = _get_report_or_404(db, report_id) if report.attendance_point_name not in accessible_point_names(db, user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限") if report.is_voided: raise HTTPException(status_code=400, detail="作废期间的报工只能查看,不能编辑") if not can_edit_report(report): raise HTTPException(status_code=400, detail="该报工已超过可编辑期限") before = _snapshot(report) schedule = get_work_schedule_config(db, report.attendance_point_name) item_map = {item.id: item for item in report.items} has_correction = False next_start_at = _effective_review_time(payload.start_at, report.start_at) next_end_at = _effective_review_time(payload.end_at, report.end_at) is_cleaning = _is_cleaning_report(report) if is_cleaning: next_start_at = report.start_at next_end_at = report.end_at else: device_has_correction, next_start_at = _apply_device_corrections( db, report, payload, next_start_at, next_end_at, ) if device_has_correction: has_correction = True if not is_cleaning and next_end_at <= next_start_at: raise HTTPException(status_code=400, detail="下班时间必须晚于上班时间") if next_start_at != report.start_at: report.start_at = next_start_at if report.session is not None: report.session.start_at = next_start_at has_correction = True if next_end_at != report.end_at: report.end_at = next_end_at if report.session is not None: report.session.end_at = next_end_at has_correction = True for correction in payload.item_corrections: item = item_map.get(correction.id) if item is None: raise HTTPException(status_code=400, detail=f"明细 {correction.id} 不存在") item_is_misc = is_misc_item(item) next_device_no = normalize_device_no(correction.device_no or item.device_no) next_project_no = correction.project_no or item.project_no next_product_name = correction.product_name or item.product_name next_process_name = correction.process_name if correction.process_name is not None else item.process_name next_raw_material_batch_no = ( correction.raw_material_batch_no if correction.raw_material_batch_no is not None else item.raw_material_batch_no ) if ( next_device_no != item.device_no or next_project_no != item.project_no or next_product_name != item.product_name or next_process_name != item.process_name ): product = db.scalar( select(Product).where( Product.attendance_point_name == report.attendance_point_name, Product.project_no == next_project_no, Product.product_name == next_product_name, Product.process_name == (next_process_name or ""), Product.device_no == "", ) ) if product is None: raise HTTPException(status_code=400, detail="校正后的产品、工序和冲压方式不在产品清单中") if is_cleaning and not is_cleaning_product(product): raise HTTPException(status_code=400, detail="清洗报工只能校正为冲压方式为清洗的产品") if not is_cleaning and is_cleaning_product(product): raise HTTPException(status_code=400, detail="非清洗报工不能校正为清洗产品") if item_is_misc and not is_misc_product(product): raise HTTPException(status_code=400, detail="处理杂活明细不能校正为普通产品") if not item_is_misc and is_misc_product(product): raise HTTPException(status_code=400, detail="普通报工明细不能校正为处理杂活") item.device_no = next_device_no item.project_no = product.project_no item.product_name = product.product_name item.material_code = product.material_code item.material_name = product.material_name item.process_name = product.process_name item.stamping_method = product.stamping_method item.operator_count = product.operator_count item.process_unit_price_yuan = product.process_unit_price_yuan item.changeover_count = item.changeover_count if is_continuous_die_product(product) else 0 item.standard_beat = as_float(product.standard_beat) item.standard_workload = 0 has_correction = True if next_raw_material_batch_no != item.raw_material_batch_no: item.raw_material_batch_no = next_raw_material_batch_no has_correction = True if correction.changeover_count is not None: next_changeover_count = as_float(correction.changeover_count) if next_changeover_count < 0: raise HTTPException(status_code=400, detail="换料次数不能为负数") if not is_continuous_die_item(item): next_changeover_count = 0 if next_changeover_count != as_float(item.changeover_count): item.changeover_count = next_changeover_count has_correction = True if correction.good_qty is not None and as_float(correction.good_qty) != as_float(item.good_qty): item.good_qty = correction.good_qty has_correction = True if correction.defect_qty is not None and as_float(correction.defect_qty) != as_float(item.defect_qty): item.defect_qty = correction.defect_qty has_correction = True if correction.scrap_qty is not None and as_float(correction.scrap_qty) != as_float(item.scrap_qty): item.scrap_qty = correction.scrap_qty has_correction = True if item_is_misc and correction.remark is not None: next_remark = str(correction.remark or "").strip() or None if next_remark != item.remark: item.remark = next_remark has_correction = True if "review_remark" in payload.model_fields_set: next_review_remark = str(payload.review_remark or "").strip() or None if next_review_remark != report.review_remark: report.review_remark = next_review_remark has_correction = True if report.is_system_auto_submitted and not is_cleaning: for item in report.items: if is_misc_item(item): continue if not normalize_device_no(item.device_no): raise HTTPException(status_code=400, detail="系统自动提交的报工请补填设备号后再审核通过") if as_float(item.good_qty) + as_float(item.defect_qty) + as_float(item.scrap_qty) <= 0: raise HTTPException(status_code=400, detail="系统自动提交的报工请补填产出数量后再审核通过") if is_cleaning: _validate_cleaning_report_devices(db, report) else: _validate_normal_report_devices(db, report) _apply_metrics(report, schedule) refresh_report_allocations(db, report, schedule=schedule, commit=False) reviewed_at = now() was_pending = report.status == ReportStatus.pending report.status = ReportStatus.approved report.reviewer_phone = user.phone if was_pending or report.reviewed_at is None: report.reviewed_at = reviewed_at report.reject_reason = None after = _snapshot(report) db.add( ReportAuditLog( report_id=report.id, reviewer_phone=user.phone, action=AuditAction.approve_with_correction if has_correction else AuditAction.approve, before_json=before, after_json=after, remark=payload.review_remark if "review_remark" in payload.model_fields_set else payload.remark, ) ) db.commit() saved = _get_report_or_404(db, report.id) return report_out(saved, schedule) @router.post("/{report_id}/void", response_model=ReportOut) def void_report( report_id: int, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> ReportOut: purge_expired_voided_reports(db) report = _get_report_or_404(db, report_id) if report.attendance_point_name not in accessible_point_names(db, user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点作废权限") mark_report_voided(report, user) db.commit() saved = _get_report_or_404(db, report.id) return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name)) @router.post("/{report_id}/unvoid", response_model=ReportOut) def unvoid_report( report_id: int, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> ReportOut: purge_expired_voided_reports(db) report = _get_report_or_404(db, report_id) if report.attendance_point_name not in accessible_point_names(db, user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点撤销作废权限") restore_voided_report(report) db.commit() saved = _get_report_or_404(db, report.id) return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name)) @router.post("/{report_id}/reject", response_model=ReportOut) def reject_report( report_id: int, payload: RejectReportRequest, user: Personnel = Depends(require_roles(Role.admin)), db: Session = Depends(get_db), ) -> ReportOut: report = _get_report_or_404(db, report_id) if report.attendance_point_name not in accessible_point_names(db, user): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无该考勤点审核权限") if report.status != ReportStatus.pending: raise HTTPException(status_code=400, detail="只有待审核报工可以驳回") before = _snapshot(report) report.status = ReportStatus.rejected report.reviewer_phone = user.phone report.reviewed_at = now() report.reject_reason = payload.reason after = _snapshot(report) db.add( ReportAuditLog( report_id=report.id, reviewer_phone=user.phone, action=AuditAction.reject, before_json=before, after_json=after, remark=payload.reason, ) ) db.commit() saved = _get_report_or_404(db, report.id) return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))