feat(dashboard): 人员工时台账——平铺明细+多维筛选+时间交集+动态工时+备注+CSV导出
This commit is contained in:
@ -1,6 +1,8 @@
|
||||
"""Dashboard API — 上帝视角(全厂数据,无用户过滤)"""
|
||||
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.services.dashboard_service import (
|
||||
@ -8,6 +10,7 @@ from app.services.dashboard_service import (
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_people_workload, PersonWorkload,
|
||||
get_people_history, PersonHistoryRecord,
|
||||
search_product_messages, ProductMessageList,
|
||||
)
|
||||
|
||||
@ -61,6 +64,76 @@ async def people_workload(
|
||||
return await get_people_workload(db)
|
||||
|
||||
|
||||
@router.get("/people-history", response_model=list[PersonHistoryRecord])
|
||||
async def people_history(
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
assignee_id: str | None = Query(None, description="负责人ID(精确)"),
|
||||
spec_model: str | None = Query(None, description="规格型号(模糊)"),
|
||||
product_sn: str | None = Query(None, description="身份证(模糊)"),
|
||||
task_name: str | None = Query(None, description="任务名(模糊)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员效能与工时台账 — 平铺 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,
|
||||
assignee_id=assignee_id, spec_model=spec_model,
|
||||
product_sn=product_sn, task_name=task_name,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/people-history/export")
|
||||
async def export_people_history(
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
assignee_id: str | None = Query(None, description="负责人ID(精确)"),
|
||||
spec_model: str | None = Query(None, description="规格型号(模糊)"),
|
||||
product_sn: str | None = Query(None, description="身份证(模糊)"),
|
||||
task_name: str | None = Query(None, description="任务名(模糊)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出工时台账为 Excel(与查询接口相同筛选条件)"""
|
||||
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,
|
||||
assignee_id=assignee_id, spec_model=spec_model,
|
||||
product_sn=product_sn, task_name=task_name,
|
||||
)
|
||||
|
||||
import csv
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["状态", "负责人", "身份证", "业务序列号", "产品名称", "规格型号", "任务名", "开始时间", "结束时间", "总耗时(小时)", "最新有效备注"])
|
||||
status_label = {"WIP": "进行中", "PENDING": "待接收", "COMPLETED": "已完成"}
|
||||
for r in records:
|
||||
writer.writerow([
|
||||
status_label.get(r.status, r.status),
|
||||
r.assignee_name,
|
||||
r.product_sn,
|
||||
r.external_serial or "",
|
||||
r.material_name,
|
||||
r.spec_model,
|
||||
r.task_name,
|
||||
r.received_at or "",
|
||||
r.completed_at or "进行中",
|
||||
r.duration_hours,
|
||||
r.latest_valid_remark or "",
|
||||
])
|
||||
|
||||
data = output.getvalue().encode("utf-8-sig") # 带 BOM,Excel 正确识别中文
|
||||
buf = io.BytesIO(data)
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=people_history.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ProductMessageList)
|
||||
async def dashboard_messages(
|
||||
keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"),
|
||||
|
||||
@ -55,6 +55,8 @@ class PersonDevice(BaseModel):
|
||||
material_name: str # 产品名称
|
||||
spec_model: str # 规格型号
|
||||
task_status: str # 该设备名下的状态 WIP/PENDING
|
||||
duration_hours: float # 滞留时长(在当前人手上多久,小时)
|
||||
received_at: str | None # 接收时间(北京时间 MM-DD HH:mm)
|
||||
|
||||
|
||||
class PersonWorkload(BaseModel):
|
||||
@ -64,6 +66,23 @@ class PersonWorkload(BaseModel):
|
||||
devices: list[PersonDevice]
|
||||
|
||||
|
||||
class PersonHistoryRecord(BaseModel):
|
||||
task_id: str
|
||||
task_name: str # 工序/任务名
|
||||
assignee_id: str # 负责人ID
|
||||
assignee_name: str # 负责人姓名
|
||||
product_sn: str # 16位身份证
|
||||
external_serial: str | None # 业务序列号
|
||||
material_name: str # 产品名称
|
||||
spec_model: str # 规格型号
|
||||
status: str # 状态 WIP/PENDING/COMPLETED
|
||||
received_at: str | None # 开始时间 MM-DD HH:mm
|
||||
completed_at: str | None # 结束时间 MM-DD HH:mm(进行中为 None)
|
||||
duration_hours: float # 总耗时(小时)
|
||||
latest_valid_remark: str | None = None # 最新有效备注(排除系统转交类)
|
||||
record_count: int = 0 # 记录总数(含系统备注)
|
||||
|
||||
|
||||
class ProductMessageItem(BaseModel):
|
||||
id: str
|
||||
content: str
|
||||
@ -269,16 +288,17 @@ async def get_completed_tasks(
|
||||
# ============================================================
|
||||
|
||||
async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
|
||||
"""上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重)。"""
|
||||
"""上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。"""
|
||||
from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP
|
||||
from app.models.product import Product
|
||||
from app.core.time_utils import get_beijing_time, BEIJING_TZ
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Task.assignee_id,
|
||||
Product.id, Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
Task.status,
|
||||
Task.status, Task.received_at, Task.created_at,
|
||||
)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(
|
||||
@ -290,46 +310,239 @@ async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]:
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
# 按 assignee 聚合,product 去重;状态优先级 WIP > PENDING
|
||||
by_assignee: dict[str, dict[str, PersonDevice]] = {}
|
||||
def _to_bj(dt):
|
||||
if not dt:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# 中间聚合:by_assignee[assignee][product_id] -> 设备快照 + 最早接手时间
|
||||
agg: dict[str, dict[str, dict]] = {}
|
||||
for row in rows:
|
||||
assignee = row[0]
|
||||
product_id = str(row[1])
|
||||
status = row[6] or ""
|
||||
devices = by_assignee.setdefault(assignee, {})
|
||||
if product_id in devices:
|
||||
# 已有该设备:若新状态为 WIP 则提升(更活跃)
|
||||
received_dt = _to_bj(row[7] or row[8])
|
||||
entry = agg.setdefault(assignee, {}).setdefault(product_id, {
|
||||
"status": "",
|
||||
"earliest_dt": None,
|
||||
"serial": row[2] or "",
|
||||
"ext": row[3] or None,
|
||||
"mat": row[4] or "",
|
||||
"spec": row[5] or "",
|
||||
})
|
||||
# 状态优先级 WIP > PENDING
|
||||
if status == "WIP":
|
||||
devices[product_id].task_status = "WIP"
|
||||
continue
|
||||
devices[product_id] = PersonDevice(
|
||||
product_id=product_id,
|
||||
serial_number=row[2] or "",
|
||||
external_serial=row[3] or None,
|
||||
material_name=row[4] or "",
|
||||
spec_model=row[5] or "",
|
||||
task_status=status,
|
||||
)
|
||||
entry["status"] = "WIP"
|
||||
elif not entry["status"]:
|
||||
entry["status"] = status
|
||||
# 取最早接手时间
|
||||
if received_dt and (entry["earliest_dt"] is None or received_dt < entry["earliest_dt"]):
|
||||
entry["earliest_dt"] = received_dt
|
||||
|
||||
raw_ids = list(by_assignee.keys())
|
||||
raw_ids = list(agg.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)
|
||||
|
||||
workloads = [
|
||||
PersonWorkload(
|
||||
workloads: list[PersonWorkload] = []
|
||||
for assignee, prods in agg.items():
|
||||
devices: list[PersonDevice] = []
|
||||
for product_id, e in prods.items():
|
||||
dt = e["earliest_dt"]
|
||||
hours = round((now - dt).total_seconds() / 3600, 1) if dt else 0.0
|
||||
received_str = dt.strftime("%m-%d %H:%M") if dt else None
|
||||
devices.append(PersonDevice(
|
||||
product_id=product_id,
|
||||
serial_number=e["serial"],
|
||||
external_serial=e["ext"],
|
||||
material_name=e["mat"],
|
||||
spec_model=e["spec"],
|
||||
task_status=e["status"],
|
||||
duration_hours=hours,
|
||||
received_at=received_str,
|
||||
))
|
||||
workloads.append(PersonWorkload(
|
||||
assignee_id=assignee,
|
||||
assignee_name=name_map.get(assignee, assignee),
|
||||
device_count=len(devices),
|
||||
devices=list(devices.values()),
|
||||
)
|
||||
for assignee, devices in by_assignee.items()
|
||||
]
|
||||
devices=devices,
|
||||
))
|
||||
workloads.sort(key=lambda w: w.device_count, reverse=True)
|
||||
return workloads
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 历史人员看板(按人聚合历史备注记录,按时段过滤)
|
||||
# ============================================================
|
||||
|
||||
async def get_people_history(
|
||||
db: AsyncSession,
|
||||
since: datetime | None = None,
|
||||
until: datetime | None = None,
|
||||
assignee_id: str | None = None,
|
||||
spec_model: str | None = None,
|
||||
product_sn: str | None = None,
|
||||
task_name: str | None = None,
|
||||
) -> list[PersonHistoryRecord]:
|
||||
"""上帝视角 — 人员效能与工时台账(平铺 Task 明细,含 WIP/PENDING/COMPLETED)。"""
|
||||
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
|
||||
from sqlalchemy import func
|
||||
|
||||
now = get_beijing_time()
|
||||
|
||||
# ── 关联 TaskRecord:最新有效备注 + 记录总数 ──
|
||||
from app.models.task import TaskRecord
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# 系统自动备注关键字(转交/移交/撤回/派发/驳回等)
|
||||
sys_remark = or_(
|
||||
TaskRecord.remark.ilike("%转交%"),
|
||||
TaskRecord.remark.ilike("%移交%"),
|
||||
TaskRecord.remark.ilike("%撤回%"),
|
||||
TaskRecord.remark.ilike("%重新接手%"),
|
||||
TaskRecord.remark.ilike("%派发%"),
|
||||
TaskRecord.remark.ilike("%分配%"),
|
||||
TaskRecord.remark.ilike("%驳回%"),
|
||||
TaskRecord.remark.ilike("%返工%"),
|
||||
TaskRecord.remark.ilike("%完工%"),
|
||||
)
|
||||
|
||||
# 最新有效备注(排除系统备注,按时间倒序取第一条)
|
||||
valid_remark_subq = (
|
||||
select(
|
||||
TaskRecord.task_id,
|
||||
TaskRecord.remark.label("latest_valid_remark"),
|
||||
func.row_number().over(
|
||||
partition_by=TaskRecord.task_id,
|
||||
order_by=TaskRecord.created_at.desc(),
|
||||
).label("rn"),
|
||||
)
|
||||
.where(
|
||||
TaskRecord.remark.isnot(None),
|
||||
func.trim(TaskRecord.remark) != "",
|
||||
~sys_remark,
|
||||
)
|
||||
).subquery("vr")
|
||||
|
||||
# 记录总数(含系统备注)
|
||||
record_count_subq = (
|
||||
select(
|
||||
TaskRecord.task_id,
|
||||
func.count(TaskRecord.id).label("record_count"),
|
||||
)
|
||||
.group_by(TaskRecord.task_id)
|
||||
).subquery("rc")
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Task.id, Task.task_name, Task.assignee_id, Task.status,
|
||||
Task.received_at, Task.created_at, Task.completed_at,
|
||||
Product.serial_number, Product.external_serial,
|
||||
Product.material_name, Product.spec_model,
|
||||
valid_remark_subq.c.latest_valid_remark,
|
||||
func.coalesce(record_count_subq.c.record_count, 0).label("record_count"),
|
||||
)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.outerjoin(
|
||||
valid_remark_subq,
|
||||
and_(valid_remark_subq.c.task_id == Task.id, valid_remark_subq.c.rn == 1),
|
||||
)
|
||||
.outerjoin(record_count_subq, record_count_subq.c.task_id == Task.id)
|
||||
.where(
|
||||
Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]),
|
||||
Task.assignee_id.isnot(None),
|
||||
)
|
||||
)
|
||||
|
||||
# 多维筛选(模糊/精确匹配)
|
||||
if assignee_id:
|
||||
stmt = stmt.where(Task.assignee_id == assignee_id)
|
||||
if spec_model:
|
||||
stmt = stmt.where(Product.spec_model.ilike(f"%{spec_model.strip()}%"))
|
||||
if product_sn:
|
||||
stmt = stmt.where(Product.serial_number.ilike(f"%{product_sn.strip()}%"))
|
||||
if task_name:
|
||||
stmt = stmt.where(Task.task_name.ilike(f"%{task_name.strip()}%"))
|
||||
|
||||
# 时间交集:start = COALESCE(received_at, created_at),end = COALESCE(completed_at, now)
|
||||
start_expr = func.coalesce(Task.received_at, Task.created_at)
|
||||
end_expr = func.coalesce(Task.completed_at, now)
|
||||
if until:
|
||||
stmt = stmt.where(start_expr <= until)
|
||||
if since:
|
||||
stmt = stmt.where(end_expr >= since)
|
||||
|
||||
# 排序:先进行中(WIP/PENDING),后已完成(COMPLETED),状态内按开始时间降序
|
||||
from sqlalchemy import case
|
||||
status_prio = case(
|
||||
(Task.status == TASK_STATUS_WIP, 0),
|
||||
(Task.status == TASK_STATUS_PENDING, 1),
|
||||
(Task.status == TASK_STATUS_COMPLETED, 2),
|
||||
else_=3,
|
||||
)
|
||||
stmt = stmt.order_by(
|
||||
status_prio.asc(),
|
||||
func.coalesce(Task.received_at, Task.created_at).desc(),
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
def _to_bj(dt):
|
||||
if not dt:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
from datetime import timezone as dt_timezone
|
||||
dt = dt.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ)
|
||||
else:
|
||||
dt = dt.astimezone(BEIJING_TZ)
|
||||
return dt
|
||||
|
||||
raw_ids = list({r[2] for r in rows if r[2]})
|
||||
name_map: dict[str, str] = {}
|
||||
if raw_ids:
|
||||
from app.services.mom_cache import get_display_names
|
||||
name_map = get_display_names(raw_ids)
|
||||
|
||||
records: list[PersonHistoryRecord] = []
|
||||
for row in rows:
|
||||
received_dt = _to_bj(row[4] or row[5]) # received_at or created_at
|
||||
completed_dt = _to_bj(row[6]) # completed_at(进行中为 None)
|
||||
if completed_dt:
|
||||
end_dt = completed_dt
|
||||
completed_str = completed_dt.strftime("%m-%d %H:%M")
|
||||
else:
|
||||
end_dt = now
|
||||
completed_str = None
|
||||
hours = round((end_dt - received_dt).total_seconds() / 3600, 1) if received_dt else 0.0
|
||||
records.append(PersonHistoryRecord(
|
||||
task_id=str(row[0]),
|
||||
task_name=row[1] or "",
|
||||
assignee_id=row[2] or "",
|
||||
assignee_name=name_map.get(row[2] or "", row[2] or ""),
|
||||
product_sn=row[7] or "",
|
||||
external_serial=row[8] or None,
|
||||
material_name=row[9] or "",
|
||||
spec_model=row[10] or "",
|
||||
status=row[3] or "",
|
||||
received_at=received_dt.strftime("%m-%d %H:%M") if received_dt else None,
|
||||
completed_at=completed_str,
|
||||
duration_hours=hours,
|
||||
latest_valid_remark=row[11],
|
||||
record_count=row[12] or 0,
|
||||
))
|
||||
return records
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 协同留言搜索(上帝视角 — 全厂)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user