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:
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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),
|
||||
|
||||
Reference in New Issue
Block a user