"""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], )