feat(ops): sync fiber ONU from F on swap (reconcile-on-load + daily)

Fiber twin of the wireless-MAC issue: when an ONT is replaced, F's `fibre` table
holds the current serial + OLT coords, but OPS keeps the old serial → dostuff
queries a serial no longer on the OLT → shows "offline" for an online customer
(C-LPB4: OPS had TPLGC4160688/ontid 0; F fibre id 13890 = TPLG55684520/ontid 2).

- hub: fibreById() reads F `fibre` by the SE's legacy_fibre_id (hub reaches
  gestionclient); reconcileFibreOnu() aligns serial_number/gpon_serial/olt_ip/
  slot/port/ontid; fibreLive({equipment}) reconciles THEN dostuffs with the current
  serial. GET /collab/fibre-live, POST /collab/fibre-sync, and syncFibreOnus()
  (park-wide, batched) folded into the daily 03h tick alongside wireless.
- ops: the fiber hero + "Do Stuff en direct" now call /collab/fibre-live (auto
  reconcile on load), reflect the corrected serial locally, and show "ONU
  synchronisée depuis F (remplacement)".

Verified: fibreLive(EQP-0000011366) auto-corrected TPLGC4160688 → TPLG55684520
(logged), and dostuff on the current serial = online (Rx -14.39 / Tx -13.78, bon,
uptime 1h17). Build clean, leak 0; deployed index.d652a898.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-21 14:36:33 -04:00
parent 81701724a2
commit 93588975ad
2 changed files with 153 additions and 22 deletions

View File

@ -99,6 +99,9 @@
</div>
<div v-else-if="!liveFLoading && liveF && !liveF.ok" class="dv-wan-ph"><q-icon name="info" size="16px" color="grey-5" /> {{ liveF.error || 'OLT injoignable' }}</div>
<div v-else-if="liveFLoading && !liveF" class="dv-wan-ph"><q-icon name="cell_tower" size="16px" color="grey-4" /> Lecture des données OLT en direct</div>
<div v-if="liveF && liveF.onuSynced" class="dsf-note" style="color:#166534">
<q-icon name="check_circle" size="13px" color="green-7" /> ONU synchronisée depuis F (remplacement) : <code>{{ liveF.serial }}</code><span v-if="liveF.onuWas"> (était {{ liveF.onuWas }})</span>
</div>
</div>
<!-- Do Stuff en direct (F) : interroge l'OLT via n8n (comme F) remonte VoIP / IPTV / distance / WAN / profil de
@ -760,14 +763,17 @@ function fibreRxColor (rx) { if (rx == null) return '#64748b'; if (rx <= -8 && r
const mgmtUrl = (ip) => ip ? ('https://' + String(ip).replace(/:\d+$/, '') + '/') : null
async function fetchLiveF () {
const sn = props.doc.serial_number, olt = props.doc.olt_ip
if (!sn || !olt) return
const olt = props.doc.olt_ip
if (!props.doc.serial_number && !props.docName) return
liveF.value = null; liveFLoading.value = true
try {
const r = await fetch(`${HUB_URL}/collab/dostuff?serial=${encodeURIComponent(sn)}&olt=${encodeURIComponent(olt)}`)
// fibre-live : reconcilie d'abord l'ONU depuis F (serial COURANT après un remplacement) puis interroge l'OLT.
const r = await fetch(`${HUB_URL}/collab/fibre-live?equipment=${encodeURIComponent(props.docName || '')}&serial=${encodeURIComponent(props.doc.serial_number || '')}&olt=${encodeURIComponent(olt || '')}`)
const res = await r.json()
if (!res.ok && res.recognized === false) res.error = 'OLT suivie par notre sonde (données ci-dessus) — direct réservé aux OLT XGS-PON (tech 3)'
liveF.value = res
// ONU réalignée sur F (remplacement d'ONT) refléter le serial courant localement + le signaler.
if (res && res.onuSynced && res.serial) { props.doc.serial_number = res.serial; $q.notify({ type: 'positive', message: 'ONU synchronisée depuis F : ' + res.serial + (res.onuWas ? ' (était ' + res.onuWas + ')' : '') }) }
} catch (e) { liveF.value = { ok: false, error: e.message } } finally { liveFLoading.value = false }
}

View File

@ -5,6 +5,8 @@
const cfg = require('./config')
const { log, json, parseBody, lookupCustomersByPhone, lookupCustomersByEmail, readJsonFile } = require('./helpers')
const erp = require('./erp')
const { QUEUES } = require('./categories') // les 4 files (aussi des Issue Type valides) — repli de classification
const { resolveIssueType } = require('./issue-types') // category → Issue Type réel canonique (ou null) — voir lib/issue-types.js
// Mots « problème » (jamais un nom → toujours retirés) vs mots « rue » (retirés SEULEMENT en contexte adresse,
// pour ne pas amputer un nom comme « Saint-Pierre »).
@ -53,38 +55,58 @@ async function searchCustomers (q) {
if (rows.length) addrResolved = true // → on saute la recherche par NOM (homonymes) plus bas
} catch (e) { log('searchCustomers addr: ' + e.message) }
}
// 3) Nom (Customer) — FUZZY : normalisé (sans séparateurs/accents/casse) + similarité trigramme (score façon Google, tolère typos).
// 3) Nom (Customer) — CHAÎNE EXACTE D'ABORD, pg_trgm en REPLI.
// Passe 1 = le nom CONTIENT la chaîne littérale tapée (NORM LIKE '%qn%') : « louispaul » ne ramène QUE les
// vrais « Louis-Paul » (« louisgagnon » ne contient PAS « louispaul ») → zéro homonyme, le flou ne tourne même pas.
// Passe 2 = SEULEMENT si la passe 1 est vide (dictée / faute de frappe, ex. « bourdn ») → on élargit à la
// similarité trigramme word_similarity. Ainsi le bruit flou n'apparaît QUE quand l'exact ne donne rien.
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORM = "regexp_replace(unaccent(lower(customer_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
// word_similarity($q, nom) = meilleure sous-chaîne → « bourdn » matche « andrebourdon ». Seuil explicite (pas de GUC).
// On INCLUT les clients désactivés (anciens / réactivation) mais on classe les ACTIFS d'abord (+ badge « inactif » côté UI).
// Passe 1 — EXACT/littéral (pas de trigramme). exact = nom identique ; tous « strong » (contiennent la chaîne).
// Actifs d'abord, puis le plus court (le plus proche de l'exact : « Louis-Paul » avant « Louis-Paul Bergeron »).
rows = (await p.query(
`SELECT name, customer_name, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 12`, [qn])).rows
`SELECT name, customer_name, (${NORM} = $1) AS exact, TRUE AS strong, NULL::float AS score FROM "tabCustomer"
WHERE ${NORM} LIKE '%'||$1||'%'
ORDER BY exact DESC, coalesce(disabled,0) ASC, length(customer_name) ASC LIMIT 12`, [qn])).rows
// Passe 2 — REPLI FLOU (pg_trgm) : uniquement si l'exact n'a RIEN trouvé. Seuil explicite (pas de GUC).
if (!rows.length) {
rows = (await p.query(
`SELECT name, customer_name, FALSE AS exact, FALSE AS strong, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
WHERE word_similarity($1, ${NORM}) > 0.4
ORDER BY coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 12`, [qn])).rows
}
} catch (e) {
// Repli si pg_trgm indisponible : sous-chaîne normalisée seule.
try { rows = (await p.query(`SELECT name, customer_name FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY coalesce(disabled,0) ASC, customer_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchCustomers name: ' + e2.message) }
// Repli si pg_trgm indisponible : chaîne littérale seule (tous « strong » par définition).
try { rows = (await p.query(`SELECT name, customer_name, (${NORM} = $1) AS exact, TRUE AS strong FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY exact DESC, coalesce(disabled,0) ASC, customer_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchCustomers name: ' + e2.message) }
}
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null, exact: !!row.exact, strong: !!row.strong })
}
}
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy.
// try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (pas de régression).
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). Sur les champs legacy,
// MÊME stratégie : chaîne EXACTE d'abord, flou en repli. Le repli flou ne tourne QUE si aucun match FORT n'a
// déjà été trouvé (nom / téléphone / courriel / adresse) — sinon un « louispaul » résolu se ferait polluer par
// des mandataires « Louis X » au trigramme. try/catch : colonnes custom absentes → ignoré (pas de régression).
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')"
const hasStrong = [...found.values()].some(v => v.exact || v.strong || ['téléphone', 'courriel', 'adresse'].includes(v.matched))
try {
const rows = (await p.query(
`SELECT name, customer_name, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
WHERE (${NORMM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORMM}) > 0.45)
ORDER BY (${NORMM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
// Passe 1 — chaîne littérale dans mandataire/contact.
let rows = (await p.query(
`SELECT name, customer_name, TRUE AS strong, NULL::float AS score FROM "tabCustomer"
WHERE ${NORMM} LIKE '%'||$1||'%' ORDER BY coalesce(disabled,0) ASC, length(customer_name) ASC LIMIT 6`, [qn])).rows
// Passe 2 — flou (pg_trgm), uniquement si rien en littéral ET aucun match fort ailleurs.
if (!rows.length && !hasStrong) {
rows = (await p.query(
`SELECT name, customer_name, FALSE AS strong, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
WHERE word_similarity($1, ${NORMM}) > 0.45 ORDER BY coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
}
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null, strong: !!row.strong })
} catch (e) { log('searchCustomers mandataire: ' + e.message) }
}
}
@ -101,6 +123,10 @@ async function searchCustomers (q) {
mobile, phone: mobile || r.tel_home || r.tel_office || '', can_sms: !!mobile,
territory: r.territory || '', address: f.address || '', matched: f.matched, inactive: !!r.disabled,
service_location: f.service_location || null,
// Tiers de correspondance (désambiguïsation) : exact = nom identique ; strong = identifiant précis
// (téléphone/courriel/adresse) OU nom qui contient la recherche complète. Le flou trigramme seul → ni l'un ni l'autre.
exact: !!f.exact,
strong: !!f.strong || ['téléphone', 'courriel', 'adresse'].includes(f.matched),
}
})
}
@ -332,12 +358,18 @@ async function createTicket ({ title, category, priority, description, customer,
const t = String(title || '').trim()
if (!t) return { ok: false, error: 'titre requis' }
const cat = String(category || '').trim()
// category → Issue Type réel canonique (ou null si hors-doctype : « Autre »/« Commercial » de l'IA/dictée NL) — voir lib/issue-types.js.
const resolvedType = await resolveIssueType(cat)
const st = (status === 'On Hold' || status === 'pending' || status === 'En attente') ? 'On Hold' : 'Open' // « pending » = suspendu jusqu'à la date de rappel
const data = {
subject: (cat ? '[' + cat + '] ' : '') + t.slice(0, 130), status: st, priority: priority || 'Medium',
// [Cat] seulement si le type n'est PAS porté par issue_type (sinon redondant — classification = champ filtrable).
subject: ((cat && !resolvedType) ? '[' + cat + '] ' : '') + t.slice(0, 130), status: st, priority: priority || 'Medium',
description: (description ? String(description) + '\n\n' : '') + (agent ? '— créé par ' + shortName(agent) + ' (OPS)' : ''),
}
if (customer) data.customer = customer
// issue_type : type choisi (résolu) en priorité ; repli = la file de routage si c'en est une (les 4 files sont des Issue Type valides).
if (resolvedType) data.issue_type = resolvedType
else if (queue && QUEUES.includes(queue)) data.issue_type = queue
// Adresse de service : celle fournie, SINON l'adresse active la plus récente du compte (auto).
if (!service_location && customer) { try { service_location = await defaultServiceLocation(customer) } catch (e) {} }
if (service_location) data.custom_service_location = service_location // lie le ticket à la BONNE adresse de service
@ -696,6 +728,14 @@ async function handle (req, res, method, p, url) {
if (p === '/collab/airos-mac-sync' && method === 'POST') {
return json(res, 200, await syncWirelessMacs())
}
// GET /collab/fibre-live?equipment=EQP-xxx[&serial=&olt=] — reconcilie l'ONU depuis F fibre (serial courant) PUIS dostuff.
if (p === '/collab/fibre-live' && method === 'GET') {
return json(res, 200, await fibreLive({ equipment: url.searchParams.get('equipment') || '', serial: url.searchParams.get('serial') || '', olt: url.searchParams.get('olt') || '' }))
}
// POST /collab/fibre-sync — reconcile ONU fibre park-wide à la demande (le planificateur 03h l'appelle aussi).
if (p === '/collab/fibre-sync' && method === 'POST') {
return json(res, 200, await syncFibreOnus())
}
// GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
if (p === '/collab/negative-billing' && method === 'GET') {
return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
@ -811,6 +851,31 @@ function dostuffStatus ({ sn, olt } = {}) {
})
}
// Signal optique fibre RAPIDE (TP-Link / tech-3) via le webhook n8n dédié `get_signal_tplink` (~3-4 s) — c'est celui
// que F utilise pour la pastille de signal instantané. Renvoie SEULEMENT rx/tx dBm + online (pas distance/uptime/etc.,
// pour ça → dostuffStatus, ~27 s). online: true (Status 1) / false (Status 0) / null (statut vide = indéterminé → repli).
function fiberSignalFast ({ sn, olt } = {}) {
return new Promise((resolve) => {
if (!sn || !olt) return resolve({ ok: false, error: 'sn + olt requis' })
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn), olt: String(olt) })
const req = https.request('https://n8napi.targo.ca/webhook/get_signal_tplink', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let j; try { j = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
const r = Array.isArray(j) ? j[0] : j
if (!r) return resolve({ ok: false, error: 'réponse vide' })
const clean = (v) => String(v == null ? '' : v).replace(/"/g, '').trim()
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
const st = clean(r.Status)
resolve({ ok: true, source: 'get_signal_tplink', rx_dbm: num(r.RxSignal), tx_dbm: num(r.TxSignal), signal: signalFrom(num(r.RxSignal)), online: st === '1' ? true : (st === '0' ? false : null) })
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~15 s)' }) })
req.write(body); req.end()
})
}
// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE. Le hub NE JOINT PAS les CPE (réseau terrain non routable —
// vérifié : timeouts). PRIMAIRE = pont F `ops_airos.php` sur facturation (F joint les CPE + a le code airOS + les
// creds ; même auth X-Ops-Token que ops_reassign.php). REPLI = webhook n8n `get_signal_airos` (chemin post-migration
@ -939,6 +1004,66 @@ async function syncWirelessMacs () {
}
// Planificateur quotidien 03 h ET (tick /30 min ; s'exécute 1×/jour dans la fenêtre 03h). Désactivable WIRELESS_MAC_SYNC=off.
// ── FIBRE : l'ONU COURANT est dans la table F `fibre` (autoritaire — F la met à jour au remplacement d'ONT). OPS
// Service Equipment garde `legacy_fibre_id` → on relit fibre.sn/slot/port/ontid/info_connect et on aligne (le serial
// OPS devient périmé après un swap → dostuff par l'ancien serial = « hors ligne » à tort). Le hub joint gestionclient.
let _fpool = null
function fPool () {
if (_fpool) return _fpool
let mysql; try { mysql = require('mysql2/promise') } catch (e) { return null }
_fpool = mysql.createPool({ host: process.env.LEGACY_DB_HOST || '10.100.80.100', user: process.env.LEGACY_DB_USER || 'facturation', password: process.env.LEGACY_DB_PASS, database: process.env.LEGACY_DB_NAME || 'gestionclient', connectionLimit: 2, connectTimeout: 8000 })
return _fpool
}
async function fibreById (fibreId) {
const p = fPool(); if (!p || !fibreId) return null
try { const [rows] = await p.query('SELECT id, sn, tech, info_connect, slot, port, ontid FROM fibre WHERE id=? LIMIT 1', [Number(fibreId)]); return (rows && rows[0]) || null } catch (e) { return null }
}
// Aligne l'ONU OPS sur la ligne F fibre (serial + gpon_serial + olt_ip/slot/port/ontid). Renvoie {changed, was}.
async function reconcileFibreOnu (se, f) {
const changed = {}
if (f.sn && String(f.sn) !== String(se.serial_number || '')) { changed.serial_number = f.sn; changed.gpon_serial = f.sn }
if (f.info_connect && String(f.info_connect) !== String(se.olt_ip || '')) changed.olt_ip = f.info_connect
if (f.slot != null && Number(f.slot) !== Number(se.olt_slot)) changed.olt_slot = Number(f.slot)
if (f.port != null && Number(f.port) !== Number(se.olt_port)) changed.olt_port = Number(f.port)
if (f.ontid != null && Number(f.ontid) !== Number(se.olt_ontid)) changed.olt_ontid = Number(f.ontid)
if (!Object.keys(changed).length) return { changed: null }
const res = await erp.update('Service Equipment', se.name, changed).catch(e => ({ ok: false, error: e.message }))
if (res && res.ok) { const was = se.serial_number || null; Object.assign(se, changed); log('[fibre] ONU sync ' + se.name + ' ' + (was || '∅') + ' → ' + (changed.serial_number || se.serial_number) + ' (F fibre)'); return { changed, was } }
return { changed: null, error: (res && res.error) }
}
// Statut fibre LIVE : reconcilie l'ONU depuis F fibre (serial COURANT après swap) PUIS dostuff avec le BON serial.
async function fibreLive ({ equipment, serial, olt } = {}) {
let se = null, onuSynced = false, onuWas = null
if (equipment) se = await erp.get('Service Equipment', equipment, { fields: ['name', 'serial_number', 'gpon_serial', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'legacy_fibre_id'] }).catch(() => null)
if (se && se.legacy_fibre_id) {
const f = await fibreById(se.legacy_fibre_id)
if (f && f.sn) { const rc = await reconcileFibreOnu(se, f); if (rc.changed) { onuSynced = true; onuWas = rc.was } serial = serial || f.sn; olt = olt || f.info_connect }
}
serial = serial || (se && se.serial_number); olt = olt || (se && se.olt_ip)
if (!serial || !olt) return { ok: false, error: 'ONU non résolu (serial/olt manquant)', onuSynced, onuWas }
const status = await dostuffStatus({ sn: serial, olt })
return { ...status, serial, onuSynced, onuWas }
}
// Reconcile fibre park-wide (job quotidien) : SE fibre (legacy_fibre_id) → F fibre (en lots) → aligne les changées.
async function syncFibreOnus () {
const p = fPool(); if (!p) return { ok: false, error: 'F DB indisponible' }
const ses = await erp.list('Service Equipment', { filters: [['legacy_fibre_id', '>', 0]], fields: ['name', 'serial_number', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'legacy_fibre_id'], limit: 20000 }).catch(() => [])
const byFib = {}; const ids = ses.map(s => Number(s.legacy_fibre_id)).filter(Boolean)
for (let i = 0; i < ids.length; i += 500) {
const batch = ids.slice(i, i + 500); if (!batch.length) break
try { const [rows] = await p.query('SELECT id, sn, info_connect, slot, port, ontid FROM fibre WHERE id IN (' + batch.map(() => '?').join(',') + ')', batch); for (const r of rows) byFib[Number(r.id)] = r } catch (e) { /* */ }
}
let checked = 0, updated = 0, errs = 0
for (const se of ses) {
const f = byFib[Number(se.legacy_fibre_id)]; if (!f || !f.sn) continue
checked++
const rc = await reconcileFibreOnu(se, f)
if (rc.changed) updated++; else if (rc.error) errs++
}
log('[fibre] daily ONU sync — SE ' + ses.length + ' · checked ' + checked + ' · updated ' + updated + ' · errs ' + errs)
return { ok: true, se: ses.length, checked, updated, errs }
}
let _lastMacSyncDay = null; let _macSyncStarted = false
function startWirelessMacSync () {
if (process.env.WIRELESS_MAC_SYNC === 'off') return
@ -947,7 +1072,7 @@ function startWirelessMacSync () {
const now = new Date()
const day = now.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const hour = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Toronto', hour: 'numeric', hour12: false }).format(now), 10)
if (hour === 3 && _lastMacSyncDay !== day) { _lastMacSyncDay = day; await syncWirelessMacs() }
if (hour === 3 && _lastMacSyncDay !== day) { _lastMacSyncDay = day; await syncWirelessMacs(); await syncFibreOnus() }
} catch (e) { log('[airos] mac-sync tick: ' + e.message) }
}
setInterval(tick, 30 * 60 * 1000)
@ -973,4 +1098,4 @@ function airosViaN8n ({ sn, ip, port, user } = {}) {
})
}
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, airosSignal, airosStatus, syncWirelessMacs, startWirelessMacSync }
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, fiberSignalFast, airosSignal, airosStatus, syncWirelessMacs, startWirelessMacSync, searchCustomers, fibreLive, syncFibreOnus }