JhHardwareWRS_BackPoint/app/routers/reviews.py

643 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.report_over_limit import CHECK_SOURCE_REVIEW, refresh_report_over_limit_snapshot
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 _missing_over_limit_snapshot(report: ProductionReport) -> bool:
return any(
item.over_limit_checked_at is None and not item.over_limit_check_source
for item in report.items
if not is_cleaning_item(item)
)
def _report_query():
return select(ProductionReport).options(
selectinload(ProductionReport.allocations),
selectinload(ProductionReport.items).selectinload(ProductionReportItem.allocations),
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
over_limit_input_changed = 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
)
product_identity_changed = (
next_project_no != item.project_no
or next_product_name != item.product_name
or next_process_name != item.process_name
)
if (
next_device_no != item.device_no
or product_identity_changed
):
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 product_identity_changed:
over_limit_input_changed = 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
over_limit_input_changed = 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
over_limit_input_changed = 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
over_limit_input_changed = 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
over_limit_input_changed = 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()
if not is_cleaning and (over_limit_input_changed or _missing_over_limit_snapshot(report)):
refresh_report_over_limit_snapshot(db, report, source=CHECK_SOURCE_REVIEW, checked_at=reviewed_at)
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))