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