Project

General

Profile

Feature #99 » eap_sez_automation.py

The individual code used to implement the step 3 in framework, which is used to extract the request id from the excel template and download it and attach it to the frappe doctype - Rajesh Gotur, 09/12/2026 05:13 PM

 
import os
import re
import time
import json
import requests
import warnings
from pathlib import Path
from datetime import datetime
import cv2
import easyocr
from openpyxl import load_workbook
from urllib.parse import quote
from playwright.sync_api import sync_playwright

# ============================================================
# WARNING SUPPRESSION
# ============================================================
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=DeprecationWarning)
os.environ["PYTHONWARNINGS"] = "ignore"

# ============================================================
# CONFIGURATION
# ============================================================
USERNAME = "canofy01"
PASSWORD = "Siddhartha#2026"

# ============================================================
# BULK INPUT EXCEL
# ============================================================

EXCEL_FILE = Path(__file__).parent / "request_list.xlsx"

BASE_URL = "https://www.sezonline-ndml.co.in/User/Login.aspx"

# ============================================================
# FRAPPE CONFIGURATION
# ============================================================

FRAPPE_URL = "https://eap.canofy.tech/"

API_KEY = "b51750272b9961b"
API_SECRET = "ad42019b8e8a820"
# -------------------------------------------------------------


# ============================================================
# DIRECTORIES & PATHS
# ============================================================
DOWNLOAD_DIR = Path.home() / "Downloads"
SCREENSHOT_DIR = Path(__file__).parent / "screenshots"

DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)

# Initialize EasyOCR Reader ONCE (module load, not per captcha)
ocr_reader = easyocr.Reader(["en"], gpu=False)


# ============================================================
# SEZ ONLINE RATE-LIMIT HANDLING
# ============================================================
class RateLimitError(Exception):
"""Raised when SEZ Online reports that the request rate is limited."""
pass


class PrintStuckError(Exception):
"""
Raised when the SEZ Online print-preview interceptor hangs.

This is what was previously happening "near 98/99 downloads": the
portal's DOM/JS state degrades after many print operations in the
same page/tab and the print-preview popup silently stops firing.
Instead of failing the whole run, this is caught by the caller so a
hard page reset can be performed and the SAME request retried.
"""
pass


class CaptchaUnsolvedError(Exception):
"""Raised when the CAPTCHA could not be confidently read after retries."""
pass


class SessionExpiredError(Exception):
"""
Raised when the SEZ Online server-side session has timed out
(redirected to SessionTimeOut.aspx / "Your Session has expired").

This mostly happens during the long, deliberate `time.sleep()` pauses
(the 15-PDF cooldown and the rate-limit backoff) -- the browser page
sits idle for 2 minutes with no activity, which is long enough for
the portal's own inactivity timeout to log it out server-side. The
fix is reactive: detect it wherever it shows up, click "Here" (or
just re-navigate to the login page), run the full login flow again,
and resume the SAME request that was in progress.
"""
pass


RATE_LIMIT_WAIT_SECONDS = 120
PDF_PAUSE_15_SECONDS = 120

# Lowered from 100 -> 60. The portal's DOM/print-JS state was observed to
# degrade and freeze the print interceptor right around the 98-99 mark,
# i.e. comfortably before a 100-download refresh would ever trigger.
# Refreshing every 60 downloads keeps the page state well inside the
# window where printing is still reliable.
PDF_REFRESH_INTERVAL = 60

# How many times navigate_to_print is allowed to hard-reset-and-retry the
# SAME request after a PrintStuckError before that request is given up on
# and marked Failed. Prevents an infinite loop if the portal itself is down.
MAX_STUCK_RETRIES = 3

# How many times a SAME request is allowed to trigger a full re-login
# after a SessionExpiredError before it's given up on and marked Failed.
MAX_SESSION_RETRIES = 5

# ============================================================
# CAPTCHA CONFIGURATION
# ============================================================
# SEZ Online captchas are short alphanumeric strings. Anything shorter than
# this is almost certainly a misread (dropped/merged characters) rather
# than a real captcha value, so it is rejected and re-read/re-rolled
# instead of being submitted and wasting a whole login attempt.
CAPTCHA_MIN_LENGTH = 4
CAPTCHA_MAX_LENGTH = 8

# Number of times we'll re-screenshot + re-OCR + (if possible) refresh the
# captcha image before giving up on THIS login attempt and starting a
# completely fresh one (new page load = new captcha, new everything).
MAX_CAPTCHA_READ_ATTEMPTS = 6

# Number of full login attempts (fresh page load + fresh captcha each time)
# before we give up entirely and raise.
MAX_LOGIN_ATTEMPTS = 8


# ============================================================
# ============================================================
# ACTIVE IDLE (SESSION KEEP-ALIVE) PAUSE
# ============================================================
def idle_keepalive_pause(page, total_seconds: int, chunk_seconds: int = 5):
"""
Wait out a required cooldown (the 15-PDF pause or the rate-limit
backoff) WITHOUT letting the browser tab go completely idle.

A plain time.sleep(120) leaves the page with zero activity for two
full minutes -- which lines up exactly with when the SEZ Online
session was observed expiring mid-run. This performs small,
harmless mouse-wheel scrolls (down for a few seconds, then up,
repeating) so the tab registers ordinary user activity for the
whole cooldown, while still taking the full required duration and
making no extra network requests (so it can't trip the rate limit
itself). This is a best-effort measure, not a guarantee -- the
session-expiry detection/recovery elsewhere still runs immediately
afterward as the real safety net.
"""
print(f"Idling for {total_seconds}s with periodic keep-alive scrolling...")

elapsed = 0.0
direction = 1 # 1 = scroll down, -1 = scroll up

while elapsed < total_seconds:
chunk_start = time.time()
remaining = total_seconds - elapsed
this_chunk = min(chunk_seconds, remaining)

try:
steps = max(1, int(round(this_chunk)))
step_delta = (300 * direction) / steps
step_wait_ms = int((this_chunk / steps) * 1000)

for _ in range(steps):
page.mouse.wheel(0, step_delta)
page.wait_for_timeout(step_wait_ms)
except Exception:
# Page/tab not in a state where scrolling is possible right
# now -- fall back to a plain wait for this chunk. The
# session-expiry check that follows the full pause will
# catch and recover from an actual timeout regardless.
time.sleep(this_chunk)

direction *= -1
elapsed += (time.time() - chunk_start)

print("Keep-alive idling complete.")


# ============================================================
# SAFE FILE PATH RESOLVER (PREVENTS PERMISSIONERROR 13)
# ============================================================
def get_safe_pdf_path(target_dir: Path, req_id: str) -> Path:
"""Returns a writable file path, adding an increment suffix if the file is locked."""
base_path = target_dir / f"{req_id}.pdf"

if base_path.exists():
try:
with open(base_path, "a+b"):
pass
return base_path
except (PermissionError, IOError):
pass

counter = 1
while True:
alt_path = target_dir / f"{req_id}_{counter}.pdf"
if not alt_path.exists():
return alt_path
try:
with open(alt_path, "a+b"):
pass
return alt_path
except (PermissionError, IOError):
counter += 1


# ============================================================
# CAPTCHA ENGINE
# ============================================================
def is_valid_captcha_token(token: str) -> bool:
"""
Cheap sanity filter applied BEFORE a captcha guess is ever submitted.

Root cause of most "captcha failing" reports: a bad OCR read (empty
string, 1-2 stray characters, or a stray symbol) was being submitted
straight to the login form, burning a login attempt and forcing a
full page reload just to try again. Rejecting obviously-wrong reads
here lets us retry the CHEAP part (re-OCR / refresh the captcha image)
before ever touching the login form.
"""
if not token:
return False
if not token.isalnum():
return False
if not (CAPTCHA_MIN_LENGTH <= len(token) <= CAPTCHA_MAX_LENGTH):
return False
return True


def resolve_captcha(image_path: Path) -> str:
"""
Multi-variant OCR with confidence-weighted consensus voting.

Trimmed from 6 preprocessing variants down to the 4 that actually
contribute distinct, useful signal (bilateral-denoised, CLAHE-enhanced,
Otsu threshold, adaptive threshold). The dropped variants ("original"
color image and plain "gray") were consistently the lowest-confidence,
noisiest readings and mostly diluted the consensus vote rather than
improving it -- cutting them roughly halves per-captcha OCR time
without giving up accuracy.
"""
image = cv2.imread(str(image_path))

if image is None:
raise FileNotFoundError(f"Could not load CAPTCHA image from {image_path}")

resized = cv2.resize(image, None, fx=2.5, fy=2.5, interpolation=cv2.INTER_CUBIC)
gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)

variants = {}

filtered = cv2.bilateralFilter(gray, 9, 75, 75)
variants["bilateral"] = filtered

clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
variants["enhanced"] = clahe.apply(filtered)

_, otsu = cv2.threshold(filtered, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
variants["otsu"] = otsu

variants["adaptive"] = cv2.adaptiveThreshold(
filtered, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2
)

allowlist = (
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789"
)

candidates = []

for variant_name, variant_image in variants.items():
try:
results = ocr_reader.readtext(
variant_image, detail=1, paragraph=False, allowlist=allowlist
)
except Exception as exc:
print(f"OCR failed for {variant_name}: {exc}")
continue

if not results:
continue

try:
results = sorted(results, key=lambda item: min(point[0] for point in item[0]))
except Exception:
pass

raw_text = "".join(str(item[1]) for item in results if len(item) >= 2)
cleaned_text = re.sub(r"[^A-Za-z0-9]", "", raw_text)

if not cleaned_text:
continue

confidences = [float(item[2]) for item in results if len(item) >= 3]
avg_confidence = (sum(confidences) / len(confidences)) if confidences else 0.0

candidates.append({
"variant": variant_name,
"text": cleaned_text,
"confidence": avg_confidence,
})

if not candidates:
return ""

# Group exact matches -- strong agreement across preprocessing methods
# is the best evidence of a correct read.
grouped = {}
for candidate in candidates:
token = candidate["text"]
bucket = grouped.setdefault(token, {"text": token, "confidence_sum": 0.0, "count": 0})
bucket["confidence_sum"] += candidate["confidence"]
bucket["count"] += 1

grouped_candidates = list(grouped.values())
for candidate in grouped_candidates:
candidate["average_confidence"] = candidate["confidence_sum"] / candidate["count"]
candidate["score"] = candidate["average_confidence"] + (0.05 * (candidate["count"] - 1))

grouped_candidates.sort(key=lambda item: item["score"], reverse=True)
best = grouped_candidates[0]

# Case-aware, confidence-weighted per-character voting within the
# winning (case-insensitive) group.
case_groups = {}
for candidate in candidates:
key = candidate["text"].lower()
case_groups.setdefault(key, []).append(candidate)

compatible_group = case_groups.get(best["text"].lower(), [])
best_text = best["text"]

if compatible_group:
max_length = max(len(item["text"]) for item in compatible_group)
final_chars = []

for index in range(max_length):
char_votes = {}
for candidate in compatible_group:
text = candidate["text"]
if index >= len(text):
continue
char = text[index]
char_votes[char] = char_votes.get(char, 0.0) + candidate["confidence"]

if char_votes:
final_chars.append(max(char_votes, key=char_votes.get))

consensus_text = "".join(final_chars)
if consensus_text:
best_text = consensus_text

cleaned_token = re.sub(r"[^A-Za-z0-9]", "", best_text)

print(f"CAPTCHA OCR candidates: {[(c['variant'], c['text'], round(c['confidence'], 3)) for c in candidates]}")
print(f"CAPTCHA consensus token: '{cleaned_token}'")

return cleaned_token


def try_refresh_captcha_image(page) -> bool:
"""
Attempt to click a "refresh captcha" control so a NEW captcha image is
generated without losing the username/password already typed in.

SEZ Online (and most ASP.NET captcha widgets) usually expose a small
refresh icon/link next to the captcha image. We probe a handful of
common selector patterns; if none exist we fall back to clicking the
captcha image itself (many widgets rotate on click), and if that also
does nothing the caller will fall back to a full page reload instead.
"""
candidate_selectors = [
"#captchaControlRevamp_btnRefreshCaptcha",
"#captchaControlRevamp_imgRefresh",
"a[id*='Refresh' i][id*='aptcha' i]",
"img[id*='Refresh' i][id*='aptcha' i]",
"a[href*='refresh' i][id*='aptcha' i]",
"[title*='refresh' i][id*='aptcha' i]",
"[title*='reload' i][id*='aptcha' i]",
]

for selector in candidate_selectors:
try:
control = page.locator(selector).first
if control.count() > 0 and control.is_visible():
control.click(timeout=3000)
page.wait_for_timeout(500)
return True
except Exception:
continue

return False


def get_captcha_token(page, captcha_image_locator) -> str:
"""
Screenshot + OCR the captcha with validation and self-refresh retries.

This is the fix for "captcha reading fails and the script doesn't
retry": previously a single OCR pass was trusted no matter what. Now:
1. Screenshot the current captcha image.
2. Run the multi-variant OCR + consensus voting.
3. If the result looks structurally valid (right length, alnum),
use it immediately.
4. If not, try to refresh the captcha image in place and re-OCR,
up to MAX_CAPTCHA_READ_ATTEMPTS times.
5. If nothing valid ever comes back, return the best (highest
confidence / longest) guess we saw rather than an empty string,
so the outer login-retry loop still has something to try.
"""
raw_captcha_path = SCREENSHOT_DIR / "raw_captcha.png"
best_fallback = ""

for attempt in range(1, MAX_CAPTCHA_READ_ATTEMPTS + 1):
try:
captcha_image_locator.wait_for(state="visible", timeout=15000)
captcha_image_locator.screenshot(path=str(raw_captcha_path))
except Exception as exc:
print(f"Captcha screenshot attempt {attempt} failed: {exc}")
continue

token = resolve_captcha(raw_captcha_path)

if token and len(token) > len(best_fallback):
best_fallback = token

if is_valid_captcha_token(token):
print(f"Captcha read accepted on attempt {attempt}: '{token}'")
return token

print(
f"Captcha read attempt {attempt}/{MAX_CAPTCHA_READ_ATTEMPTS} "
f"rejected (raw='{token}'). Trying to get a fresh captcha image..."
)

refreshed = try_refresh_captcha_image(page)
if not refreshed:
# No refresh control found/worked. Re-screenshotting the same
# static image would just repeat the same failure, so stop
# this inner loop early and let the caller reload the whole
# page for a genuinely new captcha.
break

if best_fallback:
print(f"No structurally valid captcha read; using best guess: '{best_fallback}'")
return best_fallback

raise CaptchaUnsolvedError("Could not obtain any usable CAPTCHA reading.")


# ============================================================
# PERMANENT POPUP & OVERLAY IMMUNIZATION
# ============================================================
def immunize_page_against_popups(context):
"""Permanently disables driver.js and injects aggressive style blockades."""
context.add_init_script("""
window.Driver = function() {
return {
start: function() {},
reset: function() {},
destroy: function() {},
defineSteps: function() {},
showNextStep: function() {},
preventMove: function() {}
};
};

const style = document.createElement('style');
style.innerHTML = `
.driver-overlay, .driver-popover, .driver-popover-tip,
.driver-popover-footer, .driver-popover-description,
.driver-popover-title, .driver-stage-no-animation,
.modal-backdrop, .overlay {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
width: 0 !important;
height: 0 !important;
opacity: 0 !important;
}
body, html {
overflow: auto !important;
pointer-events: auto !important;
}
`;
document.head ? document.head.appendChild(style) : document.addEventListener('DOMContentLoaded', () => document.head.appendChild(style));
""")


def clean_popups_now(page):
"""Runtime cleanup for elements already present in the active DOM."""
try:
page.evaluate("""
document.querySelectorAll('.driver-overlay, .driver-popover, .driver-popover-tip, .driver-popover-footer, .modal-backdrop, .overlay').forEach(el => el.remove());
if (document.body) {
document.body.classList.remove('driver-active', 'driver-fade');
document.body.style.overflow = 'auto';
document.body.style.pointerEvents = 'auto';
}
if (document.documentElement) {
document.documentElement.classList.remove('driver-active', 'driver-fade');
document.documentElement.style.overflow = 'auto';
}
""")
except Exception:
pass


def safe_click(page, locator_or_selector):
"""Click a portal control reliably without synthetic click fallback."""
clean_popups_now(page)

loc = (
locator_or_selector
if hasattr(locator_or_selector, "click")
else page.locator(locator_or_selector)
)

try:
loc.scroll_into_view_if_needed(timeout=5000)
except Exception:
pass

try:
loc.click(timeout=10000, force=True)
except Exception as first_exc:
try:
loc.click(timeout=10000)
except Exception as second_exc:
raise RuntimeError(f"Could not click portal control: {second_exc}") from first_exc


def check_rate_limit(page) -> None:
"""
Detect the SEZ Online server-side rate-limit page/message AND the
server-side session-timeout redirect (SessionTimeOut.aspx / "Your
Session has expired"). Both are checked here, in one place, so every
one of the many call sites throughout navigate_to_print automatically
gets session-expiry detection for free without needing individual edits.
"""
try:
current_url = page.url
except Exception:
current_url = ""

if "SessionTimeOut.aspx" in current_url or "sessionexpired=true" in current_url.lower():
raise SessionExpiredError(
"SEZ Online session has expired (redirected to SessionTimeOut.aspx)."
)

try:
body_text = page.locator("body").inner_text(timeout=2000)
except Exception:
return

normalized = re.sub(r"\s+", " ", body_text or "").strip().lower()

if "session has expired" in normalized or "session expired" in normalized:
raise SessionExpiredError(
"SEZ Online session has expired ('Your Session has expired' banner detected)."
)

if "rate limit exceeded" in normalized or "try again in 120 seconds" in normalized:
raise RateLimitError(
"SEZ Online rate limit exceeded. The portal requested a retry after 120 seconds."
)


def recover_from_session_expiry(page):
"""
Recover from a detected SessionExpiredError: click the "Here" link on
the SessionTimeOut.aspx page if present (matches the exact banner
"Your Session has expired. Click Here to login again"), then run a
full fresh login regardless, since the "Here" link just routes back
to Login.aspx and a new captcha is needed either way.
"""
print()
print("=" * 60)
print("SESSION EXPIRED — RE-AUTHENTICATING")
print("=" * 60)
print("Current URL:", page.url)

try:
here_link = page.get_by_role("link", name="Here")
if here_link.count() > 0:
print("Clicking 'Here' to return to login...")
here_link.first.click(timeout=5000)
page.wait_for_load_state("domcontentloaded", timeout=15000)
page.wait_for_timeout(500)
except Exception as exc:
print(f"Could not click 'Here' link (continuing anyway): {exc}")

# login() always navigates to BASE_URL itself, so it recovers cleanly
# even if the "Here" click above did nothing.
login(page)

print("Re-authentication after session expiry: SUCCESSFUL.")
print("=" * 60)


def login_failed_reason(page) -> str:
"""
Inspect the page after a login submit and return a short reason string
if the login did NOT succeed (still on Login.aspx or an error banner is
present), or an empty string if login looks successful.
"""
if "Login.aspx" in page.url:
try:
body_text = page.locator("body").inner_text(timeout=2000)
except Exception:
body_text = ""

normalized = re.sub(r"\s+", " ", body_text or "").strip().lower()

if "invalid captcha" in normalized or "captcha" in normalized and "invalid" in normalized:
return "invalid captcha"
if "invalid" in normalized and ("username" in normalized or "password" in normalized):
return "invalid credentials"
return "still on login page"

return ""


# ============================================================
# LOGIN FLOW
# ============================================================
def login(page):
"""
Full login flow with automatic captcha + login retries.

Root cause fix: previously a single bad captcha read meant a failed
login with NO retry logic at all -- the script would just proceed
(and every subsequent step would fail against the still-on-Login.aspx
page). Now every login attempt is verified, and on failure we reload
the login page fresh (new captcha, clean state) and try again, up to
MAX_LOGIN_ATTEMPTS times.
"""
print()
print("=" * 60)
print("SEZ ONLINE LOGIN (AUTOMATED OCR)")
print("=" * 60)

for attempt in range(1, MAX_LOGIN_ATTEMPTS + 1):
print(f"\n--- Login attempt {attempt}/{MAX_LOGIN_ATTEMPTS} ---")

print("Opening login page...")
page.goto(BASE_URL, wait_until="domcontentloaded")
clean_popups_now(page)

username_input = page.get_by_role("textbox", name="User Name")
password_input = page.get_by_role("textbox", name="Password")
captcha_input = page.get_by_role("textbox", name="Captcha")
login_button = page.get_by_role("button", name="Login")
captcha_image = page.locator("#captchaControlRevamp_imgCaptcha")

print("Entering username & password...")
username_input.wait_for(state="visible", timeout=30000)
username_input.fill(USERNAME)
password_input.fill(PASSWORD)

try:
captcha_token = get_captcha_token(page, captcha_image)
except CaptchaUnsolvedError as exc:
print(f"Captcha could not be solved this attempt: {exc}")
continue # fresh page load, fresh captcha, try again

print(f"Entering extracted CAPTCHA: '{captcha_token}'")
captcha_input.fill(captcha_token)

print("Submitting login form...")
clean_popups_now(page)
safe_click(page, login_button)

try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass

page.wait_for_timeout(1500)
clean_popups_now(page)
print("Login submission completed. Current URL:", page.url)

# Handle ConfirmUserCredentials screen if redirected there
if "ConfirmUserCredentials.aspx" in page.url:
print("Handling ConfirmUserCredentials screen...")
try:
confirm_btn = page.locator(
"input[type='submit'][value*='Continue'], "
"input[type='submit'][value*='OK'], "
"#btnSubmit, #btnConfirm"
)
if confirm_btn.count() > 0:
safe_click(page, confirm_btn.first)
page.wait_for_load_state("domcontentloaded", timeout=10000)
page.wait_for_timeout(1000)
except Exception:
pass

failure_reason = login_failed_reason(page)
if not failure_reason:
print("Login SUCCESSFUL.")
return

print(f"Login attempt {attempt} failed ({failure_reason}). Retrying with a fresh captcha...")

raise RuntimeError(
f"Could not log in to SEZ Online after {MAX_LOGIN_ATTEMPTS} attempts "
"(captcha and/or credential failures)."
)


# ============================================================
# PRINT/PAGE STATE RESET
# ============================================================
def reset_portal_page(context, page):
"""
Hard-reset the authenticated portal page. Closes stray popup/print
pages, reloads, then replaces the Playwright Page object entirely
(new Page in the same BrowserContext => same login session/cookies,
but zero accumulated JS/DOM/print state). Called both on the periodic
PDF_REFRESH_INTERVAL schedule and reactively when a PrintStuckError
is detected.
"""
print()
print("=" * 60)
print("RESETTING SEZ ONLINE PAGE STATE")
print("=" * 60)

for extra_page in list(context.pages):
if extra_page != page:
try:
print("Closing stale popup/print page:", extra_page.url)
except Exception:
pass
try:
extra_page.close()
except Exception:
pass

current_url = page.url
print("Refreshing current portal page...")
try:
page.reload(wait_until="domcontentloaded", timeout=30000)
except Exception as reload_exc:
print("DOM/page reload warning:", reload_exc)

try:
page.wait_for_timeout(2000)
clean_popups_now(page)
except Exception:
pass

print("Creating a fresh Playwright page in the same session...")
fresh_page = context.new_page()

try:
fresh_page.goto(current_url, wait_until="domcontentloaded", timeout=30000)
except Exception as goto_exc:
try:
fresh_page.close()
except Exception:
pass
raise RuntimeError(
f"Could not recreate SEZ Online page after batch refresh: {goto_exc}"
) from goto_exc

try:
fresh_page.wait_for_timeout(2000)
clean_popups_now(fresh_page)
check_rate_limit(fresh_page)
except RateLimitError:
try:
fresh_page.close()
except Exception:
pass
raise
except Exception:
pass

try:
page.close()
except Exception:
pass

for extra_page in list(context.pages):
if extra_page != fresh_page:
try:
extra_page.close()
except Exception:
pass

print("Fresh authenticated SEZ Online page is ready.")
print("Current URL:", fresh_page.url)
print("=" * 60)

return fresh_page


# ============================================================
# ============================================================
# STRAY / LEFTOVER MODAL RECOVERY
# ============================================================
def dismiss_stray_dialog(page) -> bool:
"""
Detect and forcibly close a leftover print-options dialog ("Please
Select The Type Of Copies You Want To Print") or any other jQuery
UI / Bootstrap-style modal that failed to close on a PREVIOUS
request.

Root cause of the "randomly stuck on this screen" symptom: when
something about the print-options dialog interaction fails
unexpectedly (slow render, wrong frame, a stray double-open, etc.),
the exception raised was a plain, unhandled one -- it was NOT a
RateLimitError / PrintStuckError / SessionExpiredError, so it
skipped straight past the retry loop for that request and landed in
the outer per-row handler, which just marked the row Failed and
moved to the next row WITHOUT ever cleaning up the page. The modal
was left open, silently swallowing every click on the next request
(that's why the log shows a timeout waiting for the date-picker
field to be visible -- the "Search Request" click never actually
reached the page underneath the overlay). Every request after that
point failed identically, looking like the automation was "stuck".

This is called proactively at the start of every navigate_to_print
AND reactively from the per-row failure handler in main(), so a
stuck dialog from one request can never permanently break the rest
of the run.
"""
closed_any = False

# Case 1: dialog rendered inside the #dialog-body iframe (what the
# normal print flow expects).
try:
frame = page.frame_locator("#dialog-body")
close_control = frame.locator(
"xpath=//*[self::a or self::img or self::button or self::span]"
"[contains(@class,'close') or contains(@title,'Close') or contains(@aria-label,'Close')]"
).first
if close_control.count() > 0 and close_control.is_visible():
close_control.click(timeout=3000)
closed_any = True
except Exception:
pass

# Case 2: a plain (non-iframe) modal directly on the main page --
# some renders of the "type of copies" prompt aren't in an iframe.
try:
plain_close = page.locator(
"xpath=//div[contains(@class,'ui-dialog') or contains(@class,'modal') or contains(@id,'dialog')]"
"//*[self::a or self::span or self::button]"
"[contains(@class,'close') or contains(@class,'ui-dialog-titlebar-close') or contains(@aria-label,'Close')]"
).first
if plain_close.count() > 0 and plain_close.is_visible():
plain_close.click(timeout=3000)
closed_any = True
except Exception:
pass

# Last resort: Escape closes most jQuery UI / Bootstrap modals
# regardless of the exact markup, without needing to know it.
if not closed_any:
try:
page.keyboard.press("Escape")
except Exception:
pass

try:
page.wait_for_timeout(500)
clean_popups_now(page)
except Exception:
pass

return closed_any


# ============================================================
# NAVIGATE TO PRINT & EXPORT PDF
# ============================================================
def navigate_to_print(page, req_id: str, target_dir: Path) -> str:
for extra_page in list(page.context.pages):
if extra_page != page:
try:
print("Closing stale print/popup page before request:", extra_page.url)
except Exception:
pass
try:
extra_page.close()
except Exception:
pass

# Proactively clear any dialog left open by a previous, failed
# request BEFORE attempting any navigation for this one.
dismiss_stray_dialog(page)

print()
print("=" * 60)
print("NAVIGATE TO PRINT")
print("=" * 60)
print("Target Request ID:", req_id)

check_rate_limit(page)
clean_popups_now(page)

try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass

check_rate_limit(page)

print("Opening Search Request...")
search_request_button = page.get_by_role("link", name="Search Request")
search_request_button.wait_for(state="visible", timeout=30000)
check_rate_limit(page)
safe_click(page, search_request_button)

try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass
page.wait_for_timeout(800)
clean_popups_now(page)
check_rate_limit(page)

print("Clearing date filters...")
from_date_input = page.locator("#ctl00_DefaultContent_DatePicker_RequestFromDate_txtMaskedDate")
to_date_input = page.locator("#ctl00_DefaultContent_DatePicker_RequestToDate_txtMaskedDate")

from_date_input.wait_for(state="visible", timeout=15000)
to_date_input.wait_for(state="visible", timeout=15000)
check_rate_limit(page)

from_date_input.evaluate("(el) => el.value = ''")
to_date_input.evaluate("(el) => el.value = ''")

print(f"Entering Request ID: {req_id}...")
request_id_textfield = page.locator("#ctl00_DefaultContent_txtRequestID")
request_id_textfield.wait_for(state="visible", timeout=15000)
request_id_textfield.fill(req_id)

print("Searching...")
search_button = page.get_by_role("button", name="Search")
search_button.wait_for(state="visible", timeout=15000)
check_rate_limit(page)
safe_click(page, search_button)

page.wait_for_timeout(1500)
clean_popups_now(page)
check_rate_limit(page)

print("Opening request details...")
req_id_link = page.locator(f"xpath=//a[normalize-space(text())='{req_id}']")
req_id_link.wait_for(state="visible", timeout=30000)
check_rate_limit(page)
safe_click(page, req_id_link)

try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass
page.wait_for_timeout(1200)
clean_popups_now(page)
check_rate_limit(page)

print("Clicking View / Print...")
view_receipt = page.locator("#ctl00_DefaultContent_btnPrintBOE")
view_receipt.wait_for(state="visible", timeout=30000)
check_rate_limit(page)
safe_click(page, view_receipt)

page.wait_for_timeout(800)
clean_popups_now(page)
check_rate_limit(page)

print("Handling Print Modal iframe...")
print_frame = page.frame_locator("#dialog-body")

original_checkbox = print_frame.locator("#cbNo_0")
original_checkbox.wait_for(state="visible", timeout=15000)
original_checkbox.click()
print("Selected 'Original' document option.")

ok_button = print_frame.locator("xpath=//input[@value='OK']")
ok_button.wait_for(state="visible", timeout=15000)

check_rate_limit(page)

print("Triggering print preview window...")
context = page.context
print_page = None

pages_before_print = set(context.pages)

try:
with context.expect_page(timeout=30000) as page_info:
ok_button.click()

print_page = page_info.value
print("Print window intercepted successfully.")

except Exception as exc:
new_pages = [c for c in context.pages if c not in pages_before_print]

if new_pages:
print_page = new_pages[-1]
print("Print window found after interceptor timeout; using it.")
else:
try:
check_rate_limit(page)
except RateLimitError:
raise

for candidate in list(context.pages):
if candidate != page:
try:
candidate.close()
except Exception:
pass

# This is the "stuck at 98/99" symptom: the print interceptor
# never fires and no new page ever appears. Raising a
# dedicated exception lets the caller do a hard page reset
# and retry, instead of treating this the same as an
# ordinary one-off failure.
raise PrintStuckError(
f"Print preview window was not opened for request {req_id} "
f"within 30 seconds (portal DOM/print state likely frozen): {exc}"
) from exc

try:
print_page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
print(
"Print page did not reach DOMContentLoaded within the timeout; "
"continuing because the page was already intercepted."
)

print_page.wait_for_timeout(1500)

resolved_pdf_path = get_safe_pdf_path(target_dir, req_id)

print(f"Generating PDF at: {resolved_pdf_path}...")
try:
print_page.pdf(path=str(resolved_pdf_path), print_background=True, landscape=False)
finally:
try:
print_page.close()
except Exception:
pass

if not resolved_pdf_path.exists() or resolved_pdf_path.stat().st_size == 0:
raise RuntimeError(f"PDF creation failed or output file is empty: {resolved_pdf_path}")

print()
print("=" * 60)
print("PDF CREATED SUCCESSFULLY")
print("=" * 60)
print("Location:", resolved_pdf_path)
print("File Size:", resolved_pdf_path.stat().st_size, "bytes")

return str(resolved_pdf_path)


# ============================================================
# SEZ PORTAL LOGOUT
# ============================================================
def logout(page):
print()
print("=" * 60)
print("LOGGING OUT FROM SEZ PORTAL")
print("=" * 60)

clean_popups_now(page)

logout_button = page.locator("#ctl00_LoginStatus")
logout_button.wait_for(state="visible", timeout=30000)

print("Clicking Logout...")
safe_click(page, logout_button)

try:
page.wait_for_load_state("domcontentloaded", timeout=15000)
except Exception:
pass

page.wait_for_timeout(1000)

print("Successfully Logged out")
print("Current URL:", page.url)


def read_request_excel():
print()
print("=" * 60)
print("READING BULK REQUEST EXCEL")
print("=" * 60)

if not EXCEL_FILE.exists():
raise FileNotFoundError(f"Excel file not found:\n{EXCEL_FILE}")

workbook = load_workbook(EXCEL_FILE)
sheet = workbook.active

headers = {}
for cell in sheet[1]:
if cell.value is not None:
headers[str(cell.value).strip()] = cell.column

required_columns = {"Request ID", "Doctype Name"}
missing_columns = required_columns - set(headers.keys())

if missing_columns:
raise RuntimeError("Missing required Excel columns: " + ", ".join(sorted(missing_columns)))

requests_list = []

for row_number in range(2, sheet.max_row + 1):
request_id = sheet.cell(row=row_number, column=headers["Request ID"]).value
doctype_name = sheet.cell(row=row_number, column=headers["Doctype Name"]).value

if request_id is None:
continue

request_id = str(request_id).strip()
if not request_id:
continue

if doctype_name is None:
raise RuntimeError(f"Doctype Name is empty for Excel row {row_number}.")

doctype_name = str(doctype_name).strip()

requests_list.append({
"row_number": row_number,
"request_id": request_id,
"doctype_name": doctype_name,
})

print("Total requests found:", len(requests_list))
for item in requests_list:
print(f"Row {item['row_number']} | Request ID: {item['request_id']} | Doctype: {item['doctype_name']}")

return workbook, sheet, headers, requests_list


def update_excel_row(
sheet, headers, row_number,
download_status=None, timestamp=None, filename=None,
upload_status=None, upload_timestamp=None, upload_response=None,
):
if download_status is not None:
sheet.cell(row=row_number, column=headers["Download Status"]).value = download_status
if timestamp is not None:
sheet.cell(row=row_number, column=headers["Timestamp"]).value = timestamp
if filename is not None:
sheet.cell(row=row_number, column=headers["Filename"]).value = filename
if upload_status is not None:
sheet.cell(row=row_number, column=headers["Upload Status"]).value = upload_status
if upload_timestamp is not None:
sheet.cell(row=row_number, column=headers["Upload Timestamp"]).value = upload_timestamp
if upload_response is not None:
sheet.cell(row=row_number, column=headers["Upload response"]).value = upload_response


# ============================================================
# MAIN ENTRY POINT
# ============================================================
def main():
print()
print("=" * 60)
print("SEZ PRINT AUTOMATION - BULK RUNNER")
print("=" * 60)

workbook, sheet, headers, request_rows = read_request_excel()

downloaded_items = []

with sync_playwright() as p:
browser = p.chromium.launch(
headless=False,
args=["--start-maximized", "--no-sandbox"],
)

context = browser.new_context(
no_viewport=True,
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
)

immunize_page_against_popups(context)

def auto_accept_dialogs(dialog):
print(f"\n[Dialog Intercepted] {dialog.message}")
dialog.accept()

context.on("dialog", auto_accept_dialogs)

page = context.new_page()

try:
# STEP 1: LOGIN ONCE (with internal captcha/login retries)
login(page)

# STEP 2: DOWNLOAD ALL PDFs
print()
print("=" * 60)
print("STARTING BULK PDF DOWNLOAD")
print("=" * 60)

# ========================================================
# BULK DOWNLOAD THROTTLING
# ========================================================
# - Every 15 successful PDFs -> wait 120 seconds.
# - Every 60 successful PDFs -> hard-reset the portal
# DOM/page (lowered from 100 to head off the print-
# interceptor freeze observed near 98/99 downloads).
# - A refresh-interval PDF does NOT also get the 15-PDF
# pause on the same download.
# - A frozen print interceptor (PrintStuckError) triggers
# an immediate reactive reset + retry of the SAME
# request, independent of the periodic schedule.
# ========================================================
download_count = 0

for item in request_rows:
row_number = item["row_number"]
request_id = item["request_id"]
doctype_name = item["doctype_name"]

print()
print("#" * 60)
print("PROCESSING REQUEST")
print("#" * 60)
print("Excel Row :", row_number)
print("Request ID :", request_id)
print("Doctype Name :", doctype_name)

try:
stuck_retries = 0
session_retries = 0

while True:
try:
check_rate_limit(page)
saved_pdf = navigate_to_print(page, request_id, DOWNLOAD_DIR)
break

except SessionExpiredError as session_exc:
session_retries += 1
print()
print("=" * 60)
print("SEZ ONLINE SESSION EXPIRED")
print("=" * 60)
print(f"Request ID : {request_id}")
print(f"Reason : {session_exc}")
print(f"Re-login attempt {session_retries}/{MAX_SESSION_RETRIES}")

if session_retries > MAX_SESSION_RETRIES:
raise RuntimeError(
f"Session kept expiring for {request_id} "
f"after {MAX_SESSION_RETRIES} re-login attempts."
) from session_exc

recover_from_session_expiry(page)
print(f"Retrying SAME request after re-login: {request_id}")
continue

except RateLimitError as rate_exc:
print()
print("=" * 60)
print("SEZ ONLINE RATE LIMIT DETECTED")
print("=" * 60)
print(f"Request ID : {request_id}")
print(f"Reason : {rate_exc}")
print(f"Waiting {RATE_LIMIT_WAIT_SECONDS} seconds before retrying the SAME request...")

idle_keepalive_pause(page, RATE_LIMIT_WAIT_SECONDS)

print("Refreshing SEZ Online page...")
try:
page.reload(wait_until="domcontentloaded", timeout=30000)
except Exception as reload_exc:
print("Page reload warning:", reload_exc)

page.wait_for_timeout(1500)
clean_popups_now(page)

print(f"Retrying SAME request from the beginning: {request_id}")
continue

except PrintStuckError as stuck_exc:
stuck_retries += 1
print()
print("=" * 60)
print("PRINT INTERCEPTOR FROZEN — HARD RESET")
print("=" * 60)
print(f"Request ID : {request_id}")
print(f"Reason : {stuck_exc}")
print(f"Reset attempt {stuck_retries}/{MAX_STUCK_RETRIES}")

if stuck_retries > MAX_STUCK_RETRIES:
raise RuntimeError(
f"Print interceptor stayed frozen for {request_id} "
f"after {MAX_STUCK_RETRIES} hard resets."
) from stuck_exc

page = reset_portal_page(page.context, page)
print(f"Retrying SAME request after hard reset: {request_id}")
continue

update_excel_row(
sheet, headers, row_number,
download_status="Success",
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
filename=Path(saved_pdf).name,
)
workbook.save(EXCEL_FILE)

downloaded_items.append({
"row_number": row_number,
"request_id": request_id,
"doctype_name": doctype_name,
"pdf_path": saved_pdf,
})

download_count += 1

print()
print(f"PDF download successful for {request_id}")
print("PDF:", saved_pdf)
print(f"Successful PDFs downloaded: {download_count}")

# EVERY 60 PDFs: proactive hard reset (no sleep, just
# DOM/state hygiene) to stay ahead of the freeze point.
if download_count % PDF_REFRESH_INTERVAL == 0:
print()
print("=" * 60)
print(f"{PDF_REFRESH_INTERVAL} PDFs DOWNLOADED SUCCESSFULLY")
print("PROACTIVELY REFRESHING SEZ ONLINE DOM + PAGE...")
print("=" * 60)

page = reset_portal_page(page.context, page)

print()
print("=" * 60)
print("PROACTIVE PAGE RESET COMPLETED")
print("Fresh authenticated page is active. Resuming...")
print("=" * 60)

# EVERY 15 PDFs: rate-limit cooldown pause. Skipped on
# the same download that just got the interval reset.
elif download_count % 15 == 0:
print()
print("=" * 60)
print("15 PDFs DOWNLOADED SUCCESSFULLY")
print(f"Pausing for {PDF_PAUSE_15_SECONDS} seconds...")
print("=" * 60)

idle_keepalive_pause(page, PDF_PAUSE_15_SECONDS)

# The browser page sits fully idle for the whole
# 2-minute pause above -- long enough for SEZ
# Online's server-side inactivity timeout to log
# the session out without the browser noticing.
# Proactively reload + check here so an expired
# session is caught and re-authenticated right
# away, rather than surfacing as a confusing
# failure partway through the NEXT request.
try:
page.reload(wait_until="domcontentloaded", timeout=30000)
except Exception as reload_exc:
print("Post-cooldown reload warning:", reload_exc)

page.wait_for_timeout(1000)
clean_popups_now(page)

try:
check_rate_limit(page)
except SessionExpiredError:
recover_from_session_expiry(page)
except RateLimitError:
# Will be handled reactively when the next
# request hits navigate_to_print.
pass

print()
print("=" * 60)
print("15-PDF COOLDOWN COMPLETED. Resuming...")
print("=" * 60)

except Exception as exc:
print()
print(f"PDF DOWNLOAD FAILED for {request_id}")
print(f"{type(exc).__name__}: {exc}")

update_excel_row(
sheet, headers, row_number,
download_status="Failed",
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
)
workbook.save(EXCEL_FILE)

# ------------------------------------------------
# PAGE RECOVERY BEFORE MOVING ON
# ------------------------------------------------
# This is the fix for the "randomly stuck on this
# screen" symptom: an unexpected failure here (an
# unhandled TimeoutError, a print dialog that never
# closed, etc.) used to just mark the row Failed and
# fall straight through to the next row with the
# page left in whatever broken/blocked state caused
# the failure -- so every remaining row then failed
# identically. Always attempt to leave the page
# clean before continuing: dismiss any leftover
# dialog, check for rate-limit/session-expiry, and
# fall back to a full hard reset if the page still
# isn't usable.
try:
dismiss_stray_dialog(page)
check_rate_limit(page)
except SessionExpiredError:
try:
recover_from_session_expiry(page)
except Exception as recovery_exc:
print(f"Re-login after failure also failed, hard-resetting page: {recovery_exc}")
try:
page = reset_portal_page(page.context, page)
except Exception as reset_exc:
print(f"Page reset also failed: {reset_exc}")
except RateLimitError:
pass
except Exception as cleanup_exc:
print(f"Light recovery after failure didn't confirm a clean page ({cleanup_exc}); hard-resetting...")
try:
page = reset_portal_page(page.context, page)
except Exception as reset_exc:
print(f"Page reset also failed: {reset_exc}")

continue

print()
print("=" * 60)
print("ALL PDF DOWNLOADS COMPLETED")
print("=" * 60)

logout(page)

except Exception as exc:
print()
print("=" * 60)
print("SEZ PORTAL PIPELINE FAILED")
print("=" * 60)
print(f"{type(exc).__name__}: {exc}")

failure_path = SCREENSHOT_DIR / "failure.png"
page.screenshot(path=str(failure_path), full_page=True)
print("Saved failure diagnostic screenshot to:", failure_path)

raise

finally:
context.close()
browser.close()

# ========================================================
# STEP 4: FRAPPE API PROCESSING
# ========================================================
print()
print("=" * 60)
print("STARTING FRAPPE BULK PROCESSING")
print("=" * 60)

for item in downloaded_items:
row_number = item["row_number"]
request_id = item["request_id"]
doctype_name = item["doctype_name"]
pdf_path = item["pdf_path"]

print()
print("#" * 60)
print("FRAPPE PROCESSING")
print("#" * 60)
print("Request ID :", request_id)
print("Doctype :", doctype_name)
print("PDF :", pdf_path)

try:
frappe_result = process_pdf_in_frappe(request_id, doctype_name, pdf_path)

if frappe_result.get("already_attached", False):
update_excel_row(
sheet, headers, row_number,
upload_status="Already Attached",
upload_timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
upload_response=(
f"PDF already attached | Document: {frappe_result['frappe_docname']} | "
f"System Doc No: {frappe_result['system_doc_no']} | PDF: {Path(pdf_path).name}"
),
)
print()
print("=" * 60)
print("PDF ALREADY ATTACHED")
print("=" * 60)
print("Request ID :", request_id)
print("Frappe Document:", frappe_result["frappe_docname"])
print("System Doc No. :", frappe_result["system_doc_no"])
print("Upload Status :", "Already Attached")
else:
update_excel_row(
sheet, headers, row_number,
upload_status="Success",
upload_timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
upload_response=(
f"PDF attached successfully | Document: {frappe_result['frappe_docname']} | "
f"System Doc No: {frappe_result['system_doc_no']} | PDF: {Path(pdf_path).name}"
),
)
print()
print("=" * 60)
print("PDF ATTACHED SUCCESSFULLY")
print("=" * 60)
print("Request ID :", request_id)
print("Frappe Document:", frappe_result["frappe_docname"])
print("System Doc No. :", frappe_result["system_doc_no"])
print("Upload Status :", "Success")

workbook.save(EXCEL_FILE)

except Exception as exc:
print()
print(f"FRAPPE PROCESSING FAILED FOR {request_id}")
print(f"{type(exc).__name__}: {exc}")

update_excel_row(
sheet, headers, row_number,
upload_status="Failed – Frappe document mismatch",
upload_timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
upload_response=str(exc),
)
workbook.save(EXCEL_FILE)
print("Upload Status:", "Failed – Frappe document mismatch")
continue

workbook.save(EXCEL_FILE)

print()
print("=" * 60)
print("BULK PROCESSING COMPLETED")
print("=" * 60)
print("Updated Excel:", EXCEL_FILE)


# ============================================================
# FRAPPE API HEADERS
# ============================================================
def get_frappe_headers():
return {
"Authorization": f"token {API_KEY}:{API_SECRET}",
"Accept": "application/json",
}


def find_frappe_document(request_id: str, expected_doctype: str):
print()
print("=" * 60)
print("FINDING FRAPPE DOCUMENT")
print("=" * 60)

request_id = str(request_id).strip()
expected_doctype = str(expected_doctype).strip()

if not API_KEY or not API_SECRET:
raise RuntimeError("Frappe API_KEY and API_SECRET are not configured.")

print("Excel Request ID:", request_id)
print("Excel Doctype:", expected_doctype)

encoded_doctype = quote(expected_doctype, safe="")
url = f"{FRAPPE_URL.rstrip('/')}/api/resource/{encoded_doctype}"

params = {
"filters": json.dumps([[expected_doctype, "request_id", "=", request_id]]),
"fields": json.dumps(["name", "request_id", "docstatus"]),
"limit_page_length": 100,
}

response = requests.get(url, headers=get_frappe_headers(), params=params, timeout=120)
print("Frappe search status:", response.status_code)

if not response.ok:
print(response.text)
raise RuntimeError("Failed to search Frappe document.")

documents = response.json().get("data", [])
print("Matching documents found:", len(documents))

if len(documents) != 1:
raise RuntimeError("Frappe document mismatch")

document = documents[0]
frappe_docname = document.get("name")

if not frappe_docname:
raise RuntimeError("Frappe document mismatch")

document_url = f"{FRAPPE_URL.rstrip('/')}/api/resource/{encoded_doctype}/{quote(frappe_docname, safe='')}"
document_response = requests.get(document_url, headers=get_frappe_headers(), timeout=120)

if not document_response.ok:
raise RuntimeError("Frappe document mismatch")

frappe_document = document_response.json().get("data", {})

excel_request_id = str(request_id).strip()
frappe_request_id = str(frappe_document.get("request_id", "")).strip()

print()
print("Request ID validation:")
print("Excel :", excel_request_id)
print("Frappe:", frappe_request_id)

if excel_request_id != frappe_request_id:
print("Request ID validation: FAILED")
raise RuntimeError("Frappe document mismatch")

print("Request ID validation: PASSED")

frappe_doctype = str(frappe_document.get("doctype", "")).strip()

print()
print("Doctype validation:")
print("Excel :", expected_doctype)
print("Frappe:", frappe_doctype)

if expected_doctype != frappe_doctype:
print("Doctype validation: FAILED")
raise RuntimeError("Frappe document mismatch")

print("Doctype validation: PASSED")

print()
print("BOTH FRAPPE VALIDATIONS PASSED")
print("Document Name:", frappe_docname)
print("System Doc No.:", frappe_document.get("system_doc_no"))

return (
frappe_docname,
frappe_document.get("system_doc_no"),
frappe_document.get("docstatus"),
)


def is_pdf_already_attached(pdf_path: str, frappe_docname: str, doctype_name: str, request_id: str) -> bool:
pdf_path = Path(pdf_path)
expected_filename = f"{str(request_id).strip()}.pdf"

print()
print("=" * 60)
print("CHECKING EXISTING FRAPPE ATTACHMENT")
print("=" * 60)
print("Doctype :", doctype_name)
print("Document :", frappe_docname)
print("Request ID :", request_id)
print("Local PDF :", pdf_path.name)
print("Expected PDF :", expected_filename)

encoded_file_doctype = quote("File", safe="")
url = f"{FRAPPE_URL.rstrip('/')}/api/resource/{encoded_file_doctype}"

params = {
"filters": json.dumps([
["File", "attached_to_doctype", "=", doctype_name],
["File", "attached_to_name", "=", frappe_docname],
["File", "file_name", "=", expected_filename],
]),
"fields": json.dumps(["name", "file_name", "file_url", "attached_to_doctype", "attached_to_name"]),
"limit_page_length": 10,
}

response = requests.get(url, headers=get_frappe_headers(), params=params, timeout=120)
print("Attachment check status:", response.status_code)

if not response.ok:
print("Attachment check response:", response.text)
raise RuntimeError("Failed to check existing Frappe attachment.")

files = response.json().get("data", [])

if files:
print()
print("=" * 60)
print("PDF ALREADY ATTACHED")
print("=" * 60)
for file_data in files:
print("File Name:", file_data.get("file_name"))
print("File URL:", file_data.get("file_url"))
return True

print("No existing attachment found.")
return False


def attach_pdf_to_frappe(pdf_path: str, frappe_docname: str, doctype_name: str, request_id: str):
print()
print("=" * 60)
print("ATTACHING PDF TO FRAPPE DOCUMENT")
print("=" * 60)

pdf_path = Path(pdf_path)

if not pdf_path.exists():
raise FileNotFoundError(f"PDF file does not exist:\n{pdf_path}")

if pdf_path.stat().st_size == 0:
raise RuntimeError(f"PDF file is empty:\n{pdf_path}")

if is_pdf_already_attached(pdf_path, frappe_docname, doctype_name, request_id):
print()
print("=" * 60)
print("SKIPPING PDF UPLOAD")
print("=" * 60)
print("Reason: PDF is already attached to this Frappe document.")

return {
"already_attached": True,
"file_name": pdf_path.name,
"doctype": doctype_name,
"docname": frappe_docname,
}

print("Document:", frappe_docname)
print("PDF:", pdf_path)
print("Size:", pdf_path.stat().st_size, "bytes")

upload_url = f"{FRAPPE_URL}/api/method/upload_file"
headers = get_frappe_headers()

with open(pdf_path, "rb") as pdf_file:
files = {"file": (pdf_path.name, pdf_file, "application/pdf")}
data = {"doctype": doctype_name, "docname": frappe_docname, "is_private": "0"}

print("Uploading PDF...")
response = requests.post(upload_url, headers=headers, files=files, data=data, timeout=120)

print("Upload status:", response.status_code)

if not response.ok:
print("Upload response:")
print(response.text)
raise RuntimeError("PDF upload/attachment failed.")

try:
result = response.json()
except ValueError:
raise RuntimeError("Frappe returned a non-JSON response after PDF upload.")

print()
print("Frappe attachment response:")
print(json.dumps(result, indent=4, ensure_ascii=False))

file_data = result.get("message", {})
file_url = file_data.get("file_url") if isinstance(file_data, dict) else None

print()
print("=" * 60)
print("PDF ATTACHED SUCCESSFULLY")
print("=" * 60)
print("Frappe Document:", frappe_docname)
print("Attached File:", pdf_path.name)
if file_url:
print("Frappe File URL:", file_url)

return result


def process_pdf_in_frappe(request_id: str, doctype_name: str, pdf_path: str):
print()
print()
print("#" * 60)
print("FRAPPE POST-PROCESSING")
print("#" * 60)

frappe_docname, system_doc_no, docstatus = find_frappe_document(request_id, doctype_name)

if docstatus == 2:
raise RuntimeError(f"{doctype_name} '{frappe_docname}' is cancelled.")

if docstatus == 1:
raise RuntimeError(
f"{doctype_name} '{frappe_docname}' is already submitted. "
"PDF was not attached/submitted again."
)

attachment_result = attach_pdf_to_frappe(pdf_path, frappe_docname, doctype_name, request_id)

print()
print("=" * 60)
print("PROCESS COMPLETED SUCCESSFULLY")
print("=" * 60)
print("Request ID :", request_id)
print(f"{doctype_name} :", frappe_docname)
print("System Doc No. :", system_doc_no)
print("PDF :", Path(pdf_path).name)
print("Attachment :", "Already Attached" if attachment_result.get("already_attached") else "Successfully attached")
print("=" * 60)

return {
"request_id": request_id,
"frappe_docname": frappe_docname,
"system_doc_no": system_doc_no,
"pdf_path": str(pdf_path),
"already_attached": attachment_result.get("already_attached", False),
}


if __name__ == "__main__":
main()
(6-6/10)