// ============================================= // Recycle Object — Order Detail Page // ============================================= const PAYMENT_STATUSES = [ { key: 'not_sent', label: 'Не передано в оплату', color: 'gray' }, { key: 'sent_to_payment', label: 'Передано в оплату', color: 'blue' }, { key: 'paid_50', label: 'Оплачено 50%', color: 'orange' }, { key: 'paid_100', label: 'Оплачено 100%', color: 'green' }, { key: 'postpay_100', label: 'Постоплата 100%', color: 'green' }, { key: 'special', label: 'Особые условия', color: 'red' }, ]; const HARDWARE_STATUSES = [ { key: 'discussion', label: 'Обсуждение', color: 'gray' }, { key: 'from_stock', label: 'Из наличия', color: 'blue' }, { key: 'ordered_waiting', label: 'Заказана, ждём', color: 'orange' }, { key: 'arrived', label: 'Приехала', color: 'green' }, { key: 'not_needed', label: 'Не нужна', color: 'gray' }, { key: 'to_make', label: 'Сделать', color: 'yellow' }, ]; // Plastic is always PP, print type is set at printing level — removed from UI const OrderDetail = { currentOrder: null, currentItems: [], currentFinancial: null, currentTab: 'info', // ========================================== // LOAD // ========================================== async load(orderId) { if (!orderId) { App.navigate('orders'); return; } const data = await loadOrder(orderId); if (!data) { App.toast('Заказ не найден'); App.navigate('orders'); return; } this.currentOrder = data.order; this.currentItems = data.items || []; this.currentFinancial = this.buildLiveFinancialMeta(); this.currentTab = 'info'; this.renderHeader(); this.renderStats(); this.renderInfoTab(); this.renderProductionTab(); this.renderFilesTab(); this.renderItemsTab(); await this.renderHardwareTab(); await this.renderTasksTab(); this.renderChinaTab(); // Show first tab this.switchTab('info'); if (data.repaired_duplicates) { App.toast('Дубли позиций в заказе были автоматически исправлены'); } }, // ========================================== // TABS // ========================================== switchTab(tab) { this.currentTab = tab; document.querySelectorAll('.od-tab-btn').forEach(b => { b.classList.toggle('active', b.dataset.tab === tab); }); document.querySelectorAll('.od-tab-content').forEach(c => { c.style.display = c.id === 'od-tab-' + tab ? '' : 'none'; }); if (tab === 'hardware') { void this.renderHardwareTab(); } }, // ========================================== // HEADER // ========================================== renderHeader() { const o = this.currentOrder; document.getElementById('od-title').textContent = o.order_name || 'Без названия'; const st = STATUS_OPTIONS.find(s => s.value === o.status); const badge = document.getElementById('od-status-badge'); badge.textContent = st ? st.label : o.status; badge.className = 'badge badge-' + this._statusColor(o.status); }, // ========================================== // STATS // ========================================== buildLiveFinancialMeta() { if (typeof getOrderLiveCalculatorSnapshot !== 'function') { return { revenue: Number(this.currentOrder?.total_revenue_plan || 0), marginPercent: Number(this.currentOrder?.margin_percent_plan || 0), hours: Number(this.currentOrder?.total_hours_plan || 0), }; } try { const snapshot = getOrderLiveCalculatorSnapshot(this.currentOrder || {}, this.currentItems || []); return { revenue: Number(snapshot?.revenue || 0), marginPercent: Number(snapshot?.marginPercent || 0), hours: Number(snapshot?.hours || 0), }; } catch (e) { console.warn('OrderDetail.buildLiveFinancialMeta fallback:', e); return { revenue: Number(this.currentOrder?.total_revenue_plan || 0), marginPercent: Number(this.currentOrder?.margin_percent_plan || 0), hours: Number(this.currentOrder?.total_hours_plan || 0), }; } }, renderStats() { const o = this.currentOrder; const ps = PAYMENT_STATUSES.find(s => s.key === o.payment_status) || PAYMENT_STATUSES[0]; this.currentFinancial = this.buildLiveFinancialMeta(); const revenue = Number(this.currentFinancial?.revenue || o.total_revenue_plan || 0); const marginPercent = Number(this.currentFinancial?.marginPercent || o.margin_percent_plan || 0); const hours = Number(this.currentFinancial?.hours || o.total_hours_plan || 0); document.getElementById('od-stats').innerHTML = `
Выручка
${formatRub(revenue)}
Маржа
${formatPercent(marginPercent)}
Часы
${hours.toFixed(1)} ч
Оплата
${ps.label}
`; }, // ========================================== // INFO TAB // ========================================== renderInfoTab() { const o = this.currentOrder; const container = document.getElementById('od-tab-info'); container.innerHTML = `

Основное

${this._fieldRow('order_name', 'Название', o.order_name, 'text')} ${this._fieldRow('client_name', 'Клиент', o.client_name, 'text')} ${this._fieldRow('manager_name', 'Менеджер', o.manager_name, 'text')} ${this._fieldRowDateRange('deadline_start', 'deadline_end', 'Начало → Дедлайн', o.deadline_start || o.deadline, o.deadline_end)} ${this._fieldRow('notes', 'Заметки', o.notes, 'textarea')}

Контакты и ссылки

${this._fieldRow('delivery_address', 'Адрес доставки', o.delivery_address, 'text')} ${this._fieldRow('telegram', 'Telegram', o.telegram, 'text')} ${this._fieldRow('crm_link', 'CRM', o.crm_link, 'url')} ${this._fieldRow('fintablo_link', 'Финтабло', o.fintablo_link, 'url')}
`; }, // ========================================== // PRODUCTION TAB // ========================================== renderProductionTab() { const o = this.currentOrder; const container = document.getElementById('od-tab-production'); container.innerHTML = `

Статусы

${this._fieldRowSelect('payment_status', 'Статус оплаты', o.payment_status || 'not_sent', PAYMENT_STATUSES)}
`; }, // ========================================== // FILES TAB // ========================================== renderFilesTab() { const o = this.currentOrder; const container = document.getElementById('od-tab-files'); container.innerHTML = `

Файлы проекта

${this._fieldRow('print_file_url', 'Файл печати', o.print_file_url, 'url')} ${this._fieldRow('cutting_file_url', 'Файл резки/формы', o.cutting_file_url, 'url')} ${this._fieldRow('delivery_documents_url', 'Документы доставки', o.delivery_documents_url, 'url')}

Референсы

${this._fieldRow('reference_urls', 'URL референсов', o.reference_urls, 'textarea')} ${this._renderReferenceLinks(o.reference_urls)}
`; }, _renderReferenceLinks(urls) { if (!urls) return '

Нет референсов

'; const links = urls.split(',').map(u => u.trim()).filter(Boolean); if (links.length === 0) return ''; return `
${links.map((u, i) => `Референс ${i + 1}`).join('')}
`; }, // ========================================== // ITEMS TAB // ========================================== renderItemsTab() { const container = document.getElementById('od-tab-items'); const products = this.currentItems.filter(i => i.item_type === 'product' || !i.item_type); const allHardware = this.currentItems.filter(i => i.item_type === 'hardware'); const allPackaging = this.currentItems.filter(i => i.item_type === 'packaging'); const pendantItems = this.currentItems.filter(i => i.item_type === 'pendant'); // Build pendant HTML (used in both grouped and non-grouped paths) let pendantHtml = ''; pendantItems.forEach(pndItem => { let pnd; try { pnd = typeof pndItem.item_data === 'string' ? JSON.parse(pndItem.item_data) : pndItem.item_data; } catch(e) { return; } if (!pnd) return; pendantHtml += this._renderPendantDetail(pnd, pndItem); }); // Separate order-level vs per-item hw/pkg const orderHardware = allHardware.filter(i => i.hardware_parent_item_index === null || i.hardware_parent_item_index === undefined); const orderPackaging = allPackaging.filter(i => i.packaging_parent_item_index === null || i.packaging_parent_item_index === undefined); // Check if items have marketplace_set_name for grouping const hasSetNames = [...products, ...orderHardware, ...orderPackaging].some(i => i.marketplace_set_name); let html = ''; if (hasSetNames) { // Group all items by marketplace_set_name const setGroups = new Map(); const noSetProducts = []; products.forEach((item, idx) => { const sn = item.marketplace_set_name || ''; if (sn) { if (!setGroups.has(sn)) setGroups.set(sn, { products: [], hw: [], pkg: [] }); setGroups.get(sn).products.push({ item, idx }); } else { noSetProducts.push({ item, idx }); } }); orderHardware.forEach(item => { const sn = item.marketplace_set_name || ''; if (sn) { if (!setGroups.has(sn)) setGroups.set(sn, { products: [], hw: [], pkg: [] }); setGroups.get(sn).hw.push(item); } }); orderPackaging.forEach(item => { const sn = item.marketplace_set_name || ''; if (sn) { if (!setGroups.has(sn)) setGroups.set(sn, { products: [], hw: [], pkg: [] }); setGroups.get(sn).pkg.push(item); } }); // Render grouped by set for (const [setName, group] of setGroups) { const setQty = group.products.reduce((s, p) => s + (parseFloat(p.item.quantity) || 0), 0); html += `
`; html += `

📦 ${this._esc(setName)} (${setQty} шт)

`; if (group.products.length > 0) { html += '
Изделия:
'; group.products.forEach(({ item, idx }) => { html += this._renderItemCard(item, 'product'); const itemHw = allHardware.filter(h => h.hardware_parent_item_index === idx); const itemPkg = allPackaging.filter(p => p.packaging_parent_item_index === idx); if (itemHw.length > 0 || itemPkg.length > 0) { html += '
'; itemHw.forEach(h => { html += this._renderItemCard(h, 'hardware'); }); itemPkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); }); html += '
'; } }); } if (group.hw.length > 0) { html += '
🔩 Фурнитура:
'; group.hw.forEach(h => { html += this._renderItemCard(h, 'hardware'); }); } if (group.pkg.length > 0) { html += '
📦 Упаковка:
'; group.pkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); }); } html += '
'; } // Products without set name if (noSetProducts.length > 0) { html += '

Прочие изделия

'; noSetProducts.forEach(({ item, idx }) => { html += this._renderItemCard(item, 'product'); const itemHw = allHardware.filter(h => h.hardware_parent_item_index === idx); const itemPkg = allPackaging.filter(p => p.packaging_parent_item_index === idx); if (itemHw.length > 0 || itemPkg.length > 0) { html += '
'; itemHw.forEach(h => { html += this._renderItemCard(h, 'hardware'); }); itemPkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); }); html += '
'; } }); } // HW/PKG without set name const noSetHw = orderHardware.filter(i => !i.marketplace_set_name); const noSetPkg = orderPackaging.filter(i => !i.marketplace_set_name); if (noSetHw.length > 0) { html += '

🔩 Общая фурнитура

'; html += noSetHw.map(item => this._renderItemCard(item, 'hardware')).join(''); } if (noSetPkg.length > 0) { html += '

📦 Общая упаковка

'; html += noSetPkg.map(item => this._renderItemCard(item, 'packaging')).join(''); } // Pendant items html += pendantHtml; } else { // Fallback: old display (no set grouping) if (products.length > 0) { html += '

Изделия

'; products.forEach((item, idx) => { html += this._renderItemCard(item, 'product'); const itemHw = allHardware.filter(h => h.hardware_parent_item_index === idx); const itemPkg = allPackaging.filter(p => p.packaging_parent_item_index === idx); if (itemHw.length > 0 || itemPkg.length > 0) { html += '
'; itemHw.forEach(h => { html += this._renderItemCard(h, 'hardware'); }); itemPkg.forEach(p => { html += this._renderItemCard(p, 'packaging'); }); html += '
'; } }); } if (orderHardware.length > 0) { html += '

🔩 Общая фурнитура

'; html += orderHardware.map(item => this._renderItemCard(item, 'hardware')).join(''); } if (orderPackaging.length > 0) { html += '

📦 Общая упаковка

'; html += orderPackaging.map(item => this._renderItemCard(item, 'packaging')).join(''); } // Pendant items html += pendantHtml; } if (!html) { html = '
📦

Нет позиций. Откройте в калькуляторе для добавления.

'; } container.innerHTML = html; }, async renderHardwareTab() { const container = document.getElementById('od-tab-hardware'); if (!container || !this.currentOrder) return; const orderId = Number(this.currentOrder.id || 0); if (!orderId) { container.innerHTML = '

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

'; return; } container.innerHTML = `

Загружаем фурнитуру заказа…

`; try { await Warehouse._ensureProjectHardwareStateLoaded(); const [warehouseItems, reservations, history] = await Promise.all([ loadWarehouseItems(), loadWarehouseReservations(), loadWarehouseHistory(), ]); Warehouse.allItems = Array.isArray(warehouseItems) ? warehouseItems : []; Warehouse.allReservations = Array.isArray(reservations) ? reservations : []; const byItemId = new Map((warehouseItems || []).map(item => [Number(item.id), item])); const historyDeltaMap = Warehouse._buildProjectHardwareHistoryDeltaMap(history || []); const demandRows = Warehouse._collectWarehouseDemandFromOrderItems(this.currentItems || []); const activeReservations = reservations || []; if (!demandRows.length) { container.innerHTML = `
🔧

В заказе пока нет складской фурнитуры или упаковки.

`; return; } const rows = demandRows .map(row => { const itemId = Number(row.warehouse_item_id || 0); const whItem = byItemId.get(itemId) || {}; const plannedQty = round2(Math.max(0, parseFloat(row.qty) || 0)); const targetQty = Warehouse._buildProjectHardwareTargetQty(orderId, itemId, plannedQty, historyDeltaMap); const actualQty = Warehouse._getProjectHardwareDisplayActualQty(orderId, itemId, plannedQty, historyDeltaMap, history || []); const ready = Warehouse._computeProjectHardwareReadyState(orderId, itemId, plannedQty, history || [], historyDeltaMap); const currentReserveQty = Warehouse._getProjectHardwareReservedQtyForOrderItem(activeReservations, orderId, itemId); const totalReservedQty = round2(Warehouse._getActiveReservationsForItem(itemId) .reduce((sum, reservation) => sum + (parseFloat(reservation.qty) || 0), 0)); const stockQty = round2(parseFloat(whItem.qty || 0) || 0); const availableQty = Math.max(0, round2(stockQty - totalReservedQty)); const reserveHint = ready ? 'Уже собрано' : (Math.abs(currentReserveQty - targetQty) <= 0.000001 ? 'Резерв совпадает' : (currentReserveQty < targetQty ? 'Резерв неполный' : 'Резерв скорректирован')); return { itemId, itemName: whItem.name || (Array.isArray(row.names) ? row.names.find(Boolean) : '') || 'Позиция со склада', itemSku: whItem.sku || '', itemKind: Warehouse._projectSupplyKindLabel(row.material_type || whItem.category || 'hardware'), plannedQty, targetQty, actualQty, ready, unit: String(whItem.unit || 'шт').trim() || 'шт', stockTruth: this._buildProjectHardwareStockTruth({ orderId, itemId, item: whItem, currentReserveQty, totalReservedQty, stockQty, availableQty, reservations: activeReservations, history: history || [], }), currentReserveQty, availableQty, reserveHint, }; }) .sort((a, b) => String(a.itemName).localeCompare(String(b.itemName), 'ru')); const totalPlanned = round2(rows.reduce((sum, row) => sum + row.plannedQty, 0)); const totalTarget = round2(rows.reduce((sum, row) => sum + row.targetQty, 0)); const totalReserved = round2(rows.reduce((sum, row) => sum + row.currentReserveQty, 0)); const totalReady = rows.filter(row => row.ready).length; container.innerHTML = `

Фурнитура и упаковка заказа

Меняйте здесь фактическое количество и сборку по заказу. Резерв на складе пересчитается автоматически.
Позиций
${rows.length}
План
${totalPlanned} шт
Факт / цель
${totalTarget} шт
В резерве
${totalReserved} шт
Собрано
${totalReady} / ${rows.length}
${rows.map(row => ` `).join('')}
Комплектующая План Резерв Факт Доступно сейчас Собрано
${this._esc(row.itemName)}
${this._esc(row.itemKind)}
${row.itemSku ? `
${this._esc(row.itemSku)}
` : ''} ${this._renderProjectHardwareStockTruth(row.stockTruth, row)}
${row.plannedQty}
${row.currentReserveQty}
${this._esc(row.reserveHint)}
если пусто — по плану
${row.availableQty}
`; } catch (error) { console.error('renderHardwareTab error', error); container.innerHTML = `

Не удалось загрузить фурнитуру заказа.

`; } }, async setProjectHardwareActualQty(itemId, rawValue) { if (!this.currentOrder) return; await Warehouse.setProjectHardwareActualQty(this.currentOrder.id, itemId, rawValue); const data = await loadOrder(this.currentOrder.id); if (data && data.order) { this.currentOrder = data.order; this.currentItems = data.items || []; } await this.renderHardwareTab(); }, async toggleProjectHardwareReady(itemId, checked) { if (!this.currentOrder) return; await Warehouse.toggleProjectHardwareReady(this.currentOrder.id, itemId, checked); const data = await loadOrder(this.currentOrder.id); if (data && data.order) { this.currentOrder = data.order; this.currentItems = data.items || []; } await this.renderHardwareTab(); }, _formatProjectHardwareQty(value, unitOrItem) { if (typeof Warehouse !== 'undefined' && Warehouse && typeof Warehouse._formatWarehouseQtyDisplay === 'function') { return Warehouse._formatWarehouseQtyDisplay(value, unitOrItem); } const parsed = round2(Math.max(0, parseFloat(value || 0) || 0)); return parsed.toLocaleString('ru-RU', { maximumFractionDigits: 2 }); }, _buildProjectHardwareHistoryEntryLabel(entry, item) { const qty = round2(parseFloat(entry && entry.qty_change || 0) || 0); const sign = qty > 0 ? '+' : (qty < 0 ? '−' : ''); const title = String(entry && (entry.notes || entry.order_name) || '').trim() || (String(entry && entry.type || '').trim() || 'движение'); const dateLabel = (typeof Warehouse !== 'undefined' && Warehouse && typeof Warehouse._formatDateCompact === 'function') ? Warehouse._formatDateCompact(entry && entry.created_at) : App.formatDate(entry && entry.created_at); return `${dateLabel} · ${title} · ${sign}${this._formatProjectHardwareQty(Math.abs(qty), item)} ${String(item && item.unit || 'шт').trim() || 'шт'}`; }, _buildProjectHardwareStockTruth({ orderId, itemId, item, currentReserveQty, totalReservedQty, stockQty, availableQty, reservations, history }) { const itemUnit = String(item && item.unit || 'шт').trim() || 'шт'; const activeReservations = (Array.isArray(reservations) ? reservations : []).filter(reservation => Number(reservation && reservation.item_id || 0) === Number(itemId || 0) && String(reservation && reservation.status || '') === 'active' ); const otherReservations = activeReservations.filter(reservation => Number(reservation && reservation.order_id || 0) !== Number(orderId || 0)); const otherReservedQty = round2(otherReservations.reduce((sum, reservation) => sum + (parseFloat(reservation.qty) || 0), 0)); const manualReservedQty = round2(otherReservations .filter(reservation => Warehouse._isManualReservationRecord(reservation)) .reduce((sum, reservation) => sum + (parseFloat(reservation.qty) || 0), 0)); const itemHistory = (Array.isArray(history) ? history : []) .filter(entry => Number(entry && entry.item_id || 0) === Number(itemId || 0)) .sort((a, b) => { const byDate = new Date(String(b && b.created_at || '')).getTime() - new Date(String(a && a.created_at || '')).getTime(); if (byDate !== 0) return byDate; return Number(b && b.id || 0) - Number(a && a.id || 0); }); const correctionEntries = itemHistory.filter(entry => Warehouse._isStockCorrectionHistoryEntry(entry)); const correctionNet = round2(correctionEntries.reduce((sum, entry) => sum + (parseFloat(entry.qty_change) || 0), 0)); const correctionSign = correctionNet > 0 ? '+' : (correctionNet < 0 ? '−' : ''); const recentEntries = itemHistory.slice(0, 3).map(entry => this._buildProjectHardwareHistoryEntryLabel(entry, item)); const otherReservationLabels = otherReservations.slice(0, 3).map(reservation => { const meta = Warehouse._getReservationDisplayMeta(reservation); return `${meta.primaryLabel} ${this._formatProjectHardwareQty(meta.qty, item)} ${itemUnit}`; }); return { itemUnit, stockQty: round2(stockQty), totalReservedQty: round2(totalReservedQty), currentReserveQty: round2(currentReserveQty), otherReservedQty, manualReservedQty, availableQty: round2(availableQty), correctionEntriesCount: correctionEntries.length, correctionNet, correctionSign, otherReservationLabels, hasMoreOtherReservations: otherReservations.length > otherReservationLabels.length, recentEntries, }; }, _renderProjectHardwareStockTruth(truth, row) { if (!truth) return ''; const unit = String(truth.itemUnit || row.unit || 'шт').trim() || 'шт'; const summaryBits = [ `На складе ${this._formatProjectHardwareQty(truth.stockQty, row)} ${unit}`, `резерв всего ${this._formatProjectHardwareQty(truth.totalReservedQty, row)} ${unit}`, `свободно ${this._formatProjectHardwareQty(truth.availableQty, row)} ${unit}`, ]; if (truth.correctionEntriesCount > 0) { summaryBits.push(`корр. ${truth.correctionSign}${this._formatProjectHardwareQty(Math.abs(truth.correctionNet), row)} ${unit}`); } const detailLines = []; if (truth.currentReserveQty > 0) { detailLines.push(`Этот заказ держит ${this._formatProjectHardwareQty(truth.currentReserveQty, row)} ${unit}.`); } if (truth.otherReservationLabels.length > 0) { detailLines.push(`Другие резервы: ${truth.otherReservationLabels.map(label => this._esc(label)).join('; ')}${truth.hasMoreOtherReservations ? ' …' : ''}.`); } else if (truth.manualReservedQty > 0) { detailLines.push(`Есть ручной резерв ${this._formatProjectHardwareQty(truth.manualReservedQty, row)} ${unit}.`); } else { detailLines.push('Других активных резервов сейчас нет.'); } if (truth.recentEntries.length > 0) { detailLines.push(`Последние движения: ${truth.recentEntries.map(line => this._esc(line)).join('; ')}.`); } return `
${summaryBits.map(bit => this._esc(bit)).join(' · ')}
Почему такой остаток
${detailLines.map(line => `
${line}
`).join('')}
`; }, _renderPendantDetail(pnd, dbItem) { const qty = pnd.quantity || 0; const elements = pnd.elements || []; const elemPrice = pnd.element_price_per_unit || 0; const getAttachmentAllocatedQty = (entry) => { if (typeof getPendantAttachmentAllocatedQty === 'function') { return getPendantAttachmentAllocatedQty(pnd, entry); } const allocatedQty = parseFloat(entry?.allocated_qty); if (Number.isFinite(allocatedQty) && allocatedQty >= 0) return allocatedQty; return qty > 0 ? qty : 0; }; const normalizeAttachments = (type) => { const collectionKey = type === 'cord' ? 'cords' : 'carabiners'; const legacyKey = type === 'cord' ? 'cord' : 'carabiner'; const fallbackLengthCm = type === 'cord' ? (parseFloat(pnd.cord_length_cm) || 0) : 0; let entries = Array.isArray(pnd[collectionKey]) ? pnd[collectionKey] : []; if ((!entries || entries.length === 0) && pnd[legacyKey]) { entries = [pnd[legacyKey]]; } return (entries || []) .map((entry, index) => { const normalized = { ...(entry || {}) }; const qtyPerPendant = parseFloat(normalized.qty_per_pendant); const lengthCm = parseFloat(normalized.length_cm); const allocatedQty = parseFloat(normalized.allocated_qty); const hasExplicitAllocatedQty = normalized.allocated_qty !== undefined && normalized.allocated_qty !== null && normalized.allocated_qty !== ''; normalized.qty_per_pendant = qtyPerPendant > 0 ? qtyPerPendant : 1; normalized.length_cm = Number.isFinite(lengthCm) ? lengthCm : (type === 'cord' && index === 0 ? fallbackLengthCm : 0); normalized.unit = normalized.unit || 'шт'; normalized.allocated_qty = Number.isFinite(allocatedQty) ? Math.max(0, Math.round(allocatedQty)) : (( normalized.name || normalized.warehouse_item_id || normalized.warehouse_sku || (parseFloat(normalized.price_per_unit) || 0) > 0 || (parseFloat(normalized.delivery_price) || 0) > 0 || (parseFloat(normalized.sell_price) || 0) > 0 || normalized.source === 'custom' ) && !hasExplicitAllocatedQty && qty > 0 ? qty : 0); return normalized; }) .filter(entry => entry && ( entry.name || entry.warehouse_item_id || entry.warehouse_sku || (parseFloat(entry.price_per_unit) || 0) > 0 || (parseFloat(entry.delivery_price) || 0) > 0 || (parseFloat(entry.sell_price) || 0) > 0 || entry.source === 'custom' )); }; const attachmentCostPerPendant = (type, entry) => { if (type === 'cord' && (entry.unit === 'м' || entry.unit === 'см')) { const metricFactor = typeof getPendantMetricRateFactor === 'function' ? getPendantMetricRateFactor(entry) : ((entry.unit === 'см') ? (parseFloat(entry.length_cm) || 0) : ((parseFloat(entry.length_cm) || 0) / 100)); return round2(((entry.price_per_unit || 0) * metricFactor) + (entry.delivery_price || 0)); } return round2(((entry.price_per_unit || 0) + (entry.delivery_price || 0)) * (entry.qty_per_pendant || 1)); }; const cords = normalizeAttachments('cord'); const carabiners = normalizeAttachments('carabiner'); // Group elements by color const groups = {}; elements.forEach(el => { const key = el.color || 'без цвета'; if (!groups[key]) groups[key] = []; groups[key].push(el); }); let rows = ''; cords.forEach(cord => { const isMetric = cord.unit === 'м' || cord.unit === 'см'; const allocatedQty = getAttachmentAllocatedQty(cord); const totalQtyLabel = isMetric ? `${round2((cord.length_cm || 0) * allocatedQty / 100)} м${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}` : `${round2(allocatedQty * (cord.qty_per_pendant || 1))} шт${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}`; const titleSuffix = isMetric && cord.length_cm > 0 ? ` (${cord.length_cm} см/подвес)` : ((cord.qty_per_pendant || 1) > 1 ? ` × ${cord.qty_per_pendant}` : ''); const pricePerPendant = attachmentCostPerPendant('cord', cord); rows += `🧵 ${this._esc(cord.name || 'Шнур')}${titleSuffix}${totalQtyLabel}${formatRub(pricePerPendant)}${formatRub(allocatedQty * pricePerPendant)}`; }); carabiners.forEach(carabiner => { const allocatedQty = getAttachmentAllocatedQty(carabiner); const titleSuffix = (carabiner.qty_per_pendant || 1) > 1 ? ` × ${carabiner.qty_per_pendant}` : ''; const pricePerPendant = attachmentCostPerPendant('carabiner', carabiner); rows += `🔗 ${this._esc(carabiner.name || 'Фурнитура')}${titleSuffix}${round2(allocatedQty * (carabiner.qty_per_pendant || 1))} шт${allocatedQty > 0 ? ` · ${allocatedQty} подв.` : ''}${formatRub(pricePerPendant)}${formatRub(allocatedQty * pricePerPendant)}`; }); // Element groups by color Object.entries(groups).forEach(([color, els]) => { const chars = els.map(e => e.char).join(', '); const groupQty = els.length * qty; rows += `🔤 ${this._esc(chars)} (${this._esc(color)})${groupQty}${formatRub(elemPrice)}${formatRub(groupQty * elemPrice)}`; }); // Print items elements.filter(el => el.has_print).forEach(el => { rows += `🖨 Печать на ${this._esc(el.char)}${qty}${formatRub(el.print_price)}${formatRub(qty * (el.print_price || 0))}`; }); // Packaging if (pnd.packaging?.name) { rows += `📦 ${this._esc(pnd.packaging.name)}${qty}${formatRub(pnd.packaging.price_per_unit)}${formatRub(qty * (pnd.packaging.price_per_unit || 0))}`; } const sellPrice = dbItem.sell_price_item || 0; const totalRevenue = sellPrice * qty; return `

🔤 Подвес "${this._esc(pnd.name)}" × ${qty} шт

${formatRub(totalRevenue)}
${rows}
ПозицияКол-воЦена/штИтого
`; }, _renderItemCard(item, type) { const name = item.product_name || '—'; const qty = item.quantity || 0; let costPerUnit = 0; let sellPrice = 0; if (type === 'product') { costPerUnit = item.cost_total || 0; sellPrice = item.sell_price_item || 0; } else if (type === 'hardware') { costPerUnit = item.cost_total || 0; sellPrice = item.sell_price_hardware || 0; } else if (type === 'packaging') { costPerUnit = item.cost_total || 0; sellPrice = item.sell_price_packaging || 0; } const revenue = sellPrice * qty; const cost = costPerUnit * qty; const margin = revenue > 0 ? ((revenue - cost) / revenue * 100) : 0; const productMeta = type === 'product' ? this._renderProductMeta(item) : ''; return `
${this._esc(name)} ${qty} шт
Себестоимость: ${formatRub(costPerUnit)}/шт
Продажа: ${formatRub(sellPrice)}/шт
Выручка: ${formatRub(revenue)}
Маржа: ${margin.toFixed(1)}%
${productMeta}
`; }, _renderProductMeta(item) { const colors = this._normalizeProductColors(item); const attachments = this._normalizeColorAttachments(item); const sections = []; if (colors.length > 0) { sections.push(`
Цвета:
${colors.map(color => `${this._esc(color.name || color.number || `#${color.id || '—'}`)}`).join('')}
`); } if (attachments.length > 0) { const linkHtml = attachments.map(attachment => { const label = this._esc(attachment.name || 'Файл цветового решения'); return attachment.data_url ? `${label}` : label; }).join('
'); sections.push(`
${attachments.length > 1 ? 'Файлы:' : 'Файл:'} ${linkHtml}
`); } if (sections.length === 0) return ''; return `
${sections.join('')}
`; }, _normalizeProductColors(item) { let colors = item?.colors; if (typeof colors === 'string') { try { colors = JSON.parse(colors); } catch (e) { colors = []; } } if (!Array.isArray(colors)) colors = []; colors = colors.filter(color => color && typeof color === 'object'); if (colors.length > 0) return colors; if (item?.color_name) { return [{ id: item.color_id || null, name: item.color_name, }]; } if (item?.color_id) { return [{ id: item.color_id, name: `#${item.color_id}`, }]; } return []; }, _normalizeColorAttachments(item) { return normalizeColorAttachments(item); }, // ========================================== // TASKS TAB (v33: linked tasks) // ========================================== async renderTasksTab() { const container = document.getElementById('od-tab-tasks'); const orderId = this.currentOrder.id; const bundle = await loadWorkBundle(); const projects = (bundle.projects || []).filter(project => String(project.linked_order_id || '') === String(orderId)); const projectIds = new Set(projects.map(project => String(project.id))); const tasks = (bundle.tasks || []).filter(task => String(task.order_id || '') === String(orderId) || projectIds.has(String(task.project_id || '')) ); const projectCards = projects.length === 0 ? '
Связанных проектов пока нет
' : projects.map(project => { const projectTasks = tasks.filter(task => String(task.project_id || '') === String(project.id)); return ` `; }).join(''); const taskRows = tasks.map(task => { const project = task.project_id ? projects.find(item => String(item.id) === String(task.project_id)) : null; return `
${this._esc(task.title)}
${this._esc(project ? `Проект: ${project.title}` : 'Прямая задача заказа')}
${this._esc(WorkManagementCore.getTaskStatusLabel(task.status))} ${this._esc(task.assignee_name || '—')} ${this._esc(task.due_date ? App.formatDate(task.due_date) + (task.due_time ? ' ' + task.due_time : '') : '—')} `; }).join(''); const inProgress = tasks.filter(task => task.status === 'in_progress').length; const done = tasks.filter(task => task.status === 'done').length; const total = tasks.length; container.innerHTML = `
Всего задач: ${total}  |  В работе: ${inProgress}  |  Готово: ${done}  |  Проектов: ${projects.length}

Связанные проекты

${projectCards}
${tasks.length === 0 ? `

В этом заказе пока нет задач

` : `
${taskRows}
Задача Статус Ответственный Дедлайн
` }`; }, // ========================================== // CHINA TAB // ========================================== async renderChinaTab() { const container = document.getElementById('od-tab-china'); const orderId = this.currentOrder.id; const orderName = this._esc(this.currentOrder.order_name || ''); try { const purchases = await loadChinaPurchases({ order_id: orderId }); const addBtn = `
`; if (!purchases || purchases.length === 0) { container.innerHTML = ` ${addBtn}
🇨🇳

Нет привязанных закупок из Китая

`; return; } container.innerHTML = ` ${addBtn}
${purchases.map(p => { const st = (typeof ChinaPurchases !== 'undefined' && ChinaPurchases.STATUSES) ? ChinaPurchases.STATUSES.find(s => s.key === p.status) : null; return ``; }).join('')}
Закупка Поставщик Статус Сумма CNY Трек
${this._esc(p.purchase_name || '')} ${this._esc(p.supplier_name || '')} ${st ? '' + st.label + '' : (p.status || '')} ${(p.total_cny || 0).toFixed(2)} ${this._esc(p.tracking_number || '')}
`; } catch (e) { container.innerHTML = '

Ошибка загрузки закупок

'; } }, // ========================================== // ACTIONS // ========================================== async changeStatus() { const o = this.currentOrder; const opts = STATUS_OPTIONS.filter(s => s.value !== 'deleted' && s.value !== o.status); const labels = opts.map((s, i) => `${i + 1}. ${s.label}`).join('\n'); const choice = prompt(`Текущий статус: ${App.statusLabel(o.status)}\n\nВыберите новый:\n${labels}\n\nВведите номер:`); if (!choice) return; const idx = parseInt(choice) - 1; if (isNaN(idx) || idx < 0 || idx >= opts.length) { App.toast('Неверный выбор'); return; } const newStatus = opts[idx].value; if (typeof Orders !== 'undefined' && Orders && typeof Orders._ensureStatusTransitionAllowed === 'function') { const guard = await Orders._ensureStatusTransitionAllowed(this.currentOrder.id, newStatus); if (!guard.ok) return; } const managerName = prompt('Имя менеджера (для истории):') || 'Неизвестный'; await updateOrderStatus(this.currentOrder.id, newStatus); if (typeof Orders !== 'undefined' && Orders._syncWarehouseByStatus) { await Orders._syncWarehouseByStatus( this.currentOrder.id, o.status, newStatus, this.currentOrder.order_name || o.order_name, managerName ); if (Orders._syncReadyGoodsByStatus) { await Orders._syncReadyGoodsByStatus(this.currentOrder.id, this.currentOrder, o.status, newStatus); } } await Orders.addChangeRecord(this.currentOrder.id, { field: 'status', old_value: App.statusLabel(o.status), new_value: App.statusLabel(newStatus), manager: managerName, }); this.currentOrder.status = newStatus; this.renderHeader(); App.toast(`Статус: ${App.statusLabel(newStatus)}`); }, openInCalculator() { if (this.currentOrder) { Calculator.loadOrder(this.currentOrder.id); } }, cloneOrder() { if (this.currentOrder) { Orders.cloneOrder(this.currentOrder.id); } }, // ========================================== // INLINE EDIT HELPERS // ========================================== async saveField(field, value) { await updateOrderFields(this.currentOrder.id, { [field]: value }); this.currentOrder[field] = value; // Re-render current tab const tabRenderers = { info: () => this.renderInfoTab(), production: () => this.renderProductionTab(), files: () => this.renderFilesTab(), hardware: () => this.renderHardwareTab(), }; if (tabRenderers[this.currentTab]) tabRenderers[this.currentTab](); if (['payment_status'].includes(field)) this.renderStats(); App.toast('Сохранено'); }, startEdit(field, currentValue, inputType) { const cell = document.getElementById('od-val-' + field); if (!cell) return; cell.classList.add('od-editing'); if (inputType === 'textarea') { cell.innerHTML = ``; } else { cell.innerHTML = ``; } cell.querySelector('.od-inline-input').focus(); }, finishEdit(field, value) { const trimmed = value.trim(); if (trimmed !== (this.currentOrder[field] || '').toString().trim()) { this.saveField(field, trimmed); } else { // Revert — re-render tab const tabRenderers = { info: () => this.renderInfoTab(), production: () => this.renderProductionTab(), files: () => this.renderFilesTab(), hardware: () => this.renderHardwareTab(), }; if (tabRenderers[this.currentTab]) tabRenderers[this.currentTab](); } }, startEditDate(field, currentValue) { const cell = document.getElementById('od-val-' + field); if (!cell) return; cell.innerHTML = ``; cell.querySelector('input').focus(); }, onSelectChange(field, value) { this.saveField(field, value); }, onCheckboxChange(field, checked) { this.saveField(field, checked); }, // ========================================== // FIELD RENDERERS // ========================================== _fieldRow(field, label, value, inputType) { let displayValue = ''; if (inputType === 'url' && value) { const shortUrl = value.length > 50 ? value.substring(0, 50) + '...' : value; displayValue = `${this._esc(shortUrl)}`; } else { displayValue = this._esc(value || '') || ''; } return `
${label}
${displayValue}
`; }, _fieldRowDateRange(fieldStart, fieldEnd, label, valueStart, valueEnd) { const fmtStart = valueStart ? App.formatDate(valueStart) : '—'; const fmtEnd = valueEnd ? App.formatDate(valueEnd) : ''; const display = fmtEnd ? `${fmtStart} → ${fmtEnd}` : fmtStart; return `
${label}
${fmtStart} ${fmtEnd || ''}
`; }, _fieldRowSelect(field, label, value, options) { const opts = options.map(o => `` ).join(''); return `
${label}
`; }, _fieldRowCheckbox(field, label, checked) { return `
${label}
`; }, // ========================================== // UTILS // ========================================== _statusColor(status) { const map = { draft: 'gray', calculated: 'blue', in_production: 'orange', production_printing: 'orange', completed: 'green', cancelled: 'red', deleted: 'red' }; return map[status] || 'gray'; }, _esc(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }, _escAttr(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); }, };