510 lines
20 KiB
Python
510 lines
20 KiB
Python
from datetime import datetime
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import (
|
|
AttendancePoint,
|
|
AuditAction,
|
|
Equipment,
|
|
PersonRole,
|
|
Personnel,
|
|
Product,
|
|
ProductionReport,
|
|
ProductionReportItem,
|
|
WorkSession,
|
|
WorkSessionDevice,
|
|
)
|
|
from app.schemas import (
|
|
AttendancePointOut,
|
|
ClockSessionOut,
|
|
EquipmentOption,
|
|
EquipmentOut,
|
|
PersonnelOut,
|
|
ProductOption,
|
|
ProductOut,
|
|
ReportAllocationOut,
|
|
ReportDeviceSegmentOut,
|
|
ReportItemOut,
|
|
ReportMetrics,
|
|
ReportOut,
|
|
)
|
|
from app.services.cleaning import is_cleaning_item, is_cleaning_product
|
|
from app.services.common import REPORT_STATUS_NAMES, ROLE_NAMES, as_float
|
|
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 build_result_text, calculate_report_metrics
|
|
from app.services.misc_work import is_misc_item, is_misc_product
|
|
from app.services.multi_person import is_multi_person_item, is_multi_person_product
|
|
from app.services.process_names import export_process_name
|
|
from app.services.report_allocations import allocation_summary_text
|
|
from app.services.report_lifecycle import can_edit_report, can_unvoid_report
|
|
from app.services.work_schedule import WorkScheduleConfig
|
|
from app.timezone import now
|
|
|
|
|
|
def temporary_expired(person: Personnel) -> bool:
|
|
return bool(
|
|
person.is_temporary
|
|
and (person.temporary_expires_at is None or person.temporary_expires_at <= now())
|
|
)
|
|
|
|
|
|
def attendance_point_out(point: AttendancePoint) -> AttendancePointOut:
|
|
return AttendancePointOut(
|
|
name=point.name,
|
|
latitude=None if point.latitude is None else float(point.latitude),
|
|
longitude=None if point.longitude is None else float(point.longitude),
|
|
radius_meters=int(point.radius_meters or 500),
|
|
day_start=point.day_start,
|
|
day_end=point.day_end,
|
|
lunch_start=point.lunch_start,
|
|
lunch_end=point.lunch_end,
|
|
dinner_start=point.dinner_start,
|
|
dinner_end=point.dinner_end,
|
|
overtime_start=point.overtime_start,
|
|
overtime_end=point.overtime_end,
|
|
night_start=point.night_start,
|
|
night_end=point.night_end,
|
|
remark=point.remark,
|
|
is_active=bool(point.is_active),
|
|
created_at=point.created_at,
|
|
updated_at=point.updated_at,
|
|
)
|
|
|
|
|
|
def personnel_out(person: Personnel, role=None) -> PersonnelOut:
|
|
selected_role = role if role is not None else getattr(person, "role", None)
|
|
roles = sorted([item.role for item in person.roles], key=lambda item: str(item))
|
|
points = sorted(
|
|
[item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None],
|
|
key=lambda item: item.name,
|
|
)
|
|
return PersonnelOut(
|
|
phone=person.phone,
|
|
name=person.name,
|
|
role=selected_role,
|
|
role_name=ROLE_NAMES.get(str(selected_role), str(selected_role)),
|
|
roles=roles,
|
|
role_names=[ROLE_NAMES.get(str(item), str(item)) for item in roles],
|
|
attendance_point_names=[point.name for point in points],
|
|
attendance_points=[attendance_point_out(point) for point in points],
|
|
is_temporary=bool(person.is_temporary),
|
|
temporary_expires_at=person.temporary_expires_at,
|
|
temporary_expired=temporary_expired(person),
|
|
)
|
|
|
|
|
|
def person_role_out(person_role: PersonRole) -> PersonnelOut:
|
|
return personnel_out(person_role.person, person_role.role)
|
|
|
|
|
|
def personnel_summary_out(person: Personnel) -> dict:
|
|
roles = sorted([str(item.role) for item in person.roles])
|
|
points = sorted(
|
|
[item.attendance_point for item in getattr(person, "attendance_points", []) if item.attendance_point is not None],
|
|
key=lambda item: item.name,
|
|
)
|
|
return {
|
|
"phone": person.phone,
|
|
"name": person.name,
|
|
"roles": roles,
|
|
"role_names": [ROLE_NAMES.get(role, role) for role in roles],
|
|
"role": roles[0] if roles else "",
|
|
"role_name": "、".join(ROLE_NAMES.get(role, role) for role in roles),
|
|
"attendance_point_names": [point.name for point in points],
|
|
"attendance_points": [attendance_point_out(point) for point in points],
|
|
"is_temporary": bool(person.is_temporary),
|
|
"temporary_expires_at": person.temporary_expires_at,
|
|
"temporary_expired": temporary_expired(person),
|
|
}
|
|
|
|
|
|
def product_out(product: Product) -> ProductOut:
|
|
optional_float = lambda value: None if value is None else as_float(value)
|
|
return ProductOut(
|
|
attendance_point_name=product.attendance_point_name,
|
|
project_no=product.project_no,
|
|
product_name=product.product_name,
|
|
profile_no=product.profile_no,
|
|
material_code=product.material_code,
|
|
material_name=product.material_name,
|
|
supplier=product.supplier,
|
|
product_net_weight_kg=optional_float(product.product_net_weight_kg),
|
|
product_gross_weight_kg=optional_float(product.product_gross_weight_kg),
|
|
scrap_loss_rate=optional_float(product.scrap_loss_rate),
|
|
waste_price_yuan_per_kg=optional_float(product.waste_price_yuan_per_kg),
|
|
device_no=product.device_no,
|
|
process_name=product.process_name,
|
|
stamping_method=product.stamping_method,
|
|
operator_count=as_float(product.operator_count),
|
|
process_unit_price_yuan=as_float(product.process_unit_price_yuan),
|
|
standard_beat=as_float(product.standard_beat),
|
|
standard_workload=as_float(product.standard_workload),
|
|
display_name=f"{product.project_no} / {mold_process_display_name(product.product_name, product.process_name, product.stamping_method)}",
|
|
)
|
|
|
|
|
|
def product_option(product: Product) -> ProductOption:
|
|
data = product_out(product).model_dump()
|
|
data["is_cleaning"] = is_cleaning_product(product)
|
|
data["is_misc"] = is_misc_product(product)
|
|
data["is_continuous_die"] = is_continuous_die_product(product)
|
|
data["is_multi_person"] = is_multi_person_product(product)
|
|
return ProductOption(**data)
|
|
|
|
|
|
def equipment_out(equipment: Equipment) -> EquipmentOut:
|
|
return EquipmentOut(
|
|
attendance_point_name=equipment.attendance_point_name,
|
|
device_no=equipment.device_no,
|
|
device_type=equipment.device_type or "冲压设备",
|
|
remark=equipment.remark,
|
|
created_at=equipment.created_at,
|
|
updated_at=equipment.updated_at,
|
|
)
|
|
|
|
|
|
def equipment_option(equipment: Equipment) -> EquipmentOption:
|
|
display_name = equipment.device_no
|
|
if equipment.remark:
|
|
display_name = f"{equipment.device_no} / {equipment.remark}"
|
|
return EquipmentOption(
|
|
attendance_point_name=equipment.attendance_point_name,
|
|
device_no=equipment.device_no,
|
|
device_type=equipment.device_type or "冲压设备",
|
|
remark=equipment.remark,
|
|
display_name=display_name,
|
|
)
|
|
|
|
|
|
def _session_device_stamping_method(
|
|
db: Session | None,
|
|
session: WorkSession,
|
|
device: WorkSessionDevice,
|
|
) -> str | None:
|
|
if db is None:
|
|
return None
|
|
point_name = device.attendance_point_name or session.attendance_point_name
|
|
product = db.scalar(
|
|
select(Product)
|
|
.where(
|
|
Product.attendance_point_name == 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 product.stamping_method if product else None
|
|
|
|
|
|
def session_out(session: WorkSession, db: Session | None = None) -> ClockSessionOut:
|
|
return ClockSessionOut(
|
|
id=session.id,
|
|
attendance_point_name=session.attendance_point_name,
|
|
employee_phone=session.employee_phone,
|
|
start_at=session.start_at,
|
|
end_at=session.end_at,
|
|
status=str(session.status),
|
|
devices=[
|
|
mold_process_display_name(
|
|
device.device_no,
|
|
device.process_name,
|
|
_session_device_stamping_method(db, session, device),
|
|
)
|
|
for device in session.devices
|
|
],
|
|
)
|
|
|
|
|
|
def _changed(old_value, new_value) -> bool:
|
|
return str(old_value or "") != str(new_value or "")
|
|
|
|
|
|
def _parse_datetime(value) -> datetime | None:
|
|
if isinstance(value, datetime):
|
|
return value
|
|
if not value:
|
|
return None
|
|
try:
|
|
return datetime.fromisoformat(str(value))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _datetime_changed(old_value, new_value) -> bool:
|
|
old_dt = _parse_datetime(old_value)
|
|
new_dt = _parse_datetime(new_value)
|
|
if old_dt is None or new_dt is None:
|
|
return _changed(old_value, new_value)
|
|
if old_dt.replace(second=0, microsecond=0) == new_dt.replace(second=0, microsecond=0):
|
|
return False
|
|
return old_dt != new_dt
|
|
|
|
|
|
def _numeric_changed(old_value, new_value) -> bool:
|
|
return round(as_float(old_value), 4) != round(as_float(new_value), 4)
|
|
|
|
|
|
def _process_name_changed(old_value, new_value) -> bool:
|
|
return export_process_name(old_value) != export_process_name(new_value)
|
|
|
|
|
|
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 report_correction_maps(report: ProductionReport) -> tuple[dict, dict[int, dict], dict[int, dict]]:
|
|
logs = [
|
|
log for log in getattr(report, "audit_logs", [])
|
|
if log.action == AuditAction.approve_with_correction and log.before_json
|
|
]
|
|
if not logs:
|
|
return {}, {}, {}
|
|
log = sorted(logs, key=lambda item: item.created_at)[-1]
|
|
before = log.before_json or {}
|
|
report_corrections = {}
|
|
if _datetime_changed(before.get("start_at"), report.start_at.isoformat()):
|
|
report_corrections["start_at"] = before.get("start_at")
|
|
if _datetime_changed(before.get("end_at"), report.end_at.isoformat()):
|
|
report_corrections["end_at"] = before.get("end_at")
|
|
if _changed(before.get("review_remark"), report.review_remark):
|
|
report_corrections["review_remark"] = before.get("review_remark")
|
|
|
|
before_items = {
|
|
item.get("id"): item
|
|
for item in before.get("items", [])
|
|
if item.get("id") is not None
|
|
}
|
|
item_corrections: dict[int, dict] = {}
|
|
text_fields = [
|
|
"device_no",
|
|
"project_no",
|
|
"product_name",
|
|
"raw_material_batch_no",
|
|
"process_name",
|
|
"stamping_method",
|
|
"remark",
|
|
]
|
|
numeric_fields = ["process_unit_price_yuan", "changeover_count", "good_qty", "defect_qty", "scrap_qty"]
|
|
for item in report.items:
|
|
old = before_items.get(item.id)
|
|
if not old:
|
|
continue
|
|
changes = {}
|
|
for field in text_fields:
|
|
if field == "process_name":
|
|
if _process_name_changed(old.get(field), getattr(item, field)):
|
|
changes[field] = export_process_name(old.get(field))
|
|
elif _changed(old.get(field), getattr(item, field)):
|
|
changes[field] = old.get(field)
|
|
for field in numeric_fields:
|
|
if _numeric_changed(old.get(field), getattr(item, field)):
|
|
changes[field] = as_float(old.get(field))
|
|
if changes:
|
|
item_corrections[item.id] = changes
|
|
|
|
before_devices = {
|
|
device.get("id"): device
|
|
for device in before.get("devices", [])
|
|
if device.get("id") is not None
|
|
}
|
|
device_corrections: dict[int, dict] = {}
|
|
for device in _ordered_session_devices(report.session if report.session else None):
|
|
old = before_devices.get(device.id)
|
|
if old and _datetime_changed(old.get("scanned_at"), device.scanned_at.isoformat()):
|
|
device_corrections[device.id] = {"scanned_at": old.get("scanned_at")}
|
|
return report_corrections, item_corrections, device_corrections
|
|
|
|
|
|
def report_device_segment_out(
|
|
device: WorkSessionDevice,
|
|
corrections: dict | None = None,
|
|
stamping_method: str | None = None,
|
|
) -> ReportDeviceSegmentOut:
|
|
process_name = device.process_name or ""
|
|
display_name = mold_process_display_name(device.device_no, process_name, stamping_method)
|
|
return ReportDeviceSegmentOut(
|
|
id=device.id,
|
|
attendance_point_name=device.attendance_point_name,
|
|
device_no=device.device_no,
|
|
mold_name=device.device_no,
|
|
process_name=process_name,
|
|
stamping_method=stamping_method,
|
|
display_name=display_name,
|
|
scanned_at=device.scanned_at,
|
|
released_at=device.released_at,
|
|
sort_order=int(device.sort_order or 0),
|
|
corrections=corrections or {},
|
|
)
|
|
|
|
|
|
def report_allocation_out(row) -> ReportAllocationOut:
|
|
return ReportAllocationOut(
|
|
allocation_date=row.allocation_date,
|
|
day_minutes=as_float(row.day_minutes),
|
|
overtime_minutes=as_float(row.overtime_minutes),
|
|
night_minutes=as_float(row.night_minutes),
|
|
effective_minutes=as_float(row.effective_minutes),
|
|
good_qty=as_float(row.good_qty),
|
|
defect_qty=as_float(row.defect_qty),
|
|
scrap_qty=as_float(row.scrap_qty),
|
|
changeover_count=as_float(row.changeover_count),
|
|
reference_wage=as_float(row.reference_wage),
|
|
)
|
|
|
|
|
|
def report_item_out(item: ProductionReportItem, corrections: dict | None = None) -> ReportItemOut:
|
|
return ReportItemOut(
|
|
id=item.id,
|
|
attendance_point_name=item.attendance_point_name,
|
|
device_no=item.device_no,
|
|
project_no=item.project_no,
|
|
product_name=item.product_name,
|
|
material_code=item.material_code,
|
|
material_name=item.material_name,
|
|
raw_material_batch_no=item.raw_material_batch_no,
|
|
process_name=item.process_name,
|
|
stamping_method=item.stamping_method,
|
|
operator_count=as_float(item.operator_count),
|
|
process_unit_price_yuan=as_float(item.process_unit_price_yuan),
|
|
changeover_count=as_float(item.changeover_count),
|
|
standard_beat=as_float(item.standard_beat),
|
|
standard_workload=as_float(item.standard_workload),
|
|
good_qty=as_float(item.good_qty),
|
|
defect_qty=as_float(item.defect_qty),
|
|
scrap_qty=as_float(item.scrap_qty),
|
|
allocated_minutes=as_float(item.allocated_minutes),
|
|
started_at=item.started_at,
|
|
is_misc=is_misc_item(item),
|
|
is_continuous_die=is_continuous_die_item(item),
|
|
is_multi_person=is_multi_person_item(item),
|
|
remark=item.remark,
|
|
allocations=[report_allocation_out(row) for row in getattr(item, "allocations", []) or []],
|
|
corrections=corrections or {},
|
|
)
|
|
|
|
|
|
def _report_is_cleaning(report: ProductionReport) -> bool:
|
|
return bool(report.items) and all(is_cleaning_item(item) for item in report.items)
|
|
|
|
|
|
def _report_is_misc_only(report: ProductionReport) -> bool:
|
|
return bool(report.items) and all(is_misc_item(item) for item in report.items)
|
|
|
|
|
|
def _cleaning_report_metrics(report: ProductionReport) -> dict:
|
|
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
|
|
)
|
|
return {
|
|
"duration_minutes": 0,
|
|
"effective_minutes": 0,
|
|
"shift_distribution_text": "清洗报工不计工时",
|
|
"shift_day_minutes": 0,
|
|
"shift_overtime_minutes": 0,
|
|
"shift_night_minutes": 0,
|
|
"shift_other_minutes": 0,
|
|
"shift_meal_break_minutes": 0,
|
|
"total_good_qty": total_good_qty,
|
|
"total_output_qty": total_output_qty,
|
|
"actual_beat": 0,
|
|
"standard_beat": 0,
|
|
"expected_workload": 0,
|
|
"pace_rate": 0,
|
|
"workload_rate": 0,
|
|
}
|
|
|
|
|
|
def report_out(report: ProductionReport, schedule: WorkScheduleConfig | None = None) -> ReportOut:
|
|
is_cleaning_report = _report_is_cleaning(report)
|
|
calculated_metrics = (
|
|
_cleaning_report_metrics(report)
|
|
if is_cleaning_report
|
|
else calculate_report_metrics(
|
|
report.start_at,
|
|
report.end_at,
|
|
list(report.items),
|
|
devices=report.session.devices if report.session else None,
|
|
schedule=schedule,
|
|
)
|
|
)
|
|
metrics = {
|
|
"effective_minutes": as_float(calculated_metrics["effective_minutes"]),
|
|
"shift_distribution_text": calculated_metrics["shift_distribution_text"],
|
|
"shift_day_minutes": as_float(calculated_metrics["shift_day_minutes"]),
|
|
"shift_overtime_minutes": as_float(calculated_metrics["shift_overtime_minutes"]),
|
|
"shift_night_minutes": as_float(calculated_metrics["shift_night_minutes"]),
|
|
"shift_other_minutes": as_float(calculated_metrics["shift_other_minutes"]),
|
|
"shift_meal_break_minutes": as_float(calculated_metrics["shift_meal_break_minutes"]),
|
|
"total_good_qty": as_float(calculated_metrics["total_good_qty"]),
|
|
"total_output_qty": as_float(calculated_metrics["total_output_qty"]),
|
|
"actual_beat": as_float(calculated_metrics["actual_beat"]),
|
|
"standard_beat": as_float(calculated_metrics["standard_beat"]),
|
|
"expected_workload": as_float(calculated_metrics["expected_workload"]),
|
|
"pace_rate": as_float(calculated_metrics["pace_rate"]),
|
|
"workload_rate": as_float(calculated_metrics["workload_rate"]),
|
|
}
|
|
report_corrections, item_corrections, device_corrections = report_correction_maps(report)
|
|
is_modified = bool(report_corrections or item_corrections or device_corrections)
|
|
stamping_by_mold = {
|
|
(item.product_name, item.process_name or ""): item.stamping_method
|
|
for item in report.items
|
|
}
|
|
return ReportOut(
|
|
id=report.id,
|
|
attendance_point_name=report.attendance_point_name,
|
|
session_id=report.session_id,
|
|
employee_phone=report.employee_phone,
|
|
employee_name=report.employee.name if report.employee else "",
|
|
report_date=report.report_date,
|
|
start_at=report.start_at,
|
|
end_at=report.end_at,
|
|
duration_minutes=as_float(calculated_metrics["duration_minutes"]),
|
|
break_minutes=as_float(report.break_minutes),
|
|
status=report.status,
|
|
status_name=REPORT_STATUS_NAMES.get(str(report.status), str(report.status)),
|
|
reviewer_phone=report.reviewer_phone,
|
|
reviewer_name=report.reviewer.name if report.reviewer else None,
|
|
reviewed_at=report.reviewed_at,
|
|
reject_reason=report.reject_reason,
|
|
review_remark=report.review_remark,
|
|
submitted_at=report.submitted_at,
|
|
is_system_auto_submitted=bool(report.is_system_auto_submitted),
|
|
auto_submit_reason=report.auto_submit_reason,
|
|
is_multi_person_assistant=bool(report.is_multi_person_assistant),
|
|
multi_person_source_report_id=report.multi_person_source_report_id,
|
|
is_voided=bool(report.is_voided),
|
|
voided_at=report.voided_at,
|
|
voided_by=report.voided_by,
|
|
voided_by_name=report.voider.name if report.voider else None,
|
|
unvoid_deadline_at=report.unvoid_deadline_at,
|
|
can_void=not bool(report.is_voided),
|
|
can_unvoid=can_unvoid_report(report),
|
|
can_edit=can_edit_report(report),
|
|
is_modified=is_modified,
|
|
device_segments=[
|
|
report_device_segment_out(
|
|
device,
|
|
device_corrections.get(device.id, {}),
|
|
stamping_by_mold.get((device.device_no, device.process_name or "")),
|
|
)
|
|
for device in _ordered_session_devices(report.session if report.session else None)
|
|
],
|
|
items=[report_item_out(item, item_corrections.get(item.id, {})) for item in report.items],
|
|
metrics=ReportMetrics(**metrics),
|
|
result_text=(
|
|
"清洗报工已提交,等待管理员审核"
|
|
if is_cleaning_report
|
|
else ("处理杂活已提交,等待管理员审核" if _report_is_misc_only(report) else build_result_text(metrics))
|
|
),
|
|
allocation_summary_text=allocation_summary_text(list(getattr(report, "allocations", []) or [])),
|
|
corrections=report_corrections,
|
|
)
|