353 lines
15 KiB
Python
353 lines
15 KiB
Python
# ============================================================
|
||
# 计划员约束规则库(moduleId: domain-constraint-rules, 可重生 ✅)
|
||
# 默认通用规则 = constraints.py 的 C1..C13 内建目录(只读基线,本模块不改它)
|
||
# 本模块只管理「计划员用自然语言提出、经 P2 确认后扩充的规则」:
|
||
# * 按类别归档(沿用 GROUP_LABELS 的 8 类 + 其他)
|
||
# * 按范围归档(仅本项目 / 全厂)
|
||
# * 没有接入排产引擎的规则如实标注,不假装生效
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import tempfile
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from server.aps_domain.constraints import (
|
||
DEFAULT_CONSTRAINT_DEFS,
|
||
GROUP_LABELS,
|
||
get_constraint,
|
||
get_constraint_profile,
|
||
)
|
||
|
||
World = dict[str, Any]
|
||
|
||
RULE_CATEGORIES: dict[str, str] = {**GROUP_LABELS, "other": "其他"}
|
||
RULE_SCOPES: dict[str, str] = {"project": "仅本项目", "tenant": "全厂"}
|
||
RULE_KINDS: dict[str, str] = {"hard": "必须满足", "soft": "尽量满足"}
|
||
RULE_OPS: tuple[str, ...] = ("add", "enable", "disable", "delete")
|
||
|
||
_ID_RE = re.compile(r"^R-(\d+)$")
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now().strftime("%Y-%m-%d %H:%M")
|
||
|
||
|
||
def _safe_tenant(tenant_uuid: str | None) -> str:
|
||
raw = (tenant_uuid or "platform").strip() or "platform"
|
||
return re.sub(r"[^\w\-]+", "_", raw)[:64] or "platform"
|
||
|
||
|
||
def tenant_rules_path(tenant_uuid: str | None) -> Path:
|
||
"""全厂规则落盘位置:与 world.json 同一数据根(尊重 APS_HOME / APS_DATA_DIR)。"""
|
||
from server.aps_home import data_dir
|
||
|
||
tenant = _safe_tenant(tenant_uuid)
|
||
if tenant == "platform":
|
||
return data_dir() / "constraint-rules.json"
|
||
return data_dir() / "tenants" / tenant / "constraint-rules.json"
|
||
|
||
|
||
def load_tenant_rules(tenant_uuid: str | None) -> list[dict[str, Any]]:
|
||
"""读取全厂规则(文件不存在或损坏时返回空列表,不抛错)。"""
|
||
path = tenant_rules_path(tenant_uuid)
|
||
if not path.exists():
|
||
return []
|
||
try:
|
||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||
except (OSError, ValueError):
|
||
return []
|
||
rules = raw.get("rules") if isinstance(raw, dict) else raw
|
||
if not isinstance(rules, list):
|
||
return []
|
||
return [r for r in rules if isinstance(r, dict)]
|
||
|
||
|
||
def save_tenant_rules(tenant_uuid: str | None, rules: list[dict[str, Any]]) -> str:
|
||
"""原子写入全厂规则文件(tmp + os.replace,与 WorldStore 同一写盘策略)。"""
|
||
path = tenant_rules_path(tenant_uuid)
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
body = json.dumps(
|
||
{"version": 1, "scope": "tenant", "rules": rules},
|
||
ensure_ascii=False, indent=2,
|
||
)
|
||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".constraint-rules-", suffix=".tmp")
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||
fh.write(body + "\n")
|
||
os.replace(tmp, path)
|
||
except BaseException:
|
||
try:
|
||
os.unlink(tmp)
|
||
except OSError:
|
||
pass
|
||
raise
|
||
return str(path)
|
||
|
||
|
||
def project_rules(world: World) -> list[dict[str, Any]]:
|
||
raw = world.get("constraintRules")
|
||
if not isinstance(raw, list):
|
||
return []
|
||
return [r for r in raw if isinstance(r, dict)]
|
||
|
||
|
||
def all_rules(world: World, tenant_uuid: str | None = None) -> list[dict[str, Any]]:
|
||
"""全厂规则 + 本项目规则,统一补 scope 字段(缺省按所属容器判定)。"""
|
||
out: list[dict[str, Any]] = []
|
||
for r in load_tenant_rules(tenant_uuid):
|
||
out.append({**r, "scope": r.get("scope") or "tenant"})
|
||
for r in project_rules(world):
|
||
out.append({**r, "scope": r.get("scope") or "project"})
|
||
return out
|
||
|
||
|
||
def next_rule_id(rules: list[dict[str, Any]]) -> str:
|
||
top = 0
|
||
for r in rules:
|
||
m = _ID_RE.match(str(r.get("id") or ""))
|
||
if m:
|
||
top = max(top, int(m.group(1)))
|
||
return f"R-{top + 1:03d}"
|
||
|
||
|
||
def rule_effect(world: World, rule: dict[str, Any]) -> dict[str, Any]:
|
||
"""如实说明这条规则当前靠谁执行;没有接入引擎就直说。"""
|
||
bound = str(rule.get("boundConstraintId") or "").strip()
|
||
if not bound:
|
||
return {"bound": False, "enforced": False, "text": "已记录,暂未接入排产引擎"}
|
||
c = get_constraint(world, bound)
|
||
if not c:
|
||
return {"bound": True, "enforced": False,
|
||
"text": f"绑定的内置规则 {bound} 不在当前约束目录,暂未执行"}
|
||
state = "已启用" if c.get("enabled", True) else "已关闭"
|
||
kind_label = "硬约束" if c.get("kind") == "hard" else "软约束"
|
||
enforced = bool(c.get("enabled", True))
|
||
if not enforced:
|
||
return {"bound": True, "enforced": False,
|
||
"text": f"内置规则《{c['name']}》当前已关闭,本条暂不生效"}
|
||
return {"bound": True, "enforced": True,
|
||
"text": f"由内置规则《{c['name']}》执行({state}/{kind_label})"}
|
||
|
||
|
||
def _default_ids() -> set[str]:
|
||
return {str(d["id"]) for d in DEFAULT_CONSTRAINT_DEFS}
|
||
|
||
|
||
def normalize_rule_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""校验规则库载荷;只接受计划员语言字段,不接受 JSON 片段。"""
|
||
raw = payload if isinstance(payload, dict) else {}
|
||
op = str(raw.get("op") or "add").strip().lower() or "add"
|
||
if op not in RULE_OPS:
|
||
raise ValueError("规则操作只能是 新增/启用/停用/删除")
|
||
if op != "add":
|
||
rule_id = str(raw.get("ruleId") or raw.get("id") or "").strip()
|
||
if not rule_id:
|
||
raise ValueError("请指定要操作的规则")
|
||
return {"op": op, "ruleId": rule_id}
|
||
name = str(raw.get("name") or raw.get("text") or "").strip()
|
||
if not name:
|
||
raise ValueError("规则内容不能为空,请用一句话描述")
|
||
if len(name) > 40:
|
||
raise ValueError("规则内容请控制在 40 字以内,说清「什么时候、什么不能排」即可")
|
||
category = str(raw.get("category") or "other").strip().lower() or "other"
|
||
if category not in RULE_CATEGORIES:
|
||
raise ValueError(f"类别只能是:{'、'.join(RULE_CATEGORIES.values())}")
|
||
scope = str(raw.get("scope") or "project").strip().lower() or "project"
|
||
if scope not in RULE_SCOPES:
|
||
raise ValueError("适用范围只能是:仅本项目 / 全厂")
|
||
kind = str(raw.get("kind") or "soft").strip().lower() or "soft"
|
||
if kind not in RULE_KINDS:
|
||
raise ValueError("强度只能是:必须满足 / 尽量满足")
|
||
bound = str(raw.get("boundConstraintId") or "").strip()
|
||
if bound and bound not in _default_ids():
|
||
raise ValueError(f"绑定的内置规则 {bound} 不存在,请留空或改用现有内置规则")
|
||
out: dict[str, Any] = {
|
||
"op": "add", "name": name, "category": category, "scope": scope, "kind": kind,
|
||
}
|
||
if bound:
|
||
out["boundConstraintId"] = bound
|
||
source = str(raw.get("sourceText") or "").strip()
|
||
if source:
|
||
out["sourceText"] = source[:200]
|
||
return out
|
||
|
||
|
||
def _bound_apply(world: World, norm: dict[str, Any]) -> tuple[dict[str, Any] | None, str | None]:
|
||
"""新增规则绑定内置可配项时,把强度真正落到排产引擎;返回 (补丁, 说明)。"""
|
||
bound = str(norm.get("boundConstraintId") or "")
|
||
if not bound:
|
||
return None, None
|
||
c = get_constraint(world, bound)
|
||
if not c or not c.get("kindConfigurable"):
|
||
return None, None
|
||
target = "hard" if norm.get("kind") == "hard" else "soft"
|
||
if c.get("kind") == target:
|
||
return None, None
|
||
label = "硬约束(违反阻止发布)" if target == "hard" else "软约束(只提示风险)"
|
||
return {"constraints": {bound: {"kind": target}}}, f"同时把内置规则《{c['name']}》调整为{label}"
|
||
|
||
|
||
def confirmation_for_rule_save(world: World, payload: dict[str, Any],
|
||
tenant_uuid: str | None = None) -> tuple[str, list[str]]:
|
||
"""P2 确认卡文案:计划员看得懂,写清范围、强度和是否真的接入引擎。"""
|
||
norm = normalize_rule_payload(payload)
|
||
if norm["op"] == "add":
|
||
title = f"登记约束规则:{norm['name']}"
|
||
lines = [
|
||
f"类别:{RULE_CATEGORIES[norm['category']]};适用范围:{RULE_SCOPES[norm['scope']]}",
|
||
f"强度:{RULE_KINDS[norm['kind']]}",
|
||
]
|
||
patch, note = _bound_apply(world, norm)
|
||
if patch:
|
||
lines.append(f"接入排产:{note}")
|
||
elif norm.get("boundConstraintId"):
|
||
c = get_constraint(world, str(norm["boundConstraintId"]))
|
||
lines.append(f"接入排产:由内置规则《{(c or {}).get('name') or norm['boundConstraintId']}》执行")
|
||
else:
|
||
lines.append("接入排产:暂未接入排产引擎,先进入规则库备查")
|
||
lines.append("确认后写入规则库并记审计;不影响历史版本")
|
||
return title, lines
|
||
rules = {str(r.get("id")): r for r in all_rules(world, tenant_uuid)}
|
||
rule = rules.get(norm["ruleId"])
|
||
if not rule:
|
||
raise ValueError(f"规则库中没有 {norm['ruleId']} 这条规则")
|
||
name = str(rule.get("name") or norm["ruleId"])
|
||
effect = rule_effect(world, rule)["text"]
|
||
if norm["op"] == "delete":
|
||
return f"删除约束规则:{name}", [
|
||
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')})",
|
||
f"当前执行:{effect}",
|
||
"删除后不再出现在规则库;不影响历史版本与已发布计划",
|
||
]
|
||
label = "启用" if norm["op"] == "enable" else "停用"
|
||
lines = [
|
||
f"原规则:{name}({RULE_CATEGORIES.get(str(rule.get('category')), '其他')}/"
|
||
f"{RULE_SCOPES.get(str(rule.get('scope')), '仅本项目')})",
|
||
f"当前执行:{effect}",
|
||
"该操作只调整规则库里的状态,不改动内置约束的开关",
|
||
]
|
||
return f"{label}约束规则:{name}", lines
|
||
|
||
|
||
def _locate(world: World, tenant_uuid: str | None,
|
||
rule_id: str) -> tuple[dict[str, Any] | None, str | None]:
|
||
for r in load_tenant_rules(tenant_uuid):
|
||
if str(r.get("id")) == rule_id:
|
||
return r, "tenant"
|
||
for r in project_rules(world):
|
||
if str(r.get("id")) == rule_id:
|
||
return r, "project"
|
||
return None, None
|
||
|
||
|
||
def _write_container(world: World, tenant_uuid: str | None, where: str,
|
||
rules: list[dict[str, Any]]) -> None:
|
||
if where == "tenant":
|
||
save_tenant_rules(tenant_uuid, rules)
|
||
else:
|
||
world["constraintRules"] = rules
|
||
|
||
|
||
def apply_rule_save(world: World, payload: dict[str, Any],
|
||
tenant_uuid: str | None = None) -> dict[str, Any]:
|
||
"""落库:新增/启用/停用/删除计划员扩充规则(调用方负责审计与 save)。"""
|
||
norm = normalize_rule_payload(payload)
|
||
if norm["op"] == "add":
|
||
existing = all_rules(world, tenant_uuid)
|
||
rule_id = next_rule_id(existing)
|
||
rec: dict[str, Any] = {
|
||
"id": rule_id,
|
||
"name": norm["name"],
|
||
"category": norm["category"],
|
||
"scope": norm["scope"],
|
||
"kind": norm["kind"],
|
||
"enabled": True,
|
||
"createdAt": _now(),
|
||
"updatedAt": _now(),
|
||
}
|
||
if norm.get("boundConstraintId"):
|
||
rec["boundConstraintId"] = norm["boundConstraintId"]
|
||
if norm.get("sourceText"):
|
||
rec["source"] = {"type": "PLANNER_CHAT", "text": norm["sourceText"]}
|
||
patch, note = _bound_apply(world, norm)
|
||
if patch:
|
||
from server.aps_domain.constraints import apply_profile_save
|
||
|
||
apply_profile_save(world, patch)
|
||
if norm["scope"] == "tenant":
|
||
save_tenant_rules(tenant_uuid, load_tenant_rules(tenant_uuid) + [rec])
|
||
else:
|
||
world["constraintRules"] = project_rules(world) + [rec]
|
||
effect = rule_effect(world, rec)["text"]
|
||
message = (f"约束规则已登记 ✅ {rule_id} {rec['name']}"
|
||
f"({RULE_CATEGORIES[norm['category']]}/{RULE_SCOPES[norm['scope']]})。"
|
||
f"\n执行情况:{effect}。" + (f"\n{note}。" if note else ""))
|
||
return {"ruleId": rule_id, "op": "add", "scope": norm["scope"],
|
||
"rule": rec, "message": message}
|
||
rule_id = norm["ruleId"]
|
||
rule, where = _locate(world, tenant_uuid, rule_id)
|
||
if not rule or not where:
|
||
raise ValueError(f"规则库中没有 {rule_id} 这条规则")
|
||
container = load_tenant_rules(tenant_uuid) if where == "tenant" else project_rules(world)
|
||
name = str(rule.get("name") or rule_id)
|
||
if norm["op"] == "delete":
|
||
kept = [r for r in container if str(r.get("id")) != rule_id]
|
||
_write_container(world, tenant_uuid, where, kept)
|
||
return {"ruleId": rule_id, "op": "delete", "scope": where,
|
||
"message": f"约束规则已删除 ✅ {rule_id} {name}。"}
|
||
enabled = norm["op"] == "enable"
|
||
updated = []
|
||
for r in container:
|
||
if str(r.get("id")) == rule_id:
|
||
updated.append({**r, "enabled": enabled, "updatedAt": _now()})
|
||
else:
|
||
updated.append(r)
|
||
_write_container(world, tenant_uuid, where, updated)
|
||
label = "启用" if enabled else "停用"
|
||
return {"ruleId": rule_id, "op": norm["op"], "scope": where,
|
||
"message": f"约束规则已{label} ✅ {rule_id} {name}。"}
|
||
|
||
|
||
def constraint_library(world: World, tenant_uuid: str | None = None) -> dict[str, Any]:
|
||
"""设置页数据:默认通用规则概览 + 计划员扩充规则(按类别分组)。"""
|
||
builtin = get_constraint_profile(world)["constraints"]
|
||
custom: list[dict[str, Any]] = []
|
||
for r in all_rules(world, tenant_uuid):
|
||
effect = rule_effect(world, r)
|
||
category = str(r.get("category") or "other")
|
||
custom.append({
|
||
**r,
|
||
"category": category,
|
||
"categoryLabel": RULE_CATEGORIES.get(category, "其他"),
|
||
"scopeLabel": RULE_SCOPES.get(str(r.get("scope")), "仅本项目"),
|
||
"kindLabel": RULE_KINDS.get(str(r.get("kind")), "尽量满足"),
|
||
"effect": effect,
|
||
})
|
||
groups = []
|
||
for gid, label in RULE_CATEGORIES.items():
|
||
items = [r for r in custom if r["category"] == gid]
|
||
if items:
|
||
groups.append({"id": gid, "label": label, "items": items})
|
||
return {
|
||
"builtin": {
|
||
"total": len(builtin),
|
||
"enabled": sum(1 for c in builtin if c.get("enabled", True)),
|
||
"configurable": sum(1 for c in builtin if c.get("configurable")),
|
||
},
|
||
"custom": custom,
|
||
"groups": groups,
|
||
"categories": [{"id": k, "label": v} for k, v in RULE_CATEGORIES.items()],
|
||
"scopes": [{"id": k, "label": v} for k, v in RULE_SCOPES.items()],
|
||
"summary": {
|
||
"builtinTotal": len(builtin),
|
||
"customTotal": len(custom),
|
||
"engineBound": sum(1 for r in custom if r["effect"]["enforced"]),
|
||
"recordOnly": sum(1 for r in custom if not r["effect"]["enforced"]),
|
||
},
|
||
}
|