fix(ops): accurate occupancy denominator + editable skills + support filter
Three fixes from live feedback: 1. techOccupancy counted only jobs WITH a start_time → legacy osTicket jobs (assigned, no appointment hour) showed as free (e.g. Houssam "40h libre" with 6 jobs). Now untimed assigned jobs count toward busy (rendered as hatched "unscheduled" blocks) and jobs match by technician_id OR docname. techOccupancy also accepts CSV skills (require ALL) for multi-skill needs. 2. Support now maps to a 'support' skill (was []), so it lists only support-skilled agents — techs with no skills set are excluded. Added 'support' to SKILL_VOCAB + DEPARTMENT_SKILLS. 3. AvailabilityByReason skills list is editable: auto-added skills (e.g. 'sans-fil' from wireless service address) can be removed per-chip when not relevant. Bands + summary now reflect ALL selected skills. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
1552df3984
commit
7df6ad30e3
|
|
@ -18,6 +18,7 @@ import { Notify } from 'quasar'
|
||||||
import { DEPARTMENTS, suggestDepartments } from 'src/config/departments'
|
import { DEPARTMENTS, suggestDepartments } from 'src/config/departments'
|
||||||
import { resolveSkills } from 'src/api/roster'
|
import { resolveSkills } from 'src/api/roster'
|
||||||
import OccupancyBands from 'src/components/shared/OccupancyBands.vue'
|
import OccupancyBands from 'src/components/shared/OccupancyBands.vue'
|
||||||
|
import { skillSym } from 'src/composables/useSkillIcons' // icônes de compétences (SOURCE UNIQUE, partagée avec Planif)
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
|
|
@ -27,6 +28,7 @@ const props = defineProps({
|
||||||
address: { type: String, default: '' },
|
address: { type: String, default: '' },
|
||||||
lat: { type: Number, default: null },
|
lat: { type: Number, default: null },
|
||||||
lng: { type: Number, default: null },
|
lng: { type: Number, default: null },
|
||||||
|
connectionType: { type: String, default: '' }, // type de connexion du lieu (Fibre/Wireless/LTE…) → injecte « sans-fil » si sans-fil
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue', 'book'])
|
const emit = defineEmits(['update:modelValue', 'book'])
|
||||||
|
|
||||||
|
|
@ -38,13 +40,25 @@ const resolved = ref(null) // { skills, primary, confidence, source, reason }
|
||||||
const aiLoading = ref(false)
|
const aiLoading = ref(false)
|
||||||
const occ = ref(null) // dernier chargement des bandes → résumé du dénominateur
|
const occ = ref(null) // dernier chargement des bandes → résumé du dénominateur
|
||||||
|
|
||||||
// Compétence(s) effective(s) : résolveur si présent, sinon skills du chip choisi.
|
// Le client est-il en technologie SANS-FIL (vs fibre) ? → toute intervention terrain exige aussi « sans-fil ».
|
||||||
const skills = computed(() => {
|
const wireless = computed(() => /wireless|\blte\b|sans.?fil|\bfwa\b|radio|antenne/i.test(String(props.connectionType || '')))
|
||||||
if (resolved.value && resolved.value.skills) return resolved.value.skills
|
|
||||||
const d = DEPARTMENTS.find(x => x.category === dept.value)
|
// Compétence(s) effective(s) — ÉDITABLE : dérivée du résolveur/chip (+ « sans-fil » si client sans-fil),
|
||||||
return d ? (d.skills || []) : []
|
// mais l'agent peut RETIRER une compétence ajoutée à tort (ex. « sans-fil » sur un souci non lié au radio).
|
||||||
})
|
const skills = ref([])
|
||||||
|
function deriveSkills () {
|
||||||
|
const base = (resolved.value && resolved.value.skills)
|
||||||
|
? [...resolved.value.skills]
|
||||||
|
: (() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? [...(d.skills || [])] : [] })()
|
||||||
|
// Injecté seulement s'il y a DÉJÀ une compétence terrain (une demande traitée à distance ne devient pas « sans-fil »).
|
||||||
|
if (wireless.value && base.length && !base.some(s => /sans.?fil|wireless/i.test(s))) base.push('sans-fil')
|
||||||
|
skills.value = base
|
||||||
|
}
|
||||||
|
// Re-dérive quand le motif/la résolution/le type de connexion change (réinitialise donc les retraits manuels — voulu).
|
||||||
|
watch([resolved, dept, wireless], deriveSkills, { immediate: true })
|
||||||
|
function removeSkill (s) { skills.value = skills.value.filter(x => x !== s) }
|
||||||
const primarySkill = computed(() => skills.value[0] || '')
|
const primarySkill = computed(() => skills.value[0] || '')
|
||||||
|
const isWirelessSkill = (s) => /sans.?fil|wireless/i.test(String(s || ''))
|
||||||
const durH = computed(() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? (d.dur || 1) : 1 })
|
const durH = computed(() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? (d.dur || 1) : 1 })
|
||||||
|
|
||||||
// Résumé du DÉNOMINATEUR ajusté (agrégé depuis les bandes) : heures libres + nb de techs qualifiés.
|
// Résumé du DÉNOMINATEUR ajusté (agrégé depuis les bandes) : heures libres + nb de techs qualifiés.
|
||||||
|
|
@ -137,7 +151,13 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
||||||
<q-icon :name="skills.length ? 'engineering' : 'support_agent'" size="18px" :color="skills.length ? 'teal-7' : 'blue-grey-5'" />
|
<q-icon :name="skills.length ? 'engineering' : 'support_agent'" size="18px" :color="skills.length ? 'teal-7' : 'blue-grey-5'" />
|
||||||
<span v-if="skills.length" class="text-weight-medium">
|
<span v-if="skills.length" class="text-weight-medium">
|
||||||
Compétence requise :
|
Compétence requise :
|
||||||
<q-chip v-for="s in skills" :key="s" dense square color="teal-1" text-color="teal-9" class="q-ml-xs">{{ s }}</q-chip>
|
<q-chip v-for="s in skills" :key="s" dense square removable :icon="skillSym(s)"
|
||||||
|
:color="isWirelessSkill(s) ? 'indigo-1' : 'teal-1'" :text-color="isWirelessSkill(s) ? 'indigo-9' : 'teal-9'" class="q-ml-xs"
|
||||||
|
@remove="removeSkill(s)">
|
||||||
|
{{ s }}
|
||||||
|
<q-tooltip v-if="isWirelessSkill(s) && wireless">Ajoutée automatiquement (client sans-fil {{ connectionType }}) — ✕ pour retirer si non lié</q-tooltip>
|
||||||
|
<q-tooltip v-else>✕ pour retirer cette compétence si non requise</q-tooltip>
|
||||||
|
</q-chip>
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-blue-grey-7">Aucune compétence terrain — traité à distance (tous les agents).</span>
|
<span v-else class="text-blue-grey-7">Aucune compétence terrain — traité à distance (tous les agents).</span>
|
||||||
<q-badge outline color="grey-6" class="q-ml-xs">{{ confidenceLabel[resolved.confidence] || resolved.confidence }}</q-badge>
|
<q-badge outline color="grey-6" class="q-ml-xs">{{ confidenceLabel[resolved.confidence] || resolved.confidence }}</q-badge>
|
||||||
|
|
@ -153,10 +173,10 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
||||||
<q-space />
|
<q-space />
|
||||||
<div v-if="summary" class="text-right">
|
<div v-if="summary" class="text-right">
|
||||||
<div class="text-h6 text-teal-8" style="line-height:1.1">{{ summary.free_h }} h <span class="text-caption text-grey-6">libres</span></div>
|
<div class="text-h6 text-teal-8" style="line-height:1.1">{{ summary.free_h }} h <span class="text-caption text-grey-6">libres</span></div>
|
||||||
<div class="text-caption text-grey-7">{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ primarySkill ? ' « ' + primarySkill + ' »' : '' }} · {{ summary.shift_h }} h de quart</div>
|
<div class="text-caption text-grey-7">{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ skills.length ? ' « ' + skills.join(' + ') + ' »' : '' }} · {{ summary.shift_h }} h de quart</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OccupancyBands :skill="primarySkill" :after-date="afterDate" :days="7" :active="true" @day="onOccDay" @loaded="v => occ = v" />
|
<OccupancyBands :skill="skills.join(',')" :after-date="afterDate" :days="7" :active="true" @day="onOccDay" @loaded="v => occ = v" />
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis un motif ou dicte la raison pour voir la disponibilité ajustée à la compétence.</div>
|
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis un motif ou dicte la raison pour voir la disponibilité ajustée à la compétence.</div>
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,9 @@ defineExpose({ reload: load })
|
||||||
<div v-for="(c, i) in t.days" :key="t.tech_id + i" class="occ-cell" @click="onDay(c)">
|
<div v-for="(c, i) in t.days" :key="t.tech_id + i" class="occ-cell" @click="onDay(c)">
|
||||||
<div v-if="c.off" class="occ-strip off"><q-tooltip>{{ occShort(c.date) }} · {{ offLabel(c) }}</q-tooltip></div>
|
<div v-if="c.off" class="occ-strip off"><q-tooltip>{{ occShort(c.date) }} · {{ offLabel(c) }}</q-tooltip></div>
|
||||||
<div v-else class="occ-strip clickable">
|
<div v-else class="occ-strip clickable">
|
||||||
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved }"
|
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved, untimed: b.untimed }"
|
||||||
:style="{ top: (b.top * 100) + '%', height: Math.max(3, b.height * 100) + '%' }"></div>
|
:style="{ top: (b.top * 100) + '%', height: Math.max(3, b.height * 100) + '%' }"></div>
|
||||||
<q-tooltip>{{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}<br>{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}</q-tooltip>
|
<q-tooltip>{{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}<br>{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}<span v-if="c.untimed_jobs"><br>⚠ {{ c.untimed_jobs }} sans heure fixée</span></q-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -108,6 +108,8 @@ defineExpose({ reload: load })
|
||||||
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
|
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
|
||||||
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
|
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
|
||||||
.occ-block.reserved { background: #f59e0b; }
|
.occ-block.reserved { background: #f59e0b; }
|
||||||
|
/* Jobs assignés SANS heure fixée (legacy) : occupent la journée mais heure non planifiée → hachuré. */
|
||||||
|
.occ-block.untimed { background: repeating-linear-gradient(45deg, #2563eb, #2563eb 3px, #60a5fa 3px, #60a5fa 6px); opacity: .85; }
|
||||||
.occ-legend { font-size: 10.5px; color: #475569; }
|
.occ-legend { font-size: 10.5px; color: #475569; }
|
||||||
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
||||||
.occ-dot.job { background: #2563eb; }
|
.occ-dot.job { background: #2563eb; }
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
* Éditer ici pour ajuster les mots-clés / compétences — une seule source.
|
* Éditer ici pour ajuster les mots-clés / compétences — une seule source.
|
||||||
*/
|
*/
|
||||||
export const DEPARTMENTS = [
|
export const DEPARTMENTS = [
|
||||||
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: [], dur: 1,
|
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: ['support'], dur: 1,
|
||||||
re: /\b(wi-?fi|internet|lent[e]?|ralenti|panne|coup[ée]+|connexion|connecter?|signal|modem|routeur|red[ée]marr|d[ée]connect|d[ée]branch|intermittent|latence|ping|ne marche|ne fonctionne|pas d['e]internet|hors service|bug)\b/i },
|
re: /\b(wi-?fi|internet|lent[e]?|ralenti|panne|coup[ée]+|connexion|connecter?|signal|modem|routeur|red[ée]marr|d[ée]connect|d[ée]branch|intermittent|latence|ping|ne marche|ne fonctionne|pas d['e]internet|hors service|bug)\b/i },
|
||||||
{ category: 'Facturation', queue: 'Facturations', icon: 'receipt_long', color: 'green-7', skills: [], dur: 1,
|
{ category: 'Facturation', queue: 'Facturations', icon: 'receipt_long', color: 'green-7', skills: [], dur: 1,
|
||||||
re: /\b(factur|paiement|payer|pay[ée]|solde|rembours|pr[ée]l[èe]v|carte de cr[ée]dit|montant|frais|cr[ée]dit|impay[ée]|retard de paiement|re[çc]u|virement|interac|trop per[çc]u)\b/i },
|
re: /\b(factur|paiement|payer|pay[ée]|solde|rembours|pr[ée]l[èe]v|carte de cr[ée]dit|montant|frais|cr[ée]dit|impay[ée]|retard de paiement|re[çc]u|virement|interac|trop per[çc]u)\b/i },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ const cfg = require('./config')
|
||||||
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
|
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
|
||||||
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
|
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
|
||||||
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
|
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
|
||||||
const { techHasSkill } = require('./skill-resolver')
|
const { techHasSkill, techHasSkills } = require('./skill-resolver')
|
||||||
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
|
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
|
||||||
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
|
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
|
||||||
|
|
||||||
|
|
@ -307,7 +307,8 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
||||||
const baseDate = after_date || todayET()
|
const baseDate = after_date || todayET()
|
||||||
const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech)
|
const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech)
|
||||||
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
|
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
|
||||||
const wantSkill = String(skill || '').trim().toLowerCase()
|
// `skill` accepte UNE compétence OU plusieurs en CSV (« réparation,sans-fil ») → le tech doit les avoir TOUTES.
|
||||||
|
const wantSkills = String(skill || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||||
|
|
||||||
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
|
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
|
||||||
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
|
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
|
||||||
|
|
@ -325,7 +326,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
||||||
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
|
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
|
||||||
])
|
])
|
||||||
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
|
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
|
||||||
const hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
|
const hasSkill = (t) => techHasSkills(t.skills || t._user_tags, wantSkills)
|
||||||
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
|
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
|
||||||
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
|
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
|
||||||
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
|
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
|
||||||
|
|
@ -354,21 +355,37 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
||||||
if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
|
if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
|
||||||
if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
|
if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
|
||||||
const shiftH = shift.end_h - shift.start_h
|
const shiftH = shift.end_h - shift.start_h
|
||||||
const dayJobs = allJobs
|
// Match par technician_id OU docname (comme les quarts/congés) : les jobs legacy portent parfois l'un ou l'autre.
|
||||||
.filter(j => j.assigned_tech === tech.technician_id && j.scheduled_date === dateStr && j.start_time)
|
const dayJobsRaw = allJobs
|
||||||
.map(j => { const s = timeToHours(j.start_time); return { start_h: s, end_h: s + (parseFloat(j.duration_h) || 1), reserved: j.job_type === 'Réservation' } })
|
.filter(j => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && j.scheduled_date === dateStr)
|
||||||
.sort((a, b) => a.start_h - b.start_h)
|
.map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation' } })
|
||||||
|
const timed = dayJobsRaw.filter(j => j.start_h != null).sort((a, b) => a.start_h - b.start_h)
|
||||||
|
const untimed = dayJobsRaw.filter(j => j.start_h == null) // jobs assignés SANS heure fixée (legacy osTicket) — occupent quand même la journée
|
||||||
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
|
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
|
||||||
const busyH = dayJobs.reduce((acc, j) => acc + clamp(j), 0)
|
const timedBusy = timed.reduce((acc, j) => acc + clamp(j), 0)
|
||||||
const blocks = dayJobs.map(j => ({
|
const untimedBusy = untimed.reduce((acc, j) => acc + j.dur, 0)
|
||||||
|
const busyH = timedBusy + untimedBusy // ⇐ correctif : les jobs sans heure comptent (avant : ignorés ⇒ « 40 h libre » alors que plein)
|
||||||
|
const blocks = timed.map(j => ({
|
||||||
start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
|
start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
|
||||||
top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
|
top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
|
||||||
height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
|
height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
|
||||||
reserved: j.reserved,
|
reserved: j.reserved,
|
||||||
}))
|
}))
|
||||||
|
// Jobs sans heure : empilés à partir de la charge horaire déjà placée, marqués « untimed » (rendu hachuré côté SPA).
|
||||||
|
let cum = timedBusy
|
||||||
|
for (const j of untimed) {
|
||||||
|
blocks.push({
|
||||||
|
start: null, end: null,
|
||||||
|
top: shiftH > 0 ? +Math.min(1, cum / shiftH).toFixed(3) : 0,
|
||||||
|
height: shiftH > 0 ? +Math.max(0.03, j.dur / shiftH).toFixed(3) : 0,
|
||||||
|
reserved: j.reserved, untimed: true,
|
||||||
|
})
|
||||||
|
cum += j.dur
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
|
date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
|
||||||
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1), jobs: dayJobs.length,
|
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1),
|
||||||
|
jobs: dayJobsRaw.length, untimed_jobs: untimed.length,
|
||||||
occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
|
occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -382,7 +399,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
||||||
})
|
})
|
||||||
// Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
|
// Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
|
||||||
out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
|
out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
|
||||||
return { start: baseDate, days: span, dates, skill: wantSkill, techs: out }
|
return { start: baseDate, days: span, dates, skill: wantSkills.join(','), skills: wantSkills, techs: out }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
|
// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const { log } = require('./helpers')
|
||||||
|
|
||||||
// Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill).
|
// Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill).
|
||||||
// Une demande à distance / facturation / vente ne requiert AUCUNE compétence → [].
|
// Une demande à distance / facturation / vente ne requiert AUCUNE compétence → [].
|
||||||
const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin']
|
const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin', 'support']
|
||||||
|
|
||||||
function normSkill (s) { return String(s || '').trim().toLowerCase() }
|
function normSkill (s) { return String(s || '').trim().toLowerCase() }
|
||||||
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
|
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
|
||||||
|
|
@ -71,7 +71,7 @@ const DEPARTMENT_SKILLS = {
|
||||||
Épissure: ['épissure'],
|
Épissure: ['épissure'],
|
||||||
Fusionneur: ['épissure'],
|
Fusionneur: ['épissure'],
|
||||||
'Net Admin': ['netadmin'],
|
'Net Admin': ['netadmin'],
|
||||||
Support: [], // dépannage à distance → n'importe quel agent
|
Support: ['support'], // ne lister que les agents ayant la compétence « support » (pas les techs sans compétence)
|
||||||
Facturation: [],
|
Facturation: [],
|
||||||
Commercial: [], // vente / bureau
|
Commercial: [], // vente / bureau
|
||||||
Vente: [],
|
Vente: [],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user