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:
0
backend/app/api/v1/__init__.py
Normal file
0
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""API v1 端点"""
|
||||
75
backend/app/api/v1/endpoints/analytics.py
Normal file
75
backend/app/api/v1/endpoints/analytics.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""效能分析 API — ECharts 数据源(个人能力图谱 / 设备流转对比 / 筛选选项)"""
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.analytics_service import (
|
||||
get_capability_profile, CapabilityResponse,
|
||||
get_flow_compare, FlowResponse,
|
||||
get_analytics_options, AnalyticsOptions,
|
||||
get_device_records, DeviceRecord,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["效能分析"])
|
||||
|
||||
|
||||
@router.get("/capability", response_model=CapabilityResponse)
|
||||
async def capability_profile(
|
||||
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(可选)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
mode: str = Query("workdays", description="耗时口径: workdays(工作小时,默认) / natural(自然小时)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""个人能力图谱 — X 轴=设备身份证,分组柱状图(单台设备总耗时)。"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_capability_profile(
|
||||
db, assignee_ids=ids, spec_models=specs,
|
||||
since=since_dt, until=until_dt, mode=mode,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/flow", response_model=FlowResponse)
|
||||
async def flow_compare(
|
||||
product_sns: str | None = Query(None, description="身份证,逗号分隔"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(无 product_sns 时按型号取最近设备)"),
|
||||
mode: str = Query("natural", description="时间口径: natural(自然小时) / workdays(工作小时,排除周末节假日)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""设备流转对比 — 每台设备各操作人耗时(堆叠柱状,按人堆叠,识别瓶颈)。"""
|
||||
sns = [s.strip() for s in product_sns.split(",") if s.strip()] if product_sns else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_flow_compare(db, product_sns=sns, spec_models=specs, mode=mode, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/options", response_model=AnalyticsOptions)
|
||||
async def analytics_options(
|
||||
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(联动过滤型号)"),
|
||||
spec_models: str | None = Query(None, description="规格型号,逗号分隔(联动过滤人员)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""顶部筛选栏选项 — 负责人 + 规格型号,支持动态联动。"""
|
||||
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
|
||||
specs = [s.strip() for s in spec_models.split(",") if s.strip()] if spec_models else None
|
||||
return await get_analytics_options(db, assignee_ids=ids, spec_models=specs)
|
||||
|
||||
|
||||
@router.get("/device-records", response_model=list[DeviceRecord])
|
||||
async def device_records(
|
||||
product_sn: str = Query(..., description="设备身份证"),
|
||||
assignee_ids: str | None = Query(None, description="负责人ID,逗号分隔(可选,用于过滤)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""某台设备的任务备注记录(含图片);可选按负责人过滤。"""
|
||||
ids = [s.strip() for s in assignee_ids.split(",") if s.strip()] if assignee_ids else None
|
||||
return await get_device_records(db, product_sn, assignee_ids=ids)
|
||||
87
backend/app/api/v1/endpoints/app_version.py
Normal file
87
backend/app/api/v1/endpoints/app_version.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""App 版本更新 API — OTA 热更新检测"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.app_version import AppVersion
|
||||
from app.schemas.app_version import AppVersionResponse
|
||||
|
||||
router = APIRouter(prefix="/app", tags=["App版本"])
|
||||
|
||||
|
||||
@router.get("/check-update", response_model=AppVersionResponse)
|
||||
async def check_update(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
热更新检测接口(无需传参)。
|
||||
查询 app_versions 表中 is_active=true 的最新记录,
|
||||
返回最新版本号、版本代码、WGT下载地址、更新说明。
|
||||
App 端自行对比本地版本号决定是否升级。
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AppVersion)
|
||||
.where(AppVersion.is_active.is_(True))
|
||||
.order_by(desc(AppVersion.version_code))
|
||||
.limit(1)
|
||||
)
|
||||
latest = result.scalar_one_or_none()
|
||||
|
||||
if not latest:
|
||||
return AppVersionResponse(
|
||||
version="0",
|
||||
version_code=0,
|
||||
has_update=False,
|
||||
)
|
||||
|
||||
return AppVersionResponse(
|
||||
version=latest.version,
|
||||
version_code=latest.version_code,
|
||||
has_update=bool(latest.wgt_url), # 只有配置了 WGT 下载地址才算有效更新
|
||||
wgt_url=latest.wgt_url,
|
||||
description=latest.description,
|
||||
force_update=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/version", response_model=AppVersionResponse)
|
||||
async def check_version(
|
||||
current: str = Query(..., description="当前 App 版本号,如 T1.0.0"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""检测是否有新版本可用(服务端对比)"""
|
||||
result = await db.execute(
|
||||
select(AppVersion)
|
||||
.where(AppVersion.is_active.is_(True))
|
||||
.order_by(desc(AppVersion.version_code))
|
||||
.limit(1)
|
||||
)
|
||||
latest = result.scalar_one_or_none()
|
||||
|
||||
if not latest:
|
||||
return AppVersionResponse(
|
||||
version=current,
|
||||
version_code=0,
|
||||
has_update=False,
|
||||
)
|
||||
|
||||
has_update = latest.version_code > _parse_version_code(current)
|
||||
|
||||
return AppVersionResponse(
|
||||
version=latest.version,
|
||||
version_code=latest.version_code,
|
||||
has_update=has_update,
|
||||
wgt_url=latest.wgt_url if has_update else None,
|
||||
description=latest.description if has_update else None,
|
||||
force_update=False,
|
||||
)
|
||||
|
||||
|
||||
def _parse_version_code(version_str: str) -> int:
|
||||
"""从版本字符串提取数字版本号"""
|
||||
import re
|
||||
nums = re.findall(r"\d+", version_str)
|
||||
if nums:
|
||||
return int("".join(nums[-3:]).ljust(3, "0")[:3])
|
||||
return 0
|
||||
292
backend/app/api/v1/endpoints/audit.py
Normal file
292
backend/app/api/v1/endpoints/audit.py
Normal file
@ -0,0 +1,292 @@
|
||||
"""审计日志 API —— 查看系统操作审计记录
|
||||
|
||||
与 MOM(KCGL) /audit/logs 的接口保持同构的筛选维度(操作人/模块/动作/目标/
|
||||
时间区间),便于两端运维习惯统一;额外提供 request_id 筛选,可凭它直接跳到
|
||||
结构化日志里的那一次请求。
|
||||
|
||||
另提供两个 CSV 导出端点(审计明细 / 日活统计),均支持按列导出。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.deps import require_admin
|
||||
from app.core.time_utils import BEIJING_TZ, get_beijing_time
|
||||
from app.schemas.audit import (
|
||||
AuditLogListResponse,
|
||||
AuditLogResponse,
|
||||
AuditOption,
|
||||
AuditOptionsResponse,
|
||||
DailyUsageResponse,
|
||||
DailyUsageRow,
|
||||
)
|
||||
from app.services import audit_service
|
||||
from app.services.audit_service import ACTION_LABELS, MODULE_LABELS
|
||||
|
||||
router = APIRouter(prefix="/audit", tags=["审计日志"])
|
||||
|
||||
|
||||
def _parse_day(value: str | None, *, end_of_day: bool = False) -> datetime | None:
|
||||
"""解析 YYYY-MM-DD 为北京时间。
|
||||
|
||||
结束日期取次日 00:00 作为上界(配合 < 判断)—— 直接取当天 23:59:59 会
|
||||
漏掉该秒内的记录,是日期区间筛选最常见的差一错误。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
day = datetime.strptime(value, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
if end_of_day:
|
||||
return datetime.combine(day + timedelta(days=1), time.min, tzinfo=BEIJING_TZ)
|
||||
return datetime.combine(day, time.min, tzinfo=BEIJING_TZ)
|
||||
|
||||
|
||||
@router.get("/logs", response_model=AuditLogListResponse)
|
||||
async def get_audit_logs(
|
||||
user_id: str | None = Query(None, description="操作人账号(模糊匹配)"),
|
||||
module: str | None = Query(None, description="业务模块"),
|
||||
action: str | None = Query(None, description="动作类型"),
|
||||
target_id: str | None = Query(None, description="目标ID"),
|
||||
request_id: str | None = Query(None, description="请求ID(与接口日志对账)"),
|
||||
status_code: int | None = Query(None, description="响应状态码"),
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> AuditLogListResponse:
|
||||
"""审计日志分页查询(按时间倒序)"""
|
||||
start = _parse_day(start_date)
|
||||
# 结束日期用「次日 00:00」作为开区间上界,避免漏掉当天最后几条
|
||||
end_exclusive = _parse_day(end_date, end_of_day=True)
|
||||
|
||||
rows, total = await audit_service.list_audit_logs(
|
||||
db,
|
||||
user_id=user_id,
|
||||
module=module,
|
||||
action=action,
|
||||
target_id=target_id,
|
||||
request_id=request_id,
|
||||
status_code=status_code,
|
||||
start=start,
|
||||
end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None,
|
||||
skip=(page - 1) * page_size,
|
||||
limit=page_size,
|
||||
)
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
item = AuditLogResponse.model_validate(row)
|
||||
# 中文标签由服务端补,避免前端为每个枚举再维护一份映射
|
||||
item.module_label = MODULE_LABELS.get(row.module, row.module)
|
||||
item.action_label = ACTION_LABELS.get(row.action, row.action)
|
||||
items.append(item)
|
||||
|
||||
return AuditLogListResponse(items=items, total=total)
|
||||
|
||||
|
||||
@router.get("/options", response_model=AuditOptionsResponse)
|
||||
async def get_audit_options(
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> AuditOptionsResponse:
|
||||
"""筛选项:模块与动作的中文下拉;顺带下发导出可选列"""
|
||||
return AuditOptionsResponse(
|
||||
modules=[AuditOption(value=k, label=v) for k, v in MODULE_LABELS.items()],
|
||||
actions=[AuditOption(value=k, label=v) for k, v in ACTION_LABELS.items()],
|
||||
log_export_columns=[
|
||||
AuditOption(value=k, label=v[0]) for k, v in _AUDIT_LOG_COLUMNS.items()
|
||||
],
|
||||
usage_export_columns=[
|
||||
AuditOption(value=k, label=v[0]) for k, v in _DAILY_USAGE_COLUMNS.items()
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV 导出
|
||||
# ============================================================
|
||||
|
||||
def _bj(dt: datetime | None) -> str:
|
||||
"""时间列统一按北京时间输出(与列表页、日活分日口径一致)。
|
||||
|
||||
直接输出 UTC 会让导出文件里 01:00 的操作显示成前一天 17:00,
|
||||
与网页上看到的对不上 —— 导出与页面不一致是最容易被质疑的那种问题。
|
||||
"""
|
||||
if dt is None:
|
||||
return ""
|
||||
return dt.astimezone(BEIJING_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _actor(log) -> str:
|
||||
"""操作人:优先中文名,退化为账号(与列表页的展示规则一致)"""
|
||||
if not log.user_id and not log.display_name:
|
||||
return "未认证"
|
||||
return f"{log.display_name}({log.user_id})" if log.display_name else (log.user_id or "")
|
||||
|
||||
|
||||
# 列定义:key → (表头, 取值函数)。
|
||||
# 前端只传 key 列表,中文表头与取值口径都由后端统一维护,
|
||||
# 避免两端各写一份导致"导出的列和页面上的对不上"。
|
||||
_AUDIT_LOG_COLUMNS: dict[str, tuple[str, Callable[[Any], Any]]] = {
|
||||
"time": ("时间", lambda r: _bj(r.created_at)),
|
||||
"user": ("操作人", _actor),
|
||||
"role": ("角色", lambda r: r.role or ""),
|
||||
"module": ("模块", lambda r: MODULE_LABELS.get(r.module, r.module)),
|
||||
"action": ("动作", lambda r: ACTION_LABELS.get(r.action, r.action)),
|
||||
"method": ("方法", lambda r: r.method or ""),
|
||||
"url": ("请求路径", lambda r: r.url or ""),
|
||||
"status": ("结果", lambda r: r.status_code if r.status_code is not None else ""),
|
||||
"ip": ("来源IP", lambda r: r.ip_address or ""),
|
||||
"target": ("目标", lambda r: f"{r.target_type or ''}:{r.target_id or ''}".strip(":")),
|
||||
"error": ("错误信息", lambda r: r.error_message or ""),
|
||||
"request_id": ("请求ID", lambda r: r.request_id or ""),
|
||||
"user_agent": ("User-Agent", lambda r: r.user_agent or ""),
|
||||
}
|
||||
|
||||
_DAILY_USAGE_COLUMNS: dict[str, tuple[str, Callable[[dict], Any]]] = {
|
||||
"day": ("日期", lambda r: r["day"]),
|
||||
"user": ("操作人", lambda r: f"{r['display_name']}({r['user_id']})" if r["display_name"] else (r["user_id"] or "")),
|
||||
"role": ("角色", lambda r: r["role"] or ""),
|
||||
# 上线/下线时间 = 当天首次/末次活动(非登录时间),
|
||||
# 登录/登出次数单独成列,两者不再混为一谈
|
||||
"first_active": ("上线时间", lambda r: _bj(r["first_active_at"])),
|
||||
"last_active": ("下线时间", lambda r: _bj(r["last_active_at"])),
|
||||
"login_count": ("登录次数", lambda r: r["login_count"]),
|
||||
"logout_count": ("登出次数", lambda r: r["logout_count"]),
|
||||
"op_count": ("操作次数", lambda r: r["op_count"]),
|
||||
}
|
||||
|
||||
|
||||
def _csv_response(
|
||||
columns: dict[str, tuple[str, Callable]], keys: list[str], rows: list, filename: str,
|
||||
) -> Response:
|
||||
"""把行数据渲染成 CSV 响应。
|
||||
|
||||
⚠️ 必须带 UTF-8 BOM:Excel 靠它识别编码,否则中文表头与内容全是乱码。
|
||||
这是 CSV 导出最常见、也最容易被忽略的坑。
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow([columns[k][0] for k in keys])
|
||||
for row in rows:
|
||||
writer.writerow([columns[k][1](row) for k in keys])
|
||||
|
||||
return Response(
|
||||
content=b"\xef\xbb\xbf" + buf.getvalue().encode("utf-8"),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
# 文件名用纯 ASCII:中文文件名要走 RFC 5987,各浏览器行为不一致,
|
||||
# 内部系统没必要为它引入兼容成本。
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_keys(raw: str | None, columns: dict) -> list[str]:
|
||||
"""解析前端传来的列 key。缺省 = 全部列;未知 key 直接忽略(不报错)。"""
|
||||
if not raw:
|
||||
return list(columns)
|
||||
keys = [k.strip() for k in raw.split(",") if k.strip() in columns]
|
||||
return keys or list(columns)
|
||||
|
||||
|
||||
@router.get("/logs/export")
|
||||
async def export_audit_logs(
|
||||
user_id: str | None = Query(None, description="操作人账号(模糊匹配)"),
|
||||
module: str | None = Query(None, description="业务模块"),
|
||||
action: str | None = Query(None, description="动作类型"),
|
||||
target_id: str | None = Query(None, description="目标ID"),
|
||||
request_id: str | None = Query(None, description="请求ID"),
|
||||
status_code: int | None = Query(None, description="响应状态码"),
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(含当天)"),
|
||||
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> Response:
|
||||
"""审计明细 CSV 导出 —— 筛选维度与 /logs 完全一致,保证"看到什么就能导出什么"。"""
|
||||
start = _parse_day(start_date)
|
||||
end_exclusive = _parse_day(end_date, end_of_day=True)
|
||||
|
||||
rows, truncated = await audit_service.export_audit_logs(
|
||||
db,
|
||||
user_id=user_id, module=module, action=action, target_id=target_id,
|
||||
request_id=request_id, status_code=status_code,
|
||||
start=start,
|
||||
end=end_exclusive - timedelta(microseconds=1) if end_exclusive else None,
|
||||
)
|
||||
|
||||
keys = _resolve_keys(columns, _AUDIT_LOG_COLUMNS)
|
||||
resp = _csv_response(_AUDIT_LOG_COLUMNS, keys, rows, "audit_logs.csv")
|
||||
if truncated:
|
||||
# 用响应头传递"已截断",前端据此提示用户收窄筛选条件
|
||||
resp.headers["X-Export-Truncated"] = "1"
|
||||
resp.headers["X-Export-Max-Rows"] = str(audit_service.EXPORT_MAX_ROWS)
|
||||
resp.headers["Access-Control-Expose-Headers"] = "X-Export-Truncated, X-Export-Max-Rows"
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/daily-usage/export")
|
||||
async def export_daily_usage(
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"),
|
||||
columns: str | None = Query(None, description="导出列,逗号分隔;缺省=全部"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> Response:
|
||||
"""日活统计 CSV 导出 —— 每人一行:上线/下线次数与时间、操作次数。"""
|
||||
start = _parse_day(start_date) or datetime.combine(
|
||||
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
|
||||
)
|
||||
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
|
||||
|
||||
items = await audit_service.get_daily_usage(db, start=start, end=end)
|
||||
keys = _resolve_keys(columns, _DAILY_USAGE_COLUMNS)
|
||||
return _csv_response(_DAILY_USAGE_COLUMNS, keys, items, "daily_usage.csv")
|
||||
|
||||
|
||||
@router.get("/daily-usage", response_model=DailyUsageResponse)
|
||||
async def get_daily_usage(
|
||||
start_date: str | None = Query(None, description="起始日期 YYYY-MM-DD(北京时间),默认今天"),
|
||||
end_date: str | None = Query(None, description="结束日期 YYYY-MM-DD(北京时间),默认同起始日"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
) -> DailyUsageResponse:
|
||||
"""日活 / 使用统计 —— 按【北京时间自然日 × 操作人】聚合。
|
||||
|
||||
回答的是「每天有哪些人用了系统、用了多少」:
|
||||
· 上线时间 / 下线时间:当天**首次 / 末次活动**时间(任意审计记录)
|
||||
· 操作次数:当天该用户的全部审计记录数(使用深度)
|
||||
· 登录次数 / 登出次数:真实的手动登录 / 登出行为计数
|
||||
|
||||
⚠️ 上线时间【不取登录时间】:token 有效期内(refresh 7 天)用户不重新登录,
|
||||
按登录算会让「周一登录、周二继续用」的周二变成"登录次数 0、上线时间空,
|
||||
但操作次数 35"——报表自相矛盾。改用活动口径后,当天的第一次操作即上线时间。
|
||||
|
||||
⚠️ 登出次数天然小于登录次数:用户直接关浏览器、断网、token 过期都不会
|
||||
产生登出记录。这是真实情况,不做任何"补齐"推算。
|
||||
"""
|
||||
# 起始日:未传则取北京的今天。_parse_day 返回的是北京时间当日 00:00。
|
||||
start = _parse_day(start_date) or datetime.combine(
|
||||
get_beijing_time().date(), time.min, tzinfo=BEIJING_TZ,
|
||||
)
|
||||
# 结束日:_parse_day(end_of_day=True) 已给出「次日 00:00」,正好当作半开上界。
|
||||
# 未传则默认单日查询(= 起始日当天)。
|
||||
end = _parse_day(end_date, end_of_day=True) or (start + timedelta(days=1))
|
||||
|
||||
items = await audit_service.get_daily_usage(db, start=start, end=end)
|
||||
|
||||
return DailyUsageResponse(
|
||||
start_date=start.astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
|
||||
end_date=(end - timedelta(days=1)).astimezone(BEIJING_TZ).strftime("%Y-%m-%d"),
|
||||
items=[DailyUsageRow(**row) for row in items],
|
||||
total=len(items),
|
||||
)
|
||||
80
backend/app/api/v1/endpoints/auth.py
Normal file
80
backend/app/api/v1/endpoints/auth.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""认证 API — 对接 MOM sys_user + 双 Token 刷新"""
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from app.schemas.user import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshRequest,
|
||||
RefreshResponse,
|
||||
UserResponse,
|
||||
)
|
||||
from app.core.security import peek_token_identity
|
||||
from app.services.auth_service import login, refresh_access_token, get_current_user
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login_endpoint(data: LoginRequest, request: Request):
|
||||
"""登录 — 验证 MOM sys_user 表,返回 Access + Refresh 双 Token"""
|
||||
# 登录请求本身尚未认证,中间件拿不到操作人。但「谁在尝试登录、失败了多少次」
|
||||
# 恰恰是审计里最该有的信息,所以在校验之前就把尝试的账号写进 state:
|
||||
# 登录失败时同样留痕,且能按账号追踪暴力破解。
|
||||
# 注意:绝不把 data.password 写进 state / 审计,密码不落库。
|
||||
request.state.audit_user = data.username
|
||||
result = login(data.username, data.password)
|
||||
|
||||
# 登录成功后补上显示名 / 角色 —— 否则审计里这条记录的「操作人」会退化成账号
|
||||
# (前端按 display_name || user_id 渲染,见 AdminAuditLogPage)。
|
||||
# 能在这里补的原因:中间件是在 call_next 返回【之后】才落库的,此刻写入
|
||||
# request.state 依然会被采集到。
|
||||
# 失败登录走不到这里,保持「只有账号可追责」——这正是想要的语义。
|
||||
if result.user:
|
||||
request.state.audit_display_name = result.user.display_name
|
||||
request.state.audit_role = result.user.role
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
def refresh_endpoint(data: RefreshRequest, request: Request):
|
||||
"""刷新 Access Token — 使用 Refresh Token 换取新的 Access Token"""
|
||||
# 本接口刻意不挂 get_current_user:能用到这里,正是因为 access token 已经
|
||||
# 过期/缺失,请求里没有 Authorization 头,JWT 依赖不会执行 → 审计拿不到操作人,
|
||||
# 记录只能显示「未认证」。
|
||||
# 但 refresh token 里本来就带着完整身份(sub/username/display_name/role),
|
||||
# 解出来写进 state,审计才能记到人 —— 而"谁在何时尝试刷新"正是要留痕的。
|
||||
# 注意 peek 只用于审计标注,鉴权判断一律走 get_current_user。
|
||||
identity = peek_token_identity(data.refresh_token)
|
||||
if identity:
|
||||
request.state.audit_user = identity.get("username") or identity.get("sub")
|
||||
request.state.audit_display_name = identity.get("display_name") or ""
|
||||
request.state.audit_role = identity.get("role") or ""
|
||||
return refresh_access_token(data.refresh_token)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
"""登出 —— 仅用于审计留痕。
|
||||
|
||||
JWT 是无状态的,服务端没有可吊销的会话,因此本接口**不做任何令牌失效**
|
||||
(客户端清掉本地 token 即为登出),返回体也没有实际语义。
|
||||
|
||||
它存在的唯一目的:让审计中间件记下「谁在何时退出了系统」。
|
||||
没有这个端点时,前端「退出」只清本地存储、不产生任何请求,
|
||||
退出动作在审计里完全不可见 —— 而"谁在什么时候退掉了系统"
|
||||
在追责场景下和"谁登录了"同等重要。
|
||||
|
||||
挂 Depends(get_current_user) 是为了让 JWT 依赖把操作人写进 request.state
|
||||
(见 auth_service.get_current_user),记录到真实姓名而非「未认证」。
|
||||
"""
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息(从 Access Token 解析)"""
|
||||
return UserResponse(
|
||||
id=current_user["sub"],
|
||||
username=current_user.get("username", ""),
|
||||
display_name=current_user.get("display_name", ""),
|
||||
role=current_user.get("role", "operator"),
|
||||
)
|
||||
257
backend/app/api/v1/endpoints/dashboard.py
Normal file
257
backend/app/api/v1/endpoints/dashboard.py
Normal file
@ -0,0 +1,257 @@
|
||||
"""Dashboard API — 上帝视角(全厂数据,无用户过滤)"""
|
||||
import io
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.database import get_db
|
||||
from app.core.time_utils import BEIJING_TZ
|
||||
from app.services.dashboard_service import (
|
||||
get_dashboard_stats, DashboardStats,
|
||||
get_my_stats, MyStats,
|
||||
get_wip_tasks, WipTask,
|
||||
get_completed_tasks, CompletedTask,
|
||||
get_rejected_tasks, RejectedTask,
|
||||
get_user_operations, UserOperation,
|
||||
get_user_operation_detail, OperationDetail,
|
||||
get_wip_matrix, WipMatrixRow,
|
||||
get_wip_matrix_detail, WipMatrixDetailRow,
|
||||
get_people_workload, PersonWorkload,
|
||||
get_people_history, PersonHistoryRecord,
|
||||
search_product_messages, ProductMessageList,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["管理看板"])
|
||||
|
||||
|
||||
def _parse_bound(value: str | None) -> datetime | None:
|
||||
"""解析 ISO 时间边界。
|
||||
|
||||
裸时间(无时区偏移)按**北京时间**解释 —— 否则会被当作服务器本地时间,
|
||||
在边界上整体偏移 8 小时,出现「选了今日却统计到昨天下午」这类错位。
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
dt = datetime.fromisoformat(value)
|
||||
return dt.replace(tzinfo=BEIJING_TZ) if dt.tzinfo is None else dt
|
||||
|
||||
|
||||
@router.get("/stats", response_model=DashboardStats)
|
||||
async def dashboard_stats(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
全局统计(上帝视角)。
|
||||
|
||||
时间筛选仅影响 COMPLETED / REJECTED 计数;
|
||||
PENDING / WIP / 总数永远返回实时快照。
|
||||
"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_dashboard_stats(db, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/my-stats", response_model=MyStats)
|
||||
async def my_stats(
|
||||
assignee_id: str = Query(..., description="负责人ID(移动端传当前登录用户 username)"),
|
||||
since: str | None = Query(None, description="起始时间 ISO(含时区偏移,如 2026-09-01T00:00:00+08:00);缺省=本月 1 日"),
|
||||
until: str | None = Query(None, description="截止时间 ISO(含时区偏移);缺省=此刻"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
一线工人个人效能 — 移动端「工作统计」页,支持自选时段。
|
||||
|
||||
- 生产战绩(完成 / 被驳回 / 参与产品)按本人名下任务归因;
|
||||
- 操作统计(接收 / 转交 / 上传备注)与 PC /dashboard/user-operations 严格同口径,
|
||||
工人自查的数与主管看到的面板对得上。
|
||||
|
||||
⚠️ since/until 必须带时区偏移。移动端发的是北京时间 (+08:00),
|
||||
不带偏移的裸字符串会被当成"本地时间"导致边界偏移 8 小时。
|
||||
"""
|
||||
return await get_my_stats(db, assignee_id, since=_parse_bound(since), until=_parse_bound(until))
|
||||
|
||||
|
||||
@router.get("/wip-tasks", response_model=list[WipTask])
|
||||
async def wip_tasks(
|
||||
limit: int = Query(500, ge=1, le=1000, description="防御性安全上限;默认足以覆盖全部在制品"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""在制品看板 — 永远实时的 PENDING/WIP 任务(默认全量,不再按 20 条静默截断)"""
|
||||
return await get_wip_tasks(db, limit)
|
||||
|
||||
|
||||
@router.get("/completed-tasks", response_model=list[CompletedTask])
|
||||
async def completed_tasks(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""流转完成率下钻 — 按时段查询已完成任务明细"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_completed_tasks(db, since=since_dt, until=until_dt, limit=limit)
|
||||
|
||||
|
||||
@router.get("/rejected-tasks", response_model=list[RejectedTask])
|
||||
async def rejected_tasks(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""驳回/返工下钻 — 按时段查询被驳回任务明细(含返工去向)"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_rejected_tasks(db, since=since_dt, until=until_dt, limit=limit)
|
||||
|
||||
|
||||
@router.get("/user-operations", response_model=list[UserOperation])
|
||||
async def user_operations(
|
||||
since: str | None = Query(None, description="起始日期 ISO 如 2026-08-01T00:00:00"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员操作统计 — 按人聚合 接收/转交/上传备注 次数,按时段过滤"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_user_operations(db, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/user-operations/detail", response_model=list[OperationDetail])
|
||||
async def user_operations_detail(
|
||||
user_id: str = Query(..., description="人员ID(username)"),
|
||||
action_type: str = Query(..., description="操作类型: receive/transfer/record"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员操作明细下钻 — 某人在指定时段的接收/转交/上传备注明细"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_user_operation_detail(
|
||||
db, user_id, action_type, since=since_dt, until=until_dt,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/wip-matrix", response_model=list[WipMatrixRow])
|
||||
async def wip_matrix(
|
||||
dimension: str = Query("assignee", description="聚合维度: assignee(人员) / task_name(工序)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""生产分布透视表 — 规格型号 × 人员/工序 的设备数量交叉聚合(含已完成/已入库/已出库)"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_wip_matrix(db, dimension=dimension, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/wip-matrix/detail", response_model=list[WipMatrixDetailRow])
|
||||
async def wip_matrix_detail(
|
||||
spec: str = Query(..., description="规格型号"),
|
||||
process: str = Query(..., description="当前工序(dimension_key)"),
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""WIP 矩阵单元格下钻 — 返回某 规格型号×工序 交叉点下的设备明细"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_wip_matrix_detail(db, spec_model=spec, process=process, since=since_dt, until=until_dt)
|
||||
|
||||
|
||||
@router.get("/people-workload", response_model=list[PersonWorkload])
|
||||
async def people_workload(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员负载 — 按负责人聚合当前在制品设备数(独立人员看板)"""
|
||||
return await get_people_workload(db)
|
||||
|
||||
|
||||
@router.get("/people-history", response_model=list[PersonHistoryRecord])
|
||||
async def people_history(
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
assignee_id: str | None = Query(None, description="负责人ID(精确)"),
|
||||
spec_model: str | None = Query(None, description="规格型号(模糊)"),
|
||||
product_sn: str | None = Query(None, description="身份证(模糊)"),
|
||||
task_name: str | None = Query(None, description="任务名(模糊)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""人员效能与工时台账 — 平铺 Task 明细,多维筛选 + 时间交集"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
return await get_people_history(
|
||||
db, since=since_dt, until=until_dt,
|
||||
assignee_id=assignee_id, spec_model=spec_model,
|
||||
product_sn=product_sn, task_name=task_name,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/people-history/export")
|
||||
async def export_people_history(
|
||||
since: str | None = Query(None, description="起始日期 ISO"),
|
||||
until: str | None = Query(None, description="截止日期 ISO"),
|
||||
assignee_id: str | None = Query(None, description="负责人ID(精确)"),
|
||||
spec_model: str | None = Query(None, description="规格型号(模糊)"),
|
||||
product_sn: str | None = Query(None, description="身份证(模糊)"),
|
||||
task_name: str | None = Query(None, description="任务名(模糊)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出工时台账为 Excel(与查询接口相同筛选条件)"""
|
||||
since_dt = datetime.fromisoformat(since) if since else None
|
||||
until_dt = datetime.fromisoformat(until) if until else None
|
||||
records = await get_people_history(
|
||||
db, since=since_dt, until=until_dt,
|
||||
assignee_id=assignee_id, spec_model=spec_model,
|
||||
product_sn=product_sn, task_name=task_name,
|
||||
)
|
||||
|
||||
import csv
|
||||
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["状态", "负责人", "身份证", "业务序列号", "产品名称", "规格型号", "任务名", "开始时间", "结束时间", "总耗时(小时)", "最新有效备注"])
|
||||
status_label = {"WIP": "进行中", "PENDING": "待接收", "COMPLETED": "已完成"}
|
||||
for r in records:
|
||||
writer.writerow([
|
||||
status_label.get(r.status, r.status),
|
||||
r.assignee_name,
|
||||
r.product_sn,
|
||||
r.external_serial or "",
|
||||
r.material_name,
|
||||
r.spec_model,
|
||||
r.task_name,
|
||||
r.received_at or "",
|
||||
r.completed_at or "进行中",
|
||||
r.duration_hours,
|
||||
r.latest_valid_remark or "",
|
||||
])
|
||||
|
||||
data = output.getvalue().encode("utf-8-sig") # 带 BOM,Excel 正确识别中文
|
||||
buf = io.BytesIO(data)
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
buf,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": "attachment; filename=people_history.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/messages", response_model=ProductMessageList)
|
||||
async def dashboard_messages(
|
||||
keyword: str = Query("", description="搜索: SN码/物料名/留言人/内容"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(30, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
协同留言搜索(上帝视角 — 全厂所有产品留言)。
|
||||
|
||||
关联 Product 表返回 serial_number + material_name,
|
||||
按时间倒序排列。
|
||||
"""
|
||||
return await search_product_messages(db, keyword=keyword, skip=skip, limit=limit)
|
||||
61
backend/app/api/v1/endpoints/external_products.py
Normal file
61
backend/app/api/v1/endpoints/external_products.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""外部系统产品查询 API — 供 MOM 端扫码自动带出设备数据"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.models.product import Product
|
||||
|
||||
router = APIRouter(prefix="/external/products", tags=["外部查询"])
|
||||
|
||||
|
||||
@router.get("/lookup")
|
||||
async def external_product_lookup(
|
||||
code: str = Query(..., description="16 位系统序列号 或 自定义业务序列号"),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""供 MOM 端扫码查询产品基础信息。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- code 同时匹配 Product.serial_number(16 位)与 external_serial(业务序列号)。
|
||||
- 命中返回格式化设备信息;未命中返回 404。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 联合查询:serial_number 或 external_serial 匹配 code ──
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.options(selectinload(Product.order))
|
||||
.where(
|
||||
or_(
|
||||
Product.serial_number == code,
|
||||
Product.external_serial == code,
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"serial_number": product.serial_number,
|
||||
"external_serial": product.external_serial,
|
||||
"material_id": product.material_id,
|
||||
"sku": product.spec_model,
|
||||
"material_name": product.material_name,
|
||||
"spec_model": product.spec_model,
|
||||
"material_type": product.material_type,
|
||||
"order_no": product.order.order_no if product.order else "",
|
||||
},
|
||||
}
|
||||
60
backend/app/api/v1/endpoints/holidays.py
Normal file
60
backend/app/api/v1/endpoints/holidays.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""节假日管理 — 工作日时长计算所需的放假日期配置"""
|
||||
from datetime import date
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.holiday import Holiday
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/holidays", tags=["节假日管理"])
|
||||
|
||||
|
||||
class HolidayCreate(BaseModel):
|
||||
day: date = Field(..., description="放假日期")
|
||||
name: str | None = Field(None, max_length=100, description="放假说明")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_holidays(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取全部放假日期(按日期正序)"""
|
||||
result = await db.execute(select(Holiday).order_by(Holiday.day.asc()))
|
||||
return [
|
||||
{"id": h.id, "date": h.day.isoformat(), "name": h.name}
|
||||
for h in result.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
@router.post("/", status_code=201)
|
||||
async def create_holiday(
|
||||
data: HolidayCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""添加一个放假日期"""
|
||||
exists = await db.scalar(select(Holiday).where(Holiday.day == data.day))
|
||||
if exists:
|
||||
raise HTTPException(status_code=400, detail=f"{data.day} 已在节假日列表中")
|
||||
h = Holiday(day=data.day, name=data.name)
|
||||
db.add(h)
|
||||
await db.commit()
|
||||
await db.refresh(h)
|
||||
return {"id": h.id, "date": h.day.isoformat(), "name": h.name}
|
||||
|
||||
|
||||
@router.delete("/{holiday_id}", status_code=204)
|
||||
async def delete_holiday(
|
||||
holiday_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""删除一个放假日期"""
|
||||
h = await db.get(Holiday, holiday_id)
|
||||
if not h:
|
||||
raise HTTPException(status_code=404, detail="节假日不存在")
|
||||
await db.delete(h)
|
||||
await db.commit()
|
||||
133
backend/app/api/v1/endpoints/materials.py
Normal file
133
backend/app/api/v1/endpoints/materials.py
Normal file
@ -0,0 +1,133 @@
|
||||
"""物料选择器 — 读 MOM material_base,按成品/半成品 category 手风琴分组"""
|
||||
from fastapi import APIRouter, Query, HTTPException, status, Depends
|
||||
from pydantic import BaseModel
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from app.services.auth_service import get_current_user
|
||||
from sqlalchemy import text
|
||||
|
||||
router = APIRouter(prefix="/materials", tags=["物料选择"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 响应模型
|
||||
# ============================================================
|
||||
|
||||
class MaterialGroup(BaseModel):
|
||||
category: str
|
||||
count: int
|
||||
|
||||
|
||||
class MaterialItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
spec: str
|
||||
category: str
|
||||
type: str
|
||||
unit: str
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 端点
|
||||
# ============================================================
|
||||
|
||||
@router.get("/groups", response_model=list[MaterialGroup])
|
||||
def get_material_groups(
|
||||
keyword: str = Query("", description="搜索(按名称/规格)"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
按 category 分组汇总,前端渲染手风琴外层。
|
||||
只返回成品/半成品分类。
|
||||
"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
if keyword.strip():
|
||||
sql = text("""
|
||||
SELECT category, COUNT(*) AS count
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND (name ILIKE :kw OR spec_model ILIKE :kw)
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
""")
|
||||
result = db.execute(sql, {"kw": f"%{keyword.strip()}%"})
|
||||
else:
|
||||
sql = text("""
|
||||
SELECT category, COUNT(*) AS count
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
GROUP BY category
|
||||
ORDER BY category
|
||||
""")
|
||||
result = db.execute(sql)
|
||||
|
||||
rows = result.fetchall()
|
||||
return [MaterialGroup(category=row.category, count=row.count) for row in rows]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"MOM material_base 查询失败: {str(e)}",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/items", response_model=list[MaterialItem])
|
||||
def get_material_items(
|
||||
category: str = Query(..., description="物料分类"),
|
||||
keyword: str = Query("", description="分组内搜索"),
|
||||
limit: int = Query(500, ge=1, le=9999),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取指定 category 下的物料条目,前端展开手风琴时懒加载。
|
||||
"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
if keyword.strip():
|
||||
sql = text("""
|
||||
SELECT id, name, spec_model AS spec, category, material_type AS type,
|
||||
COALESCE(unit, '') AS unit, is_enabled
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND category = :cat
|
||||
AND (name ILIKE :kw OR spec_model ILIKE :kw)
|
||||
ORDER BY name
|
||||
LIMIT :lim
|
||||
""")
|
||||
result = db.execute(
|
||||
sql, {"cat": category, "kw": f"%{keyword.strip()}%", "lim": limit}
|
||||
)
|
||||
else:
|
||||
sql = text("""
|
||||
SELECT id, name, spec_model AS spec, category, material_type AS type,
|
||||
COALESCE(unit, '') AS unit, is_enabled
|
||||
FROM material_base
|
||||
WHERE is_enabled = TRUE
|
||||
AND category = :cat
|
||||
ORDER BY name
|
||||
LIMIT :lim
|
||||
""")
|
||||
result = db.execute(sql, {"cat": category, "lim": limit})
|
||||
|
||||
rows = result.fetchall()
|
||||
return [
|
||||
MaterialItem(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
spec=row.spec,
|
||||
category=row.category,
|
||||
type=row.type,
|
||||
unit=row.unit,
|
||||
is_enabled=row.is_enabled,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"MOM material_base 查询失败: {str(e)}",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
109
backend/app/api/v1/endpoints/notifications.py
Normal file
109
backend/app/api/v1/endpoints/notifications.py
Normal file
@ -0,0 +1,109 @@
|
||||
"""通知 API 端点 — 获取列表、标记已读"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.notification import Notification
|
||||
from app.models.task import Task
|
||||
from app.models.product import Product
|
||||
from app.schemas.notification import NotificationResponse, NotificationListResponse
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["消息通知"])
|
||||
|
||||
|
||||
@router.get("/", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取当前用户的通知列表(按时间倒序)。
|
||||
|
||||
安全:user_id 强制从 JWT Token 解析,不接受查询参数,
|
||||
杜绝通过篡改 user_id 参数越权查看他人通知。
|
||||
"""
|
||||
user_id: str = current_user.get("username", "") or current_user.get("sub", "")
|
||||
|
||||
# 总数
|
||||
count_stmt = select(func.count()).select_from(Notification).where(
|
||||
Notification.user_id == user_id
|
||||
)
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# 未读数
|
||||
unread_stmt = select(func.count()).select_from(Notification).where(
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read.is_(False),
|
||||
)
|
||||
unread_result = await db.execute(unread_stmt)
|
||||
unread_count = unread_result.scalar() or 0
|
||||
|
||||
# 列表
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
# 🚀 批量查询关联的 product_serial_number
|
||||
task_ids = [n.task_id for n in notifications if n.task_id]
|
||||
serial_map: dict[uuid.UUID, str] = {}
|
||||
if task_ids:
|
||||
task_result = await db.execute(
|
||||
select(Task.id, Product.serial_number)
|
||||
.join(Product, Task.product_id == Product.id)
|
||||
.where(Task.id.in_(task_ids))
|
||||
)
|
||||
for row in task_result:
|
||||
serial_map[row[0]] = row[1]
|
||||
|
||||
# 组装响应
|
||||
response_list: list[NotificationResponse] = []
|
||||
for n in notifications:
|
||||
resp = NotificationResponse.model_validate(n)
|
||||
if n.task_id and n.task_id in serial_map:
|
||||
resp.product_serial_number = serial_map[n.task_id]
|
||||
response_list.append(resp)
|
||||
|
||||
return NotificationListResponse(
|
||||
notifications=response_list,
|
||||
total=total,
|
||||
unread_count=unread_count,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{notification_id}/read", response_model=NotificationResponse)
|
||||
async def mark_notification_read(
|
||||
notification_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""标记单条通知为已读"""
|
||||
nid = uuid.UUID(notification_id)
|
||||
result = await db.execute(
|
||||
select(Notification).where(Notification.id == nid)
|
||||
)
|
||||
notification = result.scalar_one_or_none()
|
||||
if not notification:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"通知不存在: {notification_id}",
|
||||
)
|
||||
|
||||
notification.is_read = True
|
||||
await db.commit()
|
||||
await db.refresh(notification)
|
||||
return NotificationResponse.model_validate(notification)
|
||||
41
backend/app/api/v1/endpoints/orders.py
Normal file
41
backend/app/api/v1/endpoints/orders.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""生产订单 API 端点"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.production_order import ProductionOrder
|
||||
from app.schemas.order import OrderCreate, OrderResponse
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/orders", tags=["订单管理"])
|
||||
|
||||
|
||||
@router.get("/", response_model=list[OrderResponse])
|
||||
async def list_orders(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(ProductionOrder).offset(skip).limit(limit).order_by(ProductionOrder.created_at.desc())
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
return [OrderResponse.model_validate(o) for o in orders]
|
||||
|
||||
|
||||
@router.post("/", response_model=OrderResponse, status_code=201)
|
||||
async def create_order(
|
||||
data: OrderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
order = ProductionOrder(**data.model_dump())
|
||||
db.add(order)
|
||||
await db.commit()
|
||||
await db.refresh(order)
|
||||
return OrderResponse.model_validate(order)
|
||||
99
backend/app/api/v1/endpoints/print.py
Normal file
99
backend/app/api/v1/endpoints/print.py
Normal file
@ -0,0 +1,99 @@
|
||||
"""标签打印 API — 预览 / 执行 / 打印机配置"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.label_service import generate_preview_image, send_to_printer
|
||||
from app.services.print_config import PrintConfigManager
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/print", tags=["标签打印"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 请求体
|
||||
# ============================================================
|
||||
|
||||
class LabelPreviewRequest(BaseModel):
|
||||
serial_number: str = Field(..., min_length=1, description="16位HEX系统ID(二维码内容)")
|
||||
material_name: str = Field("", description="物料名称")
|
||||
spec_model: str = Field("", description="规格型号")
|
||||
order_no: str = Field("", description="订单号(条件渲染)")
|
||||
|
||||
|
||||
class PrintExecuteRequest(LabelPreviewRequest):
|
||||
copies: int = Field(1, ge=1, le=100, description="打印份数")
|
||||
printer_ip: str | None = Field(None, description="覆盖配置的打印机 IP")
|
||||
printer_port: int | None = Field(None, description="覆盖配置的打印机端口")
|
||||
|
||||
|
||||
class PrinterConfigUpdate(BaseModel):
|
||||
ip: str = Field(..., description="打印机 IP 地址")
|
||||
port: int = Field(9100, ge=1, le=65535, description="打印机端口")
|
||||
enabled: bool = Field(True, description="是否启用")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 端点
|
||||
# ============================================================
|
||||
|
||||
@router.post("/preview")
|
||||
def print_preview(data: LabelPreviewRequest) -> dict:
|
||||
"""生成标签预览图(Base64 JPEG)"""
|
||||
try:
|
||||
data_url = generate_preview_image(**data.model_dump())
|
||||
return {"data_url": data_url}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"生成预览失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
def print_execute(
|
||||
data: PrintExecuteRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""发送打印指令到物理打标机"""
|
||||
payload = data.model_dump()
|
||||
copies = payload.pop("copies", 1)
|
||||
printer_ip = payload.pop("printer_ip", None)
|
||||
printer_port = payload.pop("printer_port", None)
|
||||
|
||||
result = send_to_printer(
|
||||
copies=copies,
|
||||
printer_ip=printer_ip,
|
||||
printer_port=printer_port,
|
||||
**payload,
|
||||
)
|
||||
if not result["success"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=result["message"],
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_printer_config() -> dict:
|
||||
"""获取打印机当前配置"""
|
||||
return PrintConfigManager.get_config()
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
def update_printer_config(
|
||||
data: PrinterConfigUpdate,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""更新打印机配置(IP/端口)"""
|
||||
current = PrintConfigManager.get_config()
|
||||
current["label_printer"] = {
|
||||
"ip": data.ip,
|
||||
"port": data.port,
|
||||
"enabled": data.enabled,
|
||||
}
|
||||
PrintConfigManager.save_config(current)
|
||||
return {
|
||||
"message": "打印机配置已更新",
|
||||
"config": current["label_printer"],
|
||||
}
|
||||
238
backend/app/api/v1/endpoints/products.py
Normal file
238
backend/app/api/v1/endpoints/products.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""产品 API 端点 — 扫码查询、CRUD、二维码生成"""
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.message import ProductMessage
|
||||
from app.schemas.product import (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductResponse,
|
||||
ProductScanResponse,
|
||||
)
|
||||
from app.services import product_service, product_finalize_service
|
||||
from app.services.auth_service import get_current_user
|
||||
from app.services.qrcode_service import generate_qrcode_png
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["产品管理"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 二维码生成 — 根据序列号生成二维码 PNG 图片
|
||||
# ============================================================
|
||||
|
||||
@router.get("/qrcode/{serial_number}")
|
||||
async def get_product_qrcode(
|
||||
serial_number: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
生成产品二维码(PNG 图片)。
|
||||
内容为 16 位序列号,扫描后可调用 /scan/{serial_number} 查询产品。
|
||||
尺寸:300×300 px,用于 PC 端打印或嵌入标签。
|
||||
"""
|
||||
if len(serial_number) != 16:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="序列号必须为 16 位",
|
||||
)
|
||||
buf = generate_qrcode_png(serial_number, size_px=300)
|
||||
return Response(content=buf.getvalue(), media_type="image/png")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 扫码查询 — 根据 16 位序列号查产品 + 顶层任务
|
||||
# ============================================================
|
||||
|
||||
@router.get("/scan/{serial_number}", response_model=ProductScanResponse)
|
||||
async def scan_product(
|
||||
serial_number: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
扫码接口:根据 16 位序列号查询产品及其当前进度。
|
||||
返回产品信息、所属订单、以及顶层任务列表。
|
||||
"""
|
||||
return await product_service.get_product_by_serial(db, serial_number)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 产品 CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/", response_model=list[ProductResponse])
|
||||
async def list_products(
|
||||
skip: int = Query(0, ge=0, description="跳过条数"),
|
||||
limit: int = Query(50, ge=1, le=1000, description="返回条数"),
|
||||
keyword: str | None = Query(None, description="多维搜索: 产品身份证/订单号/规格型号"),
|
||||
status: str | None = Query(None, description="产品状态筛选: PENDING/WIP/COMPLETED/ARCHIVED"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取产品列表 — 支持 keyword 搜索 + 状态筛选"""
|
||||
return await product_service.get_all_products(
|
||||
db, skip=skip, limit=limit, keyword=keyword, status_filter=status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=ProductResponse)
|
||||
async def get_product(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取单个产品详情"""
|
||||
import uuid
|
||||
return await product_service.get_product(db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
@router.post("/", response_model=ProductResponse, status_code=201)
|
||||
async def create_product_endpoint(
|
||||
data: ProductCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建产品 — 初始位置自动设为当前登录用户"""
|
||||
creator_username = current_user.get("username", "")
|
||||
return await product_service.create_product(db, data, creator_username)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductResponse)
|
||||
async def update_product_endpoint(
|
||||
product_id: str,
|
||||
data: ProductUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""更新产品"""
|
||||
import uuid
|
||||
return await product_service.update_product(db, uuid.UUID(product_id), data)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=204)
|
||||
async def delete_product_endpoint(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""删除产品及其关联任务"""
|
||||
import uuid
|
||||
await product_service.delete_product(db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 宏观状态更新 — 扫码定调
|
||||
# ============================================================
|
||||
|
||||
class OverallStatusUpdate(BaseModel):
|
||||
status: str = Field(..., min_length=1, max_length=20, description="宏观状态: 备货/生产/测试/维修/待仓库收货/已入库/已出库")
|
||||
|
||||
|
||||
@router.patch("/scan/{serial_number}/status", response_model=ProductScanResponse)
|
||||
async def update_product_overall_status(
|
||||
serial_number: str,
|
||||
data: OverallStatusUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
更新产品宏观流转状态。
|
||||
移动端首次扫码或手动切换时调用。
|
||||
合法值: 备货 | 生产 | 测试 | 维修 | 待仓库收货 | 已入库 | 已出库
|
||||
|
||||
权限:仅 SUPER_ADMIN 或当前操作该产品主线任务的人可以修改。
|
||||
"""
|
||||
return await product_service.update_overall_status(
|
||||
db, serial_number, data.status, current_user,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 产品收口 — 管理员将产品修正为「已入库」/「已出库」(可反向纠错)
|
||||
# ============================================================
|
||||
|
||||
class ProductFinalizeRequest(BaseModel):
|
||||
"""管理员收口入参"""
|
||||
status: str = Field(..., min_length=1, max_length=20, description="收口目标: 已入库 | 已出库")
|
||||
note: str | None = Field(None, max_length=200, description="备注(选填)")
|
||||
|
||||
|
||||
@router.post("/scan/{serial_number}/finalize", response_model=ProductScanResponse)
|
||||
async def finalize_product_status_endpoint(
|
||||
serial_number: str,
|
||||
data: ProductFinalizeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""管理员将产品整体收口为「已入库」或「已出库」(支持 入库<->出库 反向互切纠错)。
|
||||
|
||||
与 MOM 出入库回调落库语义一致:同步 product.overall_status/status、写
|
||||
warehouse_inbound/outbound 日志、幂等追加「扫码入库/扫码出库」主线收尾节点。
|
||||
权限:仅 SUPER_ADMIN / SUPERVISOR。
|
||||
"""
|
||||
from fastapi import HTTPException, status
|
||||
from app.services.task_service import ADMIN_ROLES
|
||||
|
||||
if (current_user or {}).get("role") not in ADMIN_ROLES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="仅超级管理员或主管可执行入库/出库收口",
|
||||
)
|
||||
return await product_finalize_service.finalize_product_status(
|
||||
db, serial_number, data.status, current_user, data.note,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 协同留言板
|
||||
# ============================================================
|
||||
|
||||
class MessageCreate(BaseModel):
|
||||
operator_id: str = Field(..., min_length=1, max_length=50, description="留言人姓名或工号")
|
||||
content: str = Field(..., min_length=1, description="留言内容")
|
||||
|
||||
|
||||
@router.get("/{product_id}/messages")
|
||||
async def get_product_messages(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取某产品的所有留言(按时间正序)"""
|
||||
result = await db.execute(
|
||||
select(ProductMessage)
|
||||
.where(ProductMessage.product_id == product_id)
|
||||
.order_by(ProductMessage.created_at.asc())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.post("/{product_id}/messages", status_code=201)
|
||||
async def create_product_message(
|
||||
product_id: str,
|
||||
request: MessageCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""发布新留言(operator_id 由后端 Token 强制覆写,防止越权伪造)"""
|
||||
import uuid
|
||||
real_operator_id = (
|
||||
current_user.get("username")
|
||||
or current_user.get("sub")
|
||||
or request.operator_id
|
||||
)
|
||||
msg = ProductMessage(
|
||||
product_id=uuid.UUID(product_id),
|
||||
operator_id=real_operator_id,
|
||||
content=request.content,
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
await db.refresh(msg)
|
||||
return msg
|
||||
51
backend/app/api/v1/endpoints/records.py
Normal file
51
backend/app/api/v1/endpoints/records.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""任务记录 CRUD — 编辑 / 删除"""
|
||||
import json
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.models.task import TaskRecord
|
||||
from app.schemas.task import TaskRecordCreate, TaskRecordResponse
|
||||
from app.services.auth_service import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/records", tags=["任务记录"])
|
||||
|
||||
|
||||
async def _get_record_or_404(db: AsyncSession, record_id: int) -> TaskRecord:
|
||||
result = await db.execute(select(TaskRecord).where(TaskRecord.id == record_id))
|
||||
record = result.scalar_one_or_none()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail=f"记录不存在: {record_id}")
|
||||
return record
|
||||
|
||||
|
||||
@router.put("/{record_id}", response_model=TaskRecordResponse)
|
||||
async def update_record(
|
||||
record_id: int,
|
||||
data: TaskRecordCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""更新任务记录(备注+图片)"""
|
||||
record = await _get_record_or_404(db, record_id)
|
||||
record.remark = data.remark or None
|
||||
record.images = json.dumps(data.images) if data.images else None
|
||||
await db.commit()
|
||||
await db.refresh(record)
|
||||
# 手动反序列化 images
|
||||
return TaskRecordResponse.model_validate(record)
|
||||
|
||||
|
||||
@router.delete("/{record_id}")
|
||||
async def delete_record(
|
||||
record_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""删除任务记录"""
|
||||
record = await _get_record_or_404(db, record_id)
|
||||
await db.delete(record)
|
||||
await db.commit()
|
||||
return {"message": "记录已删除"}
|
||||
52
backend/app/api/v1/endpoints/screen.py
Normal file
52
backend/app/api/v1/endpoints/screen.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""大屏 API — 面向管理层**日常运营与督导**的轻量聚合接口
|
||||
|
||||
视角:当月吞吐 / 当前卡点 / 系统活跃度。
|
||||
|
||||
与 /dashboard 的区别:/dashboard 面向 PC 后台明细下钻(返回大列表),
|
||||
/screen 只返回图表直接可用的扁平聚合数据,字段少、无分页、供高频轮询。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.screen_service import (
|
||||
get_monthly_metrics, MonthlyMetrics,
|
||||
get_wip_distribution, WipDistributionResponse,
|
||||
get_active_users, ActiveUsersResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/screen", tags=["大屏统计"])
|
||||
|
||||
|
||||
@router.get("/monthly-metrics", response_model=MonthlyMetrics)
|
||||
async def monthly_metrics(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
当月吞吐 — 大屏顶部四张数字卡。
|
||||
|
||||
返回:本月生产流转 / 本月已入库 / 本月已出库 / 本月返厂回流。
|
||||
统计区间为北京时间当月 1 日 00:00 至此刻。
|
||||
"""
|
||||
return await get_monthly_metrics(db)
|
||||
|
||||
|
||||
@router.get("/wip-distribution", response_model=WipDistributionResponse)
|
||||
async def wip_distribution(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
工序积压分布 — 当前未完结设备按 overall_status 聚合的**纯数量**。
|
||||
|
||||
返回固定阶段列表(含 0 值),保证柱状图类目稳定、不因缺数据而塌陷。
|
||||
"""
|
||||
return await get_wip_distribution(db)
|
||||
|
||||
|
||||
@router.get("/active-users", response_model=ActiveUsersResponse)
|
||||
async def active_users(
|
||||
top_n: int = Query(5, ge=1, le=20, description="返回的活跃人员数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
本月系统使用活跃度排行 — 接收 / 转交 / 上传备注次数。
|
||||
|
||||
桥接 /dashboard/user-operations 的统计口径,仅返回本月确实有操作的人员。
|
||||
"""
|
||||
return await get_active_users(db, top_n=top_n)
|
||||
322
backend/app/api/v1/endpoints/tasks.py
Normal file
322
backend/app/api/v1/endpoints/tasks.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""任务 API 端点 — 核心业务:接收、驳回返工、裂变转交、无限嵌套子任务"""
|
||||
from __future__ import annotations
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Query, Body
|
||||
from pydantic import BaseModel, Field
|
||||
from app.services.auth_service import get_current_user
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.schemas.task import (
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
TaskCompleteRequest,
|
||||
TaskRejectRequest,
|
||||
TaskTransferRequest,
|
||||
TaskTransferBranch,
|
||||
SubtaskCreate,
|
||||
TaskRecordCreate,
|
||||
TaskResponse,
|
||||
TaskCompleteResponse,
|
||||
TaskTransferResponse,
|
||||
TaskSummaryResponse,
|
||||
TaskListResponse,
|
||||
)
|
||||
from app.services import task_service
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["任务管理"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 任务 CRUD
|
||||
# ============================================================
|
||||
|
||||
@router.get("/", response_model=TaskListResponse)
|
||||
async def list_tasks(
|
||||
product_id: str | None = Query(None, description="按产品ID筛选"),
|
||||
assignee_id: str | None = Query(None, description="按负责人ID筛选(逻辑外键→老系统)"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取任务列表,可按产品/负责人筛选(只返回顶层任务)"""
|
||||
pid = uuid.UUID(product_id) if product_id else None
|
||||
return await task_service.get_all_tasks(db, product_id=pid, assignee_id=assignee_id, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
获取任务详情 — 递归包含所有层级的子任务。
|
||||
前端可根据此结果渲染完整的任务树。
|
||||
"""
|
||||
return await task_service.get_task(db, uuid.UUID(task_id))
|
||||
|
||||
|
||||
@router.post("/", response_model=TaskResponse, status_code=201)
|
||||
async def create_task_endpoint(
|
||||
data: TaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""创建任务"""
|
||||
return await task_service.create_task(db, data)
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskResponse)
|
||||
async def update_task_endpoint(
|
||||
task_id: str,
|
||||
data: TaskUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""更新任务"""
|
||||
return await task_service.update_task(db, uuid.UUID(task_id), data)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心卡点逻辑:任务完成 / 转交
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/complete", response_model=TaskCompleteResponse)
|
||||
async def complete_task_endpoint(
|
||||
task_id: str,
|
||||
request: TaskCompleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**核心接口:完成任务 + 可选创建下一步任务(转交)**
|
||||
|
||||
卡点逻辑:
|
||||
1. 检查当前任务是否已完成(幂等保护)
|
||||
2. 查询所有 `notify_parent_on_complete=True` 的子任务
|
||||
→ 如果存在未完成的,返回 HTTP 400:「请等待相关子任务完成」
|
||||
3. 全部通过后,标记任务为 completed,写入操作日志
|
||||
4. 若提供了 `next_task_name` + `next_assignee_id`,自动创建下一步任务
|
||||
|
||||
典型场景:
|
||||
- 某个加工步骤完成,需要检查所有必须的前置工序(子任务)是否已完成
|
||||
- 完成后自动创建下一步任务并指定负责人
|
||||
"""
|
||||
return await task_service.complete_task(
|
||||
db, uuid.UUID(task_id), request,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0:结束分支(终止当前节点,不创建下游)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/end", response_model=TaskResponse)
|
||||
async def end_task_endpoint(
|
||||
task_id: str,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**结束当前分支:标记任务为 COMPLETED,不创建下游任务。**
|
||||
|
||||
用于工人认为工序已完结、无需转交下一人的场景。
|
||||
"""
|
||||
return await task_service.end_task(
|
||||
db, uuid.UUID(task_id),
|
||||
operator_id or current_user.get("username", "") or None,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0.3:撤回转交 (PENDING → 删除 + 恢复父任务)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/recall", response_model=TaskResponse)
|
||||
async def recall_task_endpoint(
|
||||
task_id: str,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**撤回转交:删除 PENDING 子任务,恢复父任务为 WIP。**
|
||||
适用场景:转交后发现选错人,在对方接收前撤回。
|
||||
"""
|
||||
return await task_service.recall_task(
|
||||
db, uuid.UUID(task_id),
|
||||
operator_id or current_user.get("username", "") or None,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 0.5:并发派发协助分支 (WIP → 不改变状态,创建子任务)
|
||||
# ============================================================
|
||||
|
||||
class SpawnRequest(BaseModel):
|
||||
task_name: str = Field(..., max_length=200, description="工序名称")
|
||||
assignee_id: str | None = Field(None, max_length=64, description="负责人ID")
|
||||
remark: str | None = Field(None, max_length=2000, description="派发备注")
|
||||
|
||||
|
||||
@router.post("/{task_id}/spawn", response_model=TaskResponse, status_code=201)
|
||||
async def spawn_subtask_endpoint(
|
||||
task_id: str,
|
||||
data: SpawnRequest,
|
||||
operator_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**派发协助分支:在当前任务下创建并行子任务,父任务状态保持不变。**
|
||||
用于 WIP 期间工人需要其他人协助协同的场景。
|
||||
"""
|
||||
return await task_service.spawn_subtask(
|
||||
db, uuid.UUID(task_id), data,
|
||||
operator_id or current_user.get("username", "") or None)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 1:确认接收 (PENDING → WIP)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/receive", response_model=TaskResponse)
|
||||
async def receive_task_endpoint(
|
||||
task_id: str,
|
||||
operator_id: str | None = Query(None, description="操作人ID"),
|
||||
remark: str | None = Body(None, description="接收备注", embed=True),
|
||||
task_name: str | None = Body(None, description="接收人选定的工序名称", embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**确认接收任务。工人选定工序名称后接收。**
|
||||
|
||||
校验:只有状态为 PENDING 的任务可接收。
|
||||
动作:状态改为 WIP,记录 received_at,更新 task_name,同步产品宏观状态。
|
||||
"""
|
||||
return await task_service.receive_task(
|
||||
db, uuid.UUID(task_id),
|
||||
operator_id or current_user.get("username", "") or None,
|
||||
remark, task_name,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 2:品质驳回 (→ REJECTED + 返工闭环)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/reject", response_model=TaskResponse)
|
||||
async def reject_task_endpoint(
|
||||
task_id: str,
|
||||
request: TaskRejectRequest,
|
||||
operator_id: str | None = Query(None, description="操作人ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**品质驳回:将任务标记为 REJECTED,自动创建返工任务。**
|
||||
|
||||
防呆闭环逻辑:
|
||||
1. 将当前任务状态改为 REJECTED,记录 reject_reason 和 completed_at。
|
||||
2. 查找上一道工序的负责人(父任务的 assignee_id)。
|
||||
3. 为该负责人新建返工任务(is_rework=True, status=PENDING)。
|
||||
"""
|
||||
return await task_service.reject_task(
|
||||
db, uuid.UUID(task_id), request,
|
||||
operator_id or current_user.get("username", "") or None,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 核心业务 3:完工并裂变转交 (→ COMPLETED + 多路裂变)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/transfer", response_model=TaskTransferResponse)
|
||||
async def transfer_task_endpoint(
|
||||
task_id: str,
|
||||
request: TaskTransferRequest,
|
||||
operator_id: str | None = Query(None, description="操作人ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**完工并裂变转交:完成当前任务,批量创建下一道工序任务。**
|
||||
|
||||
动作 1(闭环当前节点):
|
||||
- 将当前任务状态改为 COMPLETED,记录 completed_at。
|
||||
|
||||
动作 2(解析下家):
|
||||
- 遍历 next_assignees 列表。
|
||||
- 如果包含 'virtual_warehouse',则将 Product 的 current_location_id 设为仓库。
|
||||
- 为每一个 assignee_id 新建 PENDING 任务。
|
||||
|
||||
裂变逻辑:
|
||||
- next_assignees > 1 → 多路裂变,新任务挂在当前任务下形成树状分支。
|
||||
- 当前任务是子任务 → 单路转交也保持在同一父任务下。
|
||||
- 否则 → 顶层同级转交。
|
||||
"""
|
||||
return await task_service.transfer_task(
|
||||
db, uuid.UUID(task_id), request,
|
||||
operator_id or current_user.get("username", "") or None,
|
||||
operator_role=current_user.get("role"),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 无限层级子任务
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{task_id}/subtasks", response_model=TaskResponse, status_code=201)
|
||||
async def create_subtask_endpoint(
|
||||
task_id: str,
|
||||
data: SubtaskCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
**创建子任务:支持无限层级嵌套。**
|
||||
|
||||
新子任务将自动继承父任务的 product_id。
|
||||
若父任务已完成,拒绝创建。
|
||||
"""
|
||||
return await task_service.create_subtask(
|
||||
db, uuid.UUID(task_id), data
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 查询产品顶层任务(便捷接口)
|
||||
# ============================================================
|
||||
|
||||
@router.get("/by-product/{product_id}", response_model=list[TaskSummaryResponse])
|
||||
async def get_tasks_by_product(
|
||||
product_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定产品的顶层任务列表(不含子任务嵌套)"""
|
||||
return await task_service.get_top_level_tasks(db, uuid.UUID(product_id))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 任务进度记录 — 备注/传图
|
||||
# ============================================================
|
||||
|
||||
@router.patch("/{task_id}/records", response_model=TaskResponse)
|
||||
async def add_task_record_endpoint(
|
||||
task_id: str,
|
||||
data: TaskRecordCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""追加进度记录(备注+图片),不改变任务状态"""
|
||||
return await task_service.add_task_record(db, uuid.UUID(task_id), data, current_user)
|
||||
51
backend/app/api/v1/endpoints/upload.py
Normal file
51
backend/app/api/v1/endpoints/upload.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""文件上传 & 静态文件访问"""
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
||||
|
||||
# Docker: os.getcwd() = /app → /app/uploads → host:backend/uploads
|
||||
BASE_DIR = os.environ.get("PROJECT_ROOT", os.getcwd())
|
||||
UPLOAD_DIR = os.path.join(BASE_DIR, "uploads")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "bmp", "webp", "pdf", "doc", "docx", "xls", "xlsx", "zip", "rar", "7z"}
|
||||
MAX_SIZE = 50 * 1024 * 1024 # 50MB
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def upload_file(file: UploadFile = File(...)) -> dict:
|
||||
"""上传文件 → 保存到 uploads/uuid.ext → 返回可访问 URL"""
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="文件名为空")
|
||||
|
||||
ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: .{ext}")
|
||||
|
||||
# 检查大小
|
||||
content = await file.read()
|
||||
if len(content) > MAX_SIZE:
|
||||
raise HTTPException(status_code=400, detail="文件超过 50MB 限制")
|
||||
await file.seek(0)
|
||||
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex}.{ext}"
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
|
||||
with open(filepath, "wb") as f:
|
||||
f.write(await file.read())
|
||||
|
||||
return {"url": f"/api/v1/upload/files/{filename}"}
|
||||
|
||||
|
||||
@router.get("/files/{filename}")
|
||||
async def serve_file(filename: str):
|
||||
"""直接返回物理文件"""
|
||||
filepath = os.path.join(UPLOAD_DIR, filename)
|
||||
if not os.path.exists(filepath):
|
||||
raise HTTPException(status_code=404, detail="文件不存在")
|
||||
return FileResponse(filepath)
|
||||
75
backend/app/api/v1/endpoints/users.py
Normal file
75
backend/app/api/v1/endpoints/users.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""用户列表 — 对接 MOM sys_user"""
|
||||
from fastapi import APIRouter, Query, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from app.core.mom_database import MomSessionLocal
|
||||
from sqlalchemy import text
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["用户"])
|
||||
|
||||
|
||||
class UserOption(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
full_name: str
|
||||
department: str = ""
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
@router.get("/", response_model=list[UserOption])
|
||||
def list_users(
|
||||
keyword: str = Query("", description="按用户名/姓名模糊搜索"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
dept: str = Query("IRIS", description="部门过滤"),
|
||||
):
|
||||
"""获取 MOM 系统用户列表,默认只返回 IRIS 部门"""
|
||||
db = MomSessionLocal()
|
||||
try:
|
||||
# 尝试按部门过滤;若 sys_user 无 department 列则降级全量查询
|
||||
try:
|
||||
base_sql = """
|
||||
SELECT id, username,
|
||||
SPLIT_PART(username, '/', 1) AS full_name,
|
||||
COALESCE(department, '') AS department
|
||||
FROM sys_user
|
||||
WHERE department = :dept
|
||||
"""
|
||||
params = {"dept": dept, "lim": limit}
|
||||
if keyword.strip():
|
||||
sql = text(base_sql + " AND username ILIKE :kw ORDER BY username LIMIT :lim")
|
||||
params["kw"] = f"%{keyword.strip()}%"
|
||||
else:
|
||||
sql = text(base_sql + " ORDER BY username LIMIT :lim")
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
except Exception:
|
||||
# 降级:不使用 department 列过滤
|
||||
fallback_sql = """
|
||||
SELECT id, username,
|
||||
SPLIT_PART(username, '/', 1) AS full_name,
|
||||
'' AS department
|
||||
FROM sys_user
|
||||
"""
|
||||
params = {"lim": limit}
|
||||
if keyword.strip():
|
||||
sql = text(fallback_sql + " WHERE username ILIKE :kw ORDER BY username LIMIT :lim")
|
||||
params["kw"] = f"%{keyword.strip()}%"
|
||||
else:
|
||||
sql = text(fallback_sql + " ORDER BY username LIMIT :lim")
|
||||
rows = db.execute(sql, params).fetchall()
|
||||
|
||||
return [
|
||||
UserOption(
|
||||
id=str(row.id),
|
||||
username=row.username.split("/")[-1] if "/" in row.username else row.username,
|
||||
full_name=row.full_name,
|
||||
department=row.department or "",
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"MOM 用户查询失败: {str(e)}",
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
417
backend/app/api/v1/endpoints/webhooks.py
Normal file
417
backend/app/api/v1/endpoints/webhooks.py
Normal file
@ -0,0 +1,417 @@
|
||||
"""外部系统回调 Webhook — Track 作为接收方
|
||||
|
||||
MOM 仓储系统确认接收产品入库后,回调本接口,将 Track 中该产品的状态
|
||||
真正标记为"已入库闭环"(更新宏观状态 + 记录 task_logs 证明仓库已接收)。
|
||||
|
||||
同一条入站通道还承担【撤回出库】的强制回滚:MOM 把误点出库的设备物理
|
||||
回滚到仓库时,Track 必须被动跟随 MOM 的权威物理状态(详见
|
||||
_mom_inbound_revoke 上方的特权通道说明)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.core.lifecycle import sync_product_status
|
||||
from app.models.product import Product
|
||||
from app.models.task import Task, TaskRecord
|
||||
from app.models.task_log import TaskLog
|
||||
|
||||
router = APIRouter(prefix="/external/webhooks", tags=["外部回调"])
|
||||
|
||||
|
||||
class MomInboundPayload(BaseModel):
|
||||
"""MOM 仓储系统确认接收入库 / 撤回出库的回调载荷"""
|
||||
serial_number: str | None = None # 产品 16 位身份证(可空,优先匹配)
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 入库操作人(写入 task_logs.operator_id)
|
||||
inbound_time: datetime | None = None # 入库确认时间
|
||||
# ↓ MOM 侧一直在发、此前被 Pydantic 静默丢弃的字段。撤回信号靠它们识别。
|
||||
event: str | None = None # 事件名,如 inbound.created / outbound.revoked
|
||||
action: str | None = None # 显式动作指令,如 revoke_outbound
|
||||
source_table: str | None = None # stock_product / stock_semi
|
||||
|
||||
|
||||
# 「撤回出库」信号词 —— 只在 action / event 里做子串匹配。
|
||||
# MOM 侧的字段命名尚未冻结,故刻意宽松:revoke_outbound / outbound.revoked /
|
||||
# rollback_outbound 都能命中,避免因对方改个词就整条链路失联。
|
||||
_OUTBOUND_REVOKE_TOKENS = ("revoke", "rollback", "revert", "cancel")
|
||||
|
||||
|
||||
def _is_outbound_revoke(payload: MomInboundPayload) -> bool:
|
||||
"""payload 是否携带**显式**的撤回出库信号。
|
||||
|
||||
注意:返回 False 不代表「不是撤回」——MOM 也可能不加任何标记、直接以
|
||||
常规 inbound.created 重推。那种隐式信号由调用方用「产品此刻是否处于
|
||||
已出库」兜底判定(见 mom_inbound_webhook 里的 was_outbound)。
|
||||
"""
|
||||
for raw in (payload.action, payload.event):
|
||||
token = (raw or "").strip().lower()
|
||||
if token and any(word in token for word in _OUTBOUND_REVOKE_TOKENS):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _pick_warehouse_log_task(db: AsyncSession, product: Product) -> Task | None:
|
||||
"""挑一条挂日志的任务:优先「在库」任务,其次该产品最新任务,都没有则 None。"""
|
||||
task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id, Task.task_name.ilike("%在库%"))
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if task is None:
|
||||
task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(Task.product_id == product.id)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
return task
|
||||
|
||||
|
||||
async def _match_inbound_product(
|
||||
db: AsyncSession, payload: MomInboundPayload, *, allow_outbound: bool,
|
||||
) -> Product | None:
|
||||
"""按 serial_number(优先)或 sku 匹配产品。
|
||||
|
||||
allow_outbound=False:只认「当前挂在虚拟仓库池」的产品(常规入库的既有语义)。
|
||||
allow_outbound=True :额外放行「已出库」产品 —— 出库回调会把 current_location_id
|
||||
置为 None,若仍用原条件,撤回信号必然失配并静默 return matched=False,
|
||||
造成 MOM 认为货已回库、Track 却永远停在「已出库」的数据脑裂。
|
||||
"""
|
||||
location_cond = Product.current_location_id == "virtual_warehouse"
|
||||
where_cond = (
|
||||
or_(
|
||||
location_cond,
|
||||
Product.overall_status == "已出库",
|
||||
Product.status == "OUTBOUND",
|
||||
)
|
||||
if allow_outbound
|
||||
else location_cond
|
||||
)
|
||||
|
||||
if payload.serial_number:
|
||||
return (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.external_serial == payload.serial_number,
|
||||
),
|
||||
where_cond,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if payload.sku:
|
||||
return (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(Product.spec_model == payload.sku, where_cond)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/mom-inbound")
|
||||
async def mom_inbound_webhook(
|
||||
payload: MomInboundPayload,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 确认接收入库 / 撤回出库后回调本接口。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 常规入库:用 serial_number(优先)或 sku 匹配「当前位于 virtual_warehouse」
|
||||
的产品,命中则标记"已实收"(overall_status=已入库 + 记录 task_logs)。
|
||||
- 撤回出库:MOM 把误出库的设备物理回滚到仓库 → 本接口强制执行特权回滚。
|
||||
- 未命中返回 200(MOM 可能操作了非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
explicit_revoke = _is_outbound_revoke(payload)
|
||||
|
||||
# ── 匹配产品 ──
|
||||
# 常规入库保持严格匹配;撤回(显式标记,或带 serial 可精确定位)才放宽到已出库产品。
|
||||
# 刻意不给 sku 兜底也无条件放宽:同型号可能有多台,放宽后可能误标到别的设备。
|
||||
product = await _match_inbound_product(
|
||||
db, payload, allow_outbound=explicit_revoke or bool(payload.serial_number),
|
||||
)
|
||||
|
||||
# ── 未命中:可能是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
|
||||
# 隐式撤回:payload 没带任何标记,但产品此刻正处于「已出库」。
|
||||
# 对一台已发货的设备来说,任何入库回调都只能意味着「货回来了」。
|
||||
was_outbound = (
|
||||
(product.overall_status or "").strip() == "已出库"
|
||||
or (product.status or "").strip().upper() == "OUTBOUND"
|
||||
)
|
||||
is_revoke = explicit_revoke or was_outbound
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# ★ 特权通道 — MOM 的物理状态同步优先级最高,强制覆写、不受任何内部守卫约束
|
||||
#
|
||||
# 与 task_service.py 的【绝对物理终态保护】(PHYSICAL_TERMINAL_OVERALL,
|
||||
# task_service.py:115-127) 方向刻意相反:那套保护约束的是「车间内部流转
|
||||
# 不许用工序名抹掉物理终态」;而本接口是物理事实的**权威来源**——MOM 说
|
||||
# 货已回到仓库,Track 必须无条件跟随。
|
||||
#
|
||||
# ⚠️ 后续维护者:不要在此处添加 _is_physical_terminal / 状态互斥 / 仅当
|
||||
# 状态为 X 才允许覆写 之类的校验。那会让设备永远卡在「已出库」,
|
||||
# 与 MOM 账面对不上——正是本次要消灭的数据脑裂。
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
changed = False
|
||||
|
||||
# 1) 宏观状态强制覆写为「已入库」(撤回时从「已出库」拉回)
|
||||
if product.overall_status != "已入库":
|
||||
product.overall_status = "已入库"
|
||||
changed = True
|
||||
|
||||
# 2) 物理位置强制回滚到虚拟仓库池(出库回调曾把它置为 None)
|
||||
if product.current_location_id != "virtual_warehouse":
|
||||
product.current_location_id = "virtual_warehouse"
|
||||
changed = True
|
||||
|
||||
# 3) 双字段同步:lifecycle.py 约定凡改写 overall_status 必调一次。
|
||||
# (原实现在这里硬编码 product.status="ARCHIVED",绕过了约定,一并纠正)
|
||||
# ⚠️ 必须把 status 的变化也计入 changed:否则当 overall_status / location
|
||||
# 本来就已经正确时,这一处纠偏会因为 changed 保持 False 而永远不提交。
|
||||
prev_status = product.status
|
||||
sync_product_status(product)
|
||||
if product.status != prev_status:
|
||||
changed = True
|
||||
|
||||
# ── 记录日志(优先"在库"任务,其次该产品最新任务;无任务则仅更新状态) ──
|
||||
log_task = await _pick_warehouse_log_task(db, product)
|
||||
if log_task is not None:
|
||||
if is_revoke:
|
||||
signal = payload.action or payload.event or "inbound.created(隐式)"
|
||||
remark = (
|
||||
f"MOM 撤回出库 → 强制回滚:宏观状态已入库、"
|
||||
f"位置已回到 virtual_warehouse(信号: {signal})"
|
||||
)
|
||||
action_type = "warehouse_outbound_revoked"
|
||||
else:
|
||||
time_str = payload.inbound_time.isoformat() if payload.inbound_time else "—"
|
||||
remark = f"MOM 仓储系统确认接收入库(inbound_time: {time_str})"
|
||||
action_type = "warehouse_inbound"
|
||||
|
||||
db.add(TaskLog(
|
||||
task_id=log_task.id,
|
||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||
action_type=action_type,
|
||||
remark=remark,
|
||||
))
|
||||
changed = True
|
||||
|
||||
# ── 动态生成主线任务节点 + 操作日志(流转树最底部长出节点) ──
|
||||
if is_revoke:
|
||||
# 撤回必须留痕:否则流转树末节点仍是「扫码出库」,而产品徽标已是
|
||||
# 「已入库」,这种可见的自相矛盾会让车间不敢信这套数据。
|
||||
#
|
||||
# ⚠️ 节点名里的「(重新入库)」不是装饰,是 [必须保留] 的契约:
|
||||
# product_service.py:166-174 的 _has_warehouse_task() 用**子串**判定
|
||||
# 仓库节点("在库" in task_name or "入库" in task_name)。而
|
||||
# 「撤回出库」四个字里只有"出库"、不含"入库",会让它判定为"无仓库任务",
|
||||
# 进而给 location==virtual_warehouse 的产品注入一个假的「已完成 /
|
||||
# 待仓库扫码」虚拟节点(product_service.py:237-242 的情况 A)——
|
||||
# 该设备明明已入库且在仓库里,树尾却显示待收货。
|
||||
# 补上「重新入库」后关键字命中,虚拟节点不再注入。
|
||||
appended = await _append_warehouse_task(
|
||||
db, product, "撤回出库(重新入库)", "MOM 撤回出库,设备已物理回滚至仓库",
|
||||
)
|
||||
else:
|
||||
appended = await _append_warehouse_task(
|
||||
db, product, "扫码入库", "通过 MOM 系统扫码入库完成",
|
||||
)
|
||||
if appended:
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"matched": True,
|
||||
"serial_number": product.serial_number,
|
||||
"revoked": is_revoke,
|
||||
}
|
||||
|
||||
|
||||
async def _append_warehouse_task(
|
||||
db: AsyncSession,
|
||||
product: Product,
|
||||
task_name: str,
|
||||
record_remark: str,
|
||||
) -> bool:
|
||||
"""在流转树主干道最底部追加一个主线任务节点(扫码入库/扫码出库)+ 操作日志。
|
||||
|
||||
逻辑:
|
||||
- 找该产品最后一个主线任务(created_at 最晚且为主线)的 id 作为 parent_task_id,
|
||||
保证树状主干连贯;
|
||||
- 插入 task_type='TRANSFER' 的主线任务(现有主线枚举 → is_main=True,画在中央主干道),
|
||||
状态直接 COMPLETED;
|
||||
- 生成一条 TaskRecord 供前端"查看操作日志"展示。
|
||||
|
||||
返回是否新增了节点(供调用方置 changed=True 触发提交)。
|
||||
"""
|
||||
from uuid import uuid4
|
||||
from datetime import datetime as _dt
|
||||
|
||||
# 0) 幂等:该产品若已有同名主线任务(扫码入库/扫码出库),则不重复插入
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Task.id).where(
|
||||
Task.product_id == product.id,
|
||||
Task.task_name == task_name,
|
||||
).limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing:
|
||||
return False
|
||||
|
||||
# 1) 最后一个主线任务(created_at 最晚,且无父任务 或 task_type 为主线枚举)
|
||||
last_main_task = (
|
||||
await db.execute(
|
||||
select(Task)
|
||||
.where(
|
||||
Task.product_id == product.id,
|
||||
or_(
|
||||
Task.parent_task_id.is_(None),
|
||||
Task.task_type.in_(["TRANSFER", "RECOVERY"]),
|
||||
),
|
||||
)
|
||||
.order_by(Task.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
# 2) 插入"扫码入库/扫码出库"主线任务
|
||||
new_task = Task(
|
||||
product_id=product.id,
|
||||
parent_task_id=last_main_task.id if last_main_task else None,
|
||||
task_name=task_name,
|
||||
# ★ assignee_id 显式置 None:不能填非 UUID 字符串,否则前端解析头像/用户信息报错导致节点跳过渲染
|
||||
assignee_id=None,
|
||||
status="COMPLETED",
|
||||
task_type="WAREHOUSE", # 仓储任务类型(is_main 判断已兼容 WAREHOUSE → 画在中央主干道)
|
||||
completed_at=_dt.now(),
|
||||
remark=record_remark,
|
||||
)
|
||||
db.add(new_task)
|
||||
await db.flush() # 生成 new_task.id
|
||||
|
||||
# 3) 生成操作日志(TaskRecord),供前端"查看操作日志"有真实数据
|
||||
db.add(TaskRecord(
|
||||
task_id=new_task.id,
|
||||
remark=record_remark,
|
||||
))
|
||||
return True
|
||||
|
||||
class MomOutboundPayload(BaseModel):
|
||||
"""MOM 仓储系统发货出库的回调载荷"""
|
||||
serial_number: str | None = None # 产品 16 位身份证(优先匹配)
|
||||
sku: str | None = None # 规格型号 spec_model(serial 缺失时的兜底匹配)
|
||||
operator: str | None = None # 出库操作人(写入 task_logs.operator_id)
|
||||
outbound_time: datetime | None = None # 出库时间
|
||||
|
||||
|
||||
@router.post("/mom-outbound")
|
||||
async def mom_outbound_webhook(
|
||||
payload: MomOutboundPayload,
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""MOM 仓储系统发货出库后回调本接口,将 Track 产品标记为"已出库"。
|
||||
|
||||
- 鉴权:Header X-API-Key 必须等于环境变量 TRACK_WEBHOOK_KEY。
|
||||
- 用 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品;
|
||||
命中则标记"已出库"(overall_status=已出库 + status=OUTBOUND + 记录 task_logs)。
|
||||
- 未命中返回 200(MOM 出库的可能是非 Track 生产的物料,直接忽略)。
|
||||
"""
|
||||
# ── 鉴权 ──
|
||||
if not settings.TRACK_WEBHOOK_KEY or x_api_key != settings.TRACK_WEBHOOK_KEY:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized: invalid X-API-Key")
|
||||
|
||||
# ── 按 serial_number(优先)或 sku 匹配"在仓库/已入库"的产品 ──
|
||||
product = None
|
||||
where_cond = or_(
|
||||
Product.current_location_id == "virtual_warehouse",
|
||||
Product.overall_status.in_(["已入库", "在库"]),
|
||||
)
|
||||
if payload.serial_number:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.serial_number == payload.serial_number,
|
||||
Product.external_serial == payload.serial_number,
|
||||
),
|
||||
where_cond,
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
elif payload.sku:
|
||||
product = (
|
||||
await db.execute(
|
||||
select(Product)
|
||||
.where(Product.spec_model == payload.sku, where_cond)
|
||||
.order_by(Product.created_at.desc())
|
||||
)
|
||||
).scalars().first()
|
||||
|
||||
# ── 未命中:可能出库的是非 Track 生产的物料,直接忽略 ──
|
||||
if product is None:
|
||||
return {"ok": True, "matched": False}
|
||||
|
||||
# ── 标记"已出库" ──
|
||||
changed = False
|
||||
if product.overall_status != "已出库":
|
||||
product.overall_status = "已出库"
|
||||
product.status = "OUTBOUND"
|
||||
changed = True
|
||||
|
||||
# 🚚 同步出清厂内位置(与 _recalc_product_location 的「货发走就离场」对齐):
|
||||
# 本回调的匹配条件之一就是 current_location_id == "virtual_warehouse",
|
||||
# 即设备此刻还挂在仓库池里。既然 MOM 已确认发货,就不该再显示为厂内仓库/工位。
|
||||
# 置空后产品列表的「当前位置」显示为「—」。
|
||||
if product.current_location_id is not None:
|
||||
product.current_location_id = None
|
||||
changed = True
|
||||
|
||||
# 记录出库日志(优先"在库"任务,其次该产品最新任务)
|
||||
outbound_task = await _pick_warehouse_log_task(db, product)
|
||||
|
||||
if outbound_task is not None:
|
||||
time_str = payload.outbound_time.isoformat() if payload.outbound_time else "—"
|
||||
db.add(TaskLog(
|
||||
task_id=outbound_task.id,
|
||||
operator_id=(payload.operator or "virtual_warehouse")[:64],
|
||||
action_type="warehouse_outbound",
|
||||
remark=f"MOM 仓储系统发货出库(outbound_time: {time_str})",
|
||||
))
|
||||
changed = True
|
||||
|
||||
# ── 动态生成"扫码出库"主线任务节点 + 操作日志(流转树最底部长出出库节点) ──
|
||||
if await _append_warehouse_task(db, product, "扫码出库", "通过 MOM 系统扫码出库完成"):
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await db.commit()
|
||||
|
||||
return {"ok": True, "matched": True, "serial_number": product.serial_number}
|
||||
41
backend/app/api/v1/router.py
Normal file
41
backend/app/api/v1/router.py
Normal file
@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints.products import router as products_router
|
||||
from app.api.v1.endpoints.tasks import router as tasks_router
|
||||
from app.api.v1.endpoints.orders import router as orders_router
|
||||
from app.api.v1.endpoints.dashboard import router as dashboard_router
|
||||
from app.api.v1.endpoints.auth import router as auth_router
|
||||
from app.api.v1.endpoints.print import router as print_router
|
||||
from app.api.v1.endpoints.materials import router as materials_router
|
||||
from app.api.v1.endpoints.users import router as users_router
|
||||
from app.api.v1.endpoints.upload import router as upload_router
|
||||
from app.api.v1.endpoints.records import router as records_router
|
||||
from app.api.v1.endpoints.notifications import router as notifications_router
|
||||
from app.api.v1.endpoints.app_version import router as app_version_router
|
||||
from app.api.v1.endpoints.analytics import router as analytics_router
|
||||
from app.api.v1.endpoints.holidays import router as holidays_router
|
||||
from app.api.v1.endpoints.webhooks import router as webhooks_router
|
||||
from app.api.v1.endpoints.external_products import router as external_products_router
|
||||
from app.api.v1.endpoints.screen import router as screen_router
|
||||
from app.api.v1.endpoints.audit import router as audit_router
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(auth_router)
|
||||
api_router.include_router(dashboard_router)
|
||||
api_router.include_router(orders_router)
|
||||
api_router.include_router(products_router)
|
||||
api_router.include_router(tasks_router)
|
||||
api_router.include_router(print_router)
|
||||
api_router.include_router(materials_router)
|
||||
api_router.include_router(users_router)
|
||||
api_router.include_router(upload_router)
|
||||
api_router.include_router(records_router)
|
||||
api_router.include_router(notifications_router)
|
||||
api_router.include_router(app_version_router)
|
||||
api_router.include_router(analytics_router)
|
||||
api_router.include_router(holidays_router)
|
||||
api_router.include_router(webhooks_router)
|
||||
api_router.include_router(external_products_router)
|
||||
api_router.include_router(screen_router)
|
||||
api_router.include_router(audit_router)
|
||||
Reference in New Issue
Block a user