aps-agent/server/state/web_demo.py

194 lines
8.1 KiB
Python
Raw Normal View History

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