chore: initialize mini program backend

This commit is contained in:
souplearn
2026-06-24 15:19:14 +08:00
commit 8f62c99893
97 changed files with 13770 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Business services."""
+132
View File
@@ -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))
+230
View File
@@ -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)
+22
View File
@@ -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))
+63
View File
@@ -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,
}
+17
View File
@@ -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))
+25
View File
@@ -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
+219
View File
@@ -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)
+406
View File
@@ -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
+506
View File
@@ -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}"
+72
View File
@@ -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)
+287
View File
@@ -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,
)
+9
View File
@@ -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
+43
View File
@@ -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)
+327
View File
@@ -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)
+94
View File
@@ -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)
+14
View File
@@ -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)
+490
View File
@@ -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,
)
+256
View File
@@ -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()
+83
View File
@@ -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
+216
View File
@@ -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='')}"
+158
View File
@@ -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