aps-agent/server/state/web_demo.py

194 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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