chore: fork from IRIS track 供 LICA 部门独立运行
- 复制来源: /home/yueli/track @ 192c8ee (feature/ai-audit-update) - 组织隔离目标: LICA - 端口规划: 前端 8030 / 后端 8031 / 数据库 8032 - 已排除 deploy.sh、deploy_full.sh、docker-compose.prod.yml(IRIS 生产发布脚本) - 已排除工作区未提交改动,取干净的 192c8ee 状态
This commit is contained in:
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@ -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
|
||||
63
AGENTS.md
Normal file
63
AGENTS.md
Normal file
@ -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:<port>/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`),
|
||||
避免前端再抄一份映射开始漂移。
|
||||
- 本仓库的提交信息用中文,说明「为什么」而非「改了什么」。
|
||||
13
backend/.env.example
Normal file
13
backend/.env.example
Normal file
@ -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
|
||||
27
backend/Dockerfile.dev
Normal file
27
backend/Dockerfile.dev
Normal file
@ -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"]
|
||||
150
backend/alembic.ini
Normal file
150
backend/alembic.ini
Normal file
@ -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 <script_location>/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
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
59
backend/alembic/env.py
Normal file
59
backend/alembic/env.py
Normal file
@ -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())
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@ -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"}
|
||||
@ -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 ###
|
||||
@ -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';
|
||||
""")
|
||||
24
backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py
Normal file
24
backend/alembic/versions/a7b8c9d0e1f2_add_task_type.py
Normal file
@ -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")
|
||||
@ -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;")
|
||||
32
backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py
Normal file
32
backend/alembic/versions/b8c9d0e1f2a3_add_notifications.py
Normal file
@ -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")
|
||||
@ -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')
|
||||
36
backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py
Normal file
36
backend/alembic/versions/c9d0e1f2a3b4_add_app_versions.py
Normal file
@ -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")
|
||||
@ -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')
|
||||
35
backend/alembic/versions/e5f6a7b8c9d0_add_task_records.py
Normal file
35
backend/alembic/versions/e5f6a7b8c9d0_add_task_records.py
Normal file
@ -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')
|
||||
23
backend/alembic/versions/f6a7b8c9d0e1_add_task_remark.py
Normal file
23
backend/alembic/versions/f6a7b8c9d0e1_add_task_remark.py
Normal file
@ -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")
|
||||
@ -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")
|
||||
32
backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py
Normal file
32
backend/alembic/versions/h1h2h3h4h5h6_add_holidays.py
Normal file
@ -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')
|
||||
@ -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")
|
||||
81
backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
Normal file
81
backend/alembic/versions/j1k2l3m4n5o6_add_audit_logs.py
Normal file
@ -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")
|
||||
53
backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
Normal file
53
backend/alembic/versions/k1l2m3n4o5p6_add_user_daily_seen.py
Normal file
@ -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")
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Track Production API
|
||||
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""API v1 端点"""
|
||||
75
backend/app/api/v1/endpoints/analytics.py
Normal file
75
backend/app/api/v1/endpoints/analytics.py
Normal file
@ -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)
|
||||
87
backend/app/api/v1/endpoints/app_version.py
Normal file
87
backend/app/api/v1/endpoints/app_version.py
Normal file
@ -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
|
||||
292
backend/app/api/v1/endpoints/audit.py
Normal file
292
backend/app/api/v1/endpoints/audit.py
Normal file
@ -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),
|
||||
)
|
||||
80
backend/app/api/v1/endpoints/auth.py
Normal file
80
backend/app/api/v1/endpoints/auth.py
Normal file
@ -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"),
|
||||
)
|
||||
257
backend/app/api/v1/endpoints/dashboard.py
Normal file
257
backend/app/api/v1/endpoints/dashboard.py
Normal file
@ -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)
|
||||
61
backend/app/api/v1/endpoints/external_products.py
Normal file
61
backend/app/api/v1/endpoints/external_products.py
Normal file
@ -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 "",
|
||||
},
|
||||
}
|
||||
60
backend/app/api/v1/endpoints/holidays.py
Normal file
60
backend/app/api/v1/endpoints/holidays.py
Normal file
@ -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()
|
||||
133
backend/app/api/v1/endpoints/materials.py
Normal file
133
backend/app/api/v1/endpoints/materials.py
Normal file
@ -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()
|
||||
109
backend/app/api/v1/endpoints/notifications.py
Normal file
109
backend/app/api/v1/endpoints/notifications.py
Normal file
@ -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)
|
||||
41
backend/app/api/v1/endpoints/orders.py
Normal file
41
backend/app/api/v1/endpoints/orders.py
Normal file
@ -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)
|
||||
99
backend/app/api/v1/endpoints/print.py
Normal file
99
backend/app/api/v1/endpoints/print.py
Normal file
@ -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"],
|
||||
}
|
||||
238
backend/app/api/v1/endpoints/products.py
Normal file
238
backend/app/api/v1/endpoints/products.py
Normal file
@ -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
|
||||
51
backend/app/api/v1/endpoints/records.py
Normal file
51
backend/app/api/v1/endpoints/records.py
Normal file
@ -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": "记录已删除"}
|
||||
52
backend/app/api/v1/endpoints/screen.py
Normal file
52
backend/app/api/v1/endpoints/screen.py
Normal file
@ -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)
|
||||
322
backend/app/api/v1/endpoints/tasks.py
Normal file
322
backend/app/api/v1/endpoints/tasks.py
Normal file
@ -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)
|
||||
51
backend/app/api/v1/endpoints/upload.py
Normal file
51
backend/app/api/v1/endpoints/upload.py
Normal file
@ -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)
|
||||
75
backend/app/api/v1/endpoints/users.py
Normal file
75
backend/app/api/v1/endpoints/users.py
Normal file
@ -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()
|
||||
417
backend/app/api/v1/endpoints/webhooks.py
Normal file
417
backend/app/api/v1/endpoints/webhooks.py
Normal file
@ -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}
|
||||
41
backend/app/api/v1/router.py
Normal file
41
backend/app/api/v1/router.py
Normal file
@ -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)
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
234
backend/app/core/audit_middleware.py
Normal file
234
backend/app/core/audit_middleware.py
Normal file
@ -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("审计采集失败(已忽略)")
|
||||
60
backend/app/core/config.py
Normal file
60
backend/app/core/config.py
Normal file
@ -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()
|
||||
33
backend/app/core/database.py
Normal file
33
backend/app/core/database.py
Normal file
@ -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()
|
||||
35
backend/app/core/deps.py
Normal file
35
backend/app/core/deps.py
Normal file
@ -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)
|
||||
93
backend/app/core/health.py
Normal file
93
backend/app/core/health.py
Normal file
@ -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,
|
||||
)
|
||||
196
backend/app/core/lifecycle.py
Normal file
196
backend/app/core/lifecycle.py
Normal file
@ -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
|
||||
89
backend/app/core/logging.py
Normal file
89
backend/app/core/logging.py
Normal file
@ -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
|
||||
103
backend/app/core/middleware.py
Normal file
103
backend/app/core/middleware.py
Normal file
@ -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,
|
||||
}
|
||||
},
|
||||
)
|
||||
24
backend/app/core/mom_database.py
Normal file
24
backend/app/core/mom_database.py
Normal file
@ -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,
|
||||
)
|
||||
31
backend/app/core/roles.py
Normal file
31
backend/app/core/roles.py
Normal file
@ -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
|
||||
71
backend/app/core/security.py
Normal file
71
backend/app/core/security.py
Normal file
@ -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)
|
||||
55
backend/app/core/time_utils.py
Normal file
55
backend/app/core/time_utils.py
Normal file
@ -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)
|
||||
113
backend/app/main.py
Normal file
113
backend/app/main.py
Normal file
@ -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)
|
||||
26
backend/app/models/__init__.py
Normal file
26
backend/app/models/__init__.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""模型包 — 导入 Base 及所有模型,供 Alembic 自动发现"""
|
||||
from app.models.base import Base
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.app_version import AppVersion
|
||||
from app.models.message import ProductMessage
|
||||
from app.models.holiday import Holiday
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.user_daily_seen import UserDailySeen
|
||||
__all__ = [
|
||||
"Base",
|
||||
"ProductionOrder",
|
||||
"Product",
|
||||
"Task",
|
||||
"TaskRecord",
|
||||
"TaskLog",
|
||||
"Notification",
|
||||
"AppVersion",
|
||||
"ProductMessage",
|
||||
"Holiday",
|
||||
"AuditLog",
|
||||
"UserDailySeen",
|
||||
]
|
||||
44
backend/app/models/app_version.py
Normal file
44
backend/app/models/app_version.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""App 版本管理模型 — 用于 OTA 热更新"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, Text, Integer
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class AppVersion(Base):
|
||||
__tablename__ = "app_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
version: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, unique=True, comment="版本号,如 T1.0.1",
|
||||
)
|
||||
|
||||
version_code: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=100, comment="数字版本号,用于比较",
|
||||
)
|
||||
|
||||
wgt_url: Mapped[str] = mapped_column(
|
||||
String(500), nullable=False, comment="WGT 升级包下载地址",
|
||||
)
|
||||
|
||||
description: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="更新说明",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean, default=True, comment="是否启用",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AppVersion {self.version}>"
|
||||
83
backend/app/models/audit_log.py
Normal file
83
backend/app/models/audit_log.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""操作审计日志模型
|
||||
|
||||
设计参考 MOM(KCGL) 的 audit_logs,但按 Track 的技术栈与诉求做了取舍:
|
||||
|
||||
- 主键用 UUID(与库内其它表一致),而非 MOM 的自增 int。
|
||||
- 增加 request_id:与 core/logging.py 的结构化日志打通 —— 凭一个 ID 就能把
|
||||
「接口访问日志」和「审计记录」对上,排障时不用再猜。MOM 无此字段。
|
||||
- 保留 module / action / target_* 的业务语义,使审计能按业务维度检索,
|
||||
而不是只能按时间翻。
|
||||
- 绝不记录请求体:登录等接口 body 含明文密码,一旦落库就成了长期泄露面。
|
||||
|
||||
与既有 task_logs 的分工:task_logs 是「任务流转轨迹」(有 task_id 非空约束,
|
||||
只能挂在任务上,供流转树渲染);本表是「操作审计」,覆盖登录、导出、
|
||||
产品增删改、权限变更等与单个任务无关的动作,且额外记录来源 IP / UA / 耗时结果。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 操作人(逻辑外键 → MOM sys_user,仅存账号,无物理约束)----
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="操作人账号(逻辑外键→MOM)",
|
||||
)
|
||||
display_name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="操作人显示名",
|
||||
)
|
||||
role: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True, comment="操作时角色快照",
|
||||
)
|
||||
|
||||
# ---- 业务语义 ----
|
||||
action: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True, comment="动作: create/update/delete/export/login/...",
|
||||
)
|
||||
module: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, index=True, comment="业务模块: product/task/order/auth/print/...",
|
||||
)
|
||||
target_type: Mapped[str | None] = mapped_column(
|
||||
String(50), nullable=True, comment="目标类型(表名或实体名)",
|
||||
)
|
||||
target_id: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, index=True, comment="目标ID",
|
||||
)
|
||||
target_name: Mapped[str | None] = mapped_column(
|
||||
String(200), nullable=True, comment="目标显示名(如产品身份证/工单号)",
|
||||
)
|
||||
details: Mapped[dict | None] = mapped_column(
|
||||
JSONB, nullable=True, comment="变更详情 {old:{}, new:{}};禁止写入密码等敏感字段",
|
||||
)
|
||||
|
||||
# ---- 请求上下文(由中间件自动填充)----
|
||||
ip_address: Mapped[str | None] = mapped_column(String(50), nullable=True, comment="来源IP")
|
||||
user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="浏览器UA")
|
||||
method: Mapped[str | None] = mapped_column(String(10), nullable=True, comment="HTTP方法")
|
||||
url: Mapped[str | None] = mapped_column(String(500), nullable=True, comment="请求路径")
|
||||
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="响应状态码")
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息(如有)")
|
||||
|
||||
# ---- 与结构化日志对账用 ----
|
||||
request_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="关联 core/logging 的 request_id",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, index=True, comment="操作时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog {self.action} {self.module} by {self.user_id}>"
|
||||
6
backend/app/models/base.py
Normal file
6
backend/app/models/base.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""SQLAlchemy 声明式基类"""
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
21
backend/app/models/holiday.py
Normal file
21
backend/app/models/holiday.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""节假日模型 — 放假日期配置,用于工作日时长计算"""
|
||||
from datetime import date
|
||||
from sqlalchemy import Date, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class Holiday(Base):
|
||||
__tablename__ = "holidays"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
day: Mapped[date] = mapped_column(
|
||||
Date, unique=True, index=True, comment="放假日期",
|
||||
)
|
||||
name: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="放假说明(如国庆节)",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Holiday {self.day}>"
|
||||
33
backend/app/models/message.py
Normal file
33
backend/app/models/message.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""产品协同留言板模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductMessage(Base):
|
||||
__tablename__ = "product_messages"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("products.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
nullable=False,
|
||||
comment="所属产品 ID",
|
||||
)
|
||||
operator_id: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, comment="留言人姓名或工号",
|
||||
)
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, comment="留言内容",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="留言时间(北京时间)",
|
||||
)
|
||||
53
backend/app/models/notification.py
Normal file
53
backend/app/models/notification.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""通知模型 — 任务转交/驳回等事件的消息提醒"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
# 通知类型常量
|
||||
NOTIFY_TRANSFER = "TRANSFER" # 新任务派发/转交
|
||||
NOTIFY_REJECT = "REJECT" # 品质驳回
|
||||
NOTIFY_COMMENT = "COMMENT" # 留言提醒
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
user_id: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, index=True, comment="接收人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
title: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="通知标题",
|
||||
)
|
||||
|
||||
content: Mapped[str] = mapped_column(
|
||||
Text, nullable=False, comment="通知内容详情",
|
||||
)
|
||||
|
||||
type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, comment="通知类型: TRANSFER(转交派发) | REJECT(驳回)",
|
||||
)
|
||||
|
||||
task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True, comment="关联任务ID",
|
||||
)
|
||||
|
||||
is_read: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否已读",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Notification {self.type} → {self.user_id}>"
|
||||
86
backend/app/models/product.py
Normal file
86
backend/app/models/product.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""产品模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
from app.core.lifecycle import LIFECYCLE_PRODUCTION
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
serial_number: Mapped[str] = mapped_column(
|
||||
String(16), unique=True, index=True, nullable=False, comment="产品序列号(16位)",
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 production_orders)— 可选 ----
|
||||
order_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("production_orders.id"), nullable=True, comment="所属订单ID(可选)",
|
||||
)
|
||||
|
||||
# ---- 外部序列号(用户自定义,可选) ----
|
||||
external_serial: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="用户自定义产品序列号(可选)",
|
||||
)
|
||||
|
||||
# ---- 逻辑外键(关联老系统物料表,仅存储 ID,无物理约束) ----
|
||||
material_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="物料ID(逻辑外键→老系统)",
|
||||
)
|
||||
# ---- MOM 物料快照字段(创产品时写入,防止老系统数据变动影响标签) ----
|
||||
material_name: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料名称快照",
|
||||
)
|
||||
spec_model: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="规格型号快照",
|
||||
)
|
||||
category: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, comment="物料分类快照",
|
||||
)
|
||||
material_type: Mapped[str | None] = mapped_column(
|
||||
String(100), nullable=True, comment="物料类型快照",
|
||||
)
|
||||
|
||||
# ---- 物理外键(自引用:父产品) ----
|
||||
parent_product_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=True, comment="父产品ID",
|
||||
)
|
||||
|
||||
# ---- 当前位置追踪(逻辑外键→用户ID 或 'virtual_warehouse') ----
|
||||
current_location_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="当前持有者ID 或 'virtual_warehouse'(仓库)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="pending", comment="产品状态",
|
||||
)
|
||||
# 宏观流转状态 — 首次扫码时强制设定:备货/生产/测试/维修/待仓库收货/已入库/已出库
|
||||
overall_status: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, comment="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库",
|
||||
)
|
||||
# 生命周期阶段 — 生产制造 vs 出库后返厂售后。
|
||||
# 设备出库后再次被派发任务 = 回流返厂,此处切为 AFTER_SALES 且不再回退。
|
||||
lifecycle_phase: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default=LIFECYCLE_PRODUCTION,
|
||||
server_default=LIFECYCLE_PRODUCTION, index=True,
|
||||
comment="生命周期阶段: PRODUCTION(生产制造) | AFTER_SALES(出库后返厂售后)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
order: Mapped["ProductionOrder"] = relationship("ProductionOrder", lazy="selectin")
|
||||
parent_product: Mapped["Product | None"] = relationship(
|
||||
"Product", remote_side="Product.id", lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Product {self.serial_number}>"
|
||||
32
backend/app/models/production_order.py
Normal file
32
backend/app/models/production_order.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""生产订单模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class ProductionOrder(Base):
|
||||
__tablename__ = "production_orders"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
order_no: Mapped[str] = mapped_column(
|
||||
String(64), unique=True, index=True, nullable=False, comment="订单编号",
|
||||
)
|
||||
customer_info: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, comment="客户信息",
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default="pending", comment="订单状态",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ProductionOrder {self.order_no}>"
|
||||
124
backend/app/models/task.py
Normal file
124
backend/app/models/task.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""任务模型 — 支持无限嵌套、任务裂变分支、返工闭环"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
# 任务状态常量
|
||||
TASK_STATUS_PENDING = "PENDING" # 待接收
|
||||
TASK_STATUS_WIP = "WIP" # 进行中
|
||||
TASK_STATUS_COMPLETED = "COMPLETED" # 已完成
|
||||
TASK_STATUS_REJECTED = "REJECTED" # 已驳回
|
||||
TASK_STATUS_ARCHIVED = "ARCHIVED" # 已入库
|
||||
TASK_STATUS_CANCELED = "CANCELED" # 已撤回/已作废
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 products) ----
|
||||
product_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, comment="所属产品ID",
|
||||
)
|
||||
|
||||
# ---- 物理外键(自引用:无限嵌套父子任务 / 裂变分支) ----
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True, index=True, comment="父任务ID(用于任务裂变树)",
|
||||
)
|
||||
|
||||
task_name: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, comment="任务名称",
|
||||
)
|
||||
|
||||
# ---- 逻辑外键(关联老系统用户表,仅存储 ID,无物理约束) ----
|
||||
assignee_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="负责人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, default=TASK_STATUS_PENDING,
|
||||
comment="任务状态: PENDING(待接收) | WIP(进行中) | COMPLETED(已完成) | REJECTED(已驳回) | ARCHIVED(已入库)",
|
||||
)
|
||||
|
||||
notify_parent_on_complete: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="完成后是否通知父任务",
|
||||
)
|
||||
|
||||
# ---- 时间追踪 ----
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
received_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="操作员确认接收时间",
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, comment="任务完工转交时间",
|
||||
)
|
||||
|
||||
# ---- 驳回/返工 ----
|
||||
reject_reason: Mapped[str | None] = mapped_column(
|
||||
String(500), nullable=True, comment="驳回原因",
|
||||
)
|
||||
is_rework: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, comment="是否为返工任务",
|
||||
)
|
||||
task_type: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, comment="任务派生类型: TRANSFER/SPAWN/RECOVERY/null=历史数据",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
String(2000), nullable=True, comment="任务初始描述/交接备注",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
product: Mapped["Product"] = relationship("Product", lazy="selectin")
|
||||
parent_task: Mapped["Task | None"] = relationship(
|
||||
"Task", remote_side="Task.id", back_populates="child_tasks", lazy="selectin",
|
||||
)
|
||||
child_tasks: Mapped[list["Task"]] = relationship(
|
||||
"Task", back_populates="parent_task", lazy="selectin",
|
||||
order_by="Task.created_at",
|
||||
)
|
||||
records: Mapped[list["TaskRecord"]] = relationship(
|
||||
"TaskRecord", back_populates="task", lazy="selectin", cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Task {self.task_name}>"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 任务进度记录 — 随时备注/传图
|
||||
# ============================================================
|
||||
|
||||
class TaskRecord(Base):
|
||||
__tablename__ = "task_records"
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
primary_key=True, autoincrement=True,
|
||||
)
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False, index=True, comment="所属任务ID",
|
||||
)
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
String(2000), nullable=True, comment="备注文本",
|
||||
)
|
||||
images: Mapped[str | None] = mapped_column(
|
||||
String(4000), nullable=True, comment="图片URL列表(JSON字符串)",
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="记录时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
task: Mapped["Task"] = relationship("Task", back_populates="records")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskRecord {self.id} @ {self.created_at}>"
|
||||
45
backend/app/models/task_log.py
Normal file
45
backend/app/models/task_log.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""任务操作日志模型"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base
|
||||
from app.core.time_utils import get_beijing_time
|
||||
|
||||
|
||||
class TaskLog(Base):
|
||||
__tablename__ = "task_logs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4,
|
||||
)
|
||||
|
||||
# ---- 物理外键(关联本库 tasks) ----
|
||||
task_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False, index=True, comment="所属任务ID",
|
||||
)
|
||||
|
||||
# ---- 逻辑外键(关联老系统用户表,仅存储 ID,无物理约束) ----
|
||||
operator_id: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, comment="操作人ID(逻辑外键→老系统)",
|
||||
)
|
||||
|
||||
action_type: Mapped[str] = mapped_column(
|
||||
String(50), nullable=False, comment="操作类型",
|
||||
)
|
||||
|
||||
remark: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True, comment="备注",
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=get_beijing_time, comment="创建时间",
|
||||
)
|
||||
|
||||
# ---- 关系 ----
|
||||
task: Mapped["Task"] = relationship("Task", lazy="selectin")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<TaskLog {self.action_type} @ {self.created_at}>"
|
||||
47
backend/app/models/user_daily_seen.py
Normal file
47
backend/app/models/user_daily_seen.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""每日用户活动表 —— 一天一人一行,只记录"今天来过"这件事。
|
||||
|
||||
为什么需要它(而不是复用 audit_logs)
|
||||
--------------------------------------
|
||||
日活报表要的「上线时间 / 下线时间」,两个都不能从审计表直接得出:
|
||||
|
||||
1. **上线/下线时间不能取登录时间**:Refresh Token 有效期 7 天,用户不必每天
|
||||
重新登录。按登录算会出现「登录次数 0、上线时间空,但操作次数 35」的
|
||||
自相矛盾报表。
|
||||
|
||||
2. **也不能只取写操作时间**:审计中间件只记录写操作(及导出/打印这类敏感读),
|
||||
普通 GET 不入账。当天只翻看、没做写操作的人会被整条漏掉。
|
||||
|
||||
3. **更不能把活动记录写进 audit_logs**:
|
||||
· 「上线时间」是**事件**(INSERT 一次即可),但「下线时间」是**状态**
|
||||
(每次活动都要刷新同一个值)。往审计流水里做 UPDATE,等于承认审计记录
|
||||
可以被改写 —— 那审计本身就失去可信度了。
|
||||
· 若改为每个请求 INSERT 一条,表会随访问量线性膨胀。
|
||||
|
||||
于是单开一张"可变的小状态表":一人一天一行,首见 INSERT、其后只
|
||||
UPDATE last_seen_at。50 人 × 365 天 ≈ 1.8 万行/年,可忽略。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class UserDailySeen(Base):
|
||||
"""用户在某个北京时间自然日的首末活动时刻"""
|
||||
|
||||
__tablename__ = "user_daily_seen"
|
||||
|
||||
# 联合主键即 UPSERT 的冲突目标,也是"一天一人一行"的保证
|
||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
day: Mapped[date] = mapped_column(Date, primary_key=True, comment="北京时间自然日")
|
||||
|
||||
first_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="当天首次活动时刻",
|
||||
)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, comment="当天末次活动时刻",
|
||||
)
|
||||
45
backend/app/schemas/__init__.py
Normal file
45
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Pydantic Schemas — 请求/响应数据模型"""
|
||||
from app.schemas.product import (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductResponse,
|
||||
ProductScanResponse,
|
||||
)
|
||||
from app.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
TaskCompleteRequest,
|
||||
TaskRejectRequest,
|
||||
TaskTransferRequest,
|
||||
SubtaskCreate,
|
||||
TaskSummaryResponse,
|
||||
TaskResponse,
|
||||
TaskCompleteResponse,
|
||||
TaskTransferResponse,
|
||||
TaskListResponse,
|
||||
)
|
||||
from app.schemas.task_log import (
|
||||
TaskLogResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Product
|
||||
"ProductCreate",
|
||||
"ProductUpdate",
|
||||
"ProductResponse",
|
||||
"ProductScanResponse",
|
||||
# Task
|
||||
"TaskCreate",
|
||||
"TaskUpdate",
|
||||
"TaskCompleteRequest",
|
||||
"TaskRejectRequest",
|
||||
"TaskTransferRequest",
|
||||
"SubtaskCreate",
|
||||
"TaskSummaryResponse",
|
||||
"TaskResponse",
|
||||
"TaskCompleteResponse",
|
||||
"TaskTransferResponse",
|
||||
"TaskListResponse",
|
||||
# TaskLog
|
||||
"TaskLogResponse",
|
||||
]
|
||||
14
backend/app/schemas/app_version.py
Normal file
14
backend/app/schemas/app_version.py
Normal file
@ -0,0 +1,14 @@
|
||||
"""App 版本 Schema"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AppVersionResponse(BaseModel):
|
||||
"""返回给 App 的版本信息"""
|
||||
version: str
|
||||
version_code: int
|
||||
has_update: bool
|
||||
wgt_url: str | None = None
|
||||
description: str | None = None
|
||||
force_update: bool = False
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
88
backend/app/schemas/audit.py
Normal file
88
backend/app/schemas/audit.py
Normal file
@ -0,0 +1,88 @@
|
||||
"""审计日志 Pydantic Schema"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
"""单条审计记录"""
|
||||
id: uuid.UUID
|
||||
user_id: str | None = None
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
action: str
|
||||
action_label: str | None = None # 服务端补的中文标签,避免前端各处硬编码
|
||||
module: str
|
||||
module_label: str | None = None
|
||||
|
||||
target_type: str | None = None
|
||||
target_id: str | None = None
|
||||
target_name: str | None = None
|
||||
details: dict | None = None
|
||||
|
||||
ip_address: str | None = None
|
||||
user_agent: str | None = None
|
||||
method: str | None = None
|
||||
url: str | None = None
|
||||
status_code: int | None = None
|
||||
error_message: str | None = None
|
||||
|
||||
# 与结构化日志对账用:拿着它就能捞到对应的接口日志
|
||||
request_id: str | None = None
|
||||
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AuditLogListResponse(BaseModel):
|
||||
"""审计日志分页列表"""
|
||||
items: list[AuditLogResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class DailyUsageRow(BaseModel):
|
||||
"""某个操作人在某一天的用量汇总(北京时间自然日)"""
|
||||
day: str # YYYY-MM-DD(北京时间)
|
||||
user_id: str | None = None
|
||||
display_name: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
login_count: int = 0 # 登录次数(当天成功登录)
|
||||
logout_count: int = 0 # 登出次数(当天成功登出)
|
||||
op_count: int = 0 # 操作次数(当天全部审计记录数)
|
||||
|
||||
# ⚠️ 上线/下线时间取【当天首次/末次活动】,不是登录/登出时间:
|
||||
# token 有效期内(refresh 7 天)用户不会重新登录,按登录算会导致
|
||||
# 「登录次数 0 但操作 35 次」这种自相矛盾。
|
||||
first_active_at: datetime | None = None # 上线时间(当天首次活动)
|
||||
last_active_at: datetime | None = None # 下线时间(当天末次活动)
|
||||
|
||||
|
||||
class DailyUsageResponse(BaseModel):
|
||||
"""日活 / 使用统计"""
|
||||
start_date: str
|
||||
end_date: str
|
||||
items: list[DailyUsageRow]
|
||||
total: int # 行数(= 天数 × 人数),不是审计记录数
|
||||
|
||||
|
||||
class AuditOption(BaseModel):
|
||||
"""筛选项(value/label 结构,直接喂给前端下拉)"""
|
||||
value: str
|
||||
label: str
|
||||
|
||||
|
||||
class AuditOptionsResponse(BaseModel):
|
||||
"""筛选项集合"""
|
||||
modules: list[AuditOption]
|
||||
actions: list[AuditOption]
|
||||
# 导出可选的列(value=后端列 key,label=中文表头)。
|
||||
# 由后端下发而非前端硬编码:列的中文名与取值口径都在后端,
|
||||
# 两端各写一份迟早会出现"导出的列和页面上的对不上"。
|
||||
log_export_columns: list[AuditOption] = []
|
||||
usage_export_columns: list[AuditOption] = []
|
||||
27
backend/app/schemas/notification.py
Normal file
27
backend/app/schemas/notification.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""通知 Pydantic Schema"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NotificationResponse(BaseModel):
|
||||
"""通知列表响应"""
|
||||
id: uuid.UUID
|
||||
user_id: str
|
||||
title: str
|
||||
content: str
|
||||
type: str
|
||||
task_id: uuid.UUID | None = None
|
||||
product_serial_number: str | None = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
"""通知分页列表"""
|
||||
notifications: list[NotificationResponse]
|
||||
total: int
|
||||
unread_count: int
|
||||
24
backend/app/schemas/order.py
Normal file
24
backend/app/schemas/order.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""生产订单 Pydantic Schemas"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OrderCreate(BaseModel):
|
||||
"""创建订单"""
|
||||
order_no: str = Field(..., max_length=64, description="订单编号")
|
||||
customer_info: str | None = Field(None, max_length=500, description="客户信息")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
"""订单响应"""
|
||||
id: uuid.UUID
|
||||
order_no: str
|
||||
customer_info: str | None
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
111
backend/app/schemas/product.py
Normal file
111
backend/app/schemas/product.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""产品 Pydantic Schemas"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProductCreate(BaseModel):
|
||||
"""创建产品 — serial_number 由后端自动生成 16 位 HEX"""
|
||||
# MOM 物料挂载(必填)
|
||||
material_id: str = Field(..., max_length=64, description="MOM物料ID(必选)")
|
||||
material_name: str = Field("", max_length=255, description="物料名称")
|
||||
spec_model: str = Field("", max_length=255, description="规格型号")
|
||||
category: str = Field("", max_length=100, description="物料类别")
|
||||
material_type: str = Field("", max_length=100, description="物料类型")
|
||||
# 可选
|
||||
external_serial: str | None = Field(None, max_length=64, description="产品序列号(用户自定义,选填)")
|
||||
order_id: uuid.UUID | None = Field(None, description="所属订单ID(选填)")
|
||||
order_no: str | None = Field(None, max_length=64, description="订单号(自由键入,选填)")
|
||||
parent_product_id: uuid.UUID | None = Field(None, description="父产品ID")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
"""更新产品"""
|
||||
serial_number: str | None = Field(None, min_length=16, max_length=16)
|
||||
external_serial: str | None = Field(None, max_length=64)
|
||||
material_id: str | None = Field(None, max_length=64)
|
||||
material_name: str | None = Field(None, max_length=255)
|
||||
spec_model: str | None = Field(None, max_length=255)
|
||||
order_no: str | None = Field(None, max_length=64, description="订单号")
|
||||
status: str | None = Field(None, max_length=50, description="产品状态")
|
||||
parent_product_id: uuid.UUID | None = Field(None)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductResponse(BaseModel):
|
||||
"""产品响应"""
|
||||
id: uuid.UUID
|
||||
serial_number: str
|
||||
external_serial: str | None = None
|
||||
order_id: uuid.UUID | None = None
|
||||
order_no: str = ""
|
||||
material_id: str | None
|
||||
material_name: str | None = None
|
||||
spec_model: str | None = None
|
||||
category: str | None = None
|
||||
material_type: str | None = None
|
||||
parent_product_id: uuid.UUID | None
|
||||
current_location_id: str | None = None
|
||||
current_location_name: str | None = None
|
||||
macro_status: str | None = None # 🔧 后端预计算的任务树状态(免前端逐条展开)
|
||||
overall_status: str | None = None
|
||||
# 【当前工序】—— 只反映"此刻在做什么",绝不拿历史工序冒充。
|
||||
# · 有活跃主干任务(WIP/PENDING) → 该任务工序名
|
||||
# · 否则产品处于宏观终态(待仓库收货/已入库/在库/已出库) → 该终态(表达"货在哪")
|
||||
# · 否则(活已干完、只剩工序名残留)→ ""(前端显示「—」)
|
||||
# ⚠️ 必须与 overall_status 分开:ProductResponse.overall_status 会被"最新主干任务名"
|
||||
# 覆盖(见 product_service 的 overall_names),于是已 COMPLETED 的历史工序
|
||||
# (扫码出库 / 测试 / 发货测试…)会被当成"当前工序"长期展示。
|
||||
current_step: str = ""
|
||||
status: str
|
||||
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||
lifecycle_phase: str = "PRODUCTION"
|
||||
created_at: datetime
|
||||
# 🔧 最新动态 — 该产品活跃任务的最新记录
|
||||
latest_record_time: datetime | None = None
|
||||
latest_record_content: str | None = None
|
||||
latest_record_has_images: bool = False
|
||||
latest_record_assignee_id: str | None = None # 🔧 最新记录操作人(消除并发张冠李戴)
|
||||
latest_record_assignee_name: str | None = None
|
||||
# 🔧 当前人滞留时长 — 活跃任务(WIP/PENDING)最早接手时间到现在的时长(小时)
|
||||
active_duration_hours: float | None = None
|
||||
# 🔧 生产总天数(自创建至今)
|
||||
production_days: int = 0 # 自然天
|
||||
production_days_workdays: int = 0 # 工作日(排除周末/节假日)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProductScanResponse(BaseModel):
|
||||
"""扫码查询响应 — 产品信息 + 完整任务树(递归嵌套)"""
|
||||
id: uuid.UUID
|
||||
serial_number: str
|
||||
external_serial: str | None = None
|
||||
order_id: uuid.UUID | None = None
|
||||
order_no: str = ""
|
||||
material_id: str | None
|
||||
material_name: str | None = None
|
||||
spec_model: str | None = None
|
||||
category: str | None = None
|
||||
material_type: str | None = None
|
||||
parent_product_id: uuid.UUID | None
|
||||
current_location_id: str | None = None
|
||||
overall_status: str | None = None
|
||||
status: str
|
||||
# 🔧 生命周期阶段:PRODUCTION(生产制造/发货测试) | AFTER_SALES(出库后返厂售后维修)
|
||||
lifecycle_phase: str = "PRODUCTION"
|
||||
created_at: datetime
|
||||
top_level_tasks: list[TaskSummaryResponse] = []
|
||||
task_tree: list[TaskResponse] = []
|
||||
assignee_names: dict[str, str] = {} # 🔧 username→中文姓名映射
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# 延迟导入,避免循环引用
|
||||
from app.schemas.task import TaskSummaryResponse, TaskResponse # noqa: E402
|
||||
ProductScanResponse.model_rebuild()
|
||||
219
backend/app/schemas/task.py
Normal file
219
backend/app/schemas/task.py
Normal file
@ -0,0 +1,219 @@
|
||||
"""任务 Pydantic Schemas — 支持无限嵌套子任务、裂变转交、驳回返工"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 请求模型
|
||||
# ============================================================
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
"""创建任务"""
|
||||
product_id: uuid.UUID = Field(..., description="所属产品ID")
|
||||
parent_task_id: uuid.UUID | None = Field(None, description="父任务ID(用于嵌套子任务/裂变分支)")
|
||||
task_name: str = Field(..., max_length=200, description="任务名称")
|
||||
assignee_id: str | None = Field(None, max_length=64, description="负责人ID(逻辑外键→老系统)")
|
||||
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
|
||||
is_rework: bool = Field(False, description="是否为返工任务")
|
||||
remark: str | None = Field(None, max_length=2000, description="初始描述/交接备注")
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""更新任务"""
|
||||
task_name: str | None = Field(None, max_length=200)
|
||||
assignee_id: str | None = Field(None, max_length=64)
|
||||
status: str | None = Field(None, max_length=50, description="任务状态")
|
||||
notify_parent_on_complete: bool | None = Field(None)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskCompleteRequest(BaseModel):
|
||||
"""完成任务请求 — 携带转交信息(保留兼容旧版单步转交)"""
|
||||
operator_id: str | None = Field(None, max_length=64, description="操作人ID")
|
||||
next_assignee_id: str | None = Field(None, max_length=64, description="下一步任务负责人ID")
|
||||
next_task_name: str | None = Field(None, max_length=200, description="下一步任务名称")
|
||||
remark: str | None = Field(None, description="完成备注")
|
||||
|
||||
|
||||
class SubtaskCreate(BaseModel):
|
||||
"""创建子任务"""
|
||||
task_name: str = Field(..., max_length=200, description="子任务名称")
|
||||
assignee_id: str | None = Field(None, max_length=64, description="负责人ID")
|
||||
notify_parent_on_complete: bool = Field(False, description="完成后是否通知父任务")
|
||||
|
||||
|
||||
class TaskRejectRequest(BaseModel):
|
||||
"""品质驳回请求 — 驳回原因必填;异常图片【选填】
|
||||
(编号错误、工序选错等场景无需拍照举证,故不强制传图)"""
|
||||
reason: str = Field(..., min_length=1, max_length=500, description="驳回原因(必填)")
|
||||
images: list[str] = Field(
|
||||
default_factory=list, max_length=9,
|
||||
description="异常图片 URL 列表(选填,可传空数组,最多 9 张)",
|
||||
)
|
||||
|
||||
@field_validator("reason", mode="before")
|
||||
@classmethod
|
||||
def _strip_reason(cls, v):
|
||||
"""先 strip 再交给 min_length 校验:否则纯空格(' ')能凑够长度绕过必填。
|
||||
顺带保证落库的 Task.reject_reason / TaskRecord.remark 不带首尾空白。
|
||||
非字符串原样返回,让 Pydantic 抛出正常的类型错误。"""
|
||||
return v.strip() if isinstance(v, str) else v
|
||||
|
||||
@field_validator("images")
|
||||
@classmethod
|
||||
def _validate_images(cls, v: list[str]) -> list[str]:
|
||||
"""剥离空串(前端可能提交 [''] 之类的占位),并做总长度上限校验,
|
||||
避免落库时才撞上 TaskRecord.images 的 String(4000) 上限。图片选填,允许为空。"""
|
||||
urls = [u.strip() for u in v if u and u.strip()]
|
||||
if sum(len(u) for u in urls) > 3500:
|
||||
raise ValueError("异常图片 URL 总长度超限,请减少图片数量")
|
||||
return urls
|
||||
|
||||
|
||||
class TaskTransferBranch(BaseModel):
|
||||
"""裂变分支"""
|
||||
task_name: str = Field(..., max_length=200, description="工序名称")
|
||||
assignees: list[str] = Field(
|
||||
default_factory=list, min_length=0,
|
||||
description=(
|
||||
"接收人列表。允许为空数组,但仅当 finish_directly=True 时合法——"
|
||||
"空分支不产生任何下游任务(见 TaskTransferRequest 的校验)。"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TaskTransferRequest(BaseModel):
|
||||
"""完工裂变转交请求 — 支持多分支 next_tasks 和旧版单线兼容"""
|
||||
next_assignees: list[str] | None = Field(None, description="[旧版] 下一道工序接收人列表")
|
||||
next_task_name: str | None = Field(None, max_length=200, description="[旧版] 下一道工序名称")
|
||||
next_tasks: list[TaskTransferBranch] | None = Field(None, description="[新版] 多分支任务列表")
|
||||
note: str | None = Field(None, description="交接备注")
|
||||
finish_directly: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"直接完结:闭环当前任务但【不产生任何下游任务】。"
|
||||
"它只是一个任务闭环动作,【不改动】产品的 overall_status —— "
|
||||
"已出库的设备完结后依然是已出库(不入库,也不会变成「待仓库收货」)。"
|
||||
"权限:仅 SUPER_ADMIN / SUPERVISOR 可调用,其余角色 403。"
|
||||
"置 True 时忽略 next_tasks / next_assignees。"
|
||||
),
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _reject_silent_empty_branch(self):
|
||||
"""空 assignees 分支会让「转交」静默退化成「直接完结」——任务闭环了却没人接手,
|
||||
是个丢件级隐患。想直接完结必须显式传 finish_directly=true,不能靠漏填凑合。"""
|
||||
if self.finish_directly:
|
||||
return self
|
||||
if any(not b.assignees for b in (self.next_tasks or [])):
|
||||
raise ValueError(
|
||||
"分支 assignees 不能为空;若意图是「直接完结该任务」,"
|
||||
"请改为传 finish_directly=true"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class TaskRecordCreate(BaseModel):
|
||||
"""任务进度记录 — 备注 + 图片"""
|
||||
remark: str = Field("", max_length=2000, description="备注文本")
|
||||
images: list[str] = Field(default_factory=list, description="图片 URL 列表")
|
||||
|
||||
|
||||
class TaskRecordResponse(BaseModel):
|
||||
id: int
|
||||
task_id: uuid.UUID
|
||||
remark: str | None = None
|
||||
images: list[str] = []
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@field_validator("images", mode="before")
|
||||
@classmethod
|
||||
def _parse_images(cls, v):
|
||||
"""处理 DB 中 images 的 JSON 字符串 → list 反序列化(不污染 ORM 对象)"""
|
||||
import json
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
if v is None:
|
||||
return []
|
||||
return v
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 响应模型
|
||||
# ============================================================
|
||||
|
||||
class TaskSummaryResponse(BaseModel):
|
||||
"""任务摘要 — 扫码时用,不含嵌套子任务"""
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
parent_task_id: uuid.UUID | None
|
||||
task_name: str
|
||||
assignee_id: str | None
|
||||
status: str
|
||||
notify_parent_on_complete: bool
|
||||
is_rework: bool = False
|
||||
task_type: str | None = None
|
||||
remark: str | None = None
|
||||
reject_reason: str | None = None
|
||||
received_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
created_by: str | None = None # 谁创建的(从task_logs追溯)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""任务详情响应 — 递归包含所有子任务"""
|
||||
id: uuid.UUID
|
||||
product_id: uuid.UUID
|
||||
product_sn: str = ""
|
||||
product_material: str = ""
|
||||
parent_task_id: uuid.UUID | None
|
||||
task_name: str
|
||||
assignee_id: str | None
|
||||
status: str
|
||||
notify_parent_on_complete: bool
|
||||
is_rework: bool = False
|
||||
task_type: str | None = None
|
||||
remark: str | None = None
|
||||
reject_reason: str | None = None
|
||||
received_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
child_tasks: list[TaskResponse] = []
|
||||
records: list[TaskRecordResponse] = []
|
||||
created_by: str | None = None # 谁创建的(从task_logs追溯)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TaskCompleteResponse(BaseModel):
|
||||
"""任务完成响应"""
|
||||
completed_task: TaskResponse
|
||||
next_task: TaskResponse | None = None
|
||||
message: str
|
||||
|
||||
|
||||
class TaskTransferResponse(BaseModel):
|
||||
"""裂变转交响应"""
|
||||
completed_task: TaskResponse
|
||||
created_tasks: list[TaskResponse] = []
|
||||
message: str
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
"""任务列表响应"""
|
||||
tasks: list[TaskResponse]
|
||||
total: int
|
||||
17
backend/app/schemas/task_log.py
Normal file
17
backend/app/schemas/task_log.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""任务操作日志 Pydantic Schemas"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskLogResponse(BaseModel):
|
||||
"""任务日志响应"""
|
||||
id: uuid.UUID
|
||||
task_id: uuid.UUID
|
||||
operator_id: str | None
|
||||
action_type: str
|
||||
remark: str | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
34
backend/app/schemas/user.py
Normal file
34
backend/app/schemas/user.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""用户 Schemas — 对接 MOM sys_user 表 + 双 Token"""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(..., max_length=64)
|
||||
password: str = Field(..., max_length=128)
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
display_name: str
|
||||
role: str
|
||||
is_active: bool = True
|
||||
created_at: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
user: UserResponse
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
refresh_token: str = Field(..., description="Refresh Token")
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
44
backend/app/services/__init__.py
Normal file
44
backend/app/services/__init__.py
Normal file
@ -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",
|
||||
]
|
||||
675
backend/app/services/analytics_service.py
Normal file
675
backend/app/services/analytics_service.py
Normal file
@ -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)
|
||||
458
backend/app/services/audit_service.py
Normal file
458
backend/app/services/audit_service.py
Normal file
@ -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
|
||||
149
backend/app/services/auth_service.py
Normal file
149
backend/app/services/auth_service.py
Normal file
@ -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")
|
||||
26
backend/app/services/counter_service.py
Normal file
26
backend/app/services/counter_service.py
Normal file
@ -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")
|
||||
1714
backend/app/services/dashboard_service.py
Normal file
1714
backend/app/services/dashboard_service.py
Normal file
File diff suppressed because it is too large
Load Diff
208
backend/app/services/label_service.py
Normal file
208
backend/app/services/label_service.py
Normal file
@ -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}",
|
||||
}
|
||||
154
backend/app/services/mom_cache.py
Normal file
154
backend/app/services/mom_cache.py
Normal file
@ -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
|
||||
44
backend/app/services/print_config.py
Normal file
44
backend/app/services/print_config.py
Normal file
@ -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", {}))
|
||||
146
backend/app/services/product_finalize_service.py
Normal file
146
backend/app/services/product_finalize_service.py
Normal file
@ -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)
|
||||
933
backend/app/services/product_service.py
Normal file
933
backend/app/services/product_service.py
Normal file
@ -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()
|
||||
34
backend/app/services/qrcode_service.py
Normal file
34
backend/app/services/qrcode_service.py
Normal file
@ -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
|
||||
254
backend/app/services/screen_service.py
Normal file
254
backend/app/services/screen_service.py
Normal file
@ -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
|
||||
],
|
||||
)
|
||||
1402
backend/app/services/task_service.py
Normal file
1402
backend/app/services/task_service.py
Normal file
File diff suppressed because it is too large
Load Diff
176
backend/app/services/task_tree_loader.py
Normal file
176
backend/app/services/task_tree_loader.py
Normal file
@ -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
|
||||
48
backend/requirements.txt
Normal file
48
backend/requirements.txt
Normal file
@ -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
|
||||
1
backend/scripts/__init__.py
Normal file
1
backend/scripts/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""一次性运维脚本集合(按模块方式运行:python -m scripts.<name>)"""
|
||||
84
backend/scripts/fix_product_status.py
Normal file
84
backend/scripts/fix_product_status.py
Normal file
@ -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))
|
||||
BIN
backend/simhei.ttf
Normal file
BIN
backend/simhei.ttf
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user