[ka6] fix connecteur msi_gestion: repli « détail seulement » pendant les maintenances nocturnes d'ADP WFN — cause de l'échec du 2026-08-29 22:51 : fenêtre de maintenance planifiée d'ADP Workforce Now du samedi soir (~22:00→02:00 ET, même événement systémique que cultures_genv a57f452, groupe_nordik a17b979, h2o_innovation 644a2d4 et mitsubishi_hc_capital 9d9e3f4), pendant laquelle workforcenow.adp.com redirige vers sorry.adp.com et la liste job-requisitions renvoie du HTML (« Expecting value: line 1 column 1 »). Un seul passage raté + cycle ~3 h suffit à dépasser le seuil stale de 4 h. Fix (pattern cultures_genv/groupe_nordik/h2o_innovation/mitsubishi_hc_capital/ciena) : si la liste échoue en 5xx/non-JSON, revalidation une à une des offres actives connues via l'API détail job-requisitions/<itemID> (réquisition complète live — aucune donnée inventée) ; 404 = offre retirée ; si RIEN n'est validable (maintenance totale) l'erreur d'origine est propagée (sync en échec, inventaire préservé). Le chemin liste normal reprend seul dès la fin de la maintenance. Tests réels (2026-08-30 00:50-00:51 ET, juste avant le retour de la fenêtre de maintenance) : run.py sync msi_gestion (chemin normal) → ok, found=5 (= médiane 5.0), 5 actives en base ; repli avec liste 502 simulée → 5 offres revalidées live (descriptions complètes). À 00:52 ET la maintenance totale a repris (liste ET détail 302 → sorry.adp.com) : le repli propage alors l'erreur comme conçu. pm2 restart job-ka-sync OK, site local 200, www.job-ka.com 200.
1 changed file +113 −4
modified
jobka/connectors/msi_gestion.py
+113 −4
@@ -3,11 +3,38 @@ | |||||
| 3 | # Auteur : Simon-Pierre Boucher | 3 | # Auteur : Simon-Pierre Boucher | ||
| 4 | # Contact : contact@spboucher.ai | 4 | # Contact : contact@spboucher.ai | ||
| 5 | # Fichier : jobka/connectors/msi_gestion.py | 5 | # Fichier : jobka/connectors/msi_gestion.py | ||
| 6 | −# Rôle : Connecteur MSI Gestion immobilière — adp (cid='47d4e792-26e5-471c-92ac-73c60131bab8', ccid='19000101_000001') | 6 | +# Rôle : Connecteur MSI Gestion immobilière — ADP WFN + repli « détail | ||
| 7 | −# [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-18] | 7 | +# seulement » quand l'API liste échoue (maintenances nocturnes ADP) | ||
| 8 | −# Créé : 2026-08-18 Modifié : 2026-08-18 | 8 | +# Créé : 2026-08-18 Modifié : 2026-08-30 | ||
| 9 | # ============================================================================= | 9 | # ============================================================================= | ||
| 10 | −from .adp import ADPWorkforceNowConnector | 10 | +"""MSI Gestion immobilière — ADP Workforce Now, avec repli de revalidation. | ||
| 11 | + | ||||
| 12 | +Contexte (2026-08-28/29) : ADP Workforce Now a des fenêtres de maintenance | ||||
| 13 | +nocturnes récurrentes (vendredi ET samedi soir ~22:00 → 02:00 ET) pendant | ||||
| 14 | +lesquelles TOUT workforcenow.adp.com redirige vers sorry.adp.com — la liste | ||||
| 15 | +job-requisitions renvoie alors du HTML, d'où le « Expecting value: line 1 | ||||
| 16 | +column 1 » du sync de 22:51 (29/08). Comme le cycle complet met ~3 h entre | ||||
| 17 | +deux passages de la source, un seul passage raté pendant la fenêtre suffit à | ||||
| 18 | +dépasser le seuil stale de 4 h. | ||||
| 19 | + | ||||
| 20 | +D'où le repli ci-dessous (même pattern que cultures_genv.py / | ||||
| 21 | +groupe_nordik.py / h2o_innovation.py / mitsubishi_hc_capital.py / ciena.py) : | ||||
| 22 | +si la pagination liste échoue en 5xx ou en non-JSON, on revalide une à une | ||||
| 23 | +les offres actives connues en base via l'API détail | ||||
| 24 | +job-requisitions/<itemID> (qui renvoie la réquisition complète : titre, date, | ||||
| 25 | +lieux, description). Aucune donnée inventée — tout vient de la réponse live. | ||||
| 26 | +Une offre en 404 est considérée retirée. Si AUCUNE offre n'est validable | ||||
| 27 | +(maintenance totale : le détail redirige aussi vers sorry.adp.com), l'erreur | ||||
| 28 | +d'origine est propagée pour que le sync reste un échec (inventaire préservé). | ||||
| 29 | +Limite assumée : le repli ne découvre pas les NOUVELLES offres — le chemin | ||||
| 30 | +liste normal reprend seul au cycle suivant la fin de la maintenance. | ||||
| 31 | +""" | ||||
| 32 | +from __future__ import annotations | ||||
| 33 | + | ||||
| 34 | +import requests | ||||
| 35 | + | ||||
| 36 | +from ..schema import JobPosting, clean_html | ||||
| 37 | +from .adp import _API, ADPWorkforceNowConnector | ||||
| 11 | 38 | ||||
| 12 | 39 | ||||
| 13 | class MsiGestionConnector(ADPWorkforceNowConnector): | 40 | class MsiGestionConnector(ADPWorkforceNowConnector): | ||
@@ -15,3 +42,85 @@ class MsiGestionConnector(ADPWorkforceNowConnector): | |||||
| 15 | EMPLOYER = 'MSI Gestion immobilière' | 42 | EMPLOYER = 'MSI Gestion immobilière' | ||
| 16 | CID = '47d4e792-26e5-471c-92ac-73c60131bab8' | 43 | CID = '47d4e792-26e5-471c-92ac-73c60131bab8' | ||
| 17 | CCID = '19000101_000001' | 44 | CCID = '19000101_000001' | ||
| 45 | + | ||||
| 46 | + def fetch(self) -> list[JobPosting]: | ||||
| 47 | + try: | ||||
| 48 | + return super().fetch() | ||||
| 49 | + except (requests.RequestException, ValueError) as exc: | ||||
| 50 | + resp = getattr(exc, "response", None) | ||||
| 51 | + if resp is not None and resp.status_code < 500: | ||||
| 52 | + raise # 4xx franc : pas une panne liste, ne pas masquer | ||||
| 53 | + jobs = self._fetch_known_jobs() | ||||
| 54 | + if jobs is None: | ||||
| 55 | + raise # API détail cassée aussi : vraie panne totale | ||||
| 56 | + return jobs | ||||
| 57 | + | ||||
| 58 | + def _known_ids(self) -> list[str]: | ||||
| 59 | + """itemID (external_id) des offres actives de la source en base.""" | ||||
| 60 | + from .. import db | ||||
| 61 | + con = db.connect() | ||||
| 62 | + try: | ||||
| 63 | + rows = con.execute( | ||||
| 64 | + "SELECT external_id FROM jobs WHERE source=? AND active=1", | ||||
| 65 | + (self.source_id,)).fetchall() | ||||
| 66 | + finally: | ||||
| 67 | + con.close() | ||||
| 68 | + return [str(eid) for (eid,) in rows if eid] | ||||
| 69 | + | ||||
| 70 | + def _fetch_known_jobs(self) -> list[JobPosting] | None: | ||||
| 71 | + """Repli sans liste : chaque offre connue est revalidée via l'API | ||||
| 72 | + détail (la réquisition complète, source de vérité de son existence). | ||||
| 73 | + Aucune donnée inventée — tout vient de la réponse du moment.""" | ||||
| 74 | + out: list[JobPosting] = [] | ||||
| 75 | + errors = 0 | ||||
| 76 | + for iid in self._known_ids(): | ||||
| 77 | + try: | ||||
| 78 | + r = self.get(f"{_API}/{iid}", params=self._params(), | ||||
| 79 | + headers={"Accept": "application/json"}).json() | ||||
| 80 | + except requests.HTTPError as exc: | ||||
| 81 | + h = getattr(exc, "response", None) | ||||
| 82 | + if h is not None and h.status_code == 404: | ||||
| 83 | + continue # offre retirée chez l'employeur | ||||
| 84 | + errors += 1 | ||||
| 85 | + continue | ||||
| 86 | + except (requests.RequestException, ValueError): | ||||
| 87 | + errors += 1 # non-JSON (maintenance) ou réseau | ||||
| 88 | + continue | ||||
| 89 | + if not (r or {}).get("requisitionTitle"): | ||||
| 90 | + errors += 1 # réponse inattendue : ne pas conclure au retrait | ||||
| 91 | + continue | ||||
| 92 | + locations = self._locations(r) | ||||
| 93 | + if not self._keep(locations): | ||||
| 94 | + continue | ||||
| 95 | + qc = next((l for l in locations | ||||
| 96 | + if l["prov"].upper() == "QC"), | ||||
| 97 | + locations[0] if locations else | ||||
| 98 | + {"city": self.FORCE_CITY, "postal": "", | ||||
| 99 | + "label": self.FORCE_CITY}) | ||||
| 100 | + job = JobPosting( | ||||
| 101 | + source=self.source_id, external_id=iid, | ||||
| 102 | + url=("https://workforcenow.adp.com/mascsr/default/mdf/" | ||||
| 103 | + f"recruitment/recruitment.html?cid={self.CID}" | ||||
| 104 | + f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"), | ||||
| 105 | + employer=self.EMPLOYER, | ||||
| 106 | + title=r.get("requisitionTitle") or "", | ||||
| 107 | + city=qc["city"], postal_code=qc["postal"], | ||||
| 108 | + location_label=qc["label"], | ||||
| 109 | + date_posted=r.get("postDate") or None, | ||||
| 110 | + ats=self.ats, | ||||
| 111 | + ) | ||||
| 112 | + pay = r.get("payGradeRange") or {} | ||||
| 113 | + lo = (pay.get("minimumRate") or {}).get("amountValue") | ||||
| 114 | + hi = (pay.get("maximumRate") or {}).get("amountValue") | ||||
| 115 | + if lo: | ||||
| 116 | + job.salary_min = float(lo) | ||||
| 117 | + job.salary_max = float(hi) if hi else None | ||||
| 118 | + job.salary_unit = "year" if float(lo) > 5000 else "hour" | ||||
| 119 | + wl = (r.get("workLevelCode") or {}).get("shortName") | ||||
| 120 | + if wl: | ||||
| 121 | + job.details["employment_label"] = wl | ||||
| 122 | + job.description = clean_html(r.get("requisitionDescription") or "") | ||||
| 123 | + out.append(job) | ||||
| 124 | + if not out and errors: | ||||
| 125 | + return None # rien de validable : laisser le sync échouer | ||||
| 126 | + return out | ||||
| 18 | 127 | ||||