From 394e1e39c37d6e234be8a6c39a9270e065759ce1 Mon Sep 17 00:00:00 2001 From: duxingchen Date: Mon, 21 Sep 2026 17:22:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=88=86=E7=BB=84=E6=9D=83=E9=99=90):=20?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E6=8E=A5=E5=8F=A3=E6=8E=A5=E5=85=A5=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E8=8C=83=E5=9B=B4=EF=BC=88dashboard=20/=20analytics?= =?UTF-8?q?=20/=20screen=20=E5=85=B1=2020=20=E4=B8=AA=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这三组接口此前是「上帝视角」且**匿名可访问**,现在全部挂 get_data_scope —— 既要求登录、又按业务分组范围过滤。顺带堵上了 AGENTS.md 点名的风险: /dashboard/people-history/export 此前匿名即可批量导出全员工时台账。 dashboard(11 个函数 / 24 处注入) · Product 主体 → product_where();Task、TaskLog 主体 → 先 join(Product) 再 task_where() · 「卡片数字」与「下钻明细」成对出现的地方用同一谓词,避免「按钮显示 2、点开却是 0 条」 · get_user_operations 的 func.count() 改为 func.count(TaskLog.id) —— 显式化,不依赖 join 形状(当前是 many-to-one 不会放大,但这样写更稳) · unread_notif **刻意不过滤**:Notification.task_id 可空,按 Product 过滤会漏掉 无任务关联的提醒(就地注释说明) · get_my_stats 本轮不动 —— 它按本人归因,语义上不受分组影响 analytics(4 个函数 / 9 条语句) · get_analytics_options 的 4 条独立语句全部处理 —— 它是筛选栏下拉的选项源, 不过滤的话维修组能在下拉里看到生产组的人(最易漏的一处) · get_device_records 的 product_id 查号是安全闸:范围外 SN 查不出 → 直接返回 [] screen(3 个函数) · month_production **不做特判** —— 维修组的「本月生产数」本来就该是 0 · get_wip_distribution 按范围裁剪工序柱子,但坚持「恒 0 才裁、有数必现」, 保证 total == sum(items) 在任何 scope 下都成立 实测(17 个端点):超管全部 200、匿名全部 401。 造 1 生产 + 1 售后产品后: 超管 products=2 / wip-matrix 2 行 / options 2 个型号 生产组 products=1 / wip-matrix 1 行 / options 1 个型号 维修组 products=1 / wip-matrix 1 行 / options 1 个型号 列表与统计口径一致;未分组用户在过渡期开关下仍走 ungrouped_fallback。 测试数据已还原。 --- backend/app/api/v1/endpoints/analytics.py | 28 ++-- backend/app/api/v1/endpoints/dashboard.py | 56 +++++-- backend/app/api/v1/endpoints/screen.py | 31 ++-- backend/app/services/analytics_service.py | 69 +++++++-- backend/app/services/dashboard_service.py | 180 ++++++++++++++++++---- backend/app/services/screen_service.py | 84 ++++++++-- 6 files changed, 360 insertions(+), 88 deletions(-) diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py index b51506d..86e76bf 100644 --- a/backend/app/api/v1/endpoints/analytics.py +++ b/backend/app/api/v1/endpoints/analytics.py @@ -1,10 +1,16 @@ -"""效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项)""" +"""效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项) + +⚠️ 2026-09 起不再是「上帝视角」:所有端点都挂了 get_data_scope, + 结果按当前用户的业务分组范围过滤(超管不受限),且不再允许匿名访问。 +""" from datetime import datetime from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.core.deps import get_data_scope +from app.services.data_scope_service import DataScope from app.services.analytics_service import ( get_capability_profile, CapabilityResponse, get_flow_compare, FlowResponse, @@ -23,14 +29,15 @@ async def capability_profile( until: str | None = Query(None, description="截止日期 ISO"), mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): - """个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。""" + """个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时),按当前用户数据范围过滤。""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None return await get_capability_profile( - db, assignee_ids=ids, spec_models=specs, + db, scope, assignee_ids=ids, spec_models=specs, since=since_dt, until=until_dt, mode=mode, ) @@ -43,13 +50,14 @@ async def flow_compare( since: str | None = Query(None, description="起始日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): - """设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。""" + """设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈),按当前用户数据范围过滤。""" sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_flow_compare(db, product_sns=sns, spec_models=specs, mode=mode, since=since_dt, until=until_dt) + return await get_flow_compare(db, scope, product_sns=sns, spec_models=specs, mode=mode, since=since_dt, until=until_dt) @router.get("/options", response_model=AnalyticsOptions) @@ -57,11 +65,12 @@ async def analytics_options( assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(联动过滤型号)"), spec_models: str | None = Query(None, description="规格型号,逗号分隔(联动过滤人员)"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): - """顶部筛选栏选项 — 负责人 + 规格型号,支持动态联动。""" + """顶部筛选栏选项 — 负责人 + 规格型号 + 设备字典,支持动态联动;按当前用户数据范围过滤。""" ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None - return await get_analytics_options(db, assignee_ids=ids, spec_models=specs) + return await get_analytics_options(db, scope, assignee_ids=ids, spec_models=specs) @router.get("/device-records", response_model=list[DeviceRecord]) @@ -69,7 +78,8 @@ async def device_records( product_sn: str = Query(..., description="设备身份证"), assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(可选,用于过滤)"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): - """某台设备的任务备注记录(含图片);可选按负责人过滤。""" + """某台设备的任务备注记录(含图片);可选按负责人过滤;范围外设备返回空。""" ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None - return await get_device_records(db, product_sn, assignee_ids=ids) + return await get_device_records(db, scope, product_sn, assignee_ids=ids) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 0bed07d..a8a6b10 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -1,10 +1,18 @@ -"""Dashboard API — 上帝视角(全厂数据,无用户过滤)""" +"""Dashboard API — 管理看板 + +⚠️ 2026-09 起不再是「上帝视角」:所有端点都挂了 get_data_scope, + 结果按当前用户的业务分组范围过滤(超管不受限)。 + 这同时把这些接口从**匿名可访问**变成了需要登录 —— 顺带堵上了 + `/people-history/export` 匿名批量导出全员工时台账的已知风险。 +""" import io from datetime import datetime from fastapi import APIRouter, Depends, Query from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.core.deps import get_data_scope +from app.services.data_scope_service import DataScope from app.core.time_utils import BEIJING_TZ from app.services.dashboard_service import ( get_dashboard_stats, DashboardStats, @@ -41,16 +49,17 @@ async def dashboard_stats( since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """ - 全局统计(上帝视角)。 + 统计概览(按当前用户的数据范围)。 时间筛选仅影响 COMPLETED / REJECTED 计数; PENDING / WIP / 总数永远返回实时快照。 """ since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_dashboard_stats(db, since=since_dt, until=until_dt) + return await get_dashboard_stats(db, scope, since=since_dt, until=until_dt) @router.get("/my-stats", response_model=MyStats) @@ -77,9 +86,10 @@ async def my_stats( async def wip_tasks( limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)""" - return await get_wip_tasks(db, limit) + return await get_wip_tasks(db, scope, limit) @router.get("/completed-tasks", response_model=list[CompletedTask]) @@ -88,11 +98,12 @@ async def completed_tasks( until: str | None = Query(None, description="截止日期 ISO"), limit: int = Query(200, ge=1, le=500), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """流转完成率下钻 — 按时段查询已完成任务明细""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit) + return await get_completed_tasks(db, scope, since=since_dt, until=until_dt, limit=limit) @router.get("/rejected-tasks", response_model=list[RejectedTask]) @@ -101,11 +112,12 @@ async def rejected_tasks( until: str | None = Query(None, description="截止日期 ISO"), limit: int = Query(200, ge=1, le=500), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit) + return await get_rejected_tasks(db, scope, since=since_dt, until=until_dt, limit=limit) @router.get("/user-operations", response_model=list[UserOperation]) @@ -113,11 +125,12 @@ async def user_operations( since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_user_operations(db, since=since_dt, until=until_dt) + return await get_user_operations(db, scope, since=since_dt, until=until_dt) @router.get("/user-operations/detail", response_model=list[OperationDetail]) @@ -127,12 +140,13 @@ async def user_operations_detail( since: str | None = Query(None, description="起始日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None return await get_user_operation_detail( - db, user_id, action_type, since=since_dt, until=until_dt, + db, user_id, action_type, scope, since=since_dt, until=until_dt, ) @@ -142,11 +156,12 @@ async def wip_matrix( since: str | None = Query(None, description="起始日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt) + return await get_wip_matrix(db, scope, dimension=dimension, since=since_dt, until=until_dt) @router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow]) @@ -156,19 +171,21 @@ async def wip_matrix_detail( since: str | None = Query(None, description="起始日期 ISO"), until: str | None = Query(None, description="截止日期 ISO"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None - return await get_wip_matrix_detail(db, spec_model=spec, process=process, since=since_dt, until=until_dt) + return await get_wip_matrix_detail(db, scope, spec_model=spec, process=process, since=since_dt, until=until_dt) @router.get("/people-workload", response_model=list[PersonWorkload]) async def people_workload( db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)""" - return await get_people_workload(db) + return await get_people_workload(db, scope) @router.get("/people-history", response_model=list[PersonHistoryRecord]) @@ -180,12 +197,13 @@ async def people_history( product_sn: str | None = Query(None, description="身份证(模糊)"), task_name: str | None = Query(None, description="任务名(模糊)"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """人员效能与工时台账 — 平铺 Task 明细,多维筛选 + 时间交集""" since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None return await get_people_history( - db, since=since_dt, until=until_dt, + db, scope, since=since_dt, until=until_dt, assignee_id=assignee_id, spec_model=spec_model, product_sn=product_sn, task_name=task_name, ) @@ -200,12 +218,17 @@ async def export_people_history( product_sn: str | None = Query(None, description="身份证(模糊)"), task_name: str | None = Query(None, description="任务名(模糊)"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): - """导出工时台账为 Excel(与查询接口相同筛选条件)""" + """导出工时台账为 Excel(与查询接口相同筛选条件) + + ⚠️ 本接口此前是**匿名可访问**的,能批量导出全员工时台账。挂上 get_data_scope + 后既要求登录、又按业务分组范围过滤 —— 这是本次改造顺带堵上的已知风险。 + """ since_dt = datetime.fromisoformat(since) if since else None until_dt = datetime.fromisoformat(until) if until else None records = await get_people_history( - db, since=since_dt, until=until_dt, + db, scope, since=since_dt, until=until_dt, assignee_id=assignee_id, spec_model=spec_model, product_sn=product_sn, task_name=task_name, ) @@ -247,11 +270,12 @@ async def dashboard_messages( skip: int = Query(0, ge=0), limit: int = Query(30, ge=1, le=200), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """ - 协同留言搜索(上帝视角 — 全厂所有产品留言)。 + 协同留言搜索(按当前用户的数据范围过滤)。 关联 Product 表返回 serial_number + material_name, 按时间倒序排列。 """ - return await search_product_messages(db, keyword=keyword, skip=skip, limit=limit) + return await search_product_messages(db, scope, keyword=keyword, skip=skip, limit=limit) diff --git a/backend/app/api/v1/endpoints/screen.py b/backend/app/api/v1/endpoints/screen.py index 7455682..d343bbc 100644 --- a/backend/app/api/v1/endpoints/screen.py +++ b/backend/app/api/v1/endpoints/screen.py @@ -4,11 +4,16 @@ 与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表), /screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。 + +⚠️ 2026-09 起不再是「上帝视角」:三个端点都挂了 get_data_scope, + 结果按当前用户的业务分组范围过滤(超管不受限),且不再允许匿名访问。 """ from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db +from app.core.deps import get_data_scope +from app.services.data_scope_service import DataScope from app.services.screen_service import ( get_monthly_metrics, MonthlyMetrics, get_wip_distribution, WipDistributionResponse, @@ -19,34 +24,42 @@ router = APIRouter(prefix="/screen", tags=["大屏统计"]) @router.get("/monthly-metrics", response_model=MonthlyMetrics) -async def monthly_metrics(db: AsyncSession = Depends(get_db)): +async def monthly_metrics( + db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), +): """ - 当月吞吐 — 大屏顶部四张数字卡。 + 当月吞吐 — 大屏顶部四张数字卡(按当前用户的数据范围)。 返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。 统计区间为北京时间当月 1 日 00:00 至此刻。 """ - return await get_monthly_metrics(db) + return await get_monthly_metrics(db, scope) @router.get("/wip-distribution", response_model=WipDistributionResponse) -async def wip_distribution(db: AsyncSession = Depends(get_db)): +async def wip_distribution( + db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), +): """ - 工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。 + 工序积压分布 — 当前数据范围内未完结设备按 overall_status 聚合的**纯数量**。 - 返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。 + 返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷; + 工序柱本身也按数据范围裁剪(不属于本组阶段的工序不画)。 """ - return await get_wip_distribution(db) + return await get_wip_distribution(db, scope) @router.get("/active-users", response_model=ActiveUsersResponse) async def active_users( top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"), db: AsyncSession = Depends(get_db), + scope: DataScope = Depends(get_data_scope), ): """ - 本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。 + 本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数(按当前用户的数据范围)。 桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。 """ - return await get_active_users(db, top_n=top_n) + return await get_active_users(db, scope, top_n=top_n) diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py index b35480b..8f053d1 100644 --- a/backend/app/services/analytics_service.py +++ b/backend/app/services/analytics_service.py @@ -8,6 +8,9 @@ 耗时口径: - 单台真实耗时 = (completed_at or now) - (received_at or created_at),单位小时。 - naive datetime 按 UTC 处理,统一换算北京时间。 + +2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope, +查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。 """ from datetime import datetime, timezone @@ -15,6 +18,8 @@ from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel +from app.services.data_scope_service import DataScope + # ============================================================ # Schemas(与前端 src/services/analyticsApi.ts 对齐) @@ -133,6 +138,7 @@ def _duration_hours(start: datetime | None, end: datetime, holidays: set = None, async def get_capability_profile( db: AsyncSession, + scope: DataScope, assignee_ids: list[str] | None = None, spec_models: list[str] | None = None, since: datetime | None = None, @@ -144,6 +150,8 @@ async def get_capability_profile( 按 (负责人, 设备) 分组,累加该人在该设备所有工序的耗时, 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, @@ -169,6 +177,7 @@ async def get_capability_profile( .where( Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]), Task.assignee_id.isnot(None), + scope.task_where(), # 🚀 业务分组数据范围(已 join Product) ) ) if assignee_ids: @@ -282,6 +291,7 @@ async def get_capability_profile( async def get_flow_compare( db: AsyncSession, + scope: DataScope, product_sns: list[str] | None = None, spec_models: list[str] | None = None, mode: str = "natural", @@ -311,11 +321,13 @@ async def get_flow_compare( now = get_beijing_time() if product_sns: + # 🚀 业务分组数据范围:主体是 Product,直接挂 product_where + # (范围外的身份证查不出来 → 与下钻接口同口径,不会泄露别组设备) 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)) + ).where(Product.serial_number.in_(product_sns), scope.product_where()) )).all() elif spec_models: latest_subq = ( @@ -332,7 +344,7 @@ async def get_flow_compare( Product.material_name, Product.spec_model, ) .join(latest_subq, latest_subq.c.product_id == Product.id) - .where(Product.spec_model.in_(spec_models)) + .where(Product.spec_model.in_(spec_models), scope.product_where()) # 🚀 业务分组数据范围(主体 Product) .order_by(latest_subq.c.latest.desc()) .limit(20) )).all() @@ -346,16 +358,20 @@ async def get_flow_compare( product_ids = [r[0] for r in product_rows] # ── 候选设备的全部任务(含工序名,用于区间图) ── + # 🚀 业务分组数据范围:product_ids 已来自上方受限结果,这里把口径写全 + # (Task 主体,必须先 join Product 再挂 task_where;结果集不变) 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, ) + .join(Product, Task.product_id == Product.id) .where( Task.product_id.in_(product_ids), Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]), Task.assignee_id.isnot(None), + scope.task_where(), ) )).all() @@ -497,28 +513,39 @@ async def get_flow_compare( async def get_device_records( db: AsyncSession, + scope: DataScope, product_sn: str, assignee_ids: list[str] | None = None, ) -> list[DeviceRecord]: """查询某台设备(身份证)的所有任务备注记录,含图片,按时间倒序。 - 可选按负责人过滤(assignee_ids)。""" + 可选按负责人过滤(assignee_ids)。 + + 按当前用户的业务分组数据范围过滤 —— 范围外的身份证查不出 product_id, + 直接返回空列表(否则维修组拿生产组的身份证就能读到别组的备注与照片)。 + """ import json from app.models.task import Task, TaskRecord from app.models.product import Product + # 🚀 业务分组数据范围:主体 Product,范围外查不出 product_id → 下方直接空返回 product_id = await db.scalar( - select(Product.id).where(Product.serial_number == product_sn) + select(Product.id).where( + Product.serial_number == product_sn, + scope.product_where(), + ) ) if not product_id: return [] + # 🚀 业务分组数据范围:product_id 已受限,这里把口径写全(Task 主体,先 join Product) 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) + .join(Product, Task.product_id == Product.id) + .where(Task.product_id == product_id, scope.task_where()) ) if assignee_ids: stmt = stmt.where(Task.assignee_id.in_(assignee_ids)) @@ -558,31 +585,45 @@ async def get_device_records( async def get_analytics_options( db: AsyncSession, + scope: DataScope, assignee_ids: list[str] | None = None, spec_models: list[str] | None = None, ) -> AnalyticsOptions: """返回筛选栏选项,支持动态联动: - 传入 spec_models:只返回碰过这些型号的人; - 传入 assignee_ids:只返回这些人处理过的型号; - - devices:根据筛选条件返回关联设备(无筛选则返回最近流转的设备)。""" + - devices:根据筛选条件返回关联设备(无筛选则返回最近流转的设备)。 + + ⚠️ 本函数是**筛选栏下拉的选项源**,四条语句(负责人 / 型号 / 设备,各含 + 带筛选与不带筛选两个分支)必须条条挂上数据范围谓词 —— 漏掉任意一条, + 维修组就能在下拉里看到生产组的人与型号,点开却是空的。 + """ from app.models.task import Task from app.models.product import Product # ── 负责人:从 tasks 去重(可选按 spec_models 过滤) ── if spec_models: + # 🚀 业务分组数据范围:已 join Product,直接挂 task_where 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), + scope.task_where(), ) .distinct() ) else: + # 🚀 业务分组数据范围:本分支原先没有 join,必须补上 Product 才能挂 + # task_where(tasks 表没有 lifecycle_phase) assignee_stmt = ( select(Task.assignee_id) - .where(Task.assignee_id.isnot(None)) + .join(Product, Task.product_id == Product.id) + .where( + Task.assignee_id.isnot(None), + scope.task_where(), + ) .distinct() ) assignee_rows = (await db.execute(assignee_stmt)).all() @@ -600,6 +641,7 @@ async def get_analytics_options( # ── 规格型号 + 物料名(可选按 assignee_ids 过滤;同型号取首个非空物料名) ── if assignee_ids: + # 🚀 业务分组数据范围:主体 Product,直接挂 product_where spec_stmt = ( select(Product.spec_model, Product.material_name) .join(Task, Task.product_id == Product.id) @@ -607,13 +649,19 @@ async def get_analytics_options( Product.spec_model.isnot(None), func.trim(Product.spec_model) != "", Task.assignee_id.in_(assignee_ids), + scope.product_where(), ) .distinct() ) else: + # 🚀 业务分组数据范围:主体 Product,直接挂 product_where spec_stmt = ( select(Product.spec_model, Product.material_name) - .where(Product.spec_model.isnot(None), func.trim(Product.spec_model) != "") + .where( + Product.spec_model.isnot(None), + func.trim(Product.spec_model) != "", + scope.product_where(), + ) .distinct() ) spec_rows = (await db.execute(spec_stmt)).all() @@ -632,10 +680,12 @@ async def get_analytics_options( # ── 设备字典(可选按 assignee_ids / spec_models 过滤;无筛选返回最近流转 100 台) ── if assignee_ids or spec_models: + # 🚀 业务分组数据范围:主体 Product,直接挂 product_where + # (即使上面 join 了 Task,产品去重后条数仍由 Product 决定) device_stmt = select( Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model, - ) + ).where(scope.product_where()) if assignee_ids: device_stmt = device_stmt.join( Task, Task.product_id == Product.id @@ -658,6 +708,7 @@ async def get_analytics_options( Product.material_name, Product.spec_model, ) .join(latest_subq, latest_subq.c.product_id == Product.id) + .where(scope.product_where()) # 🚀 业务分组数据范围(主体 Product) .order_by(latest_subq.c.latest.desc()) .limit(100) ) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 852a2d8..8dc756e 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -1,4 +1,9 @@ -"""Dashboard 统计服务 — 上帝视角(全厂全系统数据,不按用户过滤)""" +"""Dashboard 统计服务 — 按当前用户的**业务分组数据范围**过滤(超管不受限) + +2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope, +查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。 +唯一例外见 get_dashboard_stats 的 unread_notif(刻意不过滤,理由就地写明)。 +""" from datetime import datetime from sqlalchemy import select, func, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -6,6 +11,7 @@ from pydantic import BaseModel from app.core.config import settings from app.core.lifecycle import normalize_after_sales_step +from app.services.data_scope_service import DataScope # ============================================================ @@ -242,11 +248,12 @@ def _rework_visible_condition( async def get_dashboard_stats( db: AsyncSession, + scope: DataScope, since: datetime | None = None, until: datetime | None = None, ) -> DashboardStats: """ - 上帝视角 — 全厂全系统统计。 + 看板统计 — 按当前用户的业务分组数据范围过滤。 时间筛选规则: - PENDING / WIP / 总数:永远忽略时间筛选,返回实时快照。 @@ -305,36 +312,63 @@ async def get_dashboard_stats( select(Task.id).where(*done_time_conds).exists(), ) - p_unfinished = await db.scalar(select(func.count(Product.id)).where(not_finished)) or 0 - p_progress = await db.scalar( - select(func.count(Product.id)).where(not_finished, has_active_task) + # 🚀 业务分组数据范围:产品三态计数均以 Product 为主体,直接挂 product_where + p_unfinished = await db.scalar( + select(func.count(Product.id)).where(not_finished, scope.product_where()) + ) or 0 + p_progress = await db.scalar( + select(func.count(Product.id)).where(not_finished, has_active_task, scope.product_where()) + ) or 0 + p_done = await db.scalar( + select(func.count(Product.id)).where(done_in_range, scope.product_where()) ) or 0 - p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0 # 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结 # (三段互斥,保证进度条总和 = 展示总数) p_pending = max(p_unfinished - p_progress, 0) p_total = p_unfinished + p_done - # ── 任务实时快照(PENDING/WIP — 永远不过滤) ── - t_pending = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_PENDING)) - t_progress = await db.scalar(select(func.count(Task.id)).where(Task.status == TASK_STATUS_WIP)) + # ── 任务实时快照(PENDING/WIP — 永不受时间筛选,但受业务分组数据范围约束) ── + # 🚀 业务分组数据范围:Task 为主体,先 join Product 再 task_where(tasks 表无 lifecycle_phase) + t_pending = await db.scalar( + select(func.count(Task.id)) + .join(Product, Task.product_id == Product.id) + .where(Task.status == TASK_STATUS_PENDING, scope.task_where()) + ) + t_progress = await db.scalar( + select(func.count(Task.id)) + .join(Product, Task.product_id == Product.id) + .where(Task.status == TASK_STATUS_WIP, scope.task_where()) + ) # 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。 # 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件, # 保证「品质异常」卡片数字与点开后的明细条数对得上。 from app.services.screen_service import _month_bounds month_start_utc, _, _ = _month_bounds() + # 🚀 业务分组数据范围:Task 为主体,先 join Product 再 task_where t_rework = await db.scalar( - select(func.count(Task.id)).where( + select(func.count(Task.id)) + .join(Product, Task.product_id == Product.id) + .where( Task.is_rework.is_(True), _rework_visible_condition(month_start_utc, since, until), + scope.task_where(), ) ) # ── 任务已完成/驳回(时间可过滤) ── - t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED) - t_rej_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED) + # 🚀 业务分组数据范围:同上,两条独立语句各自挂 task_where + t_done_q = ( + select(func.count(Task.id)) + .join(Product, Task.product_id == Product.id) + .where(Task.status == TASK_STATUS_COMPLETED, scope.task_where()) + ) + t_rej_q = ( + select(func.count(Task.id)) + .join(Product, Task.product_id == Product.id) + .where(Task.status == TASK_STATUS_REJECTED, scope.task_where()) + ) if since: t_done_q = t_done_q.where(Task.completed_at >= since) t_rej_q = t_rej_q.where(Task.completed_at >= since) @@ -348,10 +382,19 @@ async def get_dashboard_stats( t_total = (t_pending or 0) + (t_progress or 0) + (t_done or 0) + (t_rejected or 0) # ── 通知 & 留言(实时快照) ── + # 🚀 业务分组数据范围:**有意不过滤** —— Notification.task_id 允许为空 + # (ondelete=SET NULL,且留言提醒类通知本就不挂任务),按 Product 过滤会 + # 把这些「无任务关联」的提醒整条漏掉,未读角标会凭空少几颗。 + # 这是刻意为之,不要「顺手补上」过滤。 unread_notif = await db.scalar( select(func.count(Notification.id)).where(Notification.is_read.is_(False)) ) - unread_msg = await db.scalar(select(func.count(ProductMessage.id))) + # 🚀 业务分组数据范围:ProductMessage.product_id 非空,join Product 后按阶段过滤 + unread_msg = await db.scalar( + select(func.count(ProductMessage.id)) + .join(Product, ProductMessage.product_id == Product.id) + .where(scope.product_where()) + ) return DashboardStats( products_total=p_total or 0, @@ -482,8 +525,8 @@ async def get_my_stats( # 在制品看板(永远实时) # ============================================================ -async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]: - """在制品看板 — 永远实时的 PENDING/WIP 任务。 +async def get_wip_tasks(db: AsyncSession, scope: DataScope, limit: int = 500) -> list[WipTask]: + """在制品看板 — 永远实时的 PENDING/WIP 任务(按业务分组数据范围过滤)。 默认返回**全部**活跃任务,limit 只是防御性安全上限。 @@ -502,10 +545,14 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]: holidays = {r[0] for r in hres} # 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护 + # 🚀 业务分组数据范围:已 join Product,直接挂 task_where stmt = ( select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) .join(Product, Task.product_id == Product.id) - .where(Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP])) + .where( + Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), + scope.task_where(), + ) ) result = await db.execute(stmt) rows = result.all() @@ -551,19 +598,24 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]: async def get_completed_tasks( db: AsyncSession, + scope: DataScope, since: datetime | None = None, until: datetime | None = None, limit: int = 200, ) -> list[CompletedTask]: - """按时段查询已完成任务明细(上帝视角),用于「流转完成率」卡片下钻。""" + """按时段查询已完成任务明细(按业务分组数据范围过滤),用于「流转完成率」卡片下钻。 + + 与 get_dashboard_stats 的 t_done 同一口径 + 同一数据范围,保证点进来条数对得上。 + """ from app.models.task import Task, TASK_STATUS_COMPLETED from app.models.product import Product from app.core.time_utils import BEIJING_TZ + # 🚀 业务分组数据范围:已 join Product,直接挂 task_where stmt = ( select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) .join(Product, Task.product_id == Product.id) - .where(Task.status == TASK_STATUS_COMPLETED) + .where(Task.status == TASK_STATUS_COMPLETED, scope.task_where()) ) if since: stmt = stmt.where(Task.completed_at >= since) @@ -608,11 +660,15 @@ async def get_completed_tasks( async def get_rejected_tasks( db: AsyncSession, + scope: DataScope, since: datetime | None = None, until: datetime | None = None, limit: int = 200, ) -> list[RejectedTask]: - """查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。 + """查询驳回/返工任务明细(按业务分组数据范围过滤),用于「驳回/返工」卡片下钻。 + + ⚠️ 两条语句(已驳回 / 返工)都要挂同一个 task_where,否则与 + get_dashboard_stats 的 tasks_rejected + tasks_rework 卡片数字对不上。 返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致): - kind="rejected":被驳回任务,按 completed_at 时间过滤 @@ -641,10 +697,11 @@ async def get_rejected_tasks( return dt.isoformat() # ── 1. 已驳回任务(按时间过滤)── + # 🚀 业务分组数据范围:已 join Product,直接挂 task_where stmt = ( select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) .join(Product, Task.product_id == Product.id) - .where(Task.status == TASK_STATUS_REJECTED) + .where(Task.status == TASK_STATUS_REJECTED, scope.task_where()) ) if since: stmt = stmt.where(Task.completed_at >= since) @@ -659,6 +716,7 @@ async def get_rejected_tasks( # 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。 from app.services.screen_service import _month_bounds month_start_utc, _, _ = _month_bounds() + # 🚀 业务分组数据范围:同口径挂 task_where,保证与卡片 tasks_rework 一致 rework_stmt = ( select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model) .join(Product, Task.product_id == Product.id) @@ -666,6 +724,7 @@ async def get_rejected_tasks( Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED, _rework_visible_condition(month_start_utc, since, until), + scope.task_where(), ) .order_by(Task.created_at.desc()) .limit(limit) @@ -710,9 +769,16 @@ async def get_rejected_tasks( rework_candidates: dict = {} product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id} if product_ids: + # 🚀 业务分组数据范围:本查询也是 Task 主体,一并挂 task_where + # (product_ids 已来自上方受限结果,这里只是把口径写全,结果集不变) cand_rows = await db.execute( select(Task.parent_task_id, Task.product_id, Task.created_at, Task.assignee_id) - .where(Task.is_rework.is_(True), Task.product_id.in_(product_ids)) + .join(Product, Task.product_id == Product.id) + .where( + Task.is_rework.is_(True), + Task.product_id.in_(product_ids), + scope.task_where(), + ) .order_by(Task.created_at.asc()) ) for parent_id, prod_id, created_at, assignee_id in cand_rows.all(): @@ -787,10 +853,11 @@ async def get_rejected_tasks( async def get_user_operations( db: AsyncSession, + scope: DataScope, since: datetime | None = None, until: datetime | None = None, ) -> list[UserOperation]: - """上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。 + """统计**当前数据范围内人员**的操作次数(按时段过滤)。 返回本部门(ORG_DEPARTMENT)全部人员(该时段无操作的计 0),并追加有操作 记录但不在人员清单中的账号(如历史/已离职)。 @@ -803,17 +870,24 @@ async def get_user_operations( """ from app.models.task_log import TaskLog from app.models.task import Task, TaskRecord + from app.models.product import Product from app.core.mom_database import MomSessionLocal from sqlalchemy import text, or_ # ── 1. 接收 / 转交(task_logs 按 operator_id 聚合)── - rcv = func.count().filter(TaskLog.action_type == "receive") - cpl = func.count().filter(TaskLog.action_type == "complete") + # ⚠️ 必须指明具体列:为接入数据范围本查询已 join Product,若写裸 count() + # 会按 join 出的行数重复计数,卡片数字虚高。 + rcv = func.count(TaskLog.id).filter(TaskLog.action_type == "receive") + cpl = func.count(TaskLog.id).filter(TaskLog.action_type == "complete") + # 🚀 业务分组数据范围:TaskLog → Task → Product 两级 join 后挂 task_where stmt = ( select(TaskLog.operator_id, rcv.label("receive"), cpl.label("complete")) + .join(Task, TaskLog.task_id == Task.id) + .join(Product, Task.product_id == Product.id) .where( TaskLog.action_type.in_(["receive", "complete"]), TaskLog.operator_id.isnot(None), + scope.task_where(), ) .group_by(TaskLog.operator_id) ) @@ -825,12 +899,15 @@ async def get_user_operations( op_map = {r[0]: (r[1] or 0, r[2] or 0) for r in op_rows} # ── 2. 上传备注(task_records 按任务 assignee 归因,排除系统自动备注)── + # 🚀 业务分组数据范围:TaskRecord 经 Task 关联到 Product 后挂 task_where rcd_stmt = ( select(Task.assignee_id, func.count(TaskRecord.id)) .join(Task, TaskRecord.task_id == Task.id) + .join(Product, Task.product_id == Product.id) .where( Task.assignee_id.isnot(None), or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), + scope.task_where(), ) .group_by(Task.assignee_id) ) @@ -896,11 +973,12 @@ async def get_user_operations( async def get_wip_matrix( db: AsyncSession, + scope: DataScope, dimension: str = "assignee", since: datetime | None = None, until: datetime | None = None, ) -> list[WipMatrixRow]: - """生产分布透视表:Y=规格型号,X=人员 或 工序,单元格=设备数量。 + """生产分布透视表:Y=规格型号,X=人员 或 工序,单元格=设备数量(按业务分组数据范围过滤)。 核心口径:**每台设备只统计一次**,按它「当前所处工序」归属—— 1. 取该设备最新的一条主分支任务(parent_task_id IS NULL 或 TRANSFER/RECOVERY) @@ -927,6 +1005,9 @@ async def get_wip_matrix( _ActiveTask = aliased(Task, name="active_task") # 每台设备按主任务创建时间倒序,取第一条即「最新主任务」 + # 🚀 业务分组数据范围:主体是 Product(outerjoin tasks 不影响主体),挂 product_where。 + # 矩阵格子的设备数与被过滤掉的设备在这里一次性拦掉,下钻(get_wip_matrix_detail) + # 必须用同一谓词,否则「格子里有数、点进去是空的」。 result = await db.execute( select( Product.id, @@ -957,7 +1038,8 @@ async def get_wip_matrix( Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收) Task.parent_task_id.is_(None), Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), - ) + ), + scope.product_where(), # 🚀 业务分组数据范围 ) .order_by(Product.id, Task.created_at.desc()) ) @@ -1104,6 +1186,7 @@ async def get_wip_matrix( async def get_wip_matrix_detail( db: AsyncSession, + scope: DataScope, spec_model: str, process: str, since: datetime | None = None, @@ -1113,6 +1196,7 @@ async def get_wip_matrix_detail( 口径与 get_wip_matrix 完全一致:每台设备取最新一条主分支任务, 按其在库三态(已完成/已入库/已出库)或工序名归类到 process。 + 数据范围也必须与 get_wip_matrix 同谓词,否则格子里有数、点进来却是空的。 """ from datetime import timezone as dt_timezone from sqlalchemy.orm import aliased @@ -1157,7 +1241,8 @@ async def get_wip_matrix_detail( Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收) Task.parent_task_id.is_(None), Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), - ) + ), + scope.product_where(), # 🚀 业务分组数据范围(与 get_wip_matrix 同谓词) ) .order_by(Product.id, Task.created_at.desc()) ) @@ -1269,13 +1354,16 @@ async def get_wip_matrix_detail( import uuid as uuid_mod from sqlalchemy import func as sa_func uuid_list = [uuid_mod.UUID(m.product_id) for m in matched] + # 🚀 业务分组数据范围:本查询也是 Task 主体,一并挂 task_where + # (uuid_list 已来自上方受限结果,这里只是把口径写全,结果集不变) range_rows = (await db.execute( select( Task.product_id, sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)).label("t0"), sa_func.max(sa_func.coalesce(Task.completed_at, now)).label("tmax"), ) - .where(Task.product_id.in_(uuid_list)) + .join(Product, Task.product_id == Product.id) + .where(Task.product_id.in_(uuid_list), scope.task_where()) .group_by(Task.product_id) )).all() row_by_pid = {m.product_id: m for m in matched} @@ -1297,10 +1385,13 @@ async def get_user_operation_detail( db: AsyncSession, user_id: str, action_type: str, + scope: DataScope, since: datetime | None = None, until: datetime | None = None, ) -> list[OperationDetail]: - """查询某人在指定时段内的某类操作明细。 + """查询某人在指定时段内的某类操作明细(按业务分组数据范围过滤)。 + + 与 get_user_operations 的数字同口径,否则「点数字看到的条数」对不上。 action_type: - receive: 接收(task_logs.action_type='receive') @@ -1326,11 +1417,16 @@ async def get_user_operation_detail( if action_type in ("receive", "transfer"): act = "receive" if action_type == "receive" else "complete" + # 🚀 业务分组数据范围:TaskLog 已 join Task 与 Product,直接挂 task_where stmt = ( select(TaskLog.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskLog.remark) .join(Task, TaskLog.task_id == Task.id) .join(Product, Task.product_id == Product.id) - .where(TaskLog.operator_id == user_id, TaskLog.action_type == act) + .where( + TaskLog.operator_id == user_id, + TaskLog.action_type == act, + scope.task_where(), + ) .order_by(TaskLog.created_at.desc()) ) if since: @@ -1354,6 +1450,7 @@ async def get_user_operation_detail( .where( Task.assignee_id == user_id, or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), + scope.task_where(), # 🚀 业务分组数据范围(已 join Product) ) .order_by(TaskRecord.created_at.desc()) ) @@ -1377,8 +1474,11 @@ async def get_user_operation_detail( # 人员负载(按人聚合在制品设备 — 独立「人员看板」) # ============================================================ -async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]: - """上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。""" +async def get_people_workload(db: AsyncSession, scope: DataScope) -> list[PersonWorkload]: + """按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。 + + 按业务分组数据范围过滤。 + """ from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP from app.models.product import Product from app.models.holiday import Holiday @@ -1399,6 +1499,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]: .where( Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), Task.assignee_id.isnot(None), + scope.task_where(), # 🚀 业务分组数据范围(已 join Product) ) .order_by(Task.assignee_id, Product.created_at.desc()) ) @@ -1473,6 +1574,7 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]: async def get_people_history( db: AsyncSession, + scope: DataScope, since: datetime | None = None, until: datetime | None = None, assignee_id: str | None = None, @@ -1480,7 +1582,12 @@ async def get_people_history( product_sn: str | None = None, task_name: str | None = None, ) -> list[PersonHistoryRecord]: - """上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED)。""" + """人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED,按业务分组数据范围过滤)。 + + ⚠️ 两个子查询(最新有效备注 / 记录总数)只扫 task_records,靠下面的 outerjoin + 挂到本语句的 Task 上,会被主查询的 WHERE(含数据范围谓词)一并收窄, + 故无需在子查询内部再过滤一次。 + """ 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, BEIJING_TZ, to_beijing, working_duration_hours @@ -1554,6 +1661,7 @@ async def get_people_history( .where( Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]), Task.assignee_id.isnot(None), + scope.task_where(), # 🚀 业务分组数据范围(已 join Product) ) ) @@ -1642,12 +1750,13 @@ async def get_people_history( async def search_product_messages( db: AsyncSession, + scope: DataScope, keyword: str = "", skip: int = 0, limit: int = 50, ) -> ProductMessageList: """ - 上帝视角 — 全厂所有产品的协同留言。 + 协同留言搜索 — 只返回当前业务分组数据范围内产品的留言。 关联链: ProductMessage → Product → (material_name, serial_number) 搜索支持: SN码、物料名称、留言人 @@ -1658,9 +1767,12 @@ async def search_product_messages( from app.core.time_utils import BEIJING_TZ # 基础查询 — 同时取出 16位追溯码 + 业务序列号 + # 🚀 业务分组数据范围:已 join Product,直接挂 product_where + # (下方 total 由本 stmt 派生子查询,自动继承同一谓词,条数不会对不上) stmt = ( select(ProductMessage, Product.serial_number, Product.material_name, Product.external_serial) .join(Product, ProductMessage.product_id == Product.id) + .where(scope.product_where()) ) # 关键词搜索 diff --git a/backend/app/services/screen_service.py b/backend/app/services/screen_service.py index 9dd8e98..f1d8e81 100644 --- a/backend/app/services/screen_service.py +++ b/backend/app/services/screen_service.py @@ -10,6 +10,9 @@ 视角说明: 管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」, 而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。 + +2026-09 起不再是「上帝视角」:统计函数一律接收调用方解析好的 DataScope, +查询里按 Product.lifecycle_phase 过滤(谓词只在 data_scope_service 生成,见该模块红线)。 """ from __future__ import annotations @@ -23,11 +26,13 @@ from app.core.lifecycle import ( AFTER_SALES_ONLY_STEPS, LIFECYCLE_AFTER_SALES, LIFECYCLE_PRODUCTION, + allowed_steps, ) 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 +from app.services.data_scope_service import DataScope # 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径 FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库") @@ -78,8 +83,8 @@ class MonthlyMetrics(BaseModel): month_returned: int # 本月进入售后/回流状态的设备数 -async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics: - """当月吞吐四联指标。 +async def get_monthly_metrics(db: AsyncSession, scope: DataScope) -> MonthlyMetrics: + """当月吞吐四联指标(按当前用户的业务分组数据范围过滤)。 口径: - month_production:lifecycle_phase = PRODUCTION,且「本月新建」或 @@ -90,6 +95,9 @@ async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics: - month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的 设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES 的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。 + + ⚠️ month_production 上两条口径叠加(phase=PRODUCTION AND phase IN 范围), + 维修组的本月生产数自然为 0 —— 这正是正确行为,不要为它加特判绕开范围。 """ month_start_utc, now_utc, month_label = _month_bounds() @@ -103,24 +111,33 @@ async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics: ) .exists() ) + # 🚀 业务分组数据范围:主体 Product,直接追加 product_where 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), + scope.product_where(), ) ) or 0 async def _count_log(action_type: str) -> int: + # 🚀 业务分组数据范围:TaskLog 无阶段字段,两级 join 到 Product 后挂 task_where + # (计数列已是 TaskLog.id,join 不会放大行数) return await db.scalar( - select(func.count(TaskLog.id)).where( + select(func.count(TaskLog.id)) + .join(Task, Task.id == TaskLog.task_id) + .join(Product, Task.product_id == Product.id) + .where( TaskLog.action_type == action_type, TaskLog.created_at >= month_start_utc, + scope.task_where(), ) ) or 0 month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND) month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND) + # 🚀 业务分组数据范围:已 join Product,直接挂 product_where month_returned = await db.scalar( select(func.count(func.distinct(Task.product_id))) .join(Product, Task.product_id == Product.id) @@ -128,6 +145,7 @@ async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics: Product.lifecycle_phase == LIFECYCLE_AFTER_SALES, Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)), Task.created_at >= month_start_utc, + scope.product_where(), ) ) or 0 @@ -170,17 +188,50 @@ WIP_STAGES: tuple[tuple[str, str], ...] = ( # 「待启动」对应的真实分组键(overall_status IS NULL / 空串) _UNSTARTED_KEY = "" +# 词表内工序 → 声明的阶段(补画「有数但被范围裁掉」的工序时,用它还原分区着色) +_STAGE_PHASE: dict[str, str] = { + (_UNSTARTED_KEY if s == "待启动" else s): p for s, p in WIP_STAGES +} -async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse: - """工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。 + +def _visible_stages(scope: DataScope) -> tuple[tuple[str, str], ...]: + """按当前业务分组范围裁剪工序柱 —— 只保留本组阶段内的工序。 + + 维修组(AFTER_SALES)的大屏上挂一排恒为 0 的「备货 / 生产 / 测试」柱子没有意义, + 反而让人误以为"有活压着没干"。判定取并集(宁可多留、不可错删): + · 工序自己声明的 phase 落在范围内 ——「待启动」这类合成列按 PRODUCTION 论; + · 或工序名属于该阶段的合法工序词表(词表取自 core.lifecycle,本模块不另抄一份): + 「发货测试」两个阶段都合法,故生产组也照常保留。 + 范围不限(超管 / 未分组主管)时原样返回,保证大屏类目与改造前完全一致。 + """ + if scope.phases is None: + return WIP_STAGES + allowed: set[str] = set() + for phase in scope.phases: + allowed |= set(allowed_steps(phase)) + return tuple( + (stage, phase) + for stage, phase in WIP_STAGES + if phase in scope.phases or stage in allowed + ) + + +async def get_wip_distribution(db: AsyncSession, scope: DataScope) -> WipDistributionResponse: + """工序积压分布 —— 当前数据范围内未完结设备按 overall_status 聚合的数量。 返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而 整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的 count 之和恒等于 total(避免统计悄悄漏数)。 + + ⚠️ 「恒 0 才裁、有数必现」是裁剪的铁律:被 `_visible_stages` 裁掉的工序若 + 名下**真有设备**(如上个阶段的设备卡在旧工序名上没流转走),它会带着 + 真实计数回到列表末尾 —— 宁可多画一根柱子,也绝不把设备藏起来变成 + 「合计 1 台、柱子全是 0」的鬼故事。恒 0 的柱子才真正不画。 """ + # 🚀 业务分组数据范围:主体 Product,直接挂 product_where(计数列是 Product.id,无 join 放大) rows = await db.execute( select(Product.overall_status, func.count(Product.id)) - .where(_not_finished()) + .where(_not_finished(), scope.product_where()) .group_by(Product.overall_status) ) counts: dict[str, int] = {} @@ -190,17 +241,22 @@ async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse: total = sum(counts.values()) + # 🚀 业务分组数据范围:只画本组阶段的工序柱(范围不限时即 WIP_STAGES 原样) + stages = _visible_stages(scope) items: list[WipStage] = [] - for stage, phase in WIP_STAGES: + for stage, phase in 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} + # 词表外的状态追加在末尾。known_keys 只按**可见**柱子算:被裁掉的工序若名下 + # 真有设备,就走这条追加路径回来(阶段着色按词表还原),保证合计不漏数; + # cnt 为 0 的则被下面的 `and cnt` 挡掉 —— 那才是本次裁剪要除掉的那排空柱。 + known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in stages} for key, cnt in counts.items(): if key not in known_keys and cnt: items.append(WipStage( stage=key or "待启动", - phase=LIFECYCLE_PRODUCTION, + phase=_STAGE_PHASE.get(key, LIFECYCLE_PRODUCTION), count=cnt, )) @@ -225,17 +281,23 @@ class ActiveUsersResponse(BaseModel): items: list[ActiveUser] -async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse: +async def get_active_users(db: AsyncSession, scope: DataScope, top_n: int = 5) -> ActiveUsersResponse: """本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。 不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete), 上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员, 避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。 + + 按当前用户的业务分组数据范围过滤:范围直接透传给 get_user_operations, + 两处口径共用一份谓词,不在这里另抄一遍。 """ 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) + # 🚀 业务分组数据范围:由调用方解析好的真实 scope 透传(不再是「不限」占位) + operations = await get_user_operations( + db, scope, since=month_start_utc, until=None, + ) active = [op for op in operations if op.total > 0][:top_n] return ActiveUsersResponse(