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