"""Feishu approval widget value encodings — one place, every flow. Feishu's ``POST /approval/v4/instances`` encodes each widget's value differently, and the encodings are NOT inferable from one another (``image`` wants a raw array of plain strings, ``amount`` wants the currency as a SIBLING key, ``date`` wants RFC3339 with an explicit offset, ``dateInterval`` wants a native object). Every time a flow re-derived one of these on its own it re-derived it wrong: the ``amount`` dict rejection was recorded in the apikey domain months before the approval domain hit it and lost nine days of non-CNY buybacks to it. So: shapes that more than one flow needs live HERE, next to ``feishu_form_visibility``. Domain services import this module rather than each other. """ from __future__ import annotations import json from datetime import date, datetime, timedelta, timezone from typing import Any # Feishu tenant timezone. Date-ish widget values are stamped # Asia/Shanghai so the date on the approver's card is the date the # applicant picked, wherever the approver reads it. FEISHU_TENANT_TZ = timezone(timedelta(hours=8)) # JS ``Date.getTimezoneOffset()`` convention: minutes WEST of UTC. # UTC+8 ⇒ -480. This is the convention Feishu's own dateInterval value # object carries. FEISHU_TENANT_TZ_OFFSET_MINUTES = -480 # Feishu form-validation rejections, raised at instance-create time # (before any instance exists, so a retry is safe): # 1395006 — ``validate form error 控件值不合法或者为空 控件= `` # 1390001 — per-widget value-shape complaint FEISHU_FORM_VALUE_ERROR_CODES = frozenset({1390001, 1395006}) def parse_interval_boundary(raw: Any) -> datetime | None: """Parse one end of a date range into an aware ``datetime``. Accepts every shape the submit paths produce: ``YYYY-MM-DD`` (mobile picker / ``date`` columns), ``YYYY-MM-DD HH:MM:SS`` (desktop picker), RFC3339 with an explicit offset (MCP / replay callers), and real ``date`` / ``datetime`` objects. Naive values are pinned to Asia/Shanghai; aware ones pass through untouched. """ if isinstance(raw, datetime): parsed = raw elif isinstance(raw, date): parsed = datetime(raw.year, raw.month, raw.day) elif isinstance(raw, str) and raw.strip(): text = raw.strip().replace(" ", "T", 1) try: parsed = datetime.fromisoformat(text) except ValueError: return None else: return None if parsed.tzinfo is None: return parsed.replace(tzinfo=FEISHU_TENANT_TZ) return parsed def date_interval_wire_value(answer: Any) -> dict[str, Any] | str: """Wire value for a Feishu ``dateInterval`` (日期区间) widget. **Shape (read off live Feishu 2026-08-14** — the exact object Feishu stores for a ``computer_borrow`` instance a human submitted from the Feishu app, read back via ``GET /approval/v4/instances/{code}``): .. code-block:: json {"start": "2026-02-25T00:00:00+08:00", "end": "2026-06-01T00:00:00+08:00", "interval": 97, "timezoneOffset": -480} Three details the 2026-05-19 attempts got wrong — each of them enough for ``1395006 控件值不合法或者为空``, which is why both the asset flows and the API-key flow gave up and omitted the widget entirely, leaving approvers to approve borrow windows and key validity windows with no dates on the card at all: 1. The duration key is ``interval``, not ``duration``. 2. ``interval`` is a NUMBER of days counted INCLUSIVELY (2026-03-11 → 2027-03-11 lands as 366, not 365). 3. ``timezoneOffset`` is part of the object. Returns ``""`` for a half-filled / reversed / unparseable range so callers can drop the widget instead of shipping a value Feishu would reject. Dropping is safe: Feishu accepts a submission that omits the widget even when the template marks it required — that tolerance is exactly what kept these flows submittable while the widget was skipped. """ if isinstance(answer, str): # Already-serialised value from a replay / MCP caller. return answer if not isinstance(answer, dict): return "" start = parse_interval_boundary(answer.get("start")) end = parse_interval_boundary(answer.get("end")) if start is None or end is None or end < start: return "" interval = (end.date() - start.date()).days + 1 return { "start": start.isoformat(), "end": end.isoformat(), "interval": max(interval, 1), "timezoneOffset": FEISHU_TENANT_TZ_OFFSET_MINUTES, } def rejection_targets_date_interval(form_payload: str, message: str) -> bool: """Does this Feishu form-value rejection name a ``dateInterval``? Feishu's 1395006 message ends with ``控件= ``, so a rejection caused by some OTHER widget can be told apart from one caused by the range — and dropping the range would not have helped it anyway. Narrowing the retry to the range's own widget ids saves a pointless outbound call on those. Fail-open: when the message carries no widget id we can match (a Feishu wording change, a locale we haven't seen), assume the range is implicated and let the caller degrade. A redundant retry is cheaper than a submission that fails for want of one. """ ids = date_interval_widget_ids(form_payload) if not ids: return False if any(str(widget_id) in message for widget_id in ids): return True return "控件=" not in message and "widget" not in message.lower() def date_interval_widget_ids(form_payload: str) -> list[str]: """Widget ids of every ``dateInterval`` item in a serialised form.""" try: items = json.loads(form_payload) except (TypeError, ValueError): return [] if not isinstance(items, list): return [] # Empty ids are dropped, not just null ones: ``"" in message`` is # always True, so an id-less widget would match every rejection # message and drag the fail-open branch into a pointless retry. return [ str(item.get("id")) for item in items if isinstance(item, dict) and item.get("type") == "dateInterval" and str(item.get("id") or "").strip() ] def form_without_date_interval(form_payload: str) -> str | None: """Drop every ``dateInterval`` item from a serialised Feishu form. Submit-path fallback: if Feishu rejects the range value, resubmit the identical form minus the interval widget — the shape that shipped between 2026-05-19 and 2026-08-14, which Feishu is known to accept. Worst case becomes "the card is missing the dates" rather than "nobody can file this request". Returns ``None`` when there is nothing to strip, so the caller re-raises the original rejection instead of retrying an identical payload. """ try: items = json.loads(form_payload) except (TypeError, ValueError): return None if not isinstance(items, list): return None kept = [ item for item in items if not (isinstance(item, dict) and item.get("type") == "dateInterval") ] if len(kept) == len(items): return None return json.dumps(kept, ensure_ascii=False)