chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
44
backend/app/services/__init__.py
Normal file
44
backend/app/services/__init__.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""业务逻辑层"""
|
||||
from app.services.product_service import (
|
||||
get_product_by_serial,
|
||||
get_product,
|
||||
create_product,
|
||||
update_product,
|
||||
update_overall_status,
|
||||
get_all_products,
|
||||
)
|
||||
from app.services.task_service import (
|
||||
get_task,
|
||||
get_top_level_tasks,
|
||||
create_task,
|
||||
update_task,
|
||||
complete_task,
|
||||
receive_task,
|
||||
reject_task,
|
||||
transfer_task,
|
||||
create_subtask,
|
||||
add_task_record,
|
||||
get_all_tasks,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Product
|
||||
"get_product_by_serial",
|
||||
"get_product",
|
||||
"create_product",
|
||||
"update_product",
|
||||
"update_overall_status",
|
||||
"get_all_products",
|
||||
# Task
|
||||
"get_task",
|
||||
"get_top_level_tasks",
|
||||
"create_task",
|
||||
"update_task",
|
||||
"complete_task",
|
||||
"receive_task",
|
||||
"reject_task",
|
||||
"transfer_task",
|
||||
"create_subtask",
|
||||
"add_task_record",
|
||||
"get_all_tasks",
|
||||
]
|
||||
675
backend/app/services/analytics_service.py
Normal file
675
backend/app/services/analytics_service.py
Normal file
@ -0,0 +1,675 @@
|
||||
"""效能分析服务 — 个人能力图谱 + 设备流转对比(ECharts 数据源)
|
||||
|
||||
数据来源:
|
||||
- Task 工序/任务节点,携带 assignee_id、received_at、completed_at → 计算单台耗时。
|
||||
- Product 设备身份证(sn)、规格型号(spec_model)、物料名,用于筛选与展示。
|
||||
- TaskRecord 备注时间线(本模块当前仅作耗时主数据补充,可按需在后续下钻中引入)。
|
||||
|
||||
耗时口径:
|
||||
- 单台真实耗时 = (completed_at or now) - (received_at or created_at),单位小时。
|
||||
- naive datetime 按 UTC 处理,统一换算北京时间。
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Schemas(与前端 src/services/analyticsApi.ts 对齐)
|
||||
# ============================================================
|
||||
|
||||
class CapabilityDataPoint(BaseModel):
|
||||
value: float | None # 该人员在该设备上的总耗时(未触及为 None,真实 0 为 0.0)
|
||||
spec_model: str # 规格型号
|
||||
status: str # 该设备当前状态 WIP/PENDING/COMPLETED/—
|
||||
first_received_at: str # 最早接收时间 YYYY-MM-DD HH:mm
|
||||
last_completed_at: str # 最后完成时间 YYYY-MM-DD HH:mm(无则空串)
|
||||
|
||||
|
||||
class CapabilityDevice(BaseModel):
|
||||
product_sn: str # 身份证
|
||||
external_serial: str | None # 产品序列号(业务序列号)
|
||||
spec_model: str # 规格型号
|
||||
material_name: str # 物料名称
|
||||
|
||||
|
||||
class CapabilitySeries(BaseModel):
|
||||
name: str # 人员姓名
|
||||
assignee_id: str # 人员ID
|
||||
data: list[CapabilityDataPoint] # 与 categories 严格对齐,未触及设备补 0
|
||||
|
||||
|
||||
class CapabilityResponse(BaseModel):
|
||||
categories: list[str] # X 轴:设备身份证(按时间升序)
|
||||
devices: list[CapabilityDevice] # 与 categories 对齐的设备元数据
|
||||
series: list[CapabilitySeries]
|
||||
|
||||
|
||||
class FlowDevice(BaseModel):
|
||||
product_sn: str
|
||||
external_serial: str | None
|
||||
material_name: str
|
||||
spec_model: str
|
||||
lead_time: float # 设备生命周期总时长(小时)= max_end - min_start
|
||||
started_at: str # 设备最早介入时间(T0),格式 MM-DD HH:mm
|
||||
total_days: int = 0 # 设备生产总天数(自然天,自最早介入至今)
|
||||
total_workdays: int = 0 # 设备生产总天数(工作日,排除周末/节假日)
|
||||
|
||||
|
||||
class FlowSeries(BaseModel):
|
||||
name: str # 人员姓名
|
||||
data: list[list] # 每项 = [device_index, start_offset, end_offset, task_name, duration](小时)
|
||||
|
||||
|
||||
class FlowResponse(BaseModel):
|
||||
devices: list[FlowDevice]
|
||||
series: list[FlowSeries]
|
||||
|
||||
|
||||
class AssigneeOption(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class SpecModelOption(BaseModel):
|
||||
spec_model: str
|
||||
material_name: str
|
||||
|
||||
|
||||
class DeviceOption(BaseModel):
|
||||
product_sn: str
|
||||
external_serial: str | None
|
||||
material_name: str
|
||||
spec_model: str
|
||||
|
||||
|
||||
class AnalyticsOptions(BaseModel):
|
||||
assignees: list[AssigneeOption]
|
||||
spec_models: list[SpecModelOption]
|
||||
devices: list[DeviceOption]
|
||||
|
||||
|
||||
class DeviceRecord(BaseModel):
|
||||
task_name: str # 工序名
|
||||
assignee_name: str # 负责人姓名
|
||||
status: str # 任务状态
|
||||
remark: str | None # 备注
|
||||
images: list[str] # 图片 URL 列表
|
||||
created_at: str # 记录时间 ISO
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 时间工具
|
||||
# ============================================================
|
||||
|
||||
def _to_bj(dt: datetime | None) -> datetime | None:
|
||||
"""naive datetime 按 UTC 处理,统一换算为北京时间。"""
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
if not dt:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt
|
||||
|
||||
|
||||
def _duration_hours(start: datetime | None, end: datetime, holidays: set = None, mode: str = "workdays") -> float:
|
||||
"""计算单台耗时(小时),无开始时间返回 0。
|
||||
mode=workdays: 排除周末/节假日的工作小时;mode=natural: 自然小时。"""
|
||||
if not start:
|
||||
return 0.0
|
||||
if mode == "natural":
|
||||
return (end - start).total_seconds() / 3600
|
||||
from app.core.time_utils import working_duration_hours
|
||||
return working_duration_hours(start, end, holidays)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 个人能力图谱 — 单台设备耗时对比(分组柱状图,X=设备身份证)
|
||||
# ============================================================
|
||||
|
||||
async def get_capability_profile(
|
||||
db: AsyncSession,
|
||||
assignee_ids: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
mode: str = "workdays",
|
||||
) -> CapabilityResponse:
|
||||
"""
|
||||
个人能力图谱(单机颗粒度):X 轴 = 设备身份证,每个负责人一条柱状 series。
|
||||
按 (负责人, 设备) 分组,累加该人在该设备所有工序的耗时,
|
||||
series.data 与 categories 严格对齐,未触及设备补 0。
|
||||
耗时口径:(coalesce(completed_at, now) - coalesce(received_at, created_at))。
|
||||
"""
|
||||
from app.models.task import (
|
||||
Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.holiday import Holiday
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# 读取节假日(排除非工作日)
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Task.assignee_id, Task.status,
|
||||
Task.received_at, Task.created_at, Task.completed_at,
|
||||
Product.serial_number, Product.external_serial, Product.spec_model,
|
||||
Product.material_name,
|
||||
)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
)
|
||||
if assignee_ids:
|
||||
stmt = stmt.where(Task.assignee_id.in_(assignee_ids))
|
||||
if spec_models:
|
||||
stmt = stmt.where(Product.spec_model.in_(spec_models))
|
||||
|
||||
# 时间交集(与 people-history 口径一致)
|
||||
start_expr = func.coalesce(Task.received_at, Task.created_at)
|
||||
end_expr = func.coalesce(Task.completed_at, now)
|
||||
if until:
|
||||
stmt = stmt.where(start_expr <= until)
|
||||
if since:
|
||||
stmt = stmt.where(end_expr >= since)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 设备元数据 + 时间基准;人员×设备 耗时聚合
|
||||
device_meta: dict[str, dict] = {}
|
||||
agg: dict[tuple[str, str], dict] = {}
|
||||
for row in rows:
|
||||
assignee = row[0]
|
||||
status = row[1]
|
||||
start = _to_bj(row[2] or row[3]) # received_at or created_at
|
||||
end = _to_bj(row[4]) if row[4] else now # completed_at or now
|
||||
sn = row[5]
|
||||
ext = row[6]
|
||||
spec = row[7] or "未知型号"
|
||||
mat = row[8] or ""
|
||||
|
||||
meta = device_meta.setdefault(sn, {"external_serial": ext, "spec_model": spec, "material_name": mat, "earliest": None})
|
||||
if start and (meta["earliest"] is None or start < meta["earliest"]):
|
||||
meta["earliest"] = start
|
||||
|
||||
entry = agg.setdefault((assignee, sn), {
|
||||
"hours": 0.0, "prio": 9,
|
||||
"first_start": None, "last_completed": None,
|
||||
})
|
||||
entry["hours"] += _duration_hours(start, end, holidays, mode)
|
||||
if start and (entry["first_start"] is None or start < entry["first_start"]):
|
||||
entry["first_start"] = start
|
||||
completed_dt = _to_bj(row[4]) if row[4] else None
|
||||
if completed_dt and (entry["last_completed"] is None or completed_dt > entry["last_completed"]):
|
||||
entry["last_completed"] = completed_dt
|
||||
prio = 0 if status == TASK_STATUS_WIP else (1 if status == TASK_STATUS_PENDING else 2)
|
||||
entry["prio"] = min(entry["prio"], prio)
|
||||
|
||||
if not agg:
|
||||
return CapabilityResponse(categories=[], devices=[], series=[])
|
||||
|
||||
# X 轴:按最早接收/创建时间升序的设备身份证
|
||||
categories = sorted(device_meta.keys(), key=lambda s: device_meta[s]["earliest"] or now)
|
||||
devices = [
|
||||
CapabilityDevice(
|
||||
product_sn=sn,
|
||||
external_serial=device_meta[sn]["external_serial"],
|
||||
spec_model=device_meta[sn]["spec_model"],
|
||||
material_name=device_meta[sn]["material_name"],
|
||||
)
|
||||
for sn in categories
|
||||
]
|
||||
|
||||
# 人员姓名映射
|
||||
raw_ids = list({a for (a, _) in agg.keys()})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
STATUS_CODE = {0: "WIP", 1: "PENDING", 2: "COMPLETED"}
|
||||
|
||||
by_assignee: dict[str, dict[str, dict]] = {}
|
||||
for (a, sn), entry in agg.items():
|
||||
by_assignee.setdefault(a, {})[sn] = entry
|
||||
|
||||
series: list[CapabilitySeries] = []
|
||||
for a in sorted(by_assignee.keys()):
|
||||
sn_entries = by_assignee[a]
|
||||
data: list[CapabilityDataPoint] = []
|
||||
for sn in categories:
|
||||
e = sn_entries.get(sn)
|
||||
if e:
|
||||
data.append(CapabilityDataPoint(
|
||||
value=round(e["hours"], 1),
|
||||
spec_model=device_meta[sn]["spec_model"],
|
||||
status=STATUS_CODE[e["prio"]],
|
||||
first_received_at=e["first_start"].strftime("%Y-%m-%d %H:%M") if e["first_start"] else "",
|
||||
last_completed_at=e["last_completed"].strftime("%Y-%m-%d %H:%M") if e["last_completed"] else "",
|
||||
))
|
||||
else:
|
||||
data.append(CapabilityDataPoint(
|
||||
value=None,
|
||||
spec_model=device_meta[sn]["spec_model"],
|
||||
status="—",
|
||||
first_received_at="",
|
||||
last_completed_at="",
|
||||
))
|
||||
series.append(CapabilitySeries(
|
||||
name=name_map.get(a, a),
|
||||
assignee_id=a,
|
||||
data=data,
|
||||
))
|
||||
series.sort(key=lambda s: s.name)
|
||||
return CapabilityResponse(categories=categories, devices=devices, series=series)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 流转对比 — 设备各工序耗时(堆叠柱状)
|
||||
# ============================================================
|
||||
|
||||
async def get_flow_compare(
|
||||
db: AsyncSession,
|
||||
product_sns: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
mode: str = "natural",
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
) -> FlowResponse:
|
||||
"""
|
||||
查询每台设备上各操作人的任务时间区间,拼装为「生命周期时间轴」区间图:
|
||||
x 轴 = 设备身份证,y 轴 = 相对该设备 T0(最早介入时间)的小时偏移。
|
||||
每个任务一根悬空区间柱(start_offset -> end_offset),并行任务可并排显示。
|
||||
|
||||
设备来源:
|
||||
- 传 product_sns:按给定身份证;
|
||||
- 仅传 spec_models:这些型号下最近有流转的 20 台设备;
|
||||
- 都未传:返回空。
|
||||
|
||||
data 每项 = [device_index, task_name, is_main,
|
||||
start_nat, end_nat, start_work, end_work,
|
||||
duration_total(自然), duration_work(工作日)](单位小时)。
|
||||
前端按 isWorkday 切换选取自然/工作日偏移,即时重绘无需重新请求。
|
||||
mode 参数保留兼容(历史调用),不再影响返回内容。
|
||||
"""
|
||||
from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
if product_sns:
|
||||
product_rows = (await db.execute(
|
||||
select(
|
||||
Product.id, Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
).where(Product.serial_number.in_(product_sns))
|
||||
)).all()
|
||||
elif spec_models:
|
||||
latest_subq = (
|
||||
select(
|
||||
Task.product_id,
|
||||
func.max(func.coalesce(Task.received_at, Task.created_at)).label("latest"),
|
||||
)
|
||||
.group_by(Task.product_id)
|
||||
.subquery()
|
||||
)
|
||||
product_rows = (await db.execute(
|
||||
select(
|
||||
Product.id, Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
)
|
||||
.join(latest_subq, latest_subq.c.product_id == Product.id)
|
||||
.where(Product.spec_model.in_(spec_models))
|
||||
.order_by(latest_subq.c.latest.desc())
|
||||
.limit(20)
|
||||
)).all()
|
||||
else:
|
||||
return FlowResponse(devices=[], series=[])
|
||||
|
||||
if not product_rows:
|
||||
return FlowResponse(devices=[], series=[])
|
||||
|
||||
id_to_sn = {r[0]: r[1] for r in product_rows}
|
||||
product_ids = [r[0] for r in product_rows]
|
||||
|
||||
# ── 候选设备的全部任务(含工序名,用于区间图) ──
|
||||
task_rows = (await db.execute(
|
||||
select(
|
||||
Task.product_id, Task.assignee_id, Task.task_name,
|
||||
Task.received_at, Task.created_at, Task.completed_at,
|
||||
Task.task_type,
|
||||
)
|
||||
.where(
|
||||
Task.product_id.in_(product_ids),
|
||||
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
)).all()
|
||||
|
||||
# ── 设备级时间筛选 ──
|
||||
# 语义:时间筛选 = "在该时间有活动的设备",而非"把任务切片只保留该时间"。
|
||||
# 设备一旦入选,其任务必须完整返回(完整生命周期),绝不因设备在筛选中无新动作
|
||||
# 而把它的历史任务清空。
|
||||
if since is not None or until is not None:
|
||||
active_pids: set = set()
|
||||
for row in task_rows:
|
||||
pid = row[0]
|
||||
start = _to_bj(row[3] or row[4])
|
||||
end = _to_bj(row[5]) if row[5] else now
|
||||
if start is None:
|
||||
continue
|
||||
if since is not None and end < since:
|
||||
continue
|
||||
if until is not None and start > until:
|
||||
continue
|
||||
active_pids.add(pid)
|
||||
product_rows = [r for r in product_rows if r[0] in active_pids]
|
||||
if not product_rows:
|
||||
return FlowResponse(devices=[], series=[])
|
||||
|
||||
# 显式传入身份证时按输入顺序排列
|
||||
if product_sns:
|
||||
order = {sn: i for i, sn in enumerate(product_sns)}
|
||||
product_rows.sort(key=lambda r: order.get(r[1], len(order)))
|
||||
devices = [
|
||||
FlowDevice(
|
||||
product_sn=r[1], external_serial=r[2],
|
||||
material_name=r[3] or "", spec_model=r[4] or "",
|
||||
lead_time=0.0,
|
||||
started_at="",
|
||||
)
|
||||
for r in product_rows
|
||||
]
|
||||
sn_to_index = {d.product_sn: i for i, d in enumerate(devices)}
|
||||
kept_ids = {r[0] for r in product_rows}
|
||||
|
||||
# ── 解析为区间记录(完整生命周期,不再按时间切片) ──
|
||||
intervals: list[dict] = []
|
||||
t0_by_device: dict[int, datetime] = {}
|
||||
max_end_by_device: dict[int, datetime] = {}
|
||||
for row in task_rows:
|
||||
pid, assignee_id, task_name, received_at, created_at, completed_at, task_type = row
|
||||
if pid not in kept_ids:
|
||||
continue
|
||||
sn = id_to_sn[pid]
|
||||
idx = sn_to_index[sn]
|
||||
start = _to_bj(received_at or created_at)
|
||||
end = _to_bj(completed_at) if completed_at else now
|
||||
if start is None:
|
||||
continue
|
||||
is_main = 0 if task_type == "SPAWN" else 1
|
||||
intervals.append({
|
||||
"idx": idx,
|
||||
"assignee_id": assignee_id,
|
||||
"task_name": (task_name or "").strip() or "未命名工序",
|
||||
"start": start,
|
||||
"end": end,
|
||||
"is_main": is_main,
|
||||
})
|
||||
if idx not in t0_by_device or start < t0_by_device[idx]:
|
||||
t0_by_device[idx] = start
|
||||
if idx not in max_end_by_device or end > max_end_by_device[idx]:
|
||||
max_end_by_device[idx] = end
|
||||
|
||||
# ── 计算每台设备 lead_time(最大 end - 最小 start,小时),重建 devices ──
|
||||
lead_time_by_index = {
|
||||
idx: round((max_end_by_device[idx] - t0_by_device[idx]).total_seconds() / 3600, 1)
|
||||
for idx in t0_by_device
|
||||
}
|
||||
# 🔧 设备生产总天数(自然天 + 工作日,自最早介入至今)
|
||||
import math
|
||||
from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh
|
||||
from app.models.holiday import Holiday as _Holiday
|
||||
hres = await db.execute(select(_Holiday.day))
|
||||
_holidays = {r[0] for r in hres}
|
||||
|
||||
def _total_days(t0):
|
||||
t0_bj = _tb(t0)
|
||||
if not t0_bj:
|
||||
return 1, 1
|
||||
natural = max(1, math.ceil((now - t0_bj).total_seconds() / 86400))
|
||||
workdays = max(1, math.ceil(_wdh(t0_bj, now, _holidays) / 24))
|
||||
return natural, workdays
|
||||
|
||||
days_map = {i: _total_days(t0_by_device[i]) for i in t0_by_device}
|
||||
devices = [
|
||||
FlowDevice(
|
||||
product_sn=d.product_sn, external_serial=d.external_serial,
|
||||
material_name=d.material_name, spec_model=d.spec_model,
|
||||
lead_time=lead_time_by_index.get(i, 0.0),
|
||||
started_at=t0_by_device[i].strftime("%m-%d %H:%M") if i in t0_by_device else "",
|
||||
total_days=days_map.get(i, (1, 1))[0],
|
||||
total_workdays=days_map.get(i, (1, 1))[1],
|
||||
)
|
||||
for i, d in enumerate(devices)
|
||||
]
|
||||
|
||||
# ── 按人分组,转为相对 T0 的小时偏移区间 ──
|
||||
# 每项同时携带自然天与工作日两套偏移/时长,供前端按 isWorkday 即时切换:
|
||||
# [idx, task_name, is_main, start_nat, end_nat, start_work, end_work, dur_total, dur_work]
|
||||
from app.core.time_utils import working_duration_hours as _wdh
|
||||
from app.models.holiday import Holiday as _Holiday
|
||||
hres = await db.execute(select(_Holiday.day))
|
||||
_holidays = {r[0] for r in hres}
|
||||
by_assignee: dict[str, list[list]] = {}
|
||||
for it in intervals:
|
||||
t0 = t0_by_device[it["idx"]]
|
||||
start_nat = round((it["start"] - t0).total_seconds() / 3600, 1)
|
||||
end_nat = round((it["end"] - t0).total_seconds() / 3600, 1)
|
||||
start_work = round(_wdh(t0, it["start"], _holidays), 1)
|
||||
end_work = round(_wdh(t0, it["end"], _holidays), 1)
|
||||
by_assignee.setdefault(it["assignee_id"], []).append(
|
||||
[it["idx"], it["task_name"], it["is_main"],
|
||||
start_nat, end_nat, start_work, end_work,
|
||||
round(end_nat - start_nat, 1), round(end_work - start_work, 1)]
|
||||
)
|
||||
|
||||
# 翻译人员姓名
|
||||
raw_ids = list(by_assignee.keys())
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
series = [
|
||||
FlowSeries(name=name_map.get(a, a), data=by_assignee[a])
|
||||
for a in sorted(by_assignee.keys())
|
||||
]
|
||||
return FlowResponse(devices=devices, series=series)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 设备备注记录 — 单台设备的全部备注/照片(弹窗下钻)
|
||||
# ============================================================
|
||||
|
||||
async def get_device_records(
|
||||
db: AsyncSession,
|
||||
product_sn: str,
|
||||
assignee_ids: list[str] | None = None,
|
||||
) -> list[DeviceRecord]:
|
||||
"""查询某台设备(身份证)的所有任务备注记录,含图片,按时间倒序。
|
||||
可选按负责人过滤(assignee_ids)。"""
|
||||
import json
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.product import Product
|
||||
|
||||
product_id = await db.scalar(
|
||||
select(Product.id).where(Product.serial_number == product_sn)
|
||||
)
|
||||
if not product_id:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
TaskRecord.remark, TaskRecord.images, TaskRecord.created_at,
|
||||
Task.task_name, Task.assignee_id, Task.status,
|
||||
)
|
||||
.join(Task, TaskRecord.task_id == Task.id)
|
||||
.where(Task.product_id == product_id)
|
||||
)
|
||||
if assignee_ids:
|
||||
stmt = stmt.where(Task.assignee_id.in_(assignee_ids))
|
||||
stmt = stmt.order_by(TaskRecord.created_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
raw_ids = list({r[4] for r in rows if r[4]})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
records: list[DeviceRecord] = []
|
||||
for row in rows:
|
||||
remark, images_raw, created, task_name, assignee, status = row
|
||||
try:
|
||||
images = json.loads(images_raw) if images_raw else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
images = []
|
||||
if not isinstance(images, list):
|
||||
images = []
|
||||
records.append(DeviceRecord(
|
||||
task_name=task_name or "",
|
||||
assignee_name=name_map.get(assignee or "", assignee or ""),
|
||||
status=status or "",
|
||||
remark=remark,
|
||||
images=images,
|
||||
created_at=_to_bj(created).isoformat() if created else "",
|
||||
))
|
||||
return records
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 下拉选项 — 负责人 + 规格型号
|
||||
# ============================================================
|
||||
|
||||
async def get_analytics_options(
|
||||
db: AsyncSession,
|
||||
assignee_ids: list[str] | None = None,
|
||||
spec_models: list[str] | None = None,
|
||||
) -> AnalyticsOptions:
|
||||
"""返回筛选栏选项,支持动态联动:
|
||||
- 传入 spec_models:只返回碰过这些型号的人;
|
||||
- 传入 assignee_ids:只返回这些人处理过的型号;
|
||||
- devices:根据筛选条件返回关联设备(无筛选则返回最近流转的设备)。"""
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
|
||||
# ── 负责人:从 tasks 去重(可选按 spec_models 过滤) ──
|
||||
if spec_models:
|
||||
assignee_stmt = (
|
||||
select(Task.assignee_id)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Task.assignee_id.isnot(None),
|
||||
Product.spec_model.in_(spec_models),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
assignee_stmt = (
|
||||
select(Task.assignee_id)
|
||||
.where(Task.assignee_id.isnot(None))
|
||||
.distinct()
|
||||
)
|
||||
assignee_rows = (await db.execute(assignee_stmt)).all()
|
||||
distinct_assignees = sorted({r[0] for r in assignee_rows if r[0]})
|
||||
|
||||
name_map: dict[str, str] = {}
|
||||
if distinct_assignees:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(distinct_assignees)
|
||||
|
||||
assignees = [
|
||||
AssigneeOption(id=aid, name=name_map.get(aid, aid))
|
||||
for aid in distinct_assignees
|
||||
]
|
||||
|
||||
# ── 规格型号 + 物料名(可选按 assignee_ids 过滤;同型号取首个非空物料名) ──
|
||||
if assignee_ids:
|
||||
spec_stmt = (
|
||||
select(Product.spec_model, Product.material_name)
|
||||
.join(Task, Task.product_id == Product.id)
|
||||
.where(
|
||||
Product.spec_model.isnot(None),
|
||||
func.trim(Product.spec_model) != "",
|
||||
Task.assignee_id.in_(assignee_ids),
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
spec_stmt = (
|
||||
select(Product.spec_model, Product.material_name)
|
||||
.where(Product.spec_model.isnot(None), func.trim(Product.spec_model) != "")
|
||||
.distinct()
|
||||
)
|
||||
spec_rows = (await db.execute(spec_stmt)).all()
|
||||
spec_map: dict[str, str] = {}
|
||||
for row in spec_rows:
|
||||
sm = row[0]
|
||||
mn = (row[1] or "").strip()
|
||||
if sm not in spec_map:
|
||||
spec_map[sm] = mn
|
||||
elif mn and not spec_map[sm]:
|
||||
spec_map[sm] = mn
|
||||
spec_model_opts = [
|
||||
SpecModelOption(spec_model=sm, material_name=spec_map[sm])
|
||||
for sm in sorted(spec_map.keys())
|
||||
]
|
||||
|
||||
# ── 设备字典(可选按 assignee_ids / spec_models 过滤;无筛选返回最近流转 100 台) ──
|
||||
if assignee_ids or spec_models:
|
||||
device_stmt = select(
|
||||
Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
)
|
||||
if assignee_ids:
|
||||
device_stmt = device_stmt.join(
|
||||
Task, Task.product_id == Product.id
|
||||
).where(Task.assignee_id.in_(assignee_ids))
|
||||
if spec_models:
|
||||
device_stmt = device_stmt.where(Product.spec_model.in_(spec_models))
|
||||
device_stmt = device_stmt.distinct()
|
||||
else:
|
||||
latest_subq = (
|
||||
select(
|
||||
Task.product_id,
|
||||
func.max(func.coalesce(Task.received_at, Task.created_at)).label("latest"),
|
||||
)
|
||||
.group_by(Task.product_id)
|
||||
.subquery()
|
||||
)
|
||||
device_stmt = (
|
||||
select(
|
||||
Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
)
|
||||
.join(latest_subq, latest_subq.c.product_id == Product.id)
|
||||
.order_by(latest_subq.c.latest.desc())
|
||||
.limit(100)
|
||||
)
|
||||
device_rows = (await db.execute(device_stmt)).all()
|
||||
devices = [
|
||||
DeviceOption(
|
||||
product_sn=r[0] or "",
|
||||
external_serial=r[1] or None,
|
||||
material_name=r[2] or "",
|
||||
spec_model=r[3] or "",
|
||||
)
|
||||
for r in device_rows
|
||||
]
|
||||
|
||||
return AnalyticsOptions(assignees=assignees, spec_models=spec_model_opts, devices=devices)
|
||||
458
backend/app/services/audit_service.py
Normal file
458
backend/app/services/audit_service.py
Normal file
@ -0,0 +1,458 @@
|
||||
"""审计服务 — 写入与检索
|
||||
|
||||
写入方案的取舍(与 MOM/KCGL 不同,理由如下)
|
||||
--------------------------------------------------
|
||||
MOM 用 SQLAlchemy event listener + **同事务**写入:优点是全自动、业务代码零改动;
|
||||
缺点是业务事务回滚时审计记录一起被回滚掉 —— 而失败/被拒的操作恰恰是最需要
|
||||
留痕的(比如越权尝试、参数错误导致的 4xx)。
|
||||
|
||||
Track 改为:响应生成后,用**独立 session** 写入审计。
|
||||
- 业务回滚不影响审计,失败操作照样留痕
|
||||
- 审计写入失败也不影响业务(全包裹 try/except,仅记日志)
|
||||
- 代价:审计与业务不是原子提交,极端情况(响应后进程立即被 kill)可能丢一条。
|
||||
对内部系统的操作审计,这个取舍划算。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user_daily_seen import UserDailySeen
|
||||
|
||||
logger = logging.getLogger("track.audit")
|
||||
|
||||
# 绝不落库的敏感字段名(命中即替换为 ***)
|
||||
# 登录请求体含明文密码,一旦进审计表就成了长期泄露面
|
||||
_SENSITIVE_KEYS = frozenset(
|
||||
{"password", "passwd", "pwd", "token", "access_token", "refresh_token",
|
||||
"secret", "api_key", "authorization", "password_hash"}
|
||||
)
|
||||
|
||||
# 模块 / 动作 的中文标签(前端下拉与列表展示用)
|
||||
MODULE_LABELS: dict[str, str] = {
|
||||
"auth": "认证登录",
|
||||
"product": "产品管理",
|
||||
"task": "任务流转",
|
||||
"order": "订单管理",
|
||||
"record": "任务记录",
|
||||
"print": "标签打印",
|
||||
"material": "物料",
|
||||
"user": "用户",
|
||||
"notification": "消息通知",
|
||||
"upload": "文件上传",
|
||||
"dashboard": "看板统计",
|
||||
"analytics": "效能分析",
|
||||
"screen": "数据大屏",
|
||||
"holiday": "节假日配置",
|
||||
"app": "App版本",
|
||||
"external": "外部系统对接",
|
||||
"audit": "审计日志",
|
||||
"other": "其它",
|
||||
}
|
||||
|
||||
ACTION_LABELS: dict[str, str] = {
|
||||
"create": "新增",
|
||||
"update": "修改",
|
||||
"delete": "删除",
|
||||
# 只用于被采集的 GET(核心业务详情 / 敏感读)。
|
||||
# 叫「查看详情」而不是「查询」:前者说明用户确实点开了某条业务数据,
|
||||
# 后者容易被误解成"随便搜了一下"。
|
||||
"read": "查看详情",
|
||||
"export": "导出",
|
||||
"login": "登录",
|
||||
"logout": "登出",
|
||||
# 刷新令牌 = 用户重新开始使用系统(token 2 小时一换,7 天免登录),
|
||||
# 业务上视作一次「上线」,比"刷新令牌"这种技术词更贴近车间口径
|
||||
"refresh": "上线",
|
||||
"print": "打印",
|
||||
"upload": "上传",
|
||||
"finalize": "收口",
|
||||
"receive": "接收",
|
||||
"transfer": "转交",
|
||||
"reject": "驳回",
|
||||
"recall": "撤回",
|
||||
"spawn": "派发",
|
||||
"end": "结束分支",
|
||||
"complete": "完结",
|
||||
"mark_read": "标为已读",
|
||||
}
|
||||
|
||||
|
||||
def sanitize_details(details: dict | None) -> dict | None:
|
||||
"""递归剔除敏感字段,避免密码/令牌落库"""
|
||||
if not details:
|
||||
return details
|
||||
|
||||
def _clean(value):
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
k: ("***" if str(k).lower() in _SENSITIVE_KEYS else _clean(v))
|
||||
for k, v in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_clean(v) for v in value]
|
||||
return value
|
||||
|
||||
return _clean(details)
|
||||
|
||||
|
||||
async def record_audit(
|
||||
*,
|
||||
action: str,
|
||||
module: str,
|
||||
user_id: str | None = None,
|
||||
display_name: str | None = None,
|
||||
role: str | None = None,
|
||||
target_type: str | None = None,
|
||||
target_id: str | None = None,
|
||||
target_name: str | None = None,
|
||||
details: dict | None = None,
|
||||
ip_address: str | None = None,
|
||||
user_agent: str | None = None,
|
||||
method: str | None = None,
|
||||
url: str | None = None,
|
||||
status_code: int | None = None,
|
||||
error_message: str | None = None,
|
||||
request_id: str | None = None,
|
||||
) -> None:
|
||||
"""写入一条审计记录。**绝不抛异常**:审计失败不能影响业务。"""
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(
|
||||
AuditLog(
|
||||
id=uuid.uuid4(),
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
role=role,
|
||||
action=action,
|
||||
module=module,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id) if target_id is not None else None,
|
||||
target_name=target_name,
|
||||
details=sanitize_details(details),
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent[:500] if user_agent else None,
|
||||
method=method,
|
||||
url=url[:500] if url else None,
|
||||
status_code=status_code,
|
||||
error_message=error_message,
|
||||
request_id=request_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
# 用 exception 级别但吞掉异常:保证调用方业务流程不受影响
|
||||
logger.exception(
|
||||
"审计写入失败(已忽略,不影响业务)",
|
||||
extra={"extra_fields": {"action": action, "module": module, "url": url}},
|
||||
)
|
||||
|
||||
|
||||
async def list_audit_logs(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
target_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
status_code: int | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
) -> tuple[list[AuditLog], int]:
|
||||
"""审计日志检索(按时间倒序)。返回 (当前页, 真实总数)。
|
||||
|
||||
真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。
|
||||
"""
|
||||
filters = _log_filters(
|
||||
user_id=user_id, module=module, action=action, target_id=target_id,
|
||||
request_id=request_id, status_code=status_code, start=start, end=end,
|
||||
)
|
||||
|
||||
total = await db.scalar(
|
||||
select(func.count()).select_from(AuditLog).where(*filters)
|
||||
) or 0
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AuditLog)
|
||||
.where(*filters)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
return list(rows), total
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 导出
|
||||
# ============================================================
|
||||
|
||||
# 单次导出的行数上限。审计表只增不减,全量导出迟早会撑爆内存与浏览器,
|
||||
# 故设硬上限;超出时向上层返回 truncated=True,由前端明确提示「已截断」——
|
||||
# 静默截断会让使用者以为导全了,比报错更危险。
|
||||
EXPORT_MAX_ROWS = 50000
|
||||
|
||||
|
||||
def _log_filters(
|
||||
*,
|
||||
user_id: str | None = None,
|
||||
module: str | None = None,
|
||||
action: str | None = None,
|
||||
target_id: str | None = None,
|
||||
request_id: str | None = None,
|
||||
status_code: int | None = None,
|
||||
start: datetime | None = None,
|
||||
end: datetime | None = None,
|
||||
) -> list:
|
||||
"""审计日志的筛选条件 —— list_audit_logs 与 export_audit_logs 共用。
|
||||
|
||||
抽出来的唯一目的:保证「列表看到的」和「导出出去的」永远是同一批数据。
|
||||
两处各写一份迟早会漂移,而导出与列表不一致是最让人不信任的那种 bug。
|
||||
"""
|
||||
filters = []
|
||||
if user_id:
|
||||
filters.append(AuditLog.user_id.ilike(f"%{user_id}%"))
|
||||
if module:
|
||||
filters.append(AuditLog.module == module)
|
||||
if action:
|
||||
filters.append(AuditLog.action == action)
|
||||
if target_id:
|
||||
filters.append(AuditLog.target_id == target_id)
|
||||
if request_id:
|
||||
filters.append(AuditLog.request_id == request_id)
|
||||
if status_code is not None:
|
||||
filters.append(AuditLog.status_code == status_code)
|
||||
if start:
|
||||
filters.append(AuditLog.created_at >= start)
|
||||
if end:
|
||||
filters.append(AuditLog.created_at <= end)
|
||||
return filters
|
||||
|
||||
|
||||
async def export_audit_logs(
|
||||
db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs,
|
||||
) -> tuple[list[AuditLog], bool]:
|
||||
"""导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。
|
||||
|
||||
多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(AuditLog)
|
||||
.where(*_log_filters(**kwargs))
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(limit + 1)
|
||||
)
|
||||
).scalars().all()
|
||||
truncated = len(rows) > limit
|
||||
return list(rows[:limit]), truncated
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 每日活动打点(日活报表的「上线时间 / 下线时间」来源)
|
||||
# ============================================================
|
||||
|
||||
# 同一用户两次落盘之间的最小间隔(秒)。
|
||||
#
|
||||
# 打点挂在「每个请求」上,但不希望每个请求都写一次数据库 —— 那会把
|
||||
# user_daily_seen 变成热点。这里用进程内缓存做节流:同一用户 2 分钟内
|
||||
# 只落盘一次。代价是「末次活动时间」最多落后真实值 2 分钟,
|
||||
# 对"日活统计"这个精度要求完全够用。
|
||||
#
|
||||
# 多 worker 部署时每个进程各持一份缓存,实际写库频率最多放大到 worker 数倍
|
||||
# (4 worker × 每人每 2 分钟 1 次),依然可忽略。
|
||||
_TOUCH_INTERVAL_S = 120.0
|
||||
_touch_cache: dict[str, float] = {}
|
||||
|
||||
# 缓存只增不减会缓慢泄漏(键是 user_id,量级 = 用户数,实际很小)。
|
||||
# 超过阈值就整体清空 —— 代价只是多写几次库,换来内存有界。
|
||||
_TOUCH_CACHE_MAX = 5000
|
||||
|
||||
|
||||
async def touch_daily_seen(user_id: str | None) -> None:
|
||||
"""记录「该用户此刻活动过」。首次 INSERT、其后只刷新 last_seen_at。
|
||||
|
||||
唯一的消费方是日活报表的上线/下线时间(见 get_daily_usage)。
|
||||
刻意不写进 audit_logs:那是只增不改的审计流水,而本表是需要不断
|
||||
UPDATE 的状态(详见 UserDailySeen 模型注释)。
|
||||
|
||||
任何异常都吞掉 —— 活动打点失败绝不能影响业务请求本身。
|
||||
"""
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
now_mono = time.monotonic()
|
||||
last = _touch_cache.get(user_id)
|
||||
if last is not None and now_mono - last < _TOUCH_INTERVAL_S:
|
||||
return # 节流窗口内,跳过
|
||||
if len(_touch_cache) > _TOUCH_CACHE_MAX:
|
||||
_touch_cache.clear()
|
||||
# 先占位再写库:同一用户的并发请求不会同时打进来
|
||||
_touch_cache[user_id] = now_mono
|
||||
|
||||
try:
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
now = get_beijing_time()
|
||||
day = now.date() # 北京时间自然日(与报表分日口径一致)
|
||||
async with AsyncSessionLocal() as db:
|
||||
await db.execute(
|
||||
pg_insert(UserDailySeen)
|
||||
.values(user_id=user_id, day=day, first_seen_at=now, last_seen_at=now)
|
||||
# 冲突时只刷新 last_seen_at,first_seen_at 保持当天首次值不变
|
||||
.on_conflict_do_update(
|
||||
index_elements=["user_id", "day"],
|
||||
set_={"last_seen_at": now},
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
except Exception: # noqa: BLE001 —— 打点失败不影响业务
|
||||
logger.exception("记录每日活动失败(已忽略)")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 日活 / 使用统计
|
||||
# ============================================================
|
||||
|
||||
# 成功 = 2xx/3xx。登录失败(401)也要留痕,但不应计入"上线次数"。
|
||||
_OK_STATUS_UPPER = 400
|
||||
|
||||
|
||||
async def get_daily_usage(
|
||||
db: AsyncSession, *, start: datetime, end: datetime,
|
||||
) -> list[dict]:
|
||||
"""按【北京时间自然日 × 操作人】聚合用量 —— 日活报表的数据源。
|
||||
|
||||
start/end 为半开区间 [start, end),调用方按北京时间日界传入。
|
||||
|
||||
全部指标由**一个 GROUP BY 查询**算出,不用窗口函数:
|
||||
· 登录/登出次数 = 成功登录 / 成功登出数(最终凭证是 login_count,不是"上线次数")
|
||||
· 操作频次 = 当天该用户的全部审计记录数(代表系统使用深度)
|
||||
· 登录/登出次数 = 成功登录 / 成功登出数
|
||||
· 上线/下线时间 = 当天**首次 / 末次活动**(优先取 user_daily_seen)
|
||||
|
||||
⚠️ 上线/下线时间【不能】取登录/登出时间。
|
||||
Access/Refresh Token 有效期内(refresh 7 天)用户无需重新登录,
|
||||
于是"周一登录、周二到周日继续用"会导致周二~周日:
|
||||
登录次数=0、登录时间=空,但操作次数却是几十 —— 报表自相矛盾。
|
||||
|
||||
⚠️ 也不能只取审计表的写操作时间:审计中间件只记写操作,普通 GET 不入账,
|
||||
当天只翻看、没做写操作的人会被整条漏掉。
|
||||
故上线/下线时间优先取 user_daily_seen(挂在每个请求上打点),
|
||||
仅对本表上线前的历史数据回退到审计表的写操作时间。
|
||||
|
||||
为什么用 `count(*) FILTER (WHERE ...)`:分组内一次扫描同时算出多个条件计数,
|
||||
比多次子查询或 UNION 简单得多,且语义一眼可读。Postgres 原生支持。
|
||||
|
||||
⚠️ 按【北京时间】分日:created_at 是 timestamptz(实存 UTC),
|
||||
直接按 UTC 分日会让 00:00~08:00 的早班操作掉到前一天。
|
||||
"""
|
||||
day_col = func.date(func.timezone("Asia/Shanghai", AuditLog.created_at))
|
||||
|
||||
login_ok = and_(
|
||||
AuditLog.action == "login", AuditLog.status_code < _OK_STATUS_UPPER,
|
||||
)
|
||||
logout_ok = and_(
|
||||
AuditLog.action == "logout", AuditLog.status_code < _OK_STATUS_UPPER,
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
day_col.label("day"),
|
||||
AuditLog.user_id.label("user_id"),
|
||||
# 同一用户的 display_name / role 是一致的,取 max 只是为了
|
||||
# 在 GROUP BY 下拿到一个非空代表值(避免再套一层 DISTINCT ON)
|
||||
func.max(AuditLog.display_name).label("display_name"),
|
||||
func.max(AuditLog.role).label("role"),
|
||||
func.count().filter(login_ok).label("login_count"),
|
||||
func.count().filter(logout_ok).label("logout_count"),
|
||||
func.count().label("op_count"),
|
||||
# 上线/下线时间取「任意记录」的首末,而不是登录/登出的首末(原因见 docstring)
|
||||
func.min(AuditLog.created_at).label("first_active_at"),
|
||||
func.max(AuditLog.created_at).label("last_active_at"),
|
||||
)
|
||||
.where(
|
||||
AuditLog.created_at >= start,
|
||||
AuditLog.created_at < end,
|
||||
# 只统计"人":未认证请求(如登录前的探测、refresh)没有操作人,
|
||||
# 混进来会让"日活人数"虚高。若要排查匿名异常流量,走日志列表页按
|
||||
# 结果/来源 IP 过滤更合适。
|
||||
AuditLog.user_id.isnot(None),
|
||||
)
|
||||
.group_by(day_col, AuditLog.user_id)
|
||||
.order_by(day_col.desc(), func.count().desc())
|
||||
)
|
||||
|
||||
rows = (await db.execute(stmt)).all()
|
||||
|
||||
# ── 活动表:当天首次/末次活动(覆盖"只翻看不操作"的人)──
|
||||
# day 列是北京时间 DATE,与上面的 day_col 口径一致,可直接按 (user_id, day) 对齐。
|
||||
# 取 start.date() ~ end.date()(end 是次日 00:00 的半开上界,故用 <)。
|
||||
seen_rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
UserDailySeen.user_id, UserDailySeen.day,
|
||||
UserDailySeen.first_seen_at, UserDailySeen.last_seen_at,
|
||||
).where(
|
||||
UserDailySeen.day >= start.date(),
|
||||
UserDailySeen.day < end.date(),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
seen = {
|
||||
(s.user_id, s.day.strftime("%Y-%m-%d")): (s.first_seen_at, s.last_seen_at)
|
||||
for s in seen_rows
|
||||
}
|
||||
|
||||
audit = {
|
||||
(r.user_id, r.day.strftime("%Y-%m-%d") if hasattr(r.day, "strftime") else str(r.day)): r
|
||||
for r in rows
|
||||
}
|
||||
|
||||
# ── 合并 ──
|
||||
# 并集:只有审计记录的人(本表上线前的历史数据)和只有活动记录的人
|
||||
# (当天只翻看、没做写操作)都要出现,各自缺的部分留空/计 0。
|
||||
items: list[dict] = []
|
||||
for key in set(audit) | set(seen):
|
||||
user_id, day = key
|
||||
a = audit.get(key)
|
||||
first_seen, last_seen = seen.get(key, (None, None))
|
||||
|
||||
# 取「两者的最早/最晚」,而不是简单地"活动表优先":
|
||||
# 活动表靠请求触发且有 2 分钟节流,极端情况(跨零点被节流、
|
||||
# 打点写库失败被吞掉)可能晚于当天第一次写操作。
|
||||
# 取 min/max 后,结果永远不会比任一来源更差,也不需要为兜底写分支逻辑。
|
||||
audit_first = a.first_active_at if a else None
|
||||
audit_last = a.last_active_at if a else None
|
||||
first_candidates = [t for t in (first_seen, audit_first) if t is not None]
|
||||
last_candidates = [t for t in (last_seen, audit_last) if t is not None]
|
||||
|
||||
items.append({
|
||||
"day": day,
|
||||
"user_id": user_id,
|
||||
# 姓名字段只有审计记录里有(活动表为了轻量刻意不冗余存)
|
||||
"display_name": a.display_name if a else None,
|
||||
"role": a.role if a else None,
|
||||
"login_count": (a.login_count or 0) if a else 0,
|
||||
"logout_count": (a.logout_count or 0) if a else 0,
|
||||
"op_count": (a.op_count or 0) if a else 0,
|
||||
"first_active_at": min(first_candidates) if first_candidates else None,
|
||||
"last_active_at": max(last_candidates) if last_candidates else None,
|
||||
})
|
||||
|
||||
# 与 SQL 里的排序保持一致:日期倒序 → 操作次数倒序
|
||||
items.sort(key=lambda x: (x["day"], x["op_count"]), reverse=True)
|
||||
return items
|
||||
149
backend/app/services/auth_service.py
Normal file
149
backend/app/services/auth_service.py
Normal file
@ -0,0 +1,149 @@
|
||||
"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)"""
|
||||
from fastapi import HTTPException, status, Depends, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
ALGORITHM,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
)
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.core.logging import user_var
|
||||
from app.schemas.user import LoginResponse, UserResponse
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def login(username: str, password: str) -> LoginResponse:
|
||||
"""登录 — 签发双 Token(Access + Refresh)"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表
|
||||
from sqlalchemy import text
|
||||
result = db.execute(
|
||||
text(
|
||||
"SELECT id, username, department, role, password_hash "
|
||||
"FROM sys_user "
|
||||
"WHERE username LIKE :pattern"
|
||||
),
|
||||
{"pattern": f"%/{username}"},
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
user_id, full_username, department, role, password_hash = row
|
||||
|
||||
# 2. Werkzeug scrypt 密码验证
|
||||
if not check_password_hash(password_hash, password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户名或密码错误",
|
||||
)
|
||||
|
||||
# 3. 解析 display_name("张三/zhangsan01" → "张三")
|
||||
display_name = full_username.split("/")[0] if "/" in full_username else full_username
|
||||
|
||||
token_data = {
|
||||
"sub": str(user_id),
|
||||
"role": role or "operator",
|
||||
"username": username,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
return LoginResponse(
|
||||
access_token=create_access_token(data=token_data),
|
||||
refresh_token=create_refresh_token(data=token_data),
|
||||
user=UserResponse(
|
||||
id=str(user_id),
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
role=role or "operator",
|
||||
),
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str) -> dict:
|
||||
"""
|
||||
使用 Refresh Token 换取新的 Access Token。
|
||||
校验:
|
||||
1. Token 签名是否有效
|
||||
2. Token type 是否为 "refresh"
|
||||
3. Token 是否未过期
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(refresh_token)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh Token 无效或已过期,请重新登录",
|
||||
)
|
||||
|
||||
# 校验 token 类型
|
||||
if payload.get("type") != TOKEN_TYPE_REFRESH:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的 Token 类型,仅接受 Refresh Token",
|
||||
)
|
||||
|
||||
# 提取用户信息,签发新的 Access Token
|
||||
access_token = create_access_token(
|
||||
data={
|
||||
"sub": payload.get("sub"),
|
||||
"role": payload.get("role", "operator"),
|
||||
"username": payload.get("username", ""),
|
||||
"display_name": payload.get("display_name", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> dict:
|
||||
"""从 Bearer Token 解析当前用户(仅接受 Access Token)"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
user_id = payload.get("sub")
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="无效的 Token")
|
||||
|
||||
# 校验:仅接受 access token
|
||||
if payload.get("type") == TOKEN_TYPE_REFRESH:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="请使用 Access Token 访问 API,Refresh Token 仅用于刷新",
|
||||
)
|
||||
|
||||
# 操作人身份要写两处,用途不同,缺一不可:
|
||||
# 1) contextvar —— 供本请求任务内的业务/service 日志使用;
|
||||
# 2) request.state —— 中间件在独立 task 中执行(Starlette 的
|
||||
# BaseHTTPMiddleware 用 anyio start_soon 起新 task,而 asyncio
|
||||
# 每个 Task 会复制 context),因此中间件读不到路由内改的
|
||||
# contextvar,只能通过 ASGI scope 承载的 state 拿到。
|
||||
# username 即 assignee_id 口径,比数字 id 直观得多。
|
||||
user_label = payload.get("username") or user_id
|
||||
user_var.set(user_label)
|
||||
request.state.audit_user = user_label
|
||||
request.state.audit_display_name = payload.get("display_name") or ""
|
||||
request.state.audit_role = payload.get("role") or ""
|
||||
|
||||
return payload
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=401, detail="无效的 Token")
|
||||
26
backend/app/services/counter_service.py
Normal file
26
backend/app/services/counter_service.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""16进制自增计数器 — 基于 PostgreSQL Sequence,生成 16 位 HEX 唯一 ID"""
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
SEQUENCE_NAME = "product_hex_counter"
|
||||
|
||||
|
||||
async def ensure_sequence(db: AsyncSession) -> None:
|
||||
"""确保 counter sequence 存在(幂等)"""
|
||||
await db.execute(
|
||||
text(f"CREATE SEQUENCE IF NOT EXISTS {SEQUENCE_NAME} START 1;")
|
||||
)
|
||||
|
||||
|
||||
async def next_hex_id(db: AsyncSession, length: int = 16) -> str:
|
||||
"""
|
||||
生成下一个 hex ID。
|
||||
|
||||
示例: 1 → "0000000000000001"
|
||||
15 → "000000000000000F"
|
||||
16 → "0000000000000010"
|
||||
255 → "00000000000000FF"
|
||||
"""
|
||||
result = await db.execute(text(f"SELECT nextval('{SEQUENCE_NAME}');"))
|
||||
counter: int = result.scalar_one()
|
||||
return format(counter, f"0{length}X")
|
||||
1714
backend/app/services/dashboard_service.py
Normal file
1714
backend/app/services/dashboard_service.py
Normal file
File diff suppressed because it is too large
Load Diff
208
backend/app/services/label_service.py
Normal file
208
backend/app/services/label_service.py
Normal file
@ -0,0 +1,208 @@
|
||||
"""标签打印服务 — 工业级精确定位标签 (480×360) + TSPL 发送"""
|
||||
import base64
|
||||
import socket
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import qrcode
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.services.print_config import PrintConfigManager
|
||||
|
||||
# ============================================================
|
||||
# 字体 — 项目内 simhei.ttf
|
||||
# ============================================================
|
||||
|
||||
_FONT_PATH = str(Path(__file__).resolve().parent.parent.parent / "simhei.ttf")
|
||||
|
||||
FONT_NORMAL = ImageFont.truetype(_FONT_PATH, 28) # 右侧:名/规/单
|
||||
FONT_LARGE = ImageFont.truetype(_FONT_PATH, 34) # 底部:序列号
|
||||
|
||||
# ============================================================
|
||||
# 画布 & 坐标常量
|
||||
# ============================================================
|
||||
|
||||
WIDTH, HEIGHT = 480, 360
|
||||
QR_X, QR_Y = 24, 60
|
||||
QR_SIZE = 180
|
||||
TEXT_X = 228
|
||||
TEXT_MAX_W = WIDTH - TEXT_X - 12 # ~240px
|
||||
BOTTOM_Y = 260 # 序列号 y
|
||||
LINE_H = 44 # 28px 行高
|
||||
|
||||
# ============================================================
|
||||
# QR 码
|
||||
# ============================================================
|
||||
|
||||
def _generate_qr(content: str) -> Image.Image:
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
return img.resize((QR_SIZE, QR_SIZE), Image.NEAREST)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 文字绘制 — 带描边 + 自动换行
|
||||
# ============================================================
|
||||
|
||||
def _draw(
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
x: int,
|
||||
y: int,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
max_width: int,
|
||||
stroke_width: int = 1,
|
||||
) -> int:
|
||||
"""
|
||||
绘制文字,超出 max_width 自动折行。
|
||||
返回下一行可用的 y 坐标。
|
||||
"""
|
||||
if not text:
|
||||
return y + LINE_H
|
||||
|
||||
# 单行不超宽 → 直接画(带描边)
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
draw.text((x, y), text, font=font, fill="black",
|
||||
stroke_width=stroke_width, stroke_fill="black")
|
||||
return y + LINE_H
|
||||
|
||||
# 逐字符折行
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for char in text:
|
||||
test = current + char
|
||||
w = draw.textbbox((0, 0), test, font=font)[2]
|
||||
if w <= max_width:
|
||||
current = test
|
||||
else:
|
||||
lines.append(current)
|
||||
current = char
|
||||
if current:
|
||||
lines.append(current)
|
||||
|
||||
cy = y
|
||||
for line in lines:
|
||||
draw.text((x, cy), line, font=font, fill="black",
|
||||
stroke_width=stroke_width, stroke_fill="black")
|
||||
cy += LINE_H
|
||||
return cy
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 标签绑制 — 工业级精确坐标
|
||||
# ============================================================
|
||||
|
||||
def _create_label(data: dict) -> Image.Image:
|
||||
"""
|
||||
480×360 工业级排版:
|
||||
|
||||
┌───────────────┬──────────────────────────────┐
|
||||
│ │ 名: 样品升降台V1J y=60 │ ← 28px sw=1
|
||||
│ [QR Code] │ 规: PH-B4V1J/类A y=104 │
|
||||
│ 180×180 │ 单: ORD-2024-001 y=148 │
|
||||
│ (24, 60) │ │
|
||||
│ │ │
|
||||
│ 码: 0000000000000001 y=260 │ ← 34px sw=2
|
||||
└───────────────┴──────────────────────────────┘
|
||||
"""
|
||||
img = Image.new("RGB", (WIDTH, HEIGHT), color="white")
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
serial = data.get("serial_number", "")
|
||||
|
||||
# ── QR 码 (24, 60) ──
|
||||
if serial:
|
||||
qr_img = _generate_qr(serial)
|
||||
img.paste(qr_img, (QR_X, QR_Y))
|
||||
|
||||
# ── 右侧文字 x=228 — 28px, stroke_width=1 ──
|
||||
y = 60
|
||||
|
||||
name = data.get("material_name", "") or "未命名"
|
||||
y = _draw(draw, f"名: {name}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1)
|
||||
|
||||
spec = data.get("spec_model", "") or "-"
|
||||
y = _draw(draw, f"规: {spec}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1)
|
||||
|
||||
order_no = data.get("order_no", "")
|
||||
if order_no and str(order_no).strip():
|
||||
y = _draw(draw, f"单: {str(order_no).strip()}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1)
|
||||
|
||||
# ── 底部通栏 (24, 260) — 34px, stroke_width=2 ──
|
||||
code = serial or "-"
|
||||
draw.text((24, BOTTOM_Y), f"码: {code}", font=FONT_LARGE, fill="black",
|
||||
stroke_width=2, stroke_fill="black")
|
||||
|
||||
return img
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API
|
||||
# ============================================================
|
||||
|
||||
def generate_preview_image(**data) -> str:
|
||||
"""返回 Base64 JPEG data URL"""
|
||||
img = _create_label(data)
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="JPEG", quality=92)
|
||||
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||
return f"data:image/jpeg;base64,{b64}"
|
||||
|
||||
|
||||
def send_to_printer(
|
||||
copies: int = 1,
|
||||
printer_ip: Optional[str] = None,
|
||||
printer_port: Optional[int] = None,
|
||||
**data,
|
||||
) -> dict:
|
||||
"""二值化 → TSPL → Socket 发送"""
|
||||
printer = PrintConfigManager.get_printer("label_printer")
|
||||
ip = printer_ip or printer.get("ip", "192.168.9.221")
|
||||
port = printer_port or printer.get("port", 9100)
|
||||
|
||||
img_rgb = _create_label(data)
|
||||
img_gray = img_rgb.convert("L")
|
||||
img_bw = img_gray.point(lambda px: 0 if px < 128 else 255, "1")
|
||||
|
||||
width_bytes = (img_bw.width + 7) // 8
|
||||
height_dots = img_bw.height
|
||||
|
||||
tspl = (
|
||||
"SIZE 40 mm, 30 mm\r\n"
|
||||
"GAP 2 mm, 0 mm\r\n"
|
||||
"CLS\r\n"
|
||||
"DIRECTION 1\r\n"
|
||||
).encode("gbk", errors="replace")
|
||||
|
||||
bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode("gbk", errors="replace")
|
||||
bitmap_data = img_bw.tobytes()
|
||||
footer = f"\r\nPRINT 1,{copies}\r\n".encode("gbk", errors="replace")
|
||||
|
||||
payload = tspl + bitmap_cmd + bitmap_data + footer
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.settimeout(5)
|
||||
try:
|
||||
s.connect((ip, port))
|
||||
s.sendall(payload)
|
||||
s.close()
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"打印指令已发送 → {ip}:{port},份数: {copies}",
|
||||
"printer": f"{ip}:{port}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"打印机连接失败 ({ip}:{port}): {str(e)}",
|
||||
"printer": f"{ip}:{port}",
|
||||
}
|
||||
154
backend/app/services/mom_cache.py
Normal file
154
backend/app/services/mom_cache.py
Normal file
@ -0,0 +1,154 @@
|
||||
"""
|
||||
MOM 跨库查询缓存模块 — 使用本地 TTL 缓存消除冗余跨库请求
|
||||
|
||||
解决的问题:
|
||||
1. _lookup_display_names 在 get_all_products 中被调用 3 次,每次都打开/关闭
|
||||
MOM 数据库连接,150 条产品的列表页 = 3 根管线查询。
|
||||
2. 同一批 username 在短时间内(用户翻页、多人同时访问)被反复查询。
|
||||
3. 旧实现用 OR 拼接 LIKE 条件,存在注入风险。
|
||||
|
||||
方案:python -m 内置模块(零依赖)实现线程安全 TTL 缓存 + 参数化 ANY 查询。
|
||||
|
||||
TTL: 2 小时(人员姓名不会频繁变动,可调)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 零依赖 TTL 缓存(线程安全)
|
||||
# ============================================================
|
||||
|
||||
class _TTLCache:
|
||||
"""线程安全的内存 TTL 缓存,用于 MOM 只读查询结果"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 7200) -> None:
|
||||
self._store: dict[str, str] = {}
|
||||
self._expiry: dict[str, float] = {}
|
||||
self._ttl = ttl_seconds
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def get_many(self, keys: list[str]) -> tuple[dict[str, str], list[str]]:
|
||||
"""
|
||||
批量获取 → (命中字典, 未命中 key 列表)。
|
||||
|
||||
内部自动清理过期条目。
|
||||
"""
|
||||
hits: dict[str, str] = {}
|
||||
missed: list[str] = []
|
||||
now = time.monotonic()
|
||||
|
||||
with self._lock:
|
||||
for k in keys:
|
||||
exp = self._expiry.get(k)
|
||||
if exp is not None and now < exp:
|
||||
hits[k] = self._store[k]
|
||||
else:
|
||||
missed.append(k)
|
||||
# 清理过期残留
|
||||
if k in self._store:
|
||||
del self._store[k]
|
||||
del self._expiry[k]
|
||||
|
||||
return hits, missed
|
||||
|
||||
def set_many(self, mapping: dict[str, str]) -> None:
|
||||
"""批量写入,所有 key 共享同一过期时间"""
|
||||
expiry = time.monotonic() + self._ttl
|
||||
with self._lock:
|
||||
for k, v in mapping.items():
|
||||
self._store[k] = v
|
||||
self._expiry[k] = expiry
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 全局缓存实例(2h TTL)
|
||||
# ============================================================
|
||||
|
||||
_user_name_cache = _TTLCache(ttl_seconds=7200)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API
|
||||
# ============================================================
|
||||
|
||||
def get_display_names(user_ids: list[str]) -> dict[str, str]:
|
||||
"""
|
||||
批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)。
|
||||
|
||||
缓存穿透流程:
|
||||
1. 去重 → 从缓存批量读取
|
||||
2. 计算 miss 差集
|
||||
3. miss 非空时,用参数化 ANY(:user_ids) 查 MOM(1 条 SQL)
|
||||
4. 写回缓存
|
||||
5. 合并 hits + fresh 返回
|
||||
|
||||
参数:
|
||||
user_ids: 短用户名列表,如 ["zhangsan01", "lisi02"]
|
||||
|
||||
返回:
|
||||
{"zhangsan01": "张三", "lisi02": "李四"}
|
||||
不存在的 key 不会出现在返回字典中。
|
||||
|
||||
SQL 安全:
|
||||
使用 SPLIT_PART(username, '/', 2) = ANY(:user_ids) 参数化查询,
|
||||
杜绝旧实现中 OR 拼接 LIKE 的注入风险。
|
||||
"""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
# 过滤特殊值 + 去重保序
|
||||
seen: set[str] = set()
|
||||
real_ids: list[str] = []
|
||||
for uid in user_ids:
|
||||
if uid and uid != "virtual_warehouse" and uid not in seen:
|
||||
seen.add(uid)
|
||||
real_ids.append(uid)
|
||||
|
||||
if not real_ids:
|
||||
return {}
|
||||
|
||||
# ── Step 1: 批量查缓存 ──
|
||||
hits, missed = _user_name_cache.get_many(real_ids)
|
||||
|
||||
# ── Step 2: 仅对 miss 查 MOM ──
|
||||
if missed:
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
|
||||
# 参数化 ANY 查询 — 安全防注入
|
||||
# SPLIT_PART('张三/zhangsan01', '/', 2) = 'zhangsan01'
|
||||
# OR username = ANY(...) 兜底无斜杠的用户名(如 admin)
|
||||
sql = text("""
|
||||
SELECT username,
|
||||
SPLIT_PART(username, '/', 1) AS display_name
|
||||
FROM sys_user
|
||||
WHERE SPLIT_PART(username, '/', 2) = ANY(:user_ids)
|
||||
OR username = ANY(:user_ids)
|
||||
""")
|
||||
result = db.execute(sql, {"user_ids": missed})
|
||||
rows = result.fetchall()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# ── Step 3: 解析结果 + 写回缓存 ──
|
||||
fresh: dict[str, str] = {}
|
||||
for row in rows:
|
||||
full_username: str = row[0]
|
||||
display_name: str = row[1]
|
||||
# "张三/zhangsan01" → short="zhangsan01"
|
||||
short = full_username.split("/")[-1] if "/" in full_username else full_username
|
||||
fresh[short] = display_name
|
||||
|
||||
if fresh:
|
||||
_user_name_cache.set_many(fresh)
|
||||
|
||||
# ── Step 4: 合并 ──
|
||||
hits.update(fresh)
|
||||
|
||||
return hits
|
||||
44
backend/app/services/print_config.py
Normal file
44
backend/app/services/print_config.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""打印机配置管理 — JSON 文件持久化"""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "data"
|
||||
CONFIG_FILE = CONFIG_DIR / "printer_config.json"
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"label_printer": {
|
||||
"ip": "192.168.9.221",
|
||||
"port": 9100,
|
||||
"enabled": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class PrintConfigManager:
|
||||
"""打印机 IP/端口 配置读写"""
|
||||
|
||||
@staticmethod
|
||||
def _ensure_file() -> None:
|
||||
if not CONFIG_DIR.exists():
|
||||
CONFIG_DIR.mkdir(parents=True)
|
||||
if not CONFIG_FILE.exists():
|
||||
PrintConfigManager.save_config(DEFAULT_CONFIG)
|
||||
|
||||
@staticmethod
|
||||
def get_config() -> dict:
|
||||
PrintConfigManager._ensure_file()
|
||||
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
@staticmethod
|
||||
def save_config(config: dict) -> None:
|
||||
if not CONFIG_DIR.exists():
|
||||
CONFIG_DIR.mkdir(parents=True)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
|
||||
@staticmethod
|
||||
def get_printer(name: str = "label_printer") -> dict:
|
||||
config = PrintConfigManager.get_config()
|
||||
return config.get(name, DEFAULT_CONFIG.get("label_printer", {}))
|
||||
146
backend/app/services/product_finalize_service.py
Normal file
146
backend/app/services/product_finalize_service.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""产品收口服务 — 管理员一键把产品修正为「已入库」或「已出库」(可反向互切纠错)。
|
||||
|
||||
落库语义与 MOM 仓储回调(webhooks.mom_inbound / mom_outbound)保持一致:
|
||||
1) 改 product.overall_status + product.status(映射为 ARCHIVED / OUTBOUND);
|
||||
2) 写一条 warehouse_inbound / warehouse_outbound 的 task_log;
|
||||
3) 幂等追加「扫码入库 / 扫码出库」WAREHOUSE 主线节点 + TaskRecord。
|
||||
|
||||
与 update_overall_status(仅改状态、面向主线负责人)不同:本服务面向管理员做收口,
|
||||
会补全流转树收尾节点,使任务全景/详情/矩阵能正确显示入库/出库收口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.services.product_service import get_product_by_serial
|
||||
|
||||
# target -> (product.status 映射码, 追加节点名, task_log.action_type)
|
||||
_FINALIZE_MAP = {
|
||||
"已入库": ("ARCHIVED", "扫码入库", "warehouse_inbound"),
|
||||
"已出库": ("OUTBOUND", "扫码出库", "warehouse_outbound"),
|
||||
}
|
||||
|
||||
|
||||
async def _append_warehouse_node(db: AsyncSession, product: Product, task_name: str) -> None:
|
||||
"""在流转树主干末尾幂等追加仓储收口主线节点 + TaskRecord(对齐 webhooks._append_warehouse_task)"""
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Task.id).where(
|
||||
Task.product_id == product.id,
|
||||
Task.task_name == task_name,
|
||||
).limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
return
|
||||
|
||||
last_main = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
(Task.parent_task_id.is_(None))
|
||||
| (Task.task_type.in_(["TRANSFER", "RECOVERY"])),
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
node = Task(
|
||||
product_id=product.id,
|
||||
parent_task_id=last_main.id if last_main else None,
|
||||
task_name=task_name,
|
||||
assignee_id=None, # ★ 不能填非 UUID,否则前端头像解析报错导致节点跳过渲染
|
||||
status="COMPLETED",
|
||||
task_type="WAREHOUSE",
|
||||
completed_at=get_beijing_time(),
|
||||
remark=f"管理员收口:追加{task_name}收尾节点",
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
db.add(TaskRecord(task_id=node.id, remark=f"管理员收口:追加{task_name}收尾节点"))
|
||||
|
||||
|
||||
async def finalize_product_status(
|
||||
db: AsyncSession,
|
||||
serial_number: str,
|
||||
target: str,
|
||||
current_user: dict | None,
|
||||
note: str | None = None,
|
||||
):
|
||||
"""把产品整体收口到 target(已入库 / 已出库)。已处于目标态则幂等直接返回。"""
|
||||
target = (target or "").strip()
|
||||
if target not in _FINALIZE_MAP:
|
||||
raise HTTPException(status_code=400, detail="收口目标状态仅支持:已入库 / 已出库")
|
||||
|
||||
product = (
|
||||
await db.execute(select(Product).where(Product.serial_number == serial_number))
|
||||
).scalar_one_or_none()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail=f"未找到序列号为 {serial_number} 的产品")
|
||||
|
||||
# 幂等:已在目标状态则不动(避免重复写日志/节点)
|
||||
if product.overall_status == target:
|
||||
return await get_product_by_serial(db, serial_number)
|
||||
|
||||
# 管理员强收口:仍有在产/待接收任务的,一并结束为完工收口,避免宏观口径分裂
|
||||
res = await db.execute(
|
||||
update(Task)
|
||||
.where(Task.product_id == product.id, Task.status.in_(["WIP", "PENDING"]))
|
||||
.values(
|
||||
status="COMPLETED",
|
||||
received_at=get_beijing_time(),
|
||||
completed_at=get_beijing_time(),
|
||||
)
|
||||
)
|
||||
ended_active = res.rowcount or 0
|
||||
|
||||
status_code, node_name, action_type = _FINALIZE_MAP[target]
|
||||
product.overall_status = target
|
||||
product.status = status_code
|
||||
product.current_location_id = "virtual_warehouse"
|
||||
|
||||
# task_log 绑目标任务:优先「在库」任务,其次该产品最新任务(对齐 webhook)
|
||||
log_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if log_task is None:
|
||||
log_task = (
|
||||
await db.execute(
|
||||
select(Task).where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
operator = (current_user or {}).get("username") or "virtual_warehouse"
|
||||
remark = f"管理员[{operator}]收口为{target}"
|
||||
if ended_active:
|
||||
remark += f";一并结束{ended_active}个进行中/待接收任务"
|
||||
if note and note.strip():
|
||||
remark += f";备注:{note.strip()}"
|
||||
if log_task is not None:
|
||||
db.add(
|
||||
TaskLog(
|
||||
task_id=log_task.id,
|
||||
operator_id=str(operator)[:64],
|
||||
action_type=action_type,
|
||||
remark=remark,
|
||||
)
|
||||
)
|
||||
|
||||
await _append_warehouse_node(db, product, node_name)
|
||||
await db.commit()
|
||||
return await get_product_by_serial(db, serial_number)
|
||||
933
backend/app/services/product_service.py
Normal file
933
backend/app/services/product_service.py
Normal file
@ -0,0 +1,933 @@
|
||||
"""产品服务 — 业务逻辑层:扫码查询、CRUD"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select, or_, cast, String, delete, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.lifecycle import (
|
||||
ALL_OVERALL_STEPS,
|
||||
allowed_steps,
|
||||
is_step_allowed,
|
||||
phase_label,
|
||||
resolve_phase_for_step,
|
||||
sync_product_status,
|
||||
)
|
||||
from app.models.product import Product
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.models.task import Task
|
||||
from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse
|
||||
from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse
|
||||
|
||||
|
||||
def _task_to_response(task: Task) -> TaskResponse:
|
||||
"""将 Task ORM 对象递归转为 TaskResponse(含子任务树)"""
|
||||
product_sn = ""
|
||||
product_material = ""
|
||||
try:
|
||||
if task.product:
|
||||
product_sn = task.product.serial_number or ""
|
||||
product_material = (task.product.material_name or task.product.material_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
return TaskResponse(
|
||||
id=task.id,
|
||||
product_id=task.product_id,
|
||||
product_sn=product_sn,
|
||||
product_material=product_material,
|
||||
parent_task_id=task.parent_task_id,
|
||||
task_name=task.task_name,
|
||||
assignee_id=task.assignee_id,
|
||||
status=task.status,
|
||||
notify_parent_on_complete=task.notify_parent_on_complete,
|
||||
is_rework=task.is_rework,
|
||||
task_type=task.task_type,
|
||||
remark=task.remark,
|
||||
reject_reason=task.reject_reason,
|
||||
received_at=task.received_at,
|
||||
completed_at=task.completed_at,
|
||||
created_at=task.created_at,
|
||||
child_tasks=[_task_to_response(c) for c in task.child_tasks],
|
||||
records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])],
|
||||
created_by=getattr(task, "created_by", None),
|
||||
)
|
||||
|
||||
|
||||
async def _load_task_tree(db: AsyncSession, product_id: uuid.UUID) -> list[TaskResponse]:
|
||||
"""使用 PostgreSQL Recursive CTE 一次性加载产品下完整任务树(消除 N+1)"""
|
||||
from app.services.task_tree_loader import load_task_trees_by_product
|
||||
tasks = await load_task_trees_by_product(db, product_id)
|
||||
return [_task_to_response(t) for t in tasks]
|
||||
|
||||
|
||||
async def get_product_by_serial(db: AsyncSession, serial_number: str) -> ProductScanResponse:
|
||||
"""扫码查询:根据 16 位序列号查出产品 + 所属订单 + 完整任务树"""
|
||||
result = await db.execute(
|
||||
select(Product)
|
||||
.options(
|
||||
selectinload(Product.order),
|
||||
selectinload(Product.parent_product),
|
||||
)
|
||||
.where(Product.serial_number == serial_number)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"未找到序列号为 {serial_number} 的产品",
|
||||
)
|
||||
|
||||
# 获取顶层任务摘要(兼容旧接口)
|
||||
top_tasks_result = await db.execute(
|
||||
select(Task)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
Task.parent_task_id.is_(None),
|
||||
)
|
||||
.order_by(Task.created_at)
|
||||
)
|
||||
top_tasks = top_tasks_result.scalars().all()
|
||||
|
||||
# 获取完整任务树(递归嵌套,供前端渲染十字矩阵树状图)
|
||||
task_tree = await _load_task_tree(db, product.id)
|
||||
|
||||
# 🔧 收集任务树中所有 assignee_id → 查中文姓名映射
|
||||
assignee_ids: set[str] = set()
|
||||
all_task_ids: list[uuid.UUID] = []
|
||||
def _collect_ids(tasks):
|
||||
for t in tasks:
|
||||
all_task_ids.append(t.id)
|
||||
if t.assignee_id: assignee_ids.add(t.assignee_id)
|
||||
if t.child_tasks: _collect_ids(t.child_tasks)
|
||||
for t in top_tasks:
|
||||
all_task_ids.append(t.id)
|
||||
if t.assignee_id: assignee_ids.add(t.assignee_id)
|
||||
_collect_ids(task_tree)
|
||||
|
||||
# 🔧 批量查 task_logs: 谁创建了每个任务
|
||||
creator_map: dict[uuid.UUID, str] = {}
|
||||
if all_task_ids:
|
||||
from app.models.task_log import TaskLog
|
||||
from sqlalchemy import func
|
||||
# 每个 task 取最早的 create 日志的 operator_id
|
||||
sub = (
|
||||
select(
|
||||
TaskLog.task_id,
|
||||
TaskLog.operator_id,
|
||||
func.row_number().over(
|
||||
partition_by=TaskLog.task_id,
|
||||
order_by=TaskLog.created_at.asc()
|
||||
).label("rn")
|
||||
)
|
||||
.where(
|
||||
TaskLog.task_id.in_(all_task_ids),
|
||||
TaskLog.action_type == "create"
|
||||
)
|
||||
).subquery()
|
||||
log_result = await db.execute(
|
||||
select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1)
|
||||
)
|
||||
for row in log_result:
|
||||
if row[1]:
|
||||
creator_map[row[0]] = row[1]
|
||||
|
||||
# 注入 created_by 到 task_tree 和 top_tasks(并收集 created_by 到中文名映射)
|
||||
def _inject_creator(tasks):
|
||||
for t in tasks:
|
||||
t.created_by = creator_map.get(t.id)
|
||||
if t.created_by: assignee_ids.add(t.created_by)
|
||||
if t.child_tasks:
|
||||
_inject_creator(t.child_tasks)
|
||||
_inject_creator(task_tree)
|
||||
for t in top_tasks:
|
||||
t.created_by = creator_map.get(t.id)
|
||||
if t.created_by: assignee_ids.add(t.created_by)
|
||||
|
||||
# 🔧 兜底:在库/入库任务无创建日志时,用该产品在库前最近一道主工序的负责人作为「转入人」
|
||||
all_mains: list = []
|
||||
def _collect_main(tasks):
|
||||
for t in tasks:
|
||||
if not t.parent_task_id or t.task_type in ("TRANSFER", "RECOVERY", "WAREHOUSE"):
|
||||
all_mains.append(t)
|
||||
if t.child_tasks:
|
||||
_collect_main(t.child_tasks)
|
||||
_collect_main(task_tree)
|
||||
all_mains.sort(key=lambda t: t.created_at)
|
||||
for t in all_mains:
|
||||
is_w = t.task_name and ("在库" in t.task_name or "入库" in t.task_name)
|
||||
if is_w and not t.created_by:
|
||||
prev = [m for m in all_mains if m.created_at < t.created_at and m.assignee_id]
|
||||
if prev:
|
||||
t.created_by = prev[-1].assignee_id
|
||||
assignee_ids.add(t.created_by)
|
||||
|
||||
# 🔧 在库设备若无「在库」任务,追加虚拟节点(区分"待收货"与"已实收")
|
||||
def _has_warehouse_task(tasks):
|
||||
for t in tasks:
|
||||
if t.task_name and ("在库" in t.task_name or "入库" in t.task_name):
|
||||
return True
|
||||
if t.child_tasks and _has_warehouse_task(t.child_tasks):
|
||||
return True
|
||||
return False
|
||||
has_warehouse_task = _has_warehouse_task(task_tree)
|
||||
if product.current_location_id == "virtual_warehouse" and not has_warehouse_task and all_mains:
|
||||
from app.schemas.task import TaskResponse
|
||||
from app.models.task_log import TaskLog as _TL
|
||||
# 🔧 反查 webhook 入库接收日志:判断 MOM 是否已扫码实收
|
||||
inbound_log = (
|
||||
await db.execute(
|
||||
select(_TL)
|
||||
.join(Task, _TL.task_id == Task.id)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
_TL.action_type == "warehouse_inbound",
|
||||
)
|
||||
.order_by(_TL.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
last_main = all_mains[-1]
|
||||
|
||||
# 🔧 最后操作"转入库"的人 = 该产品最后一次 complete 日志的操作人
|
||||
# (完工转交入库会记一条 complete 日志,operator 为发起转入库操作的人,
|
||||
# 可能是最后工序负责人本人,也可能是代操作的主管)
|
||||
transfer_log = (
|
||||
await db.execute(
|
||||
select(_TL)
|
||||
.join(Task, _TL.task_id == Task.id)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
_TL.action_type == "complete",
|
||||
)
|
||||
.order_by(_TL.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
last_transfer_operator = transfer_log.operator_id if transfer_log else None
|
||||
|
||||
# 🔧 反查出库日志:产品是否已被 MOM 发货出库
|
||||
outbound_log = (
|
||||
await db.execute(
|
||||
select(_TL)
|
||||
.join(Task, _TL.task_id == Task.id)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
_TL.action_type == "warehouse_outbound",
|
||||
)
|
||||
.order_by(_TL.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if product.overall_status == "已出库":
|
||||
# 情况 C:MOM 已发货出库 → 虚拟节点反映"已出库",负责人为出库操作人
|
||||
node_name = "已出库"
|
||||
node_status = "OUTBOUND"
|
||||
node_assignee = (outbound_log.operator_id if outbound_log else None) or last_transfer_operator or last_main.assignee_id
|
||||
node_created_by = last_transfer_operator or last_main.assignee_id
|
||||
elif inbound_log is not None:
|
||||
# 情况 B:MOM 已扫码实收 → "已入库",负责人为仓库接收人
|
||||
node_name = "已入库"
|
||||
node_status = "ARCHIVED"
|
||||
node_assignee = inbound_log.operator_id or "仓库"
|
||||
# "转入在库"始终显示操作转入库的人(车间),而不是 MOM 接收人
|
||||
node_created_by = last_transfer_operator or last_main.assignee_id
|
||||
else:
|
||||
# 情况 A:MOM 还没扫码 → "已完成"(车间完工待实收),负责人为占位"待仓库扫码"
|
||||
node_name = "已完成"
|
||||
node_status = "COMPLETED"
|
||||
node_assignee = "待仓库扫码"
|
||||
node_created_by = last_transfer_operator or last_main.assignee_id
|
||||
|
||||
virtual = TaskResponse(
|
||||
id=uuid.uuid4(),
|
||||
product_id=product.id,
|
||||
product_sn=product.serial_number,
|
||||
product_material=product.material_name or product.material_id or "",
|
||||
parent_task_id=None,
|
||||
task_name=node_name,
|
||||
assignee_id=node_assignee,
|
||||
status=node_status,
|
||||
notify_parent_on_complete=False,
|
||||
is_rework=False,
|
||||
task_type=None,
|
||||
remark=None,
|
||||
reject_reason=None,
|
||||
received_at=last_main.received_at or last_main.created_at,
|
||||
completed_at=last_main.completed_at,
|
||||
created_at=last_main.created_at,
|
||||
child_tasks=[],
|
||||
records=[],
|
||||
created_by=node_created_by,
|
||||
)
|
||||
task_tree.append(virtual)
|
||||
# 真实 username 负责人(含仓库接收人)加入中文名映射;占位符无需映射
|
||||
if virtual.assignee_id and virtual.assignee_id not in ("待仓库扫码", "virtual_warehouse"):
|
||||
assignee_ids.add(virtual.assignee_id)
|
||||
if virtual.created_by:
|
||||
assignee_ids.add(virtual.created_by)
|
||||
|
||||
# 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等)
|
||||
assignee_names = _lookup_display_names(list(assignee_ids))
|
||||
|
||||
return ProductScanResponse(
|
||||
id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
external_serial=product.external_serial,
|
||||
order_id=product.order_id,
|
||||
order_no=product.order.order_no if product.order else "",
|
||||
material_id=product.material_id,
|
||||
material_name=product.material_name,
|
||||
spec_model=product.spec_model,
|
||||
category=product.category,
|
||||
material_type=product.material_type,
|
||||
parent_product_id=product.parent_product_id,
|
||||
current_location_id=product.current_location_id,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
created_at=product.created_at,
|
||||
top_level_tasks=[
|
||||
TaskSummaryResponse.model_validate(t) for t in top_tasks
|
||||
],
|
||||
task_tree=task_tree,
|
||||
assignee_names=assignee_names, # 🔧 username→中文姓名
|
||||
)
|
||||
|
||||
|
||||
async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product:
|
||||
"""获取产品,不存在则 404"""
|
||||
result = await db.execute(
|
||||
select(Product)
|
||||
.options(selectinload(Product.order))
|
||||
.where(Product.id == product_id)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"产品不存在: {product_id}",
|
||||
)
|
||||
return product
|
||||
|
||||
|
||||
async def create_product(db: AsyncSession, data: ProductCreate, creator_username: str = "") -> ProductResponse:
|
||||
"""创建产品 — 自动生成 16 位 HEX 序列号,初始位置设为创建者"""
|
||||
from app.services.counter_service import ensure_sequence, next_hex_id
|
||||
from app.models.production_order import ProductionOrder
|
||||
|
||||
await ensure_sequence(db)
|
||||
hex_id = await next_hex_id(db)
|
||||
|
||||
# 处理订单: 如果传了 order_no 但没传 order_id,查找或创建
|
||||
order_id = data.order_id
|
||||
if not order_id and data.order_no:
|
||||
result = await db.execute(
|
||||
select(ProductionOrder).where(ProductionOrder.order_no == data.order_no.strip())
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
order_id = existing.id
|
||||
else:
|
||||
new_order = ProductionOrder(order_no=data.order_no.strip())
|
||||
db.add(new_order)
|
||||
await db.flush()
|
||||
order_id = new_order.id
|
||||
|
||||
product = Product(
|
||||
serial_number=hex_id,
|
||||
order_id=order_id,
|
||||
material_id=data.material_id,
|
||||
material_name=data.material_name or None,
|
||||
spec_model=data.spec_model or None,
|
||||
category=data.category or None,
|
||||
material_type=data.material_type or None,
|
||||
external_serial=data.external_serial,
|
||||
parent_product_id=data.parent_product_id,
|
||||
current_location_id=creator_username or None, # 谁创建,初始位置就是谁
|
||||
)
|
||||
db.add(product)
|
||||
await db.commit()
|
||||
await db.refresh(product, ["order"])
|
||||
|
||||
# 查创建者的真实姓名
|
||||
creator_display_name = ""
|
||||
if creator_username:
|
||||
name_map = _lookup_display_names([creator_username])
|
||||
creator_display_name = name_map.get(creator_username, "")
|
||||
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
external_serial=product.external_serial,
|
||||
order_id=product.order_id,
|
||||
order_no=product.order.order_no if product.order else (data.order_no or ""),
|
||||
material_id=product.material_id,
|
||||
material_name=product.material_name,
|
||||
spec_model=product.spec_model,
|
||||
category=product.category,
|
||||
material_type=product.material_type,
|
||||
parent_product_id=product.parent_product_id,
|
||||
current_location_id=product.current_location_id,
|
||||
current_location_name=creator_display_name or None,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductUpdate) -> ProductResponse:
|
||||
"""更新产品"""
|
||||
from app.models.production_order import ProductionOrder
|
||||
|
||||
product = await get_product(db, product_id)
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# 处理 order_no → order_id 映射
|
||||
if "order_no" in update_data:
|
||||
order_no_val = update_data.pop("order_no")
|
||||
if order_no_val and order_no_val.strip():
|
||||
result = await db.execute(
|
||||
select(ProductionOrder).where(ProductionOrder.order_no == order_no_val.strip())
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
product.order_id = existing.id
|
||||
else:
|
||||
new_order = ProductionOrder(order_no=order_no_val.strip())
|
||||
db.add(new_order)
|
||||
await db.flush()
|
||||
product.order_id = new_order.id
|
||||
else:
|
||||
product.order_id = None
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(product, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(product, ["order"])
|
||||
return ProductResponse(
|
||||
id=product.id,
|
||||
serial_number=product.serial_number,
|
||||
external_serial=product.external_serial,
|
||||
order_id=product.order_id,
|
||||
order_no=product.order.order_no if product.order else "",
|
||||
material_id=product.material_id,
|
||||
material_name=product.material_name,
|
||||
spec_model=product.spec_model,
|
||||
category=product.category,
|
||||
material_type=product.material_type,
|
||||
parent_product_id=product.parent_product_id,
|
||||
current_location_id=product.current_location_id,
|
||||
overall_status=product.overall_status,
|
||||
status=product.status,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
created_at=product.created_at,
|
||||
)
|
||||
|
||||
|
||||
# 全部合法宏观状态(两阶段并集)— 仅作"完全非法取值"的第一道粗筛;
|
||||
# 阶段内的细分校验(售后回流设备禁止改回「备货 / 生产」)见下方 is_step_allowed
|
||||
VALID_OVERALL_STATUS = ALL_OVERALL_STEPS
|
||||
|
||||
|
||||
async def update_overall_status(
|
||||
db: AsyncSession, serial_number: str, status_value: str,
|
||||
current_user: dict | None = None,
|
||||
) -> ProductScanResponse:
|
||||
"""更新产品宏观状态
|
||||
|
||||
权限校验:
|
||||
- SUPER_ADMIN 角色:直接放行
|
||||
- 当前操作该产品主线任务(WIP/PENDING 状态主干任务)的人:放行
|
||||
- 其他:403
|
||||
"""
|
||||
if status_value not in VALID_OVERALL_STATUS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无效状态: {status_value},合法值: {'、'.join(sorted(ALL_OVERALL_STEPS))}",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(Product)
|
||||
.options(selectinload(Product.order))
|
||||
.where(Product.serial_number == serial_number)
|
||||
)
|
||||
product = result.scalar_one_or_none()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail=f"未找到序列号 {serial_number} 的产品")
|
||||
|
||||
# ── 权限校验(无 current_user 一律拒绝,杜绝空 dict 绕过)──
|
||||
if not current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="请先登录",
|
||||
)
|
||||
|
||||
user_role = current_user.get("role", "")
|
||||
user_username = current_user.get("username", "")
|
||||
|
||||
# SUPER_ADMIN 直接放行
|
||||
if user_role != "SUPER_ADMIN":
|
||||
# 检查当前用户是否是该产品主线任务的负责人
|
||||
main_task_result = await db.execute(
|
||||
select(Task).where(
|
||||
Task.product_id == product.id,
|
||||
Task.status.in_(["WIP", "PENDING"]),
|
||||
or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
|
||||
),
|
||||
).order_by(Task.created_at.desc()).limit(1)
|
||||
)
|
||||
main_task = main_task_result.scalar_one_or_none()
|
||||
has_permission = (
|
||||
main_task is not None
|
||||
and main_task.assignee_id == user_username
|
||||
)
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态",
|
||||
)
|
||||
|
||||
# ── 选项隔离:阶段感知校验 ──
|
||||
# 售后回流设备禁止被改回「备货 / 生产」;选定售后专属工序(发货测试 / 售后维修)
|
||||
# 则设备随即进入售后生命周期(无历史记录的老设备由此进入售后阶段)。
|
||||
phase = resolve_phase_for_step(product.lifecycle_phase, status_value)
|
||||
if not is_step_allowed(phase, status_value):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=(
|
||||
f"产品当前处于{phase_label(product.lifecycle_phase)},不允许改为「{status_value}」。"
|
||||
f"该阶段可选:{'、'.join(allowed_steps(product.lifecycle_phase))}"
|
||||
),
|
||||
)
|
||||
product.lifecycle_phase = phase
|
||||
product.overall_status = status_value
|
||||
# 同步 status 字段(映射表已上提到 app.core.lifecycle 单一来源,
|
||||
# 供 task_service 的各写入点共用,避免各处各写一份)
|
||||
sync_product_status(product)
|
||||
await db.commit()
|
||||
await db.refresh(product)
|
||||
|
||||
return await get_product_by_serial(db, serial_number)
|
||||
|
||||
|
||||
def _lookup_display_names(location_ids: list[str]) -> dict[str, str]:
|
||||
"""批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)"""
|
||||
from app.services.mom_cache import get_display_names
|
||||
return get_display_names(location_ids)
|
||||
|
||||
|
||||
async def get_all_products(
|
||||
db: AsyncSession,
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
keyword: str | None = None,
|
||||
status_filter: str | None = None,
|
||||
) -> list[ProductResponse]:
|
||||
"""
|
||||
获取产品列表 — 支持多维 keyword 搜索 + 状态筛选
|
||||
|
||||
keyword: 同时模糊匹配 serial_number (产品身份证)、material_name/id (规格型号)、order_no (订单号)
|
||||
status_filter: 按产品状态过滤 (如 PENDING / WIP / COMPLETED / ARCHIVED)
|
||||
"""
|
||||
stmt = select(Product).options(selectinload(Product.order))
|
||||
|
||||
# keyword 多字段 OR 模糊搜索
|
||||
if keyword and keyword.strip():
|
||||
kw = f"%{keyword.strip()}%"
|
||||
stmt = stmt.outerjoin(ProductionOrder, Product.order_id == ProductionOrder.id).where(
|
||||
or_(
|
||||
Product.serial_number.ilike(kw),
|
||||
Product.external_serial.ilike(kw), # 业务序列号(用户自定义)
|
||||
Product.material_name.ilike(kw),
|
||||
cast(Product.material_id, String).ilike(kw),
|
||||
Product.spec_model.ilike(kw),
|
||||
ProductionOrder.order_no.ilike(kw),
|
||||
)
|
||||
).distinct()
|
||||
|
||||
# 状态筛选 — 大小写不敏感
|
||||
# 口径与 macro_status 完全一致(废弃 Product.status 的恒值判断),业务状态定义:
|
||||
# COMPLETED = 车间完工待实收 → overall_status == '待仓库收货'
|
||||
# ARCHIVED = 仓库已实收 → overall_status == '已入库'('在库' 为旧命名,等价)
|
||||
if status_filter and status_filter.strip():
|
||||
from sqlalchemy import func, and_, exists
|
||||
sf = status_filter.strip().upper()
|
||||
|
||||
# 最后一条任务(created_at 最新)状态为 COMPLETED 的产品子查询(兼容旧数据用)
|
||||
ranked = (
|
||||
select(
|
||||
Task.product_id, Task.status,
|
||||
func.row_number().over(
|
||||
partition_by=Task.product_id,
|
||||
order_by=Task.created_at.desc(),
|
||||
).label("rn"),
|
||||
).subquery("sf_latest_task")
|
||||
)
|
||||
latest_completed_ids = select(ranked.c.product_id).where(
|
||||
ranked.c.rn == 1, ranked.c.status == "COMPLETED",
|
||||
)
|
||||
|
||||
archived_cond = Product.overall_status.in_(["已入库", "在库"])
|
||||
completed_cond = or_(
|
||||
Product.overall_status == "待仓库收货",
|
||||
# 兼容旧数据:无定位(current_location_id IS NULL)且最后一条任务已完成
|
||||
and_(
|
||||
Product.current_location_id.is_(None),
|
||||
Product.id.in_(latest_completed_ids),
|
||||
),
|
||||
)
|
||||
# 未进入"完结"态(NULL 视为未完结,避免三值逻辑误过滤)
|
||||
not_finished = or_(
|
||||
Product.overall_status.is_(None),
|
||||
~Product.overall_status.in_(["待仓库收货", "已入库", "在库"]),
|
||||
)
|
||||
|
||||
def _has_task_status(task_status: str):
|
||||
"""存在指定状态任务 且 未完结的 EXISTS 谓词"""
|
||||
return exists(
|
||||
select(Task.id).where(Task.product_id == Product.id, Task.status == task_status)
|
||||
)
|
||||
|
||||
if sf == "DONE":
|
||||
# "已完成/已入库" = 待仓库收货(COMPLETED) 或 已入库(ARCHIVED)
|
||||
stmt = stmt.where(or_(archived_cond, completed_cond))
|
||||
elif sf == "ARCHIVED":
|
||||
# 已入库 → ARCHIVED
|
||||
stmt = stmt.where(archived_cond)
|
||||
elif sf == "COMPLETED":
|
||||
# 待仓库收货 → COMPLETED(含旧的无定位已完成数据)
|
||||
stmt = stmt.where(completed_cond)
|
||||
elif sf == "WIP":
|
||||
stmt = stmt.where(not_finished, _has_task_status("WIP"))
|
||||
elif sf == "PENDING":
|
||||
stmt = stmt.where(not_finished, _has_task_status("PENDING"))
|
||||
elif sf == "PENDING_ASSIGNED":
|
||||
# 存在已分配(等待扫码)的待接收任务
|
||||
stmt = stmt.where(not_finished, exists(
|
||||
select(Task.id).where(
|
||||
Task.product_id == Product.id,
|
||||
Task.status == "PENDING",
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
))
|
||||
else:
|
||||
# 兜底:其他状态码按"存在该状态任务"匹配(未完结)
|
||||
stmt = stmt.where(not_finished, _has_task_status(sf))
|
||||
|
||||
stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
products = result.scalars().all()
|
||||
|
||||
# 🔧 批量预计算 macro_status:一次性查出所有产品关联的任务状态
|
||||
product_ids = [p.id for p in products]
|
||||
macro_map: dict[uuid.UUID, str] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import case, func as sa_func
|
||||
task_stmt = (
|
||||
select(
|
||||
Task.product_id,
|
||||
sa_func.max(case(
|
||||
(Task.status == "WIP", 3),
|
||||
(Task.status == "PENDING", 2),
|
||||
(Task.status == "REJECTED", 2),
|
||||
(Task.status == "COMPLETED", 1),
|
||||
(Task.status == "ARCHIVED", 1),
|
||||
else_=0,
|
||||
)).label("prio"),
|
||||
)
|
||||
.where(Task.product_id.in_(product_ids))
|
||||
.group_by(Task.product_id)
|
||||
)
|
||||
task_result = await db.execute(task_stmt)
|
||||
prio_to_status = {3: "WIP", 2: "PENDING", 1: "COMPLETED", 0: None}
|
||||
for row in task_result:
|
||||
macro_map[row[0]] = prio_to_status.get(row[1], None)
|
||||
|
||||
# 🔧 每个产品最后一条任务(created_at 最新)的状态 → 用于"已完成"判定
|
||||
last_task_status_map: dict[uuid.UUID, str] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import func as sa_func
|
||||
ranked = (
|
||||
select(
|
||||
Task.product_id, Task.status,
|
||||
sa_func.row_number().over(
|
||||
partition_by=Task.product_id,
|
||||
order_by=Task.created_at.desc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(Task.product_id.in_(product_ids))
|
||||
.subquery("last_task")
|
||||
)
|
||||
last_result = await db.execute(
|
||||
select(ranked.c.product_id, ranked.c.status).where(ranked.c.rn == 1)
|
||||
)
|
||||
for row in last_result:
|
||||
last_task_status_map[row[0]] = row[1]
|
||||
|
||||
# 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名)
|
||||
overall_names: dict[uuid.UUID, str] = {}
|
||||
# 🔧 【当前工序】专用:仅「活跃主干任务」的工序名(见下方 main_stmt 循环填充)
|
||||
active_step_map: dict[uuid.UUID, str] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import and_, func as sa_func, case as sa_case
|
||||
main_where = and_(
|
||||
Task.product_id.in_(product_ids),
|
||||
or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]),
|
||||
),
|
||||
)
|
||||
prio_expr = sa_case(
|
||||
(Task.status == "WIP", 3),
|
||||
(Task.status == "PENDING", 2),
|
||||
(Task.status == "COMPLETED", 1),
|
||||
else_=0,
|
||||
)
|
||||
max_prio = (
|
||||
select(Task.product_id, sa_func.max(prio_expr).label("prio"))
|
||||
.where(main_where)
|
||||
.group_by(Task.product_id)
|
||||
).subquery("mp")
|
||||
main_stmt = (
|
||||
select(Task.product_id, Task.task_name, Task.status)
|
||||
.join(max_prio, and_(
|
||||
Task.product_id == max_prio.c.product_id,
|
||||
prio_expr == max_prio.c.prio,
|
||||
))
|
||||
.where(main_where)
|
||||
.order_by(Task.product_id, Task.created_at.desc())
|
||||
.distinct(Task.product_id)
|
||||
)
|
||||
main_result = await db.execute(main_stmt)
|
||||
for row in main_result:
|
||||
overall_names[row[0]] = row[1]
|
||||
# 🔧 供【当前工序】专用:只收【活跃】主干任务的工序名。
|
||||
# overall_names 保持原语义不动(它连 COMPLETED 的任务也收,
|
||||
# 是"最新主干任务名",被 overall_status 复用,改动影响面太大)。
|
||||
if row[2] in ("WIP", "PENDING"):
|
||||
active_step_map[row[0]] = row[1]
|
||||
|
||||
# 🔧 当前位置:汇总所有活跃任务(WIP/PENDING,不分主线/分支)的负责人,去重保序
|
||||
active_assignees_map: dict[uuid.UUID, list[str]] = {}
|
||||
if product_ids:
|
||||
active_stmt = (
|
||||
select(Task.product_id, Task.assignee_id)
|
||||
.where(
|
||||
Task.product_id.in_(product_ids),
|
||||
Task.status.in_(["WIP", "PENDING"]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
.order_by(Task.product_id, Task.created_at)
|
||||
)
|
||||
active_result = await db.execute(active_stmt)
|
||||
for row in active_result:
|
||||
pid, assignee = row[0], row[1]
|
||||
lst = active_assignees_map.setdefault(pid, [])
|
||||
if assignee not in lst:
|
||||
lst.append(assignee)
|
||||
|
||||
# 🔧 活跃负责人 + 静态位置(兜底)的 username → 中文姓名(一次批量查)
|
||||
all_active_ids = [uid for ids in active_assignees_map.values() for uid in ids]
|
||||
static_location_ids = [p.current_location_id for p in products if p.current_location_id]
|
||||
merged_location_ids = list(set(all_active_ids + static_location_ids))
|
||||
merged_name_map = _lookup_display_names(merged_location_ids)
|
||||
|
||||
# 🔧 批量查询每个产品活跃任务的最新记录(含操作人 assignee_id)
|
||||
latest_record_map: dict[uuid.UUID, tuple] = {}
|
||||
if product_ids:
|
||||
from app.models.task import TaskRecord as TR
|
||||
wip_pending_ids = select(Task.id).where(
|
||||
and_(
|
||||
Task.product_id.in_(product_ids),
|
||||
Task.status.in_(["WIP", "PENDING"]),
|
||||
)
|
||||
).subquery()
|
||||
ranked = (
|
||||
select(TR.task_id, TR.remark, TR.images, TR.created_at, Task.product_id, Task.assignee_id,
|
||||
sa_func.row_number().over(
|
||||
partition_by=Task.product_id,
|
||||
order_by=TR.created_at.desc()
|
||||
).label("rn"))
|
||||
.join(Task, TR.task_id == Task.id)
|
||||
.where(Task.id.in_(select(wip_pending_ids.c.id)))
|
||||
).subquery()
|
||||
rec_result = await db.execute(
|
||||
select(ranked.c.product_id, ranked.c.created_at, ranked.c.remark, ranked.c.images, ranked.c.assignee_id)
|
||||
.where(ranked.c.rn == 1)
|
||||
)
|
||||
for row in rec_result:
|
||||
has_img = bool(row[3] and row[3] != "[]" and row[3] != "null")
|
||||
latest_record_map[row[0]] = (row[1], row[2], has_img, row[4])
|
||||
|
||||
# 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时(排除非工作日)
|
||||
active_duration_map: dict[uuid.UUID, float] = {}
|
||||
if product_ids:
|
||||
from sqlalchemy import func as sa_func
|
||||
from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours
|
||||
from app.models.holiday import Holiday
|
||||
hres = await db.execute(select(Holiday.day))
|
||||
holidays = {r[0] for r in hres}
|
||||
start_stmt = (
|
||||
select(
|
||||
Task.product_id,
|
||||
sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)),
|
||||
)
|
||||
.where(
|
||||
Task.product_id.in_(product_ids),
|
||||
Task.status.in_(["WIP", "PENDING"]),
|
||||
)
|
||||
.group_by(Task.product_id)
|
||||
)
|
||||
start_result = await db.execute(start_stmt)
|
||||
now = get_beijing_time()
|
||||
for row in start_result:
|
||||
start = row[1]
|
||||
if start is None:
|
||||
continue
|
||||
start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时)
|
||||
active_duration_map[row[0]] = working_duration_hours(start_bj, now, holidays)
|
||||
|
||||
# 🔧 生产总天数(自然天 + 工作日):自创建至今
|
||||
import math
|
||||
from app.core.time_utils import get_beijing_time as _gbt, to_beijing as _tb, working_duration_hours as _wdh
|
||||
from app.models.holiday import Holiday as _Holiday
|
||||
hres2 = await db.execute(select(_Holiday.day))
|
||||
holidays2 = {r[0] for r in hres2}
|
||||
now2 = _gbt()
|
||||
|
||||
def _prod_days(created_at):
|
||||
created = _tb(created_at)
|
||||
if not created:
|
||||
return 1, 1
|
||||
natural = max(1, math.ceil((now2 - created).total_seconds() / 86400))
|
||||
work_hours = _wdh(created, now2, holidays2)
|
||||
workdays = max(1, math.ceil(work_hours / 24))
|
||||
return natural, workdays
|
||||
|
||||
production_days_map: dict = {}
|
||||
for _p in products:
|
||||
production_days_map[_p.id] = _prod_days(_p.created_at)
|
||||
|
||||
def _resolve_macro_status(p: Product) -> str:
|
||||
"""宏观状态 — 以 overall_status 为核心的状态定义(用户确认):
|
||||
- ARCHIVED(已入库): overall_status == '已入库'('在库' 为旧命名,等价)
|
||||
- COMPLETED(已完成): overall_status == '待仓库收货';
|
||||
兼容旧数据:无定位(current_location_id IS NULL)且最后一条任务 COMPLETED
|
||||
- 其余: 沿用原 WIP/PENDING 任务优先级;无任何任务的产品 → PENDING
|
||||
"""
|
||||
if p.overall_status in ("已入库", "在库"):
|
||||
return "ARCHIVED"
|
||||
if p.overall_status == "已出库":
|
||||
return "OUTBOUND"
|
||||
if p.overall_status == "待仓库收货":
|
||||
return "COMPLETED"
|
||||
if last_task_status_map.get(p.id) == "COMPLETED" and p.current_location_id is None:
|
||||
return "COMPLETED"
|
||||
return macro_map.get(p.id) or "PENDING"
|
||||
|
||||
# 宏观终态 —— 表达的是"货在哪",不是"在做什么"。
|
||||
# 这类值即使当前没有活跃任务也必须照常展示(业务要求保留物理标识)。
|
||||
_TERMINAL_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
|
||||
|
||||
def _resolve_current_step(p: Product) -> str:
|
||||
"""【当前工序】—— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。
|
||||
|
||||
· 有活跃主干任务(WIP/PENDING) → 该任务工序名
|
||||
· 否则产品处于宏观终态 → 该终态("货在哪",保留)
|
||||
· 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
|
||||
|
||||
历史缺陷:ProductResponse.overall_status 会被"最新主干任务名"覆盖
|
||||
(见上方 overall_names),于是已 COMPLETED 的「扫码出库 / 测试 / 发货测试」
|
||||
会长期冒充"当前工序",误导管理者以为活还在干。
|
||||
"""
|
||||
raw = (p.overall_status or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw in _TERMINAL_OVERALL:
|
||||
return raw
|
||||
return active_step_map.get(p.id, "")
|
||||
|
||||
return [
|
||||
ProductResponse(
|
||||
id=p.id,
|
||||
serial_number=p.serial_number,
|
||||
external_serial=p.external_serial,
|
||||
order_id=p.order_id,
|
||||
order_no=p.order.order_no if p.order else "",
|
||||
material_id=p.material_id,
|
||||
material_name=p.material_name,
|
||||
spec_model=p.spec_model,
|
||||
category=p.category,
|
||||
material_type=p.material_type,
|
||||
parent_product_id=p.parent_product_id,
|
||||
current_location_id=(
|
||||
",".join(active_assignees_map.get(p.id, [])) or p.current_location_id
|
||||
),
|
||||
current_location_name=(
|
||||
", ".join(merged_name_map.get(uid, uid) for uid in active_assignees_map.get(p.id, []))
|
||||
if active_assignees_map.get(p.id)
|
||||
else ("仓库" if p.current_location_id == "virtual_warehouse"
|
||||
else merged_name_map.get(p.current_location_id) if p.current_location_id else None)
|
||||
),
|
||||
macro_status=_resolve_macro_status(p),
|
||||
overall_status=overall_names.get(p.id) or p.overall_status,
|
||||
current_step=_resolve_current_step(p),
|
||||
status=p.status,
|
||||
lifecycle_phase=p.lifecycle_phase,
|
||||
created_at=p.created_at,
|
||||
latest_record_time=latest_record_map.get(p.id, (None, None, False, None))[0],
|
||||
latest_record_content=latest_record_map.get(p.id, (None, None, False, None))[1],
|
||||
latest_record_has_images=latest_record_map.get(p.id, (None, None, False, None))[2],
|
||||
latest_record_assignee_id=latest_record_map.get(p.id, (None, None, False, None))[3],
|
||||
latest_record_assignee_name=(
|
||||
merged_name_map.get(latest_record_map.get(p.id, (None, None, False, None))[3])
|
||||
if latest_record_map.get(p.id, (None, None, False, None))[3] else None
|
||||
),
|
||||
active_duration_hours=active_duration_map.get(p.id),
|
||||
production_days=production_days_map.get(p.id, (1, 1))[0],
|
||||
production_days_workdays=production_days_map.get(p.id, (1, 1))[1],
|
||||
)
|
||||
for p in products
|
||||
]
|
||||
|
||||
|
||||
async def delete_product(db: AsyncSession, product_id: uuid.UUID) -> None:
|
||||
"""删除产品及其关联任务"""
|
||||
product = await get_product(db, product_id)
|
||||
|
||||
from app.models.task import TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
# 🚀 1. 切断产品自引用:子产品的 parent_product_id 置空
|
||||
await db.execute(
|
||||
update(Product).where(Product.parent_product_id == product_id).values(parent_product_id=None)
|
||||
)
|
||||
|
||||
# 2. 查询所有关联任务
|
||||
tasks_result = await db.execute(
|
||||
select(Task).where(Task.product_id == product_id)
|
||||
)
|
||||
tasks = tasks_result.scalars().all()
|
||||
|
||||
# 🚀 3. 切断任务自引用:子任务的 parent_task_id 置空
|
||||
for task in tasks:
|
||||
await db.execute(
|
||||
update(Task).where(Task.parent_task_id == task.id).values(parent_task_id=None)
|
||||
)
|
||||
|
||||
# 4. 删除任务记录、日志、任务本身
|
||||
for task in tasks:
|
||||
await db.execute(delete(TaskRecord).where(TaskRecord.task_id == task.id))
|
||||
await db.execute(delete(TaskLog).where(TaskLog.task_id == task.id))
|
||||
await db.delete(task)
|
||||
|
||||
# 5. 删除产品(product_messages 有 ON DELETE CASCADE 自动级联)
|
||||
await db.delete(product)
|
||||
await db.commit()
|
||||
34
backend/app/services/qrcode_service.py
Normal file
34
backend/app/services/qrcode_service.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""二维码生成服务 — 参考 MOM 系统 label_service.py 的 QR 生成逻辑"""
|
||||
import io
|
||||
import qrcode
|
||||
from qrcode.image.pil import PilImage
|
||||
|
||||
|
||||
def generate_qrcode_png(content: str, size_px: int = 300) -> io.BytesIO:
|
||||
"""
|
||||
生成二维码 PNG 图片,返回 BytesIO 流。
|
||||
|
||||
参数:
|
||||
content: 二维码内容(如 16 位序列号)
|
||||
size_px: 输出图片尺寸(像素),默认 300×300
|
||||
|
||||
返回:
|
||||
io.BytesIO: PNG 格式的图片字节流
|
||||
"""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
|
||||
img: PilImage = qr.make_image(fill_color="black", back_color="white")
|
||||
img = img.convert("RGB")
|
||||
img = img.resize((size_px, size_px))
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
buf.seek(0)
|
||||
return buf
|
||||
254
backend/app/services/screen_service.py
Normal file
254
backend/app/services/screen_service.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合
|
||||
|
||||
设计约定:
|
||||
- 只读,无写操作,字段扁平,便于大屏高频轮询。
|
||||
- 时间一律按**北京时间**判定;DB 列为 timestamptz(实存 UTC),
|
||||
比较前统一把边界换算成 UTC,避免月初/凌晨的边界漂移。
|
||||
- 口径与 dashboard_service.get_dashboard_stats 保持一致:
|
||||
未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。
|
||||
|
||||
视角说明:
|
||||
管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」,
|
||||
而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.lifecycle import (
|
||||
AFTER_SALES_ONLY_STEPS,
|
||||
LIFECYCLE_AFTER_SALES,
|
||||
LIFECYCLE_PRODUCTION,
|
||||
)
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径
|
||||
FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库")
|
||||
|
||||
# 仓储动作日志类型(由 webhooks / product_finalize_service 写入)
|
||||
LOG_WAREHOUSE_INBOUND = "warehouse_inbound"
|
||||
LOG_WAREHOUSE_OUTBOUND = "warehouse_outbound"
|
||||
|
||||
|
||||
def _not_finished():
|
||||
"""未完结条件 —— overall_status 为 NULL 或不在已完结集合内。
|
||||
|
||||
注意 PostgreSQL 中 `NULL NOT IN (...)` 结果为 NULL 而非 TRUE,
|
||||
必须显式带上 IS NULL 分支,否则建单后未流转的设备会被整批漏掉。
|
||||
"""
|
||||
return or_(
|
||||
Product.overall_status.is_(None),
|
||||
~Product.overall_status.in_(FINISHED_OVERALL),
|
||||
)
|
||||
|
||||
|
||||
def _month_bounds() -> tuple[datetime, datetime, str]:
|
||||
"""返回 (本月起点 UTC, 当前时刻 UTC, 'YYYY-MM')。
|
||||
|
||||
本月起点 = 北京时间当月 1 日 00:00 —— 直接换算成 UTC 参与 timestamptz 比较,
|
||||
不依赖数据库会话时区设置。
|
||||
"""
|
||||
now_bj = get_beijing_time()
|
||||
month_start_bj = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
return (
|
||||
month_start_bj.astimezone(timezone.utc),
|
||||
now_bj.astimezone(timezone.utc),
|
||||
month_start_bj.strftime("%Y-%m"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 1. 当月吞吐指标
|
||||
# ============================================================
|
||||
|
||||
class MonthlyMetrics(BaseModel):
|
||||
"""大屏顶部四张数字卡(当月视角)"""
|
||||
month: str # "2026-09"
|
||||
month_start: str # "2026-09-01"(北京时间)
|
||||
month_production: int # 本月有流转记录或新建的生产态设备数
|
||||
month_inbound: int # 本月扫码入库数
|
||||
month_outbound: int # 本月扫码出库数
|
||||
month_returned: int # 本月进入售后/回流状态的设备数
|
||||
|
||||
|
||||
async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics:
|
||||
"""当月吞吐四联指标。
|
||||
|
||||
口径:
|
||||
- month_production:lifecycle_phase = PRODUCTION,且「本月新建」或
|
||||
「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。
|
||||
反映这个月实际被推着走的机器有多少。
|
||||
- month_inbound / month_outbound:task_logs 中 action_type =
|
||||
warehouse_inbound / warehouse_outbound 的记录数(MOM 扫码回调写入)。
|
||||
- month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的
|
||||
设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES
|
||||
的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。
|
||||
"""
|
||||
month_start_utc, now_utc, month_label = _month_bounds()
|
||||
|
||||
# 本月该设备产生过任意流转日志
|
||||
has_activity = (
|
||||
select(TaskLog.id)
|
||||
.join(Task, Task.id == TaskLog.task_id)
|
||||
.where(
|
||||
Task.product_id == Product.id,
|
||||
TaskLog.created_at >= month_start_utc,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
month_production = await db.scalar(
|
||||
select(func.count(func.distinct(Product.id))).where(
|
||||
Product.lifecycle_phase == LIFECYCLE_PRODUCTION,
|
||||
or_(Product.created_at >= month_start_utc, has_activity),
|
||||
)
|
||||
) or 0
|
||||
|
||||
async def _count_log(action_type: str) -> int:
|
||||
return await db.scalar(
|
||||
select(func.count(TaskLog.id)).where(
|
||||
TaskLog.action_type == action_type,
|
||||
TaskLog.created_at >= month_start_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND)
|
||||
month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND)
|
||||
|
||||
month_returned = await db.scalar(
|
||||
select(func.count(func.distinct(Task.product_id)))
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
Product.lifecycle_phase == LIFECYCLE_AFTER_SALES,
|
||||
Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)),
|
||||
Task.created_at >= month_start_utc,
|
||||
)
|
||||
) or 0
|
||||
|
||||
return MonthlyMetrics(
|
||||
month=month_label,
|
||||
month_start=f"{month_label}-01",
|
||||
month_production=int(month_production),
|
||||
month_inbound=int(month_inbound),
|
||||
month_outbound=int(month_outbound),
|
||||
month_returned=int(month_returned),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 2. 工序积压分布(柱状图)
|
||||
# ============================================================
|
||||
|
||||
class WipStage(BaseModel):
|
||||
stage: str # 工序名(中文,直接作为图表类目)
|
||||
phase: str # PRODUCTION / AFTER_SALES,供前端分区着色
|
||||
count: int
|
||||
|
||||
|
||||
class WipDistributionResponse(BaseModel):
|
||||
total: int # 未完结设备总数
|
||||
items: list[WipStage]
|
||||
|
||||
|
||||
# 阶段与流转顺序 —— 顺序即「工序先后」,前端据此排布柱子
|
||||
WIP_STAGES: tuple[tuple[str, str], ...] = (
|
||||
("待启动", LIFECYCLE_PRODUCTION), # overall_status 为空:建单后尚未流转
|
||||
("备货", LIFECYCLE_PRODUCTION),
|
||||
("生产", LIFECYCLE_PRODUCTION),
|
||||
("测试", LIFECYCLE_PRODUCTION),
|
||||
("维修", LIFECYCLE_PRODUCTION),
|
||||
("发货测试", LIFECYCLE_AFTER_SALES),
|
||||
("售后维修", LIFECYCLE_AFTER_SALES),
|
||||
)
|
||||
|
||||
# 「待启动」对应的真实分组键(overall_status IS NULL / 空串)
|
||||
_UNSTARTED_KEY = ""
|
||||
|
||||
|
||||
async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse:
|
||||
"""工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。
|
||||
|
||||
返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而
|
||||
整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的
|
||||
count 之和恒等于 total(避免统计悄悄漏数)。
|
||||
"""
|
||||
rows = await db.execute(
|
||||
select(Product.overall_status, func.count(Product.id))
|
||||
.where(_not_finished())
|
||||
.group_by(Product.overall_status)
|
||||
)
|
||||
counts: dict[str, int] = {}
|
||||
for raw_status, cnt in rows.all():
|
||||
key = (raw_status or "").strip()
|
||||
counts[key] = counts.get(key, 0) + cnt
|
||||
|
||||
total = sum(counts.values())
|
||||
|
||||
items: list[WipStage] = []
|
||||
for stage, phase in WIP_STAGES:
|
||||
key = _UNSTARTED_KEY if stage == "待启动" else stage
|
||||
items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0)))
|
||||
|
||||
known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in WIP_STAGES}
|
||||
for key, cnt in counts.items():
|
||||
if key not in known_keys and cnt:
|
||||
items.append(WipStage(
|
||||
stage=key or "待启动",
|
||||
phase=LIFECYCLE_PRODUCTION,
|
||||
count=cnt,
|
||||
))
|
||||
|
||||
return WipDistributionResponse(total=total, items=items)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 3. 系统使用活跃度(本月人员排行)
|
||||
# ============================================================
|
||||
|
||||
class ActiveUser(BaseModel):
|
||||
user_id: str
|
||||
user_name: str
|
||||
receive_count: int # 接收
|
||||
transfer_count: int # 转交
|
||||
record_count: int # 上传备注
|
||||
total: int
|
||||
|
||||
|
||||
class ActiveUsersResponse(BaseModel):
|
||||
month: str
|
||||
items: list[ActiveUser]
|
||||
|
||||
|
||||
async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse:
|
||||
"""本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。
|
||||
|
||||
不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete),
|
||||
上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员,
|
||||
避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。
|
||||
"""
|
||||
from app.services.dashboard_service import get_user_operations
|
||||
|
||||
month_start_utc, _now, month_label = _month_bounds()
|
||||
operations = await get_user_operations(db, since=month_start_utc, until=None)
|
||||
active = [op for op in operations if op.total > 0][:top_n]
|
||||
|
||||
return ActiveUsersResponse(
|
||||
month=month_label,
|
||||
items=[
|
||||
ActiveUser(
|
||||
user_id=op.user_id,
|
||||
user_name=op.user_name,
|
||||
receive_count=op.receive_count,
|
||||
transfer_count=op.transfer_count,
|
||||
record_count=op.record_count,
|
||||
total=op.total,
|
||||
)
|
||||
for op in active
|
||||
],
|
||||
)
|
||||
1402
backend/app/services/task_service.py
Normal file
1402
backend/app/services/task_service.py
Normal file
File diff suppressed because it is too large
Load Diff
176
backend/app/services/task_tree_loader.py
Normal file
176
backend/app/services/task_tree_loader.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""
|
||||
共享 CTE 任务树加载器 — 使用 PostgreSQL Recursive CTE 一次性拉取完整任务树
|
||||
|
||||
解决问题:原 _load_task_tree / _get_task_with_children_recursive 使用
|
||||
Python 递归逐层 SELECT,N 个节点产生 N+1 次数据库查询。
|
||||
现在无论树深度多大,仅执行 2 条查询(CTE + records selectinload)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import noload, selectinload
|
||||
from sqlalchemy.orm.attributes import set_committed_value
|
||||
|
||||
from app.models.task import Task
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 内存树组装(O(N) 时间 / O(N) 空间)
|
||||
# ============================================================
|
||||
|
||||
def _build_tree_in_memory(tasks: list[Task]) -> dict[uuid.UUID, Task]:
|
||||
"""
|
||||
给定扁平 Task ORM 列表,在内存中通过哈希表组装嵌套树结构。
|
||||
|
||||
关键安全设计:
|
||||
- 使用临时字典 temp_children_map 暂存父子关系,绝对不直接操作 ORM 的 child_tasks。
|
||||
- 通过 set_committed_value 注入最终列表,告诉 SQLAlchemy 这是"已提交数据",
|
||||
避免 add_task_record 等场景中 db.commit() 时触发级联 UPDATE 污染数据库。
|
||||
|
||||
时间复杂度: O(N),空间复杂度: O(N)。
|
||||
"""
|
||||
if not tasks:
|
||||
return {}
|
||||
|
||||
# ── Pass 1: 临时字典存储关系(不触碰 ORM 属性)──
|
||||
temp_children_map: dict[uuid.UUID, list[Task]] = {t.id: [] for t in tasks}
|
||||
task_map: dict[uuid.UUID, Task] = {t.id: t for t in tasks}
|
||||
|
||||
# ── Pass 2: 挂载到临时字典 ──
|
||||
for t in tasks:
|
||||
pid = t.parent_task_id
|
||||
if pid is not None and pid in temp_children_map:
|
||||
temp_children_map[pid].append(t)
|
||||
|
||||
# ── Pass 3: 排序 + set_committed_value 安全注入 ──
|
||||
for t in tasks:
|
||||
children = temp_children_map[t.id]
|
||||
if children:
|
||||
children.sort(key=lambda x: x.created_at)
|
||||
# 关键:标记为已提交数据,SQLAlchemy 不会对其生成 UPDATE
|
||||
set_committed_value(t, 'child_tasks', children)
|
||||
|
||||
return task_map
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API:按单一任务 ID 加载子树
|
||||
# ============================================================
|
||||
|
||||
async def load_task_tree_by_root(
|
||||
db: AsyncSession, task_id: uuid.UUID
|
||||
) -> Task:
|
||||
"""
|
||||
使用 Recursive CTE 加载以 task_id 为根的完整任务子树。
|
||||
|
||||
返回: 根 Task ORM 对象(child_tasks 已递归填充)。
|
||||
|
||||
Raises:
|
||||
HTTPException(404): 根任务不存在。
|
||||
"""
|
||||
# ── Step 1: Recursive CTE — 收集所有子孙节点 ID ──
|
||||
# WITH RECURSIVE task_tree AS (
|
||||
# SELECT tasks.* FROM tasks WHERE tasks.id = :tid
|
||||
# UNION ALL
|
||||
# SELECT tasks.* FROM tasks
|
||||
# JOIN task_tree ON tasks.parent_task_id = task_tree.id
|
||||
# )
|
||||
anchor = (
|
||||
select(Task)
|
||||
.where(Task.id == task_id)
|
||||
.cte(name="task_tree", recursive=True)
|
||||
)
|
||||
task_tree_cte = anchor.union_all(
|
||||
select(Task).join(anchor, Task.parent_task_id == anchor.c.id)
|
||||
)
|
||||
|
||||
# ── Step 2: 批量加载所有任务 + 关联数据 ──
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
noload(Task.child_tasks), # 禁掉模型默认 selectinload,由内存树接管
|
||||
noload(Task.parent_task), # 组装树不需要 parent 引用
|
||||
selectinload(Task.records), # 🔥 一次性预加载所有进度记录
|
||||
selectinload(Task.product), # 🔥 一次性预加载产品引用
|
||||
)
|
||||
.where(Task.id.in_(select(task_tree_cte.c.id)))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_tasks = result.unique().scalars().all()
|
||||
|
||||
if not all_tasks:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
# ── Step 3: 内存组装 ──
|
||||
task_map = _build_tree_in_memory(all_tasks)
|
||||
|
||||
# 根任务一定在 map 中(CTE anchor 保证了这一点)
|
||||
return task_map[task_id]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 公开 API:按产品 ID 加载所有任务树
|
||||
# ============================================================
|
||||
|
||||
async def load_task_trees_by_product(
|
||||
db: AsyncSession, product_id: uuid.UUID
|
||||
) -> list[Task]:
|
||||
"""
|
||||
使用 Recursive CTE 加载指定产品下的所有任务树。
|
||||
|
||||
返回: 顶层任务列表(parent_task_id IS NULL),每项的 child_tasks 已递归填充。
|
||||
若无任务则返回空列表。
|
||||
"""
|
||||
# ── Step 1: Recursive CTE ──
|
||||
# WITH RECURSIVE product_task_tree AS (
|
||||
# SELECT tasks.* FROM tasks
|
||||
# WHERE tasks.product_id = :pid AND tasks.parent_task_id IS NULL
|
||||
# UNION ALL
|
||||
# SELECT tasks.* FROM tasks
|
||||
# JOIN product_task_tree ON tasks.parent_task_id = product_task_tree.id
|
||||
# )
|
||||
anchor = (
|
||||
select(Task)
|
||||
.where(
|
||||
Task.product_id == product_id,
|
||||
Task.parent_task_id.is_(None),
|
||||
)
|
||||
.cte(name="product_task_tree", recursive=True)
|
||||
)
|
||||
task_tree_cte = anchor.union_all(
|
||||
select(Task).join(anchor, Task.parent_task_id == anchor.c.id)
|
||||
)
|
||||
|
||||
# ── Step 2: 批量加载 ──
|
||||
stmt = (
|
||||
select(Task)
|
||||
.options(
|
||||
noload(Task.child_tasks),
|
||||
noload(Task.parent_task),
|
||||
selectinload(Task.records),
|
||||
selectinload(Task.product),
|
||||
)
|
||||
.where(Task.id.in_(select(task_tree_cte.c.id)))
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
all_tasks = result.unique().scalars().all()
|
||||
|
||||
if not all_tasks:
|
||||
return []
|
||||
|
||||
# ── Step 3: 内存组装 ──
|
||||
_build_tree_in_memory(all_tasks)
|
||||
|
||||
# ── Step 4: 返回排序后的顶层任务 ──
|
||||
roots = [t for t in all_tasks if t.parent_task_id is None]
|
||||
roots.sort(key=lambda t: t.created_at)
|
||||
return roots
|
||||
Reference in New Issue
Block a user