// ============================================= // Recycle Object — Production Calendar // v50: canonical week/month production calendar with queue + capacity // ============================================= const Gantt = { orders: [], blockedOrders: [], reviewOrders: [], schedule: null, orderSequence: [], actualMonthSummary: { actualHours: 0, employeeCount: 0 }, planState: { order_ids: [], manual_start_dates: {}, active_workers_count: null, parallel_workers: {} }, draggedOrderId: null, zoom: 'week', isLoading: false, _loadSeq: 0, LOADABLE_STATUSES: ['sample', 'production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'delivery', 'in_production'], STATUS_LABELS: { sample: 'Образец', production_casting: 'Литьё', production_printing: 'Печать', production_hardware: 'Сборка', production_packaging: 'Упаковка', in_production: 'В производстве', delivery: 'Отгрузка', }, hydrateFromCache() { if (typeof getLocal !== 'function' || typeof LOCAL_KEYS === 'undefined') return false; const cachedOrders = getLocal(LOCAL_KEYS.orders) || []; if (!Array.isArray(cachedOrders) || cachedOrders.length === 0) return false; const planState = getLocal(LOCAL_KEYS.productionPlan) || { order_ids: [] }; const orderIds = this.buildOrderedOrders(cachedOrders, planState).map(order => Number(order.id)); const orderIdSet = new Set(orderIds); const orderItems = (getLocal(LOCAL_KEYS.orderItems) || []).filter(item => orderIdSet.has(Number(item.order_id))); const allChinaPurchases = getLocal(LOCAL_KEYS.chinaPurchases) || []; const timeEntries = getLocal(LOCAL_KEYS.timeEntries) || []; const employees = getLocal(LOCAL_KEYS.employees) || []; this.applyLoadedData({ allOrders: cachedOrders, planState, allChinaPurchases, timeEntries, employees, orderItems }); return this.orders.length > 0 || this.blockedOrders.length > 0 || this.reviewOrders.length > 0; }, buildOrderedOrders(allOrders = [], planState = { order_ids: [] }) { const normalizedState = this.normalizePlanState(planState); const priorityIds = Array.isArray(normalizedState.order_ids) ? normalizedState.order_ids.map(x => Number(x)).filter(Number.isFinite) : []; const priorityPos = new Map(priorityIds.map((id, index) => [id, index])); return (allOrders || []) .filter(order => this.isSchedulableOrder(order)) .map((order, index) => ({ ...order, production_priority: priorityPos.has(Number(order.id)) ? priorityPos.get(Number(order.id)) : 1000 + index, })) .sort((a, b) => Number(a.production_priority || 0) - Number(b.production_priority || 0)); }, applyLoadedData({ allOrders = [], planState = { order_ids: [] }, allChinaPurchases = [], timeEntries = [], employees = [], orderItems = [] }) { this.planState = this.normalizePlanState(planState); const effectivePlanningSettings = this.getEffectivePlanningSettings(App.settings || {}); const orderedOrders = this.buildOrderedOrders(allOrders, this.planState); const itemsByOrderId = new Map(); (orderItems || []).forEach(item => { const key = Number(item.order_id); if (!itemsByOrderId.has(key)) itemsByOrderId.set(key, []); itemsByOrderId.get(key).push(item); }); const chinaPurchasesByOrderId = new Map(); (allChinaPurchases || []).forEach(purchase => { const key = Number(purchase.order_id); if (!Number.isFinite(key) || key <= 0) return; if (!chinaPurchasesByOrderId.has(key)) chinaPurchasesByOrderId.set(key, []); chinaPurchasesByOrderId.get(key).push(purchase); }); const orderActuals = this.buildOrderActuals(timeEntries, employees, orderedOrders); this.orders = orderedOrders.map(order => { const actuals = orderActuals.get(Number(order.id)) || this.getEmptyOrderActuals(); const plannedMolding = round2(order.production_hours_plastic || 0); const plannedAssembly = round2(order.production_hours_hardware || 0); const plannedPackaging = round2(order.production_hours_packaging || 0); const plannedTotal = round2(plannedMolding + plannedAssembly + plannedPackaging); const remainingTotal = round2( Math.max(plannedMolding - actuals.molding, 0) + Math.max(plannedAssembly - actuals.assembly, 0) + Math.max(plannedPackaging - actuals.packaging, 0) ); const actualTotalForPlan = round2(actuals.molding + actuals.assembly + actuals.packaging); const progressPercent = plannedTotal > 0 ? round2(Math.min((actualTotalForPlan / plannedTotal) * 100, 999)) : 0; return { ...order, production_not_before: this.planState.manual_start_dates[String(order.id)] || '', production_parallel_workers: this.getOrderParallelWorkers(Number(order.id)), actual_hours_molding: actuals.molding, actual_hours_assembly: actuals.assembly, actual_hours_packaging: actuals.packaging, actual_hours_other: actuals.other, actual_hours_total: actualTotalForPlan, actual_hours_employee_count: actuals.employeeCount, actual_hours_entry_count: actuals.entryCount, actual_hours_resolved_by_name: actuals.resolvedByNameCount, planned_hours_total: plannedTotal, remaining_hours_total: remainingTotal, progress_percent: progressPercent, ...this.getOrderReadiness( order, itemsByOrderId.get(Number(order.id)) || [], chinaPurchasesByOrderId.get(Number(order.id)) || [] ), }; }); this.blockedOrders = this.orders.filter(order => order.production_ready_state === 'blocked'); this.reviewOrders = this.orders.filter(order => order.production_ready_state === 'needs_review'); this.orderSequence = this.orders.map(order => Number(order.id)); this.actualMonthSummary = this.buildActualMonthSummary(timeEntries, employees); this.schedule = buildProductionSchedule( this.orders.filter(order => order.production_ready_state === 'ready'), effectivePlanningSettings ); }, async load() { const loadSeq = ++this._loadSeq; const hydrated = (this.orders || []).length > 0 || this.hydrateFromCache(); this.isLoading = !hydrated; this.render(); try { const [allOrders, planState] = await Promise.all([ loadOrders({}), loadProductionPlanState().catch(() => ({ order_ids: [] })), ]); if (this._loadSeq !== loadSeq) return; const orderedOrders = this.buildOrderedOrders(allOrders, planState); const orderedIds = orderedOrders.map(order => Number(order.id)).filter(Number.isFinite); const orderItemsPromise = loadOrderItemsByOrderIds(orderedIds).catch(() => []); const chinaPromise = loadChinaPurchases({}).catch(() => []); const actualsPromise = Promise.all([ loadTimeEntries().catch(() => []), loadEmployees().catch(() => []), ]); const [orderItems, allChinaPurchases] = await Promise.all([orderItemsPromise, chinaPromise]); if (this._loadSeq !== loadSeq) return; this.applyLoadedData({ allOrders, planState, allChinaPurchases, timeEntries: [], employees: [], orderItems, }); this.isLoading = false; this.render(); actualsPromise .then(([timeEntries, employees]) => { if (this._loadSeq !== loadSeq) return; this.applyLoadedData({ allOrders, planState, allChinaPurchases, timeEntries, employees, orderItems, }); this.render(); }) .catch(error => { console.warn('Gantt actuals load error:', error); }); } catch (e) { console.error('Gantt load error:', e); if (this._loadSeq === loadSeq) { this.isLoading = false; this.render(); } } }, isSchedulableOrder(order) { if (!order || !this.LOADABLE_STATUSES.includes(order.status)) return false; return this.getOrderTotalHours(order) > 0; }, getOrderTotalHours(order) { return round2( (order?.production_hours_plastic || 0) + (order?.production_hours_hardware || 0) + (order?.production_hours_packaging || 0) ); }, getEmptyOrderActuals() { return { molding: 0, assembly: 0, packaging: 0, other: 0, total: 0, employeeCount: 0, entryCount: 0, resolvedByNameCount: 0, }; }, buildOrderActuals(entries = [], employees = [], orders = []) { const buckets = new Map(); const indexedOrders = (orders || []) .map(order => ({ id: Number(order.id), nameKey: this.normalizePersonName(order.order_name || ''), tokens: this.tokenizeSearchText(order.order_name || ''), })) .filter(order => Number.isFinite(order.id) && order.id > 0); indexedOrders.forEach(order => { buckets.set(order.id, { ...this.getEmptyOrderActuals(), _employees: new Set() }); }); (entries || []).forEach(entry => { const employee = this.findProductionEmployeeForEntry(entry, employees); if (!employee) return; const resolved = this.resolveEntryOrder(entry, indexedOrders); if (!resolved) return; const bucket = buckets.get(resolved.id); if (!bucket) return; const hours = round2(parseFloat(entry?.hours) || 0); if (hours <= 0) return; const phase = this.getTimeEntryPhase(entry); if (phase === 'molding') bucket.molding = round2(bucket.molding + hours); else if (phase === 'assembly') bucket.assembly = round2(bucket.assembly + hours); else if (phase === 'packaging') bucket.packaging = round2(bucket.packaging + hours); else bucket.other = round2(bucket.other + hours); bucket.total = round2(bucket.total + hours); bucket.entryCount += 1; bucket._employees.add(String(employee.id || employee.name || entry.worker_name || '')); if (resolved.source === 'name') bucket.resolvedByNameCount += 1; }); buckets.forEach(bucket => { bucket.employeeCount = bucket._employees.size; delete bucket._employees; }); return buckets; }, resolveEntryOrder(entry, indexedOrders = []) { if (!entry) return null; const directOrderId = Number(entry.order_id); if (Number.isFinite(directOrderId) && directOrderId > 0) { const exact = indexedOrders.find(order => order.id === directOrderId); if (exact) return { id: exact.id, source: 'order_id' }; } const projectKey = this.normalizePersonName(entry.project_name || entry.project || ''); if (!projectKey) return null; const exactMatches = indexedOrders.filter(order => order.nameKey === projectKey); if (exactMatches.length === 1) return { id: exactMatches[0].id, source: 'name' }; const containsMatches = indexedOrders.filter(order => order.nameKey && (order.nameKey.includes(projectKey) || projectKey.includes(order.nameKey)) ); if (containsMatches.length === 1) return { id: containsMatches[0].id, source: 'name' }; const tokens = this.tokenizeSearchText(projectKey); if (!tokens.length) return null; const tokenMatches = indexedOrders.filter(order => tokens.every(token => order.tokens.includes(token) || order.nameKey.includes(token)) ); return tokenMatches.length === 1 ? { id: tokenMatches[0].id, source: 'name' } : null; }, tokenizeSearchText(value) { return this.normalizePersonName(value) .split(' ') .map(token => token.trim()) .filter(token => token.length >= 2); }, getTimeEntryPhase(entry) { if (!entry) return 'other'; const description = String(entry.task_description || entry.description || ''); const metaMatch = description.match(/^\[meta\](\{.*?\})\[\/meta\]/); if (metaMatch) { try { const parsed = JSON.parse(metaMatch[1]); const phase = this.mapStageToProductionPhase(parsed?.stage || parsed?.stage_key || ''); if (phase !== 'other') return phase; } catch (e) { // ignore invalid meta payloads } } const stage = this.mapStageToProductionPhase(entry.stage || ''); if (stage !== 'other') return stage; const stageLine = description.match(/(?:^|\n)Этап:\s*([^\n]+)/i); return this.mapStageToProductionPhase(stageLine ? stageLine[1] : ''); }, mapStageToProductionPhase(stage) { const value = this.normalizePersonName(stage); if (!value) return 'other'; if (value.includes('casting') || value.includes('trim') || value.includes('вылив') || value.includes('срез') || value.includes('литник') || value.includes('лейник')) { return 'molding'; } if (value.includes('assembly') || value.includes('сбор')) return 'assembly'; if (value.includes('packaging') || value.includes('упаков')) return 'packaging'; return 'other'; }, normalizePlanState(state) { const raw = state && typeof state === 'object' ? state : {}; const manualStartDates = {}; const parallelWorkers = {}; Object.entries(raw.manual_start_dates || {}).forEach(([orderId, value]) => { const normalized = String(value || '').trim(); if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { manualStartDates[String(orderId)] = normalized; } }); Object.entries(raw.parallel_workers || {}).forEach(([orderId, value]) => { const normalized = Math.round(Number(value) || 0); if (normalized >= 1) { parallelWorkers[String(orderId)] = normalized; } }); const activeWorkersCountRaw = Number(raw.active_workers_count); return { order_ids: Array.isArray(raw.order_ids) ? raw.order_ids : [], manual_start_dates: manualStartDates, active_workers_count: activeWorkersCountRaw > 0 ? round2(activeWorkersCountRaw) : null, parallel_workers: parallelWorkers, }; }, getEffectivePlanningSettings(baseSettings = App.settings || {}) { const next = { ...(baseSettings || {}) }; const overrideWorkers = Number(this.planState?.active_workers_count || 0); if (overrideWorkers > 0) { next.planning_workers_count = overrideWorkers; } return next; }, getEffectivePlanningCapacity(baseSettings = App.settings || {}) { if (typeof getProductionPlanningCapacity === 'function') { return getProductionPlanningCapacity(this.getEffectivePlanningSettings(baseSettings)); } const workersCount = Number(this.planState?.active_workers_count || baseSettings?.planning_workers_count || 2) || 2; const hoursPerDay = Number(baseSettings?.planning_hours_per_day || 8) || 8; return { workersCount: round2(workersCount), hoursPerDay: round2(hoursPerDay), dailyCapacity: round2(workersCount * hoursPerDay), }; }, getWorkerSlotCount(capacity = this.getEffectivePlanningCapacity()) { const workers = Math.max(Number(capacity?.workersCount || 0), 0); const fullSlots = Math.floor(workers); const fractional = round2(workers - fullSlots); return fullSlots + (fractional > 0.001 ? 1 : 0); }, getOrderParallelWorkers(orderId) { const normalizedId = String(Number(orderId) || 0); const stored = Math.round(Number(this.planState?.parallel_workers?.[normalizedId] || 0)); return stored >= 1 ? stored : 1; }, getOrderReadiness(order, items = [], chinaPurchases = []) { const productItems = (items || []).filter(item => String(item?.item_type || 'product') === 'product'); const customMoldItems = productItems.filter(item => item && item.is_blank_mold === false); const blockedItems = customMoldItems.filter(item => !this.isTrueLike(item.base_mold_in_stock)); if (blockedItems.length > 0) { const pendingChinaPurchases = (chinaPurchases || []).filter(purchase => !this.isChinaPurchaseReceived(purchase)); if (pendingChinaPurchases.length > 0) { return { production_ready_state: 'blocked', production_blocked_reason: this.describeChinaBlocked(pendingChinaPurchases), production_blocked_items: blockedItems.length, }; } const receivedChinaPurchases = (chinaPurchases || []).filter(purchase => this.isChinaPurchaseReceived(purchase)); if (receivedChinaPurchases.length > 0) { return { production_ready_state: 'needs_review', production_blocked_reason: this.describeReviewAfterChinaReceipt(receivedChinaPurchases), production_blocked_items: blockedItems.length, }; } return { production_ready_state: 'blocked', production_blocked_reason: this.describeBlockedByMold(blockedItems), production_blocked_items: blockedItems.length, }; } return { production_ready_state: 'ready', production_blocked_reason: '', production_blocked_items: 0, }; }, isTrueLike(value) { return value === true || value === 1 || value === '1' || value === 'true'; }, describeBlockedByMold(items = []) { const names = Array.from(new Set((items || []) .map(item => String(item?.product_name || '').trim()) .filter(Boolean))); if (names.length === 1) { return `Ждет молд: ${names[0]}`; } if (names.length > 1) { return `Ждет молды для ${names.length} позиций`; } return 'Ждет молд'; }, isChinaPurchaseReceived(purchase) { return String(purchase?.status || '').trim().toLowerCase() === 'received'; }, describeChinaBlocked(purchases = []) { const names = Array.from(new Set((purchases || []) .map(purchase => String(purchase?.purchase_name || '').trim()) .filter(Boolean))); if (names.length === 1) { return `Ждет Китай: ${names[0]}`; } if (names.length > 1) { return `Ждет Китай: ${names.length} закупки`; } return 'Ждет Китай / молд'; }, describeReviewAfterChinaReceipt(purchases = []) { const names = Array.from(new Set((purchases || []) .map(purchase => String(purchase?.purchase_name || '').trim()) .filter(Boolean))); if (names.length === 1) { return `Проверьте молд: Китай уже принят (${names[0]})`; } return 'Проверьте молд: Китай уже принят'; }, async moveOrder(orderId, direction) { const orderIds = Array.isArray(this.orderSequence) && this.orderSequence.length ? [...this.orderSequence] : (this.orders || []).map(item => Number(item.id || item.orderId)); const currentIndex = orderIds.indexOf(Number(orderId)); const targetIndex = currentIndex + direction; if (currentIndex < 0 || targetIndex < 0 || targetIndex >= orderIds.length) return; [orderIds[currentIndex], orderIds[targetIndex]] = [orderIds[targetIndex], orderIds[currentIndex]]; const nextState = this.normalizePlanState(this.planState); nextState.order_ids = orderIds; await saveProductionPlanState(nextState); this.planState = nextState; await this.load(); }, reorderOrderSequence(orderIds = [], draggedOrderId, targetOrderId) { const draggedId = Number(draggedOrderId); const targetId = Number(targetOrderId); const normalized = (orderIds || []).map(id => Number(id)).filter(Number.isFinite); const currentIndex = normalized.indexOf(draggedId); const targetIndex = normalized.indexOf(targetId); if (currentIndex < 0 || targetIndex < 0 || draggedId === targetId) return normalized; const next = [...normalized]; next.splice(currentIndex, 1); const insertIndex = next.indexOf(targetId); next.splice(insertIndex, 0, draggedId); return next; }, async moveUp(orderId) { await this.moveOrder(orderId, -1); }, async moveDown(orderId) { await this.moveOrder(orderId, 1); }, async promptManualStart(orderId) { const state = this.normalizePlanState(this.planState); const current = state.manual_start_dates[String(orderId)] || ''; const answer = window.prompt('Старт не раньше даты (YYYY-MM-DD). Оставьте пусто, чтобы убрать ограничение.', current); if (answer === null) return; const normalized = String(answer || '').trim(); if (!normalized) { delete state.manual_start_dates[String(orderId)]; } else if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { App.toast('Введите дату в формате YYYY-MM-DD'); return; } else { state.manual_start_dates[String(orderId)] = normalized; } await saveProductionPlanState(state); this.planState = state; await this.load(); }, async shiftManualStart(orderId, direction) { if (!Number.isFinite(Number(direction)) || Number(direction) === 0) return; const order = (this.orders || []).find(item => Number(item.id || item.orderId) === Number(orderId)); const state = this.normalizePlanState(this.planState); const current = state.manual_start_dates[String(orderId)] || order?.production_not_before || order?.schedule?.[0]?.date || this.formatIsoDateLocal(new Date()); const nextDate = this.shiftWorkingDate(current, Number(direction), this.getHolidaySet()); state.manual_start_dates[String(orderId)] = nextDate; await saveProductionPlanState(state); this.planState = state; await this.load(); }, async setActiveWorkersCount(value) { const normalized = round2(Number(value) || 0); if (!(normalized > 0)) { App.toast('Укажите количество сотрудников больше нуля'); return; } const state = this.normalizePlanState(this.planState); state.active_workers_count = normalized; await saveProductionPlanState(state); this.planState = state; await this.load(); }, async adjustActiveWorkersCount(delta) { const capacity = this.getEffectivePlanningCapacity(); const current = Number(this.planState?.active_workers_count || capacity.workersCount || 2) || 2; const next = Math.max(0.5, round2(current + Number(delta || 0))); await this.setActiveWorkersCount(next); }, async resetActiveWorkersCount() { const state = this.normalizePlanState(this.planState); state.active_workers_count = null; await saveProductionPlanState(state); this.planState = state; await this.load(); }, async setOrderParallelWorkers(orderId, value) { const normalizedOrderId = String(Number(orderId) || 0); if (!normalizedOrderId || normalizedOrderId === '0') return; const slotLimit = Math.max(1, this.getWorkerSlotCount()); const normalized = Math.max(1, Math.min(slotLimit, Math.round(Number(value) || 1))); const state = this.normalizePlanState(this.planState); state.parallel_workers[normalizedOrderId] = normalized; await saveProductionPlanState(state); this.planState = state; await this.load(); }, async adjustParallelWorkers(orderId, delta) { const current = this.getOrderParallelWorkers(orderId); await this.setOrderParallelWorkers(orderId, current + Number(delta || 0)); }, renderToolbar() { const toolbarEl = document.getElementById('gantt-toolbar'); if (!toolbarEl) return; const capacity = this.getEffectivePlanningCapacity(); const configuredWorkers = Number(App.settings?.planning_workers_count || 0) || 2; const currentWorkers = Number(this.planState?.active_workers_count || capacity.workersCount || configuredWorkers || 2); const slotCount = Math.max(1, this.getWorkerSlotCount(capacity)); const hoursPerDay = round2(Number(capacity.hoursPerDay || App.settings?.planning_hours_per_day || 8) || 8); const holidayCount = this.getHolidaySet().size; const isOverride = Number(this.planState?.active_workers_count || 0) > 0; toolbarEl.innerHTML = `
`; }, onQueueDragStart(event, orderId) { this.draggedOrderId = Number(orderId); if (event?.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('text/plain', String(orderId)); } event?.currentTarget?.classList.add('dragging'); }, onQueueDragOver(event) { event.preventDefault(); if (event?.dataTransfer) event.dataTransfer.dropEffect = 'move'; event?.currentTarget?.classList.add('drag-over'); }, onQueueDragLeave(event) { event?.currentTarget?.classList.remove('drag-over'); }, onQueueDragEnd(event) { this.draggedOrderId = null; event?.currentTarget?.classList.remove('dragging'); document.querySelectorAll('.gantt-queue-card.drag-over').forEach(node => node.classList.remove('drag-over')); }, async onQueueDrop(event, targetOrderId) { event.preventDefault(); event?.currentTarget?.classList.remove('drag-over'); const draggedOrderId = Number( event?.dataTransfer?.getData('text/plain') || this.draggedOrderId || 0 ); if (!draggedOrderId || draggedOrderId === Number(targetOrderId)) { this.draggedOrderId = null; return; } const nextOrderIds = this.reorderOrderSequence(this.orderSequence, draggedOrderId, targetOrderId); const nextState = this.normalizePlanState(this.planState); nextState.order_ids = nextOrderIds; await saveProductionPlanState(nextState); this.planState = nextState; this.draggedOrderId = null; await this.load(); }, setZoom(z) { if (!['week', 'month'].includes(z)) return; this.zoom = z; document.querySelectorAll('.gantt-zoom-btn').forEach(button => button.classList.remove('active')); document.querySelector(`.gantt-zoom-btn[data-zoom="${z}"]`)?.classList.add('active'); this.render(); }, render() { const container = document.getElementById('gantt-container'); const capContainer = document.getElementById('gantt-capacity-chart'); const queueContainer = document.getElementById('gantt-queue'); if (!container) return; this.renderToolbar(); if (this.isLoading && !this.schedule && !(this.orders || []).length) { if (capContainer) capContainer.innerHTML = ''; if (queueContainer) queueContainer.innerHTML = ''; container.innerHTML = `Загружаем производственный календарь…
Нет заказов для планирования
Создайте заказ с изделиями в калькуляторе — после этого он появится в очереди и календаре.
${(blockedQueue.length || reviewQueue.length) ? 'Сейчас нет готовых к запуску заказов: активный план пуст, а ожидающие/требующие проверки заказы вынесены выше в отдельные блоки.' : 'Нет данных для отображения'}
Порядок сверху задает, что начальник производства запускает раньше. Фактические часы уменьшают остаток автоматически, а календарь учитывает лимит одновременных сотрудников, чтобы не планировать больше заказов, чем реально можно вести параллельно.
Эти заказы не попадают в active timeline, пока по ним нет молда на складе. Порядок очереди можно заранее настроить уже сейчас.
По этим заказам календарь видит конфликт в данных: Китай уже принят или связка неполная, но молд все еще не отмечен как доступный. Они не попадают в active timeline, пока их не проверят.