From 74d404cc11c00000d4d1132130866d0a14407b85 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Fri, 14 Aug 2026 11:44:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20=E8=AE=BE=E5=A4=87=E6=B5=81?= =?UTF-8?q?=E8=BD=AC=E7=94=98=E7=89=B9=E5=9B=BE=E6=95=B0=E6=8D=AE=E5=BA=95?= =?UTF-8?q?=E5=BA=A7=EF=BC=88lead=5Ftime/started=5Fat/task=5Ftype=20?= =?UTF-8?q?=E4=B8=BB=E6=AC=A1=E6=A0=87=E8=AF=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FlowDevice 新增 lead_time(生命周期总时长)、started_at(最早介入时间) - FlowSeries.data 改为区间数组 [device_index, start_offset, end_offset, task_name, duration, is_main] - get_flow_compare 按任务生成相对 T0 的偏移区间,task_type=SPAWN 标记为分支 --- backend/app/services/analytics_service.py | 91 +++++++++++++++-------- 1 file changed, 62 insertions(+), 29 deletions(-) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index 9721855..e9a35a3 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -51,11 +51,13 @@ class FlowDevice(BaseModel): 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 class FlowSeries(BaseModel): - name: str # 工序节点名 - data: list[float | None] # 每台设备在该工序的总耗时(未经过为 None) + name: str # 人员姓名 + data: list[list] # 每项 = [device_index, start_offset, end_offset, task_name, duration](小时) class FlowResponse(BaseModel): @@ -255,17 +257,16 @@ async def get_flow_compare( spec_models: list[str] | None = None, ) -> FlowResponse: """ - 查询每台设备上各操作人的累计耗时,拼装为堆叠柱状图: - x 轴 = 设备身份证,每个「人员」一个 series,值为该员工在这台设备上的总耗时, - 谁的色块最长谁就是该设备的瓶颈。 + 查询每台设备上各操作人的任务时间区间,拼装为「生命周期时间轴」区间图: + x 轴 = 设备身份证,y 轴 = 相对该设备 T0(最早介入时间)的小时偏移。 + 每个任务一根悬空区间柱(start_offset -> end_offset),并行任务可并排显示。 设备来源: - 传 product_sns:按给定身份证; - 仅传 spec_models:这些型号下最近有流转的 20 台设备; - 都未传:返回空。 - 排序:人员按「最早介入该设备的真实时间」(received_at/created_at 最小值)升序堆叠。 - 未参与某设备的人员返回 None(null),不补 0。 + data 每项 = [device_index, start_offset, end_offset, task_name, duration](单位小时)。 """ from app.models.task import Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED from app.models.product import Product @@ -309,6 +310,8 @@ async def get_flow_compare( 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 ] @@ -321,11 +324,12 @@ async def get_flow_compare( sn_to_index = {d.product_sn: i for i, d in enumerate(devices)} product_ids = [r[0] for r in product_rows] - # ── 这些设备的全部任务(按操作人聚合) ── + # ── 这些设备的全部任务(含工序名,用于区间图) ── task_rows = (await db.execute( select( - Task.product_id, Task.assignee_id, + 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), @@ -334,41 +338,70 @@ async def get_flow_compare( ) )).all() - # ── 聚合:人员(assignee_id) → {设备索引: 累计耗时},同时记录人员最早介入时间 ── - agg: dict[str, dict[int, float]] = {} - person_earliest: dict[str, datetime] = {} + # ── 解析为区间记录,并计算每台设备的 T0(最早)与 max_end(最晚) ── + intervals: list[dict] = [] + t0_by_device: dict[int, datetime] = {} + max_end_by_device: dict[int, datetime] = {} for row in task_rows: - pid, assignee_id, received_at, created_at, completed_at = row + pid, assignee_id, task_name, received_at, created_at, completed_at, task_type = row sn = id_to_sn.get(pid) if sn is None or sn not in sn_to_index: continue idx = sn_to_index[sn] start = _to_bj(received_at or created_at) end = _to_bj(completed_at) if completed_at else now - agg.setdefault(assignee_id, {}).setdefault(idx, 0.0) - agg[assignee_id][idx] += _duration_hours(start, end) - if start and (person_earliest.get(assignee_id) is None or start < person_earliest[assignee_id]): - person_earliest[assignee_id] = start + 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 + } + 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 "", + ) + for i, d in enumerate(devices) + ] + + # ── 按人分组,转为相对 T0 的小时偏移区间 ── + by_assignee: dict[str, list[list]] = {} + for it in intervals: + t0 = t0_by_device[it["idx"]] + start_offset = round((it["start"] - t0).total_seconds() / 3600, 1) + end_offset = round((it["end"] - t0).total_seconds() / 3600, 1) + duration = round(end_offset - start_offset, 1) + by_assignee.setdefault(it["assignee_id"], []).append( + [it["idx"], start_offset, end_offset, it["task_name"], duration, it["is_main"]] + ) # 翻译人员姓名 - raw_ids = list(agg.keys()) + 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) - # ── 按人员最早介入时间升序排序(从下到上 = 谁先接手谁后接手) ── - ordered_persons = sorted(agg.keys(), key=lambda p: person_earliest.get(p) or now) - series = [ - FlowSeries( - name=name_map.get(p, p), - data=[ - round(agg[p][i], 1) if i in agg[p] else None - for i in range(len(devices)) - ], - ) - for p in ordered_persons + FlowSeries(name=name_map.get(a, a), data=by_assignee[a]) + for a in sorted(by_assignee.keys()) ] return FlowResponse(devices=devices, series=series)