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,26 @@
"""模型包 — 导入 Base 及所有模型,供 Alembic 自动发现"""
from app.models.base import Base
from app.models.production_order import ProductionOrder
from app.models.product import Product
from app.models.task import Task, TaskRecord
from app.models.task_log import TaskLog
from app.models.notification import Notification
from app.models.app_version import AppVersion
from app.models.message import ProductMessage
from app.models.holiday import Holiday
from app.models.audit_log import AuditLog
from app.models.user_daily_seen import UserDailySeen
__all__ = [
"Base",
"ProductionOrder",
"Product",
"Task",
"TaskRecord",
"TaskLog",
"Notification",
"AppVersion",
"ProductMessage",
"Holiday",
"AuditLog",
"UserDailySeen",
]

View File

@ -0,0 +1,44 @@
"""App 版本管理模型 — 用于 OTA 热更新"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, Text, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class AppVersion(Base):
__tablename__ = "app_versions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
version: Mapped[str] = mapped_column(
String(20), nullable=False, unique=True, comment="版本号,如 T1.0.1",
)
version_code: Mapped[int] = mapped_column(
Integer, nullable=False, default=100, comment="数字版本号,用于比较",
)
wgt_url: Mapped[str] = mapped_column(
String(500), nullable=False, comment="WGT 升级包下载地址",
)
description: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="更新说明",
)
is_active: Mapped[bool] = mapped_column(
Boolean, default=True, comment="是否启用",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time,
)
def __repr__(self) -> str:
return f"<AppVersion {self.version}>"

View File

@ -0,0 +1,83 @@
"""操作审计日志模型
设计参考 MOM(KCGL) 的 audit_logs,但按 Track 的技术栈与诉求做了取舍:
- 主键用 UUID(与库内其它表一致),而非 MOM 的自增 int。
- 增加 request_id:与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
「接口访问日志」和「审计记录」对上,排障时不用再猜。MOM 无此字段。
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
而不是只能按时间翻。
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
与既有 task_logs 的分工:task_logs 是「任务流转轨迹」(有 task_id 非空约束,
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
"""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class AuditLog(Base):
__tablename__ = "audit_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 操作人(逻辑外键 → MOM sys_user,仅存账号,无物理约束)----
user_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
)
display_name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="操作人显示名",
)
role: Mapped[str | None] = mapped_column(
String(50), nullable=True, comment="操作时角色快照",
)
# ---- 业务语义 ----
action: Mapped[str] = mapped_column(
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
)
module: Mapped[str] = mapped_column(
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
)
target_type: Mapped[str | None] = mapped_column(
String(50), nullable=True, comment="目标类型(表名或实体名)",
)
target_id: Mapped[str | None] = mapped_column(
String(100), nullable=True, index=True, comment="目标ID",
)
target_name: Mapped[str | None] = mapped_column(
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
)
details: Mapped[dict | None] = mapped_column(
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
)
# ---- 请求上下文(由中间件自动填充)----
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
# ---- 与结构化日志对账用 ----
request_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
)
def __repr__(self) -> str:
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"

View File

@ -0,0 +1,6 @@
"""SQLAlchemy 声明式基类"""
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

View File

@ -0,0 +1,21 @@
"""节假日模型 — 放假日期配置,用于工作日时长计算"""
from datetime import date
from sqlalchemy import Date, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class Holiday(Base):
__tablename__ = "holidays"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
day: Mapped[date] = mapped_column(
Date, unique=True, index=True, comment="放假日期",
)
name: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="放假说明(如国庆节)",
)
def __repr__(self) -> str:
return f"<Holiday {self.day}>"

View File

@ -0,0 +1,33 @@
"""产品协同留言板模型"""
import uuid
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class ProductMessage(Base):
__tablename__ = "product_messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("products.id", ondelete="CASCADE"),
index=True,
nullable=False,
comment="所属产品 ID",
)
operator_id: Mapped[str] = mapped_column(
String(50), nullable=False, comment="留言人姓名或工号",
)
content: Mapped[str] = mapped_column(
Text, nullable=False, comment="留言内容",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="留言时间(北京时间)",
)

View File

@ -0,0 +1,53 @@
"""通知模型 — 任务转交/驳回等事件的消息提醒"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
# 通知类型常量
NOTIFY_TRANSFER = "TRANSFER" # 新任务派发/转交
NOTIFY_REJECT = "REJECT" # 品质驳回
NOTIFY_COMMENT = "COMMENT" # 留言提醒
class Notification(Base):
__tablename__ = "notifications"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
user_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)",
)
title: Mapped[str] = mapped_column(
String(200), nullable=False, comment="通知标题",
)
content: Mapped[str] = mapped_column(
Text, nullable=False, comment="通知内容详情",
)
type: Mapped[str] = mapped_column(
String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)",
)
task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID",
)
is_read: Mapped[bool] = mapped_column(
Boolean, default=False, comment="是否已读",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
def __repr__(self) -> str:
return f"<Notification {self.type} → {self.user_id}>"

View File

@ -0,0 +1,86 @@
"""产品模型"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
from app.core.lifecycle import LIFECYCLE_PRODUCTION
class Product(Base):
__tablename__ = "products"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
serial_number: Mapped[str] = mapped_column(
String(16), unique=True, index=True, nullable=False, comment="产品序列号(16位)",
)
# ---- 物理外键(关联本库 production_orders)— 可选 ----
order_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("production_orders.id"), nullable=True, comment="所属订单ID(可选)",
)
# ---- 外部序列号(用户自定义,可选) ----
external_serial: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="用户自定义产品序列号(可选)",
)
# ---- 逻辑外键(关联老系统物料表,仅存储 ID,无物理约束) ----
material_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="物料ID(逻辑外键→老系统)",
)
# ---- MOM 物料快照字段(创产品时写入,防止老系统数据变动影响标签) ----
material_name: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="物料名称快照",
)
spec_model: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="规格型号快照",
)
category: Mapped[str | None] = mapped_column(
String(255), nullable=True, comment="物料分类快照",
)
material_type: Mapped[str | None] = mapped_column(
String(100), nullable=True, comment="物料类型快照",
)
# ---- 物理外键(自引用:父产品) ----
parent_product_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id"), nullable=True, comment="父产品ID",
)
# ---- 当前位置追踪(逻辑外键→用户ID 或 'virtual_warehouse') ----
current_location_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="当前持有者ID 或 'virtual_warehouse'(仓库)",
)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="pending", comment="产品状态",
)
# 宏观流转状态 — 首次扫码时强制设定:备货/生产/测试/维修/待仓库收货/已入库/已出库
overall_status: Mapped[str | None] = mapped_column(
String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库",
)
# 生命周期阶段 — 生产制造 vs 出库后返厂售后。
# 设备出库后再次被派发任务 = 回流返厂,此处切为 AFTER_SALES 且不再回退。
lifecycle_phase: Mapped[str] = mapped_column(
String(20), nullable=False, default=LIFECYCLE_PRODUCTION,
server_default=LIFECYCLE_PRODUCTION, index=True,
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
# ---- 关系 ----
order: Mapped["ProductionOrder"] = relationship("ProductionOrder", lazy="selectin")
parent_product: Mapped["Product | None"] = relationship(
"Product", remote_side="Product.id", lazy="selectin",
)
def __repr__(self) -> str:
return f"<Product {self.serial_number}>"

View File

@ -0,0 +1,32 @@
"""生产订单模型"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class ProductionOrder(Base):
__tablename__ = "production_orders"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
order_no: Mapped[str] = mapped_column(
String(64), unique=True, index=True, nullable=False, comment="订单编号",
)
customer_info: Mapped[str | None] = mapped_column(
String(500), nullable=True, comment="客户信息",
)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="pending", comment="订单状态",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
def __repr__(self) -> str:
return f"<ProductionOrder {self.order_no}>"

124
backend/app/models/task.py Normal file
View File

@ -0,0 +1,124 @@
"""任务模型 — 支持无限嵌套、任务裂变分支、返工闭环"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, Boolean, ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
# 任务状态常量
TASK_STATUS_PENDING = "PENDING" # 待接收
TASK_STATUS_WIP = "WIP" # 进行中
TASK_STATUS_COMPLETED = "COMPLETED" # 已完成
TASK_STATUS_REJECTED = "REJECTED" # 已驳回
TASK_STATUS_ARCHIVED = "ARCHIVED" # 已入库
TASK_STATUS_CANCELED = "CANCELED" # 已撤回/已作废
class Task(Base):
__tablename__ = "tasks"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 物理外键(关联本库 products) ----
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, comment="所属产品ID",
)
# ---- 物理外键(自引用:无限嵌套父子任务 / 裂变分支) ----
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True, index=True, comment="父任务ID(用于任务裂变树)",
)
task_name: Mapped[str] = mapped_column(
String(200), nullable=False, comment="任务名称",
)
# ---- 逻辑外键(关联老系统用户表,仅存储 ID,无物理约束) ----
assignee_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="负责人ID(逻辑外键→老系统)",
)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default=TASK_STATUS_PENDING,
comment="任务状态: PENDING(待接收) | WIP(进行中) | COMPLETED(已完成) | REJECTED(已驳回) | ARCHIVED(已入库)",
)
notify_parent_on_complete: Mapped[bool] = mapped_column(
Boolean, default=False, comment="完成后是否通知父任务",
)
# ---- 时间追踪 ----
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
received_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, comment="操作员确认接收时间",
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, comment="任务完工转交时间",
)
# ---- 驳回/返工 ----
reject_reason: Mapped[str | None] = mapped_column(
String(500), nullable=True, comment="驳回原因",
)
is_rework: Mapped[bool] = mapped_column(
Boolean, default=False, comment="是否为返工任务",
)
task_type: Mapped[str | None] = mapped_column(
String(20), nullable=True, comment="任务派生类型: TRANSFER/SPAWN/RECOVERY/null=历史数据",
)
remark: Mapped[str | None] = mapped_column(
String(2000), nullable=True, comment="任务初始描述/交接备注",
)
# ---- 关系 ----
product: Mapped["Product"] = relationship("Product", lazy="selectin")
parent_task: Mapped["Task | None"] = relationship(
"Task", remote_side="Task.id", back_populates="child_tasks", lazy="selectin",
)
child_tasks: Mapped[list["Task"]] = relationship(
"Task", back_populates="parent_task", lazy="selectin",
order_by="Task.created_at",
)
records: Mapped[list["TaskRecord"]] = relationship(
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Task {self.task_name}>"
# ============================================================
# 任务进度记录 — 随时备注/传图
# ============================================================
class TaskRecord(Base):
__tablename__ = "task_records"
id: Mapped[int] = mapped_column(
primary_key=True, autoincrement=True,
)
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False, index=True, comment="所属任务ID",
)
remark: Mapped[str | None] = mapped_column(
String(2000), nullable=True, comment="备注文本",
)
images: Mapped[str | None] = mapped_column(
String(4000), nullable=True, comment="图片URL列表(JSON字符串)",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="记录时间",
)
# ---- 关系 ----
task: Mapped["Task"] = relationship("Task", back_populates="records")
def __repr__(self) -> str:
return f"<TaskRecord {self.id} @ {self.created_at}>"

View File

@ -0,0 +1,45 @@
"""任务操作日志模型"""
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, ForeignKey, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
from app.core.time_utils import get_beijing_time
class TaskLog(Base):
__tablename__ = "task_logs"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
)
# ---- 物理外键(关联本库 tasks) ----
task_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False, index=True, comment="所属任务ID",
)
# ---- 逻辑外键(关联老系统用户表,仅存储 ID,无物理约束) ----
operator_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, comment="操作人ID(逻辑外键→老系统)",
)
action_type: Mapped[str] = mapped_column(
String(50), nullable=False, comment="操作类型",
)
remark: Mapped[str | None] = mapped_column(
Text, nullable=True, comment="备注",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
)
# ---- 关系 ----
task: Mapped["Task"] = relationship("Task", lazy="selectin")
def __repr__(self) -> str:
return f"<TaskLog {self.action_type} @ {self.created_at}>"

View File

@ -0,0 +1,47 @@
"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
为什么需要它(而不是复用 audit_logs)
--------------------------------------
日活报表要的「上线时间 / 下线时间」,两个都不能从审计表直接得出:
1. **上线/下线时间不能取登录时间**:Refresh Token 有效期 7 天,用户不必每天
重新登录。按登录算会出现「登录次数 0、上线时间空,但操作次数 35」的
自相矛盾报表。
2. **也不能只取写操作时间**:审计中间件只记录写操作(及导出/打印这类敏感读),
普通 GET 不入账。当天只翻看、没做写操作的人会被整条漏掉。
3. **更不能把活动记录写进 audit_logs**:
· 「上线时间」是**事件**(INSERT 一次即可),但「下线时间」是**状态**
(每次活动都要刷新同一个值)。往审计流水里做 UPDATE,等于承认审计记录
可以被改写 —— 那审计本身就失去可信度了。
· 若改为每个请求 INSERT 一条,表会随访问量线性膨胀。
于是单开一张"可变的小状态表":一人一天一行,首见 INSERT、其后只
UPDATE last_seen_at。50 人 × 365 天 ≈ 1.8 万行/年,可忽略。
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Date, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base
class UserDailySeen(Base):
"""用户在某个北京时间自然日的首末活动时刻"""
__tablename__ = "user_daily_seen"
# 联合主键即 UPSERT 的冲突目标,也是"一天一人一行"的保证
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
day: Mapped[date] = mapped_column(Date, primary_key=True, comment="北京时间自然日")
first_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="当天首次活动时刻",
)
last_seen_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="当天末次活动时刻",
)