194 lines
8.1 KiB
Python
194 lines
8.1 KiB
Python
|
|
# ============================================================
|
|||
|
|
# Web 端演示数据播种(moduleId: state-web-demo, 可重生 ✅)
|
|||
|
|
# 背景:Web 部署(容器 + PVC 持久卷 /app/server/data)首次打开工作台时,
|
|||
|
|
# 希望直接看到一套完整演示项目,而不是空白工作区。
|
|||
|
|
# 口径:
|
|||
|
|
# - 仅 APS_MODE=web 且 APS_WEB_DEMO_SEED=1(镜像默认开启)时生效;
|
|||
|
|
# 桌面端(~/.aps 本地文件)与测试默认不受影响。
|
|||
|
|
# - 按租户懒播种:某租户首次快照且 0 项目时,为其创建演示项目;
|
|||
|
|
# 落 marker 文件防重复(即使用户删光演示项目也不会回填)。
|
|||
|
|
# - 世界数据落在 server/data 下(world_path_for 口径),随 PVC 持久化。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import tempfile
|
|||
|
|
import threading
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from server.aps_home import data_dir, mode
|
|||
|
|
from server.auth.context import IdentityContext
|
|||
|
|
from server.timeutil import add_minutes, fmt_date, today0
|
|||
|
|
|
|||
|
|
_ENV_FLAG = "APS_WEB_DEMO_SEED"
|
|||
|
|
|
|||
|
|
_lock = threading.Lock()
|
|||
|
|
_attempted: set[str] = set() # 进程内防重:播种过/失败过的租户不再每次快照重试
|
|||
|
|
|
|||
|
|
DEMO_PROJECT_NAME = "演示项目 · 智能控制器装配排产"
|
|||
|
|
DEMO_PROJECT_SCOPE = "Web 演示 · 固定产线 + 柔性能力池"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def demo_seed_enabled() -> bool:
|
|||
|
|
"""是否允许播种演示数据:仅 Web 形态 + 显式开关(默认关,镜像 ENV 打开)。"""
|
|||
|
|
if mode() != "web":
|
|||
|
|
return False
|
|||
|
|
raw = (os.environ.get(_ENV_FLAG) or "").strip().lower()
|
|||
|
|
return raw in ("1", "true", "yes", "on")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _safe(value: str, fallback: str = "platform") -> str:
|
|||
|
|
cleaned = re.sub(r"[^\w\-]+", "_", (value or fallback).strip())[:64]
|
|||
|
|
return cleaned or fallback
|
|||
|
|
|
|||
|
|
|
|||
|
|
def marker_path(tenant_uuid: str) -> Path:
|
|||
|
|
"""播种完成标记:与租户世界数据同区,随 PVC 持久化。"""
|
|||
|
|
tenant = _safe(tenant_uuid)
|
|||
|
|
if tenant == "platform":
|
|||
|
|
return data_dir() / "demo-seeded.ok"
|
|||
|
|
return data_dir() / "tenants" / tenant / "demo-seeded.ok"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|||
|
|
try:
|
|||
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(payload, handle, ensure_ascii=False, indent=1)
|
|||
|
|
os.replace(tmp, path)
|
|||
|
|
except BaseException:
|
|||
|
|
if os.path.exists(tmp):
|
|||
|
|
os.unlink(tmp)
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _tenant_project_count(tenant_uuid: str) -> int:
|
|||
|
|
from sqlalchemy import func, select
|
|||
|
|
|
|||
|
|
from server.db.database import get_session
|
|||
|
|
from server.db.models import WorkspaceProject
|
|||
|
|
|
|||
|
|
with get_session() as session:
|
|||
|
|
return int(session.execute(select(func.count(WorkspaceProject.id)).where(
|
|||
|
|
WorkspaceProject.tenant_uuid == tenant_uuid,
|
|||
|
|
WorkspaceProject.deleted == 0,
|
|||
|
|
)).scalar_one())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _demo_messages() -> list[dict[str, Any]]:
|
|||
|
|
"""一段引导式演示对话(日期相对播种当天,保证随时播种都自洽)。"""
|
|||
|
|
base = today0()
|
|||
|
|
due_vip = fmt_date(add_minutes(base, 4 * 24 * 60))
|
|||
|
|
due_normal = fmt_date(add_minutes(base, 6 * 24 * 60))
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
"role": "assistant",
|
|||
|
|
"text": (
|
|||
|
|
"欢迎使用 APS 智能排产工作台(Web 演示环境)。\n"
|
|||
|
|
"本项目已内置一套完整演示数据:\n"
|
|||
|
|
"· 固定产线层:2 车间 / 3 条装配线 / 13 个工位 / 5 道工序,3 款控制器产品与 BOM、工艺路线\n"
|
|||
|
|
"· 柔性能力池:4 类共线产品(高压线束 / PDU / 充电枪 / 高压连接器),7 个能力池、4 台可跨区调配压接机\n"
|
|||
|
|
"· 业务单据:7 张销售订单(含 1 张 VIP 急单)、10 张柔性订单、3 张预测订单、未来 30 天班次日历\n"
|
|||
|
|
"· 冲突场景:后天上午 ASSY-01 设备季度保养\n\n"
|
|||
|
|
"可以直接在下方对话框下达自然语言指令开始演示。"
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
{"role": "user", "text": "现在有哪些订单?哪些最紧急?"},
|
|||
|
|
{
|
|||
|
|
"role": "assistant",
|
|||
|
|
"text": (
|
|||
|
|
f"当前共 7 张销售订单。最紧急的是 VIP 急单(比亚迪,{due_vip} 交期,已标记插单),"
|
|||
|
|
f"其次是海尔智家 VIP 订单({due_normal} 交期)。\n"
|
|||
|
|
"可以继续追问:「一键排产」「检查订单齐套性」「MRP 分解」「查看产能负载」。"
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
{"role": "user", "text": "一键排产"},
|
|||
|
|
{
|
|||
|
|
"role": "assistant",
|
|||
|
|
"text": (
|
|||
|
|
"排产前会先校验主数据与齐套性,并按冻结窗口(24h)保护已下达工单。\n"
|
|||
|
|
"正式环境中该操作会生成 P2 确认卡,批准后才写入排产方案;"
|
|||
|
|
"演示环境可直接确认,随后在「甘特 / 负载 / 方案对比」视图查看结果。"
|
|||
|
|
),
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def seed_demo_workspace(identity: IdentityContext, *, force: bool = False) -> bool:
|
|||
|
|
"""为指定租户播种一套演示项目(幂等)。返回是否真正执行了播种。"""
|
|||
|
|
tenant = _safe(identity.tenant_uuid)
|
|||
|
|
marker = marker_path(tenant)
|
|||
|
|
if marker.exists() and not force:
|
|||
|
|
return False
|
|||
|
|
if not force and _tenant_project_count(tenant) > 0:
|
|||
|
|
# 已有真实项目:不播种,但落标记避免每次快照都查库
|
|||
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
marker.write_text("skipped: tenant already has projects\n", encoding="utf-8")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
from server.state.projects import ProjectStore
|
|||
|
|
from server.state.seed import build_demo_world
|
|||
|
|
from server.state.store import _safe_scope, _stores, world_path_for
|
|||
|
|
|
|||
|
|
store = ProjectStore(identity)
|
|||
|
|
project_id = _safe(f"demo-{tenant}")[:64]
|
|||
|
|
created = store.create_project(
|
|||
|
|
DEMO_PROJECT_NAME,
|
|||
|
|
scope_label=DEMO_PROJECT_SCOPE,
|
|||
|
|
project_id=project_id,
|
|||
|
|
)
|
|||
|
|
project = created["project"]
|
|||
|
|
session_id = created["session"]["id"]
|
|||
|
|
|
|||
|
|
# 世界数据:完整演示工厂(固定产线 + 柔性能力池 + 订单 + 日历)
|
|||
|
|
world = build_demo_world()
|
|||
|
|
world_path = Path(world_path_for(project_id, tenant))
|
|||
|
|
_write_json_atomic(world_path, world)
|
|||
|
|
# 若该 worldKey 已被进程内缓存(例如播种前被探测加载过),丢弃缓存保证读的是演示世界
|
|||
|
|
_stores.pop((identity.tenant_uuid, _safe_scope(project_id, "default")), None)
|
|||
|
|
|
|||
|
|
# 引导式演示对话 + 示例文件记录
|
|||
|
|
for message in _demo_messages():
|
|||
|
|
store.append_message(session_id, message)
|
|||
|
|
store.create_file(
|
|||
|
|
project_id,
|
|||
|
|
"锐扬APS演示数据(示例导入包)",
|
|||
|
|
kind="import",
|
|||
|
|
note="演示 CSV 包位于仓库 demo-data/锐扬APS演示数据,可在「导入」栏体验解析入库",
|
|||
|
|
)
|
|||
|
|
store.create_file(
|
|||
|
|
project_id,
|
|||
|
|
"排产方案报告(排产后生成)",
|
|||
|
|
kind="schedule",
|
|||
|
|
note="执行「一键排产」后可导出 Excel 工作计划表(工作计划 / 方案概览 / 冲突)",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
marker.write_text(f"seeded project={project_id}\n", encoding="utf-8")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def maybe_seed_demo_workspace(identity: IdentityContext) -> bool:
|
|||
|
|
"""快照入口的懒播种钩子:条件不满足时零成本返回 False。"""
|
|||
|
|
if not demo_seed_enabled():
|
|||
|
|
return False
|
|||
|
|
if not identity or not identity.user_id:
|
|||
|
|
return False # 系统/维护身份不播种,避免污染 platform 租户
|
|||
|
|
tenant = _safe(identity.tenant_uuid)
|
|||
|
|
with _lock:
|
|||
|
|
if tenant in _attempted:
|
|||
|
|
return False
|
|||
|
|
_attempted.add(tenant)
|
|||
|
|
try:
|
|||
|
|
return seed_demo_workspace(identity)
|
|||
|
|
except Exception:
|
|||
|
|
# 播种失败(如数据目录暂不可写)不阻断主流程;本进程内不再重试,重启后可再试
|
|||
|
|
import logging
|
|||
|
|
|
|||
|
|
logging.getLogger(__name__).warning("web demo seed failed for tenant %s", tenant, exc_info=True)
|
|||
|
|
return False
|