Compare commits
3 Commits
6761c9e05a
...
60da7a01da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60da7a01da | ||
|
|
947595f086 | ||
|
|
9d568986fc |
|
|
@ -1338,12 +1338,15 @@
|
||||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-pt-sm">
|
<q-card-section class="q-pt-sm">
|
||||||
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> ajout(s) · <b class="text-negative">{{ pubSummary.removed }}</b> retrait(s) · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
|
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> quart(s)<span v-if="pubSummary.removed"> · <b class="text-negative">{{ pubSummary.removed }}</b> retiré(s)</span><span v-if="pubSummary.absAdded"> · <b class="text-orange-8">{{ pubSummary.absAdded }}</b> congé(s)</span><span v-if="pubSummary.absRemoved"> · <b class="text-teal-7">{{ pubSummary.absRemoved }}</b> congé(s) levé(s)</span> · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
|
||||||
<q-list dense bordered style="border-radius:8px;max-height:44vh;overflow:auto">
|
<q-list dense bordered style="border-radius:8px;max-height:44vh;overflow:auto">
|
||||||
<template v-for="d in pubSummary.days" :key="d.date">
|
<template v-for="g in pubSummary.techs" :key="g.tech">
|
||||||
<q-item-label header class="q-py-xs text-weight-medium">{{ fmtDueLabel(d.date) }}</q-item-label>
|
<q-item-label header class="q-py-xs text-weight-bold row items-center">
|
||||||
<q-item v-for="(a, i) in d.add" :key="'a' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="add" color="positive" size="16px" /></q-item-section><q-item-section>{{ a.name }}<span class="text-grey-6"> · {{ a.shift }}</span></q-item-section></q-item>
|
<UserAvatar :tech-id="g.tech" :name="g.name" :size="18" card class="q-mr-xs" style="flex:0 0 auto" />{{ g.name }}
|
||||||
<q-item v-for="(r, i) in d.rem" :key="'r' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="remove" color="negative" size="16px" /></q-item-section><q-item-section>{{ r.name }}<span class="text-grey-6"> · {{ r.shift }}</span></q-item-section></q-item>
|
</q-item-label>
|
||||||
|
<q-item v-for="(a, i) in g.add" :key="'a' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="add" color="positive" size="16px" /></q-item-section><q-item-section>{{ a.shift }}<span class="text-grey-6"> · {{ fmtDueLabel(a.date) }}</span></q-item-section></q-item>
|
||||||
|
<q-item v-for="(r, i) in g.rem" :key="'r' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="remove" color="negative" size="16px" /></q-item-section><q-item-section><span style="text-decoration:line-through">{{ r.shift }}</span><span class="text-grey-6"> · {{ fmtDueLabel(r.date) }}</span></q-item-section></q-item>
|
||||||
|
<q-item v-for="(ab, i) in g.abs" :key="'ab' + i" dense><q-item-section avatar style="min-width:24px"><q-icon :name="ab.op === 'set' ? 'event_busy' : 'event_available'" :color="ab.op === 'set' ? 'orange-8' : 'teal-7'" size="16px" /></q-item-section><q-item-section>{{ ab.op === 'set' ? (ab.type || 'Congé') : 'congé levé' }}<span class="text-grey-6"> · {{ fmtDueLabel(ab.date) }}</span></q-item-section></q-item>
|
||||||
</template>
|
</template>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
@ -4000,14 +4003,38 @@ function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' +
|
||||||
const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' })
|
const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' })
|
||||||
// #1 — Sommaire AVANT publication : liste les quarts NON PUBLIÉS (à publier) par jour + nb SMS.
|
// #1 — Sommaire AVANT publication : liste les quarts NON PUBLIÉS (à publier) par jour + nb SMS.
|
||||||
const pubConfirm = ref(false)
|
const pubConfirm = ref(false)
|
||||||
|
// RÉSUMÉ DE PUBLICATION — regroupé PAR TECHNICIEN. Doit refléter TOUT ce que « Publier » va changer (« au cas où on
|
||||||
|
// se trompe ») : quarts à publier (ajouts/modifs), quarts RETIRÉS (le hub supprime tout ce qui n'est plus dans la
|
||||||
|
// charge — cf. roster.js publish-week), et congés/absences en attente. Le retrait est calculé contre l'instantané
|
||||||
|
// SERVEUR pris au chargement (serverSet) → capte aussi une suppression accidentelle.
|
||||||
const pubSummary = computed(() => {
|
const pubSummary = computed(() => {
|
||||||
const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
|
const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
|
||||||
const byDate = {}
|
const shiftLabel = s => { const t = tplByName.value[s]; return (t && t.template_name) || s }
|
||||||
for (const a of unpublished.value) {
|
const byTech = {}
|
||||||
const d = (byDate[a.date] = byDate[a.date] || { date: a.date, add: [], rem: [] })
|
const bucket = tid => (byTech[tid] = byTech[tid] || { tech: tid, name: nameOf(tid), add: [], rem: [], abs: [] })
|
||||||
d.add.push({ tech: a.tech, date: a.date, shift: a.shift_name || a.shift, name: nameOf(a.tech), status: a.status })
|
// Quarts à publier = état courant non Publié (Proposé/Soumis/Approuvé → deviendront Publié)
|
||||||
|
for (const a of unpublished.value) bucket(a.tech).add.push({ date: a.date, shift: a.shift_name || shiftLabel(a.shift) })
|
||||||
|
// Quarts RETIRÉS = présents au chargement mais plus dans l'état courant → seront supprimés au Publier
|
||||||
|
let removed = 0
|
||||||
|
const cur = currentSet.value
|
||||||
|
for (const k of serverSet.value) {
|
||||||
|
if (cur.has(k)) continue
|
||||||
|
const i1 = k.indexOf('|'); const i2 = k.indexOf('|', i1 + 1); if (i1 < 0 || i2 < 0) continue
|
||||||
|
bucket(k.slice(0, i1)).rem.push({ date: k.slice(i1 + 1, i2), shift: shiftLabel(k.slice(i2 + 1)) }); removed++
|
||||||
}
|
}
|
||||||
return { added: unpublished.value.length, removed: 0, days: Object.values(byDate).sort((x, y) => String(x.date).localeCompare(String(y.date))) }
|
// Congés / absences en attente (pendingAbs) — bien envoyés au Publier (flushPendingAbsences) → doivent figurer (ex. Josée-Anne).
|
||||||
|
let absAdded = 0; let absRemoved = 0
|
||||||
|
for (const [key, v] of Object.entries(pendingAbs.value || {})) {
|
||||||
|
const i1 = key.indexOf('|'); if (i1 < 0) continue
|
||||||
|
const set = v && v.op === 'set'
|
||||||
|
bucket(key.slice(0, i1)).abs.push({ date: key.slice(i1 + 1), type: set ? (v.type || 'Congé') : null, op: set ? 'set' : 'remove' })
|
||||||
|
if (set) absAdded++; else absRemoved++
|
||||||
|
}
|
||||||
|
const list = Object.values(byTech)
|
||||||
|
const byDate = (x, y) => String(x.date).localeCompare(String(y.date))
|
||||||
|
for (const g of list) { g.add.sort(byDate); g.rem.sort(byDate); g.abs.sort(byDate) }
|
||||||
|
list.sort((a, b) => String(a.name).localeCompare(String(b.name)))
|
||||||
|
return { added: unpublished.value.length, removed, absAdded, absRemoved, techs: list }
|
||||||
})
|
})
|
||||||
|
|
||||||
const holSet = computed(() => new Set(holidays.value))
|
const holSet = computed(() => new Set(holidays.value))
|
||||||
|
|
|
||||||
|
|
@ -2573,20 +2573,48 @@ async function techHistoryBackfill ({ batch = 5000, maxBatches = 40, throttleMs
|
||||||
}
|
}
|
||||||
return { ok: true, dryRun, scanned, matched, inserted, batches, watermark: wm, done: batches < maxBatches }
|
return { ok: true, dryRun, scanned, matched, inserted, batches, watermark: wm, done: batches < maxBatches }
|
||||||
}
|
}
|
||||||
// Rapport : ?tech=TECH-<id> → lignes de ce tech ; sinon → récap par tech (compte + venant du pool + dernier).
|
// Rapport — résolution DYNAMIQUE vers l'IDENTITÉ canonique (lib/identity : SOURCE UNIQUE, éditable via l'onglet
|
||||||
|
// « Identités »). On ne stocke QUE les faits bruts (to_name/to_staff_id) ; le regroupement par personne est calculé
|
||||||
|
// À LA LECTURE via resolveIdentity(staff_id → nom) → corriger une identité (Settings) reclasse le ledger AUSSITÔT.
|
||||||
|
// is_tech = l'identité a un tech_id (⇒ Mégane, staff admin sans tech_id, n'est PAS comptée comme tech). Les noms
|
||||||
|
// non résolus (ex. prénom seul « Mégane ») remontent dans `unresolved` pour être mappés dynamiquement.
|
||||||
async function techHistory ({ tech = '', limit = 200 } = {}) {
|
async function techHistory ({ tech = '', limit = 200 } = {}) {
|
||||||
await techHistEnsureSchema()
|
await techHistEnsureSchema()
|
||||||
const pg = techHistPg(); const lim = Math.max(1, Math.min(2000, Number(limit) || 200))
|
const pg = techHistPg(); const idn = require('./identity'); const lim = Math.max(1, Math.min(5000, Number(limit) || 200))
|
||||||
if (tech) {
|
const resolve = (staffId, name) => (staffId != null && idn.resolveIdentity(String(staffId))) || idn.resolveIdentity(name || '') || null
|
||||||
const r = await pg.query('SELECT legacy_ticket_id, changed_at, to_name, tech_id, from_name, from_pool, dept, changed_by_staff FROM ops_tech_ticket_history WHERE tech_id = $1 OR to_name = $1 ORDER BY changed_at DESC LIMIT $2', [tech, lim])
|
if (tech) { // tech = clé identité | courriel | tech_id | staff_id | nom
|
||||||
const tot = await pg.query('SELECT COUNT(*)::int n, SUM(CASE WHEN from_pool THEN 1 ELSE 0 END)::int pool FROM ops_tech_ticket_history WHERE tech_id = $1 OR to_name = $1', [tech])
|
const id = idn.resolveIdentity(tech)
|
||||||
return { ok: true, tech, total: tot.rows[0].n, from_pool: tot.rows[0].pool, rows: r.rows }
|
const params = []; const conds = []
|
||||||
|
if (id && id.legacy_staff_id) { params.push(Number(id.legacy_staff_id)); conds.push('to_staff_id = $' + params.length) }
|
||||||
|
const names = [...new Set([id && id.label, tech].filter(Boolean))]
|
||||||
|
if (names.length) { params.push(names); conds.push('to_name = ANY($' + params.length + ')') }
|
||||||
|
if (!conds.length) return { ok: true, query: tech, identity: null, total: 0, from_pool: 0, rows: [] }
|
||||||
|
const where = '(' + conds.join(' OR ') + ')'
|
||||||
|
params.push(lim)
|
||||||
|
const r = await pg.query('SELECT legacy_ticket_id, changed_at, to_name, to_staff_id, from_name, from_pool, dept FROM ops_tech_ticket_history WHERE ' + where + ' ORDER BY changed_at DESC LIMIT $' + params.length, params)
|
||||||
|
const tot = await pg.query('SELECT COUNT(*)::int n, SUM(CASE WHEN from_pool THEN 1 ELSE 0 END)::int pool FROM ops_tech_ticket_history WHERE ' + where, params.slice(0, params.length - 1))
|
||||||
|
return { ok: true, query: tech, identity: id ? { key: id.key, label: id.label, email: id.primary_email, tech_id: id.tech_id || '', is_tech: !!id.tech_id, kind: id.kind, active: id.active !== false } : null, total: tot.rows[0].n, from_pool: tot.rows[0].pool, rows: r.rows }
|
||||||
|
}
|
||||||
|
// Récap : on agrège les faits bruts (borné : ~260 couples), puis on résout chacun vers l'identité.
|
||||||
|
const g = await pg.query('SELECT to_staff_id, to_name, COUNT(*)::int total, SUM(CASE WHEN from_pool THEN 1 ELSE 0 END)::int from_pool, MAX(changed_at) last_at FROM ops_tech_ticket_history GROUP BY to_staff_id, to_name')
|
||||||
|
const byKey = new Map()
|
||||||
|
for (const row of g.rows) {
|
||||||
|
const id = resolve(row.to_staff_id, row.to_name)
|
||||||
|
const key = id ? id.key : ('name:' + row.to_name)
|
||||||
|
let e = byKey.get(key)
|
||||||
|
if (!e) { e = { key, label: id ? id.label : row.to_name, email: id ? id.primary_email : '', is_tech: id ? !!id.tech_id : false, kind: id ? id.kind : null, resolved: !!id, total: 0, from_pool: 0, last_at: null, raw_names: [] }; byKey.set(key, e) }
|
||||||
|
e.total += row.total; e.from_pool += row.from_pool
|
||||||
|
if (!e.last_at || row.last_at > e.last_at) e.last_at = row.last_at
|
||||||
|
if (!id) e.raw_names.push({ to_name: row.to_name, to_staff_id: row.to_staff_id })
|
||||||
|
}
|
||||||
|
const all = [...byKey.values()].sort((a, b) => b.total - a.total)
|
||||||
|
const grand = await pg.query('SELECT COUNT(*)::int n FROM ops_tech_ticket_history')
|
||||||
|
return {
|
||||||
|
ok: true, total_rows: grand.rows[0].n, assignees: all.length,
|
||||||
|
techs: all.filter(x => x.is_tech).length, non_tech: all.filter(x => x.resolved && !x.is_tech).length,
|
||||||
|
unresolved: all.filter(x => !x.resolved).map(x => ({ label: x.label, total: x.total, staff_id: (x.raw_names[0] || {}).to_staff_id || null })).slice(0, 50),
|
||||||
|
per_assignee: all.slice(0, lim).map(({ raw_names, ...e }) => e),
|
||||||
}
|
}
|
||||||
const r = await pg.query(`SELECT COALESCE(tech_id, to_name) key, MAX(to_name) name, COUNT(*)::int total,
|
|
||||||
SUM(CASE WHEN from_pool THEN 1 ELSE 0 END)::int from_pool, MAX(changed_at) last_at
|
|
||||||
FROM ops_tech_ticket_history GROUP BY COALESCE(tech_id, to_name) ORDER BY total DESC LIMIT $1`, [lim])
|
|
||||||
const grand = await pg.query('SELECT COUNT(*)::int n, COUNT(DISTINCT COALESCE(tech_id,to_name))::int techs FROM ops_tech_ticket_history')
|
|
||||||
return { ok: true, total_rows: grand.rows[0].n, techs: grand.rows[0].techs, per_tech: r.rows }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user