# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/strathmore.py # Rôle : Connecteur Strathmore — produits d'entretien, Saint-Hyacinthe — # ADP WFN + repli « détail seulement » quand l'API liste échoue # (maintenances nocturnes ADP) # Créé : 2026-08-25 Modifié : 2026-08-30 # ============================================================================= """Strathmore — ADP Workforce Now, avec repli de revalidation. Contexte (2026-08-29/30) : ADP Workforce Now a des fenêtres de maintenance nocturnes récurrentes (vendredi ET samedi soir ~22:00 → 02:00 ET) pendant lesquelles TOUT workforcenow.adp.com redirige vers sorry.adp.com — la liste job-requisitions renvoie alors du HTML, d'où le « Expecting value: line 1 column 1 » du sync de 00:31 (30/08). Comme le cycle complet met ~3 h entre deux passages de la source, un seul passage raté pendant la fenêtre suffit à dépasser le seuil stale de 4 h. D'où le repli ci-dessous (même pattern que msi_gestion.py / cultures_genv.py / groupe_nordik.py / h2o_innovation.py / mitsubishi_hc_capital.py / ciena.py) : si la pagination liste échoue en 5xx ou en non-JSON, on revalide une à une les offres actives connues en base via l'API détail job-requisitions/ (qui renvoie la réquisition complète : titre, date, lieux, description). Aucune donnée inventée — tout vient de la réponse live. Une offre en 404 est considérée retirée. Si AUCUNE offre n'est validable (maintenance totale : le détail redirige aussi vers sorry.adp.com), l'erreur d'origine est propagée pour que le sync reste un échec (inventaire préservé). Limite assumée : le repli ne découvre pas les NOUVELLES offres — le chemin liste normal reprend seul au cycle suivant la fin de la maintenance. """ from __future__ import annotations import requests from ..schema import JobPosting, clean_html from .adp import _API, ADPWorkforceNowConnector class StrathmoreConnector(ADPWorkforceNowConnector): source_id = "strathmore" EMPLOYER = "Strathmore" CID = "e6cc8038-8e63-4af3-af60-97ea91f1747c" CCID = "19000101_000001" def fetch(self) -> list[JobPosting]: try: return super().fetch() except (requests.RequestException, ValueError) as exc: resp = getattr(exc, "response", None) if resp is not None and resp.status_code < 500: raise # 4xx franc : pas une panne liste, ne pas masquer jobs = self._fetch_known_jobs() if jobs is None: raise # API détail cassée aussi : vraie panne totale return jobs def _known_ids(self) -> list[str]: """itemID (external_id) des offres actives de la source en base.""" from .. import db con = db.connect() try: rows = con.execute( "SELECT external_id FROM jobs WHERE source=? AND active=1", (self.source_id,)).fetchall() finally: con.close() return [str(eid) for (eid,) in rows if eid] def _fetch_known_jobs(self) -> list[JobPosting] | None: """Repli sans liste : chaque offre connue est revalidée via l'API détail (la réquisition complète, source de vérité de son existence). Aucune donnée inventée — tout vient de la réponse du moment.""" out: list[JobPosting] = [] errors = 0 for iid in self._known_ids(): try: r = self.get(f"{_API}/{iid}", params=self._params(), headers={"Accept": "application/json"}).json() except requests.HTTPError as exc: h = getattr(exc, "response", None) if h is not None and h.status_code == 404: continue # offre retirée chez l'employeur errors += 1 continue except (requests.RequestException, ValueError): errors += 1 # non-JSON (maintenance) ou réseau continue if not (r or {}).get("requisitionTitle"): errors += 1 # réponse inattendue : ne pas conclure au retrait continue locations = self._locations(r) if not self._keep(locations): continue qc = next((l for l in locations if l["prov"].upper() == "QC"), locations[0] if locations else {"city": self.FORCE_CITY, "postal": "", "label": self.FORCE_CITY}) job = JobPosting( source=self.source_id, external_id=iid, url=("https://workforcenow.adp.com/mascsr/default/mdf/" f"recruitment/recruitment.html?cid={self.CID}" f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"), employer=self.EMPLOYER, title=r.get("requisitionTitle") or "", city=qc["city"], postal_code=qc["postal"], location_label=qc["label"], date_posted=r.get("postDate") or None, ats=self.ats, ) pay = r.get("payGradeRange") or {} lo = (pay.get("minimumRate") or {}).get("amountValue") hi = (pay.get("maximumRate") or {}).get("amountValue") if lo: job.salary_min = float(lo) job.salary_max = float(hi) if hi else None job.salary_unit = "year" if float(lo) > 5000 else "hour" wl = (r.get("workLevelCode") or {}).get("shortName") if wl: job.details["employment_label"] = wl job.description = clean_html(r.get("requisitionDescription") or "") out.append(job) if not out and errors: return None # rien de validable : laisser le sync échouer return out