aps-agent/server/aps_domain/planning_intake.py

300 lines
17 KiB
Python

"""Read-only source review and explicit adoption before scheduling."""
from __future__ import annotations
import re
from collections.abc import Callable
from pathlib import Path
from typing import Any
from server.contracts import AgentReply, UIBlock
from server.importers.workbook_profiles import has_adoption_flow
class PlanningIntakeRejected(ValueError):
"""A frozen intake cannot be applied; callers must request a fresh review."""
def scheduling_entry_actions(*, can_schedule: bool, can_trial: bool = False,
reason: str = "") -> list[dict[str, Any]]:
"""Expose formal scheduling only after readiness passes.
Adopted workbook data is explicitly trial-only, so real blockers may remain
while a draft trial is still useful. The trial action never publishes or
dispatches a plan; that continues through the separate P2 gate.
"""
if can_schedule:
return [{"id": "start_schedule", "label": "开始排产", "command": "立即排产",
"enabled": True, "reason": ""}]
actions = [{"id": "review_missing", "label": "查看缺少资料", "command": "排产还缺什么",
"enabled": True, "reason": reason or "当前资料未通过排产检查,暂不能开始正式排产。"}]
if can_trial:
actions.append({
"id": "trial_schedule", "label": "试排", "command": "立即排产",
"enabled": True,
"reason": "按当前主数据生成草稿试排,并列出未安排订单和原因;不会发布或下发。",
})
return actions
def _matches_name(text: str, name: str) -> bool:
"""判断用户原话里是否点名了某份文件(全名或去扩展名的主名)。"""
if not text or not name:
return False
if name in text:
return True
stem = Path(name).stem
return len(stem) >= 3 and stem in text
def _select_named_file(matches: list[dict[str, Any]], query: str) -> dict[str, Any] | None:
"""按用户点名(澄清卡序号或文件名)选中一份工作簿;没有点名返回 None。"""
text = str(query or "").strip()
if not text:
return None
indexed = re.match(r"^\s*(\d{1,2})\s*[.、)]\s*(.*)$", text)
if indexed:
position = int(indexed.group(1))
rest = indexed.group(2).strip()
if 1 <= position <= len(matches):
candidate = matches[position - 1]
if not rest or _matches_name(rest, str(candidate.get("name") or "")):
return candidate
for file in matches:
if _matches_name(text, str(file.get("name") or "")):
return file
return None
def _source_choice_reply(candidates: list[dict[str, Any]]) -> AgentReply:
"""多份可用工作簿时让用户点名一份,避免不同来源互相覆盖。"""
options = [{"index": index, "label": str(file.get("name") or f"文件 {index}")}
for index, file in enumerate(candidates, 1)]
names = "、".join(f"{option['index']}. {option['label']}" for option in options)
return AgentReply(
text=f"本项目工程目录里有 {len(options)} 份可读取的排产工作簿:{names}。"
"请回复序号或文件名,我再按你点名的那一份核对资料;"
"为避免不同来源互相覆盖,本次没有写入任何数据。",
blocks=[UIBlock(blockId="intake-source-choice", type="clarify", props={
"question": "本次用哪一份工作簿?",
"options": options,
})],
)
def _same_source_file(batch_file: Any, selected_file: Any) -> bool:
"""同一目录可能有同格式的多份工作簿;批次/诊断只取本次选中的那一份。"""
batch_name = str(batch_file or "")
selected_name = str(selected_file or "")
return not batch_name or not selected_name or batch_name == selected_name
_ATTENTION_SEVERITIES = ("blocking", "warning", "error", "warn")
def _attention_issues(diagnostics: list[dict[str, Any]]) -> list[str]:
"""只挑需要用户处理的问题,重复与已忽略的记录不进首屏。"""
issues: list[str] = []
for item in diagnostics:
if str(item.get("severity") or "") not in _ATTENTION_SEVERITIES:
continue
message = str(item.get("message") or "").strip()
if message and message not in issues:
issues.append(message)
return issues
def _intake_analysis_summary(
*, counts: dict[str, Any], diagnostics: list[dict[str, Any]], adopted: bool,
can_schedule: bool, trial_available: bool, can_commit: bool, conflicts_blocked: bool,
) -> dict[str, Any]:
"""首屏摘要:先给结论,再给关键数,明细留给折叠层;内容随实际状态变化。"""
metrics = [
{"key": "orders", "label": "订单记录", "value": int(counts.get("orders") or 0)},
{"key": "materials", "label": "产品和物料", "value": int(counts.get("materials") or 0)},
{"key": "routing", "label": "加工步骤", "value": int(counts.get("routing") or 0)},
{"key": "equipment", "label": "设备记录", "value": int(counts.get("equipment") or 0)},
]
issues = _attention_issues(diagnostics)
next_step = None
if adopted and can_schedule:
status, status_label = "ready", "已采用 · 可以排产"
headline = "资料已采用并通过检查,可以生成排产方案。"
recap = "现状:资料已就绪。生成方案后仍需你确认,才会下发到车间。"
elif adopted and trial_available:
status, status_label = "adopted", "已采用 · 可试排"
headline = "资料已采用;正式排产还缺资料,可按当前主数据试排草稿。"
recap = "现状:试排只生成草稿并列出未安排订单,不会发布或下发。"
elif adopted:
status, status_label = "blocked", "已采用 · 待补齐"
headline = (f"资料已采用,但还有 {len(issues)} 项排产条件需要补齐。" if issues
else "资料已采用,但排产条件还不完整。")
recap = "现状:补齐前不会开始排产;下方「查看缺少资料」按顺序列出缺项。"
elif not can_commit or conflicts_blocked:
status = "blocked"
status_label = "需要人工核对" if conflicts_blocked else "需要修正"
headline = ("资料与现有正式订单存在冲突,本次没有写入或修改数据。" if conflicts_blocked
else "资料已读取,但有字段或关联错误;本次没有写入主数据。")
recap = "现状:未写入主数据。请先按下方问题修正原文件,再重新检查资料。"
else:
status, status_label = "review", "待核对采用"
headline = "资料已读取,确认采用后才会写入项目主数据。"
recap = "现状:本次只完成核对,尚未写入主数据;请在下方确认卡核对并采用。"
next_step = {"label": "开始排产", "command": "立即排产", "enabled": False,
"reason": "需先确认采用本次资料。"}
return {
"headline": headline,
"status": status,
"statusLabel": status_label,
"summaryLine": (f"订单记录 {metrics[0]['value']} · 产品和物料 {metrics[1]['value']} · "
f"加工步骤 {metrics[2]['value']} · 设备记录 {metrics[3]['value']}"),
"metrics": metrics,
"issues": issues[:3],
"issueOverflow": max(0, len(issues) - 3),
"nextStep": next_step,
"recap": recap,
}
def profile_intake_reply(
store: Any, session_id: str, *, schedule_requested: bool,
schedule_current: Callable[[], AgentReply], query: str = "",
) -> AgentReply | None:
from server.agent_core import harness
from server.aps_domain.folder_pack import prepare_folder_schedule
from server.aps_domain.intake_recovery import review_intake_recovery
from server.aps_domain.intake_views import planning_data_views
report = prepare_folder_schedule(store.data, session_id)
files = report.get("files") or []
matches = [file for file in files if has_adoption_flow(file.get("profile"))]
if not matches:
return None
usable = [file for file in matches if file.get("canCommit") is not False]
if len(usable) == 1:
# 目录里混着空模板等未通过校验的表时,直接用唯一一份可用工作簿;
# 用户点名了某一份(或点了澄清卡序号)就按点名的那份走。
source_file = _select_named_file(matches, query) or usable[0]
elif len(matches) == 1:
source_file = matches[0]
else:
candidates = usable or matches
source_file = _select_named_file(candidates, query)
if source_file is None:
return _source_choice_reply(candidates)
profile_id = source_file["profile"]
source = source_file.get("source") or {}
digest = str(source.get("sha256") or "")
previous = next((item for item in store.data.get("intakeSources") or []
if item.get("profile") == profile_id and item.get("sha256") == digest), None)
profile_digest = source_file.get("profileDigest") or source.get("profileDigest")
if previous and previous.get("profileDigest") and previous["profileDigest"] != profile_digest:
return AgentReply(text="这份资料的字段映射配置已经变化。请先在独立项目核对新映射;当前已维护的资料保持不变。")
adopted = previous is not None
legacy_mapping = adopted and not previous.get("profileDigest")
if not adopted and any(item.get("profile") == profile_id for item in store.data.get("intakeSources") or []):
return AgentReply(text="当前项目已经采用另一版资料。本次文件内容有变化,请在独立项目核对新版;本项目已维护的数据和方案保持不变。")
counts = source_file.get("entityCounts") or {}
batches = [batch for batch in report.get("batches") or []
if batch.get("sourceProfile") == profile_id
and _same_source_file(batch.get("sourceFile"), source_file.get("name"))]
recovery = review_intake_recovery(store.data, batches) if not adopted else {"conflicts": []}
diagnostics = [item for batch in report.get("batches") or []
if batch.get("sourceProfile") == profile_id
and _same_source_file(batch.get("sourceFile"), source_file.get("name"))
for item in batch.get("diagnostics") or []]
source_diagnostics = diagnostics
ready_orders = 0
if adopted:
from server.aps_domain.readiness import check_readiness
current = check_readiness(store.data)
ready_orders = int((current.get("summary") or {}).get("ready") or 0)
diagnostics = [{"code": issue.get("type"), "message": issue.get("detail"),
"severity": issue.get("severity"), "orderNo": order.get("orderNo")}
for order in current.get("orders") or [] for issue in order.get("issues") or []]
diagnostics += [{"code": issue.get("type"), "message": issue.get("detail"),
"severity": issue.get("severity")}
for issue in current.get("globalIssues") or []]
counts = {**counts, "orders": len(store.data.get("flexOrders") or []),
"materials": len(store.data.get("flexMaterials") or []),
"routing": len(store.data.get("flexRoutings") or []),
"equipment": len(store.data.get("flexEquipment") or [])}
can_schedule = adopted and ready_orders > 0
trial_available = adopted and bool((store.data.get("planningContext") or {}).get("trialOnly"))
if schedule_requested and adopted and (can_schedule or trial_available):
return schedule_current()
block = UIBlock(blockId="planning-data-review", type="folder-pack", props={
"profile": profile_id, "projectName": report.get("projectName"),
"files": files, "source": source, "sheetSummary": source_file.get("sheetSummary") or [],
"entityCounts": counts, "summary": {
"orders": counts.get("orders", 0), "materials": counts.get("materials", 0),
"routings": counts.get("routing", 0), "equipment": counts.get("equipment", 0),
},
"totalErrors": source_file.get("errorCount", report.get("totalErrors", 0)),
"diagnostics": diagnostics,
"sourceDiagnostics": source_diagnostics if adopted else [],
"mappingStatus": "legacy-unversioned" if legacy_mapping else "versioned",
"canSchedule": can_schedule, "adopted": adopted,
"nextActions": scheduling_entry_actions(
can_schedule=can_schedule,
can_trial=trial_available,
reason=("需先核对并采用资料;有字段错误或订单冲突时,请先处理下方问题。"
if not adopted else "请先补齐订单、工艺、工时、设备或班次等排产资料。"),
),
"trialAvailable": trial_available,
"reviewOnly": True, "missing": report.get("missing") or [],
"snapshotMode": "current" if adopted else "source",
"dataViews": planning_data_views(store.data, batches, adopted=adopted),
"analysisSummary": _intake_analysis_summary(
counts=counts, diagnostics=diagnostics, adopted=adopted,
can_schedule=can_schedule, trial_available=trial_available,
can_commit=bool(source_file.get("canCommit")),
conflicts_blocked=any(not item["canRecover"] for item in recovery["conflicts"]),
),
"intakeConflicts": [{key: item[key] for key in ("orderNo", "reason", "canRecover", "existingOrder", "incomingOrder")} for item in recovery["conflicts"]],
"recoveryProposed": bool(recovery["conflicts"]) and all(item["canRecover"] for item in recovery["conflicts"]),
})
if adopted:
status = ("当前资料已通过排产检查。点击「开始排产」会使用当前维护后的主数据计算方案,并列出未能安排的订单及原因。"
if can_schedule else
("当前资料还有真实阻断项,但可以点击「试排」按当前主数据生成草稿方案。"
"试排会保留并列出未安排订单和阻塞原因,不会发布或下发。"
if trial_available else
"当前资料还未通过排产检查。请先查看缺少资料并补齐订单、工艺、工时、设备或班次。"))
return AgentReply(
text="这份资料已经采用。" + status
+ ("旧资料未记录映射版本,本次只使用已经保存的主数据,不按新配置重新导入。" if legacy_mapping else ""),
blocks=[block],
)
if not source_file.get("canCommit"):
return AgentReply(text="资料已读取,但有字段或关联错误。请先修正下方问题,本次没有写入主数据。", blocks=[block])
if any(not item["canRecover"] for item in recovery["conflicts"]):
return AgentReply(text="资料与现有正式订单存在冲突,详情见下方。已有执行或独立维护记录会保留;请先在订单管理核对,或在独立项目试排。本次没有采用或修改数据。", blocks=[block])
params = {"filename": source_file.get("name"), "batches": batches,
"sourceProfile": profile_id, "sourceSha256": digest,
"profileDigest": profile_digest,
"intakeRecovery": recovery,
"inputManifestDigest": report.get("sourceManifestDigest")}
summary_lines = [
"确认后保存完整资料,供你检查、修改和试排;这一步不会排产或下发到车间。",
"正式订单与待评估插单分开保存,库存、工时和人员资料保留原始出处。",
str(source.get("boundary") or "来源和未确认信息见资料明细。"),
"相同文件再次采用不会覆盖已经维护的值。",
]
if recovery["conflicts"]:
numbers = "、".join(item["orderNo"] for item in recovery["conflicts"])
summary_lines.insert(0, f"确认将旧导入的 {numbers} 从正式待排恢复为原文件中的待评估插单;整理前记录及未下达建议归档,历史方案不变。")
card = harness.stage_confirmation(
session_id, "import.commit", params,
title="核对旧记录并采用资料" if recovery["conflicts"] else "核对并采用本次排产资料",
summary_lines=summary_lines,
evidence_refs=[f"workbook-sha256:{digest}", f"intake-manifest:{report.get('sourceManifestDigest') or ''}"],
)
store.save()
return AgentReply(
text=("发现旧导入的订单分类与原文件不一致,已给出可确认的整理方案。请先打开四类数据明细核对,再确认整理并采用。"
if recovery["conflicts"] else "资料检查完成,尚未写入主数据。请先核对下方信息并确认采用;需要补充的业务信息可在采用后继续维护。"),
blocks=[block, card],
)