chore: initialize mini program backend
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Business services."""
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
from math import asin, cos, radians, sin, sqrt
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import AttendancePoint, PersonAttendancePoint, Personnel, Role
|
||||
|
||||
|
||||
DEFAULT_ATTENDANCE_POINT_NAME = "宁波嘉恒智能科技有限公司"
|
||||
EARTH_RADIUS_METERS = 6371000
|
||||
|
||||
|
||||
def clean_point_name(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def ensure_default_attendance_point(db: Session) -> AttendancePoint:
|
||||
point = db.get(AttendancePoint, DEFAULT_ATTENDANCE_POINT_NAME)
|
||||
if point is not None:
|
||||
return point
|
||||
point = AttendancePoint(
|
||||
name=DEFAULT_ATTENDANCE_POINT_NAME,
|
||||
radius_meters=500,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(point)
|
||||
db.flush()
|
||||
return point
|
||||
|
||||
|
||||
def attendance_point_names(db: Session) -> list[str]:
|
||||
names = list(db.scalars(select(AttendancePoint.name).where(AttendancePoint.is_active.is_(True)).order_by(AttendancePoint.name)))
|
||||
if not names:
|
||||
point = ensure_default_attendance_point(db)
|
||||
db.commit()
|
||||
names = [point.name]
|
||||
return names
|
||||
|
||||
|
||||
def accessible_point_names(db: Session, user: Personnel) -> list[str]:
|
||||
if user.role == Role.manager:
|
||||
return attendance_point_names(db)
|
||||
|
||||
rows = list(
|
||||
db.scalars(
|
||||
select(PersonAttendancePoint.attendance_point_name)
|
||||
.join(AttendancePoint, AttendancePoint.name == PersonAttendancePoint.attendance_point_name)
|
||||
.where(
|
||||
PersonAttendancePoint.phone == user.phone,
|
||||
AttendancePoint.is_active.is_(True),
|
||||
)
|
||||
.order_by(PersonAttendancePoint.attendance_point_name)
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def accessible_points(db: Session, user: Personnel) -> list[AttendancePoint]:
|
||||
names = accessible_point_names(db, user)
|
||||
return list(
|
||||
db.scalars(
|
||||
select(AttendancePoint)
|
||||
.where(AttendancePoint.name.in_(names), AttendancePoint.is_active.is_(True))
|
||||
.order_by(AttendancePoint.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def require_attendance_point_access(db: Session, user: Personnel, point_name: str | None) -> str:
|
||||
name = clean_point_name(point_name) or DEFAULT_ATTENDANCE_POINT_NAME
|
||||
if db.get(AttendancePoint, name) is None:
|
||||
raise ValueError(f"没有该考勤点:{name}")
|
||||
if user.role != Role.manager and name not in accessible_point_names(db, user):
|
||||
raise PermissionError(f"无该考勤点权限:{name}")
|
||||
return name
|
||||
|
||||
|
||||
def distance_meters(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
delta_lat = radians(lat2 - lat1)
|
||||
delta_lng = radians(lng2 - lng1)
|
||||
first_lat = radians(lat1)
|
||||
second_lat = radians(lat2)
|
||||
value = (
|
||||
sin(delta_lat / 2) * sin(delta_lat / 2)
|
||||
+ cos(first_lat) * cos(second_lat) * sin(delta_lng / 2) * sin(delta_lng / 2)
|
||||
)
|
||||
return 2 * EARTH_RADIUS_METERS * asin(sqrt(min(1, value)))
|
||||
|
||||
|
||||
def validate_attendance_point_location(
|
||||
db: Session,
|
||||
point_name: str,
|
||||
latitude: float | None,
|
||||
longitude: float | None,
|
||||
*,
|
||||
action_name: str = "扫码报工",
|
||||
) -> None:
|
||||
if latitude is None or longitude is None:
|
||||
raise ValueError(f"{action_name}需要校验当前位置")
|
||||
point = db.get(AttendancePoint, clean_point_name(point_name))
|
||||
if point is None or not point.is_active:
|
||||
raise ValueError(f"没有该考勤点:{point_name}")
|
||||
if point.latitude is None or point.longitude is None:
|
||||
raise ValueError(f"考勤点【{point.name}】未配置经纬度")
|
||||
distance = distance_meters(float(latitude), float(longitude), float(point.latitude), float(point.longitude))
|
||||
radius = int(point.radius_meters or 500)
|
||||
if distance > radius:
|
||||
raise PermissionError(
|
||||
f"当前位置距离考勤点【{point.name}】约{round(distance)}米,超过{radius}米范围,不能{action_name}"
|
||||
)
|
||||
|
||||
|
||||
def person_with_access_points(db: Session, phone: str) -> Personnel | None:
|
||||
return db.scalar(
|
||||
select(Personnel)
|
||||
.options(selectinload(Personnel.roles), selectinload(Personnel.attendance_points))
|
||||
.where(Personnel.phone == phone)
|
||||
)
|
||||
|
||||
|
||||
def set_person_attendance_points(db: Session, phone: str, point_names: list[str]) -> None:
|
||||
cleaned = [clean_point_name(name) for name in point_names if clean_point_name(name)]
|
||||
if not cleaned:
|
||||
cleaned = [ensure_default_attendance_point(db).name]
|
||||
valid_names = set(attendance_point_names(db))
|
||||
missing = [name for name in cleaned if name not in valid_names]
|
||||
if missing:
|
||||
raise ValueError(f"没有该考勤点:{'、'.join(missing)}")
|
||||
db.query(PersonAttendancePoint).filter(PersonAttendancePoint.phone == phone).delete()
|
||||
db.flush()
|
||||
for name in dict.fromkeys(cleaned):
|
||||
db.add(PersonAttendancePoint(phone=phone, attendance_point_name=name))
|
||||
@@ -0,0 +1,230 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import SessionLocal
|
||||
from app.models import (
|
||||
Product,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportStatus,
|
||||
SessionStatus,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
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.work_schedule import DEFAULT_AUTO_SUBMIT_HOURS, get_work_schedule, get_work_schedule_config
|
||||
from app.timezone import now
|
||||
|
||||
|
||||
AUTO_SUBMIT_INTERVAL_SECONDS = 300
|
||||
AUTO_SUBMIT_REASON = "超时-系统自动提交"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ordered_session_devices(session: WorkSession) -> list[WorkSessionDevice]:
|
||||
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 _draft_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
|
||||
if session.end_at is None:
|
||||
return []
|
||||
|
||||
ordered_devices = _ordered_session_devices(session)
|
||||
ranges_by_mold: dict[tuple[str, str], dict[str, datetime]] = {}
|
||||
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,
|
||||
}
|
||||
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)
|
||||
|
||||
return [
|
||||
(mold_name, process_name, value["start_at"], value["end_at"])
|
||||
for (mold_name, process_name), value in ranges_by_mold.items()
|
||||
]
|
||||
|
||||
|
||||
def _product_for_mold(db: Session, session: WorkSession, mold_name: str, process_name: str) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == session.attendance_point_name,
|
||||
Product.product_name == mold_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
|
||||
|
||||
def _fallback_mold_ranges(session: WorkSession) -> list[tuple[str, str, datetime, datetime]]:
|
||||
if session.end_at is None:
|
||||
return []
|
||||
ordered_devices = _ordered_session_devices(session)
|
||||
if not ordered_devices:
|
||||
return []
|
||||
first = ordered_devices[0]
|
||||
return [(first.device_no, first.process_name or "", session.start_at, session.end_at)]
|
||||
|
||||
|
||||
def _build_auto_report_items(db: Session, session: WorkSession) -> list[ProductionReportItem]:
|
||||
report_items: list[ProductionReportItem] = []
|
||||
mold_ranges = _draft_mold_ranges(session) or _fallback_mold_ranges(session)
|
||||
|
||||
for mold_name, process_name, block_start_at, _block_end_at in mold_ranges:
|
||||
product = _product_for_mold(db, session, mold_name, process_name)
|
||||
item_is_misc = is_misc_product(product)
|
||||
report_items.append(
|
||||
ProductionReportItem(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
device_no=mold_name if item_is_misc else "",
|
||||
project_no=product.project_no if product else "",
|
||||
product_name=product.product_name if product else mold_name,
|
||||
material_code=product.material_code if product else None,
|
||||
material_name=product.material_name if product else None,
|
||||
raw_material_batch_no=None,
|
||||
process_name=product.process_name if product else process_name,
|
||||
stamping_method=product.stamping_method if product else None,
|
||||
operator_count=product.operator_count if product else 1,
|
||||
process_unit_price_yuan=product.process_unit_price_yuan if product else 0,
|
||||
standard_beat=0 if item_is_misc else (product.standard_beat if product else 0),
|
||||
standard_workload=0,
|
||||
good_qty=0,
|
||||
defect_qty=0,
|
||||
scrap_qty=0,
|
||||
started_at=block_start_at,
|
||||
remark=None,
|
||||
)
|
||||
)
|
||||
|
||||
return report_items
|
||||
|
||||
|
||||
def auto_submit_session(db: Session, session: WorkSession, submitted_at: datetime | None = None) -> ProductionReport | None:
|
||||
existing_report = db.scalar(select(ProductionReport).where(ProductionReport.session_id == session.id))
|
||||
if existing_report is not None:
|
||||
session.status = SessionStatus.submitted
|
||||
return None
|
||||
|
||||
timestamp = submitted_at or now()
|
||||
session.end_at = timestamp
|
||||
session.status = SessionStatus.submitted
|
||||
release_session_locks(session, None, AUTO_SUBMIT_REASON, timestamp)
|
||||
items = _build_auto_report_items(db, session)
|
||||
schedule = get_work_schedule_config(db, session.attendance_point_name)
|
||||
metrics = calculate_report_metrics(
|
||||
session.start_at,
|
||||
session.end_at,
|
||||
items,
|
||||
devices=session.devices,
|
||||
schedule=schedule,
|
||||
)
|
||||
report = ProductionReport(
|
||||
attendance_point_name=session.attendance_point_name,
|
||||
session_id=session.id,
|
||||
employee_phone=session.employee_phone,
|
||||
report_date=timestamp.date(),
|
||||
start_at=session.start_at,
|
||||
end_at=session.end_at,
|
||||
duration_minutes=metrics["duration_minutes"] if items else minutes_between(session.start_at, session.end_at),
|
||||
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=timestamp,
|
||||
is_system_auto_submitted=True,
|
||||
auto_submit_reason=AUTO_SUBMIT_REASON,
|
||||
items=items,
|
||||
)
|
||||
db.add(report)
|
||||
return report
|
||||
|
||||
|
||||
def auto_submit_overdue_sessions(
|
||||
db: Session,
|
||||
current_time: datetime | None = None,
|
||||
employee_phone: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> int:
|
||||
timestamp = current_time or now()
|
||||
try:
|
||||
auto_submit_hours = float(get_work_schedule(db).auto_submit_hours or DEFAULT_AUTO_SUBMIT_HOURS)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
auto_submit_hours = DEFAULT_AUTO_SUBMIT_HOURS
|
||||
cutoff = timestamp - timedelta(hours=auto_submit_hours)
|
||||
query = (
|
||||
select(WorkSession)
|
||||
.options(selectinload(WorkSession.devices))
|
||||
.where(
|
||||
WorkSession.status == SessionStatus.active,
|
||||
WorkSession.start_at <= cutoff,
|
||||
)
|
||||
.order_by(WorkSession.start_at.asc())
|
||||
)
|
||||
if employee_phone:
|
||||
query = query.where(WorkSession.employee_phone == employee_phone)
|
||||
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
|
||||
query = query.with_for_update(skip_locked=True)
|
||||
query = query.limit(limit)
|
||||
|
||||
sessions = db.scalars(query).all()
|
||||
submitted_count = 0
|
||||
for session in sessions:
|
||||
if auto_submit_session(db, session, timestamp) is not None:
|
||||
submitted_count += 1
|
||||
if sessions:
|
||||
db.commit()
|
||||
return submitted_count
|
||||
|
||||
|
||||
async def auto_submit_overdue_loop(interval_seconds: int = AUTO_SUBMIT_INTERVAL_SECONDS) -> None:
|
||||
while True:
|
||||
try:
|
||||
submitted_count = await asyncio.to_thread(_run_auto_submit_once)
|
||||
if submitted_count:
|
||||
logger.info("auto submitted overdue work sessions: %s", submitted_count)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("auto submit overdue work sessions failed")
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
|
||||
def _run_auto_submit_once() -> int:
|
||||
with SessionLocal() as db:
|
||||
return auto_submit_overdue_sessions(db)
|
||||
@@ -0,0 +1,22 @@
|
||||
CLEANING_STAMPING_METHOD = "清洗"
|
||||
LEGACY_CLEANING_PROCESS_NAME = "清洗"
|
||||
|
||||
|
||||
def clean_text(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def is_cleaning_stamping_method(value: str | None) -> bool:
|
||||
return clean_text(value) == CLEANING_STAMPING_METHOD
|
||||
|
||||
|
||||
def is_legacy_cleaning_process(value: str | None) -> bool:
|
||||
return clean_text(value) == LEGACY_CLEANING_PROCESS_NAME
|
||||
|
||||
|
||||
def is_cleaning_product(product) -> bool:
|
||||
return bool(product) and is_cleaning_stamping_method(getattr(product, "stamping_method", None))
|
||||
|
||||
|
||||
def is_cleaning_item(item) -> bool:
|
||||
return is_cleaning_stamping_method(getattr(item, "stamping_method", None))
|
||||
@@ -0,0 +1,63 @@
|
||||
from decimal import Decimal
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
ROLE_NAMES = {
|
||||
"worker": "冲压工人",
|
||||
"admin": "管理员",
|
||||
"manager": "经理",
|
||||
}
|
||||
|
||||
REPORT_STATUS_NAMES = {
|
||||
"pending": "待审核",
|
||||
"approved": "已通过",
|
||||
"rejected": "已驳回",
|
||||
}
|
||||
|
||||
|
||||
def normalize_device_no(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def as_float(value: Any) -> float:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if value is None:
|
||||
return 0.0
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def round1(value: Any) -> float:
|
||||
return round(as_float(value), 1)
|
||||
|
||||
|
||||
def round2(value: Any) -> float:
|
||||
return round(as_float(value), 2)
|
||||
|
||||
|
||||
def round3(value: Any) -> float:
|
||||
return round(as_float(value), 3)
|
||||
|
||||
|
||||
def paginate(db: Session, query: Select, page: int, page_size: int) -> dict[str, Any]:
|
||||
safe_page = max(1, int(page or 1))
|
||||
safe_page_size = min(100, max(1, int(page_size or 10)))
|
||||
count_query = select(func.count()).select_from(query.order_by(None).subquery())
|
||||
total = db.scalar(count_query) or 0
|
||||
total_pages = max(1, ceil(total / safe_page_size))
|
||||
safe_page = min(safe_page, total_pages)
|
||||
rows = db.scalars(query.limit(safe_page_size).offset((safe_page - 1) * safe_page_size)).all()
|
||||
return {
|
||||
"page": safe_page,
|
||||
"page_size": safe_page_size,
|
||||
"total": total,
|
||||
"total_pages": total_pages,
|
||||
"rows": rows,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
CONTINUOUS_DIE_STAMPING_METHOD = "连续模"
|
||||
|
||||
|
||||
def clean_text(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def is_continuous_die_stamping_method(value: str | None) -> bool:
|
||||
return clean_text(value) == CONTINUOUS_DIE_STAMPING_METHOD
|
||||
|
||||
|
||||
def is_continuous_die_product(product) -> bool:
|
||||
return bool(product) and is_continuous_die_stamping_method(getattr(product, "stamping_method", None))
|
||||
|
||||
|
||||
def is_continuous_die_item(item) -> bool:
|
||||
return is_continuous_die_stamping_method(getattr(item, "stamping_method", None))
|
||||
@@ -0,0 +1,25 @@
|
||||
def _clean(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def mold_process_display_name(
|
||||
product_name: str | None,
|
||||
process_name: str | None,
|
||||
stamping_method: str | None = None,
|
||||
) -> str:
|
||||
parts = [_clean(product_name), _clean(process_name) or "-"]
|
||||
stamping = _clean(stamping_method)
|
||||
if stamping:
|
||||
parts.append(stamping)
|
||||
return " / ".join(part for part in parts if part)
|
||||
|
||||
|
||||
def point_mold_display_name(
|
||||
point_name: str | None,
|
||||
product_name: str | None,
|
||||
process_name: str | None,
|
||||
stamping_method: str | None = None,
|
||||
) -> str:
|
||||
point = _clean(point_name)
|
||||
mold_text = mold_process_display_name(product_name, process_name, stamping_method)
|
||||
return f"{point} / {mold_text}" if point else mold_text
|
||||
@@ -0,0 +1,219 @@
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import PatternFill
|
||||
from sqlalchemy import and_, not_, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PersonAttendancePoint, PersonRole, Personnel, Product, Role
|
||||
from app.services.attendance_points import accessible_point_names
|
||||
from app.services.common import ROLE_NAMES, as_float
|
||||
from app.services.misc_work import MISC_WORK_PROCESS_NAME, MISC_WORK_PRODUCT_NAME, MISC_WORK_STAMPING_METHOD
|
||||
from app.services.process_names import export_process_name
|
||||
|
||||
|
||||
def _workbook_bytes(workbook: Workbook) -> bytes:
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _optional_float(value):
|
||||
return "" if value is None else as_float(value)
|
||||
|
||||
|
||||
def export_products(db: Session, user=None) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "产品清单"
|
||||
sheet.append([
|
||||
"考勤点",
|
||||
"项目号",
|
||||
"型材号",
|
||||
"产品名称",
|
||||
"物料编码",
|
||||
"物料名称",
|
||||
"供应商",
|
||||
"工序",
|
||||
"冲压方式",
|
||||
"操作人数",
|
||||
"工序单价",
|
||||
"不包含物料转运节拍",
|
||||
"标准节拍",
|
||||
"产品净重(kg)",
|
||||
"产品毛重(kg)",
|
||||
"允许报废率",
|
||||
"废料单价(元/kg)",
|
||||
])
|
||||
query = select(Product).where(
|
||||
Product.device_no == "",
|
||||
or_(Product.stamping_method.is_(None), Product.stamping_method != MISC_WORK_STAMPING_METHOD),
|
||||
not_(and_(Product.product_name == MISC_WORK_PRODUCT_NAME, Product.process_name == MISC_WORK_PROCESS_NAME)),
|
||||
)
|
||||
if user is not None:
|
||||
query = query.where(Product.attendance_point_name.in_(accessible_point_names(db, user)))
|
||||
products = db.scalars(query.order_by(Product.attendance_point_name, Product.project_no, Product.product_name, Product.process_name)).all()
|
||||
for product in products:
|
||||
sheet.append([
|
||||
product.attendance_point_name,
|
||||
product.project_no,
|
||||
product.profile_no,
|
||||
product.product_name,
|
||||
product.material_code,
|
||||
product.material_name,
|
||||
product.supplier,
|
||||
export_process_name(product.process_name),
|
||||
product.stamping_method,
|
||||
as_float(product.operator_count),
|
||||
as_float(product.process_unit_price_yuan),
|
||||
"",
|
||||
as_float(product.standard_beat),
|
||||
_optional_float(product.product_net_weight_kg),
|
||||
_optional_float(product.product_gross_weight_kg),
|
||||
_optional_float(product.scrap_loss_rate),
|
||||
_optional_float(product.waste_price_yuan_per_kg),
|
||||
])
|
||||
return _workbook_bytes(workbook)
|
||||
|
||||
|
||||
def export_people(db: Session, user=None) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "人员清单"
|
||||
sheet.append(["考勤点", "电话号", "姓名", "角色"])
|
||||
query = select(PersonRole).join(PersonRole.person).order_by(Personnel.name, PersonRole.role)
|
||||
if user is not None and user.role == Role.admin:
|
||||
point_names = accessible_point_names(db, user)
|
||||
query = query.where(
|
||||
PersonRole.role == Role.worker,
|
||||
Personnel.attendance_points.any(PersonAttendancePoint.attendance_point_name.in_(point_names)),
|
||||
)
|
||||
people = db.scalars(query).all()
|
||||
for item in people:
|
||||
point_names = sorted({point.attendance_point_name for point in item.person.attendance_points})
|
||||
sheet.append([
|
||||
"、".join(point_names),
|
||||
item.phone,
|
||||
item.person.name,
|
||||
ROLE_NAMES.get(str(item.role), str(item.role)),
|
||||
])
|
||||
return _workbook_bytes(workbook)
|
||||
|
||||
|
||||
def export_dashboard_rows(rows: list) -> bytes:
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = "报工看板"
|
||||
warn_fill = PatternFill(fill_type="solid", fgColor="FFFF6666")
|
||||
void_fill = PatternFill(fill_type="solid", fgColor="FFD9D9D9")
|
||||
sheet.append([
|
||||
"考勤点",
|
||||
"报工日期",
|
||||
"员工姓名",
|
||||
"员工电话",
|
||||
"工种",
|
||||
"项目号",
|
||||
"产品名称",
|
||||
"工序",
|
||||
"有效工时(时)",
|
||||
"班种分布",
|
||||
"成品数量",
|
||||
"不良数量",
|
||||
"标准工作量",
|
||||
"实际节拍",
|
||||
"标准节拍",
|
||||
"型材号",
|
||||
"物料编码",
|
||||
"物料名称",
|
||||
"材料库存批次号",
|
||||
"使用设备",
|
||||
"冲压方式",
|
||||
"换料次数",
|
||||
"操作人数",
|
||||
"工序单价",
|
||||
"参考工资",
|
||||
"报工标记",
|
||||
"校正项",
|
||||
"备注",
|
||||
])
|
||||
wage_summary: dict[tuple[str, str, str], dict[str, float | str]] = {}
|
||||
for row_index, row in enumerate(rows, start=2):
|
||||
report_flags = []
|
||||
if getattr(row, "is_voided", False):
|
||||
report_flags.append("作废")
|
||||
if getattr(row, "is_multi_person", False):
|
||||
report_flags.append("多人协作")
|
||||
if getattr(row, "is_continuous_die", False):
|
||||
report_flags.append("连续模")
|
||||
correction_text = ";".join(
|
||||
f"{item.get('label', '')} {item.get('old_value', '-')}→{item.get('new_value', '-')}"
|
||||
for item in getattr(row, "correction_pairs", [])
|
||||
)
|
||||
sheet.append([
|
||||
row.attendance_point_name,
|
||||
row.report_date.isoformat(),
|
||||
row.employee_name,
|
||||
row.employee_phone,
|
||||
row.worker_type,
|
||||
row.project_no,
|
||||
row.product_name,
|
||||
row.process_name,
|
||||
round(as_float(row.effective_minutes) / 60, 2),
|
||||
row.shift_distribution_text,
|
||||
row.total_good_qty,
|
||||
row.total_defect_qty,
|
||||
row.expected_workload,
|
||||
row.actual_beat,
|
||||
row.standard_beat,
|
||||
row.profile_no,
|
||||
row.material_code,
|
||||
row.material_name,
|
||||
row.raw_material_batch_no,
|
||||
row.device_no,
|
||||
row.stamping_method,
|
||||
row.changeover_count,
|
||||
row.operator_count,
|
||||
row.process_unit_price_yuan,
|
||||
row.reference_wage,
|
||||
"、".join(report_flags),
|
||||
correction_text,
|
||||
getattr(row, "review_remark", None),
|
||||
])
|
||||
summary_key = (row.employee_phone, row.employee_name, row.worker_type)
|
||||
summary = wage_summary.setdefault(
|
||||
summary_key,
|
||||
{
|
||||
"employee_phone": row.employee_phone,
|
||||
"employee_name": row.employee_name,
|
||||
"worker_type": row.worker_type,
|
||||
"total_good_qty": 0.0,
|
||||
"reference_wage": 0.0,
|
||||
"detail_count": 0.0,
|
||||
},
|
||||
)
|
||||
summary["total_good_qty"] = as_float(summary["total_good_qty"]) + as_float(row.total_good_qty)
|
||||
summary["reference_wage"] = as_float(summary["reference_wage"]) + as_float(row.reference_wage)
|
||||
summary["detail_count"] = as_float(summary["detail_count"]) + 1
|
||||
actual_beat = as_float(row.actual_beat)
|
||||
standard_beat = as_float(row.standard_beat)
|
||||
total_good_qty = as_float(row.total_good_qty)
|
||||
expected_workload = as_float(row.expected_workload)
|
||||
if standard_beat > 0 and (actual_beat > standard_beat or actual_beat < standard_beat * 0.7):
|
||||
sheet.cell(row=row_index, column=14).fill = warn_fill
|
||||
if expected_workload > 0 and (total_good_qty < expected_workload or total_good_qty > expected_workload * 1.3):
|
||||
sheet.cell(row=row_index, column=11).fill = warn_fill
|
||||
if getattr(row, "is_voided", False):
|
||||
for cell in sheet[row_index]:
|
||||
cell.fill = void_fill
|
||||
summary_sheet = workbook.create_sheet("工资汇总")
|
||||
summary_sheet.append(["员工姓名", "员工电话", "工种", "明细行数", "成品数量合计", "参考工资合计"])
|
||||
for summary in sorted(wage_summary.values(), key=lambda item: (str(item["employee_name"]), str(item["employee_phone"]))):
|
||||
summary_sheet.append([
|
||||
summary["employee_name"],
|
||||
summary["employee_phone"],
|
||||
summary["worker_type"],
|
||||
int(as_float(summary["detail_count"])),
|
||||
round(as_float(summary["total_good_qty"]), 2),
|
||||
round(as_float(summary["reference_wage"]), 2),
|
||||
])
|
||||
return _workbook_bytes(workbook)
|
||||
@@ -0,0 +1,406 @@
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PersonRole, Personnel, Product, Role
|
||||
from app.services.attendance_points import (
|
||||
DEFAULT_ATTENDANCE_POINT_NAME,
|
||||
accessible_point_names,
|
||||
attendance_point_names,
|
||||
set_person_attendance_points,
|
||||
)
|
||||
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
|
||||
from app.services.misc_work import is_misc_stamping_method
|
||||
from app.services.process_names import normalize_numeric_process_name
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportStats:
|
||||
imported: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[str] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"imported": self.imported,
|
||||
"updated": self.updated,
|
||||
"skipped": self.skipped,
|
||||
"errors": self.errors or [],
|
||||
}
|
||||
|
||||
|
||||
PRODUCT_HEADERS = {
|
||||
"考勤点": "attendance_point_name",
|
||||
"项目号": "project_no",
|
||||
"型材号": "profile_no",
|
||||
"产品名称": "product_name",
|
||||
"物料编码": "material_code",
|
||||
"物料名称": "material_name",
|
||||
"供应商": "supplier",
|
||||
"产品净重(kg)": "product_net_weight_kg",
|
||||
"产品毛重(kg)": "product_gross_weight_kg",
|
||||
"允许报废率": "scrap_loss_rate",
|
||||
"废料单价(元/kg)": "waste_price_yuan_per_kg",
|
||||
"工序": "process_name",
|
||||
"冲压方式": "stamping_method",
|
||||
"操作人数": "operator_count",
|
||||
"工序单价": "process_unit_price_yuan",
|
||||
"标准节拍": "standard_beat",
|
||||
}
|
||||
|
||||
PRODUCT_AUX_FIELDS = (
|
||||
("product_net_weight_kg", "产品净重(kg)"),
|
||||
("product_gross_weight_kg", "产品毛重(kg)"),
|
||||
("scrap_loss_rate", "允许报废率"),
|
||||
("waste_price_yuan_per_kg", "废料单价(元/kg)"),
|
||||
)
|
||||
|
||||
|
||||
class ProductImportValidationError(ValueError):
|
||||
pass
|
||||
|
||||
PERSON_HEADERS = {
|
||||
"考勤点": "attendance_point_names",
|
||||
"电话号": "phone",
|
||||
"手机号": "phone",
|
||||
"姓名": "name",
|
||||
"角色": "role",
|
||||
}
|
||||
|
||||
ROLE_MAP = {
|
||||
"冲压工人": Role.worker,
|
||||
"员工": Role.worker,
|
||||
"worker": Role.worker,
|
||||
"管理员": Role.admin,
|
||||
"admin": Role.admin,
|
||||
"经理": Role.manager,
|
||||
"manager": Role.manager,
|
||||
}
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _split_names(value: Any) -> list[str]:
|
||||
text = _text(value)
|
||||
if not text:
|
||||
return []
|
||||
for sep in [",", ",", ";", ";", "/", "|"]:
|
||||
text = text.replace(sep, "、")
|
||||
return [item.strip() for item in text.split("、") if item.strip()]
|
||||
|
||||
|
||||
def _number(value: Any, default: float = 0) -> float:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return default
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _optional_number(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalized_aux_value(value: Any) -> str:
|
||||
text = _text(value)
|
||||
if text == "":
|
||||
return ""
|
||||
try:
|
||||
return str(Decimal(text).normalize())
|
||||
except InvalidOperation:
|
||||
return text
|
||||
|
||||
|
||||
def _rows_from_excel(content: bytes) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return [], []
|
||||
headers = [_text(value) for value in rows[0]]
|
||||
mapped_rows = []
|
||||
for values in rows[1:]:
|
||||
mapped_rows.append({headers[index]: value for index, value in enumerate(values) if index < len(headers)})
|
||||
return headers, mapped_rows
|
||||
|
||||
|
||||
def _validate_product_aux_consistency(db: Session, rows: list[dict[str, Any]]) -> None:
|
||||
aux_headers = {label: field for field, label in PRODUCT_AUX_FIELDS}
|
||||
if not any(header in row for row in rows for header in aux_headers):
|
||||
return
|
||||
|
||||
grouped: dict[str, dict[str, tuple[int, str, str]]] = {}
|
||||
errors: list[str] = []
|
||||
for index, raw in enumerate(rows, start=2):
|
||||
point_name = _text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
|
||||
product_name = _text(raw.get("产品名称"))
|
||||
process_name = _text(raw.get("工序"))
|
||||
if not product_name:
|
||||
continue
|
||||
values = tuple(
|
||||
_normalized_aux_value(raw.get(label))
|
||||
for _, label in PRODUCT_AUX_FIELDS
|
||||
)
|
||||
group_key = f"{point_name}||{product_name}"
|
||||
first_by_product = grouped.setdefault(group_key, {})
|
||||
previous = first_by_product.get("values")
|
||||
current_text = ",".join(
|
||||
f"{label}={value or '空'}"
|
||||
for (_, label), value in zip(PRODUCT_AUX_FIELDS, values)
|
||||
)
|
||||
if previous is None:
|
||||
first_by_product["values"] = (index, process_name, current_text)
|
||||
first_by_product["raw_values"] = (-1, "", "|".join(values))
|
||||
continue
|
||||
previous_raw = first_by_product["raw_values"][2]
|
||||
if previous_raw != "|".join(values):
|
||||
first_row, first_process, first_text = previous
|
||||
errors.append(
|
||||
f"考勤点【{point_name}】产品【{product_name}】不同工序的ERP辅助字段不一致:"
|
||||
f"第{first_row}行({first_process or '未填写工序'})为【{first_text}】,"
|
||||
f"第{index}行({process_name or '未填写工序'})为【{current_text}】"
|
||||
)
|
||||
|
||||
if errors:
|
||||
detail = ";".join(errors[:10])
|
||||
if len(errors) > 10:
|
||||
detail += f";另有{len(errors) - 10}处不一致"
|
||||
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
|
||||
|
||||
|
||||
def _validate_point_names(db: Session, names: list[str], user=None, *, action: str) -> None:
|
||||
known = set(attendance_point_names(db))
|
||||
missing = [name for name in names if name not in known]
|
||||
if missing:
|
||||
raise ProductImportValidationError(f"没有该考勤点:{'、'.join(sorted(set(missing)))}。本次导入已取消,数据库未写入。")
|
||||
if user is not None and user.role != Role.manager:
|
||||
allowed = set(accessible_point_names(db, user))
|
||||
denied = [name for name in names if name not in allowed]
|
||||
if denied:
|
||||
raise ProductImportValidationError(f"无该考勤点{action}权限:{'、'.join(sorted(set(denied)))}。本次导入已取消,数据库未写入。")
|
||||
|
||||
|
||||
def _validate_product_process_names(rows: list[dict[str, Any]]) -> None:
|
||||
errors: list[str] = []
|
||||
for index, raw in enumerate(rows, start=2):
|
||||
if not any(_text(value) for value in raw.values()):
|
||||
continue
|
||||
raw_process_name = _text(raw.get("工序"))
|
||||
stamping_method = _text(raw.get("冲压方式"))
|
||||
is_misc = is_misc_stamping_method(stamping_method)
|
||||
if is_misc:
|
||||
errors.append(f"第{index}行处理杂活由系统自动生成,不能通过产品清单导入")
|
||||
continue
|
||||
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
|
||||
errors.append(f"第{index}行工序不能填写清洗,请把清洗填写在冲压方式中")
|
||||
continue
|
||||
try:
|
||||
normalize_numeric_process_name(raw_process_name)
|
||||
except ValueError:
|
||||
errors.append(f"第{index}行工序必须是数字")
|
||||
continue
|
||||
if errors:
|
||||
detail = ";".join(errors[:10])
|
||||
if len(errors) > 10:
|
||||
detail += f";另有{len(errors) - 10}处错误"
|
||||
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
|
||||
|
||||
|
||||
def import_products(db: Session, content: bytes, user=None) -> ImportStats:
|
||||
_, rows = _rows_from_excel(content)
|
||||
_validate_product_aux_consistency(db, rows)
|
||||
_validate_product_process_names(rows)
|
||||
point_values = [
|
||||
_text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
|
||||
for raw in rows
|
||||
if any(_text(value) for value in raw.values())
|
||||
]
|
||||
_validate_point_names(db, point_values, user, action="产品导入")
|
||||
stats = ImportStats(errors=[])
|
||||
last_project_no = ""
|
||||
seen: dict[tuple[str, str, str, str, str], Product] = {}
|
||||
aux_by_product: dict[tuple[str, str], tuple[float | None, float | None, float | None, float | None]] = {}
|
||||
|
||||
for index, raw in enumerate(rows, start=2):
|
||||
data = {field: raw[header] for header, field in PRODUCT_HEADERS.items() if header in raw}
|
||||
attendance_point_name = _text(data.get("attendance_point_name")) or DEFAULT_ATTENDANCE_POINT_NAME
|
||||
project_no = _text(data.get("project_no")) or last_project_no
|
||||
product_name = _text(data.get("product_name"))
|
||||
device_no = ""
|
||||
raw_process_name = _text(data.get("process_name"))
|
||||
stamping_method = _text(data.get("stamping_method"))
|
||||
is_cleaning = is_cleaning_stamping_method(stamping_method)
|
||||
is_misc = is_misc_stamping_method(stamping_method)
|
||||
standard_beat = _number(data.get("standard_beat"))
|
||||
if project_no:
|
||||
last_project_no = project_no
|
||||
|
||||
if is_misc:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行处理杂活由系统自动生成,不能通过产品清单导入")
|
||||
continue
|
||||
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行工序不能填写清洗,请把清洗填写在冲压方式中,工序只能填写数字")
|
||||
continue
|
||||
try:
|
||||
process_name = normalize_numeric_process_name(raw_process_name)
|
||||
except ValueError:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行工序必须是数字")
|
||||
continue
|
||||
|
||||
if not project_no or not product_name or not process_name or (not is_cleaning and not is_misc and standard_beat <= 0):
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行缺少项目号、产品名称、工序或标准节拍(冲压方式为清洗时标准节拍可空)")
|
||||
continue
|
||||
|
||||
key = (attendance_point_name, project_no, product_name, device_no, process_name)
|
||||
if key in seen:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行重复产品/工序:{attendance_point_name} / {project_no} / {product_name} / {process_name}")
|
||||
continue
|
||||
product = seen.get(key)
|
||||
if product is None:
|
||||
product = db.get(
|
||||
Product,
|
||||
{
|
||||
"attendance_point_name": attendance_point_name,
|
||||
"project_no": project_no,
|
||||
"product_name": product_name,
|
||||
"device_no": device_no,
|
||||
"process_name": process_name,
|
||||
},
|
||||
)
|
||||
if product is None:
|
||||
product = Product(
|
||||
attendance_point_name=attendance_point_name,
|
||||
project_no=project_no,
|
||||
product_name=product_name,
|
||||
device_no=device_no,
|
||||
process_name=process_name,
|
||||
)
|
||||
db.add(product)
|
||||
stats.imported += 1
|
||||
else:
|
||||
stats.updated += 1
|
||||
seen[key] = product
|
||||
|
||||
product.profile_no = _text(data.get("profile_no")) or None
|
||||
product.material_code = _text(data.get("material_code")) or None
|
||||
product.material_name = _text(data.get("material_name")) or None
|
||||
product.supplier = _text(data.get("supplier")) or None
|
||||
if "product_net_weight_kg" in data:
|
||||
product.product_net_weight_kg = _optional_number(data.get("product_net_weight_kg"))
|
||||
if "product_gross_weight_kg" in data:
|
||||
product.product_gross_weight_kg = _optional_number(data.get("product_gross_weight_kg"))
|
||||
if "scrap_loss_rate" in data:
|
||||
product.scrap_loss_rate = _optional_number(data.get("scrap_loss_rate"))
|
||||
if "waste_price_yuan_per_kg" in data:
|
||||
product.waste_price_yuan_per_kg = _optional_number(data.get("waste_price_yuan_per_kg"))
|
||||
aux_by_product[(attendance_point_name, product_name)] = (
|
||||
product.product_net_weight_kg,
|
||||
product.product_gross_weight_kg,
|
||||
product.scrap_loss_rate,
|
||||
product.waste_price_yuan_per_kg,
|
||||
)
|
||||
product.device_no = device_no
|
||||
product.process_name = process_name
|
||||
product.stamping_method = stamping_method or None
|
||||
product.operator_count = _number(data.get("operator_count"), 1) or 1
|
||||
product.process_unit_price_yuan = _number(data.get("process_unit_price_yuan"), 0)
|
||||
product.standard_beat = 0 if (is_cleaning or is_misc) else standard_beat
|
||||
product.standard_workload = 0
|
||||
|
||||
if stats.errors:
|
||||
db.rollback()
|
||||
detail = ";".join(stats.errors[:20])
|
||||
if len(stats.errors) > 20:
|
||||
detail += f";另有{len(stats.errors) - 20}处错误"
|
||||
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
|
||||
|
||||
for (attendance_point_name, product_name), values in aux_by_product.items():
|
||||
for sibling in db.scalars(
|
||||
select(Product).where(
|
||||
Product.attendance_point_name == attendance_point_name,
|
||||
Product.product_name == product_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
).all():
|
||||
sibling.product_net_weight_kg = values[0]
|
||||
sibling.product_gross_weight_kg = values[1]
|
||||
sibling.scrap_loss_rate = values[2]
|
||||
sibling.waste_price_yuan_per_kg = values[3]
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def import_people(db: Session, content: bytes, user=None) -> ImportStats:
|
||||
_, rows = _rows_from_excel(content)
|
||||
row_point_names: list[str] = []
|
||||
for raw in rows:
|
||||
row_point_names.extend(_split_names(raw.get("考勤点")) or [DEFAULT_ATTENDANCE_POINT_NAME])
|
||||
_validate_point_names(db, row_point_names, user, action="人员导入")
|
||||
stats = ImportStats(errors=[])
|
||||
|
||||
for index, raw in enumerate(rows, start=2):
|
||||
data = {field: raw[header] for header, field in PERSON_HEADERS.items() if header in raw}
|
||||
phone = _text(data.get("phone"))
|
||||
name = _text(data.get("name"))
|
||||
role_text = _text(data.get("role"))
|
||||
role = ROLE_MAP.get(role_text)
|
||||
point_names = _split_names(data.get("attendance_point_names")) or [DEFAULT_ATTENDANCE_POINT_NAME]
|
||||
if not phone or not name or role is None:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行缺少电话号、姓名或有效角色")
|
||||
continue
|
||||
if user is not None and user.role == Role.admin and role != Role.worker:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行管理员只能导入冲压工人")
|
||||
continue
|
||||
|
||||
person = db.get(Personnel, phone)
|
||||
if person is None:
|
||||
person = Personnel(phone=phone)
|
||||
db.add(person)
|
||||
stats.imported += 1
|
||||
else:
|
||||
stats.updated += 1
|
||||
person.name = name
|
||||
person.is_temporary = False
|
||||
person.temporary_expires_at = None
|
||||
person_role = db.get(PersonRole, {"phone": phone, "role": role})
|
||||
if person_role is None:
|
||||
db.add(PersonRole(phone=phone, role=role))
|
||||
try:
|
||||
set_person_attendance_points(db, phone, point_names)
|
||||
except ValueError as exc:
|
||||
stats.skipped += 1
|
||||
stats.errors.append(f"第{index}行{exc}")
|
||||
|
||||
if stats.errors:
|
||||
db.rollback()
|
||||
detail = ";".join(stats.errors[:20])
|
||||
if len(stats.errors) > 20:
|
||||
detail += f";另有{len(stats.errors) - 20}处错误"
|
||||
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
@@ -0,0 +1,506 @@
|
||||
from collections import defaultdict
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import Protocol
|
||||
|
||||
from app.services.common import as_float, normalize_device_no, round1, round2
|
||||
from app.services.misc_work import is_misc_item
|
||||
from app.services.work_schedule import DEFAULT_WORK_SCHEDULE_CONFIG, WorkScheduleConfig
|
||||
|
||||
|
||||
class ReportItemLike(Protocol):
|
||||
device_no: str
|
||||
product_name: str
|
||||
process_name: str | None
|
||||
started_at: datetime | None
|
||||
standard_beat: float
|
||||
standard_workload: float
|
||||
stamping_method: str | None
|
||||
good_qty: float
|
||||
defect_qty: float
|
||||
scrap_qty: float
|
||||
allocated_minutes: float
|
||||
|
||||
|
||||
class ReportDeviceLike(Protocol):
|
||||
device_no: str
|
||||
process_name: str | None
|
||||
scanned_at: datetime
|
||||
released_at: datetime | None
|
||||
sort_order: int
|
||||
|
||||
|
||||
SHIFT_BUCKETS = ("day", "overtime", "night")
|
||||
SHIFT_LABELS = {
|
||||
"day": "白班",
|
||||
"overtime": "加班",
|
||||
"night": "夜班",
|
||||
}
|
||||
MEAL_END_GRACE_MINUTES = 5
|
||||
|
||||
|
||||
def minutes_between(start_at: datetime, end_at: datetime) -> float:
|
||||
seconds = max(0.0, (end_at - start_at).total_seconds())
|
||||
return seconds / 60
|
||||
|
||||
|
||||
def _combine(day: date, value: time) -> datetime:
|
||||
return datetime.combine(day, value)
|
||||
|
||||
|
||||
def _parse_schedule_time(value: str) -> time:
|
||||
hour, minute = [int(part) for part in str(value).split(":", 1)]
|
||||
return time(hour, minute)
|
||||
|
||||
|
||||
def _schedule_interval(base_date: date, start_text: str, end_text: str) -> tuple[datetime, datetime]:
|
||||
start = _combine(base_date, _parse_schedule_time(start_text))
|
||||
end = _combine(base_date, _parse_schedule_time(end_text))
|
||||
if end <= start:
|
||||
end += timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
|
||||
def _overlap_minutes(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
interval_start: datetime,
|
||||
interval_end: datetime,
|
||||
) -> float:
|
||||
start = max(start_at, interval_start)
|
||||
end = min(end_at, interval_end)
|
||||
return minutes_between(start, end) if end > start else 0.0
|
||||
|
||||
|
||||
def _covered_meal_overlap_minutes(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
meal_start: datetime,
|
||||
meal_end: datetime,
|
||||
) -> float:
|
||||
if end_at <= meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES):
|
||||
return 0.0
|
||||
if start_at <= meal_start and end_at >= meal_end:
|
||||
return _overlap_minutes(start_at, end_at, meal_start, meal_end)
|
||||
return 0.0
|
||||
|
||||
|
||||
def _iter_shift_base_dates(start_at: datetime, end_at: datetime):
|
||||
current = start_at.date() - timedelta(days=1)
|
||||
last = end_at.date()
|
||||
while current <= last:
|
||||
yield current
|
||||
current += timedelta(days=1)
|
||||
|
||||
|
||||
def _raw_shift_minutes(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> dict[str, float]:
|
||||
active_schedule = schedule or DEFAULT_WORK_SCHEDULE_CONFIG
|
||||
minutes = {
|
||||
"day": 0.0,
|
||||
"overtime": 0.0,
|
||||
"night": 0.0,
|
||||
"meal_break": 0.0,
|
||||
}
|
||||
if end_at <= start_at:
|
||||
return minutes
|
||||
|
||||
for current_date in _iter_shift_base_dates(start_at, end_at):
|
||||
meal_intervals = [
|
||||
_schedule_interval(current_date, active_schedule.lunch_start, active_schedule.lunch_end),
|
||||
_schedule_interval(current_date, active_schedule.dinner_start, active_schedule.dinner_end),
|
||||
]
|
||||
shift_intervals = {
|
||||
"day": [_schedule_interval(current_date, active_schedule.day_start, active_schedule.day_end)],
|
||||
"overtime": [
|
||||
_schedule_interval(current_date, active_schedule.overtime_start, active_schedule.overtime_end)
|
||||
],
|
||||
"night": [_schedule_interval(current_date, active_schedule.night_start, active_schedule.night_end)],
|
||||
}
|
||||
|
||||
for meal_start, meal_end in meal_intervals:
|
||||
minutes["meal_break"] += _covered_meal_overlap_minutes(
|
||||
start_at,
|
||||
end_at,
|
||||
meal_start,
|
||||
meal_end,
|
||||
)
|
||||
|
||||
for bucket, intervals in shift_intervals.items():
|
||||
for interval_start, interval_end in intervals:
|
||||
raw_minutes = _overlap_minutes(start_at, end_at, interval_start, interval_end)
|
||||
shift_start = max(start_at, interval_start)
|
||||
shift_end = min(end_at, interval_end)
|
||||
meal_minutes = sum(
|
||||
_overlap_minutes(shift_start, shift_end, meal_start, meal_end)
|
||||
for meal_start, meal_end in meal_intervals
|
||||
if (
|
||||
start_at <= meal_start
|
||||
and end_at >= meal_end
|
||||
and end_at > meal_end + timedelta(minutes=MEAL_END_GRACE_MINUTES)
|
||||
)
|
||||
)
|
||||
minutes[bucket] += max(0.0, raw_minutes - meal_minutes)
|
||||
return minutes
|
||||
|
||||
|
||||
def shift_minutes_between(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
break_minutes: float = 0,
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> dict[str, float]:
|
||||
return _raw_shift_minutes(start_at, end_at, schedule)
|
||||
|
||||
|
||||
def effective_work_minutes_between(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
break_minutes: float = 0,
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> float:
|
||||
shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule)
|
||||
return sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
|
||||
|
||||
|
||||
def format_hours(minutes: float) -> str:
|
||||
text = f"{round(as_float(minutes) / 60, 2):.2f}".rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
|
||||
|
||||
def shift_distribution_text(shift_minutes: dict[str, float]) -> str:
|
||||
effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
|
||||
parts = [
|
||||
f"{SHIFT_LABELS[key]}{format_hours(shift_minutes.get(key, 0.0))}小时"
|
||||
for key in SHIFT_BUCKETS
|
||||
if round(as_float(shift_minutes.get(key, 0.0)), 2) > 0
|
||||
]
|
||||
detail = f"({'、'.join(parts)})" if parts else ""
|
||||
return f"工时 {format_hours(effective_minutes)}小时{detail}"
|
||||
|
||||
|
||||
def _set_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None:
|
||||
for key in SHIFT_BUCKETS:
|
||||
setattr(item, f"_shift_{key}_minutes", as_float(shift_minutes.get(key, 0.0)))
|
||||
|
||||
|
||||
def _add_item_shift_minutes(item: ReportItemLike, shift_minutes: dict[str, float]) -> None:
|
||||
for key in SHIFT_BUCKETS:
|
||||
setattr(
|
||||
item,
|
||||
f"_shift_{key}_minutes",
|
||||
_get_item_shift_minutes(item, key) + as_float(shift_minutes.get(key, 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def _get_item_shift_minutes(item: ReportItemLike, key: str) -> float:
|
||||
return as_float(getattr(item, f"_shift_{key}_minutes", 0.0))
|
||||
|
||||
|
||||
def _scale_item_shift_minutes(item: ReportItemLike, scale: float) -> None:
|
||||
for key in SHIFT_BUCKETS:
|
||||
setattr(item, f"_shift_{key}_minutes", _get_item_shift_minutes(item, key) * scale)
|
||||
|
||||
|
||||
def _mold_key(product_name: str | None, process_name: str | None = "") -> str:
|
||||
return f"{normalize_device_no(product_name)}\0{str(process_name or '').strip()}"
|
||||
|
||||
|
||||
WORK_TIME_IGNORED_RELEASE_REASONS = {
|
||||
"换模具释放占用",
|
||||
"报工提交释放占用",
|
||||
"超时-系统自动提交",
|
||||
}
|
||||
|
||||
|
||||
def release_at_caps_work_time(device: ReportDeviceLike) -> bool:
|
||||
if getattr(device, "released_at", None) is None:
|
||||
return False
|
||||
reason = str(getattr(device, "release_reason", "") or "").strip()
|
||||
return reason not in WORK_TIME_IGNORED_RELEASE_REASONS
|
||||
|
||||
|
||||
def _device_segment_ranges(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
devices: list[ReportDeviceLike] | None,
|
||||
) -> dict[str, list[tuple[datetime, datetime]]]:
|
||||
ordered_devices = sorted(
|
||||
devices or [],
|
||||
key=lambda device: (int(getattr(device, "sort_order", 0) or 0), device.scanned_at),
|
||||
)
|
||||
if not ordered_devices:
|
||||
return {}
|
||||
|
||||
segment_ranges: dict[str, list[tuple[datetime, datetime]]] = defaultdict(list)
|
||||
for index, device in enumerate(ordered_devices):
|
||||
segment_start = start_at if index == 0 else max(start_at, device.scanned_at)
|
||||
segment_end = end_at
|
||||
if index + 1 < len(ordered_devices):
|
||||
segment_end = min(end_at, ordered_devices[index + 1].scanned_at)
|
||||
released_at = getattr(device, "released_at", None)
|
||||
if release_at_caps_work_time(device) and released_at is not None:
|
||||
segment_end = min(segment_end, released_at)
|
||||
if segment_end > segment_start:
|
||||
segment_ranges[_mold_key(device.device_no, getattr(device, "process_name", ""))].append(
|
||||
(segment_start, segment_end)
|
||||
)
|
||||
return segment_ranges
|
||||
|
||||
|
||||
def _segment_ranges_shift_minutes(
|
||||
segment_ranges: dict[str, list[tuple[datetime, datetime]]],
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> dict[str, float]:
|
||||
minutes = {key: 0.0 for key in SHIFT_BUCKETS}
|
||||
for ranges in segment_ranges.values():
|
||||
for segment_start, segment_end in ranges:
|
||||
shift_minutes = shift_minutes_between(segment_start, segment_end, schedule=schedule)
|
||||
for key in SHIFT_BUCKETS:
|
||||
minutes[key] += shift_minutes.get(key, 0.0)
|
||||
return minutes
|
||||
|
||||
|
||||
def _clamp_datetime(value: datetime, start_at: datetime, end_at: datetime) -> datetime:
|
||||
if value < start_at:
|
||||
return start_at
|
||||
if value > end_at:
|
||||
return end_at
|
||||
return value
|
||||
|
||||
|
||||
def _allocate_device_item_minutes(
|
||||
device_items: list[ReportItemLike],
|
||||
segment_start: datetime,
|
||||
segment_end: datetime,
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> None:
|
||||
if not device_items:
|
||||
return
|
||||
|
||||
grouped_items: dict[datetime, list[ReportItemLike]] = defaultdict(list)
|
||||
for item in device_items:
|
||||
item_start = getattr(item, "started_at", None) or segment_start
|
||||
grouped_items[_clamp_datetime(item_start, segment_start, segment_end)].append(item)
|
||||
first_item_start = min(grouped_items)
|
||||
if first_item_start > segment_start:
|
||||
grouped_items[segment_start].extend(grouped_items.pop(first_item_start))
|
||||
|
||||
ordered_starts = sorted(grouped_items)
|
||||
for index, item_start in enumerate(ordered_starts):
|
||||
item_end = segment_end
|
||||
if index + 1 < len(ordered_starts):
|
||||
item_end = ordered_starts[index + 1]
|
||||
shift_minutes = shift_minutes_between(item_start, item_end, schedule=schedule)
|
||||
minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
|
||||
items_at_start = grouped_items[item_start]
|
||||
minutes_per_item = minutes / len(items_at_start)
|
||||
for item in items_at_start:
|
||||
item.allocated_minutes = as_float(item.allocated_minutes) + minutes_per_item
|
||||
_add_item_shift_minutes(
|
||||
item,
|
||||
{key: shift_minutes.get(key, 0.0) / len(items_at_start) for key in SHIFT_BUCKETS},
|
||||
)
|
||||
|
||||
|
||||
def _allocate_item_minutes(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
items: list[ReportItemLike],
|
||||
effective_minutes: float,
|
||||
devices: list[ReportDeviceLike] | None,
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> None:
|
||||
if not items:
|
||||
return
|
||||
|
||||
for item in items:
|
||||
item.allocated_minutes = 0.0
|
||||
_set_item_shift_minutes(item, {})
|
||||
|
||||
segment_ranges = _device_segment_ranges(start_at, end_at, devices)
|
||||
if not segment_ranges:
|
||||
shift_minutes = shift_minutes_between(start_at, end_at, schedule=schedule)
|
||||
minutes_per_item = effective_minutes / len(items)
|
||||
for item in items:
|
||||
item.allocated_minutes = minutes_per_item
|
||||
_set_item_shift_minutes(
|
||||
item,
|
||||
{key: shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS},
|
||||
)
|
||||
return
|
||||
|
||||
covered_shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule)
|
||||
target_effective_minutes = min(effective_minutes, sum(covered_shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS))
|
||||
target_shift_minutes = covered_shift_minutes
|
||||
|
||||
items_by_device: dict[str, list[ReportItemLike]] = defaultdict(list)
|
||||
for item in items:
|
||||
mold_key = _mold_key(
|
||||
getattr(item, "product_name", "") or item.device_no,
|
||||
getattr(item, "process_name", ""),
|
||||
)
|
||||
items_by_device[mold_key].append(item)
|
||||
|
||||
allocated_item_ids: set[int] = set()
|
||||
for device_no, ranges in segment_ranges.items():
|
||||
device_items = items_by_device.get(device_no, [])
|
||||
if not device_items:
|
||||
continue
|
||||
for segment_range in ranges:
|
||||
_allocate_device_item_minutes(device_items, segment_range[0], segment_range[1], schedule)
|
||||
for item in device_items:
|
||||
allocated_item_ids.add(id(item))
|
||||
|
||||
unmatched_items = [item for item in items if id(item) not in allocated_item_ids]
|
||||
if unmatched_items:
|
||||
allocated_minutes = sum(as_float(item.allocated_minutes) for item in items)
|
||||
remaining_minutes = max(0.0, target_effective_minutes - allocated_minutes)
|
||||
minutes_per_item = remaining_minutes / len(unmatched_items)
|
||||
allocated_shift_minutes = {
|
||||
key: sum(_get_item_shift_minutes(item, key) for item in items)
|
||||
for key in SHIFT_BUCKETS
|
||||
}
|
||||
remaining_shift_minutes = {
|
||||
key: max(0.0, target_shift_minutes.get(key, 0.0) - allocated_shift_minutes[key])
|
||||
for key in SHIFT_BUCKETS
|
||||
}
|
||||
for item in unmatched_items:
|
||||
item.allocated_minutes = minutes_per_item
|
||||
_set_item_shift_minutes(
|
||||
item,
|
||||
{key: remaining_shift_minutes[key] / len(unmatched_items) for key in SHIFT_BUCKETS},
|
||||
)
|
||||
|
||||
allocated_minutes = sum(as_float(item.allocated_minutes) for item in items)
|
||||
if target_effective_minutes <= 0:
|
||||
for item in items:
|
||||
item.allocated_minutes = 0.0
|
||||
_set_item_shift_minutes(item, {})
|
||||
elif allocated_minutes <= 0:
|
||||
minutes_per_item = target_effective_minutes / len(items)
|
||||
for item in items:
|
||||
item.allocated_minutes = minutes_per_item
|
||||
_set_item_shift_minutes(
|
||||
item,
|
||||
{key: target_shift_minutes.get(key, 0.0) / len(items) for key in SHIFT_BUCKETS},
|
||||
)
|
||||
elif abs(allocated_minutes - target_effective_minutes) > 0.000001:
|
||||
scale = target_effective_minutes / allocated_minutes
|
||||
for item in items:
|
||||
item.allocated_minutes = as_float(item.allocated_minutes) * scale
|
||||
_scale_item_shift_minutes(item, scale)
|
||||
|
||||
|
||||
def calculate_report_metrics(
|
||||
start_at: datetime,
|
||||
end_at: datetime,
|
||||
items: list[ReportItemLike],
|
||||
break_minutes: float = 0,
|
||||
devices: list[ReportDeviceLike] | None = None,
|
||||
schedule: WorkScheduleConfig | None = None,
|
||||
) -> dict[str, float]:
|
||||
segment_ranges = _device_segment_ranges(start_at, end_at, devices)
|
||||
if segment_ranges:
|
||||
duration_minutes = sum(
|
||||
minutes_between(segment_start, segment_end)
|
||||
for ranges in segment_ranges.values()
|
||||
for segment_start, segment_end in ranges
|
||||
)
|
||||
shift_minutes = _segment_ranges_shift_minutes(segment_ranges, schedule)
|
||||
shift_minutes["meal_break"] = sum(
|
||||
shift_minutes_between(segment_start, segment_end, break_minutes, schedule).get("meal_break", 0.0)
|
||||
for ranges in segment_ranges.values()
|
||||
for segment_start, segment_end in ranges
|
||||
)
|
||||
else:
|
||||
duration_minutes = minutes_between(start_at, end_at)
|
||||
shift_minutes = shift_minutes_between(start_at, end_at, break_minutes, schedule)
|
||||
effective_minutes = sum(shift_minutes.get(key, 0.0) for key in SHIFT_BUCKETS)
|
||||
_allocate_item_minutes(start_at, end_at, items, effective_minutes, devices, schedule)
|
||||
|
||||
total_good_qty = 0.0
|
||||
total_output_qty = 0.0
|
||||
weighted_standard_beat = 0.0
|
||||
standard_weight = 0.0
|
||||
expected_workload = 0.0
|
||||
productive_minutes = 0.0
|
||||
|
||||
for item in items:
|
||||
if is_misc_item(item):
|
||||
try:
|
||||
item.standard_workload = 0
|
||||
except AttributeError:
|
||||
pass
|
||||
continue
|
||||
good_qty = as_float(item.good_qty)
|
||||
defect_qty = as_float(item.defect_qty)
|
||||
scrap_qty = as_float(item.scrap_qty)
|
||||
output_qty = good_qty + defect_qty + scrap_qty
|
||||
beat = as_float(item.standard_beat)
|
||||
workload = as_float(item.allocated_minutes) * 60 / beat if beat > 0 else 0
|
||||
weight = max(1.0, output_qty)
|
||||
try:
|
||||
item.standard_workload = round1(workload)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
total_good_qty += good_qty
|
||||
total_output_qty += output_qty
|
||||
weighted_standard_beat += beat * weight
|
||||
standard_weight += weight
|
||||
expected_workload += workload
|
||||
productive_minutes += as_float(item.allocated_minutes)
|
||||
|
||||
actual_beat = productive_minutes * 60 / total_output_qty if total_output_qty > 0 else 0
|
||||
standard_beat = weighted_standard_beat / standard_weight if standard_weight > 0 else 0
|
||||
pace_rate = (actual_beat - standard_beat) / standard_beat * 100 if standard_beat > 0 else 0
|
||||
workload_rate = (
|
||||
(total_good_qty - expected_workload) / expected_workload * 100
|
||||
if expected_workload > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
return {
|
||||
"duration_minutes": round2(duration_minutes),
|
||||
"effective_minutes": round2(effective_minutes),
|
||||
"shift_day_minutes": round2(shift_minutes["day"]),
|
||||
"shift_overtime_minutes": round2(shift_minutes["overtime"]),
|
||||
"shift_night_minutes": round2(shift_minutes["night"]),
|
||||
"shift_other_minutes": 0,
|
||||
"shift_meal_break_minutes": round2(shift_minutes["meal_break"]),
|
||||
"shift_distribution_text": shift_distribution_text(shift_minutes),
|
||||
"total_good_qty": round2(total_good_qty),
|
||||
"total_output_qty": round2(total_output_qty),
|
||||
"actual_beat": round2(actual_beat),
|
||||
"standard_beat": round2(standard_beat),
|
||||
"expected_workload": round1(expected_workload),
|
||||
"pace_rate": round1(pace_rate),
|
||||
"workload_rate": round1(workload_rate),
|
||||
}
|
||||
|
||||
|
||||
def build_result_text(metrics: dict[str, float]) -> str:
|
||||
pace_rate = as_float(metrics.get("pace_rate"))
|
||||
workload_rate = as_float(metrics.get("workload_rate"))
|
||||
fast_abnormal = pace_rate < -30
|
||||
large_abnormal = workload_rate > 30
|
||||
pace_text = (
|
||||
f"今日节拍慢于标准节拍{abs(pace_rate)}%"
|
||||
if pace_rate > 0
|
||||
else f"今日节拍快于标准节拍{abs(pace_rate)}%"
|
||||
)
|
||||
workload_text = (
|
||||
f"今日工作量大于标准工作量{abs(workload_rate)}%"
|
||||
if workload_rate >= 0
|
||||
else f"今日工作量小于标准工作量{abs(workload_rate)}%"
|
||||
)
|
||||
if fast_abnormal or large_abnormal:
|
||||
suffix = "数据明显超出标准,请自我核对一下是否报工填错。"
|
||||
elif pace_rate <= 0 and workload_rate >= 0:
|
||||
suffix = "今天真的很棒呢!"
|
||||
else:
|
||||
suffix = "后续需要加油喽!"
|
||||
return f"{pace_text},{workload_text},{suffix}"
|
||||
@@ -0,0 +1,72 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Product
|
||||
|
||||
|
||||
MISC_WORK_PRODUCT_NAME = "处理杂活"
|
||||
MISC_WORK_PROCESS_NAME = "杂活"
|
||||
MISC_WORK_STAMPING_METHOD = "处理杂活"
|
||||
MISC_WORK_PROJECT_NO = "MISC"
|
||||
MISC_WORK_MATERIAL_CODE = "MISC-WORK"
|
||||
MISC_WORK_MATERIAL_NAME = "处理杂活"
|
||||
|
||||
|
||||
def is_misc_stamping_method(value: str | None) -> bool:
|
||||
return str(value or "").strip() == MISC_WORK_STAMPING_METHOD
|
||||
|
||||
|
||||
def is_misc_product(product) -> bool:
|
||||
return bool(product) and (
|
||||
is_misc_stamping_method(getattr(product, "stamping_method", None))
|
||||
or (
|
||||
getattr(product, "product_name", None) == MISC_WORK_PRODUCT_NAME
|
||||
and getattr(product, "process_name", None) == MISC_WORK_PROCESS_NAME
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_misc_item(item) -> bool:
|
||||
return bool(item) and (
|
||||
is_misc_stamping_method(getattr(item, "stamping_method", None))
|
||||
or (
|
||||
getattr(item, "product_name", None) == MISC_WORK_PRODUCT_NAME
|
||||
and getattr(item, "process_name", None) == MISC_WORK_PROCESS_NAME
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ensure_misc_work_product(db: Session, attendance_point_name: str) -> Product:
|
||||
product = db.get(
|
||||
Product,
|
||||
{
|
||||
"attendance_point_name": attendance_point_name,
|
||||
"project_no": MISC_WORK_PROJECT_NO,
|
||||
"product_name": MISC_WORK_PRODUCT_NAME,
|
||||
"device_no": "",
|
||||
"process_name": MISC_WORK_PROCESS_NAME,
|
||||
},
|
||||
)
|
||||
if product is None:
|
||||
product = Product(
|
||||
attendance_point_name=attendance_point_name,
|
||||
project_no=MISC_WORK_PROJECT_NO,
|
||||
product_name=MISC_WORK_PRODUCT_NAME,
|
||||
device_no="",
|
||||
process_name=MISC_WORK_PROCESS_NAME,
|
||||
)
|
||||
db.add(product)
|
||||
product.profile_no = product.profile_no or "MISC"
|
||||
product.material_code = product.material_code or MISC_WORK_MATERIAL_CODE
|
||||
product.material_name = product.material_name or MISC_WORK_MATERIAL_NAME
|
||||
product.supplier = product.supplier or "系统内置"
|
||||
product.stamping_method = MISC_WORK_STAMPING_METHOD
|
||||
product.operator_count = 1
|
||||
product.process_unit_price_yuan = 0
|
||||
product.standard_beat = 0
|
||||
product.standard_workload = 0
|
||||
return product
|
||||
|
||||
|
||||
def ensure_misc_work_products(db: Session, attendance_point_names: list[str]) -> None:
|
||||
for name in attendance_point_names:
|
||||
ensure_misc_work_product(db, name)
|
||||
@@ -0,0 +1,287 @@
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import (
|
||||
MoldLockFeedback,
|
||||
Personnel,
|
||||
Product,
|
||||
SessionStatus,
|
||||
WorkSession,
|
||||
WorkSessionDevice,
|
||||
)
|
||||
from app.schemas import MoldLockFeedbackOut, MonitorLockOut
|
||||
from app.services.cleaning import is_cleaning_product
|
||||
from app.services.common import round2
|
||||
from app.services.display_names import point_mold_display_name
|
||||
from app.services.metrics import minutes_between
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.multi_person import is_multi_person_product
|
||||
from app.timezone import now
|
||||
|
||||
|
||||
FEEDBACK_STATUS_PENDING = "pending"
|
||||
FEEDBACK_STATUS_HANDLED = "handled"
|
||||
MOLD_LOCK_PREFIX = "jh_mold_lock"
|
||||
|
||||
|
||||
def mold_display_name(
|
||||
point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str | None,
|
||||
stamping_method: str | None = None,
|
||||
) -> str:
|
||||
return point_mold_display_name(point_name, mold_name, process_name, stamping_method)
|
||||
|
||||
|
||||
def is_lockable_product(product: Product | None) -> bool:
|
||||
return (
|
||||
bool(product)
|
||||
and not is_cleaning_product(product)
|
||||
and not is_misc_product(product)
|
||||
and not is_multi_person_product(product)
|
||||
)
|
||||
|
||||
|
||||
def _mutex_key(point_name: str, mold_name: str, process_name: str) -> str:
|
||||
raw = f"{point_name}\0{mold_name}\0{process_name}"
|
||||
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||||
return f"{MOLD_LOCK_PREFIX}:{digest}"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def mold_lock_mutex(
|
||||
db: Session,
|
||||
point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str,
|
||||
*,
|
||||
timeout_seconds: int = 5,
|
||||
):
|
||||
bind = db.get_bind()
|
||||
if bind.dialect.name != "mysql":
|
||||
yield
|
||||
return
|
||||
|
||||
key = _mutex_key(point_name, mold_name, process_name)
|
||||
acquired = db.scalar(
|
||||
text("select get_lock(:lock_key, :timeout_seconds)"),
|
||||
{"lock_key": key, "timeout_seconds": timeout_seconds},
|
||||
)
|
||||
if acquired != 1:
|
||||
raise HTTPException(status_code=409, detail="模具占用检查繁忙,请稍后重试")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
db.execute(text("select release_lock(:lock_key)"), {"lock_key": key})
|
||||
|
||||
|
||||
def _product_for_mold(db: Session, point_name: str, mold_name: str, process_name: str) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == mold_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
|
||||
|
||||
def _ordered_devices(session: WorkSession) -> list[WorkSessionDevice]:
|
||||
return sorted(
|
||||
session.devices,
|
||||
key=lambda device: (int(device.sort_order or 0), device.scanned_at, int(device.id or 0)),
|
||||
)
|
||||
|
||||
|
||||
def current_unreleased_device(session: WorkSession) -> WorkSessionDevice | None:
|
||||
devices = [device for device in _ordered_devices(session) if device.released_at is None]
|
||||
return devices[-1] if devices else None
|
||||
|
||||
|
||||
def _is_session_current_device(session: WorkSession, device: WorkSessionDevice) -> bool:
|
||||
current = current_unreleased_device(session)
|
||||
return bool(current and current.id == device.id)
|
||||
|
||||
|
||||
def active_lock_for_mold(
|
||||
db: Session,
|
||||
point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str,
|
||||
) -> WorkSessionDevice | None:
|
||||
rows = db.scalars(
|
||||
select(WorkSessionDevice)
|
||||
.options(
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
|
||||
)
|
||||
.join(WorkSession, WorkSession.id == WorkSessionDevice.session_id)
|
||||
.where(
|
||||
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
|
||||
WorkSessionDevice.released_at.is_(None),
|
||||
WorkSessionDevice.attendance_point_name == point_name,
|
||||
WorkSessionDevice.device_no == mold_name,
|
||||
WorkSessionDevice.process_name == process_name,
|
||||
)
|
||||
.order_by(WorkSessionDevice.scanned_at.desc(), WorkSessionDevice.id.desc())
|
||||
).all()
|
||||
for device in rows:
|
||||
if _is_session_current_device(device.session, device):
|
||||
return device
|
||||
return None
|
||||
|
||||
|
||||
def active_locks_for_points(db: Session, point_names: list[str]) -> list[WorkSessionDevice]:
|
||||
rows = db.scalars(
|
||||
select(WorkSessionDevice)
|
||||
.options(
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices),
|
||||
selectinload(WorkSessionDevice.session).selectinload(WorkSession.employee),
|
||||
)
|
||||
.join(WorkSession, WorkSession.id == WorkSessionDevice.session_id)
|
||||
.where(
|
||||
WorkSession.status.in_([SessionStatus.active, SessionStatus.reporting]),
|
||||
WorkSessionDevice.released_at.is_(None),
|
||||
WorkSessionDevice.attendance_point_name.in_(point_names),
|
||||
)
|
||||
.order_by(WorkSessionDevice.scanned_at.desc(), WorkSessionDevice.id.desc())
|
||||
).all()
|
||||
locks: list[WorkSessionDevice] = []
|
||||
seen_sessions: set[int] = set()
|
||||
for device in rows:
|
||||
session_id = int(device.session_id)
|
||||
if session_id in seen_sessions or not _is_session_current_device(device.session, device):
|
||||
continue
|
||||
product = _product_for_mold(db, device.attendance_point_name, device.device_no, device.process_name)
|
||||
if not is_lockable_product(product):
|
||||
continue
|
||||
locks.append(device)
|
||||
seen_sessions.add(session_id)
|
||||
return locks
|
||||
|
||||
|
||||
def release_session_locks(
|
||||
session: WorkSession,
|
||||
released_by: str | None,
|
||||
reason: str,
|
||||
released_at=None,
|
||||
) -> None:
|
||||
timestamp = released_at or now()
|
||||
for device in session.devices:
|
||||
if device.released_at is None:
|
||||
device.released_at = timestamp
|
||||
device.released_by = released_by
|
||||
device.release_reason = reason
|
||||
|
||||
|
||||
def ensure_mold_available(
|
||||
db: Session,
|
||||
user: Personnel,
|
||||
point_name: str,
|
||||
mold_name: str,
|
||||
process_name: str,
|
||||
product: Product | None,
|
||||
) -> None:
|
||||
if not is_lockable_product(product):
|
||||
return
|
||||
lock_process_name = str(process_name or (product.process_name if product else "") or "").strip()
|
||||
lock = active_lock_for_mold(db, point_name, mold_name, lock_process_name)
|
||||
if lock is None or lock.session.employee_phone == user.phone:
|
||||
return
|
||||
occupied_name = lock.session.employee.name if lock.session and lock.session.employee else ""
|
||||
message = f"已经被{occupied_name or lock.session.employee_phone}占用,请联系管理员解除占用"
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail={
|
||||
"code": "mold_occupied",
|
||||
"message": message,
|
||||
"lock_id": lock.id,
|
||||
"attendance_point_name": point_name,
|
||||
"mold_name": mold_name,
|
||||
"process_name": process_name,
|
||||
"mold_display_name": mold_display_name(point_name, mold_name, process_name, product.stamping_method if product else None),
|
||||
"occupied_phone": lock.session.employee_phone,
|
||||
"occupied_name": occupied_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def release_lock(
|
||||
db: Session,
|
||||
lock_id: int,
|
||||
released_by: str,
|
||||
reason: str = "管理员强制解除占用",
|
||||
) -> WorkSessionDevice:
|
||||
lock = db.scalar(
|
||||
select(WorkSessionDevice)
|
||||
.options(selectinload(WorkSessionDevice.session).selectinload(WorkSession.devices))
|
||||
.where(WorkSessionDevice.id == lock_id)
|
||||
)
|
||||
if lock is None:
|
||||
raise ValueError("占用记录不存在")
|
||||
if lock.released_at is None:
|
||||
lock.released_at = now()
|
||||
lock.released_by = released_by
|
||||
lock.release_reason = reason
|
||||
mark_feedbacks_handled(db, lock.id, released_by)
|
||||
return lock
|
||||
|
||||
|
||||
def mark_feedbacks_handled(db: Session, lock_id: int, handled_by: str) -> None:
|
||||
timestamp = now()
|
||||
feedbacks = db.scalars(
|
||||
select(MoldLockFeedback).where(
|
||||
MoldLockFeedback.session_device_id == lock_id,
|
||||
MoldLockFeedback.status == FEEDBACK_STATUS_PENDING,
|
||||
)
|
||||
).all()
|
||||
for feedback in feedbacks:
|
||||
feedback.status = FEEDBACK_STATUS_HANDLED
|
||||
feedback.handled_at = timestamp
|
||||
feedback.handled_by = handled_by
|
||||
|
||||
|
||||
def lock_out(db: Session, lock: WorkSessionDevice) -> MonitorLockOut:
|
||||
occupied_name = lock.session.employee.name if lock.session and lock.session.employee else ""
|
||||
product = _product_for_mold(db, lock.attendance_point_name, lock.device_no, lock.process_name)
|
||||
return MonitorLockOut(
|
||||
id=lock.id,
|
||||
attendance_point_name=lock.attendance_point_name,
|
||||
mold_name=lock.device_no,
|
||||
process_name=lock.process_name,
|
||||
stamping_method=product.stamping_method if product else None,
|
||||
mold_display_name=mold_display_name(lock.attendance_point_name, lock.device_no, lock.process_name, product.stamping_method if product else None),
|
||||
occupied_phone=lock.session.employee_phone,
|
||||
occupied_name=occupied_name,
|
||||
occupied_start_at=lock.scanned_at,
|
||||
occupied_minutes=round2(minutes_between(lock.scanned_at, now())),
|
||||
)
|
||||
|
||||
|
||||
def feedback_out(db: Session, feedback: MoldLockFeedback) -> MoldLockFeedbackOut:
|
||||
product = _product_for_mold(db, feedback.attendance_point_name, feedback.mold_name, feedback.process_name)
|
||||
return MoldLockFeedbackOut(
|
||||
id=feedback.id,
|
||||
attendance_point_name=feedback.attendance_point_name,
|
||||
mold_name=feedback.mold_name,
|
||||
process_name=feedback.process_name,
|
||||
stamping_method=product.stamping_method if product else None,
|
||||
mold_display_name=mold_display_name(feedback.attendance_point_name, feedback.mold_name, feedback.process_name, product.stamping_method if product else None),
|
||||
lock_id=feedback.session_device_id,
|
||||
reporter_phone=feedback.reporter_phone,
|
||||
reporter_name=feedback.reporter.name if feedback.reporter else "",
|
||||
occupied_phone=feedback.occupied_phone,
|
||||
occupied_name=feedback.occupied_person.name if feedback.occupied_person else "",
|
||||
feedback_at=feedback.created_at,
|
||||
read_at=feedback.read_at,
|
||||
handled_at=feedback.handled_at,
|
||||
status=feedback.status,
|
||||
status_name="已处理" if feedback.status == FEEDBACK_STATUS_HANDLED else "未处理",
|
||||
can_release=feedback.status != FEEDBACK_STATUS_HANDLED,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
from app.services.common import as_float
|
||||
|
||||
|
||||
def is_multi_person_product(product) -> bool:
|
||||
return bool(product) and as_float(getattr(product, "operator_count", 1)) > 1
|
||||
|
||||
|
||||
def is_multi_person_item(item) -> bool:
|
||||
return as_float(getattr(item, "operator_count", 1)) > 1
|
||||
@@ -0,0 +1,43 @@
|
||||
import re
|
||||
|
||||
|
||||
MISC_PROCESS_NAME = "杂活"
|
||||
|
||||
|
||||
def normalize_process_name(value: str | None) -> str:
|
||||
text = str(value or "").strip()
|
||||
if text == MISC_PROCESS_NAME:
|
||||
return MISC_PROCESS_NAME
|
||||
if not re.fullmatch(r"\d+", text):
|
||||
raise ValueError("工序只能填写正整数,或填写杂活")
|
||||
number = int(text)
|
||||
if number <= 0:
|
||||
raise ValueError("工序只能填写正整数,或填写杂活")
|
||||
return str(number)
|
||||
|
||||
|
||||
def normalize_numeric_process_name(value: str | None) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not re.fullmatch(r"\d+", text):
|
||||
raise ValueError("工序必须填写正整数")
|
||||
number = int(text)
|
||||
if number <= 0:
|
||||
raise ValueError("工序必须填写正整数")
|
||||
return str(number)
|
||||
|
||||
|
||||
def export_process_name(value: str | None) -> str:
|
||||
text = str(value or "").strip()
|
||||
if text == MISC_PROCESS_NAME:
|
||||
return MISC_PROCESS_NAME
|
||||
legacy_match = re.fullmatch(r"0*(\d+)序", text)
|
||||
if legacy_match:
|
||||
return str(int(legacy_match.group(1)))
|
||||
if re.fullmatch(r"\d+", text):
|
||||
return str(int(text))
|
||||
return text
|
||||
|
||||
|
||||
def process_order(process_name: str | None) -> tuple[int, str]:
|
||||
text = export_process_name(process_name)
|
||||
return (int(text), text) if re.fullmatch(r"\d+", text) else (0, text)
|
||||
@@ -0,0 +1,327 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models import DeviceQrBatchTask, DeviceQRCode, Product
|
||||
from app.schemas import DeviceQrBatchTaskOut
|
||||
from app.services.cleaning import is_cleaning_product
|
||||
from app.services.display_names import point_mold_display_name
|
||||
from app.services.misc_work import is_misc_product
|
||||
from app.services.wechat import create_device_qrcode
|
||||
from app.timezone import now
|
||||
|
||||
|
||||
QRCODE_TASK_STATUS_PENDING = "pending"
|
||||
QRCODE_TASK_STATUS_RUNNING = "running"
|
||||
QRCODE_TASK_STATUS_PAUSED = "paused"
|
||||
QRCODE_TASK_STATUS_COMPLETED = "completed"
|
||||
QRCODE_TASK_STATUS_FAILED = "failed"
|
||||
QRCODE_BATCH_TASK_INTERVAL_SECONDS = 3
|
||||
|
||||
QRCODE_TASK_STATUS_NAMES = {
|
||||
QRCODE_TASK_STATUS_PENDING: "等待生成",
|
||||
QRCODE_TASK_STATUS_RUNNING: "生成中",
|
||||
QRCODE_TASK_STATUS_PAUSED: "已停止",
|
||||
QRCODE_TASK_STATUS_COMPLETED: "已完成",
|
||||
QRCODE_TASK_STATUS_FAILED: "生成失败",
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _safe_file_stem(value: str) -> str:
|
||||
normalized = re.sub(r'[\\/:*?"<>|\s]+', "_", value).strip("._")
|
||||
return normalized[:120] or "模具二维码"
|
||||
|
||||
|
||||
def _unique_zip_name(used_names: set[str], stem: str, suffix: str) -> str:
|
||||
suffix = suffix if suffix else ".jpg"
|
||||
name = f"{stem}{suffix}"
|
||||
index = 2
|
||||
while name in used_names:
|
||||
name = f"{stem}_{index}{suffix}"
|
||||
index += 1
|
||||
used_names.add(name)
|
||||
return name
|
||||
|
||||
|
||||
def _qrcode_path_from_url(qr_url: str | None) -> Path | None:
|
||||
if not qr_url:
|
||||
return None
|
||||
filename = unquote(Path(urlparse(qr_url).path).name)
|
||||
if not filename:
|
||||
return None
|
||||
path = settings.upload_path / "qrcodes" / filename
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _scene_token(point_name: str, mold_name: str, process_name: str) -> str:
|
||||
mold_key = f"{point_name}\0{mold_name}\0{process_name}"
|
||||
return hashlib.sha1(mold_key.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _product_for_mold(db, point_name: str, mold_name: str, process_name: str) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(
|
||||
Product.attendance_point_name == point_name,
|
||||
Product.product_name == mold_name,
|
||||
Product.process_name == process_name,
|
||||
Product.device_no == "",
|
||||
)
|
||||
.order_by(Product.project_no.asc())
|
||||
)
|
||||
|
||||
|
||||
def _public_base_url() -> str:
|
||||
return settings.public_base_url.rstrip("/") or "http://127.0.0.1:8000"
|
||||
|
||||
|
||||
def _task_file_name(items: list[dict]) -> str:
|
||||
fingerprint = hashlib.sha1(
|
||||
"|".join(
|
||||
f"{item.get('attendance_point_name', '')}\0{item.get('product_name', '')}\0{item.get('process_name', '')}"
|
||||
for item in items
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:10]
|
||||
unique = uuid.uuid4().hex[:8]
|
||||
return f"mold-qrcodes-{datetime.now().strftime('%Y%m%d%H%M%S')}-{fingerprint}-{unique}.zip"
|
||||
|
||||
|
||||
def _items_signature(items: list[dict]) -> tuple[tuple[str, str, str], ...]:
|
||||
return tuple(
|
||||
sorted(
|
||||
(
|
||||
str(item.get("attendance_point_name") or "").strip(),
|
||||
str(item.get("product_name") or "").strip(),
|
||||
str(item.get("process_name") or "").strip(),
|
||||
)
|
||||
for item in items
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def create_qrcode_batch_task(db, *, created_by: str, items: list[dict]) -> DeviceQrBatchTask:
|
||||
signature = _items_signature(items)
|
||||
active_tasks = db.scalars(
|
||||
select(DeviceQrBatchTask)
|
||||
.where(
|
||||
DeviceQrBatchTask.created_by == created_by,
|
||||
DeviceQrBatchTask.status.in_([
|
||||
QRCODE_TASK_STATUS_PENDING,
|
||||
QRCODE_TASK_STATUS_RUNNING,
|
||||
QRCODE_TASK_STATUS_PAUSED,
|
||||
]),
|
||||
)
|
||||
.order_by(DeviceQrBatchTask.created_at.desc(), DeviceQrBatchTask.id.desc())
|
||||
).all()
|
||||
for active_task in active_tasks:
|
||||
if _items_signature(list(active_task.items_json or [])) == signature:
|
||||
return active_task
|
||||
|
||||
task = DeviceQrBatchTask(
|
||||
file_name=_task_file_name(items),
|
||||
status=QRCODE_TASK_STATUS_PENDING,
|
||||
item_count=len(items),
|
||||
completed_count=0,
|
||||
failed_count=0,
|
||||
items_json=items,
|
||||
created_by=created_by,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
def qrcode_batch_task_out(task: DeviceQrBatchTask, public_base_url: str | None = None) -> DeviceQrBatchTaskOut:
|
||||
item_count = max(0, int(task.item_count or 0))
|
||||
completed_count = max(0, int(task.completed_count or 0))
|
||||
if task.status == QRCODE_TASK_STATUS_COMPLETED:
|
||||
progress_percent = 100.0
|
||||
elif item_count <= 0:
|
||||
progress_percent = 0.0
|
||||
else:
|
||||
progress_percent = round(min(100, completed_count / item_count * 100), 1)
|
||||
zip_url = task.zip_url
|
||||
if public_base_url and task.file_name:
|
||||
zip_url = f"{public_base_url.rstrip('/')}/uploads/qrcode_zips/{quote(task.file_name, safe='')}"
|
||||
return DeviceQrBatchTaskOut(
|
||||
id=task.id,
|
||||
file_name=task.file_name,
|
||||
status=task.status,
|
||||
status_name=QRCODE_TASK_STATUS_NAMES.get(task.status, task.status),
|
||||
item_count=item_count,
|
||||
completed_count=completed_count,
|
||||
failed_count=max(0, int(task.failed_count or 0)),
|
||||
progress_percent=progress_percent,
|
||||
zip_url=zip_url,
|
||||
error_message=task.error_message,
|
||||
started_at=task.started_at,
|
||||
finished_at=task.finished_at,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def recover_running_qrcode_batch_tasks() -> None:
|
||||
with SessionLocal() as db:
|
||||
tasks = db.scalars(
|
||||
select(DeviceQrBatchTask).where(DeviceQrBatchTask.status == QRCODE_TASK_STATUS_RUNNING)
|
||||
).all()
|
||||
for task in tasks:
|
||||
task.status = QRCODE_TASK_STATUS_PENDING
|
||||
task.failed_count = 0
|
||||
task.error_message = "服务重启后重新排队"
|
||||
task.finished_at = None
|
||||
if tasks:
|
||||
db.commit()
|
||||
|
||||
|
||||
def _claim_next_qrcode_batch_task() -> int | None:
|
||||
with SessionLocal() as db:
|
||||
query = (
|
||||
select(DeviceQrBatchTask)
|
||||
.where(DeviceQrBatchTask.status == QRCODE_TASK_STATUS_PENDING)
|
||||
.order_by(DeviceQrBatchTask.created_at.asc(), DeviceQrBatchTask.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if db.get_bind().dialect.name in {"mysql", "postgresql"}:
|
||||
query = query.with_for_update(skip_locked=True)
|
||||
task = db.scalar(query)
|
||||
if task is None:
|
||||
return None
|
||||
task.status = QRCODE_TASK_STATUS_RUNNING
|
||||
if task.started_at is None:
|
||||
task.started_at = now()
|
||||
task.finished_at = None
|
||||
task.failed_count = 0
|
||||
task.error_message = None
|
||||
db.commit()
|
||||
return int(task.id)
|
||||
|
||||
|
||||
async def _generate_task_qrcode(db, task: DeviceQrBatchTask, item: dict) -> tuple[str, Path]:
|
||||
point_name = str(item.get("attendance_point_name") or "").strip()
|
||||
mold_name = str(item.get("product_name") or "").strip()
|
||||
process_name = str(item.get("process_name") or "").strip()
|
||||
product = _product_for_mold(db, point_name, mold_name, process_name)
|
||||
if product is None:
|
||||
raise RuntimeError(f"产品清单中已不存在:{point_name} / {mold_name} / {process_name}")
|
||||
|
||||
display_name = point_mold_display_name(point_name, mold_name, process_name, product.stamping_method)
|
||||
scene = f"mold={_scene_token(point_name, mold_name, process_name)}"
|
||||
qr_url = await create_device_qrcode(
|
||||
display_name,
|
||||
page="pages/clock/clock",
|
||||
scene=scene,
|
||||
public_base_url=_public_base_url(),
|
||||
label=f"模具 {display_name}",
|
||||
)
|
||||
|
||||
record = db.get(
|
||||
DeviceQRCode,
|
||||
{"attendance_point_name": point_name, "device_no": mold_name, "process_name": process_name},
|
||||
)
|
||||
if record is None:
|
||||
record = DeviceQRCode(
|
||||
attendance_point_name=point_name,
|
||||
device_no=mold_name,
|
||||
process_name=process_name,
|
||||
created_by=task.created_by,
|
||||
)
|
||||
db.add(record)
|
||||
record.qr_scene = scene
|
||||
record.qr_url = qr_url
|
||||
db.commit()
|
||||
|
||||
qr_path = _qrcode_path_from_url(qr_url)
|
||||
if qr_path is None:
|
||||
raise RuntimeError(f"{display_name} 二维码图片未生成,无法打包")
|
||||
return display_name, qr_path
|
||||
|
||||
|
||||
async def process_qrcode_batch_task(task_id: int) -> None:
|
||||
zip_dir = settings.upload_path / "qrcode_zips"
|
||||
zip_dir.mkdir(parents=True, exist_ok=True)
|
||||
used_names: set[str] = set()
|
||||
|
||||
with SessionLocal() as db:
|
||||
task = db.get(DeviceQrBatchTask, task_id)
|
||||
if task is None:
|
||||
return
|
||||
items = list(task.items_json or [])
|
||||
zip_path = zip_dir / task.file_name
|
||||
completed_count = max(0, min(int(task.completed_count or 0), len(items)))
|
||||
if completed_count and not zip_path.exists():
|
||||
completed_count = 0
|
||||
task.completed_count = 0
|
||||
db.commit()
|
||||
zip_mode = "a" if completed_count > 0 else "w"
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, zip_mode, zipfile.ZIP_DEFLATED) as archive:
|
||||
for index, item in enumerate(items, start=1):
|
||||
if index <= completed_count:
|
||||
continue
|
||||
db.refresh(task)
|
||||
if task.status == QRCODE_TASK_STATUS_PAUSED:
|
||||
task.finished_at = None
|
||||
db.commit()
|
||||
return
|
||||
if task.status != QRCODE_TASK_STATUS_RUNNING:
|
||||
return
|
||||
display_name, qr_path = await _generate_task_qrcode(db, task, item)
|
||||
stem = _safe_file_stem(f"{index:03d}_{display_name}")
|
||||
archive.write(qr_path, _unique_zip_name(set(archive.namelist()), stem, qr_path.suffix))
|
||||
task.completed_count = index
|
||||
task.failed_count = 0
|
||||
task.updated_at = now()
|
||||
db.commit()
|
||||
|
||||
task.status = QRCODE_TASK_STATUS_COMPLETED
|
||||
task.completed_count = len(items)
|
||||
task.failed_count = 0
|
||||
task.zip_url = f"{_public_base_url()}/uploads/qrcode_zips/{quote(task.file_name, safe='')}"
|
||||
task.error_message = None
|
||||
task.finished_at = now()
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
task = db.get(DeviceQrBatchTask, task_id)
|
||||
if task is None:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
else:
|
||||
completed_count = int(task.completed_count or 0)
|
||||
task.status = QRCODE_TASK_STATUS_FAILED
|
||||
task.failed_count = max(1, int(task.item_count or 0) - completed_count)
|
||||
task.error_message = str(exc)[:1000]
|
||||
task.finished_at = now()
|
||||
db.commit()
|
||||
raise
|
||||
|
||||
|
||||
async def qrcode_batch_task_loop(interval_seconds: int = QRCODE_BATCH_TASK_INTERVAL_SECONDS) -> None:
|
||||
await asyncio.to_thread(recover_running_qrcode_batch_tasks)
|
||||
while True:
|
||||
try:
|
||||
task_id = await asyncio.to_thread(_claim_next_qrcode_batch_task)
|
||||
if task_id is None:
|
||||
await asyncio.sleep(interval_seconds)
|
||||
continue
|
||||
await process_qrcode_batch_task(task_id)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("qrcode batch task failed")
|
||||
await asyncio.sleep(interval_seconds)
|
||||
@@ -0,0 +1,94 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models import (
|
||||
MoldLockFeedback,
|
||||
Personnel,
|
||||
ProductionReport,
|
||||
ProductionReportItem,
|
||||
ReportAuditLog,
|
||||
WorkSession,
|
||||
)
|
||||
from app.timezone import now
|
||||
|
||||
|
||||
VOID_RETENTION_DAYS = 30
|
||||
APPROVED_EDIT_DAYS = 30
|
||||
|
||||
|
||||
def void_deadline(voided_at=None):
|
||||
return (voided_at or now()) + timedelta(days=VOID_RETENTION_DAYS)
|
||||
|
||||
|
||||
def can_edit_report(report: ProductionReport) -> bool:
|
||||
if bool(report.is_voided):
|
||||
return False
|
||||
if str(report.status) == "pending":
|
||||
return True
|
||||
if str(report.status) != "approved" or report.reviewed_at is None:
|
||||
return False
|
||||
return report.reviewed_at >= now() - timedelta(days=APPROVED_EDIT_DAYS)
|
||||
|
||||
|
||||
def can_unvoid_report(report: ProductionReport) -> bool:
|
||||
if not bool(report.is_voided) or report.voided_at is None:
|
||||
return False
|
||||
return report.voided_at > now() - timedelta(days=VOID_RETENTION_DAYS)
|
||||
|
||||
|
||||
def mark_report_voided(report: ProductionReport, user: Personnel) -> None:
|
||||
if bool(report.is_voided):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报工记录已作废")
|
||||
timestamp = now()
|
||||
report.is_voided = True
|
||||
report.voided_at = timestamp
|
||||
report.voided_by = user.phone
|
||||
report.unvoid_deadline_at = void_deadline(timestamp)
|
||||
|
||||
|
||||
def restore_voided_report(report: ProductionReport) -> None:
|
||||
if not bool(report.is_voided):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该报工记录未作废")
|
||||
if not can_unvoid_report(report):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="作废已超过30天,不能撤销作废")
|
||||
report.is_voided = False
|
||||
report.voided_at = None
|
||||
report.voided_by = None
|
||||
report.unvoid_deadline_at = None
|
||||
|
||||
|
||||
def purge_expired_voided_reports(db: Session) -> int:
|
||||
cutoff = now() - timedelta(days=VOID_RETENTION_DAYS)
|
||||
reports = db.scalars(
|
||||
select(ProductionReport)
|
||||
.options(selectinload(ProductionReport.session).selectinload(WorkSession.devices))
|
||||
.where(
|
||||
ProductionReport.is_voided.is_(True),
|
||||
ProductionReport.voided_at.is_not(None),
|
||||
ProductionReport.voided_at <= cutoff,
|
||||
)
|
||||
.limit(100)
|
||||
).all()
|
||||
if not reports:
|
||||
return 0
|
||||
|
||||
for report in reports:
|
||||
session = report.session
|
||||
if session is not None:
|
||||
session_device_ids = [device.id for device in session.devices if device.id is not None]
|
||||
if session_device_ids:
|
||||
db.execute(
|
||||
delete(MoldLockFeedback).where(
|
||||
MoldLockFeedback.session_device_id.in_(session_device_ids)
|
||||
)
|
||||
)
|
||||
db.execute(delete(ReportAuditLog).where(ReportAuditLog.report_id == report.id))
|
||||
db.execute(delete(ProductionReportItem).where(ProductionReportItem.report_id == report.id))
|
||||
db.delete(report)
|
||||
if session is not None:
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
return len(reports)
|
||||
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.models import WorkSession
|
||||
from app.timezone import now
|
||||
|
||||
REPORTING_EXPIRE_MINUTES = 15
|
||||
REPORTING_EXPIRED_DETAIL = "报工填写已超过15分钟,请重新扫码点击重新报工"
|
||||
|
||||
|
||||
def is_reporting_expired(session: WorkSession, current_time: datetime | None = None) -> bool:
|
||||
if session.end_at is None:
|
||||
return False
|
||||
timestamp = current_time or now()
|
||||
return timestamp - session.end_at > timedelta(minutes=REPORTING_EXPIRE_MINUTES)
|
||||
@@ -0,0 +1,490 @@
|
||||
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,
|
||||
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_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_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,
|
||||
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))
|
||||
),
|
||||
corrections=report_corrections,
|
||||
)
|
||||
@@ -0,0 +1,256 @@
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.services.cleaning import is_cleaning_item
|
||||
from app.services.common import as_float, normalize_device_no, round2
|
||||
from app.services.continuous_die import is_continuous_die_item
|
||||
from app.services.display_names import point_mold_display_name
|
||||
from app.services.misc_work import is_misc_item
|
||||
from app.services.multi_person import is_multi_person_item
|
||||
|
||||
|
||||
DEVICE_TYPE_STAMPING = "冲压设备"
|
||||
DEVICE_TYPE_CLEANING = "清洗设备"
|
||||
MOLD_TYPE_NORMAL = "普通模具"
|
||||
MOLD_TYPE_CLEANING = "清洗模具"
|
||||
MOLD_TYPE_CONTINUOUS_DIE = "连续模"
|
||||
MOLD_TYPE_MULTI_PERSON = "多人工序"
|
||||
METRIC_KIND_MINUTES = "minutes"
|
||||
METRIC_KIND_QUANTITY = "quantity"
|
||||
TAG_CLEANING = "清洗"
|
||||
TAG_CONTINUOUS_DIE = "连续模"
|
||||
TAG_MULTI_PERSON = "多人协作"
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsageStatRow:
|
||||
id: str
|
||||
category: str
|
||||
name: str
|
||||
metric_kind: str
|
||||
object_type: str
|
||||
value: float = 0
|
||||
report_count: int = 0
|
||||
tags: list[str] = field(default_factory=list)
|
||||
attendance_point_name: str = ""
|
||||
product_name: str = ""
|
||||
process_name: str = ""
|
||||
stamping_method: str = ""
|
||||
|
||||
|
||||
class UsageStatAccumulator:
|
||||
def __init__(self, category: str):
|
||||
self.category = category
|
||||
self._rows: dict[tuple[Any, ...], UsageStatRow] = {}
|
||||
|
||||
def add(
|
||||
self,
|
||||
key: tuple[Any, ...],
|
||||
*,
|
||||
name: str,
|
||||
metric_kind: str,
|
||||
object_type: str,
|
||||
value: float,
|
||||
tags: list[str] | None = None,
|
||||
attendance_point_name: str = "",
|
||||
product_name: str = "",
|
||||
process_name: str = "",
|
||||
stamping_method: str = "",
|
||||
) -> None:
|
||||
row = self._rows.get(key)
|
||||
if row is None:
|
||||
row = UsageStatRow(
|
||||
id=_build_row_id(
|
||||
self.category,
|
||||
attendance_point_name,
|
||||
name,
|
||||
product_name,
|
||||
process_name,
|
||||
stamping_method,
|
||||
metric_kind,
|
||||
),
|
||||
category=self.category,
|
||||
name=name,
|
||||
metric_kind=metric_kind,
|
||||
object_type=object_type,
|
||||
tags=[],
|
||||
attendance_point_name=attendance_point_name,
|
||||
product_name=product_name,
|
||||
process_name=process_name,
|
||||
stamping_method=stamping_method,
|
||||
)
|
||||
self._rows[key] = row
|
||||
elif _object_type_priority(object_type) > _object_type_priority(row.object_type):
|
||||
row.object_type = object_type
|
||||
|
||||
row.value += as_float(value)
|
||||
row.report_count += 1
|
||||
for tag in tags or []:
|
||||
if tag and tag not in row.tags:
|
||||
row.tags.append(tag)
|
||||
|
||||
def rows(self) -> list[UsageStatRow]:
|
||||
rows = list(self._rows.values())
|
||||
for row in rows:
|
||||
row.value = round2(row.value)
|
||||
return rows
|
||||
|
||||
|
||||
def split_cleaning_device_nos(value: str | None) -> list[str]:
|
||||
device_nos: list[str] = []
|
||||
for part in (part.strip() for part in re.split(r"[、,,]", _clean(value))):
|
||||
if part and part not in device_nos:
|
||||
device_nos.append(part)
|
||||
return device_nos
|
||||
|
||||
|
||||
def build_usage_stats(
|
||||
*,
|
||||
reports: list,
|
||||
category: str,
|
||||
equipment_type_by_key: dict[tuple[str, str], str],
|
||||
) -> list[UsageStatRow]:
|
||||
if category == "device":
|
||||
return _build_device_usage_stats(reports, equipment_type_by_key)
|
||||
if category == "mold":
|
||||
return _build_mold_usage_stats(reports)
|
||||
raise ValueError("category must be device or mold")
|
||||
|
||||
|
||||
def _build_device_usage_stats(
|
||||
reports: list,
|
||||
equipment_type_by_key: dict[tuple[str, str], str],
|
||||
) -> list[UsageStatRow]:
|
||||
acc = UsageStatAccumulator(category="device")
|
||||
for item in _iter_report_items(reports):
|
||||
if is_misc_item(item):
|
||||
continue
|
||||
|
||||
point_name = _clean(getattr(item, "attendance_point_name", None))
|
||||
if is_cleaning_item(item):
|
||||
device_nos = split_cleaning_device_nos(getattr(item, "device_no", None))
|
||||
if not device_nos:
|
||||
continue
|
||||
value = as_float(getattr(item, "good_qty", 0)) / len(device_nos)
|
||||
for device_no in device_nos:
|
||||
object_type = equipment_type_by_key.get((point_name, device_no), DEVICE_TYPE_CLEANING)
|
||||
acc.add(
|
||||
(point_name, device_no, METRIC_KIND_QUANTITY),
|
||||
name=device_no,
|
||||
metric_kind=METRIC_KIND_QUANTITY,
|
||||
object_type=object_type,
|
||||
value=value,
|
||||
tags=[TAG_CLEANING],
|
||||
attendance_point_name=point_name,
|
||||
)
|
||||
continue
|
||||
|
||||
device_no = normalize_device_no(getattr(item, "device_no", None))
|
||||
if not device_no:
|
||||
continue
|
||||
acc.add(
|
||||
(point_name, device_no, METRIC_KIND_MINUTES),
|
||||
name=device_no,
|
||||
metric_kind=METRIC_KIND_MINUTES,
|
||||
object_type=equipment_type_by_key.get((point_name, device_no), DEVICE_TYPE_STAMPING),
|
||||
value=as_float(getattr(item, "allocated_minutes", 0)),
|
||||
attendance_point_name=point_name,
|
||||
)
|
||||
return acc.rows()
|
||||
|
||||
|
||||
def _build_mold_usage_stats(reports: list) -> list[UsageStatRow]:
|
||||
acc = UsageStatAccumulator(category="mold")
|
||||
for item in _iter_report_items(reports):
|
||||
if is_misc_item(item):
|
||||
continue
|
||||
|
||||
point_name = _clean(getattr(item, "attendance_point_name", None))
|
||||
product_name = _clean(getattr(item, "product_name", None))
|
||||
process_name = _clean(getattr(item, "process_name", None))
|
||||
stamping_method = _clean(getattr(item, "stamping_method", None))
|
||||
metric_kind = METRIC_KIND_QUANTITY if is_cleaning_item(item) else METRIC_KIND_MINUTES
|
||||
value_field = "good_qty" if metric_kind == METRIC_KIND_QUANTITY else "allocated_minutes"
|
||||
tags = _item_tags(item)
|
||||
object_type = _mold_object_type(item)
|
||||
|
||||
acc.add(
|
||||
(point_name, product_name, process_name, stamping_method, metric_kind),
|
||||
name=point_mold_display_name(point_name, product_name, process_name, stamping_method),
|
||||
metric_kind=metric_kind,
|
||||
object_type=object_type,
|
||||
value=as_float(getattr(item, value_field, 0)),
|
||||
tags=tags,
|
||||
attendance_point_name=point_name,
|
||||
product_name=product_name,
|
||||
process_name=process_name,
|
||||
stamping_method=stamping_method,
|
||||
)
|
||||
return acc.rows()
|
||||
|
||||
|
||||
def _iter_report_items(reports: list):
|
||||
for report in reports:
|
||||
yield from getattr(report, "items", []) or []
|
||||
|
||||
|
||||
def _item_tags(item) -> list[str]:
|
||||
tags: list[str] = []
|
||||
if is_cleaning_item(item):
|
||||
tags.append(TAG_CLEANING)
|
||||
if is_continuous_die_item(item):
|
||||
tags.append(TAG_CONTINUOUS_DIE)
|
||||
if is_multi_person_item(item):
|
||||
tags.append(TAG_MULTI_PERSON)
|
||||
return tags
|
||||
|
||||
|
||||
def _mold_object_type(item) -> str:
|
||||
if is_cleaning_item(item):
|
||||
return MOLD_TYPE_CLEANING
|
||||
if is_continuous_die_item(item):
|
||||
return MOLD_TYPE_CONTINUOUS_DIE
|
||||
if is_multi_person_item(item):
|
||||
return MOLD_TYPE_MULTI_PERSON
|
||||
return MOLD_TYPE_NORMAL
|
||||
|
||||
|
||||
def _object_type_priority(value: str) -> int:
|
||||
return {
|
||||
MOLD_TYPE_NORMAL: 0,
|
||||
DEVICE_TYPE_STAMPING: 0,
|
||||
DEVICE_TYPE_CLEANING: 0,
|
||||
MOLD_TYPE_MULTI_PERSON: 1,
|
||||
MOLD_TYPE_CONTINUOUS_DIE: 2,
|
||||
MOLD_TYPE_CLEANING: 3,
|
||||
}.get(value, 0)
|
||||
|
||||
|
||||
def _build_row_id(
|
||||
category: str,
|
||||
attendance_point_name: str,
|
||||
name: str,
|
||||
product_name: str,
|
||||
process_name: str,
|
||||
stamping_method: str,
|
||||
metric_kind: str,
|
||||
) -> str:
|
||||
return json.dumps(
|
||||
[
|
||||
_clean(category),
|
||||
_clean(attendance_point_name),
|
||||
_clean(name),
|
||||
_clean(product_name),
|
||||
_clean(process_name),
|
||||
_clean(stamping_method),
|
||||
_clean(metric_kind),
|
||||
],
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
@@ -0,0 +1,83 @@
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill
|
||||
|
||||
|
||||
DEVICE_HEADERS = ["考勤点", "设备号", "设备类型", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
|
||||
MOLD_HEADERS = ["考勤点", "产品名称", "工序", "冲压方式", "使用时长(分钟)", "使用时长(小时)", "使用时长(天)", "清洗数量", "报工次数"]
|
||||
|
||||
|
||||
def export_usage_stats_rows(device_rows: list, mold_rows: list) -> bytes:
|
||||
workbook = Workbook()
|
||||
device_sheet = workbook.active
|
||||
device_sheet.title = "设备统计"
|
||||
mold_sheet = workbook.create_sheet("模具统计")
|
||||
|
||||
_write_device_sheet(device_sheet, device_rows)
|
||||
_write_mold_sheet(mold_sheet, mold_rows)
|
||||
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _write_device_sheet(sheet, rows: list) -> None:
|
||||
_append_headers(sheet, DEVICE_HEADERS)
|
||||
for row in rows:
|
||||
minutes, hours, days, quantity = _metric_values(row)
|
||||
sheet.append(
|
||||
[
|
||||
row.attendance_point_name,
|
||||
row.name,
|
||||
row.object_type,
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
quantity,
|
||||
row.report_count,
|
||||
]
|
||||
)
|
||||
_set_widths(sheet, [16, 16, 16, 16, 16, 14, 12, 12])
|
||||
|
||||
|
||||
def _write_mold_sheet(sheet, rows: list) -> None:
|
||||
_append_headers(sheet, MOLD_HEADERS)
|
||||
for row in rows:
|
||||
minutes, hours, days, quantity = _metric_values(row)
|
||||
sheet.append(
|
||||
[
|
||||
row.attendance_point_name,
|
||||
row.product_name,
|
||||
row.process_name,
|
||||
row.stamping_method,
|
||||
minutes,
|
||||
hours,
|
||||
days,
|
||||
quantity,
|
||||
row.report_count,
|
||||
]
|
||||
)
|
||||
_set_widths(sheet, [16, 24, 12, 16, 16, 16, 14, 12, 12])
|
||||
|
||||
|
||||
def _metric_values(row) -> tuple[float, float, float, float]:
|
||||
if row.metric_kind == "quantity":
|
||||
return 0, 0, 0, row.value
|
||||
minutes = row.value
|
||||
return minutes, round(minutes / 60, 2), round(minutes / 1440, 2), 0
|
||||
|
||||
|
||||
def _append_headers(sheet, headers: list[str]) -> None:
|
||||
sheet.append(headers)
|
||||
fill = PatternFill(fill_type="solid", fgColor="FFEFEFEF")
|
||||
font = Font(bold=True)
|
||||
for cell in sheet[1]:
|
||||
cell.fill = fill
|
||||
cell.font = font
|
||||
|
||||
|
||||
def _set_widths(sheet, widths: list[int]) -> None:
|
||||
for index, width in enumerate(widths, start=1):
|
||||
column_letter = sheet.cell(row=1, column=index).column_letter
|
||||
sheet.column_dimensions[column_letter].width = width
|
||||
@@ -0,0 +1,216 @@
|
||||
from pathlib import Path
|
||||
from io import BytesIO
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class WechatConfigError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _qrcode_file_suffix(content_type: str) -> str:
|
||||
normalized = content_type.lower()
|
||||
if "image/png" in normalized:
|
||||
return ".png"
|
||||
if "image/jpeg" in normalized or "image/jpg" in normalized:
|
||||
return ".jpg"
|
||||
return ".jpg"
|
||||
|
||||
|
||||
def _qrcode_file_name(device_no: str, suffix: str) -> str:
|
||||
safe_name = re.sub(r"[^0-9A-Za-z_.-]+", "_", device_no).strip("._")[:80] or "device"
|
||||
digest = hashlib.sha1(device_no.encode("utf-8")).hexdigest()[:8]
|
||||
return f"{safe_name}-{digest}{suffix}"
|
||||
|
||||
|
||||
def _font_candidates() -> list[Path]:
|
||||
configured = os.getenv("JH_QR_FONT_PATH")
|
||||
paths = []
|
||||
if configured:
|
||||
paths.append(Path(configured))
|
||||
paths.extend([
|
||||
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
|
||||
Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc"),
|
||||
Path("/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc"),
|
||||
Path("/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc"),
|
||||
Path("/usr/local/share/fonts/NotoSansCJK-Regular.ttc"),
|
||||
Path("/usr/local/share/fonts/NotoSansSC-Regular.otf"),
|
||||
Path("/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"),
|
||||
Path("/usr/share/fonts/truetype/arphic/ukai.ttc"),
|
||||
Path("/System/Library/Fonts/PingFang.ttc"),
|
||||
Path("/System/Library/Fonts/Hiragino Sans GB.ttc"),
|
||||
Path("/System/Library/Fonts/STHeiti Medium.ttc"),
|
||||
Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"),
|
||||
Path("/Library/Fonts/Arial Unicode.ttf"),
|
||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
||||
])
|
||||
return paths
|
||||
|
||||
|
||||
def _font_can_render_chinese(font) -> bool:
|
||||
glyphs: set[bytes] = set()
|
||||
for char in "模具宁嘉恒科":
|
||||
image = Image.new("L", (140, 140), 0)
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text((8, 8), char, font=font, fill=255)
|
||||
bbox = image.getbbox()
|
||||
if bbox is None:
|
||||
return False
|
||||
glyphs.add(image.crop(bbox).tobytes())
|
||||
return len(glyphs) >= 5
|
||||
|
||||
|
||||
def _load_label_font(size: int):
|
||||
for path in _font_candidates():
|
||||
if path.exists():
|
||||
try:
|
||||
font = ImageFont.truetype(str(path), size=size)
|
||||
if _font_can_render_chinese(font):
|
||||
return font
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError("服务器缺少可用中文字体,无法生成带中文底部文字的二维码")
|
||||
|
||||
|
||||
def _text_width(draw: ImageDraw.ImageDraw, text: str, font) -> int:
|
||||
if not text:
|
||||
return 0
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
return bbox[2] - bbox[0]
|
||||
|
||||
|
||||
def _wrap_label_text(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
|
||||
normalized = " ".join(str(text or "").split())
|
||||
if not normalized:
|
||||
return []
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for char in normalized:
|
||||
candidate = f"{current}{char}"
|
||||
if current and _text_width(draw, candidate, font) > max_width:
|
||||
lines.append(current)
|
||||
current = char
|
||||
continue
|
||||
current = candidate
|
||||
if current:
|
||||
lines.append(current)
|
||||
return lines
|
||||
|
||||
|
||||
def _draw_qrcode_label(content: bytes, suffix: str, label: str) -> bytes:
|
||||
label_text = str(label or "").strip()
|
||||
if not label_text:
|
||||
return content
|
||||
|
||||
image = Image.open(BytesIO(content)).convert("RGB")
|
||||
original_width, original_height = image.size
|
||||
width = max(original_width, 860)
|
||||
if width != original_width:
|
||||
height = round(original_height * width / original_width)
|
||||
image = image.resize((width, height), Image.Resampling.LANCZOS)
|
||||
else:
|
||||
height = original_height
|
||||
|
||||
font_size = max(54, min(78, width // 12))
|
||||
font = _load_label_font(font_size)
|
||||
line_gap = max(10, font_size // 4)
|
||||
horizontal_padding = max(42, width // 14)
|
||||
vertical_padding = max(28, width // 18)
|
||||
|
||||
measure = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
|
||||
lines = _wrap_label_text(measure, label_text, font, width - horizontal_padding * 2)
|
||||
if len(lines) > 5:
|
||||
lines = lines[:5]
|
||||
ellipsis = "..."
|
||||
while lines[-1] and _text_width(measure, f"{lines[-1]}{ellipsis}", font) > width - horizontal_padding * 2:
|
||||
lines[-1] = lines[-1][:-1]
|
||||
lines[-1] = f"{lines[-1]}{ellipsis}"
|
||||
|
||||
line_height = font_size + line_gap
|
||||
label_height = vertical_padding * 2 + line_height * max(1, len(lines)) - line_gap
|
||||
canvas = Image.new("RGB", (width, height + label_height), "white")
|
||||
canvas.paste(image, (0, 0))
|
||||
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
y = height + vertical_padding
|
||||
for line in lines:
|
||||
draw.text((horizontal_padding, y), line, font=font, fill=(20, 32, 48))
|
||||
y += line_height
|
||||
|
||||
output = BytesIO()
|
||||
if suffix.lower() in {".jpg", ".jpeg"}:
|
||||
canvas.save(output, format="JPEG", quality=95)
|
||||
else:
|
||||
canvas.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
async def get_access_token() -> str:
|
||||
if not settings.wechat_app_id or not settings.wechat_app_secret:
|
||||
raise WechatConfigError("未配置 WECHAT_APP_ID 或 WECHAT_APP_SECRET")
|
||||
|
||||
params = {
|
||||
"grant_type": "client_credential",
|
||||
"appid": settings.wechat_app_id,
|
||||
"secret": settings.wechat_app_secret,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.get("https://api.weixin.qq.com/cgi-bin/token", params=params)
|
||||
data = response.json()
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise RuntimeError(data.get("errmsg") or "获取微信 access_token 失败")
|
||||
return token
|
||||
|
||||
|
||||
async def get_phone_number(phone_code: str) -> str:
|
||||
token = await get_access_token()
|
||||
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={token}"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.post(url, json={"code": phone_code})
|
||||
data = response.json()
|
||||
phone_info = data.get("phone_info") or {}
|
||||
phone = phone_info.get("phoneNumber") or phone_info.get("purePhoneNumber")
|
||||
if not phone:
|
||||
raise RuntimeError(data.get("errmsg") or "获取微信手机号失败")
|
||||
return phone
|
||||
|
||||
|
||||
async def create_device_qrcode(
|
||||
device_no: str,
|
||||
page: str,
|
||||
scene: str,
|
||||
public_base_url: str,
|
||||
label: str | None = None,
|
||||
) -> str | None:
|
||||
token = await get_access_token()
|
||||
url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={token}"
|
||||
payload = {
|
||||
"scene": scene,
|
||||
"page": page,
|
||||
"check_path": False,
|
||||
"env_version": settings.wechat_qr_env_version,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
data = response.json()
|
||||
raise RuntimeError(data.get("errmsg") or "生成设备小程序码失败")
|
||||
|
||||
qr_dir = settings.upload_path / "qrcodes"
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
suffix = _qrcode_file_suffix(content_type)
|
||||
file_name = _qrcode_file_name(device_no, suffix)
|
||||
file_path = Path(qr_dir) / file_name
|
||||
file_path.write_bytes(_draw_qrcode_label(response.content, suffix, label or device_no))
|
||||
return f"{public_base_url.rstrip('/')}/uploads/qrcodes/{quote(file_name, safe='')}"
|
||||
@@ -0,0 +1,158 @@
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import AttendancePoint, WorkSchedule
|
||||
|
||||
|
||||
TIME_PATTERN = re.compile(r"^\d{1,2}:\d{2}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkScheduleConfig:
|
||||
day_start: str
|
||||
day_end: str
|
||||
lunch_start: str
|
||||
lunch_end: str
|
||||
dinner_start: str
|
||||
dinner_end: str
|
||||
overtime_start: str
|
||||
overtime_end: str
|
||||
night_start: str
|
||||
night_end: str
|
||||
|
||||
|
||||
DEFAULT_WORK_SCHEDULE = {
|
||||
"day_start": "08:00",
|
||||
"day_end": "17:20",
|
||||
"lunch_start": "11:40",
|
||||
"lunch_end": "12:40",
|
||||
"dinner_start": "17:20",
|
||||
"dinner_end": "18:00",
|
||||
"overtime_start": "18:00",
|
||||
"overtime_end": "20:00",
|
||||
"night_start": "20:00",
|
||||
"night_end": "06:00",
|
||||
}
|
||||
|
||||
DEFAULT_WORK_SCHEDULE_CONFIG = WorkScheduleConfig(**DEFAULT_WORK_SCHEDULE)
|
||||
DEFAULT_ATTENDANCE_RADIUS_METERS = 500
|
||||
DEFAULT_AUTO_SUBMIT_HOURS = 15
|
||||
|
||||
|
||||
def normalize_time_text(value: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not TIME_PATTERN.match(text):
|
||||
raise ValueError("时间格式必须为 HH:MM")
|
||||
hour_text, minute_text = text.split(":", 1)
|
||||
hour = int(hour_text)
|
||||
minute = int(minute_text)
|
||||
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
||||
raise ValueError("时间必须在 00:00-23:59 范围内")
|
||||
return f"{hour:02d}:{minute:02d}"
|
||||
|
||||
|
||||
def schedule_to_config(schedule: WorkSchedule | None) -> WorkScheduleConfig:
|
||||
if schedule is None:
|
||||
return DEFAULT_WORK_SCHEDULE_CONFIG
|
||||
return WorkScheduleConfig(
|
||||
day_start=schedule.day_start,
|
||||
day_end=schedule.day_end,
|
||||
lunch_start=schedule.lunch_start,
|
||||
lunch_end=schedule.lunch_end,
|
||||
dinner_start=schedule.dinner_start,
|
||||
dinner_end=schedule.dinner_end,
|
||||
overtime_start=schedule.overtime_start,
|
||||
overtime_end=schedule.overtime_end,
|
||||
night_start=schedule.night_start,
|
||||
night_end=schedule.night_end,
|
||||
)
|
||||
|
||||
|
||||
def attendance_point_to_config(point: AttendancePoint | None) -> WorkScheduleConfig | None:
|
||||
if point is None:
|
||||
return None
|
||||
return WorkScheduleConfig(
|
||||
day_start=point.day_start or DEFAULT_WORK_SCHEDULE["day_start"],
|
||||
day_end=point.day_end or DEFAULT_WORK_SCHEDULE["day_end"],
|
||||
lunch_start=point.lunch_start or DEFAULT_WORK_SCHEDULE["lunch_start"],
|
||||
lunch_end=point.lunch_end or DEFAULT_WORK_SCHEDULE["lunch_end"],
|
||||
dinner_start=point.dinner_start or DEFAULT_WORK_SCHEDULE["dinner_start"],
|
||||
dinner_end=point.dinner_end or DEFAULT_WORK_SCHEDULE["dinner_end"],
|
||||
overtime_start=point.overtime_start or DEFAULT_WORK_SCHEDULE["overtime_start"],
|
||||
overtime_end=point.overtime_end or DEFAULT_WORK_SCHEDULE["overtime_end"],
|
||||
night_start=point.night_start or DEFAULT_WORK_SCHEDULE["night_start"],
|
||||
night_end=point.night_end or DEFAULT_WORK_SCHEDULE["night_end"],
|
||||
)
|
||||
|
||||
|
||||
def get_work_schedule(db: Session) -> WorkSchedule:
|
||||
schedule = db.get(WorkSchedule, 1)
|
||||
if schedule is not None:
|
||||
return schedule
|
||||
schedule = WorkSchedule(id=1, **DEFAULT_WORK_SCHEDULE)
|
||||
db.add(schedule)
|
||||
db.commit()
|
||||
db.refresh(schedule)
|
||||
return schedule
|
||||
|
||||
|
||||
def get_work_schedule_config(db: Session, attendance_point_name: str | None = None) -> WorkScheduleConfig:
|
||||
try:
|
||||
point_name = str(attendance_point_name or "").strip()
|
||||
if point_name:
|
||||
point_config = attendance_point_to_config(db.get(AttendancePoint, point_name))
|
||||
if point_config is not None:
|
||||
return point_config
|
||||
return schedule_to_config(get_work_schedule(db))
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return DEFAULT_WORK_SCHEDULE_CONFIG
|
||||
|
||||
|
||||
def normalize_schedule_payload(payload) -> dict[str, str]:
|
||||
return {
|
||||
field: normalize_time_text(getattr(payload, field, DEFAULT_WORK_SCHEDULE[field]))
|
||||
for field in DEFAULT_WORK_SCHEDULE
|
||||
}
|
||||
|
||||
|
||||
def update_work_schedule(db: Session, payload, updated_by: str, *, commit: bool = True) -> WorkSchedule:
|
||||
values = normalize_schedule_payload(payload)
|
||||
attendance_latitude = getattr(payload, "attendance_latitude", None)
|
||||
attendance_longitude = getattr(payload, "attendance_longitude", None)
|
||||
attendance_radius_meters = getattr(payload, "attendance_radius_meters", None)
|
||||
if (attendance_latitude is None) != (attendance_longitude is None):
|
||||
raise ValueError("考勤经纬度必须同时配置")
|
||||
if attendance_latitude is not None and not (-90 <= attendance_latitude <= 90):
|
||||
raise ValueError("考勤纬度必须在 -90 到 90 之间")
|
||||
if attendance_longitude is not None and not (-180 <= attendance_longitude <= 180):
|
||||
raise ValueError("考勤经度必须在 -180 到 180 之间")
|
||||
if attendance_radius_meters is None:
|
||||
attendance_radius_meters = DEFAULT_ATTENDANCE_RADIUS_METERS
|
||||
if int(attendance_radius_meters) <= 0:
|
||||
raise ValueError("考勤范围半径必须大于0米")
|
||||
auto_submit_hours = getattr(payload, "auto_submit_hours", DEFAULT_AUTO_SUBMIT_HOURS)
|
||||
try:
|
||||
auto_submit_hours = float(auto_submit_hours)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("系统自动提交时长必须是数字") from exc
|
||||
if auto_submit_hours < 1 or auto_submit_hours > 168:
|
||||
raise ValueError("系统自动提交时长必须在1到168小时之间")
|
||||
schedule = get_work_schedule(db)
|
||||
for field, value in values.items():
|
||||
setattr(schedule, field, value)
|
||||
schedule.attendance_latitude = attendance_latitude
|
||||
schedule.attendance_longitude = attendance_longitude
|
||||
schedule.attendance_radius_meters = int(attendance_radius_meters)
|
||||
schedule.auto_submit_hours = auto_submit_hours
|
||||
schedule.updated_by = updated_by
|
||||
db.add(schedule)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(schedule)
|
||||
else:
|
||||
db.flush()
|
||||
return schedule
|
||||
Reference in New Issue
Block a user