css
/* sys_data_model — page « Modèle de données ». Tout est confiné sous .sdm-page. */
.sdm-page {
--sdm-accent: var(--primary, #2f6fed);
--sdm-accent-soft: #eef3ff;
--sdm-edge: #e3e8ef;
--sdm-background: #fff;
--sdm-background-page: #f6f8fb;
--sdm-ink: #1f2933;
--sdm-soft: #66768a;
--sdm-ok: #1e7e34;
--sdm-ok-soft: #e8f5ec;
--sdm-pending: #9a6700;
--sdm-pending-soft: #fff6e0;
--sdm-nothing: #66768a;
--sdm-nothing-soft: #f0f3f6;
--sdm-alert: #b42318;
--sdm-alert-soft: #fdeceb;
--sdm-radius: 10px;
--sdm-shadow: 0 1px 2px rgba(16, 24, 40, .05), 0 1px 3px rgba(16, 24, 40, .06);
--sdm-shadow-high: 0 12px 32px rgba(16, 24, 40, .18);
--sdm-mono: SFMono-Regular, Consolas, "Liberation Mono", monospace;
max-width: 1320px;
color: var(--sdm-ink);
font-size: 14px;
}
@supports (background: color-mix(in srgb, red 10%, white)) {
.sdm-page { --sdm-accent-soft: color-mix(in srgb, var(--sdm-accent) 9%, white); }
}
/* ── Icônes ─────────────────────────────────────────────────────────────── */
.sdm-page .sdm-icon { display: inline-flex; width: 1em; height: 1em; flex: none; }
.sdm-page .sdm-icon svg { width: 100%; height: 100%; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.sdm-page .btn { display: inline-flex; align-items: center; gap: 6px; }
.sdm-page .btn .sdm-icon { font-size: 14px; }
/* ── En-tête ────────────────────────────────────────────────────────────── */
.sdm-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.sdm-title { margin: 0 0 4px; font-weight: 700; letter-spacing: -.01em; }
.sdm-subtitle { margin: 0; color: var(--sdm-soft); }
.sdm-btn-ghost { color: var(--sdm-soft); background: transparent; border: 1px solid var(--sdm-edge); }
.sdm-btn-ghost:hover { color: var(--sdm-ink); background: var(--sdm-background); }
/* ── Parcours (étapes + chiffres) ───────────────────────────────────────── */
.sdm-path {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
list-style: none;
padding: 0;
margin: 0 0 20px;
counter-reset: sdm-step;
}
.sdm-step {
position: relative;
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 12px 14px 12px 50px;
text-align: left;
background: var(--sdm-background);
border: 1px solid var(--sdm-edge);
border-radius: var(--sdm-radius);
box-shadow: var(--sdm-shadow);
cursor: pointer;
transition: border-color .15s, box-shadow .15s, transform .15s;
}
.sdm-step:hover { border-color: var(--sdm-accent); transform: translateY(-1px); }
.sdm-step:focus-visible { outline: 2px solid var(--sdm-accent); outline-offset: 2px; }
.sdm-step-circle {
position: absolute;
left: 14px;
top: 14px;
display: grid;
place-items: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
background: var(--sdm-nothing-soft);
color: var(--sdm-nothing);
}
.sdm-step-circle .sdm-icon { font-size: 14px; }
.sdm-step-name { font-size: 12px; font-weight: 600; color: var(--sdm-soft); text-transform: uppercase; letter-spacing: .04em; }
.sdm-step-figure { font-size: 22px; font-weight: 700; line-height: 1.15; font-variant-numeric: tabular-nums; }
.sdm-step-text { font-size: 12px; color: var(--sdm-soft); }
.sdm-step-ok .sdm-step-circle { background: var(--sdm-ok-soft); color: var(--sdm-ok); }
.sdm-step-pending .sdm-step-circle { background: var(--sdm-pending-soft); color: var(--sdm-pending); }
.sdm-step-pending .sdm-step-text { color: var(--sdm-pending); font-weight: 600; }
.sdm-step-alert { border-color: var(--sdm-alert); }
.sdm-step-alert .sdm-step-circle { background: var(--sdm-alert); color: #fff; }
.sdm-step-alert .sdm-step-text { color: var(--sdm-alert); font-weight: 700; }
.sdm-step-loading .sdm-step-figure { color: transparent; }
.sdm-step-loading .sdm-step-figure::after { content: ""; display: block; width: 40px; height: 20px; border-radius: 4px; background: var(--sdm-nothing-soft); animation: sdm-pulse 1.2s ease-in-out infinite; }
/* ── Onglets ────────────────────────────────────────────────────────────── */
.sdm-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
border-bottom: 1px solid var(--sdm-edge);
margin-bottom: 16px;
scrollbar-width: thin;
}
.sdm-tab {
position: relative;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: none;
border: 0;
color: var(--sdm-soft);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
border-radius: 8px 8px 0 0;
transition: color .15s, background .15s;
}
.sdm-tab::after {
content: "";
position: absolute;
left: 10px;
right: 10px;
bottom: -1px;
height: 2px;
border-radius: 2px;
background: var(--sdm-accent);
transform: scaleX(0);
transition: transform .2s ease;
}
.sdm-tab:hover { color: var(--sdm-ink); background: var(--sdm-background-page); }
.sdm-tab.active { color: var(--sdm-accent); }
.sdm-tab.active::after { transform: scaleX(1); }
.sdm-tab:focus-visible { outline: 2px solid var(--sdm-accent); outline-offset: -2px; }
.sdm-tab > .sdm-icon { font-size: 16px; }
.sdm-pill {
display: inline-block;
min-width: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--sdm-alert);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 20px;
text-align: center;
}
.sdm-panel { animation: sdm-appear .2s ease; }
/* ── Barres, champs, cartes ─────────────────────────────────────────────── */
.sdm-bar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 14px; }
.sdm-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
.sdm-actions select.form-control { width: auto; min-width: 140px; }
.sdm-filters { display: flex; flex-wrap: wrap; gap: 8px; }
.sdm-filters select.form-control { width: auto; min-width: 140px; }
.sdm-search { position: relative; margin: 0; }
.sdm-search .sdm-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: var(--sdm-soft); font-size: 14px; pointer-events: none; }
.sdm-search input { padding-left: 30px; min-width: 240px; }
.sdm-page .form-control { border-color: var(--sdm-edge); border-radius: 8px; }
.sdm-page .form-control:focus { border-color: var(--sdm-accent); box-shadow: 0 0 0 3px var(--sdm-accent-soft); }
.sdm-page .btn-sm { border-radius: 8px; padding: 5px 12px; }
.sdm-field { display: flex; flex-direction: column; gap: 4px; margin: 0; min-width: 160px; flex: 1 1 160px; }
.sdm-field > span { font-size: 12px; font-weight: 600; color: var(--sdm-soft); }
.sdm-field-short { flex: 0 1 110px; min-width: 100px; }
.sdm-row-fields { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; }
.sdm-row-fields .sdm-actions { margin-left: 0; }
/* Test API : styles de Standards (std-api-*, settings-standards.css), rien de propre au module (TH.10). */
#sdmApiCall:empty { display: none; }
.sdm-card {
background: var(--sdm-background);
border: 1px solid var(--sdm-edge);
border-radius: var(--sdm-radius);
box-shadow: var(--sdm-shadow);
padding: 16px;
margin-bottom: 16px;
}
.sdm-card-title { font-size: 15px; font-weight: 700; margin: 0 0 12px; }
.sdm-card-table { padding: 0; overflow: hidden; }
.sdm-section-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--sdm-soft); margin: 24px 0 10px; }
.sdm-help { color: var(--sdm-soft); font-size: 13px; }
.sdm-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.sdm-code { font-family: var(--sdm-mono); font-size: .92em; }
.sdm-inset {
border: 1px solid var(--sdm-edge);
border-left: 4px solid var(--sdm-accent);
border-radius: 8px;
padding: 12px 16px;
margin-bottom: 14px;
background: var(--sdm-accent-soft);
font-size: 13px;
}
.sdm-inset ul { padding-left: 18px; margin: 6px 0 0; }
/* ── Tableaux ───────────────────────────────────────────────────────────── */
.sdm-table { font-size: 13px; margin: 0; }
.sdm-table thead th {
border-top: 0;
border-bottom: 1px solid var(--sdm-edge);
background: var(--sdm-background-page);
color: var(--sdm-soft);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
white-space: nowrap;
}
.sdm-table td { vertical-align: middle; border-top: 1px solid var(--sdm-edge); }
.sdm-table tbody tr { transition: background .1s; }
.sdm-table tbody tr:hover td { background: #fafbfd; }
.sdm-table input.form-control, .sdm-table select.form-control { min-width: 90px; }
.sdm-table tr.sdm-row-pending td { background: var(--sdm-alert-soft); }
.sdm-table tr.sdm-row-pending td:first-child { box-shadow: inset 3px 0 0 var(--sdm-alert); }
.sdm-table tr.sdm-inactive td { color: var(--sdm-soft); }
.sdm-number { text-align: right; font-variant-numeric: tabular-nums; }
/* ── États (toujours un mot ; migration en attente : rouge, plan § 21) ──── */
.sdm-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 600;
padding: 2px 10px 2px 8px;
border-radius: 999px;
white-space: nowrap;
background: var(--sdm-nothing-soft);
color: var(--sdm-nothing);
}
.sdm-state::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.sdm-state-creee, .sdm-state-integre, .sdm-state-valide, .sdm-state-mg-jouee, .sdm-state-dc-confirme, .sdm-state-dc-reconduit { color: var(--sdm-ok); background: var(--sdm-ok-soft); }
.sdm-state-analyse, .sdm-state-dc-propose { color: var(--sdm-pending); background: var(--sdm-pending-soft); }
.sdm-state-a_engendrer, .sdm-state-refuse, .sdm-state-dc-caduc { color: var(--sdm-nothing); background: var(--sdm-nothing-soft); }
.sdm-state-engendree, .sdm-state-mg-a_jouer, .sdm-state-dc-rejete { color: var(--sdm-alert); background: var(--sdm-alert-soft); font-weight: 700; }
.sdm-dot {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--sdm-edge);
color: var(--sdm-soft);
background: var(--sdm-background);
white-space: nowrap;
}
.sdm-dot-accent { color: var(--sdm-accent); border-color: transparent; background: var(--sdm-accent-soft); }
/* ── Arbre des entités ──────────────────────────────────────────────────── */
.sdm-tree { display: grid; gap: 16px; }
.sdm-group { padding: 0; overflow: hidden; margin: 0; }
.sdm-group-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--sdm-background-page);
border-bottom: 1px solid var(--sdm-edge);
}
.sdm-group-name { font-size: 15px; font-weight: 700; margin: 0; }
.sdm-group-head .sdm-actions { margin-left: auto; }
.sdm-nodes { list-style: none; margin: 0; padding: 8px 12px; }
.sdm-nodes .sdm-nodes { padding: 0 0 0 22px; margin-left: 14px; border-left: 1px dashed var(--sdm-edge); }
.sdm-node {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
width: 100%;
margin: 2px 0;
padding: 9px 12px;
text-align: left;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: background .12s, border-color .12s;
}
.sdm-node:hover { background: var(--sdm-background-page); border-color: var(--sdm-edge); }
.sdm-node:focus-visible { outline: 2px solid var(--sdm-accent); outline-offset: 1px; }
.sdm-node.sdm-inactive { opacity: .6; }
.sdm-node-icon { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 7px; background: var(--sdm-accent-soft); color: var(--sdm-accent); font-size: 14px; flex: none; }
.sdm-node-code { font-family: var(--sdm-mono); font-weight: 700; }
.sdm-node-label { color: var(--sdm-soft); flex: 1 1 160px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sdm-node-meta { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-left: auto; }
/* ── Tiroirs latéraux ───────────────────────────────────────────────────── */
.sdm-overlay {
position: fixed;
inset: 0;
z-index: 1040;
background: rgba(16, 24, 40, .35);
opacity: 0;
visibility: hidden;
transition: opacity .2s, visibility .2s;
}
.sdm-overlay.ouvert { opacity: 1; visibility: visible; }
.sdm-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
z-index: 1050;
display: flex;
flex-direction: column;
width: min(760px, 100vw);
background: var(--sdm-background);
box-shadow: var(--sdm-shadow-high);
transform: translateX(100%);
visibility: hidden;
transition: transform .25s ease, visibility .25s;
}
.sdm-drawer.ouvert { transform: none; visibility: visible; }
.sdm-drawer-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 18px 20px; border-bottom: 1px solid var(--sdm-edge); }
.sdm-drawer-head h4 { margin: 2px 0 0; font-size: 18px; font-weight: 700; }
.sdm-kicker { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--sdm-soft); }
.sdm-drawer-body { flex: 1; overflow-y: auto; padding: 20px; }
.sdm-drawer-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid var(--sdm-edge); background: var(--sdm-background-page); }
.sdm-close { display: grid; place-items: center; width: 32px; height: 32px; border: 0; border-radius: 8px; background: transparent; color: var(--sdm-soft); font-size: 18px; cursor: pointer; }
.sdm-close:hover { background: var(--sdm-nothing-soft); color: var(--sdm-ink); }
body.sdm-no-scroll { overflow: hidden; }
.sdm-grid-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; margin-bottom: 24px; }
.sdm-grid-form .sdm-field { min-width: 0; }
.sdm-toggle { grid-column: 1 / -1; }
.sdm-block-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; padding-top: 18px; border-top: 1px solid var(--sdm-edge); }
.sdm-block-head h5 { margin: 0; font-size: 15px; font-weight: 700; }
.sdm-table-attributes td { padding: 6px; }
.sdm-table-attributes .custom-switch { padding-left: 2.5rem; min-height: 1.25rem; }
.sdm-table-attributes .btn-icone { padding: 4px 6px; color: var(--sdm-soft); }
.sdm-table-attributes .btn-icone:hover { color: var(--sdm-ink); }
.sdm-table-attributes .btn-icone.sdm-danger:hover { color: var(--sdm-alert); }
.sdm-role:not(.sdm-role-aucun) { color: var(--sdm-accent); background-color: var(--sdm-accent-soft); font-weight: 600; }
/* ── Imports ────────────────────────────────────────────────────────────── */
/* Même écran que l'onglet « Intégrations » de Standards : menu à gauche, fiche à droite. */
.sdm-integ { display: flex; align-items: flex-start; gap: 20px; }
.sdm-integ-menu {
flex: 0 0 clamp(240px, 24%, 340px); display: flex; flex-direction: column; gap: 12px; padding: 14px;
background: var(--sdm-background); border: 1px solid var(--sdm-edge); border-radius: var(--sdm-radius); box-shadow: var(--sdm-shadow);
}
.sdm-integ-tree { display: flex; flex-direction: column; gap: 14px; }
.sdm-integ-section-title {
display: flex; align-items: center; gap: 8px; width: 100%; margin: 0 0 4px; padding: 5px 6px;
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--sdm-soft);
background: none; border: 0; border-radius: 6px; cursor: pointer; text-align: left;
}
.sdm-integ-section-title:hover { background: var(--sdm-background-page); color: var(--sdm-ink); }
.sdm-integ-section-title:focus-visible, .sdm-integ-item:focus-visible { outline: 2px solid var(--sdm-accent); outline-offset: 2px; }
.sdm-integ-section-name { flex: 1 1 auto; min-width: 0; }
.sdm-integ-chevron {
flex: 0 0 8px; width: 8px; height: 8px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-45deg); transition: transform .15s ease;
}
.sdm-integ-section.ouverte .sdm-integ-chevron { transform: rotate(45deg); }
.sdm-integ-count {
flex: 0 0 auto; padding: 0 7px; border-radius: 10px; font-size: 11px; font-weight: 700; letter-spacing: 0;
color: var(--sdm-soft); background: var(--sdm-nothing-soft);
}
.sdm-integ-section.ouverte .sdm-integ-count { color: #fff; background: var(--sdm-accent); }
.sdm-integ-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.sdm-integ-item {
display: flex; flex-direction: column; gap: 1px; width: 100%; padding: 6px 8px; text-align: left;
background: none; border: 1px solid transparent; border-radius: 6px; cursor: pointer;
}
.sdm-integ-item:hover { background: var(--sdm-background-page); }
.sdm-integ-item.courant { background: var(--sdm-accent-soft); border-color: var(--sdm-accent); }
.sdm-integ-code { font-family: var(--sdm-mono); font-size: 12px; font-weight: 700; color: var(--sdm-accent); }
.sdm-integ-name { font-size: 13px; color: var(--sdm-ink); }
.sdm-integ-sheet {
flex: 1 1 auto; min-width: 0; padding: 18px 20px;
background: var(--sdm-background); border: 1px solid var(--sdm-edge); border-radius: var(--sdm-radius); box-shadow: var(--sdm-shadow);
}
.sdm-integ-empty { margin: 0; color: var(--sdm-soft); font-style: italic; }
.sdm-integ-head { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 14px; }
.sdm-integ-head .btn { margin-left: auto; }
.sdm-integ-title { margin: 0; font-size: 18px; font-weight: 600; }
.sdm-integ-title .sdm-integ-code { font-size: 18px; }
.sdm-integ-panel { margin-top: 18px; border-top: 1px solid var(--sdm-edge); }
.sdm-integ-panel > summary {
display: flex; align-items: center; gap: 10px; padding: 14px 2px; cursor: pointer; list-style: none;
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--sdm-soft);
}
.sdm-integ-panel > summary::-webkit-details-marker { display: none; }
.sdm-integ-panel > summary::before {
content: ''; flex: 0 0 auto; width: 7px; height: 7px; margin-left: 2px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform .15s ease;
}
.sdm-integ-panel[open] > summary::before { transform: rotate(45deg); }
.sdm-integ-panel > summary:hover { color: var(--sdm-accent); }
.sdm-integ-panel > summary:focus-visible { outline: 2px solid var(--sdm-accent); outline-offset: 2px; }
.sdm-integ-panel .sdm-card { margin-bottom: 0; box-shadow: none; }
.sdm-integ-active {
display: inline-flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin: 0; padding: 6px 12px;
border-radius: 6px; background: var(--sdm-background); font-size: 13px;
}
.sdm-integ-active-title { font-weight: 600; color: var(--sdm-soft); }
.sdm-integ-active strong { font-size: 14px; font-variant-numeric: tabular-nums; }
.sdm-integ-active-nb { color: var(--sdm-soft); font-variant-numeric: tabular-nums; }
.sdm-integ-active-nb::before { content: '·'; margin-right: 8px; color: var(--sdm-edge); }
.sdm-integ-run { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 14px; margin-top: 14px; }
/* La phrase se cale sur la hauteur du bouton, pas sur celle du champ et de son titre. */
.sdm-integ-run .sdm-help { display: flex; align-items: center; min-height: 31px; margin: 0; }
.sdm-integ-sheet .sdm-report { margin-top: 18px; }
@media (max-width: 992px) {
.sdm-integ { flex-direction: column; }
.sdm-integ-menu { flex: 1 1 auto; width: 100%; }
}
@media (prefers-reduced-motion: reduce) { .sdm-integ-chevron, .sdm-integ-panel > summary::before { transition: none; } }
.sdm-dropzones { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 14px; }
.sdm-dropzone {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
margin: 0;
padding: 20px 12px;
text-align: center;
border: 2px dashed var(--sdm-edge);
border-radius: var(--sdm-radius);
background: var(--sdm-background-page);
cursor: pointer;
transition: border-color .15s, background .15s;
}
.sdm-dropzone input[type=file] { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.sdm-dropzone:hover, .sdm-dropzone.survol { border-color: var(--sdm-accent); background: var(--sdm-accent-soft); }
.sdm-dropzone:focus-within { outline: 2px solid var(--sdm-accent); outline-offset: 2px; }
.sdm-dropzone-icon { font-size: 26px; color: var(--sdm-soft); }
.sdm-dropzone-lang { font-size: 11px; font-weight: 700; letter-spacing: .06em; padding: 1px 8px; border-radius: 999px; background: var(--sdm-background); border: 1px solid var(--sdm-edge); }
.sdm-dropzone-name { font-size: 12px; color: var(--sdm-soft); word-break: break-all; }
.sdm-dropzone.charge { border-style: solid; border-color: var(--sdm-ok); background: var(--sdm-ok-soft); }
.sdm-dropzone.charge .sdm-dropzone-icon, .sdm-dropzone.charge .sdm-dropzone-name { color: var(--sdm-ok); font-weight: 600; }
.sdm-backfill-result { margin-top: 12px; font-size: 13px; }
.sdm-backfill-result ul { padding-left: 18px; margin: 6px 0 0; }
.sdm-report { margin-top: 16px; border-top: 4px solid var(--sdm-accent); }
.sdm-report-head { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 10px; }
.sdm-report-titles { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1 1 auto; }
.sdm-report-kicker { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--sdm-soft); }
.sdm-report-title { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; font-size: 18px; font-weight: 600; color: var(--sdm-ink); }
.sdm-report-pill { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 999px; background: var(--sdm-accent-soft); color: var(--sdm-accent); font-size: 12px; font-weight: 600; }
.sdm-report-head .sdm-close { flex: none; margin-left: auto; }
.sdm-report-sub { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--sdm-soft); margin: 16px 0 8px; }
.sdm-figures { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; margin-bottom: 8px; }
.sdm-figure { display: flex; flex-direction: column; padding: 10px 12px; border: 1px solid var(--sdm-edge); border-radius: 8px; background: var(--sdm-background-page); }
.sdm-figure b { font-size: 22px; line-height: 1.2; font-variant-numeric: tabular-nums; }
.sdm-figure span { color: var(--sdm-soft); font-size: 12px; }
.sdm-figure.sdm-figure-alert { border-color: var(--sdm-alert); background: var(--sdm-alert-soft); }
.sdm-figure.sdm-figure-alert b { color: var(--sdm-alert); }
.sdm-report details { margin: 6px 0; border: 1px solid var(--sdm-edge); border-radius: 8px; background: var(--sdm-background); }
.sdm-report summary { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; font-weight: 600; list-style: none; }
.sdm-report summary::-webkit-details-marker { display: none; }
.sdm-report summary::before { content: "›"; display: inline-block; font-size: 16px; line-height: 1; transition: transform .15s; color: var(--sdm-soft); }
.sdm-report details[open] summary::before { transform: rotate(90deg); }
.sdm-report details.sdm-alert { border-color: #f3c1bc; background: var(--sdm-alert-soft); }
.sdm-report details.sdm-alert summary { color: var(--sdm-alert); }
.sdm-report pre {
max-height: 260px;
overflow: auto;
margin: 0;
padding: 10px 12px;
border-top: 1px solid var(--sdm-edge);
background: var(--sdm-background);
font-size: 12px;
white-space: pre-wrap;
}
.sdm-report-decision { display: flex; flex-direction: column; gap: 12px; margin-top: 18px; padding: 14px 16px; border: 1px solid var(--sdm-edge); border-radius: 8px; background: var(--sdm-background-page); }
.sdm-report-decision-text { margin: 0; font-size: 13px; color: var(--sdm-soft); }
.sdm-report-decision-buttons { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.sdm-report-decision-buttons .btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; font-weight: 600; }
.sdm-report-validate { order: 1; }
@media (max-width: 575.98px) { .sdm-report-decision-buttons .btn { flex: 1 1 100%; justify-content: center; } }
/* ── Liens ──────────────────────────────────────────────────────────────── */
.sdm-collapsible { padding: 0; }
.sdm-collapsible > summary { display: flex; align-items: center; gap: 8px; padding: 12px 16px; font-weight: 700; cursor: pointer; list-style: none; color: var(--sdm-accent); }
.sdm-collapsible > summary::-webkit-details-marker { display: none; }
.sdm-collapsible[open] > summary { border-bottom: 1px solid var(--sdm-edge); }
.sdm-collapsible > form { padding: 16px; }
.sdm-link { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.sdm-link-end { font-family: var(--sdm-mono); font-weight: 700; padding: 2px 8px; border-radius: 6px; background: var(--sdm-background-page); border: 1px solid var(--sdm-edge); }
.sdm-link-arrow { display: inline-flex; align-items: center; gap: 4px; color: var(--sdm-soft); font-family: var(--sdm-mono); font-size: 12px; }
.sdm-link-arrow .sdm-icon { font-size: 14px; }
.sdm-nature-natif { color: var(--sdm-accent); background: var(--sdm-accent-soft); border-color: transparent; }
.sdm-nature-declare { color: #6941c6; background: #f4f0ff; border-color: transparent; }
/* ── À confirmer (bloc en tête de l'onglet Liens) ───────────────────────── */
.sdm-confirm { margin-bottom: 16px; }
.sdm-confirm-title { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 15px; font-weight: 600; }
.sdm-decisions { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 14px; }
.sdm-decision { display: flex; flex-direction: column; gap: 12px; margin: 0; border-left: 4px solid var(--sdm-pending); }
.sdm-decision-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.sdm-decision-head strong { margin-right: auto; }
.sdm-decision-why { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--sdm-pending-soft); color: var(--sdm-pending); font-size: 13px; }
.sdm-decision .sdm-footer { margin-top: 0; }
/* ── États vides et chargement ──────────────────────────────────────────── */
.sdm-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 40px 16px;
text-align: center;
color: var(--sdm-soft);
border: 1px dashed var(--sdm-edge);
border-radius: var(--sdm-radius);
background: var(--sdm-background);
grid-column: 1 / -1;
}
.sdm-empty > .sdm-icon { display: grid; place-items: center; width: 52px; height: 52px; padding: 13px; margin-bottom: 4px; border-radius: 50%; background: var(--sdm-accent-soft); color: var(--sdm-accent); }
.sdm-empty strong { color: var(--sdm-ink); font-size: 15px; }
.sdm-empty p { margin: 0; max-width: 460px; font-size: 13px; }
.sdm-skeleton { display: grid; gap: 10px; padding: 16px; border: 1px solid var(--sdm-edge); border-radius: var(--sdm-radius); background: var(--sdm-background); }
.sdm-skeleton span, .sdm-row-skeleton span {
display: block;
height: 14px;
border-radius: 6px;
background: linear-gradient(90deg, var(--sdm-nothing-soft) 25%, #f8fafc 50%, var(--sdm-nothing-soft) 75%);
background-size: 200% 100%;
animation: sdm-shine 1.3s linear infinite;
}
.sdm-skeleton span:nth-child(odd) { width: 70%; }
.sdm-skeleton span:nth-child(3n) { width: 45%; }
/* Bouton occupé (occupe()) : l'icône cède la place à un spinner. */
.sdm-running { cursor: progress; }
.sdm-running > .sdm-icon { display: none; }
.sdm-running::before {
content: ''; display: inline-block; flex: none;
width: .9em; height: .9em; margin-right: .35em; vertical-align: -.125em;
border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
animation: sdm-spin .7s linear infinite;
}
@keyframes sdm-spin { to { transform: rotate(360deg); } }
@keyframes sdm-shine { to { background-position: -200% 0; } }
@keyframes sdm-pulse { 50% { opacity: .5; } }
@keyframes sdm-appear { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) {
.sdm-page *, .sdm-page *::before, .sdm-page *::after { animation: none !important; transition: none !important; }
/* Le spinner reste utile : il tourne lentement plutôt que de disparaître. */
.sdm-page .sdm-running::before { animation: sdm-spin 2s linear infinite !important; }
}
/* ── Petits écrans ──────────────────────────────────────────────────────── */
@media (max-width: 1100px) {
.sdm-path { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 640px) {
.sdm-path { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.sdm-actions { margin-left: 0; }
.sdm-field, .sdm-field-short { min-width: 100%; }
.sdm-search, .sdm-search input { width: 100%; min-width: 0; }
.sdm-filters { width: 100%; }
.sdm-filters select.form-control { flex: 1; }
.sdm-dropzones, .sdm-grid-form { grid-template-columns: minmax(0, 1fr); }
.sdm-decisions { grid-template-columns: minmax(0, 1fr); }
.sdm-node-meta { margin-left: 36px; }
}
/* ── Onglet Données ─────────────────────────────────────────────────────── */
/* Le gabarit vient des classes std-* de Standards (TH.16) ; ne restent ici que
ce que Standards écrit en ligne et ce que SDM ajoute (enfants, filtre parent). */
.sdm-page .std-data-viewer .sdm-icon { width: 14px; height: 14px; vertical-align: -2px; }
.sdm-page .std-filter-btn .sdm-icon { margin-right: 4px; }
.std-data-table td.sdm-data-empty { text-align: center; padding: 40px; color: #6c757d; }
.std-data-table td.sdm-data-empty .sdm-icon { display: block; width: 32px; height: 32px; margin: 0 auto 10px; }
.sdm-pages-ellipsis { padding: 0 5px; }
.sdm-data-filter-parent { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 6px 12px; border-radius: 4px; background: #f8f9fa; border: 1px dashed #dee2e6; font-size: 13px; }
.sdm-data-label { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Ligne dépliée et ses enfants : même teinte bleue, filet à gauche, pour voir d'un coup d'œil qui est à qui. */
.std-data-table tr.sdm-row-open td { background: #eef4fc; border-bottom-color: #cfe0f5; }
.std-data-table tr.sdm-row-open td:first-child { box-shadow: inset 3px 0 0 #0062cc; }
.std-data-table tr.sdm-row-child td { background: #f6f9fd; padding-top: 8px; padding-bottom: 8px; border-bottom-color: #e3ecf7; font-size: 13px; }
.std-data-table tr.sdm-row-child td:first-child { box-shadow: inset 3px 0 0 #8db4e8; }
.std-data-table tr.sdm-row-child td:first-child .std-badge { opacity: .55; }
.sdm-code-child { white-space: normal; }
.sdm-child-entity { display: block; margin-bottom: 2px; font-size: 11px; color: #6c757d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
.sdm-child-code { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.sdm-elbow { color: #8db4e8; font-weight: 700; line-height: 1; }
.sdm-expand { display: inline-flex; align-items: center; gap: 4px; margin: 2px 6px 2px 0; padding: 2px 8px; border: 1px solid #dee2e6; border-radius: 999px; background: #ffffff; color: #333; font-size: 12px; cursor: pointer; }
.sdm-expand:hover { border-color: #0062cc; color: #0062cc; }
.sdm-expand .sdm-icon { transition: transform .15s; }
.sdm-expand[aria-expanded="true"] { border-color: #0062cc; color: #0062cc; }
.sdm-expand[aria-expanded="true"] .sdm-icon { transform: rotate(180deg); }
.std-detail-field-value { overflow-wrap: anywhere; white-space: pre-wrap; }
/* Neuf colonnes : le cadre défile plutôt que d'écraser la table. */
#sdmCardData { overflow-x: auto; }
@media (max-width: 575.98px) {
.sdm-page .std-filter-group, .sdm-page .std-filter-select, .sdm-page .std-filter-input { width: 100%; min-width: 0; }
.sdm-data-label { max-width: 200px; }
}
/* Fenêtre de confirmation (jouer, annuler, supprimer une migration) */
.sdm-dialog { width: min(520px, calc(100vw - 32px)); padding: 0; border: 0; border-radius: 10px; background: var(--sdm-background); color: var(--sdm-ink); box-shadow: var(--sdm-shadow-high); }
.sdm-dialog::backdrop { background: rgba(15, 23, 42, .45); }
.sdm-dialog-body { padding: 20px; }
.sdm-dialog-body h4 { margin: 0 0 10px; font-size: 18px; font-weight: 700; }
.sdm-dialog-body p { margin: 0; color: var(--sdm-soft); }
.sdm-dialog-list { max-height: 200px; overflow-y: auto; margin: 12px 0 0; padding: 8px 12px 8px 28px; border: 1px solid var(--sdm-edge); border-radius: 8px; background: var(--sdm-background-page); }
.sdm-dialog .sdm-drawer-footer { border-radius: 0 0 10px 10px; }
/* Sections Standards, repliables (TH.11) */
.sdm-section { margin-bottom: 14px; }
.sdm-section > summary.sdm-section-head {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 6px 2px; color: var(--sdm-ink); list-style: revert;
}
.sdm-section > .sdm-group { margin-top: 8px; }
/* Migrations : nom de fichier contenu (TH.6) */
.sdm-col-file { max-width: 340px; overflow-wrap: anywhere; }
/* Import : aide, phrase de synthèse, fichiers de vérification (TH.4, TH.12) */
.sdm-help-import {
margin: 10px 0; padding: 10px 12px; border: 1px solid var(--sdm-edge);
border-radius: var(--sdm-radius); background: var(--sdm-background-page); font-size: 13px;
}
.sdm-help-import > * + * { margin-top: 4px; }
.sdm-help-import .sdm-alert { color: var(--sdm-alert); }
.sdm-help-import pre { white-space: pre-wrap; margin: 4px 0 0; font-size: 12px; }
/* Source d'extraction du référentiel (E28) */
.sdm-sheet-source { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--sdm-edge); }
.sdm-sheet-source > * + * { margin-top: 8px; }
.sdm-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
/* Les liens en cartes, comme .sti-url-list de Standards. */
.sdm-sheet-links { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.sdm-sheet-links li { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border: 1px solid var(--sdm-edge); border-radius: var(--sdm-radius); background: var(--sdm-background-page); }
.sdm-sheet-links li.sdm-sheet-none { color: var(--sdm-soft); font-size: 13px; border-style: dashed; background: transparent; }
.sdm-sheet-links a { flex: 1 1 auto; min-width: 0; display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--sdm-accent); overflow-wrap: anywhere; text-decoration: none; }
.sdm-sheet-links a:hover span { text-decoration: underline; }
.sdm-sheet-links a svg { flex: 0 0 auto; width: 14px; height: 14px; }
.sdm-sheet-remove { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--sdm-alert); cursor: pointer; }
.sdm-sheet-remove svg { width: 14px; height: 14px; }
.sdm-sheet-remove:hover { background: var(--sdm-alert-soft); }
.sdm-sheet-remove:focus-visible { outline: 2px solid var(--sdm-accent); outline-offset: 2px; }
.sdm-sheet-source .sdm-footer { margin-top: 8px; }
.sdm-sheet-source textarea[readonly] { background: var(--sdm-background-page); }
.sdm-sheet-add { display: flex; gap: 6px; }
.sdm-sheet-add input { flex: 1 1 auto; min-width: 0; }
.sdm-sheet-source textarea { resize: vertical; }
/* TH.12 : avancement d'une analyse ou d'une intégration. */
.sdm-progress { display: flex; flex-direction: column; gap: 4px; margin: 10px 0; }
.sdm-progress-track { height: 8px; border-radius: 4px; background: var(--sdm-nothing-soft); overflow: hidden; }
.sdm-progress-bar { height: 100%; width: 0; background: var(--sdm-accent); border-radius: 4px; transition: width .3s ease; }
.sdm-progress-text { font-size: 12px; color: var(--sdm-soft); }
@media (prefers-reduced-motion: reduce) { .sdm-progress-bar { transition: none; } }
.sdm-report-sentence { margin: 6px 0 10px; font-size: 15px; }
.sdm-inset.sdm-alert { background: var(--sdm-alert-soft); color: var(--sdm-alert); border-color: var(--sdm-alert); }
/* ── Liens : arbre des entités et fiche (E37) ──────────────────────────────
Repris de l'onglet Hiérarchie de Standards (settings-standards.css, .shx-*) :
mêmes mesures, mêmes couleurs, pour que les deux écrans se lisent pareil. */
.sdm-lx-bar { display: flex; flex-wrap: wrap; gap: 16px; justify-content: space-between; align-items: center; margin: 4px 0 14px; }
.sdm-lx-help { flex: 1 1 380px; margin: 0; font-size: 12.5px; color: #6b7280; }
.sdm-lx-group { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.sdm-lx-check { display: inline-flex; align-items: center; gap: 6px; margin: 0; font-size: 13px; font-weight: 400; color: #374151; cursor: pointer; }
/* Les boutons de Standards (.std-button) */
.sdm-page .sdm-lx-btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px;
border: none; border-radius: 4px; background: #eef0f3; color: #1f2933;
font-size: 14px; font-weight: 500; cursor: pointer; transition: background-color .2s, opacity .2s;
}
.sdm-page .sdm-lx-btn:hover { opacity: .9; background: #e3e7ec; }
.sdm-page .sdm-lx-btn:focus-visible { outline: 2px solid #0066cc; outline-offset: 2px; }
.sdm-page .sdm-lx-btn:disabled { opacity: .5; cursor: not-allowed; }
.sdm-page .sdm-lx-btn-primary { background: #0062cc; color: #fff; }
.sdm-page .sdm-lx-btn-primary:hover { background: #0056b3; }
.sdm-page .sdm-lx-btn-sm { padding: 6px 12px; font-size: 12px; gap: 6px; }
.sdm-lx { display: flex; gap: 16px; align-items: flex-start; }
.sdm-lx-tree { flex: 1 1 auto; min-width: 0; }
.sdm-lx-side { flex: 0 0 450px; display: flex; flex-direction: column; gap: 12px; }
@media (max-width: 1100px) {
.sdm-lx { flex-direction: column; }
.sdm-lx-side { flex: 1 1 auto; width: 100%; }
}
/* L'arbre (.shx-tree, .shx-card) */
.sdm-lx-list, .sdm-lx-list ul { list-style: none; margin: 0; padding: 0; }
.sdm-lx-list ul { margin-left: 20px; padding-left: 16px; border-left: 1px solid #dbe3ec; }
.sdm-lx-list li { margin: 4px 0; }
.sdm-lx-card {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
width: 100%; padding: 8px 12px; text-align: left; cursor: pointer; color: #111827;
border: 1px solid #e5e7eb; border-radius: 7px; background: #fff;
}
.sdm-lx-card:hover { border-color: #4680ff; background: #f7faff; }
.sdm-lx-card:focus-visible { outline: 2px solid #4680ff; outline-offset: 1px; }
.sdm-lx-card.is-selected { border-color: #4680ff; background: #eef4ff; box-shadow: inset 3px 0 0 #4680ff; }
.sdm-lx-card.is-loop { opacity: .65; border-style: dashed; }
.sdm-lx-card.is-inactive { opacity: .6; }
.sdm-lx-code { font-weight: 700; font-size: 13px; color: #111827; }
.sdm-lx-meta { font-size: 12px; color: #6b7280; font-variant-numeric: tabular-nums; }
.sdm-lx-tag { padding: 1px 8px; border-radius: 999px; background: #eef2f7; color: #4b5563; font-size: 11px; font-weight: 600; }
.sdm-lx-flag { padding: 1px 8px; border-radius: 999px; background: #fff6e0; color: #9a6700; font-size: 11px; font-weight: 600; }
.sdm-lx-loop { font-size: 11px; color: #b45309; font-style: italic; }
.sdm-lx-kind { padding: 1px 8px; border-radius: 999px; background: #ede9fe; color: #5b21b6; font-size: 11px; font-weight: 600; }
.sdm-lx-card .sdm-lx-kind { margin-left: auto; }
.sdm-lx-kind-natif { background: #d1fae5; color: #065f46; }
.sdm-lx-kind-parente { background: #eef2f7; color: #4b5563; }
.sdm-lx-flat > li > .sdm-lx-card { background: #fbfcfe; }
.sdm-lx-none { padding: 18px; margin: 0; color: #6b7280; font-size: 13px; }
.sdm-lx-isolated { margin-top: 22px; padding-top: 14px; border-top: 1px solid #e5e7eb; }
.sdm-lx-isolated > h4 { margin: 0 0 10px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: #6b7280; }
/* La fiche (.shx-side, .shx-card) */
.sdm-lx-box { padding: 12px; border: 1px solid #e5e7eb; border-radius: 8px; background: #fff; }
.sdm-lx-title-line { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.sdm-lx-title { margin: 0; font-size: 14px; font-weight: 700; color: #111827; }
.sdm-lx-sub { font-size: 12px; color: #6b7280; }
.sdm-lx-sum { list-style: none; margin: 8px 0 0; padding: 0; font-size: 12.5px; color: #4b5563; }
.sdm-lx-sum li { padding: 2px 0; }
.sdm-lx-sum strong { display: inline-block; min-width: 26px; color: #111827; font-variant-numeric: tabular-nums; }
.sdm-lx-sum .sdm-lx-warn, .sdm-lx-sum .sdm-lx-warn strong { color: #b45309; }
.sdm-lx-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 14px 0 8px; }
.sdm-lx-subtitle { margin: 0; font-size: 12px; font-weight: 700; color: #374151; }
.sdm-lx-count { font-size: 12px; font-weight: 600; color: #6b7280; }
.sdm-lx-hint { margin: 0; font-size: 11.5px; color: #6b7280; }
.sdm-lx-empty { margin: 0; padding: 18px; font-size: 13px; color: #6b7280; }
/* Les liens de la fiche (.shx-need) */
.sdm-lx-items { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.sdm-lx-item { padding: 8px 10px; border: 1px solid #e5e7eb; border-radius: 7px; background: #fbfcfe; }
.sdm-lx-item.is-inactive { opacity: .65; }
.sdm-lx-item.is-incomplete { border-color: #f5d9a8; background: #fffaf0; }
.sdm-lx-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px;
margin-bottom: 8px; padding-bottom: 8px; border-bottom: 1px solid #edf1f6; }
.sdm-lx-item-head .sdm-lx-check { font-size: 12px; }
.sdm-lx-item-foot { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 6px;
margin-top: 8px; padding-top: 8px; border-top: 1px solid #edf1f6; }
.sdm-lx-line { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #334155; }
.sdm-lx-line + .sdm-lx-line { margin-top: 6px; }
.sdm-lx-label { flex: 0 0 110px; font-size: 11.5px; font-weight: 600; color: #475569; }
.sdm-lx-value { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.sdm-page .sdm-lx-part { flex: 1 1 0; min-width: 0; height: auto; font-size: 12px; padding: 4px 6px; }
.sdm-page .sdm-lx-short { flex: 0 0 80px; }
.sdm-lx-del { border: 1px solid #f5c2c7; background: #fff; border-radius: 4px; color: #b42318; cursor: pointer; line-height: 1; padding: 3px 6px; }
.sdm-lx-del:hover { background: #fdf3f4; border-color: #dc3545; }
.sdm-lx-warn-line { margin: 6px 0 0; font-size: 11px; color: #b45309; }
.sdm-lx-note { margin: 6px 0 0; font-size: 11.5px; color: #6b7280; font-style: italic; }
js
/**
* sys_data_model — page « Modèle de données ».
*
* Parcours : Définir → Engendrer → Jouer → Importer → Lier, avec l'état réel de chaque étape.
* Entités : arbre par référentiel, éditeur en tiroir, reprise depuis Standards, génération des migrations.
* Migrations : fichiers engendrés, « marquer jouée », vue de compatibilité, écarts à la base.
* Imports : analyse (n'écrit rien), puis intégration ou refus ; reprise des versions Standards.
* Liens : arbre des entités et fiche à droite (E37), déclaration, reprise de l'existant,
* instances des liens déclarés (tiroir), et en tête les liens douteux à trancher
* un par un (bloc masqué s'il n'y en a pas).
* Test API : les fonctions de l'API Standards, servies par sys-data-model.
*/
(function () {
const API = '/ajax/sdm';
const root = document.querySelector('.sdm-page');
const right = (name) => root.dataset[name] === '1';
const $ = (sel, inside = document) => inside.querySelector(sel);
const state = { entities: [], referentials: [], importable: [], types: [], roles: [], syndications: ['none', 'user', 'admin'], editing: null };
/** Ce qu'affiche le parcours ; null = pas encore chargé. */
const summary = { entities: false, migrations: null, imports: null, links: null, confirm: null };
// ── Outils ─────────────────────────────────────────────────────────────
async function request(method, url, body) {
const options = { method: method, headers: {} };
if (body instanceof FormData) options.body = body;
else if (body !== undefined) {
options.headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(body);
}
// Jamais de réponse en cache : l'écran doit suivre la base (TH.5).
options.cache = 'no-store';
const response = await fetch(url, options);
const json = await response.json().catch(() => ({ success: false, error: `Erreur ${response.status}` }));
if (!json.success) throw new Error(json.error || `Erreur ${response.status}`);
return json.data;
}
/** Crée un élément : el('td', { className: 'x' }, 'texte', enfant…). */
function el(tag, props = {}, ...children) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === 'dataset') Object.assign(n.dataset, v);
else if (k in n) n[k] = v;
else n.setAttribute(k, v);
}
for (const e of children.flat()) if (e != null) n.append(e instanceof Node ? e : String(e));
return n;
}
/** replaceChildren qui aplatit les listes et ignore les null (sinon « nullnull »). */
function fill(node, ...children) {
node.replaceChildren(...children.flat(Infinity).filter((e) => e != null));
return node;
}
const ICONS = {
entities: '<path d="M12 2 2 7l10 5 10-5-10-5z"/><path d="m2 17 10 5 10-5"/><path d="m2 12 10 5 10-5"/>',
migrations: '<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>',
imports: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/>',
links: '<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>',
confirm: '<path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><path d="m22 4-10 10-3-3"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/>',
play: '<path d="m6 4 14 8-14 8z"/>',
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-3"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
code: '<path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/>',
close: '<path d="M18 6 6 18M6 6l12 12"/>',
validate: '<path d="M20 6 9 17l-5-5"/>',
define: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/>',
alert: '<path d="M12 9v4M12 17h.01"/><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/>',
arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>',
up: '<path d="m18 15-6-6-6 6"/>',
down: '<path d="m6 9 6 6 6-6"/>',
trash: '<path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6"/>',
empty: '<path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.5 5.1 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.5-6.9A2 2 0 0 0 16.8 4H7.2a2 2 0 0 0-1.7 1.1z"/>',
records: '<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M3 15h18M9 3v18"/>',
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/><circle cx="12" cy="12" r="3"/>',
external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14 21 3"/>',
};
/** Icône SVG en ligne (chaînes fixes ci-dessus, jamais de donnée). */
function icon(name) {
const n = el('span', { className: 'sdm-icon', 'aria-hidden': 'true' });
n.innerHTML = `<svg viewBox="0 0 24 24">${ICONS[name] || ''}</svg>`;
return n;
}
/** Remplace les <span data-icone="…"> du gabarit. */
function setIcons(inside = document) {
inside.querySelectorAll('[data-icon]').forEach((n) => {
const i = icon(n.dataset.icon);
i.className = `sdm-icon ${n.className}`.trim();
n.replaceWith(i);
});
}
const notify = (type, text) => window.x1Notification(type, text);
const label = (l) => (l && (l[window.getUserLang()] || l.fr || l.en)) || '';
const ENTITY_STATES = { creee: 'créées', engendree: 'migration en attente', a_engendrer: 'à engendrer' };
const IMPORT_STATES = { analyse: 'à valider', perime: 'périmée', integre: 'intégré', refuse: 'refusé', valide: 'validé' };
const MIGRATION_STATES = { a_jouer: 'à jouer', jouee: 'jouée' };
const TABLE_STATES = { creee: 'créée', engendree: 'migration en attente', a_engendrer: 'à engendrer', sans_table: '—' };
const DECISION_STATES = { propose: 'à confirmer', confirme: 'confirmé', reconduit: 'reconduit', rejete: 'rejeté', caduc: 'caduc' };
const date = (d) => (d ? new Date(d).toLocaleString() : '');
const plural = (n, one, several) => `${n} ${n > 1 ? several : one}`;
const stateDot = (cls, text, title) => el('span', { className: `sdm-state sdm-state-${cls}`, title: title || '' }, text);
const dot = (text, cls = '') => el('span', { className: `sdm-dot ${cls}`.trim() }, text);
function size(bytes) {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} Ko`;
return `${(bytes / 1024 / 1024).toFixed(1)} Mo`;
}
/**
* Le bouton est désactivé et montre un spinner le temps de l'action ;
* au moins 400 ms, pour qu'une action rapide se voie quand même.
*/
async function busy(button, fn) {
button.disabled = true;
button.classList.add('sdm-running');
button.setAttribute('aria-busy', 'true');
const start = Date.now();
try { await fn(); } catch (e) { notify('alert', e.message); } finally {
const rest = 400 - (Date.now() - start);
if (rest > 0) await new Promise((r) => setTimeout(r, rest));
button.classList.remove('sdm-running');
button.removeAttribute('aria-busy');
button.disabled = Boolean(button.dataset.reason);
updateButtons();
}
}
/**
* Une tâche longue (TH.12) : la requête part avec `?fond=1` et rend un
* numéro ; une barre sous le bouton suit l'avancement jusqu'à la fin.
* Rend le résultat de la tâche, ou lève son erreur.
*/
async function runTask(button, method, url, body) {
const { task } = await request(method, `${url}${url.includes('?') ? '&' : '?'}fond=1`, body);
const bar = el('div', { className: 'sdm-progress-bar' });
const text = el('span', { className: 'sdm-progress-text' }, 'démarrage');
const box = el('div', {
className: 'sdm-progress', role: 'progressbar', 'aria-valuemin': '0', 'aria-valuemax': '100', 'aria-valuenow': '0',
}, el('div', { className: 'sdm-progress-track' }, bar), text);
const slot = button?.closest('.sdm-report-decision, form, .sdm-bar') || button?.parentNode;
if (slot) slot.after(box);
try {
for (;;) {
const t = await request('GET', `${API}/tasks/${task}`);
const pct = Math.round((t.fraction || 0) * 100);
bar.style.width = `${pct}%`;
box.setAttribute('aria-valuenow', String(pct));
text.textContent = `${pct} % — ${t.step}`;
if (t.state === 'fini') return t.result;
if (t.state === 'error') throw new Error(t.error);
await new Promise((r) => setTimeout(r, 700));
}
} finally {
box.remove();
}
}
/**
* Fenêtre de confirmation (<dialog> natif : fond, Échap, focus piégé).
* Résout true si l'utilisateur valide, false sinon.
*/
function askConfirmation({ title, text, list = [], validate = 'Confirmer', danger = false }) {
return new Promise((resolve) => {
const validateButton = el('button', { type: 'submit', value: 'oui', className: `btn btn-sm ${danger ? 'btn-danger' : 'btn-primary'}` }, validate);
const dialog = el('dialog', { className: 'sdm-dialog', 'aria-labelledby': 'sdmDialogTitle' },
el('form', { method: 'dialog' },
el('div', { className: 'sdm-dialog-body' },
el('h4', { id: 'sdmDialogTitle' }, title),
el('p', {}, text),
list.length ? el('ul', { className: 'sdm-dialog-list' }, ...list.map((x) => el('li', { className: 'sdm-code' }, x))) : null),
el('div', { className: 'sdm-drawer-footer' },
el('button', { type: 'submit', value: 'non', className: 'btn btn-outline-secondary btn-sm' }, 'Annuler'),
validateButton)));
dialog.addEventListener('close', () => {
resolve(dialog.returnValue === 'oui');
dialog.remove();
});
// Dans .sdm-page : les couleurs de la page y sont définies (sinon fond transparent).
(document.querySelector('.sdm-page') || document.body).append(dialog);
dialog.showModal();
validateButton.focus();
});
}
/** Grise un bouton qui n'a pas de sens maintenant, et dit pourquoi au survol. */
function dim(button, reason, title = '') {
if (!button) return;
button.dataset.reason = reason || '';
button.disabled = Boolean(reason);
button.title = reason ? `Indisponible : ${reason}` : title;
}
/** Les boutons de la page selon l'état : `etat.gestes` vient du serveur, avec ses propres contrôles. */
function updateButtons() {
const g = state.actions;
if (!g) return;
const filter = $('#sdmFilterRef').value;
const n = filter ? (g.toBackfill[filter] || 0) : Object.values(g.toBackfill).reduce((a, b) => a + b, 0);
dim($('#sdmBtnBackfill'), n ? null : `toutes les entités de ${filter || 'Standards'} sont déjà déclarées`,
`${plural(n, 'entité', 'entités')} à reprendre`);
dim($('#sdmBtnPlay'), g.play);
dim($('#sdmBtnReset'), g.reset, 'DEV : vide tout ce que sys_data_model a écrit');
dim($('#sdmBtnDiff'), g.diff);
const refView = $('#sdmRefView').value;
dim($('#sdmBtnView'), refView ? g.view[refView] : 'aucune entité déclarée');
const importRef = $('#sdmFormImport').elements.referential.value;
const obstacle = !importRef ? 'aucun référentiel importable'
: importRef in g.import ? g.import[importRef] : `import non disponible pour ${importRef}`;
dim($('#sdmBtnAnalyze'), obstacle);
dim($('#sdmBtnBackfillData'), obstacle);
dim($('#sdmBtnBackfillLinks'), g.links);
document.querySelectorAll('[data-generate]').forEach((b) => dim(b, g.migration[b.dataset.generate]));
}
function applyRights() {
document.querySelectorAll('[data-right]').forEach((n) => {
if (!right(n.dataset.right)) n.classList.add('d-none');
});
}
/** État vide illustré. */
function empty(name, title, text) {
return el('div', { className: 'sdm-empty' }, icon(name), el('strong', {}, title), text ? el('p', {}, text) : null);
}
/** Une table : la carte si des lignes, sinon l'état vide. */
function fillTable(card, body, rows, emptyZone, emptyArgs) {
$(body).replaceChildren(...rows);
$(card).classList.toggle('d-none', rows.length === 0);
$(emptyZone).replaceChildren(...(rows.length ? [] : [empty(...emptyArgs)]));
}
/** Lignes fantômes pendant le premier chargement. */
function skeleton(body, columns) {
const tbody = $(body);
if (tbody.rows.length) return;
tbody.replaceChildren(...[0, 1, 2].map(() => el('tr', { className: 'sdm-row-skeleton' },
...Array.from({ length: columns }, () => el('td', {}, el('span', {}))))));
}
// ── Parcours ───────────────────────────────────────────────────────────
function showPath() {
const n = state.entities.length;
const toGenerate = state.entities.filter((e) => e.state === 'a_engendrer').length;
const m = summary.migrations;
const toPlay = m ? m.filter((x) => x.state === 'a_jouer').length : 0;
const im = summary.imports;
const integrated = im ? im.filter((x) => x.SDM_IM_State === 'integre').length : 0;
const toValidate = im ? im.filter((x) => x.SDM_IM_State === 'analyse').length : 0;
const li = summary.links;
const active = li ? li.filter((x) => x.active).length : 0;
const toConfirm = summary.confirm ? summary.confirm.length : 0;
const steps = [
{
name: 'Définir', icon: 'define', tab: 'entities', figure: n, loaded: summary.entities,
text: plural(n, 'entité', 'entités'), cls: n ? 'ok' : 'empty',
},
{
name: 'Engendrer', icon: 'code', tab: 'entities', figure: toGenerate, loaded: summary.entities,
text: toGenerate ? 'à engendrer' : (n ? 'tout est engendré' : 'rien à engendrer'),
cls: toGenerate ? 'pending' : (n ? 'ok' : 'empty'),
},
{
name: 'Jouer', icon: 'play', tab: 'migrations', figure: toPlay, loaded: m !== null,
text: toPlay ? `${toPlay > 1 ? 'migrations' : 'migration'} à jouer` : 'base à jour',
cls: toPlay ? 'alert' : (m && m.length ? 'ok' : 'empty'),
},
{
name: 'Importer', icon: 'imports', tab: 'imports', figure: integrated, loaded: im !== null,
text: `${integrated > 1 ? 'versions intégrées' : 'version intégrée'}${toValidate ? ` · ${toValidate} à valider` : ''}`,
cls: toValidate ? 'pending' : (integrated ? 'ok' : 'empty'),
},
{
name: 'Lier', icon: 'links', tab: 'links', figure: active,
loaded: li !== null && summary.confirm !== null,
text: `${active > 1 ? 'liens actifs' : 'lien actif'}${toConfirm ? ` · ${toConfirm} à confirmer` : ''}`,
cls: toConfirm ? 'pending' : (active ? 'ok' : 'empty'),
},
];
$('#sdmPath').replaceChildren(...steps.map((e, i) => {
const loaded = e.loaded !== false;
return el('li', {}, el('button', {
type: 'button',
className: `sdm-step sdm-step-${loaded ? e.cls : 'chargement'}`,
title: `Ouvrir : ${e.name}`,
onclick: () => openTab(e.tab, true),
},
el('span', { className: 'sdm-step-circle' }, e.cls === 'ok' && loaded ? icon('validate') : String(i + 1)),
el('span', { className: 'sdm-step-name' }, e.name),
el('span', { className: 'sdm-step-figure' }, loaded ? String(e.figure) : ''),
el('span', { className: 'sdm-step-text' }, loaded ? e.text : 'chargement…')));
}));
}
// ── Onglets ────────────────────────────────────────────────────────────
function openTab(name, focus = false) {
document.querySelectorAll('[data-tab]').forEach((b) => {
const active = b.dataset.tab === name;
b.classList.toggle('active', active);
b.setAttribute('aria-selected', String(active));
b.tabIndex = active ? 0 : -1;
if (active && focus) b.focus();
$(`#${b.getAttribute('aria-controls')}`).classList.toggle('d-none', !active);
});
const load = {
imports: loadImports, migrations: loadMigrations, records: loadData, links: () => Promise.all([loadLinks(), loadConfirm()]), api: loadApi,
}[name];
if (load) load().catch((e) => notify('alert', e.message));
}
/** Flèches gauche/droite, Début, Fin entre les onglets. */
function tabsKeyboard(ev) {
const tabs = [...document.querySelectorAll('[data-tab]')];
const i = tabs.indexOf(document.activeElement);
if (i < 0) return;
const target = { ArrowRight: i + 1, ArrowLeft: i - 1, Home: 0, End: tabs.length - 1 }[ev.key];
if (target === undefined) return;
ev.preventDefault();
openTab(tabs[(target + tabs.length) % tabs.length].dataset.tab, true);
}
// ── Tiroirs ────────────────────────────────────────────────────────────
let returnFocus = null;
function openDrawer(sel) {
if (!document.querySelector('.sdm-drawer.ouvert')) returnFocus = document.activeElement;
document.querySelectorAll('.sdm-drawer.ouvert').forEach((t) => t.classList.remove('ouvert'));
$(sel).classList.add('ouvert');
$('#sdmOverlay').classList.add('ouvert');
document.body.classList.add('sdm-no-scroll');
}
function closeDrawer() {
if (!document.querySelector('.sdm-drawer.ouvert')) return;
document.querySelectorAll('.sdm-drawer.ouvert').forEach((t) => t.classList.remove('ouvert'));
$('#sdmOverlay').classList.remove('ouvert');
document.body.classList.remove('sdm-no-scroll');
openLink = null;
if (returnFocus && document.contains(returnFocus)) returnFocus.focus();
returnFocus = null;
}
// ── Entités ────────────────────────────────────────────────────────────
async function loadEntities() {
const data = await request('GET', `${API}/entities`);
Object.assign(state, data);
summary.entities = true;
fillFilter();
showEntities();
showPath();
$('#sdmBtnPlay').classList.toggle('d-none', !(data.playMigrations && right('generate')));
$('#sdmBtnReset').classList.toggle('d-none', !(data.playMigrations && right('mark')));
updateButtons();
}
const knownReferentials = () => [...new Set([...state.referentials.map((r) => r.code), ...state.entities.map((e) => e.Referentiel)])].sort();
const refSheet = (code) => state.referentials.find((r) => r.code === code);
/** Le nom d'un référentiel dans la langue active (TH.7) ; le code reste la valeur. */
const refName = (code) => {
const name = label(refSheet(code)?.name);
return name && name !== code ? `${name} (${code})` : code;
};
/** La section d'un référentiel (TH.11), lue dans sa fiche SDM ; '' si aucune. */
const refSection = (code) => refSheet(code)?.section || '';
/**
* Les options d'une liste de référentiels, rangées par section comme dans
* Standards (TH.11) : un <optgroup> par section, les autres à la fin.
*/
function refOptions(codes) {
const bySection = new Map();
for (const c of codes) {
const s = refSection(c);
if (!bySection.has(s)) bySection.set(s, []);
bySection.get(s).push(el('option', { value: c }, refName(c)));
}
const sections = [...bySection.keys()].filter(Boolean).sort();
if (!sections.length) return bySection.get('') || [];
return [
...sections.map((s) => el('optgroup', { label: s }, ...bySection.get(s))),
...(bySection.has('') ? [el('optgroup', { label: 'Sans section' }, ...bySection.get(''))] : []),
];
}
function fillFilter() {
const filter = $('#sdmFilterRef');
const chosen = filter.value;
const codes = knownReferentials();
fill(filter, el('option', { value: '' }, 'Tous les référentiels'), refOptions(codes));
filter.value = codes.includes(chosen) ? chosen : '';
}
function generate(referential) {
return (ev) => busy(ev.currentTarget, async () => {
const r = await request('POST', `${API}/migrations`, { referential });
notify('success', `Migration engendrée : ${r.file} (${r.entities.join(', ')}) — ${r.models.length} modèle(s) Sequelize.`);
await loadEntities();
await loadMigrations().catch(() => {});
});
}
function entityNode(e, children, seen) {
seen.add(e.Code);
const meta = [
e.ParenteCode === e.Code ? dot('hiérarchie interne', 'sdm-dot-accent') : null,
e.Actif ? null : dot('inactive'),
dot(plural(e.attributes.length, 'attribut', 'attributs')),
stateDot(e.state, ENTITY_STATES[e.state] || e.state, e.migration || ''),
];
const sub = (children.get(e.Code) || []).filter((x) => !seen.has(x.Code));
return el('li', {},
el('button', {
type: 'button',
className: `sdm-node${e.Actif ? '' : ' sdm-inactive'}`,
onclick: () => edit(e.Code),
title: right('write') ? 'Modifier' : 'Voir',
},
el('span', { className: 'sdm-node-icon' }, icon('entities')),
el('span', { className: 'sdm-node-code' }, e.Code),
el('span', { className: 'sdm-node-label' }, label(e.Libelle)),
el('span', { className: 'sdm-node-meta' }, ...meta)),
sub.length ? el('ul', { className: 'sdm-nodes' }, ...sub.map((x) => entityNode(x, children, seen))) : null);
}
function showEntities() {
const filter = $('#sdmFilterRef').value;
const text = $('#sdmSearch').value.trim().toLowerCase();
const matches = (e) => !text || e.Code.toLowerCase().includes(text)
|| Object.values(e.Libelle || {}).some((l) => String(l).toLowerCase().includes(text));
const list = state.entities.filter((e) => (!filter || e.Referentiel === filter) && matches(e));
const tree = $('#sdmTree');
if (!state.entities.length) {
tree.replaceChildren(empty('entities', 'Aucune entité déclarée',
'Reprenez les entités depuis Standards, ou déclarez-en une nouvelle.'));
return;
}
if (!list.length) {
tree.replaceChildren(empty('search', 'Aucune entité ne correspond', 'Changez le référentiel ou la recherche.'));
return;
}
const groups = new Map();
for (const e of list) {
if (!groups.has(e.Referentiel)) groups.set(e.Referentiel, []);
groups.get(e.Referentiel).push(e);
}
const cards = [...groups].map(([referential, entities]) => {
const codes = new Set(entities.map((e) => e.Code));
const children = new Map();
const roots = [];
for (const e of entities) {
const p = e.ParenteCode;
if (p && p !== e.Code && codes.has(p)) {
if (!children.has(p)) children.set(p, []);
children.get(p).push(e);
} else roots.push(e);
}
const seen = new Set();
const nodes = roots.map((e) => entityNode(e, children, seen));
// Une boucle de parenté ne doit pas faire disparaître d'entité.
for (const e of entities) if (!seen.has(e.Code)) nodes.push(entityNode(e, children, seen));
const toGenerate = entities.filter((e) => e.state === 'a_engendrer').length;
return el('section', { className: 'sdm-card sdm-group', dataset: { section: refSection(referential) } },
el('header', { className: 'sdm-group-head' },
el('h5', { className: 'sdm-group-name', title: referential }, refName(referential)),
dot(plural(entities.length, 'entité', 'entités')),
toGenerate ? stateDot('a_engendrer', `${toGenerate} à engendrer`) : null,
right('generate') ? el('div', { className: 'sdm-actions' }, el('button', {
type: 'button',
className: `btn btn-sm ${toGenerate ? 'btn-primary' : 'btn-outline-secondary'}`,
dataset: { generate: referential },
onclick: generate(referential),
}, icon('code'), 'Engendrer la migration')) : null),
el('ul', { className: 'sdm-nodes' }, ...nodes));
});
// Rangées par section Standards, repliables (TH.11) ; sans section, à plat.
const sections = new Map();
for (const c of cards) {
const s = c.dataset.section;
if (!sections.has(s)) sections.set(s, []);
sections.get(s).push(c);
}
if ([...sections.keys()].every((s) => !s)) {
tree.replaceChildren(...cards);
return;
}
const ordre = [...sections.keys()].sort((a, b) => (!a) - (!b) || a.localeCompare(b));
fill(tree, ordre.map((s) => el('details', { className: 'sdm-section', open: true },
el('summary', { className: 'sdm-section-head' }, el('strong', {}, s || 'Sans section'),
dot(plural(sections.get(s).length, 'référentiel', 'référentiels'))),
sections.get(s))));
}
// ── Éditeur ────────────────────────────────────────────────────────────
const form = () => $('#sdmFormEntity');
function parentOptions(referential, code) {
const select = form().elements.ParenteCode;
const candidates = state.entities.filter((e) => e.Referentiel === referential && e.Code !== code);
select.replaceChildren(
el('option', { value: '' }, '— aucune —'),
...(code ? [el('option', { value: code }, 'elle-même (hiérarchie interne)')] : []),
...candidates.map((e) => el('option', { value: e.Code }, `${e.Code} — ${label(e.Libelle)}`)),
);
}
function edit(code) {
const e = code ? state.entities.find((x) => x.Code === code) : null;
state.editing = e ? e.Code : null;
const f = form();
const refs = knownReferentials();
fill(f.elements.Referentiel, refOptions(refs));
f.elements.Code.value = e ? e.Code : '';
f.elements.Code.readOnly = Boolean(e);
f.elements.Referentiel.value = e ? e.Referentiel : ($('#sdmFilterRef').value || refs[0] || '');
f.elements.Referentiel.disabled = Boolean(e);
f.elements.LibelleFr.value = e?.Libelle?.fr || '';
f.elements.LibelleEn.value = e?.Libelle?.en || '';
f.elements.Ordre.value = e ? e.Ordre : 0;
// La section est celle du référentiel (sa fiche SDM_RF), avec les sections existantes en suggestion.
f.elements.Section.value = refSection(f.elements.Referentiel.value);
fill($('#sdmSections'), [...new Set(state.referentials.map((r) => r.section).filter(Boolean))].sort()
.map((s) => el('option', { value: s })));
f.elements.Actif.checked = e ? e.Actif : true;
parentOptions(f.elements.Referentiel.value, e?.Code);
f.elements.ParenteCode.value = e?.ParenteCode || '';
f.elements.ParenteCode.disabled = e?.state === 'creee';
f.elements.ParenteCode.title = e?.state === 'creee' ? 'Les tables existent : la parenté est figée' : '';
$('#sdmEditorKicker').replaceChildren(e ? `Entité · ${e.Referentiel} · ` : 'Entité', e ? stateDot(e.state, ENTITY_STATES[e.state] || e.state) : '');
$('#sdmEditorTitle').textContent = e ? e.Code : 'Nouvelle entité';
$('#sdmBlockAttributes').classList.toggle('d-none', !e);
$('#sdmTableAttributes tbody').replaceChildren(...(e ? e.attributes.map(attributeRow) : []));
const read = !right('write');
f.querySelectorAll('input, select').forEach((n) => { if (read) n.disabled = true; });
openDrawer('#sdmEditor');
setTimeout(() => (e ? f.elements.LibelleFr : f.elements.Code).focus({ preventScroll: true }), 50);
}
function select(name, values, value) {
return el('select', { className: 'form-control form-control-sm', name: name, 'aria-label': name },
...values.map((v) => el('option', { value: v, selected: v === value }, v)));
}
let number = 0;
/** Interrupteur Bootstrap ; renvoie [conteneur, case]. */
function toggle(name, checked, text) {
const id = `sdmInter${++number}`;
const checkbox = el('input', { type: 'checkbox', className: 'custom-control-input', id, name: name, checked: checked });
return [el('div', { className: 'custom-control custom-switch' }, checkbox,
el('label', { className: 'custom-control-label', htmlFor: id }, el('span', { className: 'sr-only' }, text))), checkbox];
}
function iconButton(name, title, onclick, cls = '') {
return el('button', { type: 'button', className: `btn btn-link btn-sm btn-icone ${cls}`.trim(), title: title, 'aria-label': title, onclick }, icon(name));
}
function attributeRow(a = {}) {
const read = !right('write');
const [multiBlock, multi] = toggle('Multivalue', Boolean(a.Multivalue), 'Multivalué');
const [fingerprintBlock] = toggle('DansEmpreinte', a.DansEmpreinte !== false, 'Dans l\'empreinte');
const sep = el('input', {
className: 'form-control form-control-sm', name: 'Separateur', value: a.Separateur ?? '', size: 3,
disabled: !a.Multivalue, 'aria-label': 'Séparateur',
});
multi.addEventListener('change', () => { sep.disabled = !multi.checked; if (!multi.checked) sep.value = ''; });
const role = select('Role', state.roles, a.Role || 'aucun');
const tint = () => { role.className = `form-control form-control-sm sdm-role sdm-role-${role.value}`; };
role.addEventListener('change', tint);
tint();
const tr = el('tr', {},
el('td', {}, el('input', { className: 'form-control form-control-sm sdm-code', name: 'Nom', value: a.Nom || '', 'aria-label': 'Nom', spellcheck: false })),
el('td', {}, select('Type', state.types, a.Type || 'texte')),
el('td', {}, role),
el('td', {}, multiBlock),
el('td', {}, sep),
el('td', {}, fingerprintBlock),
el('td', {}, el('input', { className: 'form-control form-control-sm', name: 'LibelleFr', value: a.Libelle?.fr || '', 'aria-label': 'Libellé FR' })),
el('td', {}, el('input', { className: 'form-control form-control-sm', name: 'LibelleEn', value: a.Libelle?.en || '', 'aria-label': 'Libellé EN' })),
el('td', {}, el('input', {
className: 'form-control form-control-sm', name: 'LongueurFixe', type: 'number', min: 1, step: 1,
value: a.LongueurFixe ?? '', 'aria-label': 'Longueur fixe',
})),
el('td', {}, select('Syndication', state.syndications, a.Syndication || 'none')),
el('td', { className: 'text-right text-nowrap' },
iconButton('up', 'Monter', () => tr.previousElementSibling?.before(tr)),
iconButton('down', 'Descendre', () => tr.nextElementSibling?.after(tr)),
iconButton('trash', 'Retirer', () => tr.remove(), 'sdm-danger')),
);
if (read) tr.querySelectorAll('input, select, button').forEach((n) => { n.disabled = true; });
return tr;
}
function readAttributes() {
return [...$('#sdmTableAttributes tbody').rows].map((tr) => {
const v = (name) => tr.querySelector(`[name="${name}"]`);
return {
Nom: v('Nom').value.trim(),
Type: v('Type').value,
Role: v('Role').value,
Multivalue: v('Multivalue').checked,
Separateur: v('Multivalue').checked ? v('Separateur').value : null,
DansEmpreinte: v('DansEmpreinte').checked,
Libelle: { fr: v('LibelleFr').value.trim(), en: v('LibelleEn').value.trim() },
LongueurFixe: v('LongueurFixe').value === '' ? null : Number(v('LongueurFixe').value),
Syndication: v('Syndication').value,
};
});
}
async function save() {
const f = form();
if (!f.reportValidity()) return;
const entity = {
Libelle: { fr: f.elements.LibelleFr.value.trim(), en: f.elements.LibelleEn.value.trim() },
ParenteCode: f.elements.ParenteCode.value || null,
Ordre: Number(f.elements.Ordre.value) || 0,
Actif: f.elements.Actif.checked,
Section: f.elements.Section.value.trim(),
};
let code = state.editing;
if (!code) {
const created = await request('POST', `${API}/entities`, {
...entity, Code: f.elements.Code.value.trim(), Referentiel: f.elements.Referentiel.value,
});
code = created.Code;
notify('success', `Entité ${code} créée : ajoutez ses attributs.`);
} else {
const before = state.entities.find((x) => x.Code === code);
if (before.state === 'creee') delete entity.ParenteCode;
await request('PUT', `${API}/entities/${encodeURIComponent(code)}`, entity);
await request('PUT', `${API}/entities/${encodeURIComponent(code)}/attributes`, { attributes: readAttributes() });
notify('success', `Entité ${code} enregistrée.`);
}
await loadEntities();
edit(code);
}
// ── Reprise et migrations ──────────────────────────────────────────────
function showFindings(r) {
const c = r.findings;
const rows = [
['Entités ajoutées', r.added],
['Déjà déclarées (non touchées)', r.ignored],
['Types non déclarés → texte', c.defaultTypes.map((x) => `${x.referential}.${x.column}`)],
['Séparateur « bracketed » → « , » (crochets non retirés)', c.closeSeparators.map((x) => `${x.referential}.${x.column}`)],
['Séparateurs inconnus', c.unknownSeparators.map((x) => `${x.referential}.${x.column} (${x.separator})`)],
['Type non déclaré : séparateur ignoré par Standards → retour à la ligne', (c.ignoredSeparators || []).map((x) => `${x.referential}.${x.column} (${x.separator})`)],
['Colonnes placées par défaut sur l\'entité la plus profonde', c.defaultPlacement.map((x) => `${x.column} → ${x.entity}`)],
['Référentiels sans schéma', c.withoutSchema],
].filter(([, v]) => v.length);
const box = $('#sdmFindings');
box.replaceChildren(
el('strong', {}, 'Reprise depuis Standards'),
el('ul', {}, ...rows.map(([t, v]) => el('li', {}, `${t} (${v.length}) : `, el('span', { className: 'sdm-code' }, v.join(', '))))),
);
box.classList.remove('d-none');
}
// ── Imports ────────────────────────────────────────────────────────────
// Même écran que l'onglet « Intégrations » de Standards : menu par section à
// gauche (une seule ouverte à la fois), fiche du référentiel choisi à droite.
// Le <select> caché du formulaire reste la source du choix : tout le reste le lit.
let openSection = null;
let history = [];
const importRef = () => $('#sdmFormImport').elements.referential.value;
function pickImport(code) {
const select = $('#sdmFormImport').elements.referential;
if (select.value === code) return;
select.value = code;
select.dispatchEvent(new Event('change'));
}
function drawImportsMenu() {
const chosen = importRef();
const bySection = new Map();
for (const c of state.importable) {
const s = refSection(c);
if (!bySection.has(s)) bySection.set(s, []);
bySection.get(s).push(c);
}
// Les sections nommées d'abord, « Sans section » en dernier.
const sections = [...bySection.keys()].sort((a, b) => (a === '') - (b === '') || a.localeCompare(b));
const single = sections.length === 1;
fill($('#sdmMenuImports'), state.importable.length ? sections.map((s, i) => {
const open = single || openSection === s;
const id = `sdmMenuSection${i}`;
return el('div', { className: `sdm-integ-section${open ? ' ouverte' : ''}` },
single && !s ? null : el('button', {
type: 'button', className: 'sdm-integ-section-title', 'aria-expanded': String(open), 'aria-controls': id,
onclick: () => { openSection = openSection === s ? null : s; drawImportsMenu(); },
}, el('span', { className: 'sdm-integ-chevron', 'aria-hidden': 'true' }),
el('span', { className: 'sdm-integ-section-name' }, s || 'Sans section'),
el('span', { className: 'sdm-integ-count' }, bySection.get(s).length)),
el('ul', { className: 'sdm-integ-list', id, hidden: !open }, bySection.get(s).map((c) => {
const name = label(refSheet(c)?.name);
return el('li', {}, el('button', {
type: 'button', className: `sdm-integ-item${c === chosen ? ' courant' : ''}`,
...(c === chosen ? { 'aria-current': 'true' } : {}), onclick: () => pickImport(c),
}, el('span', { className: 'sdm-integ-code' }, c), name && name !== c ? el('span', { className: 'sdm-integ-name' }, name) : null));
})));
}) : el('p', { className: 'sdm-integ-empty' }, 'Aucun référentiel importable : déclarez ses entités, puis jouez sa migration.'));
}
/** La fiche de droite suit le choix ; un rapport ou une reprise d'un autre référentiel s'efface. */
function showImportSheet() {
const code = importRef();
$('#sdmSheetImportEmpty').classList.toggle('d-none', Boolean(code));
$('#sdmSheetImport').classList.toggle('d-none', !code);
if (code) {
openSection = refSection(code);
const name = label(refSheet(code)?.name);
$('#sdmSheetImportCode').textContent = code;
$('#sdmSheetImportName').textContent = name && name !== code ? name : '';
}
for (const id of ['#sdmReport', '#sdmBackfillData']) {
if ($(id).dataset.referential !== code) $(id).classList.add('d-none');
}
drawImportsMenu();
showHistory();
}
async function loadImports() {
// La liste suit les déclarations (une reprise en ajoute) ; le choix est gardé.
const select = $('#sdmFormImport').elements.referential;
const chosen = select.value;
if ([...select.options].map((o) => o.value).filter(Boolean).join() !== state.importable.join()) {
fill(select, el('option', { value: '' }), state.importable.map((c) => el('option', { value: c }, c)));
select.value = state.importable.includes(chosen) ? chosen : '';
if (select.value) loadHelp().catch(() => {});
}
showImportSheet();
updateButtons();
skeleton('#sdmTableImports tbody', 8);
history = await request('GET', `${API}/imports`);
summary.imports = history;
showPath();
showHistory();
}
/** L'historique du seul référentiel choisi. */
function showHistory() {
const code = importRef();
const imports = history.filter((i) => i.SDM_IM_Referential === code);
$('#sdmNbImports').textContent = imports.length;
fillTable('#sdmCardImports', '#sdmTableImports tbody', imports.map((i) => el('tr', {},
el('td', { className: 'text-nowrap' }, date(i.SDM_IM_AnalyzedAt)),
el('td', {}, dot(i.SDM_IM_VersionNum, 'sdm-dot-accent')),
el('td', { className: 'sdm-code' }, i.SDM_IM_File),
el('td', {}, stateDot(i.SDM_IM_State, IMPORT_STATES[i.SDM_IM_State] || i.SDM_IM_State)),
el('td', { className: 'sdm-number' }, i.SDM_IM_State === 'integre' ? i.SDM_IM_CreatedItems : ''),
el('td', { className: 'sdm-number' }, i.SDM_IM_State === 'integre' ? i.SDM_IM_CreatedContents : ''),
el('td', { className: 'sdm-number' }, i.SDM_IM_State === 'integre' ? i.SDM_IM_ReusedContents : ''),
el('td', { className: 'text-right' }, el('button', {
type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: (ev) => busy(ev.currentTarget, () => viewImport(i.SDM_IM_Id)),
}, icon('eye'), 'Rapport')),
)), '#sdmImportsEmpty', ['imports', 'Aucun import', `Aucune version de ${code} analysée : déposez ses fichiers ci-dessus, ou reprenez les versions Standards.`]);
}
async function viewImport(id) {
const i = await request('GET', `${API}/imports/${id}`);
showReport({
id: i.SDM_IM_Id, state: i.SDM_IM_State, referential: i.SDM_IM_Referential,
versionNum: i.SDM_IM_VersionNum, report: i.SDM_IM_Report, stats: i.SDM_IM_Report.stats,
});
}
/** Une liste du rapport, repliable ; `alerte` la signale en rouge. */
function detail(title, list, format, alert = false) {
if (!list || !list.length) return null;
const rows = list.slice(0, 500).map(format).join('\n') + (list.length > 500 ? `\n… et ${list.length - 500} autres` : '');
return el('details', { className: alert ? 'sdm-alert' : '' },
el('summary', {}, alert ? icon('alert') : null, `${title} (${list.length})`), el('pre', {}, rows));
}
/** La phrase qui résume un rapport, en clair (TH.4). */
function reportSentence(r, versionNum) {
const total = (r.newMembers || 0) + (r.rewrittenMembers || 0) + (r.unchangedMembers || 0);
const pieces = [`La version ${versionNum} compte ${plural(total, 'code', 'codes')}`];
if (r.createdIdentities) pieces.push(`dont ${plural(r.createdIdentities, 'nouveau', 'nouveaux')} jamais vu${r.createdIdentities > 1 ? 's' : ''}`);
const texts = `${plural(r.createdContents || 0, 'texte nouveau ou modifié', 'textes nouveaux ou modifiés')}, ${r.reusedContents || 0} déjà connu${r.reusedContents > 1 ? 's' : ''}`;
return `${pieces.join(', ')} ; ${texts}.`;
}
/** `liensNatifs` est un compte par table de liens ({ table: n }) ; un ancien rapport peut porter un nombre. */
const totalLinks = (v) => (v && typeof v === 'object' ? Object.values(v).reduce((a, n) => a + (Number(n) || 0), 0) : (v || 0));
const linkDetails = (v) => (v && typeof v === 'object' ? Object.entries(v).map(([t, n]) => `${t} : ${n}`).join('\n') : '');
function showReport({ id, state: importState, referential, versionNum, report: r, stats, gaps }) {
const figure = (n, t, alert = false, title = '') => el('div', { className: `sdm-figure${alert && n ? ' sdm-figure-alert' : ''}`, title }, el('b', {}, n ?? 0), el('span', {}, t));
const box = $('#sdmReport');
const queued = importState === 'analyse';
const perime = importState === 'perime';
const dead = r.deadLinkCount || 0;
const removed = (r.removedMembers || []).length;
// Le réimport d'une version déjà présente : seulement s'il y a quelque chose à en dire.
const reimport = (r.rewrittenMembers || 0) + (r.unchangedMembers || 0) + removed > 0;
box.className = 'sdm-card sdm-report';
box.dataset.referential = referential;
fill(box,
el('div', { className: 'sdm-report-head' },
el('div', { className: 'sdm-report-titles' },
el('span', { className: 'sdm-report-kicker' }, "Rapport d'analyse"),
el('h5', { className: 'sdm-report-title' },
el('span', {}, label(refSheet(referential)?.name) || referential),
el('span', { className: 'sdm-report-pill sdm-code' }, referential),
el('span', { className: 'sdm-report-pill' }, `v${versionNum}`),
stateDot(importState, IMPORT_STATES[importState] || importState))),
el('button', { type: 'button', className: 'sdm-close', 'aria-label': 'Fermer le rapport', onclick: () => box.classList.add('d-none') }, icon('close'))),
el('p', { className: 'sdm-report-sentence' }, reportSentence(r, versionNum)),
gaps && gaps.length ? el('p', { className: 'sdm-help' }, `Depuis l'analyse précédente : ${gaps.join(' ; ')}.`) : null,
perime ? el('div', { className: 'sdm-inset sdm-alert' },
icon('alert'), ' Analyse périmée : une autre version a été intégrée depuis. Relancez l\'analyse avant de valider.') : null,
el('div', { className: 'sdm-report-sub' }, 'Contenu'),
el('div', { className: 'sdm-figures' },
figure(r.createdIdentities, 'nouveaux codes', false, 'Codes jamais vus dans une version précédente'),
figure(r.createdContents, 'textes nouveaux ou modifiés', false, 'Libellés et données enregistrés pour la première fois'),
figure(r.reusedContents, 'textes déjà connus', false, 'Identiques à une version précédente : réutilisés, pas recopiés'),
figure(r.newMembers, 'codes ajoutés à la version'),
stats ? figure(stats.source, 'lignes lues') : null,
),
reimport ? [
el('div', { className: 'sdm-report-sub' }, 'Réimport de la version'),
el('p', { className: 'sdm-help' }, 'Cette version existe déjà : voici ce que le nouvel import y change.'),
el('div', { className: 'sdm-figures' },
figure(r.unchangedMembers, 'codes inchangés'),
figure(r.rewrittenMembers, 'codes réécrits', false, 'Même code, contenu ou parent différent'),
figure(removed, 'codes retirés de la version', true)),
] : null,
detail('Codes réapparus', r.reappeared, (x) => `${x.entity} ${x.code} : absent de ${x.missingFrom}, vu en ${x.lastPresence}`, true),
detail('Changements de niveau', r.levelChanges, (x) => `${x.code} : ${x.before} → ${x.after} (depuis ${x.version})`, true),
detail('Retirés de la version', r.removedMembers, (x) => `${x.entity} ${x.code}`, true),
detail('Parents introuvables', r.missingParents, (x) => `${x.entity} ${x.code} → ${x.parentEntity} ${x.parentCode}`, true),
detail('Types inconnus', r.unknownTypes, (x) => `${x.code} : ${x.value}`, true),
detail('Doublons', r.duplicates, (x) => `${x.code}${x.lang ? ` (${x.lang})` : ''}`),
detail('Monolingues', r.monolingual, (x) => `${x.code} : ${x.lang} seulement`),
detail('Divergences FR/EN (FR retenu)', r.divergences, (x) => `${x.code}.${x.column} : « ${x.fr} » / « ${x.en} »`),
r.withoutCode ? el('p', { className: 'sdm-help' }, `${r.withoutCode} ligne(s) sans code ignorée(s).`) : null,
// Contrôle de structure (T2.2.13) : ces valeurs ne seront pas importées, ces colonnes resteront vides.
detail('Colonnes du fichier non déclarées — valeurs ignorées', r.undeclaredColumns,
(x) => `${x.entity}.${x.attribute} : ${plural(x.nb, 'ligne', 'lignes')}`, true),
detail('Colonnes déclarées absentes du fichier', r.missingColumns, (x) => `${x.entity}.${x.attribute}`, true),
// Les alertes du traitement Standards (TH.12) : à corriger dans le classeur source.
detail('Questions désalignées FR/EN — à corriger dans le classeur', r.alerts?.misalignedRows,
(x) => `${x.sheet ? `[${x.sheet}] ` : ''}${x.code} : « ${x.fr ?? '—'} » / « ${x.en ?? '—'} »`, true),
r.alerts?.misalignedCols ? el('div', { className: 'sdm-inset sdm-alert' }, icon('alert'),
` Colonnes désalignées : FR « ${r.alerts.misalignedCols.fr} » / EN « ${r.alerts.misalignedCols.en} ».`) : null,
r.alerts?.remarks?.length ? el('ul', { className: 'sdm-help' }, r.alerts.remarks.map((t) => el('li', {}, t))) : null,
r.nativeLinks !== undefined ? [
el('div', { className: 'sdm-report-sub' }, 'Liens'),
el('div', { className: 'sdm-figures' },
figure(totalLinks(r.nativeLinks), 'liens natifs', false, linkDetails(r.nativeLinks)),
figure(dead, 'liens vers un code absent', true),
figure(r.renewedLinks, 'liens déclarés reconduits'),
figure((r.linksToConfirm || []).length, 'liens à confirmer', true),
figure((r.staleLinks || []).length, 'liens caducs'),
),
] : null,
detail('Liens vers un code absent — l\'intégration sera refusée', r.deadLinks, (x) => `${x.source}.${x.attribute} → ${x.target} ${x.code}`, true),
detail('Liens à confirmer après intégration', r.linksToConfirm, (x) => `${x.link} : ${x.source} → ${x.target} (${x.reason})`, true),
detail('Liens caducs', r.staleLinks, (x) => `${x.link} : ${x.source || '?'} → ${x.target || '?'} (${x.reason})`),
(queued || perime) && right('validate') ? el('div', { className: 'sdm-report-decision' },
el('p', { className: 'sdm-report-decision-text' }, !droppedFiles(referential)
? 'Les fichiers ne sont pas gardés sur le serveur : redéposez les fichiers analysés ci-dessus pour valider ou relancer.'
: perime
? 'La base a changé depuis cette analyse : relancez-la avec les fichiers déposés.'
: dead
? 'Des liens pointent vers un code absent : l\'intégration sera refusée tant qu\'ils ne sont pas corrigés.'
: 'Rien n\'est encore écrit. Validez pour intégrer cette version, ou refusez-la.'),
el('div', { className: 'sdm-report-decision-buttons' },
el('button', { type: 'button', className: 'btn btn-sm btn-outline-danger', onclick: (ev) => busy(ev.currentTarget, () => refuse(id)) }, icon('close'), 'Refuser'),
el('button', { type: 'button', className: 'btn btn-sm btn-outline-secondary', onclick: (ev) => busy(ev.currentTarget, () => rerun(id, referential, ev.currentTarget)) }, icon('refresh'), 'Relancer l\'analyse'),
queued ? el('button', { type: 'button', className: 'btn btn-sm btn-success sdm-report-validate', onclick: (ev) => busy(ev.currentTarget, () => integrate(id, referential, versionNum, ev.currentTarget)) }, icon('validate'), 'Valider et intégrer') : null),
) : null,
);
box.classList.remove('d-none');
box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/** Les fichiers déposés dans les zones, si elles en portent pour ce référentiel. */
function droppedFiles(referential) {
const form = $('#sdmFormImport');
if (form.elements.referential.value !== referential) return null;
const records = new FormData(form);
const dropped = ['fileFr', 'fileEn', 'file'].some((n) => form.elements[n]?.files?.[0]);
return dropped ? records : null;
}
/**
* Rien n'est gardé sur le serveur entre l'analyse et l'intégration : on
* renvoie les fichiers des zones ; le serveur vérifie que ce sont les mêmes.
*/
function filesToResend(referential) {
const records = droppedFiles(referential);
if (!records) notify('warning', 'Redéposez les fichiers analysés dans les zones de dépôt : ils ne sont pas gardés sur le serveur.');
return records;
}
async function integrate(id, referential, versionNum, button) {
const records = filesToResend(referential);
if (!records) return;
const ok = await askConfirmation({
title: `Intégrer la version ${versionNum} ?`,
text: 'Cette action écrit dans les données. Les autres analyses en attente du référentiel deviendront périmées.',
validate: 'Intégrer',
});
if (!ok) return;
await runTask(button, 'POST', `${API}/imports/${id}/integrate`, records);
notify('success', `Version ${versionNum} intégrée.`);
clearImportInput();
await viewImport(id);
await loadImports();
loadConfirm().catch(() => {});
}
/** Refait l'analyse avec les fichiers redéposés dans les zones (TH.4). */
async function rerun(id, referential, button) {
const records = filesToResend(referential);
if (!records) return;
const r = await runTask(button, 'POST', `${API}/imports/${id}/rerun`, records);
const i = await request('GET', `${API}/imports/${id}`);
showReport({
id, state: i.SDM_IM_State, referential: i.SDM_IM_Referential, versionNum: r.versionNum, report: r.report, stats: r.stats, gaps: r.gaps,
});
notify('success', r.gaps.length ? 'Analyse relancée : les chiffres ont changé.' : 'Analyse relancée : rien n\'a changé.');
await loadImports();
}
/**
* L'aide de l'import (TH.12) : versions présentes, version proposée,
* dépendances absentes, fiche Standards et fichiers attendus.
*/
async function loadHelp() {
const referential = $('#sdmFormImport').elements.referential.value;
const box = $('#sdmHelpImport');
if (!referential) { box.classList.add('d-none'); $('#sdmSheetSource').replaceChildren(); return; }
const a = await request('GET', `${API}/imports/help/${encodeURIComponent(referential)}`);
// Jamais pré-remplie : une version oubliée deviendrait « 1 » sans qu'on l'ait choisie.
// La proposée n'est qu'un exemple grisé ; changer de référentiel vide la saisie.
const versionField = $('#sdmFormImport').elements.versionNum;
if (versionField.dataset.referential !== referential) versionField.value = '';
versionField.dataset.referential = referential;
versionField.placeholder = `ex. ${a.proposedVersion}`;
const f = a.sheet;
const active = a.versions.find((v) => String(v.version) === String(a.activeVersion));
// Comme Standards : la version active en repère sous le titre, la source d'extraction au-dessus du traitement.
fill($('#sdmSheetSource'), sheetBlock(referential, f));
fill(box,
el('p', { className: 'sdm-integ-active' }, a.activeVersion
? [el('span', { className: 'sdm-integ-active-title' }, 'Version active'), el('strong', {}, a.activeVersion),
active ? el('span', { className: 'sdm-integ-active-nb' }, plural(active.nb, 'code', 'codes')) : null,
el('span', { className: 'sdm-integ-active-nb' }, `proposée : ${a.proposedVersion}`)]
: [el('span', { className: 'sdm-integ-active-title' }, 'Aucune version intégrée'), 'ce sera la première.']),
a.versions.length > 1 ? el('div', { className: 'sdm-help' },
`Versions présentes : ${a.versions.map((v) => `${v.version} (${v.nb})`).join(', ')}`) : null,
a.missingDependencies.length ? el('div', { className: 'sdm-alert' }, icon('alert'),
` Dépend de ${a.missingDependencies.join(', ')}, qui n'a encore aucune version : importez-le d'abord.`) : null,
el('div', { className: 'sdm-help' },
a.format.flat.length ? ['Format plat : la ligne 1 porte ', el('span', { className: 'sdm-code' }, a.format.flat.join(', ')), '.'] : null,
a.format.source === 'bilingual' ? ' Ou les deux classeurs source FR et EN (traitement Standards).' : null,
a.format.source && a.format.source !== 'bilingual' ? ` Ou, sans les fichiers FR et EN, ${a.format.expected} (traitement Standards ${a.format.source}).` : null));
box.classList.remove('d-none');
showSourceDropzones(a.format.source);
}
/**
* La source d'extraction du référentiel (E28), lue dans sa fiche SDM. Comme
* Standards, toujours ouverte : les liens et le mode opératoire restent sous
* les yeux de qui extrait, et « Enregistrer » n'apparaît qu'une fois la fiche
* touchée. Sans le droit d'écrire, les mêmes champs, en lecture seule.
*/
function sheetBlock(referential, f) {
const sheet = f || { sourceUrls: [], procedure: '', internalOnly: false };
const write = right('write');
const links = [...sheet.sourceUrls];
const block = el('div', { className: 'sdm-sheet-source' });
const footer = el('div', { className: 'sdm-footer d-none' });
const touched = () => footer.classList.remove('d-none');
const list = el('ul', { className: 'sdm-sheet-links' });
const drawLinks = () => fill(list, links.length
? links.map((u, i) => el('li', {},
el('a', { href: u, target: '_blank', rel: 'noopener noreferrer' }, icon('external'), el('span', {}, u)),
write ? el('button', {
type: 'button', className: 'sdm-sheet-remove', title: 'Retirer ce lien', 'aria-label': `Retirer ${u}`,
onclick: () => { links.splice(i, 1); drawLinks(); touched(); },
}, icon('trash')) : null))
: el('li', { className: 'sdm-sheet-none' }, 'Aucun lien renseigné.'));
const fresh = el('input', { type: 'url', className: 'form-control form-control-sm', placeholder: 'https://…' });
const add = () => {
const value = fresh.value.trim();
if (!value) return;
if (!/^https?:\/\/\S+$/i.test(value)) { notify('alert', 'Lien attendu en http:// ou https://'); return; }
if (!links.includes(value)) links.push(value);
fresh.value = '';
drawLinks();
touched();
fresh.focus();
};
fresh.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { ev.preventDefault(); add(); } });
const internal = el('input', { type: 'checkbox', className: 'custom-control-input', id: 'sdmSheetInternal', disabled: !write });
internal.checked = sheet.internalOnly;
internal.addEventListener('change', touched);
const procedure = el('textarea', { className: 'form-control form-control-sm', rows: 5, maxLength: 20000, readOnly: !write,
placeholder: write ? 'Les étapes pour obtenir les fichiers : où cliquer, quoi télécharger, quelles options…' : 'Aucun mode opératoire renseigné.' });
procedure.value = sheet.procedure;
procedure.addEventListener('input', touched);
const save = el('button', { type: 'button', className: 'btn btn-primary btn-sm' }, 'Enregistrer');
save.addEventListener('click', () => busy(save, async () => {
// Un lien tapé sans cliquer « Ajouter » n'est pas perdu.
if (fresh.value.trim()) add();
await request('PUT', `${API}/referentials/${encodeURIComponent(referential)}`, {
Sources: links, Procedure: procedure.value, InterneSeul: internal.checked,
});
notify('success', "Source d'extraction enregistrée.");
await loadHelp();
}));
fill(footer,
el('button', { type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: () => loadHelp() }, 'Annuler'),
save);
drawLinks();
fill(block,
el('div', { className: 'sdm-sheet-head' }, el('strong', {}, "Source d'extraction")),
el('div', { className: 'custom-control custom-switch' }, internal,
el('label', { className: 'custom-control-label', htmlFor: 'sdmSheetInternal' }, 'Usage interne X-One uniquement')),
// Un div, pas un label : le label renverrait ses clics vers le bouton « Ajouter ».
el('div', { className: 'sdm-field' }, el('span', {}, "Lien vers la source d'extraction des données"),
write ? el('div', { className: 'sdm-sheet-add' }, fresh,
el('button', { type: 'button', className: 'btn btn-outline-secondary btn-sm', onclick: add }, icon('plus'), ' Ajouter')) : null,
list),
el('label', { className: 'sdm-field' }, el('span', {}, "Mode opératoire d'extraction des données"), procedure),
footer);
return block;
}
/** Les zones de l'extraction source : celles de la sorte du traitement, vidées sinon. */
function showSourceDropzones(kind) {
const block = $('#sdmDropzonesSource');
let visible = false;
block.querySelectorAll('.sdm-dropzone').forEach((zone) => {
const shown = Boolean(kind) && zone.dataset.kind.split(' ').includes(kind);
zone.classList.toggle('d-none', !shown);
if (!shown && zone.querySelector('input').value) {
zone.querySelector('input').value = '';
zone.querySelector('input').dispatchEvent(new Event('change'));
}
visible = visible || shown;
});
block.querySelector('[data-label-source]').textContent = kind === 'risks' ? 'Niveaux 1 à 3'
: kind === 'csv' ? 'Extraction CSV' : 'Source';
block.classList.toggle('d-none', !visible);
}
async function refuse(id) {
await request('POST', `${API}/imports/${id}/refuse`);
notify('info', 'Import refusé.');
await viewImport(id);
await loadImports();
}
/** Après une intégration : zones de dépôt et version vidées, pour ne pas réimporter par erreur. */
function clearImportInput() {
const form = $('#sdmFormImport');
form.querySelectorAll('.sdm-dropzone input[type=file]').forEach((field) => {
if (!field.value) return;
field.value = '';
field.dispatchEvent(new Event('change'));
});
form.elements.versionNum.value = '';
}
/** Zones de dépôt : nom et taille du fichier choisi, surbrillance au survol. */
function wireDropzones() {
document.querySelectorAll('.sdm-dropzone').forEach((zone) => {
const field = zone.querySelector('input[type=file]');
const name = zone.querySelector('.sdm-dropzone-name');
const prompt = name.textContent;
const show = () => {
const f = field.files[0];
zone.classList.toggle('charge', Boolean(f));
name.textContent = f ? `${f.name} · ${size(f.size)}` : prompt;
};
field.addEventListener('change', show);
['dragenter', 'dragover'].forEach((t) => field.addEventListener(t, () => zone.classList.add('survol')));
['dragleave', 'drop'].forEach((t) => field.addEventListener(t, () => zone.classList.remove('survol')));
});
}
// ── Reprise des versions Standards ─────────────────────────────────────
async function dataBackfill() {
const referential = $('#sdmFormImport').elements.referential.value;
if (!referential) return;
const ok = await askConfirmation({
title: 'Reprendre les versions Standards ?',
text: `Toutes les versions Standards de ${refName(referential)} sont rejouées dans les trios, de la plus ancienne à la plus récente. Cette action écrit dans les données.`,
validate: 'Reprendre',
});
if (!ok) return;
const r = await request('POST', `${API}/backfill/data`, { referential });
const STATE = { backfill: 'reprise', deja_presente: 'déjà présente' };
const box = $('#sdmBackfillData');
box.dataset.referential = referential;
box.replaceChildren(
el('strong', {}, `Reprise des données ${referential}`),
el('ul', {}, ...r.map((v) => el('li', {},
el('strong', {}, v.versionNum), ` : ${STATE[v.state] || v.state}`,
v.state === 'backfill' ? ` — ${v.backfilledCodes} codes repris / ${v.codesStandards} dans Standards `
+ `(${v.matching ? 'concordant' : 'ÉCART'}), ${v.createdIdentities} items créés, `
+ `${v.createdContents} contenus créés, ${v.reusedContents} réutilisés`
+ (v.truncatedLabels ? `, ${v.truncatedLabels} libellé(s) tronqué(s) par Standards` : '') : '',
))),
);
box.classList.remove('d-none');
await loadImports();
}
// ── Migrations ─────────────────────────────────────────────────────────
function migrationObject(plan) {
if (plan.type === 'entities') return `${plan.referential} : ${plan.entities.join(', ')}`;
if (plan.type === 'link') return `lien ${plan.source}.${plan.attribute} → ${plan.target} (${plan.table})`;
if (plan.type === 'view') return `vue ${plan.view}`;
return plan.type || '';
}
async function loadMigrations() {
const refs = [...new Set(state.entities.map((e) => e.Referentiel))].sort();
const choice = $('#sdmRefView');
const before = choice.value;
// TH.8 : « tous » engendre une migration par vue.
fill(choice, refs.length > 1 ? el('option', { value: '*' }, 'Tous les référentiels') : null, refOptions(refs));
if (before === '*' || refs.includes(before)) choice.value = before;
updateButtons();
skeleton('#sdmTableMigrations tbody', 7);
const migrations = await request('GET', `${API}/migrations`);
summary.migrations = migrations;
showPath();
const pending = migrations.filter((m) => m.state === 'a_jouer').length;
pill('#sdmNbPending', pending, 'en attente');
fillTable('#sdmCardMigrations', '#sdmTableMigrations tbody', migrations.map((m) => el('tr', { className: m.state === 'a_jouer' ? 'sdm-row-pending' : '' },
el('td', { className: 'sdm-code sdm-col-file', title: m.file }, m.file,
m.models && m.models.length ? el('div', {
className: 'sdm-help',
title: `Modèles Sequelize engendrés (src/database/models) :\n${m.models.map((n) => `db.${n}`).join('\n')}`,
}, `modèles : ${m.models.slice(0, 2).map((n) => `db.${n}`).join(', ')}${m.models.length > 2 ? ` +${m.models.length - 2}` : ''}`) : null),
el('td', {}, migrationObject(m.plan)),
el('td', {}, stateDot(`mg-${m.state}`, MIGRATION_STATES[m.state] || m.state)),
el('td', {}, m.present ? 'oui' : 'non'),
el('td', { className: 'text-nowrap' }, date(m.generatedAt), m.generatedBy ? el('div', { className: 'sdm-help' }, m.generatedBy) : null),
el('td', { className: 'text-nowrap' }, date(m.playedAt)),
el('td', { className: 'text-right text-nowrap' }, m.state === 'a_jouer' && right('mark')
? el('button', {
type: 'button', className: 'btn btn-outline-secondary btn-sm text-nowrap',
disabled: !m.present,
title: m.present ? '' : 'Indisponible : ses objets ne sont pas en base — jouez-la d\'abord',
onclick: (ev) => busy(ev.currentTarget, async () => {
await request('PUT', `${API}/migrations/${m.id}/played`);
notify('success', `${m.file} marquée jouée.`);
await Promise.all([loadMigrations(), loadEntities()]);
}),
}, icon('validate'), 'Marquer jouée') : null,
undoButton(m)),
)), '#sdmMigrationsEmpty', ['migrations', 'Aucune migration engendrée', 'Engendrez la migration d\'un référentiel depuis l\'onglet Entités.']);
}
/** DEV : supprimer une migration en attente, annuler (down) une migration jouée. */
function undoButton(m) {
if (!state.playMigrations) return null;
const queued = m.state === 'a_jouer';
if (!right(queued ? 'generate' : 'mark')) return null;
const confirmation = queued
? {
title: 'Supprimer la migration ?',
text: `Le fichier est supprimé${m.models.length ? ', avec ses modèles Sequelize' : ''}.`,
list: [m.file, ...m.models.map((n) => `db.${n}`)],
validate: 'Supprimer',
danger: true,
}
: {
title: 'Annuler la migration ?',
text: `Son « down » est joué : ses tables et toutes leurs données sont supprimées${
m.plan.type === 'entities' ? `, ainsi que les imports de ${m.plan.referential} et les décisions de liens de ses entités` : ''
}. La migration repasse « à jouer ».`,
list: [m.file],
validate: 'Annuler la migration',
danger: true,
};
return el('button', {
type: 'button', className: 'btn btn-outline-danger btn-sm text-nowrap ml-1',
disabled: Boolean(m.undo),
title: m.undo ? `Indisponible : ${m.undo}`
: queued ? 'Supprimer le fichier et ses modèles (DEV)' : 'Rollback : jouer le down (DEV)',
onclick: async (ev) => {
const button = ev.currentTarget;
if (!(await askConfirmation(confirmation))) return;
busy(button, async () => {
if (queued) {
const r = await request('DELETE', `${API}/migrations/${m.id}`);
notify('success', `${r.file} supprimée${r.models.length ? ` avec ${r.models.length} modèle(s) — redémarrez le serveur` : ''}.`);
} else {
const r = await request('POST', `${API}/migrations/${m.id}/undo`);
notify('success', `${r.file} annulée${r.imports || r.decisions ? ` (${r.imports} import(s), ${r.decisions} décision(s) retirés)` : ''}.`);
}
await loadEntities();
await loadMigrations();
});
},
}, icon(queued ? 'trash' : 'undo'), queued ? 'Supprimer' : 'Annuler');
}
async function diff() {
const gaps = await request('GET', `${API}/schema/diff`);
const box = $('#sdmDiff');
fill(box,
el('strong', {}, gaps.length ? `${gaps.length} écart(s) entre les déclarations et la base` : 'Aucun écart : la base suit les déclarations.'),
gaps.length ? el('ul', {}, ...gaps.map((e) => el('li', {},
el('span', { className: 'sdm-code' }, e.object), ` — ${e.finding} → ${e.action}`))) : null,
);
box.classList.remove('d-none');
}
// ── Liens ──────────────────────────────────────────────────────────────
const links = { links: [], entities: [] };
/** source ─attribut→ cible */
function linkDrawing(source, attribute, target) {
return el('span', { className: 'sdm-link' },
el('span', { className: 'sdm-link-end' }, source),
el('span', { className: 'sdm-link-arrow' }, attribute ? el('span', {}, attribute) : null, icon('arrow')),
el('span', { className: 'sdm-link-end' }, target));
}
/**
* L'arbre des liens (E37), sur le modèle de la Hiérarchie Standards.
*
* Un lien dit « la source pointe vers la cible » : la cible est donc le
* parent dans l'arbre, la source son enfant. La parenté déclarée sur l'entité
* (ParenteCode) compte aussi, quand aucun lien ne la porte déjà : l'arbre se
* lit avant même la reprise des liens. Les liens étant n,n, une entité
* apparaît autant de fois qu'elle est visée ; une branche déjà parcourue ne se
* redéploie pas.
*/
const KINDS = { natif: 'native', declare: 'à rapprocher', parente: 'parenté' };
/** `draft` : le lien en cours de déclaration dans la fiche, ou null. */
const tree = { selected: null, hideIsolated: false, draft: null };
function linkEdges() {
const known = new Set(links.entities.map((e) => e.Code));
const out = links.links.filter((l) => known.has(l.source) && known.has(l.target));
const carried = new Set(out.map((l) => `${l.source}|${l.target}`));
links.entities.forEach((e) => {
if (e.ParenteCode && known.has(e.ParenteCode) && !carried.has(`${e.Code}|${e.ParenteCode}`)) {
out.push({ source: e.Code, target: e.ParenteCode, nature: 'parente', active: true });
}
});
return out;
}
function renderLinkTree() {
const host = $('#sdmLinkTree');
const edges = linkEdges();
const byCode = new Map(links.entities.map((e) => [e.Code, e]));
const children = new Map();
edges.forEach((l) => {
if (!children.has(l.target)) children.set(l.target, []);
children.get(l.target).push(l);
});
children.forEach((list) => list.sort((a, b) => a.source.localeCompare(b.source)));
const order = (a, b) => byCode.get(a).Referentiel.localeCompare(byCode.get(b).Referentiel) || a.localeCompare(b);
const reached = new Set();
const card = (code, edge, path) => {
reached.add(code);
const entity = byCode.get(code);
const looped = path.includes(code);
const kids = looped ? [] : children.get(code) || [];
const toConfirm = edge && edge.decisions ? edge.decisions.propose : 0;
const selected = tree.selected === code;
return el('li', { role: 'treeitem', 'aria-selected': String(selected), ...(kids.length ? { 'aria-expanded': 'true' } : {}) },
el('button', {
type: 'button', dataset: { code },
className: `sdm-lx-card${selected ? ' is-selected' : ''}${looped ? ' is-loop' : ''}${edge && !edge.active ? ' is-inactive' : ''}`,
onclick: () => selectEntity(code),
},
el('span', { className: 'sdm-lx-code' }, code),
el('span', { className: 'sdm-lx-tag' }, entity.Referentiel),
edge && (edge.attribute || edge.targetAttribute)
? el('span', { className: 'sdm-lx-meta' }, `${edge.attribute || '—'} → ${edge.targetAttribute || 'code'}`) : null,
edge && edge.cardinality ? el('span', { className: 'sdm-lx-meta sdm-code' }, edge.cardinality) : null,
toConfirm ? el('span', { className: 'sdm-lx-flag' }, `${toConfirm} à confirmer`) : null,
edge && edge.tableState === 'a_engendrer' ? el('span', { className: 'sdm-lx-flag' }, 'table à engendrer') : null,
edge && !edge.active ? dot('inactif') : null,
looped ? el('span', { className: 'sdm-lx-loop' }, 'déjà parcouru') : null,
edge ? el('span', { className: `sdm-lx-kind sdm-lx-kind-${edge.nature}` }, KINDS[edge.nature]) : null),
kids.length ? el('ul', { role: 'group' }, ...kids.map((k) => card(k.source, k, path.concat(code)))) : null);
};
// Les racines se déduisent : les entités liées qui ne pointent vers rien.
// Un cycle n'en a pas ; ce qui reste hors d'atteinte devient racine à son tour.
const involved = [...new Set(edges.flatMap((l) => [l.source, l.target]))].sort(order);
const sources = new Set(edges.map((l) => l.source));
const roots = involved.filter((c) => !sources.has(c)).map((c) => card(c, null, []));
involved.forEach((c) => { if (!reached.has(c)) roots.push(card(c, null, [])); });
const isolated = links.entities.map((e) => e.Code).filter((c) => !involved.includes(c)).sort(order);
if (!links.entities.length) {
fill(host, empty('links', 'Aucune entité déclarée', 'Déclarez des entités dans l\'onglet Entités : leurs liens s\'afficheront ici.'));
return;
}
fill(host,
roots.length
? el('ul', { className: 'sdm-lx-list', role: 'group' }, ...roots)
: el('p', { className: 'sdm-lx-none' }, 'Aucun lien : reprenez les liens existants, ou choisissez une entité et déclarez-en un.'),
isolated.length && !tree.hideIsolated
? el('div', { className: 'sdm-lx-isolated' },
el('h4', {}, `Sans lien (${isolated.length})`),
el('ul', { className: 'sdm-lx-list sdm-lx-flat', role: 'group' }, ...isolated.map((c) => card(c, null, []))))
: null);
}
function selectEntity(code) {
tree.selected = code;
renderLinkTree();
renderLinkSide();
// L'arbre se redessine : on rend le focus à la carte cliquée.
const again = [...$('#sdmLinkTree').querySelectorAll('.sdm-lx-card')].find((b) => b.dataset.code === code);
if (again) again.focus();
}
/** Les gestes d'un lien enregistré ; une parenté seule n'en a pas. */
function linkActions(l) {
if (!l.id) return [];
const actions = [];
if (l.tableState === 'a_engendrer' && right('generate')) {
actions.push(el('button', {
type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm sdm-lx-btn-primary',
onclick: (ev) => busy(ev.currentTarget, async () => {
const r = await request('POST', `${API}/migrations`, { linkId: l.id });
notify('success', `Migration engendrée : ${r.file} — ${r.models.length} modèle(s) Sequelize.`);
await loadLinks();
}),
}, icon('code'), 'Engendrer la table'));
}
if (l.nature === 'declare') {
actions.push(el('button', {
type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm', onclick: (ev) => busy(ev.currentTarget, () => viewInstances(l)),
}, icon('eye'), 'Instances'));
}
if (right('link')) {
actions.push(el('button', {
type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm',
onclick: (ev) => busy(ev.currentTarget, async () => {
await request('PUT', `${API}/links/${l.id}`, { active: !l.active });
await loadLinks();
}),
}, l.active ? 'Désactiver' : 'Activer'));
}
return actions;
}
/** Une ligne « libellé : valeur » de carte de lien, comme les besoins de Standards. */
const linkLine = (name, ...value) => el('div', { className: 'sdm-lx-line' },
el('span', { className: 'sdm-lx-label' }, name), el('span', { className: 'sdm-lx-value' }, ...value));
/** Un lien de la fiche. `side` : l'extrémité à nommer (la cible pour « Pointe vers », la source pour « Visée par »). */
function linkItem(l, side) {
const d = l.decisions;
const instances = d && l.nature === 'declare'
? `${d.confirme + d.reconduit} établi(s)${d.propose ? `, ${d.propose} à confirmer` : ''}${d.caduc ? `, ${d.caduc} caduc(s)` : ''}${d.rejete ? `, ${d.rejete} rejeté(s)` : ''}`
: '';
const actions = linkActions(l);
const end = side === 'target'
? linkLine('Cible', el('strong', {}, l.target), l.targetAttribute ? ` · ${l.targetAttribute}` : '')
: linkLine('Source', el('strong', {}, l.source), l.attribute ? ` · ${l.attribute}` : '');
return el('li', { className: `sdm-lx-item${l.active ? '' : ' is-inactive'}` },
el('div', { className: 'sdm-lx-item-head' },
el('span', { className: `sdm-lx-kind sdm-lx-kind-${l.nature}` }, KINDS[l.nature]),
l.active ? null : dot('inactif')),
end,
side === 'target' && l.nature === 'natif' ? linkLine('Attribut source', l.attribute || '— aucun (pas de table)') : null,
side === 'source' && l.targetAttribute ? linkLine('Attribut cible', l.targetAttribute) : null,
l.cardinality ? linkLine('Cardinalité', el('span', { className: 'sdm-code' }, l.cardinality)) : null,
l.table ? linkLine('Table', el('span', { className: 'sdm-code' }, l.table), ' ', stateDot(l.tableState, TABLE_STATES[l.tableState])) : null,
instances ? linkLine('Instances', instances) : null,
l.nature === 'parente' ? el('p', { className: 'sdm-lx-note' }, 'Parenté déclarée sur l\'entité, pas encore reprise en lien : « Reprendre les liens existants » la crée.') : null,
actions.length ? el('div', { className: 'sdm-lx-item-foot' }, ...actions) : null);
}
/**
* Le lien en cours de déclaration, dans la fiche (le « Nouveau » des besoins
* Standards). La nature se choisit d'abord, elle commande le reste : seul un
* lien natif a un attribut source, porté par une colonne du fichier.
*/
function draftItem() {
const draft = tree.draft;
const entityOf = (code) => links.entities.find((e) => e.Code === code);
const options = (list, selected, first) => [
el('option', { value: '' }, first),
...list.map(([value, text]) => el('option', { value, selected: value === selected }, text)),
];
const attributesOf = (code) => (entityOf(code)?.attributes || [])
.map((a) => [a.Nom, `${a.Nom}${a.Role !== 'aucun' ? ` (${a.Role})` : ''}`]);
const set = (key) => (ev) => {
draft[key] = ev.target.type === 'checkbox' ? ev.target.checked : ev.target.value;
if (key === 'source') {
draft.attribute = '';
// La fiche suit la source choisie.
if (draft.source) { tree.selected = draft.source; renderLinkTree(); }
}
if (key === 'target') draft.targetAttribute = '';
renderLinkSide();
};
const select = (key, list, first, extra = {}) => el('select', {
className: 'form-control form-control-sm sdm-lx-part', onchange: set(key), ...extra,
}, ...options(list, draft[key], first));
const entities = links.entities.map((e) => [e.Code, `${e.Code} (${e.Referentiel})`]);
const complete = draft.source && draft.target;
return el('li', { className: `sdm-lx-item sdm-lx-draft${complete ? '' : ' is-incomplete'}` },
el('div', { className: 'sdm-lx-item-head' },
el('label', { className: 'sdm-lx-check' },
el('input', { type: 'checkbox', checked: draft.native, onchange: set('native') }), ' Liaison native'),
el('button', {
type: 'button', className: 'sdm-lx-del', 'aria-label': 'Abandonner ce lien',
onclick: () => { tree.draft = null; renderLinkSide(); },
}, icon('trash'))),
el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Source'),
select('source', entities, '—', { 'aria-label': 'Entité source' })),
draft.native
? el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Attribut source'),
select('attribute', attributesOf(draft.source), '— aucun (pas de table) —', { 'aria-label': 'Attribut source', disabled: !draft.source }))
: null,
el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Entité / attr. cible'),
select('target', entities, '—', { 'aria-label': 'Entité cible' }),
select('targetAttribute', attributesOf(draft.target), '— code —', { 'aria-label': 'Attribut cible', disabled: !draft.target })),
el('div', { className: 'sdm-lx-line' }, el('span', { className: 'sdm-lx-label' }, 'Cardinalité'),
el('input', {
className: 'form-control form-control-sm sdm-lx-part sdm-lx-short', value: draft.cardinality, pattern: '[01n],[01n]',
'aria-label': 'Cardinalité', oninput: (ev) => { draft.cardinality = ev.target.value; },
})),
complete ? null : el('p', { className: 'sdm-lx-warn-line' }, 'Lien à compléter : choisissez la source et la cible.'),
el('div', { className: 'sdm-lx-item-foot' },
el('button', {
type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm sdm-lx-btn-primary', disabled: !complete,
onclick: (ev) => busy(ev.currentTarget, createLink),
}, icon('links'), 'Déclarer')));
}
/** Ouvre un lien en cours de déclaration, l'entité choisie (sinon la première) comme source. */
function newLink() {
const source = tree.selected || (links.entities[0] && links.entities[0].Code) || '';
tree.draft = { native: true, source, attribute: '', target: '', targetAttribute: '', cardinality: 'n,n' };
if (source && tree.selected !== source) selectEntity(source);
else renderLinkSide();
$('#sdmLinkSide').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
async function createLink() {
const d = tree.draft;
if (!/^[01n],[01n]$/.test(d.cardinality)) {
notify('error', 'Cardinalité : de la forme 1,n');
return;
}
await request('POST', `${API}/links`, {
source: d.source, attribute: d.native ? d.attribute || null : null, target: d.target,
targetAttribute: d.targetAttribute || null, nature: d.native ? 'natif' : 'declare', cardinality: d.cardinality,
});
notify('success', 'Lien déclaré.');
tree.selected = d.source;
tree.draft = null;
await loadLinks();
}
function renderLinkSide() {
const side = $('#sdmLinkSide');
const edges = linkEdges();
const count = (nature) => links.links.filter((l) => l.nature === nature).length;
const toConfirm = links.links.reduce((n, l) => n + l.decisions.propose, 0);
const entity = links.entities.find((e) => e.Code === tree.selected);
if (!entity) tree.selected = null;
const outgoing = entity ? edges.filter((l) => l.source === entity.Code) : [];
const incoming = entity ? edges.filter((l) => l.target === entity.Code) : [];
const draft = tree.draft ? draftItem() : null;
const head = (title, n, button) => el('div', { className: 'sdm-lx-head' },
el('h5', { className: 'sdm-lx-subtitle' }, title, ' ', el('span', { className: 'sdm-lx-count' }, String(n))), button);
fill(side,
el('section', { className: 'sdm-lx-box' },
el('h4', { className: 'sdm-lx-title' }, 'Vue d\'ensemble'),
el('ul', { className: 'sdm-lx-sum' },
el('li', {}, el('strong', {}, String(links.entities.length)), ' entités'),
el('li', {}, el('strong', {}, String(count('natif'))), ' liaison(s) native(s)'),
el('li', {}, el('strong', {}, String(count('declare'))), ' liaison(s) à rapprocher'),
el('li', {}, el('strong', {}, String(edges.filter((l) => l.nature === 'parente').length)), ' parenté(s) pas encore reprise(s)'),
toConfirm ? el('li', { className: 'sdm-lx-warn' }, el('strong', {}, String(toConfirm)), ' instance(s) à confirmer') : null)),
entity
? el('section', { className: 'sdm-lx-box' },
el('div', { className: 'sdm-lx-title-line' },
el('h4', { className: 'sdm-lx-title' }, entity.Code),
el('span', { className: 'sdm-lx-sub' }, `${entity.Referentiel} · ${plural(entity.attributes.length, 'attribut', 'attributs')}`)),
head('Pointe vers', outgoing.length, right('link') && !tree.draft
? el('button', { type: 'button', className: 'sdm-lx-btn sdm-lx-btn-sm', onclick: newLink }, icon('plus'), 'Nouveau')
: null),
draft || outgoing.length
? el('ul', { className: 'sdm-lx-items' }, draft, ...outgoing.map((l) => linkItem(l, 'target')))
: el('p', { className: 'sdm-lx-hint' }, 'Cette entité ne pointe vers aucune autre.'),
head('Visée par', incoming.length, null),
incoming.length
? el('ul', { className: 'sdm-lx-items' }, ...incoming.map((l) => linkItem(l, 'source')))
: el('p', { className: 'sdm-lx-hint' }, 'Aucune entité ne pointe vers elle.'))
: draft
? el('section', { className: 'sdm-lx-box' }, head('Nouveau lien', '', null), el('ul', { className: 'sdm-lx-items' }, draft))
: el('p', { className: 'sdm-lx-empty' }, 'Choisissez une entité dans l\'arbre pour voir et déclarer ses liens.'));
}
async function loadLinks() {
Object.assign(links, await request('GET', `${API}/links`));
summary.links = links.links;
showPath();
renderLinkTree();
renderLinkSide();
}
async function linkBackfill() {
const r = await request('POST', `${API}/links/backfill`);
const box = $('#sdmBackfillLinks');
const c = r.matches || { cards: 0, backfills: 0, already: 0, ignored: [], unpublishedCards: 0 };
box.replaceChildren(
el('strong', {}, `Liens repris : ${r.created.length}`),
el('ul', {},
...r.created.map((t) => el('li', { className: 'sdm-code' }, t)),
...r.ignored.map((t) => el('li', {}, `ignoré — ${t}`))),
// T3.2.1 : les correspondances code à code des cartes publiées de Standards.
el('strong', {}, `Correspondances reprises des cartes Standards : ${c.backfills}`),
el('ul', {},
el('li', {}, c.cards
? `${plural(c.cards, 'carte publiée lue', 'cartes publiées lues')}${c.already ? `, ${c.already} correspondance(s) déjà présente(s)` : ''}`
: 'aucune carte publiée dans Standards'),
c.unpublishedCards ? el('li', {}, `${plural(c.unpublishedCards, 'carte jamais publiée', 'cartes jamais publiées')} : non reprise(s)`) : null,
...c.ignored.map((t) => el('li', {}, `ignoré — ${t}`))),
);
box.classList.remove('d-none');
await loadLinks();
}
let openLink = null;
async function viewInstances(l) {
const instances = await request('GET', `${API}/links/${l.id}/decisions`);
openLink = l;
$('#sdmInstancesTitle').replaceChildren(linkDrawing(l.source, l.attribute, l.target));
const rows = instances.map((d) => el('tr', {},
el('td', { className: 'sdm-code' }, d.sourceCode || d.sourceId),
el('td', { className: 'sdm-code' }, d.targetCode || d.targetId),
el('td', {}, dot(d.versionNum)),
el('td', {}, stateDot(`dc-${d.state}`, DECISION_STATES[d.state] || d.state)),
el('td', {}, d.decidedBy || (d.state === 'reconduit' || d.state === 'caduc' ? 'rejeu' : '')),
el('td', {}, d.reason || ''),
));
$('#sdmTableInstances tbody').replaceChildren(...rows);
$('#sdmTableInstances').classList.toggle('d-none', !rows.length);
$('#sdmInstancesEmpty').replaceChildren(...(rows.length ? [] : [empty('links', 'Aucune instance', 'Établissez le lien entre deux codes ci-dessus.')]));
if (!$('#sdmInstances').classList.contains('ouvert')) openDrawer('#sdmInstances');
}
async function establish(ev) {
const f = ev.target;
if (!f.reportValidity() || !openLink) return;
const v = f.elements;
const r = await request('POST', `${API}/links/${openLink.id}/decisions`, {
sourceCode: v.sourceCode.value.trim(), targetCode: v.targetCode.value.trim(), versionNum: v.versionNum.value.trim() || undefined,
});
notify('success', `Lien établi pour la version ${r.versionNum}.`);
f.reset();
await viewInstances(openLink);
await loadLinks();
}
// ── À confirmer (bloc de l'onglet Liens) ───────────────────────────────
function pill(sel, n, text) {
const p = $(sel);
p.textContent = n ? String(n) : '';
p.setAttribute('aria-label', `${n} ${text}`);
p.classList.toggle('d-none', !n);
}
async function loadConfirm() {
const queue = await request('GET', `${API}/links/to-confirm`);
summary.confirm = queue;
showPath();
pill('#sdmNbConfirm', queue.length, 'à confirmer');
const list = $('#sdmListConfirm');
$('#sdmBlockConfirm').classList.toggle('d-none', !queue.length);
if (!queue.length) {
list.replaceChildren();
return;
}
list.replaceChildren(...queue.map((d) => {
const reason = el('input', {
className: 'form-control form-control-sm', placeholder: 'Motif (facultatif)',
'aria-label': `Motif pour ${d.sourceCode} → ${d.targetCode}`, maxLength: 500,
});
const settle = (decisionState) => (ev) => busy(ev.currentTarget, async () => {
await request('PUT', `${API}/links/decisions/${d.id}`, { state: decisionState, reason: reason.value.trim() || undefined });
notify('success', `${d.sourceCode} → ${d.targetCode} : ${DECISION_STATES[decisionState]}.`);
await loadConfirm();
});
return el('article', { className: 'sdm-card sdm-decision', role: 'listitem' },
el('div', { className: 'sdm-decision-head' },
el('strong', {}, d.link),
dot(`version ${d.versionNum}`, 'sdm-dot-accent'),
stateDot('dc-propose', DECISION_STATES.propose)),
linkDrawing(d.sourceCode || d.sourceId, null, d.targetCode || d.targetId),
d.reason ? el('p', { className: 'sdm-decision-why' }, d.reason) : null,
right('confirm') ? [
reason,
el('div', { className: 'sdm-footer' },
el('button', { type: 'button', className: 'btn btn-outline-danger btn-sm', onclick: settle('rejete') }, icon('close'), 'Rejeter'),
el('button', { type: 'button', className: 'btn btn-success btn-sm', onclick: settle('confirme') }, icon('validate'), 'Confirmer')),
] : null,
);
}));
}
// ── Test API (compat Standards) ────────────────────────────────────────
/** Les champs de chaque fonction : [nom, libellé, genre, indication]. */
const API_FIELDS = {
getReferentials: [['languageCode', 'Langue', 'lang'], ['isActive', 'Actives', 'triple']],
getReferentialData: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang'], ['version', 'Version', 'short'],
['itemCodes', 'Codes', 'texte', 'A,B,C'], ['type', 'Type', 'short'], ['maxLevel', 'Niveau max', 'short'],
['withParent', 'Avec parents', 'case'], ['normalizeCode', 'Normaliser', 'case'], ['includeQuestions', 'Questions', 'case']],
searchItems: [['refCode', 'Référentiel', 'ref'], ['query', 'Recherche', 'texte'], ['languageCode', 'Langue', 'lang'],
['version', 'Version', 'short'], ['limit', 'Limite', 'short', '50']],
getChildren: [['refCode', 'Référentiel', 'ref'], ['parentCode', 'Parent', 'texte', 'vide = racines'],
['languageCode', 'Langue', 'lang'], ['version', 'Version', 'short']],
getActiveVersions: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang']],
getAllVersions: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang']],
getReferenceTextFromKeys: [['refCode', 'Référentiel', 'ref'], ['languageCode', 'Langue', 'lang'],
['itemCodes', 'Codes', 'texte', 'A,B,C'], ['withCode', 'Avec code', 'case']],
getXoneStandardLabel: [['section', 'Section', 'texte'], ['code', 'Code', 'texte'], ['languageCode', 'Langue', 'lang']],
};
/** Une phrase par fonction, comme la liste de l'onglet Test API de Standards. */
const API_DESCRIPTIONS = {
getReferentials: 'Liste tous les référentiels',
getReferentialData: 'Récupère les données d\'un référentiel',
searchItems: 'Recherche dans un référentiel',
getChildren: 'Enfants d\'un élément (racines si vide)',
getActiveVersions: 'Versions actives',
getAllVersions: 'Toutes les versions',
getReferenceTextFromKeys: 'Libellés à partir des codes',
getXoneStandardLabel: 'Libellé d\'un standard X-One',
};
const API_TYPES = { lang: 'select', triple: 'select', case: 'boolean', short: 'string', text: 'string', ref: 'string' };
// Les paramètres obligatoires, marqués d'un astérisque comme dans Standards.
const API_REQUIRED = new Set(['refCode', 'query', 'section', 'code']);
let apiFunction = 'getReferentials';
/** Un paramètre, au gabarit de Standards : nom, type, astérisque, puis la saisie. */
function apiField([name, , kind, hint]) {
const required = kind === 'ref' || API_REQUIRED.has(name);
let input;
if (kind === 'lang' || kind === 'triple' || kind === 'case') {
const choice = {
lang: [['', 'Toutes les langues'], ['fr', 'fr'], ['en', 'en']],
triple: [['', 'Toutes les versions'], ['true', 'Versions actives uniquement'], ['false', 'Versions inactives uniquement']],
case: [['', 'Non (par défaut)'], ['true', 'Oui']],
}[kind];
input = el('select', { name: name, id: `sdmApiParam_${name}`, className: 'std-form-control' },
choice.map(([v, t]) => el('option', { value: v }, t)));
} else {
input = el('input', {
type: 'text', name: name, id: `sdmApiParam_${name}`, className: 'std-form-control',
placeholder: hint ? `Ex : ${hint}` : (kind === 'ref' ? 'Ex : GOVERNANCE' : ''), spellcheck: false,
...(kind === 'ref' ? { list: 'sdmApiRefs' } : {}), ...(required ? { required: true } : {}),
});
}
return el('div', { className: 'std-api-param-group' },
el('label', { className: 'std-api-param-label', htmlFor: `sdmApiParam_${name}` },
el('span', { className: 'std-api-param-name' }, name),
el('span', { className: 'std-api-param-type' }, API_TYPES[kind] || 'string'),
required ? el('span', { className: 'std-api-param-required' }, '*') : null),
input);
}
function pickApiFunction(func) {
apiFunction = func;
$('#sdmApiFunctions').querySelectorAll('.std-api-function-item').forEach((li) => {
const active = li.dataset.function === func;
li.classList.toggle('std-active', active);
li.setAttribute('aria-selected', String(active));
});
const fields = (API_FIELDS[func] || []).map(apiField);
$('#sdmApiFields').replaceChildren(...(fields.length ? fields
: [el('p', { className: 'sdm-help' }, 'Aucun paramètre requis')]));
}
/** La signature affichée, comme Standards : getReferentialData(refCode, …). */
const apiSignature = (f) => `${f}(${(API_FIELDS[f] || [])
.filter(([n, , g]) => g === 'ref' || API_REQUIRED.has(n)).map(([n]) => n).join(', ')})`;
let apiReferentials = null;
async function loadApi() {
const list = $('#sdmApiFunctions');
if (!list.children.length) {
list.replaceChildren(...Object.keys(API_FIELDS).map((f) => {
const li = el('li', { className: 'std-api-function-item', role: 'option', tabIndex: 0, onclick: () => pickApiFunction(f) },
el('div', { className: 'std-api-function-name' }, apiSignature(f)),
el('div', { className: 'std-api-function-desc' }, API_DESCRIPTIONS[f] || ''));
li.dataset.function = f;
li.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); pickApiFunction(f); }
});
return li;
}));
pickApiFunction(apiFunction);
}
if (apiReferentials) return;
const r = await request('GET', `${API}/api/getReferentials`);
apiReferentials = r.result.map((x) => x.code);
$('#sdmApiRefs').replaceChildren(...apiReferentials.map((c) => el('option', { value: c })));
if (!apiReferentials.length) $('#sdmApiCall').textContent = 'Aucun référentiel servi : jouez d\'abord la vue de compatibilité d\'un référentiel.';
}
/** La réponse, au gabarit de Standards : badge SUCCESS / ERROR, durée, JSON. */
function showApiResponse(response, duration, success) {
$('#sdmApiStatus').style.display = 'flex';
const badge = $('#sdmApiBadge');
badge.className = `std-api-status-badge ${success ? 'std-success' : 'std-error'}`;
badge.textContent = success ? 'SUCCESS' : 'ERROR';
$('#sdmApiDuration').textContent = `${Math.round(duration)}ms`;
const zone = $('#sdmApiResponse');
zone.className = '';
zone.replaceChildren(el('pre', { className: 'std-api-response-code' }, JSON.stringify(response, null, 2)));
}
async function callApi() {
const params = new URLSearchParams();
for (const [name, value] of new FormData($('#sdmFormApi'))) if (String(value).trim()) params.append(name, value);
const url = `${API}/api/${encodeURIComponent(apiFunction)}${params.size ? `?${params}` : ''}`;
const start = performance.now();
try {
const r = await request('GET', url);
$('#sdmApiCall').textContent = `${r.func}(${r.arguments.map((a) => JSON.stringify(a)).join(', ')})`;
// Comme Standards : une réponse { success: false } s'affiche en ERROR.
showApiResponse(r.result, r.durationMs ?? performance.now() - start, r.result?.success !== false);
} catch (e) {
$('#sdmApiCall').textContent = `GET ${url}`;
showApiResponse({ success: false, error: e.message }, performance.now() - start, false);
}
}
// ── Branchements ───────────────────────────────────────────────────────
// ── Données ────────────────────────────────────────────────────────────
const DATA_PAGE_SIZE = 20; // comme Standards (`itemsPerPage`)
/**
* Les filtres de Standards (E32) : entité ('' = toutes celles de tête),
* langue ('' = toutes), version ('current' ou 'all'). parent = { code, entite }
* quand on filtre sur un objet.
*/
const DATA_DEFAULTS = { entity: '', lang: 'fr', version: 'current', q: '', parent: null };
const dataState = { ...DATA_DEFAULTS, offset: 0 };
const createdEntities = () => state.entities.filter((e) => e.state === 'creee');
const entityOf = (code) => state.entities.find((e) => e.Code === code);
const entityName = (code) => { const e = entityOf(code); return (e && label(e.Libelle)) || code; };
/** Un libellé { fr, en } dans la langue du filtre, sinon celle de la page. */
const dataLabel = (l) => (l && dataState.lang && l[dataState.lang]) || label(l);
const query = (p) => new URLSearchParams(Object.entries(p).filter(([, v]) => v !== '' && v != null).map(([k, v]) => [k, String(v)]));
const exploreUrl = (entity, params) => `${API}/explore/${encodeURIComponent(entity)}?${query(params)}`;
/** Une valeur du contenu en texte : une liste devient « a, b ». */
const asText = (v) => (Array.isArray(v) ? v.join(', ')
: v != null && typeof v === 'object' ? JSON.stringify(v) : (v == null ? '' : String(v)));
/** Le libellé d'une ligne : l'attribut de rôle libellé, sinon le premier texte non vide. */
function rowLabel(entity, data, lang) {
const d = (data && (data[lang] || data.fr || data.en)) || {};
const att = entity && entity.attributes.find((a) => a.Role === 'libelle');
if (att && d[att.Nom] != null) return asText(d[att.Nom]);
const code = entity && entity.attributes.find((a) => a.Role === 'code');
const first = Object.entries(d).find(([k, v]) => (!code || k !== code.Nom) && typeof v === 'string' && v.trim());
return first ? first[1] : '';
}
/** « Tous les référentiels », puis chaque entité créée par son libellé, rangée sous son référentiel. */
function fillDataChoices() {
const byRef = new Map();
for (const e of createdEntities()) {
if (!byRef.has(e.Referentiel)) byRef.set(e.Referentiel, []);
byRef.get(e.Referentiel).push(e);
}
const sel = $('#sdmDataRef');
sel.replaceChildren(el('option', { value: '' }, 'Tous les référentiels'),
...[...byRef].sort(([a], [b]) => a.localeCompare(b)).map(([ref, list]) => el('optgroup', { label: refName(ref) },
...list.map((e) => el('option', { value: e.Code }, dataLabel(e.Libelle) || e.Code)))));
if (!entityOf(dataState.entity) || entityOf(dataState.entity).state !== 'creee') dataState.entity = '';
sel.value = dataState.entity;
}
function showParentFilter() {
const zone = $('#sdmDataFilterParent');
zone.classList.toggle('d-none', !dataState.parent);
zone.replaceChildren(...(dataState.parent ? [
el('span', {}, 'Enfants de ', el('strong', { className: 'sdm-code' }, dataState.parent.code), ` (${entityName(dataState.parent.entity)})`),
el('button', {
type: 'button', className: 'btn btn-link btn-sm',
onclick: () => { dataState.parent = null; rerunData(); },
}, icon('close'), 'Retirer le filtre'),
] : []));
}
/** Replie les lignes d'enfants dépliées sous une ligne, récursivement. */
function collapse(tr, child) {
for (const x of tr.sdmSub[child] || []) {
if (x.sdmSub) Object.keys(x.sdmSub).forEach((f) => collapse(x, f));
x.remove();
}
delete tr.sdmSub[child];
}
/** Le bouton qui déplie, sous la ligne, les enfants d'une entité fille. */
function childrenButton(tr, entityCode, l, child, n, level) {
const b = el('button', {
type: 'button', className: 'sdm-expand', 'aria-expanded': 'false',
title: `Afficher les enfants (${entityName(child)}) de ${l.code}`,
}, icon('down'), `${n} ${entityName(child)}`);
b.addEventListener('click', () => busy(b, async () => {
if (b.getAttribute('aria-expanded') === 'true') {
collapse(tr, child);
b.setAttribute('aria-expanded', 'false');
tr.classList.toggle('sdm-row-open', Object.keys(tr.sdmSub).length > 0);
return;
}
// Les enfants dans la version et la langue de la ligne.
const r = await request('GET', exploreUrl(child, { version: l.version_num, parent: l.code, limit: 500 }));
const rows = r.rows.map((x) => dataRow(child,
{ ...x, version_num: l.version_num, lang: l.lang, current: l.current }, level + 1));
if (r.total > r.rows.length) {
rows.push(el('tr', { className: 'sdm-row-child' }, el('td', { colSpan: 9, className: 'sdm-help' },
`… et ${r.total - r.rows.length} de plus. `,
el('button', {
type: 'button', className: 'btn btn-link btn-sm p-0',
onclick: () => {
Object.assign(dataState, { entity: child, parent: { code: l.code, entity: entityCode }, lang: l.lang });
if (!l.current) dataState.version = 'all';
$('#sdmDataLang').value = dataState.lang;
$('#sdmDataVersion').value = dataState.version;
rerunData();
},
}, 'Tout voir'))));
}
tr.sdmSub[child] = rows;
tr.after(...rows);
b.setAttribute('aria-expanded', 'true');
tr.classList.add('sdm-row-open');
}));
return b;
}
/** Une pastille std-badge, comme les cellules de l'onglet Données de Standards. */
const badge = (kind, text) => el('span', { className: `std-badge std-badge-${kind}` }, text);
/** Une ligne au gabarit de Standards ; `niveau` > 0 pour les enfants dépliés dessous. */
function dataRow(entityCode, l, level = 0) {
const tr = el('tr', { className: level ? 'sdm-row-child' : '' });
tr.sdmSub = {};
const entity = entityOf(entityCode);
const children = Object.entries(l.children || {}).filter(([, n]) => n > 0);
const active = l.current;
const text = rowLabel(entity, l.data, l.lang);
// Enfant : retrait par niveau, coude « ↳ » et nom de l'entité sur sa propre ligne,
// pour que la cellule passe à la ligne au lieu d'élargir le tableau. Sous
// « Tous les référentiels », les lignes de tête disent aussi leur entité.
const withEntity = level || !dataState.entity;
const cellCode = el('td', { className: withEntity ? 'sdm-code-child' : 'text-nowrap' },
withEntity ? el('span', { className: 'sdm-child-entity' }, entityName(entityCode)) : null,
el('span', { className: 'sdm-child-code' }, level ? el('span', { className: 'sdm-elbow', 'aria-hidden': 'true' }, '↳') : null, badge('code', l.code)));
if (level) cellCode.style.paddingLeft = `${15 + (level - 1) * 20}px`;
tr.append(
el('td', {}, badge('code', entity ? entity.Referentiel : '')),
cellCode,
el('td', {}, l.parent_code ? badge('code', l.parent_code) : null),
el('td', { className: 'sdm-data-label', title: text }, text),
el('td', {}, badge('language', l.lang === 'en' ? 'Anglais' : 'Français')),
el('td', {}, badge('version', l.version_num)),
el('td', {}, badge(active ? 'status-active' : 'status-inactive', active ? 'Actif' : 'Inactif')),
el('td', {}, children.length
? children.map(([child, n]) => childrenButton(tr, entityCode, l, child, n, level))
: el('span', { className: 'sdm-help' }, '—')),
el('td', {}, el('button', {
type: 'button', className: 'std-button std-btn-sm std-btn-secondary', title: 'Détail', 'aria-label': `Détail de ${l.code}`,
onclick: () => openDataRow(entityCode, l),
}, icon('eye'))),
);
return tr;
}
/** Le détail sous le tableau, champ par champ, comme le panneau de Standards. */
function openDataRow(entityCode, l) {
const entity = entityOf(entityCode);
const d = (l.data && l.data[l.lang]) || {};
const declared = entity ? entity.attributes.map((a) => a.Nom).filter((n) => n in d) : [];
const names = [...new Set([...declared, ...Object.keys(d)])];
const field = (name, value) => el('div', { className: 'std-detail-field' },
el('div', { className: 'std-detail-field-label' }, name),
el('div', { className: 'std-detail-field-value' }, value == null || value === '' ? '—' : asText(value)));
$('#sdmDataDetailTitle').textContent = [l.code, rowLabel(entity, l.data, l.lang)].filter(Boolean).join(' - ');
fill($('#sdmDataDetailContent'),
names.map((n) => field(n, d[n])),
field('Entité', entityName(entityCode)),
field('Version', l.version_num),
l.ordre != null ? field('Ordre', l.ordre) : null,
field('Empreinte', l.empreinte));
const panel = $('#sdmDataDetail');
panel.style.display = 'block';
panel.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
/** Le message d'une table vide, centré dans le tableau comme chez Standards. */
function emptyDataRow(iconName, text) {
return el('tr', {}, el('td', { colSpan: 9, className: 'sdm-data-empty' }, icon(iconName), el('div', {}, text)));
}
/** « Affichage a-b sur n » et les boutons « 1 … 4 5 6 … 12 », comme Standards. */
function dataPagination(total) {
const page = Math.floor(dataState.offset / DATA_PAGE_SIZE) + 1;
const pages = Math.ceil(total / DATA_PAGE_SIZE);
const start = total ? dataState.offset + 1 : 0;
const end = Math.min(dataState.offset + DATA_PAGE_SIZE, total);
$('#sdmDataCount').textContent = `Affichage ${start}-${end} sur ${total}`;
const go = (p) => { dataState.offset = (p - 1) * DATA_PAGE_SIZE; loadData().catch((err) => notify('alert', err.message)); };
const button = (text, p, arrow) => el('button', {
type: 'button', className: `std-pagination-btn${p === page && !arrow ? ' std-active' : ''}`,
disabled: p < 1 || p > pages, onclick: () => go(p),
...(arrow ? { 'aria-label': arrow } : {}),
}, text);
const buttons = [];
if (pages > 1) {
buttons.push(button('«', page - 1, 'Page précédente'));
for (let i = 1; i <= pages; i += 1) {
if (i === 1 || i === pages || (i >= page - 2 && i <= page + 2)) buttons.push(button(String(i), i));
else if (i === page - 3 || i === page + 3) buttons.push(el('span', { className: 'sdm-pages-ellipsis' }, '...'));
}
buttons.push(button('»', page + 1, 'Page suivante'));
}
fill($('#sdmDataPages'), buttons);
}
/** Le spinner de Standards à la place du tableau pendant la lecture. */
function dataLoading(active) {
$('#sdmDataLoading').style.display = active ? 'flex' : 'none';
$('#sdmCardData').style.display = active ? 'none' : '';
$('#sdmDataPagination').style.display = active ? 'none' : '';
}
async function loadData() {
fillDataChoices();
showParentFilter();
$('#sdmDataDetail').style.display = 'none';
const body = $('#sdmTableData tbody');
if (!createdEntities().length) {
fill(body, emptyDataRow('records', "Aucune table de données : engendrez et jouez la migration d'un référentiel, puis intégrez un import."));
dataPagination(0);
return;
}
dataLoading(true);
let r;
try {
r = await request('GET', `${API}/data?${query({
entity: dataState.entity, lang: dataState.lang, version: dataState.version, q: dataState.q,
parent: dataState.parent ? dataState.parent.code : '', limit: DATA_PAGE_SIZE, offset: dataState.offset,
})}`);
} finally {
dataLoading(false);
}
fill(body, r.rows.length ? r.rows.map((l) => dataRow(l.entity, l))
: emptyDataRow(dataState.q ? 'search' : 'records', dataState.q ? `Rien ne correspond à « ${dataState.q} ».`
: "Aucune donnée importée : intégrez un import depuis l'onglet Imports."));
dataPagination(r.total);
}
function rerunData() {
dataState.offset = 0;
loadData().catch((e) => notify('alert', e.message));
}
async function loadAll() {
await loadEntities();
await Promise.allSettled([loadMigrations(), loadImports(), loadLinks(), loadConfirm()]);
}
document.addEventListener('DOMContentLoaded', () => {
setIcons();
applyRights();
showPath();
wireDropzones();
document.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => openTab(b.dataset.tab)));
$('.sdm-tabs').addEventListener('keydown', tabsKeyboard);
$('#sdmFilterRef').addEventListener('change', () => { showEntities(); updateButtons(); });
$('#sdmRefView').addEventListener('change', updateButtons);
$('#sdmFormImport').elements.referential.addEventListener('change', () => {
showImportSheet();
updateButtons();
loadHelp().catch((e) => notify('alert', e.message));
});
$('#sdmSearch').addEventListener('input', showEntities);
form().elements.Referentiel.addEventListener('change', (ev) => {
parentOptions(ev.target.value, null);
form().elements.Section.value = refSection(ev.target.value) || '';
});
document.querySelectorAll('[data-close]').forEach((b) => b.addEventListener('click', closeDrawer));
$('#sdmOverlay').addEventListener('click', closeDrawer);
document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') closeDrawer(); });
$('#sdmBtnRefresh').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
await loadAll();
notify('success', 'Données actualisées.');
}));
$('#sdmBtnNew').addEventListener('click', () => edit(null));
$('#sdmBtnAddAttribute').addEventListener('click', () => {
const tr = attributeRow();
$('#sdmTableAttributes tbody').append(tr);
tr.querySelector('[name="Nom"]').focus();
});
$('#sdmBtnSave').addEventListener('click', (ev) => busy(ev.currentTarget, save));
$('#sdmBtnBackfill').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
const filter = $('#sdmFilterRef').value;
const r = await request('POST', `${API}/backfill`, { referentials: filter ? [filter] : [] });
showFindings(r);
await loadEntities();
}));
$('#sdmBtnPlay').addEventListener('click', async (ev) => {
const button = ev.currentTarget;
const migrations = summary.migrations || await request('GET', `${API}/migrations`).catch(() => []);
const queued = migrations.filter((m) => m.state === 'a_jouer').map((m) => m.file);
const ok = await askConfirmation({
title: 'Jouer les migrations ?',
text: queued.length
? `${queued.length > 1 ? `Les ${queued.length} migrations en attente sont jouées` : 'La migration en attente est jouée'}, dans l'ordre : elles créent leurs tables et vues dans la base.`
: 'Les migrations en attente sont jouées : elles créent leurs tables et vues dans la base.',
list: queued,
validate: 'Jouer',
});
if (!ok) return;
busy(button, async () => {
const r = await request('POST', `${API}/migrations/play`);
notify('success', r.played.length ? `Jouée(s) : ${r.played.join(', ')}` : 'Aucune migration en attente.');
await loadEntities();
await loadMigrations().catch(() => {});
});
});
$('#sdmBtnReset').addEventListener('click', async (ev) => {
const button = ev.currentTarget;
const ok = await askConfirmation({
title: 'Tout remettre à zéro ?',
text: 'Sont supprimés : le schéma xone_ref et toutes ses données, les migrations et modèles engendrés, '
+ 'les entités, attributs, liens, décisions et imports. Irréversible.',
validate: 'Tout remettre à zéro',
danger: true,
});
if (!ok) return;
busy(button, async () => {
const r = await request('POST', `${API}/reset`);
notify('success', `Remis à zéro : ${r.removedFiles.length} migration(s) et ${r.removedModels.length} modèle(s) retirés`
+ `${r.removedModels.length ? ' — redémarrez le serveur' : ''}.`);
await loadAll();
});
});
$('#sdmFormImport').addEventListener('submit', (ev) => {
ev.preventDefault();
// Contrôle avant envoi (TH.12) : FR et EN, ou l'extraction source ; aucun fichier vide.
const file = (n) => ev.target.elements[n]?.files?.[0];
const source = file('file');
const byLang = file('fileFr') || file('fileEn');
if (source && byLang) return notify('warning', 'Déposez soit les fichiers FR et EN, soit l\'extraction source, pas les deux.');
if (!source && !(file('fileFr') && file('fileEn'))) return notify('warning', 'Déposez les deux fichiers, FR et EN.');
if (source && !$('[name=fileL4]').closest('.sdm-dropzone').classList.contains('d-none') && !file('fileL4')) {
return notify('warning', 'Déposez aussi l\'extraction du niveau 4.');
}
const files = ['fileFr', 'fileEn', 'file', 'fileL4', 'fileStdFr', 'fileStdEn'].map(file).filter(Boolean);
const empty = files.find((f) => !f.size);
if (empty) return notify('warning', `Le fichier ${empty.name} est vide.`);
const versionField = ev.target.elements.versionNum;
if (!versionField.value.trim()) {
versionField.focus();
return notify('warning', 'Saisissez le numéro de version avant d\'analyser.');
}
return busy($('#sdmBtnAnalyze'), async () => {
const r = await runTask($('#sdmBtnAnalyze'), 'POST', `${API}/imports/analyze`, new FormData(ev.target));
const f = ev.target.elements;
showReport({ id: r.id, state: 'analyse', referential: f.referential.value, versionNum: r.versionNum, report: r.report, stats: r.stats });
await loadImports();
});
});
$('#sdmBtnBackfillData').addEventListener('click', (ev) => busy(ev.currentTarget, dataBackfill));
$('#sdmBtnView').addEventListener('click', (ev) => busy(ev.currentTarget, async () => {
const view = $('#sdmRefView').value;
const r = await request('POST', `${API}/migrations`, { view });
if (view === '*') {
notify(r.generated.length ? 'success' : 'info', `${plural(r.generated.length, 'vue engendrée', 'vues engendrées')}`
+ `${r.ignored.length ? `, ${r.ignored.length} ignorée(s)` : ''}.`);
const box = $('#sdmDiff');
fill(box,
el('strong', {}, 'Engendrer toutes les vues'),
el('ul', {},
r.generated.map((x) => el('li', {}, el('span', { className: 'sdm-code' }, x.file), ` — ${x.view}`)),
r.ignored.map((x) => el('li', {}, `${refName(x.referential)} ignoré : ${x.reason}`))));
box.classList.remove('d-none');
} else {
notify('success', `Migration engendrée : ${r.file} (${r.view})`);
}
// L'état des entités et des gestes change : les deux sont rechargés (TH.5).
await loadEntities();
await loadMigrations();
}));
$('#sdmBtnDiff').addEventListener('click', (ev) => busy(ev.currentTarget, diff));
$('#sdmBtnBackfillLinks').addEventListener('click', (ev) => busy(ev.currentTarget, linkBackfill));
$('#sdmBtnNewLink').addEventListener('click', newLink);
$('#sdmHideIsolated').addEventListener('change', (ev) => { tree.hideIsolated = ev.target.checked; renderLinkTree(); });
$('#sdmFormInstance').addEventListener('submit', (ev) => { ev.preventDefault(); busy(ev.submitter || ev.target, () => establish(ev)); });
$('#sdmFormApi').addEventListener('submit', (ev) => { ev.preventDefault(); busy($('#sdmBtnApi'), callApi); });
$('#sdmDataRef').addEventListener('change', (ev) => { dataState.entity = ev.target.value; dataState.parent = null; rerunData(); });
$('#sdmDataVersion').addEventListener('change', (ev) => { dataState.version = ev.target.value; rerunData(); });
$('#sdmDataLang').addEventListener('change', (ev) => { dataState.lang = ev.target.value; rerunData(); });
let searchDelay = null;
$('#sdmDataSearch').addEventListener('input', (ev) => {
clearTimeout(searchDelay);
searchDelay = setTimeout(() => { dataState.q = ev.target.value.trim(); rerunData(); }, 300);
});
$('#sdmDataClear').addEventListener('click', () => {
// Comme « Effacer » de Standards, mais vers nos valeurs de départ (E32).
clearTimeout(searchDelay);
Object.assign(dataState, DATA_DEFAULTS);
$('#sdmDataSearch').value = '';
$('#sdmDataLang').value = dataState.lang;
$('#sdmDataVersion').value = dataState.version;
rerunData();
});
$('#sdmDataDetailClose').addEventListener('click', () => { $('#sdmDataDetail').style.display = 'none'; });
loadAll().catch((e) => notify('alert', e.message));
});
}());
script init
/**
* DEV SEULEMENT — prépare une base locale vide pour essayer sys_data_model :
*
* 1. crée les tables STD_* depuis les fichiers de modèles (sync, sans
* toucher une table existante) ;
* 2. pose le référentiel GOVERNANCE dans STD_RF_Referentials s'il manque,
* avec le schéma de GOV_HEADERS, pour que la reprise ait de quoi lire ;
* 3. joue les migrations (tables SDM_*).
*
* node scripts/dev-db-init.js
*
* Ne JAMAIS lancer contre une base X-One : les STD_* y existent déjà.
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const db = require('../src/database/models');
const { migrate } = require('./sdm-migrate');
const MODELS_DIR = path.join(__dirname, '..', 'src', 'database', 'models');
const col = (name, extra = {}) => ({
name, normalizedName: name.toLowerCase(), type: 'string', separator: 'none', ...extra,
});
const GOVERNANCE_SCHEMA = {
columns: [
col('ID', { isCode: true }), col('PARENT_ID', { isParent: true }), col('CODE'),
col('LIBELLE', { isLabel: true }), col('LINK_STDS', { separator: 'semicolon' }),
col('CONTROLS', { separator: 'newline' }), col('CONTROLS_VERSION'), col('TYPE'),
],
};
async function main() {
const { sequelize, Sequelize } = db;
const models = fs.readdirSync(MODELS_DIR)
.filter((f) => /^std_.*\.js$/.test(f))
.map((f) => require(path.join(MODELS_DIR, f))(sequelize, Sequelize.DataTypes));
for (const model of models) await model.sync();
console.log(`tables STD_* : ${models.length} vérifiées`);
const [exists] = await sequelize.query(
'SELECT 1 FROM "STD_RF_Referentials" WHERE "STD_RF_Code" = \'GOVERNANCE\'',
{ type: Sequelize.QueryTypes.SELECT },
);
if (!exists) {
await sequelize.query(
`INSERT INTO "STD_RF_Referentials" ("STD_RF_Id", "STD_RF_Code", "STD_RF_Name", "STD_RF_Schema",
"STD_RF_SourceType", "STD_RF_IsActive", "STD_RF_CreatedAt", "STD_RF_UpdatedAt")
VALUES (:id, 'GOVERNANCE', 'Gouvernance', :schema, 'excel', true, now(), now())`,
{ replacements: { id: uuidv4(), schema: JSON.stringify(GOVERNANCE_SCHEMA) } },
);
console.log('référentiel GOVERNANCE posé');
}
const played = await migrate();
console.log(`${played.length} migration(s) jouée(s)`);
}
main()
.catch((e) => { console.error(e.message); process.exitCode = 1; })
.finally(() => db.sequelize.close());
script reset
/**
* DEV SEULEMENT — remet sys_data_model à zéro sur la base locale, pour
* rejouer le parcours complet : définition → génération → import. Le détail
* est dans src/app/views/sys-data-model/sdm-annulation.utils.js (même geste
* que le bouton « Tout remettre à zéro » de l'écran).
*
* node scripts/dev-sdm-reset.js --oui
*
* Ne JAMAIS lancer contre une base X-One.
*/
require('dotenv').config();
const db = require('../src/database/models');
const { resetAll } = require('../src/app/views/sys-data-model/sdm-undo.utils');
if (require.main === module) {
if (!process.argv.includes('--oui')) {
console.error(`Remise à zéro de sys_data_model sur ${String(process.env.DATABASE_URL).replace(/\/\/[^@]*@/, '//…@')}.\nRelancez avec --oui pour confirmer.`);
process.exit(2);
}
resetAll()
.then((r) => { console.log('sys_data_model remis à zéro', r); return db.sequelize.close(); })
.catch((e) => { console.error(e.message); process.exitCode = 1; return db.sequelize.close(); });
}
module.exports = { resetAll };
script import
/**
* DEV — charge une version dans Standards (STD_RV, STD_RD, STD_VD) par la
* vraie fonction d'import de Standards, `importReferentialData`, sans passer
* par l'écran. Sert à préparer une base où il y a « de l'existant » : la
* reprise des données (POST /ajax/sdm/reprise/donnees) et la vue de
* compatibilité se mesurent ensuite contre ces lignes.
*
* node scripts/dev-std-import.js GOVERNANCE 6.6 chemin/FR.xlsx chemin/EN.xlsx
*
* Les fichiers sont lus comme le fait l'import sdm (format plat ou format
* entreprise passé par son traitement), puis importés langue par langue.
* Une version déjà présente est remplacée.
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../src/database/models');
const standards = require('../src/controllers/admin/settings/settings-standards.utils');
const { SPLIT_RULES } = require('../src/app/views/sys-data-model/sdm-routing.utils');
const { readOutput } = require('../src/app/views/sys-data-model/sdm-files.utils');
const { BUILDERS } = require('../src/controllers/admin/settings/standards-integrations.utils');
async function importData(referential, versionNumber, pathFr, pathEn) {
const ref = await standards.getReferentialByCode(referential);
if (!ref) throw new Error(`${referential} : référentiel Standards introuvable`);
const columns = ref.STD_RF_Schema?.columns || [];
const rule = SPLIT_RULES[referential] || {};
const output = readOutput(rule, BUILDERS[referential], {
frBuffer: fs.readFileSync(pathFr), asBuffer: fs.readFileSync(pathEn),
});
if (!output.ok) throw new Error(output.message || 'fichiers illisibles');
// Les colonnes du schéma, dans son ordre : Standards vérifie la structure.
const index = columns.map((c) => output.headers.findIndex((h) => standards.normalizeColumnName(h) === c.normalizedName));
const missing = columns.filter((c, i) => index[i] < 0).map((c) => c.name);
if (missing.length) throw new Error(`colonnes du schéma absentes des fichiers : ${missing.join(', ')}`);
const reorder = (row) => index.map((i) => (row[i] === undefined || row[i] === null ? '' : row[i]));
const results = {};
for (const [lang, rows, filePath] of [['fr', output.fr, pathFr], ['en', output.en, pathEn]]) {
const r = await standards.importReferentialData({
referentialId: ref.STD_RF_Id,
referentialCode: ref.STD_RF_Code,
isNewReferential: false,
versionNumber,
languageCode: lang,
data: rows.map(reorder),
columns: columns.map((c) => c.name),
codeColumnIndex: columns.findIndex((c) => c.isCode),
labelColumnIndex: columns.findIndex((c) => c.isLabel),
parentColumnIndex: columns.findIndex((c) => c.isParent) >= 0 ? columns.findIndex((c) => c.isParent) : undefined,
sourceFileName: path.basename(filePath),
userId: 'dev-std-import',
replaceExisting: true,
});
if (r && r.success === false) throw new Error(`${lang} : ${r.error || r.message}`);
results[lang] = { rows: rows.length, result: r && (r.stats || r.message || r.success) };
}
return results;
}
if (require.main === module) {
const [referential, version, fr, en] = process.argv.slice(2);
if (!referential || !version || !fr || !en) {
console.error('usage : node scripts/dev-std-import.js <REFERENTIEL> <version> <FR.xlsx> <EN.xlsx>');
process.exit(2);
}
importData(referential, version, fr, en)
.then((r) => { console.log(JSON.stringify(r, null, 2)); return db.sequelize.close(); })
.catch((e) => { console.error(e.message); process.exitCode = 1; return db.sequelize.close(); });
}
module.exports = { importData };
script parity
/**
* La parité de l'API compat : pour un référentiel repris, la même lecture
* faite par Standards (settings-standards.utils) et par sys-data-model
* (sdm-api.utils), comparée élément par élément. C'est la preuve qu'un
* module peut changer son `require` sans rien voir changer.
*
* node scripts/sdm-api-parite.js GOVERNANCE [--version 6.7] [--langue fr] [--exemples 10]
*
* Chaque version présente des deux côtés est lue avec sa version explicite :
* la règle de la version active diffère (Standards : activée par langue ;
* sys-data-model : la plus récente), elle est comparée à part.
*
* Lecture seule. Code de sortie 1 s'il y a un écart.
*/
require('dotenv').config();
const util = require('util');
/** Une valeur JSON aux clés triées, pour comparer sans dépendre de l'ordre. */
function canonical(v) {
if (Array.isArray(v)) return v.map(canonical);
if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]));
return v;
}
const FIELDS = ['label', 'parentCode', 'sortOrder', 'data'];
/** Les clés de `data` qui diffèrent entre deux items. */
function differentKeys(a = {}, b = {}) {
return [...new Set([...Object.keys(a || {}), ...Object.keys(b || {})])]
.filter((k) => !util.isDeepStrictEqual(canonical(a?.[k]), canonical(b?.[k]))).sort();
}
/**
* Compare deux listes d'items Standards.
* @returns {{ identiques, differents: [{code, champs, data}], manquants: [code], enTrop: [code], ordre }}
*/
function compare(itemsStd = [], itemsSdm = []) {
const sdm = new Map(itemsSdm.map((i) => [i.code, i]));
const std = new Map(itemsStd.map((i) => [i.code, i]));
const r = { identical: 0, different: [], missing: [], extra: [], ordre: true };
for (const [code, a] of std) {
const b = sdm.get(code);
if (!b) { r.missing.push(code); continue; }
const fields = FIELDS.filter((c) => !util.isDeepStrictEqual(canonical(a[c] ?? null), canonical(b[c] ?? null)));
if (fields.length) r.different.push({ code, fields, data: fields.includes('data') ? differentKeys(a.data, b.data) : [] });
else r.identical += 1;
}
for (const code of sdm.keys()) if (!std.has(code)) r.extra.push(code);
const common = (list, other) => list.map((i) => i.code).filter((c) => other.has(c));
r.ordre = util.isDeepStrictEqual(common(itemsStd, sdm), common(itemsSdm, std));
return r;
}
const gap = (r) => r.different.length + r.missing.length + r.extra.length + (r.ordre ? 0 : 1);
async function main() {
const args = process.argv.slice(2);
const option = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
const refCode = args.find((a, i) => !a.startsWith('--') && !args[i - 1]?.startsWith('--'));
if (!refCode) {
console.error('Usage : node scripts/sdm-api-parity.js <REFERENTIEL> [--version X] [--langue fr|en] [--exemples N]');
return 2;
}
const examples = Number(option('examples') || 10);
const db = require('../src/database/models');
const std = require('../src/controllers/admin/settings/settings-standards.utils');
const sdm = require('../src/app/views/sys-data-model/sdm-api.utils');
try {
const versionsOf = async (api) => {
const r = await api.getAllVersions({ refCode });
return r.success ? r.versions : [];
};
// getAllVersions de Standards lève dès qu'une version existe : les
// versions Standards se lisent donc dans sa table.
const versionsStd = (await db.sequelize.query(`
SELECT DISTINCT rv."STD_RV_VersionNumber" AS v FROM "STD_RV_ReferentialVersions" rv
JOIN "STD_RF_Referentials" rf ON rf."STD_RF_Id" = rv."STD_RV_ReferentialId"
WHERE rf."STD_RF_Code" = :refCode`, { replacements: { refCode }, type: db.Sequelize.QueryTypes.SELECT })).map((l) => l.v);
const versionsSdm = [...new Set((await versionsOf(sdm)).map((v) => v.versionNumber))];
const versions = (option('version') ? [option('version')] : versionsStd.filter((v) => versionsSdm.includes(v))).sort();
console.log(`${refCode} — Standards : ${versionsStd.join(', ') || 'aucune'} ; sys-data-model : ${versionsSdm.join(', ') || 'aucune'}`);
if (!versions.length) {
console.log('Aucune version commune à comparer (référentiel repris ? vue jouée ?).');
return 1;
}
let total = 0;
for (const version of versions) {
for (const languageCode of (option('lang') ? [option('lang')] : ['fr', 'en'])) {
const p = { refCode, version, languageCode };
const [a, b] = await Promise.all([std.getReferentialData(p), sdm.getReferentialData(p)]);
const itemsA = a.success ? a.versions[0]?.items : [];
const itemsB = b.success ? b.versions[0]?.items : [];
const r = compare(itemsA, itemsB);
total += gap(r);
const state = gap(r) ? 'ÉCART' : 'identique';
console.log(`\n${version} ${languageCode} : ${state} — ${itemsA.length} Standards / ${itemsB.length} sys-data-model, `
+ `${r.identical} identiques, ${r.different.length} différents, ${r.missing.length} manquants, `
+ `${r.extra.length} en trop${r.ordre ? '' : ', ORDRE différent'}`);
if (!b.success) console.log(` sys-data-model : ${b.error}${b.reason ? ` (${b.reason})` : ''}`);
if (r.different.length) {
const byField = {};
for (const d of r.different) for (const c of [...d.fields.filter((x) => x !== 'data'), ...d.data.map((k) => `data.${k}`)]) byField[c] = (byField[c] || 0) + 1;
console.log(' champs en écart :', byField);
for (const d of r.different.slice(0, examples)) {
const ia = itemsA.find((i) => i.code === d.code);
const ib = itemsB.find((i) => i.code === d.code);
const view = (i) => Object.fromEntries([...d.fields.filter((c) => c !== 'data').map((c) => [c, i[c]]),
...d.data.map((k) => [`data.${k}`, i.data?.[k]])]);
console.log(` ${d.code}\n Standards ${JSON.stringify(view(ia))}\n sys-data-model ${JSON.stringify(view(ib))}`);
}
}
if (r.missing.length) console.log(` manquants : ${r.missing.slice(0, examples).join(', ')}${r.missing.length > examples ? '…' : ''}`);
if (r.extra.length) console.log(` en trop : ${r.extra.slice(0, examples).join(', ')}${r.extra.length > examples ? '…' : ''}`);
}
}
const [actA, actB] = await Promise.all([std.getActiveVersions({ refCode }), sdm.getActiveVersions({ refCode })]);
const active = (r) => (r.success ? r.versions.map((v) => `${v.versionNumber}/${v.versionLanguage}`).sort().join(', ') : r.error);
console.log(`\nVersion active — Standards : ${active(actA)} ; sys-data-model : ${active(actB)}`
+ `${active(actA) === active(actB) ? '' : ' (règles différentes : voir E16)'}`);
console.log(total ? `\n${total} écart(s).` : '\nParité complète.');
return total ? 1 : 0;
} finally {
await db.sequelize.close();
}
}
if (require.main === module) {
main()
.then((code) => { process.exitCode = code; })
.catch((e) => { console.error(e.message); process.exitCode = 1; });
}
module.exports = { compare, canonical };
script migrate
/**
* Joue les migrations de src/database/migrations pas encore passées, et les
* inscrit dans SequelizeMeta, comme `sequelize-cli db:migrate` (absent de
* l'extrait). Dans X-One, c'est sequelize-cli qui s'en charge.
*
* node scripts/sdm-migrate.js
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const db = require('../src/database/models');
const DIR = path.join(__dirname, '..', 'src', 'database', 'migrations');
async function migrate() {
const { sequelize, Sequelize } = db;
const qi = sequelize.getQueryInterface();
await sequelize.query('CREATE TABLE IF NOT EXISTS "SequelizeMeta" (name varchar(255) PRIMARY KEY)');
const played = new Set((await sequelize.query('SELECT name FROM "SequelizeMeta"', { type: Sequelize.QueryTypes.SELECT }))
.map((r) => r.name));
const newOnes = [];
const files = fs.existsSync(DIR) ? fs.readdirSync(DIR).filter((f) => f.endsWith('.js')).sort() : [];
for (const file of files) {
if (played.has(file)) continue;
const migration = require(path.join(DIR, file));
await sequelize.transaction(async (transaction) => {
await migration.up(qi, Sequelize, { transaction });
await sequelize.query('INSERT INTO "SequelizeMeta" (name) VALUES (:file)', { replacements: { file }, transaction });
});
newOnes.push(file);
}
return newOnes;
}
module.exports = { migrate };
if (require.main === module) {
migrate()
.then((n) => { n.forEach((f) => console.log(`jouée : ${f}`)); console.log(`${n.length} migration(s) jouée(s)`); })
.catch((e) => { console.error(e.message); process.exitCode = 1; })
.finally(() => db.sequelize.close());
}
src routes
/**
* Routes du module sys_data_model (plan § 19), v0.
*
* Deux droits seulement (TH.9, écart au plan § 18) :
* system.sysDataModel.access lecture seule : toutes les routes GET ;
* system.sysDataModel.manage toutes les actions : le reste.
* Dans l'extrait, l'évaluateur de droits est un stub qui accorde tout ; dans
* X-One, ce sera `sysCanAccess`. Jouer, annuler et remettre à zéro demandent
* en plus SDM_JOUER_MIGRATIONS=1 (DEV).
*/
const express = require('express');
const multer = require('multer');
const c = require('./sys-data-model.controller');
const { FILES } = require('./sdm-files.utils');
const { sysCheckRight } = require('../../../sys-rights/sys-rights.evaluator');
const router = express.Router();
const can = (pAction) => async (req, res, next) => {
try {
if (await sysCheckRight(req.loggedUser, 'system', `system.sysDataModel.${pAction}`, null)) return next();
} catch (error) {
return next(error);
}
if (req.path.startsWith('/ajax/')) return res.status(403).json({ success: false, error: 'Accès refusé' });
return res.redirect('/');
};
const read = can('access');
const manage = can('manage');
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: Number(process.env.UPLOAD_MAX_MB || 200) * 1024 * 1024 },
});
// Deux classeurs FR et EN, ou une extraction source et ses compléments (TH.12).
const importFiles = upload.fields(FILES.map(([, name]) => ({ name, maxCount: 1 })));
// La page
router.get('/settings/sys-data-model', read, c.page);
// P1 — déclaration
router.get('/ajax/sdm/entities', read, c.listEntities);
router.post('/ajax/sdm/entities', manage, c.createEntity);
router.put('/ajax/sdm/entities/:code', manage, c.updateEntity);
router.put('/ajax/sdm/entities/:code/attributes', manage, c.updateAttributes);
router.put('/ajax/sdm/referentials/:code', manage, c.updateSheet);
router.post('/ajax/sdm/backfill', manage, c.backfill);
// P1 — génération
router.post('/ajax/sdm/migrations', manage, c.generateMigration);
router.post('/ajax/sdm/migrations/play', manage, c.playMigrations);
router.get('/ajax/sdm/migrations', read, c.listMigrations);
router.put('/ajax/sdm/migrations/:id/played', manage, c.markPlayed);
// DEV (SDM_JOUER_MIGRATIONS=1) — défaire
router.delete('/ajax/sdm/migrations/:id', manage, c.removeMigration);
router.post('/ajax/sdm/migrations/:id/undo', manage, c.undoMigration);
router.post('/ajax/sdm/reset', manage, c.resetAll);
router.get('/ajax/sdm/schema/diff', read, c.schemaDiff);
// P2 — import en deux temps
router.get('/ajax/sdm/imports', read, c.listImports);
router.get('/ajax/sdm/imports/help/:referential', read, c.importHelp);
router.post('/ajax/sdm/imports/analyze', manage, importFiles, c.analyzeImport);
router.get('/ajax/sdm/imports/:id', read, c.readReport);
router.post('/ajax/sdm/imports/:id/integrate', manage, importFiles, c.integrateImport);
router.post('/ajax/sdm/imports/:id/refuse', manage, c.refuseImport);
router.post('/ajax/sdm/imports/:id/rerun', manage, importFiles, c.rerunImport);
router.get('/ajax/sdm/versions/:referential', read, c.versions);
router.get('/ajax/sdm/tasks/:id', read, c.readTask);
// Écart au plan : reprise des versions Standards existantes par le chemin d'un import
router.post('/ajax/sdm/backfill/data', manage, c.dataBackfill);
// P3 — liens (l'ordre compte : les chemins fixes avant `/liens/:id`)
router.get('/ajax/sdm/links', read, c.listLinks);
router.post('/ajax/sdm/links', manage, c.createLink);
router.post('/ajax/sdm/links/backfill', manage, c.linkBackfill);
router.get('/ajax/sdm/links/to-confirm', read, c.confirmationQueue);
router.put('/ajax/sdm/links/decisions/:id', manage, c.decideLink);
router.put('/ajax/sdm/links/:id', manage, c.updateLink);
router.get('/ajax/sdm/links/:id/decisions', read, c.linkDecisions);
router.post('/ajax/sdm/links/:id/decisions', manage, c.establishLink);
// Lecture
router.get('/ajax/sdm/data/:entity', read, c.readData);
// Onglet Données : page, total, recherche, enfants (écran du module)
router.get('/ajax/sdm/explore/:entity', read, c.explore);
router.get('/ajax/sdm/data', read, c.records);
// Écart au plan (E16) : l'API compat Standards, appelable pour l'onglet Test API
router.get('/ajax/sdm/api/:func', read, c.callApi);
module.exports = router;
src api
/**
* sdm-api.utils.js — la couche de COMPATIBILITÉ avec l'API Standards
* (écart au plan, E16).
*
* Mêmes noms, mêmes paramètres, même forme de réponse que
* `settings-standards.utils.js` : un module qui lisait Standards bascule en
* changeant une ligne, son `require`. Le format Standards est gardé tel quel,
* défauts compris ; le format natif, optimisé, est celui de
* `sdm-lecture.utils.js`, vers lequel les modules migreront ensuite.
*
* Tout se lit dans la vue `xone_ref.compat_<ref>`, qui rend déjà les lignes à
* la forme de STD_RD_ReferentialData. Un référentiel dont la vue n'est pas
* jouée est « non trouvé ».
*
* La version ACTIVE est la plus récente intégrée (sys_data_model n'active pas
* de version par langue) ; ses langues actives sont celles qu'elle contient.
*
* Non portées : setItem / removeItem (on n'écrit qu'en important) ;
* getReferentialCrossData (lot suivant, sur les liens du module).
*/
const { logger } = require('../../../observability');
const G = require('./sdm-generator.utils');
const S = require('./sdm-storage.utils');
const { compareVersions } = require('./sdm-import.utils');
const { mergeVersionLabels } = require('../../../controllers/admin/settings/settings-standards.utils');
/**
* Copie de `normalizeReferentialCode` (settings-standards.utils, non exportée :
* Standards n'est pas modifié). « GOV_A.1 » → « A-1 », sur code, parent,
* data.code et data.controls.
*/
function normalizeReferentialCode(items) {
const normalize = (code) => {
if (!code) return null;
const s = String(code);
const withoutPrefix = s.includes('_') ? s.split('_').slice(1).join('_') : s;
return withoutPrefix.replace(/[_.\s]+/g, '-');
};
return items.map((item) => {
const normalizedItem = {
...item,
code: normalize(item.code),
parentCode: normalize(item.parentCode),
data: { ...item.data, code: normalize(item.data?.code) },
};
if (Array.isArray(item.data?.controls)) {
normalizedItem.data.controls = item.data.controls.map((ctrl) => normalize(ctrl));
} else if (item.data?.controls) {
normalizedItem.data.controls = new Array(normalize(item.data.controls));
}
return normalizedItem;
});
}
// ───────────────────────────────────────────────────────────────────────────
// Cache (comme Standards : 5 min, vidé à chaque écriture du module)
// ───────────────────────────────────────────────────────────────────────────
const TTL = 5 * 60 * 1000;
const cache = new Map();
/** Vide le cache : appelé après toute écriture de sys_data_model. */
const clearCache = () => cache.clear();
const keyOf = (p) => [p.refCode, p.languageCode || '*', p.version || '*', [...(p.itemCodes || [])].sort().join(','),
p.withParent ? 1 : 0, p.type || '*', p.normalizeCode ? 1 : 0, p.includeQuestions ? 1 : 0, p.maxLevel || '*'].join('|');
// ───────────────────────────────────────────────────────────────────────────
// Fonctions pures
// ───────────────────────────────────────────────────────────────────────────
/** Les numéros de version, du plus récent au plus ancien. */
const descending = (versions) => [...new Set(versions)].sort((a, b) => compareVersions(b, a));
/**
* Les lignes de la vue → les items Standards : dédoublonnés par code (le
* premier gagne, l'ordre est celui de la requête), sans le niveau QUESTION
* sauf demande, codes normalisés sur demande.
*/
function asItems(rows, { includeQuestions = false, normalizeCode = false } = {}) {
const byCode = new Map();
for (const l of rows) {
if (byCode.has(l.STD_RD_ItemCode)) continue;
byCode.set(l.STD_RD_ItemCode, {
code: l.STD_RD_ItemCode,
label: l.STD_RD_Label,
parentCode: l.STD_RD_ParentCode,
sortOrder: l.STD_RD_SortOrder,
data: l.STD_RD_Data,
});
}
let items = [...byCode.values()];
if (!includeQuestions) items = items.filter((it) => String(it.data?.type || '').toUpperCase() !== 'QUESTION');
return normalizeCode ? normalizeReferentialCode(items) : items;
}
/** Tri Standards après la récursion des parents : ordre, puis code. */
const byOrderAndCode = (a, b) => ((a.STD_RD_SortOrder || 0) - (b.STD_RD_SortOrder || 0))
|| a.STD_RD_ItemCode.localeCompare(b.STD_RD_ItemCode);
/** Résumé d'une ligne pour searchItems / getChildren (sans sortOrder). */
const short = (l) => ({ code: l.STD_RD_ItemCode, label: l.STD_RD_Label, parentCode: l.STD_RD_ParentCode, data: l.STD_RD_Data });
// ───────────────────────────────────────────────────────────────────────────
// Le référentiel et ses versions, lus dans la vue
// ───────────────────────────────────────────────────────────────────────────
/** Nom et description dans la fiche SDM du référentiel (E28) ; le code à défaut. */
async function descriptions(codes) {
if (!codes.length) return new Map();
try {
const rows = await S.select(`
SELECT "SDM_RF_Code", "SDM_RF_Label", "SDM_RF_Description" FROM "SDM_RF_Referentials"
WHERE "SDM_RF_Code" IN (:codes)`, { codes });
return new Map(rows.map((l) => [l.SDM_RF_Code, {
name: l.SDM_RF_Label?.fr || l.SDM_RF_Label?.en || null, description: l.SDM_RF_Description,
}]));
} catch {
return new Map();
}
}
/** Les référentiels déclarés dont la vue est jouée : [code], triés. */
async function servedReferentials() {
const registry = await S.loadRegistry();
const { views } = await S.presentRelations();
const codes = new Set([...registry.entities.values()].map((e) => e.Referentiel).filter(Boolean));
return [...codes].filter((c) => views.has(G.compatViewName(c))).sort();
}
/**
* Le référentiel servi : { code, name, description, vue, versions } où
* versions = [{ version, langues: Map<langue, nbItems> }], la plus récente
* en tête. null s'il n'est pas déclaré ou si sa vue n'est pas jouée.
*/
async function referential(refCode, desc = null) {
const { views } = await S.presentRelations();
const viewName = G.compatViewName(refCode);
if (!views.has(viewName)) return null;
const view = `${G.DATA_SCHEMA}.${viewName}`;
const rows = await S.select(`
SELECT "STD_RV_VersionNumber" AS version, "STD_RD_LanguageCode" AS lang, count(DISTINCT "STD_RD_ItemCode")::int AS nb
FROM ${view} WHERE "STD_RF_Code" = :refCode GROUP BY 1, 2`, { refCode });
const versions = descending(rows.map((l) => l.version)).map((version) => ({
version,
langs: new Map(rows.filter((l) => l.version === version).sort((a, b) => a.lang.localeCompare(b.lang))
.map((l) => [l.lang, l.nb])),
}));
const d = (desc || await descriptions([refCode])).get(refCode) || {};
return { code: refCode, name: d.name || refCode, description: d.description || null, view, versions };
}
const notFound = (refCode, reason = 'vue de compatibilité non jouée') => ({
success: false,
error: `Référentiel '${refCode}' non trouvé`,
reason,
});
/** Les versions lues : celle demandée, ou l'active. */
function readVersions(ref, version) {
if (version) return ref.versions.filter((v) => v.version === version);
return ref.versions.slice(0, 1);
}
// ───────────────────────────────────────────────────────────────────────────
// L'API, à la forme de Standards
// ───────────────────────────────────────────────────────────────────────────
/**
* @param {Object} params { refCode, languageCode, version, itemCodes, withParent,
* type, normalizeCode, includeQuestions, maxLevel }
* @returns {{ success, referential: {code,name}, versions: [{versionId, version, languageCode, items}] }}
*/
async function getReferentialData(params) {
const {
refCode, languageCode, version, itemCodes, withParent = false, type,
normalizeCode = false, includeQuestions = false, maxLevel = null,
} = params;
if (!refCode) throw new Error('Le code du référentiel est requis');
const key = keyOf(params);
const cached = cache.get(key);
if (cached && Date.now() - cached.ts < TTL) return cached.data;
const ref = await referential(refCode);
if (!ref) return notFound(refCode);
const read = readVersions(ref, version);
if (!read.length) {
return {
success: false,
error: version ? `Version '${version}' non trouvée pour le référentiel '${refCode}'` : `Aucune version trouvée pour le référentiel '${refCode}'`,
};
}
const filters = [];
const r = { refCode };
if (type) { filters.push(`AND "STD_RD_Data" ->> 'type' = :type`); r.type = type; }
if (maxLevel) {
// Comparaison entière, comme Standards : « 10 » ne précède pas « 3 ».
filters.push(`AND COALESCE(NULLIF("STD_RD_Data" ->> 'level', ''), '0')::int <= :maxLevel`);
r.maxLevel = Number(maxLevel);
}
const codes = Array.isArray(itemCodes) && itemCodes.length ? itemCodes : null;
if (codes) { filters.push('AND "STD_RD_ItemCode" IN (:codes)'); r.codes = codes; }
const versions = [];
for (const v of read) {
const langs = languageCode ? [languageCode] : [...v.langs.keys()];
const base = `FROM ${ref.view} WHERE "STD_RF_Code" = :refCode AND "STD_RV_VersionNumber" = :version
AND "STD_RD_LanguageCode" IN (:langs)`;
const rr = { ...r, version: v.version, langs };
let rows;
if (withParent && codes) {
// Une seule requête récursive : les items retenus, puis leur lignée.
// Le niveau borne la base, jamais la remontée (même règle que Standards).
rows = await S.select(`
WITH RECURSIVE arbre AS (
SELECT * ${base} ${filters.join(' ')}
UNION
SELECT p.* FROM ${ref.view} p
JOIN arbre a ON a."STD_RD_ParentCode" = p."STD_RD_ItemCode" AND a."STD_RD_LanguageCode" = p."STD_RD_LanguageCode"
WHERE p."STD_RF_Code" = :refCode AND p."STD_RV_VersionNumber" = :version
)
SELECT DISTINCT ON ("STD_RD_LanguageCode", "STD_RD_ItemCode") * FROM arbre
ORDER BY "STD_RD_LanguageCode", "STD_RD_ItemCode", "STD_RD_SortOrder"`, rr);
rows.sort(byOrderAndCode);
} else {
rows = await S.select(`SELECT * ${base} ${filters.join(' ')} ORDER BY "STD_RD_SortOrder", "STD_RD_ItemCode"`, rr);
}
for (const lang of langs) {
const items = asItems(rows.filter((l) => l.STD_RD_LanguageCode === lang), { includeQuestions, normalizeCode });
if (items.length) versions.push({ versionId: null, version: v.version, languageCode: lang, items });
}
}
const result = { success: true, referential: { code: ref.code, name: ref.name }, versions };
cache.set(key, { data: result, ts: Date.now() });
return result;
}
/**
* @param {Object} params { refCode, query, languageCode, version, limit = 50 }
* @returns {{ success, query, referential, versions: [{version, languageCode, resultsCount, results}] }}
*/
async function searchItems(params) {
const { refCode, query, languageCode, version, limit = 50 } = params;
if (!refCode || !query) throw new Error('Le code du référentiel et le texte de recherche sont requis');
const ref = await referential(refCode);
if (!ref) return notFound(refCode);
const read = readVersions(ref, version);
if (!read.length) {
return {
success: false,
error: version ? `Version '${version}' non trouvée pour le référentiel '${refCode}'` : `Aucune version active trouvée pour le référentiel '${refCode}'`,
};
}
const versions = [];
for (const v of read) {
for (const lang of (languageCode ? [languageCode] : [...v.langs.keys()])) {
const rows = await S.select(`
SELECT * FROM ${ref.view}
WHERE "STD_RF_Code" = :refCode AND "STD_RV_VersionNumber" = :version AND "STD_RD_LanguageCode" = :lang
AND ("STD_RD_ItemCode" ILIKE :reason OR "STD_RD_Label" ILIKE :reason)
ORDER BY "STD_RD_ItemCode" LIMIT :limit`,
{ refCode, version: v.version, lang, reason: `%${query}%`, limit: Number(limit) || 50 });
if (rows.length) {
versions.push({ version: v.version, languageCode: lang, resultsCount: rows.length, results: rows.map(short) });
}
}
}
return { success: true, query, referential: { code: ref.code, name: ref.name }, versions };
}
/**
* @param {Object} params { refCode, parentCode, languageCode = 'fr', version }
* parentCode absent : les racines
*/
async function getChildren(params) {
const { refCode, parentCode, languageCode = 'fr', version } = params;
if (!refCode) throw new Error('Le code du référentiel est requis');
const ref = await referential(refCode);
if (!ref) return notFound(refCode);
const v = version
? ref.versions.find((x) => x.version === version)
: ref.versions.slice(0, 1).find((x) => x.langs.has(languageCode));
if (!v) return { success: false, error: 'Aucune version trouvée' };
const rows = await S.select(`
SELECT * FROM ${ref.view}
WHERE "STD_RF_Code" = :refCode AND "STD_RV_VersionNumber" = :version AND "STD_RD_LanguageCode" = :lang
AND ${parentCode ? '"STD_RD_ParentCode" = :parentCode' : '"STD_RD_ParentCode" IS NULL'}
ORDER BY "STD_RD_ItemCode"`, { refCode, version: v.version, lang: languageCode, parentCode: parentCode || null });
const children = rows.map(short);
return {
success: true,
referential: { code: ref.code, name: ref.name },
version: v.version,
parentCode: parentCode || null,
languageCode,
childrenCount: children.length,
children,
};
}
/** Une entrée par version et langue : { versionNumber, versionLanguage, itemCount }. */
const byLang = (versions, languageCode) => versions.flatMap((v) => [...v.langs]
.filter(([lang]) => !languageCode || lang === languageCode)
.map(([lang, nb]) => ({ versionNumber: v.version, versionLanguage: lang, itemCount: nb })));
/** @param {Object} params { refCode, languageCode } — la version active seulement. */
async function getActiveVersions(params) {
const { refCode, languageCode } = params;
if (!refCode) throw new Error('Le code du référentiel est requis');
const ref = await referential(refCode);
if (!ref) return notFound(refCode);
const versions = byLang(ref.versions.slice(0, 1), languageCode);
return {
success: true,
referential: { code: ref.code, name: ref.name },
languageFilter: languageCode || null,
versionsCount: versions.length,
versions,
};
}
/**
* @param {Object} params { refCode, languageCode } — toutes les versions.
* Forme de Standards, `referentials` répété par entrée compris. (La fonction
* Standards ne rend en fait que les actives et lève sur `lang` indéfini :
* c'est l'intention documentée qui est reprise, pas le défaut.)
*/
async function getAllVersions(params) {
const { refCode, languageCode } = params;
if (!refCode) throw new Error('Le code du référentiel est requis');
const ref = await referential(refCode);
if (!ref) return notFound(refCode);
const versions = byLang(ref.versions, languageCode);
return {
success: true,
referentials: versions.map(() => ({ code: ref.code, name: ref.name })),
languageFilter: languageCode || null,
versionsCount: versions.length,
versions,
};
}
/**
* @param {Object} options { languageCode, isActive } — isActive : true les
* actives, false les autres, absent toutes.
* @returns {Array} (pas d'enveloppe success, comme Standards)
*/
async function getReferentials(options = {}) {
const { languageCode, isActive } = options;
const codes = await servedReferentials();
const desc = await descriptions(codes);
const result = [];
for (const code of codes) {
const ref = await referential(code, desc);
if (!ref) continue;
const details = [];
ref.versions.forEach((v, i) => {
const available = [...v.langs.keys()];
const active = i === 0;
if (languageCode && !available.includes(languageCode)) return;
if (isActive === true && !active) return;
if (isActive === false && active) return;
details.push({
versionNumber: v.version, isActive: active, activeLanguages: active ? available : [], availableLanguages: available,
});
});
if (!details.length) continue;
let currentVersion;
if (languageCode) {
currentVersion = details.find((d) => d.isActive)?.versionNumber || null;
} else {
const active = details.find((d) => d.isActive);
currentVersion = Object.fromEntries((active?.activeLanguages || []).map((l) => [l, active.versionNumber]));
}
result.push({
code: ref.code,
name: ref.name,
description: ref.description,
versionsCount: details.length,
currentVersion,
versions: details,
});
}
return result;
}
/** Les libellés de ces codes, dans la version active (« code - libellé » sur demande). */
async function getReferenceTextFromKeys(refCode, languageCode, itemCodes, withCode = false) {
if (!itemCodes || !itemCodes.length) return [];
const data = await getReferentialData({ refCode, languageCode, itemCodes });
const items = data?.versions?.[0]?.items;
if (!Array.isArray(items)) return [];
return items.map((el) => (withCode ? `${el.data?.code ?? ''} - ${el.label}` : el.label));
}
/** Le libellé d'un code d'enum de XONE_STANDARDS ; le code brut à défaut. */
async function getXoneStandardLabel(section, code, languageCode = 'fr') {
if (!code) return '';
try {
const result = await getReferentialData({ refCode: 'XONE_STANDARDS', languageCode });
if (!result || !result.success) return code;
const upSection = String(section || '').toUpperCase();
const match = (result.versions?.[0]?.items || []).find((it) => (it.data?.section || '').toUpperCase() === upSection
&& ((it.data?.code) || it.code) === code);
return match?.label || code;
} catch {
return code;
}
}
/** Pas encore porté : les liens de sys_data_model ne sont pas les cartes Standards. */
async function getReferentialCrossData() {
throw new Error('getReferentialCrossData : pas encore portée sur sys-data-model (lot suivant)');
}
// ───────────────────────────────────────────────────────────────────────────
// L'appel par HTTP (onglet Test API) : la requête → les arguments
// ───────────────────────────────────────────────────────────────────────────
const text = (v) => (v === undefined || v === null || String(v).trim() === '' ? undefined : String(v).trim());
const boolean = (v) => (text(v) === undefined ? undefined : ['true', '1', 'on'].includes(String(v).toLowerCase()));
const number = (v) => (text(v) === undefined ? undefined : Number(v));
const list = (v) => {
const raw = Array.isArray(v) ? v : String(v ?? '').split(',');
const codes = raw.map((c) => String(c).trim()).filter(Boolean);
return codes.length ? codes : undefined;
};
/** Les fonctions de lecture appelables, et leurs arguments tirés de la requête. */
const ARGS = {
getReferentials: (q) => [{ languageCode: text(q.languageCode), isActive: boolean(q.isActive) }],
getReferentialData: (q) => [{
refCode: text(q.refCode),
languageCode: text(q.languageCode),
version: text(q.version),
itemCodes: list(q.itemCodes),
withParent: boolean(q.withParent),
type: text(q.type),
normalizeCode: boolean(q.normalizeCode),
includeQuestions: boolean(q.includeQuestions),
maxLevel: number(q.maxLevel),
}],
searchItems: (q) => [{
refCode: text(q.refCode), query: text(q.query), languageCode: text(q.languageCode), version: text(q.version), limit: number(q.limit),
}],
getChildren: (q) => [{
refCode: text(q.refCode), parentCode: text(q.parentCode), languageCode: text(q.languageCode), version: text(q.version),
}],
getActiveVersions: (q) => [{ refCode: text(q.refCode), languageCode: text(q.languageCode) }],
getAllVersions: (q) => [{ refCode: text(q.refCode), languageCode: text(q.languageCode) }],
getReferenceTextFromKeys: (q) => [text(q.refCode), text(q.languageCode), list(q.itemCodes), boolean(q.withCode)],
getXoneStandardLabel: (q) => [text(q.section), text(q.code), text(q.languageCode)],
};
/** Les arguments d'une fonction, sans les undefined de queue ni les clés vides. */
function argsOf(func, query = {}) {
if (!Object.hasOwn(ARGS, func)) return null;
const factory = ARGS[func];
return factory(query).map((a) => (a && typeof a === 'object' && !Array.isArray(a)
? Object.fromEntries(Object.entries(a).filter(([, v]) => v !== undefined))
: a));
}
const writeRefused = (func) => async () => {
logger.warn(`[sdm-api] ${func} appelée : refusée, sys-data-model n'écrit qu'en important`);
return { success: false, error: `${func} : sys-data-model n'écrit qu'en important une version (onglet Import)` };
};
module.exports = {
// l'API, à la forme de Standards
getReferentials,
getReferentialData,
searchItems,
getChildren,
getActiveVersions,
getAllVersions,
getReferenceTextFromKeys,
getXoneStandardLabel,
getReferentialCrossData,
setItem: writeRefused('setItem'),
removeItem: writeRefused('removeItem'),
mergeVersionLabels,
// le module
clearCache,
FUNCTIONS: Object.keys(ARGS),
argsOf,
// pour les tests
descending,
asItems,
byLang,
};
src backfill data
/**
* sdm-reprise-donnees.utils.js — la reprise des données existantes (lot 2.3),
* en mémoire.
*
* Une version de Standards (les lignes STD_RD d'une STD_RV, une par code et
* par langue) est remise à la forme d'une sortie de traitement
* `{ headers, fr, en }`, puis rejouée par le même chemin qu'un import :
* aiguillage, moteur, écriture. Il n'y a donc qu'une seule façon d'entrer
* dans les trios.
*
* colonne code ← STD_RD_ItemCode
* colonne libellé ← STD_RD_Label (tronqué à 500 par Standards : signalé)
* colonne parent ← STD_RD_ParentCode
* autres colonnes ← STD_RD_Data[normalizedName] ; une liste est rejointe
* par un retour à la ligne, comme dans la cellule d'origine
*
* Les lignes sont rangées par STD_RD_SortOrder : l'ordre du fichier.
*/
const LABEL_LENGTH = 500;
/** Une valeur de STD_RD_Data rendue en cellule. */
function asCell(value) {
if (value === null || value === undefined) return '';
if (Array.isArray(value)) return value.map((v) => (v === null || v === undefined ? '' : String(v))).join('\n');
if (typeof value === 'object') return JSON.stringify(value);
return value;
}
/**
* @param {Array} colonnes STD_RF_Schema.columns
* @param {Array} lignes STD_RD d'une version : ItemCode, LanguageCode,
* Data, Label, ParentCode, SortOrder
* @returns {{ headers, fr, en, constats: { labelsTronques: string[], codes: {fr, en} } }}
*/
function outputFromStd(columns, rows) {
const headers = columns.map((c) => c.name);
const byLang = { fr: [], en: [] };
const truncatedLabels = new Set();
const sorted = [...rows].sort((a, b) => (a.STD_RD_SortOrder ?? 0) - (b.STD_RD_SortOrder ?? 0)
|| String(a.STD_RD_ItemCode).localeCompare(String(b.STD_RD_ItemCode)));
for (const l of sorted) {
const lang = String(l.STD_RD_LanguageCode || '').toLowerCase();
if (!byLang[lang]) continue;
const data = l.STD_RD_Data || {};
if (String(l.STD_RD_Label || '').length >= LABEL_LENGTH) truncatedLabels.add(l.STD_RD_ItemCode);
byLang[lang].push(columns.map((col) => {
if (col.isCode) return l.STD_RD_ItemCode;
if (col.isLabel) return l.STD_RD_Label || '';
if (col.isParent) return l.STD_RD_ParentCode || '';
return asCell(data[col.normalizedName]);
}));
}
const codes = (lang) => new Set(rows.filter((l) => !lang || String(l.STD_RD_LanguageCode).toLowerCase() === lang)
.map((l) => l.STD_RD_ItemCode)).size;
return {
headers,
fr: byLang.fr,
en: byLang.en,
findings: { truncatedLabels: [...truncatedLabels], codes: { fr: codes('fr'), en: codes('en'), total: codes() } },
};
}
module.exports = { outputFromStd, asCell };
src backfill utils
/**
* sdm-reprise.utils.js — la reprise automatique des déclarations depuis
* l'existant (lot 1.1, T1.1.3), en mémoire.
*
* ── L'ENTRÉE ──────────────────────────────────────────────────────────────
*
* Les lignes de STD_RF_Referentials telles que la base les rend
* (`STD_RF_Code`, `STD_RF_Name`, `STD_RF_Schema.columns[]`). La lecture en
* base se fera en lecture seule ; ce fichier ne dépend que du registre.
*
* ── CE QU'IL FAIT ─────────────────────────────────────────────────────────
*
* Un référentiel donne une entité, dont les attributs sont les colonnes du
* schéma, dans leur ordre. Un référentiel découpé (REGLES_DECOUPAGE, P10)
* donne une entité par valeur du discriminant, chaînées en parenté :
*
* - code, libellé et parent vont sur chacune ;
* - le discriminant et les colonnes `ignorer` ne sont déclarés nulle part ;
* - les autres colonnes (LINK_STDS, CONTROLS, CONTROLS_VERSION pour
* GOVERNANCE) vont où la mesure trouve des valeurs (P12), sinon sur
* l'entité la plus profonde.
*
* ── LES CONVERSIONS ───────────────────────────────────────────────────────
*
* type : les types STD déclarés sont déjà ceux de SDM ; le type
* historique `string` (non déclaré) devient `texte` ;
* séparateur: pipe, semicolon, comma, newline → le caractère ; `bracketed`
* devient `,` (les crochets ne sont pas retirés : signalé) ;
* sur une colonne NON déclarée (type `string`), Standards ignore
* le séparateur et découpe aux retours à la ligne
* (`formatCellValue`) : on reprend donc le retour à la ligne, et on le signale
* (T2.2.11) ;
* empreinte : CREATION et UPDATE en sont exclus (décision A), ainsi que le
* code et le parent, qui ne sont pas du contenu.
*/
const { SPLIT_RULES } = require('./sdm-routing.utils');
const registry = require('./sdm-registry.utils');
const SEPARATORS = { pipe: '|', semicolon: ';', comma: ',', newline: '\n', bracketed: ',' };
const DATES_OUTSIDE_FINGERPRINT = ['creation', 'update'];
/** Libellés des entités d'un référentiel découpé. */
const ENTITY_LABELS = {
gov_procedure: { fr: 'Procédure', en: 'Procedure' },
gov_processus: { fr: 'Processus', en: 'Process' },
gov_regle: { fr: 'Règle', en: 'Rule' },
gov_exigence: { fr: 'Exigence', en: 'Requirement' },
};
/** Le code d'entité d'un référentiel non découpé : RISK_PROCESS → risk_process. */
const entityCode = (referentialCode) => String(referentialCode).toLowerCase().replace(/[^a-z0-9_]+/g, '_');
/**
* La description d'une colonne (TH.12) : longueur fixe, libellés et
* syndication, pris du schéma puis de STD_IN_Config.columns.
*/
function description(column, config = {}) {
const length = Number(column.fixedLength ?? config.fixedLength);
const fr = String(column.labelFr || config.labelFr || '').trim();
const en = String(column.labelEn || config.labelEn || '').trim();
const syndication = column.syndication || config.syndication;
return {
LongueurFixe: Number.isInteger(length) && length > 0 ? length : null,
Libelle: fr || en ? { fr, en } : null,
Syndication: registry.SYNDICATIONS.includes(syndication) ? syndication : 'none',
};
}
/** Une colonne de STD_RF_Schema en attribut SDM ; `constats` reçoit les écarts. */
function asAttribute(column, ordre, findings, referential, config) {
const name = column.name;
const role = column.isCode ? 'code' : column.isLabel ? 'libelle' : column.isParent ? 'parent' : 'aucun';
let type = column.type;
const declared = registry.TYPES.includes(type);
if (!declared) {
findings.defaultTypes.push({ referential, column: name, type: type ?? null });
type = 'texte';
}
const withSeparator = Boolean(column.separator && column.separator !== 'none');
let separator = withSeparator ? SEPARATORS[column.separator] : null;
if (withSeparator && !declared) {
if (column.separator !== 'newline') {
findings.ignoredSeparators.push({ referential, column: name, separator: column.separator });
}
separator = SEPARATORS.newline;
} else {
if (column.separator === 'bracketed') findings.closeSeparators.push({ referential, column: name });
if (withSeparator && !separator) {
findings.unknownSeparators.push({ referential, column: name, separator: column.separator });
}
}
return {
Nom: name,
Type: type,
Role: role,
Multivalue: Boolean(separator),
Separateur: separator,
DansEmpreinte: role !== 'code' && role !== 'parent'
&& !DATES_OUTSIDE_FINGERPRINT.includes(String(name).toLowerCase()),
Ordre: ordre,
...description(column, config),
};
}
/** Les entités où la mesure a trouvé des valeurs pour cette colonne. */
function measuredEntities(measure, column, entities) {
return entities.filter((e) => measure.some((m) => m.entity === e
&& m.attribute.toLowerCase() === column.toLowerCase() && m.nb > 0));
}
/**
* Reprend les déclarations.
*
* @param {Array<{STD_RF_Code: string, STD_RF_Name: string, STD_RF_Schema: {columns: Array}}>} referentiels
* @param {Object} [options]
* @param {Object<string, Array<{entite, attribut, nb}>>} [options.mesures]
* par référentiel découpé, les valeurs trouvées par entité — la sortie de
* `horsDeclaration(lignes, {})` sur un fichier réel (P12)
* @returns {{ registre: Object, constats: Object }}
*/
function runBackfill(referentials, { measures = {} } = {}) {
const reg = registry.emptyRegistry();
const findings = {
defaultTypes: [], closeSeparators: [], unknownSeparators: [], ignoredSeparators: [],
withoutSchema: [], defaultPlacement: [],
};
referentials.forEach((ref, rank) => {
const referential = ref.STD_RF_Code;
const columns = ref.STD_RF_Schema?.columns || [];
if (!columns.length) { findings.withoutSchema.push(referential); return; }
const rule = SPLIT_RULES[referential];
const configs = new Map((ref.STD_IN_Columns || []).map((c) => [String(c.name).toUpperCase(), c]));
const attributes = columns.map((c, i) => asAttribute(c, i, findings, referential,
configs.get(String(c.name).toUpperCase())));
if (!rule) {
const code = entityCode(referential);
const name = ref.STD_RF_Name || referential;
registry.createEntity(reg, { Code: code, Libelle: { fr: name, en: name }, Referentiel: referential, Ordre: rank * 10 });
// Un parent ici pointe la même entité : c'est la hiérarchie interne.
if (attributes.some((a) => a.Role === 'parent')) registry.editEntity(reg, code, { ParenteCode: code });
for (const a of attributes) registry.addAttribute(reg, code, a);
return;
}
// ── Référentiel découpé ────────────────────────────────────────────
const entities = [...new Set(Object.values(rule.values))];
entities.forEach((code, level) => {
registry.createEntity(reg, {
Code: code,
Libelle: ENTITY_LABELS[code] || { fr: code, en: code },
Referentiel: referential,
ParenteCode: level ? entities[level - 1] : null,
Ordre: rank * 10 + level,
});
});
const excluded = new Set([rule.column, ...(rule.ignore || [])].map((c) => String(c).toLowerCase()));
for (const a of attributes) {
if (excluded.has(a.Nom.toLowerCase())) continue;
let targets;
if (a.Role !== 'aucun') {
targets = a.Role === 'parent' ? entities.slice(1) : entities;
} else {
targets = measuredEntities(measures[referential] || [], a.Nom, entities);
if (!targets.length) {
targets = [entities[entities.length - 1]];
findings.defaultPlacement.push({ referential, column: a.Nom, entity: targets[0] });
}
}
for (const code of targets) registry.addAttribute(reg, code, a);
}
});
return { registry: reg, findings };
}
module.exports = {
runBackfill,
entityCode,
};
/**
* sdm-fichiers.utils.js — des deux fichiers déposés à la sortie attendue par
* l'aiguillage : `{ ok, format, headers, fr, en }`.
*
* Deux formats sont acceptés, reconnus à leur ligne d'en-tête :
*
* plat le fichier que l'équipe importe aujourd'hui dans Standards :
* en-tête en ligne 1, une ligne par élément, colonnes du
* référentiel (pour GOVERNANCE : ID, PARENT_ID, CODE, LIBELLE,
* LINK_STDS, CONTROLS, CONTROLS_VERSION, TYPE). Seul ce format
* porte CONTROLS. Lu comme Standards le lit (`parseImportWorkbook`),
* puis passé tel quel.
*
* source le fichier partagé par l'entreprise, transformé par le
* traitement Standards du référentiel (`BUILDERS[ref].build`),
* appelé comme Standards l'appelle selon sa sorte (TH.12) :
* bilingual deux classeurs FR et EN ;
* csv une extraction CSV (+ la dépendance) ;
* workbook un classeur, les deux langues dedans (+ dépendances) ;
* risks deux extractions, niveaux 1 à 3 puis niveau 4 ;
* jobs une extraction, deux compléments FR / EN facultatifs.
*
* Un fichier est « plat » s'il porte en ligne 1 les colonnes de structure de
* la règle de découpage (code, parent, discriminant). Les deux fichiers
* doivent être du même format. Les colonnes EN sont remises dans l'ordre FR :
* l'aiguillage partage une seule ligne d'en-tête.
*/
const standards = require('../../../controllers/admin/settings/settings-standards.utils');
const integrations = require('../../../controllers/admin/settings/standards-integrations.utils');
/**
* Les fichiers qu'un import peut recevoir : [clé, champ du formulaire].
* L'ordre fixe celui de la signature : FR puis EN d'abord, comme avant.
*/
const FILES = [
['fr', 'fileFr'], ['en', 'fileEn'], ['source', 'file'], ['l4', 'fileL4'], ['stdFr', 'fileStdFr'], ['stdEn', 'fileStdEn'],
];
/** Ce que chaque sorte de traitement attend, pour l'aide et les messages. */
const EXPECTED = {
bilingual: 'les deux classeurs source FR et EN',
csv: 'l\'extraction CSV',
workbook: 'le classeur source (FR et EN dedans)',
risks: 'les deux extractions : niveaux 1 à 3, puis niveau 4',
jobs: 'l\'extraction des postes (compléments FR / EN facultatifs)',
};
/** Les sortes dont le traitement lit les référentiels dont il dépend. */
const WITH_DEPENDENCIES = ['csv', 'workbook', 'risks'];
const text = (v) => (v === undefined || v === null ? '' : String(v).trim());
/** Les colonnes qui signent un fichier plat pour cette règle. */
const structureColumns = (rule) => [rule.code, rule.parent, rule.column].filter(Boolean);
/** Lit un classeur comme Standards ; `null` s'il est illisible. */
function readFlat(buffer, parse) {
try {
const { columns, data } = parse(buffer);
return { columns: columns.map(text), rows: data };
} catch (_) {
return null;
}
}
/** Appelle le traitement d'une extraction source unique, selon sa sorte. */
function buildSource(builder, { sourceBuffer, l4Buffer, stdFrBuffer, stdEnBuffer, deps = {} }) {
const dependencies = Object.keys(deps);
switch (builder.kind) {
case 'csv': {
const rows = integrations.parseCsv(integrations.decode(sourceBuffer));
if (rows.length < 2) return { ok: false, message: 'fichier vide' };
const dep = dependencies[0];
return builder.build(rows, dep ? { depFr: deps[dep].fr, depEn: deps[dep].en, dependency: dep } : null);
}
case 'workbook':
return builder.build(sourceBuffer, dependencies.length ? { deps } : undefined);
case 'risks':
if (!l4Buffer) return { ok: false, message: `les deux extractions sont attendues : ${EXPECTED.risks}` };
return builder.build({ l3Buffer: sourceBuffer, l4Buffer }, { deps });
case 'jobs':
return builder.build({ mainBuffer: sourceBuffer, stdFrBuffer, stdEnBuffer });
default:
return { ok: false, message: `traitement ${builder.kind} inconnu` };
}
}
/**
* @param {object} regle règle de découpage du référentiel (REGLES_DECOUPAGE)
* @param {object} builder traitement Standards du référentiel, ou null
* @param {{frBuffer?, enBuffer?, sourceBuffer?, l4Buffer?, stdFrBuffer?, stdEnBuffer?, deps?}} fichiers
* deux classeurs FR et EN (plat ou bilingual), ou une extraction source ;
* `deps` : les référentiels dont le traitement dépend (`loadStandard`)
* @param {Function} [parse] lecteur de classeur plat (injectable pour les tests)
*/
function readOutput(rule, builder, files, parse = standards.parseImportWorkbook) {
const { frBuffer, asBuffer, sourceBuffer } = files;
if (!(frBuffer && asBuffer)) {
if (!sourceBuffer) return { ok: false, message: 'déposez les deux fichiers FR et EN, ou l\'extraction source' };
if (!builder || builder.kind === 'bilingual') {
return { ok: false, message: `ce référentiel attend deux fichiers FR et EN${builder ? ` (${EXPECTED.bilingual})` : ' au format plat'}` };
}
let output;
try {
output = buildSource(builder, files);
} catch (e) {
return { ok: false, message: `traitement ${builder.kind} impossible : ${e.message}` };
}
return output.ok ? { ...output, format: 'source' } : output;
}
const structure = structureColumns(rule);
const [fr, en] = [frBuffer, asBuffer].map((b) => readFlat(b, parse));
const isFlat = (l) => Boolean(l) && structure.every((c) => l.columns.includes(c));
if (isFlat(fr) && isFlat(en)) {
const missing = fr.columns.filter((c) => c && !en.columns.includes(c));
if (missing.length) {
return { ok: false, message: `colonnes absentes du fichier EN : ${missing.join(', ')}` };
}
const ordre = fr.columns.map((c) => en.columns.indexOf(c));
return {
ok: true,
format: 'flat',
headers: fr.columns,
fr: fr.rows,
en: en.rows.map((row) => ordre.map((i) => (i < 0 ? '' : row[i]))),
};
}
if (isFlat(fr) || isFlat(en)) {
return { ok: false, message: `un fichier est au format plat (${structure.join(', ')} en ligne 1), l'autre non : déposez deux fichiers du même format` };
}
// Le formulaire dépose deux classeurs FR et EN : seul un traitement
// `bilingual` les attend (les autres prennent un CSV, un classeur unique…).
if (!builder || builder.kind !== 'bilingual') {
const other = builder ? `, ou déposez ${EXPECTED[builder.kind] || 'l\'extraction source'} seul(e)` : '';
return { ok: false, message: `format non reconnu : en format plat, la ligne 1 doit porter ${structure.join(', ')}${other}` };
}
const output = builder.build({ frBuffer, asBuffer });
return output.ok ? { ...output, format: 'source' } : output;
}
module.exports = { readOutput, structureColumns, FILES, EXPECTED, WITH_DEPENDENCIES };
/**
* sdm-empreinte.utils.js — l'empreinte d'un contenu (plan § 20.1).
*
* ⚠ FICHIER GELÉ — RECETTE n° 1
*
* Toute modification de ce fichier change les empreintes qu'il calcule, donc
* rend fausses toutes celles déjà stockées : la déduplication cesserait de
* reconnaître des contenus identiques, en silence. Une évolution impose
* d'incrémenter RECETTE_EMPREINTE et de recalculer l'ensemble.
* (NFC a été ajouté le 29/10 sans incrément : aucune empreinte n'était
* encore stockée.)
*
* ── CE QUI ENTRE DANS L'EMPREINTE ─────────────────────────────────────────
*
* Le contenu de TOUTES les langues ensemble ({ fr: {…}, en: {…} }), réduit
* aux attributs déclarés `DansEmpreinte` (décision A : les dates en sont
* exclues). Le code, le parent et l'ordre n'y entrent pas : ils ne sont pas
* dans le contenu, mais sur l'identité et l'appartenance.
*
* ── LES RÈGLES (plan § 20.1, P4, P6) ──────────────────────────────────────
*
* 1. clés triées, à tous les niveaux ;
* 2. valeur vide = clé absente ("" , null, [], {} et l'absence se valent) ;
* 3. texte normalisé en NFC (un « é » saisi en un ou deux caractères donne
* la même empreinte), espaces de début et de fin coupés, toute suite de
* blancs réduite à un espace ; la casse et les accents sont conservés ;
* 4. un attribut multivalué est toujours un tableau : "STD001" et ["STD001"]
* donnent la même empreinte ; l'ordre des valeurs est celui de la source.
*
* ── POURQUOI UN FICHIER À PART ────────────────────────────────────────────
*
* `computeHash` (settings-standards.utils.js) calcule l'empreinte de
* STD_RD_DataHash, que lisent l'import et les mappings. On n'y touche pas :
* les deux recettes cohabitent jusqu'à la reprise du lot 2.3. Ce fichier ne
* dépend de rien d'autre que `crypto`, pour pouvoir être testé sans base.
*/
'use strict';
const crypto = require('crypto');
/** Numéro de la recette. À incrémenter à toute modification de ce fichier. */
const FINGERPRINT_RECIPE = 1;
/** Coupe les bords et réduit toute suite de blancs (sauts de ligne compris). */
function normaliserTexte(texte) {
return texte.normalize('NFC').replace(/\s+/g, ' ').trim();
}
/** Vraie si la valeur normalisée doit être traitée comme absente. */
function estVide(valeur) {
if (valeur === undefined || valeur === null || valeur === '') return true;
if (Array.isArray(valeur)) return valeur.length === 0;
if (typeof valeur === 'object') return Object.keys(valeur).length === 0;
return false;
}
/**
* Normalise une valeur quelconque. Rend `undefined` si elle est vide, pour
* que l'appelant omette la clé.
*/
function normalizeValue(valeur) {
if (valeur === undefined || valeur === null) return undefined;
if (typeof valeur === 'string') {
const texte = normaliserTexte(valeur);
return texte === '' ? undefined : texte;
}
if (typeof valeur === 'number') {
return Number.isFinite(valeur) ? valeur : undefined;
}
if (typeof valeur === 'boolean') return valeur;
if (valeur instanceof Date) {
return Number.isNaN(valeur.getTime()) ? undefined : valeur.toISOString();
}
if (Array.isArray(valeur)) {
const elements = valeur.map(normalizeValue).filter((v) => v !== undefined);
return elements.length === 0 ? undefined : elements;
}
if (typeof valeur === 'object') {
const objet = {};
for (const cle of Object.keys(valeur)) {
const v = normalizeValue(valeur[cle]);
if (v !== undefined) objet[cle] = v;
}
return estVide(objet) ? undefined : objet;
}
return undefined;
}
/**
* Normalise la valeur d'un attribut multivalué : toujours un tableau.
* Une chaîne est découpée sur le séparateur déclaré, s'il y en a un.
*/
function normaliserMultivaluee(valeur, separateur) {
let elements;
if (Array.isArray(valeur)) elements = valeur;
else if (typeof valeur === 'string' && separateur) elements = valeur.split(separateur);
else elements = [valeur];
return normalizeValue(elements);
}
/**
* JSON canonique : clés triées à tous les niveaux, sans espace superflu.
* Les tableaux gardent leur ordre.
*/
function canonical(valeur) {
if (Array.isArray(valeur)) return `[${valeur.map(canonical).join(',')}]`;
if (valeur !== null && typeof valeur === 'object') {
const cles = Object.keys(valeur).sort();
return `{${cles.map((c) => `${JSON.stringify(c)}:${canonical(valeur[c])}`).join(',')}}`;
}
return JSON.stringify(valeur);
}
/**
* Le contenu retenu pour l'empreinte : par langue, les seuls attributs
* `DansEmpreinte`, normalisés, sous leur nom en minuscules.
*
* @param {Array<{Nom: string, DansEmpreinte?: boolean, Multivalue?: boolean, Separateur?: string}>} attributs
* les attributs déclarés de l'entité (SDM_AT, sans le préfixe de colonne)
* @param {Object<string, Object>} contenu { fr: { NOM: valeur, … }, en: { … } }
* @returns {Object<string, Object>} { en: {…}, fr: {…} } — langues vides omises
*/
function keptContent(attributs, contenu) {
const retenu = {};
for (const langue of Object.keys(contenu || {})) {
const source = {};
for (const [cle, valeur] of Object.entries(contenu[langue] || {})) {
source[cle.toLowerCase()] = valeur;
}
const valeurs = {};
for (const attribut of attributs) {
if (attribut.DansEmpreinte === false) continue; // décision A
const nom = attribut.Nom.toLowerCase();
const v = attribut.Multivalue
? normaliserMultivaluee(source[nom], attribut.Separateur)
: normalizeValue(source[nom]);
if (v !== undefined) valeurs[nom] = v; // absent = absent
}
if (!estVide(valeurs)) retenu[langue.toLowerCase()] = valeurs;
}
return retenu;
}
/**
* L'empreinte d'un contenu : sha256 hexadécimal de son JSON canonique.
*
* @param {Array<Object>} attributs voir contenuRetenu
* @param {Object<string, Object>} contenu { fr: {…}, en: {…} }
* @returns {string} 64 caractères hexadécimaux
*/
function empreinte(attributs, contenu) {
const texte = canonical(keptContent(attributs, contenu));
return crypto.createHash('sha256').update(texte, 'utf8').digest('hex');
}
module.exports = {
FINGERPRINT_RECIPE,
empreinte,
keptContent,
canonical,
normalizeValue,
};
/**
* sdm-generateur.utils.js — déclaration → fichier de migration (lots 1.2, 2.3, 3.1).
*
* Pour chaque entité, le trio du plan § 16, dans le schéma `xone_ref` :
*
* <e> l'identité : id, code ;
* <e>_contenu un état distinct du texte, toutes langues ensemble ;
* <e>_membre l'appartenance à une version, avec le parent s'il y en a un.
*
* Les attributs ne deviennent pas des colonnes : le contenu est en jsonb.
* Ajouter ou retirer un attribut n'impose donc aucune migration.
*
* Trois gestes produisent du DDL :
*
* nouvelle entité le trio ;
* nouveau lien natif une table de liaison <source>_l_<attribut>, dont les
* deux clés étrangères tiennent le lien (écart E12 :
* une valeur du jsonb ne peut pas porter de FK) ;
* vue de compat. xone_ref.compat_<référentiel>, à la forme de
* STD_RD ⋈ STD_RV (lot 2.3).
*
* Le fichier engendré est au format sequelize-cli ; il porte son plan
* déclaratif (`SDM_PLAN`), recopié dans SDM_MG_Migrations, et, pour un lot
* d'entités, leur liste (`ENTITES`).
*
* Les codes d'entité sont validés par le registre (^[a-z][a-z0-9_]{0,63}$) :
* ils entrent tels quels dans le SQL.
*/
const fs = require('fs');
const path = require('path');
const DATA_SCHEMA = 'xone_ref';
const MIGRATIONS_DIR = path.resolve(__dirname, '../../../database/migrations');
const ENTITY_CODE = /^[a-z][a-z0-9_]{0,63}$/;
const table = (code, suffix = '') => `${DATA_SCHEMA}.${code}${suffix}`;
const literal = (v) => `'${String(v).replace(/'/g, "''")}'`;
/** Le SQL du trio d'une entité (plan § 16). */
function sqlTrio({ Code, ParenteCode }) {
if (!ENTITY_CODE.test(Code)) throw new Error(`code d'entité invalide : ${Code}`);
const parent = ParenteCode
? `\n parent_id uuid REFERENCES ${table(ParenteCode)} (id),`
: '';
return `CREATE TABLE ${table(Code)} (
id uuid PRIMARY KEY,
code varchar(128) NOT NULL UNIQUE
);
CREATE TABLE ${table(Code, '_contenu')} (
id uuid PRIMARY KEY,
objet_id uuid NOT NULL REFERENCES ${table(Code)} ON DELETE CASCADE,
data jsonb NOT NULL,
empreinte varchar(64) NOT NULL,
cree_le timestamptz NOT NULL DEFAULT now(),
UNIQUE (objet_id, empreinte)
);
CREATE TABLE ${table(Code, '_membre')} (
version_num varchar(32) NOT NULL,
objet_id uuid NOT NULL REFERENCES ${table(Code)},
contenu_id uuid NOT NULL REFERENCES ${table(Code, '_contenu')},${parent}
ordre integer,
PRIMARY KEY (version_num, objet_id)
);
CREATE INDEX ON ${table(Code, '_membre')} (objet_id);${ParenteCode ? `
CREATE INDEX ON ${table(Code, '_membre')} (parent_id);` : ''}
CREATE INDEX ON ${table(Code, '_membre')} (version_num);`;
}
/**
* Range les entités pour que chaque parent soit créé avant ses enfants.
* Un parent hors du lot (déjà créé) ne bloque pas ; une entité parente
* d'elle-même non plus.
*/
function creationOrder(entities) {
const remaining = new Map(entities.map((e) => [e.Code, e]));
const ordre = [];
while (remaining.size) {
const ready = [...remaining.values()].filter((e) => !e.ParenteCode
|| e.ParenteCode === e.Code || !remaining.has(e.ParenteCode));
if (!ready.length) throw new Error('boucle de parenté dans le lot à engendrer');
for (const e of ready) { ordre.push(e); remaining.delete(e.Code); }
}
return ordre;
}
const indent = (sql) => sql.split('\n').map((l) => (l ? ` ${l}` : l)).join('\n');
/** Le SQL entre dans un gabarit `…` : on neutralise \, ` et ${. */
const escape = (sql) => sql.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
/** Le gabarit commun des fichiers engendrés. */
function template({ title, constants = '', exports = '', plan, up, down }) {
return `/**
* Engendrée par sys_data_model (sdm-generator.utils.js) — ne pas modifier.
* ${title}
*/
${constants}const SDM_PLAN = ${JSON.stringify(plan)};
module.exports = {
${exports} SDM_PLAN,
async up(queryInterface, Sequelize, options = {}) {
await queryInterface.sequelize.query(\`
${indent(escape(up))}
\`, { transaction: options.transaction });
},
async down(queryInterface, Sequelize, options = {}) {
await queryInterface.sequelize.query(\`
${indent(escape(down))}
\`, { transaction: options.transaction });
},
};
`;
}
/** Le texte du fichier de migration pour un lot d'entités. */
function migrationFile(entities, { referential = null } = {}) {
const batch = creationOrder(entities);
const codes = batch.map((e) => e.Code);
return template({
title: `Entités : ${codes.join(', ')}`,
constants: `const ENTITES = ${JSON.stringify(codes)};\n`,
exports: ' ENTITES,\n',
plan: { type: 'entities', referential, entities: codes },
up: [`CREATE SCHEMA IF NOT EXISTS ${DATA_SCHEMA};`, ...batch.map(sqlTrio)].join('\n\n'),
down: [...batch].reverse()
.map((e) => `DROP TABLE IF EXISTS ${table(e.Code, '_membre')}, ${table(e.Code, '_contenu')}, ${table(e.Code)};`)
.join('\n'),
});
}
// ───────────────────────────────────────────────────────────────────────────
// Liens natifs (lot 3.1)
// ───────────────────────────────────────────────────────────────────────────
/** Un nom d'attribut en morceau d'identifiant SQL : LINK_STDS → link_stds. */
const asIdentifier = (name) => String(name || '').toLowerCase().normalize('NFD')
.replace(/[̀-ͯ]/g, '').replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
/**
* La table de liaison d'un lien natif porté par un attribut :
* <source>_l_<attribut>. PostgreSQL coupe un identifiant à 63 octets : un
* nom plus long est refusé plutôt que tronqué en silence.
*/
function linkTableName({ SDM_LN_SourceEntity: source, SDM_LN_SourceAttribute: attribute }) {
const name = `${source}_l_${asIdentifier(attribute)}`;
if (!attribute || !ENTITY_CODE.test(name) || name.length > 63) throw new Error(`nom de table de lien invalide : ${name}`);
return name;
}
/** Le SQL d'un lien natif : deux clés étrangères vers les identités. */
function sqlLinkTable(link) {
const { SDM_LN_SourceEntity: source, SDM_LN_TargetEntity: target } = link;
for (const code of [source, target]) if (!ENTITY_CODE.test(code || '')) throw new Error(`code d'entité invalide : ${code}`);
const name = linkTableName(link);
return `CREATE TABLE ${table(name)} (
version_num varchar(32) NOT NULL,
source_id uuid NOT NULL REFERENCES ${table(source)} (id),
cible_id uuid NOT NULL REFERENCES ${table(target)} (id),
PRIMARY KEY (version_num, source_id, cible_id)
);
CREATE INDEX ON ${table(name)} (cible_id);`;
}
/** Le fichier de migration d'un lien natif. */
function linkMigrationFile(link) {
const name = linkTableName(link);
return template({
title: `Lien natif : ${link.SDM_LN_SourceEntity}.${link.SDM_LN_SourceAttribute} → ${link.SDM_LN_TargetEntity}`,
plan: {
type: 'link',
linkId: link.SDM_LN_Id,
table: name,
source: link.SDM_LN_SourceEntity,
attribute: link.SDM_LN_SourceAttribute,
target: link.SDM_LN_TargetEntity,
},
up: sqlLinkTable(link),
down: `DROP TABLE IF EXISTS ${table(name)};`,
});
}
// ───────────────────────────────────────────────────────────────────────────
// Vue de compatibilité (lot 2.3)
// ───────────────────────────────────────────────────────────────────────────
// Pas de barre oblique inverse dans le SQL : chr(10) et chr(13) suffisent.
const NO_CR = (v) => `replace(${v}, chr(13), '')`;
const SEPARATOR_PATTERN = { pipe: '[|]', semicolon: ';', comma: ',', newline: 'chr(10)', bracketed: ',' };
/** Les morceaux non vides d'un texte, en jsonb : null, valeur seule ou tableau. */
const sqlList = (v, reason) => `(SELECT CASE count(*) WHEN 0 THEN NULL WHEN 1 THEN to_jsonb(min(x)) ELSE jsonb_agg(x ORDER BY n) END
FROM (SELECT btrim(p) AS x, n FROM regexp_split_to_table(${v}, ${reason}) WITH ORDINALITY AS t(p, n) WHERE btrim(p) <> '') q)`;
/**
* Une valeur du contenu, rendue comme Standards l'aurait écrite dans
* STD_RD_Data (`applyColumnType`) :
*
* colonne non déclarée (type `string`) : texte ; plusieurs lignes non vides
* deviennent un tableau (`formatCellValue`) ;
* séparateur déclaré : découpé, un seul élément reste une valeur simple ;
* nombre : numérique s'il se lit comme tel.
*
* Une valeur qui n'est pas du texte dans le contenu (nombre lu par le
* classeur, liste) est rendue telle quelle, comme `formatCellValue` le fait.
*
* @param {string} j l'accès jsonb à la valeur (c.data -> 'fr' -> 'X')
*/
function sqlCompatValue(j, declaration) {
const v = `NULLIF(btrim(${j} #>> '{}'), '')`;
let text;
if (!declaration) {
text = `CASE WHEN strpos(${v}, chr(10)) > 0 THEN ${sqlList(NO_CR(v), 'chr(10)')} ELSE to_jsonb(${v}) END`;
} else if (SEPARATOR_PATTERN[declaration.separator]) {
const reason = SEPARATOR_PATTERN[declaration.separator];
const source = declaration.separator === 'bracketed' ? `btrim(${v}, '[] ')` : NO_CR(v);
text = sqlList(source, reason === 'chr(10)' ? reason : literal(reason));
} else if (declaration.type === 'nombre') {
text = `CASE WHEN replace(${v}, ',', '.') ~ '^-?[0-9]+([.][0-9]+)?$' THEN to_jsonb(replace(${v}, ',', '.')::numeric) ELSE to_jsonb(${v}) END`;
} else {
text = `to_jsonb(${v})`;
}
return `CASE jsonb_typeof(${j}) WHEN 'string' THEN ${text} WHEN 'null' THEN NULL ELSE ${j} END`;
}
/**
* Le SELECT d'une entité dans la vue : une ligne par (version, objet, langue),
* à la forme de STD_RD_ReferentialData jointe à sa version.
*
* @param {Object} entite { Code, ParenteCode }
* @param {Array} colonnes STD_RF_Schema.columns, chacune avec `declaration`
* (parseColumnDeclaration) déjà calculée
* @param {Object} regle règle de découpage (colonne, valeurs, deduire)
*/
function sqlCompatSelect(entity, columns, rule, referential) {
const valueType = rule.values
? (Object.entries(rule.values).find(([, e]) => e === entity.Code) || [])[0]
: null;
const content = (name) => `(c.data -> l.langue -> ${literal(name)})`;
let label = 'NULL';
const pairs = [];
for (const col of columns) {
if (col.isCode || col.isParent) continue;
if (col.isLabel) { label = `left(btrim(${content(col.name)} #>> '{}'), 500)`; continue; }
let value;
if (rule.column && col.name === rule.column) value = valueType ? `to_jsonb(${literal(valueType)}::text)` : 'NULL';
else if (rule.infer && rule.infer[col.name] === 'dernierSegment') value = `to_jsonb(regexp_replace(i.code, '^.*-', ''))`;
else value = sqlCompatValue(content(col.name), col.declaration);
pairs.push(`${literal(col.normalizedName)}, ${value}`);
}
// jsonb_build_object est limité à 100 arguments : on fusionne par paquets.
const objects = [];
for (let i = 0; i < pairs.length; i += 40) objects.push(`jsonb_build_object(\n ${pairs.slice(i, i + 40).join(',\n ')})`);
const data = objects.length ? `jsonb_strip_nulls(${objects.join(' || ')})` : `'{}'::jsonb`;
const parent = entity.ParenteCode;
return `SELECT ${literal(referential)}::varchar(64) AS "STD_RF_Code",
m.version_num AS "STD_RV_VersionNumber",
c.id AS "STD_RD_Id",
i.code AS "STD_RD_ItemCode",
l.langue::varchar(5) AS "STD_RD_LanguageCode",
c.empreinte AS "STD_RD_DataHash",
${data} AS "STD_RD_Data",
${label} AS "STD_RD_Label",
${parent ? 'pi.code' : 'NULL::varchar(128)'} AS "STD_RD_ParentCode",
m.ordre - 1 AS "STD_RD_SortOrder",
${literal(entity.Code)}::varchar(64) AS sdm_entite
FROM ${table(entity.Code, '_membre')} m
JOIN ${table(entity.Code)} i ON i.id = m.objet_id
JOIN ${table(entity.Code, '_contenu')} c ON c.id = m.contenu_id
CROSS JOIN (VALUES ('fr'), ('en')) AS l(langue)${parent ? `
LEFT JOIN ${table(parent)} pi ON pi.id = m.parent_id` : ''}
WHERE c.data ? l.langue`;
}
/** Le nom de la vue de compatibilité d'un référentiel. */
const compatViewName = (referential) => `compat_${asIdentifier(referential)}`;
/** Le fichier de migration de la vue de compatibilité d'un référentiel. */
function viewMigrationFile({ referential, entities, columns, rule }) {
if (!entities.length) throw new Error('aucune entité pour la vue');
const name = compatViewName(referential);
const selects = entities.map((e) => sqlCompatSelect(e, columns, rule || {}, referential));
return template({
title: `Vue de compatibilité : ${referential} (${entities.map((e) => e.Code).join(', ')})`,
plan: { type: 'view', referential, view: name, entities: entities.map((e) => e.Code) },
up: `DROP VIEW IF EXISTS ${table(name)};\nCREATE VIEW ${table(name)} AS\n${selects.join('\nUNION ALL\n')};`,
down: `DROP VIEW IF EXISTS ${table(name)};`,
});
}
// ───────────────────────────────────────────────────────────────────────────
// Modèles Sequelize (écart au plan : le plan ne prévoit que les migrations)
// ───────────────────────────────────────────────────────────────────────────
//
// Un fichier par table engendrée, dans src/database/models/, chargé par
// l'index des modèles comme les autres : db.GovExigence.findOne(…).
// Le fichier porte `SDM_MODELE` (table, migration) pour être retrouvé.
// Écriture par l'import seulement : les modèles servent surtout à lire.
const MODELS_DIR = path.resolve(__dirname, '../../../database/models');
/** Le nom du modèle d'une table : gov_exigence_contenu → GovExigenceContenu. */
const modelNameOf = (tableCode) => tableCode.split('_').filter(Boolean)
.map((m) => m[0].toUpperCase() + m.slice(1)).join('');
/** Le nom du fichier du modèle d'une table. */
const modelFileOf = (tableCode) => `${DATA_SCHEMA}_${tableCode}.js`;
const COLUMN = {
uuidPk: '{ type: DataTypes.UUID, primaryKey: true, defaultValue: DataTypes.UUIDV4 }',
uuid: '{ type: DataTypes.UUID, allowNull: false }',
nilUuid: '{ type: DataTypes.UUID, allowNull: true }',
versionPk: '{ type: DataTypes.STRING(32), allowNull: false, primaryKey: true }',
uuidPkFk: '{ type: DataTypes.UUID, allowNull: false, primaryKey: true }',
};
/**
* Le texte d'un fichier de modèle.
* @param {Object} d { table, migration, colonnes: [[nom, def]], associations: [ligne] }
*/
function modelText({ table: tableCode, migration, title, columns, associations }) {
const name = modelNameOf(tableCode);
const assoc = associations.length
? ` ${name}.associate = (db) => {\n${associations.map((a) => ` ${a}`).join('\n')}\n };\n`
: '';
return `/**
* Engendré par sys_data_model (sdm-generator.utils.js) — ne pas modifier.
* ${title}
* Table ${DATA_SCHEMA}.${tableCode}, créée par la migration ${migration}.
* Écriture uniquement par l'import (sdm-import) ; ailleurs, lecture seule.
*/
const SDM_MODELE = ${JSON.stringify({ table: tableCode, model: name, migration })};
module.exports = (sequelize, DataTypes) => {
const ${name} = sequelize.define('${name}', {
${columns.map(([c, def]) => ` ${c}: ${def},`).join('\n')}
}, {
schema: '${DATA_SCHEMA}',
tableName: '${tableCode}',
timestamps: false,
});
${assoc} ${name}.SDM_MODELE = SDM_MODELE;
return ${name};
};
`;
}
/**
* Une association vers un modèle d'un autre fichier : ignorée si ce modèle
* n'est pas (encore) engendré, pour ne jamais empêcher le chargement.
*/
const associer = (target, row) => `if (db.${target}) ${row}`;
/** Les trois fichiers de modèle du trio d'une entité : [{ fichier, texte }]. */
function trioModels({ Code, ParenteCode }, migration) {
if (!ENTITY_CODE.test(Code)) throw new Error(`code d'entité invalide : ${Code}`);
const I = modelNameOf(Code);
const C = modelNameOf(`${Code}_contenu`);
const M = modelNameOf(`${Code}_membre`);
const P = ParenteCode ? modelNameOf(ParenteCode) : null;
const title = `Entité ${Code}`;
return [
{
table: Code,
text: modelText({
table: Code, migration, title: `${title} — identité`,
columns: [['id', COLUMN.uuidPk], ['code', '{ type: DataTypes.STRING(128), allowNull: false, unique: true }']],
associations: [
`${I}.hasMany(db.${C}, { foreignKey: 'objet_id', as: 'contenus' });`,
`${I}.hasMany(db.${M}, { foreignKey: 'objet_id', as: 'membres' });`,
],
}),
},
{
table: `${Code}_contenu`,
text: modelText({
table: `${Code}_contenu`, migration, title: `${title} — contenu (jsonb, toutes langues)`,
columns: [
['id', COLUMN.uuidPk],
['objet_id', COLUMN.uuid],
['data', '{ type: DataTypes.JSONB, allowNull: false }'],
['empreinte', '{ type: DataTypes.STRING(64), allowNull: false }'],
['cree_le', '{ type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }'],
],
associations: [`${C}.belongsTo(db.${I}, { foreignKey: 'objet_id', as: 'objet' });`],
}),
},
{
table: `${Code}_membre`,
text: modelText({
table: `${Code}_membre`, migration, title: `${title} — appartenance à une version`,
columns: [
['version_num', COLUMN.versionPk],
['objet_id', COLUMN.uuidPkFk],
['contenu_id', COLUMN.uuid],
...(P ? [['parent_id', COLUMN.nilUuid]] : []),
['ordre', '{ type: DataTypes.INTEGER, allowNull: true }'],
],
associations: [
`${M}.belongsTo(db.${I}, { foreignKey: 'objet_id', as: 'objet' });`,
`${M}.belongsTo(db.${C}, { foreignKey: 'contenu_id', as: 'contenu' });`,
...(P ? [associer(P, `${M}.belongsTo(db.${P}, { foreignKey: 'parent_id', as: 'parent' });`)] : []),
],
}),
},
];
}
/** Le fichier de modèle d'une table de lien natif : [{ table, texte }]. */
function linkModels(link, migration) {
const tableCode = linkTableName(link);
const L = modelNameOf(tableCode);
const S = modelNameOf(link.SDM_LN_SourceEntity);
const Ci = modelNameOf(link.SDM_LN_TargetEntity);
return [{
table: tableCode,
text: modelText({
table: tableCode, migration,
title: `Lien natif ${link.SDM_LN_SourceEntity}.${link.SDM_LN_SourceAttribute} → ${link.SDM_LN_TargetEntity}`,
columns: [['version_num', COLUMN.versionPk], ['source_id', COLUMN.uuidPkFk], ['cible_id', COLUMN.uuidPkFk]],
associations: [
associer(S, `${L}.belongsTo(db.${S}, { foreignKey: 'source_id', as: 'source' });`),
associer(Ci, `${L}.belongsTo(db.${Ci}, { foreignKey: 'cible_id', as: 'cible' });`),
],
}),
}];
}
/** Le descripteur d'un fichier de modèle engendré, ou null. */
function modelFromText(text) {
const m = text.match(/^const SDM_MODELE = (\{.*\});$/m);
return m ? JSON.parse(m[1]) : null;
}
/** Les modèles engendrés du dossier : [{ fichier, table, modele, migration }]. */
function generatedModels(dir = MODELS_DIR) {
if (!fs.existsSync(dir)) return [];
const result = [];
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.js')).sort()) {
const d = modelFromText(fs.readFileSync(path.join(dir, file), 'utf8'));
if (d) result.push({ file, ...d });
}
return result;
}
/**
* Écrit les fichiers de modèle, tout ou rien : un fichier déjà présent ou un
* nom de modèle déjà pris par un autre modèle du dossier arrête tout avant
* la moindre écriture. Rend les noms de fichiers.
*/
function writeModels(models, { dir = MODELS_DIR } = {}) {
fs.mkdirSync(dir, { recursive: true });
const taken = new Set();
for (const f of fs.readdirSync(dir).filter((x) => x.endsWith('.js'))) {
const text = fs.readFileSync(path.join(dir, f), 'utf8');
for (const [, n] of text.matchAll(/(?:modelName:\s*|sequelize\.define\()'([^']+)'/g)) taken.add(n);
}
for (const { table: t } of models) {
const file = modelFileOf(t);
if (fs.existsSync(path.join(dir, file))) throw new Error(`le modèle ${file} existe déjà`);
if (taken.has(modelNameOf(t))) throw new Error(`le nom de modèle ${modelNameOf(t)} est déjà pris`);
}
return models.map(({ table: t, text }) => {
const file = modelFileOf(t);
fs.writeFileSync(path.join(dir, file), text, { flag: 'wx' });
return file;
});
}
// ───────────────────────────────────────────────────────────────────────────
// Fichiers
// ───────────────────────────────────────────────────────────────────────────
/** Horodatage sequelize-cli : AAAAMMJJhhmmss. */
const timestamp = (date = new Date()) => date.toISOString().replace(/\D/g, '').slice(0, 14);
/** Le plan porté par le texte d'un fichier engendré, ou null. */
function planFromText(text) {
const plan = text.match(/^const SDM_PLAN = (\{.*\});$/m);
if (plan) return JSON.parse(plan[1]);
const entities = text.match(/^const ENTITES = (\[.*\]);$/m);
return entities ? { type: 'entities', referential: null, entities: JSON.parse(entities[1]) } : null;
}
/** Les fichiers engendrés du dossier, dans l'ordre : [{ fichier, plan }]. */
function generatedMigrations(dir = MIGRATIONS_DIR) {
if (!fs.existsSync(dir)) return [];
const result = [];
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.js')).sort()) {
const plan = planFromText(fs.readFileSync(path.join(dir, file), 'utf8'));
if (plan) result.push({ file, plan });
}
return result;
}
/**
* Les entités déjà engendrées dans un fichier du dossier des migrations,
* jouées ou non : Map<code, nomDeFichier>.
*/
function generatedEntities(dir = MIGRATIONS_DIR) {
const result = new Map();
for (const { file, plan } of generatedMigrations(dir)) {
if (plan.type !== 'entities') continue;
for (const code of plan.entities) result.set(code, file);
}
return result;
}
/** Écrit un fichier engendré, sans jamais en écraser un. Rend son nom. */
function writeFile(suffix, text, { dir = MIGRATIONS_DIR, date } = {}) {
const name = `${timestamp(date)}-sdm-${String(suffix).toLowerCase().replace(/[^a-z0-9]+/g, '-')}.js`;
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, name), text, { flag: 'wx' });
return name;
}
/**
* Écrit le fichier de migration d'un lot d'entités. Rend son nom.
*/
function writeMigration(entities, { referential, dir, date } = {}) {
if (!entities.length) throw new Error('aucune entité à engendrer');
return writeFile(referential || 'entities', migrationFile(entities, { referential: referential || null }), { dir, date });
}
module.exports = {
DATA_SCHEMA,
MIGRATIONS_DIR,
sqlTrio,
creationOrder,
migrationFile,
asIdentifier,
linkTableName,
sqlLinkTable,
linkMigrationFile,
sqlCompatValue,
sqlCompatSelect,
compatViewName,
viewMigrationFile,
planFromText,
generatedMigrations,
generatedEntities,
writeFile,
writeMigration,
MODELS_DIR,
modelNameOf,
modelFileOf,
trioModels,
linkModels,
modelFromText,
generatedModels,
writeModels,
};
/**
* sdm-import.utils.js — l'import en deux temps, en mémoire (lot 2.2, plan § 6).
*
* PHASE 1 — analyser aiguille, planifie, repère les anomalies, signe les
* fichiers ; n'écrit RIEN.
* PHASE 2 — integrer déclenchée par un humain : refuse si l'analyse n'est
* pas validée, si un fichier a changé, ou si elle a déjà
* été intégrée ; sinon applique le plan.
*
* L'analyse rendue ici tient lieu, pour l'instant, de la ligne SDM_IM : la
* version SQL (enregistrement, transaction) viendra avec la forme des tables.
*
* Constats du rapport (plan § 6) couverts ici : 1 (créés / réutilisés),
* 2 (codes réapparus), 3 (changements de niveau), 5 (monolingues). Le
* constat 4 (liens caducs) attend les tables de liens.
*/
const crypto = require('crypto');
const { route, outsideDeclaration } = require('./sdm-routing.utils');
const { buildPlan, apply } = require('./sdm-resolution.utils');
/** La signature d'un lot de fichiers, dans l'ordre donné. */
function sign(files) {
const hash = crypto.createHash('sha256');
for (const file of files) {
hash.update(crypto.createHash('sha256').update(file).digest());
}
return hash.digest('hex');
}
/** Compare deux numéros de version segment par segment : 6.5 < 6.10. */
function compareVersions(a, b) {
const sa = String(a).split('.');
const sb = String(b).split('.');
for (let i = 0; i < Math.max(sa.length, sb.length); i += 1) {
const x = sa[i] ?? '';
const y = sb[i] ?? '';
const nx = Number(x);
const ny = Number(y);
const diff = x !== '' && y !== '' && Number.isFinite(nx) && Number.isFinite(ny)
? nx - ny
: x.localeCompare(y);
if (diff) return Math.sign(diff);
}
return 0;
}
/** Les entités d'une règle de découpage. */
const entitiesOf = (rule) => (rule.values ? [...new Set(Object.values(rule.values))] : [rule.entity]);
/**
* Les versions du référentiel antérieures à `versionNum`, triées. Elles se
* lisent sur toutes les entités de la règle : une entité vidée entièrement
* dans une version n'en fait pas disparaître la version.
*/
function previousVersions(state, rule, versionNum) {
const versions = new Set();
for (const entity of entitiesOf(rule)) {
for (const v of state.members.get(entity)?.keys() || []) versions.add(v);
}
return [...versions]
.filter((v) => compareVersions(v, versionNum) < 0)
.sort(compareVersions);
}
/**
* Constat 2 — les codes absents de la version précédente du référentiel,
* mais présents dans une version plus ancienne.
*/
function reappeared(state, rule, versionNum, rows) {
const earlier = previousVersions(state, rule, versionNum);
const previous = earlier[earlier.length - 1];
if (!previous) return [];
const findings = [];
for (const { entity, code } of rows) {
const objectId = state.identities.get(entity)?.get(code);
if (!objectId) continue;
const present = (v) => Boolean(state.members.get(entity)?.get(v)?.has(objectId));
if (present(previous)) continue;
const last = earlier.filter(present).pop();
if (last) findings.push({ entity, code, lastPresence: last, missingFrom: previous });
}
return findings;
}
/**
* Constat 3 — les codes qui changent de niveau : à leur dernière présence,
* ils appartenaient à une autre entité de la règle (une règle devenue
* exigence, par exemple). Pour un référentiel non découpé, il n'y a qu'un
* niveau : le constat est toujours vide.
*/
function levelChanges(state, rule, versionNum, rows) {
const entities = entitiesOf(rule);
if (entities.length < 2) return [];
const earlier = previousVersions(state, rule, versionNum).reverse();
const findings = [];
for (const { entity, code } of rows) {
for (const version of earlier) {
const before = entities.find((e) => {
const objectId = state.identities.get(e)?.get(code);
return objectId && state.members.get(e)?.get(version)?.has(objectId);
});
if (!before) continue;
if (before !== entity) findings.push({ code, before, after: entity, version });
break;
}
}
return findings;
}
/**
* Les attributs déclarés que le fichier ne porte pas : sans ce relevé, ils
* resteraient vides sans un mot. Les colonnes déduites ne sont pas attendues.
*
* @returns {Array<{entite: string, attribut: string}>}
*/
function missingColumns(rule, attributes, headers = []) {
const present = new Set(headers.map((h) => String(h).toLowerCase()));
const inferred = new Set(Object.keys(rule.infer || {}).map((c) => c.toLowerCase()));
const missing = [];
for (const entity of entitiesOf(rule)) {
for (const a of attributes[entity] || []) {
const name = a.Nom.toLowerCase();
if (!present.has(name) && !inferred.has(name)) missing.push({ entity, attribute: a.Nom });
}
}
return missing;
}
/**
* Phase 1. N'écrit rien dans l'état.
*
* @param {Object} etat
* @param {Object} entree
* @param {string} entree.versionNum
* @param {Object} entree.regle une règle de découpage (sdm-aiguillage)
* @param {Object} entree.attributs attributs déclarés, par entité
* @param {{headers, fr, en}} entree.sortie la sortie du traitement BUILDERS
* @param {Buffer[]} entree.fichiers les fichiers déposés, pour la signature
* @returns {Object} l'analyse : signature, plan, rapport, valide, integre
*/
function analyze(state, { versionNum, rule, attributes, output, files }) {
const { rows, report: routing } = route(rule, output);
const plan = buildPlan(state, { versionNum, attributes, rows, scope: entitiesOf(rule) });
return {
versionNum,
signature: sign(files),
plan,
rows,
report: {
...plan.report,
reappeared: reappeared(state, rule, versionNum, rows),
levelChanges: levelChanges(state, rule, versionNum, rows),
...routing,
// Contrôle de structure (T2.2.13 point 3) : signalé, pas bloquant en v0.
undeclaredColumns: outsideDeclaration(rows, attributes),
missingColumns: missingColumns(rule, attributes, output.headers),
},
validated: false,
integrated: false,
};
}
/** Le geste humain : valider une analyse lue. */
function validate(analysis) {
analysis.validated = true;
return analysis;
}
/**
* Phase 2. Refuse plutôt que d'appliquer une analyse douteuse.
*
* @param {Object} etat
* @param {Object} analyse rendue par `analyser`
* @param {Buffer[]} fichiers les fichiers tels qu'ils sont au moment d'intégrer
*/
function integrate(state, analysis, files) {
if (analysis.integrated) throw new Error('import déjà intégré');
if (!analysis.validated) throw new Error('import non validé');
if (sign(files) !== analysis.signature) throw new Error('fichier modifié depuis l\'analyse');
apply(state, analysis.plan);
analysis.integrated = true;
return analysis;
}
module.exports = {
compareVersions,
analyze,
validate,
integrate,
};
/**
* sdm-liens.utils.js — les liens, en mémoire (lots 3.1 et 3.2, plan § 20.4).
*
* ── LIENS NATIFS (3.1) ────────────────────────────────────────────────────
*
* Un lien natif est dans le fichier : un attribut de la source porte le ou
* les codes de la cible. Chaque valeur est résolue en identité de la cible ;
* la paire (source, cible) va dans la table de liaison du lien, dont les
* clés étrangères garantissent qu'aucun lien ne pointe dans le vide. Un code
* introuvable est un lien mort : l'import échoue (plan, lot 3.1).
*
* ── LIENS NON NATIFS (3.2) ────────────────────────────────────────────────
*
* Un lien non natif est une décision humaine (SDM_DC). À chaque version, les
* liens établis (confirmés ou reconduits) sur la version précédente sont
* rejoués :
*
* les deux extrémités présentes, rien n'a bougé → reconduit (silencieux) ;
* présentes, mais un parent a changé → propose (file d'attente) ;
* une extrémité absente → caduc (signalé).
*
* Le lien pointe une identité : une reformulation ne le casse pas.
* Rien n'est jamais confirmé ici.
*/
const { linkTableName } = require('./sdm-generator.utils');
const { compareVersions } = require('./sdm-import.utils');
/** Les états d'une décision. `reconduit` s'ajoute aux états du plan (écart signalé). */
const STATES = ['propose', 'confirme', 'rejete', 'caduc', 'reconduit'];
const ESTABLISHED = new Set(['confirme', 'reconduit']);
const text = (v) => (Array.isArray(v) ? v.join('\n') : v == null ? '' : String(v));
/** La valeur d'une colonne dans une ligne aiguillée : FR, sinon EN. Casse ignorée. */
function valueFor(row, attribute) {
const target = String(attribute).toLowerCase();
for (const lang of ['fr', 'en']) {
const values = row.content?.[lang];
if (!values) continue;
const key = Object.keys(values).find((k) => k.toLowerCase() === target);
if (key && text(values[key]).trim()) return values[key];
}
return null;
}
/** Les codes d'une valeur, découpée par le séparateur déclaré. */
function codesOf(value, separator) {
if (Array.isArray(value)) return value.flatMap((v) => codesOf(v, separator));
const t = text(value);
const pieces = separator === '\n' ? t.split(/\r\n|\n|\r/) : separator ? t.split(separator) : [t];
return [...new Set(pieces.map((m) => m.trim()).filter(Boolean))];
}
/** Les identités après le plan : celles de l'état, plus celles qu'il crée. */
function identitiesAfter(state, plan) {
const ids = new Map([...state.identities].map(([e, m]) => [e, new Map(m)]));
for (const { entity, code, objectId } of plan?.identities || []) {
if (!ids.has(entity)) ids.set(entity, new Map());
ids.get(entity).set(code, objectId);
}
return ids;
}
/**
* Les appartenances de la nouvelle version, lues dans les lignes :
* Map<entite, Map<objetId, { code, parentId }>>.
*/
function membershipsAfter(rows, ids) {
const result = new Map();
for (const l of rows) {
const objectId = ids.get(l.entity)?.get(l.code);
if (!objectId) continue;
if (!result.has(l.entity)) result.set(l.entity, new Map());
const parentId = l.parentEntity ? ids.get(l.parentEntity)?.get(l.parentCode) || null : null;
result.get(l.entity).set(objectId, { code: l.code, parentId });
}
return result;
}
/**
* Les paires des liens natifs portés par les lignes.
*
* @param {Array} lignes la sortie de `aiguiller`
* @param {Array} liens SDM_LN natifs actifs, avec attribut source
* @param {Object} ctx
* @param {Map} ctx.identites Map<entite, Map<code, id>> (cibles comprises)
* @param {Object} ctx.attributs attributs déclarés, par entité
* @returns {{ paires: Array, morts: Array, compte: Object }}
*/
function nativeLinks(rows, links, { identities, attributes }) {
const pairs = [];
const dead = [];
const count = {};
for (const link of links) {
const table = linkTableName(link);
const declared = (attributes[link.SDM_LN_SourceEntity] || [])
.find((a) => a.Nom.toLowerCase() === String(link.SDM_LN_SourceAttribute).toLowerCase());
const separator = declared?.Multivalue ? declared.Separateur : null;
const targets = identities.get(link.SDM_LN_TargetEntity) || new Map();
const views = new Set();
count[table] = 0;
for (const row of rows) {
if (row.entity !== link.SDM_LN_SourceEntity) continue;
const sourceId = identities.get(row.entity)?.get(row.code);
const value = valueFor(row, link.SDM_LN_SourceAttribute);
if (!sourceId || value == null) continue;
for (const code of codesOf(value, separator)) {
const targetId = targets.get(code);
if (!targetId) {
dead.push({ table, source: row.code, attribute: link.SDM_LN_SourceAttribute, target: link.SDM_LN_TargetEntity, code });
continue;
}
const key = `${sourceId}|${targetId}`;
if (views.has(key)) continue;
views.add(key);
pairs.push({ linkId: link.SDM_LN_Id, table, sourceId, targetId });
count[table] += 1;
}
}
}
return { pairs, dead, count };
}
/**
* Les liens établis avant `versionNum` : pour chaque paire, sa dernière
* décision antérieure, si elle est confirmée ou reconduite. Une paire déjà
* décidée pour `versionNum` n'est pas rejouée.
*/
function establishedBefore(decisions, versionNum) {
const byPair = new Map();
const alreadyDecided = new Set();
for (const d of decisions) {
const key = `${d.SDM_DC_LinkId}|${d.SDM_DC_SourceId}|${d.SDM_DC_TargetId}`;
const ordre = compareVersions(d.SDM_DC_VersionNum, versionNum);
if (ordre === 0) { alreadyDecided.add(key); continue; }
if (ordre > 0) continue;
const before = byPair.get(key);
if (!before || compareVersions(d.SDM_DC_VersionNum, before.SDM_DC_VersionNum) > 0) byPair.set(key, d);
}
return [...byPair].filter(([key, d]) => !alreadyDecided.has(key) && ESTABLISHED.has(d.SDM_DC_State)).map(([, d]) => d);
}
/**
* Rejoue les liens établis sur la nouvelle version (plan § 20.4).
*
* @param {Array} etablis rendus par `etablisAvant`
* @param {Map} liens Map<lienId, SDM_LN>
* @param {Object} ctx
* @param {string} ctx.versionNum
* @param {Function} ctx.apres (entite, objetId) → { code, parentId } | null
* — présence dans la nouvelle version
* @param {Function} ctx.avant (entite, objetId, version) → { code, parentId } | null | undefined
* — undefined : inconnu, on ne compare pas
* @returns {Array} les décisions à écrire, chacune avec son état et son motif
*/
function replay(established, links, { versionNum, after, before }) {
const decisions = [];
for (const d of established) {
const link = links.get(d.SDM_DC_LinkId);
if (!link || !link.SDM_LN_Active) continue;
const ends = [
['source', link.SDM_LN_SourceEntity, d.SDM_DC_SourceId],
['target', link.SDM_LN_TargetEntity, d.SDM_DC_TargetId],
];
const reasons = [];
let caduc = false;
for (const [role, entity, objectId] of ends) {
const now = after(entity, objectId);
if (!now) { caduc = true; reasons.push(`${role} absente de la version ${versionNum}`); continue; }
const yesterday = before(entity, objectId, d.SDM_DC_VersionNum);
if (yesterday && (yesterday.parentId || null) !== (now.parentId || null)) {
reasons.push(`${role} ${now.code} a changé de parent`);
}
}
const state = caduc ? 'caduc' : reasons.length ? 'propose' : 'reconduit';
decisions.push({
linkId: d.SDM_DC_LinkId,
sourceId: d.SDM_DC_SourceId,
targetId: d.SDM_DC_TargetId,
versionNum,
state,
reason: reasons.join(' ; ') || `reconduit depuis ${d.SDM_DC_VersionNum}`,
from: d.SDM_DC_VersionNum,
});
}
return decisions;
}
module.exports = {
STATES,
valueFor,
codesOf,
identitiesAfter,
membershipsAfter,
nativeLinks,
establishedBefore,
replay,
};
/**
* sdm-lecture.utils.js — la lecture au service des autres modules (plan § 17) :
* un objet, une version, une langue.
*
* Une version absente de la demande est la plus récente de l'entité.
*/
const S = require('./sdm-storage.utils');
const { compareVersions } = require('./sdm-import.utils');
/** La plus récente d'une liste de numéros de version, ou null. */
const lastVersion = (versions) => (versions.length ? [...versions].sort(compareVersions).pop() : null);
/**
* Les données d'une entité déclarée et créée.
*
* @param {Object} entite { Code, ParenteCode }
* @param {Object} [options] { versionNum, langue: 'fr'|'en', limite, decalage }
*/
async function read(entity, { versionNum, lang, limit = 500, offset = 0 } = {}) {
const versions = await S.versionsOf(entity.Code);
const version = versionNum || lastVersion(versions);
if (!version) return { entity: entity.Code, versionNum: null, versions: [], rows: [] };
const rows = await S.readData(entity.Code, {
versionNum: version,
lang: lang || null,
withParentRow: entity.ParenteCode || null,
limit: Math.min(Number(limit) || 500, 5000),
offset: Number(offset) || 0,
});
return { entity: entity.Code, versionNum: version, versions: versions.sort(compareVersions), rows };
}
module.exports = { lastVersion, read };
/**
* sdm-registre.utils.js — les déclarations : entités et attributs (lot 1.1,
* T1.1.2), en mémoire.
*
* La forme suit SDM_EN_Entities et SDM_AT_Attributes (plan § 15), sans les
* préfixes de colonnes. La version SQL viendra avec les tables `SDM_*`.
*
* registre.entites : Map<code, { Code, Libelle {fr,en}, Referentiel,
* ParenteCode, Ordre, Actif, attributs }>
* entite.attributs : Map<Nom, { Nom, Type, Role, Multivalue, Separateur,
* DansEmpreinte, Ordre, LongueurFixe,
* Libelle {fr,en}, Syndication }>
*
* Toute règle violée lève une erreur : une déclaration fausse produirait
* plus tard une mauvaise migration ou une mauvaise empreinte.
*/
const TYPES = ['identifiant', 'texte', 'texte_long', 'date', 'nombre', 'booleen'];
const ROLES = ['code', 'libelle', 'parent', 'aucun'];
// Un rôle de structure n'existe qu'une fois par entité.
const UNIQUE_ROLES = ['code', 'libelle', 'parent'];
const ENTITY_CODE = /^[a-z][a-z0-9_]{0,63}$/;
// Comme la syndication des colonnes Standards (STD_IN_Config.columns).
const SYNDICATIONS = ['none', 'user', 'admin'];
/** Un registre vide. */
function emptyRegistry() {
return { entities: new Map() };
}
function entityOrError(registry, code) {
const entity = registry.entities.get(code);
if (!entity) throw new Error(`entité inconnue : ${code}`);
return entity;
}
/**
* Le parent existe, dans le même référentiel, et ne crée pas de boucle.
* Une entité peut être son propre parent : c'est une hiérarchie interne
* (RISK_PROCESS, par exemple).
*/
function checkParentage(registry, code, referential, parentageCode) {
if (!parentageCode || parentageCode === code) return;
const parentage = entityOrError(registry, parentageCode);
if (parentage.Referentiel !== referential) {
throw new Error(`l'entité parente ${parentageCode} n'est pas du référentiel ${referential}`);
}
for (let current = parentage; current; current = registry.entities.get(current.ParenteCode)) {
if (current.Code === code) throw new Error(`boucle de parenté sur ${code}`);
if (current.ParenteCode === current.Code) break;
}
}
/** Déclare une entité. */
function createEntity(registry, { Code, Libelle, Referentiel, ParenteCode = null, Ordre = 0, Actif = true }) {
if (!ENTITY_CODE.test(Code || '')) throw new Error(`code d'entité invalide : ${Code}`);
if (registry.entities.has(Code)) throw new Error(`entité déjà déclarée : ${Code}`);
if (!Referentiel) throw new Error(`référentiel manquant pour ${Code}`);
if (!Libelle || (!Libelle.fr && !Libelle.en)) throw new Error(`libellé manquant pour ${Code}`);
checkParentage(registry, Code, Referentiel, ParenteCode);
const entity = { Code, Libelle: { ...Libelle }, Referentiel, ParenteCode, Ordre, Actif, attributes: new Map() };
registry.entities.set(Code, entity);
return entity;
}
/** Modifie une entité. Le code et le référentiel ne changent pas. */
function editEntity(registry, code, changes) {
const entity = entityOrError(registry, code);
if ('Code' in changes || 'Referentiel' in changes) {
throw new Error('le code et le référentiel d\'une entité ne se modifient pas');
}
if ('ParenteCode' in changes) checkParentage(registry, code, entity.Referentiel, changes.ParenteCode);
for (const key of ['Libelle', 'ParenteCode', 'Ordre', 'Actif']) {
if (key in changes) entity[key] = key === 'Libelle' ? { ...changes.Libelle } : changes[key];
}
return entity;
}
/** Vérifie un attribut complet, dans son entité. */
function checkAttribute(entity, attribute, oldName) {
const { Nom, Type, Role, Multivalue, Separateur } = attribute;
if (!Nom) throw new Error(`attribut sans nom dans ${entity.Code}`);
if (!TYPES.includes(Type)) throw new Error(`type invalide pour ${Nom} : ${Type}`);
if (!ROLES.includes(Role)) throw new Error(`rôle invalide pour ${Nom} : ${Role}`);
if (Separateur && !Multivalue) throw new Error(`${Nom} a un séparateur sans être multivalué`);
const { LongueurFixe, Syndication } = attribute;
if (LongueurFixe != null && !(Number.isInteger(LongueurFixe) && LongueurFixe > 0)) {
throw new Error(`longueur fixe invalide pour ${Nom} : ${LongueurFixe}`);
}
if (Syndication && !SYNDICATIONS.includes(Syndication)) throw new Error(`syndication invalide pour ${Nom} : ${Syndication}`);
for (const other of entity.attributes.values()) {
if (other.Nom === oldName) continue;
if (other.Nom.toLowerCase() === Nom.toLowerCase()) throw new Error(`attribut déjà déclaré : ${entity.Code}.${Nom}`);
if (UNIQUE_ROLES.includes(Role) && other.Role === Role) {
throw new Error(`l'entité ${entity.Code} a déjà un attribut de rôle ${Role} (${other.Nom})`);
}
}
if (Role === 'parent' && !entity.ParenteCode) {
throw new Error(`${entity.Code}.${Nom} : rôle parent sans entité parente`);
}
}
/** Déclare un attribut. */
function addAttribute(registry, entityCode, {
Nom, Type = 'texte', Role = 'aucun', Multivalue = false, Separateur = null, DansEmpreinte = true, Ordre,
LongueurFixe = null, Libelle = null, Syndication = 'none',
}) {
const entity = entityOrError(registry, entityCode);
const attribute = {
Nom, Type, Role, Multivalue, Separateur, DansEmpreinte, Ordre: Ordre ?? entity.attributes.size,
LongueurFixe, Libelle, Syndication,
};
checkAttribute(entity, attribute);
entity.attributes.set(Nom, attribute);
return attribute;
}
/** Modifie un attribut. */
function editAttribute(registry, entityCode, name, changes) {
const entity = entityOrError(registry, entityCode);
const old = entity.attributes.get(name);
if (!old) throw new Error(`attribut inconnu : ${entityCode}.${name}`);
const attribute = { ...old, ...changes };
checkAttribute(entity, attribute, name);
entity.attributes.delete(name);
entity.attributes.set(attribute.Nom, attribute);
return attribute;
}
/** Retire un attribut. */
function removeAttribute(registry, entityCode, name) {
const entity = entityOrError(registry, entityCode);
if (!entity.attributes.delete(name)) throw new Error(`attribut inconnu : ${entityCode}.${name}`);
}
/** Les entités d'un référentiel, triées par ordre. */
function referentialEntities(registry, referential) {
return [...registry.entities.values()]
.filter((e) => e.Referentiel === referential)
.sort((a, b) => a.Ordre - b.Ordre || a.Code.localeCompare(b.Code));
}
/**
* Les attributs par entité, triés par ordre : la forme qu'attendent
* `planifier` et `empreinte`.
*/
function attributesByEntity(registry, referential) {
const result = {};
for (const entity of registry.entities.values()) {
if (referential && entity.Referentiel !== referential) continue;
result[entity.Code] = [...entity.attributes.values()].sort((a, b) => a.Ordre - b.Ordre);
}
return result;
}
module.exports = {
TYPES,
ROLES,
SYNDICATIONS,
emptyRegistry,
createEntity,
editEntity,
addAttribute,
editAttribute,
removeAttribute,
referentialEntities,
attributesByEntity,
};
/**
* sdm-resolution.utils.js — le moteur anti-duplication, en mémoire (lot 2.1).
*
* ── CE QU'IL FAIT ─────────────────────────────────────────────────────────
*
* Pour une version et ses lignes (déjà aiguillées vers leur entité), il
* décide, ligne par ligne :
*
* identité → le code est-il connu ? sinon, une identité neuve ;
* contenu → cette empreinte existe-t-elle déjà pour cette identité ?
* oui : on la réutilise (aucune ligne) ; non : un contenu neuf ;
* appartenance → (version, identité) → contenu, parent, ordre.
*
* Le parent est un lien de PLACEMENT : il vit sur l'appartenance et pointe
* l'identité du parent. Le changer ne crée donc aucun contenu.
*
* ── EN DEUX TEMPS ─────────────────────────────────────────────────────────
*
* `planifier` calcule tout et n'écrit rien : c'est ce qu'il faut à la phase
* d'analyse d'un import (plan § 20.2). `appliquer` reporte un plan dans
* l'état. Les parents sont résolus en deux passes, tous les objets de la
* version d'abord, car une ligne peut citer un parent placé plus bas.
*
* ── L'ÉTAT ────────────────────────────────────────────────────────────────
*
* En mémoire, pour l'instant, la même forme que les tables à venir :
*
* identites : Map<entite, Map<code, objetId>>
* contenus : Map<objetId, Map<empreinte, { contenuId, data }>>
* membres : Map<entite, Map<versionNum, Map<objetId, { contenuId, parentId, ordre }>>>
*
* La version SQL viendra quand la forme des tables sera arrêtée.
*
* ── LE RÉIMPORT D'UNE VERSION ─────────────────────────────────────────────
*
* Réimporter une version déjà intégrée la remplace : un objet de cette
* version absent du fichier en est RETIRÉ (son appartenance seule ; son
* identité et ses contenus restent pour les autres versions). Le retrait ne
* touche que les entités du `perimetre` passé à `planifier` — sans lui, rien
* n'est retiré, pour qu'un import ne vide jamais un autre référentiel.
*/
const crypto = require('crypto');
const { empreinte } = require('./sdm-fingerprint.utils');
/** Un état vide. */
function emptyState() {
return { identities: new Map(), contents: new Map(), members: new Map() };
}
/** Lit dans une Map de Maps sans rien créer. */
function read(map, ...keys) {
let current = map;
for (const key of keys) {
if (!current) return undefined;
current = current.get(key);
}
return current;
}
/** Lit ou crée la Map imbriquée sous `cle`. */
function subMap(map, key) {
if (!map.has(key)) map.set(key, new Map());
return map.get(key);
}
/**
* Calcule ce qu'une version produirait, sans rien écrire.
*
* @param {Object} etat voir etatVide
* @param {Object} entree
* @param {string} entree.versionNum
* @param {Object<string, Array>} entree.attributs attributs déclarés, par entité
* @param {string[]} [entree.perimetre] entités que le fichier remplace entièrement
* @param {Array<{entite: string, code: string, contenu: Object, parentEntite?: string, parentCode?: string, ordre?: number}>} entree.lignes
* @returns {Object} le plan : identites, contenus, membres à écrire, et le rapport
*/
function buildPlan(state, { versionNum, attributes, rows, scope = [] }) {
if (!versionNum) throw new TypeError('versionNum est obligatoire');
const plan = { versionNum, identities: [], contents: [], members: [], removals: [] };
const report = {
createdIdentities: 0,
createdContents: 0,
reusedContents: 0,
newMembers: 0,
unchangedMembers: 0,
rewrittenMembers: 0,
removedMembers: [],
duplicates: [],
missingParents: [],
};
// Les objets de CETTE version, par entité puis par code : la passe 2 n'y
// cherche que les parents présents dans la version en cours.
const versionObjects = new Map();
// Les contenus neufs de ce plan, pour que deux lignes ne créent pas deux
// fois le même (impossible par code, mais tenu par construction).
const planContents = new Map();
const kept = [];
// ── PASSE 1 — les objets ───────────────────────────────────────────────
for (const row of rows) {
const { entity, code } = row;
const attrs = attributes[entity];
if (!attrs) throw new TypeError(`entité non déclarée : ${entity}`);
if (!code) throw new TypeError(`ligne sans code dans l'entité ${entity}`);
const ofEntity = subMap(versionObjects, entity);
if (ofEntity.has(code)) {
report.duplicates.push({ entity, code });
continue;
}
let objectId = read(state.identities, entity, code);
if (!objectId) {
objectId = crypto.randomUUID();
plan.identities.push({ entity, code, objectId });
report.createdIdentities += 1;
}
ofEntity.set(code, objectId);
const emp = empreinte(attrs, row.content);
let contentId = read(state.contents, objectId, emp)?.contentId
|| read(planContents, objectId, emp);
if (contentId) {
report.reusedContents += 1;
} else {
contentId = crypto.randomUUID();
subMap(planContents, objectId).set(emp, contentId);
plan.contents.push({ objectId, contentId, empreinte: emp, data: row.content });
report.createdContents += 1;
}
kept.push({ row, objectId, contentId });
}
// ── PASSE 2 — les parents, puis les appartenances ──────────────────────
for (const { row, objectId, contentId } of kept) {
const { entity, code, parentEntity, parentCode } = row;
let parentId = null;
if (parentCode) {
parentId = read(versionObjects, parentEntity, parentCode) || null;
if (!parentId) {
report.missingParents.push({ entity, code, parentEntity, parentCode });
}
}
const member = { entity, objectId, contentId, parentId, ordre: row.ordre ?? null };
const existing = read(state.members, entity, versionNum, objectId);
if (!existing) {
report.newMembers += 1;
} else if (existing.contentId === contentId
&& existing.parentId === parentId
&& existing.ordre === member.ordre) {
report.unchangedMembers += 1;
continue;
} else {
report.rewrittenMembers += 1;
}
plan.members.push(member);
}
// ── LES RETRAITS — ce que la version contenait et que le fichier n'a plus ─
for (const entity of scope) {
const existing = read(state.members, entity, versionNum);
if (!existing) continue;
const present = versionObjects.get(entity) || new Map();
const presentIds = new Set(present.values());
for (const objectId of existing.keys()) {
if (presentIds.has(objectId)) continue;
const code = [...(state.identities.get(entity) || [])].find(([, id]) => id === objectId)?.[0];
plan.removals.push({ entity, objectId });
report.removedMembers.push({ entity, code });
}
}
plan.report = report;
return plan;
}
/** Reporte un plan dans l'état. Rend l'état, modifié en place. */
function apply(state, plan) {
for (const { entity, code, objectId } of plan.identities) {
subMap(state.identities, entity).set(code, objectId);
}
for (const { objectId, contentId, empreinte: emp, data } of plan.contents) {
subMap(state.contents, objectId).set(emp, { contentId, data });
}
for (const { entity, objectId, contentId, parentId, ordre } of plan.members) {
subMap(subMap(state.members, entity), plan.versionNum).set(objectId, { contentId, parentId, ordre });
}
for (const { entity, objectId } of plan.removals || []) {
read(state.members, entity, plan.versionNum)?.delete(objectId);
}
return state;
}
module.exports = {
emptyState,
buildPlan,
apply,
};
/**
* sdm-aiguillage.utils.js — de la sortie d'un traitement aux lignes du moteur
* (lot 2.2, T2.2.1 et T2.2.2).
*
* ── L'ENTRÉE ──────────────────────────────────────────────────────────────
*
* Ce que rendent déjà les traitements de `BUILDERS`
* (standards-integrations.utils.js) : `{ headers, fr, en }`, deux tableaux de
* lignes au même format, une par langue. On ne rappelle pas le traitement
* ici : ce fichier ne dépend de rien, pour être testé sans base.
*
* ── CE QU'IL FAIT ─────────────────────────────────────────────────────────
*
* 1. apparie FR et EN par CODE (la colonne déclarée, `ID` pour GOVERNANCE),
* jamais par position ;
* 2. aiguille chaque objet vers son entité selon la règle de découpage du
* référentiel (P10) ;
* 3. donne à chaque ligne l'entité de son parent, lue sur la ligne du parent
* dans la même version — d'où deux passes ;
* 4. range le reste des colonnes dans le contenu `{ fr: {…}, en: {…} }`.
*
* Il ne résout rien en identité : c'est le rôle de sdm-resolution.utils.js,
* qui consomme directement les lignes rendues ici.
*
* ── CE QU'IL SIGNALE (sans rien écarter en silence) ───────────────────────
*
* sansCode ligne sans code, ignorée ;
* doublons code répété dans une langue, la première ligne fait foi ;
* monolingues code présent dans une seule langue (P7) ;
* divergences parent ou type différent entre FR et EN, le FR fait foi ;
* typesInconnus valeur du discriminant hors de la règle, ligne ignorée.
*/
/**
* Règles de découpage, par référentiel (P10). Un référentiel absent d'ici
* donne une seule entité, et sa règle se lit dans sa déclaration (`regleDe`). Les colonnes de structure (code, parent,
* discriminant) ne vont pas dans le contenu, ni celles de `ignorer`.
*/
const SPLIT_RULES = {
GOVERNANCE: {
code: 'ID',
parent: 'PARENT_ID',
column: 'TYPE',
ignore: ['CODE'], // le dernier segment de ID : déjà porté par l'identité
infer: { CODE: 'dernierSegment' }, // rendu ainsi par la vue de compatibilité
values: {
procedure: 'gov_procedure',
process: 'gov_processus',
rule: 'gov_regle',
requirement: 'gov_exigence',
},
},
};
const LANGS = ['fr', 'en'];
const text = (v) => (v === undefined || v === null ? '' : String(v).trim());
/** Une ligne-tableau en objet { COLONNE: valeur }. */
function asObject(headers, row) {
const object = {};
headers.forEach((h, i) => { object[h] = row[i]; });
return object;
}
/**
* Apparie, aiguille et range les lignes d'une version.
*
* @param {Object} regle une entrée de REGLES_DECOUPAGE, ou
* `{ code, parent, entite }` pour un référentiel non découpé
* @param {{ headers: string[], fr: Array<Array>, en: Array<Array> }} sortie
* @returns {{ lignes: Array, rapport: Object }}
*/
function route(rule, { headers, fr = [], en = [] }) {
const report = { withoutCode: 0, duplicates: [], monolingual: [], divergences: [], unknownTypes: [] };
const structure = new Set([rule.code, rule.parent, rule.column, ...(rule.ignore || [])].filter(Boolean));
// ── Appariement par code ───────────────────────────────────────────────
const byCode = new Map(); // code → { fr: {…}, en: {…} }, dans l'ordre FR puis EN
for (const [lang, rows] of [['fr', fr], ['en', en]]) {
const seen = new Set();
for (const row of rows) {
const object = asObject(headers, row);
const code = text(object[rule.code]);
if (!code) { report.withoutCode += 1; continue; }
if (seen.has(code)) { report.duplicates.push({ code, lang }); continue; }
seen.add(code);
if (!byCode.has(code)) byCode.set(code, {});
byCode.get(code)[lang] = object;
}
}
// ── Passe 1 : l'entité de chaque code ──────────────────────────────────
const entityOf = new Map();
for (const [code, langs] of byCode) {
const reference = langs.fr || langs.en;
const other = langs.fr && langs.en;
if (!langs.fr || !langs.en) {
report.monolingual.push({ code, lang: langs.fr ? 'fr' : 'en' });
} else {
for (const column of [rule.parent, rule.column].filter(Boolean)) {
if (text(langs.fr[column]) !== text(other[column])) {
report.divergences.push({ code, column, fr: text(langs.fr[column]), en: text(other[column]) });
}
}
}
if (!rule.column) {
entityOf.set(code, rule.entity);
continue;
}
const value = text(reference[rule.column]);
const entity = rule.values[value];
if (!entity) { report.unknownTypes.push({ code, value }); continue; }
entityOf.set(code, entity);
}
// ── Passe 2 : parents et contenus ──────────────────────────────────────
const rows = [];
let ordre = 0;
for (const [code, langs] of byCode) {
const entity = entityOf.get(code);
if (!entity) continue;
const reference = langs.fr || langs.en;
const content = {};
for (const lang of LANGS) {
if (!langs[lang]) continue;
const values = {};
for (const [column, value] of Object.entries(langs[lang])) {
if (!structure.has(column)) values[column] = value;
}
content[lang] = values;
}
const row = { entity, code, content, ordre: (ordre += 1) };
const parentCode = rule.parent ? text(reference[rule.parent]) : '';
if (parentCode) {
row.parentCode = parentCode;
row.parentEntity = entityOf.get(parentCode); // absent → parent introuvable au moteur
}
rows.push(row);
}
return { rows, report };
}
/**
* P12 — les valeurs portées par une entité qui ne les déclare pas.
*
* Le moteur ignore un attribut non déclaré : sans ce relevé, une valeur
* (un LINK_STDS posé sur une règle, par exemple) disparaîtrait en silence.
*
* @param {Array} lignes la sortie de `aiguiller`
* @param {Object<string, Array<{Nom: string}>>} attributs déclarés, par entité
* @returns {Array<{entite: string, attribut: string, nb: number}>} trié
*/
function outsideDeclaration(rows, attributes) {
const declared = new Map(Object.entries(attributes)
.map(([entity, attrs]) => [entity, new Set(attrs.map((a) => a.Nom.toLowerCase()))]));
const count = new Map();
for (const { entity, content } of rows) {
const known = declared.get(entity) || new Set();
const seen = new Set();
for (const values of Object.values(content)) {
for (const [column, value] of Object.entries(values)) {
if (known.has(column.toLowerCase()) || !text(value) || seen.has(column)) continue;
seen.add(column);
const key = `${entity}|${column}`;
count.set(key, (count.get(key) || 0) + 1);
}
}
}
return [...count]
.map(([key, nb]) => { const [entity, attribute] = key.split('|'); return { entity, attribute, nb }; })
.sort((a, b) => a.entity.localeCompare(b.entity) || a.attribute.localeCompare(b.attribute));
}
/**
* La règle d'un référentiel. Découpé : celle de REGLES_DECOUPAGE. Sinon, la
* règle simple `{ code, parent, entite }` lue dans la déclaration : son entité
* unique et ses attributs de rôle code et parent, repris de STD_RF_Schema
* (isCode, isParent). Lève le motif quand elle ne se déduit pas.
*
* @param {string} referentiel
* @param {{ entites: Map }} registre
*/
function ruleOf(referential, registry) {
if (SPLIT_RULES[referential]) return SPLIT_RULES[referential];
const entities = [...registry.entities.values()].filter((e) => e.Referentiel === referential && e.Actif !== false);
if (!entities.length) throw new Error(`${referential} : aucune entité déclarée — lancez la reprise`);
if (entities.length > 1) {
throw new Error(`${referential} : ${entities.length} entités sans règle de découpage (${entities.map((e) => e.Code).join(', ')})`);
}
const [entity] = entities;
const role = (r) => [...entity.attributes.values()].find((a) => a.Role === r)?.Nom || null;
if (!role('code')) throw new Error(`${referential} : l'entité ${entity.Code} n'a pas d'attribut de rôle code`);
return { code: role('code'), parent: role('parent'), entity: entity.Code };
}
module.exports = {
SPLIT_RULES,
ruleOf,
route,
outsideDeclaration,
};
/**
* sdm-fiche.utils.js — la fiche d'un référentiel (SDM_RF) : libellés,
* section, liens vers la source d'extraction, mode opératoire, usage interne.
*
* C'est ce que portait la fiche Intégration de Standards (STD_IN). Les
* tables Standards disparaîtront : elles ne sont lues qu'à la reprise, qui
* remplit les champs vides ; ensuite la fiche se saisit ici (CONTEXT.md E28).
*
* Fonctions pures, sans base.
*/
// Comme Standards (PROCEDURE_MAX de standards-integrations.controller.js).
const PROCEDURE_MAX = 20000;
const SECTION_MAX = 64;
const SOURCES_MAX = 20;
const error = (message) => Object.assign(new Error(message), { status: 400 });
const emptySheet = (Code) => ({
Code, Libelle: {}, Description: '', Section: '', Sources: [], Procedure: '', InterneSeul: false,
});
/** Un lien de source : http ou https seulement, sinon une erreur. */
function checkLink(text) {
let url;
try { url = new URL(String(text).trim()); } catch (_) { throw error(`lien invalide : ${text}`); }
if (!['http:', 'https:'].includes(url.protocol)) throw error(`lien http(s) attendu : ${text}`);
return url.href;
}
/**
* La fiche saisie à l'écran, contrôlée et nettoyée. Seuls les champs reçus
* changent ; les autres gardent la valeur de `avant`.
*/
function inputSheet(code, input = {}, before = null) {
const sheet = { ...(before || emptySheet(code)), Code: code };
if ('Libelle' in input) {
sheet.Libelle = { fr: String(input.Libelle?.fr || '').trim(), en: String(input.Libelle?.en || '').trim() };
}
if ('Section' in input) {
sheet.Section = String(input.Section ?? '').trim();
if (sheet.Section.length > SECTION_MAX) throw error(`section : ${SECTION_MAX} caractères au plus`);
}
if ('Sources' in input) {
if (!Array.isArray(input.Sources)) throw error('sources : une liste de liens est attendue');
const links = input.Sources.map((s) => String(s ?? '').trim()).filter(Boolean).map(checkLink);
sheet.Sources = [...new Set(links)];
if (sheet.Sources.length > SOURCES_MAX) throw error(`sources : ${SOURCES_MAX} liens au plus`);
}
if ('Procedure' in input) {
sheet.Procedure = String(input.Procedure ?? '').trim();
if (sheet.Procedure.length > PROCEDURE_MAX) throw error(`mode opératoire : ${PROCEDURE_MAX} caractères au plus`);
}
if ('InterneSeul' in input) sheet.InterneSeul = Boolean(input.InterneSeul);
return sheet;
}
/** La fiche reprise d'une ligne de `lireReferentiels` (STD_RF + STD_IN). */
function backfillSheet(ref) {
const f = ref.STD_IN_Fiche || {};
const name = ref.STD_RF_Name || '';
const sources = (f.sourceUrls || []).filter((u) => { try { checkLink(u); return true; } catch (_) { return false; } });
return {
Code: ref.STD_RF_Code,
Libelle: { fr: f.labelFr || name, en: f.labelEn || f.labelFr || name },
Description: ref.STD_RF_Description || '',
Section: String(f.section || '').slice(0, SECTION_MAX),
Sources: [...new Set(sources)].slice(0, SOURCES_MAX),
Procedure: String(f.procedure || '').slice(0, PROCEDURE_MAX),
InterneSeul: Boolean(f.internalOnly),
};
}
/**
* Complète une fiche existante avec la fiche reprise, sans toucher à ce qui
* a été saisi : seuls les champs vides prennent la valeur reprise. Rend la
* fiche complétée, ou null si rien ne change.
*/
function completeSheet(sheet, backfill) {
const next = { ...sheet, Libelle: { ...sheet.Libelle } };
let change = false;
for (const lang of ['fr', 'en']) {
if (!next.Libelle[lang] && backfill.Libelle[lang]) { next.Libelle[lang] = backfill.Libelle[lang]; change = true; }
}
for (const key of ['Description', 'Section', 'Procedure']) {
if (!next[key] && backfill[key]) { next[key] = backfill[key]; change = true; }
}
if (!next.Sources.length && backfill.Sources.length) { next.Sources = [...backfill.Sources]; change = true; }
if (!next.InterneSeul && backfill.InterneSeul) { next.InterneSeul = true; change = true; }
return change ? next : null;
}
/** La fiche telle que l'écran la lit (mêmes clés que la fiche Standards). */
const screenSheet = (sheet) => (sheet ? {
labelFr: sheet.Libelle.fr || '',
labelEn: sheet.Libelle.en || '',
section: sheet.Section,
sourceUrls: sheet.Sources,
procedure: sheet.Procedure,
internalOnly: sheet.InterneSeul,
} : null);
module.exports = {
PROCEDURE_MAX, SECTION_MAX, SOURCES_MAX, emptySheet, checkLink, inputSheet, backfillSheet, completeSheet, screenSheet,
};
/**
* sdm-stockage.utils.js — la base de sys_data_model : les déclarations
* (SDM_EN, SDM_AT), les imports (SDM_IM) et les trios de `xone_ref`.
*
* Les règles restent dans les fichiers en mémoire (registre, moteur) : ce
* fichier ne fait que charger leur état et écrire leur résultat. SQL brut,
* pour tourner pareil dans l'extrait et dans X-One.
*
* STD_RF_Referentials n'est que LU.
*/
const crypto = require('crypto');
const db = require('../../../database/models');
const R = require('./sdm-registry.utils');
const { emptyState } = require('./sdm-resolution.utils');
const { DATA_SCHEMA } = require('./sdm-generator.utils');
const { QueryTypes } = db.Sequelize;
const select = (sql, replacements = {}, transaction = null) => db.sequelize.query(sql, {
type: QueryTypes.SELECT, replacements, transaction,
});
const execute = (sql, replacements = {}, transaction = null) => db.sequelize.query(sql, { replacements, transaction });
// ───────────────────────────────────────────────────────────────────────────
// Déclarations
// ───────────────────────────────────────────────────────────────────────────
/** Le registre, tel que la base le porte. */
async function loadRegistry(transaction = null) {
const entities = await select(`SELECT * FROM "SDM_EN_Entities" ORDER BY "SDM_EN_SortOrder", "SDM_EN_Code"`, {}, transaction);
const attributes = await select(`SELECT * FROM "SDM_AT_Attributes" ORDER BY "SDM_AT_SortOrder"`, {}, transaction);
// Lu tel quel, sans repasser par les validations : la base fait foi.
const registry = R.emptyRegistry();
const byId = new Map();
for (const e of entities) {
const entity = {
Id: e.SDM_EN_Id,
Code: e.SDM_EN_Code,
Libelle: e.SDM_EN_Label,
Referentiel: e.SDM_EN_Referential,
ParenteCode: e.SDM_EN_ParentCode,
Ordre: e.SDM_EN_SortOrder,
Actif: e.SDM_EN_Active,
CreatedDate: e.SDM_EN_CreatedDate,
CreatedBy: e.SDM_EN_CreatedBy,
attributes: new Map(),
};
registry.entities.set(entity.Code, entity);
byId.set(entity.Id, entity);
}
for (const a of attributes) {
byId.get(a.SDM_AT_EntityId)?.attributes.set(a.SDM_AT_Name, {
Nom: a.SDM_AT_Name,
Type: a.SDM_AT_Type,
Role: a.SDM_AT_Role,
Multivalue: a.SDM_AT_Multivalue,
Separateur: a.SDM_AT_Separator,
DansEmpreinte: a.SDM_AT_InFingerprint,
Ordre: a.SDM_AT_SortOrder,
LongueurFixe: a.SDM_AT_FixedLength ?? null,
Libelle: a.SDM_AT_Label ?? null,
Syndication: a.SDM_AT_Syndication || 'none',
});
}
return registry;
}
/**
* Écrit une entité et remplace ses attributs. L'identifiant de l'entité est
* conservé d'une écriture à l'autre.
*/
async function saveEntity(entity, { user, transaction }) {
const [row] = await select(`
INSERT INTO "SDM_EN_Entities" ("SDM_EN_Id", "SDM_EN_Code", "SDM_EN_Label", "SDM_EN_Referential",
"SDM_EN_ParentCode", "SDM_EN_SortOrder", "SDM_EN_Active", "SDM_EN_CreatedBy")
VALUES (:id, :code, :libelle, :referential, :parentage, :ordre, :active, :by)
ON CONFLICT ("SDM_EN_Code") DO UPDATE SET
"SDM_EN_Label" = EXCLUDED."SDM_EN_Label",
"SDM_EN_ParentCode" = EXCLUDED."SDM_EN_ParentCode",
"SDM_EN_SortOrder" = EXCLUDED."SDM_EN_SortOrder",
"SDM_EN_Active" = EXCLUDED."SDM_EN_Active"
RETURNING "SDM_EN_Id"`, {
id: entity.Id || crypto.randomUUID(),
code: entity.Code,
libelle: JSON.stringify(entity.Libelle),
referential: entity.Referentiel,
parentage: entity.ParenteCode || null,
ordre: entity.Ordre ?? 0,
active: entity.Actif !== false,
by: user || null,
}, transaction);
const entityId = row.SDM_EN_Id;
await execute(`DELETE FROM "SDM_AT_Attributes" WHERE "SDM_AT_EntityId" = :entityId`, { entityId }, transaction);
for (const a of entity.attributes.values()) {
await execute(`
INSERT INTO "SDM_AT_Attributes" ("SDM_AT_Id", "SDM_AT_EntityId", "SDM_AT_Name", "SDM_AT_Type", "SDM_AT_Role",
"SDM_AT_Multivalue", "SDM_AT_Separator", "SDM_AT_InFingerprint", "SDM_AT_SortOrder",
"SDM_AT_FixedLength", "SDM_AT_Label", "SDM_AT_Syndication")
VALUES (:id, :entityId, :name, :type, :role, :multivalue, :separator, :inFingerprint, :ordre,
:fixedLength, :libelle, :syndication)`, {
id: crypto.randomUUID(),
entityId,
name: a.Nom,
type: a.Type,
role: a.Role,
multivalue: Boolean(a.Multivalue),
separator: a.Separateur || null,
inFingerprint: a.DansEmpreinte !== false,
ordre: a.Ordre ?? 0,
fixedLength: a.LongueurFixe ?? null,
libelle: a.Libelle ? JSON.stringify(a.Libelle) : null,
syndication: a.Syndication || 'none',
}, transaction);
}
entity.Id = entityId;
return entityId;
}
/**
* Les référentiels Standards, en lecture seule : la source de la reprise
* seulement, ces tables disparaîtront. `STD_IN_Fiche` porte la fiche
* Intégration (libellés, section, sources, mode opératoire) et
* `STD_IN_Columns` la description des colonnes ; vides si la fiche n'existe pas.
* Vide si les tables Standards n'existent plus : l'écran, qui l'appelle pour
* proposer la reprise, doit leur survivre.
*/
async function readReferentials(codes = null) {
const params = codes ? { codes } : {};
const [refs, sheets] = await Promise.all([
select(`
SELECT "STD_RF_Code", "STD_RF_Name", "STD_RF_Description", "STD_RF_Schema"
FROM "STD_RF_Referentials"
WHERE "STD_RF_DeletedAt" IS NULL ${codes ? 'AND "STD_RF_Code" IN (:codes)' : ''}
ORDER BY "STD_RF_Code"`, params).catch(() => []),
select(`
SELECT "STD_IN_Code", "STD_IN_LabelFr", "STD_IN_LabelEn", "STD_IN_Section", "STD_IN_Config"
FROM "STD_IN_Integrations"
${codes ? 'WHERE "STD_IN_Code" IN (:codes)' : ''}`, params).catch(() => []),
]);
const byCode = new Map(sheets.map((f) => [f.STD_IN_Code, f]));
return refs.map((r) => {
const f = byCode.get(r.STD_RF_Code);
const config = f?.STD_IN_Config || {};
return {
...r,
STD_IN_Fiche: f ? {
labelFr: f.STD_IN_LabelFr || '',
labelEn: f.STD_IN_LabelEn || '',
section: f.STD_IN_Section || '',
sourceUrls: Array.isArray(config.sourceUrls) ? config.sourceUrls.filter(Boolean) : (config.sourceUrl ? [config.sourceUrl] : []),
procedure: config.procedure || '',
internalOnly: Boolean(config.internalOnly),
} : null,
STD_IN_Columns: config.columns || [],
};
});
}
// ───────────────────────────────────────────────────────────────────────────
// Fiches des référentiels (SDM_RF)
// ───────────────────────────────────────────────────────────────────────────
const asSheet = (l) => ({
Code: l.SDM_RF_Code,
Libelle: l.SDM_RF_Label || {},
Description: l.SDM_RF_Description || '',
Section: l.SDM_RF_Section || '',
Sources: Array.isArray(l.SDM_RF_Sources) ? l.SDM_RF_Sources : [],
Procedure: l.SDM_RF_Procedure || '',
InterneSeul: Boolean(l.SDM_RF_InternalOnly),
UpdatedDate: l.SDM_RF_UpdatedDate,
UpdatedBy: l.SDM_RF_UpdatedBy,
});
/** Les fiches des référentiels, par code. */
async function readSheets(transaction = null) {
const rows = await select(`SELECT * FROM "SDM_RF_Referentials" ORDER BY "SDM_RF_Code"`, {}, transaction);
return new Map(rows.map((l) => [l.SDM_RF_Code, asSheet(l)]));
}
/** La fiche d'un référentiel, null si elle n'existe pas. */
async function readSheet(code, transaction = null) {
const [l] = await select(`SELECT * FROM "SDM_RF_Referentials" WHERE "SDM_RF_Code" = :code`, { code }, transaction);
return l ? asSheet(l) : null;
}
/** Écrit la fiche entière d'un référentiel (création ou remplacement). */
async function saveSheet(sheet, { user = null, transaction = null } = {}) {
const [l] = await select(`
INSERT INTO "SDM_RF_Referentials" ("SDM_RF_Code", "SDM_RF_Label", "SDM_RF_Description", "SDM_RF_Section",
"SDM_RF_Sources", "SDM_RF_Procedure", "SDM_RF_InternalOnly", "SDM_RF_UpdatedBy")
VALUES (:code, :libelle, :description, :section, :sources, :procedure, :internal, :by)
ON CONFLICT ("SDM_RF_Code") DO UPDATE SET
"SDM_RF_Label" = EXCLUDED."SDM_RF_Label",
"SDM_RF_Description" = EXCLUDED."SDM_RF_Description",
"SDM_RF_Section" = EXCLUDED."SDM_RF_Section",
"SDM_RF_Sources" = EXCLUDED."SDM_RF_Sources",
"SDM_RF_Procedure" = EXCLUDED."SDM_RF_Procedure",
"SDM_RF_InternalOnly" = EXCLUDED."SDM_RF_InternalOnly",
"SDM_RF_UpdatedDate" = now(),
"SDM_RF_UpdatedBy" = EXCLUDED."SDM_RF_UpdatedBy"
RETURNING *`, {
code: sheet.Code,
libelle: JSON.stringify(sheet.Libelle || {}),
description: sheet.Description || null,
section: sheet.Section || null,
sources: JSON.stringify(sheet.Sources || []),
procedure: sheet.Procedure || null,
internal: Boolean(sheet.InterneSeul),
by: user || null,
}, transaction);
return asSheet(l);
}
// ───────────────────────────────────────────────────────────────────────────
// Les trios de xone_ref
// ───────────────────────────────────────────────────────────────────────────
/** Les entités dont le trio existe en base : Set<code>. */
async function presentTrio(transaction = null) {
// Pas information_schema : Sequelize y reconnaît un « show tables » et
// rend alors des tableaux au lieu de lignes.
const rows = await select(`
SELECT c.relname AS name FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = :schema AND c.relkind IN ('r', 'p')`,
{ schema: DATA_SCHEMA }, transaction);
const tables = new Set(rows.map((l) => l.name));
return new Set([...tables].filter((t) => tables.has(`${t}_contenu`) && tables.has(`${t}_membre`)));
}
const t = (code, suffix = '') => `${DATA_SCHEMA}.${code}${suffix}`;
/**
* L'état du moteur pour ces entités, lu dans leurs trios. Les données des
* contenus ne sont pas lues : le moteur ne compare que les empreintes.
*/
async function loadState(entities, transaction = null) {
const state = emptyState();
for (const { Code, ParenteCode } of entities) {
const identities = new Map();
for (const l of await select(`SELECT id, code FROM ${t(Code)}`, {}, transaction)) identities.set(l.code, l.id);
state.identities.set(Code, identities);
for (const l of await select(`SELECT id, objet_id, empreinte FROM ${t(Code, '_contenu')}`, {}, transaction)) {
if (!state.contents.has(l.objet_id)) state.contents.set(l.objet_id, new Map());
state.contents.get(l.objet_id).set(l.empreinte, { contentId: l.id, data: null });
}
const byVersion = new Map();
const members = await select(`
SELECT version_num, objet_id, contenu_id, ${ParenteCode ? 'parent_id' : 'NULL AS parent_id'}, ordre
FROM ${t(Code, '_membre')}`, {}, transaction);
for (const l of members) {
if (!byVersion.has(l.version_num)) byVersion.set(l.version_num, new Map());
byVersion.get(l.version_num).set(l.objet_id, { contentId: l.contenu_id, parentId: l.parent_id, ordre: l.ordre });
}
state.members.set(Code, byVersion);
}
return state;
}
const CHUNK = 2000;
const chunks = (list) => {
const result = [];
for (let i = 0; i < list.length; i += CHUNK) result.push(list.slice(i, i + CHUNK));
return result;
};
/**
* Écrit un plan du moteur dans les trios : identités, contenus, appartenances
* (remplacées si elles existent), retraits. Dans la transaction donnée.
* `avance(fraction, etape)` suit l'écriture paquet par paquet (TH.12).
*/
async function writePlan(plan, entities, transaction, progress = () => {}) {
const withParentRow = new Map(entities.map((e) => [e.Code, Boolean(e.ParenteCode)]));
const byEntity = (list) => list.reduce((m, x) => m.set(x.entity, [...(m.get(x.entity) || []), x]), new Map());
const total = plan.identities.length + plan.contents.length + plan.members.length + (plan.removals || []).length;
let done = 0;
const written = (p, what) => { done += p.length; progress(total ? done / total : 1, `écriture des ${what} (${done} / ${total})`); };
for (const [entity, identities] of byEntity(plan.identities)) {
for (const p of chunks(identities)) {
await execute(`INSERT INTO ${t(entity)} (id, code)
SELECT * FROM unnest(ARRAY[:ids]::uuid[], ARRAY[:codes]::varchar[])`,
{ ids: p.map((x) => x.objectId), codes: p.map((x) => x.code) }, transaction);
written(p, 'codes');
}
}
// Un contenu neuf est toujours porté par une appartenance du plan.
const contentEntity = new Map(plan.members.map((m) => [m.contentId, m.entity]));
const contents = plan.contents.map((c) => ({ ...c, entity: contentEntity.get(c.contentId) }));
for (const [entity, list] of byEntity(contents)) {
for (const p of chunks(list)) {
await execute(`INSERT INTO ${t(entity, '_contenu')} (id, objet_id, data, empreinte)
SELECT * FROM unnest(ARRAY[:ids]::uuid[], ARRAY[:objects]::uuid[], ARRAY[:data]::jsonb[], ARRAY[:fingerprints]::varchar[])`,
{
ids: p.map((x) => x.contentId),
objects: p.map((x) => x.objectId),
data: p.map((x) => JSON.stringify(x.data)),
fingerprints: p.map((x) => x.empreinte),
}, transaction);
written(p, 'textes');
}
}
for (const [entity, members] of byEntity(plan.members)) {
const parent = withParentRow.get(entity);
for (const p of chunks(members)) {
await execute(`INSERT INTO ${t(entity, '_membre')} (version_num, objet_id, contenu_id, ${parent ? 'parent_id, ' : ''}ordre)
SELECT :version, * FROM unnest(ARRAY[:objects]::uuid[], ARRAY[:contents]::uuid[], ${parent ? 'ARRAY[:parents]::uuid[], ' : ''}ARRAY[:orders]::integer[])
ON CONFLICT (version_num, objet_id) DO UPDATE SET
contenu_id = EXCLUDED.contenu_id, ${parent ? 'parent_id = EXCLUDED.parent_id, ' : ''}ordre = EXCLUDED.ordre`,
{
version: plan.versionNum,
objects: p.map((x) => x.objectId),
contents: p.map((x) => x.contentId),
parents: p.map((x) => x.parentId),
orders: p.map((x) => x.ordre),
}, transaction);
written(p, 'appartenances');
}
}
for (const [entity, removals] of byEntity(plan.removals || [])) {
for (const p of chunks(removals)) {
await execute(`DELETE FROM ${t(entity, '_membre')}
WHERE version_num = :version AND objet_id = ANY(ARRAY[:objects]::uuid[])`,
{ version: plan.versionNum, objects: p.map((x) => x.objectId) }, transaction);
written(p, 'retraits');
}
}
}
/** Les versions présentes, par entité : [{ entite, version_num, nb }]. */
async function presentVersions(entities) {
const result = [];
for (const { Code } of entities) {
const rows = await select(`SELECT version_num, count(*)::int AS nb FROM ${t(Code, '_membre')} GROUP BY version_num`);
for (const l of rows) result.push({ entity: Code, ...l });
}
return result;
}
// ───────────────────────────────────────────────────────────────────────────
// Imports
// ───────────────────────────────────────────────────────────────────────────
async function createImport(row) {
const id = row.id || crypto.randomUUID();
await execute(`
INSERT INTO "SDM_IM_Imports" ("SDM_IM_Id", "SDM_IM_Referential", "SDM_IM_VersionNum", "SDM_IM_File",
"SDM_IM_FileFingerprint", "SDM_IM_Report", "SDM_IM_AnalyzedBy")
VALUES (:id, :referential, :versionNum, :file, :empreinte, :report, :by)`, {
id, ...row, report: JSON.stringify(row.report), by: row.by || null,
});
return id;
}
/** Une version reprise de Standards (lot 2.3) : inscrite directement intégrée. */
async function recordIntegratedImport(row, transaction) {
const id = crypto.randomUUID();
const r = row.report;
await execute(`
INSERT INTO "SDM_IM_Imports" ("SDM_IM_Id", "SDM_IM_Referential", "SDM_IM_VersionNum", "SDM_IM_File",
"SDM_IM_FileFingerprint", "SDM_IM_State", "SDM_IM_Report", "SDM_IM_AnalyzedBy", "SDM_IM_ValidatedAt",
"SDM_IM_ValidatedBy", "SDM_IM_IntegratedAt", "SDM_IM_CreatedItems", "SDM_IM_CreatedContents",
"SDM_IM_ReusedContents", "SDM_IM_Memberships")
VALUES (:id, :referential, :versionNum, :file, :empreinte, 'integre', :report, :by, now(), :by, now(),
:items, :created, :reused, :members)`, {
id,
referential: row.referential,
versionNum: row.versionNum,
file: row.file,
empreinte: row.empreinte,
report: JSON.stringify(r),
by: row.by || null,
items: r.createdIdentities,
created: r.createdContents,
reused: r.reusedContents,
members: r.newMembers + r.rewrittenMembers + r.unchangedMembers,
}, transaction);
return id;
}
async function readImport(id, transaction = null, lock = false) {
const [row] = await select(`SELECT * FROM "SDM_IM_Imports" WHERE "SDM_IM_Id" = :id ${lock ? 'FOR UPDATE' : ''}`,
{ id }, transaction);
return row || null;
}
async function listImports() {
return select(`
SELECT "SDM_IM_Id", "SDM_IM_Referential", "SDM_IM_VersionNum", "SDM_IM_File", "SDM_IM_State",
"SDM_IM_AnalyzedAt", "SDM_IM_AnalyzedBy", "SDM_IM_ValidatedAt", "SDM_IM_ValidatedBy", "SDM_IM_IntegratedAt",
"SDM_IM_CreatedItems", "SDM_IM_CreatedContents", "SDM_IM_ReusedContents", "SDM_IM_Memberships"
FROM "SDM_IM_Imports" ORDER BY "SDM_IM_AnalyzedAt" DESC LIMIT 200`);
}
async function markIntegrated(id, { by, report }, transaction) {
await execute(`
UPDATE "SDM_IM_Imports" SET
"SDM_IM_State" = 'integre', "SDM_IM_ValidatedAt" = now(), "SDM_IM_ValidatedBy" = :by, "SDM_IM_IntegratedAt" = now(),
"SDM_IM_CreatedItems" = :items, "SDM_IM_CreatedContents" = :created,
"SDM_IM_ReusedContents" = :reused, "SDM_IM_Memberships" = :members
WHERE "SDM_IM_Id" = :id`, {
id,
by: by || null,
items: report.createdIdentities,
created: report.createdContents,
reused: report.reusedContents,
members: report.newMembers + report.rewrittenMembers + report.unchangedMembers,
}, transaction);
}
async function markRefused(id, by) {
const [, n] = await execute(`
UPDATE "SDM_IM_Imports" SET "SDM_IM_State" = 'refuse', "SDM_IM_ValidatedAt" = now(), "SDM_IM_ValidatedBy" = :by
WHERE "SDM_IM_Id" = :id AND "SDM_IM_State" = 'analyse'`, { id, by: by || null });
return n?.rowCount ?? n;
}
/**
* Une analyse en attente devient « périmée » dès qu'une version du même
* référentiel est intégrée : la base n'est plus celle qu'elle a lue.
*/
async function expireAnalyses(referential, transaction = null) {
await execute(`
UPDATE "SDM_IM_Imports" SET "SDM_IM_State" = 'perime'
WHERE "SDM_IM_Referential" = :referential AND "SDM_IM_State" = 'analyse'`, { referential }, transaction);
}
/** Relancer une analyse : nouveau rapport, de nouveau « à valider ». */
async function rerunAnalysis(id, { empreinte, report, by }) {
await execute(`
UPDATE "SDM_IM_Imports" SET "SDM_IM_State" = 'analyse', "SDM_IM_FileFingerprint" = :empreinte,
"SDM_IM_Report" = :report, "SDM_IM_AnalyzedAt" = now(), "SDM_IM_AnalyzedBy" = :by
WHERE "SDM_IM_Id" = :id AND "SDM_IM_State" IN ('analyse', 'perime')`, {
id, empreinte, report: JSON.stringify(report), by: by || null,
});
}
/** Verrou de transaction par référentiel : deux intégrations ne se croisent pas. */
async function lock(referential, transaction) {
await execute(`SELECT pg_advisory_xact_lock(hashtext(:key))`, { key: `sdm:${referential}` }, transaction);
}
// ───────────────────────────────────────────────────────────────────────────
// Migrations engendrées (SDM_MG, lot 1.2)
// ───────────────────────────────────────────────────────────────────────────
/** Les tables et vues de xone_ref : { tables: Set, vues: Set }. */
async function presentRelations(transaction = null) {
const rows = await select(`
SELECT c.relname AS name, c.relkind AS kind FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = :schema AND c.relkind IN ('r', 'p', 'v')`,
{ schema: DATA_SCHEMA }, transaction);
return {
tables: new Set(rows.filter((l) => l.kind !== 'v').map((l) => l.name)),
views: new Set(rows.filter((l) => l.kind === 'v').map((l) => l.name)),
};
}
/** Les fichiers inscrits dans SequelizeMeta (vide si la table n'existe pas). */
async function playedFiles(transaction = null) {
const [{ exists }] = await select(`SELECT to_regclass('public."SequelizeMeta"') IS NOT NULL AS exists`, {}, transaction);
if (!exists) return new Set();
return new Set((await select('SELECT name FROM "SequelizeMeta"', {}, transaction)).map((l) => l.name));
}
async function recordMigration({ file, plan, by }, transaction = null) {
const id = crypto.randomUUID();
await execute(`
INSERT INTO "SDM_MG_Migrations" ("SDM_MG_Id", "SDM_MG_FileName", "SDM_MG_Plan", "SDM_MG_GeneratedBy")
VALUES (:id, :file, :plan, :by) ON CONFLICT ("SDM_MG_FileName") DO NOTHING`,
{ id, file, plan: JSON.stringify(plan), by: by || null }, transaction);
return id;
}
/**
* Complète SDM_MG avec les fichiers engendrés qu'elle ne connaît pas (écrits
* avant elle, ou par un autre poste), et passe `jouee` ce que SequelizeMeta
* a joué. Rend le nombre de lignes touchées.
*/
async function syncMigrations(generated) {
const known = new Set((await select(`SELECT "SDM_MG_FileName" AS f FROM "SDM_MG_Migrations"`)).map((l) => l.f));
let n = 0;
for (const { file, plan } of generated) {
if (known.has(file)) continue;
await recordMigration({ file, plan });
n += 1;
}
const played = [...(await playedFiles())];
if (played.length) {
const [, r] = await execute(`
UPDATE "SDM_MG_Migrations" SET "SDM_MG_State" = 'jouee', "SDM_MG_PlayedAt" = now()
WHERE "SDM_MG_State" = 'a_jouer' AND "SDM_MG_FileName" IN (:played)`, { played });
n += r?.rowCount ?? 0;
}
return n;
}
async function listMigrations() {
return select(`SELECT * FROM "SDM_MG_Migrations" ORDER BY "SDM_MG_FileName" DESC`);
}
async function readMigration(id) {
const [row] = await select(`SELECT * FROM "SDM_MG_Migrations" WHERE "SDM_MG_Id" = :id`, { id });
return row || null;
}
async function markMigrationPlayed(id) {
const [, r] = await execute(`
UPDATE "SDM_MG_Migrations" SET "SDM_MG_State" = 'jouee', "SDM_MG_PlayedAt" = now()
WHERE "SDM_MG_Id" = :id AND "SDM_MG_State" = 'a_jouer'`, { id });
return r?.rowCount ?? 0;
}
/** Les plans des migrations encore à jouer. */
async function pendingMigrations(transaction = null) {
return select(`SELECT "SDM_MG_Id", "SDM_MG_FileName", "SDM_MG_Plan" FROM "SDM_MG_Migrations" WHERE "SDM_MG_State" = 'a_jouer'`,
{}, transaction);
}
// ───────────────────────────────────────────────────────────────────────────
// Liens (SDM_LN, lot 3.1) et décisions (SDM_DC, lot 3.2)
// ───────────────────────────────────────────────────────────────────────────
async function listLinks(transaction = null) {
return select(`SELECT * FROM "SDM_LN_Links" ORDER BY "SDM_LN_SourceEntity", "SDM_LN_TargetEntity", "SDM_LN_SourceAttribute"`,
{}, transaction);
}
/**
* Les liaisons du modèle hiérarchique publié de Standards (`STD_HL`, lecture
* seule), à reprendre en liens (TH.13). Vide si la table n'existe pas.
*/
async function standardsLinks() {
return select(`
SELECT l."STD_HL_FromRef" AS "fromRef", l."STD_HL_FromField" AS "fromField",
l."STD_HL_ToRef" AS "toRef", l."STD_HL_ToField" AS "toField",
l."STD_HL_IsNative" AS "isNative", l."STD_HL_Coverage" AS coverage
FROM "STD_HL_Links" l
JOIN "STD_HM_Models" m ON m."STD_HM_Id" = l."STD_HL_ModelId"
WHERE m."STD_HM_IsPublished" = true
ORDER BY l."STD_HL_SortOrder"`).catch(() => []);
}
/**
* Les cartes de correspondance PUBLIÉES de Standards (`STD_MC` + l'instantané
* publié de `STD_ML`, lecture seule), à reprendre en instances de liens non
* natifs (T3.2.1). Une carte vaut pour un couple de versions. Les cartes jamais
* publiées ne sont pas reprises, seulement comptées. Vide si les tables
* n'existent plus.
* @returns {{ cartes: Array<{ id, fromRef, fromVersion, toRef, toVersion, paires: Array<{ de, vers }> }>, nonPubliees: number }}
*/
async function standardsCards() {
const rows = await select(`
SELECT c."STD_MC_Id" AS id, c."STD_MC_FromRef" AS "fromRef", c."STD_MC_FromVersion" AS "fromVersion",
c."STD_MC_ToRef" AS "toRef", c."STD_MC_ToVersion" AS "toVersion",
l."STD_ML_FromItemCode" AS "from", l."STD_ML_ToItemCode" AS "to"
FROM "STD_MC_MappingCards" c
JOIN "STD_ML_MappingLinks" l ON l."STD_ML_CardId" = c."STD_MC_Id" AND l."STD_ML_Scope" = 'published'
WHERE c."STD_MC_PublishedDate" IS NOT NULL
ORDER BY c."STD_MC_FromRef", c."STD_MC_ToRef", c."STD_MC_FromVersion", c."STD_MC_ToVersion", l."STD_ML_FromItemCode", l."STD_ML_ToItemCode"`)
.catch(() => []);
const [{ n } = { n: 0 }] = await select(`
SELECT count(*)::int AS n FROM "STD_MC_MappingCards" WHERE "STD_MC_PublishedDate" IS NULL`).catch(() => []);
const byId = new Map();
for (const l of rows) {
if (!byId.has(l.id)) {
byId.set(l.id, {
id: l.id, fromRef: l.fromRef, fromVersion: String(l.fromVersion), toRef: l.toRef, toVersion: String(l.toVersion), pairs: [],
});
}
byId.get(l.id).pairs.push({ from: l.from, to: l.to });
}
return { cards: [...byId.values()], unpublished: n };
}
/** Standards a-t-il au moins une carte de correspondance publiée ? (faux si les tables n'existent plus) */
async function hasPublishedCards() {
const [l] = await select(`SELECT 1 AS one FROM "STD_MC_MappingCards" WHERE "STD_MC_PublishedDate" IS NOT NULL LIMIT 1`)
.catch(() => []);
return Boolean(l);
}
async function createLink(link, transaction = null) {
const id = crypto.randomUUID();
await execute(`
INSERT INTO "SDM_LN_Links" ("SDM_LN_Id", "SDM_LN_SourceEntity", "SDM_LN_SourceAttribute", "SDM_LN_TargetEntity",
"SDM_LN_TargetAttribute", "SDM_LN_Nature", "SDM_LN_Cardinality", "SDM_LN_Active")
VALUES (:id, :source, :attribute, :target, :targetAttribute, :nature, :cardinality, true)`, {
id,
source: link.source,
attribute: link.attribute || null,
target: link.target,
targetAttribute: link.targetAttribute || null,
nature: link.nature,
cardinality: link.cardinality || '1,n',
}, transaction);
return id;
}
async function enableLink(id, active) {
const [, r] = await execute(`UPDATE "SDM_LN_Links" SET "SDM_LN_Active" = :active WHERE "SDM_LN_Id" = :id`, { id, active });
return r?.rowCount ?? 0;
}
/** Les décisions des liens donnés (tous si `lienIds` est absent). */
async function listDecisions({ linkIds = null, state = null } = {}, transaction = null) {
if (linkIds && !linkIds.length) return [];
return select(`
SELECT * FROM "SDM_DC_Decisions"
WHERE true ${linkIds ? 'AND "SDM_DC_LinkId" IN (:linkIds)' : ''} ${state ? 'AND "SDM_DC_State" = :state' : ''}
ORDER BY "SDM_DC_VersionNum" DESC, "SDM_DC_DecidedAt" DESC NULLS LAST`, { linkIds, state }, transaction);
}
/**
* Écrit des décisions. Une décision déjà présente pour la même paire et la
* même version est remplacée : une décision humaine se reprend.
*/
async function writeDecisions(decisions, { by = null } = {}, transaction = null) {
for (const p of chunks(decisions)) {
await execute(`
INSERT INTO "SDM_DC_Decisions" ("SDM_DC_Id", "SDM_DC_LinkId", "SDM_DC_SourceId", "SDM_DC_TargetId",
"SDM_DC_VersionNum", "SDM_DC_State", "SDM_DC_DecidedAt", "SDM_DC_DecidedBy", "SDM_DC_Reason")
SELECT d.id, d.lien, d.source, d.cible, d.version, d.etat,
CASE WHEN d.humain THEN now() END, CASE WHEN d.humain THEN :by END, d.motif
FROM unnest(ARRAY[:ids]::uuid[], ARRAY[:links]::uuid[], ARRAY[:sources]::uuid[], ARRAY[:targets]::uuid[],
ARRAY[:versions]::varchar[], ARRAY[:states]::varchar[], ARRAY[:human]::boolean[], ARRAY[:reasons]::text[])
AS d(id, lien, source, cible, version, etat, humain, motif)
ON CONFLICT ("SDM_DC_LinkId", "SDM_DC_SourceId", "SDM_DC_TargetId", "SDM_DC_VersionNum") DO UPDATE SET
"SDM_DC_State" = EXCLUDED."SDM_DC_State", "SDM_DC_DecidedAt" = EXCLUDED."SDM_DC_DecidedAt",
"SDM_DC_DecidedBy" = EXCLUDED."SDM_DC_DecidedBy", "SDM_DC_Reason" = EXCLUDED."SDM_DC_Reason"`, {
by,
ids: p.map(() => crypto.randomUUID()),
links: p.map((d) => d.linkId),
sources: p.map((d) => d.sourceId),
targets: p.map((d) => d.targetId),
versions: p.map((d) => d.versionNum),
states: p.map((d) => d.state),
human: p.map((d) => Boolean(d.human)),
reasons: p.map((d) => d.reason || ''),
}, transaction);
}
}
async function readDecision(id, transaction = null) {
const [row] = await select(`SELECT * FROM "SDM_DC_Decisions" WHERE "SDM_DC_Id" = :id ${transaction ? 'FOR UPDATE' : ''}`,
{ id }, transaction);
return row || null;
}
async function settleDecision(id, { state, by, reason }, transaction = null) {
await execute(`
UPDATE "SDM_DC_Decisions" SET "SDM_DC_State" = :state, "SDM_DC_DecidedAt" = now(), "SDM_DC_DecidedBy" = :by,
"SDM_DC_Reason" = COALESCE(:reason, "SDM_DC_Reason")
WHERE "SDM_DC_Id" = :id`, { id, state, by: by || null, reason: reason || null }, transaction);
}
/** Remplace les paires d'une version dans les tables de liaison des liens natifs. */
async function writeNativeLinks(versionNum, tables, pairs, transaction) {
for (const name of tables) {
await execute(`DELETE FROM ${t(name)} WHERE version_num = :versionNum`, { versionNum }, transaction);
const own = pairs.filter((p) => p.table === name);
for (const p of chunks(own)) {
await execute(`INSERT INTO ${t(name)} (version_num, source_id, cible_id)
SELECT :versionNum, * FROM unnest(ARRAY[:sources]::uuid[], ARRAY[:targets]::uuid[])`,
{ versionNum, sources: p.map((x) => x.sourceId), targets: p.map((x) => x.targetId) }, transaction);
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// Lectures dans les trios
// ───────────────────────────────────────────────────────────────────────────
/** Les identités d'une entité : Map<code, id>. */
async function identities(entity, transaction = null) {
return new Map((await select(`SELECT id, code FROM ${t(entity)}`, {}, transaction)).map((l) => [l.code, l.id]));
}
/** Les codes d'identités données : Map<id, code>. */
async function codesById(entity, ids, transaction = null) {
if (!ids.length) return new Map();
return new Map((await select(`SELECT id, code FROM ${t(entity)} WHERE id = ANY(ARRAY[:ids]::uuid[])`, { ids }, transaction))
.map((l) => [l.id, l.code]));
}
/** Les numéros de version d'une entité. */
async function versionsOf(entity, transaction = null) {
return (await select(`SELECT DISTINCT version_num FROM ${t(entity, '_membre')}`, {}, transaction)).map((l) => l.version_num);
}
/** Les appartenances d'une version : Map<objetId, { code, parentId }>. */
async function memberships(entity, versionNum, withParentRow, transaction = null) {
const rows = await select(`
SELECT m.objet_id, i.code, ${withParentRow ? 'm.parent_id' : 'NULL AS parent_id'}
FROM ${t(entity, '_membre')} m JOIN ${t(entity)} i ON i.id = m.objet_id
WHERE m.version_num = :versionNum`, { versionNum }, transaction);
return new Map(rows.map((l) => [l.objet_id, { code: l.code, parentId: l.parent_id }]));
}
/**
* Les données d'une entité pour une version (plan § 17) : une ligne par
* objet, le contenu dans la langue demandée, ou toutes langues.
*/
async function readData(entity, { versionNum, lang, withParentRow, limit = 500, offset = 0 }) {
const content = lang ? 'c.data -> :lang' : 'c.data';
const parent = withParentRow ? `, (SELECT p.code FROM ${t(withParentRow)} p WHERE p.id = m.parent_id) AS parent_code` : '';
return select(`
SELECT i.code, m.version_num, m.ordre, ${content} AS data, c.empreinte${parent}
FROM ${t(entity, '_membre')} m
JOIN ${t(entity)} i ON i.id = m.objet_id
JOIN ${t(entity, '_contenu')} c ON c.id = m.contenu_id
WHERE m.version_num = :versionNum
ORDER BY m.ordre NULLS LAST, i.code
LIMIT :limit OFFSET :offset`, { versionNum, lang, limit, offset });
}
/**
* L'exploration d'une entité pour l'onglet Données : une page de lignes
* (toutes langues), le total, une recherche sur le code et le contenu, et
* au besoin les seuls enfants d'un objet parent.
*/
async function exploreData(entity, { versionNum, search, withParentRow, parentCode, limit = 50, offset = 0 }) {
const filters = ['m.version_num = :versionNum'];
if (search) filters.push(`(i.code ILIKE :pattern OR c.data::text ILIKE :pattern)`);
if (withParentRow && parentCode) filters.push(`m.parent_id = (SELECT p.id FROM ${t(withParentRow)} p WHERE p.code = :parentCode)`);
const parent = withParentRow ? `, (SELECT p.code FROM ${t(withParentRow)} p WHERE p.id = m.parent_id) AS parent_code` : '';
const rows = await select(`
SELECT m.objet_id, i.code, m.ordre, c.data, c.empreinte${parent}, count(*) OVER ()::int AS total
FROM ${t(entity, '_membre')} m
JOIN ${t(entity)} i ON i.id = m.objet_id
JOIN ${t(entity, '_contenu')} c ON c.id = m.contenu_id
WHERE ${filters.join(' AND ')}
ORDER BY m.ordre NULLS LAST, i.code
LIMIT :limit OFFSET :offset`, {
versionNum,
pattern: search ? `%${String(search).replace(/[\\%_]/g, (c) => `\\${c}`)}%` : null,
parentCode: parentCode || null,
limit,
offset,
});
return { total: rows.length ? rows[0].total : 0, rows: rows.map(({ total, ...l }) => l) };
}
/**
* L'onglet Données comme celui de Standards : plusieurs entités, une ou
* toutes les versions, une ou toutes les langues, à plat (une ligne par
* objet, version et langue), triées puis paginées en une requête.
*
* `sources` : [{ code, avecParent, racinesSeules, versions (triées), courante, toutes }].
* `racinesSeules` : une entité qui est sa propre parente ne montre que ses
* objets de tête, les autres se déplient dessous.
*/
async function browseData(sources, { langs, search, parentCode, limit = 20, offset = 0 }) {
if (!sources.length) return { total: 0, rows: [] };
const values = {
langs,
pattern: search ? `%${String(search).replace(/[\\%_]/g, (c) => `\\${c}`)}%` : null,
parentCode: parentCode || null,
limit,
offset,
};
const parts = sources.map((s, n) => {
Object.assign(values, { [`e${n}`]: s.code, [`v${n}`]: s.versions, [`c${n}`]: s.current });
const filters = ['(c.data -> l.lang) IS NOT NULL'];
if (!s.allOf) filters.push(`m.version_num = :c${n}`);
if (search) filters.push('(o.code ILIKE :pattern OR (c.data -> l.lang)::text ILIKE :pattern)');
if (s.withParentRow && parentCode) filters.push(`m.parent_id IN (SELECT p.id FROM ${t(s.withParentRow)} p WHERE p.code = :parentCode)`);
else if (s.rootsOnly) filters.push('m.parent_id IS NULL');
const parent = s.withParentRow ? `(SELECT p.code FROM ${t(s.withParentRow)} p WHERE p.id = m.parent_id)` : 'NULL';
return `
SELECT :e${n}::text AS entity, ${n} AS rang_entite, m.objet_id, o.code, m.version_num,
(m.version_num = :c${n}) AS current,
array_position(ARRAY[:v${n}]::text[], m.version_num) AS rang_version,
m.ordre, l.lang, jsonb_build_object(l.lang, c.data -> l.lang) AS data,
c.empreinte, ${parent}::text AS parent_code
FROM ${t(s.code, '_membre')} m
JOIN ${t(s.code)} o ON o.id = m.objet_id
JOIN ${t(s.code, '_contenu')} c ON c.id = m.contenu_id
CROSS JOIN unnest(ARRAY[:langs]::text[]) AS l(lang)
WHERE ${filters.join(' AND ')}`;
});
const rows = await select(`
SELECT u.*, count(*) OVER ()::int AS total FROM (${parts.join('\n UNION ALL')}) u
ORDER BY u.rang_entite, u.rang_version DESC, u.ordre NULLS LAST, u.code,
array_position(ARRAY[:langs]::text[], u.lang)
LIMIT :limit OFFSET :offset`, values);
return {
total: rows.length ? rows[0].total : 0,
rows: rows.map(({ total, rang_entite: re, rang_version: rv, ...l }) => l),
};
}
/** Le nombre d'enfants de chaque objet parent dans une version : Map<parentId, n>. */
async function countChildren(child, versionNum, parentIds) {
if (!parentIds.length) return new Map();
const rows = await select(`
SELECT parent_id, count(*)::int AS n FROM ${t(child, '_membre')}
WHERE version_num = :versionNum AND parent_id IN (:parentIds)
GROUP BY parent_id`, { versionNum, parentIds });
return new Map(rows.map((l) => [l.parent_id, l.n]));
}
module.exports = {
transaction: (fn) => db.sequelize.transaction(fn),
exploreData,
browseData,
countChildren,
select,
presentRelations,
playedFiles,
recordMigration,
syncMigrations,
listMigrations,
readMigration,
markMigrationPlayed,
pendingMigrations,
listLinks,
standardsLinks,
standardsCards,
hasPublishedCards,
createLink,
enableLink,
listDecisions,
writeDecisions,
readDecision,
settleDecision,
writeNativeLinks,
identities,
codesById,
versionsOf,
memberships,
readData,
loadRegistry,
saveEntity,
readReferentials,
readSheets,
readSheet,
saveSheet,
presentTrio,
loadState,
writePlan,
presentVersions,
createImport,
recordIntegratedImport,
readImport,
listImports,
markIntegrated,
markRefused,
expireAnalyses,
rerunAnalysis,
lock,
};
/**
* Les tâches longues du module (TH.12) : analyse, intégration, relance.
*
* Le proxy coupe les requêtes longues : la requête lance la tâche et répond
* tout de suite avec son numéro ; l'écran suit l'avancement par
* GET /ajax/sdm/taches/:id, comme `generateProgress` / `integrateProgress`
* de Standards. Les tâches vivent en mémoire du processus : un redémarrage
* les perd (l'écran le dit), une intégration interrompue est annulée par sa
* transaction.
*/
const crypto = require('crypto');
const GUARD_MS = 5 * 60 * 1000;
const tasks = new Map();
const bound = (f) => Math.max(0, Math.min(1, Number(f) || 0));
/**
* Lance `travail(avance)` sans l'attendre ; rend le numéro de la tâche.
* `avance(fraction, etape)` fait avancer la barre (jamais en arrière).
* `fin()` est appelé à la fin, réussie ou non.
*/
function start(type, work, { end = () => {} } = {}) {
const id = crypto.randomUUID();
const task = { id, type, state: 'en_cours', fraction: 0, step: 'démarrage', start: new Date().toISOString() };
tasks.set(id, task);
const progress = (fraction, step) => {
task.fraction = Math.max(task.fraction, bound(fraction));
if (step) task.step = step;
};
const forget = () => setTimeout(() => tasks.delete(id), GUARD_MS).unref?.();
Promise.resolve()
.then(() => work(progress))
.then((result) => Object.assign(task, { state: 'fini', fraction: 1, step: 'terminé', result }))
.catch((e) => Object.assign(task, { state: 'error', error: e.message, status: e.status || 400 }))
.finally(() => { try { end(task); } catch { /* rien */ } forget(); });
return id;
}
/** L'état d'une tâche, ou null si elle est inconnue (finie depuis longtemps, ou serveur redémarré). */
const read = (id) => tasks.get(String(id || '')) || null;
/**
* Une avance limitée à une tranche [de, a] : les étapes d'un sous-travail
* (ex. l'écriture, paquet par paquet) prennent leur place dans la barre.
*/
const slice = (progress, from, a) => (f, step) => progress(from + bound(f) * (a - from), step);
module.exports = { start, read, slice };
/**
* sdm-annulation.utils.js — DEV SEULEMENT : défaire ce que l'écran a
* engendré ou joué (hors plan, voir CONTEXT.md E15).
*
* supprimer(fichier) une migration pas encore jouée : son fichier, ses
* modèles Sequelize, sa ligne SDM_MG ;
* annuler(fichier) une migration jouée : son `down`, sa ligne
* SequelizeMeta, SDM_MG repasse `a_jouer`, et les
* imports / décisions qui vivaient dans ses tables ;
* remettreAZero() tout ce que sys_data_model a écrit.
*
* Une migration dont une plus récente dépend (elle en cite les tables) ne se
* défait pas : on défait d'abord la plus récente, dans l'ordre inverse.
*
* Ne JAMAIS ouvrir contre une base X-One : dans X-One, c'est le déploiement
* (sequelize-cli db:migrate:undo) qui défait une migration.
*/
const fs = require('fs');
const path = require('path');
const db = require('../../../database/models');
const G = require('./sdm-generator.utils');
const error = (message, status = 400) => Object.assign(new Error(message), { status });
/** Les tables et vues qu'une migration engendrée crée, sans le schéma. */
function planRelations(plan) {
if (plan.type === 'entities') return plan.entities.flatMap((c) => [c, `${c}_contenu`, `${c}_membre`]);
if (plan.type === 'link') return [plan.table];
if (plan.type === 'view') return [G.compatViewName(plan.referential)];
return [];
}
/**
* Les migrations engendrées après `fichier` qui citent une de ses tables.
* `lire(fichier)` rend le texte d'un fichier.
*/
function dependents(file, generated, read) {
const me = generated.find((m) => m.file === file);
if (!me) return [];
const reasons = planRelations(me.plan).map((r) => new RegExp(`${G.DATA_SCHEMA}\\.${r}\\b`));
return generated
.filter((m) => m.file > file && reasons.some((re) => re.test(read(m.file))))
.map((m) => m.file);
}
const readFile = (file) => fs.readFileSync(path.join(G.MIGRATIONS_DIR, file), 'utf8');
async function playedFiles(transaction = null) {
const [{ exists }] = await db.sequelize.query(`SELECT to_regclass('public."SequelizeMeta"') IS NOT NULL AS exists`,
{ type: db.Sequelize.QueryTypes.SELECT, transaction });
if (!exists) return new Set();
return new Set((await db.sequelize.query('SELECT name FROM "SequelizeMeta"',
{ type: db.Sequelize.QueryTypes.SELECT, transaction })).map((l) => l.name));
}
/** Retire le fichier de migration et les modèles qui en viennent. */
function removeFiles(file) {
const models = G.generatedModels().filter((m) => m.migration === file);
for (const m of models) fs.rmSync(path.join(G.MODELS_DIR, m.file), { force: true });
fs.rmSync(path.join(G.MIGRATIONS_DIR, file), { force: true });
return models.map((m) => m.model);
}
/** Supprime une migration engendrée pas encore jouée. */
async function remove(file) {
if ((await playedFiles()).has(file)) throw error(`${file} est jouée : annulez-la d'abord`, 409);
const generated = G.generatedMigrations();
const present = generated.some((m) => m.file === file);
if (present) {
const before = dependents(file, generated, readFile);
if (before.length) throw error(`${file} : ${before.join(', ')} en dépend — supprimez-la d'abord`, 409);
}
// Un fichier déjà retiré à la main : on ne nettoie que SDM_MG.
const models = present ? removeFiles(file) : [];
await db.sequelize.query('DELETE FROM "SDM_MG_Migrations" WHERE "SDM_MG_FileName" = :file',
{ replacements: { file } });
return { file, models };
}
/** Joue le `down` d'une migration jouée et défait ce qui vivait dans ses tables. */
async function undo(file) {
const generated = G.generatedMigrations();
const me = generated.find((m) => m.file === file);
if (!me) throw error(`${file} : fichier introuvable dans ${path.basename(G.MIGRATIONS_DIR)}`, 404);
const played = await playedFiles();
const before = dependents(file, generated, readFile).filter((f) => played.has(f));
if (before.length) throw error(`${file} : ${before.join(', ')} en dépend — annulez-la d'abord`, 409);
const filePath = path.join(G.MIGRATIONS_DIR, file);
delete require.cache[require.resolve(filePath)];
// eslint-disable-next-line global-require, import/no-dynamic-require
const migration = require(filePath);
const { sequelize, Sequelize } = db;
const purge = { imports: 0, decisions: 0 };
await sequelize.transaction(async (transaction) => {
const q = (sql, replacements) => sequelize.query(sql, { replacements, transaction });
await migration.down(sequelize.getQueryInterface(), Sequelize, { transaction });
if (played.has(file)) await q('DELETE FROM "SequelizeMeta" WHERE name = :file', { file });
await q(`UPDATE "SDM_MG_Migrations" SET "SDM_MG_State" = 'a_jouer', "SDM_MG_PlayedAt" = NULL
WHERE "SDM_MG_FileName" = :file`, { file });
const { plan } = me;
if (plan.type === 'entities') {
const [, im] = await q('DELETE FROM "SDM_IM_Imports" WHERE "SDM_IM_Referential" = :ref', { ref: plan.referential });
const [, dc] = await q(`DELETE FROM "SDM_DC_Decisions" WHERE "SDM_DC_LinkId" IN (
SELECT "SDM_LN_Id" FROM "SDM_LN_Links"
WHERE "SDM_LN_SourceEntity" IN (:codes) OR "SDM_LN_TargetEntity" IN (:codes))`, { codes: plan.entities });
purge.imports = im?.rowCount ?? 0;
purge.decisions = dc?.rowCount ?? 0;
} else if (plan.type === 'link') {
const [, dc] = await q('DELETE FROM "SDM_DC_Decisions" WHERE "SDM_DC_LinkId" = :id', { id: plan.linkId });
purge.decisions = dc?.rowCount ?? 0;
}
});
return { file, ...purge };
}
/**
* Remet sys_data_model à zéro sur la base locale :
* 1. retire les migrations engendrées (celles qui portent un plan sdm),
* leurs lignes SequelizeMeta et leurs modèles Sequelize ; les
* migrations des tables SDM_* elles-mêmes restent ;
* 2. supprime le schéma xone_ref (trios, tables de lien, vues) ;
* 3. vide SDM_RF, SDM_EN, SDM_AT, SDM_MG, SDM_IM, SDM_LN, SDM_DC ;
* 4. remet le schéma STD de GOVERNANCE à celui de dev-db-init (CONTROLS
* séparé par retour à la ligne).
*/
async function resetAll() {
const { sequelize } = db;
const generated = G.generatedMigrations().map((m) => m.file);
await sequelize.transaction(async (transaction) => {
const q = (sql, replacements) => sequelize.query(sql, { replacements, transaction });
await q(`DROP SCHEMA IF EXISTS ${G.DATA_SCHEMA} CASCADE`);
if (generated.length) await q('DELETE FROM "SequelizeMeta" WHERE name IN (:generated)', { generated });
await q(`TRUNCATE "SDM_DC_Decisions", "SDM_LN_Links", "SDM_MG_Migrations", "SDM_IM_Imports",
"SDM_AT_Attributes", "SDM_EN_Entities", "SDM_RF_Referentials" CASCADE`);
await q(`UPDATE "STD_RF_Referentials"
SET "STD_RF_Schema" = jsonb_set("STD_RF_Schema", '{columns}', (
SELECT jsonb_agg(CASE WHEN c->>'name' = 'CONTROLS'
THEN c || '{"separator":"newline"}'::jsonb ELSE c END ORDER BY n)
FROM jsonb_array_elements("STD_RF_Schema"->'columns') WITH ORDINALITY AS t(c, n)))
WHERE "STD_RF_Code" = 'GOVERNANCE' AND "STD_RF_Schema" ? 'columns'`);
});
for (const file of generated) fs.rmSync(path.join(G.MIGRATIONS_DIR, file), { force: true });
const models = G.generatedModels().map((m) => m.file);
for (const file of models) fs.rmSync(path.join(G.MODELS_DIR, file), { force: true });
return { removedFiles: generated, removedModels: models };
}
module.exports = { planRelations, dependents, remove, undo, resetAll };
/**
* sdm-annulation.utils.js — DEV SEULEMENT : défaire ce que l'écran a
* engendré ou joué (hors plan, voir CONTEXT.md E15).
*
* supprimer(fichier) une migration pas encore jouée : son fichier, ses
* modèles Sequelize, sa ligne SDM_MG ;
* annuler(fichier) une migration jouée : son `down`, sa ligne
* SequelizeMeta, SDM_MG repasse `a_jouer`, et les
* imports / décisions qui vivaient dans ses tables ;
* remettreAZero() tout ce que sys_data_model a écrit.
*
* Une migration dont une plus récente dépend (elle en cite les tables) ne se
* défait pas : on défait d'abord la plus récente, dans l'ordre inverse.
*
* Ne JAMAIS ouvrir contre une base X-One : dans X-One, c'est le déploiement
* (sequelize-cli db:migrate:undo) qui défait une migration.
*/
const fs = require('fs');
const path = require('path');
const db = require('../../../database/models');
const G = require('./sdm-generator.utils');
const error = (message, status = 400) => Object.assign(new Error(message), { status });
/** Les tables et vues qu'une migration engendrée crée, sans le schéma. */
function planRelations(plan) {
if (plan.type === 'entities') return plan.entities.flatMap((c) => [c, `${c}_contenu`, `${c}_membre`]);
if (plan.type === 'link') return [plan.table];
if (plan.type === 'view') return [G.compatViewName(plan.referential)];
return [];
}
/**
* Les migrations engendrées après `fichier` qui citent une de ses tables.
* `lire(fichier)` rend le texte d'un fichier.
*/
function dependents(file, generated, read) {
const me = generated.find((m) => m.file === file);
if (!me) return [];
const reasons = planRelations(me.plan).map((r) => new RegExp(`${G.DATA_SCHEMA}\\.${r}\\b`));
return generated
.filter((m) => m.file > file && reasons.some((re) => re.test(read(m.file))))
.map((m) => m.file);
}
const readFile = (file) => fs.readFileSync(path.join(G.MIGRATIONS_DIR, file), 'utf8');
async function playedFiles(transaction = null) {
const [{ exists }] = await db.sequelize.query(`SELECT to_regclass('public."SequelizeMeta"') IS NOT NULL AS exists`,
{ type: db.Sequelize.QueryTypes.SELECT, transaction });
if (!exists) return new Set();
return new Set((await db.sequelize.query('SELECT name FROM "SequelizeMeta"',
{ type: db.Sequelize.QueryTypes.SELECT, transaction })).map((l) => l.name));
}
/** Retire le fichier de migration et les modèles qui en viennent. */
function removeFiles(file) {
const models = G.generatedModels().filter((m) => m.migration === file);
for (const m of models) fs.rmSync(path.join(G.MODELS_DIR, m.file), { force: true });
fs.rmSync(path.join(G.MIGRATIONS_DIR, file), { force: true });
return models.map((m) => m.model);
}
/** Supprime une migration engendrée pas encore jouée. */
async function remove(file) {
if ((await playedFiles()).has(file)) throw error(`${file} est jouée : annulez-la d'abord`, 409);
const generated = G.generatedMigrations();
const present = generated.some((m) => m.file === file);
if (present) {
const before = dependents(file, generated, readFile);
if (before.length) throw error(`${file} : ${before.join(', ')} en dépend — supprimez-la d'abord`, 409);
}
// Un fichier déjà retiré à la main : on ne nettoie que SDM_MG.
const models = present ? removeFiles(file) : [];
await db.sequelize.query('DELETE FROM "SDM_MG_Migrations" WHERE "SDM_MG_FileName" = :file',
{ replacements: { file } });
return { file, models };
}
/** Joue le `down` d'une migration jouée et défait ce qui vivait dans ses tables. */
async function undo(file) {
const generated = G.generatedMigrations();
const me = generated.find((m) => m.file === file);
if (!me) throw error(`${file} : fichier introuvable dans ${path.basename(G.MIGRATIONS_DIR)}`, 404);
const played = await playedFiles();
const before = dependents(file, generated, readFile).filter((f) => played.has(f));
if (before.length) throw error(`${file} : ${before.join(', ')} en dépend — annulez-la d'abord`, 409);
const filePath = path.join(G.MIGRATIONS_DIR, file);
delete require.cache[require.resolve(filePath)];
// eslint-disable-next-line global-require, import/no-dynamic-require
const migration = require(filePath);
const { sequelize, Sequelize } = db;
const purge = { imports: 0, decisions: 0 };
await sequelize.transaction(async (transaction) => {
const q = (sql, replacements) => sequelize.query(sql, { replacements, transaction });
await migration.down(sequelize.getQueryInterface(), Sequelize, { transaction });
if (played.has(file)) await q('DELETE FROM "SequelizeMeta" WHERE name = :file', { file });
await q(`UPDATE "SDM_MG_Migrations" SET "SDM_MG_State" = 'a_jouer', "SDM_MG_PlayedAt" = NULL
WHERE "SDM_MG_FileName" = :file`, { file });
const { plan } = me;
if (plan.type === 'entities') {
const [, im] = await q('DELETE FROM "SDM_IM_Imports" WHERE "SDM_IM_Referential" = :ref', { ref: plan.referential });
const [, dc] = await q(`DELETE FROM "SDM_DC_Decisions" WHERE "SDM_DC_LinkId" IN (
SELECT "SDM_LN_Id" FROM "SDM_LN_Links"
WHERE "SDM_LN_SourceEntity" IN (:codes) OR "SDM_LN_TargetEntity" IN (:codes))`, { codes: plan.entities });
purge.imports = im?.rowCount ?? 0;
purge.decisions = dc?.rowCount ?? 0;
} else if (plan.type === 'link') {
const [, dc] = await q('DELETE FROM "SDM_DC_Decisions" WHERE "SDM_DC_LinkId" = :id', { id: plan.linkId });
purge.decisions = dc?.rowCount ?? 0;
}
});
return { file, ...purge };
}
/**
* Remet sys_data_model à zéro sur la base locale :
* 1. retire les migrations engendrées (celles qui portent un plan sdm),
* leurs lignes SequelizeMeta et leurs modèles Sequelize ; les
* migrations des tables SDM_* elles-mêmes restent ;
* 2. supprime le schéma xone_ref (trios, tables de lien, vues) ;
* 3. vide SDM_RF, SDM_EN, SDM_AT, SDM_MG, SDM_IM, SDM_LN, SDM_DC ;
* 4. remet le schéma STD de GOVERNANCE à celui de dev-db-init (CONTROLS
* séparé par retour à la ligne).
*/
async function resetAll() {
const { sequelize } = db;
const generated = G.generatedMigrations().map((m) => m.file);
await sequelize.transaction(async (transaction) => {
const q = (sql, replacements) => sequelize.query(sql, { replacements, transaction });
await q(`DROP SCHEMA IF EXISTS ${G.DATA_SCHEMA} CASCADE`);
if (generated.length) await q('DELETE FROM "SequelizeMeta" WHERE name IN (:generated)', { generated });
await q(`TRUNCATE "SDM_DC_Decisions", "SDM_LN_Links", "SDM_MG_Migrations", "SDM_IM_Imports",
"SDM_AT_Attributes", "SDM_EN_Entities", "SDM_RF_Referentials" CASCADE`);
await q(`UPDATE "STD_RF_Referentials"
SET "STD_RF_Schema" = jsonb_set("STD_RF_Schema", '{columns}', (
SELECT jsonb_agg(CASE WHEN c->>'name' = 'CONTROLS'
THEN c || '{"separator":"newline"}'::jsonb ELSE c END ORDER BY n)
FROM jsonb_array_elements("STD_RF_Schema"->'columns') WITH ORDINALITY AS t(c, n)))
WHERE "STD_RF_Code" = 'GOVERNANCE' AND "STD_RF_Schema" ? 'columns'`);
});
for (const file of generated) fs.rmSync(path.join(G.MIGRATIONS_DIR, file), { force: true });
const models = G.generatedModels().map((m) => m.file);
for (const file of models) fs.rmSync(path.join(G.MODELS_DIR, file), { force: true });
return { removedFiles: generated, removedModels: models };
}
module.exports = { planRelations, dependents, remove, undo, resetAll };
/**
* sys-data-model.controller.js — les points d'entrée HTTP du module
* sys_data_model (plan § 19) : déclarations, reprise depuis Standards,
* migrations engendrées, import en deux temps, liens, lecture.
*
* Les règles vivent dans les fichiers sdm-*.utils.js ; ici, on charge,
* on appelle, on écrit, dans une transaction quand il y a plusieurs écritures.
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { logger } = require('../../../observability');
const renderingParams = require('../../../utils/renderingParams');
const R = require('./sdm-registry.utils');
const { runBackfill } = require('./sdm-backfill.utils');
const { SPLIT_RULES, ruleOf } = require('./sdm-routing.utils');
const { analyze, compareVersions } = require('./sdm-import.utils');
const { readOutput, structureColumns, FILES, EXPECTED, WITH_DEPENDENCIES } = require('./sdm-files.utils');
const L = require('./sdm-links.utils');
const { outputFromStd } = require('./sdm-backfill-data.utils');
const read = require('./sdm-read.utils');
const G = require('./sdm-generator.utils');
const S = require('./sdm-storage.utils');
const F = require('./sdm-sheet.utils');
const A = require('./sdm-undo.utils');
const T = require('./sdm-tasks.utils');
const api = require('./sdm-api.utils');
const {
BUILDERS, DEPENDS_ON, dependenciesOf, loadStandard,
} = require('../../../controllers/admin/settings/standards-integrations.utils');
const { parseColumnDeclaration } = require('../../../controllers/admin/settings/settings-standards.utils');
const VERSION_NUM = /^[0-9A-Za-z._-]{1,32}$/;
const user = (req) => req.loggedUser?.XON_US_UID || null;
/** Rend la réponse JSON ; une règle violée répond 400 avec son message. */
const reply = (fn) => async (req, res) => {
try {
const data = await fn(req, res);
// Toute écriture du module peut changer ce que l'API compat rend.
if (req.method !== 'GET') api.clearCache();
res.json({ success: true, data });
} catch (error) {
if (req.method !== 'GET') api.clearCache();
logger.error(`[sdm] ${req.method} ${req.originalUrl} : ${error.message}`);
res.status(error.status || 400).json({ success: false, error: error.message });
}
};
const error = (message, status = 400) => Object.assign(new Error(message), { status });
/**
* TH.12 : avec `?fond=1`, le travail part en arrière-plan et la réponse rend
* le numéro de la tâche (le proxy coupe les requêtes longues) ; sans, la
* requête attend le résultat, comme avant (API, tests).
*/
function inBackground(req, type, work) {
if (req.query?.background !== '1') return work(() => {});
const task = T.start(type, work, {
end: (t) => {
api.clearCache();
if (t.state === 'error') logger.error(`[sdm] tâche ${type} : ${t.error}`);
},
});
return { task };
}
/** GET /ajax/sdm/tasks/:id — l'avancement d'une tâche longue. */
const readTask = reply(async (req) => {
const t = T.read(req.params.id);
if (!t) throw error('tâche inconnue : terminée depuis longtemps, ou serveur redémarré — rechargez la liste des imports', 404);
return t;
});
// ───────────────────────────────────────────────────────────────────────────
// La page
// ───────────────────────────────────────────────────────────────────────────
async function page(req, res) {
res.render(path.join(__dirname, 'sys-data-model.hbs'), { ...(await renderingParams(req)), page: 'sys-data-model' });
}
// ───────────────────────────────────────────────────────────────────────────
// Migrations : ce qui attend d'être joué
// ───────────────────────────────────────────────────────────────────────────
/** Recopie dans SDM_MG les fichiers engendrés qu'elle ne connaît pas. */
const sync = () => S.syncMigrations(G.generatedMigrations());
/** Les entités qu'une migration en attente touche : Map<code, fichier>. */
function pendingEntities(pending) {
const result = new Map();
for (const { SDM_MG_FileName: file, SDM_MG_Plan: plan } of pending) {
const codes = plan.type === 'entities' ? plan.entities : plan.type === 'link' ? [plan.source, plan.target] : [];
for (const code of codes) result.set(code, file);
}
return result;
}
/** Le lien natif a-t-il une table de liaison ? Pas la hiérarchie : parent_id la porte. */
function tableCarrier(link, registry) {
if (link.SDM_LN_Nature !== 'natif' || !link.SDM_LN_SourceAttribute) return false;
const attribute = registry.entities.get(link.SDM_LN_SourceEntity)?.attributes.get(link.SDM_LN_SourceAttribute);
return attribute?.Role !== 'parent';
}
/** L'objet d'une migration est-il en base ? */
function migrationExists(plan, relations) {
if (plan.type === 'entities') {
return plan.entities.every((c) => ['', '_contenu', '_membre'].every((s) => relations.tables.has(`${c}${s}`)));
}
if (plan.type === 'link') return relations.tables.has(plan.table);
if (plan.type === 'view') return relations.views.has(plan.view);
return false;
}
// ───────────────────────────────────────────────────────────────────────────
// Déclarations
// ───────────────────────────────────────────────────────────────────────────
/**
* L'état d'une entité côté base :
* engendree une migration la touche et attend d'être jouée (l'import est bloqué) ;
* creee son trio existe ;
* a_engendrer rien encore.
*/
function entityStates(registry, present, queued, generated = new Map()) {
const states = {};
for (const code of registry.entities.keys()) {
states[code] = queued.has(code) ? 'engendree'
: present.has(code) ? 'creee'
: generated.has(code) ? 'engendree' : 'a_engendrer';
}
return states;
}
const asJson = (entity, state, migration = null) => ({
Code: entity.Code,
Libelle: entity.Libelle,
Referentiel: entity.Referentiel,
ParenteCode: entity.ParenteCode,
Ordre: entity.Ordre,
Actif: entity.Actif,
state,
migration,
attributes: [...entity.attributes.values()].sort((a, b) => a.Ordre - b.Ordre),
});
/** GET /ajax/sdm/entities */
const listEntities = reply(async () => {
await sync();
const [registry, present, referentials, pending, links] = await Promise.all([
S.loadRegistry(), S.presentTrio(), S.readReferentials(), S.pendingMigrations(), S.listLinks(),
]);
const queued = pendingEntities(pending);
const generated = G.generatedEntities();
const states = entityStates(registry, present, queued, generated);
const entities = [...registry.entities.values()]
.sort((a, b) => a.Referentiel.localeCompare(b.Referentiel) || a.Ordre - b.Ordre || a.Code.localeCompare(b.Code));
// Libellés FR/EN et section (TH.7, TH.11) : la fiche SDM du référentiel (E28).
const sheets = await S.readSheets();
return {
actions: await possibleActions({ registry, present, referentials, pending, links, generated }),
entities: entities.map((e) => asJson(e, states[e.Code], queued.get(e.Code) || generated.get(e.Code) || null)),
referentials: [...new Set([
...referentials.map((r) => r.STD_RF_Code), ...sheets.keys(), ...entities.map((e) => e.Referentiel),
])].sort().map((code) => {
const r = referentials.find((x) => x.STD_RF_Code === code);
const f = sheets.get(code);
return {
code,
// Le nom Standards ne sert qu'aux référentiels pas encore repris.
name: { fr: f?.Libelle.fr || r?.STD_RF_Name || code, en: f?.Libelle.en || f?.Libelle.fr || r?.STD_RF_Name || code },
section: f?.Section || '',
};
}),
importable: importable(registry),
types: R.TYPES,
roles: R.ROLES,
syndications: R.SYNDICATIONS,
playMigrations: process.env.SDM_JOUER_MIGRATIONS === '1',
};
});
/**
* Les référentiels proposés à l'import (écart E17) : les découpés, et tout
* référentiel déclaré. Celui dont la règle ne se déduit pas est proposé
* grisé, avec le motif de `controlerImport`.
*/
const importable = (registry) => [...new Set([
...Object.keys(SPLIT_RULES),
...[...registry.entities.values()].map((e) => e.Referentiel),
])].sort();
/**
* Pour chaque geste de l'écran : null s'il est possible, sinon le motif. Le
* contrôle est celui du geste lui-même : l'écran ne propose que ce qui a un
* sens, et dit pourquoi le reste est grisé.
*/
async function possibleActions({ registry, present, referentials, pending, links, generated }) {
const reason = async (fn) => { try { await fn(); return null; } catch (e) { return e.message; } };
const refs = [...new Set([...registry.entities.values()].map((e) => e.Referentiel))].sort();
const byRef = async (codes, fn) => Object.fromEntries(await Promise.all(codes.map(async (r) => [r, await reason(() => fn(r))])));
const toBackfill = {};
for (const e of runBackfill(referentials).registry.entities.values()) {
if (!registry.entities.has(e.Code)) toBackfill[e.Referentiel] = (toBackfill[e.Referentiel] || 0) + 1;
}
const reset = registry.entities.size || present.size || links.length
|| G.generatedMigrations().length || G.generatedModels().length;
return {
toBackfill,
migration: await byRef(refs, (r) => batchToGenerate(registry, r, present, generated)),
view: await (async () => {
const v = await byRef(refs, prepareView);
v['*'] = Object.values(v).some((m) => m === null) ? null : 'aucune vue à engendrer';
return v;
})(),
import: await byRef(importable(registry), (r) => checkImport(r)),
play: pending.length ? null : 'aucune migration en attente',
reset: reset ? null : 'rien à remettre à zéro : aucune entité, migration, table ni lien',
diff: registry.entities.size ? null : 'aucune entité déclarée',
// Avec des cartes publiées dans Standards, le bouton reste actif : il reprend aussi leurs correspondances (T3.2.1).
links: linksToBackfill(registry, links, await S.standardsLinks()).toCreate.length || await S.hasPublishedCards()
? null : 'tous les liens de l\'existant sont déjà repris',
};
}
/** POST /ajax/sdm/entities */
const createEntity = reply(async (req) => {
const { Code, Libelle, Referentiel, ParenteCode, Ordre } = req.body || {};
return S.transaction(async (transaction) => {
const registry = await S.loadRegistry(transaction);
const entity = R.createEntity(registry, {
Code, Libelle, Referentiel, ParenteCode: ParenteCode || null, Ordre: Number(Ordre) || 0,
});
await S.saveEntity(entity, { user: user(req), transaction });
await sheetSection(entity.Referentiel, req, transaction);
return asJson(entity, 'a_engendrer');
});
});
/**
* La section saisie dans la fiche entité est celle du référentiel : elle va
* dans sa fiche SDM_RF (E28), créée au besoin.
*/
async function sheetSection(referential, req, transaction) {
if (!req.body || !('Section' in req.body)) return;
const before = await S.readSheet(referential, transaction);
const sheet = F.inputSheet(referential, { Section: req.body.Section }, before);
if (before && sheet.Section === before.Section) return;
await S.saveSheet(sheet, { user: user(req), transaction });
}
/**
* PUT /ajax/sdm/referentials/:code — la fiche du référentiel (E28) : libellés,
* section, liens vers la source d'extraction, mode opératoire, usage interne.
* Seuls les champs reçus changent.
*/
const updateSheet = reply(async (req) => {
const code = req.params.code;
return S.transaction(async (transaction) => {
const registry = await S.loadRegistry(transaction);
const before = await S.readSheet(code, transaction);
const known = before || [...registry.entities.values()].some((e) => e.Referentiel === code)
|| (await S.readReferentials([code])).length;
if (!known) throw error(`référentiel inconnu : ${code}`, 404);
const sheet = F.inputSheet(code, req.body || {}, before);
return F.screenSheet(await S.saveSheet(sheet, { user: user(req), transaction }));
});
});
/** PUT /ajax/sdm/entities/:code */
const updateEntity = reply(async (req) => {
const changes = {};
for (const key of ['Libelle', 'ParenteCode', 'Ordre', 'Actif', 'Code', 'Referentiel']) {
if (req.body && key in req.body) changes[key] = req.body[key];
}
if ('ParenteCode' in changes) changes.ParenteCode = changes.ParenteCode || null;
return S.transaction(async (transaction) => {
const registry = await S.loadRegistry(transaction);
const before = registry.entities.get(req.params.code);
// La parenté est gravée dans la clé étrangère de `_membre`.
if (before && 'ParenteCode' in changes && changes.ParenteCode !== before.ParenteCode
&& (await S.presentTrio(transaction)).has(before.Code)) {
throw error('la parenté ne change plus une fois les tables créées');
}
const entity = R.editEntity(registry, req.params.code, changes);
await S.saveEntity(entity, { transaction });
await sheetSection(entity.Referentiel, req, transaction);
return asJson(entity);
});
});
/** La description saisie d'un attribut (TH.12) : longueur fixe, libellés, syndication. */
function attributeDescription(a) {
const length = a.LongueurFixe === '' || a.LongueurFixe == null ? null : Number(a.LongueurFixe);
const fr = String(a.Libelle?.fr || '').trim();
const en = String(a.Libelle?.en || '').trim();
return { LongueurFixe: length, Libelle: fr || en ? { fr, en } : null, Syndication: a.Syndication || 'none' };
}
/**
* Complète la description des attributs d'une entité déjà déclarée, sans
* toucher à ce qui a été saisi : seuls les champs vides prennent la valeur
* reprise. Rend vrai si quelque chose a changé.
*/
function completeDescription(entity, backfill) {
let change = false;
for (const a of entity.attributes.values()) {
const r = backfill.attributes.get(a.Nom);
if (!r) continue;
if (a.LongueurFixe == null && r.LongueurFixe != null) { a.LongueurFixe = r.LongueurFixe; change = true; }
if (!a.Libelle && r.Libelle) { a.Libelle = { ...r.Libelle }; change = true; }
if ((a.Syndication || 'none') === 'none' && r.Syndication !== 'none') { a.Syndication = r.Syndication; change = true; }
}
return change;
}
/** PUT /ajax/sdm/entities/:code/attributes — remplace la liste entière. */
const updateAttributes = reply(async (req) => {
const attributes = req.body?.attributes;
if (!Array.isArray(attributes)) throw error('attributs : une liste est attendue');
return S.transaction(async (transaction) => {
const registry = await S.loadRegistry(transaction);
const entity = registry.entities.get(req.params.code);
if (!entity) throw error(`entité inconnue : ${req.params.code}`, 404);
entity.attributes = new Map();
attributes.forEach((a, i) => R.addAttribute(registry, entity.Code, {
Nom: String(a.Nom || '').trim(),
Type: a.Type,
Role: a.Role,
Multivalue: Boolean(a.Multivalue),
Separateur: a.Multivalue ? (a.Separateur || null) : null,
DansEmpreinte: a.DansEmpreinte !== false,
Ordre: i,
...attributeDescription(a),
}));
await S.saveEntity(entity, { transaction });
return asJson(entity);
});
});
/**
* POST /ajax/sdm/backfill — reprend les déclarations depuis STD_RF_Referentials.
* N'ajoute que les entités absentes : une déclaration retouchée à la main
* n'est jamais écrasée. Sur les entités présentes, seule la description
* vide des attributs est complétée (TH.12).
*/
const backfill = reply(async (req) => {
const codes = Array.isArray(req.body?.referentials) && req.body.referentials.length ? req.body.referentials : null;
const referentials = await S.readReferentials(codes);
const { registry: backfilled, findings } = runBackfill(referentials);
return S.transaction(async (transaction) => {
const registry = await S.loadRegistry(transaction);
const added = [];
const ignored = [];
const completed = [];
for (const entity of backfilled.entities.values()) {
const existing = registry.entities.get(entity.Code);
if (existing) {
if (completeDescription(existing, entity)) {
await S.saveEntity(existing, { transaction });
completed.push(entity.Code);
} else ignored.push(entity.Code);
continue;
}
await S.saveEntity(entity, { user: user(req), transaction });
added.push(entity.Code);
}
// La fiche Intégration de Standards devient la fiche SDM du référentiel (E28) :
// seuls les champs vides sont remplis, ce qui a été saisi ici reste.
const sheets = [];
for (const ref of referentials) {
const backfilled = F.backfillSheet(ref);
const before = await S.readSheet(backfilled.Code, transaction);
const sheet = before ? F.completeSheet(before, backfilled) : backfilled;
if (!sheet) continue;
await S.saveSheet(sheet, { user: user(req), transaction });
sheets.push(backfilled.Code);
}
return { added, ignored, completed, sheets, findings };
});
});
// ───────────────────────────────────────────────────────────────────────────
// Génération (lot 1.2)
// ───────────────────────────────────────────────────────────────────────────
/**
* Écrit un fichier engendré, puis ses modèles Sequelize s'il en a
* (`modelesDe(fichier)`), et l'inscrit dans SDM_MG. Si les modèles ne
* peuvent pas être écrits, la migration est retirée : tout ou rien.
*/
async function generate(suffix, text, by, modelsOf = null) {
const file = G.writeFile(suffix, text);
let models = [];
try {
if (modelsOf) models = G.writeModels(modelsOf(file));
} catch (e) {
fs.unlinkSync(path.join(G.MIGRATIONS_DIR, file));
throw error(e.message);
}
await S.recordMigration({ file, plan: G.planFromText(text), by });
logger.info(`[sdm] migration engendrée : ${file}${models.length ? ` (modèles : ${models.join(', ')})` : ''}`);
return { file, models };
}
/** Le lot d'entités d'un référentiel qui n'a pas encore de migration. */
async function generateEntities(referential, by) {
const [registry, present] = await Promise.all([S.loadRegistry(), S.presentTrio()]);
const batch = batchToGenerate(registry, referential, present, G.generatedEntities());
const { file, models } = await generate(referential, G.migrationFile(batch, { referential }), by,
(migration) => batch.flatMap((e) => G.trioModels(e, migration)));
return { file, models, entities: batch.map((e) => e.Code) };
}
/** Les entités d'un référentiel sans migration ; lève l'obstacle s'il y en a un. */
function batchToGenerate(registry, referential, present, generated) {
const batch = R.referentialEntities(registry, referential)
.filter((e) => e.Actif && !present.has(e.Code) && !generated.has(e.Code));
if (!batch.length) throw error('rien à engendrer : toutes les entités ont déjà leur migration');
const inBatch = new Set(batch.map((e) => e.Code));
for (const e of batch) {
const p = e.ParenteCode;
if (p && p !== e.Code && !inBatch.has(p) && !present.has(p) && !generated.has(p)) {
throw error(`${e.Code} : l'entité parente ${p} n'a pas de migration`);
}
}
return batch;
}
/** La table de liaison d'un lien natif. */
async function generateLink(linkId, by) {
const [registry, links, relations] = await Promise.all([S.loadRegistry(), S.listLinks(), S.presentRelations()]);
const link = links.find((l) => l.SDM_LN_Id === linkId);
if (!link) throw error('lien introuvable', 404);
if (!tableCarrier(link, registry)) throw error('ce lien n\'a pas de table : seul un lien natif porté par un attribut (hors parent) en a une');
const table = G.linkTableName(link);
if (relations.tables.has(table)) throw error(`la table ${table} existe déjà`);
if (G.generatedMigrations().some(({ plan }) => plan.type === 'link' && plan.table === table)) {
throw error(`la migration de ${table} est déjà engendrée`);
}
const generated = G.generatedEntities();
for (const code of [link.SDM_LN_SourceEntity, link.SDM_LN_TargetEntity]) {
if (!relations.tables.has(code) && !generated.has(code)) throw error(`${code} n'a pas encore de migration`);
}
const { file, models } = await generate(`lien-${table}`, G.linkMigrationFile(link), by,
(migration) => G.linkModels(link, migration));
return { file, models, table };
}
/** La vue de compatibilité d'un référentiel (lot 2.3). */
async function generateView(referential, by) {
const { file } = await generate(`vue-${referential}`, await prepareView(referential), by);
return { file, view: G.compatViewName(referential) };
}
/** Le texte de la nouvelle vue d'un référentiel ; lève l'obstacle s'il y en a un. */
async function prepareView(referential) {
const [registry, [ref], present] = await Promise.all([
S.loadRegistry(), S.readReferentials([referential]), S.presentTrio(),
]);
if (!ref?.STD_RF_Schema?.columns?.length) throw error(`${referential} : schéma Standards introuvable`);
const generated = G.generatedEntities();
const entities = R.referentialEntities(registry, referential)
.filter((e) => e.Actif && (present.has(e.Code) || generated.has(e.Code)));
if (!entities.length) throw error(`${referential} : aucune entité n'a encore de migration`);
const columns = ref.STD_RF_Schema.columns.map((c) => ({ ...c, declaration: parseColumnDeclaration(c) }));
const text = G.viewMigrationFile({ referential, entities, columns, rule: SPLIT_RULES[referential] || {} });
// Une nouvelle vue seulement si elle change : une entité ou une colonne de
// plus. Le texte engendré ne porte pas de date, la comparaison suffit.
const views = G.generatedMigrations()
.filter(({ plan }) => plan.type === 'view' && plan.referential === referential);
const pending = new Set((await S.pendingMigrations()).map((m) => m.SDM_MG_FileName));
const queued = views.find((v) => pending.has(v.file));
if (queued) throw error(`la vue de ${referential} attend déjà d'être jouée : ${queued.file}`, 409);
const last = views[views.length - 1];
if (last && fs.readFileSync(path.join(G.MIGRATIONS_DIR, last.file), 'utf8') === text) {
throw error(`la vue de ${referential} est déjà à jour (${last.file}) : rien n'a changé`, 409);
}
return text;
}
/**
* Les vues de tous les référentiels déclarés (TH.8) : une migration par vue ;
* celles qui ne peuvent pas l'être sont rendues avec leur motif.
*/
async function generateAllViews(by) {
const registry = await S.loadRegistry();
const refs = [...new Set([...registry.entities.values()].map((e) => e.Referentiel))].sort();
const generated = [];
const ignored = [];
for (const referential of refs) {
try {
generated.push({ referential, ...(await generateView(referential, by)) });
} catch (e) {
ignored.push({ referential, reason: e.message });
}
}
return { generated, ignored };
}
/**
* POST /ajax/sdm/migrations — engendre un fichier :
* { referentiel } le trio des entités sans migration ;
* { lienId } la table de liaison d'un lien natif ;
* { vue: referentiel } la vue de compatibilité ;
* { vue: '*' } les vues de tous les référentiels.
*/
const generateMigration = reply(async (req) => {
const { referential, linkId, view } = req.body || {};
if (linkId) return generateLink(linkId, user(req));
if (view === '*') return generateAllViews(user(req));
if (view) return generateView(view, user(req));
if (!referential) throw error('référentiel manquant');
return generateEntities(referential, user(req));
});
/** GET /ajax/sdm/migrations — les fichiers engendrés, en attente d'abord. */
const listMigrations = reply(async () => {
await sync();
const [rows, relations, played] = await Promise.all([S.listMigrations(), S.presentRelations(), S.playedFiles()]);
const models = new Map();
for (const m of G.generatedModels()) models.set(m.migration, [...(models.get(m.migration) || []), m.model]);
const generated = G.generatedMigrations();
const texts = new Map();
const read = (f) => {
if (!texts.has(f)) texts.set(f, fs.readFileSync(path.join(G.MIGRATIONS_DIR, f), 'utf8'));
return texts.get(f);
};
/** Pourquoi on ne peut pas la défaire (supprimer ou annuler), null sinon. */
const obstacle = (m) => {
const queued = m.SDM_MG_State === 'a_jouer';
const before = A.dependents(m.SDM_MG_FileName, generated, read).filter((f) => queued || played.has(f));
return before.length ? `${before.join(', ')} en dépend : ${queued ? 'supprimez' : 'annulez'}-la d'abord` : null;
};
return rows
.map((m) => ({
undo: obstacle(m),
id: m.SDM_MG_Id,
file: m.SDM_MG_FileName,
plan: m.SDM_MG_Plan,
state: m.SDM_MG_State,
generatedAt: m.SDM_MG_GeneratedAt,
generatedBy: m.SDM_MG_GeneratedBy,
playedAt: m.SDM_MG_PlayedAt,
present: migrationExists(m.SDM_MG_Plan, relations),
models: models.get(m.SDM_MG_FileName) || [],
}))
.sort((a, b) => (a.state === b.state ? b.file.localeCompare(a.file) : a.state === 'a_jouer' ? -1 : 1));
});
/**
* PUT /ajax/sdm/migrations/:id/played — le déploiement a joué le fichier.
* Vérifié en base : on ne marque pas jouée une migration dont les objets
* n'existent pas.
*/
const markPlayed = reply(async (req) => {
const m = await S.readMigration(req.params.id);
if (!m) throw error('migration introuvable', 404);
if (m.SDM_MG_State === 'jouee') return { id: m.SDM_MG_Id, state: 'jouee' };
const [relations, played] = await Promise.all([S.presentRelations(), S.playedFiles()]);
if (!played.has(m.SDM_MG_FileName) && !migrationExists(m.SDM_MG_Plan, relations)) {
throw error(`${m.SDM_MG_FileName} : ses objets ne sont pas en base — jouez-la d'abord`, 409);
}
await S.markMigrationPlayed(m.SDM_MG_Id);
return { id: m.SDM_MG_Id, state: 'jouee' };
});
/**
* POST /ajax/sdm/migrations/play — DEV : joue les migrations en attente.
* Ouvert seulement si SDM_JOUER_MIGRATIONS=1 ; dans X-One, c'est le
* déploiement qui les joue.
*/
const playMigrations = reply(async () => {
if (process.env.SDM_JOUER_MIGRATIONS !== '1') throw error('jouer les migrations depuis l\'écran est fermé', 403);
// eslint-disable-next-line global-require
const { migrate } = require('../../../../scripts/sdm-migrate');
const played = await migrate();
await sync();
const byFile = new Map((await S.pendingMigrations()).map((m) => [m.SDM_MG_FileName, m.SDM_MG_Id]));
for (const file of played) if (byFile.has(file)) await S.markMigrationPlayed(byFile.get(file));
return { played };
});
/** Défaire depuis l'écran : DEV seulement, comme jouer. */
function devOnly() {
if (process.env.SDM_JOUER_MIGRATIONS !== '1') throw error('défaire depuis l\'écran est fermé (SDM_JOUER_MIGRATIONS=1 en dev)', 403);
}
async function migrationOf(req) {
const m = await S.readMigration(req.params.id);
if (!m) throw error('migration introuvable', 404);
return m;
}
/**
* DELETE /ajax/sdm/migrations/:id — DEV : retire une migration pas encore
* jouée, ses modèles Sequelize et sa ligne SDM_MG.
*/
const removeMigration = reply(async (req) => {
devOnly();
const m = await migrationOf(req);
if (m.SDM_MG_State === 'jouee') throw error(`${m.SDM_MG_FileName} est jouée : annulez-la d'abord`, 409);
const r = await A.remove(m.SDM_MG_FileName);
logger.info(`[sdm] migration supprimée : ${r.file}${r.models.length ? ` (modèles : ${r.models.join(', ')})` : ''}`);
return r;
});
/**
* POST /ajax/sdm/migrations/:id/undo — DEV : joue le down d'une migration
* jouée ; elle repasse en attente. Les données de ses tables sont perdues.
*/
const undoMigration = reply(async (req) => {
devOnly();
const m = await migrationOf(req);
if (m.SDM_MG_State !== 'jouee' && !(await S.playedFiles()).has(m.SDM_MG_FileName)) {
throw error(`${m.SDM_MG_FileName} n'est pas jouée : rien à annuler`, 409);
}
const r = await A.undo(m.SDM_MG_FileName);
logger.info(`[sdm] migration annulée : ${r.file} (imports retirés : ${r.imports}, décisions : ${r.decisions})`);
return r;
});
/** POST /ajax/sdm/reset — DEV : remet sys_data_model à zéro. */
const resetAll = reply(async () => {
devOnly();
const r = await A.resetAll();
logger.info(`[sdm] remise à zéro : ${r.removedFiles.length} migration(s), ${r.removedModels.length} modèle(s) retirés`);
return r;
});
/**
* GET /ajax/sdm/schema/diff — les écarts entre les déclarations et la base,
* chacun avec le geste qui le résout.
*/
const schemaDiff = reply(async () => {
await sync();
const [registry, relations, links, pending, withParentRow] = await Promise.all([
S.loadRegistry(), S.presentRelations(), S.listLinks(), S.pendingMigrations(),
S.select(`
SELECT c.relname AS name FROM pg_catalog.pg_attribute a
JOIN pg_catalog.pg_class c ON c.oid = a.attrelid
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = :schema AND a.attname = 'parent_id' AND NOT a.attisdropped`, { schema: G.DATA_SCHEMA }),
]);
const parentPresent = new Set(withParentRow.map((l) => l.name));
const queued = pendingEntities(pending);
const generated = G.generatedEntities();
const gaps = [];
for (const e of registry.entities.values()) {
if (!e.Actif) continue;
const trio = ['', '_contenu', '_membre'].every((s) => relations.tables.has(`${e.Code}${s}`));
if (!trio) {
const file = queued.get(e.Code) || generated.get(e.Code);
gaps.push({
object: e.Code, referential: e.Referentiel, finding: 'tables absentes',
action: file ? `jouer la migration ${file}` : `engendrer la migration du référentiel ${e.Referentiel}`,
});
continue;
}
const hasParent = parentPresent.has(`${e.Code}_membre`);
if (e.ParenteCode && !hasParent) {
gaps.push({ object: e.Code, referential: e.Referentiel, finding: 'parent déclaré, colonne parent_id absente', action: 'parenté déclarée après création : migration à écrire à la main' });
}
if (!e.ParenteCode && hasParent) {
gaps.push({ object: e.Code, referential: e.Referentiel, finding: 'colonne parent_id présente, parent non déclaré', action: 'redéclarer le parent de l\'entité' });
}
}
for (const link of links) {
if (!link.SDM_LN_Active || !tableCarrier(link, registry)) continue;
const table = G.linkTableName(link);
if (relations.tables.has(table)) continue;
const file = G.generatedMigrations().find(({ plan }) => plan.type === 'link' && plan.table === table)?.file;
gaps.push({
object: table, referential: registry.entities.get(link.SDM_LN_SourceEntity)?.Referentiel || null,
finding: 'table de lien absente', linkId: link.SDM_LN_Id,
action: file ? `jouer la migration ${file}` : 'engendrer la migration du lien',
});
}
const declared = new Set(registry.entities.keys());
const linkTables = new Set(links.filter((l) => tableCarrier(l, registry)).map((l) => G.linkTableName(l)));
for (const name of relations.tables) {
const base = name.replace(/_(contenu|membre)$/, '');
if (declared.has(base) || linkTables.has(name)) continue;
gaps.push({ object: name, referential: null, finding: 'table sans déclaration', action: 'déclarer l\'entité, ou retirer la table à la main' });
}
return gaps;
});
// ───────────────────────────────────────────────────────────────────────────
// Import en deux temps (lot 2.2), liens à l'import (lots 3.1, 3.2)
// ───────────────────────────────────────────────────────────────────────────
/** Les fichiers en buffers : { frBuffer, enBuffer, sourceBuffer, … } → liste, dans l'ordre de FICHIERS. */
const bufferList = (buffers) => FILES.map(([key]) => buffers[`${key}Buffer`]).filter(Boolean);
/**
* La sortie des fichiers d'un import. Une extraction source dont le
* traitement dépend d'autres référentiels les reçoit dans leur version
* active, lue par Standards (`loadStandard`), comme Standards le fait.
*/
async function outputOf(referential, rule, buffers) {
const builder = BUILDERS[referential] || null;
let deps = {};
if (buffers.sourceBuffer && builder && WITH_DEPENDENCIES.includes(builder.kind)) {
for (const dep of dependenciesOf(referential)) {
const std = await loadStandard(dep);
if (!std.fr.size || !std.en.size) throw error(`aucune version active de ${dep} : traitement impossible`);
deps = { ...deps, [dep]: std };
}
}
const output = readOutput(rule, builder, { ...buffers, deps });
if (!output.ok) throw error(output.message || 'fichiers illisibles');
return output;
}
/**
* Tout ce que l'analyse demande, hors fichiers : la règle, les entités (trios
* créés, aucune migration en attente), leurs attributs, l'état lu en base, et
* les liens natifs à résoudre.
*/
async function context(referential, transaction = null) {
const ctx = await checkImport(referential, transaction);
const state = await S.loadState(ctx.entities, transaction);
return { ...ctx, state };
}
/** Ce qu'un import exige avant de lire les fichiers ; lève l'obstacle s'il y en a un. */
async function checkImport(referential, transaction = null) {
const registry = await S.loadRegistry(transaction);
let rule;
try {
rule = ruleOf(referential, registry);
} catch (e) {
throw error(e.message);
}
const entities = R.referentialEntities(registry, referential);
const expected = rule.values ? [...new Set(Object.values(rule.values))] : [rule.entity];
const missing = expected.filter((c) => !registry.entities.has(c));
if (missing.length) throw error(`entités non déclarées : ${missing.join(', ')} — lancez la reprise`);
const queued = pendingEntities(await S.pendingMigrations(transaction));
const blocked = entities.filter((e) => queued.has(e.Code));
if (blocked.length) {
throw error(`migration en attente pour ${blocked.map((e) => `${e.Code} (${queued.get(e.Code)})`).join(', ')} — jouez-la avant d'importer`);
}
const present = await S.presentTrio(transaction);
const withoutTables = expected.filter((c) => !present.has(c));
if (withoutTables.length) throw error(`tables absentes pour : ${withoutTables.join(', ')} — engendrez et jouez la migration`);
const codes = new Set(entities.map((e) => e.Code));
const links = (await S.listLinks(transaction)).filter((l) => l.SDM_LN_Active);
const native = links.filter((l) => codes.has(l.SDM_LN_SourceEntity) && tableCarrier(l, registry));
const { tables } = await S.presentRelations(transaction);
const withoutTable = native.filter((l) => !tables.has(G.linkTableName(l)));
if (withoutTable.length) {
throw error(`lien natif sans table : ${withoutTable.map((l) => G.linkTableName(l)).join(', ')} — engendrez et jouez sa migration`);
}
return {
rule, registry, entities, codes, native,
declared: links.filter((l) => l.SDM_LN_Nature === 'declare' && (codes.has(l.SDM_LN_SourceEntity) || codes.has(l.SDM_LN_TargetEntity))),
attributes: R.attributesByEntity(registry, referential),
};
}
/** Le contexte, plus la sortie des fichiers déposés. */
async function prepare(referential, buffers, transaction = null, progress = () => {}) {
progress(0.1, 'lecture de la déclaration et de la base');
const ctx = await context(referential, transaction);
progress(0.25, 'lecture des fichiers');
// Sans traitement Standards, seul le format plat est lu.
const output = await outputOf(referential, ctx.rule, buffers);
return { ...ctx, output };
}
/** Une description courte d'un lien : source.attribut → cible. */
const describeLink = (l) => `${l.SDM_LN_SourceEntity}${l.SDM_LN_SourceAttribute ? `.${l.SDM_LN_SourceAttribute}` : ''} → ${l.SDM_LN_TargetEntity}`;
/** Les codes des extrémités de décisions : [{ ...d, sourceCode, cibleCode }]. */
async function withCodes(decisions, linksById, transaction = null) {
const byEntity = new Map();
const added = (entity, id) => { if (!byEntity.has(entity)) byEntity.set(entity, new Set()); byEntity.get(entity).add(id); };
for (const d of decisions) {
const link = linksById.get(d.linkId);
if (!link) continue;
added(link.SDM_LN_SourceEntity, d.sourceId);
added(link.SDM_LN_TargetEntity, d.targetId);
}
const codes = new Map();
for (const [entity, ids] of byEntity) codes.set(entity, await S.codesById(entity, [...ids], transaction));
return decisions.map((d) => {
const link = linksById.get(d.linkId);
return {
...d,
link: link ? describeLink(link) : d.linkId,
sourceCode: link ? codes.get(link.SDM_LN_SourceEntity)?.get(d.sourceId) || null : null,
targetCode: link ? codes.get(link.SDM_LN_TargetEntity)?.get(d.targetId) || null : null,
};
});
}
/**
* Les liens d'une analyse : paires natives (et liens morts), rejeu des liens
* non natifs établis. N'écrit rien.
*/
async function computeLinks(ctx, analysis, versionNum, transaction = null) {
const ids = L.identitiesAfter(ctx.state, analysis.plan);
for (const link of ctx.native) {
const target = link.SDM_LN_TargetEntity;
if (!ids.has(target)) ids.set(target, await S.identities(target, transaction));
}
const native = L.nativeLinks(analysis.rows, ctx.native, { identities: ids, attributes: ctx.attributes });
const linksById = new Map(ctx.declared.map((l) => [l.SDM_LN_Id, l]));
const decisions = await S.listDecisions({ linkIds: [...linksById.keys()] }, transaction);
const established = L.establishedBefore(decisions, versionNum);
const afterHere = L.membershipsAfter(analysis.rows, ids);
const elsewhere = new Map();
for (const d of established) {
const link = linksById.get(d.SDM_DC_LinkId);
for (const entity of [link.SDM_LN_SourceEntity, link.SDM_LN_TargetEntity]) {
if (ctx.codes.has(entity) || elsewhere.has(entity)) continue;
const last = read.lastVersion(await S.versionsOf(entity, transaction));
const withParentRow = Boolean(ctx.registry.entities.get(entity)?.ParenteCode);
elsewhere.set(entity, last ? await S.memberships(entity, last, withParentRow, transaction) : new Map());
}
}
const replay = L.replay(established, linksById, {
versionNum,
after: (entity, id) => (ctx.codes.has(entity) ? afterHere.get(entity) : elsewhere.get(entity))?.get(id) || null,
before: (entity, id, version) => {
if (!ctx.codes.has(entity)) return undefined;
const m = ctx.state.members.get(entity)?.get(version)?.get(id);
return m ? { parentId: m.parentId } : undefined;
},
});
const readable = await withCodes(replay.filter((d) => d.state !== 'reconduit'), linksById, transaction);
const short = (d) => ({ link: d.link, source: d.sourceCode, target: d.targetCode, reason: d.reason });
return {
native,
replay,
report: {
nativeLinks: native.count,
deadLinks: native.dead.slice(0, 500),
deadLinkCount: native.dead.length,
renewedLinks: replay.filter((d) => d.state === 'reconduit').length,
linksToConfirm: readable.filter((d) => d.state === 'propose').map(short),
staleLinks: readable.filter((d) => d.state === 'caduc').map(short),
},
};
}
/** Écrit une version analysée : trios, tables de liaison, décisions rejouées. */
async function writeVersion(ctx, analysis, links, versionNum, transaction, progress = () => {}) {
if (links.native.dead.length) {
const examples = links.native.dead.slice(0, 5).map((m) => `${m.source} → ${m.code}`).join(', ');
throw error(`${links.native.dead.length} lien(s) natif(s) vers un code inexistant (${examples}…) : import refusé`);
}
await S.writePlan(analysis.plan, ctx.entities, transaction, T.slice(progress, 0, 0.9));
progress(0.9, 'écriture des liens');
await S.writeNativeLinks(versionNum, ctx.native.map((l) => G.linkTableName(l)), links.native.pairs, transaction);
await S.writeDecisions(links.replay, {}, transaction);
}
/** Les compteurs qui doivent être les mêmes à l'analyse et à l'intégration. */
const COUNTERS = ['createdIdentities', 'createdContents', 'reusedContents',
'newMembers', 'unchangedMembers', 'rewrittenMembers'];
const COUNTER_NAMES = {
createdIdentities: 'nouveaux codes',
createdContents: 'textes nouveaux ou modifiés',
reusedContents: 'textes déjà connus',
newMembers: 'codes ajoutés à la version',
unchangedMembers: 'codes inchangés',
rewrittenMembers: 'codes réécrits',
removedMembers: 'codes retirés de la version',
};
const counters = (report) => ({
...Object.fromEntries(COUNTERS.map((c) => [c, report[c] ?? 0])),
removedMembers: (report.removedMembers || []).length,
});
/** Ce qui a changé entre deux rapports, compteur par compteur ; [] si rien. */
function counterGaps(before, after) {
const [a, b] = [counters(before), counters(after)];
return Object.keys(a).filter((c) => a[c] !== b[c]).map((c) => `${COUNTER_NAMES[c]} : ${a[c]} → ${b[c]}`);
}
/** Les alertes du traitement Standards (TH.12) : questions FR/EN désalignées, remarques. */
const alertsOf = (output) => ({
misalignedRows: (output.misaligned || []).slice(0, 500),
misalignedCols: output.misalignedColumns || null,
remarks: output.notices || [],
});
/**
* Les fichiers joints à la requête : `recus` [[cle, fichier multer]] et
* `buffers` { frBuffer, enBuffer } ou { sourceBuffer, … }. Rien n'est gardé
* entre l'analyse et l'intégration : l'écran renvoie les mêmes fichiers, et
* l'empreinte de l'analyse prouve que ce sont bien eux (plan § 6).
*/
function receivedFiles(req) {
const received = FILES.map(([key, field]) => [key, req.files?.[field]?.[0]]).filter(([, f]) => f);
return { received, buffers: Object.fromEntries(received.map(([key, f]) => [`${key}Buffer`, f.buffer])) };
}
/** Les fichiers renvoyés pour intégrer ou relancer ; refus clair s'ils manquent. */
function resentFiles(req) {
const { buffers } = receivedFiles(req);
if (!(buffers.frBuffer && buffers.asBuffer) && !buffers.sourceBuffer) {
throw error('fichiers absents : redéposez les fichiers analysés (ils ne sont pas gardés sur le serveur)');
}
return buffers;
}
/** POST /ajax/sdm/imports/analyze — phase 1 : n'écrit que la ligne d'import. */
const analyzeImport = reply(async (req) => {
const referential = req.body?.referential;
const versionNum = String(req.body?.versionNum || '').trim();
// Deux classeurs FR et EN, ou une extraction source (TH.12) et ses compléments.
const { received, buffers } = receivedFiles(req);
const byKey = Object.fromEntries(received);
if (!referential) throw error('référentiel manquant');
if (!VERSION_NUM.test(versionNum)) throw error('numéro de version invalide');
if (!(byKey.fr && byKey.en) && !byKey.source) {
const kind = BUILDERS[referential]?.kind;
throw error(`attendus : les fichiers FR et EN${kind && kind !== 'bilingual' ? `, ou ${EXPECTED[kind]}` : ''}`);
}
await sync();
// TH.12 : un référentiel qui dépend d'un autre ne s'analyse pas sans lui.
const missing = await missingDependencies(referential);
if (missing.length) throw error(`dépend de ${missing.join(', ')}, sans aucune version intégrée : importez-le d'abord`);
const by = user(req);
return inBackground(req, 'analyse', async (progress) => {
const files = bufferList(buffers);
const p = await prepare(referential, buffers, null, progress);
progress(0.4, 'comparaison avec la base');
const analysis = analyze(p.state, {
versionNum, rule: p.rule, attributes: p.attributes, output: p.output, files,
});
progress(0.65, 'calcul des liens');
const links = await computeLinks(p, analysis, versionNum);
const report = { ...analysis.report, ...links.report };
progress(0.85, 'enregistrement du rapport');
const id = crypto.randomUUID();
await S.createImport({
id,
referential,
versionNum,
file: received.map(([, f]) => f.originalname).join(' | ').slice(0, 256),
empreinte: analysis.signature,
report: { ...report, stats: p.output.stats, alerts: alertsOf(p.output) },
by,
});
return { id, versionNum, report: { ...report, alerts: alertsOf(p.output) }, stats: p.output.stats };
});
});
/** GET /ajax/sdm/imports */
const listImports = reply(async () => S.listImports());
/** GET /ajax/sdm/imports/:id */
const readReport = reply(async (req) => {
const row = await S.readImport(req.params.id);
if (!row) throw error('import introuvable', 404);
return row;
});
/**
* POST /ajax/sdm/imports/:id/integrate — phase 2, le geste humain qui écrit.
*
* Tout se refait dans une transaction, sous verrou du référentiel : les
* fichiers, renvoyés par l'écran, sont re-signés (autres que ceux analysés :
* refus), l'analyse est refaite sur la base du moment ; si ses compteurs
* diffèrent de ceux qu'on a lus, on refuse.
*/
const integrateImport = reply(async (req) => {
const [id, by] = [req.params.id, user(req)];
// TH.12 : les refus simples répondent tout de suite, avant de lancer la tâche.
const check = (row) => {
if (!row) throw error('import introuvable', 404);
if (row.SDM_IM_State === 'perime') throw error('analyse périmée : une autre version a été intégrée depuis — relancez l\'analyse', 409);
if (row.SDM_IM_State !== 'analyse') throw error(`import déjà ${row.SDM_IM_State}`);
};
check(await S.readImport(id));
const buffers = resentFiles(req);
return inBackground(req, 'integration', (progress) => S.transaction(async (transaction) => {
progress(0.02, 'attente du verrou du référentiel');
const row = await S.readImport(id, transaction, true);
check(row);
await S.lock(row.SDM_IM_Referential, transaction);
progress(0.05, 'lecture des fichiers');
const versionNum = row.SDM_IM_VersionNum;
const p = await prepare(row.SDM_IM_Referential, buffers, transaction, T.slice(progress, 0, 0.5));
progress(0.2, 'nouvelle analyse sur la base du moment');
const analysis = analyze(p.state, {
versionNum, rule: p.rule, attributes: p.attributes, output: p.output, files: bufferList(buffers),
});
if (analysis.signature !== row.SDM_IM_FileFingerprint) throw error('ces fichiers ne sont pas ceux de l\'analyse : redéposez les mêmes, ou relancez l\'analyse', 409);
const gaps = counterGaps(row.SDM_IM_Report, analysis.report);
if (gaps.length) {
throw error(`la base a changé depuis l'analyse (déclarations ou autre import) — ${gaps.join(' ; ')}. Relancez l'analyse.`, 409);
}
progress(0.3, 'calcul des liens');
const links = await computeLinks(p, analysis, versionNum, transaction);
await writeVersion(p, analysis, links, versionNum, transaction, T.slice(progress, 0.35, 0.95));
progress(0.96, 'validation');
await S.markIntegrated(row.SDM_IM_Id, { by, report: analysis.report }, transaction);
await S.expireAnalyses(row.SDM_IM_Referential, transaction);
logger.info(`[sdm] import intégré : ${row.SDM_IM_Referential} ${versionNum}`);
return { id: row.SDM_IM_Id, report: { ...analysis.report, ...links.report } };
}));
});
/** POST /ajax/sdm/imports/:id/refuse */
const refuseImport = reply(async (req) => {
const n = await S.markRefused(req.params.id, user(req));
if (!n) throw error('seul un import en attente se refuse');
return { id: req.params.id };
});
/**
* POST /ajax/sdm/imports/:id/rerun — refait l'analyse d'un import en
* attente ou périmé, avec les fichiers que l'écran renvoie.
*/
const rerunImport = reply(async (req) => {
const row = await S.readImport(req.params.id);
if (!row) throw error('import introuvable', 404);
if (!['analyse', 'perime'].includes(row.SDM_IM_State)) throw error(`import déjà ${row.SDM_IM_State} : rien à relancer`);
const buffers = resentFiles(req);
await sync();
const by = user(req);
return inBackground(req, 'relance', async (progress) => {
progress(0.05, 'lecture des fichiers');
const versionNum = row.SDM_IM_VersionNum;
const p = await prepare(row.SDM_IM_Referential, buffers, null, progress);
progress(0.4, 'comparaison avec la base');
const analysis = analyze(p.state, {
versionNum, rule: p.rule, attributes: p.attributes, output: p.output, files: bufferList(buffers),
});
progress(0.7, 'calcul des liens');
const links = await computeLinks(p, analysis, versionNum);
const report = { ...analysis.report, ...links.report, stats: p.output.stats, alerts: alertsOf(p.output) };
progress(0.9, 'enregistrement du rapport');
await S.rerunAnalysis(row.SDM_IM_Id, { empreinte: analysis.signature, report, by });
return { id: row.SDM_IM_Id, versionNum, report, stats: p.output.stats, gaps: counterGaps(row.SDM_IM_Report, report) };
});
});
/** La version suivante proposée : le dernier nombre de `v` augmenté de 1. */
function nextVersion(v) {
if (!v) return '1';
const m = /^(.*?)(\d+)([^\d]*)$/.exec(v);
return m ? `${m[1]}${Number(m[2]) + 1}${m[3]}` : `${v}.1`;
}
/** Les dépendances (DEPENDS_ON) d'un référentiel : [{ code, presente }]. */
async function dependencyState(referential, registry, present) {
const dep = DEPENDS_ON[referential];
const result = [];
for (const code of dep ? [].concat(dep) : []) {
const ents = R.referentialEntities(registry, code).filter((e) => present.has(e.Code));
const nb = ents.length ? (await S.presentVersions(ents)).length : 0;
result.push({ code, present: nb > 0 });
}
return result;
}
async function missingDependencies(referential) {
if (!DEPENDS_ON[referential]) return [];
const [registry, present] = await Promise.all([S.loadRegistry(), S.presentTrio()]);
return (await dependencyState(referential, registry, present)).filter((d) => !d.present).map((d) => d.code);
}
/**
* GET /ajax/sdm/imports/help/:referential — ce qu'il faut savoir avant de
* déposer (TH.12) : versions présentes, version proposée, dépendances
* absentes, fiche du référentiel (Standards), fichiers attendus.
*/
const importHelp = reply(async (req) => {
const referential = req.params.referential;
const registry = await S.loadRegistry();
const present = await S.presentTrio();
const entities = R.referentialEntities(registry, referential).filter((e) => present.has(e.Code));
const byVersion = new Map();
for (const v of await S.presentVersions(entities)) byVersion.set(v.version_num, (byVersion.get(v.version_num) || 0) + v.nb);
const versionsEx = [...byVersion].map(([version, nb]) => ({ version, nb }))
.sort((a, b) => compareVersions(a.version, b.version));
const active = versionsEx.length ? versionsEx[versionsEx.length - 1].version : '';
const dependencies = await dependencyState(referential, registry, present);
const sheet = await S.readSheet(referential);
let rule = null;
try { rule = ruleOf(referential, registry); } catch (_) { /* référentiel sans règle : l'aide le dit */ }
const builder = BUILDERS[referential];
return {
referential,
activeVersion: active,
proposedVersion: nextVersion(active),
versions: versionsEx,
dependencies,
missingDependencies: dependencies.filter((d) => !d.present).map((d) => d.code),
sheet: F.screenSheet(sheet),
format: {
flat: rule ? structureColumns(rule) : [],
source: builder ? builder.kind : null,
expected: builder ? EXPECTED[builder.kind] || null : null,
},
};
});
/** GET /ajax/sdm/versions/:referential — ce que les trios contiennent. */
const versions = reply(async (req) => {
const registry = await S.loadRegistry();
const present = await S.presentTrio();
const entities = R.referentialEntities(registry, req.params.referential).filter((e) => present.has(e.Code));
return S.presentVersions(entities);
});
// ───────────────────────────────────────────────────────────────────────────
// Reprise des données existantes (lot 2.3)
// ───────────────────────────────────────────────────────────────────────────
/**
* POST /ajax/sdm/backfill/data — rejoue, dans l'ordre, chaque version
* Standards du référentiel absente des trios, par le chemin d'un import.
* Chaque version entre dans sa propre transaction et laisse une ligne
* SDM_IM « integre ». Les nombres de codes sont comparés.
*/
const dataBackfill = reply(async (req) => {
const referential = req.body?.referential;
if (!referential) throw error('référentiel manquant');
const [ref] = await S.select(`
SELECT "STD_RF_Id", "STD_RF_Schema" FROM "STD_RF_Referentials"
WHERE "STD_RF_Code" = :referential AND "STD_RF_DeletedAt" IS NULL`, { referential }).catch(() => []);
if (!ref?.STD_RF_Schema?.columns?.length) throw error(`${referential} : référentiel Standards introuvable`);
const versionsStd = (await S.select(`
SELECT "STD_RV_Id", "STD_RV_VersionNumber" FROM "STD_RV_ReferentialVersions"
WHERE "STD_RV_ReferentialId" = :id`, { id: ref.STD_RF_Id }))
.sort((a, b) => compareVersions(a.STD_RV_VersionNumber, b.STD_RV_VersionNumber));
if (!versionsStd.length) throw error(`${referential} : aucune version dans Standards`);
const result = [];
for (const v of versionsStd) {
const versionNum = v.STD_RV_VersionNumber;
const rows = await S.select(`
SELECT rd."STD_RD_ItemCode", rd."STD_RD_LanguageCode", rd."STD_RD_Data", rd."STD_RD_Label",
rd."STD_RD_ParentCode", rd."STD_RD_SortOrder"
FROM "STD_VD_ReferentialVersionData" vd
JOIN "STD_RD_ReferentialData" rd ON rd."STD_RD_Id" = vd."STD_VD_DataId"
WHERE vd."STD_VD_VersionId" = :id AND COALESCE(vd."STD_VD_IsActive", true)`, { id: v.STD_RV_Id });
const output = outputFromStd(ref.STD_RF_Schema.columns, rows);
result.push(await S.transaction(async (transaction) => {
await S.lock(referential, transaction);
const ctx = await context(referential, transaction);
const already = (await S.presentVersions(ctx.entities)).some((x) => x.version_num === versionNum);
if (already) return { versionNum, state: 'deja_presente' };
const signature = crypto.createHash('sha256').update(JSON.stringify(output)).digest('hex');
const analysis = analyze(ctx.state, {
versionNum, rule: ctx.rule, attributes: ctx.attributes, output, files: [Buffer.from(JSON.stringify(output))],
});
const links = await computeLinks(ctx, analysis, versionNum, transaction);
await writeVersion(ctx, analysis, links, versionNum, transaction);
const report = { ...analysis.report, ...links.report, backfill: output.findings };
await S.recordIntegratedImport({
referential, versionNum, file: `reprise STD_RV ${v.STD_RV_Id}`, empreinte: signature, report, by: user(req),
}, transaction);
await S.expireAnalyses(referential, transaction);
const codesStd = output.findings.codes.total;
return {
versionNum,
state: 'backfill',
codesStandards: codesStd,
backfilledCodes: analysis.rows.length,
matching: codesStd === analysis.rows.length,
truncatedLabels: output.findings.truncatedLabels.length,
createdIdentities: analysis.report.createdIdentities,
createdContents: analysis.report.createdContents,
reusedContents: analysis.report.reusedContents,
};
}));
}
logger.info(`[sdm] reprise des données ${referential} : ${result.map((r) => `${r.versionNum}=${r.state}`).join(', ')}`);
return result;
});
// ───────────────────────────────────────────────────────────────────────────
// Liens (lots 3.1 et 3.2)
// ───────────────────────────────────────────────────────────────────────────
/** GET /ajax/sdm/links — les liens déclarés et ce dont chacun dispose en base. */
const listLinks = reply(async () => {
await sync();
const [registry, links, relations, decisions, pending] = await Promise.all([
S.loadRegistry(), S.listLinks(), S.presentRelations(), S.listDecisions(), S.pendingMigrations(),
]);
const generated = G.generatedMigrations();
return {
links: links.map((l) => {
let table = null;
let tableState = 'sans_table';
if (tableCarrier(l, registry)) {
table = G.linkTableName(l);
const queued = pending.some((m) => m.SDM_MG_Plan.type === 'link' && m.SDM_MG_Plan.table === table);
tableState = relations.tables.has(table) && !queued ? 'creee'
: generated.some(({ plan }) => plan.type === 'link' && plan.table === table) ? 'engendree' : 'a_engendrer';
}
const own = decisions.filter((d) => d.SDM_DC_LinkId === l.SDM_LN_Id);
const count = (state) => own.filter((d) => d.SDM_DC_State === state).length;
return {
id: l.SDM_LN_Id,
source: l.SDM_LN_SourceEntity,
attribute: l.SDM_LN_SourceAttribute,
target: l.SDM_LN_TargetEntity,
targetAttribute: l.SDM_LN_TargetAttribute,
nature: l.SDM_LN_Nature,
cardinality: l.SDM_LN_Cardinality,
active: l.SDM_LN_Active,
table,
tableState,
decisions: { confirme: count('confirme'), reconduit: count('reconduit'), propose: count('propose'), caduc: count('caduc'), rejete: count('rejete') },
};
}),
entities: [...registry.entities.values()].filter((e) => e.Actif).map((e) => ({
Code: e.Code, Referentiel: e.Referentiel, ParenteCode: e.ParenteCode,
attributes: [...e.attributes.values()].map((a) => ({ Nom: a.Nom, Role: a.Role })),
})),
};
});
const NATURES = ['natif', 'declare'];
const CARDINALITY = /^[01n],[01n]$/;
/** POST /ajax/sdm/links — déclare un lien entre deux entités. */
/**
* Le cycle qu'ajouterait le lien natif source → cible, ou null (TH.13). Un
* lien natif impose d'importer la cible avant la source : un cycle rend
* l'ordre impossible. Le lien d'une entité vers elle-même (hiérarchie
* interne) n'en est pas un.
*/
function nativeCycle(links, source, target) {
if (source === target) return null;
const following = new Map();
for (const l of links) {
if (l.SDM_LN_SourceEntity === l.SDM_LN_TargetEntity) continue;
if (!following.has(l.SDM_LN_SourceEntity)) following.set(l.SDM_LN_SourceEntity, []);
following.get(l.SDM_LN_SourceEntity).push(l.SDM_LN_TargetEntity);
}
// Un chemin cible → … → source ferme la boucle.
const stack = [[target, [source, target]]];
const seen = new Set();
while (stack.length) {
const [node, filePath] = stack.pop();
if (node === source) return filePath;
if (seen.has(node)) continue;
seen.add(node);
for (const s of following.get(node) || []) stack.push([s, [...filePath, s]]);
}
return null;
}
const createLink = reply(async (req) => {
const { source, attribute, target, targetAttribute, nature, cardinality } = req.body || {};
if (!NATURES.includes(nature)) throw error(`nature : ${NATURES.join(' ou ')}`);
if (cardinality && !CARDINALITY.test(cardinality)) throw error('cardinalité : de la forme 1,n');
const registry = await S.loadRegistry();
for (const code of [source, target]) if (!registry.entities.has(code)) throw error(`entité inconnue : ${code}`);
let name = null;
if (nature === 'natif' && attribute) {
const declared = registry.entities.get(source).attributes.get(attribute);
if (!declared) throw error(`${source} ne déclare pas l'attribut ${attribute}`);
name = declared.Nom;
G.linkTableName({ SDM_LN_SourceEntity: source, SDM_LN_SourceAttribute: name }); // valide le nom de table
}
if (nature === 'declare' && attribute) throw error('un lien non natif n\'a pas d\'attribut : il n\'est dans aucun fichier');
// L'attribut cible, dont la source reprend les valeurs (le « champ cible » de Standards) : pour les deux natures.
let targetName = null;
if (targetAttribute) {
const declared = registry.entities.get(target).attributes.get(targetAttribute);
if (!declared) throw error(`${target} ne déclare pas l'attribut ${targetAttribute}`);
targetName = declared.Nom;
}
const existing = await S.listLinks();
const duplicate = existing.find((l) => l.SDM_LN_SourceEntity === source && l.SDM_LN_TargetEntity === target
&& (l.SDM_LN_SourceAttribute || null) === name && (l.SDM_LN_TargetAttribute || null) === targetName
&& l.SDM_LN_Nature === nature);
if (duplicate) throw error('ce lien est déjà déclaré');
if (nature === 'natif') {
const cycle = nativeCycle(existing.filter((l) => l.SDM_LN_Active && l.SDM_LN_Nature === 'natif'), source, target);
if (cycle) throw error(`cycle de liens natifs : ${cycle.join(' → ')} — aucun ordre d'import possible`);
}
const id = await S.createLink({ source, attribute: name, target, targetAttribute: targetName, nature, cardinality });
return { id };
});
/**
* Le cycle que fermerait la réactivation du lien `id`, ou null. Réactiver un
* lien natif revient à le déclarer de nouveau : même contrôle (TH.13).
*/
function reactivationCycle(links, id) {
const link = links.find((l) => String(l.SDM_LN_Id) === String(id));
if (!link || link.SDM_LN_Active || link.SDM_LN_Nature !== 'natif') return null;
const native = links.filter((l) => l.SDM_LN_Active && l.SDM_LN_Nature === 'natif');
return nativeCycle(native, link.SDM_LN_SourceEntity, link.SDM_LN_TargetEntity);
}
/** PUT /ajax/sdm/links/:id — active ou désactive un lien. */
const updateLink = reply(async (req) => {
if (typeof req.body?.active !== 'boolean') throw error('actif : vrai ou faux');
if (req.body.active) {
const cycle = reactivationCycle(await S.listLinks(), req.params.id);
if (cycle) throw error(`cycle de liens natifs : ${cycle.join(' → ')} — aucun ordre d'import possible`);
}
const n = await S.enableLink(req.params.id, req.body.active);
if (!n) throw error('lien introuvable', 404);
return { id: req.params.id, active: req.body.active };
});
/** L'entité racine d'un référentiel : sans parent (ou parente d'elle-même), la première. */
function root(registry, referential) {
return R.referentialEntities(registry, referential)
.filter((e) => e.Actif && (!e.ParenteCode || e.ParenteCode === e.Code))
.sort((a, b) => a.Ordre - b.Ordre)[0] || null;
}
/**
* POST /ajax/sdm/links/backfill — reprend les liens de l'existant :
* la hiérarchie (le parent de chaque entité) ;
* DEPENDS_ON (standards-integrations), au niveau du référentiel, sans
* attribut : pas de table tant que la colonne n'est pas précisée ;
* les liaisons du modèle hiérarchique publié (`STD_HL`, TH.13).
*/
const linkBackfill = reply(async (req) => {
const [registry, existing, stdLinks, { cards, unpublished }] = await Promise.all([
S.loadRegistry(), S.listLinks(), S.standardsLinks(), S.standardsCards(),
]);
const { toCreate, ignored } = linksToBackfill(registry, existing, stdLinks);
const matches = await S.transaction(async (transaction) => {
for (const l of toCreate) await S.createLink(l, transaction);
// T3.2.1 : puis les correspondances des cartes publiées, sur les liens non natifs (anciens et nouveaux).
if (!cards.length) return { backfills: 0, already: 0, ignored: [] };
const declared = (await S.listLinks(transaction)).filter((l) => l.SDM_LN_Active && l.SDM_LN_Nature === 'declare');
const presence = await cardsPresence(registry, cards, transaction);
const decisions = await S.listDecisions({ linkIds: declared.map((l) => l.SDM_LN_Id) }, transaction);
const already = new Set(decisions.map((d) => `${d.SDM_DC_LinkId}|${d.SDM_DC_SourceId}|${d.SDM_DC_TargetId}|${d.SDM_DC_VersionNum}`));
const r = matchesToBackfill(cards, { registry, declared, presence, already });
await S.writeDecisions(r.toWrite, { by: user(req) }, transaction);
return { backfills: r.toWrite.length, already: r.already, ignored: r.ignored };
});
return {
created: toCreate.map((l) => `${l.source}${l.attribute ? `.${l.attribute}` : ''} → ${l.target} (${l.nature}, ${l.origin})`),
ignored,
matches: { ...matches, cards: cards.length, unpublishedCards: unpublished },
};
});
/**
* Les codes présents, par entité et par version, des deux côtés des cartes :
* Map<'entite|version', Map<code, objetId> | null> — null si l'entité n'a pas
* de tables ou pas cette version dans SDM.
*/
async function cardsPresence(registry, cards, transaction) {
const tables = await S.presentTrio(transaction);
const presence = new Map();
const load = async (referential, version) => {
for (const e of R.referentialEntities(registry, referential)) {
const key = `${e.Code}|${version}`;
if (presence.has(key)) continue;
if (!tables.has(e.Code)) { presence.set(key, null); continue; }
const members = await S.memberships(e.Code, version, Boolean(e.ParenteCode), transaction);
presence.set(key, members.size ? new Map([...members].map(([id, m]) => [m.code, id])) : null);
}
};
for (const c of cards) {
await load(c.fromRef, c.fromVersion);
await load(c.toRef, c.toVersion);
}
return presence;
}
/**
* Les correspondances des cartes publiées de Standards à écrire en décisions
* « confirmé » (T3.2.1). Pour chaque paire de codes, le lien non natif dont la
* source porte le code de départ (à la version de départ) et la cible le code
* d'arrivée ; un lien déclaré dans l'autre sens prend la paire retournée.
* La décision est datée de la version du côté source du lien, comme
* l'établissement à la main. Ce qui est déjà décidé (même refusé) n'est pas
* touché. Rien d'écrit pour une paire sans lien, sans code ou sans version :
* motif regroupé dans `ignorees`.
*/
function matchesToBackfill(cards, { registry, declared, presence, already = new Set() }) {
const toWrite = [];
const views = new Set(already);
const reasons = new Map();
const ignore = (reason, example) => {
const m = reasons.get(reason) || { nb: 0, examples: [] };
m.nb += 1;
if (m.examples.length < 3) m.examples.push(example);
reasons.set(reason, m);
};
let alreadyBackfilled = 0;
for (const c of cards) {
const card = `${c.fromRef} ${c.fromVersion} → ${c.toRef} ${c.toVersion}`;
const initial = R.referentialEntities(registry, c.fromRef).map((e) => e.Code);
const arrival = R.referentialEntities(registry, c.toRef).map((e) => e.Code);
if (!initial.length || !arrival.length) {
for (const p of c.pairs) ignore(`${card} : ${initial.length ? c.toRef : c.fromRef} n'est pas déclaré`, `${p.from} → ${p.to}`);
continue;
}
const missing = (codes, version) => codes.every((e) => !presence.get(`${e}|${version}`));
if (missing(initial, c.fromVersion) || missing(arrival, c.toVersion)) {
const [ref, v] = missing(initial, c.fromVersion) ? [c.fromRef, c.fromVersion] : [c.toRef, c.toVersion];
for (const p of c.pairs) ignore(`${card} : version ${v} de ${ref} absente de SDM — reprenez d'abord ses données`, `${p.from} → ${p.to}`);
continue;
}
// Les liens non natifs entre les deux référentiels, dans un sens ou dans l'autre.
const candidates = declared.flatMap((l) => {
if (initial.includes(l.SDM_LN_SourceEntity) && arrival.includes(l.SDM_LN_TargetEntity)) return [{ link: l, direction: 1 }];
if (arrival.includes(l.SDM_LN_SourceEntity) && initial.includes(l.SDM_LN_TargetEntity)) return [{ link: l, direction: -1 }];
return [];
});
if (!candidates.length) {
for (const p of c.pairs) ignore(`${card} : aucun lien non natif déclaré entre ${c.fromRef} et ${c.toRef}`, `${p.from} → ${p.to}`);
continue;
}
const idOf = (entity, version, code) => presence.get(`${entity}|${version}`)?.get(code) || null;
for (const p of c.pairs) {
let found = null;
for (const { link, direction } of candidates) {
const [entOf, entTo] = direction === 1
? [link.SDM_LN_SourceEntity, link.SDM_LN_TargetEntity] : [link.SDM_LN_TargetEntity, link.SDM_LN_SourceEntity];
const from = idOf(entOf, c.fromVersion, p.from);
const to = idOf(entTo, c.toVersion, p.to);
if (!from || !to) continue;
found = direction === 1
? { linkId: link.SDM_LN_Id, sourceId: from, targetId: to, versionNum: c.fromVersion }
: { linkId: link.SDM_LN_Id, sourceId: to, targetId: from, versionNum: c.toVersion };
break;
}
if (!found) {
const knownFrom = initial.some((e) => idOf(e, c.fromVersion, p.from));
const knownTo = arrival.some((e) => idOf(e, c.toVersion, p.to));
const reason = !knownFrom ? `code absent de ${c.fromRef} ${c.fromVersion}`
: !knownTo ? `code absent de ${c.toRef} ${c.toVersion}`
: 'aucun lien non natif déclaré entre les entités de ces deux codes';
ignore(`${card} : ${reason}`, `${p.from} → ${p.to}`);
continue;
}
const key = `${found.linkId}|${found.sourceId}|${found.targetId}|${found.versionNum}`;
if (views.has(key)) { alreadyBackfilled += 1; continue; }
views.add(key);
toWrite.push({ ...found, state: 'confirme', human: true, reason: `repris de Standards (carte ${card})` });
}
}
const ignored = [...reasons].map(([reason, m]) => `${reason} — ${m.nb} paire(s), ex. ${m.examples.join(', ')}`);
return { toWrite, already: alreadyBackfilled, ignored };
}
const sameName = (a, b) => String(a || '').toUpperCase() === String(b || '').toUpperCase();
/**
* L'entité d'un côté d'une liaison `STD_HL`. Référentiel découpé : la
* couverture (`champ = valeur` sur le discriminant) désigne l'entité ; sinon,
* l'entité qui déclare le champ (côté source) ; sinon, la racine.
* Rend { entite } ou { motif }.
*/
function sideEntity(registry, referential, decl, field) {
const entities = R.referentialEntities(registry, referential).filter((e) => e.Actif);
if (!entities.length) return { reason: `${referential} n'est pas déclaré` };
const rule = SPLIT_RULES[referential];
if (rule && decl?.field && sameName(decl.field, rule.column)) {
const code = Object.entries(rule.values).find(([v]) => sameName(v, decl.value))?.[1];
const e = code && entities.find((x) => x.Code === code);
if (e) return { entity: e };
return { reason: `${referential} : aucune entité pour ${decl.field} = ${decl.value}` };
}
if (field) {
const carriers = entities.filter((e) => [...e.attributes.values()].some((a) => sameName(a.Nom, field)));
if (carriers.length === 1) return { entity: carriers[0] };
if (carriers.length > 1) return { reason: `${referential}.${field} porté par plusieurs entités, sans couverture pour choisir` };
return { reason: `${referential} ne déclare pas la colonne ${field}` };
}
const e = root(registry, referential);
return e ? { entity: e } : { reason: `${referential} sans entité racine` };
}
/** Les liens de l'existant pas encore déclarés. */
function linksToBackfill(registry, existing, stdLinks = []) {
// Un lien désactivé à la main compte comme déjà repris : on ne le recrée pas.
const already = new Set(existing.map((l) => `${l.SDM_LN_SourceEntity}|${l.SDM_LN_SourceAttribute || ''}|${l.SDM_LN_TargetEntity}`));
// Les liens natifs, existants puis repris : pour refuser un cycle (aucun ordre d'import possible).
const native = existing.filter((l) => l.SDM_LN_Active !== false && l.SDM_LN_Nature === 'natif')
.map((l) => ({ SDM_LN_SourceEntity: l.SDM_LN_SourceEntity, SDM_LN_TargetEntity: l.SDM_LN_TargetEntity }));
const toCreate = [];
const ignored = [];
const added = (l) => {
const key = `${l.source}|${l.attribute || ''}|${l.target}`;
if (already.has(key)) return;
const nature = l.nature || 'natif';
if (nature === 'natif') {
const cycle = nativeCycle(native, l.source, l.target);
if (cycle) { ignored.push(`${l.origin} : cycle de liens natifs ${cycle.join(' → ')}`); return; }
native.push({ SDM_LN_SourceEntity: l.source, SDM_LN_TargetEntity: l.target });
}
already.add(key);
toCreate.push({ ...l, nature });
};
for (const e of registry.entities.values()) {
if (!e.Actif || !e.ParenteCode) continue;
const parent = [...e.attributes.values()].find((a) => a.Role === 'parent');
added({ source: e.Code, attribute: parent?.Nom || null, target: e.ParenteCode, cardinality: 'n,1', origin: 'hiérarchie' });
}
for (const [referential, deps] of Object.entries(DEPENDS_ON)) {
for (const dep of [].concat(deps)) {
const source = root(registry, referential);
const target = root(registry, dep);
if (!source || !target) { ignored.push(`DEPENDS_ON ${referential} → ${dep} : référentiel non déclaré`); continue; }
added({ source: source.Code, attribute: null, target: target.Code, cardinality: 'n,1', origin: `DEPENDS_ON ${referential} → ${dep}` });
}
}
// STD_HL : `fwd` couvre le côté source (FromRef), `rev` le côté cible (ToRef).
for (const h of stdLinks) {
const origin = `Hiérarchie ${h.fromRef}.${h.fromField} → ${h.toRef}.${h.toField}`;
const source = sideEntity(registry, h.fromRef, h.coverage?.fwd, h.fromField);
const target = sideEntity(registry, h.toRef, h.coverage?.rev, null);
if (!source.entity || !target.entity) { ignored.push(`${origin} : ${source.reason || target.reason}`); continue; }
const attribute = [...source.entity.attributes.values()].find((a) => sameName(a.Nom, h.fromField))?.Nom || null;
added({
source: source.entity.Code, attribute, target: target.entity.Code, cardinality: 'n,1',
nature: h.isNative ? 'natif' : 'declare', origin,
});
}
return { toCreate, ignored };
}
const asDecision = (d) => ({
id: d.SDM_DC_Id,
linkId: d.SDM_DC_LinkId,
sourceId: d.SDM_DC_SourceId,
targetId: d.SDM_DC_TargetId,
versionNum: d.SDM_DC_VersionNum,
state: d.SDM_DC_State,
decidedAt: d.SDM_DC_DecidedAt,
decidedBy: d.SDM_DC_DecidedBy,
reason: d.SDM_DC_Reason,
});
/** GET /ajax/sdm/links/:id/decisions — les instances d'un lien non natif. */
const linkDecisions = reply(async (req) => {
const links = await S.listLinks();
const linksById = new Map(links.map((l) => [l.SDM_LN_Id, l]));
if (!linksById.has(req.params.id)) throw error('lien introuvable', 404);
const decisions = (await S.listDecisions({ linkIds: [req.params.id] })).map(asDecision);
return withCodes(decisions, linksById);
});
/**
* POST /ajax/sdm/links/:id/decisions — établit une instance d'un lien non
* natif : { sourceCode, cibleCode, versionNum? }. C'est une décision humaine :
* elle est enregistrée confirmée, pour la version donnée (par défaut, la
* dernière de la source).
*/
const establishLink = reply(async (req) => {
const { sourceCode, targetCode } = req.body || {};
const link = (await S.listLinks()).find((l) => l.SDM_LN_Id === req.params.id);
if (!link) throw error('lien introuvable', 404);
if (link.SDM_LN_Nature !== 'declare') throw error('seul un lien non natif s\'établit à la main : un lien natif vient du fichier');
const present = await S.presentTrio();
for (const e of [link.SDM_LN_SourceEntity, link.SDM_LN_TargetEntity]) if (!present.has(e)) throw error(`${e} n'a pas encore de tables`);
const sourceId = (await S.identities(link.SDM_LN_SourceEntity)).get(String(sourceCode || '').trim());
const targetId = (await S.identities(link.SDM_LN_TargetEntity)).get(String(targetCode || '').trim());
if (!sourceId) throw error(`code source inconnu dans ${link.SDM_LN_SourceEntity} : ${sourceCode}`);
if (!targetId) throw error(`code cible inconnu dans ${link.SDM_LN_TargetEntity} : ${targetCode}`);
const versionNum = String(req.body?.versionNum || '').trim()
|| read.lastVersion(await S.versionsOf(link.SDM_LN_SourceEntity));
if (!versionNum || !VERSION_NUM.test(versionNum)) throw error('numéro de version invalide');
await S.writeDecisions([{
linkId: link.SDM_LN_Id, sourceId, targetId, versionNum, state: 'confirme', human: true, reason: 'établi à la main',
}], { by: user(req) });
return { linkId: link.SDM_LN_Id, sourceCode, targetCode, versionNum, state: 'confirme' };
});
/** GET /ajax/sdm/links/to-confirm — la file d'attente, avec le motif du doute. */
const confirmationQueue = reply(async () => {
const links = await S.listLinks();
const decisions = (await S.listDecisions({ state: 'propose' })).map(asDecision);
return withCodes(decisions, new Map(links.map((l) => [l.SDM_LN_Id, l])));
});
/**
* PUT /ajax/sdm/links/decisions/:id — { etat: confirme | rejete, motif? }.
* Une décision à la fois : il n'y a pas de « tout confirmer » (décision B).
*/
const decideLink = reply(async (req) => {
const { state, reason } = req.body || {};
if (!['confirme', 'rejete'].includes(state)) throw error('etat : confirme ou rejete');
return S.transaction(async (transaction) => {
const d = await S.readDecision(req.params.id, transaction);
if (!d) throw error('décision introuvable', 404);
if (d.SDM_DC_State !== 'propose') throw error(`décision déjà ${d.SDM_DC_State}`);
await S.settleDecision(d.SDM_DC_Id, { state, by: user(req), reason }, transaction);
return { id: d.SDM_DC_Id, state };
});
});
// ───────────────────────────────────────────────────────────────────────────
// Lecture (plan § 17)
// ───────────────────────────────────────────────────────────────────────────
/** GET /ajax/sdm/data/:entity?version=&langue=&limite=&decalage= */
const readData = reply(async (req) => {
const registry = await S.loadRegistry();
const entity = registry.entities.get(req.params.entity);
if (!entity) throw error(`entité inconnue : ${req.params.entity}`, 404);
if (!(await S.presentTrio()).has(entity.Code)) throw error(`${entity.Code} n'a pas encore de tables`, 409);
const { version, lang, limit, offset } = req.query;
if (version && !VERSION_NUM.test(version)) throw error('numéro de version invalide');
if (lang && !['fr', 'en'].includes(lang)) throw error('langue : fr ou en');
return read.read(entity, { versionNum: version, lang, limit, offset });
});
/**
* GET /ajax/sdm/explore/:entity?version=&q=&parent=&limite=&decalage=
* L'onglet Données : une page de lignes toutes langues, le total, et pour
* chaque objet le nombre de ses enfants dans les entités filles.
*/
const explore = reply(async (req) => {
const registry = await S.loadRegistry();
const entity = registry.entities.get(req.params.entity);
if (!entity) throw error(`entité inconnue : ${req.params.entity}`, 404);
const present = await S.presentTrio();
if (!present.has(entity.Code)) throw error(`${entity.Code} n'a pas encore de tables`, 409);
const { version, q, parent } = req.query;
if (version && !VERSION_NUM.test(version)) throw error('numéro de version invalide');
const versions = (await S.versionsOf(entity.Code)).sort(compareVersions);
const versionNum = version || read.lastVersion(versions);
const empty = { entity: entity.Code, versionNum, versions, total: 0, rows: [], children: [] };
if (!versionNum) return empty;
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 500);
const offset = Math.max(Number(req.query.offset) || 0, 0);
const { total, rows } = await S.exploreData(entity.Code, {
versionNum,
search: q ? String(q).slice(0, 200) : null,
withParentRow: entity.ParenteCode || null,
parentCode: parent || null,
limit,
offset,
});
// Les entités filles créées (elle-même si elle est sa propre parente) :
// une hiérarchie dépliable objet par objet.
const children = [...registry.entities.values()]
.filter((e) => e.ParenteCode === entity.Code && e.Actif && present.has(e.Code));
const ids = rows.map((l) => l.objet_id);
const counts = await Promise.all(children.map((f) => S.countChildren(f.Code, versionNum, ids)));
return {
...empty,
total,
children: children.map((f) => f.Code),
rows: rows.map(({ objet_id: id, ...l }) => ({
...l,
children: Object.fromEntries(children.map((f, i) => [f.Code, counts[i].get(id) || 0])),
})),
};
});
/**
* GET /ajax/sdm/data?entite=&langue=&version=current|all&q=&parent=&limite=&decalage=
* L'onglet Données aux filtres de Standards (E32) : sans entité, toutes les
* entités de tête (les filles se déplient dessous) ; sans langue, une ligne
* par langue ; `all`, une ligne par version. Chaque ligne porte son entité,
* sa version, sa langue et le nombre de ses enfants dans cette version.
*/
const records = reply(async (req) => {
const registry = await S.loadRegistry();
const present = await S.presentTrio();
const { entity: code, lang, version = 'current', q, parent } = req.query;
if (lang && !['fr', 'en'].includes(lang)) throw error('langue invalide');
if (!['current', 'all'].includes(version)) throw error('version invalide : current ou all');
const created = [...registry.entities.values()].filter((e) => e.Actif && present.has(e.Code));
let chosen;
if (code) {
const e = registry.entities.get(code);
if (!e || !created.includes(e)) throw error(`entité inconnue ou sans tables : ${code}`, 404);
chosen = [e];
} else {
chosen = created.filter((e) => !e.ParenteCode || e.ParenteCode === e.Code);
}
const sources = [];
for (const e of chosen) {
const versions = (await S.versionsOf(e.Code)).sort(compareVersions);
if (!versions.length) continue;
sources.push({
code: e.Code,
withParentRow: e.ParenteCode || null,
rootsOnly: !code && e.ParenteCode === e.Code,
versions,
current: versions[versions.length - 1],
allOf: version === 'all',
});
}
const { total, rows } = await S.browseData(sources, {
langs: lang ? [lang] : ['fr', 'en'],
search: q ? String(q).slice(0, 200) : null,
parentCode: code && parent ? String(parent) : null,
limit: Math.min(Math.max(Number(req.query.limit) || 20, 1), 500),
offset: Math.max(Number(req.query.offset) || 0, 0),
});
// Les enfants, comptés par entité fille et par version de la ligne.
const childrenOf = (c) => created.filter((f) => f.ParenteCode === c);
const counts = new Map();
const groups = new Map();
for (const l of rows) {
const key = `${l.entity}|${l.version_num}`;
if (!groups.has(key)) groups.set(key, { entity: l.entity, versionNum: l.version_num, ids: new Set() });
groups.get(key).ids.add(l.objet_id);
}
for (const [key, g] of groups) {
for (const f of childrenOf(g.entity)) {
counts.set(`${key}|${f.Code}`, await S.countChildren(f.Code, g.versionNum, [...g.ids]));
}
}
return {
total,
referentialsWithoutData: chosen.length - sources.length,
rows: rows.map(({ objet_id: id, ...l }) => ({
...l,
children: Object.fromEntries(childrenOf(l.entity).map((f) => [f.Code,
counts.get(`${l.entity}|${l.version_num}|${f.Code}`).get(id) || 0])),
})),
};
});
/**
* GET /ajax/sdm/api/:func?… — une fonction de l'API compat Standards
* (sdm-api.utils), ses arguments pris dans la requête. Sert l'onglet Test API.
*/
const callApi = reply(async (req) => {
const args = api.argsOf(req.params.func, req.query);
if (!args) throw error(`fonction inconnue : ${req.params.func}`, 404);
const start = Date.now();
const result = await api[req.params.func](...args);
return { func: req.params.func, arguments: args, durationMs: Date.now() - start, result };
});
module.exports = {
callApi,
explore,
records,
page,
listEntities,
createEntity,
updateEntity,
updateSheet,
updateAttributes,
backfill,
generateMigration,
listMigrations,
markPlayed,
playMigrations,
removeMigration,
undoMigration,
resetAll,
schemaDiff,
analyzeImport,
listImports,
readReport,
integrateImport,
refuseImport,
rerunImport,
readTask,
importHelp,
versions,
dataBackfill,
listLinks,
createLink,
updateLink,
linkBackfill,
linkDecisions,
establishLink,
confirmationQueue,
decideLink,
readData,
// pour les tests
entityStates,
pendingEntities,
migrationExists,
counterGaps,
nativeCycle,
reactivationCycle,
linksToBackfill,
matchesToBackfill,
nextVersion,
};
{{#> website
pageId="sys-data-model"
pageTitle="sdm.pageTitle"
loggedUser=loggedUser
releaseData=releaseData
authorizedSection= authorizedSection
pLang=pLang
page=page
}}
{{#*inline "langs"}}{{/inline}}
{{#*inline "head"}}
<!-- Onglet Test API : mêmes composants que l'onglet Test API de Standards (TH.10), classes std-* seulement -->
<link rel="stylesheet" href="/assets/pages/settings/settings-standards.css">
<link rel="stylesheet" href="/assets/pages/sys-data-model/sys-data-model.css">
<script src="/assets/pages/sys-data-model/sys-data-model.js" defer></script>
{{/inline}}
{{#*inline "breadcrumb"}}
<ul class="navbar-nav breadcrumb flex-row">
<li class="nav-item breadcrumb-item"><a href="/" aria-label="Accueil">Accueil</a></li>
<li class="nav-item breadcrumb-item"><a href="/settings/sys-data-model">Modèle de données</a></li>
</ul>
{{/inline}}
{{!-- TH.9 : deux droits seulement. system.sysDataModel.access ouvre la page
(lecture) ; system.sysDataModel.manage ouvre toutes les actions. --}}
<div class="sdm-page"
{{#sysHasAction "system" "system.sysDataModel.manage"}}data-write="1" data-generate="1" data-analyze="1"
data-validate="1" data-mark="1" data-link="1" data-confirm="1"{{/sysHasAction}}>
<header class="sdm-header">
<div>
<h3 class="sdm-title">Modèle de données</h3>
<p class="sdm-subtitle">Déclarez les entités des référentiels, engendrez leurs tables, importez les versions et reliez les objets.</p>
</div>
<button type="button" class="btn btn-sm sdm-btn-ghost" id="sdmBtnRefresh" title="Actualiser">
<span data-icon="refresh"></span><span class="d-none d-sm-inline">Actualiser</span>
</button>
</header>
<ol class="sdm-path" id="sdmPath" aria-label="Parcours"></ol>
<div class="sdm-tabs" role="tablist" aria-label="Sections">
<button type="button" class="sdm-tab active" role="tab" id="sdmTabEntities"
aria-controls="sdmPanelEntities" aria-selected="true" data-tab="entities">
<span data-icon="entities"></span>Entités
</button>
<button type="button" class="sdm-tab" role="tab" id="sdmTabMigrations" tabindex="-1"
aria-controls="sdmPanelMigrations" aria-selected="false" data-tab="migrations">
<span data-icon="migrations"></span>Migrations <span class="sdm-pill d-none" id="sdmNbPending"></span>
</button>
<button type="button" class="sdm-tab" role="tab" id="sdmTabImports" tabindex="-1"
aria-controls="sdmPanelImports" aria-selected="false" data-tab="imports">
<span data-icon="imports"></span>Imports
</button>
<button type="button" class="sdm-tab" role="tab" id="sdmTabData" tabindex="-1"
aria-controls="sdmPanelData" aria-selected="false" data-tab="records">
<span data-icon="records"></span>Données
</button>
<button type="button" class="sdm-tab" role="tab" id="sdmTabLinks" tabindex="-1"
aria-controls="sdmPanelLinks" aria-selected="false" data-tab="links">
<span data-icon="links"></span>Liens
</button>
<button type="button" class="sdm-tab" role="tab" id="sdmTabApi" tabindex="-1"
aria-controls="sdmPanelApi" aria-selected="false" data-tab="api">
<span data-icon="code"></span>Test API
</button>
</div>
<!-- ============ ONGLET ENTITÉS ============ -->
<section id="sdmPanelEntities" class="sdm-panel" role="tabpanel" aria-labelledby="sdmTabEntities">
<div class="sdm-bar">
<div class="sdm-filters">
<label class="sdm-search">
<span data-icon="search"></span>
<input type="search" id="sdmSearch" class="form-control form-control-sm" placeholder="Chercher une entité…" aria-label="Chercher une entité">
</label>
<select id="sdmFilterRef" class="form-control form-control-sm" aria-label="Référentiel"></select>
</div>
<div class="sdm-actions">
<button type="button" class="btn btn-outline-secondary btn-sm" id="sdmBtnBackfill" data-right="write">
<span data-icon="refresh"></span>Reprendre depuis Standards
</button>
<button type="button" class="btn btn-primary btn-sm" id="sdmBtnNew" data-right="write">
<span data-icon="plus"></span>Nouvelle entité
</button>
</div>
</div>
<div id="sdmFindings" class="sdm-inset d-none" role="status"></div>
<div id="sdmTree" class="sdm-tree" aria-live="polite">
<div class="sdm-skeleton" aria-hidden="true"><span></span><span></span><span></span><span></span></div>
</div>
</section>
<!-- ============ ONGLET MIGRATIONS ============ -->
<section id="sdmPanelMigrations" class="sdm-panel d-none" role="tabpanel" aria-labelledby="sdmTabMigrations">
<div class="sdm-bar">
<p class="sdm-help mb-0">Les fichiers engendrés vont dans <code>src/database/migrations</code> ; le déploiement les joue.
Une migration « à jouer » bloque l'import des entités qu'elle touche.</p>
<div class="sdm-actions">
<select id="sdmRefView" class="form-control form-control-sm" aria-label="Référentiel de la vue de compatibilité" data-right="generate"></select>
<button type="button" class="btn btn-outline-primary btn-sm" id="sdmBtnView" data-right="generate">
<span data-icon="code"></span>Engendrer la vue
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" id="sdmBtnDiff" data-right="generate">
<span data-icon="search"></span>Comparer à la base
</button>
<button type="button" class="btn btn-outline-danger btn-sm d-none" id="sdmBtnPlay" data-right="generate">
<span data-icon="play"></span>Jouer les migrations
</button>
<button type="button" class="btn btn-outline-danger btn-sm d-none" id="sdmBtnReset" data-right="mark"
title="DEV : vide tout ce que sys_data_model a écrit">
<span data-icon="trash"></span>Tout remettre à zéro
</button>
</div>
</div>
<div id="sdmDiff" class="sdm-inset d-none" role="status"></div>
<div class="sdm-card sdm-card-table" id="sdmCardMigrations">
<div class="table-responsive">
<table class="table sdm-table" id="sdmTableMigrations">
<thead>
<tr>
<th scope="col">Fichier</th>
<th scope="col">Objet</th>
<th scope="col">État</th>
<th scope="col">En base</th>
<th scope="col">Engendrée</th>
<th scope="col">Jouée</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="sdmMigrationsEmpty"></div>
</section>
<!-- ============ ONGLET IMPORTS ============ -->
<section id="sdmPanelImports" class="sdm-panel d-none" role="tabpanel" aria-labelledby="sdmTabImports">
{{!-- Même écran que l'onglet « Intégrations » de Standards : menu des référentiels
par section à gauche, fiche du référentiel choisi à droite. Seuls les dépôts
de fichiers et le chemin analyser → rapport → valider → intégrer sont les nôtres. --}}
<div class="sdm-integ">
<nav class="sdm-integ-menu" aria-label="Référentiels à alimenter">
<div class="sdm-integ-tree" id="sdmMenuImports"></div>
</nav>
<section class="sdm-integ-sheet" aria-live="polite">
<p class="sdm-integ-empty" id="sdmSheetImportEmpty">Choisissez un référentiel dans le menu.</p>
<div id="sdmSheetImport" class="d-none">
<header class="sdm-integ-head">
<h3 class="sdm-integ-title"><span class="sdm-integ-code" id="sdmSheetImportCode"></span> <span id="sdmSheetImportName"></span></h3>
<button type="button" class="btn btn-outline-secondary btn-sm" id="sdmBtnBackfillData" data-right="validate"
title="Rejoue chaque version déjà présente dans Standards, de la plus ancienne à la plus récente, par le chemin d'un import.">
<span data-icon="refresh"></span>Reprendre les versions Standards
</button>
</header>
<div id="sdmBackfillData" class="sdm-backfill-result sdm-inset d-none" role="status"></div>
<div id="sdmSheetSource"></div>
<form id="sdmFormImport" data-right="analyze" autocomplete="off">
<select name="referential" hidden aria-hidden="true" tabindex="-1"></select>
<details class="sdm-integ-panel" open>
<summary>Traitement d'une extraction</summary>
<div id="sdmHelpImport" class="sdm-help-import d-none" aria-live="polite"></div>
<div class="sdm-dropzones">
<label class="sdm-dropzone">
<input name="fileFr" type="file" accept=".xlsx,.xls">
<span class="sdm-dropzone-icon" data-icon="file"></span>
<span class="sdm-dropzone-lang">FR</span>
<span class="sdm-dropzone-name">Glissez le fichier ici ou cliquez</span>
</label>
<label class="sdm-dropzone">
<input name="fileEn" type="file" accept=".xlsx,.xls">
<span class="sdm-dropzone-icon" data-icon="file"></span>
<span class="sdm-dropzone-lang">EN</span>
<span class="sdm-dropzone-name">Glissez le fichier ici ou cliquez</span>
</label>
</div>
{{!-- TH.12 : l'extraction source, montrée selon le traitement Standards du référentiel --}}
<div class="sdm-dropzones d-none" id="sdmDropzonesSource">
<label class="sdm-dropzone" data-kind="csv workbook risks jobs">
<input name="file" type="file" accept=".xlsx,.xls,.csv,.txt">
<span class="sdm-dropzone-icon" data-icon="file"></span>
<span class="sdm-dropzone-lang" data-label-source>Source</span>
<span class="sdm-dropzone-name">Glissez le fichier ici ou cliquez</span>
</label>
<label class="sdm-dropzone" data-kind="risks">
<input name="fileL4" type="file" accept=".xlsx,.xls">
<span class="sdm-dropzone-icon" data-icon="file"></span>
<span class="sdm-dropzone-lang">Niveau 4</span>
<span class="sdm-dropzone-name">Glissez le fichier ici ou cliquez</span>
</label>
<label class="sdm-dropzone" data-kind="jobs">
<input name="fileStdFr" type="file" accept=".xlsx,.xls">
<span class="sdm-dropzone-icon" data-icon="file"></span>
<span class="sdm-dropzone-lang">Complément FR</span>
<span class="sdm-dropzone-name">Facultatif</span>
</label>
<label class="sdm-dropzone" data-kind="jobs">
<input name="fileStdEn" type="file" accept=".xlsx,.xls">
<span class="sdm-dropzone-icon" data-icon="file"></span>
<span class="sdm-dropzone-lang">Complément EN</span>
<span class="sdm-dropzone-name">Facultatif</span>
</label>
</div>
<div class="sdm-integ-run">
<label class="sdm-field sdm-field-short"><span>Version</span>
<input name="versionNum" class="form-control form-control-sm" required placeholder="6.6" pattern="[0-9A-Za-z._\-]{1,32}">
</label>
<button type="submit" class="btn btn-primary btn-sm" id="sdmBtnAnalyze"><span data-icon="search"></span>Analyser</button>
<span class="sdm-help">L'analyse n'écrit rien : elle produit un rapport, que vous validez ou refusez.</span>
</div>
</details>
</form>
<div id="sdmReport" class="sdm-report d-none" aria-live="polite"></div>
<details class="sdm-integ-panel" open>
<summary>Historique <span class="sdm-integ-count" id="sdmNbImports">0</span></summary>
<div class="sdm-card sdm-card-table" id="sdmCardImports">
<div class="table-responsive">
<table class="table sdm-table" id="sdmTableImports">
<thead>
<tr>
<th scope="col">Analysé le</th>
<th scope="col">Version</th>
<th scope="col">Fichiers</th>
<th scope="col">État</th>
<th scope="col" class="text-right" title="Codes jamais vus avant cette version">Nouveaux codes</th>
<th scope="col" class="text-right" title="Textes nouveaux ou modifiés, enregistrés">Textes nouveaux</th>
<th scope="col" class="text-right" title="Textes identiques à une version précédente, réutilisés">Textes connus</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="sdmImportsEmpty"></div>
</details>
</div>
</section>
</div>
</section>
<!-- ============ ONGLET DONNÉES ============ -->
<section id="sdmPanelData" class="sdm-panel d-none" role="tabpanel" aria-labelledby="sdmTabData">
<!-- Même gabarit et mêmes classes std-* que l'onglet « Données » de Standards (TH.16) -->
<div class="std-data-viewer">
<div class="std-filters">
<div class="std-filter-group">
<label for="sdmDataRef" class="std-filter-label">Référentiel</label>
<select id="sdmDataRef" class="std-filter-select">
<option value="">Tous les référentiels</option>
</select>
</div>
<div class="std-filter-group">
<label for="sdmDataLang" class="std-filter-label">Langue</label>
<select id="sdmDataLang" class="std-filter-select">
<option value="">Toutes les langues</option>
<option value="fr" selected>Français</option>
<option value="en">Anglais</option>
</select>
</div>
<div class="std-filter-group">
<label for="sdmDataVersion" class="std-filter-label">Version</label>
<select id="sdmDataVersion" class="std-filter-select">
<option value="all">Toutes les versions</option>
<option value="current" selected>Version courante</option>
</select>
</div>
<div class="std-filter-group">
<label for="sdmDataSearch" class="std-filter-label">Recherche</label>
<input type="text" id="sdmDataSearch" class="std-filter-input" placeholder="Code ou libellé...">
</div>
<button type="button" class="std-button std-btn-secondary std-btn-sm std-filter-btn" id="sdmDataClear">
<span data-icon="close"></span>
<span>Effacer</span>
</button>
</div>
<div class="sdm-data-filter-parent d-none" id="sdmDataFilterParent"></div>
<div id="sdmDataLoading" class="std-data-loading" style="display: none;">
<div class="std-spinner"></div>
<span>Chargement...</span>
</div>
<div class="std-data-table-container" id="sdmCardData">
<table class="std-data-table" id="sdmTableData">
<thead>
<tr>
<th scope="col">Référentiel</th>
<th scope="col">Code</th>
<th scope="col">Code parent</th>
<th scope="col">Libellé</th>
<th scope="col">Langue</th>
<th scope="col">Version</th>
<th scope="col">Statut</th>
<th scope="col">Enfants</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="std-pagination" id="sdmDataPagination">
<div class="std-pagination-info" id="sdmDataCount">Affichage 0-0 sur 0</div>
<div class="std-pagination-controls" id="sdmDataPages"></div>
</div>
<div id="sdmDataDetail" class="std-detail-panel" style="display: none;">
<div class="std-detail-header">
<h4 class="std-detail-title" id="sdmDataDetailTitle"></h4>
<button type="button" class="std-button std-btn-secondary std-btn-sm" aria-label="Fermer" id="sdmDataDetailClose">
<span data-icon="close"></span>
</button>
</div>
<div class="std-detail-content" id="sdmDataDetailContent"></div>
</div>
</div>
</section>
<!-- ============ ONGLET LIENS ============ -->
<section id="sdmPanelLinks" class="sdm-panel d-none" role="tabpanel" aria-labelledby="sdmTabLinks">
<!-- Barre et arbre sur le modèle de la Hiérarchie Standards (E37) -->
<div class="sdm-lx-bar">
<p class="sdm-lx-help"><strong>Native</strong> : portée par une colonne du fichier, recalculée à chaque import.
<strong>À rapprocher</strong> : établie à la main, rejouée à chaque version ; en cas de doute, elle passe « à confirmer ».</p>
<div class="sdm-lx-group">
<label class="sdm-lx-check"><input type="checkbox" id="sdmHideIsolated"> Masquer les isolés</label>
<button type="button" class="sdm-lx-btn" id="sdmBtnBackfillLinks" data-right="link">
<span data-icon="refresh"></span>Reprendre les liens existants
</button>
<button type="button" class="sdm-lx-btn sdm-lx-btn-primary" id="sdmBtnNewLink" data-right="link">
<span data-icon="plus"></span>Nouveau lien
</button>
</div>
</div>
<div id="sdmBackfillLinks" class="sdm-inset d-none" role="status"></div>
<!-- Liens à confirmer : visible seulement s'il y a quelque chose à trancher (E35) -->
<section id="sdmBlockConfirm" class="sdm-confirm d-none" aria-labelledby="sdmTitleConfirm">
<h3 class="sdm-confirm-title" id="sdmTitleConfirm"><span data-icon="confirm"></span>À confirmer
<span class="sdm-pill" id="sdmNbConfirm"></span></h3>
<p class="sdm-help">Liens déclarés dont la reconduction est douteuse (un parent a changé). Chacun se tranche un par un.</p>
<div id="sdmListConfirm" class="sdm-decisions" role="list"></div>
</section>
<div class="sdm-lx">
<div class="sdm-lx-tree" id="sdmLinkTree" role="tree" aria-label="Arbre des entités et de leurs liens">
<div class="sdm-skeleton" aria-hidden="true"><span></span><span></span><span></span></div>
</div>
<aside class="sdm-lx-side" id="sdmLinkSide" aria-live="polite"></aside>
</div>
</section>
<!-- ============ ONGLET TEST API ============ -->
<section id="sdmPanelApi" class="sdm-panel d-none" role="tabpanel" aria-labelledby="sdmTabApi">
<p class="sdm-help">Les fonctions de l'API Standards, servies par sys-data-model (<code>sdm-api.utils.js</code>) :
mêmes noms, mêmes paramètres, même réponse. Lues dans les vues <code>xone_ref.compat_*</code> ;
la version active est la plus récente intégrée.</p>
<form id="sdmFormApi" class="std-api-tester" autocomplete="off">
<!-- Panneau de requête -->
<div class="std-api-request-panel">
<div class="std-api-panel-header">
<h4 class="std-api-panel-title">Requête</h4>
</div>
<div class="std-api-panel-body">
<div class="std-api-function-select">
<label class="std-form-label" id="sdmApiFuncTitle">Sélectionner une fonction</label>
<ul class="std-api-function-list" id="sdmApiFunctions" role="listbox" aria-labelledby="sdmApiFuncTitle"></ul>
</div>
<div class="std-api-params">
<h5 class="std-api-params-title">Paramètres</h5>
<div id="sdmApiFields"></div>
</div>
<div class="std-api-execute">
<button type="submit" class="std-button std-btn-primary" id="sdmBtnApi" style="width: 100%;">
<span data-icon="play"></span><span>Exécuter</span>
</button>
</div>
</div>
</div>
<!-- Panneau de résultat -->
<div class="std-api-response-panel">
<div class="std-api-response-header">
<h4 class="std-api-panel-title">Réponse</h4>
<div class="std-api-response-status" id="sdmApiStatus" style="display: none;">
<span class="std-api-status-badge" id="sdmApiBadge"></span>
<span class="std-api-response-time" id="sdmApiDuration"></span>
</div>
</div>
<div class="std-api-response-body">
<p class="sdm-help" id="sdmApiCall"></p>
<div id="sdmApiResponse" class="std-api-response-placeholder" tabindex="0" aria-label="Réponse" aria-live="polite">
<div class="std-api-response-placeholder-icon"><span data-icon="code"></span></div>
<p>Sélectionnez une fonction et cliquez sur « Exécuter » pour voir le résultat</p>
</div>
</div>
</div>
</form>
<datalist id="sdmApiRefs"></datalist>
</section>
<!-- ============ TIROIRS ============ -->
<div class="sdm-overlay" id="sdmOverlay" aria-hidden="true"></div>
<aside id="sdmEditor" class="sdm-drawer" role="dialog" aria-modal="true" aria-labelledby="sdmEditorTitle">
<header class="sdm-drawer-head">
<div>
<span class="sdm-kicker" id="sdmEditorKicker">Entité</span>
<h4 id="sdmEditorTitle"></h4>
</div>
<button type="button" class="sdm-close" data-close aria-label="Fermer"><span data-icon="close"></span></button>
</header>
<div class="sdm-drawer-body">
<form id="sdmFormEntity" class="sdm-grid-form" autocomplete="off">
<label class="sdm-field"><span>Code</span>
<input name="Code" class="form-control form-control-sm sdm-code" required pattern="[a-z][a-z0-9_]{0,63}" spellcheck="false">
</label>
<label class="sdm-field"><span>Référentiel</span>
<select name="Referentiel" class="form-control form-control-sm" required></select>
</label>
<label class="sdm-field"><span>Libellé FR</span>
<input name="LibelleFr" class="form-control form-control-sm">
</label>
<label class="sdm-field"><span>Libellé EN</span>
<input name="LibelleEn" class="form-control form-control-sm">
</label>
<label class="sdm-field"><span>Section</span>
<input name="Section" class="form-control form-control-sm" maxlength="64" list="sdmSections"
placeholder="Sans section" title="Regroupe les référentiels à l'écran ; enregistrée dans la fiche du référentiel, elle vaut pour toutes ses entités">
<datalist id="sdmSections"></datalist>
</label>
<label class="sdm-field"><span>Entité parente</span>
<select name="ParenteCode" class="form-control form-control-sm"></select>
</label>
<label class="sdm-field"><span>Ordre</span>
<input name="Ordre" type="number" class="form-control form-control-sm" value="0">
</label>
<div class="custom-control custom-switch sdm-toggle">
<input type="checkbox" class="custom-control-input" id="sdmActive" name="Actif" checked>
<label class="custom-control-label" for="sdmActive">Entité active</label>
</div>
</form>
<div id="sdmBlockAttributes">
<div class="sdm-block-head">
<h5>Attributs</h5>
<button type="button" class="btn btn-outline-primary btn-sm" id="sdmBtnAddAttribute" data-right="write"><span data-icon="plus"></span>Ajouter</button>
</div>
<p class="sdm-help">Les attributs décrivent le contenu jsonb : les modifier ne demande pas de migration,
mais change l'empreinte des prochains imports si « Empreinte » bouge. Libellés, longueur et
syndication décrivent la colonne comme dans Standards, sans effet sur l'empreinte.</p>
<div class="table-responsive">
<table class="table sdm-table sdm-table-attributes" id="sdmTableAttributes">
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">Type</th>
<th scope="col">Rôle</th>
<th scope="col">Multivalué</th>
<th scope="col">Séparateur</th>
<th scope="col">Empreinte</th>
<th scope="col">Libellé FR</th>
<th scope="col">Libellé EN</th>
<th scope="col">Longueur</th>
<th scope="col">Syndication</th>
<th scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<footer class="sdm-drawer-footer">
<button type="button" class="btn btn-light btn-sm" data-close>Fermer</button>
<button type="button" class="btn btn-primary btn-sm" id="sdmBtnSave" data-right="write"><span data-icon="validate"></span>Enregistrer</button>
</footer>
</aside>
<aside id="sdmInstances" class="sdm-drawer" role="dialog" aria-modal="true" aria-labelledby="sdmInstancesTitle">
<header class="sdm-drawer-head">
<div>
<span class="sdm-kicker">Instances du lien</span>
<h4 id="sdmInstancesTitle"></h4>
</div>
<button type="button" class="sdm-close" data-close aria-label="Fermer"><span data-icon="close"></span></button>
</header>
<div class="sdm-drawer-body">
<form id="sdmFormInstance" class="sdm-card sdm-row-fields" data-right="confirm" autocomplete="off">
<label class="sdm-field"><span>Code source</span><input name="sourceCode" class="form-control form-control-sm sdm-code" required></label>
<label class="sdm-field"><span>Code cible</span><input name="targetCode" class="form-control form-control-sm sdm-code" required></label>
<label class="sdm-field sdm-field-short"><span>Version</span><input name="versionNum" class="form-control form-control-sm" placeholder="la dernière" pattern="[0-9A-Za-z._\-]{1,32}"></label>
<div class="sdm-actions"><button type="submit" class="btn btn-primary btn-sm"><span data-icon="links"></span>Établir</button></div>
</form>
<div class="table-responsive">
<table class="table sdm-table" id="sdmTableInstances">
<thead>
<tr>
<th scope="col">Source</th>
<th scope="col">Cible</th>
<th scope="col">Version</th>
<th scope="col">État</th>
<th scope="col">Décidé par</th>
<th scope="col">Motif</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="sdmInstancesEmpty"></div>
</div>
<footer class="sdm-drawer-footer">
<button type="button" class="btn btn-light btn-sm" data-close>Fermer</button>
</footer>
</aside>
</div>
{{#*inline "js"}}{{/inline}}
{{/website}}
/**
* sys_data_model — les tables du module (plan § 15), écrites à la main.
*
* V0 : entités, attributs, imports. Liens, migrations et décisions
* (SDM_LN, SDM_MG, SDM_DC) viendront avec leurs lots.
*
* Format sequelize-cli : jouable tel quel dans X-One.
*/
module.exports = {
async up(queryInterface, Sequelize, options = {}) {
await queryInterface.sequelize.query(`
-- La fiche d'un référentiel (TH.11, TH.12) : ce que la fiche Intégration
-- de Standards portait. Reprise une fois depuis STD_IN, puis saisie ici.
CREATE TABLE "SDM_RF_Referentials" (
"SDM_RF_Code" varchar(64) PRIMARY KEY,
"SDM_RF_Label" jsonb NOT NULL DEFAULT '{}',
"SDM_RF_Description" text,
"SDM_RF_Section" varchar(64),
"SDM_RF_Sources" jsonb NOT NULL DEFAULT '[]',
"SDM_RF_Procedure" text,
"SDM_RF_InternalOnly" boolean NOT NULL DEFAULT false,
"SDM_RF_CreatedDate" timestamptz NOT NULL DEFAULT now(),
"SDM_RF_UpdatedDate" timestamptz NOT NULL DEFAULT now(),
"SDM_RF_UpdatedBy" varchar(32)
);
CREATE TABLE "SDM_EN_Entities" (
"SDM_EN_Id" uuid PRIMARY KEY,
"SDM_EN_Code" varchar(64) NOT NULL UNIQUE,
"SDM_EN_Label" jsonb NOT NULL,
"SDM_EN_Referential" varchar(64) NOT NULL,
"SDM_EN_ParentCode" varchar(64),
"SDM_EN_SortOrder" integer NOT NULL DEFAULT 0,
"SDM_EN_Active" boolean NOT NULL DEFAULT true,
"SDM_EN_CreatedDate" timestamptz NOT NULL DEFAULT now(),
"SDM_EN_CreatedBy" varchar(32)
);
CREATE INDEX ON "SDM_EN_Entities" ("SDM_EN_Referential");
CREATE TABLE "SDM_AT_Attributes" (
"SDM_AT_Id" uuid PRIMARY KEY,
"SDM_AT_EntityId" uuid NOT NULL REFERENCES "SDM_EN_Entities" ON DELETE CASCADE,
"SDM_AT_Name" varchar(64) NOT NULL,
"SDM_AT_Type" varchar(16) NOT NULL,
"SDM_AT_Role" varchar(16) NOT NULL DEFAULT 'aucun',
"SDM_AT_Multivalue" boolean NOT NULL DEFAULT false,
"SDM_AT_Separator" varchar(16),
"SDM_AT_InFingerprint" boolean NOT NULL DEFAULT true,
"SDM_AT_SortOrder" integer NOT NULL DEFAULT 0,
-- Description de la colonne, comme STD_IN_Config.columns (TH.12) : sans effet sur l'empreinte.
"SDM_AT_FixedLength" integer,
"SDM_AT_Label" jsonb,
"SDM_AT_Syndication" varchar(8) NOT NULL DEFAULT 'none',
UNIQUE ("SDM_AT_EntityId", "SDM_AT_Name")
);
CREATE TABLE "SDM_IM_Imports" (
"SDM_IM_Id" uuid PRIMARY KEY,
"SDM_IM_Referential" varchar(64) NOT NULL,
"SDM_IM_VersionNum" varchar(32) NOT NULL,
"SDM_IM_File" varchar(256) NOT NULL,
"SDM_IM_FileFingerprint" varchar(64) NOT NULL,
"SDM_IM_State" varchar(16) NOT NULL DEFAULT 'analyse',
"SDM_IM_Report" jsonb NOT NULL DEFAULT '{}',
"SDM_IM_AnalyzedAt" timestamptz NOT NULL DEFAULT now(),
"SDM_IM_AnalyzedBy" varchar(32),
"SDM_IM_ValidatedAt" timestamptz,
"SDM_IM_ValidatedBy" varchar(32),
"SDM_IM_IntegratedAt" timestamptz,
"SDM_IM_CreatedItems" integer DEFAULT 0,
"SDM_IM_CreatedContents" integer DEFAULT 0,
"SDM_IM_ReusedContents" integer DEFAULT 0,
"SDM_IM_Memberships" integer DEFAULT 0
);
`, { transaction: options.transaction });
},
async down(queryInterface, Sequelize, options = {}) {
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS "SDM_IM_Imports";
DROP TABLE IF EXISTS "SDM_AT_Attributes";
DROP TABLE IF EXISTS "SDM_EN_Entities";
DROP TABLE IF EXISTS "SDM_RF_Referentials";
`, { transaction: options.transaction });
},
};
/**
* sys_data_model — les trois tables restantes du module (plan § 15), écrites
* à la main : liens (lot 3.1), migrations engendrées (lot 1.2), décisions
* sur les liens non natifs (lot 3.2). SQL repris du plan tel quel.
*
* Format sequelize-cli : jouable tel quel dans X-One.
*/
module.exports = {
async up(queryInterface, Sequelize, options = {}) {
await queryInterface.sequelize.query(`
CREATE TABLE "SDM_LN_Links" (
"SDM_LN_Id" uuid PRIMARY KEY,
"SDM_LN_SourceEntity" varchar(64) NOT NULL,
"SDM_LN_SourceAttribute" varchar(64),
"SDM_LN_TargetEntity" varchar(64) NOT NULL,
"SDM_LN_TargetAttribute" varchar(64),
"SDM_LN_Nature" varchar(16) NOT NULL,
"SDM_LN_Cardinality" varchar(8) NOT NULL DEFAULT '1,n',
"SDM_LN_Active" boolean NOT NULL DEFAULT true
);
CREATE TABLE "SDM_MG_Migrations" (
"SDM_MG_Id" uuid PRIMARY KEY,
"SDM_MG_FileName" varchar(128) NOT NULL UNIQUE,
"SDM_MG_Plan" jsonb NOT NULL,
"SDM_MG_State" varchar(16) NOT NULL DEFAULT 'a_jouer',
"SDM_MG_GeneratedAt" timestamptz NOT NULL DEFAULT now(),
"SDM_MG_GeneratedBy" varchar(32),
"SDM_MG_PlayedAt" timestamptz
);
CREATE TABLE "SDM_DC_Decisions" (
"SDM_DC_Id" uuid PRIMARY KEY,
"SDM_DC_LinkId" uuid NOT NULL REFERENCES "SDM_LN_Links",
"SDM_DC_SourceId" uuid NOT NULL,
"SDM_DC_TargetId" uuid NOT NULL,
"SDM_DC_VersionNum" varchar(32) NOT NULL,
"SDM_DC_State" varchar(16) NOT NULL,
"SDM_DC_DecidedAt" timestamptz,
"SDM_DC_DecidedBy" varchar(32),
"SDM_DC_Reason" text,
UNIQUE ("SDM_DC_LinkId", "SDM_DC_SourceId", "SDM_DC_TargetId", "SDM_DC_VersionNum")
);
`, { transaction: options.transaction });
},
async down(queryInterface, Sequelize, options = {}) {
await queryInterface.sequelize.query(`
DROP TABLE IF EXISTS "SDM_DC_Decisions";
DROP TABLE IF EXISTS "SDM_MG_Migrations";
DROP TABLE IF EXISTS "SDM_LN_Links";
`, { transaction: options.transaction });
},
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class SDM_AT_Attributes extends Model {
static associate(models) {
// Un attribut appartient à une entité
SDM_AT_Attributes.belongsTo(models.SDM_EN_Entities, {
foreignKey: 'SDM_AT_EntityId',
as: 'entity',
});
}
}
SDM_AT_Attributes.init(
{
SDM_AT_Id: { allowNull: false, primaryKey: true, type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
SDM_AT_EntityId: { allowNull: false, type: DataTypes.UUID },
SDM_AT_Name: { allowNull: false, type: DataTypes.STRING(64) },
SDM_AT_Type: { allowNull: false, type: DataTypes.STRING(16) },
SDM_AT_Role: { allowNull: false, type: DataTypes.STRING(16), defaultValue: 'aucun' },
SDM_AT_Multivalue: { allowNull: false, type: DataTypes.BOOLEAN, defaultValue: false },
SDM_AT_Separator: { allowNull: true, type: DataTypes.STRING(16) },
SDM_AT_InFingerprint: { allowNull: false, type: DataTypes.BOOLEAN, defaultValue: true },
SDM_AT_SortOrder: { allowNull: false, type: DataTypes.INTEGER, defaultValue: 0 },
// Description de la colonne (TH.12), sans effet sur l'empreinte : longueur fixe, { fr, en }, none | user | admin
SDM_AT_FixedLength: { allowNull: true, type: DataTypes.INTEGER },
SDM_AT_Label: { allowNull: true, type: DataTypes.JSONB },
SDM_AT_Syndication: { allowNull: false, type: DataTypes.STRING(8), defaultValue: 'none' },
},
{
sequelize,
modelName: 'SDM_AT_Attributes',
tableName: 'SDM_AT_Attributes',
timestamps: false,
paranoid: false,
}
);
return SDM_AT_Attributes;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class SDM_DC_Decisions extends Model {
static associate(models) {
SDM_DC_Decisions.belongsTo(models.SDM_LN_Links, { foreignKey: 'SDM_DC_LinkId', as: 'link' });
}
}
SDM_DC_Decisions.init(
{
SDM_DC_Id: { allowNull: false, primaryKey: true, type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
SDM_DC_LinkId: { allowNull: false, type: DataTypes.UUID },
// identités source et cible (xone_ref.<entite>.id)
SDM_DC_SourceId: { allowNull: false, type: DataTypes.UUID },
SDM_DC_TargetId: { allowNull: false, type: DataTypes.UUID },
SDM_DC_VersionNum: { allowNull: false, type: DataTypes.STRING(32) },
// propose | confirme | rejete | caduc | reconduit
SDM_DC_State: { allowNull: false, type: DataTypes.STRING(16) },
SDM_DC_DecidedAt: { allowNull: true, type: DataTypes.DATE },
SDM_DC_DecidedBy: { allowNull: true, type: DataTypes.STRING(32) },
SDM_DC_Reason: { allowNull: true, type: DataTypes.TEXT },
},
{
sequelize,
modelName: 'SDM_DC_Decisions',
tableName: 'SDM_DC_Decisions',
timestamps: false,
paranoid: false,
indexes: [{ unique: true, fields: ['SDM_DC_LinkId', 'SDM_DC_SourceId', 'SDM_DC_TargetId', 'SDM_DC_VersionNum'] }],
}
);
return SDM_DC_Decisions;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class SDM_EN_Entities extends Model {
static associate(models) {
// Une entité déclare ses attributs
SDM_EN_Entities.hasMany(models.SDM_AT_Attributes, {
foreignKey: 'SDM_AT_EntityId',
as: 'attributes',
});
}
}
SDM_EN_Entities.init(
{
SDM_EN_Id: { allowNull: false, primaryKey: true, type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
SDM_EN_Code: { allowNull: false, unique: true, type: DataTypes.STRING(64) },
SDM_EN_Label: { allowNull: false, type: DataTypes.JSONB },
SDM_EN_Referential: { allowNull: false, type: DataTypes.STRING(64) },
SDM_EN_ParentCode: { allowNull: true, type: DataTypes.STRING(64) },
SDM_EN_SortOrder: { allowNull: false, type: DataTypes.INTEGER, defaultValue: 0 },
SDM_EN_Active: { allowNull: false, type: DataTypes.BOOLEAN, defaultValue: true },
SDM_EN_CreatedDate: { allowNull: false, type: DataTypes.DATE, defaultValue: DataTypes.NOW },
SDM_EN_CreatedBy: { allowNull: true, type: DataTypes.STRING(32) },
},
{
sequelize,
modelName: 'SDM_EN_Entities',
tableName: 'SDM_EN_Entities',
timestamps: false,
paranoid: false,
}
);
return SDM_EN_Entities;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class SDM_IM_Imports extends Model {
static associate() {}
}
SDM_IM_Imports.init(
{
SDM_IM_Id: { allowNull: false, primaryKey: true, type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
SDM_IM_Referential: { allowNull: false, type: DataTypes.STRING(64) },
SDM_IM_VersionNum: { allowNull: false, type: DataTypes.STRING(32) },
SDM_IM_File: { allowNull: false, type: DataTypes.STRING(256) },
SDM_IM_FileFingerprint: { allowNull: false, type: DataTypes.STRING(64) },
// analyse | perime | valide | integre | refuse
SDM_IM_State: { allowNull: false, type: DataTypes.STRING(16), defaultValue: 'analyse' },
SDM_IM_Report: { allowNull: false, type: DataTypes.JSONB, defaultValue: {} },
SDM_IM_AnalyzedAt: { allowNull: false, type: DataTypes.DATE, defaultValue: DataTypes.NOW },
SDM_IM_AnalyzedBy: { allowNull: true, type: DataTypes.STRING(32) },
SDM_IM_ValidatedAt: { allowNull: true, type: DataTypes.DATE },
SDM_IM_ValidatedBy: { allowNull: true, type: DataTypes.STRING(32) },
SDM_IM_IntegratedAt: { allowNull: true, type: DataTypes.DATE },
SDM_IM_CreatedItems: { type: DataTypes.INTEGER, defaultValue: 0 },
SDM_IM_CreatedContents: { type: DataTypes.INTEGER, defaultValue: 0 },
SDM_IM_ReusedContents: { type: DataTypes.INTEGER, defaultValue: 0 },
SDM_IM_Memberships: { type: DataTypes.INTEGER, defaultValue: 0 },
},
{
sequelize,
modelName: 'SDM_IM_Imports',
tableName: 'SDM_IM_Imports',
timestamps: false,
paranoid: false,
}
);
return SDM_IM_Imports;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class SDM_LN_Links extends Model {
static associate() {}
}
SDM_LN_Links.init(
{
SDM_LN_Id: { allowNull: false, primaryKey: true, type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
SDM_LN_SourceEntity: { allowNull: false, type: DataTypes.STRING(64) },
// NULL si lien non natif
SDM_LN_SourceAttribute: { allowNull: true, type: DataTypes.STRING(64) },
SDM_LN_TargetEntity: { allowNull: false, type: DataTypes.STRING(64) },
SDM_LN_TargetAttribute: { allowNull: true, type: DataTypes.STRING(64) },
// natif | declare | deduit
SDM_LN_Nature: { allowNull: false, type: DataTypes.STRING(16) },
SDM_LN_Cardinality: { allowNull: false, type: DataTypes.STRING(8), defaultValue: '1,n' },
SDM_LN_Active: { allowNull: false, type: DataTypes.BOOLEAN, defaultValue: true },
},
{
sequelize,
modelName: 'SDM_LN_Links',
tableName: 'SDM_LN_Links',
timestamps: false,
paranoid: false,
}
);
return SDM_LN_Links;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
class SDM_MG_Migrations extends Model {
static associate() {}
}
SDM_MG_Migrations.init(
{
SDM_MG_Id: { allowNull: false, primaryKey: true, type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4 },
SDM_MG_FileName: { allowNull: false, unique: true, type: DataTypes.STRING(128) },
// le plan déclaratif : { type: entites | lien | vue, ... }
SDM_MG_Plan: { allowNull: false, type: DataTypes.JSONB },
// a_jouer | jouee
SDM_MG_State: { allowNull: false, type: DataTypes.STRING(16), defaultValue: 'a_jouer' },
SDM_MG_GeneratedAt: { allowNull: false, type: DataTypes.DATE, defaultValue: DataTypes.NOW },
SDM_MG_GeneratedBy: { allowNull: true, type: DataTypes.STRING(32) },
SDM_MG_PlayedAt: { allowNull: true, type: DataTypes.DATE },
},
{
sequelize,
modelName: 'SDM_MG_Migrations',
tableName: 'SDM_MG_Migrations',
timestamps: false,
paranoid: false,
}
);
return SDM_MG_Migrations;
};
const { Model } = require('sequelize');
module.exports = (sequelize, DataTypes) => {
// La fiche d'un référentiel : libellés, section, sources, mode opératoire
class SDM_RF_Referentials extends Model {}
SDM_RF_Referentials.init(
{
SDM_RF_Code: { allowNull: false, primaryKey: true, type: DataTypes.STRING(64) },
SDM_RF_Label: { allowNull: false, type: DataTypes.JSONB, defaultValue: {} },
SDM_RF_Description: { allowNull: true, type: DataTypes.TEXT },
SDM_RF_Section: { allowNull: true, type: DataTypes.STRING(64) },
// Liens http(s) vers la source d'extraction
SDM_RF_Sources: { allowNull: false, type: DataTypes.JSONB, defaultValue: [] },
// Mode opératoire d'extraction : les étapes à suivre pour l'import
SDM_RF_Procedure: { allowNull: true, type: DataTypes.TEXT },
// Référentiel propre à X-One, sans source externe
SDM_RF_InternalOnly: { allowNull: false, type: DataTypes.BOOLEAN, defaultValue: false },
SDM_RF_CreatedDate: { allowNull: false, type: DataTypes.DATE, defaultValue: DataTypes.NOW },
SDM_RF_UpdatedDate: { allowNull: false, type: DataTypes.DATE, defaultValue: DataTypes.NOW },
SDM_RF_UpdatedBy: { allowNull: true, type: DataTypes.STRING(32) },
},
{
sequelize,
modelName: 'SDM_RF_Referentials',
tableName: 'SDM_RF_Referentials',
timestamps: false,
paranoid: false,
}
);
return SDM_RF_Referentials;
};
Top comments (0)