"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合 设计约定: - 只读,无写操作,字段扁平,便于大屏高频轮询。 - 时间一律按**北京时间**判定;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 ], )