commit 3286a11bc7fefaeda7af987fa968dc94c09c0db2 Author: duxingchen Date: Mon Sep 21 15:56:52 2026 +0800 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 状态 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7c2f21c --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# ===== Python ===== +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +dist/ +build/ +*.egg +.eggs/ +venv/ +.venv/ +env/ + +# ===== 环境变量(含密钥) ===== +.env +*.env.local + +# ===== IDE ===== +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# ===== OS ===== +.DS_Store +Thumbs.db + +# ===== Node / Frontend ===== +node_modules/ +frontend/dist/ +frontend/src-tauri/target/ + +# ===== Docker ===== +docker-compose.override.yml + +# ===== Runtime 目录 ===== +backend/uploads/ +backend/app/uploads/ +backend/data/ + +# ===== Misc ===== +*.log +*.tmp + +# ===== 部署产物 ===== +*.tar.gz +*.sql.gz + +# ===== 排除独立项目/个人文档 ===== +track1.0.md +PROJECT_STATUS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c1227d9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md + +本仓库(Track 生产流转系统)的工作笔记。仅在验证过之后才写入,避免传谣。 + +## 架构速览 + +- `backend/` FastAPI + SQLAlchemy 2.x(async) + Alembic,PostgreSQL。 +- `frontend/` React 19 + Vite + antd + Tailwind。路由见 `src/App.tsx`, + 管理端菜单见 `src/components/layout/AdminLayout.tsx`(`MENU` 数组)。 +- 登录不走 Track 自己的用户表,而是**只读** MOM(KCGL) 的 `sys_user`: + `sys_user.username` 存 `"真实姓名/登录账号"`,`login()` 用 + `WHERE username LIKE '%/<账号>'` 匹配,`display_name` 由 `/` 拆解得到。 + MOM 连接配置在 `app/core/mom_database.py`(同步 psycopg2 引擎)。 + +## 本地起环境(关键,踩过的坑都在这) + +1. **本机没有 Postgres 时需要先装**(容器内 `sudo` 可用): + `sudo -n apt-get install -y --fix-missing postgresql postgresql-contrib` + 然后 `sudo -n pg_ctlcluster 17 main start`。 + 本仓库不使用 pgvector,无需额外扩展。 +2. **数据库端口与生产默认值不同**,必须用环境变量覆盖: + - `DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@127.0.0.1:5432/track_production` + - `MOM_DB_HOST=127.0.0.1`、`MOM_DB_PORT=5432` + - `SECRET_KEY=<≥32 字符>`:`DEBUG=false` 时配置项会**拒绝**默认 SECRET_KEY + (见 `app/core/config.py` 的校验),不设会直接 import 失败。 +3. 迁移:`cd backend && python3 -m alembic upgrade head`(没有全局 `alembic` 命令, + 要用 `python3 -m alembic`)。校验纯 SQL 用 `alembic upgrade head --sql`。 +4. 前端 proxy 指向 Docker 服务名 `backend:8000`。本机跑要么把 + `127.0.0.1 backend` 写进 `/etc/hosts`,要么直接给 + `VITE_API_BASE_URL=http://localhost:/api/v1` 绕过 proxy。 + 注意 dev server 由 `basicSsl` 起 HTTPS,跨域需要后端 + `CORS_ORIGINS` 加上 `https://localhost:1420`。 + +## 测试的坑(重要) + +- **不要用 `starlette.testclient.TestClient` 测异步 SQLAlchemy 应用。** + 它每个请求新建事件循环,而引擎是模块级单例、池里挂着 asyncpg 连接, + 跨循环复用会报 `got Future attached to a different loop`,表现为随机 500。 + 正确做法:`httpx.AsyncClient(transport=httpx.ASGITransport(app=app))` + 并在单个 `asyncio.run()` 里跑完全部请求。生产 uvicorn 单循环无此问题。 +- 仓库目前**没有** pytest 基建,也没有前端测试脚本。 + +## 已知的待修问题(截至 1.0应用 分支) + +- **读接口大面积未鉴权**(已实测,非推测):无 token 直接 200 的包括 + `/api/v1/users/`、全部 `/api/v1/dashboard/*`(含 + `people-history/export` —— 匿名即可批量导出个人工时台账)、 + `/api/v1/analytics/*`、`/api/v1/screen/*`、`/api/v1/orders/`。 + 写操作和 `/api/v1/tasks`、`/api/v1/products` 是有鉴权的。 + 新增接口请统一用 `app/core/deps.py` 的 `require_admin` / `require_roles`。 +- 「管理员角色」这份规则此前散在 4 处(后端 `task_service`、`products.py` 内联、 + 前端 `constants/task.ts`、`AdminProductsPage` 内联),已因此出过事故。 + **后端唯一事实来源是 `app/core/roles.py`,前端用 `constants/task.ts::isAdminRole`。** + 新增判断不要手写 `===` 比较。 +- `task_logs.task_id` 是 NOT NULL 外键,只能挂任务,不是通用审计。通用审计是 + `audit_logs`(本轮新增,由 `app/core/audit_middleware.py` 自动采集)。 + +## 约定 + +- 时间统一北京时间(`app/core/time_utils.py`),库里存 timestamptz。 +- 中文枚举标签尽量由服务端下发(如审计接口的 `module_label`/`action_label`), + 避免前端再抄一份映射开始漂移。 +- 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..e501671 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,13 @@ +DATABASE_URL=postgresql+asyncpg://track:track_prod_2026@localhost:5433/track_production +SECRET_KEY=change-me-to-a-random-secret-key-in-production +ACCESS_TOKEN_EXPIRE_MINUTES=30 +DEBUG=true +CORS_ORIGINS='["http://localhost:1420", "tauri://localhost"]' + +# 日志:LOG_JSON=true 输出单行 JSON(便于采集),本地调试可设 false 换可读格式 +LOG_LEVEL=INFO +LOG_JSON=true + +# 错误追踪(可选):填了 DSN 且已安装 sentry-sdk 才会启用,否则自动跳过 +# SENTRY_DSN= +# SENTRY_TRACES_SAMPLE_RATE=0.1 diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..448dd00 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,27 @@ +# ============================================================ +# 后端开发 Dockerfile — FastAPI + Uvicorn 热更新 +# ============================================================ +FROM python:3.13-slim + +WORKDIR /app + +# 换阿里云源(国内网络加速) +RUN sed -i 's|http://deb.debian.org/debian|https://mirrors.aliyun.com/debian|g' /etc/apt/sources.list.d/debian.sources 2>/dev/null || \ + sed -i 's|http://deb.debian.org/debian|https://mirrors.aliyun.com/debian|g' /etc/apt/sources.list 2>/dev/null || true + +# 系统依赖(Pillow / cryptography 编译所需) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libpq-dev \ + libffi-dev \ + libjpeg-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +# 先装 Python 依赖(利用 Docker 缓存层,改代码不重装) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 代码通过 docker-compose volumes 挂载,不需要 COPY +# --reload 监听代码变化自动重启 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..ad54a88 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,150 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url 已由 env.py 从 app.core.config.settings 自动读取,此处仅作占位 +sqlalchemy.url = postgresql+asyncpg://track:track_prod_2026@localhost:5433/track_production + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e8c0130 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,59 @@ +"""Alembic 迁移环境配置 — 异步引擎 + 自动加载模型""" +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import settings +from app.models import Base # 自动发现所有 SQLAlchemy 模型 + +# Alembic Config 对象 +config = context.config + +# 日志配置 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 将 DATABASE_URL 同步到 alembic 配置中(覆盖 alembic.ini 中的占位值) +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# 自动生成迁移时需要的 models 元数据 +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """离线模式:生成 SQL 脚本而非直接执行""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection): + """在线模式:通过数据库连接执行迁移""" + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + """在线模式:异步引擎""" + connectable = create_async_engine( + config.get_main_option("sqlalchemy.url"), + echo=settings.DEBUG, + ) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/80f8cb64544a_init_production_orders_products_tasks_.py b/backend/alembic/versions/80f8cb64544a_init_production_orders_products_tasks_.py new file mode 100644 index 0000000..b0c8cd3 --- /dev/null +++ b/backend/alembic/versions/80f8cb64544a_init_production_orders_products_tasks_.py @@ -0,0 +1,85 @@ +"""init: production_orders, products, tasks, task_logs + +Revision ID: 80f8cb64544a +Revises: +Create Date: 2026-08-04 10:08:19.670158 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '80f8cb64544a' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('production_orders', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('order_no', sa.String(length=64), nullable=False, comment='订单编号'), + sa.Column('customer_info', sa.String(length=500), nullable=True, comment='客户信息'), + sa.Column('status', sa.String(length=50), nullable=False, comment='订单状态'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_production_orders_order_no'), 'production_orders', ['order_no'], unique=True) + op.create_table('products', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('serial_number', sa.String(length=16), nullable=False, comment='产品序列号(16位)'), + sa.Column('order_id', sa.UUID(), nullable=False, comment='所属订单ID'), + sa.Column('material_id', sa.String(length=64), nullable=True, comment='物料ID(逻辑外键→老系统)'), + sa.Column('parent_product_id', sa.UUID(), nullable=True, comment='父产品ID'), + sa.Column('status', sa.String(length=50), nullable=False, comment='产品状态'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'), + sa.ForeignKeyConstraint(['order_id'], ['production_orders.id'], ), + sa.ForeignKeyConstraint(['parent_product_id'], ['products.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_products_serial_number'), 'products', ['serial_number'], unique=True) + op.create_table('tasks', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('product_id', sa.UUID(), nullable=False, comment='所属产品ID'), + sa.Column('parent_task_id', sa.UUID(), nullable=True, comment='父任务ID'), + sa.Column('task_name', sa.String(length=200), nullable=False, comment='任务名称'), + sa.Column('assignee_id', sa.String(length=64), nullable=True, comment='负责人ID(逻辑外键→老系统)'), + sa.Column('status', sa.String(length=50), nullable=False, comment='任务状态'), + sa.Column('notify_parent_on_complete', sa.Boolean(), nullable=False, comment='完成后是否通知父任务'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'), + sa.ForeignKeyConstraint(['parent_task_id'], ['tasks.id'], ), + sa.ForeignKeyConstraint(['product_id'], ['products.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_tasks_parent_task_id'), 'tasks', ['parent_task_id'], unique=False) + op.create_table('task_logs', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('task_id', sa.UUID(), nullable=False, comment='所属任务ID'), + sa.Column('operator_id', sa.String(length=64), nullable=True, comment='操作人ID(逻辑外键→老系统)'), + sa.Column('action_type', sa.String(length=50), nullable=False, comment='操作类型'), + sa.Column('remark', sa.Text(), nullable=True, comment='备注'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='创建时间'), + sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_task_logs_task_id'), 'task_logs', ['task_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_task_logs_task_id'), table_name='task_logs') + op.drop_table('task_logs') + op.drop_index(op.f('ix_tasks_parent_task_id'), table_name='tasks') + op.drop_table('tasks') + op.drop_index(op.f('ix_products_serial_number'), table_name='products') + op.drop_table('products') + op.drop_index(op.f('ix_production_orders_order_no'), table_name='production_orders') + op.drop_table('production_orders') + # ### end Alembic commands ### diff --git a/backend/alembic/versions/a1b2c3d4e5f6_add_task_rework_and_product_location.py b/backend/alembic/versions/a1b2c3d4e5f6_add_task_rework_and_product_location.py new file mode 100644 index 0000000..1941125 --- /dev/null +++ b/backend/alembic/versions/a1b2c3d4e5f6_add_task_rework_and_product_location.py @@ -0,0 +1,102 @@ +"""add task rework fields and product current_location + +Revision ID: a1b2c3d4e5f6 +Revises: 80f8cb64544a +Create Date: 2026-08-04 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'a1b2c3d4e5f6' +down_revision: Union[str, Sequence[str], None] = '80f8cb64544a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # === 数据迁移:将已有任务状态从小写转为大写 === + op.execute(""" + UPDATE tasks SET status = 'PENDING' WHERE lower(status) = 'pending'; + """) + op.execute(""" + UPDATE tasks SET status = 'WIP' WHERE lower(status) = 'wip'; + """) + op.execute(""" + UPDATE tasks SET status = 'COMPLETED' WHERE lower(status) = 'completed'; + """) + op.execute(""" + UPDATE tasks SET status = 'REJECTED' WHERE lower(status) = 'rejected'; + """) + op.execute(""" + UPDATE tasks SET status = 'ARCHIVED' WHERE lower(status) = 'archived'; + """) + + # === tasks 表新增字段 === + op.add_column('tasks', sa.Column( + 'received_at', + sa.DateTime(timezone=True), + nullable=True, + comment='操作员确认接收时间', + )) + op.add_column('tasks', sa.Column( + 'completed_at', + sa.DateTime(timezone=True), + nullable=True, + comment='任务完工转交时间', + )) + op.add_column('tasks', sa.Column( + 'reject_reason', + sa.String(length=500), + nullable=True, + comment='驳回原因', + )) + op.add_column('tasks', sa.Column( + 'is_rework', + sa.Boolean(), + nullable=False, + server_default=sa.text('false'), + comment='是否为返工任务', + )) + + # === products 表新增字段 === + op.add_column('products', sa.Column( + 'current_location_id', + sa.String(length=64), + nullable=True, + comment="当前持有者ID 或 'virtual_warehouse'(仓库)", + )) + + +def downgrade() -> None: + """Downgrade schema.""" + # === products 表移除字段 === + op.drop_column('products', 'current_location_id') + + # === tasks 表移除字段 === + op.drop_column('tasks', 'is_rework') + op.drop_column('tasks', 'reject_reason') + op.drop_column('tasks', 'completed_at') + op.drop_column('tasks', 'received_at') + + # === 数据回迁:将任务状态从大写转回小写 === + op.execute(""" + UPDATE tasks SET status = 'pending' WHERE status = 'PENDING'; + """) + op.execute(""" + UPDATE tasks SET status = 'wip' WHERE status = 'WIP'; + """) + op.execute(""" + UPDATE tasks SET status = 'completed' WHERE status = 'COMPLETED'; + """) + op.execute(""" + UPDATE tasks SET status = 'rejected' WHERE status = 'REJECTED'; + """) + op.execute(""" + UPDATE tasks SET status = 'archived' WHERE status = 'ARCHIVED'; + """) diff --git a/backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py b/backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py new file mode 100644 index 0000000..d1fd82b --- /dev/null +++ b/backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py @@ -0,0 +1,24 @@ +"""add_task_type + +Revision ID: a7b8c9d0e1f2 +Revises: f6a7b8c9d0e1 +Create Date: 2026-08-06 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "a7b8c9d0e1f2" +down_revision: Union[str, None] = "f6a7b8c9d0e1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("task_type", sa.String(20), nullable=True, comment="派生类型: TRANSFER/SPAWN/RECOVERY")) + # 刷老数据:parent_task_id 非空的默认为 TRANSFER + op.execute("UPDATE tasks SET task_type = 'TRANSFER' WHERE parent_task_id IS NOT NULL AND task_type IS NULL") + + +def downgrade() -> None: + op.drop_column("tasks", "task_type") diff --git a/backend/alembic/versions/b2c3d4e5f6a7_product_hex_counter_and_material_fields.py b/backend/alembic/versions/b2c3d4e5f6a7_product_hex_counter_and_material_fields.py new file mode 100644 index 0000000..e91bc07 --- /dev/null +++ b/backend/alembic/versions/b2c3d4e5f6a7_product_hex_counter_and_material_fields.py @@ -0,0 +1,44 @@ +"""product hex counter + material fields + optional order + +Revision ID: b2c3d4e5f6a7 +Revises: a1b2c3d4e5f6 +Create Date: 2026-08-04 15:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'b2c3d4e5f6a7' +down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # === 16进制计数器 Sequence === + op.execute("CREATE SEQUENCE IF NOT EXISTS product_hex_counter START 1;") + + # === products 表: order_id → 可选 === + op.alter_column('products', 'order_id', + existing_type=sa.UUID(), + nullable=True, + existing_comment='所属订单ID(可选)') + + # === products 表: 新增 external_serial === + op.add_column('products', sa.Column( + 'external_serial', + sa.String(length=64), + nullable=True, + comment='用户自定义产品序列号(可选)', + )) + + +def downgrade() -> None: + op.drop_column('products', 'external_serial') + op.alter_column('products', 'order_id', + existing_type=sa.UUID(), + nullable=False) + op.execute("DROP SEQUENCE IF EXISTS product_hex_counter;") diff --git a/backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py b/backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py new file mode 100644 index 0000000..47cbfc4 --- /dev/null +++ b/backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py @@ -0,0 +1,32 @@ +"""add_notifications + +Revision ID: b8c9d0e1f2a3 +Revises: a7b8c9d0e1f2 +Create Date: 2026-08-07 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "b8c9d0e1f2a3" +down_revision: Union[str, None] = "a7b8c9d0e1f2" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("user_id", sa.String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)"), + sa.Column("title", sa.String(200), nullable=False, comment="通知标题"), + sa.Column("content", sa.Text(), nullable=False, comment="通知内容详情"), + sa.Column("type", sa.String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)"), + sa.Column("task_id", sa.UUID(), sa.ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID"), + sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), comment="是否已读"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), comment="创建时间"), + ) + + +def downgrade() -> None: + op.drop_table("notifications") diff --git a/backend/alembic/versions/c3d4e5f6a7b8_add_product_material_snapshot_fields.py b/backend/alembic/versions/c3d4e5f6a7b8_add_product_material_snapshot_fields.py new file mode 100644 index 0000000..22e6a0a --- /dev/null +++ b/backend/alembic/versions/c3d4e5f6a7b8_add_product_material_snapshot_fields.py @@ -0,0 +1,35 @@ +"""add product material snapshot fields + +Revision ID: c3d4e5f6a7b8 +Revises: b2c3d4e5f6a7 +Create Date: 2026-08-05 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'c3d4e5f6a7b8' +down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6a7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('products', sa.Column( + 'material_name', sa.String(length=255), nullable=True, comment='物料名称快照')) + op.add_column('products', sa.Column( + 'spec_model', sa.String(length=255), nullable=True, comment='规格型号快照')) + op.add_column('products', sa.Column( + 'category', sa.String(length=255), nullable=True, comment='物料分类快照')) + op.add_column('products', sa.Column( + 'material_type', sa.String(length=100), nullable=True, comment='物料类型快照')) + + +def downgrade() -> None: + op.drop_column('products', 'material_type') + op.drop_column('products', 'category') + op.drop_column('products', 'spec_model') + op.drop_column('products', 'material_name') diff --git a/backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py b/backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py new file mode 100644 index 0000000..09522e4 --- /dev/null +++ b/backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py @@ -0,0 +1,36 @@ +"""add_app_versions + +Revision ID: c9d0e1f2a3b4 +Revises: b8c9d0e1f2a3 +Create Date: 2026-08-07 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "c9d0e1f2a3b4" +down_revision: Union[str, None] = "b8c9d0e1f2a3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "app_versions", + sa.Column("id", sa.UUID(), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("version", sa.String(20), nullable=False, unique=True, comment="版本号"), + sa.Column("version_code", sa.Integer(), nullable=False, server_default="100", comment="数字版本号"), + sa.Column("wgt_url", sa.String(500), nullable=False, comment="WGT下载地址"), + sa.Column("description", sa.Text(), nullable=True, comment="更新说明"), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), comment="是否启用"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + ) + # 插入初始版本记录 + op.execute( + "INSERT INTO app_versions (version, version_code, wgt_url, description, is_active) " + "VALUES ('T1.0.1', 101, '', '初始版本', true)" + ) + + +def downgrade() -> None: + op.drop_table("app_versions") diff --git a/backend/alembic/versions/d4e5f6a7b8c9_add_product_overall_status.py b/backend/alembic/versions/d4e5f6a7b8c9_add_product_overall_status.py new file mode 100644 index 0000000..f5b1b5b --- /dev/null +++ b/backend/alembic/versions/d4e5f6a7b8c9_add_product_overall_status.py @@ -0,0 +1,30 @@ +"""add product overall_status + +Revision ID: d4e5f6a7b8c9 +Revises: c3d4e5f6a7b8 +Create Date: 2026-08-05 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'd4e5f6a7b8c9' +down_revision: Union[str, Sequence[str], None] = 'c3d4e5f6a7b8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('products', sa.Column( + 'overall_status', + sa.String(length=20), + nullable=True, + comment='宏观状态: 备货/生产/测试/维修/在库', + )) + + +def downgrade() -> None: + op.drop_column('products', 'overall_status') diff --git a/backend/alembic/versions/e5f6a7b8c9d0_add_task_records.py b/backend/alembic/versions/e5f6a7b8c9d0_add_task_records.py new file mode 100644 index 0000000..1e5e324 --- /dev/null +++ b/backend/alembic/versions/e5f6a7b8c9d0_add_task_records.py @@ -0,0 +1,35 @@ +"""add task_records table + +Revision ID: e5f6a7b8c9d0 +Revises: d4e5f6a7b8c9 +Create Date: 2026-08-06 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +revision: str = 'e5f6a7b8c9d0' +down_revision: Union[str, Sequence[str], None] = 'd4e5f6a7b8c9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('task_records', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('task_id', UUID(as_uuid=True), sa.ForeignKey('tasks.id'), nullable=False, comment='所属任务ID'), + sa.Column('remark', sa.String(length=2000), nullable=True, comment='备注文本'), + sa.Column('images', sa.String(length=4000), nullable=True, comment='图片URL列表(JSON字符串)'), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, comment='记录时间'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index(op.f('ix_task_records_task_id'), 'task_records', ['task_id'], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f('ix_task_records_task_id'), table_name='task_records') + op.drop_table('task_records') diff --git a/backend/alembic/versions/f6a7b8c9d0e1_add_task_remark.py b/backend/alembic/versions/f6a7b8c9d0e1_add_task_remark.py new file mode 100644 index 0000000..d8d8092 --- /dev/null +++ b/backend/alembic/versions/f6a7b8c9d0e1_add_task_remark.py @@ -0,0 +1,23 @@ +"""add_task_remark + +Revision ID: f6a7b8c9d0e1 +Revises: e5f6a7b8c9d0 +Create Date: 2026-08-05 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "f6a7b8c9d0e1" +down_revision: Union[str, None] = "e5f6a7b8c9d0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("tasks", sa.Column("remark", sa.String(2000), nullable=True, comment="任务初始描述/交接备注")) + + +def downgrade() -> None: + op.drop_column("tasks", "remark") diff --git a/backend/alembic/versions/g1h2i3j4k5l6_add_product_messages.py b/backend/alembic/versions/g1h2i3j4k5l6_add_product_messages.py new file mode 100644 index 0000000..70a4b4d --- /dev/null +++ b/backend/alembic/versions/g1h2i3j4k5l6_add_product_messages.py @@ -0,0 +1,33 @@ +"""add_product_messages + +Revision ID: g1h2i3j4k5l6 +Revises: c9d0e1f2a3b4 +Create Date: 2026-08-10 + +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "g1h2i3j4k5l6" +down_revision: Union[str, None] = "c9d0e1f2a3b4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "product_messages", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("product_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("products.id", ondelete="CASCADE"), + index=True, nullable=False), + sa.Column("operator_id", sa.String(50), nullable=False), + sa.Column("content", sa.Text, nullable=False), + sa.Column("created_at", sa.DateTime, nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("product_messages") diff --git a/backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py b/backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py new file mode 100644 index 0000000..72c3cf6 --- /dev/null +++ b/backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py @@ -0,0 +1,32 @@ +"""add holidays table + +Revision ID: h1h2h3h4h5h6 +Revises: g1h2i3j4k5l6 +Create Date: 2026-08-28 12:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'h1h2h3h4h5h6' +down_revision: Union[str, Sequence[str], None] = 'g1h2i3j4k5l6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'holidays', + sa.Column('id', sa.Integer(), autoincrement=True, primary_key=True), + sa.Column('day', sa.Date(), nullable=False, comment='放假日期'), + sa.Column('name', sa.String(length=100), nullable=True, comment='放假说明(如国庆节)'), + ) + op.create_index('ix_holidays_day', 'holidays', ['day'], unique=True) + + +def downgrade() -> None: + op.drop_index('ix_holidays_day', table_name='holidays') + op.drop_table('holidays') diff --git a/backend/alembic/versions/i1j2k3l4m5n6_add_product_lifecycle_phase.py b/backend/alembic/versions/i1j2k3l4m5n6_add_product_lifecycle_phase.py new file mode 100644 index 0000000..8ba1dc0 --- /dev/null +++ b/backend/alembic/versions/i1j2k3l4m5n6_add_product_lifecycle_phase.py @@ -0,0 +1,45 @@ +"""add_product_lifecycle_phase + +Revision ID: i1j2k3l4m5n6 +Revises: h1h2h3h4h5h6 +Create Date: 2026-09-14 + +产品生命周期阶段(lifecycle_phase) +-------------------------------- +用于区分「生产阶段的测试(发货测试)」与「出库后再次返厂的售后维修」。 + +存量数据一律回填 'PRODUCTION'(历史产品视为从未出库回流); +新数据由 SQLAlchemy 端 default 提供,双保险。 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "i1j2k3l4m5n6" +down_revision: Union[str, None] = "h1h2h3h4h5h6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "products", + sa.Column( + "lifecycle_phase", + sa.String(20), + nullable=False, + server_default="PRODUCTION", + comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)", + ), + ) + # 存量数据回填:历史产品从未出库回流,统一视为生产阶段 + op.execute("UPDATE products SET lifecycle_phase = 'PRODUCTION' WHERE lifecycle_phase IS NULL") + # 加索引:售后设备排查(按阶段筛选)会高频用到 + op.create_index( + "ix_products_lifecycle_phase", "products", ["lifecycle_phase"], unique=False, + ) + + +def downgrade() -> None: + op.drop_index("ix_products_lifecycle_phase", table_name="products") + op.drop_column("products", "lifecycle_phase") diff --git a/backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py b/backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py new file mode 100644 index 0000000..c8522a6 --- /dev/null +++ b/backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py @@ -0,0 +1,81 @@ +"""add_audit_logs + +Revision ID: j1k2l3m4n5o6 +Revises: i1j2k3l4m5n6 +Create Date: 2026-09-21 + +操作审计日志表(audit_logs) +-------------------------- +新增一张独立的审计表,用于记录 task_logs 覆盖不到的操作: +登录、导出、产品增删改、收口、权限/配置变更等与单个任务无关的动作。 + +为什么另起一张表而不复用 task_logs: +task_logs.task_id 是 NOT NULL 外键,只能挂在任务上,无法表达「张三导出了 +产品清单」这类动作;且缺少来源 IP / UA / 结果状态等审计必需字段。 + +存量数据无需回填(本表从上线时刻开始记录)。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "j1k2l3m4n5o6" +down_revision: Union[str, None] = "i1j2k3l4m5n6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "audit_logs", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False), + # 操作人 + sa.Column("user_id", sa.String(64), nullable=True, comment="操作人账号(逻辑外键→MOM)"), + sa.Column("display_name", sa.String(100), nullable=True, comment="操作人显示名"), + sa.Column("role", sa.String(50), nullable=True, comment="操作时角色快照"), + # 业务语义 + sa.Column("action", sa.String(50), nullable=False, comment="动作"), + sa.Column("module", sa.String(50), nullable=False, comment="业务模块"), + sa.Column("target_type", sa.String(50), nullable=True), + sa.Column("target_id", sa.String(100), nullable=True), + sa.Column("target_name", sa.String(200), nullable=True), + sa.Column("details", postgresql.JSONB(), nullable=True, comment="变更详情"), + # 请求上下文 + sa.Column("ip_address", sa.String(50), nullable=True), + sa.Column("user_agent", sa.String(500), nullable=True), + sa.Column("method", sa.String(10), nullable=True), + sa.Column("url", sa.String(500), nullable=True), + sa.Column("status_code", sa.Integer(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + # 与结构化日志对账 + sa.Column("request_id", sa.String(64), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + ) + + # 索引:按审计页最常用的检索维度建 + op.create_index("ix_audit_logs_created_at", "audit_logs", ["created_at"]) + op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"]) + op.create_index("ix_audit_logs_module", "audit_logs", ["module"]) + op.create_index("ix_audit_logs_action", "audit_logs", ["action"]) + op.create_index("ix_audit_logs_target_id", "audit_logs", ["target_id"]) + op.create_index("ix_audit_logs_request_id", "audit_logs", ["request_id"]) + # 组合索引:审计页默认「按时间倒序 + 按模块/动作过滤」 + op.create_index("ix_audit_logs_module_created", "audit_logs", ["module", "created_at"]) + + +def downgrade() -> None: + op.drop_index("ix_audit_logs_module_created", table_name="audit_logs") + op.drop_index("ix_audit_logs_request_id", table_name="audit_logs") + op.drop_index("ix_audit_logs_target_id", table_name="audit_logs") + op.drop_index("ix_audit_logs_action", table_name="audit_logs") + op.drop_index("ix_audit_logs_module", table_name="audit_logs") + op.drop_index("ix_audit_logs_user_id", table_name="audit_logs") + op.drop_index("ix_audit_logs_created_at", table_name="audit_logs") + op.drop_table("audit_logs") diff --git a/backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py b/backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py new file mode 100644 index 0000000..8f4498b --- /dev/null +++ b/backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py @@ -0,0 +1,53 @@ +"""add_user_daily_seen + +Revision ID: k1l2m3n4o5p6 +Revises: j1k2l3m4n5o6 +Create Date: 2026-09-21 + +每日用户活动表(user_daily_seen) +-------------------------------- +一天一人一行,记录当天首次 / 末次活动时刻,供日活报表计算 +「上线时间 / 下线时间」。 + +为什么不复用 audit_logs: + · 上线/下线时间不能取登录时间 —— Refresh Token 有效期 7 天,用户不必每天 + 重新登录,「登录次数 0 却操作 35 次」的报表没有意义。 + · 也不能只取写操作时间 —— 审计中间件只记写操作,普通 GET 不入账, + 当天只翻看的人会被漏掉。 + · 更不能把活动写进审计表 —— 「末次活动」是需要不断 UPDATE 的状态, + 而审计流水必须只增不改;能改的审计记录等于没有审计价值。 + +存量数据无需回填:本表从上线时刻开始记录;日活接口对更早的日期会自动 +回退到审计表的写操作时间去推算。 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "k1l2m3n4o5p6" +down_revision: Union[str, None] = "j1k2l3m4n5o6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user_daily_seen", + sa.Column("user_id", sa.String(64), primary_key=True, + comment="操作人账号(逻辑外键→MOM)"), + sa.Column("day", sa.Date(), primary_key=True, + comment="北京时间自然日"), + sa.Column("first_seen_at", sa.DateTime(timezone=True), nullable=False, + comment="当天首次活动时刻"), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False, + comment="当天末次活动时刻"), + ) + # 日活查询按日期区间扫,给 day 单独建索引。 + # (主键是 (user_id, day),前缀是 user_id,按 day 过滤用不上,故需补一条) + op.create_index("ix_user_daily_seen_day", "user_daily_seen", ["day"]) + + +def downgrade() -> None: + op.drop_index("ix_user_daily_seen_day", table_name="user_daily_seen") + op.drop_table("user_daily_seen") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..15dfd01 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# Track Production API diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..5d6dc85 --- /dev/null +++ b/backend/app/api/v1/endpoints/__init__.py @@ -0,0 +1 @@ +"""API v1 端点""" diff --git a/backend/app/api/v1/endpoints/analytics.py b/backend/app/api/v1/endpoints/analytics.py new file mode 100644 index 0000000..b51506d --- /dev/null +++ b/backend/app/api/v1/endpoints/analytics.py @@ -0,0 +1,75 @@ +"""效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项)""" +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.services.analytics_service import ( + get_capability_profile, CapabilityResponse, + get_flow_compare, FlowResponse, + get_analytics_options, AnalyticsOptions, + get_device_records, DeviceRecord, +) + +router = APIRouter(prefix="/analytics", tags=["效能分析"]) + + +@router.get("/capability", response_model=CapabilityResponse) +async def capability_profile( + assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔"), + spec_models: str | None = Query(None, description="规格型号,逗号分隔(可选)"), + since: str | None = Query(None, description="起始日期 ISO"), + until: str | None = Query(None, description="截止日期 ISO"), + mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"), + db: AsyncSession = Depends(get_db), +): + """个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None + specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None + return await get_capability_profile( + db, assignee_ids=ids, spec_models=specs, + since=since_dt, until=until_dt, mode=mode, + ) + + +@router.get("/flow", response_model=FlowResponse) +async def flow_compare( + product_sns: str | None = Query(None, description="身份证,逗号分隔"), + spec_models: str | None = Query(None, description="规格型号,逗号分隔(无 product_sns 时按型号取最近设备)"), + mode: str = Query("natural", description="时间口径: natural(自然小时) / workdays(工作小时,排除周末节假日)"), + since: str | None = Query(None, description="起始日期 ISO"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。""" + sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None + specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_flow_compare(db, product_sns=sns, spec_models=specs, mode=mode, since=since_dt, until=until_dt) + + +@router.get("/options", response_model=AnalyticsOptions) +async def analytics_options( + assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(联动过滤型号)"), + spec_models: str | None = Query(None, description="规格型号,逗号分隔(联动过滤人员)"), + db: AsyncSession = Depends(get_db), +): + """顶部筛选栏选项 — 负责人 + 规格型号,支持动态联动。""" + ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None + specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None + return await get_analytics_options(db, assignee_ids=ids, spec_models=specs) + + +@router.get("/device-records", response_model=list[DeviceRecord]) +async def device_records( + product_sn: str = Query(..., description="设备身份证"), + assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(可选,用于过滤)"), + db: AsyncSession = Depends(get_db), +): + """某台设备的任务备注记录(含图片);可选按负责人过滤。""" + ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None + return await get_device_records(db, product_sn, assignee_ids=ids) diff --git a/backend/app/api/v1/endpoints/app_version.py b/backend/app/api/v1/endpoints/app_version.py new file mode 100644 index 0000000..0e1d876 --- /dev/null +++ b/backend/app/api/v1/endpoints/app_version.py @@ -0,0 +1,87 @@ +"""App 版本更新 API — OTA 热更新检测""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.app_version import AppVersion +from app.schemas.app_version import AppVersionResponse + +router = APIRouter(prefix="/app", tags=["App版本"]) + + +@router.get("/check-update", response_model=AppVersionResponse) +async def check_update( + db: AsyncSession = Depends(get_db), +): + """ + 热更新检测接口(无需传参)。 + 查询 app_versions 表中 is_active=true 的最新记录, + 返回最新版本号、版本代码、WGT下载地址、更新说明。 + App 端自行对比本地版本号决定是否升级。 + """ + result = await db.execute( + select(AppVersion) + .where(AppVersion.is_active.is_(True)) + .order_by(desc(AppVersion.version_code)) + .limit(1) + ) + latest = result.scalar_one_or_none() + + if not latest: + return AppVersionResponse( + version="0", + version_code=0, + has_update=False, + ) + + return AppVersionResponse( + version=latest.version, + version_code=latest.version_code, + has_update=bool(latest.wgt_url), # 只有配置了 WGT 下载地址才算有效更新 + wgt_url=latest.wgt_url, + description=latest.description, + force_update=False, + ) + + +@router.get("/version", response_model=AppVersionResponse) +async def check_version( + current: str = Query(..., description="当前 App 版本号,如 T1.0.0"), + db: AsyncSession = Depends(get_db), +): + """检测是否有新版本可用(服务端对比)""" + result = await db.execute( + select(AppVersion) + .where(AppVersion.is_active.is_(True)) + .order_by(desc(AppVersion.version_code)) + .limit(1) + ) + latest = result.scalar_one_or_none() + + if not latest: + return AppVersionResponse( + version=current, + version_code=0, + has_update=False, + ) + + has_update = latest.version_code > _parse_version_code(current) + + return AppVersionResponse( + version=latest.version, + version_code=latest.version_code, + has_update=has_update, + wgt_url=latest.wgt_url if has_update else None, + description=latest.description if has_update else None, + force_update=False, + ) + + +def _parse_version_code(version_str: str) -> int: + """从版本字符串提取数字版本号""" + import re + nums = re.findall(r"\d+", version_str) + if nums: + return int("".join(nums[-3:]).ljust(3, "0")[:3]) + return 0 diff --git a/backend/app/api/v1/endpoints/audit.py b/backend/app/api/v1/endpoints/audit.py new file mode 100644 index 0000000..55de865 --- /dev/null +++ b/backend/app/api/v1/endpoints/audit.py @@ -0,0 +1,292 @@ +"""审计日志 API —— 查看系统操作审计记录 + +与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/ +时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到 +结构化日志里的那一次请求。 + +另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。 +""" +from __future__ import annotations + +import csv +import io +from datetime import datetime, time, timedelta +from typing import Any, Callable + +from fastapi import APIRouter, Depends, Query, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.deps import require_admin +from app.core.time_utils import BEIJING_TZ, get_beijing_time +from app.schemas.audit import ( + AuditLogListResponse, + AuditLogResponse, + AuditOption, + AuditOptionsResponse, + DailyUsageResponse, + DailyUsageRow, +) +from app.services import audit_service +from app.services.audit_service import ACTION_LABELS, MODULE_LABELS + +router = APIRouter(prefix="/audit", tags=["审计日志"]) + + +def _parse_day(value: str | None, *, end_of_day: bool = False) -> datetime | None: + """解析 YYYY-MM-DD 为北京时间。 + + 结束日期取次日 00:00 作为上界(配合 < 判断)—— 直接取当天 23:59:59 会 + 漏掉该秒内的记录,是日期区间筛选最常见的差一错误。 + """ + if not value: + return None + try: + day = datetime.strptime(value, "%Y-%m-%d").date() + except ValueError: + return None + if end_of_day: + return datetime.combine(day + timedelta(days=1), time.min, tzinfo=BEIJING_TZ) + return datetime.combine(day, time.min, tzinfo=BEIJING_TZ) + + +@router.get("/logs", response_model=AuditLogListResponse) +async def get_audit_logs( + user_id: str | None = Query(None, description="操作人账号(模糊匹配)"), + module: str | None = Query(None, description="业务模块"), + action: str | None = Query(None, description="动作类型"), + target_id: str | None = Query(None, description="目标ID"), + request_id: str | None = Query(None, description="请求ID(与接口日志对账)"), + status_code: int | None = Query(None, description="响应状态码"), + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> AuditLogListResponse: + """审计日志分页查询(按时间倒序)""" + start = _parse_day(start_date) + # 结束日期用「次日 00:00」作为开区间上界,避免漏掉当天最后几条 + end_exclusive = _parse_day(end_date, end_of_day=True) + + rows, total = await audit_service.list_audit_logs( + db, + user_id=user_id, + module=module, + action=action, + target_id=target_id, + request_id=request_id, + status_code=status_code, + start=start, + end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None, + skip=(page - 1) * page_size, + limit=page_size, + ) + + items = [] + for row in rows: + item = AuditLogResponse.model_validate(row) + # 中文标签由服务端补,避免前端为每个枚举再维护一份映射 + item.module_label = MODULE_LABELS.get(row.module, row.module) + item.action_label = ACTION_LABELS.get(row.action, row.action) + items.append(item) + + return AuditLogListResponse(items=items, total=total) + + +@router.get("/options", response_model=AuditOptionsResponse) +async def get_audit_options( + current_user: dict = Depends(require_admin), +) -> AuditOptionsResponse: + """筛选项:模块与动作的中文下拉;顺带下发导出可选列""" + return AuditOptionsResponse( + modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()], + actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()], + log_export_columns=[ + AuditOption(value=k, label=v[0]) for k, v in _AUDIT_LOG_COLUMNS.items() + ], + usage_export_columns=[ + AuditOption(value=k, label=v[0]) for k, v in _DAILY_USAGE_COLUMNS.items() + ], + ) + + +# ============================================================ +# CSV 导出 +# ============================================================ + +def _bj(dt: datetime | None) -> str: + """时间列统一按北京时间输出(与列表页、日活分日口径一致)。 + + 直接输出 UTC 会让导出文件里 01:00 的操作显示成前一天 17:00, + 与网页上看到的对不上 —— 导出与页面不一致是最容易被质疑的那种问题。 + """ + if dt is None: + return "" + return dt.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S") + + +def _actor(log) -> str: + """操作人:优先中文名,退化为账号(与列表页的展示规则一致)""" + if not log.user_id and not log.display_name: + return "未认证" + return f"{log.display_name}({log.user_id})" if log.display_name else (log.user_id or "") + + +# 列定义:key → (表头, 取值函数)。 +# 前端只传 key 列表,中文表头与取值口径都由后端统一维护, +# 避免两端各写一份导致"导出的列和页面上的对不上"。 +_AUDIT_LOG_COLUMNS: dict[str, tuple[str, Callable[[Any], Any]]] = { + "time": ("时间", lambda r: _bj(r.created_at)), + "user": ("操作人", _actor), + "role": ("角色", lambda r: r.role or ""), + "module": ("模块", lambda r: MODULE_LABELS.get(r.module, r.module)), + "action": ("动作", lambda r: ACTION_LABELS.get(r.action, r.action)), + "method": ("方法", lambda r: r.method or ""), + "url": ("请求路径", lambda r: r.url or ""), + "status": ("结果", lambda r: r.status_code if r.status_code is not None else ""), + "ip": ("来源IP", lambda r: r.ip_address or ""), + "target": ("目标", lambda r: f"{r.target_type or ''}:{r.target_id or ''}".strip(":")), + "error": ("错误信息", lambda r: r.error_message or ""), + "request_id": ("请求ID", lambda r: r.request_id or ""), + "user_agent": ("User-Agent", lambda r: r.user_agent or ""), +} + +_DAILY_USAGE_COLUMNS: dict[str, tuple[str, Callable[[dict], Any]]] = { + "day": ("日期", lambda r: r["day"]), + "user": ("操作人", lambda r: f"{r['display_name']}({r['user_id']})" if r["display_name"] else (r["user_id"] or "")), + "role": ("角色", lambda r: r["role"] or ""), + # 上线/下线时间 = 当天首次/末次活动(非登录时间), + # 登录/登出次数单独成列,两者不再混为一谈 + "first_active": ("上线时间", lambda r: _bj(r["first_active_at"])), + "last_active": ("下线时间", lambda r: _bj(r["last_active_at"])), + "login_count": ("登录次数", lambda r: r["login_count"]), + "logout_count": ("登出次数", lambda r: r["logout_count"]), + "op_count": ("操作次数", lambda r: r["op_count"]), +} + + +def _csv_response( + columns: dict[str, tuple[str, Callable]], keys: list[str], rows: list, filename: str, +) -> Response: + """把行数据渲染成 CSV 响应。 + + ⚠️ 必须带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头与内容全是乱码。 + 这是 CSV 导出最常见、也最容易被忽略的坑。 + """ + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([columns[k][0] for k in keys]) + for row in rows: + writer.writerow([columns[k][1](row) for k in keys]) + + return Response( + content=b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8"), + media_type="text/csv; charset=utf-8", + # 文件名用纯 ASCII:中文文件名要走 RFC 5987,各浏览器行为不一致, + # 内部系统没必要为它引入兼容成本。 + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +def _resolve_keys(raw: str | None, columns: dict) -> list[str]: + """解析前端传来的列 key。缺省 = 全部列;未知 key 直接忽略(不报错)。""" + if not raw: + return list(columns) + keys = [k.strip() for k in raw.split(",") if k.strip() in columns] + return keys or list(columns) + + +@router.get("/logs/export") +async def export_audit_logs( + user_id: str | None = Query(None, description="操作人账号(模糊匹配)"), + module: str | None = Query(None, description="业务模块"), + action: str | None = Query(None, description="动作类型"), + target_id: str | None = Query(None, description="目标ID"), + request_id: str | None = Query(None, description="请求ID"), + status_code: int | None = Query(None, description="响应状态码"), + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"), + columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> Response: + """审计明细 CSV 导出 —— 筛选维度与 /logs 完全一致,保证"看到什么就能导出什么"。""" + start = _parse_day(start_date) + end_exclusive = _parse_day(end_date, end_of_day=True) + + rows, truncated = await audit_service.export_audit_logs( + db, + user_id=user_id, module=module, action=action, target_id=target_id, + request_id=request_id, status_code=status_code, + start=start, + end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None, + ) + + keys = _resolve_keys(columns, _AUDIT_LOG_COLUMNS) + resp = _csv_response(_AUDIT_LOG_COLUMNS, keys, rows, "audit_logs.csv") + if truncated: + # 用响应头传递"已截断",前端据此提示用户收窄筛选条件 + resp.headers["X-Export-Truncated"] = "1" + resp.headers["X-Export-Max-Rows"] = str(audit_service.EXPORT_MAX_ROWS) + resp.headers["Access-Control-Expose-Headers"] = "X-Export-Truncated, X-Export-Max-Rows" + return resp + + +@router.get("/daily-usage/export") +async def export_daily_usage( + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"), + columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> Response: + """日活统计 CSV 导出 —— 每人一行:上线/下线次数与时间、操作次数。""" + start = _parse_day(start_date) or datetime.combine( + get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ, + ) + end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1)) + + items = await audit_service.get_daily_usage(db, start=start, end=end) + keys = _resolve_keys(columns, _DAILY_USAGE_COLUMNS) + return _csv_response(_DAILY_USAGE_COLUMNS, keys, items, "daily_usage.csv") + + +@router.get("/daily-usage", response_model=DailyUsageResponse) +async def get_daily_usage( + start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"), + end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(require_admin), +) -> DailyUsageResponse: + """日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合。 + + 回答的是「每天有哪些人用了系统、用了多少」: + · 上线时间 / 下线时间:当天**首次 / 末次活动**时间(任意审计记录) + · 操作次数:当天该用户的全部审计记录数(使用深度) + · 登录次数 / 登出次数:真实的手动登录 / 登出行为计数 + + ⚠️ 上线时间【不取登录时间】:token 有效期内(refresh 7 天)用户不重新登录, + 按登录算会让「周一登录、周二继续用」的周二变成"登录次数 0、上线时间空, + 但操作次数 35"——报表自相矛盾。改用活动口径后,当天的第一次操作即上线时间。 + + ⚠️ 登出次数天然小于登录次数:用户直接关浏览器、断网、token 过期都不会 + 产生登出记录。这是真实情况,不做任何"补齐"推算。 + """ + # 起始日:未传则取北京的今天。_parse_day 返回的是北京时间当日 00:00。 + start = _parse_day(start_date) or datetime.combine( + get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ, + ) + # 结束日:_parse_day(end_of_day=True) 已给出「次日 00:00」,正好当作半开上界。 + # 未传则默认单日查询(= 起始日当天)。 + end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1)) + + items = await audit_service.get_daily_usage(db, start=start, end=end) + + return DailyUsageResponse( + start_date=start.astimezone(BEIJING_TZ).strftime("%Y-%m-%d"), + end_date=(end - timedelta(days=1)).astimezone(BEIJING_TZ).strftime("%Y-%m-%d"), + items=[DailyUsageRow(**row) for row in items], + total=len(items), + ) diff --git a/backend/app/api/v1/endpoints/auth.py b/backend/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..20e42de --- /dev/null +++ b/backend/app/api/v1/endpoints/auth.py @@ -0,0 +1,80 @@ +"""认证 API — 对接 MOM sys_user + 双 Token 刷新""" +from fastapi import APIRouter, Depends, Request +from app.schemas.user import ( + LoginRequest, + LoginResponse, + RefreshRequest, + RefreshResponse, + UserResponse, +) +from app.core.security import peek_token_identity +from app.services.auth_service import login, refresh_access_token, get_current_user + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +@router.post("/login", response_model=LoginResponse) +def login_endpoint(data: LoginRequest, request: Request): + """登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token""" + # 登录请求本身尚未认证,中间件拿不到操作人。但「谁在尝试登录、失败了多少次」 + # 恰恰是审计里最该有的信息,所以在校验之前就把尝试的账号写进 state: + # 登录失败时同样留痕,且能按账号追踪暴力破解。 + # 注意:绝不把 data.password 写进 state / 审计,密码不落库。 + request.state.audit_user = data.username + result = login(data.username, data.password) + + # 登录成功后补上显示名 / 角色 —— 否则审计里这条记录的「操作人」会退化成账号 + # (前端按 display_name || user_id 渲染,见 AdminAuditLogPage)。 + # 能在这里补的原因:中间件是在 call_next 返回【之后】才落库的,此刻写入 + # request.state 依然会被采集到。 + # 失败登录走不到这里,保持「只有账号可追责」——这正是想要的语义。 + if result.user: + request.state.audit_display_name = result.user.display_name + request.state.audit_role = result.user.role + return result + + +@router.post("/refresh", response_model=RefreshResponse) +def refresh_endpoint(data: RefreshRequest, request: Request): + """刷新 Access Token — 使用 Refresh Token 换取新的 Access Token""" + # 本接口刻意不挂 get_current_user:能用到这里,正是因为 access token 已经 + # 过期/缺失,请求里没有 Authorization 头,JWT 依赖不会执行 → 审计拿不到操作人, + # 记录只能显示「未认证」。 + # 但 refresh token 里本来就带着完整身份(sub/username/display_name/role), + # 解出来写进 state,审计才能记到人 —— 而"谁在何时尝试刷新"正是要留痕的。 + # 注意 peek 只用于审计标注,鉴权判断一律走 get_current_user。 + identity = peek_token_identity(data.refresh_token) + if identity: + request.state.audit_user = identity.get("username") or identity.get("sub") + request.state.audit_display_name = identity.get("display_name") or "" + request.state.audit_role = identity.get("role") or "" + return refresh_access_token(data.refresh_token) + + +@router.post("/logout") +def logout_endpoint(current_user: dict = Depends(get_current_user)): + """登出 —— 仅用于审计留痕。 + + JWT 是无状态的,服务端没有可吊销的会话,因此本接口**不做任何令牌失效** + (客户端清掉本地 token 即为登出),返回体也没有实际语义。 + + 它存在的唯一目的:让审计中间件记下「谁在何时退出了系统」。 + 没有这个端点时,前端「退出」只清本地存储、不产生任何请求, + 退出动作在审计里完全不可见 —— 而"谁在什么时候退掉了系统" + 在追责场景下和"谁登录了"同等重要。 + + 挂 Depends(get_current_user) 是为了让 JWT 依赖把操作人写进 request.state + (见 auth_service.get_current_user),记录到真实姓名而非「未认证」。 + """ + return {"ok": True} + + +@router.get("/me", response_model=UserResponse) +def get_me(current_user: dict = Depends(get_current_user)): + """获取当前用户信息(从 Access Token 解析)""" + return UserResponse( + id=current_user["sub"], + username=current_user.get("username", ""), + display_name=current_user.get("display_name", ""), + role=current_user.get("role", "operator"), + ) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py new file mode 100644 index 0000000..0bed07d --- /dev/null +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -0,0 +1,257 @@ +"""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.core.time_utils import BEIJING_TZ +from app.services.dashboard_service import ( + get_dashboard_stats, DashboardStats, + get_my_stats, MyStats, + get_wip_tasks, WipTask, + get_completed_tasks, CompletedTask, + get_rejected_tasks, RejectedTask, + get_user_operations, UserOperation, + get_user_operation_detail, OperationDetail, + get_wip_matrix, WipMatrixRow, + get_wip_matrix_detail, WipMatrixDetailRow, + get_people_workload, PersonWorkload, + get_people_history, PersonHistoryRecord, + search_product_messages, ProductMessageList, +) + +router = APIRouter(prefix="/dashboard", tags=["管理看板"]) + + +def _parse_bound(value: str | None) -> datetime | None: + """解析 ISO 时间边界。 + + 裸时间(无时区偏移)按**北京时间**解释 —— 否则会被当作服务器本地时间, + 在边界上整体偏移 8 小时,出现「选了今日却统计到昨天下午」这类错位。 + """ + if not value: + return None + dt = datetime.fromisoformat(value) + return dt.replace(tzinfo=BEIJING_TZ) if dt.tzinfo is None else dt + + +@router.get("/stats", response_model=DashboardStats) +async def dashboard_stats( + since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """ + 全局统计(上帝视角)。 + + 时间筛选仅影响 COMPLETED / REJECTED 计数; + PENDING / WIP / 总数永远返回实时快照。 + """ + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_dashboard_stats(db, since=since_dt, until=until_dt) + + +@router.get("/my-stats", response_model=MyStats) +async def my_stats( + assignee_id: str = Query(..., description="负责人ID(移动端传当前登录用户 username)"), + since: str | None = Query(None, description="起始时间 ISO(含时区偏移,如 2026-09-01T00:00:00+08:00);缺省=本月 1 日"), + until: str | None = Query(None, description="截止时间 ISO(含时区偏移);缺省=此刻"), + db: AsyncSession = Depends(get_db), +): + """ + 一线工人个人效能 — 移动端「工作统计」页,支持自选时段。 + + - 生产战绩(完成 / 被驳回 / 参与产品)按本人名下任务归因; + - 操作统计(接收 / 转交 / 上传备注)与 PC /dashboard/user-operations 严格同口径, + 工人自查的数与主管看到的面板对得上。 + + ⚠️ since/until 必须带时区偏移。移动端发的是北京时间 (+08:00), + 不带偏移的裸字符串会被当成"本地时间"导致边界偏移 8 小时。 + """ + return await get_my_stats(db, assignee_id, since=_parse_bound(since), until=_parse_bound(until)) + + +@router.get("/wip-tasks", response_model=list[WipTask]) +async def wip_tasks( + limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"), + db: AsyncSession = Depends(get_db), +): + """在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)""" + return await get_wip_tasks(db, limit) + + +@router.get("/completed-tasks", response_model=list[CompletedTask]) +async def completed_tasks( + since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), + until: str | None = Query(None, description="截止日期 ISO"), + limit: int = Query(200, ge=1, le=500), + db: AsyncSession = Depends(get_db), +): + """流转完成率下钻 — 按时段查询已完成任务明细""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit) + + +@router.get("/rejected-tasks", response_model=list[RejectedTask]) +async def rejected_tasks( + since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), + until: str | None = Query(None, description="截止日期 ISO"), + limit: int = Query(200, ge=1, le=500), + db: AsyncSession = Depends(get_db), +): + """驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit) + + +@router.get("/user-operations", response_model=list[UserOperation]) +async def user_operations( + since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_user_operations(db, since=since_dt, until=until_dt) + + +@router.get("/user-operations/detail", response_model=list[OperationDetail]) +async def user_operations_detail( + user_id: str = Query(..., description="人员ID(username)"), + action_type: str = Query(..., description="操作类型: receive/transfer/record"), + since: str | None = Query(None, description="起始日期 ISO"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_user_operation_detail( + db, user_id, action_type, since=since_dt, until=until_dt, + ) + + +@router.get("/wip-matrix", response_model=list[WipMatrixRow]) +async def wip_matrix( + dimension: str = Query("assignee", description="聚合维度: assignee(人员) / task_name(工序)"), + since: str | None = Query(None, description="起始日期 ISO"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt) + + +@router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow]) +async def wip_matrix_detail( + spec: str = Query(..., description="规格型号"), + process: str = Query(..., description="当前工序(dimension_key)"), + since: str | None = Query(None, description="起始日期 ISO"), + until: str | None = Query(None, description="截止日期 ISO"), + db: AsyncSession = Depends(get_db), +): + """WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细""" + since_dt = datetime.fromisoformat(since) if since else None + until_dt = datetime.fromisoformat(until) if until else None + return await get_wip_matrix_detail(db, spec_model=spec, process=process, since=since_dt, until=until_dt) + + +@router.get("/people-workload", response_model=list[PersonWorkload]) +async def people_workload( + db: AsyncSession = Depends(get_db), +): + """人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)""" + 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码/物料名/留言人/内容"), + skip: int = Query(0, ge=0), + limit: int = Query(30, ge=1, le=200), + db: AsyncSession = Depends(get_db), +): + """ + 协同留言搜索(上帝视角 — 全厂所有产品留言)。 + + 关联 Product 表返回 serial_number + material_name, + 按时间倒序排列。 + """ + return await search_product_messages(db, keyword=keyword, skip=skip, limit=limit) diff --git a/backend/app/api/v1/endpoints/external_products.py b/backend/app/api/v1/endpoints/external_products.py new file mode 100644 index 0000000..d387439 --- /dev/null +++ b/backend/app/api/v1/endpoints/external_products.py @@ -0,0 +1,61 @@ +"""外部系统产品查询 API — 供 MOM 端扫码自动带出设备数据""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Header, HTTPException, Query +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.config import settings +from app.core.database import get_db +from app.models.product import Product + +router = APIRouter(prefix="/external/products", tags=["外部查询"]) + + +@router.get("/lookup") +async def external_product_lookup( + code: str = Query(..., description="16 位系统序列号 或 自定义业务序列号"), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + db: AsyncSession = Depends(get_db), +) -> dict: + """供 MOM 端扫码查询产品基础信息。 + + - 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。 + - code 同时匹配 Product.serial_number(16 位)与 external_serial(业务序列号)。 + - 命中返回格式化设备信息;未命中返回 404。 + """ + # ── 鉴权 ── + if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY: + raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key") + + # ── 联合查询:serial_number 或 external_serial 匹配 code ── + product = ( + await db.execute( + select(Product) + .options(selectinload(Product.order)) + .where( + or_( + Product.serial_number == code, + Product.external_serial == code, + ) + ) + ) + ).scalars().first() + + if product is None: + raise HTTPException(status_code=404, detail="产品不存在") + + return { + "code": 200, + "data": { + "serial_number": product.serial_number, + "external_serial": product.external_serial, + "material_id": product.material_id, + "sku": product.spec_model, + "material_name": product.material_name, + "spec_model": product.spec_model, + "material_type": product.material_type, + "order_no": product.order.order_no if product.order else "", + }, + } diff --git a/backend/app/api/v1/endpoints/holidays.py b/backend/app/api/v1/endpoints/holidays.py new file mode 100644 index 0000000..cab700c --- /dev/null +++ b/backend/app/api/v1/endpoints/holidays.py @@ -0,0 +1,60 @@ +"""节假日管理 — 工作日时长计算所需的放假日期配置""" +from datetime import date +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.holiday import Holiday +from app.services.auth_service import get_current_user + +router = APIRouter(prefix="/holidays", tags=["节假日管理"]) + + +class HolidayCreate(BaseModel): + day: date = Field(..., description="放假日期") + name: str | None = Field(None, max_length=100, description="放假说明") + + +@router.get("/") +async def list_holidays( + db: AsyncSession = Depends(get_db), +): + """获取全部放假日期(按日期正序)""" + result = await db.execute(select(Holiday).order_by(Holiday.day.asc())) + return [ + {"id": h.id, "date": h.day.isoformat(), "name": h.name} + for h in result.scalars().all() + ] + + +@router.post("/", status_code=201) +async def create_holiday( + data: HolidayCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """添加一个放假日期""" + exists = await db.scalar(select(Holiday).where(Holiday.day == data.day)) + if exists: + raise HTTPException(status_code=400, detail=f"{data.day} 已在节假日列表中") + h = Holiday(day=data.day, name=data.name) + db.add(h) + await db.commit() + await db.refresh(h) + return {"id": h.id, "date": h.day.isoformat(), "name": h.name} + + +@router.delete("/{holiday_id}", status_code=204) +async def delete_holiday( + holiday_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """删除一个放假日期""" + h = await db.get(Holiday, holiday_id) + if not h: + raise HTTPException(status_code=404, detail="节假日不存在") + await db.delete(h) + await db.commit() diff --git a/backend/app/api/v1/endpoints/materials.py b/backend/app/api/v1/endpoints/materials.py new file mode 100644 index 0000000..17971c6 --- /dev/null +++ b/backend/app/api/v1/endpoints/materials.py @@ -0,0 +1,133 @@ +"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组""" +from fastapi import APIRouter, Query, HTTPException, status, Depends +from pydantic import BaseModel +from app.core.mom_database import MomSessionLocal +from app.services.auth_service import get_current_user +from sqlalchemy import text + +router = APIRouter(prefix="/materials", tags=["物料选择"]) + + +# ============================================================ +# 响应模型 +# ============================================================ + +class MaterialGroup(BaseModel): + category: str + count: int + + +class MaterialItem(BaseModel): + id: int + name: str + spec: str + category: str + type: str + unit: str + is_enabled: bool + + +# ============================================================ +# 端点 +# ============================================================ + +@router.get("/groups", response_model=list[MaterialGroup]) +def get_material_groups( + keyword: str = Query("", description="搜索(按名称/规格)"), + current_user: dict = Depends(get_current_user), +): + """ + 按 category 分组汇总,前端渲染手风琴外层。 + 只返回成品/半成品分类。 + """ + db = MomSessionLocal() + try: + if keyword.strip(): + sql = text(""" + SELECT category, COUNT(*) AS count + FROM material_base + WHERE is_enabled = TRUE + AND (name ILIKE :kw OR spec_model ILIKE :kw) + GROUP BY category + ORDER BY category + """) + result = db.execute(sql, {"kw": f"%{keyword.strip()}%"}) + else: + sql = text(""" + SELECT category, COUNT(*) AS count + FROM material_base + WHERE is_enabled = TRUE + GROUP BY category + ORDER BY category + """) + result = db.execute(sql) + + rows = result.fetchall() + return [MaterialGroup(category=row.category, count=row.count) for row in rows] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"MOM material_base 查询失败: {str(e)}", + ) + finally: + db.close() + + +@router.get("/items", response_model=list[MaterialItem]) +def get_material_items( + category: str = Query(..., description="物料分类"), + keyword: str = Query("", description="分组内搜索"), + limit: int = Query(500, ge=1, le=9999), + current_user: dict = Depends(get_current_user), +): + """ + 获取指定 category 下的物料条目,前端展开手风琴时懒加载。 + """ + db = MomSessionLocal() + try: + if keyword.strip(): + sql = text(""" + SELECT id, name, spec_model AS spec, category, material_type AS type, + COALESCE(unit, '') AS unit, is_enabled + FROM material_base + WHERE is_enabled = TRUE + AND category = :cat + AND (name ILIKE :kw OR spec_model ILIKE :kw) + ORDER BY name + LIMIT :lim + """) + result = db.execute( + sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit} + ) + else: + sql = text(""" + SELECT id, name, spec_model AS spec, category, material_type AS type, + COALESCE(unit, '') AS unit, is_enabled + FROM material_base + WHERE is_enabled = TRUE + AND category = :cat + ORDER BY name + LIMIT :lim + """) + result = db.execute(sql, {"cat": category, "lim": limit}) + + rows = result.fetchall() + return [ + MaterialItem( + id=row.id, + name=row.name, + spec=row.spec, + category=row.category, + type=row.type, + unit=row.unit, + is_enabled=row.is_enabled, + ) + for row in rows + ] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"MOM material_base 查询失败: {str(e)}", + ) + finally: + db.close() diff --git a/backend/app/api/v1/endpoints/notifications.py b/backend/app/api/v1/endpoints/notifications.py new file mode 100644 index 0000000..3a96ae7 --- /dev/null +++ b/backend/app/api/v1/endpoints/notifications.py @@ -0,0 +1,109 @@ +"""通知 API 端点 — 获取列表、标记已读""" +from __future__ import annotations +import uuid +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_db +from app.models.notification import Notification +from app.models.task import Task +from app.models.product import Product +from app.schemas.notification import NotificationResponse, NotificationListResponse +from app.services.auth_service import get_current_user + +router = APIRouter(prefix="/notifications", tags=["消息通知"]) + + +@router.get("/", response_model=NotificationListResponse) +async def list_notifications( + skip: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + 获取当前用户的通知列表(按时间倒序)。 + + 安全:user_id 强制从 JWT Token 解析,不接受查询参数, + 杜绝通过篡改 user_id 参数越权查看他人通知。 + """ + user_id: str = current_user.get("username", "") or current_user.get("sub", "") + + # 总数 + count_stmt = select(func.count()).select_from(Notification).where( + Notification.user_id == user_id + ) + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 未读数 + unread_stmt = select(func.count()).select_from(Notification).where( + Notification.user_id == user_id, + Notification.is_read.is_(False), + ) + unread_result = await db.execute(unread_stmt) + unread_count = unread_result.scalar() or 0 + + # 列表 + stmt = ( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc()) + .offset(skip) + .limit(limit) + ) + result = await db.execute(stmt) + notifications = result.scalars().all() + + # 🚀 批量查询关联的 product_serial_number + task_ids = [n.task_id for n in notifications if n.task_id] + serial_map: dict[uuid.UUID, str] = {} + if task_ids: + task_result = await db.execute( + select(Task.id, Product.serial_number) + .join(Product, Task.product_id == Product.id) + .where(Task.id.in_(task_ids)) + ) + for row in task_result: + serial_map[row[0]] = row[1] + + # 组装响应 + response_list: list[NotificationResponse] = [] + for n in notifications: + resp = NotificationResponse.model_validate(n) + if n.task_id and n.task_id in serial_map: + resp.product_serial_number = serial_map[n.task_id] + response_list.append(resp) + + return NotificationListResponse( + notifications=response_list, + total=total, + unread_count=unread_count, + ) + + +@router.put("/{notification_id}/read", response_model=NotificationResponse) +async def mark_notification_read( + notification_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """标记单条通知为已读""" + nid = uuid.UUID(notification_id) + result = await db.execute( + select(Notification).where(Notification.id == nid) + ) + notification = result.scalar_one_or_none() + if not notification: + from fastapi import HTTPException, status + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"通知不存在: {notification_id}", + ) + + notification.is_read = True + await db.commit() + await db.refresh(notification) + return NotificationResponse.model_validate(notification) diff --git a/backend/app/api/v1/endpoints/orders.py b/backend/app/api/v1/endpoints/orders.py new file mode 100644 index 0000000..22268df --- /dev/null +++ b/backend/app/api/v1/endpoints/orders.py @@ -0,0 +1,41 @@ +"""生产订单 API 端点""" +from __future__ import annotations +import uuid +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.database import get_db +from app.models.production_order import ProductionOrder +from app.schemas.order import OrderCreate, OrderResponse +from app.services.auth_service import get_current_user + +router = APIRouter(prefix="/orders", tags=["订单管理"]) + + +@router.get("/", response_model=list[OrderResponse]) +async def list_orders( + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + result = await db.execute( + select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc()) + ) + orders = result.scalars().all() + return [OrderResponse.model_validate(o) for o in orders] + + +@router.post("/", response_model=OrderResponse, status_code=201) +async def create_order( + data: OrderCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + order = ProductionOrder(**data.model_dump()) + db.add(order) + await db.commit() + await db.refresh(order) + return OrderResponse.model_validate(order) diff --git a/backend/app/api/v1/endpoints/print.py b/backend/app/api/v1/endpoints/print.py new file mode 100644 index 0000000..82a25ab --- /dev/null +++ b/backend/app/api/v1/endpoints/print.py @@ -0,0 +1,99 @@ +"""标签打印 API — 预览 / 执行 / 打印机配置""" +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field + +from app.services.label_service import generate_preview_image, send_to_printer +from app.services.print_config import PrintConfigManager +from app.services.auth_service import get_current_user + +router = APIRouter(prefix="/print", tags=["标签打印"]) + + +# ============================================================ +# 请求体 +# ============================================================ + +class LabelPreviewRequest(BaseModel): + serial_number: str = Field(..., min_length=1, description="16位HEX系统ID(二维码内容)") + material_name: str = Field("", description="物料名称") + spec_model: str = Field("", description="规格型号") + order_no: str = Field("", description="订单号(条件渲染)") + + +class PrintExecuteRequest(LabelPreviewRequest): + copies: int = Field(1, ge=1, le=100, description="打印份数") + printer_ip: str | None = Field(None, description="覆盖配置的打印机 IP") + printer_port: int | None = Field(None, description="覆盖配置的打印机端口") + + +class PrinterConfigUpdate(BaseModel): + ip: str = Field(..., description="打印机 IP 地址") + port: int = Field(9100, ge=1, le=65535, description="打印机端口") + enabled: bool = Field(True, description="是否启用") + + +# ============================================================ +# 端点 +# ============================================================ + +@router.post("/preview") +def print_preview(data: LabelPreviewRequest) -> dict: + """生成标签预览图(Base64 JPEG)""" + try: + data_url = generate_preview_image(**data.model_dump()) + return {"data_url": data_url} + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"生成预览失败: {str(e)}", + ) + + +@router.post("/execute") +def print_execute( + data: PrintExecuteRequest, + current_user: dict = Depends(get_current_user), +) -> dict: + """发送打印指令到物理打标机""" + payload = data.model_dump() + copies = payload.pop("copies", 1) + printer_ip = payload.pop("printer_ip", None) + printer_port = payload.pop("printer_port", None) + + result = send_to_printer( + copies=copies, + printer_ip=printer_ip, + printer_port=printer_port, + **payload, + ) + if not result["success"]: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=result["message"], + ) + return result + + +@router.get("/config") +def get_printer_config() -> dict: + """获取打印机当前配置""" + return PrintConfigManager.get_config() + + +@router.post("/config") +def update_printer_config( + data: PrinterConfigUpdate, + current_user: dict = Depends(get_current_user), +) -> dict: + """更新打印机配置(IP/端口)""" + current = PrintConfigManager.get_config() + current["label_printer"] = { + "ip": data.ip, + "port": data.port, + "enabled": data.enabled, + } + PrintConfigManager.save_config(current) + return { + "message": "打印机配置已更新", + "config": current["label_printer"], + } diff --git a/backend/app/api/v1/endpoints/products.py b/backend/app/api/v1/endpoints/products.py new file mode 100644 index 0000000..878faa8 --- /dev/null +++ b/backend/app/api/v1/endpoints/products.py @@ -0,0 +1,238 @@ +"""产品 API 端点 — 扫码查询、CRUD、二维码生成""" +from __future__ import annotations +from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.message import ProductMessage +from app.schemas.product import ( + ProductCreate, + ProductUpdate, + ProductResponse, + ProductScanResponse, +) +from app.services import product_service, product_finalize_service +from app.services.auth_service import get_current_user +from app.services.qrcode_service import generate_qrcode_png + +router = APIRouter(prefix="/products", tags=["产品管理"]) + + +# ============================================================ +# 二维码生成 — 根据序列号生成二维码 PNG 图片 +# ============================================================ + +@router.get("/qrcode/{serial_number}") +async def get_product_qrcode( + serial_number: str, + current_user: dict = Depends(get_current_user), +): + """ + 生成产品二维码(PNG 图片)。 + 内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。 + 尺寸:300×300 px,用于 PC 端打印或嵌入标签。 + """ + if len(serial_number) != 16: + from fastapi import HTTPException, status + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="序列号必须为 16 位", + ) + buf = generate_qrcode_png(serial_number, size_px=300) + return Response(content=buf.getvalue(), media_type="image/png") + + +# ============================================================ +# 扫码查询 — 根据 16 位序列号查产品 + 顶层任务 +# ============================================================ + +@router.get("/scan/{serial_number}", response_model=ProductScanResponse) +async def scan_product( + serial_number: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + 扫码接口:根据 16 位序列号查询产品及其当前进度。 + 返回产品信息、所属订单、以及顶层任务列表。 + """ + return await product_service.get_product_by_serial(db, serial_number) + + +# ============================================================ +# 产品 CRUD +# ============================================================ + +@router.get("/", response_model=list[ProductResponse]) +async def list_products( + skip: int = Query(0, ge=0, description="跳过条数"), + limit: int = Query(50, ge=1, le=1000, description="返回条数"), + keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"), + status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """获取产品列表 — 支持 keyword 搜索 + 状态筛选""" + return await product_service.get_all_products( + db, skip=skip, limit=limit, keyword=keyword, status_filter=status, + ) + + +@router.get("/{product_id}", response_model=ProductResponse) +async def get_product( + product_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """获取单个产品详情""" + import uuid + return await product_service.get_product(db, uuid.UUID(product_id)) + + +@router.post("/", response_model=ProductResponse, status_code=201) +async def create_product_endpoint( + data: ProductCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """创建产品 — 初始位置自动设为当前登录用户""" + creator_username = current_user.get("username", "") + return await product_service.create_product(db, data, creator_username) + + +@router.patch("/{product_id}", response_model=ProductResponse) +async def update_product_endpoint( + product_id: str, + data: ProductUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """更新产品""" + import uuid + return await product_service.update_product(db, uuid.UUID(product_id), data) + + +@router.delete("/{product_id}", status_code=204) +async def delete_product_endpoint( + product_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """删除产品及其关联任务""" + import uuid + await product_service.delete_product(db, uuid.UUID(product_id)) + + +# ============================================================ +# 宏观状态更新 — 扫码定调 +# ============================================================ + +class OverallStatusUpdate(BaseModel): + status: str = Field(..., min_length=1, max_length=20, description="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库") + + +@router.patch("/scan/{serial_number}/status", response_model=ProductScanResponse) +async def update_product_overall_status( + serial_number: str, + data: OverallStatusUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + 更新产品宏观流转状态。 + 移动端首次扫码或手动切换时调用。 + 合法值: 备货 | 生产 | 测试 | 维修 | 待仓库收货 | 已入库 | 已出库 + + 权限:仅 SUPER_ADMIN 或当前操作该产品主线任务的人可以修改。 + """ + return await product_service.update_overall_status( + db, serial_number, data.status, current_user, + ) + + +# ============================================================ +# 产品收口 — 管理员将产品修正为「已入库」/「已出库」(可反向纠错) +# ============================================================ + +class ProductFinalizeRequest(BaseModel): + """管理员收口入参""" + status: str = Field(..., min_length=1, max_length=20, description="收口目标: 已入库 | 已出库") + note: str | None = Field(None, max_length=200, description="备注(选填)") + + +@router.post("/scan/{serial_number}/finalize", response_model=ProductScanResponse) +async def finalize_product_status_endpoint( + serial_number: str, + data: ProductFinalizeRequest, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """管理员将产品整体收口为「已入库」或「已出库」(支持 入库<->出库 反向互切纠错)。 + + 与 MOM 出入库回调落库语义一致:同步 product.overall_status/status、写 + warehouse_inbound/outbound 日志、幂等追加「扫码入库/扫码出库」主线收尾节点。 + 权限:仅 SUPER_ADMIN / SUPERVISOR。 + """ + from fastapi import HTTPException, status + from app.services.task_service import ADMIN_ROLES + + if (current_user or {}).get("role") not in ADMIN_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅超级管理员或主管可执行入库/出库收口", + ) + return await product_finalize_service.finalize_product_status( + db, serial_number, data.status, current_user, data.note, + ) + + +# ============================================================ +# 协同留言板 +# ============================================================ + +class MessageCreate(BaseModel): + operator_id: str = Field(..., min_length=1, max_length=50, description="留言人姓名或工号") + content: str = Field(..., min_length=1, description="留言内容") + + +@router.get("/{product_id}/messages") +async def get_product_messages( + product_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """获取某产品的所有留言(按时间正序)""" + result = await db.execute( + select(ProductMessage) + .where(ProductMessage.product_id == product_id) + .order_by(ProductMessage.created_at.asc()) + ) + return result.scalars().all() + + +@router.post("/{product_id}/messages", status_code=201) +async def create_product_message( + product_id: str, + request: MessageCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """发布新留言(operator_id 由后端 Token 强制覆写,防止越权伪造)""" + import uuid + real_operator_id = ( + current_user.get("username") + or current_user.get("sub") + or request.operator_id + ) + msg = ProductMessage( + product_id=uuid.UUID(product_id), + operator_id=real_operator_id, + content=request.content, + ) + db.add(msg) + await db.commit() + await db.refresh(msg) + return msg diff --git a/backend/app/api/v1/endpoints/records.py b/backend/app/api/v1/endpoints/records.py new file mode 100644 index 0000000..8ac7531 --- /dev/null +++ b/backend/app/api/v1/endpoints/records.py @@ -0,0 +1,51 @@ +"""任务记录 CRUD — 编辑 / 删除""" +import json +import uuid +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.models.task import TaskRecord +from app.schemas.task import TaskRecordCreate, TaskRecordResponse +from app.services.auth_service import get_current_user + +router = APIRouter(prefix="/records", tags=["任务记录"]) + + +async def _get_record_or_404(db: AsyncSession, record_id: int) -> TaskRecord: + result = await db.execute(select(TaskRecord).where(TaskRecord.id == record_id)) + record = result.scalar_one_or_none() + if not record: + raise HTTPException(status_code=404, detail=f"记录不存在: {record_id}") + return record + + +@router.put("/{record_id}", response_model=TaskRecordResponse) +async def update_record( + record_id: int, + data: TaskRecordCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """更新任务记录(备注+图片)""" + record = await _get_record_or_404(db, record_id) + record.remark = data.remark or None + record.images = json.dumps(data.images) if data.images else None + await db.commit() + await db.refresh(record) + # 手动反序列化 images + return TaskRecordResponse.model_validate(record) + + +@router.delete("/{record_id}") +async def delete_record( + record_id: int, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """删除任务记录""" + record = await _get_record_or_404(db, record_id) + await db.delete(record) + await db.commit() + return {"message": "记录已删除"} diff --git a/backend/app/api/v1/endpoints/screen.py b/backend/app/api/v1/endpoints/screen.py new file mode 100644 index 0000000..7455682 --- /dev/null +++ b/backend/app/api/v1/endpoints/screen.py @@ -0,0 +1,52 @@ +"""大屏 API — 面向管理层**日常运营与督导**的轻量聚合接口 + +视角:当月吞吐 / 当前卡点 / 系统活跃度。 + +与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表), +/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。 +""" +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.services.screen_service import ( + get_monthly_metrics, MonthlyMetrics, + get_wip_distribution, WipDistributionResponse, + get_active_users, ActiveUsersResponse, +) + +router = APIRouter(prefix="/screen", tags=["大屏统计"]) + + +@router.get("/monthly-metrics", response_model=MonthlyMetrics) +async def monthly_metrics(db: AsyncSession = Depends(get_db)): + """ + 当月吞吐 — 大屏顶部四张数字卡。 + + 返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。 + 统计区间为北京时间当月 1 日 00:00 至此刻。 + """ + return await get_monthly_metrics(db) + + +@router.get("/wip-distribution", response_model=WipDistributionResponse) +async def wip_distribution(db: AsyncSession = Depends(get_db)): + """ + 工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。 + + 返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。 + """ + return await get_wip_distribution(db) + + +@router.get("/active-users", response_model=ActiveUsersResponse) +async def active_users( + top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"), + db: AsyncSession = Depends(get_db), +): + """ + 本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。 + + 桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。 + """ + return await get_active_users(db, top_n=top_n) diff --git a/backend/app/api/v1/endpoints/tasks.py b/backend/app/api/v1/endpoints/tasks.py new file mode 100644 index 0000000..c161e7e --- /dev/null +++ b/backend/app/api/v1/endpoints/tasks.py @@ -0,0 +1,322 @@ +"""任务 API 端点 — 核心业务:接收、驳回返工、裂变转交、无限嵌套子任务""" +from __future__ import annotations +import uuid +from fastapi import APIRouter, Depends, Query, Body +from pydantic import BaseModel, Field +from app.services.auth_service import get_current_user +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.schemas.task import ( + TaskCreate, + TaskUpdate, + TaskCompleteRequest, + TaskRejectRequest, + TaskTransferRequest, + TaskTransferBranch, + SubtaskCreate, + TaskRecordCreate, + TaskResponse, + TaskCompleteResponse, + TaskTransferResponse, + TaskSummaryResponse, + TaskListResponse, +) +from app.services import task_service + +router = APIRouter(prefix="/tasks", tags=["任务管理"]) + + +# ============================================================ +# 任务 CRUD +# ============================================================ + +@router.get("/", response_model=TaskListResponse) +async def list_tasks( + product_id: str | None = Query(None, description="按产品ID筛选"), + assignee_id: str | None = Query(None, description="按负责人ID筛选(逻辑外键→老系统)"), + skip: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """获取任务列表,可按产品/负责人筛选(只返回顶层任务)""" + pid = uuid.UUID(product_id) if product_id else None + return await task_service.get_all_tasks(db, product_id=pid, assignee_id=assignee_id, skip=skip, limit=limit) + + +@router.get("/{task_id}", response_model=TaskResponse) +async def get_task( + task_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + 获取任务详情 — 递归包含所有层级的子任务。 + 前端可根据此结果渲染完整的任务树。 + """ + return await task_service.get_task(db, uuid.UUID(task_id)) + + +@router.post("/", response_model=TaskResponse, status_code=201) +async def create_task_endpoint( + data: TaskCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """创建任务""" + return await task_service.create_task(db, data) + + +@router.patch("/{task_id}", response_model=TaskResponse) +async def update_task_endpoint( + task_id: str, + data: TaskUpdate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """更新任务""" + return await task_service.update_task(db, uuid.UUID(task_id), data) + + +# ============================================================ +# 核心卡点逻辑:任务完成 / 转交 +# ============================================================ + +@router.post("/{task_id}/complete", response_model=TaskCompleteResponse) +async def complete_task_endpoint( + task_id: str, + request: TaskCompleteRequest, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **核心接口:完成任务 + 可选创建下一步任务(转交)** + + 卡点逻辑: + 1. 检查当前任务是否已完成(幂等保护) + 2. 查询所有 `notify_parent_on_complete=True` 的子任务 + → 如果存在未完成的,返回 HTTP 400:「请等待相关子任务完成」 + 3. 全部通过后,标记任务为 completed,写入操作日志 + 4. 若提供了 `next_task_name` + `next_assignee_id`,自动创建下一步任务 + + 典型场景: + - 某个加工步骤完成,需要检查所有必须的前置工序(子任务)是否已完成 + - 完成后自动创建下一步任务并指定负责人 + """ + return await task_service.complete_task( + db, uuid.UUID(task_id), request, + operator_role=current_user.get("role"), + ) + + +# ============================================================ +# 核心业务 0:结束分支(终止当前节点,不创建下游) +# ============================================================ + +@router.post("/{task_id}/end", response_model=TaskResponse) +async def end_task_endpoint( + task_id: str, + operator_id: str | None = Query(None), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **结束当前分支:标记任务为 COMPLETED,不创建下游任务。** + + 用于工人认为工序已完结、无需转交下一人的场景。 + """ + return await task_service.end_task( + db, uuid.UUID(task_id), + operator_id or current_user.get("username", "") or None, + operator_role=current_user.get("role"), + ) + + +# ============================================================ +# 核心业务 0.3:撤回转交 (PENDING → 删除 + 恢复父任务) +# ============================================================ + +@router.post("/{task_id}/recall", response_model=TaskResponse) +async def recall_task_endpoint( + task_id: str, + operator_id: str | None = Query(None), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。** + 适用场景:转交后发现选错人,在对方接收前撤回。 + """ + return await task_service.recall_task( + db, uuid.UUID(task_id), + operator_id or current_user.get("username", "") or None, + operator_role=current_user.get("role"), + ) + + +# ============================================================ +# 核心业务 0.5:并发派发协助分支 (WIP → 不改变状态,创建子任务) +# ============================================================ + +class SpawnRequest(BaseModel): + task_name: str = Field(..., max_length=200, description="工序名称") + assignee_id: str | None = Field(None, max_length=64, description="负责人ID") + remark: str | None = Field(None, max_length=2000, description="派发备注") + + +@router.post("/{task_id}/spawn", response_model=TaskResponse, status_code=201) +async def spawn_subtask_endpoint( + task_id: str, + data: SpawnRequest, + operator_id: str | None = Query(None), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。** + 用于 WIP 期间工人需要其他人协助协同的场景。 + """ + return await task_service.spawn_subtask( + db, uuid.UUID(task_id), data, + operator_id or current_user.get("username", "") or None) + + +# ============================================================ +# 核心业务 1:确认接收 (PENDING → WIP) +# ============================================================ + +@router.post("/{task_id}/receive", response_model=TaskResponse) +async def receive_task_endpoint( + task_id: str, + operator_id: str | None = Query(None, description="操作人ID"), + remark: str | None = Body(None, description="接收备注", embed=True), + task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **确认接收任务。工人选定工序名称后接收。** + + 校验:只有状态为 PENDING 的任务可接收。 + 动作:状态改为 WIP,记录 received_at,更新 task_name,同步产品宏观状态。 + """ + return await task_service.receive_task( + db, uuid.UUID(task_id), + operator_id or current_user.get("username", "") or None, + remark, task_name, + operator_role=current_user.get("role"), + ) + + +# ============================================================ +# 核心业务 2:品质驳回 (→ REJECTED + 返工闭环) +# ============================================================ + +@router.post("/{task_id}/reject", response_model=TaskResponse) +async def reject_task_endpoint( + task_id: str, + request: TaskRejectRequest, + operator_id: str | None = Query(None, description="操作人ID"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **品质驳回:将任务标记为 REJECTED,自动创建返工任务。** + + 防呆闭环逻辑: + 1. 将当前任务状态改为 REJECTED,记录 reject_reason 和 completed_at。 + 2. 查找上一道工序的负责人(父任务的 assignee_id)。 + 3. 为该负责人新建返工任务(is_rework=True, status=PENDING)。 + """ + return await task_service.reject_task( + db, uuid.UUID(task_id), request, + operator_id or current_user.get("username", "") or None, + operator_role=current_user.get("role"), + ) + + +# ============================================================ +# 核心业务 3:完工并裂变转交 (→ COMPLETED + 多路裂变) +# ============================================================ + +@router.post("/{task_id}/transfer", response_model=TaskTransferResponse) +async def transfer_task_endpoint( + task_id: str, + request: TaskTransferRequest, + operator_id: str | None = Query(None, description="操作人ID"), + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **完工并裂变转交:完成当前任务,批量创建下一道工序任务。** + + 动作 1(闭环当前节点): + - 将当前任务状态改为 COMPLETED,记录 completed_at。 + + 动作 2(解析下家): + - 遍历 next_assignees 列表。 + - 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为仓库。 + - 为每一个 assignee_id 新建 PENDING 任务。 + + 裂变逻辑: + - next_assignees > 1 → 多路裂变,新任务挂在当前任务下形成树状分支。 + - 当前任务是子任务 → 单路转交也保持在同一父任务下。 + - 否则 → 顶层同级转交。 + """ + return await task_service.transfer_task( + db, uuid.UUID(task_id), request, + operator_id or current_user.get("username", "") or None, + operator_role=current_user.get("role"), + ) + + +# ============================================================ +# 无限层级子任务 +# ============================================================ + +@router.post("/{task_id}/subtasks", response_model=TaskResponse, status_code=201) +async def create_subtask_endpoint( + task_id: str, + data: SubtaskCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """ + **创建子任务:支持无限层级嵌套。** + + 新子任务将自动继承父任务的 product_id。 + 若父任务已完成,拒绝创建。 + """ + return await task_service.create_subtask( + db, uuid.UUID(task_id), data + ) + + +# ============================================================ +# 查询产品顶层任务(便捷接口) +# ============================================================ + +@router.get("/by-product/{product_id}", response_model=list[TaskSummaryResponse]) +async def get_tasks_by_product( + product_id: str, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """获取指定产品的顶层任务列表(不含子任务嵌套)""" + return await task_service.get_top_level_tasks(db, uuid.UUID(product_id)) + + +# ============================================================ +# 任务进度记录 — 备注/传图 +# ============================================================ + +@router.patch("/{task_id}/records", response_model=TaskResponse) +async def add_task_record_endpoint( + task_id: str, + data: TaskRecordCreate, + db: AsyncSession = Depends(get_db), + current_user: dict = Depends(get_current_user), +): + """追加进度记录(备注+图片),不改变任务状态""" + return await task_service.add_task_record(db, uuid.UUID(task_id), data, current_user) diff --git a/backend/app/api/v1/endpoints/upload.py b/backend/app/api/v1/endpoints/upload.py new file mode 100644 index 0000000..e8595af --- /dev/null +++ b/backend/app/api/v1/endpoints/upload.py @@ -0,0 +1,51 @@ +"""文件上传 & 静态文件访问""" +import os +import uuid + +from fastapi import APIRouter, UploadFile, File, HTTPException, status +from fastapi.responses import FileResponse + +router = APIRouter(prefix="/upload", tags=["文件上传"]) + +# Docker: os.getcwd() = /app → /app/uploads → host:backend/uploads +BASE_DIR = os.environ.get("PROJECT_ROOT", os.getcwd()) +UPLOAD_DIR = os.path.join(BASE_DIR, "uploads") +os.makedirs(UPLOAD_DIR, exist_ok=True) + +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip", "rar", "7z"} +MAX_SIZE = 50 * 1024 * 1024 # 50MB + + +@router.post("/") +async def upload_file(file: UploadFile = File(...)) -> dict: + """上传文件 → 保存到 uploads/uuid.ext → 返回可访问 URL""" + if not file.filename: + raise HTTPException(status_code=400, detail="文件名为空") + + ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + if ext not in ALLOWED_EXTENSIONS: + raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{ext}") + + # 检查大小 + content = await file.read() + if len(content) > MAX_SIZE: + raise HTTPException(status_code=400, detail="文件超过 50MB 限制") + await file.seek(0) + + os.makedirs(UPLOAD_DIR, exist_ok=True) + filename = f"{uuid.uuid4().hex}.{ext}" + filepath = os.path.join(UPLOAD_DIR, filename) + + with open(filepath, "wb") as f: + f.write(await file.read()) + + return {"url": f"/api/v1/upload/files/{filename}"} + + +@router.get("/files/{filename}") +async def serve_file(filename: str): + """直接返回物理文件""" + filepath = os.path.join(UPLOAD_DIR, filename) + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="文件不存在") + return FileResponse(filepath) diff --git a/backend/app/api/v1/endpoints/users.py b/backend/app/api/v1/endpoints/users.py new file mode 100644 index 0000000..7fb6ec6 --- /dev/null +++ b/backend/app/api/v1/endpoints/users.py @@ -0,0 +1,75 @@ +"""用户列表 — 对接 MOM sys_user""" +from fastapi import APIRouter, Query, HTTPException, status +from pydantic import BaseModel +from app.core.mom_database import MomSessionLocal +from sqlalchemy import text + +router = APIRouter(prefix="/users", tags=["用户"]) + + +class UserOption(BaseModel): + id: str + username: str + full_name: str + department: str = "" + + model_config = {"from_attributes": True} + + +@router.get("/", response_model=list[UserOption]) +def list_users( + keyword: str = Query("", description="按用户名/姓名模糊搜索"), + limit: int = Query(100, ge=1, le=500), + dept: str = Query("IRIS", description="部门过滤"), +): + """获取 MOM 系统用户列表,默认只返回 IRIS 部门""" + db = MomSessionLocal() + try: + # 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询 + try: + base_sql = """ + SELECT id, username, + SPLIT_PART(username, '/', 1) AS full_name, + COALESCE(department, '') AS department + FROM sys_user + WHERE department = :dept + """ + params = {"dept": dept, "lim": limit} + if keyword.strip(): + sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim") + params["kw"] = f"%{keyword.strip()}%" + else: + sql = text(base_sql + " ORDER BY username LIMIT :lim") + rows = db.execute(sql, params).fetchall() + except Exception: + # 降级:不使用 department 列过滤 + fallback_sql = """ + SELECT id, username, + SPLIT_PART(username, '/', 1) AS full_name, + '' AS department + FROM sys_user + """ + params = {"lim": limit} + if keyword.strip(): + sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim") + params["kw"] = f"%{keyword.strip()}%" + else: + sql = text(fallback_sql + " ORDER BY username LIMIT :lim") + rows = db.execute(sql, params).fetchall() + + return [ + UserOption( + id=str(row.id), + username=row.username.split("/")[-1] if "/" in row.username else row.username, + full_name=row.full_name, + department=row.department or "", + ) + for row in rows + ] + except Exception as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"MOM 用户查询失败: {str(e)}", + ) + finally: + db.close() diff --git a/backend/app/api/v1/endpoints/webhooks.py b/backend/app/api/v1/endpoints/webhooks.py new file mode 100644 index 0000000..649e0e8 --- /dev/null +++ b/backend/app/api/v1/endpoints/webhooks.py @@ -0,0 +1,417 @@ +"""外部系统回调 Webhook — Track 作为接收方 + +MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态 +真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。 + +同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理 +回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见 +_mom_inbound_revoke 上方的特权通道说明)。 +""" +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.database import get_db +from app.core.lifecycle import sync_product_status +from app.models.product import Product +from app.models.task import Task, TaskRecord +from app.models.task_log import TaskLog + +router = APIRouter(prefix="/external/webhooks", tags=["外部回调"]) + + +class MomInboundPayload(BaseModel): + """MOM 仓储系统确认接收入库 / 撤回出库的回调载荷""" + serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配) + sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配) + operator: str | None = None # 入库操作人(写入 task_logs.operator_id) + inbound_time: datetime | None = None # 入库确认时间 + # ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。 + event: str | None = None # 事件名,如 inbound.created / outbound.revoked + action: str | None = None # 显式动作指令,如 revoke_outbound + source_table: str | None = None # stock_product / stock_semi + + +# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。 +# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked / +# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。 +_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel") + + +def _is_outbound_revoke(payload: MomInboundPayload) -> bool: + """payload 是否携带**显式**的撤回出库信号。 + + 注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以 + 常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于 + 已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。 + """ + for raw in (payload.action, payload.event): + token = (raw or "").strip().lower() + if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS): + return True + return False + + +async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None: + """挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。""" + task = ( + await db.execute( + select(Task) + .where(Task.product_id == product.id, Task.task_name.ilike("%在库%")) + .order_by(Task.created_at.desc()) + .limit(1) + ) + ).scalars().first() + if task is None: + task = ( + await db.execute( + select(Task) + .where(Task.product_id == product.id) + .order_by(Task.created_at.desc()) + .limit(1) + ) + ).scalars().first() + return task + + +async def _match_inbound_product( + db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool, +) -> Product | None: + """按 serial_number(优先)或 sku 匹配产品。 + + allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。 + allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id + 置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False, + 造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。 + """ + location_cond = Product.current_location_id == "virtual_warehouse" + where_cond = ( + or_( + location_cond, + Product.overall_status == "已出库", + Product.status == "OUTBOUND", + ) + if allow_outbound + else location_cond + ) + + if payload.serial_number: + return ( + await db.execute( + select(Product).where( + or_( + Product.serial_number == payload.serial_number, + Product.external_serial == payload.serial_number, + ), + where_cond, + ) + ) + ).scalar_one_or_none() + if payload.sku: + return ( + await db.execute( + select(Product) + .where(Product.spec_model == payload.sku, where_cond) + .order_by(Product.created_at.desc()) + ) + ).scalars().first() + return None + + +@router.post("/mom-inbound") +async def mom_inbound_webhook( + payload: MomInboundPayload, + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + db: AsyncSession = Depends(get_db), +) -> dict: + """MOM 确认接收入库 / 撤回出库后回调本接口。 + + - 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。 + - 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」 + 的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。 + - 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。 + - 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。 + """ + # ── 鉴权 ── + if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY: + raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key") + + explicit_revoke = _is_outbound_revoke(payload) + + # ── 匹配产品 ── + # 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。 + # 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。 + product = await _match_inbound_product( + db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number), + ) + + # ── 未命中:可能是非 Track 生产的物料,直接忽略 ── + if product is None: + return {"ok": True, "matched": False} + + # 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。 + # 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。 + was_outbound = ( + (product.overall_status or "").strip() == "已出库" + or (product.status or "").strip().upper() == "OUTBOUND" + ) + is_revoke = explicit_revoke or was_outbound + + # ══════════════════════════════════════════════════════════════════════ + # ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束 + # + # 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL, + # task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转 + # 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说 + # 货已回到仓库,Track 必须无条件跟随。 + # + # ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当 + # 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」, + # 与 MOM 账面对不上——正是本次要消灭的数据脑裂。 + # ══════════════════════════════════════════════════════════════════════ + changed = False + + # 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回) + if product.overall_status != "已入库": + product.overall_status = "已入库" + changed = True + + # 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None) + if product.current_location_id != "virtual_warehouse": + product.current_location_id = "virtual_warehouse" + changed = True + + # 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。 + # (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正) + # ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location + # 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。 + prev_status = product.status + sync_product_status(product) + if product.status != prev_status: + changed = True + + # ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ── + log_task = await _pick_warehouse_log_task(db, product) + if log_task is not None: + if is_revoke: + signal = payload.action or payload.event or "inbound.created(隐式)" + remark = ( + f"MOM 撤回出库 → 强制回滚:宏观状态已入库、" + f"位置已回到 virtual_warehouse(信号: {signal})" + ) + action_type = "warehouse_outbound_revoked" + else: + time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—" + remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})" + action_type = "warehouse_inbound" + + db.add(TaskLog( + task_id=log_task.id, + operator_id=(payload.operator or "virtual_warehouse")[:64], + action_type=action_type, + remark=remark, + )) + changed = True + + # ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ── + if is_revoke: + # 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是 + # 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。 + # + # ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约: + # product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定 + # 仓库节点("在库" in task_name or "入库" in task_name)。而 + # 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务", + # 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 / + # 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)—— + # 该设备明明已入库且在仓库里,树尾却显示待收货。 + # 补上「重新入库」后关键字命中,虚拟节点不再注入。 + appended = await _append_warehouse_task( + db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库", + ) + else: + appended = await _append_warehouse_task( + db, product, "扫码入库", "通过 MOM 系统扫码入库完成", + ) + if appended: + changed = True + + if changed: + await db.commit() + + return { + "ok": True, + "matched": True, + "serial_number": product.serial_number, + "revoked": is_revoke, + } + + +async def _append_warehouse_task( + db: AsyncSession, + product: Product, + task_name: str, + record_remark: str, +) -> bool: + """在流转树主干道最底部追加一个主线任务节点(扫码入库/扫码出库)+ 操作日志。 + + 逻辑: + - 找该产品最后一个主线任务(created_at 最晚且为主线)的 id 作为 parent_task_id, + 保证树状主干连贯; + - 插入 task_type='TRANSFER' 的主线任务(现有主线枚举 → is_main=True,画在中央主干道), + 状态直接 COMPLETED; + - 生成一条 TaskRecord 供前端"查看操作日志"展示。 + + 返回是否新增了节点(供调用方置 changed=True 触发提交)。 + """ + from uuid import uuid4 + from datetime import datetime as _dt + + # 0) 幂等:该产品若已有同名主线任务(扫码入库/扫码出库),则不重复插入 + existing = ( + await db.execute( + select(Task.id).where( + Task.product_id == product.id, + Task.task_name == task_name, + ).limit(1) + ) + ).scalars().first() + if existing: + return False + + # 1) 最后一个主线任务(created_at 最晚,且无父任务 或 task_type 为主线枚举) + last_main_task = ( + await db.execute( + select(Task) + .where( + Task.product_id == product.id, + or_( + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY"]), + ), + ) + .order_by(Task.created_at.desc()) + .limit(1) + ) + ).scalars().first() + + # 2) 插入"扫码入库/扫码出库"主线任务 + new_task = Task( + product_id=product.id, + parent_task_id=last_main_task.id if last_main_task else None, + task_name=task_name, + # ★ assignee_id 显式置 None:不能填非 UUID 字符串,否则前端解析头像/用户信息报错导致节点跳过渲染 + assignee_id=None, + status="COMPLETED", + task_type="WAREHOUSE", # 仓储任务类型(is_main 判断已兼容 WAREHOUSE → 画在中央主干道) + completed_at=_dt.now(), + remark=record_remark, + ) + db.add(new_task) + await db.flush() # 生成 new_task.id + + # 3) 生成操作日志(TaskRecord),供前端"查看操作日志"有真实数据 + db.add(TaskRecord( + task_id=new_task.id, + remark=record_remark, + )) + return True + +class MomOutboundPayload(BaseModel): + """MOM 仓储系统发货出库的回调载荷""" + serial_number: str | None = None # 产品 16 位身份证(优先匹配) + sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配) + operator: str | None = None # 出库操作人(写入 task_logs.operator_id) + outbound_time: datetime | None = None # 出库时间 + + +@router.post("/mom-outbound") +async def mom_outbound_webhook( + payload: MomOutboundPayload, + x_api_key: str | None = Header(default=None, alias="X-API-Key"), + db: AsyncSession = Depends(get_db), +) -> dict: + """MOM 仓储系统发货出库后回调本接口,将 Track 产品标记为"已出库"。 + + - 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。 + - 用 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品; + 命中则标记"已出库"(overall_status=已出库 + status=OUTBOUND + 记录 task_logs)。 + - 未命中返回 200(MOM 出库的可能是非 Track 生产的物料,直接忽略)。 + """ + # ── 鉴权 ── + if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY: + raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key") + + # ── 按 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品 ── + product = None + where_cond = or_( + Product.current_location_id == "virtual_warehouse", + Product.overall_status.in_(["已入库", "在库"]), + ) + if payload.serial_number: + product = ( + await db.execute( + select(Product).where( + or_( + Product.serial_number == payload.serial_number, + Product.external_serial == payload.serial_number, + ), + where_cond, + ) + ) + ).scalars().first() + elif payload.sku: + product = ( + await db.execute( + select(Product) + .where(Product.spec_model == payload.sku, where_cond) + .order_by(Product.created_at.desc()) + ) + ).scalars().first() + + # ── 未命中:可能出库的是非 Track 生产的物料,直接忽略 ── + if product is None: + return {"ok": True, "matched": False} + + # ── 标记"已出库" ── + changed = False + if product.overall_status != "已出库": + product.overall_status = "已出库" + product.status = "OUTBOUND" + changed = True + + # 🚚 同步出清厂内位置(与 _recalc_product_location 的「货发走就离场」对齐): + # 本回调的匹配条件之一就是 current_location_id == "virtual_warehouse", + # 即设备此刻还挂在仓库池里。既然 MOM 已确认发货,就不该再显示为厂内仓库/工位。 + # 置空后产品列表的「当前位置」显示为「—」。 + if product.current_location_id is not None: + product.current_location_id = None + changed = True + + # 记录出库日志(优先"在库"任务,其次该产品最新任务) + outbound_task = await _pick_warehouse_log_task(db, product) + + if outbound_task is not None: + time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—" + db.add(TaskLog( + task_id=outbound_task.id, + operator_id=(payload.operator or "virtual_warehouse")[:64], + action_type="warehouse_outbound", + remark=f"MOM 仓储系统发货出库(outbound_time: {time_str})", + )) + changed = True + + # ── 动态生成"扫码出库"主线任务节点 + 操作日志(流转树最底部长出出库节点) ── + if await _append_warehouse_task(db, product, "扫码出库", "通过 MOM 系统扫码出库完成"): + changed = True + + if changed: + await db.commit() + + return {"ok": True, "matched": True, "serial_number": product.serial_number} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py new file mode 100644 index 0000000..9134603 --- /dev/null +++ b/backend/app/api/v1/router.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter + +from app.api.v1.endpoints.products import router as products_router +from app.api.v1.endpoints.tasks import router as tasks_router +from app.api.v1.endpoints.orders import router as orders_router +from app.api.v1.endpoints.dashboard import router as dashboard_router +from app.api.v1.endpoints.auth import router as auth_router +from app.api.v1.endpoints.print import router as print_router +from app.api.v1.endpoints.materials import router as materials_router +from app.api.v1.endpoints.users import router as users_router +from app.api.v1.endpoints.upload import router as upload_router +from app.api.v1.endpoints.records import router as records_router +from app.api.v1.endpoints.notifications import router as notifications_router +from app.api.v1.endpoints.app_version import router as app_version_router +from app.api.v1.endpoints.analytics import router as analytics_router +from app.api.v1.endpoints.holidays import router as holidays_router +from app.api.v1.endpoints.webhooks import router as webhooks_router +from app.api.v1.endpoints.external_products import router as external_products_router +from app.api.v1.endpoints.screen import router as screen_router +from app.api.v1.endpoints.audit import router as audit_router + +api_router = APIRouter() + +api_router.include_router(auth_router) +api_router.include_router(dashboard_router) +api_router.include_router(orders_router) +api_router.include_router(products_router) +api_router.include_router(tasks_router) +api_router.include_router(print_router) +api_router.include_router(materials_router) +api_router.include_router(users_router) +api_router.include_router(upload_router) +api_router.include_router(records_router) +api_router.include_router(notifications_router) +api_router.include_router(app_version_router) +api_router.include_router(analytics_router) +api_router.include_router(holidays_router) +api_router.include_router(webhooks_router) +api_router.include_router(external_products_router) +api_router.include_router(screen_router) +api_router.include_router(audit_router) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/audit_middleware.py b/backend/app/core/audit_middleware.py new file mode 100644 index 0000000..f42f1b0 --- /dev/null +++ b/backend/app/core/audit_middleware.py @@ -0,0 +1,234 @@ +"""审计采集中间件 + +在响应生成后,把「谁 / 何时 / 从哪来 / 调了哪个接口 / 做了什么 / 结果如何」 +落进 audit_logs。 + +为什么用中间件自动采集,而不是在每个业务函数里手写 record_audit +------------------------------------------------------------------ +1. 手写必然漏。新加的端点很容易忘记补审计,而审计的价值恰恰建立在「完整」上。 + 现状可佐证:task_logs 全项目只有 4 处写入点,凡是不挂在任务上的动作 + (登录、导出、改产品)全都没有留痕。 +2. 中间件能拿到业务函数拿不到的事实:真实来源 IP、UA、最终状态码、 + 以及与结构化日志对齐的 request_id。 +3. 业务语义(module / target)由路径推导,不如手写精确,但对「谁动了什么」 + 的追责场景已经够用;关键动作后续可再调 record_audit 补 details 做增强。 + +采集范围 +-------- +- 所有写操作(POST/PUT/PATCH/DELETE) +- 少数**读但敏感**的操作:导出、下载、打印(本项目 GET /people-history/export + 就是导出,只按方法过滤会漏掉) + +明确不采集:GET /health*、/docs、/openapi.json —— 探针与文档的噪声没有审计价值。 +""" +from __future__ import annotations + +import logging + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +from app.services.audit_service import record_audit + +logger = logging.getLogger("track.audit") + +# 写操作一律采集 +_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +# 读操作里需要留痕的(导出/下载/打印属于「读」,但把数据带出了系统) +_SENSITIVE_READ_KEYWORDS = frozenset({"export", "download", "print"}) + +# 核心业务模块 —— 这些前缀下的「查看详情」GET 也采集, +# 用于回答「谁在什么时候看过哪条业务数据」,而不只是「谁改过」。 +# +# ⚠️ 只覆盖【核心业务实体】: +# products —— 移动端扫码查询 GET /products/scan/{sn} 是车间最高频的读操作 +# tasks —— 查看任务详情 /tasks/{id} +# records —— 任务记录 +# notifications / orders —— 见下方 _is_bare_list 的说明 +_TRACKED_READ_PREFIXES = ( + "/api/v1/notifications", + "/api/v1/tasks", + "/api/v1/orders", + "/api/v1/products", + "/api/v1/records", +) + +# 永久忽略的路径前缀 +_IGNORED_PREFIXES = ("/health", "/docs", "/redoc", "/openapi.json") + +# 路径段 → 审计模块 +_PATH_MODULE: dict[str, str] = { + "products": "product", + "tasks": "task", + "orders": "order", + "records": "record", + "print": "print", + "materials": "material", + "users": "user", + "upload": "upload", + "notifications": "notification", + "app-version": "app", + "analytics": "analytics", + "dashboard": "dashboard", + "holidays": "holiday", + "screen": "screen", + "webhooks": "external", + "external": "external", + "audit": "audit", + "auth": "auth", +} + +# 路径段 → 动作(优先于按 HTTP 方法推断) +_SEGMENT_ACTION: dict[str, str] = { + "login": "login", + "logout": "logout", + "refresh": "refresh", + "export": "export", + "download": "export", + "print": "print", + "upload": "upload", + "finalize": "finalize", + "receive": "receive", + "transfer": "transfer", + "reject": "reject", + "recall": "recall", + "spawn": "spawn", + "complete": "complete", + "end": "end", + # 消息已读:PUT /notifications/{id}/read。 + # 没有这一条时会回退到 _METHOD_ACTION(PUT → update → "修改"), + # 把"点开一条通知"记成"修改了某样东西",语义完全走样。 + "read": "mark_read", +} + +_METHOD_ACTION: dict[str, str] = { + "POST": "create", + "PUT": "update", + "PATCH": "update", + "DELETE": "delete", + "GET": "read", +} + +# 不可能是业务 ID 的路径段,避免把动作词误当成 target_id +_NON_ID_SEGMENTS = frozenset( + set(_SEGMENT_ACTION) | {"api", "v1", "me", "options", "export", "lookup", "batch"} +) + + +def _is_bare_list(path: str) -> bool: + """判断是否只是「拉整个列表」(如 GET /api/v1/tasks/)。 + + 这类请求【不采集】,理由: + · 列表接口被前端高频轮询(消息、任务列表尤其明显),逐条留痕会让 + audit_logs 迅速膨胀,真正有价值的操作反而被淹没; + · 「查看详情」(/tasks/{id}) 才代表用户真的点开了某条业务数据。 + + 判定用「去掉末尾斜杠后是否恰好等于某个受跟踪前缀」, + 比正则更直观,也天然把查询串排除在外(request.url.path 不含 ?query)。 + """ + return path.rstrip("/") in _TRACKED_READ_PREFIXES + + +def _derive_module_and_action(path: str, method: str) -> tuple[str, str, str | None]: + """由请求路径与 HTTP 方法推导 (module, action, target_id)""" + parts = [p for p in path.split("/") if p] + + module = "other" + module_idx = -1 + for i, seg in enumerate(parts): + if seg in _PATH_MODULE: + module = _PATH_MODULE[seg] + module_idx = i + break + + action = None + for seg in reversed(parts): + if seg in _SEGMENT_ACTION: + action = _SEGMENT_ACTION[seg] + break + if action is None: + action = _METHOD_ACTION.get(method, method.lower()) + + target_id = None + if module_idx >= 0 and module_idx + 1 < len(parts): + candidate = parts[module_idx + 1] + if candidate not in _NON_ID_SEGMENTS: + target_id = candidate + + return module, action, target_id + + +class AuditMiddleware(BaseHTTPMiddleware): + """写操作审计采集。 + + 必须注册在 RequestContextMiddleware **内层**,因为它依赖后者写入 + request.state 的 request_id 才能与结构化日志对账。 + """ + + def _should_audit(self, request: Request) -> bool: + path = request.url.path + if path.startswith(_IGNORED_PREFIXES): + return False + if request.method in _MUTATING_METHODS: + return True + if request.method == "GET": + lowered = path.lower() + if any(kw in lowered for kw in _SENSITIVE_READ_KEYWORDS): + return True + # 核心业务数据的「查看详情」也留痕(证明用户在真的使用系统) + if path.startswith(_TRACKED_READ_PREFIXES): + return not _is_bare_list(path) + return False + return False + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + if not self._should_audit(request): + return await call_next(request) + + status_code = 500 + error_message: str | None = None + try: + response = await call_next(request) + status_code = response.status_code + return response + except Exception as exc: + # 异常最终由 ServerErrorMiddleware 转成 500;这里先标记, + # 保证「失败的操作也有审计」——这正是选用独立 session 的目的 + error_message = f"{type(exc).__name__}: {exc}"[:1000] + raise + finally: + await self._write(request, status_code, error_message) + + async def _write( + self, request: Request, status_code: int, error_message: str | None + ) -> None: + try: + module, action, target_id = _derive_module_and_action( + request.url.path, request.method + ) + client = request.client + await record_audit( + action=action, + module=module, + user_id=getattr(request.state, "audit_user", None), + display_name=getattr(request.state, "audit_display_name", None), + role=getattr(request.state, "audit_role", None), + target_type=module, + target_id=target_id, + # 对产品而言路径里的 ID 就是身份证号,本身即人可读的标识 + target_name=target_id if module == "product" else None, + ip_address=client.host if client else None, + user_agent=request.headers.get("user-agent"), + method=request.method, + url=request.url.path, + status_code=status_code, + error_message=error_message, + request_id=getattr(request.state, "request_id", None), + ) + except Exception: + # record_audit 内部已兜底;这里再兜一层,确保审计绝不冒泡成 500 + logger.exception("审计采集失败(已忽略)") diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..4fef81d --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,60 @@ +"""核心配置 — Pydantic Settings 自动从 .env 读取""" +import json +from pydantic import model_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # ---- 数据库 ---- + DATABASE_URL: str = "postgresql+asyncpg://track:track_prod_2026@localhost:5433/track_production" + + # ---- JWT ---- + SECRET_KEY: str = "change-me-in-production" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 120 # Access Token: 2 小时 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 # Refresh Token: 7 天 + + # ---- 调试 ---- + DEBUG: bool = True + + # ---- 应用元信息 ---- + APP_VERSION: str = "1.0.0" + + # ---- 日志 ---- + LOG_LEVEL: str = "INFO" + LOG_JSON: bool = True # 生产保持 True(便于采集);本地调试可设 False 换可读格式 + + # ---- 错误追踪(可选,不装 sentry-sdk 则自动跳过)---- + SENTRY_DSN: str | None = None + SENTRY_TRACES_SAMPLE_RATE: float = 0.0 + + # ---- CORS 跨域白名单(JSON 数组字符串,直接从 .env 的 CORS_ORIGINS 读取) ---- + CORS_ORIGINS: str = '["http://localhost:1420", "tauri://localhost"]' + + # ---- MOM 仓储系统回调 Webhook(Track 作为接收方,验签用) ---- + TRACK_WEBHOOK_KEY: str | None = None # MOM 回调 POST 时 Header X-API-Key 须等于此值 + + @property + def CORS_ORIGINS_LIST(self) -> list[str]: + """将 JSON 字符串解析为 Python list,供 CORSMiddleware 使用""" + try: + return json.loads(self.CORS_ORIGINS) + except (json.JSONDecodeError, TypeError): + return ["http://localhost:1420", "tauri://localhost"] + + @model_validator(mode="after") + def _validate_production_secret(self): + """生产环境强制校验:SECRET_KEY 禁止使用默认值""" + if not self.DEBUG and self.SECRET_KEY == "change-me-in-production": + raise ValueError( + "生产环境 (DEBUG=False) 禁止使用默认 SECRET_KEY。" + "请在 .env 中设置 SECRET_KEY 为至少 32 字符的随机值。" + "示例: python -c \"import secrets; print(secrets.token_urlsafe(32))\"" + ) + return self + + class Config: + env_file = ".env" + extra = "ignore" + + +settings = Settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..a8192b9 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,33 @@ +"""数据库连接 — 异步引擎 + 连接池""" +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.core.config import settings + +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.DEBUG, + pool_size=20, # 连接池常驻连接数 + max_overflow=10, # 超出 pool_size 时最多再创建的连接数 + pool_recycle=3600, # 连接回收时间(秒),防止 MySQL 8 小时断连 + pool_pre_ping=True, # 每次取出连接前先 ping 检测可用性 + connect_args={ + "server_settings": {"TimeZone": "Asia/Shanghai"}, # PG 会话级北京时间 + }, +) + +AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) + + +async def get_db() -> AsyncSession: + """FastAPI 依赖注入:每次请求获取一个数据库会话(带事务安全兜底)""" + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise + finally: + await session.close() diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..a532b59 --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,35 @@ +"""通用 FastAPI 依赖""" +from __future__ import annotations + +from fastapi import Depends, HTTPException, status + +from app.core.roles import ADMIN_ROLES +from app.services.auth_service import get_current_user + + +def require_roles(*roles: str): + """生成「限定角色」依赖,避免同一个内联判断被复制到每个端点。 + + 用法:: + + @router.get("/x") + async def x(current_user: dict = Depends(require_admin)): + ... + + 失败一律 403 且不透露允许的角色集合(避免给探测者提供线索)。 + """ + allowed = frozenset(roles) + + async def _guard(current_user: dict = Depends(get_current_user)) -> dict: + if (current_user or {}).get("role") not in allowed: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="当前角色无权访问该接口", + ) + return current_user + + return _guard + + +# 审计日志等高权限接口复用同一实例 +require_admin = require_roles(*ADMIN_ROLES) diff --git a/backend/app/core/health.py b/backend/app/core/health.py new file mode 100644 index 0000000..b535b00 --- /dev/null +++ b/backend/app/core/health.py @@ -0,0 +1,93 @@ +"""健康检查 — 存活探针与就绪探针分离 + +为什么必须拆开: +- 存活探针(liveness)只回答「进程还活着吗」,绝不能探测外部依赖。 + 否则数据库抖一下,编排系统会判定进程已死并反复重启容器, + 把一次依赖故障放大成全站雪崩。 +- 就绪探针(readiness)回答「现在能对外服务吗」。依赖不可用时返回 503, + 由负载均衡把该实例摘掉,依赖恢复后自动回来。 +""" +from __future__ import annotations + +import logging + +import anyio +from fastapi import APIRouter +from fastapi.responses import JSONResponse +from sqlalchemy import text + +from app.core.config import settings +from app.core.database import AsyncSessionLocal +from app.core.mom_database import mom_engine + +logger = logging.getLogger("track.health") + +router = APIRouter(tags=["健康检查"]) + + +async def _probe_primary_db() -> bool: + """主库探活 — 业务强依赖,失败即不就绪""" + try: + async with AsyncSessionLocal() as session: + await session.execute(text("SELECT 1")) + return True + except Exception: + logger.exception("主库探活失败") + return False + + +def _probe_mom_db_sync() -> bool: + try: + with mom_engine.connect() as conn: + conn.execute(text("SELECT 1")) + return True + except Exception: + logger.exception("MOM 库探活失败") + return False + + +async def _probe_mom_db() -> bool: + # MOM 用的是同步引擎,放线程池执行,避免阻塞事件循环 + return await anyio.to_thread.run_sync(_probe_mom_db_sync) + + +async def _collect() -> tuple[bool, dict[str, str]]: + primary_ok = await _probe_primary_db() + mom_ok = await _probe_mom_db() + checks = { + "database": "ok" if primary_ok else "fail", + # MOM 是外部只读依赖:挂掉时登录/选料降级,但扫码、流转、看板仍可用。 + # 因此只标记 degraded、不摘流量 —— 否则 MOM 一抖就让在产车间全线停摆。 + "mom_database": "ok" if mom_ok else "degraded", + } + return primary_ok, checks + + +@router.get("/health/live", include_in_schema=False) +async def liveness() -> dict: + """存活探针:不触碰任何依赖,恒定快速返回""" + return {"status": "ok"} + + +@router.get("/health/ready", include_in_schema=False) +async def readiness() -> JSONResponse: + """就绪探针:主库不可用时返回 503,让负载均衡摘流量""" + ready, checks = await _collect() + return JSONResponse( + {"status": "ready" if ready else "not_ready", "checks": checks}, + status_code=200 if ready else 503, + ) + + +@router.get("/health", include_in_schema=False) +async def health() -> JSONResponse: + """兼容旧监控脚本:语义等同就绪探针,并附带版本号""" + ready, checks = await _collect() + return JSONResponse( + { + "status": "ok" if ready else "unavailable", + "version": settings.APP_VERSION, + "checks": checks, + }, + status_code=200 if ready else 503, + ) diff --git a/backend/app/core/lifecycle.py b/backend/app/core/lifecycle.py new file mode 100644 index 0000000..70db4bb --- /dev/null +++ b/backend/app/core/lifecycle.py @@ -0,0 +1,196 @@ +"""生命周期阶段(Lifecycle Phase)词汇表与选项隔离 — 单一事实来源 + +生产制造(PRODUCTION)与售后回流(AFTER_SALES)各自拥有一套合法的 +宏观状态 / 工序名。回流设备绝不允许被重新排产回「备货 / 生产」等前期环节, +本模块集中定义两套词表并提供校验,前端各端复制同一份口径即可。 + +设计要点 +-------- +1. 售后阶段使用**独立工序名**(发货测试 / 售后维修),不复用生产阶段的 + 「测试 / 维修」。这样报表、看板、导出无需 JOIN lifecycle_phase 就能区分, + 也不会出现"同一个词两种含义"。 +2. 选定售后专属工序 = 设备进入售后生命周期,单向不可回退。 + 这条规则同时覆盖两类设备: + - 已出库后被重新派发任务(由 task_service 的 outbound 判定捕获) + - 无任何历史记录、直接走售后流程的老设备(首次选定售后工序即判定) +3. 「待确认」与仓库虚拟节点是建单占位符,不参与阶段校验,否则会卡死建单流程。 +""" +from __future__ import annotations + +# ============================================================ +# 生命周期阶段 +# ============================================================ +LIFECYCLE_PRODUCTION = "PRODUCTION" # 生产制造阶段(发货前) +LIFECYCLE_AFTER_SALES = "AFTER_SALES" # 已出库后再次回流返厂(售后) + +PHASE_LABELS = { + LIFECYCLE_PRODUCTION: "生产制造阶段", + LIFECYCLE_AFTER_SALES: "售后回流阶段", +} + +# ============================================================ +# 售后专属工序名 +# ============================================================ +STEP_SHIP_TEST = "发货测试" # 出厂 / 发货前测试 +STEP_AFTER_SALES_REPAIR = "售后维修" # 售后返修本体 + +# 售后区独立成列所用的两个工序名(前端分列 / 大屏返厂统计仍以它为准) +AFTER_SALES_ONLY_STEPS = frozenset({STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR}) + +# 🔧 工序语义细分(2026-09-17)——「售后专属」不等于「回流返厂」: +# · 出厂质检(发货测试):设备可能压根没回厂,只是补做发货前测试。 +# 它【不】构成回流信号,选中它不应把设备永久烙进售后生命周期。 +# · 明确修机指令(售后维修):设备确实是回厂返修 → 判定回流。 +# 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的 +# AFTER_SALES 烙印(售后死锁),而「已出库」的物理终态也被抹掉。 +OUTBOUND_QC_STEPS = frozenset({STEP_SHIP_TEST}) +AFTER_SALES_REPAIR_STEPS = frozenset({STEP_AFTER_SALES_REPAIR}) + +# ============================================================ +# 各阶段合法的工序名 / 宏观状态名 +# ============================================================ + +# ── 生产制造阶段 ── +# 🔧 2026-09-17 补入「发货测试」:出厂质检现在被允许在生产阶段执行 +# (设备的 lifecycle_phase 不再因选中它而翻成售后)。若不补, +# _enforce_step_isolation 会把「已出库设备的发货测试」直接 400 掉。 +PRODUCTION_TASK_STEPS = ("备货", "生产", "测试", "维修", "在库") +PRODUCTION_OVERALL_STEPS = ( + "备货", "生产", "测试", "维修", "在库", "待仓库收货", "已入库", "已出库", + STEP_SHIP_TEST, +) + +# ── 售后回流阶段:只保留「发货测试 / 售后维修 / 入库出库」── +AFTER_SALES_TASK_STEPS = (STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库") +AFTER_SALES_OVERALL_STEPS = ( + STEP_SHIP_TEST, STEP_AFTER_SALES_REPAIR, "在库", "待仓库收货", "已入库", "已出库", +) + +# 仅属于生产制造阶段的工序名 — 售后设备出现即视为"跨阶段误排"。 +# 注意:「在库 / 已入库 / 已出库」是两阶段通用的,不在此列。 +PRODUCTION_ONLY_STEPS = frozenset({"备货", "生产", "测试", "维修"}) + +# 建单占位工序名 — 建单时为「待确认」,接收时才由操作员选定真实工序 +PLACEHOLDER_STEPS = frozenset({"待确认"}) + +# 仓库虚拟节点标记(转交入库时作为 assignee 传递) +VIRTUAL_WAREHOUSE = "virtual_warehouse" + +# 两阶段并集 — 用于"完全非法取值"的第一道粗筛 +ALL_OVERALL_STEPS = frozenset(PRODUCTION_OVERALL_STEPS) | frozenset(AFTER_SALES_OVERALL_STEPS) + +_ALLOWED_BY_PHASE = { + LIFECYCLE_PRODUCTION: frozenset(PRODUCTION_OVERALL_STEPS), + LIFECYCLE_AFTER_SALES: frozenset(AFTER_SALES_OVERALL_STEPS), +} + + +# ============================================================ +# 查询 / 校验 +# ============================================================ + +def phase_label(phase: str | None) -> str: + """阶段的中文名(用于报错文案)""" + return PHASE_LABELS.get(phase or "", PHASE_LABELS[LIFECYCLE_PRODUCTION]) + + +def allowed_steps(phase: str | None) -> tuple[str, ...]: + """该阶段合法的全部工序/状态名""" + if (phase or "") == LIFECYCLE_AFTER_SALES: + return AFTER_SALES_OVERALL_STEPS + return PRODUCTION_OVERALL_STEPS + + +def is_placeholder_step(step: str | None) -> bool: + """建单占位符(待确认)/ 仓库虚拟节点 — 不做阶段校验 + + 否则 create_task 会被「待确认」卡住,转交入库会被 + 「🏭 入库 (virtual_warehouse)」卡住,整条流程直接断掉。 + """ + if not step: + return True + return step in PLACEHOLDER_STEPS or VIRTUAL_WAREHOUSE in step + + +def resolve_phase_for_step(current_phase: str | None, step: str | None) -> str: + """选定【明确修机指令】→ 设备进入售后生命周期(单向,不可回退) + + ⚠️ 只有「售后维修」触发,出厂质检(发货测试)【不】触发。 + 「已出库的设备补做发货测试」不代表它回厂返修;若在此烙印, + 设备会被永久打成售后(该标志单向不可回退),这就是售后死锁的第二个入口。 + + 这是"无历史记录的老设备"进入售后阶段的入口:首次选定「售后维修」即判定回流。 + """ + if step and step.strip() in AFTER_SALES_REPAIR_STEPS: + return LIFECYCLE_AFTER_SALES + return current_phase or LIFECYCLE_PRODUCTION + + +def is_step_allowed(phase: str | None, step: str | None) -> bool: + """工序名在该生命周期阶段下是否合法""" + if is_placeholder_step(step): + return True + allowed = _ALLOWED_BY_PHASE.get( + phase or LIFECYCLE_PRODUCTION, _ALLOWED_BY_PHASE[LIFECYCLE_PRODUCTION], + ) + return step.strip() in allowed + + +# 老数据兼容:售后设备的工序曾沿用生产阶段的「测试 / 维修」写法 +# (售后独立工序名是后加的)。统计/看板展示时折算到售后区的独立工序名, +# 否则售后设备的历史工序会混进生产区的同名工序,导致统计失真。 +AFTER_SALES_STEP_ALIAS = { + "测试": STEP_SHIP_TEST, + "维修": STEP_AFTER_SALES_REPAIR, +} + + +def normalize_after_sales_step(phase: str | None, step: str | None) -> str | None: + """售后回流设备的历史工序名归一 —— 折算到售后区的独立工序名。 + + 仅对 AFTER_SALES 生效;生产设备的「测试 / 维修」原样保留。 + 统计口径(WIP 矩阵 / 下钻)必须与展示口径一致,故集中在模块内。 + """ + if (phase or "") != LIFECYCLE_AFTER_SALES or not step: + return step + return AFTER_SALES_STEP_ALIAS.get(step.strip(), step) + + +# ============================================================ +# overall_status(中文宏观状态) ⇄ product.status(英文状态码) +# ============================================================ +# 这两个字段必须始终同步。历史缺陷:receive_task / transfer_task 只改 +# overall_status 不改 status,导致设备出库回流后 status 永远停在 OUTBOUND, +# 统计口径被污染(WIP 矩阵曾因此把正在做「发货测试」的设备吞进「已出库」列)。 +# 所有改写 overall_status 的地方一律调 sync_product_status()。 + +# 终态映射;不在表内的(备货/生产/测试/维修/发货测试/售后维修…)都是活跃工序 → WIP +OVERALL_TO_PRODUCT_STATUS = { + "已入库": "ARCHIVED", + "在库": "ARCHIVED", + "已出库": "OUTBOUND", + "待仓库收货": "COMPLETED", +} + +DEFAULT_PRODUCT_STATUS = "WIP" + + +def overall_to_product_status(overall: str | None) -> str: + """中文宏观状态 → product.status 状态码。 + + 活跃工序(含售后「发货测试 / 售后维修」)不在映射表里,统一落到 WIP —— + 这正是消除「status 残留在 OUTBOUND」的关键。 + """ + if not overall: + return DEFAULT_PRODUCT_STATUS + return OVERALL_TO_PRODUCT_STATUS.get(overall.strip(), DEFAULT_PRODUCT_STATUS) + + +def sync_product_status(product) -> str: + """把 product.status 与 overall_status 对齐,返回落定的状态码。 + + 刻意用 duck-typing 而非导入 ORM 模型,避免 core 层反向依赖 models。 + 调用点:任何改写 product.overall_status 之后都必须调一次。 + """ + product.status = overall_to_product_status(getattr(product, "overall_status", None)) + return product.status diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..e49c06f --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,89 @@ +"""结构化日志 — 单行 JSON 输出 + 请求上下文注入 + +设计要点: +1. 零第三方依赖,只用 stdlib(logging + json + contextvars)。 +2. 业务代码通过 `extra={"extra_fields": {...}}` 附加结构化字段, + 不要把可检索的字段拼进 msg 字符串 —— 拼进去就只能靠正则捞了。 +3. request_id / user 走 contextvar。contextvar 在 asyncio 下按任务隔离, + 并发请求之间不会串号;由 RequestContextMiddleware 与 get_current_user 写入。 +""" +from __future__ import annotations + +import json +import logging +import sys +from contextvars import ContextVar +from datetime import datetime, timezone + +# 请求级上下文 +request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None) +user_var: ContextVar[str | None] = ContextVar("user", default=None) + + +class _ContextFilter(logging.Filter): + """把 contextvar 注入每条 record,使 JSON 自带 request_id / user""" + + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = request_id_var.get() + record.user = user_var.get() + return True + + +class JsonFormatter(logging.Formatter): + """单行 JSON — 便于 Loki / ELK / CloudWatch 直接解析,无需正则""" + + def format(self, record: logging.LogRecord) -> str: + payload: dict = { + "ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(), + "level": record.levelname, + "logger": record.name, + "msg": record.getMessage(), + } + if getattr(record, "request_id", None): + payload["request_id"] = record.request_id + if getattr(record, "user", None): + payload["user"] = record.user + payload.update(getattr(record, "extra_fields", None) or {}) + if record.exc_info: + payload["exc"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=False, default=str) + + +class TextFormatter(logging.Formatter): + """本地开发可读格式(LOG_JSON=false 时启用)""" + + def format(self, record: logging.LogRecord) -> str: + line = ( + f"{self.formatTime(record, '%H:%M:%S')} " + f"{record.levelname:<5} {record.name} - {record.getMessage()}" + ) + extras = getattr(record, "extra_fields", None) + if extras: + line += " | " + " ".join(f"{k}={v}" for k, v in extras.items()) + if record.exc_info: + line += "\n" + self.formatException(record.exc_info) + return line + + +def setup_logging(level: str = "INFO", json_output: bool = True) -> None: + """配置根 logger。必须在应用启动前调用一次。""" + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(JsonFormatter() if json_output else TextFormatter()) + handler.addFilter(_ContextFilter()) + + root = logging.getLogger() + # 清空既有 handler:uvicorn --reload / 多 worker 下模块可能被重复导入, + # 不清会看到每条日志打印 N 遍 + root.handlers.clear() + root.addHandler(handler) + root.setLevel(level.upper()) + + # uvicorn 自带 handler 会绕过上面的 formatter,必须清掉并让它向根传播 + for name in ("uvicorn", "uvicorn.error", "uvicorn.access"): + lg = logging.getLogger(name) + lg.handlers.clear() + lg.propagate = True + + # 访问日志统一由 RequestContextMiddleware 输出(含耗时 / 用户 / request_id), + # 故关闭 uvicorn 自带的访问日志,避免重复 + logging.getLogger("uvicorn.access").disabled = True diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..36b74b0 --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,103 @@ +"""请求上下文中间件 — request_id 生成/透传 + 结构化访问日志""" +from __future__ import annotations + +import logging +import time +import uuid + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from app.core.logging import request_id_var, user_var +from app.services.audit_service import touch_daily_seen + +access_log = logging.getLogger("track.access") + +# 探针被高频轮询,降级为 DEBUG 避免把有价值的信息淹掉 +_QUIET_PATHS = frozenset({"/health", "/health/live", "/health/ready"}) + + +class RequestContextMiddleware(BaseHTTPMiddleware): + """为每个请求建立可追踪上下文。 + + - request_id:优先沿用上游网关传来的 X-Request-ID,实现全链路追踪; + 没有就生成一个。响应头回写该 ID,前端报错时可直接带上, + 运维拿 ID 就能在日志里精确定位到这一次请求。 + - 访问日志:method / path / status / duration_ms / client / user。 + """ + + async def dispatch(self, request: Request, call_next): + request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex + # 同时写入 request.state:它由 ASGI scope 承载,作用域比 contextvar 更长。 + # FastAPI 把 Exception 处理器交给 ServerErrorMiddleware(位于本中间件外层), + # 异常传播到那里时 contextvar 已在 finally 中被重置,只有 state 还留着 ID。 + request.state.request_id = request_id + rid_token = request_id_var.set(request_id) + user_token = user_var.set(None) + started = time.perf_counter() + logged = False + status_code = 500 + try: + response = await call_next(request) + status_code = response.status_code + response.headers["X-Request-ID"] = request_id + self._log_access(request, status_code, started) + logged = True + await self._touch_activity(request) + return response + finally: + # 异常路径也要留下访问记录,否则接口 500 时日志里反而没有痕迹 + if not logged: + self._log_access(request, status_code, started) + await self._touch_activity(request) + request_id_var.reset(rid_token) + user_var.reset(user_token) + + async def _touch_activity(self, request: Request) -> None: + """记录「该用户今天活动过」,供日活报表算上线/下线时间。 + + 为什么挂在这一层:本中间件是最外层,能覆盖**所有**请求 —— + 包括不被审计的普通 GET。而审计中间件只记写操作,当天只翻看、 + 没做写操作的人会被日活完全漏掉。 + + user 同样只能从 request.state 取:本中间件在独立 task 中执行, + 路由内写的 contextvar 不会回流(详见 _log_access 的说明)。 + 未认证请求取不到 user,自然跳过。 + """ + await touch_daily_seen(getattr(request.state, "audit_user", None)) + + def _log_access(self, request: Request, status_code: int, started: float) -> None: + path = request.url.path + duration_ms = round((time.perf_counter() - started) * 1000, 1) + + # user 必须从 request.state 取:本中间件在独立 task 中执行,路由内 + # 写入的 contextvar 不会回流到这里(详见 get_current_user 的说明)。 + user = getattr(request.state, "audit_user", None) or user_var.get() + + if status_code >= 500: + level = logging.ERROR + elif status_code >= 400: + level = logging.WARNING + elif path in _QUIET_PATHS: + level = logging.DEBUG + else: + level = logging.INFO + + access_log.log( + level, + "%s %s -> %s (%.1fms)", + request.method, + path, + status_code, + duration_ms, + extra={ + "extra_fields": { + "method": request.method, + "path": path, + "status": status_code, + "duration_ms": duration_ms, + "client": request.client.host if request.client else None, + "user": user, + } + }, + ) diff --git a/backend/app/core/mom_database.py b/backend/app/core/mom_database.py new file mode 100644 index 0000000..8c0d853 --- /dev/null +++ b/backend/app/core/mom_database.py @@ -0,0 +1,24 @@ +"""MOM 系统数据库 — 只读连接,用于登录验证 sys_user 表""" +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from sqlalchemy.pool import NullPool + +# MOM 数据库连接(同步引擎,仅用于登录验证) +import os +# Docker 容器内用 host.docker.internal 访问宿主机 +MOM_HOST = os.environ.get("MOM_DB_HOST", "host.docker.internal") +MOM_PORT = os.environ.get("MOM_DB_PORT", "5435") +MOM_DATABASE_URL = f"postgresql://prod_user:StrongPassword123!@{MOM_HOST}:{MOM_PORT}/inventory_system" + +mom_engine = create_engine( + MOM_DATABASE_URL, + echo=False, + poolclass=NullPool, # 登录频率低,不用连接池 +) + +MomSessionLocal = sessionmaker( + mom_engine, + class_=Session, + autocommit=False, + autoflush=False, +) diff --git a/backend/app/core/roles.py b/backend/app/core/roles.py new file mode 100644 index 0000000..cca0ff3 --- /dev/null +++ b/backend/app/core/roles.py @@ -0,0 +1,31 @@ +"""角色定义与管理员判定 —— 单一事实来源 + +背景:角色字符串此前散落在至少三处 —— task_service.ADMIN_ROLES、 +products.py 的内联判断、以及前端 constants/task.ts。同一份规则抄多份的后果 +已经发生过:前端 constants/task.ts:233 的注释记录了一次「移动端只判了 +SUPER_ADMIN、漏了 SUPERVISOR,导致主管被误挡」的事故。 + +本模块把**角色常量与管理员判定**先收敛到一处,供后端统一引用。 +完整的「角色 × 权限点」可配置矩阵是后续工作;但任何推进都应从这里出发, +不要再新增第四份副本。 +""" +from __future__ import annotations + +SUPER_ADMIN = "SUPER_ADMIN" +SUPERVISOR = "SUPERVISOR" +# 注意:MOM 登录返回的默认角色是小写 operator(见 auth_service.login) +OPERATOR = "OPERATOR" + +# 管理员角色:可执行收口、审计查看等高权限动作 +ADMIN_ROLES: frozenset[str] = frozenset({SUPER_ADMIN, SUPERVISOR}) + +ROLE_LABELS: dict[str, str] = { + SUPER_ADMIN: "超级管理员", + SUPERVISOR: "主管", + OPERATOR: "操作员", +} + + +def is_admin(role: str | None) -> bool: + """role 为 None / 未知值一律视为无权限(fail-closed,不做兜底放行)""" + return role in ADMIN_ROLES diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..aa3fbd0 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,71 @@ +"""安全模块 — JWT Token 生成与验证(双 Token 架构)""" +from datetime import timedelta +from jose import JWTError, jwt +from passlib.context import CryptContext +from app.core.config import settings +from app.core.time_utils import get_beijing_time + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +ALGORITHM = "HS256" + +# Token 类型声明 +TOKEN_TYPE_ACCESS = "access" +TOKEN_TYPE_REFRESH = "refresh" + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + """生成 JWT Access Token(2 小时有效)""" + to_encode = data.copy() + expire = get_beijing_time() + ( + expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire, "type": TOKEN_TYPE_ACCESS}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def create_refresh_token(data: dict, expires_delta: timedelta | None = None) -> str: + """生成 JWT Refresh Token(7 天有效,仅用于刷新 Access Token)""" + to_encode = data.copy() + expire = get_beijing_time() + ( + expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + ) + to_encode.update({"exp": expire, "type": TOKEN_TYPE_REFRESH}) + return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + + +def decode_token(token: str) -> dict: + """解码并验证 JWT Token,返回 payload""" + return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM]) + + +def peek_token_identity(token: str) -> dict | None: + """读出令牌里的用户身份 —— **仅供审计标注,绝不可用于授权**。 + + 与 decode_token 的唯一区别:**关闭过期校验**。 + + 为什么需要它:刷新令牌接口正是"access token 过期了才来"的场景, + 请求里不带 Authorization 头,JWT 依赖根本不执行,审计只能记成 + 「未认证」—— 而"谁在什么时候尝试刷新"恰恰是该留痕的信息。 + 签名校验照常进行,伪造的令牌解不出任何东西。 + + ⚠️ 返回值只允许写进 request.state 的审计字段; + 任何鉴权判断一律走 get_current_user,不要用本函数。 + """ + try: + return jwt.decode( + token, settings.SECRET_KEY, algorithms=[ALGORITHM], + options={"verify_exp": False}, + ) + except JWTError: + return None + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证明文密码 vs 哈希密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def hash_password(password: str) -> str: + """对明文密码进行哈希""" + return pwd_context.hash(password) diff --git a/backend/app/core/time_utils.py b/backend/app/core/time_utils.py new file mode 100644 index 0000000..865dc30 --- /dev/null +++ b/backend/app/core/time_utils.py @@ -0,0 +1,55 @@ +"""全局北京时间 (UTC+8) 与工作日时长计算""" +from datetime import datetime, date, time, timedelta, timezone +from zoneinfo import ZoneInfo + +BEIJING_TZ = ZoneInfo("Asia/Shanghai") + + +def get_beijing_time() -> datetime: + """返回当前北京时间""" + return datetime.now(BEIJING_TZ) + + +def to_beijing(dt: datetime | None) -> datetime | None: + """将任意 datetime 统一转为北京时间 aware。 + + - naive 时间按 UTC 处理(数据库 timestamptz 实存 UTC,SQLAlchemy 读出常为 naive) + - 带时区时间直接 astimezone 到北京 + """ + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ) + return dt.astimezone(BEIJING_TZ) + + +def working_duration_hours( + start: datetime | None, + end: datetime | None, + holidays: set[date] | None = None, +) -> float: + """计算 start~end 之间排除周末与节假日的工作小时数。 + + - 周末(周六/周日)整天排除 + - holidays 中配置的放假日期整天排除 + - 其余日期按 24 小时连续计(一天内的时间都算) + - start/end 可为 naive(按 UTC 转)或 aware 北京时间 + """ + if not holidays: + holidays = set() + s = to_beijing(start) + e = to_beijing(end) + if s is None or e is None or e <= s: + return 0.0 + + total = 0.0 + day = s.date() + last = e.date() + while day <= last: + if day.weekday() < 5 and day not in holidays: + seg_start = max(s, datetime.combine(day, time.min, tzinfo=BEIJING_TZ)) + seg_end = min(e, datetime.combine(day, time.max, tzinfo=BEIJING_TZ)) + if seg_end > seg_start: + total += (seg_end - seg_start).total_seconds() / 3600 + day += timedelta(days=1) + return round(total, 1) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..efbba30 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,113 @@ +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from app.core.config import settings +from app.core.audit_middleware import AuditMiddleware +from app.core.health import router as health_router +from app.core.logging import request_id_var, setup_logging +from app.core.middleware import RequestContextMiddleware +from app.api.v1.router import api_router + +# 日志必须在任何模块开始产日志之前配置好,故放模块顶层而非 lifespan 内 +setup_logging(level=settings.LOG_LEVEL, json_output=settings.LOG_JSON) + +logger = logging.getLogger("track.main") + + +def _init_error_tracking() -> None: + """可选错误追踪:未配置 DSN,或未安装 sentry-sdk 时静默跳过""" + if not settings.SENTRY_DSN: + return + try: + import sentry_sdk + except ImportError: + logger.warning( + "已配置 SENTRY_DSN 但未安装 sentry-sdk,错误追踪未启用;" + "需要时执行 pip install sentry-sdk" + ) + return + sentry_sdk.init( + dsn=settings.SENTRY_DSN, + traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE, + environment="production" if not settings.DEBUG else "development", + release=settings.APP_VERSION, + ) + logger.info("错误追踪已启用 (Sentry)") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期:启动时初始化连接,关闭时释放资源""" + _init_error_tracking() + logger.info( + "服务启动", + extra={ + "extra_fields": { + "version": settings.APP_VERSION, + "debug": settings.DEBUG, + "cors_origins": settings.CORS_ORIGINS_LIST, + } + }, + ) + yield + logger.info("服务关闭") + + +app = FastAPI( + title="Track Production API", + description="工厂生产流转管理系统 API", + version=settings.APP_VERSION, + lifespan=lifespan, +) + +# ---- CORS 跨域配置(从环境变量读取白名单) ---- +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS_LIST, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + # 暴露给浏览器 JS 读取:前端报错时才能把 request_id 一起带上便于对账 + expose_headers=["X-Request-ID"], +) + +# Starlette 的 add_middleware 是「后添加者在外层」。执行顺序(由外到内): +# RequestContextMiddleware -> AuditMiddleware -> CORS -> 路由 +# AuditMiddleware 必须在 RequestContext 内层,才能读到后者写入 request.state +# 的 request_id,从而把审计记录与结构化日志对上。 +app.add_middleware(AuditMiddleware) +app.add_middleware(RequestContextMiddleware) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """兜底异常处理。 + + 完整堆栈只进日志;响应体仅返回 request_id —— 既不把内部实现泄露给客户端, + 又让用户报障时能凭这个 ID 在日志里精确定位到本次失败。 + """ + # 优先取 request.state(见 RequestContextMiddleware 的说明): + # 本处理器由 ServerErrorMiddleware 调用,此时 contextvar 已被重置 + request_id = getattr(request.state, "request_id", None) or request_id_var.get() + logger.exception( + "未处理异常: %s %s", + request.method, + request.url.path, + extra={"extra_fields": {"method": request.method, "path": request.url.path}}, + ) + return JSONResponse( + status_code=500, + content={"detail": "服务器内部错误", "request_id": request_id}, + # 该响应由 ServerErrorMiddleware(位于 RequestContextMiddleware 外层) + # 生成,中间件没机会再往响应头写 X-Request-ID,故在此显式补上, + # 保证报障时前端从响应头就能拿到可对账的 ID。 + headers={"X-Request-ID": request_id} if request_id else None, + ) + + +# ---- 注册路由 ---- +app.include_router(api_router, prefix="/api/v1") +# 健康检查挂在根路径(/health*),运维探针不经过 /api/v1 +app.include_router(health_router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..951d631 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/app_version.py b/backend/app/models/app_version.py new file mode 100644 index 0000000..05d18f9 --- /dev/null +++ b/backend/app/models/app_version.py @@ -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"" diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py new file mode 100644 index 0000000..dc9d7de --- /dev/null +++ b/backend/app/models/audit_log.py @@ -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"" diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..ac20ee5 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,6 @@ +"""SQLAlchemy 声明式基类""" +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/backend/app/models/holiday.py b/backend/app/models/holiday.py new file mode 100644 index 0000000..53e3c30 --- /dev/null +++ b/backend/app/models/holiday.py @@ -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"" diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..e0d1604 --- /dev/null +++ b/backend/app/models/message.py @@ -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="留言时间(北京时间)", + ) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..aab12d4 --- /dev/null +++ b/backend/app/models/notification.py @@ -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"" diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..b558a00 --- /dev/null +++ b/backend/app/models/product.py @@ -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"" diff --git a/backend/app/models/production_order.py b/backend/app/models/production_order.py new file mode 100644 index 0000000..44edcba --- /dev/null +++ b/backend/app/models/production_order.py @@ -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"" diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..6e458e4 --- /dev/null +++ b/backend/app/models/task.py @@ -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"" + + +# ============================================================ +# 任务进度记录 — 随时备注/传图 +# ============================================================ + +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"" diff --git a/backend/app/models/task_log.py b/backend/app/models/task_log.py new file mode 100644 index 0000000..5b9c42f --- /dev/null +++ b/backend/app/models/task_log.py @@ -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"" diff --git a/backend/app/models/user_daily_seen.py b/backend/app/models/user_daily_seen.py new file mode 100644 index 0000000..38cf73d --- /dev/null +++ b/backend/app/models/user_daily_seen.py @@ -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="当天末次活动时刻", + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..a01bbfa --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/app_version.py b/backend/app/schemas/app_version.py new file mode 100644 index 0000000..f0d600f --- /dev/null +++ b/backend/app/schemas/app_version.py @@ -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} diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py new file mode 100644 index 0000000..b63877a --- /dev/null +++ b/backend/app/schemas/audit.py @@ -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] = [] diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..f719ed1 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -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 diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py new file mode 100644 index 0000000..a27a69c --- /dev/null +++ b/backend/app/schemas/order.py @@ -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} diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..e612deb --- /dev/null +++ b/backend/app/schemas/product.py @@ -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() diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..b6ab481 --- /dev/null +++ b/backend/app/schemas/task.py @@ -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 diff --git a/backend/app/schemas/task_log.py b/backend/app/schemas/task_log.py new file mode 100644 index 0000000..beada64 --- /dev/null +++ b/backend/app/schemas/task_log.py @@ -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} diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..fbad24b --- /dev/null +++ b/backend/app/schemas/user.py @@ -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" diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..37ad194 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,44 @@ +"""业务逻辑层""" +from app.services.product_service import ( + get_product_by_serial, + get_product, + create_product, + update_product, + update_overall_status, + get_all_products, +) +from app.services.task_service import ( + get_task, + get_top_level_tasks, + create_task, + update_task, + complete_task, + receive_task, + reject_task, + transfer_task, + create_subtask, + add_task_record, + get_all_tasks, +) + +__all__ = [ + # Product + "get_product_by_serial", + "get_product", + "create_product", + "update_product", + "update_overall_status", + "get_all_products", + # Task + "get_task", + "get_top_level_tasks", + "create_task", + "update_task", + "complete_task", + "receive_task", + "reject_task", + "transfer_task", + "create_subtask", + "add_task_record", + "get_all_tasks", +] diff --git a/backend/app/services/analytics_service.py b/backend/app/services/analytics_service.py new file mode 100644 index 0000000..b35480b --- /dev/null +++ b/backend/app/services/analytics_service.py @@ -0,0 +1,675 @@ +"""效能分析服务 — 个人能力图谱 + 设备流转对比(ECharts 数据源) + +数据来源: + - Task 工序/任务节点,携带 assignee_id、received_at、completed_at → 计算单台耗时。 + - Product 设备身份证(sn)、规格型号(spec_model)、物料名,用于筛选与展示。 + - TaskRecord 备注时间线(本模块当前仅作耗时主数据补充,可按需在后续下钻中引入)。 + +耗时口径: + - 单台真实耗时 = (completed_at or now) - (received_at or created_at),单位小时。 + - naive datetime 按 UTC 处理,统一换算北京时间。 +""" +from datetime import datetime, timezone + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + + +# ============================================================ +# Schemas(与前端 src/services/analyticsApi.ts 对齐) +# ============================================================ + +class CapabilityDataPoint(BaseModel): + value: float | None # 该人员在该设备上的总耗时(未触及为 None,真实 0 为 0.0) + spec_model: str # 规格型号 + status: str # 该设备当前状态 WIP/PENDING/COMPLETED/— + first_received_at: str # 最早接收时间 YYYY-MM-DD HH:mm + last_completed_at: str # 最后完成时间 YYYY-MM-DD HH:mm(无则空串) + + +class CapabilityDevice(BaseModel): + product_sn: str # 身份证 + external_serial: str | None # 产品序列号(业务序列号) + spec_model: str # 规格型号 + material_name: str # 物料名称 + + +class CapabilitySeries(BaseModel): + name: str # 人员姓名 + assignee_id: str # 人员ID + data: list[CapabilityDataPoint] # 与 categories 严格对齐,未触及设备补 0 + + +class CapabilityResponse(BaseModel): + categories: list[str] # X 轴:设备身份证(按时间升序) + devices: list[CapabilityDevice] # 与 categories 对齐的设备元数据 + series: list[CapabilitySeries] + + +class FlowDevice(BaseModel): + product_sn: str + external_serial: str | None + material_name: str + spec_model: str + lead_time: float # 设备生命周期总时长(小时)= max_end - min_start + started_at: str # 设备最早介入时间(T0),格式 MM-DD HH:mm + total_days: int = 0 # 设备生产总天数(自然天,自最早介入至今) + total_workdays: int = 0 # 设备生产总天数(工作日,排除周末/节假日) + + +class FlowSeries(BaseModel): + name: str # 人员姓名 + data: list[list] # 每项 = [device_index, start_offset, end_offset, task_name, duration](小时) + + +class FlowResponse(BaseModel): + devices: list[FlowDevice] + series: list[FlowSeries] + + +class AssigneeOption(BaseModel): + id: str + name: str + + +class SpecModelOption(BaseModel): + spec_model: str + material_name: str + + +class DeviceOption(BaseModel): + product_sn: str + external_serial: str | None + material_name: str + spec_model: str + + +class AnalyticsOptions(BaseModel): + assignees: list[AssigneeOption] + spec_models: list[SpecModelOption] + devices: list[DeviceOption] + + +class DeviceRecord(BaseModel): + task_name: str # 工序名 + assignee_name: str # 负责人姓名 + status: str # 任务状态 + remark: str | None # 备注 + images: list[str] # 图片 URL 列表 + created_at: str # 记录时间 ISO + + +# ============================================================ +# 时间工具 +# ============================================================ + +def _to_bj(dt: datetime | None) -> datetime | None: + """naive datetime 按 UTC 处理,统一换算为北京时间。""" + from app.core.time_utils import BEIJING_TZ + if not dt: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc).astimezone(BEIJING_TZ) + else: + dt = dt.astimezone(BEIJING_TZ) + return dt + + +def _duration_hours(start: datetime | None, end: datetime, holidays: set = None, mode: str = "workdays") -> float: + """计算单台耗时(小时),无开始时间返回 0。 + mode=workdays: 排除周末/节假日的工作小时;mode=natural: 自然小时。""" + if not start: + return 0.0 + if mode == "natural": + return (end - start).total_seconds() / 3600 + from app.core.time_utils import working_duration_hours + return working_duration_hours(start, end, holidays) + + +# ============================================================ +# 个人能力图谱 — 单台设备耗时对比(分组柱状图,X=设备身份证) +# ============================================================ + +async def get_capability_profile( + db: AsyncSession, + assignee_ids: list[str] | None = None, + spec_models: list[str] | None = None, + since: datetime | None = None, + until: datetime | None = None, + mode: str = "workdays", +) -> CapabilityResponse: + """ + 个人能力图谱(单机颗粒度):X 轴 = 设备身份证,每个负责人一条柱状 series。 + 按 (负责人, 设备) 分组,累加该人在该设备所有工序的耗时, + series.data 与 categories 严格对齐,未触及设备补 0。 + 耗时口径:(coalesce(completed_at, now) - coalesce(received_at, created_at))。 + """ + from app.models.task import ( + Task, TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED, + ) + from app.models.product import Product + from app.models.holiday import Holiday + from app.core.time_utils import get_beijing_time + + now = get_beijing_time() + + # 读取节假日(排除非工作日) + hres = await db.execute(select(Holiday.day)) + holidays = {r[0] for r in hres} + + stmt = ( + select( + Task.assignee_id, Task.status, + Task.received_at, Task.created_at, Task.completed_at, + Product.serial_number, Product.external_serial, Product.spec_model, + Product.material_name, + ) + .join(Product, Task.product_id == Product.id) + .where( + Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]), + Task.assignee_id.isnot(None), + ) + ) + if assignee_ids: + stmt = stmt.where(Task.assignee_id.in_(assignee_ids)) + if spec_models: + stmt = stmt.where(Product.spec_model.in_(spec_models)) + + # 时间交集(与 people-history 口径一致) + 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) + + result = await db.execute(stmt) + rows = result.all() + + # 设备元数据 + 时间基准;人员×设备 耗时聚合 + device_meta: dict[str, dict] = {} + agg: dict[tuple[str, str], dict] = {} + for row in rows: + assignee = row[0] + status = row[1] + start = _to_bj(row[2] or row[3]) # received_at or created_at + end = _to_bj(row[4]) if row[4] else now # completed_at or now + sn = row[5] + ext = row[6] + spec = row[7] or "未知型号" + mat = row[8] or "" + + meta = device_meta.setdefault(sn, {"external_serial": ext, "spec_model": spec, "material_name": mat, "earliest": None}) + if start and (meta["earliest"] is None or start < meta["earliest"]): + meta["earliest"] = start + + entry = agg.setdefault((assignee, sn), { + "hours": 0.0, "prio": 9, + "first_start": None, "last_completed": None, + }) + entry["hours"] += _duration_hours(start, end, holidays, mode) + if start and (entry["first_start"] is None or start < entry["first_start"]): + entry["first_start"] = start + completed_dt = _to_bj(row[4]) if row[4] else None + if completed_dt and (entry["last_completed"] is None or completed_dt > entry["last_completed"]): + entry["last_completed"] = completed_dt + prio = 0 if status == TASK_STATUS_WIP else (1 if status == TASK_STATUS_PENDING else 2) + entry["prio"] = min(entry["prio"], prio) + + if not agg: + return CapabilityResponse(categories=[], devices=[], series=[]) + + # X 轴:按最早接收/创建时间升序的设备身份证 + categories = sorted(device_meta.keys(), key=lambda s: device_meta[s]["earliest"] or now) + devices = [ + CapabilityDevice( + product_sn=sn, + external_serial=device_meta[sn]["external_serial"], + spec_model=device_meta[sn]["spec_model"], + material_name=device_meta[sn]["material_name"], + ) + for sn in categories + ] + + # 人员姓名映射 + raw_ids = list({a for (a, _) in 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) + + STATUS_CODE = {0: "WIP", 1: "PENDING", 2: "COMPLETED"} + + by_assignee: dict[str, dict[str, dict]] = {} + for (a, sn), entry in agg.items(): + by_assignee.setdefault(a, {})[sn] = entry + + series: list[CapabilitySeries] = [] + for a in sorted(by_assignee.keys()): + sn_entries = by_assignee[a] + data: list[CapabilityDataPoint] = [] + for sn in categories: + e = sn_entries.get(sn) + if e: + data.append(CapabilityDataPoint( + value=round(e["hours"], 1), + spec_model=device_meta[sn]["spec_model"], + status=STATUS_CODE[e["prio"]], + first_received_at=e["first_start"].strftime("%Y-%m-%d %H:%M") if e["first_start"] else "", + last_completed_at=e["last_completed"].strftime("%Y-%m-%d %H:%M") if e["last_completed"] else "", + )) + else: + data.append(CapabilityDataPoint( + value=None, + spec_model=device_meta[sn]["spec_model"], + status="—", + first_received_at="", + last_completed_at="", + )) + series.append(CapabilitySeries( + name=name_map.get(a, a), + assignee_id=a, + data=data, + )) + series.sort(key=lambda s: s.name) + return CapabilityResponse(categories=categories, devices=devices, series=series) + + +# ============================================================ +# 流转对比 — 设备各工序耗时(堆叠柱状) +# ============================================================ + +async def get_flow_compare( + db: AsyncSession, + product_sns: list[str] | None = None, + spec_models: list[str] | None = None, + mode: str = "natural", + since: datetime | None = None, + until: datetime | None = None, +) -> FlowResponse: + """ + 查询每台设备上各操作人的任务时间区间,拼装为「生命周期时间轴」区间图: + x 轴 = 设备身份证,y 轴 = 相对该设备 T0(最早介入时间)的小时偏移。 + 每个任务一根悬空区间柱(start_offset -> end_offset),并行任务可并排显示。 + + 设备来源: + - 传 product_sns:按给定身份证; + - 仅传 spec_models:这些型号下最近有流转的 20 台设备; + - 都未传:返回空。 + + data 每项 = [device_index, task_name, is_main, + start_nat, end_nat, start_work, end_work, + duration_total(自然), duration_work(工作日)](单位小时)。 + 前端按 isWorkday 切换选取自然/工作日偏移,即时重绘无需重新请求。 + mode 参数保留兼容(历史调用),不再影响返回内容。 + """ + 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 + + now = get_beijing_time() + + if product_sns: + product_rows = (await db.execute( + select( + Product.id, Product.serial_number, Product.external_serial, + Product.material_name, Product.spec_model, + ).where(Product.serial_number.in_(product_sns)) + )).all() + elif spec_models: + latest_subq = ( + select( + Task.product_id, + func.max(func.coalesce(Task.received_at, Task.created_at)).label("latest"), + ) + .group_by(Task.product_id) + .subquery() + ) + product_rows = (await db.execute( + select( + Product.id, Product.serial_number, Product.external_serial, + Product.material_name, Product.spec_model, + ) + .join(latest_subq, latest_subq.c.product_id == Product.id) + .where(Product.spec_model.in_(spec_models)) + .order_by(latest_subq.c.latest.desc()) + .limit(20) + )).all() + else: + return FlowResponse(devices=[], series=[]) + + if not product_rows: + return FlowResponse(devices=[], series=[]) + + id_to_sn = {r[0]: r[1] for r in product_rows} + product_ids = [r[0] for r in product_rows] + + # ── 候选设备的全部任务(含工序名,用于区间图) ── + task_rows = (await db.execute( + select( + Task.product_id, Task.assignee_id, Task.task_name, + Task.received_at, Task.created_at, Task.completed_at, + Task.task_type, + ) + .where( + Task.product_id.in_(product_ids), + Task.status.in_([TASK_STATUS_WIP, TASK_STATUS_PENDING, TASK_STATUS_COMPLETED]), + Task.assignee_id.isnot(None), + ) + )).all() + + # ── 设备级时间筛选 ── + # 语义:时间筛选 = "在该时间有活动的设备",而非"把任务切片只保留该时间"。 + # 设备一旦入选,其任务必须完整返回(完整生命周期),绝不因设备在筛选中无新动作 + # 而把它的历史任务清空。 + if since is not None or until is not None: + active_pids: set = set() + for row in task_rows: + pid = row[0] + start = _to_bj(row[3] or row[4]) + end = _to_bj(row[5]) if row[5] else now + if start is None: + continue + if since is not None and end < since: + continue + if until is not None and start > until: + continue + active_pids.add(pid) + product_rows = [r for r in product_rows if r[0] in active_pids] + if not product_rows: + return FlowResponse(devices=[], series=[]) + + # 显式传入身份证时按输入顺序排列 + if product_sns: + order = {sn: i for i, sn in enumerate(product_sns)} + product_rows.sort(key=lambda r: order.get(r[1], len(order))) + devices = [ + FlowDevice( + product_sn=r[1], external_serial=r[2], + material_name=r[3] or "", spec_model=r[4] or "", + lead_time=0.0, + started_at="", + ) + for r in product_rows + ] + sn_to_index = {d.product_sn: i for i, d in enumerate(devices)} + kept_ids = {r[0] for r in product_rows} + + # ── 解析为区间记录(完整生命周期,不再按时间切片) ── + intervals: list[dict] = [] + t0_by_device: dict[int, datetime] = {} + max_end_by_device: dict[int, datetime] = {} + for row in task_rows: + pid, assignee_id, task_name, received_at, created_at, completed_at, task_type = row + if pid not in kept_ids: + continue + sn = id_to_sn[pid] + idx = sn_to_index[sn] + start = _to_bj(received_at or created_at) + end = _to_bj(completed_at) if completed_at else now + if start is None: + continue + is_main = 0 if task_type == "SPAWN" else 1 + intervals.append({ + "idx": idx, + "assignee_id": assignee_id, + "task_name": (task_name or "").strip() or "未命名工序", + "start": start, + "end": end, + "is_main": is_main, + }) + if idx not in t0_by_device or start < t0_by_device[idx]: + t0_by_device[idx] = start + if idx not in max_end_by_device or end > max_end_by_device[idx]: + max_end_by_device[idx] = end + + # ── 计算每台设备 lead_time(最大 end - 最小 start,小时),重建 devices ── + lead_time_by_index = { + idx: round((max_end_by_device[idx] - t0_by_device[idx]).total_seconds() / 3600, 1) + for idx in t0_by_device + } + # 🔧 设备生产总天数(自然天 + 工作日,自最早介入至今) + import math + from app.core.time_utils import to_beijing as _tb, working_duration_hours as _wdh + from app.models.holiday import Holiday as _Holiday + hres = await db.execute(select(_Holiday.day)) + _holidays = {r[0] for r in hres} + + def _total_days(t0): + t0_bj = _tb(t0) + if not t0_bj: + return 1, 1 + natural = max(1, math.ceil((now - t0_bj).total_seconds() / 86400)) + workdays = max(1, math.ceil(_wdh(t0_bj, now, _holidays) / 24)) + return natural, workdays + + days_map = {i: _total_days(t0_by_device[i]) for i in t0_by_device} + devices = [ + FlowDevice( + product_sn=d.product_sn, external_serial=d.external_serial, + material_name=d.material_name, spec_model=d.spec_model, + lead_time=lead_time_by_index.get(i, 0.0), + started_at=t0_by_device[i].strftime("%m-%d %H:%M") if i in t0_by_device else "", + total_days=days_map.get(i, (1, 1))[0], + total_workdays=days_map.get(i, (1, 1))[1], + ) + for i, d in enumerate(devices) + ] + + # ── 按人分组,转为相对 T0 的小时偏移区间 ── + # 每项同时携带自然天与工作日两套偏移/时长,供前端按 isWorkday 即时切换: + # [idx, task_name, is_main, start_nat, end_nat, start_work, end_work, dur_total, dur_work] + from app.core.time_utils import working_duration_hours as _wdh + from app.models.holiday import Holiday as _Holiday + hres = await db.execute(select(_Holiday.day)) + _holidays = {r[0] for r in hres} + by_assignee: dict[str, list[list]] = {} + for it in intervals: + t0 = t0_by_device[it["idx"]] + start_nat = round((it["start"] - t0).total_seconds() / 3600, 1) + end_nat = round((it["end"] - t0).total_seconds() / 3600, 1) + start_work = round(_wdh(t0, it["start"], _holidays), 1) + end_work = round(_wdh(t0, it["end"], _holidays), 1) + by_assignee.setdefault(it["assignee_id"], []).append( + [it["idx"], it["task_name"], it["is_main"], + start_nat, end_nat, start_work, end_work, + round(end_nat - start_nat, 1), round(end_work - start_work, 1)] + ) + + # 翻译人员姓名 + raw_ids = list(by_assignee.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) + + series = [ + FlowSeries(name=name_map.get(a, a), data=by_assignee[a]) + for a in sorted(by_assignee.keys()) + ] + return FlowResponse(devices=devices, series=series) + + +# ============================================================ +# 设备备注记录 — 单台设备的全部备注/照片(弹窗下钻) +# ============================================================ + +async def get_device_records( + db: AsyncSession, + product_sn: str, + assignee_ids: list[str] | None = None, +) -> list[DeviceRecord]: + """查询某台设备(身份证)的所有任务备注记录,含图片,按时间倒序。 + 可选按负责人过滤(assignee_ids)。""" + import json + from app.models.task import Task, TaskRecord + from app.models.product import Product + + product_id = await db.scalar( + select(Product.id).where(Product.serial_number == product_sn) + ) + if not product_id: + return [] + + stmt = ( + select( + TaskRecord.remark, TaskRecord.images, TaskRecord.created_at, + Task.task_name, Task.assignee_id, Task.status, + ) + .join(Task, TaskRecord.task_id == Task.id) + .where(Task.product_id == product_id) + ) + if assignee_ids: + stmt = stmt.where(Task.assignee_id.in_(assignee_ids)) + stmt = stmt.order_by(TaskRecord.created_at.desc()) + result = await db.execute(stmt) + rows = result.all() + + raw_ids = list({r[4] for r in rows if r[4]}) + 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[DeviceRecord] = [] + for row in rows: + remark, images_raw, created, task_name, assignee, status = row + try: + images = json.loads(images_raw) if images_raw else [] + except (json.JSONDecodeError, TypeError): + images = [] + if not isinstance(images, list): + images = [] + records.append(DeviceRecord( + task_name=task_name or "", + assignee_name=name_map.get(assignee or "", assignee or ""), + status=status or "", + remark=remark, + images=images, + created_at=_to_bj(created).isoformat() if created else "", + )) + return records + + +# ============================================================ +# 下拉选项 — 负责人 + 规格型号 +# ============================================================ + +async def get_analytics_options( + db: AsyncSession, + assignee_ids: list[str] | None = None, + spec_models: list[str] | None = None, +) -> AnalyticsOptions: + """返回筛选栏选项,支持动态联动: + - 传入 spec_models:只返回碰过这些型号的人; + - 传入 assignee_ids:只返回这些人处理过的型号; + - devices:根据筛选条件返回关联设备(无筛选则返回最近流转的设备)。""" + from app.models.task import Task + from app.models.product import Product + + # ── 负责人:从 tasks 去重(可选按 spec_models 过滤) ── + if spec_models: + assignee_stmt = ( + select(Task.assignee_id) + .join(Product, Task.product_id == Product.id) + .where( + Task.assignee_id.isnot(None), + Product.spec_model.in_(spec_models), + ) + .distinct() + ) + else: + assignee_stmt = ( + select(Task.assignee_id) + .where(Task.assignee_id.isnot(None)) + .distinct() + ) + assignee_rows = (await db.execute(assignee_stmt)).all() + distinct_assignees = sorted({r[0] for r in assignee_rows if r[0]}) + + name_map: dict[str, str] = {} + if distinct_assignees: + from app.services.mom_cache import get_display_names + name_map = get_display_names(distinct_assignees) + + assignees = [ + AssigneeOption(id=aid, name=name_map.get(aid, aid)) + for aid in distinct_assignees + ] + + # ── 规格型号 + 物料名(可选按 assignee_ids 过滤;同型号取首个非空物料名) ── + if assignee_ids: + spec_stmt = ( + select(Product.spec_model, Product.material_name) + .join(Task, Task.product_id == Product.id) + .where( + Product.spec_model.isnot(None), + func.trim(Product.spec_model) != "", + Task.assignee_id.in_(assignee_ids), + ) + .distinct() + ) + else: + spec_stmt = ( + select(Product.spec_model, Product.material_name) + .where(Product.spec_model.isnot(None), func.trim(Product.spec_model) != "") + .distinct() + ) + spec_rows = (await db.execute(spec_stmt)).all() + spec_map: dict[str, str] = {} + for row in spec_rows: + sm = row[0] + mn = (row[1] or "").strip() + if sm not in spec_map: + spec_map[sm] = mn + elif mn and not spec_map[sm]: + spec_map[sm] = mn + spec_model_opts = [ + SpecModelOption(spec_model=sm, material_name=spec_map[sm]) + for sm in sorted(spec_map.keys()) + ] + + # ── 设备字典(可选按 assignee_ids / spec_models 过滤;无筛选返回最近流转 100 台) ── + if assignee_ids or spec_models: + device_stmt = select( + Product.serial_number, Product.external_serial, + Product.material_name, Product.spec_model, + ) + if assignee_ids: + device_stmt = device_stmt.join( + Task, Task.product_id == Product.id + ).where(Task.assignee_id.in_(assignee_ids)) + if spec_models: + device_stmt = device_stmt.where(Product.spec_model.in_(spec_models)) + device_stmt = device_stmt.distinct() + else: + latest_subq = ( + select( + Task.product_id, + func.max(func.coalesce(Task.received_at, Task.created_at)).label("latest"), + ) + .group_by(Task.product_id) + .subquery() + ) + device_stmt = ( + select( + Product.serial_number, Product.external_serial, + Product.material_name, Product.spec_model, + ) + .join(latest_subq, latest_subq.c.product_id == Product.id) + .order_by(latest_subq.c.latest.desc()) + .limit(100) + ) + device_rows = (await db.execute(device_stmt)).all() + devices = [ + DeviceOption( + product_sn=r[0] or "", + external_serial=r[1] or None, + material_name=r[2] or "", + spec_model=r[3] or "", + ) + for r in device_rows + ] + + return AnalyticsOptions(assignees=assignees, spec_models=spec_model_opts, devices=devices) diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py new file mode 100644 index 0000000..b44d963 --- /dev/null +++ b/backend/app/services/audit_service.py @@ -0,0 +1,458 @@ +"""审计服务 — 写入与检索 + +写入方案的取舍(与 MOM/KCGL 不同,理由如下) +-------------------------------------------------- +MOM 用 SQLAlchemy event listener + **同事务**写入:优点是全自动、业务代码零改动; +缺点是业务事务回滚时审计记录一起被回滚掉 —— 而失败/被拒的操作恰恰是最需要 +留痕的(比如越权尝试、参数错误导致的 4xx)。 + +Track 改为:响应生成后,用**独立 session** 写入审计。 +- 业务回滚不影响审计,失败操作照样留痕 +- 审计写入失败也不影响业务(全包裹 try/except,仅记日志) +- 代价:审计与业务不是原子提交,极端情况(响应后进程立即被 kill)可能丢一条。 + 对内部系统的操作审计,这个取舍划算。 +""" +from __future__ import annotations + +import logging +import time +import uuid +from datetime import datetime + +from sqlalchemy import and_, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.time_utils import get_beijing_time +from app.models.audit_log import AuditLog +from app.models.user_daily_seen import UserDailySeen + +logger = logging.getLogger("track.audit") + +# 绝不落库的敏感字段名(命中即替换为 ***) +# 登录请求体含明文密码,一旦进审计表就成了长期泄露面 +_SENSITIVE_KEYS = frozenset( + {"password", "passwd", "pwd", "token", "access_token", "refresh_token", + "secret", "api_key", "authorization", "password_hash"} +) + +# 模块 / 动作 的中文标签(前端下拉与列表展示用) +MODULE_LABELS: dict[str, str] = { + "auth": "认证登录", + "product": "产品管理", + "task": "任务流转", + "order": "订单管理", + "record": "任务记录", + "print": "标签打印", + "material": "物料", + "user": "用户", + "notification": "消息通知", + "upload": "文件上传", + "dashboard": "看板统计", + "analytics": "效能分析", + "screen": "数据大屏", + "holiday": "节假日配置", + "app": "App版本", + "external": "外部系统对接", + "audit": "审计日志", + "other": "其它", +} + +ACTION_LABELS: dict[str, str] = { + "create": "新增", + "update": "修改", + "delete": "删除", + # 只用于被采集的 GET(核心业务详情 / 敏感读)。 + # 叫「查看详情」而不是「查询」:前者说明用户确实点开了某条业务数据, + # 后者容易被误解成"随便搜了一下"。 + "read": "查看详情", + "export": "导出", + "login": "登录", + "logout": "登出", + # 刷新令牌 = 用户重新开始使用系统(token 2 小时一换,7 天免登录), + # 业务上视作一次「上线」,比"刷新令牌"这种技术词更贴近车间口径 + "refresh": "上线", + "print": "打印", + "upload": "上传", + "finalize": "收口", + "receive": "接收", + "transfer": "转交", + "reject": "驳回", + "recall": "撤回", + "spawn": "派发", + "end": "结束分支", + "complete": "完结", + "mark_read": "标为已读", +} + + +def sanitize_details(details: dict | None) -> dict | None: + """递归剔除敏感字段,避免密码/令牌落库""" + if not details: + return details + + def _clean(value): + if isinstance(value, dict): + return { + k: ("***" if str(k).lower() in _SENSITIVE_KEYS else _clean(v)) + for k, v in value.items() + } + if isinstance(value, list): + return [_clean(v) for v in value] + return value + + return _clean(details) + + +async def record_audit( + *, + action: str, + module: str, + user_id: str | None = None, + display_name: str | None = None, + role: 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, +) -> None: + """写入一条审计记录。**绝不抛异常**:审计失败不能影响业务。""" + try: + async with AsyncSessionLocal() as session: + session.add( + AuditLog( + id=uuid.uuid4(), + user_id=user_id, + display_name=display_name, + role=role, + action=action, + module=module, + target_type=target_type, + target_id=str(target_id) if target_id is not None else None, + target_name=target_name, + details=sanitize_details(details), + ip_address=ip_address, + user_agent=user_agent[:500] if user_agent else None, + method=method, + url=url[:500] if url else None, + status_code=status_code, + error_message=error_message, + request_id=request_id, + ) + ) + await session.commit() + except Exception: + # 用 exception 级别但吞掉异常:保证调用方业务流程不受影响 + logger.exception( + "审计写入失败(已忽略,不影响业务)", + extra={"extra_fields": {"action": action, "module": module, "url": url}}, + ) + + +async def list_audit_logs( + db: AsyncSession, + *, + user_id: str | None = None, + module: str | None = None, + action: str | None = None, + target_id: str | None = None, + request_id: str | None = None, + status_code: int | None = None, + start: datetime | None = None, + end: datetime | None = None, + skip: int = 0, + limit: int = 50, +) -> tuple[list[AuditLog], int]: + """审计日志检索(按时间倒序)。返回 (当前页, 真实总数)。 + + 真实总数走独立 COUNT —— 前端分页器依赖它,不能用 len(当前页)。 + """ + filters = _log_filters( + user_id=user_id, module=module, action=action, target_id=target_id, + request_id=request_id, status_code=status_code, start=start, end=end, + ) + + total = await db.scalar( + select(func.count()).select_from(AuditLog).where(*filters) + ) or 0 + + rows = ( + await db.execute( + select(AuditLog) + .where(*filters) + .order_by(AuditLog.created_at.desc()) + .offset(skip) + .limit(limit) + ) + ).scalars().all() + + return list(rows), total + + +# ============================================================ +# 导出 +# ============================================================ + +# 单次导出的行数上限。审计表只增不减,全量导出迟早会撑爆内存与浏览器, +# 故设硬上限;超出时向上层返回 truncated=True,由前端明确提示「已截断」—— +# 静默截断会让使用者以为导全了,比报错更危险。 +EXPORT_MAX_ROWS = 50000 + + +def _log_filters( + *, + user_id: str | None = None, + module: str | None = None, + action: str | None = None, + target_id: str | None = None, + request_id: str | None = None, + status_code: int | None = None, + start: datetime | None = None, + end: datetime | None = None, +) -> list: + """审计日志的筛选条件 —— list_audit_logs 与 export_audit_logs 共用。 + + 抽出来的唯一目的:保证「列表看到的」和「导出出去的」永远是同一批数据。 + 两处各写一份迟早会漂移,而导出与列表不一致是最让人不信任的那种 bug。 + """ + filters = [] + if user_id: + filters.append(AuditLog.user_id.ilike(f"%{user_id}%")) + if module: + filters.append(AuditLog.module == module) + if action: + filters.append(AuditLog.action == action) + if target_id: + filters.append(AuditLog.target_id == target_id) + if request_id: + filters.append(AuditLog.request_id == request_id) + if status_code is not None: + filters.append(AuditLog.status_code == status_code) + if start: + filters.append(AuditLog.created_at >= start) + if end: + filters.append(AuditLog.created_at <= end) + return filters + + +async def export_audit_logs( + db: AsyncSession, *, limit: int = EXPORT_MAX_ROWS, **kwargs, +) -> tuple[list[AuditLog], bool]: + """导出用:按筛选条件取全部记录(不分页)。返回 (rows, truncated)。 + + 多取一行来判断是否被截断 —— 比再跑一次 COUNT 便宜。 + """ + rows = ( + await db.execute( + select(AuditLog) + .where(*_log_filters(**kwargs)) + .order_by(AuditLog.created_at.desc()) + .limit(limit + 1) + ) + ).scalars().all() + truncated = len(rows) > limit + return list(rows[:limit]), truncated + + +# ============================================================ +# 每日活动打点(日活报表的「上线时间 / 下线时间」来源) +# ============================================================ + +# 同一用户两次落盘之间的最小间隔(秒)。 +# +# 打点挂在「每个请求」上,但不希望每个请求都写一次数据库 —— 那会把 +# user_daily_seen 变成热点。这里用进程内缓存做节流:同一用户 2 分钟内 +# 只落盘一次。代价是「末次活动时间」最多落后真实值 2 分钟, +# 对"日活统计"这个精度要求完全够用。 +# +# 多 worker 部署时每个进程各持一份缓存,实际写库频率最多放大到 worker 数倍 +# (4 worker × 每人每 2 分钟 1 次),依然可忽略。 +_TOUCH_INTERVAL_S = 120.0 +_touch_cache: dict[str, float] = {} + +# 缓存只增不减会缓慢泄漏(键是 user_id,量级 = 用户数,实际很小)。 +# 超过阈值就整体清空 —— 代价只是多写几次库,换来内存有界。 +_TOUCH_CACHE_MAX = 5000 + + +async def touch_daily_seen(user_id: str | None) -> None: + """记录「该用户此刻活动过」。首次 INSERT、其后只刷新 last_seen_at。 + + 唯一的消费方是日活报表的上线/下线时间(见 get_daily_usage)。 + 刻意不写进 audit_logs:那是只增不改的审计流水,而本表是需要不断 + UPDATE 的状态(详见 UserDailySeen 模型注释)。 + + 任何异常都吞掉 —— 活动打点失败绝不能影响业务请求本身。 + """ + if not user_id: + return + + now_mono = time.monotonic() + last = _touch_cache.get(user_id) + if last is not None and now_mono - last < _TOUCH_INTERVAL_S: + return # 节流窗口内,跳过 + if len(_touch_cache) > _TOUCH_CACHE_MAX: + _touch_cache.clear() + # 先占位再写库:同一用户的并发请求不会同时打进来 + _touch_cache[user_id] = now_mono + + try: + from sqlalchemy.dialects.postgresql import insert as pg_insert + + now = get_beijing_time() + day = now.date() # 北京时间自然日(与报表分日口径一致) + async with AsyncSessionLocal() as db: + await db.execute( + pg_insert(UserDailySeen) + .values(user_id=user_id, day=day, first_seen_at=now, last_seen_at=now) + # 冲突时只刷新 last_seen_at,first_seen_at 保持当天首次值不变 + .on_conflict_do_update( + index_elements=["user_id", "day"], + set_={"last_seen_at": now}, + ) + ) + await db.commit() + except Exception: # noqa: BLE001 —— 打点失败不影响业务 + logger.exception("记录每日活动失败(已忽略)") + + +# ============================================================ +# 日活 / 使用统计 +# ============================================================ + +# 成功 = 2xx/3xx。登录失败(401)也要留痕,但不应计入"上线次数"。 +_OK_STATUS_UPPER = 400 + + +async def get_daily_usage( + db: AsyncSession, *, start: datetime, end: datetime, +) -> list[dict]: + """按【北京时间自然日 × 操作人】聚合用量 —— 日活报表的数据源。 + + start/end 为半开区间 [start, end),调用方按北京时间日界传入。 + + 全部指标由**一个 GROUP BY 查询**算出,不用窗口函数: + · 登录/登出次数 = 成功登录 / 成功登出数(最终凭证是 login_count,不是"上线次数") + · 操作频次 = 当天该用户的全部审计记录数(代表系统使用深度) + · 登录/登出次数 = 成功登录 / 成功登出数 + · 上线/下线时间 = 当天**首次 / 末次活动**(优先取 user_daily_seen) + + ⚠️ 上线/下线时间【不能】取登录/登出时间。 + Access/Refresh Token 有效期内(refresh 7 天)用户无需重新登录, + 于是"周一登录、周二到周日继续用"会导致周二~周日: + 登录次数=0、登录时间=空,但操作次数却是几十 —— 报表自相矛盾。 + + ⚠️ 也不能只取审计表的写操作时间:审计中间件只记写操作,普通 GET 不入账, + 当天只翻看、没做写操作的人会被整条漏掉。 + 故上线/下线时间优先取 user_daily_seen(挂在每个请求上打点), + 仅对本表上线前的历史数据回退到审计表的写操作时间。 + + 为什么用 `count(*) FILTER (WHERE ...)`:分组内一次扫描同时算出多个条件计数, + 比多次子查询或 UNION 简单得多,且语义一眼可读。Postgres 原生支持。 + + ⚠️ 按【北京时间】分日:created_at 是 timestamptz(实存 UTC), + 直接按 UTC 分日会让 00:00~08:00 的早班操作掉到前一天。 + """ + day_col = func.date(func.timezone("Asia/Shanghai", AuditLog.created_at)) + + login_ok = and_( + AuditLog.action == "login", AuditLog.status_code < _OK_STATUS_UPPER, + ) + logout_ok = and_( + AuditLog.action == "logout", AuditLog.status_code < _OK_STATUS_UPPER, + ) + + stmt = ( + select( + day_col.label("day"), + AuditLog.user_id.label("user_id"), + # 同一用户的 display_name / role 是一致的,取 max 只是为了 + # 在 GROUP BY 下拿到一个非空代表值(避免再套一层 DISTINCT ON) + func.max(AuditLog.display_name).label("display_name"), + func.max(AuditLog.role).label("role"), + func.count().filter(login_ok).label("login_count"), + func.count().filter(logout_ok).label("logout_count"), + func.count().label("op_count"), + # 上线/下线时间取「任意记录」的首末,而不是登录/登出的首末(原因见 docstring) + func.min(AuditLog.created_at).label("first_active_at"), + func.max(AuditLog.created_at).label("last_active_at"), + ) + .where( + AuditLog.created_at >= start, + AuditLog.created_at < end, + # 只统计"人":未认证请求(如登录前的探测、refresh)没有操作人, + # 混进来会让"日活人数"虚高。若要排查匿名异常流量,走日志列表页按 + # 结果/来源 IP 过滤更合适。 + AuditLog.user_id.isnot(None), + ) + .group_by(day_col, AuditLog.user_id) + .order_by(day_col.desc(), func.count().desc()) + ) + + rows = (await db.execute(stmt)).all() + + # ── 活动表:当天首次/末次活动(覆盖"只翻看不操作"的人)── + # day 列是北京时间 DATE,与上面的 day_col 口径一致,可直接按 (user_id, day) 对齐。 + # 取 start.date() ~ end.date()(end 是次日 00:00 的半开上界,故用 <)。 + seen_rows = ( + await db.execute( + select( + UserDailySeen.user_id, UserDailySeen.day, + UserDailySeen.first_seen_at, UserDailySeen.last_seen_at, + ).where( + UserDailySeen.day >= start.date(), + UserDailySeen.day < end.date(), + ) + ) + ).all() + seen = { + (s.user_id, s.day.strftime("%Y-%m-%d")): (s.first_seen_at, s.last_seen_at) + for s in seen_rows + } + + audit = { + (r.user_id, r.day.strftime("%Y-%m-%d") if hasattr(r.day, "strftime") else str(r.day)): r + for r in rows + } + + # ── 合并 ── + # 并集:只有审计记录的人(本表上线前的历史数据)和只有活动记录的人 + # (当天只翻看、没做写操作)都要出现,各自缺的部分留空/计 0。 + items: list[dict] = [] + for key in set(audit) | set(seen): + user_id, day = key + a = audit.get(key) + first_seen, last_seen = seen.get(key, (None, None)) + + # 取「两者的最早/最晚」,而不是简单地"活动表优先": + # 活动表靠请求触发且有 2 分钟节流,极端情况(跨零点被节流、 + # 打点写库失败被吞掉)可能晚于当天第一次写操作。 + # 取 min/max 后,结果永远不会比任一来源更差,也不需要为兜底写分支逻辑。 + audit_first = a.first_active_at if a else None + audit_last = a.last_active_at if a else None + first_candidates = [t for t in (first_seen, audit_first) if t is not None] + last_candidates = [t for t in (last_seen, audit_last) if t is not None] + + items.append({ + "day": day, + "user_id": user_id, + # 姓名字段只有审计记录里有(活动表为了轻量刻意不冗余存) + "display_name": a.display_name if a else None, + "role": a.role if a else None, + "login_count": (a.login_count or 0) if a else 0, + "logout_count": (a.logout_count or 0) if a else 0, + "op_count": (a.op_count or 0) if a else 0, + "first_active_at": min(first_candidates) if first_candidates else None, + "last_active_at": max(last_candidates) if last_candidates else None, + }) + + # 与 SQL 里的排序保持一致:日期倒序 → 操作次数倒序 + items.sort(key=lambda x: (x["day"], x["op_count"]), reverse=True) + return items diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..7bb2203 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,149 @@ +"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT(双 Token 架构)""" +from fastapi import HTTPException, status, Depends, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from werkzeug.security import check_password_hash + +from app.core.config import settings +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_token, + ALGORITHM, + TOKEN_TYPE_ACCESS, + TOKEN_TYPE_REFRESH, +) +from app.core.mom_database import MomSessionLocal +from app.core.logging import user_var +from app.schemas.user import LoginResponse, UserResponse + +security = HTTPBearer() + + +def login(username: str, password: str) -> LoginResponse: + """登录 — 签发双 Token(Access + Refresh)""" + db = MomSessionLocal() + try: + # 1. 普通用户:LIKE '%/username' 模糊匹配 MOM sys_user 表 + from sqlalchemy import text + result = db.execute( + text( + "SELECT id, username, department, role, password_hash " + "FROM sys_user " + "WHERE username LIKE :pattern" + ), + {"pattern": f"%/{username}"}, + ) + row = result.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + user_id, full_username, department, role, password_hash = row + + # 2. Werkzeug scrypt 密码验证 + if not check_password_hash(password_hash, password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + # 3. 解析 display_name("张三/zhangsan01" → "张三") + display_name = full_username.split("/")[0] if "/" in full_username else full_username + + token_data = { + "sub": str(user_id), + "role": role or "operator", + "username": username, + "display_name": display_name, + } + + return LoginResponse( + access_token=create_access_token(data=token_data), + refresh_token=create_refresh_token(data=token_data), + user=UserResponse( + id=str(user_id), + username=username, + display_name=display_name, + role=role or "operator", + ), + ) + + finally: + db.close() + + +def refresh_access_token(refresh_token: str) -> dict: + """ + 使用 Refresh Token 换取新的 Access Token。 + 校验: + 1. Token 签名是否有效 + 2. Token type 是否为 "refresh" + 3. Token 是否未过期 + """ + try: + payload = decode_token(refresh_token) + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Refresh Token 无效或已过期,请重新登录", + ) + + # 校验 token 类型 + if payload.get("type") != TOKEN_TYPE_REFRESH: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 Token 类型,仅接受 Refresh Token", + ) + + # 提取用户信息,签发新的 Access Token + access_token = create_access_token( + data={ + "sub": payload.get("sub"), + "role": payload.get("role", "operator"), + "username": payload.get("username", ""), + "display_name": payload.get("display_name", ""), + } + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> dict: + """从 Bearer Token 解析当前用户(仅接受 Access Token)""" + token = credentials.credentials + try: + payload = decode_token(token) + user_id = payload.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="无效的 Token") + + # 校验:仅接受 access token + if payload.get("type") == TOKEN_TYPE_REFRESH: + raise HTTPException( + status_code=401, + detail="请使用 Access Token 访问 API,Refresh Token 仅用于刷新", + ) + + # 操作人身份要写两处,用途不同,缺一不可: + # 1) contextvar —— 供本请求任务内的业务/service 日志使用; + # 2) request.state —— 中间件在独立 task 中执行(Starlette 的 + # BaseHTTPMiddleware 用 anyio start_soon 起新 task,而 asyncio + # 每个 Task 会复制 context),因此中间件读不到路由内改的 + # contextvar,只能通过 ASGI scope 承载的 state 拿到。 + # username 即 assignee_id 口径,比数字 id 直观得多。 + user_label = payload.get("username") or user_id + user_var.set(user_label) + request.state.audit_user = user_label + request.state.audit_display_name = payload.get("display_name") or "" + request.state.audit_role = payload.get("role") or "" + + return payload + except JWTError: + raise HTTPException(status_code=401, detail="无效的 Token") diff --git a/backend/app/services/counter_service.py b/backend/app/services/counter_service.py new file mode 100644 index 0000000..ac4535e --- /dev/null +++ b/backend/app/services/counter_service.py @@ -0,0 +1,26 @@ +"""16进制自增计数器 — 基于 PostgreSQL Sequence,生成 16 位 HEX 唯一 ID""" +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +SEQUENCE_NAME = "product_hex_counter" + + +async def ensure_sequence(db: AsyncSession) -> None: + """确保 counter sequence 存在(幂等)""" + await db.execute( + text(f"CREATE SEQUENCE IF NOT EXISTS {SEQUENCE_NAME} START 1;") + ) + + +async def next_hex_id(db: AsyncSession, length: int = 16) -> str: + """ + 生成下一个 hex ID。 + + 示例: 1 → "0000000000000001" + 15 → "000000000000000F" + 16 → "0000000000000010" + 255 → "00000000000000FF" + """ + result = await db.execute(text(f"SELECT nextval('{SEQUENCE_NAME}');")) + counter: int = result.scalar_one() + return format(counter, f"0{length}X") diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..bb5591d --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -0,0 +1,1714 @@ +"""Dashboard 统计服务 — 上帝视角(全厂全系统数据,不按用户过滤)""" +from datetime import datetime +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from app.core.lifecycle import normalize_after_sales_step + + +# ============================================================ +# Schemas +# ============================================================ + +class DashboardStats(BaseModel): + products_total: int + products_pending: int + products_in_progress: int + products_completed: int + tasks_total: int + tasks_pending: int + tasks_in_progress: int + tasks_completed: int + tasks_rejected: int + tasks_rework: int + unread_notifications: int = 0 + unread_messages: int = 0 + + +class MyStats(BaseModel): + """一线工人个人效能 — /dashboard/my-stats 桥接口径。 + + 与 DashboardStats 的「上帝视角」不同,这里强制过滤到本人; + 供移动端「个人中心 → 工作统计」使用,支持自选时段。 + + 两组指标口径: + 「生产战绩」按 Task.assignee_id 归因(我名下的活); + 「操作统计」与 PC /dashboard/user-operations 完全一致, + 便于工人自查看到的数与管理端面板对得上。 + """ + assignee_id: str + # ── 生产战绩(所选时段内)── + tasks_completed: int = 0 # 完成数 + tasks_rejected: int = 0 # 被驳回数(本人名下任务被驳回) + products_touched: int = 0 # 参与产品数(按 product_id 去重) + + # ── 操作统计(与 PC /dashboard/user-operations 同一口径,所选时段内)── + receive_count: int = 0 # 接收次数 + transfer_count: int = 0 # 转交次数 + record_count: int = 0 # 上传备注次数 + op_total: int = 0 # 操作总计 = 接收 + 转交 + 上传备注 + + +class WipTask(BaseModel): + task_id: str + task_name: str + assignee: str + product_sn: str # 16位HEX身份证 + external_serial: str | None # 业务序列号 + material_name: str # 设备名称 + spec_model: str # 规格型号 + status: str + received_at: str + duration_hours: float + + +class CompletedTask(BaseModel): + task_id: str + task_name: str # 完成的工序节点 + assignee: str # 完成人中文姓名 + product_sn: str # 16位HEX身份证 + external_serial: str | None # 业务序列号 + material_name: str # 产品名称(物料名称) + spec_model: str # 规格型号 + completed_at: str # 完成时间 ISO + + +class RejectedTask(BaseModel): + task_id: str + kind: str = "rejected" # "rejected"(已驳回) | "rework"(返工任务) + task_name: str # 工序 + product_sn: str # 16位HEX身份证 + external_serial: str | None # 业务序列号 + material_name: str # 产品名称(物料名称) + spec_model: str # 规格型号 + rejected_by: str = "—" # 驳回人中文姓名(rework 可能无) + rework_assignee: str # 返工任务负责人中文姓名(转交给谁返工) + reject_reason: str | None # 驳回原因 + status: str | None = None # 当前状态(rework 任务用:PENDING/WIP/COMPLETED) + rejected_at: str | None # 驳回时间 ISO(BEIJING_TZ) + + +class UserOperation(BaseModel): + user_id: str # 登录名 username + user_name: str # 中文姓名 + receive_count: int = 0 # 接收次数 + transfer_count: int = 0 # 转交次数(action=complete) + record_count: int = 0 # 上传备注次数(action=record) + total: int = 0 # 总操作次数 + + +class OperationDetail(BaseModel): + task_name: str # 任务/工序名 + product_sn: str # 产品身份证 + material_name: str # 设备名称 + remark: str | None # 备注/说明 + time: str # 操作时间 ISO(BEIJING_TZ) + + +class WipMatrixRow(BaseModel): + spec_model: str # 规格型号(Y 轴) + product_name: str = "" # 产品名称(物料名称,用于首列复合显示) + dimension_key: str # 人员姓名 或 工序名称(X 轴) + count: int = 0 # 该交叉点的设备数量 + assignees: list[str] = [] # 该交叉点涉及的主负责人(中文名,去重) + + +class WipMatrixDetailRow(BaseModel): + """WIP 矩阵单元格下钻 — 某规格型号 × 某工序下的设备明细""" + product_id: str + serial_number: str # 16位HEX身份证 + external_serial: str | None = None # 业务序列号 + material_name: str = "" # 产品名称 + spec_model: str = "" + task_status: str = "" # 当前状态 WIP/PENDING/COMPLETED/ARCHIVED/OUTBOUND + # 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修) + lifecycle_phase: str = "PRODUCTION" + assignee_id: str = "" + assignee: str = "" # 负责人中文名 + duration_hours: float = 0.0 # 已在该工序滞留时长(小时) + total_hours: float = 0.0 # 整个项目总时长(自然小时,最早介入→最晚结束) + work_hours: float = 0.0 # 仅工作日有效时长(小时,排除周末/节假日) + received_at: str | None = None # 接手时间 MM-DD HH:mm + + +class PersonDevice(BaseModel): + product_id: str + serial_number: str # 16位HEX身份证 + external_serial: str | None # 业务序列号 + 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): + assignee_id: str + assignee_name: str # 中文姓名 + device_count: int + 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 + operator_name: str # 留言人中文姓名 + product_sn: str # 16位HEX系统追溯码 + external_serial: str | None # 业务产品序列号(如 25022) + material_name: str # 物料名称 + created_at: str # ISO时间字符串 + + +class ProductMessageList(BaseModel): + items: list[ProductMessageItem] + 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) + + +# ============================================================ +# 看板统计(时间快照语义) +# ============================================================ + +async def get_dashboard_stats( + db: AsyncSession, + since: datetime | None = None, + until: datetime | None = None, +) -> DashboardStats: + """ + 上帝视角 — 全厂全系统统计。 + + 时间筛选规则: + - PENDING / WIP / 总数:永远忽略时间筛选,返回实时快照。 + - COMPLETED / REJECTED / 完成率:严格按 since~until 过滤(用于时段报表)。 + """ + from app.models.product import Product + from app.models.task import ( + Task, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, + TASK_STATUS_REJECTED, + ) + from app.models.notification import Notification + from app.models.message import ProductMessage + # ── 产品流转三段 ── + # 状态口径(用户确认):废弃 Product.status 恒值判断, + # COMPLETED=待仓库收货,ARCHIVED=已入库('在库' 旧命名等价),OUTBOUND=已出库。 + # + # ⚠️「已完结」= 产出,必须响应用户在右上角选的时间范围(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_(["待仓库收货", "已入库", "在库", "已出库"]) + not_finished = or_(Product.overall_status.is_(None), ~finished_cond) + + # 在制 WIP = 未完结 且 存在活跃任务(PENDING/WIP) 的产品数 + has_active_task = select(Task.id).where( + Task.product_id == Product.id, + Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), + ).exists() + + # 所选时段内完结 = 已完结 且 名下有工序在该时段内完成 + 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_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 + + # 待流转 = 未完结 - 在制;展示总数 = 未完结 + 所选时段已完结 + # (三段互斥,保证进度条总和 = 展示总数) + 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)) + + # 返工数不再是无边界的全时段快照:未完结卡点永远计入,已了结的只算本月。 + # 与 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) + t_rej_q = select(func.count(Task.id)).where(Task.status == TASK_STATUS_REJECTED) + if since: + t_done_q = t_done_q.where(Task.completed_at >= since) + t_rej_q = t_rej_q.where(Task.completed_at >= since) + if until: + t_done_q = t_done_q.where(Task.completed_at <= until) + t_rej_q = t_rej_q.where(Task.completed_at <= until) + t_done = await db.scalar(t_done_q) + t_rejected = await db.scalar(t_rej_q) + + # ── 任务总数 = 实时快照段 + 时间过滤段(确保进度条段总和=总数) ── + t_total = (t_pending or 0) + (t_progress or 0) + (t_done or 0) + (t_rejected or 0) + + # ── 通知 & 留言(实时快照) ── + unread_notif = await db.scalar( + select(func.count(Notification.id)).where(Notification.is_read.is_(False)) + ) + unread_msg = await db.scalar(select(func.count(ProductMessage.id))) + + return DashboardStats( + products_total=p_total or 0, + products_pending=p_pending or 0, + products_in_progress=p_progress or 0, + products_completed=p_done or 0, + tasks_total=t_total or 0, + tasks_pending=t_pending or 0, + tasks_in_progress=t_progress or 0, + tasks_completed=t_done or 0, + tasks_rejected=t_rejected or 0, + tasks_rework=t_rework or 0, + unread_notifications=unread_notif or 0, + unread_messages=unread_msg or 0, + ) + + +# ============================================================ +# 个人效能(按 assignee_id 过滤到本人 — 移动端工作统计) +# ============================================================ + +async def get_my_stats( + db: AsyncSession, + assignee_id: str, + since: datetime | None = None, + until: datetime | None = None, +) -> MyStats: + """一线工人个人效能:生产战绩 + 操作统计(均可按自选时段)。 + + 时段缺省为「本月」;由调用方(移动端)传入北京时间边界。 + + 【生产战绩】按 Task.assignee_id 归因: + - 完成 = status == COMPLETED 且 completed_at 落在区间内(与 + get_dashboard_stats 的 t_done_q 同一口径,只多加了 assignee_id 过滤) + - 被驳回 = status == REJECTED。reject_task 只把状态改成 REJECTED 并写 + completed_at,assignee_id 仍是被驳回的那个人,直接过滤即可拿到 + 「我的活被驳了几次」 + - 参与产品数按 product_id 去重 —— 同一台设备做多道工序只算一台 + + 【操作统计】与 PC /dashboard/user-operations 严格同口径,便于工人自查的 + 数与主管看到的面板对得上: + - 接收 task_logs.action_type='receive' ,按 operator_id 归因 + - 转交 task_logs.action_type='complete',按 operator_id 归因 + - 上传备注 task_records 按 **Task.assignee_id** 归因,并排除以 '[' 开头的 + 系统自动备注(如 "[接收] 操作员已确认接收") + 注意前两项归因于 operator_id、第三项归因于 assignee_id —— 这是 PC 端既有 + 口径,此处刻意保持一致,不要"顺手统一"。 + + 时间边界统一用北京时间再转 UTC:时间戳字段都是 timestamptz,拿北京时区的 + aware datetime 比较语义一致,且不受数据库会话时区影响 + (与 screen_service._month_bounds 的写法一致)。 + """ + from datetime import timezone as dt_timezone + from app.core.time_utils import get_beijing_time + from app.models.task import Task, TaskRecord, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED + from app.models.task_log import TaskLog + from sqlalchemy import or_ + + now_bj = get_beijing_time() + if since is None: + since = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + if until is None: + until = now_bj + since_utc = since.astimezone(dt_timezone.utc) + until_utc = until.astimezone(dt_timezone.utc) + + # ── 1. 生产战绩 ── + async def _count_task(status: str) -> int: + return await db.scalar( + select(func.count(Task.id)).where( + Task.assignee_id == assignee_id, + Task.status == status, + Task.completed_at.isnot(None), + Task.completed_at >= since_utc, + Task.completed_at <= until_utc, + ) + ) or 0 + + products_touched = await db.scalar( + select(func.count(func.distinct(Task.product_id))).where( + Task.assignee_id == assignee_id, + Task.status == TASK_STATUS_COMPLETED, + Task.completed_at.isnot(None), + Task.completed_at >= since_utc, + Task.completed_at <= until_utc, + ) + ) or 0 + + # ── 2. 操作统计(口径同 get_user_operations,只是固定到本人)── + async def _count_log(action_type: str) -> int: + return await db.scalar( + select(func.count(TaskLog.id)).where( + TaskLog.operator_id == assignee_id, + TaskLog.action_type == action_type, + TaskLog.created_at >= since_utc, + TaskLog.created_at <= until_utc, + ) + ) or 0 + + # 备注归因给任务负责人(不是记录人)—— 与 PC 面板一致 + record_count = await db.scalar( + select(func.count(TaskRecord.id)) + .join(Task, TaskRecord.task_id == Task.id) + .where( + Task.assignee_id == assignee_id, + or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), + TaskRecord.created_at >= since_utc, + TaskRecord.created_at <= until_utc, + ) + ) or 0 + + receive_count = await _count_log("receive") + transfer_count = await _count_log("complete") + + return MyStats( + assignee_id=assignee_id, + tasks_completed=await _count_task(TASK_STATUS_COMPLETED), + tasks_rejected=await _count_task(TASK_STATUS_REJECTED), + products_touched=products_touched, + receive_count=receive_count, + transfer_count=transfer_count, + record_count=record_count, + op_total=receive_count + transfer_count + record_count, + ) + + +# ============================================================ +# 在制品看板(永远实时) +# ============================================================ + +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 + from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours + + # 读取节假日(排除非工作日) + 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])) + ) + result = await db.execute(stmt) + rows = result.all() + + raw_ids = list({t.assignee_id for t, *_ in rows if t.assignee_id}) + name_map: dict[str, str] = {} + if raw_ids: + from app.services.mom_cache import get_display_names + name_map = get_display_names(raw_ids) + + now = get_beijing_time() + wip_list: list[WipTask] = [] + for task, product_sn, ext_sn, mat_name, spec in rows: + start = task.received_at or task.created_at + if start: + start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时) + hours = working_duration_hours(start_bj, now, holidays) + recv_str = start_bj.strftime("%m-%d %H:%M") + else: + hours = 0 + recv_str = "" + + wip_list.append(WipTask( + task_id=str(task.id), + task_name=task.task_name, + assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"), + product_sn=product_sn or "", + external_serial=ext_sn or None, + material_name=mat_name or "", + spec_model=spec or "", + status=task.status, + received_at=recv_str, + duration_hours=hours, + )) + # 统一按滞留时间降序排列(无视 status,纯数值排序) + wip_list.sort(key=lambda t: t.duration_hours, reverse=True) + return wip_list[:limit] # limit 仅作上限保护,默认 500 实际不会截断 + + +# ============================================================ +# 已完成任务明细(流转完成率下钻 — 按时段过滤) +# ============================================================ + +async def get_completed_tasks( + db: AsyncSession, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 200, +) -> list[CompletedTask]: + """按时段查询已完成任务明细(上帝视角),用于「流转完成率」卡片下钻。""" + from app.models.task import Task, TASK_STATUS_COMPLETED + from app.models.product import Product + from app.core.time_utils import BEIJING_TZ + + 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 == TASK_STATUS_COMPLETED) + ) + if since: + stmt = stmt.where(Task.completed_at >= since) + if until: + stmt = stmt.where(Task.completed_at <= until) + stmt = stmt.order_by(Task.completed_at.desc()).limit(limit) + result = await db.execute(stmt) + rows = result.all() + + raw_ids = list({t.assignee_id for t, *_ in rows if t.assignee_id}) + name_map: dict[str, str] = {} + if raw_ids: + from app.services.mom_cache import get_display_names + name_map = get_display_names(raw_ids) + + items: list[CompletedTask] = [] + for task, sn, ext, mat, spec in rows: + t = task.completed_at + if t: + if t.tzinfo is None: + from datetime import timezone as dt_timezone + t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ) + else: + t = t.astimezone(BEIJING_TZ) + time_str = t.isoformat() if t else "" + items.append(CompletedTask( + task_id=str(task.id), + task_name=task.task_name, + assignee=name_map.get(task.assignee_id or "", task.assignee_id or "未分配"), + product_sn=sn or "", + external_serial=ext or None, + material_name=mat or "", + spec_model=spec or "", + completed_at=time_str, + )) + return items + + +# ============================================================ +# 被驳回任务明细(品质驳回下钻 — 按时段过滤) +# ============================================================ + +async def get_rejected_tasks( + db: AsyncSession, + since: datetime | None = None, + until: datetime | None = None, + limit: int = 200, +) -> list[RejectedTask]: + """查询驳回/返工任务明细(上帝视角),用于「驳回/返工」卡片下钻。 + + 返回两类(与卡片数字 tasks_rejected + tasks_rework 口径一致): + - kind="rejected":被驳回任务,按 completed_at 时间过滤 + - kind="rework":返工任务(is_rework=True 且非驳回状态)。 + 未完结(PENDING/WIP)永远展示;已了结的仅限本月 —— 详见 + _rework_visible_condition。该条件与 get_dashboard_stats 的 t_rework + 共用,保证卡片数字与明细条数一致。 + + 返工负责人(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 + from app.models.product import Product + from app.core.time_utils import BEIJING_TZ + + def _to_bj_iso(dt) -> str: + if not dt: + return "" + 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.isoformat() + + # ── 1. 已驳回任务(按时间过滤)── + 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 == TASK_STATUS_REJECTED) + ) + if since: + stmt = stmt.where(Task.completed_at >= since) + if until: + stmt = stmt.where(Task.completed_at <= until) + stmt = stmt.order_by(Task.completed_at.desc()).limit(limit) + rejected_rows = (await db.execute(stmt)).all() + + # ── 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, + _rework_visible_condition(month_start_utc, since, until), + ) + .order_by(Task.created_at.desc()) + .limit(limit) + ) + rework_rows = (await db.execute(rework_stmt)).all() + + # 合并:rejected 在前,rework 在后 + all_rows: list[tuple[str, object]] = [ + *[("rejected", t) for t in rejected_rows], + *[("rework", t) for t in rework_rows], + ] + + # ── 已驳回任务:批量取驳回人 / 最早 create log / 父任务负责人 ── + rejected_ids = [t.id for t, *_ in rejected_rows] + reject_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.desc(), + ).label("rn"), + ) + .where(TaskLog.task_id.in_(rejected_ids), TaskLog.action_type == "reject") + ).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]: + reject_op[row[0]] = row[1] + + # ── 派生返工任务的真实负责人 ── + # 不再复刻 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 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(derived_map.get(t.id) or "") + raw_ids.add(t.assignee_id or "") + raw_ids.discard("") + name_map: dict[str, str] = {} + if raw_ids: + from app.services.mom_cache import get_display_names + name_map = get_display_names(list(raw_ids)) + + items: list[RejectedTask] = [] + for kind, (task, sn, ext, mat, spec) in all_rows: + if kind == "rejected": + # 直接取派生返工任务的真实负责人(见 _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), + kind="rejected", + task_name=task.task_name, + product_sn=sn or "", + external_serial=ext or None, + material_name=mat or "", + spec_model=spec or "", + rejected_by=name_map.get(rejected_by_id or "", rejected_by_id or "—"), + rework_assignee=name_map.get(rework_id or "", rework_id or "—"), + reject_reason=task.reject_reason, + status=task.status, + rejected_at=_to_bj_iso(task.completed_at), + )) + else: + # 返工任务:负责人即其 assignee + items.append(RejectedTask( + task_id=str(task.id), + kind="rework", + task_name=task.task_name, + product_sn=sn or "", + external_serial=ext or None, + material_name=mat or "", + spec_model=spec or "", + rejected_by="—", + rework_assignee=name_map.get(task.assignee_id or "", task.assignee_id or "—"), + reject_reason=None, + status=task.status, + rejected_at=_to_bj_iso(task.received_at or task.created_at), + )) + return items + + +# ============================================================ +# 人员操作统计(接收/转交/上传备注 — 按人聚合,时间可筛选) +# ============================================================ + +async def get_user_operations( + db: AsyncSession, + since: datetime | None = None, + until: datetime | None = None, +) -> list[UserOperation]: + """上帝视角 — 统计**全部人员**的操作次数(按时段过滤)。 + + 返回所有 IRIS 部门人员(该时段无操作的计 0),并追加有操作记录但不在 + 人员清单中的账号(如历史/已离职)。 + + 操作口径(均不修改数据库): + - 接收: task_logs.action_type='receive'(按操作人) + - 转交: task_logs.action_type='complete'(按操作人) + - 上传备注: task_records 按**任务负责人**归因(排除系统自动生成的 + 以 '[' 开头的备注,如 "[接收] 操作员已确认接收"),历史数据可回溯 + """ + from app.models.task_log import TaskLog + from app.models.task import Task, TaskRecord + from app.core.mom_database import MomSessionLocal + from sqlalchemy import text, or_ + + # ── 1. 接收 / 转交(task_logs 按 operator_id 聚合)── + rcv = func.count().filter(TaskLog.action_type == "receive") + cpl = func.count().filter(TaskLog.action_type == "complete") + stmt = ( + select(TaskLog.operator_id, rcv.label("receive"), cpl.label("complete")) + .where( + TaskLog.action_type.in_(["receive", "complete"]), + TaskLog.operator_id.isnot(None), + ) + .group_by(TaskLog.operator_id) + ) + if since: + stmt = stmt.where(TaskLog.created_at >= since) + if until: + stmt = stmt.where(TaskLog.created_at <= until) + op_rows = (await db.execute(stmt)).all() + op_map = {r[0]: (r[1] or 0, r[2] or 0) for r in op_rows} + + # ── 2. 上传备注(task_records 按任务 assignee 归因,排除系统自动备注)── + rcd_stmt = ( + select(Task.assignee_id, func.count(TaskRecord.id)) + .join(Task, TaskRecord.task_id == Task.id) + .where( + Task.assignee_id.isnot(None), + or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), + ) + .group_by(Task.assignee_id) + ) + if since: + rcd_stmt = rcd_stmt.where(TaskRecord.created_at >= since) + if until: + rcd_stmt = rcd_stmt.where(TaskRecord.created_at <= until) + rcd_rows = (await db.execute(rcd_stmt)).all() + record_map = {r[0]: r[1] or 0 for r in rcd_rows} + + # ── 3. 获取全部 MOM 用户清单(IRIS 部门)── + users: list[dict] = [] + try: + dbm = MomSessionLocal() + try: + rows = dbm.execute(text(""" + SELECT username, SPLIT_PART(username, '/', 1) AS full_name + FROM sys_user WHERE department = 'IRIS' + """)).fetchall() + except Exception: + rows = dbm.execute(text(""" + SELECT username, SPLIT_PART(username, '/', 1) AS full_name + FROM sys_user + """)).fetchall() + finally: + dbm.close() + for row in rows: + short = row.username.split("/")[-1] if "/" in row.username else row.username + users.append({"username": short, "full_name": row.full_name or short}) + except Exception: + users = [] + + # ── 4. 合并:所有人员 + 有操作但不在清单的账号 ── + merged: dict[str, dict] = {} + for u in users: + merged[u["username"]] = {"name": u["full_name"], "rcv": 0, "cpl": 0, "rcd": 0} + for uid in set(op_map.keys()) | set(record_map.keys()): + if uid not in merged: + merged[uid] = {"name": uid, "rcv": 0, "cpl": 0, "rcd": 0} + r, c = op_map.get(uid, (0, 0)) + merged[uid]["rcv"] = r + merged[uid]["cpl"] = c + merged[uid]["rcd"] = record_map.get(uid, 0) + + # ── 5. 组装 + 排序(总次数降序,无操作的排在最后)── + items: list[UserOperation] = [] + for uid, v in merged.items(): + items.append(UserOperation( + user_id=uid, + user_name=v["name"], + receive_count=v["rcv"], + transfer_count=v["cpl"], + record_count=v["rcd"], + total=v["rcv"] + v["cpl"] + v["rcd"], + )) + items.sort(key=lambda x: x.total, reverse=True) + return items + + +# ============================================================ +# 在制品分布透视表(WIP Matrix:规格型号 × 人员/工序) +# ============================================================ + +async def get_wip_matrix( + db: AsyncSession, + dimension: str = "assignee", + since: datetime | None = None, + until: datetime | None = None, +) -> list[WipMatrixRow]: + """生产分布透视表:Y=规格型号,X=人员 或 工序,单元格=设备数量。 + + 核心口径:**每台设备只统计一次**,按它「当前所处工序」归属—— + 1. 取该设备最新的一条主分支任务(parent_task_id IS NULL 或 TRANSFER/RECOVERY) + 2. 设备当前工序 = 该最新主任务的工序名(task_name),不区分状态 + - 「待确认」(PENDING) = 别人转给我但未接收 + - 「已入库」= MOM 已扫码实收;「已完成」= 车间完工待实收 + - 其他已完成工序(如「测试」完成)按原工序显示,不强制归仓库态 + + 这样一台设备在「上一步已完成 + 下一步待确认」时只算一次(待确认),不会重复计数。 + since/until 时间过滤为“动静分离”:仅对 终结(收口/在库/出库) 设备过滤,锚点取 + 最新主线任务的 completed_at(完工/变动时间,兜底 created_at,schema 无 updated_at); + 在制(未收口)设备无条件全量计入,保证与人员看板实时在制不脱节。 + + dimension: + - assignee: 按当前任务负责人聚合(dimension_key 为中文姓名) + - task_name: 按当前工序聚合(dimension_key 为工序名,附主负责人) + """ + from datetime import timezone as dt_timezone + from sqlalchemy.orm import aliased + from app.models.task import Task + from app.models.product import Product + + # 外层已 join 了 tasks,内层判活跃任务必须换别名,否则语义无歧义 + _ActiveTask = aliased(Task, name="active_task") + + # 每台设备按主任务创建时间倒序,取第一条即「最新主任务」 + result = await db.execute( + select( + Product.id, + Product.spec_model, + Product.material_name, + Task.task_name, + Task.assignee_id, + Task.status.label("task_status"), + Task.created_at, + Task.completed_at, + Product.current_location_id, + Product.overall_status, + Product.status, + Product.lifecycle_phase, + # 🔧 该产品名下是否还有 ANY 活跃任务(含协助分支)—— + # 注意:这比下面的 is_active 更宽。is_active 只看"最新一条主干任务", + # 而这里扫描该产品的全部任务,用于 is_terminal 兜底判定。 + # ⚠️ 必须用别名:外层 FROM 里已有一个 tasks,同名会让 SQLAlchemy + # 自动关联掉内层 FROM 而报 "no FROM clauses"。别名后语义才无歧义。 + select(_ActiveTask.id).where( + _ActiveTask.product_id == Product.id, + _ActiveTask.status.in_(["WIP", "PENDING"]), + ).exists().label("has_any_active"), + ) + .outerjoin(Task, Task.product_id == Product.id) + .where( + or_( + Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收) + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), + ) + ) + .order_by(Product.id, Task.created_at.desc()) + ) + rows = result.all() + + # 每台设备 → (spec, 当前工序/负责人, 当前负责人ID) + device_cur: dict[str, tuple] = {} + seen: set[str] = set() + for pid, spec, product_name, task_name, assignee, tstatus, created, completed, loc, overall_status, product_status, lifecycle, has_any_active in rows: + if pid in seen: + continue + seen.add(pid) + + # ── 真实产出时间锚点:完工/变动时间优先,创建时间仅兜底 ── + # 说明:tasks 表无 updated_at 列(已核实 information_schema),故锚点取 + # completed_at or created_at;若后续新增 updated_at 可并入。 + anchor = completed or created + + # ── 活跃任务判定:决定"按当前工序归列"还是"降级判完结态"的唯一依据 ── + # 必须在所有终结态判定之前。否则「已出库」的残留状态(设备回流后 + # product.status 仍是 OUTBOUND,因为 receive_task/transfer_task 只同步 + # overall_status 不同步 status)会把正在做售后工序的设备误判为终结态。 + is_active = tstatus in ("WIP", "PENDING") + + # ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ── + # 有活跃任务的一律视为在制,不做时间过滤(与"在制无条件全量"口径一致) + is_terminal = (not is_active) and ( + loc == "virtual_warehouse" + or overall_status in ("待仓库收货", "已入库", "在库", "已出库") + or str(product_status).upper() in ("ARCHIVED", "OUTBOUND") + or task_name in ("扫码入库", "扫码出库") + # 🔧 兜底(2026-09-17):设备名下【再无任何活跃任务】= 真的没活了 → 视同终结态。 + # 历史缺陷:生产设备被【直接完结】后 overall_status 停在工序名(如「测试」), + # 既不满足上面任何一条,又因 is_terminal=False 对时间筛选完全免疫 —— + # 于是它永远挂在看板上冒充"在制"(报表游魂)。 + # 补上这一条后,它照常接受 since~until 过滤,不再污染在制口径。 + # 仍保留原有的终结态判定(本条件是「或」,只会让更多设备被正确归类为终结)。 + or not has_any_active + ) + if is_terminal: + if anchor is not None and anchor.tzinfo is None: + anchor = anchor.replace(tzinfo=dt_timezone.utc) + if since and anchor is not None and anchor < since: + continue + if until and anchor is not None and anchor > until: + continue + # else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致 + + if dimension == "task_name": + # ══ ① 第一优先级:有活跃任务(WIP/PENDING) → 一律按当前工序归列 ══ + # 绝不看 overall_status / product.status 的终结态字段。这两个字段 + # 在售后回流场景下会残留旧值(status 停在 OUTBOUND),若先判终结态, + # 正在做「发货测试」的设备会被吞进「已出库」列。 + if is_active: + if tstatus == "PENDING": + # 转交后待接收(工序名还是占位符「待确认」)→ 统一归「待接收」 + key = "待接收" + else: + key = task_name if (task_name and task_name != "—") else "待接收" + # 售后回流设备按折算后的工序名落列:发货测试 / 售后维修 + key = normalize_after_sales_step(lifecycle, key) + # ══ ② 无活跃任务,才降级判完结态(多维防御,不依赖单一 loc / overall)══ + # 已入库: 整体实收(已入库/在库) 或 status==ARCHIVED 或 收口节点「扫码入库」 + elif overall_status in ("已入库", "在库") or str(product_status).upper() == "ARCHIVED" or task_name == "扫码入库": + key = "已入库" + # 已出库: 整体已出库 或 status==OUTBOUND 或 收口节点「扫码出库」 + elif overall_status == "已出库" or str(product_status).upper() == "OUTBOUND" or task_name == "扫码出库": + key = "已出库" + # 已完成: 车间完工待实收(待仓库收货) 或 处于仓库(未实收/未出库) + elif overall_status == "待仓库收货" or loc == "virtual_warehouse": + key = "已完成" + else: + # 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」 + if tstatus is None and task_name is None: + key = "待接收" + else: + # 已完工但未收口的工序(如「测试」完成),按原工序名显示 + key = task_name if (task_name and task_name != "—") else "待接收" + # 🔧 售后回流设备的历史工序归一:老数据里售后设备的工序可能仍叫 + # 「测试 / 维修」,折算进售后区的独立列,避免混入生产区同名工序 + key = normalize_after_sales_step(lifecycle, key) + else: + key = assignee or "未分配" + device_cur[pid] = (spec or "未知型号", key, assignee or "", product_name or "") + + from collections import Counter + + # 聚合:规格 × 当前工序 → 设备数;负责人按人头计数,空值显式计为「未分配」 + agg: dict[tuple, int] = {} + assignee_counter: dict[tuple, Counter] = {} + product_name_map: dict[tuple, str] = {} + for spec, key, assignee, product_name in device_cur.values(): + k = (spec, key) + agg[k] = agg.get(k, 0) + 1 + product_name_map.setdefault(k, product_name) + raw = assignee if assignee and str(assignee).strip() not in ("", "—", "-", "null", "None") else "" + assignee_counter.setdefault(k, Counter())[raw] += 1 + + # 负责人 ID → 中文名 + raw_ids: set[str] = set() + for counter in assignee_counter.values(): + raw_ids |= set(counter.keys()) + raw_ids.discard("") + name_map: dict[str, str] = {} + if raw_ids: + from app.services.mom_cache import get_display_names + name_map = get_display_names(list(raw_ids)) + + def _disp(raw: str) -> str: + """负责人显示名:空值统一为 未分配""" + return "未分配" if not raw else name_map.get(raw, raw) + + def _fmt_label(raw: str, n: int) -> str: + """负责人标签:>1 台必须带数量后缀;1 台不带""" + disp = _disp(raw) + return f"{disp}({n})" if n > 1 else disp + + items: list[WipMatrixRow] = [] + for (spec, key), cnt in agg.items(): + dim_display = _disp(key) if dimension == "assignee" else key + counter = assignee_counter.get((spec, key), Counter()) + # 负责人名单:未分配固定排最前,其余按台数降序、名称升序;带数量后缀(>1 台) + labels = [ + _fmt_label(raw, n) + for raw, n in sorted( + counter.items(), + key=lambda kv: (0 if kv[0] == "" else 1, -kv[1], _disp(kv[0])), + ) + ] + items.append(WipMatrixRow( + spec_model=spec, + product_name=product_name_map.get((spec, key), ""), + dimension_key=dim_display, + count=cnt, + assignees=labels, + )) + items.sort(key=lambda x: (x.spec_model, x.dimension_key)) + return items + + +# ============================================================ +# WIP 矩阵单元格下钻 — 某规格型号 × 某工序下的设备明细 +# ============================================================ + +async def get_wip_matrix_detail( + db: AsyncSession, + spec_model: str, + process: str, + since: datetime | None = None, + until: datetime | None = None, +) -> list[WipMatrixDetailRow]: + """WIP 矩阵单元格下钻:返回该 规格型号×工序 交叉点下的设备明细。 + + 口径与 get_wip_matrix 完全一致:每台设备取最新一条主分支任务, + 按其在库三态(已完成/已入库/已出库)或工序名归类到 process。 + """ + from datetime import timezone as dt_timezone + from sqlalchemy.orm import aliased + from app.models.task import Task + from app.models.product import Product + from app.models.holiday import Holiday + from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours + + # 与外层 join 的 tasks 区分开(理由见 get_wip_matrix) + _ActiveTask = aliased(Task, name="active_task") + + hres = await db.execute(select(Holiday.day)) + holidays = {r[0] for r in hres} + + result = await db.execute( + select( + Product.id, + Product.serial_number, + Product.external_serial, + Product.material_name, + Product.spec_model, + Product.current_location_id, + Product.overall_status, + Product.status, + Product.lifecycle_phase, + Task.task_name, + Task.assignee_id, + Task.status.label("task_status"), + Task.created_at, + Task.completed_at, + Task.received_at, + # 🔧 与 get_wip_matrix 同口径:该产品名下是否还有 ANY 活跃任务 + # (同样必须用别名,理由见 get_wip_matrix 处注释) + select(_ActiveTask.id).where( + _ActiveTask.product_id == Product.id, + _ActiveTask.status.in_(["WIP", "PENDING"]), + ).exists().label("has_any_active"), + ) + .outerjoin(Task, Task.product_id == Product.id) + .where( + or_( + Task.id.is_(None), # 🔧 允许该产品完全没有任务记录(新建档/待接收) + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), + ) + ) + .order_by(Product.id, Task.created_at.desc()) + ) + rows = result.all() + + now = get_beijing_time() + seen: set[str] = set() + matched: list[WipMatrixDetailRow] = [] + raw_names: set[str] = set() + + for pid, serial, ext, mat_name, spec, loc, overall, pstatus, lifecycle, task_name, assignee, tstatus, created, completed, received, has_any_active in rows: + if pid in seen: + continue + seen.add(pid) + + if spec_model and (spec or "") != spec_model: + continue + + # ── 真实产出时间锚点:完工/变动时间优先,创建时间仅兜底(schema 无 updated_at)── + anchor = completed or created + + # ── 活跃任务判定:与 get_wip_matrix 同口径,必须先于一切终结态判定 ── + # 否则「已出库」残留状态(回流后 pstatus 仍是 OUTBOUND)会把正在做 + # 售后工序的设备误判为终结态,既错配时间过滤又错列。 + is_active = tstatus in ("WIP", "PENDING") + + # ── 动静分离:仅终结(收口/在库/出库)设备按 since~until 过滤;在制设备无条件全量 ── + # ⚠️ 必须与 get_wip_matrix 一字不差同步,否则会出现 + # 「矩阵格子里有数、点进来下钻却是空的」。 + is_terminal = (not is_active) and ( + loc == "virtual_warehouse" + or overall in ("待仓库收货", "已入库", "在库", "已出库") + or str(pstatus).upper() in ("ARCHIVED", "OUTBOUND") + or task_name in ("扫码入库", "扫码出库") + # 🔧 兜底:名下再无任何活跃任务 → 视同终结态,接受时间筛选(防报表游魂) + or not has_any_active + ) + if is_terminal: + if anchor is not None and anchor.tzinfo is None: + anchor = anchor.replace(tzinfo=dt_timezone.utc) + if since and anchor is not None and anchor < since: + continue + if until and anchor is not None and anchor > until: + continue + # else: 在制设备无视时间参数,保证与人员看板“实时在制”总数一致 + + # ══ 列归类(与 get_wip_matrix 一字不差同步)══ + # ① 第一优先级:有活跃任务 → 一律按当前工序归列,不看终结态字段 + if is_active: + if tstatus == "PENDING": + key = "待接收" + else: + key = task_name if (task_name and task_name != "—") else "待接收" + key = normalize_after_sales_step(lifecycle, key) + # ② 无活跃任务,才降级判完结态 + elif overall in ("已入库", "在库") or str(pstatus).upper() == "ARCHIVED" or task_name == "扫码入库": + key = "已入库" + elif overall == "已出库" or str(pstatus).upper() == "OUTBOUND" or task_name == "扫码出库": + key = "已出库" + elif overall == "待仓库收货" or loc == "virtual_warehouse": + key = "已完成" + else: + # 🔧 对齐全景/产品管理:无任何任务的新品统一归「待接收」 + if tstatus is None and task_name is None: + key = "待接收" + else: + key = task_name if (task_name and task_name != "—") else "待接收" + # 🔧 必须与 get_wip_matrix 同口径归一:否则矩阵格子里有数、 + # 点进来下钻却是空的(售后设备的历史「测试/维修」工序) + key = normalize_after_sales_step(lifecycle, key) + + if process and key != process: + continue + + # 滞留时长(当前工序接手时间 → 现在) + start = received or created + duration = 0.0 + received_str = None + if start: + start_bj = to_beijing(start) + duration = working_duration_hours(start_bj, now, holidays) + received_str = start_bj.strftime("%m-%d %H:%M") + + matched.append(WipMatrixDetailRow( + product_id=str(pid), + serial_number=serial or "", + external_serial=ext, + material_name=mat_name or "", + spec_model=spec or "", + task_status=tstatus or "", + lifecycle_phase=lifecycle or "PRODUCTION", + assignee_id=assignee or "", + duration_hours=duration, + received_at=received_str, + )) + if assignee: + raw_names.add(assignee) + + # 负责人中文名映射 + name_map: dict[str, str] = {} + if raw_names: + from app.services.mom_cache import get_display_names + name_map = get_display_names(list(raw_names)) + for r in matched: + r.assignee = name_map.get(r.assignee_id, r.assignee_id) + + # ── 整个项目总时长:设备最早介入 → 最晚结束(自然 + 工作日) ── + if matched: + import uuid as uuid_mod + from sqlalchemy import func as sa_func + uuid_list = [uuid_mod.UUID(m.product_id) for m in matched] + range_rows = (await db.execute( + select( + Task.product_id, + sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)).label("t0"), + sa_func.max(sa_func.coalesce(Task.completed_at, now)).label("tmax"), + ) + .where(Task.product_id.in_(uuid_list)) + .group_by(Task.product_id) + )).all() + row_by_pid = {m.product_id: m for m in matched} + for pid, t0, tmax in range_rows: + row = row_by_pid.get(str(pid)) + if row is None or t0 is None or tmax is None: + continue + row.total_hours = round((tmax - t0).total_seconds() / 3600, 1) + row.work_hours = round(working_duration_hours(to_beijing(t0), to_beijing(tmax), holidays), 1) + + return matched + + +# ============================================================ +# 人员操作明细(点击数字下钻 — 接收/转交/上传备注) +# ============================================================ + +async def get_user_operation_detail( + db: AsyncSession, + user_id: str, + action_type: str, + since: datetime | None = None, + until: datetime | None = None, +) -> list[OperationDetail]: + """查询某人在指定时段内的某类操作明细。 + + action_type: + - receive: 接收(task_logs.action_type='receive') + - transfer: 转交(task_logs.action_type='complete') + - record: 上传备注(该人名下任务的手动备注,排除系统自动生成的) + """ + from app.models.task_log import TaskLog + from app.models.task import Task, TaskRecord + from app.models.product import Product + from app.core.time_utils import BEIJING_TZ + + def _to_iso(dt) -> str: + if not dt: + return "" + 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.isoformat() + + items: list[OperationDetail] = [] + + if action_type in ("receive", "transfer"): + act = "receive" if action_type == "receive" else "complete" + stmt = ( + select(TaskLog.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskLog.remark) + .join(Task, TaskLog.task_id == Task.id) + .join(Product, Task.product_id == Product.id) + .where(TaskLog.operator_id == user_id, TaskLog.action_type == act) + .order_by(TaskLog.created_at.desc()) + ) + if since: + stmt = stmt.where(TaskLog.created_at >= since) + if until: + stmt = stmt.where(TaskLog.created_at <= until) + rows = (await db.execute(stmt)).all() + for r in rows: + items.append(OperationDetail( + task_name=r[1] or "", + product_sn=r[2] or "", + material_name=r[3] or "", + remark=r[4] or "", + time=_to_iso(r[0]), + )) + elif action_type == "record": + stmt = ( + select(TaskRecord.created_at, Task.task_name, Product.serial_number, Product.material_name, TaskRecord.remark) + .join(Task, TaskRecord.task_id == Task.id) + .join(Product, Task.product_id == Product.id) + .where( + Task.assignee_id == user_id, + or_(TaskRecord.remark.is_(None), ~TaskRecord.remark.like("[%")), + ) + .order_by(TaskRecord.created_at.desc()) + ) + if since: + stmt = stmt.where(TaskRecord.created_at >= since) + if until: + stmt = stmt.where(TaskRecord.created_at <= until) + rows = (await db.execute(stmt)).all() + for r in rows: + items.append(OperationDetail( + task_name=r[1] or "", + product_sn=r[2] or "", + material_name=r[3] or "", + remark=r[4] or "", + time=_to_iso(r[0]), + )) + return items + + +# ============================================================ +# 人员负载(按人聚合在制品设备 — 独立「人员看板」) +# ============================================================ + +async def get_people_workload(db: AsyncSession) -> list[PersonWorkload]: + """上帝视角 — 按负责人聚合当前在制品设备(WIP/PENDING 任务,product 去重,含滞留时长)。""" + from app.models.task import Task, TASK_STATUS_PENDING, TASK_STATUS_WIP + from app.models.product import Product + from app.models.holiday import Holiday + from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours + + # 读取节假日(排除非工作日) + hres = await db.execute(select(Holiday.day)) + holidays = {r[0] for r in hres} + + stmt = ( + select( + Task.assignee_id, + Product.id, Product.serial_number, Product.external_serial, + Product.material_name, Product.spec_model, + Task.status, Task.received_at, Task.created_at, + ) + .join(Product, Task.product_id == Product.id) + .where( + Task.status.in_([TASK_STATUS_PENDING, TASK_STATUS_WIP]), + Task.assignee_id.isnot(None), + ) + .order_by(Task.assignee_id, Product.created_at.desc()) + ) + result = await db.execute(stmt) + rows = result.all() + + def _to_bj(dt): + return to_beijing(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 "" + 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": + 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(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: list[PersonWorkload] = [] + for assignee, prods in agg.items(): + devices: list[PersonDevice] = [] + for product_id, e in prods.items(): + dt = e["earliest_dt"] + hours = working_duration_hours(dt, now, holidays) 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=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, to_beijing, working_duration_hours + from app.models.holiday import Holiday + from sqlalchemy import func + + now = get_beijing_time() + + # 读取节假日(排除非工作日) + hres = await db.execute(select(Holiday.day)) + holidays = {r[0] for r in hres} + + # ── 关联 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 = working_duration_hours(received_dt, end_dt, holidays) 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 + + +# ============================================================ +# 协同留言搜索(上帝视角 — 全厂) +# ============================================================ + +async def search_product_messages( + db: AsyncSession, + keyword: str = "", + skip: int = 0, + limit: int = 50, +) -> ProductMessageList: + """ + 上帝视角 — 全厂所有产品的协同留言。 + + 关联链: ProductMessage → Product → (material_name, serial_number) + 搜索支持: SN码、物料名称、留言人 + 排序: created_at 倒序(最新在前) + """ + from app.models.message import ProductMessage + from app.models.product import Product + from app.core.time_utils import BEIJING_TZ + + # 基础查询 — 同时取出 16位追溯码 + 业务序列号 + stmt = ( + select(ProductMessage, Product.serial_number, Product.material_name, Product.external_serial) + .join(Product, ProductMessage.product_id == Product.id) + ) + + # 关键词搜索 + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + stmt = stmt.where(or_( + Product.serial_number.ilike(kw), + Product.external_serial.ilike(kw), + Product.material_name.ilike(kw), + ProductMessage.operator_id.ilike(kw), + ProductMessage.content.ilike(kw), + )) + + # 总数 + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = await db.scalar(count_stmt) or 0 + + # 分页 + 排序 + stmt = stmt.order_by(ProductMessage.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + rows = result.all() + + # 收集 operator_id → 批量翻译中文姓名 + raw_ids = list({row[0].operator_id for row in rows if row[0].operator_id}) + name_map: dict[str, str] = {} + if raw_ids: + from app.services.mom_cache import get_display_names + name_map = get_display_names(raw_ids) + + items: list[ProductMessageItem] = [] + for msg, sn, mat_name, ext_sn in rows: + t = msg.created_at + if t: + if t.tzinfo is None: + # 旧数据(datetime.utcnow):naive UTC → 转北京时间 + from datetime import timezone as dt_timezone + t = t.replace(tzinfo=dt_timezone.utc).astimezone(BEIJING_TZ) + else: + t = t.astimezone(BEIJING_TZ) + time_str = t.isoformat() if t else "" + + items.append(ProductMessageItem( + id=str(msg.id), + content=msg.content, + operator_name=name_map.get(msg.operator_id, msg.operator_id), + product_sn=sn or "", + external_serial=ext_sn or None, + material_name=mat_name or "", + created_at=time_str, + )) + + return ProductMessageList(items=items, total=total) diff --git a/backend/app/services/label_service.py b/backend/app/services/label_service.py new file mode 100644 index 0000000..26db074 --- /dev/null +++ b/backend/app/services/label_service.py @@ -0,0 +1,208 @@ +"""标签打印服务 — 工业级精确定位标签 (480×360) + TSPL 发送""" +import base64 +import socket +from io import BytesIO +from pathlib import Path +from typing import Optional + +import qrcode +from PIL import Image, ImageDraw, ImageFont + +from app.services.print_config import PrintConfigManager + +# ============================================================ +# 字体 — 项目内 simhei.ttf +# ============================================================ + +_FONT_PATH = str(Path(__file__).resolve().parent.parent.parent / "simhei.ttf") + +FONT_NORMAL = ImageFont.truetype(_FONT_PATH, 28) # 右侧:名/规/单 +FONT_LARGE = ImageFont.truetype(_FONT_PATH, 34) # 底部:序列号 + +# ============================================================ +# 画布 & 坐标常量 +# ============================================================ + +WIDTH, HEIGHT = 480, 360 +QR_X, QR_Y = 24, 60 +QR_SIZE = 180 +TEXT_X = 228 +TEXT_MAX_W = WIDTH - TEXT_X - 12 # ~240px +BOTTOM_Y = 260 # 序列号 y +LINE_H = 44 # 28px 行高 + +# ============================================================ +# QR 码 +# ============================================================ + +def _generate_qr(content: str) -> Image.Image: + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(content) + qr.make(fit=True) + img = qr.make_image(fill_color="black", back_color="white") + return img.resize((QR_SIZE, QR_SIZE), Image.NEAREST) + + +# ============================================================ +# 文字绘制 — 带描边 + 自动换行 +# ============================================================ + +def _draw( + draw: ImageDraw.Draw, + text: str, + x: int, + y: int, + font: ImageFont.FreeTypeFont, + max_width: int, + stroke_width: int = 1, +) -> int: + """ + 绘制文字,超出 max_width 自动折行。 + 返回下一行可用的 y 坐标。 + """ + if not text: + return y + LINE_H + + # 单行不超宽 → 直接画(带描边) + bbox = draw.textbbox((0, 0), text, font=font) + if bbox[2] - bbox[0] <= max_width: + draw.text((x, y), text, font=font, fill="black", + stroke_width=stroke_width, stroke_fill="black") + return y + LINE_H + + # 逐字符折行 + lines: list[str] = [] + current = "" + for char in text: + test = current + char + w = draw.textbbox((0, 0), test, font=font)[2] + if w <= max_width: + current = test + else: + lines.append(current) + current = char + if current: + lines.append(current) + + cy = y + for line in lines: + draw.text((x, cy), line, font=font, fill="black", + stroke_width=stroke_width, stroke_fill="black") + cy += LINE_H + return cy + + +# ============================================================ +# 标签绑制 — 工业级精确坐标 +# ============================================================ + +def _create_label(data: dict) -> Image.Image: + """ + 480×360 工业级排版: + + ┌───────────────┬──────────────────────────────┐ + │ │ 名: 样品升降台V1J y=60 │ ← 28px sw=1 + │ [QR Code] │ 规: PH-B4V1J/类A y=104 │ + │ 180×180 │ 单: ORD-2024-001 y=148 │ + │ (24, 60) │ │ + │ │ │ + │ 码: 0000000000000001 y=260 │ ← 34px sw=2 + └───────────────┴──────────────────────────────┘ + """ + img = Image.new("RGB", (WIDTH, HEIGHT), color="white") + draw = ImageDraw.Draw(img) + + serial = data.get("serial_number", "") + + # ── QR 码 (24, 60) ── + if serial: + qr_img = _generate_qr(serial) + img.paste(qr_img, (QR_X, QR_Y)) + + # ── 右侧文字 x=228 — 28px, stroke_width=1 ── + y = 60 + + name = data.get("material_name", "") or "未命名" + y = _draw(draw, f"名: {name}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1) + + spec = data.get("spec_model", "") or "-" + y = _draw(draw, f"规: {spec}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1) + + order_no = data.get("order_no", "") + if order_no and str(order_no).strip(): + y = _draw(draw, f"单: {str(order_no).strip()}", TEXT_X, y, FONT_NORMAL, TEXT_MAX_W, stroke_width=1) + + # ── 底部通栏 (24, 260) — 34px, stroke_width=2 ── + code = serial or "-" + draw.text((24, BOTTOM_Y), f"码: {code}", font=FONT_LARGE, fill="black", + stroke_width=2, stroke_fill="black") + + return img + + +# ============================================================ +# 公开 API +# ============================================================ + +def generate_preview_image(**data) -> str: + """返回 Base64 JPEG data URL""" + img = _create_label(data) + buf = BytesIO() + img.save(buf, format="JPEG", quality=92) + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/jpeg;base64,{b64}" + + +def send_to_printer( + copies: int = 1, + printer_ip: Optional[str] = None, + printer_port: Optional[int] = None, + **data, +) -> dict: + """二值化 → TSPL → Socket 发送""" + printer = PrintConfigManager.get_printer("label_printer") + ip = printer_ip or printer.get("ip", "192.168.9.221") + port = printer_port or printer.get("port", 9100) + + img_rgb = _create_label(data) + img_gray = img_rgb.convert("L") + img_bw = img_gray.point(lambda px: 0 if px < 128 else 255, "1") + + width_bytes = (img_bw.width + 7) // 8 + height_dots = img_bw.height + + tspl = ( + "SIZE 40 mm, 30 mm\r\n" + "GAP 2 mm, 0 mm\r\n" + "CLS\r\n" + "DIRECTION 1\r\n" + ).encode("gbk", errors="replace") + + bitmap_cmd = f"BITMAP 0,0,{width_bytes},{height_dots},0,".encode("gbk", errors="replace") + bitmap_data = img_bw.tobytes() + footer = f"\r\nPRINT 1,{copies}\r\n".encode("gbk", errors="replace") + + payload = tspl + bitmap_cmd + bitmap_data + footer + + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(5) + try: + s.connect((ip, port)) + s.sendall(payload) + s.close() + return { + "success": True, + "message": f"打印指令已发送 → {ip}:{port},份数: {copies}", + "printer": f"{ip}:{port}", + } + except Exception as e: + return { + "success": False, + "message": f"打印机连接失败 ({ip}:{port}): {str(e)}", + "printer": f"{ip}:{port}", + } diff --git a/backend/app/services/mom_cache.py b/backend/app/services/mom_cache.py new file mode 100644 index 0000000..cdbbbe4 --- /dev/null +++ b/backend/app/services/mom_cache.py @@ -0,0 +1,154 @@ +""" +MOM 跨库查询缓存模块 — 使用本地 TTL 缓存消除冗余跨库请求 + +解决的问题: + 1. _lookup_display_names 在 get_all_products 中被调用 3 次,每次都打开/关闭 + MOM 数据库连接,150 条产品的列表页 = 3 根管线查询。 + 2. 同一批 username 在短时间内(用户翻页、多人同时访问)被反复查询。 + 3. 旧实现用 OR 拼接 LIKE 条件,存在注入风险。 + +方案:python -m 内置模块(零依赖)实现线程安全 TTL 缓存 + 参数化 ANY 查询。 + +TTL: 2 小时(人员姓名不会频繁变动,可调)。 +""" +from __future__ import annotations + +import threading +import time + +from app.core.mom_database import MomSessionLocal + + +# ============================================================ +# 零依赖 TTL 缓存(线程安全) +# ============================================================ + +class _TTLCache: + """线程安全的内存 TTL 缓存,用于 MOM 只读查询结果""" + + def __init__(self, ttl_seconds: int = 7200) -> None: + self._store: dict[str, str] = {} + self._expiry: dict[str, float] = {} + self._ttl = ttl_seconds + self._lock = threading.RLock() + + def get_many(self, keys: list[str]) -> tuple[dict[str, str], list[str]]: + """ + 批量获取 → (命中字典, 未命中 key 列表)。 + + 内部自动清理过期条目。 + """ + hits: dict[str, str] = {} + missed: list[str] = [] + now = time.monotonic() + + with self._lock: + for k in keys: + exp = self._expiry.get(k) + if exp is not None and now < exp: + hits[k] = self._store[k] + else: + missed.append(k) + # 清理过期残留 + if k in self._store: + del self._store[k] + del self._expiry[k] + + return hits, missed + + def set_many(self, mapping: dict[str, str]) -> None: + """批量写入,所有 key 共享同一过期时间""" + expiry = time.monotonic() + self._ttl + with self._lock: + for k, v in mapping.items(): + self._store[k] = v + self._expiry[k] = expiry + + +# ============================================================ +# 全局缓存实例(2h TTL) +# ============================================================ + +_user_name_cache = _TTLCache(ttl_seconds=7200) + + +# ============================================================ +# 公开 API +# ============================================================ + +def get_display_names(user_ids: list[str]) -> dict[str, str]: + """ + 批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)。 + + 缓存穿透流程: + 1. 去重 → 从缓存批量读取 + 2. 计算 miss 差集 + 3. miss 非空时,用参数化 ANY(:user_ids) 查 MOM(1 条 SQL) + 4. 写回缓存 + 5. 合并 hits + fresh 返回 + + 参数: + user_ids: 短用户名列表,如 ["zhangsan01", "lisi02"] + + 返回: + {"zhangsan01": "张三", "lisi02": "李四"} + 不存在的 key 不会出现在返回字典中。 + + SQL 安全: + 使用 SPLIT_PART(username, '/', 2) = ANY(:user_ids) 参数化查询, + 杜绝旧实现中 OR 拼接 LIKE 的注入风险。 + """ + if not user_ids: + return {} + + # 过滤特殊值 + 去重保序 + seen: set[str] = set() + real_ids: list[str] = [] + for uid in user_ids: + if uid and uid != "virtual_warehouse" and uid not in seen: + seen.add(uid) + real_ids.append(uid) + + if not real_ids: + return {} + + # ── Step 1: 批量查缓存 ── + hits, missed = _user_name_cache.get_many(real_ids) + + # ── Step 2: 仅对 miss 查 MOM ── + if missed: + db = MomSessionLocal() + try: + from sqlalchemy import text + + # 参数化 ANY 查询 — 安全防注入 + # SPLIT_PART('张三/zhangsan01', '/', 2) = 'zhangsan01' + # OR username = ANY(...) 兜底无斜杠的用户名(如 admin) + sql = text(""" + SELECT username, + SPLIT_PART(username, '/', 1) AS display_name + FROM sys_user + WHERE SPLIT_PART(username, '/', 2) = ANY(:user_ids) + OR username = ANY(:user_ids) + """) + result = db.execute(sql, {"user_ids": missed}) + rows = result.fetchall() + finally: + db.close() + + # ── Step 3: 解析结果 + 写回缓存 ── + fresh: dict[str, str] = {} + for row in rows: + full_username: str = row[0] + display_name: str = row[1] + # "张三/zhangsan01" → short="zhangsan01" + short = full_username.split("/")[-1] if "/" in full_username else full_username + fresh[short] = display_name + + if fresh: + _user_name_cache.set_many(fresh) + + # ── Step 4: 合并 ── + hits.update(fresh) + + return hits diff --git a/backend/app/services/print_config.py b/backend/app/services/print_config.py new file mode 100644 index 0000000..7c730e1 --- /dev/null +++ b/backend/app/services/print_config.py @@ -0,0 +1,44 @@ +"""打印机配置管理 — JSON 文件持久化""" +import json +import os +from pathlib import Path + +CONFIG_DIR = Path(__file__).resolve().parent.parent.parent / "data" +CONFIG_FILE = CONFIG_DIR / "printer_config.json" + +DEFAULT_CONFIG = { + "label_printer": { + "ip": "192.168.9.221", + "port": 9100, + "enabled": False, + }, +} + + +class PrintConfigManager: + """打印机 IP/端口 配置读写""" + + @staticmethod + def _ensure_file() -> None: + if not CONFIG_DIR.exists(): + CONFIG_DIR.mkdir(parents=True) + if not CONFIG_FILE.exists(): + PrintConfigManager.save_config(DEFAULT_CONFIG) + + @staticmethod + def get_config() -> dict: + PrintConfigManager._ensure_file() + with open(CONFIG_FILE, "r", encoding="utf-8") as f: + return json.load(f) + + @staticmethod + def save_config(config: dict) -> None: + if not CONFIG_DIR.exists(): + CONFIG_DIR.mkdir(parents=True) + with open(CONFIG_FILE, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + @staticmethod + def get_printer(name: str = "label_printer") -> dict: + config = PrintConfigManager.get_config() + return config.get(name, DEFAULT_CONFIG.get("label_printer", {})) diff --git a/backend/app/services/product_finalize_service.py b/backend/app/services/product_finalize_service.py new file mode 100644 index 0000000..d3344b4 --- /dev/null +++ b/backend/app/services/product_finalize_service.py @@ -0,0 +1,146 @@ +"""产品收口服务 — 管理员一键把产品修正为「已入库」或「已出库」(可反向互切纠错)。 + +落库语义与 MOM 仓储回调(webhooks.mom_inbound / mom_outbound)保持一致: + 1) 改 product.overall_status + product.status(映射为 ARCHIVED / OUTBOUND); + 2) 写一条 warehouse_inbound / warehouse_outbound 的 task_log; + 3) 幂等追加「扫码入库 / 扫码出库」WAREHOUSE 主线节点 + TaskRecord。 + +与 update_overall_status(仅改状态、面向主线负责人)不同:本服务面向管理员做收口, +会补全流转树收尾节点,使任务全景/详情/矩阵能正确显示入库/出库收口。 +""" +from __future__ import annotations + +from fastapi import HTTPException +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.time_utils import get_beijing_time +from app.models.product import Product +from app.models.task import Task, TaskRecord +from app.models.task_log import TaskLog +from app.services.product_service import get_product_by_serial + +# target -> (product.status 映射码, 追加节点名, task_log.action_type) +_FINALIZE_MAP = { + "已入库": ("ARCHIVED", "扫码入库", "warehouse_inbound"), + "已出库": ("OUTBOUND", "扫码出库", "warehouse_outbound"), +} + + +async def _append_warehouse_node(db: AsyncSession, product: Product, task_name: str) -> None: + """在流转树主干末尾幂等追加仓储收口主线节点 + TaskRecord(对齐 webhooks._append_warehouse_task)""" + existing = ( + await db.execute( + select(Task.id).where( + Task.product_id == product.id, + Task.task_name == task_name, + ).limit(1) + ) + ).scalar_one_or_none() + if existing: + return + + last_main = ( + await db.execute( + select(Task) + .where( + Task.product_id == product.id, + (Task.parent_task_id.is_(None)) + | (Task.task_type.in_(["TRANSFER", "RECOVERY"])), + ) + .order_by(Task.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + node = Task( + product_id=product.id, + parent_task_id=last_main.id if last_main else None, + task_name=task_name, + assignee_id=None, # ★ 不能填非 UUID,否则前端头像解析报错导致节点跳过渲染 + status="COMPLETED", + task_type="WAREHOUSE", + completed_at=get_beijing_time(), + remark=f"管理员收口:追加{task_name}收尾节点", + ) + db.add(node) + await db.flush() + db.add(TaskRecord(task_id=node.id, remark=f"管理员收口:追加{task_name}收尾节点")) + + +async def finalize_product_status( + db: AsyncSession, + serial_number: str, + target: str, + current_user: dict | None, + note: str | None = None, +): + """把产品整体收口到 target(已入库 / 已出库)。已处于目标态则幂等直接返回。""" + target = (target or "").strip() + if target not in _FINALIZE_MAP: + raise HTTPException(status_code=400, detail="收口目标状态仅支持:已入库 / 已出库") + + product = ( + await db.execute(select(Product).where(Product.serial_number == serial_number)) + ).scalar_one_or_none() + if product is None: + raise HTTPException(status_code=404, detail=f"未找到序列号为 {serial_number} 的产品") + + # 幂等:已在目标状态则不动(避免重复写日志/节点) + if product.overall_status == target: + return await get_product_by_serial(db, serial_number) + + # 管理员强收口:仍有在产/待接收任务的,一并结束为完工收口,避免宏观口径分裂 + res = await db.execute( + update(Task) + .where(Task.product_id == product.id, Task.status.in_(["WIP", "PENDING"])) + .values( + status="COMPLETED", + received_at=get_beijing_time(), + completed_at=get_beijing_time(), + ) + ) + ended_active = res.rowcount or 0 + + status_code, node_name, action_type = _FINALIZE_MAP[target] + product.overall_status = target + product.status = status_code + product.current_location_id = "virtual_warehouse" + + # task_log 绑目标任务:优先「在库」任务,其次该产品最新任务(对齐 webhook) + log_task = ( + await db.execute( + select(Task) + .where(Task.product_id == product.id, Task.task_name.ilike("%在库%")) + .order_by(Task.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if log_task is None: + log_task = ( + await db.execute( + select(Task).where(Task.product_id == product.id) + .order_by(Task.created_at.desc()) + .limit(1) + ) + ).scalar_one_or_none() + + operator = (current_user or {}).get("username") or "virtual_warehouse" + remark = f"管理员[{operator}]收口为{target}" + if ended_active: + remark += f";一并结束{ended_active}个进行中/待接收任务" + if note and note.strip(): + remark += f";备注:{note.strip()}" + if log_task is not None: + db.add( + TaskLog( + task_id=log_task.id, + operator_id=str(operator)[:64], + action_type=action_type, + remark=remark, + ) + ) + + await _append_warehouse_node(db, product, node_name) + await db.commit() + return await get_product_by_serial(db, serial_number) diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py new file mode 100644 index 0000000..bbeadf6 --- /dev/null +++ b/backend/app/services/product_service.py @@ -0,0 +1,933 @@ +"""产品服务 — 业务逻辑层:扫码查询、CRUD""" +from __future__ import annotations +import uuid +from fastapi import HTTPException, status +from sqlalchemy import select, or_, cast, String, delete, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.lifecycle import ( + ALL_OVERALL_STEPS, + allowed_steps, + is_step_allowed, + phase_label, + resolve_phase_for_step, + sync_product_status, +) +from app.models.product import Product +from app.models.production_order import ProductionOrder +from app.models.task import Task +from app.schemas.product import ProductCreate, ProductUpdate, ProductResponse, ProductScanResponse +from app.schemas.task import TaskSummaryResponse, TaskResponse, TaskRecordResponse + + +def _task_to_response(task: Task) -> TaskResponse: + """将 Task ORM 对象递归转为 TaskResponse(含子任务树)""" + product_sn = "" + product_material = "" + try: + if task.product: + product_sn = task.product.serial_number or "" + product_material = (task.product.material_name or task.product.material_id or "") + except Exception: + pass + return TaskResponse( + id=task.id, + product_id=task.product_id, + product_sn=product_sn, + product_material=product_material, + parent_task_id=task.parent_task_id, + task_name=task.task_name, + assignee_id=task.assignee_id, + status=task.status, + notify_parent_on_complete=task.notify_parent_on_complete, + is_rework=task.is_rework, + task_type=task.task_type, + remark=task.remark, + reject_reason=task.reject_reason, + received_at=task.received_at, + completed_at=task.completed_at, + created_at=task.created_at, + child_tasks=[_task_to_response(c) for c in task.child_tasks], + records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], + created_by=getattr(task, "created_by", None), + ) + + +async def _load_task_tree(db: AsyncSession, product_id: uuid.UUID) -> list[TaskResponse]: + """使用 PostgreSQL Recursive CTE 一次性加载产品下完整任务树(消除 N+1)""" + from app.services.task_tree_loader import load_task_trees_by_product + tasks = await load_task_trees_by_product(db, product_id) + return [_task_to_response(t) for t in tasks] + + +async def get_product_by_serial(db: AsyncSession, serial_number: str) -> ProductScanResponse: + """扫码查询:根据 16 位序列号查出产品 + 所属订单 + 完整任务树""" + result = await db.execute( + select(Product) + .options( + selectinload(Product.order), + selectinload(Product.parent_product), + ) + .where(Product.serial_number == serial_number) + ) + product = result.scalar_one_or_none() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"未找到序列号为 {serial_number} 的产品", + ) + + # 获取顶层任务摘要(兼容旧接口) + top_tasks_result = await db.execute( + select(Task) + .where( + Task.product_id == product.id, + Task.parent_task_id.is_(None), + ) + .order_by(Task.created_at) + ) + top_tasks = top_tasks_result.scalars().all() + + # 获取完整任务树(递归嵌套,供前端渲染十字矩阵树状图) + task_tree = await _load_task_tree(db, product.id) + + # 🔧 收集任务树中所有 assignee_id → 查中文姓名映射 + assignee_ids: set[str] = set() + all_task_ids: list[uuid.UUID] = [] + def _collect_ids(tasks): + for t in tasks: + all_task_ids.append(t.id) + if t.assignee_id: assignee_ids.add(t.assignee_id) + if t.child_tasks: _collect_ids(t.child_tasks) + for t in top_tasks: + all_task_ids.append(t.id) + if t.assignee_id: assignee_ids.add(t.assignee_id) + _collect_ids(task_tree) + + # 🔧 批量查 task_logs: 谁创建了每个任务 + creator_map: dict[uuid.UUID, str] = {} + if all_task_ids: + from app.models.task_log import TaskLog + from sqlalchemy import func + # 每个 task 取最早的 create 日志的 operator_id + 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_(all_task_ids), + TaskLog.action_type == "create" + ) + ).subquery() + log_result = await db.execute( + select(sub.c.task_id, sub.c.operator_id).where(sub.c.rn == 1) + ) + for row in log_result: + if row[1]: + creator_map[row[0]] = row[1] + + # 注入 created_by 到 task_tree 和 top_tasks(并收集 created_by 到中文名映射) + def _inject_creator(tasks): + for t in tasks: + t.created_by = creator_map.get(t.id) + if t.created_by: assignee_ids.add(t.created_by) + if t.child_tasks: + _inject_creator(t.child_tasks) + _inject_creator(task_tree) + for t in top_tasks: + t.created_by = creator_map.get(t.id) + if t.created_by: assignee_ids.add(t.created_by) + + # 🔧 兜底:在库/入库任务无创建日志时,用该产品在库前最近一道主工序的负责人作为「转入人」 + all_mains: list = [] + def _collect_main(tasks): + for t in tasks: + if not t.parent_task_id or t.task_type in ("TRANSFER", "RECOVERY", "WAREHOUSE"): + all_mains.append(t) + if t.child_tasks: + _collect_main(t.child_tasks) + _collect_main(task_tree) + all_mains.sort(key=lambda t: t.created_at) + for t in all_mains: + is_w = t.task_name and ("在库" in t.task_name or "入库" in t.task_name) + if is_w and not t.created_by: + prev = [m for m in all_mains if m.created_at < t.created_at and m.assignee_id] + if prev: + t.created_by = prev[-1].assignee_id + assignee_ids.add(t.created_by) + + # 🔧 在库设备若无「在库」任务,追加虚拟节点(区分"待收货"与"已实收") + def _has_warehouse_task(tasks): + for t in tasks: + if t.task_name and ("在库" in t.task_name or "入库" in t.task_name): + return True + if t.child_tasks and _has_warehouse_task(t.child_tasks): + return True + return False + has_warehouse_task = _has_warehouse_task(task_tree) + if product.current_location_id == "virtual_warehouse" and not has_warehouse_task and all_mains: + from app.schemas.task import TaskResponse + from app.models.task_log import TaskLog as _TL + # 🔧 反查 webhook 入库接收日志:判断 MOM 是否已扫码实收 + inbound_log = ( + await db.execute( + select(_TL) + .join(Task, _TL.task_id == Task.id) + .where( + Task.product_id == product.id, + _TL.action_type == "warehouse_inbound", + ) + .order_by(_TL.created_at.desc()) + .limit(1) + ) + ).scalars().first() + + last_main = all_mains[-1] + + # 🔧 最后操作"转入库"的人 = 该产品最后一次 complete 日志的操作人 + # (完工转交入库会记一条 complete 日志,operator 为发起转入库操作的人, + # 可能是最后工序负责人本人,也可能是代操作的主管) + transfer_log = ( + await db.execute( + select(_TL) + .join(Task, _TL.task_id == Task.id) + .where( + Task.product_id == product.id, + _TL.action_type == "complete", + ) + .order_by(_TL.created_at.desc()) + .limit(1) + ) + ).scalars().first() + last_transfer_operator = transfer_log.operator_id if transfer_log else None + + # 🔧 反查出库日志:产品是否已被 MOM 发货出库 + outbound_log = ( + await db.execute( + select(_TL) + .join(Task, _TL.task_id == Task.id) + .where( + Task.product_id == product.id, + _TL.action_type == "warehouse_outbound", + ) + .order_by(_TL.created_at.desc()) + .limit(1) + ) + ).scalars().first() + + if product.overall_status == "已出库": + # 情况 C:MOM 已发货出库 → 虚拟节点反映"已出库",负责人为出库操作人 + node_name = "已出库" + node_status = "OUTBOUND" + node_assignee = (outbound_log.operator_id if outbound_log else None) or last_transfer_operator or last_main.assignee_id + node_created_by = last_transfer_operator or last_main.assignee_id + elif inbound_log is not None: + # 情况 B:MOM 已扫码实收 → "已入库",负责人为仓库接收人 + node_name = "已入库" + node_status = "ARCHIVED" + node_assignee = inbound_log.operator_id or "仓库" + # "转入在库"始终显示操作转入库的人(车间),而不是 MOM 接收人 + node_created_by = last_transfer_operator or last_main.assignee_id + else: + # 情况 A:MOM 还没扫码 → "已完成"(车间完工待实收),负责人为占位"待仓库扫码" + node_name = "已完成" + node_status = "COMPLETED" + node_assignee = "待仓库扫码" + node_created_by = last_transfer_operator or last_main.assignee_id + + virtual = TaskResponse( + id=uuid.uuid4(), + product_id=product.id, + product_sn=product.serial_number, + product_material=product.material_name or product.material_id or "", + parent_task_id=None, + task_name=node_name, + assignee_id=node_assignee, + status=node_status, + notify_parent_on_complete=False, + is_rework=False, + task_type=None, + remark=None, + reject_reason=None, + received_at=last_main.received_at or last_main.created_at, + completed_at=last_main.completed_at, + created_at=last_main.created_at, + child_tasks=[], + records=[], + created_by=node_created_by, + ) + task_tree.append(virtual) + # 真实 username 负责人(含仓库接收人)加入中文名映射;占位符无需映射 + if virtual.assignee_id and virtual.assignee_id not in ("待仓库扫码", "virtual_warehouse"): + assignee_ids.add(virtual.assignee_id) + if virtual.created_by: + assignee_ids.add(virtual.created_by) + + # 🔧 中文名映射(负责人 + 创建人,供前端显示"谁转入在库"等) + assignee_names = _lookup_display_names(list(assignee_ids)) + + return ProductScanResponse( + id=product.id, + serial_number=product.serial_number, + external_serial=product.external_serial, + order_id=product.order_id, + order_no=product.order.order_no if product.order else "", + material_id=product.material_id, + material_name=product.material_name, + spec_model=product.spec_model, + category=product.category, + material_type=product.material_type, + parent_product_id=product.parent_product_id, + current_location_id=product.current_location_id, + overall_status=product.overall_status, + status=product.status, + lifecycle_phase=product.lifecycle_phase, + created_at=product.created_at, + top_level_tasks=[ + TaskSummaryResponse.model_validate(t) for t in top_tasks + ], + task_tree=task_tree, + assignee_names=assignee_names, # 🔧 username→中文姓名 + ) + + +async def get_product(db: AsyncSession, product_id: uuid.UUID) -> Product: + """获取产品,不存在则 404""" + result = await db.execute( + select(Product) + .options(selectinload(Product.order)) + .where(Product.id == product_id) + ) + product = result.scalar_one_or_none() + if not product: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"产品不存在: {product_id}", + ) + return product + + +async def create_product(db: AsyncSession, data: ProductCreate, creator_username: str = "") -> ProductResponse: + """创建产品 — 自动生成 16 位 HEX 序列号,初始位置设为创建者""" + from app.services.counter_service import ensure_sequence, next_hex_id + from app.models.production_order import ProductionOrder + + await ensure_sequence(db) + hex_id = await next_hex_id(db) + + # 处理订单: 如果传了 order_no 但没传 order_id,查找或创建 + order_id = data.order_id + if not order_id and data.order_no: + result = await db.execute( + select(ProductionOrder).where(ProductionOrder.order_no == data.order_no.strip()) + ) + existing = result.scalar_one_or_none() + if existing: + order_id = existing.id + else: + new_order = ProductionOrder(order_no=data.order_no.strip()) + db.add(new_order) + await db.flush() + order_id = new_order.id + + product = Product( + serial_number=hex_id, + order_id=order_id, + material_id=data.material_id, + material_name=data.material_name or None, + spec_model=data.spec_model or None, + category=data.category or None, + material_type=data.material_type or None, + external_serial=data.external_serial, + parent_product_id=data.parent_product_id, + current_location_id=creator_username or None, # 谁创建,初始位置就是谁 + ) + db.add(product) + await db.commit() + await db.refresh(product, ["order"]) + + # 查创建者的真实姓名 + creator_display_name = "" + if creator_username: + name_map = _lookup_display_names([creator_username]) + creator_display_name = name_map.get(creator_username, "") + + return ProductResponse( + id=product.id, + serial_number=product.serial_number, + external_serial=product.external_serial, + order_id=product.order_id, + order_no=product.order.order_no if product.order else (data.order_no or ""), + material_id=product.material_id, + material_name=product.material_name, + spec_model=product.spec_model, + category=product.category, + material_type=product.material_type, + parent_product_id=product.parent_product_id, + current_location_id=product.current_location_id, + current_location_name=creator_display_name or None, + overall_status=product.overall_status, + status=product.status, + lifecycle_phase=product.lifecycle_phase, + created_at=product.created_at, + ) + + +async def update_product(db: AsyncSession, product_id: uuid.UUID, data: ProductUpdate) -> ProductResponse: + """更新产品""" + from app.models.production_order import ProductionOrder + + product = await get_product(db, product_id) + update_data = data.model_dump(exclude_unset=True) + + # 处理 order_no → order_id 映射 + if "order_no" in update_data: + order_no_val = update_data.pop("order_no") + if order_no_val and order_no_val.strip(): + result = await db.execute( + select(ProductionOrder).where(ProductionOrder.order_no == order_no_val.strip()) + ) + existing = result.scalar_one_or_none() + if existing: + product.order_id = existing.id + else: + new_order = ProductionOrder(order_no=order_no_val.strip()) + db.add(new_order) + await db.flush() + product.order_id = new_order.id + else: + product.order_id = None + + for field, value in update_data.items(): + setattr(product, field, value) + await db.commit() + await db.refresh(product, ["order"]) + return ProductResponse( + id=product.id, + serial_number=product.serial_number, + external_serial=product.external_serial, + order_id=product.order_id, + order_no=product.order.order_no if product.order else "", + material_id=product.material_id, + material_name=product.material_name, + spec_model=product.spec_model, + category=product.category, + material_type=product.material_type, + parent_product_id=product.parent_product_id, + current_location_id=product.current_location_id, + overall_status=product.overall_status, + status=product.status, + lifecycle_phase=product.lifecycle_phase, + created_at=product.created_at, + ) + + +# 全部合法宏观状态(两阶段并集)— 仅作"完全非法取值"的第一道粗筛; +# 阶段内的细分校验(售后回流设备禁止改回「备货 / 生产」)见下方 is_step_allowed +VALID_OVERALL_STATUS = ALL_OVERALL_STEPS + + +async def update_overall_status( + db: AsyncSession, serial_number: str, status_value: str, + current_user: dict | None = None, +) -> ProductScanResponse: + """更新产品宏观状态 + + 权限校验: + - SUPER_ADMIN 角色:直接放行 + - 当前操作该产品主线任务(WIP/PENDING 状态主干任务)的人:放行 + - 其他:403 + """ + if status_value not in VALID_OVERALL_STATUS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无效状态: {status_value},合法值: {'、'.join(sorted(ALL_OVERALL_STEPS))}", + ) + + result = await db.execute( + select(Product) + .options(selectinload(Product.order)) + .where(Product.serial_number == serial_number) + ) + product = result.scalar_one_or_none() + if not product: + raise HTTPException(status_code=404, detail=f"未找到序列号 {serial_number} 的产品") + + # ── 权限校验(无 current_user 一律拒绝,杜绝空 dict 绕过)── + if not current_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录", + ) + + user_role = current_user.get("role", "") + user_username = current_user.get("username", "") + + # SUPER_ADMIN 直接放行 + if user_role != "SUPER_ADMIN": + # 检查当前用户是否是该产品主线任务的负责人 + main_task_result = await db.execute( + select(Task).where( + Task.product_id == product.id, + Task.status.in_(["WIP", "PENDING"]), + or_( + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), + ), + ).order_by(Task.created_at.desc()).limit(1) + ) + main_task = main_task_result.scalar_one_or_none() + has_permission = ( + main_task is not None + and main_task.assignee_id == user_username + ) + if not has_permission: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有 SUPER_ADMIN 或当前操作该产品主线任务的人才能修改宏观状态", + ) + + # ── 选项隔离:阶段感知校验 ── + # 售后回流设备禁止被改回「备货 / 生产」;选定售后专属工序(发货测试 / 售后维修) + # 则设备随即进入售后生命周期(无历史记录的老设备由此进入售后阶段)。 + phase = resolve_phase_for_step(product.lifecycle_phase, status_value) + if not is_step_allowed(phase, status_value): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"产品当前处于{phase_label(product.lifecycle_phase)},不允许改为「{status_value}」。" + f"该阶段可选:{'、'.join(allowed_steps(product.lifecycle_phase))}" + ), + ) + product.lifecycle_phase = phase + product.overall_status = status_value + # 同步 status 字段(映射表已上提到 app.core.lifecycle 单一来源, + # 供 task_service 的各写入点共用,避免各处各写一份) + sync_product_status(product) + await db.commit() + await db.refresh(product) + + return await get_product_by_serial(db, serial_number) + + +def _lookup_display_names(location_ids: list[str]) -> dict[str, str]: + """批量查询 MOM sys_user,将 username 映射为真实姓名(带 2h TTL 缓存)""" + from app.services.mom_cache import get_display_names + return get_display_names(location_ids) + + +async def get_all_products( + db: AsyncSession, + skip: int = 0, + limit: int = 50, + keyword: str | None = None, + status_filter: str | None = None, +) -> list[ProductResponse]: + """ + 获取产品列表 — 支持多维 keyword 搜索 + 状态筛选 + + keyword: 同时模糊匹配 serial_number (产品身份证)、material_name/id (规格型号)、order_no (订单号) + status_filter: 按产品状态过滤 (如 PENDING / WIP / COMPLETED / ARCHIVED) + """ + stmt = select(Product).options(selectinload(Product.order)) + + # keyword 多字段 OR 模糊搜索 + if keyword and keyword.strip(): + kw = f"%{keyword.strip()}%" + stmt = stmt.outerjoin(ProductionOrder, Product.order_id == ProductionOrder.id).where( + or_( + Product.serial_number.ilike(kw), + Product.external_serial.ilike(kw), # 业务序列号(用户自定义) + Product.material_name.ilike(kw), + cast(Product.material_id, String).ilike(kw), + Product.spec_model.ilike(kw), + ProductionOrder.order_no.ilike(kw), + ) + ).distinct() + + # 状态筛选 — 大小写不敏感 + # 口径与 macro_status 完全一致(废弃 Product.status 的恒值判断),业务状态定义: + # COMPLETED = 车间完工待实收 → overall_status == '待仓库收货' + # ARCHIVED = 仓库已实收 → overall_status == '已入库'('在库' 为旧命名,等价) + if status_filter and status_filter.strip(): + from sqlalchemy import func, and_, exists + sf = status_filter.strip().upper() + + # 最后一条任务(created_at 最新)状态为 COMPLETED 的产品子查询(兼容旧数据用) + ranked = ( + select( + Task.product_id, Task.status, + func.row_number().over( + partition_by=Task.product_id, + order_by=Task.created_at.desc(), + ).label("rn"), + ).subquery("sf_latest_task") + ) + latest_completed_ids = select(ranked.c.product_id).where( + ranked.c.rn == 1, ranked.c.status == "COMPLETED", + ) + + archived_cond = Product.overall_status.in_(["已入库", "在库"]) + completed_cond = or_( + Product.overall_status == "待仓库收货", + # 兼容旧数据:无定位(current_location_id IS NULL)且最后一条任务已完成 + and_( + Product.current_location_id.is_(None), + Product.id.in_(latest_completed_ids), + ), + ) + # 未进入"完结"态(NULL 视为未完结,避免三值逻辑误过滤) + not_finished = or_( + Product.overall_status.is_(None), + ~Product.overall_status.in_(["待仓库收货", "已入库", "在库"]), + ) + + def _has_task_status(task_status: str): + """存在指定状态任务 且 未完结的 EXISTS 谓词""" + return exists( + select(Task.id).where(Task.product_id == Product.id, Task.status == task_status) + ) + + if sf == "DONE": + # "已完成/已入库" = 待仓库收货(COMPLETED) 或 已入库(ARCHIVED) + stmt = stmt.where(or_(archived_cond, completed_cond)) + elif sf == "ARCHIVED": + # 已入库 → ARCHIVED + stmt = stmt.where(archived_cond) + elif sf == "COMPLETED": + # 待仓库收货 → COMPLETED(含旧的无定位已完成数据) + stmt = stmt.where(completed_cond) + elif sf == "WIP": + stmt = stmt.where(not_finished, _has_task_status("WIP")) + elif sf == "PENDING": + stmt = stmt.where(not_finished, _has_task_status("PENDING")) + elif sf == "PENDING_ASSIGNED": + # 存在已分配(等待扫码)的待接收任务 + stmt = stmt.where(not_finished, exists( + select(Task.id).where( + Task.product_id == Product.id, + Task.status == "PENDING", + Task.assignee_id.isnot(None), + ) + )) + else: + # 兜底:其他状态码按"存在该状态任务"匹配(未完结) + stmt = stmt.where(not_finished, _has_task_status(sf)) + + stmt = stmt.offset(skip).limit(limit).order_by(Product.created_at.desc()) + + result = await db.execute(stmt) + products = result.scalars().all() + + # 🔧 批量预计算 macro_status:一次性查出所有产品关联的任务状态 + product_ids = [p.id for p in products] + macro_map: dict[uuid.UUID, str] = {} + if product_ids: + from sqlalchemy import case, func as sa_func + task_stmt = ( + select( + Task.product_id, + sa_func.max(case( + (Task.status == "WIP", 3), + (Task.status == "PENDING", 2), + (Task.status == "REJECTED", 2), + (Task.status == "COMPLETED", 1), + (Task.status == "ARCHIVED", 1), + else_=0, + )).label("prio"), + ) + .where(Task.product_id.in_(product_ids)) + .group_by(Task.product_id) + ) + task_result = await db.execute(task_stmt) + prio_to_status = {3: "WIP", 2: "PENDING", 1: "COMPLETED", 0: None} + for row in task_result: + macro_map[row[0]] = prio_to_status.get(row[1], None) + + # 🔧 每个产品最后一条任务(created_at 最新)的状态 → 用于"已完成"判定 + last_task_status_map: dict[uuid.UUID, str] = {} + if product_ids: + from sqlalchemy import func as sa_func + ranked = ( + select( + Task.product_id, Task.status, + sa_func.row_number().over( + partition_by=Task.product_id, + order_by=Task.created_at.desc(), + ).label("rn"), + ) + .where(Task.product_id.in_(product_ids)) + .subquery("last_task") + ) + last_result = await db.execute( + select(ranked.c.product_id, ranked.c.status).where(ranked.c.rn == 1) + ) + for row in last_result: + last_task_status_map[row[0]] = row[1] + + # 🔧 动态主干状态名:只从主干任务中获取最高优先级任务的 task_name(宏观状态名) + overall_names: dict[uuid.UUID, str] = {} + # 🔧 【当前工序】专用:仅「活跃主干任务」的工序名(见下方 main_stmt 循环填充) + active_step_map: dict[uuid.UUID, str] = {} + if product_ids: + from sqlalchemy import and_, func as sa_func, case as sa_case + main_where = and_( + Task.product_id.in_(product_ids), + or_( + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY", "WAREHOUSE"]), + ), + ) + prio_expr = sa_case( + (Task.status == "WIP", 3), + (Task.status == "PENDING", 2), + (Task.status == "COMPLETED", 1), + else_=0, + ) + max_prio = ( + select(Task.product_id, sa_func.max(prio_expr).label("prio")) + .where(main_where) + .group_by(Task.product_id) + ).subquery("mp") + main_stmt = ( + select(Task.product_id, Task.task_name, Task.status) + .join(max_prio, and_( + Task.product_id == max_prio.c.product_id, + prio_expr == max_prio.c.prio, + )) + .where(main_where) + .order_by(Task.product_id, Task.created_at.desc()) + .distinct(Task.product_id) + ) + main_result = await db.execute(main_stmt) + for row in main_result: + overall_names[row[0]] = row[1] + # 🔧 供【当前工序】专用:只收【活跃】主干任务的工序名。 + # overall_names 保持原语义不动(它连 COMPLETED 的任务也收, + # 是"最新主干任务名",被 overall_status 复用,改动影响面太大)。 + if row[2] in ("WIP", "PENDING"): + active_step_map[row[0]] = row[1] + + # 🔧 当前位置:汇总所有活跃任务(WIP/PENDING,不分主线/分支)的负责人,去重保序 + active_assignees_map: dict[uuid.UUID, list[str]] = {} + if product_ids: + active_stmt = ( + select(Task.product_id, Task.assignee_id) + .where( + Task.product_id.in_(product_ids), + Task.status.in_(["WIP", "PENDING"]), + Task.assignee_id.isnot(None), + ) + .order_by(Task.product_id, Task.created_at) + ) + active_result = await db.execute(active_stmt) + for row in active_result: + pid, assignee = row[0], row[1] + lst = active_assignees_map.setdefault(pid, []) + if assignee not in lst: + lst.append(assignee) + + # 🔧 活跃负责人 + 静态位置(兜底)的 username → 中文姓名(一次批量查) + all_active_ids = [uid for ids in active_assignees_map.values() for uid in ids] + static_location_ids = [p.current_location_id for p in products if p.current_location_id] + merged_location_ids = list(set(all_active_ids + static_location_ids)) + merged_name_map = _lookup_display_names(merged_location_ids) + + # 🔧 批量查询每个产品活跃任务的最新记录(含操作人 assignee_id) + latest_record_map: dict[uuid.UUID, tuple] = {} + if product_ids: + from app.models.task import TaskRecord as TR + wip_pending_ids = select(Task.id).where( + and_( + Task.product_id.in_(product_ids), + Task.status.in_(["WIP", "PENDING"]), + ) + ).subquery() + ranked = ( + select(TR.task_id, TR.remark, TR.images, TR.created_at, Task.product_id, Task.assignee_id, + sa_func.row_number().over( + partition_by=Task.product_id, + order_by=TR.created_at.desc() + ).label("rn")) + .join(Task, TR.task_id == Task.id) + .where(Task.id.in_(select(wip_pending_ids.c.id))) + ).subquery() + rec_result = await db.execute( + select(ranked.c.product_id, ranked.c.created_at, ranked.c.remark, ranked.c.images, ranked.c.assignee_id) + .where(ranked.c.rn == 1) + ) + for row in rec_result: + has_img = bool(row[3] and row[3] != "[]" and row[3] != "null") + latest_record_map[row[0]] = (row[1], row[2], has_img, row[4]) + + # 🔧 当前人滞留时长:每个产品活跃任务(WIP/PENDING)最早接手时间 → 小时(排除非工作日) + active_duration_map: dict[uuid.UUID, float] = {} + if product_ids: + from sqlalchemy import func as sa_func + from app.core.time_utils import get_beijing_time, to_beijing, working_duration_hours + from app.models.holiday import Holiday + hres = await db.execute(select(Holiday.day)) + holidays = {r[0] for r in hres} + start_stmt = ( + select( + Task.product_id, + sa_func.min(sa_func.coalesce(Task.received_at, Task.created_at)), + ) + .where( + Task.product_id.in_(product_ids), + Task.status.in_(["WIP", "PENDING"]), + ) + .group_by(Task.product_id) + ) + start_result = await db.execute(start_stmt) + now = get_beijing_time() + for row in start_result: + start = row[1] + if start is None: + continue + start_bj = to_beijing(start) # 🚀 naive 按 UTC 转北京时间(修复多算8小时) + active_duration_map[row[0]] = working_duration_hours(start_bj, now, holidays) + + # 🔧 生产总天数(自然天 + 工作日):自创建至今 + import math + from app.core.time_utils import get_beijing_time as _gbt, to_beijing as _tb, working_duration_hours as _wdh + from app.models.holiday import Holiday as _Holiday + hres2 = await db.execute(select(_Holiday.day)) + holidays2 = {r[0] for r in hres2} + now2 = _gbt() + + def _prod_days(created_at): + created = _tb(created_at) + if not created: + return 1, 1 + natural = max(1, math.ceil((now2 - created).total_seconds() / 86400)) + work_hours = _wdh(created, now2, holidays2) + workdays = max(1, math.ceil(work_hours / 24)) + return natural, workdays + + production_days_map: dict = {} + for _p in products: + production_days_map[_p.id] = _prod_days(_p.created_at) + + def _resolve_macro_status(p: Product) -> str: + """宏观状态 — 以 overall_status 为核心的状态定义(用户确认): + - ARCHIVED(已入库): overall_status == '已入库'('在库' 为旧命名,等价) + - COMPLETED(已完成): overall_status == '待仓库收货'; + 兼容旧数据:无定位(current_location_id IS NULL)且最后一条任务 COMPLETED + - 其余: 沿用原 WIP/PENDING 任务优先级;无任何任务的产品 → PENDING + """ + if p.overall_status in ("已入库", "在库"): + return "ARCHIVED" + if p.overall_status == "已出库": + return "OUTBOUND" + if p.overall_status == "待仓库收货": + return "COMPLETED" + if last_task_status_map.get(p.id) == "COMPLETED" and p.current_location_id is None: + return "COMPLETED" + return macro_map.get(p.id) or "PENDING" + + # 宏观终态 —— 表达的是"货在哪",不是"在做什么"。 + # 这类值即使当前没有活跃任务也必须照常展示(业务要求保留物理标识)。 + _TERMINAL_OVERALL = ("待仓库收货", "已入库", "在库", "已出库") + + def _resolve_current_step(p: Product) -> str: + """【当前工序】—— 只反映"此刻在做什么",绝不拿已完结的历史工序冒充。 + + · 有活跃主干任务(WIP/PENDING) → 该任务工序名 + · 否则产品处于宏观终态 → 该终态("货在哪",保留) + · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」) + + 历史缺陷:ProductResponse.overall_status 会被"最新主干任务名"覆盖 + (见上方 overall_names),于是已 COMPLETED 的「扫码出库 / 测试 / 发货测试」 + 会长期冒充"当前工序",误导管理者以为活还在干。 + """ + raw = (p.overall_status or "").strip() + if not raw: + return "" + if raw in _TERMINAL_OVERALL: + return raw + return active_step_map.get(p.id, "") + + return [ + ProductResponse( + id=p.id, + serial_number=p.serial_number, + external_serial=p.external_serial, + order_id=p.order_id, + order_no=p.order.order_no if p.order else "", + material_id=p.material_id, + material_name=p.material_name, + spec_model=p.spec_model, + category=p.category, + material_type=p.material_type, + parent_product_id=p.parent_product_id, + current_location_id=( + ",".join(active_assignees_map.get(p.id, [])) or p.current_location_id + ), + current_location_name=( + ", ".join(merged_name_map.get(uid, uid) for uid in active_assignees_map.get(p.id, [])) + if active_assignees_map.get(p.id) + else ("仓库" if p.current_location_id == "virtual_warehouse" + else merged_name_map.get(p.current_location_id) if p.current_location_id else None) + ), + macro_status=_resolve_macro_status(p), + overall_status=overall_names.get(p.id) or p.overall_status, + current_step=_resolve_current_step(p), + status=p.status, + lifecycle_phase=p.lifecycle_phase, + created_at=p.created_at, + latest_record_time=latest_record_map.get(p.id, (None, None, False, None))[0], + latest_record_content=latest_record_map.get(p.id, (None, None, False, None))[1], + latest_record_has_images=latest_record_map.get(p.id, (None, None, False, None))[2], + latest_record_assignee_id=latest_record_map.get(p.id, (None, None, False, None))[3], + latest_record_assignee_name=( + merged_name_map.get(latest_record_map.get(p.id, (None, None, False, None))[3]) + if latest_record_map.get(p.id, (None, None, False, None))[3] else None + ), + active_duration_hours=active_duration_map.get(p.id), + production_days=production_days_map.get(p.id, (1, 1))[0], + production_days_workdays=production_days_map.get(p.id, (1, 1))[1], + ) + for p in products + ] + + +async def delete_product(db: AsyncSession, product_id: uuid.UUID) -> None: + """删除产品及其关联任务""" + product = await get_product(db, product_id) + + from app.models.task import TaskRecord + from app.models.task_log import TaskLog + + # 🚀 1. 切断产品自引用:子产品的 parent_product_id 置空 + await db.execute( + update(Product).where(Product.parent_product_id == product_id).values(parent_product_id=None) + ) + + # 2. 查询所有关联任务 + tasks_result = await db.execute( + select(Task).where(Task.product_id == product_id) + ) + tasks = tasks_result.scalars().all() + + # 🚀 3. 切断任务自引用:子任务的 parent_task_id 置空 + for task in tasks: + await db.execute( + update(Task).where(Task.parent_task_id == task.id).values(parent_task_id=None) + ) + + # 4. 删除任务记录、日志、任务本身 + for task in tasks: + await db.execute(delete(TaskRecord).where(TaskRecord.task_id == task.id)) + await db.execute(delete(TaskLog).where(TaskLog.task_id == task.id)) + await db.delete(task) + + # 5. 删除产品(product_messages 有 ON DELETE CASCADE 自动级联) + await db.delete(product) + await db.commit() diff --git a/backend/app/services/qrcode_service.py b/backend/app/services/qrcode_service.py new file mode 100644 index 0000000..edc80ba --- /dev/null +++ b/backend/app/services/qrcode_service.py @@ -0,0 +1,34 @@ +"""二维码生成服务 — 参考 MOM 系统 label_service.py 的 QR 生成逻辑""" +import io +import qrcode +from qrcode.image.pil import PilImage + + +def generate_qrcode_png(content: str, size_px: int = 300) -> io.BytesIO: + """ + 生成二维码 PNG 图片,返回 BytesIO 流。 + + 参数: + content: 二维码内容(如 16 位序列号) + size_px: 输出图片尺寸(像素),默认 300×300 + + 返回: + io.BytesIO: PNG 格式的图片字节流 + """ + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=2, + ) + qr.add_data(content) + qr.make(fit=True) + + img: PilImage = qr.make_image(fill_color="black", back_color="white") + img = img.convert("RGB") + img = img.resize((size_px, size_px)) + + buf = io.BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + return buf diff --git a/backend/app/services/screen_service.py b/backend/app/services/screen_service.py new file mode 100644 index 0000000..9dd8e98 --- /dev/null +++ b/backend/app/services/screen_service.py @@ -0,0 +1,254 @@ +"""大屏统计服务 — 面向管理层**日常运营与督导**的轻量只读聚合 + +设计约定: + - 只读,无写操作,字段扁平,便于大屏高频轮询。 + - 时间一律按**北京时间**判定;DB 列为 timestamptz(实存 UTC), + 比较前统一把边界换算成 UTC,避免月初/凌晨的边界漂移。 + - 口径与 dashboard_service.get_dashboard_stats 保持一致: + 未完结 = overall_status 不属于 {待仓库收货, 已入库, 在库, 已出库}。 + +视角说明: + 管理层每天要看的是「这个月干得怎么样、现在卡在哪、系统有没有在跑」, + 而不是历史品质排名。故本模块聚焦三件事 —— 当月吞吐、当前卡点、使用活跃度。 +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from pydantic import BaseModel +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.lifecycle import ( + AFTER_SALES_ONLY_STEPS, + LIFECYCLE_AFTER_SALES, + LIFECYCLE_PRODUCTION, +) +from app.core.time_utils import get_beijing_time +from app.models.product import Product +from app.models.task import Task +from app.models.task_log import TaskLog + +# 已完结的宏观状态 —— 与 dashboard_service.get_dashboard_stats 同口径 +FINISHED_OVERALL = ("待仓库收货", "已入库", "在库", "已出库") + +# 仓储动作日志类型(由 webhooks / product_finalize_service 写入) +LOG_WAREHOUSE_INBOUND = "warehouse_inbound" +LOG_WAREHOUSE_OUTBOUND = "warehouse_outbound" + + +def _not_finished(): + """未完结条件 —— overall_status 为 NULL 或不在已完结集合内。 + + 注意 PostgreSQL 中 `NULL NOT IN (...)` 结果为 NULL 而非 TRUE, + 必须显式带上 IS NULL 分支,否则建单后未流转的设备会被整批漏掉。 + """ + return or_( + Product.overall_status.is_(None), + ~Product.overall_status.in_(FINISHED_OVERALL), + ) + + +def _month_bounds() -> tuple[datetime, datetime, str]: + """返回 (本月起点 UTC, 当前时刻 UTC, 'YYYY-MM')。 + + 本月起点 = 北京时间当月 1 日 00:00 —— 直接换算成 UTC 参与 timestamptz 比较, + 不依赖数据库会话时区设置。 + """ + now_bj = get_beijing_time() + month_start_bj = now_bj.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + return ( + month_start_bj.astimezone(timezone.utc), + now_bj.astimezone(timezone.utc), + month_start_bj.strftime("%Y-%m"), + ) + + +# ============================================================ +# 1. 当月吞吐指标 +# ============================================================ + +class MonthlyMetrics(BaseModel): + """大屏顶部四张数字卡(当月视角)""" + month: str # "2026-09" + month_start: str # "2026-09-01"(北京时间) + month_production: int # 本月有流转记录或新建的生产态设备数 + month_inbound: int # 本月扫码入库数 + month_outbound: int # 本月扫码出库数 + month_returned: int # 本月进入售后/回流状态的设备数 + + +async def get_monthly_metrics(db: AsyncSession) -> MonthlyMetrics: + """当月吞吐四联指标。 + + 口径: + - month_production:lifecycle_phase = PRODUCTION,且「本月新建」或 + 「本月产生过任意 TaskLog 流转记录」的设备数(按设备去重)。 + 反映这个月实际被推着走的机器有多少。 + - month_inbound / month_outbound:task_logs 中 action_type = + warehouse_inbound / warehouse_outbound 的记录数(MOM 扫码回调写入)。 + - month_returned:本月创建过售后专属工序任务(发货测试 / 售后维修)的 + 设备数(去重)。Product 表无 updated_at,无法直接查「转为 AFTER_SALES + 的时刻」,故以售后工序任务的创建时间作为进入售后阶段的时间锚点。 + """ + month_start_utc, now_utc, month_label = _month_bounds() + + # 本月该设备产生过任意流转日志 + has_activity = ( + select(TaskLog.id) + .join(Task, Task.id == TaskLog.task_id) + .where( + Task.product_id == Product.id, + TaskLog.created_at >= month_start_utc, + ) + .exists() + ) + month_production = await db.scalar( + select(func.count(func.distinct(Product.id))).where( + Product.lifecycle_phase == LIFECYCLE_PRODUCTION, + or_(Product.created_at >= month_start_utc, has_activity), + ) + ) or 0 + + async def _count_log(action_type: str) -> int: + return await db.scalar( + select(func.count(TaskLog.id)).where( + TaskLog.action_type == action_type, + TaskLog.created_at >= month_start_utc, + ) + ) or 0 + + month_inbound = await _count_log(LOG_WAREHOUSE_INBOUND) + month_outbound = await _count_log(LOG_WAREHOUSE_OUTBOUND) + + month_returned = await db.scalar( + select(func.count(func.distinct(Task.product_id))) + .join(Product, Task.product_id == Product.id) + .where( + Product.lifecycle_phase == LIFECYCLE_AFTER_SALES, + Task.task_name.in_(tuple(AFTER_SALES_ONLY_STEPS)), + Task.created_at >= month_start_utc, + ) + ) or 0 + + return MonthlyMetrics( + month=month_label, + month_start=f"{month_label}-01", + month_production=int(month_production), + month_inbound=int(month_inbound), + month_outbound=int(month_outbound), + month_returned=int(month_returned), + ) + + +# ============================================================ +# 2. 工序积压分布(柱状图) +# ============================================================ + +class WipStage(BaseModel): + stage: str # 工序名(中文,直接作为图表类目) + phase: str # PRODUCTION / AFTER_SALES,供前端分区着色 + count: int + + +class WipDistributionResponse(BaseModel): + total: int # 未完结设备总数 + items: list[WipStage] + + +# 阶段与流转顺序 —— 顺序即「工序先后」,前端据此排布柱子 +WIP_STAGES: tuple[tuple[str, str], ...] = ( + ("待启动", LIFECYCLE_PRODUCTION), # overall_status 为空:建单后尚未流转 + ("备货", LIFECYCLE_PRODUCTION), + ("生产", LIFECYCLE_PRODUCTION), + ("测试", LIFECYCLE_PRODUCTION), + ("维修", LIFECYCLE_PRODUCTION), + ("发货测试", LIFECYCLE_AFTER_SALES), + ("售后维修", LIFECYCLE_AFTER_SALES), +) + +# 「待启动」对应的真实分组键(overall_status IS NULL / 空串) +_UNSTARTED_KEY = "" + + +async def get_wip_distribution(db: AsyncSession) -> WipDistributionResponse: + """工序积压分布 —— 当前未完结设备按 overall_status 聚合的数量。 + + 返回**固定阶段列表**(含 0 值),保证大屏布局稳定、柱子不因某天缺数据而 + 整根消失;同时把数据里出现但不在词表内的状态追加在末尾,确保 items 的 + count 之和恒等于 total(避免统计悄悄漏数)。 + """ + rows = await db.execute( + select(Product.overall_status, func.count(Product.id)) + .where(_not_finished()) + .group_by(Product.overall_status) + ) + counts: dict[str, int] = {} + for raw_status, cnt in rows.all(): + key = (raw_status or "").strip() + counts[key] = counts.get(key, 0) + cnt + + total = sum(counts.values()) + + items: list[WipStage] = [] + for stage, phase in WIP_STAGES: + key = _UNSTARTED_KEY if stage == "待启动" else stage + items.append(WipStage(stage=stage, phase=phase, count=counts.get(key, 0))) + + known_keys = {_UNSTARTED_KEY if s == "待启动" else s for s, _ in WIP_STAGES} + for key, cnt in counts.items(): + if key not in known_keys and cnt: + items.append(WipStage( + stage=key or "待启动", + phase=LIFECYCLE_PRODUCTION, + count=cnt, + )) + + return WipDistributionResponse(total=total, items=items) + + +# ============================================================ +# 3. 系统使用活跃度(本月人员排行) +# ============================================================ + +class ActiveUser(BaseModel): + user_id: str + user_name: str + receive_count: int # 接收 + transfer_count: int # 转交 + record_count: int # 上传备注 + total: int + + +class ActiveUsersResponse(BaseModel): + month: str + items: list[ActiveUser] + + +async def get_active_users(db: AsyncSession, top_n: int = 5) -> ActiveUsersResponse: + """本月系统活跃度排行 —— 直接桥接 /dashboard/user-operations 的口径。 + + 不重复实现聚合逻辑:转交/接收取 task_logs(action_type = receive / complete), + 上传备注取 task_records(排除系统自动备注)。仅返回本月**确实有操作**的人员, + 避免排行榜被一串 0 稀释 —— 领导要看到的是"系统真的有人在用"。 + """ + from app.services.dashboard_service import get_user_operations + + month_start_utc, _now, month_label = _month_bounds() + operations = await get_user_operations(db, since=month_start_utc, until=None) + active = [op for op in operations if op.total > 0][:top_n] + + return ActiveUsersResponse( + month=month_label, + items=[ + ActiveUser( + user_id=op.user_id, + user_name=op.user_name, + receive_count=op.receive_count, + transfer_count=op.transfer_count, + record_count=op.record_count, + total=op.total, + ) + for op in active + ], + ) diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..6da7220 --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,1402 @@ +"""任务服务 — 核心业务逻辑:接收、驳回返工、裂变转交、无限嵌套子任务""" +from __future__ import annotations +import json +import uuid +from fastapi import HTTPException, status +from sqlalchemy import select, delete, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models.task import Task, TaskRecord, TASK_STATUS_PENDING, TASK_STATUS_WIP, TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, TASK_STATUS_ARCHIVED +from app.models.notification import Notification, NOTIFY_TRANSFER, NOTIFY_REJECT +from app.core.time_utils import get_beijing_time +from app.core.lifecycle import ( + AFTER_SALES_REPAIR_STEPS, + LIFECYCLE_AFTER_SALES, + PRODUCTION_ONLY_STEPS, + allowed_steps, + is_step_allowed, + phase_label, + resolve_phase_for_step, + sync_product_status, +) +from app.models.product import Product +from app.models.task_log import TaskLog +from app.core.roles import ADMIN_ROLES +from app.schemas.task import ( + TaskCreate, + TaskUpdate, + TaskCompleteRequest, + TaskRejectRequest, + TaskTransferRequest, + SubtaskCreate, + TaskRecordCreate, + TaskRecordResponse, + TaskResponse, + TaskCompleteResponse, + TaskTransferResponse, + TaskSummaryResponse, + TaskListResponse, +) + +# 特殊位置常量 +VIRTUAL_WAREHOUSE = "virtual_warehouse" + +# 管理员/主管角色白名单 — 拥有上帝视角操作权限 +# 定义已收敛到 app.core.roles(单一事实来源);本模块继续以同名导出, +# 兼容 products.py 等处 `from app.services.task_service import ADMIN_ROLES` 的既有引用 + + +async def _recalc_product_location( + db: AsyncSession, product_id: uuid.UUID, completed_task_id: uuid.UUID | None = None, +) -> None: + """ + 任务完工/结束时触发:只跟随主干任务(主分支),无视协助分支。 + + 主干任务定义: + parent_task_id IS NULL OR task_type IN ('TRANSFER', 'RECOVERY') + + 优先级: + WIP > PENDING > COMPLETED/ARCHIVED > None + """ + from sqlalchemy import select as sa_select, case as sa_case, or_ as sa_or_ + + product_result = await db.execute( + sa_select(Product).where(Product.id == product_id) + ) + product = product_result.scalar_one_or_none() + if not product: + return + + # ── 只查主干任务: parent_task_id IS NULL 或 task_type IN (TRANSFER, RECOVERY) ── + stmt = ( + sa_select(Task) + .where( + Task.product_id == product_id, + sa_or_( + Task.parent_task_id.is_(None), + Task.task_type.in_(["TRANSFER", "RECOVERY"]), + ), + ) + .order_by( + # 优先级排序: WIP=3, PENDING=2, COMPLETED=1, ARCHIVED=1, else=0 + sa_case( + (Task.status == TASK_STATUS_WIP, 3), + (Task.status == TASK_STATUS_PENDING, 2), + (Task.status == TASK_STATUS_COMPLETED, 1), + (Task.status == TASK_STATUS_ARCHIVED, 1), + else_=0, + ).desc(), + Task.created_at.desc(), + ) + .limit(1) + ) + result = await db.execute(stmt) + main_task = result.scalar_one_or_none() + + # 上面的排序把 WIP(3) > PENDING(2) 放最前,所以取到的这条即最高优先级: + # 它是 WIP/PENDING 说明产品仍有活跃主线任务,否则视为闲置。 + has_active_main = main_task is not None and main_task.status in ( + TASK_STATUS_WIP, TASK_STATUS_PENDING, + ) + + new_location = main_task.assignee_id if main_task else None + + # 🚚 「货发走就离场」——已出库且闲置的设备,厂内不再有它的位置。 + # 直接完结后若继续停在最后经手人名下(或残留 virtual_warehouse), + # 列表里会与「已出库」自相矛盾。置空后前端统一显示「—」。 + # ⚠️ 只清「已出库」:已入库 / 在库 的设备确实还在仓库里,位置必须保留。 + if product.overall_status == "已出库" and not has_active_main: + new_location = None + + if product.current_location_id != new_location: + product.current_location_id = new_location + await db.flush() # 唯一的落盘点 + + +# 绝对物理终态 — 描述「设备此刻物理上在哪」的宏观状态,由 MOM 仓储/发货回调驱动。 +# 接收 / 派发 / 转交任务时【禁止】用任务名覆写它们:否则「已出库」会被 +# 「发货测试」这类工序名静默抹掉,设备在按终态口径统计的报表里就不再是已出库 +# (WIP 矩阵因为优先看活跃任务而看不出问题,但全局概览 / 大屏会把它移出已完结)。 +# +# ⚠️ 刻意不含「待仓库收货」:那是「车间完工待实收」的过渡态,设备随后仍会被 +# 重新派活,宏观状态理应随工序更新。 +PHYSICAL_TERMINAL_OVERALL = ("已入库", "在库", "已出库") + + +def _is_physical_terminal(overall: str | None) -> bool: + """该宏观状态是否为「绝对物理终态」(不应被任务名覆写)""" + return (overall or "").strip() in PHYSICAL_TERMINAL_OVERALL + + +async def _mark_after_sales_if_reactivated( + db: AsyncSession, product_id: uuid.UUID, + steps: str | list[str | None] | None = None, +) -> bool: + """出库后又接到【明确修机指令】= 设备回流返厂 → 生命周期切到 AFTER_SALES。 + + 判定依据(必须同时满足): + 1. 产品当前处于「已出库」终态(overall_status == '已出库' 或 status == 'OUTBOUND') + 2. 本次接到的工序是明确修机指令(售后维修) + + ⚠️ 出厂质检(发货测试)【不】触发。设备可能压根没回厂,只是补做发货前测试。 + 历史缺陷:两者混为一谈,导致「已出库 + 发货测试」被打上不可回退的 + AFTER_SALES 烙印(售后死锁),且设备的物理终态「已出库」被工序名抹掉。 + + 该标志单向:一旦进入 AFTER_SALES 不再回退,这样前端就能把 + 生产阶段的「发货测试」与回流后的「售后维修」区分开。 + + 调用时机必须早于调用方改写 overall_status,否则会漏判。 + + :param steps: 本次接到的工序名;转交场景可能多分支,故接受列表。 + 返回是否发生了翻转。product 已在本 session 加载时 db.get 直接命中 + identity map,不产生额外查询。 + """ + product = await db.get(Product, product_id) + if product is None or product.lifecycle_phase == LIFECYCLE_AFTER_SALES: + return False + + names = [steps] if isinstance(steps, str) else [s for s in (steps or []) if s] + if not any(n.strip() in AFTER_SALES_REPAIR_STEPS for n in names): + return False + + is_outbound = ( + product.overall_status == "已出库" + or (product.status or "").upper() == "OUTBOUND" + ) + if not is_outbound: + return False + + product.lifecycle_phase = LIFECYCLE_AFTER_SALES + await db.flush() + return True + + +def _enforce_step_isolation( + product: Product, step: str | None, *, action: str, +) -> None: + """选项隔离守卫 — 售后回流设备禁止被重新排产回「备货 / 生产」等前期工序。 + + 行为两步: + 1. 选定售后专属工序(发货测试 / 售后维修)→ 设备随即进入售后生命周期。 + 这是「无历史记录的老设备」进入售后阶段的入口。 + 2. 再校验该工序在当前阶段是否合法,非法直接 400 —— 前端下拉被绕过、 + 请求被伪造时,仍在此拦下。 + + 必须在 _mark_after_sales_if_reactivated 之后调用,这样出库回流的设备 + 已处于 AFTER_SALES,自然排不回前期工序。 + + 注意:「待确认」与仓库虚拟节点属建单占位符,由 is_step_allowed 直接放行, + 否则建单 / 转交入库整条链路会被卡死。 + """ + phase = resolve_phase_for_step(product.lifecycle_phase, step) + if not is_step_allowed(phase, step): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"产品当前处于{phase_label(phase)},{action}不允许使用工序「{step}」。" + f"该阶段可选:{'、'.join(allowed_steps(phase))}" + ), + ) + if phase != product.lifecycle_phase: + product.lifecycle_phase = phase + + +def _reject_cross_phase_steps( + phase: str | None, steps: list[str | None], *, action: str, +) -> None: + """跨阶段工序拦截 — 用于「转交」这类自由文本工序名路径。 + + 转交对话框允许用户手打工序名(喷漆 / 老化 / 待确认…),所以不能用 + 白名单,否则会误伤合法命名;但"售后回流设备被转交到【生产】"必须挡住。 + 因此这里只拒绝**生产阶段专属**词(备货 / 生产 / 测试 / 维修)。 + + 「在库 / 已入库 / 已出库」两阶段通用,占位符同样放行。 + """ + if (phase or "") != LIFECYCLE_AFTER_SALES: + return + bad = sorted({ + s.strip() for s in steps + if s and s.strip() in PRODUCTION_ONLY_STEPS + }) + if bad: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"产品当前处于{phase_label(LIFECYCLE_AFTER_SALES)},{action}不允许使用前期工序:" + f"{'、'.join(bad)}。该阶段可选:{'、'.join(allowed_steps(LIFECYCLE_AFTER_SALES))}" + ), + ) + + +def _check_permission(task_assignee_id: str | None, operator_id: str | None, operator_role: str | None = None) -> None: + """权限校验:本人 或 管理员/主管 可操作""" + if operator_role and operator_role in ADMIN_ROLES: + return # 上帝视角,直接放行 + if operator_id and task_assignee_id and operator_id != task_assignee_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"您无权操作此任务,当前任务负责人为 {task_assignee_id}", + ) + + +# ============================================================ +# 内部辅助函数 +# ============================================================ + +async def _get_task_or_404(db: AsyncSession, task_id: uuid.UUID) -> Task: + """获取任务,不存在则 404""" + result = await db.execute( + select(Task) + .options( + selectinload(Task.child_tasks), + selectinload(Task.parent_task), + selectinload(Task.product), + selectinload(Task.records), + ) + .where(Task.id == task_id) + ) + task = result.scalar_one_or_none() + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"任务不存在: {task_id}", + ) + return task + + +async def _get_task_with_children_recursive(db: AsyncSession, task_id: uuid.UUID) -> Task: + """使用 PostgreSQL Recursive CTE 一次性加载任务及其所有子孙任务(消除 N+1)""" + from app.services.task_tree_loader import load_task_tree_by_root + return await load_task_tree_by_root(db, task_id) + + +def _to_flat_response(task: Task) -> TaskResponse: + """扁平序列化,不递归 children(避免 MissingGreenlet)""" + product_sn = "" + product_material = "" + try: + if task.product: + product_sn = task.product.serial_number or "" + product_material = (task.product.material_name or task.product.material_id or "") + except Exception: + pass + return TaskResponse( + id=task.id, + product_id=task.product_id, + product_sn=product_sn, + product_material=product_material, + parent_task_id=task.parent_task_id, + task_name=task.task_name, + assignee_id=task.assignee_id, + status=task.status, + notify_parent_on_complete=task.notify_parent_on_complete, + is_rework=task.is_rework, + task_type=task.task_type, + remark=task.remark, + reject_reason=task.reject_reason, + received_at=task.received_at, + completed_at=task.completed_at, + created_at=task.created_at, + child_tasks=[], + records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], + ) + + +def _to_response(task: Task) -> TaskResponse: + """将 Task ORM 对象转为递归 TaskResponse""" + product_sn = "" + product_material = "" + try: + if task.product: + product_sn = task.product.serial_number or "" + product_material = (task.product.material_name or task.product.material_id or "") + except Exception: + pass + return TaskResponse( + id=task.id, + product_id=task.product_id, + product_sn=product_sn, + product_material=product_material, + parent_task_id=task.parent_task_id, + task_name=task.task_name, + assignee_id=task.assignee_id, + status=task.status, + notify_parent_on_complete=task.notify_parent_on_complete, + is_rework=task.is_rework, + task_type=task.task_type, + remark=task.remark, + reject_reason=task.reject_reason, + received_at=task.received_at, + completed_at=task.completed_at, + created_at=task.created_at, + child_tasks=[_to_response(c) for c in task.child_tasks], + records=[TaskRecordResponse.model_validate(r) for r in (task.records or [])], + ) + + +async def _check_all_critical_children_completed( + db: AsyncSession, task_id: uuid.UUID +) -> tuple[bool, list[str]]: + """ + 检查指定任务下所有 notify_parent_on_complete=True 的子任务是否都已完成。 + 返回 (是否全部完成, 未完成的子任务名称列表)。 + """ + result = await db.execute( + select(Task).where( + Task.parent_task_id == task_id, + Task.notify_parent_on_complete.is_(True), + ) + ) + critical_children = result.scalars().all() + + incomplete = [ + child.task_name + for child in critical_children + if child.status != TASK_STATUS_COMPLETED + ] + return len(incomplete) == 0, incomplete + + +def _admin_proxy_note(operator_id: str | None, task_assignee_id: str | None) -> str: + """检测管理员代办 → 返回审计标记后缀""" + if operator_id and task_assignee_id and operator_id != task_assignee_id: + return f" [管理员 {operator_id} 代办]" + return "" + + +async def _create_task_log( + db: AsyncSession, + task_id: uuid.UUID, + action_type: str, + operator_id: str | None = None, + remark: str | None = None, + task_assignee_id: str | None = None, +) -> TaskLog: + """创建任务操作日志。自动检测管理员代办并拼接审计标记。""" + final_remark = (remark or "") + _admin_proxy_note(operator_id, task_assignee_id) + log = TaskLog( + task_id=task_id, + operator_id=operator_id, + action_type=action_type, + remark=final_remark.strip() or None, + ) + db.add(log) + return log + + +# ============================================================ +# 公开 API — 查询 +# ============================================================ + +async def get_task(db: AsyncSession, task_id: uuid.UUID) -> TaskResponse: + """获取任务详情 — 递归包含所有子任务""" + task = await _get_task_with_children_recursive(db, task_id) + return _to_response(task) + + +async def get_top_level_tasks(db: AsyncSession, product_id: uuid.UUID) -> list[TaskSummaryResponse]: + """获取产品的顶层任务列表""" + result = await db.execute( + select(Task) + .where( + Task.product_id == product_id, + Task.parent_task_id.is_(None), + ) + .order_by(Task.created_at) + ) + tasks = result.scalars().all() + return [TaskSummaryResponse.model_validate(t) for t in tasks] + + +async def create_task(db: AsyncSession, data: TaskCreate) -> TaskResponse: + """创建任务,并同步产品宏观状态""" + task = Task(**data.model_dump()) + db.add(task) + + # 同步产品宏观状态 + 当前位置 + product_result = await db.execute(select(Product).where(Product.id == data.product_id)) + product = product_result.scalar_one_or_none() + if product: + # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期 + # (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。 + await _mark_after_sales_if_reactivated(db, data.product_id, data.task_name) + # 🔧 选项隔离:售后回流设备禁止被排回「备货 / 生产」等前期工序(防伪造传参) + _enforce_step_isolation(product, data.task_name, action="创建任务") + # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— 设备已是 + # 已入库/在库/已出库 时禁止用任务名覆写,否则「已出库」会被工序名抹掉。 + if ( + data.task_name + and (not data.parent_task_id or data.task_type in ("TRANSFER", "RECOVERY")) + and not _is_physical_terminal(product.overall_status) + ): + product.overall_status = "已入库" if "virtual_warehouse" in data.task_name else data.task_name + # 🔧 双字段同步:overall_status 改动后必须对齐 status, + # 否则出库回流设备的 status 会永远停在 OUTBOUND,污染统计口径 + sync_product_status(product) + # 派发给人 → 产品离开仓库 + if data.assignee_id and data.assignee_id != VIRTUAL_WAREHOUSE: + product.current_location_id = data.assignee_id + + await db.commit() + await db.refresh(task) + return _to_response(task) + + +async def update_task(db: AsyncSession, task_id: uuid.UUID, data: TaskUpdate) -> TaskResponse: + """更新任务""" + task = await _get_task_or_404(db, task_id) + update_data = data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(task, field, value) + await db.commit() + await db.refresh(task) + return _to_response(task) + + +async def get_all_tasks( + db: AsyncSession, product_id: uuid.UUID | None = None, + assignee_id: str | None = None, skip: int = 0, limit: int = 50 +) -> TaskListResponse: + """获取任务列表,可按产品/负责人筛选""" + filters = [] + if product_id: + filters.append(Task.product_id == product_id) + if assignee_id: + filters.append(Task.assignee_id == assignee_id) + + # 总数必须独立 COUNT:移动端「我的任务」用 total 判断 hasMore + # (tasks.length < total),若 total 取当前页条数,首页满员时 + # hasMore 恒为 false,列表永远停在第一页。 + total = await db.scalar( + select(func.count()).select_from(Task).where(*filters) + ) or 0 + + stmt = ( + select(Task) + .options( + selectinload(Task.records), + selectinload(Task.product), + ) + .where(*filters) + .offset(skip) + .limit(limit) + .order_by(Task.created_at.desc()) + ) + + result = await db.execute(stmt) + tasks = result.scalars().all() + + # 返回扁平列表(不递归 children,避免 MissingGreenlet) + flat_tasks = [_to_flat_response(t) for t in tasks] + return TaskListResponse(tasks=flat_tasks, total=total) + + +# ============================================================ +# 核心业务 0:结束分支(终止当前节点) +# ============================================================ + +async def end_task( + db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, + operator_role: str | None = None, +) -> TaskResponse: + """ + 结束当前分支:标记任务为 COMPLETED,不创建下游任务。 + 用于工序已完结、无需转交下一人的场景。 + """ + task = await _get_task_or_404(db, task_id) + + # 权限校验:本人 或 管理员/主管 可结束 + _check_permission(task.assignee_id, operator_id, operator_role) + + # 校验:仅 SPAWN 协助分支可以结束,主分支(TRANSFER/RECOVERY)不能通过此接口终止 + if not task.parent_task_id: + raise HTTPException(status_code=409, detail="根任务无法结束,请使用完工转交") + if task.task_type != "SPAWN": + raise HTTPException(status_code=409, detail="仅协助分支可以结束,主分支请使用完工转交") + + # 校验:必须等待所有协助分支完成 + await _check_children_done(db, task_id) + + if task.status == TASK_STATUS_COMPLETED: + raise HTTPException(status_code=409, detail="此分支已经结束") + if task.status == TASK_STATUS_PENDING: + raise HTTPException(status_code=409, detail="请先接收任务再结束分支") + + now = get_beijing_time() + task.status = TASK_STATUS_COMPLETED + task.completed_at = now + + await _create_task_log(db, task_id, action_type="end", + operator_id=operator_id, remark=f"分支「{task.task_name}」已终止(无下游)", + task_assignee_id=task.assignee_id) + + # 🔧 位置回溯:分支结束后优先回溯到父任务负责人 + await _recalc_product_location(db, task.product_id, task.id) + + await db.commit() + await db.refresh(task) + return _to_response(task) + + +# ============================================================ +# 核心业务 0.3:撤回转交 +# ============================================================ + +async def recall_task( + db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, + operator_role: str | None = None, +) -> TaskResponse: + """撤回 PENDING 转交:标记为 CANCELED,以被撤回节点为父生成接力新任务给操作人。 + + 权限校验: + - 管理员(SUPER_ADMIN / SUPERVISOR):直接放行 + - 操作人 必须等于 上游任务的负责人(谁发出的谁才能撤回),否则 403 + """ + task = await _get_task_or_404(db, task_id) + + if task.status != TASK_STATUS_PENDING: + raise HTTPException(status_code=409, detail="只有待接收(PENDING)的任务可以撤回") + + # ── 权限校验:谁发出的谁才能撤回 ── + if not (operator_role and operator_role in ADMIN_ROLES): + # 查找上游任务的负责人(发出者) + upstream_assignee: str | None = None + if task.parent_task_id: + parent_result = await db.execute( + select(Task).where(Task.id == task.parent_task_id) + ) + parent_task = parent_result.scalar_one_or_none() + if parent_task: + upstream_assignee = parent_task.assignee_id + else: + # 无父任务:从任务日志追溯创建人 + log_result = await db.execute( + select(TaskLog).where( + TaskLog.task_id == task_id, + TaskLog.action_type == "create", + ).order_by(TaskLog.created_at.asc()).limit(1) + ) + create_log = log_result.scalar_one_or_none() + if create_log: + upstream_assignee = create_log.operator_id + + # 如果找不到上游负责人,或者操作人不等于上游负责人 → 403 + if not upstream_assignee or operator_id != upstream_assignee: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="您不是该任务的发起人,无法撤回", + ) + + now = get_beijing_time() + + # 1. 废掉当前待接收任务 + task.status = TASK_STATUS_CANCELED + task.completed_at = now + await _create_task_log(db, task_id, action_type="recall", operator_id=operator_id, + remark=f"撤回转交「{task.task_name}」→ {task.assignee_id}", + task_assignee_id=task.assignee_id) + db.add(TaskRecord(task_id=task.id, remark=f"[撤回] 转交至 {task.assignee_id} 已撤回", images="[]")) + + # 2. 生成接力新任务(以撤回节点为父,还给操作人) + recovery = Task( + product_id=task.product_id, + parent_task_id=task.id, + task_name=task.task_name, + assignee_id=operator_id, + status=TASK_STATUS_WIP, + task_type="RECOVERY", + notify_parent_on_complete=False, + is_rework=False, + remark=f"撤回「{task.task_name}」后重新接手", + ) + db.add(recovery) + await db.flush() + await _create_task_log(db, recovery.id, action_type="create", operator_id=operator_id, + remark=f"撤回接力:撤回「{task.task_name}」→ {task.assignee_id} 后重新指派给 {operator_id}", + task_assignee_id=recovery.assignee_id) + db.add(TaskRecord(task_id=recovery.id, remark=f"[重新接手] 撤回转交后系统自动生成接力节点", images="[]")) + + # 3. 更新产品位置 + product_result = await db.execute(select(Product).where(Product.id == task.product_id)) + product = product_result.scalar_one_or_none() + if product and operator_id: + product.current_location_id = operator_id + + await db.commit() + await db.refresh(recovery) + return _to_response(recovery) + + +# ============================================================ +# 核心业务 0.5:派发协助分支(不改变父任务状态) +# ============================================================ + +async def spawn_subtask( + db: AsyncSession, task_id: uuid.UUID, data, operator_id: str | None = None +) -> TaskResponse: + """在当前任务下创建并行子任务,父任务状态保持不变。""" + task = await _get_task_or_404(db, task_id) + + if task.status == TASK_STATUS_COMPLETED: + raise HTTPException(status_code=409, detail="任务已完成,无法派发协助分支") + if task.status == TASK_STATUS_REJECTED: + raise HTTPException(status_code=409, detail="任务已驳回,无法派发协助分支") + + child = Task( + product_id=task.product_id, + parent_task_id=task.id, + task_name=data.task_name, + assignee_id=data.assignee_id, + status=TASK_STATUS_PENDING, + task_type="SPAWN", + notify_parent_on_complete=False, + is_rework=False, + remark=data.remark or None, + ) + db.add(child) + await db.flush() + + # 协助分支不改变产品宏观状态(只有主分支影响 overall_status) + db.add(TaskRecord(task_id=task.id, remark=data.remark or f"[派发协助] 分配给 {data.assignee_id}", images="[]")) + + await _create_task_log(db, child.id, action_type="create", operator_id=operator_id, + remark=f"协助分支(由「{task.task_name}」派发,分配给 {data.assignee_id})", + task_assignee_id=child.assignee_id) + await db.commit() + await db.refresh(child) + return _to_response(child) + + +async def _check_children_done(db: AsyncSession, task_id: uuid.UUID): + """检查当前任务的所有子任务是否都已完结。未完结则抛出 409。""" + result = await db.execute( + select(Task).where(Task.parent_task_id == task_id) + ) + children = result.scalars().all() + incomplete = [c for c in children if c.status not in ( + TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED, TASK_STATUS_CANCELED, ARCHIVED_STATUS + )] + if incomplete: + names = "、".join(c.task_name for c in incomplete) + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"当前工序还有未完成的协助分支({names}),必须等待分支结束才能转交或完工!", + ) + return True + + +ARCHIVED_STATUS = "ARCHIVED" + + +# ============================================================ +# 核心业务 1:确认接收 (PENDING → WIP) +# ============================================================ + +async def receive_task( + db: AsyncSession, task_id: uuid.UUID, operator_id: str | None = None, + remark: str | None = None, task_name: str | None = None, + operator_role: str | None = None, +) -> TaskResponse: + """ + 操作员确认接收任务。工人选定工序名称后接收。 + + 校验:只有状态为 PENDING 的任务可接收。 + 动作:状态改为 WIP,记录 received_at,更新 task_name,同步产品宏观状态。 + """ + task = await _get_task_or_404(db, task_id) + + # 权限校验:本人 或 管理员/主管 可操作 + _check_permission(task.assignee_id, operator_id, operator_role) + + # 校验:只有 PENDING 状态可接收 + if task.status != TASK_STATUS_PENDING: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"只有待接收(PENDING)状态的任务可接收,当前状态: {task.status}", + ) + + now = get_beijing_time() + task.status = TASK_STATUS_WIP + task.received_at = now + if remark: + task.remark = remark + if task_name: + task.task_name = task_name + + await _create_task_log( + db, task_id, + action_type="receive", + operator_id=operator_id, + remark=remark or f"操作员确认接收任务「{task.task_name}」", + task_assignee_id=task.assignee_id, + ) + db.add(TaskRecord(task_id=task.id, + remark=(remark or f"[接收] 操作员已确认接收") + _admin_proxy_note(operator_id, task.assignee_id), + images="[]")) + + # 接收时同步产品位置到接收人 + 宏观状态同步 + product_result = await db.execute(select(Product).where(Product.id == task.product_id)) + product = product_result.scalar_one_or_none() + if product: + # 🔧 出库后任务被重新接收 = 设备回流返厂 → 切到售后生命周期 + # (须早于下方改写 overall_status)。出厂质检(发货测试)不触发。 + await _mark_after_sales_if_reactivated( + db, task.product_id, task_name or task.task_name, + ) + # 🔧 选项隔离:接收时选定的工序必须落在该产品当前生命周期阶段的合法集合内 + #(task_name 为 None 时表示本次未指定工序,跳过校验,不阻断 PC 端「直接接收」) + _enforce_step_isolation(product, task_name, action="接收任务") + if task.assignee_id: + product.current_location_id = task.assignee_id + # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— + # 设备已是 已入库/在库/已出库 时禁止用任务名覆写。 + # 这是「发货测试 抹掉 已出库」的关键拦截点:若不拦,设备会在 + # 概览/大屏等按终态口径统计的报表里不再是已出库,而矩阵因优先看 + # 活跃任务看不出问题(跨报表口径就此打架)。 + if ( + task_name + and (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")) + and not _is_physical_terminal(product.overall_status) + ): + product.overall_status = task_name + # 🔧 双字段同步:无条件对齐一次,顺带治愈历史残留 + # (如整体已是「发货测试」而 status 还停在 OUTBOUND) + sync_product_status(product) + + await db.commit() + await db.refresh(task) + + return _to_response(task) + + +# ============================================================ +# 核心业务 2:品质驳回 (→ REJECTED + 返工闭环) +# ============================================================ + +async def reject_task( + db: AsyncSession, task_id: uuid.UUID, request: TaskRejectRequest, operator_id: str | None = None, + operator_role: str | None = None, +) -> TaskResponse: + """ + 品质驳回:将当前任务标记为 REJECTED,并自动创建返工任务给上一道工序负责人。 + + 防呆闭环逻辑: + 1. 将当前任务状态改为 REJECTED,记录 reject_reason 和 completed_at。 + 2. 查找当前任务的父任务 (parent_task) 的负责人 (assignee_id)。 + - 若有父任务:返工任务分配给父任务的 assignee_id。 + - 若无父任务(顶层任务):返工任务分配给当前任务自己的 assignee_id。 + 3. 把驳回原因 + 异常图片写入 TaskRecord,挂到被驳回的任务下(前端时间线可见)。 + 4. 为该负责人新建一个完全一样的任务,但 is_rework=True,status=PENDING。 + 5. 新返工任务挂在同一个 parent_task_id 下(与原任务同级)。 + """ + task = await _get_task_or_404(db, task_id) + + # 权限校验:本人 或 管理员/主管 可驳回 + _check_permission(task.assignee_id, operator_id, operator_role) + + # 校验:不能重复驳回已完成/已驳回的任务 + if task.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"任务状态为 {task.status},无法驳回", + ) + + now = get_beijing_time() + + # --- 1. 标记当前任务为已驳回 --- + task.status = TASK_STATUS_REJECTED + task.reject_reason = request.reason + task.completed_at = now + + await _create_task_log( + db, task_id, + action_type="reject", + operator_id=operator_id, + remark=f"品质驳回: {request.reason}", + task_assignee_id=task.assignee_id, + ) + + # --- 2. 确定返工任务的负责人(追溯上一道工序的转交人) --- + rework_assignee_id: str | None = None + + # 🚀 优先方式:从任务日志追溯创建人(准确记录是谁发起的转交) + log_result = await db.execute( + select(TaskLog).where( + TaskLog.task_id == task_id, + TaskLog.action_type == "create", + ).order_by(TaskLog.created_at.asc()).limit(1) + ) + create_log = log_result.scalar_one_or_none() + if create_log and create_log.operator_id: + rework_assignee_id = create_log.operator_id + + # 兜底方式1:有父任务 → 返工给父任务的负责人 + if not rework_assignee_id and task.parent_task_id: + parent_result = await db.execute( + select(Task).where(Task.id == task.parent_task_id) + ) + parent_task = parent_result.scalar_one_or_none() + if parent_task: + rework_assignee_id = parent_task.assignee_id + + # 兜底方式2:用当前任务的负责人 + if not rework_assignee_id: + rework_assignee_id = task.assignee_id + + # --- 3. 写入驳回记录(原因 + 异常图片)→ 修复前端时间线缺失 --- + # 图片为选填(编号错误等场景可不传),空数组落库为 "[]",前端时间线照常渲染 + db.add(TaskRecord( + task_id=task.id, + remark=f"[品质驳回] {request.reason}(返工任务已派发给 {rework_assignee_id or '未指派'})", + images=json.dumps(request.images, ensure_ascii=False), + created_at=now, + )) + + # --- 4. 创建返工任务 --- + rework_task = Task( + product_id=task.product_id, + parent_task_id=task.parent_task_id, # 与原任务同级 + task_name=task.task_name, + assignee_id=rework_assignee_id, + status=TASK_STATUS_PENDING, + task_type=task.task_type, # 🚀 继承被驳回任务的基因:主线→主线,协助→协助 + notify_parent_on_complete=task.notify_parent_on_complete, + is_rework=True, + ) + db.add(rework_task) + await db.flush() + + await _create_task_log( + db, rework_task.id, + action_type="create", + operator_id=operator_id, + remark=f"返工任务(驳回自「{task.task_name}」,原因: {request.reason}),分配给 {rework_assignee_id}", + task_assignee_id=rework_task.assignee_id, + ) + + # 🔔 通知:品质驳回 + product_sn = "" + try: + product_result = await db.execute(select(Product).where(Product.id == task.product_id)) + p = product_result.scalar_one_or_none() + if p: + product_sn = p.serial_number or "" + except Exception: + pass + if rework_assignee_id: + db.add(Notification( + user_id=rework_assignee_id, + title="🔴 品质驳回提醒", + content=f"产品 [{product_sn}] 的「{task.task_name}」被驳回,原因: {request.reason}", + type=NOTIFY_REJECT, + task_id=rework_task.id, + )) + + await db.commit() + await db.refresh(task) + + return _to_response(task) + + +# ============================================================ +# 核心业务 3:完工并裂变转交 (→ COMPLETED + 裂变创建下家任务) +# ============================================================ + +async def transfer_task( + db: AsyncSession, task_id: uuid.UUID, request: TaskTransferRequest, operator_id: str | None = None, + operator_role: str | None = None, +) -> TaskTransferResponse: + """ + 完工并裂变转交: + + 动作 1(闭环当前节点): + - 将当前任务状态改为 COMPLETED,记录 completed_at。 + + 动作 2(解析下家): + - 遍历 next_assignees 列表。 + - 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为 'virtual_warehouse'。 + - 为每一个 assignee_id(非 virtual_warehouse)新建一条 Task 记录(状态 PENDING)。 + + 动作 2'(直接完结 finish_directly=True): + - 不解析任何下家,分支强制为空,直接落入下方"无下家"兜底分支。 + - 用于「售后返厂直接发走 / 半成品被提走」这类**无需入库**的收官场景: + 工人不必再借道「入库(virtual_warehouse)」来关闭任务, + 从而避免把产品误标成「待仓库收货」并卡住 MOM 对账。 + + 裂变逻辑: + - 如果 len(next_assignees) > 1:多路裂变 → 所有新任务挂到当前任务下(parent_task_id = 当前任务ID)。 + - 如果当前任务本身就是子任务(有 parent_task_id):单路转交也挂到同一父任务下。 + - 否则(顶层单路转交):新任务与当前任务同级(parent_task_id = None)。 + - 更新 Product 的 current_location_id 为对应的人员(非仓库)。 + """ + task = await _get_task_or_404(db, task_id) + + # 权限校验:本人 或 管理员/主管 可转交 + _check_permission(task.assignee_id, operator_id, operator_role) + + # ── 🏁 直接完结:额外收紧为「仅管理员/主管」── + # 这是【正向】拦截(非管理员一律拒绝),刻意不复用 _check_permission 的反向写法: + # 后者在 operator_id 为空时会静默放行,而直接完结是不可逆的收官动作 + # (不产生下游任务、产品落终态),必须默认拒绝。 + # ⚠️ 判据只能是 operator_role —— 它来自签名保护的 JWT; + # operator_id 是客户端可通过 ?operator_id= 自行填写的,不可作为权限依据。 + if request.finish_directly and not (operator_role and operator_role in ADMIN_ROLES): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅超管和主管有权限进行直接完结操作", + ) + + # 校验:不能重复完成 + if task.status == TASK_STATUS_COMPLETED: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"任务「{task.task_name}」已经完成(当前状态: {task.status}),请刷新页面", + ) + # 校验:PENDING 状态不允许转交(必须先接收) + if task.status == TASK_STATUS_PENDING: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"任务「{task.task_name}」尚未接收(当前状态: PENDING),请先接收再转交", + ) + + # 校验:卡点逻辑 — 检查关键子任务 + all_done, incomplete_names = await _check_all_critical_children_completed(db, task_id) + if not all_done: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"请等待相关子任务完成:{', '.join(incomplete_names)}", + ) + + now = get_beijing_time() + + # --- 动作 1:闭环当前节点 --- + # 🔧 直接完结走独立文案:action_type 沿用 "complete"(不新增取值,避免扰动现有 + # 看板的完成数口径),仅在 remark 里区分,审计时可按文本筛出「直接完结」。 + finish_directly = bool(request.finish_directly) + if finish_directly: + log_remark = request.note or f"直接完结任务「{task.task_name}」(无下游,不入库)" + record_remark = request.note or "[直接完结] 无下游任务,产品宏观状态保持不变" + else: + log_remark = request.note or f"完成任务「{task.task_name}」,转交至下一道工序" + record_remark = request.note or "[完工转交] 移交下一工序" + + task.status = TASK_STATUS_COMPLETED + task.completed_at = now + + await _create_task_log( + db, task_id, + action_type="complete", + operator_id=operator_id, + remark=log_remark, + task_assignee_id=task.assignee_id, + ) + db.add(TaskRecord(task_id=task.id, + remark=record_remark + _admin_proxy_note(operator_id, task.assignee_id), + images="[]")) + + # --- 动作 2:解析下家 & 裂变 --- + # 🔧 提前查产品:下面的阶段隔离校验依赖它的生命周期阶段。 + # (通知也需要 product_sn,原本在创建任务之后才查,提前不增加查询次数) + product_result = await db.execute( + select(Product).where(Product.id == task.product_id) + ) + product = product_result.scalar_one_or_none() + + # 兼容新旧格式 + # 🔧 直接完结:不解析任何下家,分支强制为空,直接命中下方"无下家"兜底分支。 + if finish_directly: + branches: list[tuple[str | None, str]] = [] + elif request.next_tasks: + branches = [ + (b.task_name, a) + for b in request.next_tasks + for a in (b.assignees or []) + ] + else: + branches = [ + (request.next_task_name, a) + for a in (request.next_assignees or []) + ] + + has_warehouse = any(a == VIRTUAL_WAREHOUSE for _, a in branches) + real_branches = [(tn, a) for tn, a in branches if a != VIRTUAL_WAREHOUSE] + + # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期。 + # 必须在下方阶段校验之前执行,否则"已出库设备被转交到生产工序"会被误放行。 + # (故本调用安排在分支解析之后、_reject_cross_phase_steps 之前。) + # ⚠️ 直接完结【不产生新任务】,不构成"回流返厂"信号,必须跳过: + # 否则一台已出库设备的正常收官,会把产品误翻成售后机(该标志单向不可回退)。 + # ⚠️ 出厂质检(发货测试)同样不触发 —— 详见 _mark_after_sales_if_reactivated。 + if product and not finish_directly: + await _mark_after_sales_if_reactivated( + db, task.product_id, [tn for tn, _ in real_branches], + ) + + # 🔧 选项隔离:售后回流设备不允许被转交到「备货 / 生产 / 测试 / 维修」。 + # 转交允许自由填写工序名(喷漆/老化…),故只精准拦跨阶段词,不用白名单。 + if product: + _reject_cross_phase_steps( + product.lifecycle_phase, [tn for tn, _ in real_branches], action="转交", + ) + + is_fission = len(real_branches) > 1 or (request.next_tasks and len(request.next_tasks) > 1) + is_child_task = task.parent_task_id is not None + + created_tasks: list[Task] = [] + + for task_name, assignee_id in real_branches: + # 🔧 动态推导 task_type:禁止分支任务转交被硬编码为 TRANSFER(否则 is_main 判定误升为主线) + if is_fission: + new_parent_task_id = task.id + new_task_type = "SPAWN" # 裂变产生新分支 + elif is_child_task: + new_parent_task_id = task.parent_task_id + new_task_type = task.task_type or "SPAWN" # 继承父任务的分支血统 + else: + new_parent_task_id = task.id + new_task_type = "TRANSFER" # 主干常规流转 + + new_task = Task( + product_id=task.product_id, + parent_task_id=new_parent_task_id, + task_name=task_name, + assignee_id=assignee_id, + status=TASK_STATUS_PENDING, + task_type=new_task_type, # ← 使用动态推导的类型 + notify_parent_on_complete=False, + is_rework=False, + remark=request.note or None, + ) + db.add(new_task) + created_tasks.append(new_task) + + # 批量 flush 以生成 ID + await db.flush() + + product_sn = product.serial_number if product else "" + + for nt in created_tasks: + await _create_task_log( + db, nt.id, + action_type="create", + operator_id=operator_id, + remark=request.note or f"由任务「{task.task_name}」裂变转交创建,分配给 {nt.assignee_id}", + task_assignee_id=nt.assignee_id, + ) + # 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上。 + # 原先留言只落在「已完工的上游任务记录」+「新任务的 Task.remark 字段」里, + # 接手人打开自己任务的「操作日志」只能看到 [接收],看不到交接上下文, + # 转交备注的协作意义就丢了。这里补齐下游视角。 + if request.note and request.note.strip(): + db.add(TaskRecord( + task_id=nt.id, + remark=f"[上一工序转交留言]:{request.note.strip()}", + images="[]", + )) + # 🔔 通知:新任务派发 + if nt.assignee_id: + db.add(Notification( + user_id=nt.assignee_id, + title=f"🟢 新任务派发", + content=f"产品 [{product_sn}] 的「{nt.task_name}」任务已分配给你", + type=NOTIFY_TRANSFER, + task_id=nt.id, + )) + + # --- 更新 Product 的 current_location_id / overall_status --- + # 🏁 直接完结【不改动 overall_status】:它只是一个「任务闭环动作」, + # 不改变产品的物理状态。已出库的设备完结后依然是已出库, + # 从而回归 WIP 矩阵的【已出库】列(无活跃任务 → 落终结态判定)。 + # 下方 has_warehouse / real_branches 两个分支对直接完结均为假, + # overall_status 自然原样保留。 + if product: + if has_warehouse and not real_branches: + # 转入虚拟仓库池:位置一定改(下一手才能在这里看到并接手), + # 但宏观状态要分情况 —— + # · 绝对物理终态(已入库 / 在库 / 已出库):禁止改写成「待仓库收货」。 + # 已出库设备售后返修时选择【入库】是为了「放回池子让下一个人继续转」, + # 改成「待仓库收货」会抹掉它的物理终态,且与 MOM 侧「已发货」的记录冲突 + # (MOM 认为货已出门,Track 却说「等仓库收货」,双方对不上账)。 + # 已入库 / 在库设备本就在仓库,再入库更不该回退成「待收货」。 + # · 普通生产设备(备货/生产/测试/维修…):维持原行为,正常置为「待仓库收货」。 + product.current_location_id = VIRTUAL_WAREHOUSE + if not _is_physical_terminal(product.overall_status): + product.overall_status = "待仓库收货" + elif real_branches: + product.current_location_id = real_branches[0][1] + # 🔒 只主线任务同步宏观状态;且【绝对物理终态保护】—— + # 设备已是 已入库/在库/已出库 时禁止用任务名覆写, + # 否则「已出库」会被「发货测试」这类工序名抹掉。 + if ( + (not task.parent_task_id or task.task_type in ("TRANSFER", "RECOVERY")) + and not _is_physical_terminal(product.overall_status) + ): + product.overall_status = real_branches[0][0] + # 🔧 双字段同步:各分支统一在这里对齐一次(原先只有入库分支硬编码 + # product.status="COMPLETED",普通转交分支完全没同步 → 残留 OUTBOUND) + # 注意顺序:必须在上面的 overall_status 赋值【之后】,否则 status 同步到旧值。 + sync_product_status(product) + + # 🔧 位置回溯:如果有新任务创建,优先新任务负责人;否则回溯到父任务 + # 「直接完结」(finish_directly) 与旧版空分支都落到这里: + # current_location_id 回溯到最后一手经手人(业务确认:位置停在经手人合理)。 + # 注意它只改 location,不碰 overall_status,故上面的终态不会被覆盖。 + if not real_branches and not has_warehouse: + await _recalc_product_location(db, task.product_id, task_id) + + await db.commit() + + # --- 构建响应 --- + refreshed_task = await _get_task_with_children_recursive(db, task_id) + created_task_responses = [] + for nt in created_tasks: + await db.refresh(nt) + created_task_responses.append(_to_response(nt)) + + # 🔧 直接完结没有下家,不能套用"已创建 N 个任务(接收人: 仓库)"的模板 + # (否则会拼出「已创建 0 个下一道工序任务「None」(接收人: 仓库)」这种误导文案) + if finish_directly: + message = f"任务「{task.task_name}」已直接完结(无下游任务,产品状态保持不变)" + else: + assignee_list = ", ".join(a for _, a in real_branches) if real_branches else "仓库" + location_info = "" + if has_warehouse: + location_info = ",产品已入库(virtual_warehouse)" + message = ( + f"任务「{task.task_name}」已完成," + f"已创建 {len(created_tasks)} 个下一道工序任务「{request.next_task_name}」" + f"(接收人: {assignee_list}){location_info}" + ) + + return TaskTransferResponse( + completed_task=_to_response(refreshed_task), + created_tasks=created_task_responses, + message=message, + ) + + +# ============================================================ +# 保留兼容:旧版 complete_task(单步完成/转交) +# ============================================================ + +async def complete_task( + db: AsyncSession, task_id: uuid.UUID, request: TaskCompleteRequest, + operator_role: str | None = None, +) -> TaskCompleteResponse: + """ + 核心业务:完成任务 + 可选创建下一步任务。 + + 逻辑: + 1. 检查当前任务是否已完成(幂等) + 2. 检查所有 notify_parent_on_complete=True 的子任务是否都已完成 + → 如果存在未完成的关键子任务,返回 400 错误 + 3. 将当前任务状态改为 completed,记录日志 + 4. 如果提供了 next_task_name 和 next_assignee_id,创建下一步任务 + 5. 返回完成结果 + """ + task = await _get_task_or_404(db, task_id) + + # 权限校验:本人 或 管理员/主管 可操作 + _check_permission(task.assignee_id, request.operator_id, operator_role) + + # --- 1. 幂等检查 --- + if task.status == TASK_STATUS_COMPLETED: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"任务「{task.task_name}」已经完成,无需重复操作", + ) + + # --- 2. 卡点逻辑:检查关键子任务 --- + all_done, incomplete_names = await _check_all_critical_children_completed(db, task_id) + if not all_done: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"请等待相关子任务完成:{', '.join(incomplete_names)}", + ) + + now = get_beijing_time() + + # --- 3. 标记当前任务为已完成 --- + task.status = TASK_STATUS_COMPLETED + task.completed_at = now + await _create_task_log( + db, task_id, + action_type="complete", + operator_id=request.operator_id, + remark=request.remark or f"完成任务: {task.task_name}", + task_assignee_id=task.assignee_id, + ) + # 🔧 完工留痕(与 transfer_task 同口径):备注要写成 TaskRecord, + # 否则「操作日志」时间线只读 TaskRecord、看不到 TaskLog,交接内容就丢了。 + _will_transfer = bool(request.next_task_name and request.next_assignee_id) + db.add(TaskRecord( + task_id=task.id, + remark=( + request.remark + or ("[完工转交] 移交下一工序" if _will_transfer else "[完工] 任务已完成") + ) + _admin_proxy_note(request.operator_id, task.assignee_id), + images="[]", + )) + + # --- 4. 可选:创建下一步任务(转交) --- + next_task = None + if request.next_task_name and request.next_assignee_id: + # 🔧 出库后又接到【明确修机指令】= 设备回流返厂 → 切到售后生命周期 + # (出厂质检「发货测试」不触发) + await _mark_after_sales_if_reactivated( + db, task.product_id, request.next_task_name, + ) + # 🚀 智能父节点继承算法 + # 主线任务转交 → 保持平级继承(主分支永远在一维主干上) + # 协助分支转交 → 认当前任务为父(形成向外无限延伸的孙子节点树枝) + # 注:TASK_TYPE 实际值为 TRANSFER/RECOVERY/SPAWN,不存在 "MAIN" + is_main_line = ( + not task.parent_task_id + or task.task_type in ("TRANSFER", "RECOVERY") + ) + new_parent_id = task.parent_task_id if is_main_line else task.id + + next_task = Task( + product_id=task.product_id, + parent_task_id=new_parent_id, # 👈 智能计算 + task_name=request.next_task_name, + assignee_id=request.next_assignee_id, + status=TASK_STATUS_PENDING, + task_type=task.task_type, # 👈 基因严格继承(绝不篡位成 MAIN) + notify_parent_on_complete=False, + ) + db.add(next_task) + await db.flush() + await _create_task_log( + db, next_task.id, + action_type="create", + operator_id=request.operator_id, + remark=f"由任务「{task.task_name}」完成后转交创建", + task_assignee_id=next_task.assignee_id, + ) + # 🔧 转交留言留痕:把上一工序的嘱咐也挂一条记录到**下家任务**上, + # 让接手人打开自己的「操作日志」就能看到交接上下文 + # (与 transfer_task 同口径,保证两条转交路径行为一致) + if request.remark and request.remark.strip(): + db.add(TaskRecord( + task_id=next_task.id, + remark=f"[上一工序转交留言]:{request.remark.strip()}", + images="[]", + )) + + # 🔧 位置回溯:老接口也触发(父任务优先) + await _recalc_product_location(db, task.product_id, task_id) + + await db.commit() + + # --- 5. 构建响应 --- + refreshed_task = await _get_task_with_children_recursive(db, task_id) + next_task_response = None + if next_task: + await db.refresh(next_task) + next_task_response = _to_response(next_task) + + return TaskCompleteResponse( + completed_task=_to_response(refreshed_task), + next_task=next_task_response, + message=f"任务「{task.task_name}」已完成" + + (f",已创建下一步任务「{request.next_task_name}」" if next_task else ""), + ) + + +# ============================================================ +# 子任务 +# ============================================================ + +async def create_subtask( + db: AsyncSession, parent_task_id: uuid.UUID, data: SubtaskCreate +) -> TaskResponse: + """ + 在现有任务下创建子任务 — 支持无限层级嵌套。 + 新子任务继承父任务的 product_id。 + """ + parent = await _get_task_or_404(db, parent_task_id) + + # 禁止在已完成/已驳回的任务下创建子任务 + if parent.status in (TASK_STATUS_COMPLETED, TASK_STATUS_REJECTED): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"无法在状态为 {parent.status} 的任务「{parent.task_name}」下创建子任务", + ) + + subtask = Task( + product_id=parent.product_id, + parent_task_id=parent_task_id, + task_name=data.task_name, + assignee_id=data.assignee_id, + status=TASK_STATUS_PENDING, + notify_parent_on_complete=data.notify_parent_on_complete, + ) + db.add(subtask) + await db.commit() + await db.refresh(subtask) + + await _create_task_log( + db, subtask.id, + action_type="create", + operator_id=data.assignee_id, + remark=f"创建子任务「{data.task_name}」,父任务: 「{parent.task_name}」", + task_assignee_id=subtask.assignee_id, + ) + await db.commit() + + return _to_response(subtask) + + +# ============================================================ +# 任务进度记录 — 随时备注/传图 +# ============================================================ + +async def add_task_record( + db: AsyncSession, task_id: uuid.UUID, data: TaskRecordCreate, + current_user: dict | None = None, +) -> TaskResponse: + """追加进度记录(备注+图片),不改变任务状态""" + import json + + task = await _get_task_with_children_recursive(db, task_id) + + record = TaskRecord( + task_id=task_id, + remark=data.remark or None, + images=json.dumps(data.images) if data.images else None, + ) + db.add(record) + await db.flush() + + # 🚀 留言通知:给任务当前负责人发送提醒(不给自己发) + if ( + current_user + and task.assignee_id + and task.assignee_id != current_user.get("username", "") + and data.remark + ): + # 截取留言内容前 30 字作为摘要 + remark_text = data.remark.strip() + short_content = remark_text[:30] + ("..." if len(remark_text) > 30 else "") + # 查询产品条码 + product_result = await db.execute( + select(Product).where(Product.id == task.product_id) + ) + product = product_result.scalar_one_or_none() + product_sn = product.serial_number if product else "未知" + db.add(Notification( + user_id=task.assignee_id, + title="💬 收到新留言", + content=f"产品 [{product_sn}] 的「{task.task_name}」有新留言:{short_content}", + type="COMMENT", + task_id=task.id, + )) + + await db.commit() + await db.refresh(record) + + # 手动追加新 record,避免二次加载整棵树(task 已在 L1051 由 CTE 完整加载) + task.records.append(record) + return _to_response(task) diff --git a/backend/app/services/task_tree_loader.py b/backend/app/services/task_tree_loader.py new file mode 100644 index 0000000..8ad0493 --- /dev/null +++ b/backend/app/services/task_tree_loader.py @@ -0,0 +1,176 @@ +""" +共享 CTE 任务树加载器 — 使用 PostgreSQL Recursive CTE 一次性拉取完整任务树 + +解决问题:原 _load_task_tree / _get_task_with_children_recursive 使用 +Python 递归逐层 SELECT,N 个节点产生 N+1 次数据库查询。 +现在无论树深度多大,仅执行 2 条查询(CTE + records selectinload)。 +""" +from __future__ import annotations + +import uuid + +from fastapi import HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import noload, selectinload +from sqlalchemy.orm.attributes import set_committed_value + +from app.models.task import Task + + +# ============================================================ +# 内存树组装(O(N) 时间 / O(N) 空间) +# ============================================================ + +def _build_tree_in_memory(tasks: list[Task]) -> dict[uuid.UUID, Task]: + """ + 给定扁平 Task ORM 列表,在内存中通过哈希表组装嵌套树结构。 + + 关键安全设计: + - 使用临时字典 temp_children_map 暂存父子关系,绝对不直接操作 ORM 的 child_tasks。 + - 通过 set_committed_value 注入最终列表,告诉 SQLAlchemy 这是"已提交数据", + 避免 add_task_record 等场景中 db.commit() 时触发级联 UPDATE 污染数据库。 + + 时间复杂度: O(N),空间复杂度: O(N)。 + """ + if not tasks: + return {} + + # ── Pass 1: 临时字典存储关系(不触碰 ORM 属性)── + temp_children_map: dict[uuid.UUID, list[Task]] = {t.id: [] for t in tasks} + task_map: dict[uuid.UUID, Task] = {t.id: t for t in tasks} + + # ── Pass 2: 挂载到临时字典 ── + for t in tasks: + pid = t.parent_task_id + if pid is not None and pid in temp_children_map: + temp_children_map[pid].append(t) + + # ── Pass 3: 排序 + set_committed_value 安全注入 ── + for t in tasks: + children = temp_children_map[t.id] + if children: + children.sort(key=lambda x: x.created_at) + # 关键:标记为已提交数据,SQLAlchemy 不会对其生成 UPDATE + set_committed_value(t, 'child_tasks', children) + + return task_map + + +# ============================================================ +# 公开 API:按单一任务 ID 加载子树 +# ============================================================ + +async def load_task_tree_by_root( + db: AsyncSession, task_id: uuid.UUID +) -> Task: + """ + 使用 Recursive CTE 加载以 task_id 为根的完整任务子树。 + + 返回: 根 Task ORM 对象(child_tasks 已递归填充)。 + + Raises: + HTTPException(404): 根任务不存在。 + """ + # ── Step 1: Recursive CTE — 收集所有子孙节点 ID ── + # WITH RECURSIVE task_tree AS ( + # SELECT tasks.* FROM tasks WHERE tasks.id = :tid + # UNION ALL + # SELECT tasks.* FROM tasks + # JOIN task_tree ON tasks.parent_task_id = task_tree.id + # ) + anchor = ( + select(Task) + .where(Task.id == task_id) + .cte(name="task_tree", recursive=True) + ) + task_tree_cte = anchor.union_all( + select(Task).join(anchor, Task.parent_task_id == anchor.c.id) + ) + + # ── Step 2: 批量加载所有任务 + 关联数据 ── + stmt = ( + select(Task) + .options( + noload(Task.child_tasks), # 禁掉模型默认 selectinload,由内存树接管 + noload(Task.parent_task), # 组装树不需要 parent 引用 + selectinload(Task.records), # 🔥 一次性预加载所有进度记录 + selectinload(Task.product), # 🔥 一次性预加载产品引用 + ) + .where(Task.id.in_(select(task_tree_cte.c.id))) + ) + + result = await db.execute(stmt) + all_tasks = result.unique().scalars().all() + + if not all_tasks: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"任务不存在: {task_id}", + ) + + # ── Step 3: 内存组装 ── + task_map = _build_tree_in_memory(all_tasks) + + # 根任务一定在 map 中(CTE anchor 保证了这一点) + return task_map[task_id] + + +# ============================================================ +# 公开 API:按产品 ID 加载所有任务树 +# ============================================================ + +async def load_task_trees_by_product( + db: AsyncSession, product_id: uuid.UUID +) -> list[Task]: + """ + 使用 Recursive CTE 加载指定产品下的所有任务树。 + + 返回: 顶层任务列表(parent_task_id IS NULL),每项的 child_tasks 已递归填充。 + 若无任务则返回空列表。 + """ + # ── Step 1: Recursive CTE ── + # WITH RECURSIVE product_task_tree AS ( + # SELECT tasks.* FROM tasks + # WHERE tasks.product_id = :pid AND tasks.parent_task_id IS NULL + # UNION ALL + # SELECT tasks.* FROM tasks + # JOIN product_task_tree ON tasks.parent_task_id = product_task_tree.id + # ) + anchor = ( + select(Task) + .where( + Task.product_id == product_id, + Task.parent_task_id.is_(None), + ) + .cte(name="product_task_tree", recursive=True) + ) + task_tree_cte = anchor.union_all( + select(Task).join(anchor, Task.parent_task_id == anchor.c.id) + ) + + # ── Step 2: 批量加载 ── + stmt = ( + select(Task) + .options( + noload(Task.child_tasks), + noload(Task.parent_task), + selectinload(Task.records), + selectinload(Task.product), + ) + .where(Task.id.in_(select(task_tree_cte.c.id))) + ) + + result = await db.execute(stmt) + all_tasks = result.unique().scalars().all() + + if not all_tasks: + return [] + + # ── Step 3: 内存组装 ── + _build_tree_in_memory(all_tasks) + + # ── Step 4: 返回排序后的顶层任务 ── + roots = [t for t in all_tasks if t.parent_task_id is None] + roots.sort(key=lambda t: t.created_at) + return roots diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1b39c9f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,48 @@ +alembic==1.18.5 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +asyncpg==0.31.0 +bcrypt==5.0.0 +cffi==2.1.1 +click==8.4.2 +colorama==0.4.6 +cryptography==50.0.0 +ecdsa==0.19.2 +fastapi==0.141.1 +greenlet==3.5.4 +h11==0.16.0 +httptools==0.8.0 +idna==3.18 +Mako==1.3.12 +MarkupSafe==3.0.3 +passlib==1.7.4 +psycopg2-binary==2.9.12 +pyasn1==0.6.4 +pycparser==3.0 +pydantic==2.13.4 +pydantic-settings==2.14.2 +pydantic_core==2.46.4 +python-dotenv==1.2.2 +python-jose==3.5.0 +python-multipart==0.0.32 +PyYAML==6.0.3 +rsa==4.9.1 +six==1.17.0 +SQLAlchemy==2.0.51 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +uvicorn==0.52.1 +watchfiles==1.2.0 +websockets==17.0.1 + +# QR code generation +qrcode[pil]==8.2 +Pillow==12.3.0 + +# MOM 登录对接 — Werkzeug scrypt 密码验证 +werkzeug==3.0.6 + +# MOM 仓储入库推送 — 异步 HTTP 客户端 +httpx==0.28.1 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..db201f2 --- /dev/null +++ b/backend/scripts/__init__.py @@ -0,0 +1 @@ +"""一次性运维脚本集合(按模块方式运行:python -m scripts.)""" diff --git a/backend/scripts/fix_product_status.py b/backend/scripts/fix_product_status.py new file mode 100644 index 0000000..5516ae9 --- /dev/null +++ b/backend/scripts/fix_product_status.py @@ -0,0 +1,84 @@ +"""一次性数据清洗 — 修复 Product.status 与 overall_status 脱节的历史存量 + +背景 +---- +app/core/lifecycle.py 记录了历史缺陷:早期 receive_task / transfer_task 只改 +overall_status、不改 status,导致部分设备的 status 残留为 'pending' / 'OUTBOUND' +等旧值。 + +现状(重要) +---------- +所有 overall_status 的写入点**都已补上 sync_product_status()**: + task_service.py:387 / :691 / :1007、product_service.py:512、 + webhooks.py:84 / :256、product_finalize_service.py:107 +因此新数据不会再脱节,本脚本只处理历史存量。 + +影响面 +------ +前端列表筛选走的是 macro_status(由 overall_status 实时派生,见 +product_service._resolve_macro_status),所以这批脏数据**基本不影响页面展示**。 +但 _mark_after_sales_if_reactivated 等逻辑会读 product.status 判断"是否出库回流", +脏值可能引发误判 —— 故仍需清洗。 + +用法 +---- + python -m scripts.fix_product_status # 干跑:只打印待修复清单 + python -m scripts.fix_product_status --apply # 确认后真正写库 +""" +from __future__ import annotations + +import argparse +import asyncio + +from sqlalchemy import select + +from app.core.database import AsyncSessionLocal +from app.core.lifecycle import overall_to_product_status +from app.models.product import Product + + +async def main(apply: bool) -> None: + async with AsyncSessionLocal() as db: + products = ( + await db.execute(select(Product).order_by(Product.serial_number)) + ).scalars().all() + + fixes: list[tuple[Product, str, str]] = [] + for p in products: + expected = overall_to_product_status(p.overall_status) + current = (p.status or "").strip() + # 大小写不敏感比较:历史数据里存在小写 'pending' + if current.upper() == expected: + continue + fixes.append((p, current or "(空)", expected)) + + print(f"扫描 {len(products)} 台设备,需修复 {len(fixes)} 台\n") + + if fixes: + header = f"{'序列号':<18}{'overall_status':<16}{'当前 status':<14}→ 目标 status" + print(header) + print("-" * len(header) * 2) + for p, cur, exp in fixes: + overall = p.overall_status or "(NULL)" + phase = p.lifecycle_phase or "-" + print(f"{p.serial_number:<18}{overall:<16}{cur:<14}→ {exp} [{phase}]") + else: + print("所有设备的 status 均已与 overall_status 对齐。") + + if not apply: + print("\n[干跑] 未写库。确认清单无误后,加 --apply 执行。") + return + + if not fixes: + return + + for p, _cur, exp in fixes: + p.status = exp + await db.commit() + print(f"\n[已提交] {len(fixes)} 台设备的 status 已对齐到 overall_status。") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="对齐 Product.status 与 overall_status 的历史脏数据") + parser.add_argument("--apply", action="store_true", help="真正写库(默认仅干跑)") + asyncio.run(main(parser.parse_args().apply)) diff --git a/backend/simhei.ttf b/backend/simhei.ttf new file mode 100644 index 0000000..3326815 Binary files /dev/null and b/backend/simhei.ttf differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..723d7e8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ +# ============================================================ +# 生产流转管理系统 — 全栈 Docker 热更新开发环境 +# 启动: docker compose up -d --build +# 停止: docker compose down +# 日志: docker compose logs -f +# ============================================================ + +services: + # ============================================================ + # PostgreSQL 15 + pgvector + # ============================================================ + db: + image: pgvector/pgvector:pg15 + container_name: track_db + restart: unless-stopped + environment: + POSTGRES_USER: track + POSTGRES_PASSWORD: track_prod_2026 + POSTGRES_DB: track_production + ports: + - "8012:5432" + volumes: + - track_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U track -d track_production"] + interval: 10s + timeout: 5s + retries: 5 + + # ============================================================ + # FastAPI 后端 — --reload 热更新 + # ============================================================ + backend: + build: + context: ./backend + dockerfile: Dockerfile.dev + container_name: track_backend + restart: unless-stopped + environment: + # 🚀 track 主库用容器名 track_db,避免与 MOM 的 inventory_db(别名 db) 在同一网络下 DNS 解析冲突 + DATABASE_URL: postgresql+asyncpg://track:track_prod_2026@track_db:5432/track_production + SECRET_KEY: change-me-in-production + DEBUG: "true" + # 8010 = PC 管理端;5173/8020 = track-uniapp 的 H5 调试服务 + # (HBuilderX「运行到浏览器」起在 5173,直接跑 vite 按 track-uniapp/vite.config.ts 是 8020)。 + # ⚠️ 漏了 uniapp 的来源时,浏览器会在 CORS 预检阶段就掐掉请求, + # 表现是「点登录没反应」,且控制台只报一句笼统的 404/400。 + CORS_ORIGINS: '["http://localhost:8010","https://localhost:8010","http://192.168.9.80:8010","https://192.168.9.80:8010","http://localhost:5173","https://localhost:5173","http://192.168.9.80:5173","https://192.168.9.80:5173","http://localhost:8020","https://localhost:8020","tauri://localhost"]' + # 🚀 MOM 老系统数据库 — 通过容器名解析(projects_default 网络内 DNS),不再写死 IP + MOM_DB_HOST: inventory_db + MOM_DB_PORT: "5432" + # 🚀 MOM 仓储系统回调 Webhook 验签 Key(与 MOM 侧 TRACK_WEBHOOK_KEY 保持一致) + TRACK_WEBHOOK_KEY: 2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768 + ports: + - "8011:8000" + volumes: + - ./backend:/app + depends_on: + db: + condition: service_healthy + networks: + - default + - mom_net + + # ============================================================ + # Vite + React 前端 — HMR 热更新 + # ============================================================ + frontend: + build: + context: ./frontend + dockerfile: Dockerfile.dev + container_name: track_frontend + restart: unless-stopped + environment: + VITE_API_BASE_URL: /api/v1 + ports: + - "8010:1420" + volumes: + - ./frontend:/app + - /app/node_modules # 匿名卷,不覆盖容器里的 node_modules + depends_on: + - backend + +networks: + mom_net: + external: true + name: projects_default + +volumes: + track_pgdata: + name: track_pgdata diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..515e3ea --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,13 @@ +# ============================================================ +# 前端开发 Dockerfile — Vite + React HMR 热更新 +# ============================================================ +FROM node:20-alpine + +WORKDIR /app + +# 先装依赖(利用 Docker 缓存层) +COPY package.json package-lock.json ./ +RUN npm install + +# 代码通过 docker-compose volumes 挂载 +CMD ["npx", "vite", "--host", "0.0.0.0"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 0000000..731f5a6 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,20 @@ +# ============================================================ +# 前端生产 Dockerfile — 多阶段构建:Vite 打包 → Nginx 托管 +# ============================================================ + +# --- 阶段 1:编译 React --- +FROM node:20-alpine AS builder +WORKDIR /app +# 🚀 构建内存优化:显式放宽 node 堆上限,避免低配服务器 OOM 挂起(卡在 vite build) +ENV NODE_OPTIONS="--max-old-space-size=4096" +COPY package.json package-lock.json ./ +RUN npm ci --production=false +COPY . . +RUN npx vite build + +# --- 阶段 2:Nginx 托管静态文件 --- +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=builder /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..8357eba --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 生产流转 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9da175f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,32 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip 压缩 + gzip on; + gzip_types text/css application/javascript application/json image/svg+xml; + gzip_min_length 256; + + # 静态文件缓存 + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API 代理到后端 + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # SPA 路由回退 + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f53927a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3539 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-virtual": "^3.14.9", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-shell": "^2.3.5", + "antd": "^6.5.3", + "axios": "^1.19.0", + "echarts": "^5.6.0", + "html5-qrcode": "^2.3.8", + "lucide-react": "^1.28.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "tailwindcss": "^4.3.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.4", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.77.0.tgz", + "integrity": "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.77.0.tgz", + "integrity": "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.77.0.tgz", + "integrity": "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.77.0.tgz", + "integrity": "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.77.0.tgz", + "integrity": "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.77.0.tgz", + "integrity": "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.77.0.tgz", + "integrity": "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.77.0.tgz", + "integrity": "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.77.0.tgz", + "integrity": "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.77.0.tgz", + "integrity": "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.77.0.tgz", + "integrity": "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.77.0.tgz", + "integrity": "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.77.0.tgz", + "integrity": "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.77.0.tgz", + "integrity": "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.77.0.tgz", + "integrity": "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.77.0.tgz", + "integrity": "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.77.0.tgz", + "integrity": "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.77.0.tgz", + "integrity": "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.77.0.tgz", + "integrity": "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-6.0.0.tgz", + "integrity": "sha512-D3AGQwdyE58gmvx6waVSXJ80JGO+IY5L2O8HDnSOex7JNlzB3GuN/4hyHNTdhy2qtOhkpbIjmeAN3tL993wKbA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.17.0.tgz", + "integrity": "sha512-3cVNG0zrQF1PoXq262L3wGCU+/YLEC1mGSVHDl577dQmA0ZKkXFbY6nwyXo+beCcM7buo49t24jkr+QZdL7O8w==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.2.tgz", + "integrity": "sha512-uiGpAlblCNlziHPwj4S4Iy/oemeuz/hR03mbiEjTCXwsqOIN3BOzsRMyDwpyO5Fm0vIEEJRUf9ZtbRLbhksuTA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.10.0.tgz", + "integrity": "sha512-eDukNlz9vNszAGv7i3zKXdxEd3wgVmNxuJijYt8zvTh17QwTu8KK/bdURRd/lU4qaMzhO1HKKmMrwOnkaw0BvQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.3.tgz", + "integrity": "sha512-YTST/N6kpqpDz3IMuM/PSSZnrDpSOA6dgHv12gPA90ZTSLv2CoqkZ0+9NtwTY6BeO7dstPblSic2QJg7dSFy/g==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.6.tgz", + "integrity": "sha512-1EXVsSKPZC6kGrIqxVck7kAWM6T65b6et/n5wIcyiaIa41VrDshD0nVLHoBDEIZr2ATQsOP6icb4XQkNFMLBAw==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^6.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.9.0.tgz", + "integrity": "sha512-khF7w7xkBH5B1bsBcI1FSUZdkyd1aqpl2eYyILCqCzzQH3XdfehGUaZTnptyaJJfs09/R5hv9jXWyazOMFIClQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.3.1.tgz", + "integrity": "sha512-iFvTUT9W+JC/MSin2aGAk8NqsVlTzcExNC9DZariON1IWirju9NoNeEk47an4Q8iHazkoVI/y1LnDi88+CPcig==", + "license": "MIT", + "dependencies": { + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.10.0.tgz", + "integrity": "sha512-CI1njYUVY0NjHtLhNoVmXlJyy568Sfep9Wsak6vmGjtT6uazx98djGYlCXz2xkHhEm73g91Y3MTvzUyE5avI7w==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.3.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.4.1.tgz", + "integrity": "sha512-3GsVRoQ4cnF/AoIQ4P+Z1haBfgfBPQfLT1RJY3Nu4DzOnheTslfCiGSPj7bv/cLj5sW5pHqN25dDXGP3JELAlQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.3.tgz", + "integrity": "sha512-Xh3IszxvlSv3/PLYFyC2UZi9LNB83yOnkB/LNmRzaypZLvkhqUIPS7MQpGZcCMWrNsXV2p6YTSWbSGvFpEle9A==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-2.0.7.tgz", + "integrity": "sha512-nqZzpf6BPdaj+3ILx7si79LLmqPKyUmQoXa+/9gg0SkH0v1DbD66oJgRMSBEVnd/zUT3D4gwxWIHUKebYf2ZXQ==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.1.tgz", + "integrity": "sha512-syfmgAABaHCnCDzPwHZ/2tuvIcpOO3jefYZMmfkN+pmo8HKTzsfhS57vxo4ksPdN0By+uWVJhJWNFozNBxi2eA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.4.0.tgz", + "integrity": "sha512-CW1g7P9V8u+e8JQdUsl2RWg+GCsoee0mtJjZUCCxn/vb3jzOwDKm6hAdwddHCVBfWJ58eGUBZz3IvnU8rRktjw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.12.0.tgz", + "integrity": "sha512-0FZGgDiDZFMm2hcfGv4jyjm7yWIj2MiwfeTzLL0vWkO+8cCSKdiM9XhWpkn1aAsoI2EwUy48Oz2cGfvL8ZKrfw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.1.tgz", + "integrity": "sha512-ck+r1kW/JSv0wxPji3KN2ss9K6Z0qqwusw/mf/0JobXhZ8hC2ejZwCJObW/SvDi0uhA0VzmCnx0CaCci95tcmA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-2.0.0.tgz", + "integrity": "sha512-aAv3QhPP1xyafuTZOxub6a54pCeBnN3IwQkpETrBtthq4BL5IgxnCbuoBWPDpdLw1y1j6BgBUCAKV92+yX06Dw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.8.2.tgz", + "integrity": "sha512-HQ9zuYqjfZTlcEMWlU1GAPBajd2OHIMVHyjZSGVTCVARwkfCgvXZMTEn0cduy3L+ejAKkaZluOQvxovZoaJaQw==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.1.1.tgz", + "integrity": "sha512-LSzgWGYDgeCDgR4r1XlU29gbYws6HpLnvJd/uMhLeW/vQgxldeR+Wb4uzHDCHiYEbr1bnEHWdjkPxjJRHxuiig==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.10.4.tgz", + "integrity": "sha512-HwoTnrwc29zeoXkXGhWqzJh8FIibGUxi1jM4LtoSzmR9d5Vv5osUQpZxnXKBP8iOCvyD6BQzZm1nXJRcnrxpAg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.11.0.tgz", + "integrity": "sha512-hA/drZYOVa/MMIb4M2fWf3yaTyTG4qVuIABmghvEhyfw2nBob5VTH69lMCDjSVKmgODjO6nWlCV+gVn3xBrj5Q==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.4.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.4.0.tgz", + "integrity": "sha512-aui4r4TqmTzwaBgcQxHYep8kM8PTjZFufjokObpy35KfFeZ0k9ArquWFZqegQlH24P14t+F0qO0mGTgzlav1yg==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.3.2.tgz", + "integrity": "sha512-bJFj46wEkpBPnWyTm18XmgAgNQ/4YvprxMOPPY2a6rmhGJYxLuNKEFiL5Qej4Qctu9wHJm8WW+v2SYskafE0kA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.11.1", + "@rc-component/virtual-list": "^1.2.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.11.0.tgz", + "integrity": "sha512-EhS0X0wtUhBfK4S5TlpSY3MR9ndPMGgujtt1PJW3Ej+ToAlnS/6ohYURtCoXBYGqazUwHmgQGVUDsfpVwhWPkg==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.8.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.10.1.tgz", + "integrity": "sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.3.3", + "@rc-component/portal": "^2.2.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.1.tgz", + "integrity": "sha512-GvYWSKeaJTOxxC5p6+nOSadzfvXA1h8C/iHFPFZX+szH3JUXrvs+DLiW8YUTBgvMh8m63mJeHrlYlJzAlg+pDA==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.11.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.12.0.tgz", + "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.5.1.tgz", + "integrity": "sha512-boqHxdtyWC88u8quYgEO49bcBy5fzRiOcnBge+N4nLzs2k8hUQ/yw7JE9dM6yCBE4jSm5YSHVCVMS+suBuJGKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^8.0.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list/node_modules/@babel/runtime": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.0.tgz", + "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tauri-apps/api": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", + "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-basic-ssl": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", + "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/antd": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.5.3.tgz", + "integrity": "sha512-Q5r8sztf9Yk9B70bSUjnPYMCJ4A/eZM7uMoTj8UAhlSKR9aftjEuBEPcNSmRux7hB+87rxO8vN1X4HNjR97qyQ==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.3.2", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.29.2", + "@rc-component/cascader": "~1.17.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.10.0", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.3", + "@rc-component/form": "~1.8.6", + "@rc-component/image": "~1.9.0", + "@rc-component/input": "~1.3.1", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.10.0", + "@rc-component/menu": "~1.4.1", + "@rc-component/motion": "^1.3.3", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~2.0.7", + "@rc-component/pagination": "~1.4.0", + "@rc-component/picker": "~1.12.0", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~2.0.0", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.8.2", + "@rc-component/slider": "~1.1.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.10.4", + "@rc-component/tabs": "~1.11.0", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.4.0", + "@rc-component/tree": "~1.3.2", + "@rc-component/tree-select": "~1.11.0", + "@rc-component/trigger": "^3.10.1", + "@rc-component/upload": "~1.1.1", + "@rc-component/util": "^1.12.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.77.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.77.0.tgz", + "integrity": "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.77.0", + "@oxlint/binding-android-arm64": "1.77.0", + "@oxlint/binding-darwin-arm64": "1.77.0", + "@oxlint/binding-darwin-x64": "1.77.0", + "@oxlint/binding-freebsd-x64": "1.77.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", + "@oxlint/binding-linux-arm-musleabihf": "1.77.0", + "@oxlint/binding-linux-arm64-gnu": "1.77.0", + "@oxlint/binding-linux-arm64-musl": "1.77.0", + "@oxlint/binding-linux-ppc64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-gnu": "1.77.0", + "@oxlint/binding-linux-riscv64-musl": "1.77.0", + "@oxlint/binding-linux-s390x-gnu": "1.77.0", + "@oxlint/binding-linux-x64-gnu": "1.77.0", + "@oxlint/binding-linux-x64-musl": "1.77.0", + "@oxlint/binding-openharmony-arm64": "1.77.0", + "@oxlint/binding-win32-arm64-msvc": "1.77.0", + "@oxlint/binding-win32-ia32-msvc": "1.77.0", + "@oxlint/binding-win32-x64-msvc": "1.77.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..de034a3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "tauri": "tauri", + "tauri:dev": "tauri dev", + "tauri:build": "tauri build", + "tauri:android:init": "tauri android init", + "tauri:android:build": "tauri android build" + }, + "dependencies": { + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-virtual": "^3.14.9", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/plugin-shell": "^2.3.5", + "antd": "^6.5.3", + "axios": "^1.19.0", + "echarts": "^5.6.0", + "html5-qrcode": "^2.3.8", + "lucide-react": "^1.28.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.2", + "tailwindcss": "^4.3.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.4", + "@types/node": "^24.13.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-basic-ssl": "^2.3.0", + "@vitejs/plugin-react": "^6.0.4", + "oxlint": "^1.75.0", + "typescript": "~6.0.2", + "vite": "^8.2.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml new file mode 100644 index 0000000..8c83c02 --- /dev/null +++ b/frontend/src-tauri/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "track" +version = "0.1.0" +description = "工厂生产流转管理系统" +authors = ["you"] +edition = "2021" + +[lib] +name = "track_lib" +crate-type = ["lib", "cdylib", "staticlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/frontend/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json new file mode 100644 index 0000000..b9a3f11 --- /dev/null +++ b/frontend/src-tauri/capabilities/default.json @@ -0,0 +1,9 @@ +{ + "identifier": "default", + "description": "默认权限配置", + "windows": ["main"], + "permissions": [ + "core:default", + "shell:default" + ] +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs new file mode 100644 index 0000000..e89e1c4 --- /dev/null +++ b/frontend/src-tauri/src/lib.rs @@ -0,0 +1,7 @@ +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs new file mode 100644 index 0000000..f91b35e --- /dev/null +++ b/frontend/src-tauri/src/main.rs @@ -0,0 +1,14 @@ +// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +#[tauri::command] +fn greet(name: &str) -> String { + format!("Hello, {}! You've been greeted from Rust!", name) +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .invoke_handler(tauri::generate_handler![greet]) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json new file mode 100644 index 0000000..9d158b2 --- /dev/null +++ b/frontend/src-tauri/tauri.conf.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://raw.githubusercontent.com/nickolay/tauri-docs/refs/heads/v2/src/content/docs/_schema/config.schema.json", + "productName": "track", + "version": "0.1.0", + "identifier": "com.track.production", + "build": { + "frontendDist": "../dist", + "devUrl": "http://localhost:1420", + "beforeDevCommand": "npm run dev", + "beforeBuildCommand": "npm run build" + }, + "app": { + "title": "生产流转", + "windows": [ + { + "title": "工厂生产流转管理系统", + "width": 390, + "height": 844, + "resizable": true, + "fullscreen": false + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e774b91 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,93 @@ +import { Suspense, lazy } from "react"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { App as AntApp, ConfigProvider } from "antd"; +import zhCN from "antd/locale/zh_CN"; +import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; + +// ── dayjs 中文 locale ──────────────────────────────────────────────── +// 必须设在应用根模块,不能下放到各页面。 +// +// antd 的 ConfigProvider locale 只负责「请选择日期 / 今天 / 此刻」这类**文案**; +// 日期面板上的**月份与星期名**是 rc-picker 交给 dayjs 取的 —— +// @rc-component/picker/es/generate/dayjs.js 里: +// getShortWeekDays: locale => dayjs().locale(parseLocale(locale)).localeData().weekdaysMin() +// parseLocale 把 antd 传来的 "zh_CN"(下划线)在 localeMap 未命中后按 '_' 切分, +// 得到 "zh",最终查的是 dayjs 的 locale 表 —— 不注册就永远是默认的 en。 +// +// 此前这两行被分别写在 AdminPeoplePage / AnalyticsDashboard 两个页面里,而路由是 +// lazy() 代码分割:直接打开 /admin/dashboard 或 /admin/matrix 时那两个模块根本没 +// 被加载,dayjs 仍是 en,面板就露出 Sep / Su / Mo —— 表现为「有的页面中文、有的 +// 页面英文」。放到根模块可保证任何路由下都先生效。 +dayjs.locale("zh-cn"); + +import { ToastProvider } from "./components/ui/Toast"; +import { AuthProvider } from "./contexts/AuthContext"; +import LoadingSpinner from "./components/ui/LoadingSpinner"; + +// Layout 组件静态导入(始终需要,体积小) +import AppLayout from "./components/layout/AppLayout"; +import AdminLayout from "./components/layout/AdminLayout"; + +// 🚀 路由级代码分割 — 按需懒加载页面组件 +const ScanPage = lazy(() => import("./pages/ScanPage")); +const MyTasksPage = lazy(() => import("./pages/MyTasksPage")); +const NotificationsPage = lazy(() => import("./pages/NotificationsPage")); +const ProfilePage = lazy(() => import("./pages/ProfilePage")); + +const AdminLoginPage = lazy(() => import("./pages/admin/AdminLoginPage")); +const AdminDashboard = lazy(() => import("./pages/admin/AdminDashboard")); +const AdminProductsPage = lazy(() => import("./pages/admin/AdminProductsPage")); +const AdminTasksPage = lazy(() => import("./pages/admin/AdminTasksPage")); +const AdminPeoplePage = lazy(() => import("./pages/admin/AdminPeoplePage")); +const AdminPrintConfigPage = lazy(() => import("./pages/admin/AdminPrintConfigPage")); +const AdminAuditLogPage = lazy(() => import("./pages/admin/AdminAuditLogPage")); +const AnalyticsDashboard = lazy(() => import("./pages/admin/AnalyticsDashboard")); +const MatrixBoard = lazy(() => import("./pages/MatrixBoard")); +const ScreenDashboard = lazy(() => import("./pages/admin/ScreenDashboard")); + +export default function App() { + return ( + + + + + + }> + + {/* 移动端 */} + } /> + }> + } /> + } /> + } /> + } /> + + + {/* PC 管理端 — 登录页(独立,无侧边栏) */} + } /> + + {/* PC 管理端 — 管理层数据大屏(独立全屏,无侧边栏,组件内自带鉴权守卫) */} + } /> + + {/* PC 管理端 — 需要登录 */} + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + + + + ); +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/BaseEChart.tsx b/frontend/src/components/BaseEChart.tsx new file mode 100644 index 0000000..d66b705 --- /dev/null +++ b/frontend/src/components/BaseEChart.tsx @@ -0,0 +1,83 @@ +/** 轻量级 ECharts 封装 — 原生 echarts/core + ResizeObserver 响应式缩放 + * 支持 onEvents(常规事件)与 onZrClick(ZRender 底层点击,扩大热区)。 */ +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { LineChart, BarChart, CustomChart, GaugeChart } from "echarts/charts"; +import { + GridComponent, TooltipComponent, LegendComponent, DataZoomComponent, GraphicComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import type { EChartsCoreOption } from "echarts/core"; + +// 按需注册(新增图表/组件时在此追加,避免全量打包) +echarts.use([ + LineChart, + BarChart, + CustomChart, + GaugeChart, // 仪表盘(预留,当前大屏未使用) + GridComponent, + TooltipComponent, + LegendComponent, + DataZoomComponent, + GraphicComponent, // 大屏饼图中心文字(graphic 需显式注册,否则静默不渲染) + CanvasRenderer, +]); + +type EChartsInstance = ReturnType; + +interface BaseEChartProps { + option: EChartsCoreOption; + height?: number | string; + className?: string; + onEvents?: Record void>; + onZrClick?: (chart: EChartsInstance, event: any) => void; +} + +export default function BaseEChart({ option, height = 380, className, onEvents, onZrClick }: BaseEChartProps) { + const containerRef = useRef(null); + const chartRef = useRef(null); + const eventsRef = useRef(onEvents); + eventsRef.current = onEvents; + const onZrClickRef = useRef(onZrClick); + onZrClickRef.current = onZrClick; + + // 初始化(仅一次):init → setOption → 注册事件 → ResizeObserver + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const chart = echarts.init(el); + chartRef.current = chart; + chart.setOption(option); + + // 常规 ECharts 事件(读 eventsRef,保证始终用最新 handler) + Object.keys(eventsRef.current ?? {}).forEach((event) => { + chart.on(event, (params: any) => { + eventsRef.current?.[event]?.(params); + }); + }); + + // ZRender 底层点击(点击列阴影/背景也能触发,热区更大) + chart.getZr().on("click", (e: any) => { + onZrClickRef.current?.(chart, e); + }); + + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(el); + + return () => { + observer.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + // 仅挂载时执行一次;option 更新交给下面的 effect + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // option 更新:整表替换,避免 merge 残留旧 series + useEffect(() => { + chartRef.current?.setOption(option, { notMerge: true }); + }, [option]); + + return
; +} diff --git a/frontend/src/components/QrScanner/QrScanner.tsx b/frontend/src/components/QrScanner/QrScanner.tsx new file mode 100644 index 0000000..2ee2583 --- /dev/null +++ b/frontend/src/components/QrScanner/QrScanner.tsx @@ -0,0 +1,225 @@ +/** + * 摄像头扫码组件 — 基于 html5-qrcode + * + * 策略:getCameras() 列出所有 → 关键词匹配后置 → 取末位 + * 如果 getCameras() 失败 → facingMode: "environment" + * 用户可以点切换按钮循环切换摄像头 + */ +import { useEffect, useRef, useCallback, useState } from "react"; +import { + Html5Qrcode, + Html5QrcodeSupportedFormats, + type CameraDevice, +} from "html5-qrcode"; +import { Camera, Loader2, Repeat } from "lucide-react"; + +interface QrScannerProps { + onScan: (decodedText: string) => void; + onError?: (error: string) => void; + active: boolean; + width?: string; + height?: string; +} + +const SCANNER_ID = "qr-scanner-viewport"; +const REAR_KEYWORDS = ["back", "environment", "rear", "后置", "背面", "后面"]; + +export default function QrScanner({ + onScan, + onError, + active, + width = "100%", + height = "260px", +}: QrScannerProps) { + const scannerRef = useRef(null); + const isRunningRef = useRef(false); + const lastScanRef = useRef(""); + const cooldownRef = useRef(false); + const [status, setStatus] = useState<"idle" | "starting" | "running" | "error">("idle"); + const [errorMsg, setErrorMsg] = useState(""); + const [cameras, setCameras] = useState([]); + const [currentCameraIdx, setCurrentCameraIdx] = useState(0); + + const handleDecode = useCallback( + (decodedText: string) => { + if (cooldownRef.current && decodedText === lastScanRef.current) return; + lastScanRef.current = decodedText; + cooldownRef.current = true; + setTimeout(() => { cooldownRef.current = false; }, 2000); + if (navigator.vibrate) navigator.vibrate(100); + onScan(decodedText); + }, + [onScan] + ); + + // 启动摄像头 — 每次用全新实例,确保摄像头硬件切换 + const startCamera = useCallback( + async (cameraIdx: number, devices: CameraDevice[]) => { + setCurrentCameraIdx(cameraIdx); + setStatus("starting"); + + try { + // 1. 彻底销毁旧实例 + if (scannerRef.current) { + try { await scannerRef.current.stop(); } catch {} + try { await scannerRef.current.clear(); } catch {} + scannerRef.current = null; + } + isRunningRef.current = false; + + // 2. 清空容器 DOM(避免残留 video 元素) + const container = document.getElementById(SCANNER_ID); + if (container) container.innerHTML = ""; + + // 3. 创建全新实例 + const scanner = new Html5Qrcode(SCANNER_ID, { + verbose: false, + useBarCodeDetectorIfSupported: true, + formatsToSupport: [ + Html5QrcodeSupportedFormats.CODE_128, + Html5QrcodeSupportedFormats.QR_CODE, + ], + }); + scannerRef.current = scanner; + + // 4. 选择摄像头 + const cameraConfig = + devices.length > 0 && cameraIdx < devices.length + ? { deviceId: { exact: devices[cameraIdx].id } } + : { facingMode: "environment" as const }; + + // 5. 启动 + await scanner.start( + cameraConfig, + { + fps: 20, + videoConstraints: { + width: { min: 1280, ideal: 2560 }, + height: { min: 720, ideal: 1440 }, + }, + }, + handleDecode, + () => {} + ); + isRunningRef.current = true; + setStatus("running"); + } catch (err: unknown) { + setStatus("error"); + setErrorMsg(err instanceof Error ? err.message : "启动失败"); + onError?.(err instanceof Error ? err.message : "无法启动摄像头"); + } + }, + [handleDecode, onError] + ); + + // 切换摄像头 + const switchCamera = useCallback(async () => { + if (cameras.length < 2) return; + const nextIdx = (currentCameraIdx + 1) % cameras.length; + await startCamera(nextIdx, cameras); + }, [cameras, currentCameraIdx, startCamera]); + + useEffect(() => { + let cancelled = false; + + if (!active) { + if (scannerRef.current) { + try { scannerRef.current.stop(); } catch {} + try { scannerRef.current.clear(); } catch {} + scannerRef.current = null; + } + isRunningRef.current = false; + const container = document.getElementById(SCANNER_ID); + if (container) container.innerHTML = ""; + setStatus("idle"); + return; + } + + const init = async () => { + // 枚举摄像头 + let devices: CameraDevice[] = []; + try { + devices = await Html5Qrcode.getCameras(); + } catch {} + if (cancelled) return; + setCameras(devices); + + // 选初始摄像头:关键词匹配后置 → 末位 + let startIdx = 0; + if (devices.length > 0) { + const rearIdx = devices.findIndex((d) => + REAR_KEYWORDS.some((kw) => d.label.toLowerCase().includes(kw)) + ); + startIdx = rearIdx >= 0 ? rearIdx : devices.length - 1; + } + + await startCamera(startIdx, devices); + }; + + init(); + + return () => { + cancelled = true; + if (scannerRef.current) { + try { scannerRef.current.stop(); } catch {} + try { scannerRef.current.clear(); } catch {} + scannerRef.current = null; + } + isRunningRef.current = false; + }; + }, [active, startCamera, onError]); + + return ( +
+
+ + {!active && ( +
+ +

点击按钮启动后置摄像头

+
+ )} + + {status === "starting" && ( +
+ +
+ )} + + {status === "running" && ( + <> + {/* 当前摄像头标签 */} +
+ + {cameras.length > 0 && currentCameraIdx < cameras.length + ? cameras[currentCameraIdx].label + : "摄像头"} · 扫描中 + +
+ + {/* 切换摄像头按钮 — 只在有多个摄像头时显示 */} + {cameras.length >= 2 && ( + + )} + + )} + + {status === "error" && ( +
+

摄像头不可用

+

{errorMsg}

+
+ )} +
+ ); +} diff --git a/frontend/src/components/TaskTree/TaskFlowView.tsx b/frontend/src/components/TaskTree/TaskFlowView.tsx new file mode 100644 index 0000000..fb84e60 --- /dev/null +++ b/frontend/src/components/TaskTree/TaskFlowView.tsx @@ -0,0 +1,354 @@ +/** + * 流转树双模式可视化 — 焦点模式 + 全景模式 + */ +import { memo, useMemo, useState, useEffect } from "react"; +import { Image } from "antd"; +import { Package, User, X } from "lucide-react"; +import type { TaskResponse } from "../../types/api"; +import { TASK_STATUS } from "../../types/api"; +import { getStatusConfig } from "../../constants/task"; +import type { ModalTarget } from "./TaskTreeViewer"; + +// ============================================================ +// 工具 +// ============================================================ +function fmtTime(d: string | null) { if (!d) return ""; const dt = new Date(d); const pad = (n: number) => String(n).padStart(2, "0"); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; } +function isMain(t: TaskResponse) { return !t.parent_task_id || t.task_type === "TRANSFER" || t.task_type === "RECOVERY" || t.task_type === "WAREHOUSE"; } +function active(s: string) { return s === "WIP" || s === "PENDING"; } +/** 微型右箭头 SVG */ +function ArrowRight({ color = "#9ca3af", big }: { color?: string; big?: boolean }) { + return ; +} +/** 微型左箭头 SVG */ +function ArrowLeft({ color = "#9ca3af", big }: { color?: string; big?: boolean }) { + return ; +} +function parseImages(s: any): string[] { + if (!s) return []; + if (Array.isArray(s)) return s; // Pydantic 序列化后的数组 + if (typeof s === "string") { try { return JSON.parse(s); } catch { return []; } } + return []; +} +function imageUrl(u: string) { + if (!u) return ""; + if (u.startsWith("http")) return u; // 已是完整绝对地址(含跨域域名),直接用 + const base = (import.meta.env.VITE_API_BASE_URL || "").replace(/\/+$/, ""); // 去掉末尾斜杠 + const path = u.startsWith("/") ? u : "/" + u; + // 后端图片固定返回 /api/v1/upload/files/... 这类 /api/ 开头的相对路径, + // 需拼上 base 才能访问到后端;同时避免重复前缀: + // - base 为纯域名(如 https://track_back.iris-rs.cn)→ 正常 base + path + // - base 以 /api 或 /api/v1 结尾 → 先剥掉该段,否则会拼出 /api/api/ 或 /api/v1/api/v1 + if (path.startsWith("/api/")) { + const origin = base.replace(/\/api(\/v\d+)?$/, ""); + return origin + path; + } + return base + path; +} + +const ALL_TASKS = new Set(); +function collectAll(tasks: TaskResponse[]) { tasks.forEach(t => { ALL_TASKS.add(t); if (t.child_tasks) collectAll(t.child_tasks); }); } +function findParent(child: TaskResponse): TaskResponse | undefined { for (const t of ALL_TASKS) { if (t.id === child.parent_task_id) return t; } return undefined; } + +// ============================================================ +// 尺寸映射表(sm=现状小卡片,lg=弹窗放大) +// ============================================================ +const SIZE_MAP = { + sm: { + card: "w-44 p-2.5 shadow-sm", + badge: "-top-1.5 right-2 px-1.5 py-px text-[8px]", + title: "mt-1 text-xs", + metaRow: "mt-1", + status: "px-1.5 py-px text-[8px]", + assignee: "text-[9px]", + time: "mt-1 text-[8px]", + sub: "mt-1 text-[8px]", + btnRow: "mt-1.5 pt-1.5", + btn: "py-0.5 text-[8px]", + record: "mt-1 px-1.5 py-0.5 text-[8px]", + recordIcon: "h-2.5 w-2.5", + }, + lg: { + card: "w-72 p-4 shadow-md", + badge: "-top-2 right-3 px-2 py-0.5 text-xs", + title: "mt-1.5 text-base", + metaRow: "mt-2", + status: "px-2 py-0.5 text-xs", + assignee: "text-sm", + time: "mt-2 text-xs", + sub: "mt-1.5 text-xs", + btnRow: "mt-2.5 pt-2.5", + btn: "py-1.5 text-sm", + record: "mt-2 px-2.5 py-1 text-xs", + recordIcon: "h-4 w-4", + }, +} as const; + +// ============================================================ +// 极简卡片 +// ============================================================ +const SlimCard = memo(function SlimCard({ + task, assigneeName, active, legacy, onAction, currentUser, onViewRecords, rootMainId, size = "sm", +}: { + task: TaskResponse; assigneeName?: string; active: boolean; legacy?: boolean; + onAction: (t: ModalTarget) => void; currentUser?: { username?: string; role?: string } | null; + onViewRecords?: (t: TaskResponse) => void; + rootMainId?: string; + size?: "sm" | "lg"; +}) { + const cfg = getStatusConfig(task.status); + const sz = SIZE_MAP[size]; + const isOwner = !!(currentUser?.username && task.assignee_id === currentUser.username); + const isManager = currentUser?.role === "SUPER_ADMIN" || currentUser?.role === "SUPERVISOR"; + const main = isMain(task); + const isNestedSpawn = !main && rootMainId && task.parent_task_id !== rootMainId && !!task.parent_task_id; + // 📥 在库/入库任务:显示"谁转入在库"(创建该任务的人) + const isWarehouse = !!( + task.task_name?.includes("在库") || + task.task_name?.includes("入库") || + task.assignee_id === "virtual_warehouse" + ); + + return ( +
+ {/* 左上角:主线/分支标签 + 状态标签并排 */} +
+ {main ? "主线" : "分支"} + {cfg.label} +
+

{task.task_name}

+ {/* 操作人(带用户图标)+ 在库转入人;仓储任务防御性渲染为系统节点 */} +
+ {(() => { + const isSystemTask = task.task_name?.includes("扫码") || task.task_type === "WAREHOUSE"; + if (isSystemTask) { + // ★ 仓储系统任务:不请求用户数据,直接显示系统图标 + 固定名(assignee_id 为 null 不报错) + return ( + + MOM 仓储系统 + + ); + } + if (isWarehouse && task.assignee_id) { + return ( + 📥 转入在库: {assigneeName || task.assignee_id} + ); + } + return ( + + {assigneeName || task.assignee_id || "—"} + + ); + })()} +
+ {/* 单行时间(精确到分钟) */} +

+ ⏰ {fmtTime(task.created_at)} + {task.completed_at ? ` → ${fmtTime(task.completed_at)}` : " → 至今"} +

+ {/* 驳回/返工原因:原 REJECTED 取 reject_reason;返工节点取含“驳回/返工”的备注 */} + {(() => { + const rj = task.reject_reason; + const rm = task.remark; + const txt = rj ? `❌ 驳回: ${rj}` : (rm && /驳回|返工/.test(rm) ? rm : null); + return txt ?

{txt}

: null; + })()} + {legacy &&

源自: {assigneeName || (findParent(task)?.assignee_id) || "历史任务"}

} + {isNestedSpawn &&

协助: {findParent(task)?.assignee_id || "—"}

} + {/* 操作按钮 */} + {active && isOwner && ( +
+ {task.status?.toUpperCase() === TASK_STATUS.PENDING && } + {task.status?.toUpperCase() !== TASK_STATUS.PENDING && } + +
+ )} + {active && !isOwner && isManager && ( +
+ + +
+ )} + {/* 操作日志:全行蓝色横条,铺满卡片宽度(含 padding+border),整条可点击 */} + {task.records && task.records.length > 0 && ( +
{ e.stopPropagation(); onViewRecords?.(task); }} + className={`${size === "lg" ? "-mx-[17px] -mb-[17px]" : "-mx-[11px] -mb-[11px]"} mt-3 py-1.5 bg-blue-50 text-blue-600 text-center text-sm font-medium cursor-pointer transition-colors hover:bg-blue-100 border-t border-blue-100 rounded-b-lg`} + > + 查看操作日志 ({task.records.length}条) +
+ )} +
+ ); +}); + +// ============================================================ +// 主视图 +// ============================================================ +interface TaskFlowViewProps { + tasks: TaskResponse[]; onAction: (t: ModalTarget) => void; + currentUser?: { username?: string; role?: string } | null; + assigneeNames?: Record; + size?: "sm" | "lg"; +} + +export const TaskFlowView = memo(function TaskFlowView({ tasks, onAction, currentUser, assigneeNames, size = "sm" }: TaskFlowViewProps) { + const [showFullMap, setShowFullMap] = useState(false); + const [recordsTask, setRecordsTask] = useState(null); + + // 🚀 记录弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复 + useEffect(() => { + if (!recordsTask) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = prev; }; + }, [recordsTask]); + + // 数据分类 — 🚀 仅根级主干作为垂直时间线节点,子节点通过 childMap 分支递归渲染 + const { allMains, childMap } = useMemo(() => { + ALL_TASKS.clear(); if (tasks.length) collectAll(tasks); + const all = Array.from(ALL_TASKS); + // 🚀 收集所有主线任务(全部进入中央垂直主轴) + const mains: TaskResponse[] = []; + for (const t of all) { + if (isMain(t)) mains.push(t); + } + mains.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); + // 🚀 构建全局 childMap(按 parent_task_id 索引直接子节点,保留真实树结构) + const childMap: Record = {}; + for (const t of all) { + const pid = t.parent_task_id || ''; + if (!childMap[pid]) childMap[pid] = []; + childMap[pid].push(t); + } + Object.values(childMap).forEach(arr => arr.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())); + return { allMains: mains, childMap }; + }, [tasks]); + + // 🚀 递归渲染分支节点 — 每个节点从自己的 childMap 获取直系子孙,保持树结构不断裂 + const renderBranch = (node: TaskResponse, side: 'left' | 'right', isLegacy: boolean, rootMainId: string): React.JSX.Element => { + const kids = childMap[node.id] || []; + const big = size === "lg"; + const arrow = side === 'left' + ? (
) + : (
); + const card = ; + const kidsContainer = kids.length > 0 ? ( +
+ {kids.map(k => renderBranch(k, side, isLegacy, rootMainId))} +
+ ) : null; + return ( +
+ {side === 'left' && kidsContainer} + {side === 'left' && card} + {arrow} + {side === 'right' && card} + {side === 'right' && kidsContainer} +
+ ); + }; + + // 🚀 焦点模式 vs 全景模式:共用同一套垂直时间线布局,仅数据过滤不同 + const visibleMains = useMemo(() => { + if (showFullMap) return allMains; + // 焦点模式:保留自身活跃的主线,或含活跃分支(后代)的主线, + // 避免「主线已完成、但并发协助分支仍在进行中」时整条分支被错误隐藏 + const hasActiveDescendant = (node: TaskResponse): boolean => { + const kids = childMap[node.id] || []; + return kids.some(k => active(k.status) || hasActiveDescendant(k)); + }; + return allMains.filter(t => active(t.status) || hasActiveDescendant(t)); + }, [allMains, childMap, showFullMap]); + + return ( +
+ {/* 模式切换 */} +
+ +
+ + {/* ─── 统一垂直时间线布局(焦点/全景共用) ─── */} +
+ {visibleMains.map((mainTask, mainIdx) => { + // 🔧 侧翼严格过滤:主线归主轴,仅 SPAWN 协助分支进入左右翼 + const directChildren = (childMap[mainTask.id] || []).filter(c => !isMain(c)); + const hasLegacyActive = directChildren.some(c => !isMain(c) && !active(mainTask.status)); + const leftDirect = directChildren.filter((_, i) => i % 2 === 0); + const rightDirect = directChildren.filter((_, i) => i % 2 === 1); + const lineStyle = hasLegacyActive && !active(mainTask.status); + + return ( +
+ {/* 中央垂直线:按行渲染;首行从卡片中间起,末行到卡片中间止,避免冒出/多截 */} +
+
+ {/* 左翼 — 递归渲染,子子孙孙向外延伸 */} +
+ {leftDirect.map(c => renderBranch(c, 'left', lineStyle, mainTask.id))} +
+ {/* 中央 */} +
+ + {active(mainTask.status) && ( +
+ )} +
+ {/* 右翼 — 递归渲染,子子孙孙向外延伸 */} +
+ {rightDirect.map(c => renderBranch(c, 'right', lineStyle, mainTask.id))} +
+
+ + {visibleMains.indexOf(mainTask) < visibleMains.length - 1 && ( +
+ +
+ )} +
+ ); + })} + {visibleMains.length === 0 && ( +

+ {showFullMap ? "暂无流转记录" : "当前无活跃主线任务"} +

+ )} +
+ + {/* 记录弹窗 */} + {recordsTask && ( +
+
setRecordsTask(null)} /> +
+

提交记录 — {recordsTask.task_name}

+ {(recordsTask.records || []).length === 0 ?

暂无记录

: +
{[...recordsTask.records!].reverse().map((r, i) => ( +
+
+

{fmtTime(r.created_at)}

+ {r.remark &&

{r.remark}

} + {(() => { const imgs = parseImages(r.images); if (!imgs.length) return null; return ( + +
{imgs.map((img, j) => )}
+
+ ); })()} +
+
+ ))}
} +
+
+ )} +
+ ); +}); + +export default TaskFlowView; diff --git a/frontend/src/components/TaskTree/TaskTreeViewer.tsx b/frontend/src/components/TaskTree/TaskTreeViewer.tsx new file mode 100644 index 0000000..fd06858 --- /dev/null +++ b/frontend/src/components/TaskTree/TaskTreeViewer.tsx @@ -0,0 +1,885 @@ +import { useState, useCallback, useMemo, useEffect, memo } from "react"; +import { + Search, + Loader2, + AlertCircle, + GitBranch, + RefreshCw, + X, + Plus, + Warehouse, + UserPlus, +} from "lucide-react"; +import TaskFlowView from "./TaskFlowView"; +import { + getTaskTree, + receiveTask, + rejectTask, + transferTask, +} from "../../services/taskApi"; +import { useToast } from "../ui/Toast"; +import type { ProductScanResponse, TaskResponse } from "../../types/api"; +import { getStatusConfig, isAdminRole } from "../../constants/task"; +import { useAuth } from "../../contexts/AuthContext"; +import ImageUploader from "../ui/ImageUploader"; +import { extractErrorMessage } from "../../utils/errorMessage"; + +// ============================================================ +// 通用 Modal 容器 +// ============================================================ + +export const Modal = memo(function Modal({ + open, + onClose, + title, + children, + widthClass = "max-w-md", + bodyClassName = "", +}: { + open: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + widthClass?: string; + bodyClassName?: string; +}) { + // 🚀 弹窗打开时锁定背景滚动(防止滚动穿透),关闭时恢复 + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = prev; }; + }, [open]); + + if (!open) return null; + + return ( +
+ {/* 遮罩 */} +
+ {/* 弹窗 */} +
+
+

{title}

+ +
+ {children} +
+
+ ); +}); + +// ============================================================ +// 确认接收弹窗 +// ============================================================ + +export const ReceiveConfirmModal = memo(function ReceiveConfirmModal({ + open, + task, + submitting, + onClose, + onConfirm, +}: { + open: boolean; + task: TaskResponse | null; + submitting: boolean; + onClose: () => void; + onConfirm: () => void; +}) { + if (!task) return null; + + return ( + +
+

{task.task_name}

+

+ 当前状态: {getStatusConfig(task.status).label} + {" → "} + 进行中 (WIP) +

+
+

+ 确认接收后,该任务将标记为"进行中",系统将记录接收时间。 +

+
+ + +
+
+ ); +}); + +// ============================================================ +// 品质驳回弹窗 +// ============================================================ + +export const RejectModal = memo(function RejectModal({ + open, + task, + submitting, + onClose, + onSubmit, +}: { + open: boolean; + task: TaskResponse | null; + submitting: boolean; + onClose: () => void; + onSubmit: (reason: string, images: string[]) => void; +}) { + const [reason, setReason] = useState(""); + const [images, setImages] = useState([]); + + if (!task) return null; + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = reason.trim(); + if (!trimmed) return; + // 异常图片为选填:编号错误、选错工序等场景无需拍照举证,不再拦截无图提交 + onSubmit(trimmed, images); + setReason(""); + setImages([]); + } + + function handleClose() { + setReason(""); + setImages([]); + onClose(); + } + + return ( + +
+

{task.task_name}

+

+ ⚠ 驳回后将自动生成返工任务,分配给上一道工序负责人 +

+
+
+ +