JhHardwareWRS_BackPoint/app/routers/reports.py
2026-06-24 15:19:14 +08:00

657 lines
28 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, 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_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.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)
db.commit()
saved = _get_report_or_404(db, report.id)
return report_out(saved, get_work_schedule_config(db, saved.attendance_point_name))
@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))