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

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

53
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
Generic single-database configuration.

59
backend/alembic/env.py Normal file
View 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())

View 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"}

View File

@ -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 ###

View File

@ -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';
""")

View 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")

View File

@ -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;")

View 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")

View File

@ -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')

View 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")

View File

@ -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')

View 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')

View 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")

View File

@ -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")

View 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')

View File

@ -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")

View 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")

View 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
View File

@ -0,0 +1 @@
# Track Production API

View File

View File

View File

@ -0,0 +1 @@
"""API v1 端点"""

View 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)

View 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

View 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),
)

View 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"),
)

View 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)

View 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 "",
},
}

View 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()

View 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()

View 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)

View 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)

View 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"],
}

View 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

View 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": "记录已删除"}

View 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)

View 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)

View 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)

View 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()

View 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}

View 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)

View File

View 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("审计采集失败(已忽略)")

View 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()

View 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
View 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)

View 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,
)

View 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

View 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

View 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,
}
},
)

View 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
View 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

View 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)

View 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
View 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)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View 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",
]

View 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)

View 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

View 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")

View 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")

File diff suppressed because it is too large Load Diff

View 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}",
}

View 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

View 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", {}))

View 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)

View 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()

View 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

View 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
],
)

File diff suppressed because it is too large Load Diff

View 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
View 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

View File

@ -0,0 +1 @@
"""一次性运维脚本集合(按模块方式运行:python -m scripts.<name>)"""

View 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

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More