import { toast } from 'bulma-toast' /** * Controller for the cross-dock planning board (page-local, provided via * provide('cdBoard', ...) like the label designer's useLabelDesign). * * Owns: the visible date window, board data (inbound POs / outbound SOs / * allocation links), selection, the allocation dialog, the optimistic * DatePromised drag-write, and the one-click shipment generation. */ export interface CdOrder { id: number documentNo: string dateOrdered: string | null datePromised: string | null grandTotal: number orgId: number orgName: string warehouseId: number | null warehouseName: string partnerId: number | null partnerName: string lineCount: number totalQty: number openQty: number received?: boolean // inbound only isUnfulfillable?: boolean // outbound only } export interface CdLink { poOrderId: number soOrderId: number qtyAllocated: number soOpenTotal: number coveragePct: number arrived: boolean products: Array<{ allocId: number productId: number productName: string qtyAllocated: number poOpenQty: number soOpenQty: number poOrderLineId: number soOrderLineId: number }> } export interface CdSelection { type: 'order' | 'link' lane?: 'in' | 'out' id?: number poOrderId?: number soOrderId?: number } const DAY_MS = 86400000 const pad2 = (n: number) => String(n).padStart(2, '0') export const cdFormatDate = (d: Date) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` const localMidnight = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()) // Calendar-day addition (NOT ms addition — that drifts an hour across DST // changes and would mislabel/misplace days on the board). const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n) export const useCrossdockBoard = () => { // ---- date window ---- const rangeStart = ref(addDays(new Date(), -3)) const days = ref(21) const rangeEnd = computed(() => addDays(rangeStart.value, days.value - 1)) // ---- filters ---- const warehouseId = ref('') // ---- data ---- const pending = ref(false) const error = ref(null) const inbound = ref([]) const outbound = ref([]) const links = ref([]) const linkTableMissing = ref(false) const loadedOnce = ref(false) // ---- interaction ---- const selection = ref(null) const dropTargetId = ref(null) // opposite-lane truck under a cross-lane drag const draggingId = ref(null) // ---- allocation dialog ---- const allocDialog = ref<{ open: boolean poOrderId: number soOrderId: number loading: boolean saving: boolean rows: Array<{ productName: string sku: string poOrderLineId: number soOrderLineId: number poOpenQty: number soOpenQty: number poAllocatedOther: number soAllocatedOther: number existingId: number | null existingQty: number qty: number maxQty: number }> }>({ open: false, poOrderId: 0, soOrderId: 0, loading: false, saving: false, rows: [] }) // ---- per-SO shipment generation status ---- const generateStatus = ref>({}) // ---- date helpers (board data is date-only 'YYYY-MM-DD', parsed DST-safe) ---- const parseDate = (v: any): Date | null => parseIdempiereTimestamp(v) const dayIndex = (v: any): number => { const d = parseDate(v) if (!d) return 0 // round absorbs the ±1h DST offset between local midnights return Math.round((localMidnight(d).getTime() - rangeStart.value.getTime()) / DAY_MS) } const dayToDate = (i: number): Date => addDays(rangeStart.value, i) // ---- lookups ---- const inboundById = computed(() => { const m: Record = {} for (const o of inbound.value) m[o.id] = o return m }) const outboundById = computed(() => { const m: Record = {} for (const o of outbound.value) m[o.id] = o return m }) // Only ribbons whose both trucks are visible in the current window get drawn. const visibleLinks = computed(() => links.value.filter(l => inboundById.value[l.poOrderId] && outboundById.value[l.soOrderId]) ) const linksForOrder = (lane: 'in' | 'out', id: number) => links.value.filter(l => (lane === 'in' ? l.poOrderId === id : l.soOrderId === id)) /** Outbound truck state derived from its links: null | 'planned' | 'partial' | 'ready' */ const soLinkState = (id: number): string | null => { const ls = linksForOrder('out', id) if (!ls.length) return null if (ls.every(l => l.arrived)) return 'ready' if (ls.some(l => l.arrived)) return 'partial' return 'planned' } // ---- loading ---- const load = async (silent = false) => { if (!silent) { pending.value = true } error.value = null try { const params = new URLSearchParams({ from: cdFormatDate(rangeStart.value), to: cdFormatDate(rangeEnd.value) }) if (warehouseId.value) params.set('warehouseId', String(warehouseId.value)) const res: any = await $fetch('/api/fulfillment/crossdock/board?' + params.toString(), { headers: useRequestHeaders(['cookie']) }) inbound.value = res?.inbound ?? [] outbound.value = res?.outbound ?? [] links.value = res?.links ?? [] linkTableMissing.value = !!res?.linkTableMissing loadedOnce.value = true } catch (err: any) { error.value = err?.data?.message ?? err?.message ?? 'Board konnte nicht geladen werden' } finally { pending.value = false } } const shiftRange = (deltaDays: number) => { rangeStart.value = addDays(rangeStart.value, deltaDays) load() } const setDays = (n: number) => { days.value = n load() } const resetRange = () => { rangeStart.value = addDays(new Date(), -3) load() } // ---- drag: date change (optimistic write + revert) ---- const moveOrder = async (lane: 'in' | 'out', orderId: number, newDay: number) => { const list = lane === 'in' ? inbound : outbound const order = list.value.find(o => o.id === orderId) if (!order) return const newDate = cdFormatDate(dayToDate(newDay)) const oldDate = order.datePromised if (oldDate && cdFormatDate(parseDate(oldDate) as Date) === newDate) return order.datePromised = newDate // optimistic try { const res: any = await $fetch('/api/fulfillment/crossdock/order-date', { method: 'PUT', headers: useRequestHeaders(['cookie']), body: { id: orderId, datePromised: newDate } }) if (Number(res?.status) !== 200) throw new Error(res?.message || 'update failed') toast({ message: `${order.documentNo}: Termin → ${newDate}`, type: 'is-success', duration: 1800 }) } catch (err: any) { order.datePromised = oldDate // revert toast({ message: `${order.documentNo}: Termin konnte nicht geändert werden — ${err?.data?.message ?? err?.message ?? ''}`, type: 'is-danger', duration: 3500 }) } } // ---- allocation dialog ---- const openAllocation = async (poOrderId: number, soOrderId: number) => { if (linkTableMissing.value) { toast({ message: 'Cross-Dock Tabelle fehlt noch in iDempiere (CUST_CrossDockAlloc)', type: 'is-warning', duration: 3500 }) return } allocDialog.value = { open: true, poOrderId, soOrderId, loading: true, saving: false, rows: [] } try { const res: any = await $fetch( `/api/fulfillment/crossdock/match-proposal?poOrderId=${poOrderId}&soOrderId=${soOrderId}`, { headers: useRequestHeaders(['cookie']) } ) const rows = (res?.rows ?? []).map((r: any) => { const maxQty = Math.max(0, Math.min( r.poOpenQty - r.poAllocatedOther, r.soOpenQty - r.soAllocatedOther )) return { ...r, maxQty, // pre-fill: keep an existing allocation, otherwise propose the max coverable qty qty: r.existingId ? r.existingQty : maxQty } }) allocDialog.value.rows = rows } catch (err: any) { toast({ message: err?.data?.message ?? err?.message ?? 'Vorschlag konnte nicht geladen werden', type: 'is-danger', duration: 3000 }) allocDialog.value.open = false } finally { allocDialog.value.loading = false } } const saveAllocation = async () => { const d = allocDialog.value if (d.saving) return d.saving = true try { const res: any = await $fetch('/api/fulfillment/crossdock/allocations/save', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { poOrderId: d.poOrderId, soOrderId: d.soOrderId, rows: d.rows.map(r => ({ id: r.existingId, poOrderLineId: r.poOrderLineId, soOrderLineId: r.soOrderLineId, qty: Number(r.qty) || 0 })) } }) if (Number(res?.status) !== 200) throw new Error(res?.message || 'save failed') if (Array.isArray(res?.errors) && res.errors.length) { toast({ message: `${res.errors.length} Position(en) konnten nicht gespeichert werden`, type: 'is-warning', duration: 3500 }) } else { toast({ message: 'Cross-Dock Zuordnung gespeichert', type: 'is-success', duration: 2000 }) } d.open = false await load(true) } catch (err: any) { toast({ message: err?.data?.message ?? err?.message ?? 'Speichern fehlgeschlagen', type: 'is-danger', duration: 3500 }) } finally { d.saving = false } } const removeLink = async (link: CdLink) => { const ids = link.products.map(p => p.allocId).filter(Boolean) if (!ids.length) return try { const res: any = await $fetch('/api/fulfillment/crossdock/allocations/destroy', { method: 'DELETE', headers: useRequestHeaders(['cookie']), body: { ids } }) if (Number(res?.status) !== 200) throw new Error(res?.message || 'delete failed') links.value = links.value.filter(l => !(l.poOrderId === link.poOrderId && l.soOrderId === link.soOrderId)) if (selection.value?.type === 'link') selection.value = null toast({ message: 'Cross-Dock Verknüpfung entfernt', type: 'is-success', duration: 2000 }) } catch (err: any) { toast({ message: err?.data?.message ?? err?.message ?? 'Löschen fehlgeschlagen', type: 'is-danger', duration: 3500 }) } } // ---- one-click shipment generation (same process call as the SO edit page) ---- const generateShipment = async (so: CdOrder) => { if (generateStatus.value[so.id] === 'loading') return if (!so.warehouseId || !so.partnerId) { toast({ message: 'Lager und Geschäftspartner werden benötigt', type: 'is-warning', duration: 2500 }) return } generateStatus.value = { ...generateStatus.value, [so.id]: 'loading' } try { const today = cdFormatDate(new Date()) const res: any = await $fetch('/api/processes/inouts/generate', { method: 'POST', headers: useRequestHeaders(['cookie']), body: { organizationId: so.orgId, warehouseId: so.warehouseId, partnerId: so.partnerId, dateAcct: today, datePromised: today, docStatusId: 'CO', isUnconfirmed: false, isOneDocument: false, inProgress: false, isRunJob: false } }) if (Number(res?.status) !== 200) throw new Error(res?.message || 'Lieferung konnte nicht erstellt werden') generateStatus.value = { ...generateStatus.value, [so.id]: 'success' } toast({ message: res?.summary || 'Lieferung(en) erstellt', type: 'is-success', duration: 2500 }) await load(true) } catch (err: any) { generateStatus.value = { ...generateStatus.value, [so.id]: 'idle' } toast({ message: err?.data?.message ?? err?.message ?? 'Lieferung konnte nicht erstellt werden', type: 'is-danger', duration: 3500 }) } } // ---- auto refresh (60s, silent) ---- let refreshTimer: any = null const startAutoRefresh = () => { stopAutoRefresh() refreshTimer = setInterval(() => { if (!allocDialog.value.open && !draggingId.value) load(true) }, 60000) } const stopAutoRefresh = () => { if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null } } return { // state rangeStart, rangeEnd, days, warehouseId, pending, error, inbound, outbound, links, linkTableMissing, loadedOnce, selection, dropTargetId, draggingId, allocDialog, generateStatus, // lookups inboundById, outboundById, visibleLinks, linksForOrder, soLinkState, // date helpers parseDate, dayIndex, dayToDate, // actions load, shiftRange, setDays, resetRange, moveOrder, openAllocation, saveAllocation, removeLink, generateShipment, startAutoRefresh, stopAutoRefresh } }