chore: fork from IRIS track 供 LICA 部门独立运行

- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update)
- 组织隔离目标: LICA
- 端口规划: 前端 8030 / 后端 8031 / 数据库 8032
- 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本)
- 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
2026-09-21 15:56:52 +08:00
commit 3286a11bc7
212 changed files with 44060 additions and 0 deletions

View File

@ -0,0 +1,45 @@
"""Pydantic Schemas — 请求/响应数据模型"""
from app.schemas.product import (
ProductCreate,
ProductUpdate,
ProductResponse,
ProductScanResponse,
)
from app.schemas.task import (
TaskCreate,
TaskUpdate,
TaskCompleteRequest,
TaskRejectRequest,
TaskTransferRequest,
SubtaskCreate,
TaskSummaryResponse,
TaskResponse,
TaskCompleteResponse,
TaskTransferResponse,
TaskListResponse,
)
from app.schemas.task_log import (
TaskLogResponse,
)
__all__ = [
# Product
"ProductCreate",
"ProductUpdate",
"ProductResponse",
"ProductScanResponse",
# Task
"TaskCreate",
"TaskUpdate",
"TaskCompleteRequest",
"TaskRejectRequest",
"TaskTransferRequest",
"SubtaskCreate",
"TaskSummaryResponse",
"TaskResponse",
"TaskCompleteResponse",
"TaskTransferResponse",
"TaskListResponse",
# TaskLog
"TaskLogResponse",
]

View File

@ -0,0 +1,14 @@
"""App 版本 Schema"""
from pydantic import BaseModel
class AppVersionResponse(BaseModel):
"""返回给 App 的版本信息"""
version: str
version_code: int
has_update: bool
wgt_url: str | None = None
description: str | None = None
force_update: bool = False
model_config = {"from_attributes": True}

View File

@ -0,0 +1,88 @@
"""审计日志 Pydantic Schema"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel
class AuditLogResponse(BaseModel):
"""单条审计记录"""
id: uuid.UUID
user_id: str | None = None
display_name: str | None = None
role: str | None = None
action: str
action_label: str | None = None # 服务端补的中文标签,避免前端各处硬编码
module: str
module_label: str | None = None
target_type: str | None = None
target_id: str | None = None
target_name: str | None = None
details: dict | None = None
ip_address: str | None = None
user_agent: str | None = None
method: str | None = None
url: str | None = None
status_code: int | None = None
error_message: str | None = None
# 与结构化日志对账用:拿着它就能捞到对应的接口日志
request_id: str | None = None
created_at: datetime
model_config = {"from_attributes": True}
class AuditLogListResponse(BaseModel):
"""审计日志分页列表"""
items: list[AuditLogResponse]
total: int
class DailyUsageRow(BaseModel):
"""某个操作人在某一天的用量汇总(北京时间自然日)"""
day: str # YYYY-MM-DD(北京时间)
user_id: str | None = None
display_name: str | None = None
role: str | None = None
login_count: int = 0 # 登录次数(当天成功登录)
logout_count: int = 0 # 登出次数(当天成功登出)
op_count: int = 0 # 操作次数(当天全部审计记录数)
# ⚠️ 上线/下线时间取【当天首次/末次活动】,不是登录/登出时间:
# token 有效期内(refresh 7 天)用户不会重新登录,按登录算会导致
# 「登录次数 0 但操作 35 次」这种自相矛盾。
first_active_at: datetime | None = None # 上线时间(当天首次活动)
last_active_at: datetime | None = None # 下线时间(当天末次活动)
class DailyUsageResponse(BaseModel):
"""日活 / 使用统计"""
start_date: str
end_date: str
items: list[DailyUsageRow]
total: int # 行数(= 天数 × 人数),不是审计记录数
class AuditOption(BaseModel):
"""筛选项(value/label 结构,直接喂给前端下拉)"""
value: str
label: str
class AuditOptionsResponse(BaseModel):
"""筛选项集合"""
modules: list[AuditOption]
actions: list[AuditOption]
# 导出可选的列(value=后端列 key,label=中文表头)。
# 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
# 两端各写一份迟早会出现"导出的列和页面上的对不上"。
log_export_columns: list[AuditOption] = []
usage_export_columns: list[AuditOption] = []

View File

@ -0,0 +1,27 @@
"""通知 Pydantic Schema"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class NotificationResponse(BaseModel):
"""通知列表响应"""
id: uuid.UUID
user_id: str
title: str
content: str
type: str
task_id: uuid.UUID | None = None
product_serial_number: str | None = None
is_read: bool
created_at: datetime
model_config = {"from_attributes": True}
class NotificationListResponse(BaseModel):
"""通知分页列表"""
notifications: list[NotificationResponse]
total: int
unread_count: int

View File

@ -0,0 +1,24 @@
"""生产订单 Pydantic Schemas"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class OrderCreate(BaseModel):
"""创建订单"""
order_no: str = Field(..., max_length=64, description="订单编号")
customer_info: str | None = Field(None, max_length=500, description="客户信息")
model_config = {"from_attributes": True}
class OrderResponse(BaseModel):
"""订单响应"""
id: uuid.UUID
order_no: str
customer_info: str | None
status: str
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,111 @@
"""产品 Pydantic Schemas"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class ProductCreate(BaseModel):
"""创建产品 — serial_number 由后端自动生成 16 位 HEX"""
# MOM 物料挂载(必填)
material_id: str = Field(..., max_length=64, description="MOM物料ID(必选)")
material_name: str = Field("", max_length=255, description="物料名称")
spec_model: str = Field("", max_length=255, description="规格型号")
category: str = Field("", max_length=100, description="物料类别")
material_type: str = Field("", max_length=100, description="物料类型")
# 可选
external_serial: str | None = Field(None, max_length=64, description="产品序列号(用户自定义,选填)")
order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)")
order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)")
parent_product_id: uuid.UUID | None = Field(None, description="父产品ID")
model_config = {"from_attributes": True}
class ProductUpdate(BaseModel):
"""更新产品"""
serial_number: str | None = Field(None, min_length=16, max_length=16)
external_serial: str | None = Field(None, max_length=64)
material_id: str | None = Field(None, max_length=64)
material_name: str | None = Field(None, max_length=255)
spec_model: str | None = Field(None, max_length=255)
order_no: str | None = Field(None, max_length=64, description="订单号")
status: str | None = Field(None, max_length=50, description="产品状态")
parent_product_id: uuid.UUID | None = Field(None)
model_config = {"from_attributes": True}
class ProductResponse(BaseModel):
"""产品响应"""
id: uuid.UUID
serial_number: str
external_serial: str | None = None
order_id: uuid.UUID | None = None
order_no: str = ""
material_id: str | None
material_name: str | None = None
spec_model: str | None = None
category: str | None = None
material_type: str | None = None
parent_product_id: uuid.UUID | None
current_location_id: str | None = None
current_location_name: str | None = None
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
overall_status: str | None = None
# 【当前工序】—— 只反映"此刻在做什么",绝不拿历史工序冒充。
# · 有活跃主干任务(WIP/PENDING) → 该任务工序名
# · 否则产品处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态(表达"货在哪")
# · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
# ⚠️ 必须与 overall_status 分开:ProductResponse.overall_status 会被"最新主干任务名"
# 覆盖(见 product_service 的 overall_names),于是已 COMPLETED 的历史工序
# (扫码出库 / 测试 / 发货测试…)会被当成"当前工序"长期展示。
current_step: str = ""
status: str
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
created_at: datetime
# 🔧 最新动态 — 该产品活跃任务的最新记录
latest_record_time: datetime | None = None
latest_record_content: str | None = None
latest_record_has_images: bool = False
latest_record_assignee_id: str | None = None # 🔧 最新记录操作人(消除并发张冠李戴)
latest_record_assignee_name: str | None = None
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
active_duration_hours: float | None = None
# 🔧 生产总天数(自创建至今)
production_days: int = 0 # 自然天
production_days_workdays: int = 0 # 工作日(排除周末/节假日)
model_config = {"from_attributes": True}
class ProductScanResponse(BaseModel):
"""扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)"""
id: uuid.UUID
serial_number: str
external_serial: str | None = None
order_id: uuid.UUID | None = None
order_no: str = ""
material_id: str | None
material_name: str | None = None
spec_model: str | None = None
category: str | None = None
material_type: str | None = None
parent_product_id: uuid.UUID | None
current_location_id: str | None = None
overall_status: str | None = None
status: str
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
lifecycle_phase: str = "PRODUCTION"
created_at: datetime
top_level_tasks: list[TaskSummaryResponse] = []
task_tree: list[TaskResponse] = []
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
model_config = {"from_attributes": True}
# 延迟导入,避免循环引用
from app.schemas.task import TaskSummaryResponse, TaskResponse # noqa: E402
ProductScanResponse.model_rebuild()

219
backend/app/schemas/task.py Normal file
View File

@ -0,0 +1,219 @@
"""任务 Pydantic Schemas — 支持无限嵌套子任务、裂变转交、驳回返工"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator, model_validator
# ============================================================
# 请求模型
# ============================================================
class TaskCreate(BaseModel):
"""创建任务"""
product_id: uuid.UUID = Field(..., description="所属产品ID")
parent_task_id: uuid.UUID | None = Field(None, description="父任务ID(用于嵌套子任务/裂变分支)")
task_name: str = Field(..., max_length=200, description="任务名称")
assignee_id: str | None = Field(None, max_length=64, description="负责人ID(逻辑外键→老系统)")
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
is_rework: bool = Field(False, description="是否为返工任务")
remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注")
model_config = {"from_attributes": True}
class TaskUpdate(BaseModel):
"""更新任务"""
task_name: str | None = Field(None, max_length=200)
assignee_id: str | None = Field(None, max_length=64)
status: str | None = Field(None, max_length=50, description="任务状态")
notify_parent_on_complete: bool | None = Field(None)
model_config = {"from_attributes": True}
class TaskCompleteRequest(BaseModel):
"""完成任务请求 — 携带转交信息(保留兼容旧版单步转交)"""
operator_id: str | None = Field(None, max_length=64, description="操作人ID")
next_assignee_id: str | None = Field(None, max_length=64, description="下一步任务负责人ID")
next_task_name: str | None = Field(None, max_length=200, description="下一步任务名称")
remark: str | None = Field(None, description="完成备注")
class SubtaskCreate(BaseModel):
"""创建子任务"""
task_name: str = Field(..., max_length=200, description="子任务名称")
assignee_id: str | None = Field(None, max_length=64, description="负责人ID")
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
class TaskRejectRequest(BaseModel):
"""品质驳回请求 — 驳回原因必填;异常图片【选填】
(编号错误、工序选错等场景无需拍照举证,故不强制传图)"""
reason: str = Field(..., min_length=1, max_length=500, description="驳回原因(必填)")
images: list[str] = Field(
default_factory=list, max_length=9,
description="异常图片 URL 列表(选填,可传空数组,最多 9 张)",
)
@field_validator("reason", mode="before")
@classmethod
def _strip_reason(cls, v):
"""先 strip 再交给 min_length 校验:否则纯空格(' ')能凑够长度绕过必填。
顺带保证落库的 Task.reject_reason / TaskRecord.remark 不带首尾空白。
非字符串原样返回,让 Pydantic 抛出正常的类型错误。"""
return v.strip() if isinstance(v, str) else v
@field_validator("images")
@classmethod
def _validate_images(cls, v: list[str]) -> list[str]:
"""剥离空串(前端可能提交 [''] 之类的占位),并做总长度上限校验,
避免落库时才撞上 TaskRecord.images 的 String(4000) 上限。图片选填,允许为空。"""
urls = [u.strip() for u in v if u and u.strip()]
if sum(len(u) for u in urls) > 3500:
raise ValueError("异常图片 URL 总长度超限,请减少图片数量")
return urls
class TaskTransferBranch(BaseModel):
"""裂变分支"""
task_name: str = Field(..., max_length=200, description="工序名称")
assignees: list[str] = Field(
default_factory=list, min_length=0,
description=(
"接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
"空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
),
)
class TaskTransferRequest(BaseModel):
"""完工裂变转交请求 — 支持多分支 next_tasks 和旧版单线兼容"""
next_assignees: list[str] | None = Field(None, description="[旧版] 下一道工序接收人列表")
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
note: str | None = Field(None, description="交接备注")
finish_directly: bool = Field(
False,
description=(
"直接完结:闭环当前任务但【不产生任何下游任务】。"
"它只是一个任务闭环动作,【不改动】产品的 overall_status —— "
"已出库的设备完结后依然是已出库(不入库,也不会变成「待仓库收货」)。"
"权限:仅 SUPER_ADMIN / SUPERVISOR 可调用,其余角色 403。"
"置 True 时忽略 next_tasks / next_assignees。"
),
)
@model_validator(mode="after")
def _reject_silent_empty_branch(self):
"""空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
if self.finish_directly:
return self
if any(not b.assignees for b in (self.next_tasks or [])):
raise ValueError(
"分支 assignees 不能为空;若意图是「直接完结该任务」,"
"请改为传 finish_directly=true"
)
return self
class TaskRecordCreate(BaseModel):
"""任务进度记录 — 备注 + 图片"""
remark: str = Field("", max_length=2000, description="备注文本")
images: list[str] = Field(default_factory=list, description="图片 URL 列表")
class TaskRecordResponse(BaseModel):
id: int
task_id: uuid.UUID
remark: str | None = None
images: list[str] = []
created_at: datetime | None = None
model_config = {"from_attributes": True}
@field_validator("images", mode="before")
@classmethod
def _parse_images(cls, v):
"""处理 DB 中 images 的 JSON 字符串 → list 反序列化(不污染 ORM 对象)"""
import json
if isinstance(v, str):
try:
return json.loads(v)
except (json.JSONDecodeError, TypeError):
return []
if v is None:
return []
return v
# ============================================================
# 响应模型
# ============================================================
class TaskSummaryResponse(BaseModel):
"""任务摘要 — 扫码时用,不含嵌套子任务"""
id: uuid.UUID
product_id: uuid.UUID
parent_task_id: uuid.UUID | None
task_name: str
assignee_id: str | None
status: str
notify_parent_on_complete: bool
is_rework: bool = False
task_type: str | None = None
remark: str | None = None
reject_reason: str | None = None
received_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
created_by: str | None = None # 谁创建的(从task_logs追溯)
model_config = {"from_attributes": True}
class TaskResponse(BaseModel):
"""任务详情响应 — 递归包含所有子任务"""
id: uuid.UUID
product_id: uuid.UUID
product_sn: str = ""
product_material: str = ""
parent_task_id: uuid.UUID | None
task_name: str
assignee_id: str | None
status: str
notify_parent_on_complete: bool
is_rework: bool = False
task_type: str | None = None
remark: str | None = None
reject_reason: str | None = None
received_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
child_tasks: list[TaskResponse] = []
records: list[TaskRecordResponse] = []
created_by: str | None = None # 谁创建的(从task_logs追溯)
model_config = {"from_attributes": True}
class TaskCompleteResponse(BaseModel):
"""任务完成响应"""
completed_task: TaskResponse
next_task: TaskResponse | None = None
message: str
class TaskTransferResponse(BaseModel):
"""裂变转交响应"""
completed_task: TaskResponse
created_tasks: list[TaskResponse] = []
message: str
class TaskListResponse(BaseModel):
"""任务列表响应"""
tasks: list[TaskResponse]
total: int

View File

@ -0,0 +1,17 @@
"""任务操作日志 Pydantic Schemas"""
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, Field
class TaskLogResponse(BaseModel):
"""任务日志响应"""
id: uuid.UUID
task_id: uuid.UUID
operator_id: str | None
action_type: str
remark: str | None
created_at: datetime
model_config = {"from_attributes": True}

View File

@ -0,0 +1,34 @@
"""用户 Schemas — 对接 MOM sys_user 表 + 双 Token"""
from pydantic import BaseModel, Field
class LoginRequest(BaseModel):
username: str = Field(..., max_length=64)
password: str = Field(..., max_length=128)
class UserResponse(BaseModel):
id: str
username: str
display_name: str
role: str
is_active: bool = True
created_at: str | None = None
model_config = {"from_attributes": True}
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserResponse
class RefreshRequest(BaseModel):
refresh_token: str = Field(..., description="Refresh Token")
class RefreshResponse(BaseModel):
access_token: str
token_type: str = "bearer"