643 lines
27 KiB
Python
643 lines
27 KiB
Python
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))
|