// ============================================= // Recycle Object — План-факт (v2 — multi-order) // All active orders on one page with accordion // ============================================= const Factual = { _allOrders: [], _filteredOrders: [], _entries: [], _employees: [], _orderCache: {}, // orderId → { plan, fact, hours, order, items } _openOrderId: null, _periodKind: 'month', _periodAnchor: null, _renderTimer: null, _monthSnapshots: {}, _visibleOrderRecords: [], VISIBLE_STATUSES: ['sample', 'production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery', 'completed'], STATUS_LABELS: { sample: '🔬 Образец', production_casting: '🔧 Литьё', production_printing: '🖨️ Печать', production_hardware: '🔩 Сборка', production_packaging: '📦 Упаковка', in_production: '⚙️ Производство', delivery: '🚚 Доставка', completed: '✅ Готово', }, STATUS_ORDER: { production_casting: 1, production_printing: 2, production_hardware: 3, production_packaging: 4, in_production: 5, delivery: 6, sample: 7, completed: 8, }, ROWS: [ { key: 'salary_production', label: 'ЗП выливание', planField: 'salaryProduction', hint: 'часы × ставка' }, { key: 'salary_trim', label: 'ЗП срезание', planField: 'salaryTrim', hint: 'часы × ставка' }, { key: 'salary_assembly', label: 'ЗП сборка', planField: 'salaryAssembly', hint: 'часы × ставка' }, { key: 'salary_packaging', label: 'ЗП упаковка', planField: 'salaryPackaging', hint: 'часы × ставка' }, { key: 'indirect_production', label: 'Косвенные', planField: 'indirectProduction', hint: 'часы × косв./ч' }, { key: 'hardware_total', label: 'Фурнитура', planField: 'hardwareTotal', hint: 'склад или план' }, { key: 'nfc_total', label: 'NFC', planField: 'nfcTotal', hint: 'склад или план' }, { key: 'packaging_total', label: 'Упаковка', planField: 'packagingTotal', hint: 'склад или план' }, { key: 'design_printing', label: 'Нанесение', planField: 'designPrinting', hint: 'FinTablo / вруч.' }, { key: 'plastic', label: 'Пластик / материалы', planField: 'plastic', hint: 'план + ФинТабло / вруч.' }, { key: 'molds', label: 'Молды', planField: 'molds', hint: 'FinTablo / вруч.' }, { key: 'delivery_client', label: 'Доставка', planField: 'delivery', hint: 'вручную' }, { key: 'taxes', label: 'Налоги', planField: 'taxes', hint: '7% от выручки без НДС / ФинТабло' }, { key: 'commercial', label: 'Коммерческий отдел', planField: 'commercial', hint: '6.5% от выручки без НДС' }, { key: 'charity', label: 'Благотворительность', planField: 'charity', hint: '1% от выручки без НДС / ФинТабло' }, { key: 'other', label: 'Прочее', planField: 'other', hint: 'FinTablo / вруч.' }, ], HOUR_ROWS: [ { key: 'hours_production', label: 'Выливание', planField: 'hoursPlastic' }, { key: 'hours_trim', label: 'Срезание', planField: 'hoursTrim' }, { key: 'hours_assembly', label: 'Сборка', planField: 'hoursHardware' }, { key: 'hours_packaging', label: 'Упаковка', planField: 'hoursPackaging' }, ], AUTO_FACT_KEYS: new Set([ 'salary_production', 'salary_trim', 'salary_assembly', 'salary_packaging', 'indirect_production', 'hardware_total', 'nfc_total', 'packaging_total', 'design_printing', 'plastic', 'molds', 'delivery_client', 'taxes', 'commercial', 'charity', 'other', ]), AUTO_FACT_FIELDS: [ 'fact_salary_production', 'fact_salary_trim', 'fact_salary_assembly', 'fact_salary_packaging', 'fact_indirect_production', 'fact_hardware_total', 'fact_nfc_total', 'fact_packaging_total', 'fact_design_printing', 'fact_plastic', 'fact_molds', 'fact_delivery_client', 'fact_taxes', 'fact_commercial', 'fact_charity', 'fact_other', 'fact_revenue', ], MATERIAL_ROW_KEYS: new Set(['hardware_total', 'nfc_total', 'packaging_total']), MONEY_ONLY_ROW_KEYS: new Set(['design_printing', 'delivery_client', 'taxes', 'commercial', 'charity', 'other']), _num(v) { const n = parseFloat(v); return Number.isFinite(n) ? n : 0; }, _vatRate(params) { const raw = Number(params?.vatRate); return Number.isFinite(raw) ? raw : 0.05; }, _taxRate(params) { const raw = Number(params?.taxRate); return Number.isFinite(raw) ? raw : 0.07; }, _charityRate(params) { const raw = Number(params?.charityRate); return Number.isFinite(raw) ? raw : 0.01; }, _commercialRate() { return 0.065; }, _calcTaxesByRevenue(revenue, params) { return round2(this._num(revenue) * this._taxRate(params)); }, _calcCommercialByRevenue(revenue, params) { return round2(this._num(revenue) * this._commercialRate()); }, _calcCharityByRevenue(revenue, params) { return round2(this._num(revenue) * this._charityRate(params)); }, _planItemCost(item, ...keys) { for (const key of keys) { const value = this._num(item?.[key]); if (value !== 0) return value; } return 0; }, _planItemHours(item, ...keys) { for (const key of keys) { const value = this._num(item?.[key]); if (value > 0) return value; } return 0; }, _makePlanDedupKey(item) { if (!item || !item.item_type) return null; if (item.item_type === 'hardware') { return [ 'hardware', item.product_name || '', this._num(item.quantity), this._num(item.hardware_price_per_unit), this._num(item.hardware_delivery_per_unit), item.hardware_warehouse_sku || '', item.hardware_source || '', item.hardware_parent_item_index ?? '', ].join('|'); } if (item.item_type === 'packaging') { return [ 'packaging', item.product_name || '', this._num(item.quantity), this._num(item.packaging_price_per_unit), this._num(item.packaging_delivery_per_unit), item.packaging_warehouse_sku || '', item.packaging_source || '', item.packaging_parent_item_index ?? '', ].join('|'); } return null; }, _dedupePlanItems(rawItems = []) { const seen = new Set(); return (rawItems || []).filter(item => { const key = this._makePlanDedupKey(item); if (!key) return true; if (seen.has(key)) return false; seen.add(key); return true; }); }, _collectWarehouseDemandRows(items = [], allItems = []) { if (window.Warehouse && typeof window.Warehouse._collectWarehouseDemandFromOrderItems === 'function') { const previousItems = window.Warehouse.allItems; try { if ((!window.Warehouse.allItems || !window.Warehouse.allItems.length) && Array.isArray(allItems) && allItems.length) { window.Warehouse.allItems = allItems; } const rows = window.Warehouse._collectWarehouseDemandFromOrderItems(items || []) || []; return Array.isArray(rows) ? rows : []; } finally { if (previousItems !== undefined) window.Warehouse.allItems = previousItems; } } const grouped = new Map(); const explicitHardwareWarehouseIds = new Set(); const addDemandRow = (itemId, qty, name, materialType = 'hardware', attachmentType = '') => { const normalizedItemId = Number(itemId || 0); const normalizedQty = parseFloat(qty) || 0; if (!normalizedItemId || normalizedQty <= 0) return; const key = String(normalizedItemId); const prev = grouped.get(key); const normalizedName = name || ''; if (!prev) { grouped.set(key, { warehouse_item_id: normalizedItemId, qty: normalizedQty, names: normalizedName ? [normalizedName] : [], material_type: materialType, attachment_type: attachmentType || '', }); return; } prev.qty += normalizedQty; if (normalizedName && !prev.names.includes(normalizedName)) prev.names.push(normalizedName); if (prev.material_type !== materialType) prev.material_type = 'mixed'; if (attachmentType && prev.attachment_type !== attachmentType) prev.attachment_type = attachmentType; grouped.set(key, prev); }; (items || []).forEach(item => { const itemType = String(item.item_type || '').toLowerCase(); if (itemType !== 'hardware') return; const src = String(item.source || item.hardware_source || '').toLowerCase(); const itemId = Number(item.warehouse_item_id ?? item.hardware_warehouse_item_id ?? 0); const qty = parseFloat(item.quantity ?? item.hardware_qty ?? item.qty ?? 0) || 0; if (src === 'warehouse' && itemId && qty > 0) explicitHardwareWarehouseIds.add(itemId); }); (items || []).forEach(item => { const itemType = String(item.item_type || '').toLowerCase(); if (itemType === 'pendant' && typeof getPendantWarehouseDemandRows === 'function') { (getPendantWarehouseDemandRows(item) || []).forEach(row => { addDemandRow(row.warehouse_item_id, row.qty, row.name, row.material_type || 'hardware', row.attachment_type || ''); }); return; } if (itemType === 'product' && typeof getProductWarehouseDemandRows === 'function') { (getProductWarehouseDemandRows(item, allItems || []) || []).forEach(row => { if (explicitHardwareWarehouseIds.has(Number(row.warehouse_item_id || 0))) return; addDemandRow(row.warehouse_item_id, row.qty, row.name, row.material_type || 'hardware', row.attachment_type || ''); }); return; } const isHardware = itemType === 'hardware'; const isPackaging = itemType === 'packaging'; if (!isHardware && !isPackaging) return; const src = String( item.source || (isHardware ? item.hardware_source : item.packaging_source) || '' ).toLowerCase(); if (src !== 'warehouse') return; const itemId = Number( item.warehouse_item_id ?? (isHardware ? item.hardware_warehouse_item_id : item.packaging_warehouse_item_id) ?? 0 ); const qty = parseFloat( item.quantity ?? (isHardware ? item.hardware_qty : item.packaging_qty) ?? item.qty ?? 0 ) || 0; const name = item.product_name || item.name || ''; addDemandRow(itemId, qty, name, isPackaging ? 'packaging' : 'hardware', item.attachment_type || ''); }); return Array.from(grouped.values()); }, _isNfcWarehouseRow(row = {}, warehouseItem = null) { const attachmentType = String(row?.attachment_type || '').trim().toLowerCase(); if (attachmentType === 'nfc') return true; const candidates = [ row?.warehouse_sku, row?.sku, warehouseItem?.sku, row?.name, Array.isArray(row?.names) ? row.names.join(' ') : '', row?.product_name, warehouseItem?.name, ]; return candidates.some(value => { const normalized = String(value || '').trim().toLowerCase(); if (!normalized) return false; if (normalized === 'nfc') return true; return /(^|[^a-zа-яё])nfc([^a-zа-яё]|$)/i.test(normalized) || normalized.includes('нфс') || normalized.includes('чип'); }); }, _getSourceHints(factData) { const raw = factData && factData._source_hints; return (raw && typeof raw === 'object') ? raw : {}; }, _getFinTabloBreakdowns(factData) { const raw = factData && factData._fintablo_breakdown; return (raw && typeof raw === 'object') ? raw : {}; }, _getStaleFinTabloSummary(factData) { const raw = factData && factData._stale_fintablo; return (raw && typeof raw === 'object') ? raw : null; }, _getConfirmedMaterialFacts(factData) { const raw = factData && factData._confirmed_material_facts; return (raw && typeof raw === 'object') ? raw : {}; }, _setConfirmedMaterialFact(factData, rowKey, payload) { if (!factData || !rowKey) return; const current = this._getConfirmedMaterialFacts(factData); if (payload && typeof payload === 'object') current[rowKey] = payload; else delete current[rowKey]; factData._confirmed_material_facts = current; }, _getMaterialFactBreakdowns(factData) { const raw = factData && factData._material_fact_breakdown; return (raw && typeof raw === 'object') ? raw : {}; }, _setMaterialFactBreakdown(factData, rowKey, payload) { if (!factData || !rowKey) return; const current = this._getMaterialFactBreakdowns(factData); if (payload && typeof payload === 'object') current[rowKey] = payload; else delete current[rowKey]; factData._material_fact_breakdown = current; }, _setFinTabloBreakdowns(factData, breakdown) { if (!factData) return; factData._fintablo_breakdown = (breakdown && typeof breakdown === 'object') ? breakdown : {}; }, _setStaleFinTabloSummary(factData, summary) { if (!factData) return; if (summary && typeof summary === 'object' && this._num(summary.total) > 0) { factData._stale_fintablo = summary; } else { delete factData._stale_fintablo; } }, _setSourceHint(factData, key, hint) { if (!factData || !key || !hint) return; const hints = this._getSourceHints(factData); hints[key] = hint; factData._source_hints = hints; }, _getRowSourceHint(factData, factKey, fallback) { return this._getSourceHints(factData)[factKey] || (factData?._auto_fintablo?.[factKey] ? 'ФинТабло' : fallback); }, _splitWeightedValue(total, weights, stageKeys) { const out = { casting: 0, trim: 0, assembly: 0, packaging: 0 }; const activeKeys = (stageKeys || []).filter(stage => this._num(weights?.[stage]) > 0); if (!activeKeys.length) return out; let allocated = 0; activeKeys.forEach((stage, idx) => { const part = idx === activeKeys.length - 1 ? round2(total - allocated) : round2(total * this._num(weights[stage])); out[stage] = part; allocated += part; }); return out; }, _isLegacyImportedEntry(entry) { const desc = String(entry?.description || entry?.task_description || ''); return /legacy google-таблиц/i.test(desc) || /Импорт часов 1[–-]15 марта/i.test(desc); }, _isWorkshopOrder(orderRef = null) { const value = typeof orderRef === 'string' ? orderRef : (orderRef?.order_name || orderRef?.project_name || orderRef?.name || ''); return /воркшоп|workshop/i.test(String(value || '')); }, _collectStageActuals(orderId, planHours = {}, params = {}, orderRef = null) { const stageKeys = ['casting', 'trim', 'assembly', 'packaging']; const entries = (this._entries || []).filter(e => Number(e.order_id) === Number(orderId)); const stageHours = { casting: 0, trim: 0, assembly: 0, packaging: 0 }; const stageSalary = { casting: 0, trim: 0, assembly: 0, packaging: 0 }; const planStageHours = { casting: this._num(planHours.hoursPlastic), trim: this._num(planHours.hoursTrim), assembly: this._num(planHours.hoursHardware), packaging: this._num(planHours.hoursPackaging), }; const isWorkshopOrder = this._isWorkshopOrder(orderRef); const legacyEligibleStages = isWorkshopOrder ? ['casting', 'trim'] : stageKeys; const legacyStageKeys = legacyEligibleStages.filter(stage => this._num(planStageHours[stage]) > 0); const legacyPlanTotal = legacyStageKeys.reduce((sum, stage) => sum + planStageHours[stage], 0); const legacyWeights = legacyPlanTotal > 0 ? legacyStageKeys.reduce((acc, stage) => ({ ...acc, [stage]: planStageHours[stage] / legacyPlanTotal }), {}) : null; let usedLegacyDistribution = false; entries.forEach(entry => { const stage = this._stageKey(entry); const hours = this._num(entry.hours); if (hours <= 0) return; const rate = this._employeeRateByName(entry.worker_name, params, entry); if (stageHours[stage] !== undefined) { stageHours[stage] += hours; stageSalary[stage] += hours * rate; return; } if (!legacyWeights || !this._isLegacyImportedEntry(entry)) return; const distributed = this._splitWeightedValue(hours, legacyWeights, legacyStageKeys); legacyStageKeys.forEach(stageKey => { const splitHours = this._num(distributed[stageKey]); if (splitHours <= 0) return; stageHours[stageKey] += splitHours; stageSalary[stageKey] += splitHours * rate; }); usedLegacyDistribution = true; }); stageKeys.forEach(stage => { stageHours[stage] = round2(stageHours[stage]); stageSalary[stage] = round2(stageSalary[stage]); }); return { hours: stageHours, salary: stageSalary, usedLegacyDistribution, legacyScope: isWorkshopOrder ? 'production_only' : 'all_planned', }; }, // ========================================== // Load // ========================================== async load() { this._orderCache = {}; this._visibleOrderRecords = []; const allOrders = await loadOrders(); this._allOrders = (allOrders || []).filter(o => this.VISIBLE_STATUSES.includes(o.status)); this._allOrders.sort((a, b) => (this.STATUS_ORDER[a.status] || 99) - (this.STATUS_ORDER[b.status] || 99)); this._monthSnapshots = (typeof loadFactualSnapshots === 'function' ? ((await loadFactualSnapshots()) || {}) : {}) || {}; this._ensurePeriodState(); this._syncPeriodControls(); this._applyFilter(); await this._syncFinTabloImportsIfNeeded(); this._entries = await loadTimeEntries(); this._employees = (await loadEmployees()) || []; await this._renderAll(); }, // ========================================== // Period filter // ========================================== _currentMonthValue() { const now = new Date(); const month = String(now.getMonth() + 1).padStart(2, '0'); return `${now.getFullYear()}-${month}`; }, _ensurePeriodState() { if (!this._periodAnchor) this._periodAnchor = this._currentMonthValue(); if (!this._periodKind) this._periodKind = 'month'; }, _syncPeriodControls() { this._ensurePeriodState(); const periodInput = document.getElementById('fact-period-anchor'); if (periodInput && periodInput.value !== this._periodAnchor) { periodInput.value = this._periodAnchor; } if (typeof document.querySelectorAll === 'function') { document.querySelectorAll('.fact-period-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.period === this._periodKind); }); } }, setPeriodKind(kind) { if (!kind) return; this._periodKind = kind; this._syncPeriodControls(); this._applyFilter(); this._renderAll(); }, setPeriodAnchor(value = null) { const nextValue = value || document.getElementById('fact-period-anchor')?.value || this._currentMonthValue(); this._periodAnchor = nextValue; this._syncPeriodControls(); this._applyFilter(); this._renderAll(); }, shiftPeriod(direction) { this._ensurePeriodState(); const [yearRaw, monthRaw] = String(this._periodAnchor || this._currentMonthValue()).split('-'); const year = Number(yearRaw) || new Date().getFullYear(); const monthIndex = (Number(monthRaw) || 1) - 1; const stepMonths = this._periodKind === 'quarter' ? 3 : this._periodKind === 'halfyear' ? 6 : this._periodKind === 'year' ? 12 : 1; const anchor = new Date(year, monthIndex + stepMonths * Number(direction || 0), 1); const nextMonth = String(anchor.getMonth() + 1).padStart(2, '0'); this.setPeriodAnchor(`${anchor.getFullYear()}-${nextMonth}`); }, // Backward compatibility with old rolling buttons setFilter(range) { const map = { '1m': 'month', '3m': 'quarter', '6m': 'halfyear', '1y': 'year' }; this.setPeriodKind(map[range] || 'month'); }, async _syncFinTabloImportsIfNeeded() { if (!Array.isArray(this._allOrders) || this._allOrders.length === 0) return; if (typeof window === 'undefined' || !window.FinTablo || typeof window.FinTablo.autoSyncMatchedImports !== 'function') return; const scopedOrders = Array.isArray(this._filteredOrders) && this._filteredOrders.length ? this._filteredOrders : this._allOrders; const orderIds = scopedOrders .map(order => Number(order?.id)) .filter(Number.isFinite); if (!orderIds.length) return; const staleImportOrderIds = await this._collectStaleFinTabloOrderIds(orderIds); await window.FinTablo.autoSyncMatchedImports({ orderIds, orders: scopedOrders, silent: true, minIntervalMs: staleImportOrderIds.length ? 0 : 60 * 1000, force: staleImportOrderIds.length > 0, }); }, async _collectStaleFinTabloOrderIds(orderIds = []) { const staleOrderIds = []; for (const rawId of orderIds) { const orderId = Number(rawId); if (!Number.isFinite(orderId)) continue; const imports = (await loadFintabloImports(orderId)) || []; if (imports.some(row => this._isStaleSplitFinTabloImport(row))) { staleOrderIds.push(orderId); } } return staleOrderIds; }, _isStaleSplitFinTabloImport(row = {}) { const rawData = row?.raw_data || {}; if (String(row?.source || '') !== 'api') return false; if (!rawData?.splitApplied) return false; const breakdown = rawData?.field_breakdown; const hasBreakdown = breakdown && Object.values(breakdown).some(value => Array.isArray(value) && value.length > 0); if (hasBreakdown) return false; const factOther = this._num(row?.fact_other); if (factOther <= 0) return false; const classifiedTotal = this._num(row?.fact_materials) + this._num(row?.fact_hardware) + this._num(row?.fact_packaging) + this._num(row?.fact_delivery) + this._num(row?.fact_printing) + this._num(row?.fact_molds) + this._num(row?.fact_taxes) + this._num(row?.fact_commercial) + this._num(row?.fact_charity); return classifiedTotal <= 0.01; }, _partitionFinTabloImports(imports = []) { const healthy = []; const stale = []; (Array.isArray(imports) ? imports : []).forEach(row => { if (this._isStaleSplitFinTabloImport(row)) stale.push(row); else healthy.push(row); }); return { healthy, stale }; }, _summarizeStaleFinTabloImports(rows = []) { const list = Array.isArray(rows) ? rows : []; const total = round2(list.reduce((sum, row) => { const rowTotal = this._num(row?.fact_materials) + this._num(row?.fact_hardware) + this._num(row?.fact_packaging) + this._num(row?.fact_delivery) + this._num(row?.fact_printing) + this._num(row?.fact_molds) + this._num(row?.fact_taxes) + this._num(row?.fact_commercial) + this._num(row?.fact_charity) + this._num(row?.fact_other); return sum + rowTotal; }, 0)); if (total <= 0) return null; return { count: list.length, total, latestUpdatedAt: list .map(row => row?.updated_at || row?.import_date || row?.created_at || '') .filter(Boolean) .sort() .slice(-1)[0] || '', }; }, _resetDerivedMoneyFacts(factData) { if (!factData) return; this.AUTO_FACT_FIELDS.forEach(field => { if (!this._isManualOverride(factData, field)) factData[field] = 0; }); const hints = { ...this._getSourceHints(factData) }; [ 'fact_hardware_total', 'fact_nfc_total', 'fact_packaging_total', 'fact_design_printing', 'fact_plastic', 'fact_molds', 'fact_delivery_client', 'fact_taxes', 'fact_commercial', 'fact_charity', 'fact_other', 'fact_revenue', ].forEach(key => delete hints[key]); factData._source_hints = hints; factData._auto_fintablo = {}; this._setFinTabloBreakdowns(factData, {}); factData._material_fact_breakdown = {}; this._setStaleFinTabloSummary(factData, null); }, setCustomRange() { this._applyFilter(); this._renderAll(); }, _applyFilter() { const range = this._getPeriodRange(); this._filteredOrders = this._allOrders.filter(order => { const orderDate = this._getOrderPeriodDate(order); if (!orderDate) return false; return orderDate >= range.from && orderDate <= range.to; }); }, _snapshotMonthKey() { this._ensurePeriodState(); return this._periodKind === 'month' ? String(this._periodAnchor || this._currentMonthValue()) : ''; }, _currentMonthKey() { return this._currentMonthValue(); }, _isPastMonthKey(monthKey) { const normalized = String(monthKey || '').slice(0, 7); return !!normalized && normalized < this._currentMonthKey(); }, _getSnapshotStore() { if (!this._monthSnapshots || typeof this._monthSnapshots !== 'object') this._monthSnapshots = {}; return this._monthSnapshots; }, _getActiveSnapshot() { const monthKey = this._snapshotMonthKey(); if (!monthKey || !this._isPastMonthKey(monthKey)) return null; const snapshot = this._getSnapshotStore()[monthKey]; if (!snapshot || !Array.isArray(snapshot.records)) return null; return snapshot; }, _makeLiveRecord(computed) { if (!computed?.order) return null; return { orderId: Number(computed.order.id), order: computed.order, planData: computed.planData || {}, planHours: computed.planHours || {}, planMeta: computed.planMeta || {}, factData: computed.factData || {}, snapshot: false, snapshotMeta: null, }; }, async _getVisibleOrderRecords() { const snapshot = this._getActiveSnapshot(); if (snapshot) { return (snapshot.records || []).map(record => ({ ...JSON.parse(JSON.stringify(record)), snapshot: true, snapshotMeta: { monthKey: snapshot.monthKey || this._snapshotMonthKey(), savedAt: snapshot.savedAt || snapshot.saved_at || '', }, })); } const computedOrders = await Promise.all(this._filteredOrders.map(order => this._ensureComputedOrder(order))); return computedOrders.map(computed => this._makeLiveRecord(computed)).filter(Boolean); }, async saveMonthlySnapshot() { if (this._periodKind !== 'month') { App.toast('Снимки доступны только для помесячной аналитики'); return; } const period = this._getPeriodRange(); const computedOrders = await Promise.all(this._filteredOrders.map(order => this._ensureComputedOrder(order))); const records = computedOrders .map(computed => this._makeLiveRecord(computed)) .filter(Boolean) .map(record => JSON.parse(JSON.stringify({ orderId: record.orderId, order: record.order, planData: record.planData, planHours: record.planHours, planMeta: record.planMeta, factData: record.factData, }))); const store = this._getSnapshotStore(); const monthKey = this._snapshotMonthKey(); store[monthKey] = { monthKey, label: period.label, caption: period.caption, savedAt: new Date().toISOString(), records, }; this._monthSnapshots = store; if (typeof saveFactualSnapshots === 'function') { await saveFactualSnapshots(store); } App.toast(`Снимок ${period.label} зафиксирован`); await this._renderAll(); }, _renderSnapshotBanner() { const el = document.getElementById('fact-snapshot-banner'); if (!el) return; if (this._periodKind !== 'month') { el.innerHTML = ''; return; } const monthKey = this._snapshotMonthKey(); const snapshot = this._getSnapshotStore()[monthKey]; const isPast = this._isPastMonthKey(monthKey); const savedAt = snapshot?.savedAt || snapshot?.saved_at || ''; const savedLabel = savedAt ? this._formatDateLabel(new Date(savedAt)) : ''; if (snapshot && Array.isArray(snapshot.records) && isPast) { el.innerHTML = `
Снимок месяца активен
Для ${this._esc(monthKey)} показывается зафиксированный срез${savedLabel ? ` от ${savedLabel}` : ''}. Эти цифры не дрейфуют от поздних правок заказов.
`; return; } if (isPast) { el.innerHTML = `
Снимок месяца ещё не зафиксирован
Сейчас для ${this._esc(monthKey)} показываются живые данные. Если позже кто-то пересохранит заказ, цифры месяца изменятся.
`; return; } el.innerHTML = `
Текущий месяц показывается по живым данным
Можно вручную зафиксировать снапшот, когда нужно закрыть месяц для финдиректора.
`; }, // ========================================== // Render all // ========================================== async _renderAll() { this._visibleOrderRecords = await this._getVisibleOrderRecords(); this._renderSnapshotBanner(); await this._renderGlobalStats(); this._renderOrdersTable(); }, async _renderGlobalStats() { const records = Array.isArray(this._visibleOrderRecords) ? this._visibleOrderRecords : []; const orders = records.map(record => record.order).filter(Boolean); const $ = id => document.getElementById(id); if (!$('fact-stat-orders')) return; const period = this._getPeriodRange(); const inProd = orders.filter(o => this.SECTION_PRODUCTION.has(o.status)); const completed = orders.filter(o => o.status === 'completed'); const samples = orders.filter(o => o.status === 'sample'); $('fact-stat-period').textContent = period.label; $('fact-stat-period-hint').textContent = period.caption; $('fact-stat-orders').textContent = String(orders.length); $('fact-stat-orders-hint').textContent = `произв: ${inProd.length} · готово: ${completed.length} · образцы: ${samples.length}`; let planRevTotal = 0; let planCostTotal = 0; let planHoursTotal = 0; let factRevTotal = 0; let factCostTotal = 0; let hasFactData = false; records.forEach(record => { const order = record?.order || {}; const plan = record?.planData || {}; const planHours = record?.planHours || {}; const fact = record?.factData || {}; planRevTotal += this._num(plan.revenue ?? order?.total_revenue_plan); planCostTotal += this._num(plan.totalCosts ?? order?.total_cost_plan); planHoursTotal += this._num(planHours.hoursPlastic) + this._num(planHours.hoursTrim) + this._num(planHours.hoursHardware) + this._num(planHours.hoursPackaging); const factRevenue = this._num(fact.fact_revenue); let factCosts = 0; this.ROWS.forEach(r => { factCosts += this._num(fact['fact_' + r.key]); }); if (factRevenue > 0 || factCosts > 0) { factRevTotal += factRevenue; factCostTotal += factCosts; hasFactData = true; } }); const planProfit = this._calcProfitability(planRevTotal, planCostTotal); $('fact-stat-plan-revenue').textContent = this.fmtRub(planRevTotal); $('fact-stat-plan-costs').textContent = this.fmtRub(planCostTotal); $('fact-stat-plan-margin').textContent = this.fmtRub(planProfit.profit); $('fact-stat-plan-margin').style.color = planProfit.color; if ($('fact-stat-plan-margin-hint')) { $('fact-stat-plan-margin-hint').textContent = planRevTotal > 0 ? `${planProfit.margin}% рентаб.` : '—'; } const params = App.params || {}; const workloadPerMonth = this._num(params.workLoadHours); const capacity = workloadPerMonth > 0 ? workloadPerMonth * period.months : 0; const planLoadPct = capacity > 0 ? round2(planHoursTotal * 100 / capacity) : 0; const factLoadHours = this._getFactLoadHoursForPeriod(period.from, period.to); const factLoadPct = capacity > 0 ? round2(factLoadHours * 100 / capacity) : 0; this._renderLoadCard($('fact-stat-plan-load'), $('fact-stat-plan-load-hint'), planLoadPct, planHoursTotal, capacity, `план по заказам периода · ${period.months} мес`); this._renderLoadCard($('fact-stat-fact-load'), $('fact-stat-fact-load-hint'), factLoadPct, factLoadHours, capacity, `табель сотрудников · ${period.months} мес`); const indirectMonthly = this._num(App.settings?.indirect_costs_monthly); if (indirectMonthly > 0) { $('fact-stat-indirect').textContent = this.fmtRub(indirectMonthly); const perHour = this._num(params.indirectPerHour); $('fact-stat-indirect-hint').textContent = perHour > 0 ? `${this.fmtRub(perHour)}/ч` : 'фикс на месяц'; } else { $('fact-stat-indirect').textContent = '—'; $('fact-stat-indirect-hint').textContent = ''; } const factProfit = this._calcProfitability(factRevTotal, factCostTotal); $('fact-stat-fact-revenue').textContent = hasFactData ? this.fmtRub(factRevTotal) : '—'; $('fact-stat-fact-costs').textContent = hasFactData ? this.fmtRub(factCostTotal) : '—'; const factProfitEl = $('fact-stat-fact-margin'); factProfitEl.textContent = hasFactData ? this.fmtRub(factProfit.profit) : '—'; factProfitEl.style.color = hasFactData ? factProfit.color : ''; if ($('fact-stat-fact-margin-hint')) { $('fact-stat-fact-margin-hint').textContent = !hasFactData ? '—' : (factProfit.margin != null ? `${factProfit.margin}% по полученным деньгам` : 'выручка ещё не поступила'); } const revDelta = factRevTotal - planRevTotal; const costDelta = factCostTotal - planCostTotal; const profitDelta = factProfit.profit - planProfit.profit; this._renderDelta($('fact-stat-rev-delta'), revDelta, hasFactData); this._renderDelta($('fact-stat-cost-delta'), costDelta, hasFactData, true); this._renderDelta($('fact-stat-earned-delta'), profitDelta, hasFactData); const summaryNote = $('fact-stat-summary-note'); const summaryHint = $('fact-stat-summary-note-hint'); const ordersLoadMeta = typeof getLastDataLoadMeta === 'function' ? getLastDataLoadMeta('orders') : null; if (summaryNote) { if (!orders.length && ordersLoadMeta?.unavailable) { summaryNote.textContent = 'Источник заказов недоступен'; summaryNote.style.color = 'var(--red)'; if (summaryHint) summaryHint.textContent = 'Сейчас база не ответила, поэтому нули не считаем фактом'; } else if (!orders.length) { summaryNote.textContent = 'Нет заказов'; summaryNote.style.color = 'var(--text-muted)'; if (summaryHint) summaryHint.textContent = 'Попробуй переключить период'; } else if (!hasFactData) { summaryNote.textContent = 'Факт ещё копится'; summaryNote.style.color = 'var(--text-muted)'; if (summaryHint) summaryHint.textContent = 'Пока есть только план'; } else if (profitDelta >= 0) { summaryNote.textContent = 'Идём лучше плана'; summaryNote.style.color = 'var(--green)'; if (summaryHint) summaryHint.textContent = 'Факт прибыли не хуже плана'; } else { summaryNote.textContent = 'Факт хуже плана'; summaryNote.style.color = 'var(--red)'; if (summaryHint) summaryHint.textContent = 'Проверь выручку, фурнитуру и часы'; } } const ordersTitle = $('fact-orders-title'); const ordersHint = $('fact-orders-hint'); if (ordersTitle) ordersTitle.textContent = `План-факт по заказам · ${period.label}`; if (ordersHint) { ordersHint.textContent = 'Внутри каждого заказа отдельно показаны: план заказа, факт производства и факт денег. Для прошлого месяца может открываться зафиксированный снапшот.'; } }, _renderDelta(el, delta, hasData, invertColor = false) { if (!el) return; if (!hasData) { el.textContent = '—'; el.style.color = ''; return; } el.textContent = `${delta >= 0 ? '+' : ''}${this.fmtRub(delta)}`; if (invertColor) { el.style.color = delta <= 0 ? 'var(--green)' : 'var(--red)'; } else { el.style.color = delta >= 0 ? 'var(--green)' : 'var(--red)'; } }, _calcProfitability(revenue, cost) { const rev = this._num(revenue); const expenses = this._num(cost); const hasRevenue = rev > 0; const hasCosts = expenses > 0; const profit = round2(rev - expenses); const margin = hasRevenue ? round2((profit * 100) / rev) : null; let color = 'var(--text-muted)'; if (margin != null) { color = margin >= 30 ? 'var(--green)' : margin >= 20 ? 'var(--yellow)' : 'var(--red)'; } else if (hasCosts && !hasRevenue) { color = 'var(--red)'; } else if (profit > 0) { color = 'var(--green)'; } return { revenue: rev, cost: expenses, profit, margin, hasRevenue, hasCosts, color }; }, _renderCompactResult(result, options = {}) { const emptyLabel = options.emptyLabel || '—'; if (!result || (!result.hasRevenue && !result.hasCosts)) { return `${emptyLabel}`; } const hint = result.margin != null ? `${result.margin}%` : (result.hasCosts ? 'без выручки' : '—'); const hintColor = result.margin != null ? result.color : 'var(--text-muted)'; return `
${this.fmtRub(result.profit)}
${hint}
`; }, _renderLoadCard(valueEl, hintEl, percent, hours, capacity, suffix = '') { if (!valueEl) return; if (!(capacity > 0)) { valueEl.textContent = '—'; if (hintEl) hintEl.textContent = ''; return; } const loadColor = percent >= 100 ? 'var(--red)' : percent >= 80 ? 'var(--yellow)' : 'var(--green)'; valueEl.innerHTML = `${percent}%`; if (hintEl) { hintEl.textContent = `${round2(hours)}ч / ${round2(capacity)}ч${suffix ? ` · ${suffix}` : ''}`; } }, _sourceBadgeStyle(label) { const normalized = String(label || '').toLowerCase(); if (normalized.includes('финтабло')) return 'background:rgba(59,130,246,0.12);color:#1d4ed8;border:1px solid rgba(59,130,246,0.18)'; if (normalized.includes('план')) return 'background:rgba(148,163,184,0.14);color:#475569;border:1px solid rgba(148,163,184,0.2)'; if (normalized.includes('склад') || normalized.includes('фурнитура проекта')) return 'background:rgba(16,185,129,0.12);color:#047857;border:1px solid rgba(16,185,129,0.18)'; if (normalized.includes('вручную')) return 'background:rgba(245,158,11,0.12);color:#b45309;border:1px solid rgba(245,158,11,0.2)'; if (normalized.includes('ожидает')) return 'background:#fff7ed;color:#9a3412;border:1px solid rgba(245,158,11,0.25)'; if (normalized.includes('формула') || normalized.includes('табель') || normalized.includes('норма') || normalized.includes('заказ')) return 'background:rgba(168,85,247,0.1);color:#7e22ce;border:1px solid rgba(168,85,247,0.16)'; return 'background:var(--bg-muted);color:var(--text-muted);border:1px solid var(--border)'; }, _renderSourceBadges(labels = []) { const unique = [...new Set((labels || []).filter(Boolean).map(label => String(label).trim()).filter(Boolean))]; if (!unique.length) return ''; return `
${unique.map(label => { return `${this._esc(label)}`; }).join('')}
`; }, _renderBreakdownDetails(detailHtml = '') { if (!detailHtml) return ''; return `
из чего сложилось
${detailHtml}
`; }, _renderLayerCell(view = {}, options = {}) { const amount = view?.amount; const hasValue = amount != null && (this._num(amount) > 0 || view?.showZero); const amountHtml = options.editable ? `` : (hasValue ? `
${this.fmtRub(amount)}
` : '
'); return `
${amountHtml} ${this._renderSourceBadges(view?.labels || [])} ${this._renderBreakdownDetails(view?.detailHtml || '')} ${view?.actionHtml ? `
${view.actionHtml}
` : ''}
`; }, _buildMaterialPlanLabels(meta = {}) { const labels = ['план']; if (meta.source === 'current_warehouse') labels.push('склад'); else if (meta.source === 'manual_items') labels.push('ожидает подтверждения'); else if (meta.source === 'mixed') labels.push('склад', 'ожидает подтверждения'); return labels; }, _buildPlanRowLabels(rowKey, planMeta = {}) { const meta = planMeta?.[rowKey] || {}; if (this.MATERIAL_ROW_KEYS.has(rowKey)) return this._buildMaterialPlanLabels(meta); if (rowKey.startsWith('salary_')) { const labels = ['план']; if (meta.source === 'blank_norms' || meta.source === 'blank_norms_plus_manual') labels.push('бланки'); if (meta.source === 'saved_items' || meta.source === 'saved_order' || meta.source === 'blank_norms_plus_manual') labels.push('заказ'); return labels; } if (rowKey === 'indirect_production') return ['план', 'формула']; if (rowKey === 'taxes' || rowKey === 'commercial' || rowKey === 'charity') return ['план', 'формула']; if (rowKey === 'plastic' || rowKey === 'molds') return ['план', 'норма']; return ['план']; }, _buildMoneyRowLabels(rowKey, factData = {}, planMeta = {}) { if (rowKey === 'revenue') { if (this._isManualOverride(factData, 'fact_revenue')) return ['вручную']; if (factData?._auto_fintablo?.fact_revenue) return ['ФинТабло']; return []; } const factKey = `fact_${rowKey}`; if (this._isManualOverride(factData, factKey)) return ['вручную']; if (factData?._auto_fintablo?.[factKey]) return ['ФинТабло']; if (this.MATERIAL_ROW_KEYS.has(rowKey)) { const breakdown = this._getMaterialFactBreakdowns(factData)?.[rowKey] || {}; const labels = []; if (this._num(breakdown.warehouseActual) > 0) labels.push(breakdown.actualHint || 'склад'); if (this._num(breakdown.confirmedManual) > 0) labels.push('вручную'); if (!labels.length && (this._num(breakdown.warehousePlan) > 0 || this._num(breakdown.manualPlan) > 0)) labels.push('ожидает подтверждения'); return labels.length ? labels : this._buildMaterialPlanLabels(planMeta?.[rowKey] || {}); } const hint = this._getRowSourceHint(factData, factKey, ''); if (hint.includes('переимпорт')) return ['нужен переимпорт']; if (hint.includes('ФинТабло')) return ['ФинТабло']; if (hint.includes('вручную')) return ['вручную']; if (hint.includes('табель') || hint.includes('legacy')) return ['табель']; if (hint.includes('формула')) return ['формула']; if (hint.includes('план')) return ['план']; return []; }, _buildMoneyRowDetail(rowKey, factData = {}, planMeta = {}, planData = {}) { const breakdownRows = this._getFinTabloBreakdowns(factData)[`fact_${rowKey}`]; if (Array.isArray(breakdownRows) && breakdownRows.length) { return breakdownRows.slice(0, 8).map(row => { const label = this._esc(row.description || row.category || 'операция'); return `${label} — ${this.fmtRub(row.amount)}`; }).join('
'); } if (this.MATERIAL_ROW_KEYS.has(rowKey)) { const breakdown = this._getMaterialFactBreakdowns(factData)?.[rowKey] || {}; const lines = []; if (this._num(breakdown.warehousePlan) > 0) lines.push(`план со склада — ${this.fmtRub(breakdown.warehousePlan)}`); if (Array.isArray(breakdown.details) && breakdown.details.length) { breakdown.details.slice(0, 6).forEach(item => { lines.push(`${item.label} · ${item.qty} шт — ${this.fmtRub(item.amount)}`); }); } else if (this._num(breakdown.warehouseActual) > 0) { lines.push(`подтверждено складом — ${this.fmtRub(breakdown.warehouseActual)}`); } if (this._num(breakdown.manualPlan) > 0) lines.push(`кастом по заказу — ${this.fmtRub(breakdown.manualPlan)}`); if (this._num(breakdown.confirmedManual) > 0) lines.push(`подтверждено вручную — ${this.fmtRub(breakdown.confirmedManual)}`); if (!this._num(breakdown.confirmedManual) && this._num(breakdown.manualPlan) > 0) lines.push(`ожидает подтверждения — ${this.fmtRub(breakdown.manualPlan)}`); return lines.join('
'); } if (rowKey === 'other') { const stale = this._getStaleFinTabloSummary(factData); if (stale && this._num(stale.total) > 0) { const dateLabel = stale.latestUpdatedAt ? this._formatDateLabel(new Date(stale.latestUpdatedAt)) : ''; return `старый split-импорт ФинТабло без расшифровки исключён из факта денег — ${this.fmtRub(stale.total)}${dateLabel ? ` · последнее обновление ${dateLabel}` : ''}
нужен новый переимпорт из ФинТабло`; } } if (rowKey === 'taxes') { const factRevenue = this._num(factData.fact_revenue); const taxRate = this._taxRate(App.params || {}); return factRevenue > 0 ? `${round2(taxRate * 100)}% от ${this.fmtRub(factRevenue)} без НДС` : ''; } if (rowKey === 'commercial') { const factRevenue = this._num(factData.fact_revenue); return factRevenue > 0 ? `6.5% от ${this.fmtRub(factRevenue)} без НДС` : ''; } if (rowKey === 'charity') { const factRevenue = this._num(factData.fact_revenue); return factRevenue > 0 ? `1% от ${this.fmtRub(factRevenue)} без НДС` : ''; } if ((rowKey === 'plastic' || rowKey === 'molds') && this._num(planData?.[rowKey === 'delivery_client' ? 'delivery' : rowKey]) > 0) { return `без отдельного денежного факта, пока используем расчетную норму — ${this.fmtRub(planData[rowKey === 'delivery_client' ? 'delivery' : rowKey])}`; } return ''; }, _buildProductionMaterialView(rowKey, factData = {}, planMeta = {}) { const breakdown = this._getMaterialFactBreakdowns(factData)?.[rowKey] || {}; const amount = round2(this._num(breakdown.warehouseActual) + this._num(breakdown.confirmedManual)); const labels = []; if (this._num(breakdown.warehouseActual) > 0) labels.push(breakdown.actualHint || 'склад'); if (this._num(breakdown.confirmedManual) > 0) labels.push('вручную'); if (!labels.length && (this._num(breakdown.warehousePlan) > 0 || this._num(breakdown.manualPlan) > 0)) labels.push('ожидает подтверждения'); const lines = []; if (this._num(breakdown.warehousePlan) > 0) lines.push(`план со склада — ${this.fmtRub(breakdown.warehousePlan)}`); if (Array.isArray(breakdown.details) && breakdown.details.length) { breakdown.details.slice(0, 6).forEach(item => { lines.push(`${item.label} · ${item.qty} шт — ${this.fmtRub(item.amount)}`); }); } else if (this._num(breakdown.warehouseActual) > 0) { lines.push(`подтверждено складом — ${this.fmtRub(breakdown.warehouseActual)}`); } if (this._num(breakdown.manualPlan) > 0) lines.push(`кастом по заказу — ${this.fmtRub(breakdown.manualPlan)}`); if (this._num(breakdown.confirmedManual) > 0) lines.push(`подтверждено вручную — ${this.fmtRub(breakdown.confirmedManual)}`); if (!this._num(breakdown.confirmedManual) && this._num(breakdown.manualPlan) > 0) lines.push(`ожидает подтверждения — ${this.fmtRub(breakdown.manualPlan)}`); return { amount, labels, detailHtml: lines.join('
'), waitingManual: this._num(breakdown.manualPlan) > this._num(breakdown.confirmedManual), manualOutstanding: round2(Math.max(0, this._num(breakdown.manualPlan) - this._num(breakdown.confirmedManual))), }; }, _parseDateValue(rawValue) { if (!rawValue) return null; if (rawValue instanceof Date) return Number.isFinite(rawValue.getTime()) ? rawValue : null; const raw = String(rawValue).trim(); if (!raw) return null; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? new Date(`${raw}T12:00:00`) : new Date(raw); return Number.isFinite(normalized.getTime()) ? normalized : null; }, _monthLabel(date) { return new Intl.DateTimeFormat('ru-RU', { month: 'long', year: 'numeric' }).format(date); }, _formatDateLabel(date) { return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' }).format(date); }, _getPeriodRange() { this._ensurePeriodState(); const [yearRaw, monthRaw] = String(this._periodAnchor || this._currentMonthValue()).split('-'); const year = Number(yearRaw) || new Date().getFullYear(); const monthIndex = Math.max(0, (Number(monthRaw) || 1) - 1); let startMonth = monthIndex; let months = 1; let label = this._monthLabel(new Date(year, monthIndex, 1)); if (this._periodKind === 'quarter') { startMonth = Math.floor(monthIndex / 3) * 3; months = 3; label = `${Math.floor(startMonth / 3) + 1} квартал ${year}`; } else if (this._periodKind === 'halfyear') { startMonth = monthIndex < 6 ? 0 : 6; months = 6; label = `${startMonth === 0 ? 'I' : 'II'} полугодие ${year}`; } else if (this._periodKind === 'year') { startMonth = 0; months = 12; label = String(year); } const from = new Date(year, startMonth, 1, 0, 0, 0, 0); const to = new Date(year, startMonth + months, 0, 23, 59, 59, 999); return { from, to, months, label, caption: `${this._formatDateLabel(from)} — ${this._formatDateLabel(to)}`, }; }, _getOrderPeriodDate(order) { return this._parseDateValue( order?.deadline_end || order?.deadline || order?.deadline_start || order?.created_at ); }, _getEntryPeriodDate(entry) { return this._parseDateValue(entry?.date || entry?.work_date || entry?.created_at || entry?.updated_at); }, _isProductionLoadEntry(entry) { const stage = this._stageKey(entry); return stage === 'casting' || stage === 'trim' || stage === 'assembly' || stage === 'packaging' || this._isLegacyImportedEntry(entry); }, _getFactLoadHoursForPeriod(from, to) { return round2((this._entries || []).reduce((sum, entry) => { const date = this._getEntryPeriodDate(entry); if (!date || date < from || date > to) return sum; if (!this._isProductionLoadEntry(entry)) return sum; return sum + this._num(entry.hours); }, 0)); }, SECTION_PRODUCTION: new Set(['production_casting', 'production_printing', 'production_hardware', 'production_packaging', 'in_production', 'delivery']), SECTION_SAMPLE: new Set(['sample']), SECTION_COMPLETED: new Set(['completed']), SECTIONS: [ { key: 'production', label: '⚙️ В производстве', icon: '🔧', statuses: null }, { key: 'sample', label: '🔬 Образцы', icon: '🔬', statuses: null }, { key: 'completed', label: '✅ Готово', icon: '✅', statuses: null }, ], _getSection(status) { if (this.SECTION_PRODUCTION.has(status)) return 'production'; if (this.SECTION_SAMPLE.has(status)) return 'sample'; if (this.SECTION_COMPLETED.has(status)) return 'completed'; return 'production'; }, _renderOrdersTable() { const tbody = document.getElementById('fact-orders-body'); if (!tbody) return; const records = Array.isArray(this._visibleOrderRecords) ? this._visibleOrderRecords : []; const ordersLoadMeta = typeof getLastDataLoadMeta === 'function' ? getLastDataLoadMeta('orders') : null; if (records.length === 0) { if (ordersLoadMeta?.unavailable) { tbody.innerHTML = 'Не удалось загрузить заказы из базы. Попробуй обновить страницу чуть позже.'; } else { tbody.innerHTML = 'Нет заказов за выбранный период'; } return; } // Group orders by section const groups = { production: [], sample: [], completed: [] }; records.forEach(record => { const sec = this._getSection(record?.order?.status); groups[sec].push(record); }); const sectionMeta = [ { key: 'production', label: '⚙️ В производстве', color: 'var(--yellow)' }, { key: 'sample', label: '🔬 Образцы', color: 'var(--accent)' }, { key: 'completed', label: '✅ Готово', color: 'var(--green)' }, ]; let html = ''; sectionMeta.forEach(sec => { const sectionRecords = groups[sec.key]; if (sectionRecords.length === 0) return; // Section header row html += ` ${sec.label} (${sectionRecords.length}) `; sectionRecords.forEach(record => { html += this._renderOrderRow(record); }); }); tbody.innerHTML = html; // Load fact summaries for each order (lightweight — just fact totals) this._loadFactSummaries(); // Re-open detail if was open if (this._openOrderId) { const detailRow = document.getElementById('fact-detail-row-' + this._openOrderId); if (detailRow) { detailRow.style.display = ''; this._loadAndRenderDetail(this._openOrderId); } } }, _renderOrderRow(record) { const o = record?.order || {}; const planRevenue = this._num(record?.planData?.revenue ?? o.total_revenue_plan); const planCost = this._num(record?.planData?.totalCosts ?? o.total_cost_plan); const planResult = this._calcProfitability(planRevenue, planCost); const status = this.STATUS_LABELS[o.status] || o.status; const isOpen = this._openOrderId === o.id; const snapshotHint = record?.snapshot ? '
снимок месяца
' : ''; let html = ` ${this._esc(o.order_name || '—')}${snapshotHint} ${status} ${this.fmtRub(planCost)} ${this.fmtRub(planRevenue)} ${this._renderCompactResult(planResult)} ${isOpen ? '▼' : '▶'} `; html += `
`; return html; }, async _loadFactSummaries() { const records = Array.isArray(this._visibleOrderRecords) ? this._visibleOrderRecords : []; records.forEach(record => { const o = record?.order || {}; const f = record?.factData; if (!f) return; const factRevenue = parseFloat(f.fact_revenue) || 0; let factCosts = 0; this.ROWS.forEach(r => { factCosts += parseFloat(f['fact_' + r.key]) || 0; }); const hasFactData = factRevenue > 0 || factCosts > 0; const costEl = document.getElementById('fact-row-fcost-' + o.id); const revenueEl = document.getElementById('fact-row-frevenue-' + o.id); const resultEl = document.getElementById('fact-row-fresult-' + o.id); if (costEl) { if (hasFactData) { const planCost = this._num(record?.planData?.totalCosts ?? o.total_cost_plan); const alarm = this.getAlarm(factCosts, planCost); costEl.innerHTML = `${this.fmtRub(factCosts)}`; } else { costEl.innerHTML = ''; } } if (revenueEl) { if (hasFactData) { const color = factRevenue > 0 ? 'var(--green)' : 'var(--text-muted)'; revenueEl.innerHTML = `${this.fmtRub(factRevenue)}`; } else { revenueEl.innerHTML = ''; } } if (resultEl) { resultEl.innerHTML = hasFactData ? this._renderCompactResult(this._calcProfitability(factRevenue, factCosts)) : ''; } }); }, // ========================================== // Accordion toggle // ========================================== async toggleDetail(orderId) { if (typeof orderId === 'string') orderId = Number(orderId); const detailRow = document.getElementById('fact-detail-row-' + orderId); if (!detailRow) return; if (this._openOrderId === orderId) { // Close detailRow.style.display = 'none'; this._openOrderId = null; this._renderOrdersTable(); return; } // Close previous if (this._openOrderId) { const prev = document.getElementById('fact-detail-row-' + this._openOrderId); if (prev) prev.style.display = 'none'; } this._openOrderId = orderId; detailRow.style.display = ''; this._renderOrdersTable(); await this._loadAndRenderDetail(orderId); }, // ========================================== // Load plan+fact for one order // ========================================== async _loadAndRenderDetail(orderId) { const container = document.getElementById('fact-detail-' + orderId); if (!container) return; container.innerHTML = '

⏳ Загрузка...

'; const snapshotRecord = (this._visibleOrderRecords || []).find(record => Number(record?.orderId) === Number(orderId)); if (snapshotRecord?.snapshot) { this._renderDetail( orderId, container, snapshotRecord.planData || {}, snapshotRecord.planHours || {}, snapshotRecord.factData || {}, snapshotRecord.order || {}, snapshotRecord.planMeta || {}, { snapshot: true, snapshotMeta: snapshotRecord.snapshotMeta || null } ); return; } const computed = await this._ensureComputedOrder(orderId); if (!computed) { container.innerHTML = '

Заказ не найден

'; return; } const { order, planData, planHours, factData, planMeta } = computed; this._renderDetail(orderId, container, planData, planHours, factData, order, planMeta || {}, { snapshot: false, snapshotMeta: null }); }, _buildPlan(order, rawItems, params) { const planItems = this._dedupePlanItems(rawItems || []); let planHoursPlastic = 0, planHoursTrim = 0, planHoursAssembly = 0, planHoursPackaging = 0; let planHoursAssemblyProducts = 0, planHoursAssemblyExternal = 0; let hardwarePurchase = 0, hardwareDelivery = 0, nfcTotal = 0, packagingPurchase = 0, packagingDelivery = 0; let designPrinting = 0, plastic = 0, molds = 0, delivery = 0; let savedIndirect = 0; let hasProductAssemblySnapshot = false; let hasSavedSnapshot = false; let usedDuplicateCollapse = planItems.length !== (rawItems || []).length; planItems.forEach(ri => { const qty = this._num(ri.quantity); if (qty <= 0) return; if (ri.item_type === 'product') { const liveTemplate = ri.template_id && Array.isArray(App?.templates) ? App.templates.find(template => String(template.id) === String(ri.template_id)) : null; const snapshotHoursPlastic = this._num(ri.hours_plastic) || this._num(ri.result?.hoursPlastic); const snapshotHoursTrim = this._num(ri.hours_cutting) || this._num(ri.result?.hoursCutting); const snapshotHoursAssembly = this._planItemHours(ri, 'hours_assembly'); const calcFallback = ((!snapshotHoursPlastic && !snapshotHoursTrim) || !(snapshotHoursAssembly > 0)) ? calculateItemCost({ quantity: ri.quantity, pieces_per_hour: ri.pieces_per_hour, weight_grams: ri.weight_grams, extra_molds: ri.extra_molds || 0, base_mold_in_stock: ri.base_mold_in_stock || false, complex_design: ri.complex_design || false, is_blank_mold: ri.is_blank_mold || false, is_nfc: ri.is_nfc || false, nfc_programming: ri.nfc_programming || false, delivery_included: ri.delivery_included || false, printings: ri.printings ? (typeof ri.printings === 'string' ? JSON.parse(ri.printings) : ri.printings) : [], sell_price_item: ri.sell_price_item || 0, sell_price_printing: ri.sell_price_printing || 0, product_name: ri.product_name, template_id: ri.template_id || null, builtin_hw_name: ri.builtin_hw_name || liveTemplate?.hw_name || '', builtin_hw_price: ri.builtin_hw_price || liveTemplate?.hw_price_per_unit || 0, builtin_hw_delivery_total: ri.builtin_hw_delivery_total || liveTemplate?.hw_delivery_total || 0, builtin_hw_speed: ri.builtin_hw_speed || liveTemplate?.hw_speed || 0, builtin_assembly_name: ri.builtin_assembly_name || liveTemplate?.builtin_assembly_name || '', builtin_assembly_speed: ri.builtin_assembly_speed || liveTemplate?.builtin_assembly_speed || 0, }, params) : null; planHoursPlastic += snapshotHoursPlastic > 0 ? snapshotHoursPlastic : this._num(calcFallback?.hoursPlastic); planHoursTrim += snapshotHoursTrim > 0 ? snapshotHoursTrim : this._num(calcFallback?.hoursCutting); const resolvedProductAssemblyHours = snapshotHoursAssembly > 0 ? snapshotHoursAssembly : this._num(calcFallback?.hoursAssemblyZone); planHoursAssembly += resolvedProductAssemblyHours; planHoursAssemblyProducts += resolvedProductAssemblyHours; const productFot = this._planItemCost(ri, 'cost_fot'); const productCutting = this._planItemCost(ri, 'cost_cutting'); const productIndirect = this._planItemCost(ri, 'cost_indirect'); const cuttingIndirect = this._planItemCost(ri, 'cost_cutting_indirect'); const productAssembly = this._planItemCost(ri, 'cost_builtin_assembly'); const productAssemblyIndirect = this._planItemCost(ri, 'cost_builtin_assembly_indirect'); const productPlastic = this._planItemCost(ri, 'cost_plastic'); const productMold = this._planItemCost(ri, 'cost_mold_amortization'); const productDesign = this._planItemCost(ri, 'cost_design'); const productPrinting = this._planItemCost(ri, 'cost_printing'); const productDelivery = this._planItemCost(ri, 'cost_delivery'); const productNfc = this._planItemCost(ri, 'cost_nfc_tag'); if (productFot || productCutting || productIndirect || cuttingIndirect || productAssembly || productAssemblyIndirect || productPlastic || productMold || productDesign || productPrinting || productDelivery || productNfc) { hasSavedSnapshot = true; } if (productAssembly || productAssemblyIndirect || snapshotHoursAssembly > 0) { hasProductAssemblySnapshot = true; } plastic += qty * productPlastic; molds += qty * productMold; designPrinting += qty * (productDesign + productPrinting); delivery += qty * productDelivery; nfcTotal += qty * productNfc; savedIndirect += qty * (productIndirect + cuttingIndirect + productAssemblyIndirect); } else if (ri.item_type === 'hardware') { const savedHardwareHours = this._num(ri.hours_hardware) || this._num(ri.result?.hoursHardware); planHoursAssembly += savedHardwareHours; planHoursAssemblyExternal += savedHardwareHours; hardwarePurchase += qty * this._planItemCost(ri, 'hardware_price_per_unit'); hardwareDelivery += qty * this._planItemCost(ri, 'hardware_delivery_per_unit'); if (savedHardwareHours > 0 || this._planItemCost(ri, 'hardware_price_per_unit', 'hardware_delivery_per_unit') > 0) { hasSavedSnapshot = true; } } else if (ri.item_type === 'packaging') { const savedPackagingHours = this._num(ri.hours_packaging) || this._num(ri.result?.hoursPackaging); planHoursPackaging += savedPackagingHours; packagingPurchase += qty * this._planItemCost(ri, 'packaging_price_per_unit'); packagingDelivery += qty * this._planItemCost(ri, 'packaging_delivery_per_unit'); if (savedPackagingHours > 0 || this._planItemCost(ri, 'packaging_price_per_unit', 'packaging_delivery_per_unit') > 0) { hasSavedSnapshot = true; } } }); const derivedAssemblyHours = round2(planHoursAssembly); const derivedPackagingHours = round2(planHoursPackaging); const savedAssemblyHours = this._num(order.production_hours_hardware); if (savedAssemblyHours > 0) planHoursAssembly = savedAssemblyHours; const savedPackagingHours = this._num(order.production_hours_packaging); if (savedPackagingHours > 0) planHoursPackaging = savedPackagingHours; const salaryProduction = round2(hasSavedSnapshot ? planItems.reduce((sum, ri) => ri.item_type === 'product' ? sum + (this._num(ri.quantity) * this._planItemCost(ri, 'cost_fot')) : sum, 0) : (planHoursPlastic * (params.fotPerHour || 0))); const salaryTrim = round2(hasSavedSnapshot ? planItems.reduce((sum, ri) => ri.item_type === 'product' ? sum + (this._num(ri.quantity) * this._planItemCost(ri, 'cost_cutting')) : sum, 0) : (planHoursTrim * (params.fotPerHour || 0))); const externalAssemblyHoursForSalary = Math.max(0, planHoursAssembly - planHoursAssemblyProducts); const salaryAssembly = round2(hasSavedSnapshot ? (hasProductAssemblySnapshot ? ( planItems.reduce((sum, ri) => ri.item_type === 'product' ? sum + (this._num(ri.quantity) * this._planItemCost(ri, 'cost_builtin_assembly')) : sum, 0) + (externalAssemblyHoursForSalary * (params.fotPerHour || 0)) ) : (planHoursAssembly * (params.fotPerHour || 0))) : (planHoursAssembly * (params.fotPerHour || 0))); const salaryPackaging = round2(planHoursPackaging * (params.fotPerHour || 0)); const plannedHoursTotal = planHoursPlastic + planHoursTrim + planHoursAssembly + planHoursPackaging; const prodIndirect = round2(hasSavedSnapshot ? (savedIndirect + ((planHoursAssemblyExternal + planHoursPackaging) * (params.indirectPerHour || 0))) : (plannedHoursTotal * (params.indirectPerHour || 0))); const orderRevenue = this._num(order.total_revenue_plan); const rowsWithoutTaxes = round2( round2(salaryProduction) + round2(salaryTrim) + round2(salaryAssembly) + round2(salaryPackaging) + round2(prodIndirect) + round2(hardwarePurchase) + round2(hardwareDelivery) + round2(nfcTotal) + round2(packagingPurchase) + round2(packagingDelivery) + round2(designPrinting) + round2(plastic) + round2(molds) + round2(delivery) ); const taxes = this._calcTaxesByRevenue(orderRevenue, params); const commercial = this._calcCommercialByRevenue(orderRevenue, params); const charity = this._calcCharityByRevenue(orderRevenue, params); const otherBalance = 0; const computedTotalCosts = round2(rowsWithoutTaxes + taxes + commercial + charity + otherBalance); const computedMarginPercent = orderRevenue > 0 ? round2(((orderRevenue - computedTotalCosts) / orderRevenue) * 100) : 0; const computedEarned = round2(orderRevenue - computedTotalCosts); return { planData: { salaryProduction: round2(salaryProduction), salaryTrim: round2(salaryTrim), salaryAssembly: round2(salaryAssembly), salaryPackaging: round2(salaryPackaging), indirectProduction: round2(prodIndirect), hardwareTotal: round2(hardwarePurchase + hardwareDelivery), nfcTotal: round2(nfcTotal), packagingTotal: round2(packagingPurchase + packagingDelivery), designPrinting: round2(designPrinting), plastic: round2(plastic), molds: round2(molds), delivery: round2(delivery), taxes: round2(taxes), commercial: round2(commercial), charity: round2(charity), other: round2(otherBalance), totalCosts: computedTotalCosts, revenue: orderRevenue > 0 ? round2(orderRevenue) : 0, planMarginPercent: computedMarginPercent, planEarned: computedEarned, }, planHours: { hoursPlastic: round2(planHoursPlastic), hoursTrim: round2(planHoursTrim), hoursHardware: round2(planHoursAssembly), hoursPackaging: round2(planHoursPackaging), }, planMeta: { salary_production: { planHours: round2(planHoursPlastic), source: hasSavedSnapshot ? 'saved_items' : 'derived_items', }, salary_trim: { planHours: round2(planHoursTrim), source: hasSavedSnapshot ? 'saved_items' : 'derived_items', }, salary_assembly: { planHours: round2(planHoursAssembly), source: savedAssemblyHours > 0 ? 'saved_order' : (derivedAssemblyHours > 0 ? 'saved_items' : 'derived_items'), savedHours: round2(savedAssemblyHours), derivedHours: round2(derivedAssemblyHours), }, salary_packaging: { planHours: round2(planHoursPackaging), source: savedPackagingHours > 0 ? 'saved_order' : (derivedPackagingHours > 0 ? 'saved_items' : 'derived_items'), savedHours: round2(savedPackagingHours), derivedHours: round2(derivedPackagingHours), }, indirect_production: { planHours: round2(plannedHoursTotal), perHour: this._num(params.indirectPerHour), source: hasSavedSnapshot ? 'saved_items' : 'hours_formula', detailHtml: hasSavedSnapshot ? `косвенные из текущих строк заказа${usedDuplicateCollapse ? '
одинаковые складские позиции объединены, чтобы не считать фурнитуру дважды' : ''}` : undefined, formula: hasSavedSnapshot ? 'текущие cost_indirect из строк заказа' : 'общие плановые часы × косв./ч', }, }, }; }, async _applyCurrentPlanMaterialTotals(planBuild, rawItems = []) { if (!planBuild || !planBuild.planData) return planBuild; let warehouseItems = []; try { warehouseItems = (await loadWarehouseItems()) || []; } catch (error) { console.warn('Factual: failed to load warehouse items for current plan materials', error); warehouseItems = []; } if (!warehouseItems.length) return planBuild; const planItems = this._dedupePlanItems(rawItems || []); if (!planItems.length) return planBuild; const itemMap = new Map((warehouseItems || []).map(item => [Number(item.id || 0), item])); const demandRows = this._collectWarehouseDemandRows(planItems, warehouseItems); let hardwareWarehouseTotal = 0; let nfcWarehouseTotal = 0; let packagingWarehouseTotal = 0; (demandRows || []).forEach(row => { const itemId = Number(row.warehouse_item_id || 0); const qty = this._num(row.qty); if (!itemId || qty <= 0) return; const warehouseItem = itemMap.get(itemId) || null; const unitPrice = this._num(warehouseItem?.price_per_unit); const rowTotal = round2(qty * unitPrice); const category = String(row.material_type || warehouseItem?.category || '').toLowerCase(); if (category === 'packaging') packagingWarehouseTotal += rowTotal; else if (this._isNfcWarehouseRow(row, warehouseItem)) nfcWarehouseTotal += rowTotal; else hardwareWarehouseTotal += rowTotal; }); let hardwareManualTotal = 0; let nfcManualTotal = 0; let packagingManualTotal = 0; planItems.forEach(item => { const type = String(item?.item_type || '').toLowerCase(); if (type !== 'hardware' && type !== 'packaging') return; const source = String(item?.source || (type === 'hardware' ? item?.hardware_source : item?.packaging_source) || '').toLowerCase(); if (source === 'warehouse') return; const qty = this._num(item?.quantity ?? item?.qty); if (qty <= 0) return; const unitPrice = type === 'hardware' ? this._planItemCost(item, 'hardware_price_per_unit') : this._planItemCost(item, 'packaging_price_per_unit'); const unitDelivery = type === 'hardware' ? this._planItemCost(item, 'hardware_delivery_per_unit') : this._planItemCost(item, 'packaging_delivery_per_unit'); const rowTotal = round2(qty * (unitPrice + unitDelivery)); if (type === 'packaging') packagingManualTotal += rowTotal; else if (this._isNfcWarehouseRow({ warehouse_sku: item?.hardware_warehouse_sku, sku: item?.hardware_warehouse_sku, name: item?.product_name || item?.name, product_name: item?.product_name || item?.name, })) nfcManualTotal += rowTotal; else hardwareManualTotal += rowTotal; }); const hasWarehousePlan = hardwareWarehouseTotal > 0 || nfcWarehouseTotal > 0 || packagingWarehouseTotal > 0; if (!hasWarehousePlan && !hardwareManualTotal && !nfcManualTotal && !packagingManualTotal) return planBuild; const planData = planBuild.planData; const nextHardwareTotal = round2(hardwareWarehouseTotal + hardwareManualTotal); const nextNfcTotal = round2(nfcWarehouseTotal + nfcManualTotal); const nextPackagingTotal = round2(packagingWarehouseTotal + packagingManualTotal); planData.hardwareTotal = nextHardwareTotal; planData.nfcTotal = nextNfcTotal; planData.packagingTotal = nextPackagingTotal; const recomputedTotalCosts = round2( this._num(planData.salaryProduction) + this._num(planData.salaryTrim) + this._num(planData.salaryAssembly) + this._num(planData.salaryPackaging) + this._num(planData.indirectProduction) + this._num(planData.hardwareTotal) + this._num(planData.nfcTotal) + this._num(planData.packagingTotal) + this._num(planData.designPrinting) + this._num(planData.plastic) + this._num(planData.molds) + this._num(planData.delivery) + this._num(planData.taxes) + this._num(planData.commercial) + this._num(planData.charity) + this._num(planData.other) ); planData.totalCosts = recomputedTotalCosts; planData.planMarginPercent = planData.revenue > 0 ? round2(((this._num(planData.revenue) - recomputedTotalCosts) / this._num(planData.revenue)) * 100) : 0; planData.planEarned = round2(this._num(planData.revenue) - recomputedTotalCosts); const materialSource = (warehouseTotal, manualTotal) => { const hasWarehouse = this._num(warehouseTotal) > 0; const hasManual = this._num(manualTotal) > 0; if (hasWarehouse && hasManual) return 'mixed'; if (hasWarehouse) return 'current_warehouse'; if (hasManual) return 'manual_items'; return 'none'; }; const planMeta = planBuild.planMeta || {}; planMeta.hardware_total = { source: materialSource(hardwareWarehouseTotal, hardwareManualTotal), warehouseTotal: round2(hardwareWarehouseTotal), manualTotal: round2(hardwareManualTotal), }; planMeta.nfc_total = { source: materialSource(nfcWarehouseTotal, nfcManualTotal), warehouseTotal: round2(nfcWarehouseTotal), manualTotal: round2(nfcManualTotal), }; planMeta.packaging_total = { source: materialSource(packagingWarehouseTotal, packagingManualTotal), warehouseTotal: round2(packagingWarehouseTotal), manualTotal: round2(packagingManualTotal), }; planBuild.planMeta = planMeta; return planBuild; }, // ========================================== // Auto-fact sources (same logic as before) // ========================================== _getManualOverrides(factData) { const raw = factData && factData._manual_overrides; return (raw && typeof raw === 'object') ? raw : {}; }, _isManualOverride(factData, key) { return !!this._getManualOverrides(factData)[key]; }, _setManualOverride(factData, key, enabled) { if (!factData) return; const ov = this._getManualOverrides(factData); if (enabled) ov[key] = true; else delete ov[key]; factData._manual_overrides = ov; }, _applyAutoFactValue(factData, key, value) { if (!factData || this._isManualOverride(factData, key)) return; factData[key] = round2(this._num(value)); }, _isAutoFactRow(factData, key) { if (this.AUTO_FACT_KEYS.has(key)) return true; // Revenue is auto if sourced from fintablo if (key === 'revenue' && factData?._auto_fintablo?.fact_revenue) return true; return false; }, async _recomputeCachedFact(cached) { if (!cached) return; const factData = cached.factData || {}; const planData = cached.planData || {}; const planHours = cached.planHours || {}; const orderId = Number(cached.order?.id || cached.orderId || 0); await this._applyHoursFromEntries(factData, orderId, planHours, App.params || {}, cached.order || null); await this._applyDerivedFacts(factData, planData, planHours, App.params || {}, orderId, cached.order?.order_name || '', cached.order || null, cached.items || [], cached.planMeta || {}); let factTotal = 0; this.ROWS.forEach(row => { factTotal += parseFloat(factData['fact_' + row.key]) || 0; }); factData.fact_total = round2(factTotal); }, _renderAutoResetControl(orderId, key, isAuto, manualOverride) { if (!isAuto || !manualOverride) return ''; return ``; }, async _ensureComputedOrder(orderRef) { const orderId = typeof orderRef === 'object' ? Number(orderRef?.id) : Number(orderRef); if (!Number.isFinite(orderId)) return null; const cached = this._orderCache[orderId]; if (cached?.computed) return cached; const orderData = await loadOrder(orderId); if (!orderData?.order) return null; const params = App.params || {}; const { order, items: rawItems } = orderData; const planBuild = this._buildPlan(order, rawItems || [], params); await this._applyCurrentPlanMaterialTotals(planBuild, rawItems || []); const { planData, planHours, planMeta } = planBuild; const factData = { ...(await loadFactual(orderId) || {}) }; this._applyHoursFromEntries(factData, orderId, planHours, params, order); await this._applyDerivedFacts(factData, planData, planHours, params, orderId, order.order_name || '', order, rawItems || [], planMeta || {}); const computed = { ...(cached || {}), order, items: rawItems || [], planData, planHours, planMeta, factData, computed: true, }; this._orderCache[orderId] = computed; return computed; }, _applyHoursFromEntries(factData, orderId, planHours = {}, params = {}, orderRef = null) { const stageActuals = this._collectStageActuals(orderId, planHours, params, orderRef); factData.fact_hours_production = round2(stageActuals.hours.casting); factData.fact_hours_trim = round2(stageActuals.hours.trim); factData.fact_hours_assembly = round2(stageActuals.hours.assembly); factData.fact_hours_packaging = round2(stageActuals.hours.packaging); factData._auto_stage_salary = stageActuals.salary; factData._hours_source = stageActuals.usedLegacyDistribution ? 'timetrack+legacy-stage-estimate' : 'timetrack'; factData._legacy_stage_estimate = stageActuals.usedLegacyDistribution; factData._legacy_stage_scope = stageActuals.legacyScope || 'all_planned'; if (stageActuals.usedLegacyDistribution) { this._setSourceHint(factData, 'fact_salary_production', 'часы × ставка, legacy по плану'); this._setSourceHint(factData, 'fact_salary_trim', 'часы × ставка, legacy по плану'); if (stageActuals.legacyScope !== 'production_only') { this._setSourceHint(factData, 'fact_salary_assembly', 'часы × ставка, legacy по плану'); this._setSourceHint(factData, 'fact_salary_packaging', 'часы × ставка, legacy по плану'); this._setSourceHint(factData, 'fact_indirect_production', 'часы × косв./ч, legacy по плану'); } else { this._setSourceHint(factData, 'fact_indirect_production', 'часы × косв./ч, legacy в выливание/срезание'); } } }, _employeeRateByName(name, params, entry = null) { const fallback = params?.fotPerHour || 0; const employees = this._employees || []; let emp = null; const entryEmployeeId = Number(entry?.employee_id); if (Number.isFinite(entryEmployeeId) && entryEmployeeId > 0) { emp = employees.find(candidate => Number(candidate.id) === entryEmployeeId) || null; } if (!emp && name) { const needle = String(name || '').trim().toLowerCase(); emp = employees.find(candidate => String(candidate.name || '').trim().toLowerCase() === needle) || null; if (!emp && needle) { emp = employees.find(candidate => { const candidateName = String(candidate.name || '').trim().toLowerCase(); return candidateName.startsWith(needle) || needle.startsWith(candidateName); }) || null; } } if (!emp) return fallback; const baseSalary = this._num(emp.pay_base_salary_month); const baseHours = this._num(emp.pay_base_hours_month); if (baseSalary > 0 && baseHours > 0) return baseSalary / baseHours; const overtimeRate = this._num(emp.pay_overtime_hour_rate); if (overtimeRate > 0) return overtimeRate; const rate = this._num(emp.hourly_rate) || this._num(emp.hourly_cost) || this._num(emp.cost_per_hour) || this._num(emp.fot_per_hour); return rate > 0 ? rate : fallback; }, _sumStageSalary(orderId, stage, params, planHours = {}, orderRef = null) { const stageActuals = this._collectStageActuals(orderId, planHours, params, orderRef); return round2(stageActuals.salary[stage] || 0); }, async _applyDerivedFacts(factData, planData, planHours, params, orderId, orderName, orderRef = null, orderItems = [], planMeta = {}) { const hProd = parseFloat(factData.fact_hours_production) || 0; const hTrim = parseFloat(factData.fact_hours_trim) || 0; const hAsm = parseFloat(factData.fact_hours_assembly) || 0; const hPkg = parseFloat(factData.fact_hours_packaging) || 0; const totalHours = hProd + hTrim + hAsm + hPkg; const stageSalary = factData._auto_stage_salary || { casting: this._sumStageSalary(orderId, 'casting', params, planHours, orderName), trim: this._sumStageSalary(orderId, 'trim', params, planHours, orderName), assembly: this._sumStageSalary(orderId, 'assembly', params, planHours, orderName), packaging: this._sumStageSalary(orderId, 'packaging', params, planHours, orderName), }; this._resetDerivedMoneyFacts(factData); // 1. Salaries from time entries (per-stage) this._applyAutoFactValue(factData, 'fact_salary_production', stageSalary.casting); this._applyAutoFactValue(factData, 'fact_salary_trim', stageSalary.trim); this._applyAutoFactValue(factData, 'fact_salary_assembly', stageSalary.assembly); this._applyAutoFactValue(factData, 'fact_salary_packaging', stageSalary.packaging); // 2. Indirect costs from hours this._applyAutoFactValue(factData, 'fact_indirect_production', totalHours * (params?.indirectPerHour || 0)); // 3. FinTablo imports — pull all available fact fields const rawImports = (await loadFintabloImports(orderId)) || []; const { healthy: imports, stale: staleImports } = this._partitionFinTabloImports(rawImports); this._setStaleFinTabloSummary(factData, this._summarizeStaleFinTabloImports(staleImports)); if (imports.length > 0) { const importTotals = imports.reduce((acc, row) => { acc.fact_materials += this._num(row?.fact_materials); acc.fact_hardware += this._num(row?.fact_hardware); acc.fact_packaging += this._num(row?.fact_packaging); acc.fact_delivery += this._num(row?.fact_delivery); acc.fact_printing += this._num(row?.fact_printing); acc.fact_molds += this._num(row?.fact_molds); acc.fact_taxes += this._num(row?.fact_taxes); acc.fact_commercial += this._num(row?.fact_commercial); acc.fact_charity += this._num(row?.fact_charity); acc.fact_other += this._num(row?.fact_other); acc.fact_revenue += this._num(row?.fact_revenue); return acc; }, { fact_materials: 0, fact_hardware: 0, fact_packaging: 0, fact_delivery: 0, fact_printing: 0, fact_molds: 0, fact_taxes: 0, fact_commercial: 0, fact_charity: 0, fact_other: 0, fact_revenue: 0, }); const breakdown = {}; const ftKeyToFactKey = { fact_materials: 'fact_plastic', fact_hardware: 'fact_hardware_total', fact_packaging: 'fact_packaging_total', fact_delivery: 'fact_delivery_client', fact_printing: 'fact_design_printing', fact_molds: 'fact_molds', fact_taxes: 'fact_taxes', fact_commercial: 'fact_commercial', fact_charity: 'fact_charity', fact_other: 'fact_other', }; imports.forEach(row => { const importBreakdown = row?.raw_data?.field_breakdown; if (!importBreakdown || typeof importBreakdown !== 'object') return; Object.entries(importBreakdown).forEach(([ftKey, rows]) => { const factKey = ftKeyToFactKey[ftKey]; if (!factKey || !Array.isArray(rows) || !rows.length) return; if (!Array.isArray(breakdown[factKey])) breakdown[factKey] = []; rows.forEach(item => { const amount = this._num(item?.amount); if (amount <= 0) return; breakdown[factKey].push({ amount, description: String(item?.description || '').trim(), category: String(item?.category || '').trim(), date: item?.date || '', }); }); }); }); Object.keys(breakdown).forEach(key => { breakdown[key] = breakdown[key] .sort((a, b) => this._num(b.amount) - this._num(a.amount)) .slice(0, 10); }); this._setFinTabloBreakdowns(factData, breakdown); const ftMap = { fact_hardware: 'fact_hardware_total', fact_printing: 'fact_design_printing', fact_molds: 'fact_molds', fact_delivery: 'fact_delivery_client', fact_other: 'fact_other', fact_charity: 'fact_charity', }; for (const [ftKey, ourKey] of Object.entries(ftMap)) { const val = this._num(importTotals[ftKey]); if (val > 0) { this._applyAutoFactValue(factData, ourKey, val); factData._auto_fintablo[ourKey] = true; this._setSourceHint(factData, ourKey, 'ФинТабло'); } } const ftMaterials = this._num(importTotals.fact_materials); if (ftMaterials > 0 && this._num(planData?.plastic) <= 0) { this._applyAutoFactValue(factData, 'fact_plastic', ftMaterials); factData._auto_fintablo.fact_plastic = true; this._setSourceHint(factData, 'fact_plastic', 'ФинТабло'); } const ftRevenue = this._num(importTotals.fact_revenue); if (ftRevenue > 0) { this._applyAutoFactValue(factData, 'fact_revenue', ftRevenue); factData._auto_fintablo.fact_revenue = true; } const importedTaxes = this._num(importTotals.fact_taxes); const importedCommercial = this._num(importTotals.fact_commercial); const importedCharity = this._num(importTotals.fact_charity); const currentFactRevenue = this._num(factData.fact_revenue); const taxRate = this._taxRate(params); const taxesByRevenue = currentFactRevenue > 0 ? this._calcTaxesByRevenue(currentFactRevenue, params) : 0; const commercialByRevenue = currentFactRevenue > 0 ? this._calcCommercialByRevenue(currentFactRevenue, params) : 0; const charityByRevenue = currentFactRevenue > 0 ? this._calcCharityByRevenue(currentFactRevenue, params) : 0; const effectiveTaxes = importedTaxes > 0 ? importedTaxes : taxesByRevenue; if (effectiveTaxes > 0) { this._applyAutoFactValue(factData, 'fact_taxes', effectiveTaxes); factData._auto_fintablo.fact_taxes = importedTaxes > 0; this._setSourceHint( factData, 'fact_taxes', importedTaxes > 0 ? 'ФинТабло' : `${round2(taxRate * 100)}% от факта выручки без НДС` ); } const effectiveCommercial = importedCommercial > 0 ? importedCommercial : commercialByRevenue; if (effectiveCommercial > 0) { this._applyAutoFactValue(factData, 'fact_commercial', effectiveCommercial); factData._auto_fintablo.fact_commercial = importedCommercial > 0; this._setSourceHint(factData, 'fact_commercial', importedCommercial > 0 ? 'ФинТабло' : '6.5% от факта выручки без НДС'); } const effectiveCharity = importedCharity > 0 ? importedCharity : charityByRevenue; if (effectiveCharity > 0) { this._applyAutoFactValue(factData, 'fact_charity', effectiveCharity); factData._auto_fintablo.fact_charity = importedCharity > 0; this._setSourceHint(factData, 'fact_charity', importedCharity > 0 ? 'ФинТабло' : '1% от факта выручки без НДС'); } const ftPackaging = this._num(importTotals.fact_packaging); if (ftPackaging > 0) { this._applyAutoFactValue(factData, 'fact_packaging_total', ftPackaging); factData._auto_fintablo.fact_packaging_total = true; this._setSourceHint(factData, 'fact_packaging_total', 'ФинТабло'); } } if (!this._isManualOverride(factData, 'fact_plastic') && !factData._auto_fintablo.fact_plastic && this._num(planData?.plastic) > 0) { this._applyAutoFactValue(factData, 'fact_plastic', planData.plastic); this._setSourceHint(factData, 'fact_plastic', 'план'); } if (!this._isManualOverride(factData, 'fact_molds') && !factData._auto_fintablo.fact_molds && this._num(planData?.molds) > 0) { this._applyAutoFactValue(factData, 'fact_molds', planData.molds); this._setSourceHint(factData, 'fact_molds', 'план'); } // 4. Warehouse fallback for hardware/packaging (only if fintablo didn't provide them) const whActual = this._isWorkshopOrder(orderRef || orderName) ? await this._deriveWorkshopMaterialFacts(orderId, orderRef || { order_name: orderName }, orderItems || []) : await this._deriveMaterialFacts(orderId, orderName); const confirmedMaterials = this._getConfirmedMaterialFacts(factData); const resolveMaterialFallback = (rowKey, actualValue, meta = {}, actualHint, waitingHint, details = []) => { const factKey = `fact_${rowKey}`; const warehousePlan = this._num(meta?.warehouseTotal); const manualPlan = this._num(meta?.manualTotal); const warehouseActual = whActual.found ? this._num(actualValue) : 0; const confirmedManual = this._num(confirmedMaterials?.[rowKey]?.amount); const nextValue = round2(Math.max(0, warehouseActual + confirmedManual)); this._setMaterialFactBreakdown(factData, rowKey, { warehousePlan: round2(warehousePlan), manualPlan: round2(manualPlan), warehouseActual: round2(warehouseActual), confirmedManual: round2(confirmedManual), details: Array.isArray(details) ? details : [], actualHint: actualHint || 'склад', waitingHint, }); if (!factData._auto_fintablo[factKey]) { this._applyAutoFactValue(factData, factKey, nextValue); let hint = ''; if (warehouseActual > 0 && confirmedManual > 0) hint = `${actualHint} + вручную подтверждено`; else if (warehouseActual > 0) hint = actualHint || 'склад'; else if (confirmedManual > 0) hint = 'вручную подтверждено'; else if (warehousePlan > 0 || manualPlan > 0) hint = waitingHint; if (hint) this._setSourceHint(factData, factKey, hint); } }; resolveMaterialFallback( 'hardware_total', whActual.hardware, planMeta?.hardware_total || {}, whActual.sourceHint || 'склад', 'ожидает подтверждения', whActual.hardwareDetails || [] ); resolveMaterialFallback( 'nfc_total', whActual.nfc, planMeta?.nfc_total || {}, whActual.nfcSourceHint || whActual.sourceHint || 'склад', 'ожидает подтверждения', whActual.nfcDetails || [] ); resolveMaterialFallback( 'packaging_total', whActual.packaging, planMeta?.packaging_total || {}, whActual.packagingSourceHint || whActual.sourceHint || 'склад', 'ожидает подтверждения', whActual.packagingDetails || [] ); }, async _deriveWorkshopMaterialFacts(orderId, orderRef = null, orderItems = []) { const projectState = (typeof loadProjectHardwareState === 'function' ? (await loadProjectHardwareState()) : { checks: {}, actual_qtys: {} }) || { checks: {}, actual_qtys: {} }; const checks = (projectState && projectState.checks && typeof projectState.checks === 'object') ? projectState.checks : {}; const actuals = (projectState && projectState.actual_qtys && typeof projectState.actual_qtys === 'object') ? projectState.actual_qtys : {}; const items = (await loadWarehouseItems()) || []; const itemMap = new Map(items.map(i => [Number(i.id), i])); const demandRows = this._collectWarehouseDemandRows(orderItems || [], items || []); if (!demandRows.length) return { found: false, hardware: 0, packaging: 0 }; let hardware = 0; let nfc = 0; let packaging = 0; let found = false; const hardwareDetails = []; const nfcDetails = []; const packagingDetails = []; const normalizedOrderId = Number(typeof orderRef === 'object' ? orderRef?.id ?? orderId : orderId) || Number(orderId) || 0; demandRows.forEach(row => { const itemId = Number(row.warehouse_item_id || 0); if (!itemId) return; const key = `${normalizedOrderId}:${itemId}`; const ready = !!checks[key]; if (!ready) return; const plannedQty = this._num(row.qty); const hasActual = Object.prototype.hasOwnProperty.call(actuals, key); const consumedQty = hasActual ? Math.max(0, this._num(actuals[key])) : plannedQty; const unitPrice = this._num((itemMap.get(itemId) || {}).price_per_unit); const deltaCost = round2(consumedQty * unitPrice); const category = String(row.material_type || (itemMap.get(itemId) || {}).category || '').toLowerCase(); const detailEntry = { label: this._esc((row.names || []).join(', ') || (itemMap.get(itemId) || {}).name || `#${itemId}`), qty: round2(consumedQty), amount: deltaCost, }; if (category === 'packaging') { packaging += deltaCost; packagingDetails.push(detailEntry); } else if (this._isNfcWarehouseRow(row, itemMap.get(itemId) || null)) { nfc += deltaCost; nfcDetails.push(detailEntry); } else { hardware += deltaCost; hardwareDetails.push(detailEntry); } found = true; }); return { found, hardware: round2(hardware), nfc: round2(nfc), packaging: round2(packaging), sourceHint: found ? 'фурнитура проекта' : 'склад', nfcSourceHint: found ? 'фурнитура проекта' : 'склад', packagingSourceHint: found ? 'фурнитура проекта' : 'склад', hardwareDetails, nfcDetails, packagingDetails, }; }, async _deriveMaterialFacts (orderId, orderName) { const history = (await loadWarehouseHistory()) || []; if (history.length === 0) return { found: false, hardware: 0, nfc: 0, packaging: 0 }; const items = (await loadWarehouseItems()) || []; const itemMap = new Map(items.map(i => [Number(i.id), i])); const sameOrder = (h) => { if (h.order_id !== undefined && h.order_id !== null && h.order_id !== '') return Number(h.order_id) === Number(orderId); return orderName && String(h.order_name || '').trim() === String(orderName).trim(); }; let hardware = 0, nfc = 0, packaging = 0, found = false; const byGroup = { hardware: new Map(), nfc: new Map(), packaging: new Map(), }; history.forEach(h => { if (!sameOrder(h)) return; const type = String(h.type || ''); if (type !== 'deduction' && type !== 'addition') return; const qtyChange = this._num(h.qty_change); if (qtyChange === 0) return; const unitPrice = this._num(h.unit_price) || this._num((itemMap.get(Number(h.item_id)) || {}).price_per_unit); const deltaCost = round2(-qtyChange * unitPrice); if (deltaCost === 0) return; const category = String(h.item_category || (itemMap.get(Number(h.item_id)) || {}).category || '').toLowerCase(); const entryLabel = String(h.item_name || (itemMap.get(Number(h.item_id)) || {}).name || h.sku || `#${h.item_id}`).trim() || `#${h.item_id}`; const qtyAbs = Math.abs(this._num(qtyChange)); const addDetail = (bucketKey, label) => { const bucket = byGroup[bucketKey]; const prev = bucket.get(label) || { label: this._esc(label), qty: 0, amount: 0 }; prev.qty = round2(prev.qty + qtyAbs); prev.amount = round2(prev.amount + deltaCost); bucket.set(label, prev); }; if (category === 'packaging') { packaging += deltaCost; addDetail('packaging', entryLabel); } else { hardware += deltaCost; addDetail('hardware', entryLabel); } if (category !== 'packaging' && this._isNfcWarehouseRow(h, itemMap.get(Number(h.item_id)) || null)) { nfc += deltaCost; hardware -= deltaCost; addDetail('nfc', entryLabel); const hardwareBucket = byGroup.hardware.get(entryLabel); if (hardwareBucket) { hardwareBucket.amount = round2(hardwareBucket.amount - deltaCost); if (hardwareBucket.amount <= 0.009) byGroup.hardware.delete(entryLabel); else byGroup.hardware.set(entryLabel, hardwareBucket); } } found = true; }); return { found, hardware: round2(Math.max(0, hardware)), nfc: round2(Math.max(0, nfc)), packaging: round2(Math.max(0, packaging)), nfcSourceHint: found ? 'склад' : 'план', hardwareDetails: Array.from(byGroup.hardware.values()).filter(item => item.amount > 0), nfcDetails: Array.from(byGroup.nfc.values()).filter(item => item.amount > 0), packagingDetails: Array.from(byGroup.packaging.values()).filter(item => item.amount > 0), }; }, _stageKey(entry) { const desc = String(entry?.description || ''); const marker = desc.match(/^\[meta\](\{.*?\})\[\/meta\]\s*/); if (marker) { try { const p = JSON.parse(marker[1]); if (p?.stage) return p.stage; } catch (e) {} } if (entry?.stage) return entry.stage; const stageMatch = desc.match(/(?:^|\n)Этап:\s*([^\n]+)/i); const label = (stageMatch?.[1] || '').toLowerCase(); if (label.includes('вылив')) return 'casting'; if (label.includes('литник') || label.includes('лейник') || label.includes('срез')) return 'trim'; if (label.includes('сбор')) return 'assembly'; if (label.includes('упаков')) return 'packaging'; return 'other'; }, // ========================================== // Render detail accordion // ========================================== _renderDetail(orderId, container, plan, planHours, fact, order, planMeta = {}, options = {}) { let html = ''; const isSnapshot = !!options?.snapshot; const snapshotSavedAt = options?.snapshotMeta?.savedAt || ''; const planRev = this._num(plan.revenue); const planCost = this._num(plan.totalCosts); const factRev = this._num(fact.fact_revenue); let factCost = 0; this.ROWS.forEach(r => { factCost += this._num(fact['fact_' + r.key]); }); const planResult = this._calcProfitability(planRev, planCost); const factResult = this._calcProfitability(factRev, factCost); const hasFactPnL = factRev > 0 || factCost > 0; const revIsAuto = this._isAutoFactRow(fact, 'revenue'); const revManual = this._isManualOverride(fact, 'fact_revenue'); const revAutoClass = revIsAuto && !revManual ? 'fact-input-auto' : ''; const revenueResetControl = this._renderAutoResetControl(orderId, 'revenue', revIsAuto, revManual); const factRevenueHint = fact._auto_fintablo?.fact_revenue ? (factRev > 0 && planRev > factRev ? 'получено из ФинТабло, оплата пока частичная' : 'получено из ФинТабло') : (revManual ? 'внесено вручную' : 'пока не внесена'); const _isAdmin = App.isAdmin(); const planTotalRowsVisible = round2(this.ROWS.reduce((sum, row) => { const planVal = this._num(plan[row.planField]); const factVal = this._num(fact[`fact_${row.key}`]); if (row.key === 'molds' && planVal === 0 && factVal === 0) return sum; const isSalaryRow = row.key.startsWith('salary_') || row.key === 'indirect_production'; if (isSalaryRow && !_isAdmin) return sum; return sum + planVal; }, 0)); const planTotalBase = round2(plan.totalCosts || planTotalRowsVisible); const hasPlanDrift = Math.abs(planTotalRowsVisible - planTotalBase) > 0.01; const factHoursByRow = { salary_production: this._num(fact.fact_hours_production), salary_trim: this._num(fact.fact_hours_trim), salary_assembly: this._num(fact.fact_hours_assembly), salary_packaging: this._num(fact.fact_hours_packaging), indirect_production: round2( this._num(fact.fact_hours_production) + this._num(fact.fact_hours_trim) + this._num(fact.fact_hours_assembly) + this._num(fact.fact_hours_packaging) ), }; let productionTotal = 0; if (isSnapshot) { html += `
Снимок месяца
Открыт зафиксированный срез${snapshotSavedAt ? ` от ${this._formatDateLabel(new Date(snapshotSavedAt))}` : ''}. В этом режиме строки только для чтения.
`; } html += `
План заказа · расходы
${this.fmtRub(planCost)}
что было заложено в калькуляторе
Факт денег · расходы
${hasFactPnL ? this.fmtRub(factCost) : '—'}
ФинТабло + вручную + подтверждённые факты
План заказа · выручка
${this.fmtRub(planRev)}
без НДС
Факт денег · выручка
${hasFactPnL ? this.fmtRub(factRev) : '—'}
${factRevenueHint}
План заказа · прибыль
${this._renderCompactResult(planResult)}
Факт денег · прибыль
${this._renderCompactResult(factResult)}
`; if (fact._legacy_stage_estimate) { html += `
${fact._legacy_stage_scope === 'production_only' ? 'Legacy-часы без этапа распределены только в выливание/срезание по плановым стадиям заказа. Сборка и упаковка считаются только по явно указанным этапам.' : 'Legacy-часы без этапа распределены по плановым стадиям заказа, чтобы выливание/срезание/сборка отражали реальную загрузку до ручной детализации.'}
`; } const staleFinTablo = this._getStaleFinTabloSummary(fact); if (staleFinTablo && this._num(staleFinTablo.total) > 0) { const staleDateLabel = staleFinTablo.latestUpdatedAt ? this._formatDateLabel(new Date(staleFinTablo.latestUpdatedAt)) : ''; html += `
Найден старый split-импорт ФинТабло без расшифровки. Он исключён из факта денег, чтобы не засорять “Прочее”: ${this.fmtRub(staleFinTablo.total)}${staleDateLabel ? ` · последнее обновление ${staleDateLabel}` : ''}. Нужен свежий переимпорт ФинТабло.
`; } if (hasPlanDrift) { html += `
Видимые плановые строки сейчас дают ${this.fmtRub(planTotalRowsVisible)}, но сохранённый план заказа в калькуляторе равен ${this.fmtRub(planTotalBase)}. В прибыли и ИТОГО используется сохранённый план заказа.
`; } html += '
'; html += ''; this.ROWS.forEach(row => { const planVal = this._num(plan[row.planField]); const factKey = `fact_${row.key}`; const factVal = this._num(fact[factKey]); if (row.key === 'molds' && planVal === 0 && factVal === 0) return; const isSalaryRow = row.key.startsWith('salary_') || row.key === 'indirect_production'; if (isSalaryRow && !_isAdmin) return; const planView = { amount: planVal, showZero: true, labels: this._buildPlanRowLabels(row.key, planMeta), detailHtml: this._renderPlanRowDetail(row.key, planMeta, plan), }; let productionView = { amount: null, labels: [], detailHtml: '' }; if (row.key.startsWith('salary_')) { productionView = { amount: this._num((fact._auto_stage_salary || {})[ row.key === 'salary_production' ? 'casting' : row.key === 'salary_trim' ? 'trim' : row.key === 'salary_assembly' ? 'assembly' : 'packaging' ]), labels: ['табель'], detailHtml: factHoursByRow[row.key] > 0 ? this.fmtHours(factHoursByRow[row.key]) : '', }; } else if (row.key === 'indirect_production') { productionView = { amount: this._num(fact._auto_stage_salary) ? this._num(fact.fact_indirect_production) : this._num(fact.fact_indirect_production), labels: factHoursByRow.indirect_production > 0 ? ['табель', 'формула'] : [], detailHtml: factHoursByRow.indirect_production > 0 ? `${this.fmtHours(factHoursByRow.indirect_production)} × ${this.fmtRub(planMeta?.indirect_production?.perHour || 0)}/ч` : '', }; } else if (this.MATERIAL_ROW_KEYS.has(row.key)) { productionView = this._buildProductionMaterialView(row.key, fact, planMeta); } else if (row.key === 'plastic' || row.key === 'molds') { productionView = { amount: planVal, labels: planVal > 0 ? ['норма'] : [], detailHtml: planVal > 0 ? `расчетная норма из калькулятора — ${this.fmtRub(planVal)}` : '', }; } if (productionView.amount != null) productionTotal += this._num(productionView.amount); const moneyView = { amount: factVal, showZero: this.MATERIAL_ROW_KEYS.has(row.key) && !!this._getMaterialFactBreakdowns(fact)?.[row.key], labels: this._buildMoneyRowLabels(row.key, fact, planMeta), detailHtml: this._buildMoneyRowDetail(row.key, fact, planMeta, plan), actionHtml: '', }; if (this.MATERIAL_ROW_KEYS.has(row.key) && !isSnapshot && !this._isManualOverride(fact, factKey) && !fact?._auto_fintablo?.[factKey]) { const confirmedAmount = this._num(this._getConfirmedMaterialFacts(fact)?.[row.key]?.amount); if (productionView.waitingManual && productionView.manualOutstanding > 0) { moneyView.actionHtml = ``; } else if (confirmedAmount > 0) { moneyView.actionHtml = ``; } } const delta = factVal - planVal; const pct = planVal > 0 ? ((delta / planVal) * 100) : 0; const alarm = this.getAlarm(factVal, planVal); const isAuto = this._isAutoFactRow(fact, row.key); const manualOverride = this._isManualOverride(fact, factKey); const inputClass = isAuto && !manualOverride ? 'fact-input-auto' : ''; const resetControl = this._renderAutoResetControl(orderId, row.key, isAuto, manualOverride); const rowHint = row.key.startsWith('salary_') ? 'план/табель/деньги' : this.MATERIAL_ROW_KEYS.has(row.key) ? 'план/склад/деньги' : this.MONEY_ONLY_ROW_KEYS.has(row.key) ? 'денежная статья' : 'норма/деньги'; html += ``; }); const productionAlarm = this.getAlarm(productionTotal, planTotalBase); const moneyAlarm = this.getAlarm(factCost, planTotalBase); const productionDelta = productionTotal - planTotalBase; const productionPct = planTotalBase > 0 ? (productionDelta / planTotalBase) * 100 : 0; const moneyDelta = factCost - planTotalBase; const moneyPct = planTotalBase > 0 ? (moneyDelta / planTotalBase) * 100 : 0; html += ``; html += '
Статья расходовПлан заказаФакт производстваФакт денегΔ денег
${row.label}
${rowHint}${resetControl}
${this._renderLayerCell(planView)} ${this._renderLayerCell(productionView)} ${this._renderLayerCell(moneyView, { editable: !isSnapshot, disabled: isSnapshot, inputClass, onInput: `Factual.onFactInput(${orderId}, '${row.key}', this.value)`, })} ${factVal > 0 ? alarm.icon + ' ' + this.fmtDelta(delta, pct) : ''}
ИТОГО расходы ${this.fmtRub(planTotalBase)} ${productionTotal > 0 ? `${this.fmtRub(productionTotal)}
${productionAlarm.icon} ${this.fmtDelta(productionDelta, productionPct)}
` : '—'}
${factCost > 0 ? this.fmtRub(factCost) : '—'} ${factCost > 0 ? moneyAlarm.icon + ' ' + this.fmtDelta(moneyDelta, moneyPct) : '—'}
'; html += `
Деньги по сделке
Факт выручка без НДС
${isSnapshot ? `
${factRev > 0 ? this.fmtRub(factRev) : '—'}
` : ``} ${!isSnapshot ? revenueResetControl : ''} ${this._renderSourceBadges(this._buildMoneyRowLabels('revenue', fact, planMeta))}
Факт денег = реальные деньги и статьи расходов по сделке. Факт производства рядом показывает только подтвержденные часы/склад/ручные подтверждения, чтобы план не путался с денежным фактом.
`; html += '
'; this.HOUR_ROWS.forEach(row => { const pv = this._num(planHours[row.planField]); const fv = this._num(fact[`fact_${row.key}`]); const d = fv - pv; const a = fv > 0 ? this.getAlarm(fv, pv) : { color: 'var(--text-muted)', icon: '' }; html += `
${row.label}
план ${pv.toFixed(1)}ч → ${fv > 0 ? fv.toFixed(1) + 'ч' : '—'} ${fv > 0 ? ` ${a.icon}${d >= 0 ? '+' : ''}${d.toFixed(1)}` : ''}
`; }); html += '
'; html += `
${isSnapshot ? '' : ``}
`; container.innerHTML = html; }, // ========================================== // User input handlers // ========================================== onFactInput(orderId, key, value) { const cached = this._orderCache[orderId]; if (!cached) return; const num = parseFloat(value.replace(/\s/g, '').replace(',', '.')) || 0; if (key === 'revenue') { cached.factData.fact_revenue = num; this._setManualOverride(cached.factData, 'fact_revenue', true); } else { cached.factData['fact_' + key] = num; this._setManualOverride(cached.factData, 'fact_' + key, true); } clearTimeout(this._renderTimer); this._renderTimer = setTimeout(() => { const container = document.getElementById('fact-detail-' + orderId); if (container && cached) { this._renderDetail(orderId, container, cached.planData, cached.planHours, cached.factData, cached.order, cached.planMeta || {}); } }, 500); }, async resetFactInput(orderId, key) { const cached = this._orderCache[orderId] || await this._ensureComputedOrder(orderId); if (!cached) return; if (!this._isAutoFactRow(cached.factData, key)) return; if (key === 'revenue') { delete cached.factData.fact_revenue; this._setManualOverride(cached.factData, 'fact_revenue', false); } else { delete cached.factData['fact_' + key]; this._setManualOverride(cached.factData, 'fact_' + key, false); } await this._recomputeCachedFact(cached); const container = document.getElementById('fact-detail-' + orderId); if (container) { this._renderDetail(orderId, container, cached.planData, cached.planHours, cached.factData, cached.order, cached.planMeta || {}); } await this._renderGlobalStats(); await this._loadFactSummaries(); }, onNotesChange(orderId, value) { const cached = this._orderCache[orderId]; if (cached) cached.factData.notes = value; }, async saveFact(orderId) { const cached = this._orderCache[orderId]; if (!cached) { App.toast('Данные не загружены'); return; } let factTotal = 0; this.ROWS.forEach(row => { factTotal += parseFloat(cached.factData['fact_' + row.key]) || 0; }); cached.factData.fact_total = round2(factTotal); cached.factData.updated_by = document.getElementById('calc-manager-name')?.value || ''; await saveFactual(orderId, cached.factData); App.toast('Сохранено: ' + (cached.order.order_name || '')); await this._renderGlobalStats(); // Update summary in table this._loadFactSummaries(); }, async confirmMaterialFact(orderId, rowKey) { const activeSnapshot = this._getActiveSnapshot(); if (activeSnapshot) { App.toast('Снимок месяца открыт только для чтения'); return; } const cached = this._orderCache[orderId] || await this._ensureComputedOrder(orderId); if (!cached) return; const breakdown = this._getMaterialFactBreakdowns(cached.factData)?.[rowKey] || {}; const targetAmount = this._num(breakdown.manualPlan); if (!(targetAmount > 0)) { App.toast('Для этой строки нечего подтверждать вручную'); return; } this._setConfirmedMaterialFact(cached.factData, rowKey, { amount: round2(targetAmount), confirmed_at: new Date().toISOString(), source: 'manual_confirmation', }); await this._recomputeCachedFact(cached); const container = document.getElementById(`fact-detail-${orderId}`); if (container) { this._renderDetail(orderId, container, cached.planData, cached.planHours, cached.factData, cached.order, cached.planMeta || {}, { snapshot: false, snapshotMeta: null }); } await this._renderGlobalStats(); await this._loadFactSummaries(); }, async clearConfirmedMaterialFact(orderId, rowKey) { const activeSnapshot = this._getActiveSnapshot(); if (activeSnapshot) { App.toast('Снимок месяца открыт только для чтения'); return; } const cached = this._orderCache[orderId] || await this._ensureComputedOrder(orderId); if (!cached) return; this._setConfirmedMaterialFact(cached.factData, rowKey, null); await this._recomputeCachedFact(cached); const container = document.getElementById(`fact-detail-${orderId}`); if (container) { this._renderDetail(orderId, container, cached.planData, cached.planHours, cached.factData, cached.order, cached.planMeta || {}, { snapshot: false, snapshotMeta: null }); } await this._renderGlobalStats(); await this._loadFactSummaries(); }, // ========================================== // Alarm & formatting // ========================================== getAlarm(factVal, planVal) { if (factVal <= 0) return { color: 'var(--text-muted)', icon: '', bgStyle: '' }; if (planVal <= 0) return { color: 'var(--text-muted)', icon: '\u2014', bgStyle: '' }; const ratio = factVal / planVal; if (ratio <= 1.0) return { color: 'var(--green)', icon: '\u2713', bgStyle: '' }; if (ratio <= 1.15) return { color: 'var(--yellow)', icon: '\u26A0', bgStyle: 'background:rgba(255,193,7,0.05)' }; return { color: 'var(--red)', icon: '\u2717', bgStyle: 'background:rgba(220,53,69,0.05)' }; }, fmtRub(n) { if (!n && n !== 0) return '0 \u20BD'; return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(Math.round(n)) + ' \u20BD'; }, fmtDelta(delta, pct) { const sign = delta >= 0 ? '+' : ''; return sign + new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(Math.round(delta)) + ' \u20BD (' + sign + pct.toFixed(0) + '%)'; }, fmtHours(n) { const num = this._num(n); if (Math.abs(num) < 0.005) return '0ч'; const fractionDigits = Number.isInteger(num) ? 0 : (Math.abs(num * 10 - Math.round(num * 10)) < 0.001 ? 1 : 2); return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: fractionDigits, }).format(round2(num)) + 'ч'; }, _renderPlanRowDetail(rowKey, planMeta = {}, planData = {}) { const meta = planMeta?.[rowKey]; if (meta?.detailHtml) return meta.detailHtml; if (rowKey === 'indirect_production') { const planHours = this._num(meta?.planHours); const perHour = this._num(meta?.perHour || 0); return (planHours > 0 || perHour > 0) ? `${this.fmtHours(planHours)} × ${this.fmtRub(perHour)}/ч` : ''; } if (this.MATERIAL_ROW_KEYS.has(rowKey)) { const lines = []; if (this._num(meta?.warehouseTotal) > 0) lines.push(`склад по заказу — ${this.fmtRub(meta.warehouseTotal)}`); if (this._num(meta?.manualTotal) > 0) lines.push(`задано в заказе — ${this.fmtRub(meta.manualTotal)}`); return lines.join('
'); } if (rowKey === 'taxes') { const rate = this._taxRate(App.params || {}); return rate > 0 ? `${round2(rate * 100)}% от выручки без НДС` : ''; } if (rowKey === 'commercial') return '6.5% от выручки без НДС'; if (rowKey === 'charity') return '1% от выручки без НДС'; if (rowKey === 'plastic' || rowKey === 'molds') { const amount = this._num(planData?.[rowKey === 'delivery_client' ? 'delivery' : rowKey]); return amount > 0 ? `расчетная норма — ${this.fmtRub(amount)}` : ''; } if (!meta) return ''; if (!rowKey.startsWith('salary_')) return ''; let detail = this.fmtHours(meta.planHours); if (meta.source === 'blank_norms_plus_manual') { detail += ' • бланки + вручную'; detail += `
по текущим бланкам: ${this.fmtHours(meta.derivedHours)}`; detail += `
вручную добавлено: ${this.fmtHours(meta.savedHours)}`; return detail; } if (meta.source === 'blank_norms') { detail += ' • по текущим бланкам'; if (this._num(meta.savedHours) > 0 && Math.abs(this._num(meta.savedHours) - this._num(meta.planHours)) > 0.05) { detail += `
в заказе было: ${this.fmtHours(meta.savedHours)}`; } return detail; } if (meta.source === 'saved_items') { detail += ' • из текущих строк заказа'; if (Math.abs(this._num(meta.derivedHours) - this._num(meta.planHours)) > 0.05) { detail += `
по текущим строкам: ${this.fmtHours(meta.derivedHours)}`; } return detail; } if (meta.source === 'saved_order') { detail += ' • текущее значение заказа'; if (Math.abs(this._num(meta.derivedHours) - this._num(meta.planHours)) > 0.05) { detail += `
по текущим строкам: ${this.fmtHours(meta.derivedHours)}`; } } return detail; }, _renderFactRowDetail(rowKey, factHoursByRow = {}, planMeta = {}, factData = {}) { if (this.MATERIAL_ROW_KEYS.has(rowKey)) { return this._buildMoneyRowDetail(rowKey, factData, planMeta, {}); } const factHours = this._num(factHoursByRow?.[rowKey]); if (rowKey === 'indirect_production') { if (factHours <= 0) return ''; return `${this.fmtHours(factHours)} × ${this.fmtRub(planMeta?.indirect_production?.perHour || 0)}/ч`; } if (rowKey.startsWith('salary_')) return factHours > 0 ? this.fmtHours(factHours) : ''; const breakdownRows = this._getFinTabloBreakdowns(factData)[`fact_${rowKey}`]; if (Array.isArray(breakdownRows) && breakdownRows.length) { return breakdownRows.slice(0, 3).map(row => { const label = this._esc(row.description || row.category || 'операция'); return `${label} — ${this.fmtRub(row.amount)}`; }).join('
'); } return ''; }, _esc(str) { return String(str || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }, };