feat: 生产看板多维度重构(全量在制品、时间维度联动、口径提示)

本次只含后端看板接口与 PC 端页面。移动端的体验优化已在之前的 12 个提交中
入库,不在本提交范围内。

后端 · 服务层 dashboard_service.py
- 在制品列表不再按 20 条静默截断:此前 `.limit(limit*2)` 预取再排序切片,
  前端又拿数组长度当总数,表现为「卡片 23、列表却写共 20 个」的数据被吞。
  现默认返回全量,limit 降级为防御性上限
- 产品流转「已完结」接入右上角时间筛选(since/until),废弃硬编码「本月」。
  此前选「今天」也会看到整月产出,业务语义分裂;待流转/流转中仍为实时快照。
  Product 表无 completed_at,以名下工序的完成时间锚定「完结时刻」
- 返工任务可见性:未完结卡点永远展示,已了结的仅限本月,且与卡片数字共用
  同一份条件,避免「按钮显示 2、点开共 0 条」
- 驳回明细的「返工给」改为真实溯源:直接查派生出的返工任务当前 assignee_id,
  不再复刻 reject_task 的推算逻辑(返工任务事后被转交时会显示旧名字)

后端 · 接口层 dashboard.py
- /wip-tasks 的 limit 默认 20→500、上限 100→1000
- /my-stats 支持 since/until,新增接收/转交/上传备注口径(与 PC 人员操作统计
  逐字段交叉验证一致)

PC 端
- AdminDashboard: 在制品计数绑定真实总数并加截断提示、列表改为容器内滚动;
  驳回明细只展示「已驳回」行(返工行不再独立成行,其信息已由「返工给」承载);
  「待流转」「待接收」图例新增口径 Tooltip,消除「产品数 > 任务数」的误解;
  移除已失真的时间筛选提示条
- App.tsx: dayjs 中文 locale 上提到应用根。此前散落在 AdminPeoplePage /
  AnalyticsDashboard 两个页面里,而路由是 lazy 分割的 —— 直接打开概览页时
  那两个模块未加载,日期面板就露出 Sep/Su/Mo
- AdminPeoplePage / AnalyticsDashboard: 移除重复的页面级 locale 设置
- dashboardApi.ts: fetchWipTasks 默认值同步为 500
This commit is contained in:
2026-09-16 14:29:56 +08:00
parent 6bc6531e6a
commit dcc92b223e
7 changed files with 268 additions and 129 deletions

View File

@ -75,10 +75,10 @@ async def my_stats(
@router.get("/wip-tasks", response_model=list[WipTask])
async def wip_tasks(
limit: int = Query(20, ge=1, le=100),
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
db: AsyncSession = Depends(get_db),
):
"""在制品看板 — 永远实时的 PENDING/WIP 任务"""
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
return await get_wip_tasks(db, limit)

View File

@ -182,6 +182,59 @@ class ProductMessageList(BaseModel):
total: int
# ============================================================
# 看板「当下性」闸门 —— 返工任务可见性
# ============================================================
def _rework_visible_condition(
month_start: datetime,
since: datetime | None = None,
until: datetime | None = None,
):
"""构造返工任务在看板上的可见条件。
⚠️ 卡片数字get_dashboard_stats.tasks_rework与明细抽屉
get_rejected_tasks 的 kind="rework"**必须共用这一份**,否则会出现
「按钮显示 2、点开却是共 0 条」的自相矛盾。
背景2026-09 整改):原实现里返工查询是 `is_rework=True AND status != REJECTED`
的纯实时快照,没有任何状态/时间边界,导致上季度甚至更早的「已返工完成」记录
无限期挂在品质异常区持续报警 —— 报警永远不消,等于没有报警。
规则:
- 未完结卡点PENDING / WIP无论发生在何时都展示。没解决的异常必须
一直可见,这正是看板的价值所在。
- 已了结的COMPLETED / ARCHIVED / CANCELED …):只保留「本月 1 日
(北京时间)之后」的,并进一步尊重调用方所选时段。
用 status.notin_(开放态) 而不是枚举已了结态:将来新增状态时会自动落进
「已了结」一侧(受时间约束),而不是凭空消失或无限期留存。
"""
from datetime import timezone
from sqlalchemy import and_
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
open_states = [TASK_STATUS_PENDING, TASK_STATUS_WIP]
open_cond = Task.status.in_(open_states)
# 已了结项的时间下限 = max(本月 1 日, 调用方给的 since)
# 既保证「绝不早于本月」,也不会越过用户选定的时段。
# since 可能是裸时间(无时区),先补成 UTC 再比较,否则 max() 会抛 TypeError。
floor = month_start
if since is not None:
floor = max(floor, since if since.tzinfo else since.replace(tzinfo=timezone.utc))
closed_cond = and_(
Task.status.notin_(open_states),
Task.completed_at.isnot(None),
Task.completed_at >= floor,
)
if until is not None:
closed_cond = and_(closed_cond, Task.completed_at <= until)
return or_(open_cond, closed_cond)
# ============================================================
# 看板统计(时间快照语义)
# ============================================================
@ -205,32 +258,78 @@ async def get_dashboard_stats(
)
from app.models.notification import Notification
from app.models.message import ProductMessage
# ── 产品(实时快照,不过滤) ──
# ── 产品流转三段 ──
# 状态口径(用户确认):废弃 Product.status 恒值判断,
# COMPLETED=待仓库收货ARCHIVED=已入库('在库' 旧命名等价OUTBOUND=已出库。
# 产品流转卡片第三段"已完结"= 待收货 + 已实收 + 旧在库 + 已出库,保证三段和 = 总数。
p_total = await db.scalar(select(func.count(Product.id)))
#
# ⚠️「已完结」= 产出必须响应用户在右上角选的时间范围since / until
# 不能钉死在某个固定窗口 —— 否则选「今天」却看到「本月」的产出,业务语义就裂了。
# 历史上这里先是无边界(历史全量无限累加),后又改成硬编码「本月」,两次都错。
# Product 表没有 completed_at / updated_at只有 created_at故用它名下工序的
# 完成时间来锚定「完结时刻」:只要有一条 Task.completed_at 落在所选时段内即计入。
#
# 「待流转 / 流转中」保持实时快照:只要还没完结,不论多久以前建的单都要统计出来 ——
# 它们才是车间真实的积压物料,正是看板要盯的东西。
from sqlalchemy import and_
# 时间下限优先取调用方所选时段的起点;未给时段时退回「本月」作为**防御性下限**
# 避免退化成历史全量累加(注意用独立变量,不要覆盖 since —— 下方 t_done / t_rej
# 与返工可见性还要用它,改了会连带改变那些口径)。
if since is not None:
done_since = since
else:
from app.services.screen_service import _month_bounds
done_since = _month_bounds()[0]
finished_cond = Product.overall_status.in_(["待仓库收货", "已入库", "在库", "已出库"])
p_done = await db.scalar(select(func.count(Product.id)).where(finished_cond))
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
# 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数
not_finished = or_(Product.overall_status.is_(None), ~finished_cond)
has_active_task = select(Task.id).where(
Task.product_id == Product.id,
Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]),
).exists()
p_progress = await db.scalar(
select(func.count(Product.id)).where(not_finished, has_active_task)
# 所选时段内完结 = 已完结 且 名下有工序在该时段内完成
done_time_conds = [
Task.product_id == Product.id,
Task.completed_at.isnot(None),
Task.completed_at >= done_since,
]
if until is not None:
done_time_conds.append(Task.completed_at <= until)
done_in_range = and_(
finished_cond,
select(Task.id).where(*done_time_conds).exists(),
)
# 待流转 = 总数 - 在制 - 完结(三段互斥,保证进度条总和=总数)
p_pending = max((p_total or 0) - (p_progress or 0) - (p_done or 0), 0)
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)
) or 0
p_done = await db.scalar(select(func.count(Product.id)).where(done_in_range)) or 0
# ── 任务实时快照PENDING/WIP/返工 — 永远不过滤) ──
# 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结
# (三段互斥,保证进度条总和 = 展示总数)
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))
t_rework = await db.scalar(select(func.count(Task.id)).where(Task.is_rework.is_(True)))
# 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。
# 与 get_rejected_tasks 的 kind="rework" 共用同一份可见性条件,
# 保证「品质异常」卡片数字与点开后的明细条数对得上。
from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds()
t_rework = await db.scalar(
select(func.count(Task.id)).where(
Task.is_rework.is_(True),
_rework_visible_condition(month_start_utc, since, until),
)
)
# ── 任务已完成/驳回(时间可过滤) ──
t_done_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_COMPLETED)
@ -382,7 +481,16 @@ async def get_my_stats(
# 在制品看板(永远实时)
# ============================================================
async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
async def get_wip_tasks(db: AsyncSession, limit: int = 500) -> list[WipTask]:
"""在制品看板 — 永远实时的 PENDING/WIP 任务。
默认返回**全部**活跃任务limit 只是防御性安全上限。
⚠️ 这里曾默认 limit=20且先 `.limit(limit * 2)` 预取 40 条、按滞留时长排序后
再 `[:limit]` 截断 —— 列表被静默砍到 20 条,而卡片数字来自
tasks_pending + tasks_in_progress 的真实计数,于是看板上出现
「卡片 23、列表却写共 20 个」的数据被吞现象。
"""
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
from app.models.product import Product
from app.models.holiday import Holiday
@ -392,11 +500,11 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
hres = await db.execute(select(Holiday.day))
holidays = {r[0] for r in hres}
# 不在 SQL 层做预取限制:排序在内存里按滞留时长进行,截断只作为上限保护
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]))
.limit(limit * 2)
)
result = await db.execute(stmt)
rows = result.all()
@ -433,7 +541,7 @@ async def get_wip_tasks(db: AsyncSession, limit: int = 20) -> list[WipTask]:
))
# 统一按滞留时间降序排列(无视 status纯数值排序
wip_list.sort(key=lambda t: t.duration_hours, reverse=True)
return wip_list[:limit]
return wip_list[:limit] # limit 仅作上限保护,默认 500 实际不会截断
# ============================================================
@ -507,10 +615,14 @@ async def get_rejected_tasks(
返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致):
- kind="rejected":被驳回任务,按 completed_at 时间过滤
- kind="rework"返工任务is_rework=True 且非驳回状态),实时快照不过滤时间
- kind="rework"返工任务is_rework=True 且非驳回状态)
未完结PENDING/WIP永远展示已了结的仅限本月 —— 详见
_rework_visible_condition。该条件与 get_dashboard_stats 的 t_rework
共用,保证卡片数字与明细条数一致。
返工负责人追溯逻辑与 task_service.reject_task 一致:
优先最早 create log 的 operator → 兜底父任务负责人 → 兜底自身。
返工负责人rework_assignee不再复刻 reject_task 的推算逻辑,而是直接查
本次驳回派生出的那条返工任务、读其**当前** assignee_id —— 这样返工任务事后
被转交/撤回时,看板显示的仍是真实责任人。详见 _derived_rework_assignee。
"""
from app.models.task import Task, TASK_STATUS_REJECTED
from app.models.task_log import TaskLog
@ -540,11 +652,20 @@ async def get_rejected_tasks(
stmt = stmt.order_by(Task.completed_at.desc()).limit(limit)
rejected_rows = (await db.execute(stmt)).all()
# ── 2. 返工任务is_rework=True 且当前非驳回状态,实时不过滤时间)──
# ── 2. 返工任务is_rework=True 且当前非驳回状态)──
# 原实现此处无任何状态/时间边界,是本页「数据发霉」的根源:上季度甚至更早的
# 「已返工完成」记录会无限期留在品质异常区。改由 _rework_visible_condition
# 统一约束 —— 未完结卡点永远展示,已了结的仅限本月(详见该函数说明)。
from app.services.screen_service import _month_bounds
month_start_utc, _, _ = _month_bounds()
rework_stmt = (
select(Task, Product.serial_number, Product.external_serial, Product.material_name, Product.spec_model)
.join(Product, Task.product_id == Product.id)
.where(Task.is_rework.is_(True), Task.status != TASK_STATUS_REJECTED)
.where(
Task.is_rework.is_(True),
Task.status != TASK_STATUS_REJECTED,
_rework_visible_condition(month_start_utc, since, until),
)
.order_by(Task.created_at.desc())
.limit(limit)
)
@ -575,41 +696,44 @@ async def get_rejected_tasks(
if row[1]:
reject_op[row[0]] = row[1]
create_op: dict = {}
if rejected_ids:
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_(rejected_ids), TaskLog.action_type == "create")
).subquery()
r = await db.execute(select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1))
for row in r:
if row[1]:
create_op[row[0]] = row[1]
parent_assignee: dict = {}
parent_ids = [t.parent_task_id for t, *_ in rejected_rows if t.parent_task_id and t.id not in create_op]
if parent_ids:
r = await db.execute(
select(Task.id, Task.assignee_id).where(Task.id.in_(parent_ids))
# ── 派生返工任务的真实负责人 ──
# 不再复刻 reject_task 的推算逻辑去「猜」当时派给了谁 —— 那只是把当时的推导
# 重跑一遍,返工任务事后一旦被转交/撤回,看板就会继续显示当初那个旧名字。
# 改为去库里定位真正由本次驳回派生出的那条返工任务,读它**当前**的 assignee_id。
#
# 定位方式reject_task 建返工任务时只继承了 product_id 与 parent_task_id
# (数据模型里没有「派生自哪条任务」的外键),故按「同产品 + 同父节点 +
# is_rework」筛出候选再用「创建时间在驳回之后的最早一条」消歧 —— 若返工任务
# 又被驳回,会再派生一条更晚的,取最早即命中本次那条。
# 实测两者时间戳完全相等reject_task 里用的是同一个 now。
rework_candidates: dict = {}
product_ids = {t.product_id for t, *_ in rejected_rows if t.product_id}
if 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))
.order_by(Task.created_at.asc())
)
for row in r:
if row[1]:
parent_assignee[row[0]] = row[1]
for parent_id, prod_id, created_at, assignee_id in cand_rows.all():
rework_candidates.setdefault((parent_id, prod_id), []).append((created_at, assignee_id))
def _derived_rework_assignee(task) -> str | None:
"""取本次驳回派生出的返工任务的**当前**负责人。"""
cands = rework_candidates.get((task.parent_task_id, task.product_id)) or []
if not cands:
return None # 找不到就如实返回空,不拿推算值冒充
for created_at, assignee_id in cands: # 已按 created_at 升序
if task.completed_at and created_at and created_at >= task.completed_at:
return assignee_id
return cands[0][1] # 缺 completed_at 的历史数据:退回最早一条
# ── 中文名映射(驳回人 + 返工负责人 一次批量查)──
derived_map = {t.id: _derived_rework_assignee(t) for t, *_ in rejected_rows}
raw_ids: set[str] = set()
for kind, (t, *_row) in all_rows:
if kind == "rejected":
raw_ids.add(reject_op.get(t.id) or "")
raw_ids.add(create_op.get(t.id) or "")
if t.id not in create_op and t.parent_task_id:
raw_ids.add(parent_assignee.get(t.parent_task_id) or "")
raw_ids.add(derived_map.get(t.id) or "")
raw_ids.add(t.assignee_id or "")
raw_ids.discard("")
name_map: dict[str, str] = {}
@ -620,12 +744,8 @@ async def get_rejected_tasks(
items: list[RejectedTask] = []
for kind, (task, sn, ext, mat, spec) in all_rows:
if kind == "rejected":
# 复刻 reject_task 追溯逻辑create op → 父任务负责人 → 自身
rework_id = create_op.get(task.id)
if not rework_id and task.parent_task_id:
rework_id = parent_assignee.get(task.parent_task_id)
if not rework_id:
rework_id = task.assignee_id
# 直接取派生返工任务的真实负责人(见 _derived_rework_assignee
rework_id = derived_map.get(task.id)
rejected_by_id = reject_op.get(task.id) or task.assignee_id
items.append(RejectedTask(
task_id=str(task.id),