Spaces:
Running
Running
| """ | |
| src/graph/worker_a.py | |
| Worker A: ERP Transaction Processing Node | |
| Flow: | |
| β extract_erp_action() β LLM structured output β ERPActionRequest (Pydantic) | |
| β‘ run_validation_query() β Text-to-SQL β SQLite execution β erp_validation_result | |
| β’ check_business_rules() β Stock / delivery-status checks β BLOCKED_* or pass | |
| β£ call_sap_odata_patch() β SAP Sandbox OData v4 PATCH (async β asyncio.run wrapper) | |
| β€ set PENDING_APPROVAL β Signals human_loop that approval is required | |
| β₯ (human_loop handles) β worker_a is responsible only up to β€ | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import logging | |
| import os | |
| from dotenv import load_dotenv | |
| load_dotenv() | |
| from langchain_core.prompts import ChatPromptTemplate | |
| from langchain_openai import ChatOpenAI | |
| from pydantic import ValidationError | |
| from src.api.schemas import ERPActionRequest | |
| from src.config import get_config | |
| from src.graph.state import AgentState | |
| from src.tools.text2sql import run_validation_query | |
| from src.tools.sap_odata_tools import call_sap_odata_patch | |
| logger = logging.getLogger(__name__) | |
| # --------------------------------------------------------------------------- | |
| # ERP Action Extraction β Prompt + LLM | |
| # --------------------------------------------------------------------------- | |
| _EXTRACTION_SYSTEM = """\ | |
| You are an expert at parsing B2B customer emails for SAP ERP order modification requests. | |
| Extract the ERP action parameters from the email below. | |
| CRITICAL β reading numbers correctly: | |
| A single email often contains SEVERAL numbers: the order number, the item/line | |
| number, and a quantity. Map each to the correct field and copy EVERY digit | |
| exactly as written β do NOT add, drop, reorder, pad, or invent any digit. | |
| Do NOT zero-pad β the system pads automatically. Just return the raw digits. | |
| Ignore unrelated how-to / configuration / inquiry sentences; they never change | |
| the order or item number. | |
| Field rules: | |
| - order_id : SAP sales order number (VBELN) β introduced by "order", "order #", | |
| "sales order", or "PO". Copy its digits EXACTLY as written. | |
| Strip separators like "-" only. It is NOT the item number and NOT a | |
| quantity. No padding. | |
| - item_no : Item / line number (POSNR) β introduced by "item", "line", or "position". | |
| Copy its digits exactly as written. No padding. | |
| Never confuse it with the order number or a quantity. | |
| - action_type: One of EXACTLY FIVE values β read the definitions carefully: | |
| "CHANGE_QTY" β customer wants to INCREASE or DECREASE the ordered quantity of an item. | |
| "CHANGE_DATE" β customer wants to RESCHEDULE or UPDATE the delivery/requested date of an item. | |
| "CANCEL_ITEM" β customer wants to CANCEL or REMOVE an order line item entirely. | |
| "CHANGE_ADDR" β customer wants to UPDATE or CHANGE the shipping address / ship-to location. | |
| Use this whenever the request mentions: shipping address, delivery address, | |
| ship-to party, destination address, or similar address-related changes. | |
| "OTHER" β any other ERP action that does NOT fit the four above. Use "OTHER" when the | |
| request involves any of the following (even if an order/item number is mentioned): | |
| β’ Unblocking a delivery or removing a delivery block | |
| β’ Assigning or changing a batch number | |
| β’ Changing the shipping method, carrier, or route (NOT address) | |
| β’ Updating payment terms or billing conditions | |
| β’ Any clarification, inquiry, or system question | |
| β’ Any action not directly mapping to qty / date / cancel / address | |
| - Quantity (only for CHANGE_QTY) β distinguish ABSOLUTE vs RELATIVE, fill exactly ONE: | |
| β’ new_quantity : the ABSOLUTE target quantity, when the customer states the final | |
| value β e.g. "set quantity to 264", "change quantity to 100", | |
| "make it 80 units". Integer > 0. | |
| β’ quantity_change : the RELATIVE change, when the customer states an adjustment rather | |
| than a final value β e.g. "reduce by 50" β -50, "decrease by 20" β -20, | |
| "increase by 30" β +30, "add 10 more units" β +10. | |
| Use a NEGATIVE number for reductions, POSITIVE for increases. | |
| Never put a relative adjustment into new_quantity. "reduce by 50" is quantity_change=-50, | |
| NOT new_quantity=50. Leave the other field null. | |
| - new_date : ISO date string "YYYY-MM-DD" (only for CHANGE_DATE). Omit or null for other actions. | |
| - new_address : Free-text string with the new shipping address (only for CHANGE_ADDR). | |
| If the email does not specify a new address, use "[Address to be provided by customer]". | |
| Omit or null for other actions. | |
| Output ONLY valid JSON matching the required schema. No explanation, no markdown. | |
| """ | |
| _EXTRACTION_HUMAN = """\ | |
| Customer email: | |
| {user_input}{preprocess_hint}""" | |
| _EXTRACTION_PROMPT = ChatPromptTemplate.from_messages([ | |
| ("system", _EXTRACTION_SYSTEM), | |
| ("human", _EXTRACTION_HUMAN), | |
| ]) | |
| def _build_extraction_llm() -> ChatOpenAI: | |
| """Build the worker_a LLM (OpenRouter) for structured extraction.""" | |
| cfg = get_config() | |
| api_key = os.getenv("OPENROUTER_API_KEY", "") | |
| if not api_key: | |
| raise EnvironmentError("OPENROUTER_API_KEY is not set. Check your .env file.") | |
| wa_cfg = cfg.models.worker_a | |
| return ChatOpenAI( | |
| model=wa_cfg.name, | |
| temperature=wa_cfg.temperature, | |
| openai_api_key=api_key, | |
| openai_api_base=cfg.openrouter.base_url, | |
| default_headers={ | |
| "HTTP-Referer": "https://github.com/daisysooyeon/SAP-ERP-AI-Agent", | |
| "X-Title": "SAP ERP AI Agent - Worker A", | |
| }, | |
| ) | |
| # Build once at import time | |
| _extraction_llm = _build_extraction_llm() | |
| _extraction_chain = _EXTRACTION_PROMPT | _extraction_llm.with_structured_output(ERPActionRequest) | |
| # --------------------------------------------------------------------------- | |
| # β Parameter Extraction | |
| # --------------------------------------------------------------------------- | |
| def _build_preprocess_hint(email_context: dict | None) -> str: | |
| """μ μ²λ¦¬ κ²°κ³Όμμ worker_a μΆμΆμ λμμ΄ λλ hintλ₯Ό λ§λ λ€. | |
| LLMμ μ¬μ ν μκΈ° νλ¨μΌλ‘ μ΅μ’ κ°μ μ νμ§λ§(=order_ids μ€ μ΄λ κ² μ§μ§ order_idμΈμ§, | |
| item_nos μ€ μ΄λ κ² μ§μ§ item_noμΈμ§λ λ¬Έλ§₯μ μμ‘΄), ν보 νμ 미리 μ’νμ£Όλ©΄ λ€λ₯Έ | |
| μ«μ(μλ, λ μ§μ μΌλΆ)μ ν·κ°λ¦¬λ μ€μλ₯Ό μ€μΌ μ μλ€.""" | |
| if not email_context or not email_context.get("preprocess_ok"): | |
| return "" | |
| parts = ["\n\n---\nPreprocessor hints (use as a cross-check, not authoritative):"] | |
| if email_context.get("request_summary"): | |
| parts.append(f"- Request summary: {email_context['request_summary']}") | |
| if email_context.get("order_ids"): | |
| parts.append(f"- Candidate order IDs detected in email: {email_context['order_ids']}") | |
| if email_context.get("item_nos"): | |
| parts.append(f"- Candidate item numbers detected in email: {email_context['item_nos']}") | |
| return "\n".join(parts) | |
| def extract_erp_action(user_input: str, email_context: dict | None = None) -> ERPActionRequest | None: | |
| """ | |
| Extract ERPActionRequest from the email using LLM structured output. | |
| Args: | |
| user_input: raw email text. Used as the extraction input only when the | |
| preprocessor produced no usable cleaned_body. | |
| email_context: optional preprocessor result. When preprocess_ok and a | |
| cleaned_body is present, the cleaned_body (greetings/signatures | |
| stripped, but order/item/quantity digits preserved) becomes the | |
| extraction input; its summary/entity fields are still passed as | |
| soft cross-check hints. Mirrors worker_b's pattern. | |
| Returns None if extraction fails or validation errors occur. | |
| """ | |
| # μ μ²λ¦¬κ° λ§λ cleaned_bodyκ° μμΌλ©΄ μΆμΆ μ λ ₯μΌλ‘ μ¬μ©νλ€(worker_bμ λμΌ ν¨ν΄). | |
| # μΈμ¬Β·μλͺ λ Έμ΄μ¦κ° μ κ±°λΌ λ μμ μ μ΄λ©°, cleaned_bodyλ μ£Όλ¬Έ/μμ΄ν /μλ μ«μλ₯Ό | |
| # μλ¬Έ κ·Έλλ‘ λ³΄μ‘΄νλ€. μκ±°λ μ μ²λ¦¬ μ€ν¨ μ μλ³Έ user_inputμΌλ‘ ν΄λ°±. | |
| extraction_input = user_input | |
| used_cleaned = bool( | |
| email_context | |
| and email_context.get("preprocess_ok") | |
| and email_context.get("cleaned_body") | |
| ) | |
| if used_cleaned: | |
| extraction_input = email_context["cleaned_body"] | |
| logger.info("[worker_a] β Extracting ERP action parameters β¦ (input=%s%s)", | |
| "cleaned_body" if used_cleaned else "raw", | |
| ", with hints" if email_context else "") | |
| try: | |
| result: ERPActionRequest = _extraction_chain.invoke({ | |
| "user_input": extraction_input, | |
| "preprocess_hint": _build_preprocess_hint(email_context), | |
| }) | |
| # ν¨λ©μ μ¬κΈ°(κ²°μ λ‘ μ μ½λ)μ λ΄λΉνλ€. LLMμ μλ³Έ μ«μλ§ μΆμΆνλ―λ‘ | |
| # λμ리λ₯Ό ν리λ μ€ν¨λ©μ΄ λ°μνμ§ μλλ€. | |
| # order_id β 10μ리, item_no β 6μ리 μ’μΈ‘ μ λ‘ν¨λ©. | |
| raw_id = "".join(c for c in result.order_id if c.isdigit()) | |
| result.order_id = str(int(raw_id)).zfill(10) if raw_id else result.order_id | |
| raw_item = "".join(c for c in result.item_no if c.isdigit()) | |
| result.item_no = str(int(raw_item)).zfill(6) if raw_item else result.item_no | |
| logger.info( | |
| "[worker_a] Extracted: order_id=%s item_no=%s action_type=%s", | |
| result.order_id, result.item_no, result.action_type, | |
| ) | |
| return result | |
| except ValidationError as e: | |
| logger.error("[worker_a] ERPActionRequest validation failed: %s", e) | |
| return None | |
| except Exception as e: | |
| logger.error("[worker_a] Extraction LLM call failed: %s", e) | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # β’ Business Rule Checks | |
| # --------------------------------------------------------------------------- | |
| # Delivery status codes that block modification | |
| _BLOCKED_STATUSES = {"C"} # C = Fully processed / shipped | |
| def check_business_rules(action: ERPActionRequest, validation_result: dict | None) -> str | None: | |
| """ | |
| Validate ERP business rules against the DB snapshot. | |
| Returns: | |
| None β all checks passed, proceed | |
| "BLOCKED_NO_STOCK" β additional quantity (delta) exceeds available stock | |
| "BLOCKED_SHIPPED" β item already fully shipped (WBSTA = C) | |
| "BLOCKED_PARTIALLY_PROCESSED" β cancellation of an item whose goods movement | |
| already started (WBSTA = B) | |
| "BLOCKED_NO_DATA" β order/item not found in DB | |
| """ | |
| logger.info("[worker_a] β’ Checking business rules β¦") | |
| if validation_result is None: | |
| logger.warning("[worker_a] No DB record found for order_id=%s item_no=%s", | |
| action.order_id, action.item_no) | |
| return "BLOCKED_NO_DATA" | |
| delivery_status = validation_result.get("delivery_status", "") | |
| available_stock = validation_result.get("available_stock") or 0.0 | |
| # Rule 1: Block if item is already fully shipped | |
| if delivery_status in _BLOCKED_STATUSES: | |
| logger.warning( | |
| "[worker_a] BLOCKED_SHIPPED: delivery_status=%s for order=%s item=%s", | |
| delivery_status, action.order_id, action.item_no, | |
| ) | |
| return "BLOCKED_SHIPPED" | |
| # Rule 1b: An item cannot be CANCELLED once goods movement has started. | |
| # Full shipment (WBSTA=C) is already caught by Rule 1 (BLOCKED_SHIPPED); here we | |
| # additionally block partial processing (WBSTA=B) β but only for cancellations. | |
| # Other actions (qty/date) on a partially processed item remain allowed. | |
| if action.action_type == "CANCEL_ITEM" and delivery_status == "B": | |
| logger.warning( | |
| "[worker_a] BLOCKED_PARTIALLY_PROCESSED: cannot cancel partially processed " | |
| "item (WBSTA=B) for order=%s item=%s", | |
| action.order_id, action.item_no, | |
| ) | |
| return "BLOCKED_PARTIALLY_PROCESSED" | |
| # Rule 2: For a quantity change, only the ADDITIONAL units (delta) must be covered | |
| # by available stock β not the whole new quantity. A decrease frees stock, so its | |
| # delta is negative and never blocks. new_quantity is already absolute here | |
| # (resolve_quantity normalized any relative "reduce by N"/"increase by N" upstream), | |
| # so the delta is computed identically regardless of how the email phrased it. | |
| if action.action_type == "CHANGE_QTY" and action.new_quantity is not None: | |
| current_qty = validation_result.get("quantity") | |
| if current_qty is None: | |
| # Current quantity unknown β can't compute the delta. Fall back to the | |
| # conservative absolute check so we never under-reserve stock. | |
| additional_needed = float(action.new_quantity) | |
| else: | |
| additional_needed = action.new_quantity - float(current_qty) | |
| if additional_needed > available_stock: | |
| logger.warning( | |
| "[worker_a] BLOCKED_NO_STOCK: new_qty=%d current=%s additional_needed=%.2f " | |
| "available=%.2f for order=%s item=%s", | |
| action.new_quantity, current_qty, additional_needed, available_stock, | |
| action.order_id, action.item_no, | |
| ) | |
| return "BLOCKED_NO_STOCK" | |
| # Rule 3: CHANGE_ADDR β always passes business rules (address validity is human's responsibility) | |
| if action.action_type == "CHANGE_ADDR": | |
| logger.info("[worker_a] CHANGE_ADDR: forwarding to human approval.") | |
| return None | |
| logger.info("[worker_a] Business rules passed.") | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # β’-b Relative quantity resolution | |
| # --------------------------------------------------------------------------- | |
| def resolve_quantity(action: ERPActionRequest, validation_result: dict | None) -> str | None: | |
| """ | |
| For CHANGE_QTY with a RELATIVE quantity_change (e.g. "reduce by 50" β -50), | |
| resolve it into an absolute new_quantity using the current DB quantity (KWMENG), | |
| then write the result back into action.new_quantity. | |
| Must run AFTER run_validation_query (needs the current quantity) and BEFORE | |
| check_business_rules (which validates the absolute new_quantity against stock). | |
| Returns: | |
| None β nothing to resolve, or resolved successfully | |
| "BLOCKED_NO_DATA" β relative change requested but current quantity unknown | |
| "BLOCKED_INVALID_QTY" β resolved quantity would be <= 0 | |
| """ | |
| if action.action_type != "CHANGE_QTY" or action.quantity_change is None: | |
| return None # absolute path or not a qty change β nothing to do | |
| current = validation_result.get("quantity") if validation_result else None | |
| if current is None: | |
| logger.warning("[worker_a] Cannot resolve relative quantity β current quantity unknown.") | |
| return "BLOCKED_NO_DATA" | |
| resolved = int(current) + action.quantity_change # quantity_change is signed | |
| logger.info( | |
| "[worker_a] Relative qty resolved: current=%s change=%+d β new_quantity=%d", | |
| current, action.quantity_change, resolved, | |
| ) | |
| if resolved <= 0: | |
| logger.warning( | |
| "[worker_a] BLOCKED_INVALID_QTY: current=%s change=%+d β %d (must be > 0)", | |
| current, action.quantity_change, resolved, | |
| ) | |
| return "BLOCKED_INVALID_QTY" | |
| action.new_quantity = resolved | |
| action.quantity_change = None # consumed β downstream uses the absolute value | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # β‘-context β Text-to-SQL μμ² μ»¨ν μ€νΈ | |
| # --------------------------------------------------------------------------- | |
| def _build_request_context(action: ERPActionRequest) -> str: | |
| """text2sqlμ λκΈΈ μμ² μ»¨ν μ€νΈ λ¬Έμμ΄μ λ§λ λ€. | |
| LLMμ΄ μ΄ μ‘μ μ κ²μ¦νλ €λ©΄ (νμ 5κ° μ»¬λΌ μΈμ) μ΄λ€ μΆκ° 컬λΌμ΄ νμνμ§ νλ¨ν | |
| μ μλλ‘ μ‘μ μλλ₯Ό μμ νλ€. νμ 5κ° μ»¬λΌμ 컨ν μ€νΈμ 무κ΄νκ² νμ λ°νλλ€. | |
| μ£Όμ: νμ¬ check_business_rulesλ κ²°μ λ‘ μ μν΄ κ³ μ 5κ° νλλ§ μλΉνλ―λ‘, μΆκ°λ‘ | |
| μ‘°νλ 컬λΌμ μμ§ λ€μ΄μ€νΈλ¦Όμμ μ¬μ©λμ§ μλλ€(ν₯ν κ°λ³ κ²μ¦ νμ₯ λλΉ). | |
| """ | |
| at = action.action_type | |
| if at == "CHANGE_QTY": | |
| if action.new_quantity is not None: | |
| tgt = f"to an absolute quantity of {action.new_quantity}" | |
| elif action.quantity_change is not None: | |
| verb = "increase" if action.quantity_change > 0 else "decrease" | |
| tgt = f"by a relative {verb} of {abs(action.quantity_change)}" | |
| else: | |
| tgt = "(quantity change)" | |
| return (f"- Action: CHANGE_QTY {tgt}. To judge stock feasibility, the current " | |
| f"ordered quantity and the available stock at the order's own plant matter.") | |
| if at == "CHANGE_DATE": | |
| return (f"- Action: CHANGE_DATE to {action.new_date or '(unspecified)'}. " | |
| f"The current delivery/requested schedule date is relevant for validation.") | |
| if at == "CANCEL_ITEM": | |
| return ("- Action: CANCEL_ITEM (cancel this order line). Goods-movement status and " | |
| "any already-processed/delivered quantity are relevant to whether cancellation is allowed.") | |
| if at == "CHANGE_ADDR": | |
| return "- Action: CHANGE_ADDR (change ship-to address). No extra stock/schedule context is needed." | |
| return f"- Action: {at} (general status validation)." | |
| # --------------------------------------------------------------------------- | |
| # β£ OData PATCH (async β sync wrapper) | |
| # --------------------------------------------------------------------------- | |
| def _call_odata_sync(action: ERPActionRequest) -> dict: | |
| """Run the async OData PATCH call synchronously.""" | |
| try: | |
| loop = asyncio.get_event_loop() | |
| if loop.is_running(): | |
| # Inside an already-running event loop (e.g. Jupyter / FastAPI) | |
| import nest_asyncio # type: ignore | |
| nest_asyncio.apply() | |
| return loop.run_until_complete(call_sap_odata_patch(action)) | |
| else: | |
| return loop.run_until_complete(call_sap_odata_patch(action)) | |
| except RuntimeError: | |
| # No event loop exists yet β create a fresh one | |
| return asyncio.run(call_sap_odata_patch(action)) | |
| # --------------------------------------------------------------------------- | |
| # LangGraph Node | |
| # --------------------------------------------------------------------------- | |
| def worker_a_node(state: AgentState) -> dict: | |
| """ | |
| LangGraph Worker A node β ERP transaction processing. | |
| Reads: | |
| state["user_input"] β raw email text | |
| state["error_messages"] β existing error list (appended on failure) | |
| Returns a partial AgentState update: | |
| { | |
| "erp_action": ERPActionRequest dict | None, | |
| "erp_validation_result": dict | None, | |
| "erp_action_status": "PENDING_APPROVAL" | "BLOCKED_*", | |
| "odata_response": dict | None, | |
| "requires_human_approval": bool, | |
| "error_messages": [...], | |
| } | |
| """ | |
| user_input: str = state["user_input"] | |
| email_context: dict | None = state.get("email_context") | |
| errors: list[str] = list(state.get("error_messages", [])) | |
| logger.info("[worker_a] βββββββββββββββ Worker A START βββββββββββββββ") | |
| # ββ β Extract ERP action parameters ββββββββββββββββββββββββββββββββββββ | |
| action = extract_erp_action(user_input, email_context=email_context) | |
| if action is None: | |
| msg = "worker_a: Failed to extract ERP action parameters from email." | |
| logger.error("[worker_a] %s", msg) | |
| errors.append(msg) | |
| return { | |
| "erp_action": None, | |
| "erp_validation_result": None, | |
| "erp_action_status": "BLOCKED_EXTRACTION_FAILED", | |
| "odata_response": None, | |
| "requires_human_approval": False, | |
| "error_messages": errors, | |
| } | |
| # ββ OTHER μ‘μ μ μμ€ν μ΄ μλ μ²λ¦¬ν μ μλ€ β μλ μ²λ¦¬λ‘ λΆκΈ° ββββββββββββββββ | |
| # μ§μ μ‘μ (CHANGE_QTY/CHANGE_DATE/CANCEL_ITEM/CHANGE_ADDR)μ΄ μλλ―λ‘ κ²μ¦Β·ODataΒ· | |
| # μΉμΈ νλ₯Ό νμ°μ§ μκ³ MANUAL_REQUIREDλ‘ μ’ λ£νλ€. SUCCESS/PENDING_APPROVALμ΄ λμ΄ | |
| # "μλ£νλ€"κ³ λ΅νλ©΄ μ λλ©°(goldenλ 'μλ μ²λ¦¬ νμ'λ‘ λ λ), λΆνμν OData νΈμΆλ λ§λλ€. | |
| if action.action_type == "OTHER": | |
| logger.info("[worker_a] action_type=OTHER β μλ μ²λ¦¬ λΆκ°, MANUAL_REQUIREDλ‘ λΆκΈ°.") | |
| logger.info("[worker_a] βββββββββββββββ Worker A END βββββββββββββββ") | |
| return { | |
| "erp_action": action.model_dump(), | |
| "erp_validation_result": None, | |
| "erp_action_status": "MANUAL_REQUIRED", | |
| "odata_response": None, | |
| "requires_human_approval": False, | |
| "error_messages": errors, | |
| } | |
| # ββ β‘ Text-to-SQL validation query βββββββββββββββββββββββββββββββββββββ | |
| logger.info("[worker_a] β‘ Running Text-to-SQL validation query β¦") | |
| validation_result = run_validation_query( | |
| action.order_id, action.item_no, | |
| request_context=_build_request_context(action), | |
| ) | |
| logger.info("[worker_a] Validation result: %s", validation_result) | |
| # ββ β’ Business rule checks ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Resolve relative quantity ("reduce by 50") β absolute, then validate. | |
| block_reason = resolve_quantity(action, validation_result) \ | |
| or check_business_rules(action, validation_result) | |
| if block_reason is not None: | |
| logger.warning("[worker_a] Blocked: %s", block_reason) | |
| return { | |
| "erp_action": action.model_dump(), | |
| "erp_validation_result": validation_result, | |
| "erp_action_status": block_reason, | |
| "odata_response": None, | |
| "requires_human_approval": False, | |
| "error_messages": errors, | |
| } | |
| # ββ β£ SAP OData PATCH call βββββββββββββββββββββββββββββββββββββββββββββ | |
| logger.info("[worker_a] β£ Calling SAP OData PATCH β¦") | |
| odata_response: dict | None = None | |
| try: | |
| odata_response = _call_odata_sync(action) | |
| status_code = odata_response.get("status_code", 0) | |
| logger.info("[worker_a] OData response: status_code=%s", status_code) | |
| if status_code in (200, 204): | |
| logger.info("[worker_a] SAP OData PATCH succeeded.") | |
| elif status_code == 405: | |
| # SAP Business Accelerator Hub sandbox is read-only (GET only). | |
| # 405 confirms the endpoint is reachable; write ops require a live S/4HANA system. | |
| logger.info("[worker_a] SAP sandbox reached (405 expected β write ops not supported in sandbox).") | |
| else: | |
| msg = f"worker_a: SAP OData PATCH returned unexpected status {status_code}." | |
| logger.warning("[worker_a] %s", msg) | |
| errors.append(msg) | |
| except Exception as e: | |
| msg = f"worker_a: OData PATCH call failed: {e}" | |
| logger.error("[worker_a] %s", msg) | |
| errors.append(msg) | |
| # Non-fatal: proceed to approval queue so a human can retry manually. | |
| # ββ β€ Queue for human approval βββββββββββββββββββββββββββββββββββββββββ | |
| logger.info("[worker_a] β€ Setting PENDING_APPROVAL β routing to human_loop β¦") | |
| logger.info("[worker_a] βββββββββββββββ Worker A END βββββββββββββββ") | |
| return { | |
| "erp_action": action.model_dump(), | |
| "erp_validation_result": validation_result, | |
| "erp_action_status": "PENDING_APPROVAL", | |
| "odata_response": odata_response, | |
| "requires_human_approval": True, | |
| "error_messages": errors, | |
| } | |