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
|