// theo đúng những gì Long thực sự nhập, không còn phụ thuộc bộ mẫu này nữa. const DUTOAN_MAU_KHOI_TAO = [ {nhom:'Ray trên', ten:'Khung trên - thép 40x40', dvt:'m', donGia:27000, qty:8.8}, {nhom:'Ray trên', ten:'Chống 2 hồi - thép 40x40', dvt:'m', donGia:27000, qty:13.64}, {nhom:'Ray trên', ten:'Thanh ngang - thép 10x30', dvt:'m', donGia:15000, qty:2}, {nhom:'Ray trên', ten:'U thép 1.2mm - 30x40x30', dvt:'m', donGia:36667, qty:19}, {nhom:'Ray dưới (Nhôm)', ten:'Ray giữa', dvt:'m', donGia:72000, qty:26.6}, {nhom:'Ray dưới (Nhôm)', ten:'Ray cạnh', dvt:'m', donGia:42000, qty:7.6}, {nhom:'Trán trần', ten:'Thép 20x20', dvt:'m', donGia:12000, qty:19.76}, {nhom:'Trán trần', ten:'LED T5', dvt:'bộ', donGia:39000, qty:12}, {nhom:'Bảng gạch', ten:'Khung ngoài - thép 30x30', dvt:'m', donGia:20000, qty:62.4}, {nhom:'Bảng gạch', ten:'Nẹp nhôm', dvt:'m', donGia:30000, qty:76.8}, {nhom:'Sơn tĩnh điện', ten:'Sơn tĩnh điện toàn bộ khung', dvt:'kg', donGia:12000, qty:75}, {nhom:'Nhân công', ten:'Nhân công lắp dựng', dvt:'công', donGia:600000, qty:2}, {nhom:'Vận chuyển', ten:'Vận chuyển đến công trình', dvt:'chuyến', donGia:150000, qty:5}, ]; async function loadDuToanCatalog(){ try{ const r = await supaGet('dutoan_vat_lieu_catalog'); duToanCatalog = (r && r.value) ? JSON.parse(r.value) : []; }catch(e){ console.error('[Dự toán] Lỗi tải danh mục vật tư:', e); duToanCatalog = []; } if(duToanCatalog.length===0){ // Chưa ai nhập gì - mồi bằng bộ mẫu (kèm SL mẫu thật, không phải 0), để lần đầu mở ra giống hệt demo const today = new Date().toISOString().slice(0,10); duToanCatalog = DUTOAN_MAU_KHOI_TAO.map(r=>({ten:r.ten, dvt:r.dvt, donGia:r.donGia, qty:r.qty, nhom:r.nhom, priceUpdatedAt:today})); await persistDuToanCatalog(); } duToanCatalogLoaded = true; } async function persistDuToanCatalog(){ try{ await supaSet('dutoan_vat_lieu_catalog', JSON.stringify(duToanCatalog)); }catch(e){} } function renderDatalistDuToanCatalog(){ let dl = document.getElementById('dsDuToanVatLieu'); if(!dl){ dl = document.createElement('datalist'); dl.id = 'dsDuToanVatLieu'; document.body.appendChild(dl); } const tuKho = (typeof vatTuList!=='undefined'?vatTuList:[]).map(vt=>``); const tuDanhMuc = duToanCatalog.map(c=>``); dl.innerHTML = tuKho.join('') + tuDanhMuc.join(''); } function chonDongDangSua(gid, iid){ dongDangChonGid = gid; dongDangChonIid = iid; } function openDuToanChoDongDangChon(){ if(!dongDangChonGid || !dongDangChonIid || !groups.find(g=>g.id===dongDangChonGid && g.items.some(i=>i.id===dongDangChonIid))){ alert('Hãy bấm vào 1 dòng báo giá trước (gõ Mô tả, ĐVT, SL hoặc Đơn giá), rồi mở lại Bảng dự toán.'); return; } openDuToan(dongDangChonGid, dongDangChonIid); } function openDuToan(gid, iid){ const g = groups.find(x=>x.id===gid); const it = g && g.items.find(x=>x.id===iid); if(!it) return; duToanCurGid = gid; duToanCurIid = iid; document.getElementById('duToanTenHangMuc').textContent = it.desc || '(chưa đặt tên)'; document.getElementById('duToan_giaBan').value = it.price || 0; const moModal = () => { if(it.duToan && it.duToan.length){ duToanRows = it.duToan.map(r=>({...r})); // hạng mục này đã dự toán trước đây - mở lại đúng của nó } else { // Hạng mục mới, chưa dự toán lần nào - mở ra CẢ DANH MỤC (giống hệt file demo Long gửi), Long // chỉ cần sửa SL cho phù hợp hạng mục này, xoá dòng không dùng, không phải gõ lại từ đầu. duToanRows = duToanCatalog.map(c=>({id:uid(), nhom:c.nhom||'', ten:c.ten, dvt:c.dvt||'', donGia:c.donGia||0, qty:c.qty||0, maVatTu:null, priceUpdatedAt:c.priceUpdatedAt||null})); } renderDatalistDuToanCatalog(); renderDuToan(); document.getElementById('duToanOverlay').classList.add('open'); }; if(!duToanCatalogLoaded) loadDuToanCatalog().then(moModal); else moModal(); } function chonVatLieuTuDanhMuc(rid, tenChon){ const c = duToanCatalog.find(x=>x.ten===tenChon); if(!c) return; const r = duToanRows.find(x=>x.id===rid); if(!r) return; r.ten = c.ten; r.dvt = c.dvt||''; r.donGia = c.donGia||0; r.nhom = r.nhom || c.nhom || ''; r.priceUpdatedAt = c.priceUpdatedAt || new Date().toISOString().slice(0,10); renderDuToan(); } function closeDuToan(){ document.getElementById('duToanOverlay').classList.remove('open'); duToanCurGid=null; duToanCurIid=null; } function addDuToanRow(){ duToanRows.push({id:uid(), nhom:'', ten:'', dvt:'', donGia:0, qty:1, maVatTu:null, priceUpdatedAt:new Date().toISOString().slice(0,10)}); renderDuToan(); } function removeDuToanRow(rid){ duToanRows = duToanRows.filter(r=>r.id!==rid); renderDuToan(); } function updateDuToanRow(rid, field, value){ const r = duToanRows.find(x=>x.id===rid); if(!r) return; r[field] = ['donGia','qty'].includes(field) ? (parseFloat(value)||0) : value; if(field==='donGia') r.priceUpdatedAt = new Date().toISOString().slice(0,10); // theo đúng file demo Long gửi - đổi giá tay thì tính lại "mới cập nhật" renderDuToan(); } function chonVatLieuChoDuToanRow(rid, value){ const co3Gach = value.includes(' - '); if(co3Gach){ // Định dạng "MÃ - Tên" chỉ có ở vật tư từ Kho const maSKU = (value.split(' - ')[0]||'').trim(); const vt = (typeof vatTuList!=='undefined'?vatTuList:[]).find(v=>v.code===maSKU); const r = duToanRows.find(x=>x.id===rid); if(!r) return; if(!vt){ r.maVatTu = null; renderDuToan(); return; } r.ten = vt.ten; r.dvt = vt.dvt || ''; r.donGia = (vt.giaVonBinhQuan!==undefined ? vt.giaVonBinhQuan : (vt.donGia||0)); r.maVatTu = vt.code; r.priceUpdatedAt = new Date().toISOString().slice(0,10); // giá vừa kéo từ Kho = giá mới nhất ngay lúc này renderDuToan(); } else { chonVatLieuTuDanhMuc(rid, value.trim()); } } function renderDuToan(){ const body = document.getElementById('duToanRowsTbody'); body.innerHTML = duToanRows.map(r=>{ const days = r.priceUpdatedAt ? Math.floor((Date.now() - new Date(r.priceUpdatedAt)) / 86400000) : null; const stale = days !== null && days > 30; return ` ${days!==null ? `
${stale?'⚠ ':''}${r.maVatTu?'Kho · ':''}${days===0?'hôm nay':days+' ngày trước'}
` : ''} ${fmt(r.donGia*r.qty)} `;}).join('') || 'Chưa có vật tư nào — bấm "+ Thêm vật tư".'; const tongChiPhi = duToanRows.reduce((s,r)=>s+(r.donGia*r.qty||0),0); document.getElementById('duToanTongChiPhi').textContent = fmt(tongChiPhi); const giaBan = parseFloat(document.getElementById('duToan_giaBan').value) || 0; const loiNhuan = giaBan - tongChiPhi; const tySuat = giaBan>0 ? Math.round(loiNhuan/giaBan*100) : 0; document.getElementById('duToan_chiPhi').textContent = fmt(tongChiPhi); document.getElementById('duToan_loiNhuan').textContent = fmt(loiNhuan); document.getElementById('duToan_tySuat').textContent = tySuat + '%'; } function applyDuToanToItem(){ const g = groups.find(x=>x.id===duToanCurGid); const it = g && g.items.find(x=>x.id===duToanCurIid); if(!it){ closeDuToan(); return; } it.duToan = duToanRows; // lưu nguyên danh sách đang sửa cho hạng mục này - đúng như file demo (không tự lọc bớt, Long tự xoá dòng không dùng bằng nút ×) const giaBan = parseFloat(document.getElementById('duToan_giaBan').value) || 0; if(giaBan>0) it.price = giaBan; // Ghi/đè vào danh mục vật tư dự toán DÙNG CHUNG - chỉ áp dụng vật liệu KHÔNG link Kho (Kho đã có // giá thật riêng, không cần nhớ lại). Nhớ đúng giá GẦN NHẤT vừa dùng, để lần sau chọn ra là có sẵn. let coThayDoiDanhMuc = false; duToanRows.filter(r=>!r.maVatTu && r.ten && r.ten.trim()).forEach(r=>{ const idx = duToanCatalog.findIndex(c=>c.ten===r.ten.trim()); const entry = { ten: r.ten.trim(), dvt: r.dvt||'', donGia: r.donGia||0, qty: r.qty||0, nhom: r.nhom||'', priceUpdatedAt: r.priceUpdatedAt||new Date().toISOString().slice(0,10) }; if(idx>=0) duToanCatalog[idx] = entry; else duToanCatalog.push(entry); coThayDoiDanhMuc = true; }); if(coThayDoiDanhMuc) persistDuToanCatalog(); closeDuToan(); renderGroups(); } function updateVatDisplay(){ const total = groups.reduce((s,g)=>s+g.items.reduce((s2,it)=>s2+itemTotal(it),0),0); const cooperationFee = parseFloat(document.getElementById('d_cooperation_fee').value) || 0; const realRevenue = total - cooperationFee; const vat = parseFloat(document.getElementById('d_vat').value)||0; document.getElementById('subTotalDisplay').textContent = fmt(total); document.getElementById('totalAfterVat').textContent = fmt(total + total*vat/100); document.getElementById('realRevenueRow').style.display = cooperationFee>0 ? 'table-row' : 'none'; if(cooperationFee>0) document.getElementById('realRevenueDisplay').textContent = fmt(realRevenue); } document.addEventListener('input', (e)=>{ if(e.target && e.target.id==='d_vat') updateVatDisplay(); }); // ===== CỔNG BOM - 4-6 nhóm linh hoạt (SỬA 2026-08-11 theo yêu cầu Long, trước đây 4 ô cố định) ===== function tinhTongSauVAT(){ const total = groups.reduce((s,g)=>s+g.items.reduce((s2,it)=>s2+itemTotal(it),0),0); const vat = parseFloat(document.getElementById('d_vat').value)||0; return total*(1+vat/100); } // Đổi BOM định dạng CŨ (go/sat/phuKien/nhanCong cố định) sang định dạng MỚI (groups linh hoạt) - // áp dụng khi mở lại báo giá cũ đã lưu BOM từ trước, không mất dữ liệu, không bắt Long nhập lại. function chuanHoaBomGroups(b){ if(!b) return [ {id:uid(), label:'Gỗ/MDF', amount:0}, {id:uid(), label:'Sắt/Kim loại', amount:0}, {id:uid(), label:'Phụ kiện/Vật tư phụ', amount:0}, {id:uid(), label:'Nhân công/Gia công ngoài', amount:0}, ]; if(Array.isArray(b.groups) && b.groups.length) return JSON.parse(JSON.stringify(b.groups)); // BOM cũ (trước 2026-08-11) - 4 khoản cố định, chuyển sang 4 nhóm tương ứng return [ {id:uid(), label:'Gỗ/MDF', amount:b.go||0}, {id:uid(), label:'Sắt/Kim loại', amount:b.sat||0}, {id:uid(), label:'Phụ kiện/Vật tư phụ', amount:b.phuKien||0}, {id:uid(), label:'Nhân công/Gia công ngoài', amount:b.nhanCong||0}, ]; } let bomGroupsTam = []; // danh sách nhóm đang sửa dở trên form (chưa bấm Lưu BOM) function renderBomGate(){ const box = document.getElementById('bomGateBox'); if(!box) return; bomGroupsTam = chuanHoaBomGroups(currentBom); renderBomGateInner(); } function renderBomGateInner(){ const box = document.getElementById('bomGateBox'); if(!box) return; const tong = bomGroupsTam.reduce((s,g)=>s+(g.amount||0),0); const tongSauVAT = tinhTongSauVAT(); const marginPct = tongSauVAT>0 ? Math.round((tongSauVAT-tong)/tongSauVAT*100) : 100; const hienThiSo = (n) => n ? Number(n).toLocaleString('vi-VN') : ''; const rows = bomGroupsTam.map((g,idx)=>`
${bomGroupsTam.length>4 ? `` : ''}
`).join(''); box.innerHTML = `
${currentBom ? `✓ Đã có BOM — Margin dự kiến: ${marginPct}%` : '⚠️ Nhắc nhở: Chưa nhập BOM chi phí (Margin tạm tính 100%)'}
${rows}
${bomGroupsTam.length<6 ? `` : `
Đã đạt tối đa 6 nhóm.
`}
Tổng BOM: ${fmt(tong)}
Margin: ${marginPct}%
`; } function capNhatBomGroup(id, field, value){ const g = bomGroupsTam.find(x=>x.id===id); if(!g) return; g[field] = field==='amount' ? (parseFloat(String(value).replace(/\D/g,''))||0) : value; capNhatTongMarginBOM(); } function themBomGroup(){ if(bomGroupsTam.length>=6) return; bomGroupsTam.push({id:uid(), label:'', amount:0}); renderBomGateInner(); } function xoaBomGroup(id){ if(bomGroupsTam.length<=4){ alert('BOM cần tối thiểu 4 nhóm chi phí.'); return; } bomGroupsTam = bomGroupsTam.filter(g=>g.id!==id); renderBomGateInner(); } // SỬA 2026-08-11 (theo báo lỗi Long: gõ số bị nhảy lung tung, không nhập được). Nguyên nhân: trước // đây hàm này chạy NGAY LÚC ĐANG GÕ (oninput) để tự chèn dấu chấm phân cách nghìn, phải tự tính lại // vị trí con trỏ mỗi lần - dễ lệch khi gõ giữa số hoặc xoá lùi, gây cảm giác "nhảy số". Giờ CHỈ chạy // khi rời khỏi ô (onblur) - lúc đó con trỏ không còn quan trọng nữa (ô đã mất focus), nên không còn // rủi ro tính sai vị trí, tuyệt đối không nhảy trong lúc gõ vì không còn đụng vào ô trong lúc gõ nữa. function formatSoTienBOM(el){ const raw = el.value.replace(/\D/g,''); el.value = raw ? Number(raw).toLocaleString('vi-VN') : ''; } function capNhatTongMarginBOM(){ const tong = bomGroupsTam.reduce((s,g)=>s+(g.amount||0),0); const tongSauVAT = tinhTongSauVAT(); const marginPct = tongSauVAT>0 ? Math.round((tongSauVAT-tong)/tongSauVAT*100) : 100; const tongEl = document.getElementById('bomTongLabel'); if(tongEl) tongEl.textContent = `Tổng BOM: ${fmt(tong)}`; const marginEl = document.getElementById('bomMarginLabel'); if(marginEl){ marginEl.textContent = `Margin: ${marginPct}%`; marginEl.style.color = marginPct<15 ? 'var(--red)' : '#3A9D6F'; } } function luuBOM(){ const chiPhi = bomGroupsTam.reduce((s,g)=>s+(g.amount||0),0); const tongSauVAT = tinhTongSauVAT(); const bienLNPct = tongSauVAT>0 ? Math.round((tongSauVAT-chiPhi)/tongSauVAT*100) : 0; currentBom = {groups: JSON.parse(JSON.stringify(bomGroupsTam)), chiPhi, bienLNPct}; renderBomGate(); } // ---- Chốt / Thất bại (dùng modal thật, không dùng prompt() vì dễ bị chặn khi mở trong app Claude) ---- function openChotFlow(){ document.getElementById('chot_loai').value = 'cong_trinh'; document.getElementById('chot_so_hd').value = ''; document.getElementById('chotModal').classList.add('open'); } function closeChotModal(){ document.getElementById('chotModal').classList.remove('open'); } function xacNhanChotFlow(){ const loai = document.getElementById('chot_loai').value; const soHD = document.getElementById('chot_so_hd').value.trim(); if(!soHD){ alert('Cần nhập số hợp đồng để tạo.'); return; } document.getElementById('d_code').dataset.loai = loai; document.getElementById('c_number').value = soHD; document.getElementById('loaiDisplay').textContent = LOAI_LABEL[loai]; document.getElementById('trangThaiDisplay').textContent = 'Đang thực hiện'; document.getElementById('chotSection').style.display = 'none'; document.getElementById('postChotSections').style.display = 'block'; document.getElementById('statusBadge').className = 'badge b-loai'; document.getElementById('statusBadge').textContent = LOAI_LABEL[loai]; closeChotModal(); toggleProcessType(); renderPayRows(); renderProgressRows(); renderStepRows(); toggleRetention(); // SỬA 2026-08-11 (theo báo lỗi Long: "Hợp đồng/Đơn hàng không hiển thị dù có"). Nguyên nhân thật: // xác nhận chốt ở đây CHỈ đổi giao diện, CHƯA lưu vào deals[] - phải bấm thêm nút "Lưu" riêng ở // đầu form. Nếu đóng form/chuyển trang ngay sau khi chốt (tưởng đã xong), việc chốt mất trắng, // deal không bao giờ có loai để xuất hiện ở Hợp đồng/Đơn hàng. Giờ TỰ ĐỘNG lưu ngay tại đây. saveDeal(true); } function markThatBai(){ document.getElementById('thatbai_ly_do').value = ''; document.getElementById('thatBaiModal').classList.add('open'); } function closeThatBaiModal(){ document.getElementById('thatBaiModal').classList.remove('open'); } function xacNhanThatBai(){ const ly_do = document.getElementById('thatbai_ly_do').value.trim(); if(!ly_do){ alert('Cần nhập lý do thất bại.'); return; } document.getElementById('statusBadge').className = 'badge b-that-bai'; document.getElementById('statusBadge').textContent = 'Thất bại'; document.getElementById('d_code').dataset.thatbai = ly_do; closeThatBaiModal(); // SỬA 2026-08-11 (cùng lỗi với luồng chốt - tự động lưu ngay, không chờ người dùng nhớ bấm Lưu riêng). saveDeal(true); } // ---- Tiến độ ---- function toggleProcessType(){ const isSimple = document.getElementById('d_process_type').value==='simple'; document.getElementById('detailedStatusField').style.display = isSimple?'none':'block'; document.getElementById('simpleStatusField').style.display = isSimple?'block':'none'; renderProgress(); } function renderProgress(){ const isSimple = document.getElementById('d_process_type').value==='simple'; const latest = progressRows.length ? progressRows[progressRows.length-1] : null; let pct; if(latest) pct = latest.pct; else if(isSimple) pct = SIMPLE_STATUS_PCT[document.getElementById('d_simple_status').value]||0; else pct = (statusMeta[document.getElementById('d_status').value]||statusMeta.s01).pct; document.getElementById('progressFill').style.width = pct+'%'; document.getElementById('progressPctLabel').textContent = latest ? `Đạt ${pct}% (cập nhật ${latest.date})` : `Đạt ${pct}% (theo trạng thái)`; } function addProgressRow(){ progressRows.push({id:uid(), date:new Date().toISOString().slice(0,10), pct:0, note:''}); renderProgressRows(); } function removeProgressRow(id){ progressRows = progressRows.filter(p=>p.id!==id); renderProgressRows(); } function updateProgressRow(id,field,value){ const p=progressRows.find(x=>x.id===id); p[field]=field==='pct'?(parseFloat(value)||0):value; renderProgressRows(); } function renderProgressRows(){ document.getElementById('progressRows').innerHTML = progressRows.map(p=>` `).join(''); renderProgress(); } function addStepRow(){ stepRows.push({id:uid(), step:'', person:'', headcount:1, start:new Date().toISOString().slice(0,10), end:''}); renderStepRows(); } function removeStepRow(id){ stepRows = stepRows.filter(s=>s.id!==id); renderStepRows(); } function updateStepRow(id,field,value){ const s=stepRows.find(x=>x.id===id); s[field] = field==='headcount' ? (parseInt(value)||1) : value; if(field==='step' && !s.person){ const key = Object.keys(statusMeta).find(k=>statusMeta[k].l===value); if(key && STEP_DEFAULT_TEAM[key]) s.person = STEP_DEFAULT_TEAM[key]; } renderStepRows(); } function renderStepRows(){ const stepOptions = Object.values(statusMeta).map(s=>s.l); document.getElementById('stepRows').innerHTML = stepRows.map(s=>` `).join(''); const totalCong = stepRows.reduce((sum,s)=>{ if(!s.start||!s.end) return sum; const days=Math.max(0,Math.round((new Date(s.end)-new Date(s.start))/86400000)+1); return sum+days*(s.headcount||1); },0); document.getElementById('totalCongLabel').textContent = `Tổng ngày công đã ghi nhận: ${totalCong} công`; } // ---- Bóc tách vật tư dự kiến (Hoàng, sau khi chốt hợp đồng) - đối chiếu với thực tế đã xuất kho ---- function addBocTachRow(){ bocTachRows.push({ id:uid(), maVT:'', soLuongDuKien:0 }); renderBocTachRows(); } function removeBocTachRow(id){ bocTachRows = bocTachRows.filter(r=>r.id!==id); renderBocTachRows(); } function updateBocTachRow(id, field, value){ const r = bocTachRows.find(x=>x.id===id); if(!r) return; r[field] = field==='soLuongDuKien' ? (parseFloat(value)||0) : value; renderBocTachRows(); } // Tổng đã xuất kho THẬT cho đúng dự án này + đúng mã vật tư này (mọi mục đích xuất, không chỉ sản xuất - // vì vật tư có thể xuất bán thẳng hoặc điều chuyển phục vụ dự án) function tinhThucTeXuatChoDuAn(maVT){ const dealCode = editingId ? (deals.find(x=>x.id===editingId)||{}).code : null; if(!dealCode || !maVT) return 0; return nhapXuatList.filter(n=>n.loai==='xuat' && n.lienQuan===dealCode && n.maVT===maVT).reduce((s,n)=>s+n.soLuong,0); } function renderBocTachRows(){ const box = document.getElementById('bocTachRows'); if(!box) return; const vtOptions = (typeof vatTuList!=='undefined'?vatTuList:[]); box.innerHTML = bocTachRows.map(r=>{ const thucTe = tinhThucTeXuatChoDuAn(r.maVT); const vt = vtOptions.find(v=>v.code===r.maVT); let mauSo = 'var(--sub)', nhan = ''; const conLai = Math.max(0, r.soLuongDuKien - thucTe); if(r.maVT && r.soLuongDuKien>0){ if(thucTe===0){ mauSo='var(--sub)'; nhan='chưa xuất'; } else if(thucTe < r.soLuongDuKien){ mauSo='var(--gold)'; nhan='còn thiếu '+conLai; } else if(thucTe === r.soLuongDuKien){ mauSo='#3A9D6F'; nhan='khớp đúng dự kiến'; } else { mauSo='var(--red)'; nhan='vượt '+(thucTe-r.soLuongDuKien)+' - kiểm tra lại'; } } const tonKhoHienTai = r.maVT ? tonKho(r.maVT) : 0; const duHang = conLai>0 && tonKhoHienTai < conLai; return ` ${thucTe}${vt?' '+vt.dvt:''}${nhan?`
${nhan}`:''} ${(r.maVT && conLai>0) ? `` : ''} ${duHang ? `` : ''} `; }).join(''); const soVatTuKhopDu = bocTachRows.filter(r=>r.maVT && tinhThucTeXuatChoDuAn(r.maVT) >= r.soLuongDuKien && r.soLuongDuKien>0).length; const tongVatTu = bocTachRows.filter(r=>r.maVT).length; document.getElementById('bocTachTongLabel').textContent = tongVatTu>0 ? `Đã xuất đủ ${soVatTuKhopDu}/${tongVatTu} loại vật tư dự kiến` : ''; } // Bấm 1 nút trên dòng bóc tách -> nhảy sang tab Xuất kho, điền sẵn vật tư + đúng số còn thiếu + gắn mã dự án - // Hoàng chỉ cần kiểm tra lại kho xuất/ai nhận rồi bấm Lưu, không phải gõ tay lại từ đầu. function xuatNhanhTheoBocTach(maVT, soLuong){ const d = editingId ? deals.find(x=>x.id===editingId) : null; setKhoTab ? null : null; // no-op giữ chỗ, Kho dùng chuyenTrang riêng chuyenTrang('kho'); setTimeout(()=>{ setKhoTab('xuat'); document.getElementById('xuat_vattu').value = maVT; document.getElementById('xuat_soluong').value = soLuong; document.getElementById('xuat_mucdich').value = 'san_xuat'; if(d) document.getElementById('xuat_donhang').value = d.code; document.getElementById('xuat_nguoiThucHien').value = 'Hoàng'; capNhatCanhBaoXuat(); document.getElementById('xuat_soluong').scrollIntoView({behavior:'smooth', block:'center'}); }, 150); } // Đề xuất nhập thêm hàng - Hoàng bấm khi kho không đủ để xuất theo bóc tách. Hương/Long xem ở Dashboard/Kho. let deXuatNhapHangList = []; function deXuatNhapThemHang(maVT, soLuongThieu){ const vt = vatTuList.find(v=>v.code===maVT); const d = editingId ? deals.find(x=>x.id===editingId) : null; deXuatNhapHangList.unshift({ id: uid(), maVT, tenVT: vt?vt.ten:maVT, soLuongThieu, dvt: vt?vt.dvt:'', dealCode: d?d.code:'', dealTitle: d?d.title:'', ngayDeXuat: new Date().toISOString().slice(0,10), nguoiDeXuat: 'Hoàng', trangThai: 'moi', }); persistKho(); renderDeXuatNhapHang(); alert(`Đã gửi đề xuất nhập thêm ${soLuongThieu} ${vt?vt.dvt:''} "${vt?vt.ten:maVT}" - Hương/Long xem ở trang Kho.`); } function renderDeXuatNhapHang(){ const box = document.getElementById('deXuatNhapHangBox'); if(!box) return; const dsMoi = deXuatNhapHangList.filter(x=>x.trangThai==='moi'); if(dsMoi.length===0){ box.innerHTML = `
Không có đề xuất nhập hàng nào.
`; return; } box.innerHTML = dsMoi.map(x=>`
${x.tenVT} - thiếu ${x.soLuongThieu} ${x.dvt}
Hoàng đề xuất ${ngayVN(x.ngayDeXuat)}${x.dealCode?' · cho dự án '+x.dealCode:''}
`).join(''); } function danhDauDaXuLyDeXuat(id){ const x = deXuatNhapHangList.find(v=>v.id===id); if(x){ x.trangThai = 'da_xu_ly'; persistKho(); renderDeXuatNhapHang(); } } // ---- Thanh toán ---- // ---- Kế hoạch thanh toán (dự kiến, thoả thuận riêng từng hợp đồng) ---- // Chốt chặn: hợp đồng phải có Số hợp đồng + Ngày ký (2 bên đã ký kết) rồi mới cho theo dõi tạm ứng/thanh toán. // Ẩn/hiện phần "4. Kế hoạch thanh toán" + "5. Công nợ/Thanh toán", đồng thời addPaymentPlanRow()/addPayRow() // tự chặn cứng phía dưới (không chỉ ẩn giao diện) để không có đường vòng nào bấm được khi chưa đủ điều kiện. function capNhatGateThanhToan(){ const soHD = (document.getElementById('c_number')?.value||'').trim(); const ngayKy = document.getElementById('c_signed_date')?.value||''; const daKy = !!(soHD && ngayKy); const canhBao = document.getElementById('thanhToanGateWarning'); const noiDung = document.getElementById('thanhToanGateNoiDung'); if(canhBao) canhBao.style.display = daKy ? 'none' : 'block'; if(noiDung) noiDung.style.display = daKy ? 'block' : 'none'; return daKy; } function addPaymentPlanRow(){ if(!capNhatGateThanhToan()){ alert('Cần điền Số hợp đồng và Ngày ký trước - hợp đồng phải 2 bên ký kết rồi mới bắt đầu theo dõi thanh toán.'); return; } paymentPlanRows.push({ id:uid(), phanTram:0, dieuKien:'', trangThai:'chua_du_dieu_kien', ngayDanhDau:'' }); renderPaymentPlanRows(); } function removePaymentPlanRow(id){ paymentPlanRows = paymentPlanRows.filter(p=>p.id!==id); renderPaymentPlanRows(); } function updatePaymentPlanRow(id, field, value){ const p = paymentPlanRows.find(x=>x.id===id); if(!p) return; p[field] = field==='phanTram' ? (parseFloat(value)||0) : value; renderPaymentPlanRows(); } function danhDauDuDieuKien(id){ const p = paymentPlanRows.find(x=>x.id===id); if(!p) return; p.trangThai = 'du_dieu_kien'; p.ngayDanhDau = new Date().toISOString().slice(0,10); renderPaymentPlanRows(); } function danhDauDaThuDot(id){ const p = paymentPlanRows.find(x=>x.id===id); if(!p) return; p.trangThai = 'da_thu'; renderPaymentPlanRows(); } const PAYMENT_PLAN_STATUS_META = { chua_du_dieu_kien: { label:'Chưa tới điều kiện', color:'#94A3B8' }, du_dieu_kien: { label:'Đủ điều kiện, chờ thu', color:'#B5720A' }, da_thu: { label:'Đã thu', color:'#3A9D6F' }, }; function renderPaymentPlanRows(){ const box = document.getElementById('paymentPlanRows'); if(!box) return; const tongPctToanBo = paymentPlanRows.reduce((s,p)=>s+(p.phanTram||0),0); box.innerHTML = paymentPlanRows.map((p,idx)=>{ const meta = PAYMENT_PLAN_STATUS_META[p.trangThai]; let actionHtml = ''; if(p.trangThai==='chua_du_dieu_kien') actionHtml = ``; else if(p.trangThai==='du_dieu_kien') actionHtml = ``; else actionHtml = `${p.ngayDanhDau?('Đủ điều kiện lúc '+p.ngayDanhDau):''}`; // Tự đánh số "Đợt 1/2/3..." theo đúng thứ tự nhập - dòng cuối cùng được gắn nhãn "Quyết toán" nếu tổng % đã đủ 100. const laDotCuoi = idx===paymentPlanRows.length-1; const nhanDot = (laDotCuoi && tongPctToanBo===100) ? `Đợt ${idx+1} (Quyết toán)` : `Đợt ${idx+1}`; return ` ${nhanDot} ${meta.label}
${actionHtml} `; }).join(''); const el = document.getElementById('paymentPlanSumLabel'); if(el) el.innerHTML = `Tổng các đợt: ${tongPctToanBo}%${tongPctToanBo!==100?' — cần đủ 100% giá trị hợp đồng':' — đủ 100%, đợt cuối là Quyết toán'}`; } function addPayRow(){ if(!capNhatGateThanhToan()){ alert('Cần điền Số hợp đồng và Ngày ký trước - hợp đồng phải 2 bên ký kết rồi mới bắt đầu ghi nhận tạm ứng/thanh toán.'); return; } payRows.push({id:uid(), date:new Date().toISOString().slice(0,10), type:'tam_ung', amount:0, note:''}); renderPayRows(); } function removePayRow(id){ payRows = payRows.filter(p=>p.id!==id); renderPayRows(); } function updatePayRow(id,field,value){ const p=payRows.find(x=>x.id===id); p[field]=field==='amount'?(parseFloat(value)||0):value; renderPayRows(); } function renderPayRows(){ document.getElementById('payRows').innerHTML = payRows.map(p=>` `).join(''); const total = groups.reduce((s,g)=>s+g.items.reduce((s2,it)=>s2+itemTotal(it),0),0); const vatPctDebt = parseFloat(document.getElementById('d_vat')?.value)||0; const tongSauVAT = total + total*vatPctDebt/100; // Công nợ phải tính trên số khách THỰC TRẢ (gồm VAT), không phải giá trước VAT const paid = payRows.reduce((s,p)=>s+(p.amount||0),0); document.getElementById('debtDisplay').textContent = fmt(tongSauVAT - paid); } function toggleRetention(){ const isRetain = document.getElementById('c_warranty_type').value === 'giu_lai_tien'; document.getElementById('retentionField').style.display = isRetain ? 'block' : 'none'; } function onLiquidateToggle(){ if(!document.getElementById('c_is_liquidated').checked) return; if(!document.getElementById('d_accepted').checked){ alert('Chưa ký nghiệm thu (biên bản bàn giao) thì chưa thể thanh lý hợp đồng.'); document.getElementById('c_is_liquidated').checked = false; return; } // v114 (đặc tả mục 12): thứ tự Nghiệm thu → Quyết toán → Thanh lý → Bảo hành const d = deals.find(x=>x.id===editingId); if(d && !d.quyetToan){ if(!confirm('Đơn này CHƯA quyết toán. Theo quy trình phải quyết toán rồi mới thanh lý hợp đồng.\n\nVẫn tick thanh lý?')){ document.getElementById('c_is_liquidated').checked = false; } } } async function saveDeal(keepOpen){ try{ await saveDealThat(keepOpen); }catch(err){ alert('⚠ LƯU KHÔNG THÀNH CÔNG — dữ liệu chưa mất, đang vẫn ở trên màn hình.\n\nLỗi: ' + err.message + '\n\nChụp màn hình lỗi này gửi lại để kiểm tra, đừng đóng cửa sổ vội.'); console.error('[saveDeal] Lỗi khi lưu giao dịch:', err); } } async function saveDealThat(keepOpen){ const title = document.getElementById('d_title').value.trim(); if(!title){ alert('Vui lòng nhập Tên/Mô tả.'); return; } const [customerCode, customerName] = document.getElementById('d_customer').value.split('|'); if(!customerCode || !(typeof customers!=='undefined'?customers:[]).some(c=>c.code===customerCode)){ alert('⚠ Chưa chọn đúng Khách hàng — vui lòng gõ tìm và BẤM CHỌN 1 khách hàng trong danh sách gợi ý trước khi lưu.'); document.getElementById('d_customer_search').focus(); return; } const vat = parseFloat(document.getElementById('d_vat').value)||0; const cooperationFee = parseFloat(document.getElementById('d_cooperation_fee').value)||0; const hanTraHopTac = (document.getElementById('d_han_tra_hop_tac')||{}).value || null; const notes = document.getElementById('d_notes').value; const loaiFromChot = document.getElementById('d_code').dataset.loai; const thatBaiReason = document.getElementById('d_code').dataset.thatbai; let trangThai = 'bao_gia'; let loai = null; if(editingId){ const existing = deals.find(x=>x.id===editingId); loai = existing.loai || loaiFromChot || null; } else if(loaiFromChot){ loai = loaiFromChot; } if(loai) trangThai = 'hoan_tat'; // chốt xong (có loai) = ĐÃ CHỐT/ĐÃ KÝ HĐ - ghi nhận doanh số bán hàng // LƯU Ý (v103): 'hoan_tat' nghĩa là ĐÃ CHỐT ĐƯỢC HỢP ĐỒNG, KHÔNG phải "đơn đã làm xong". // Tiến độ thực hiện nằm ở simpleStatus (cho_sx/dang_sx/dang_lap_dat/hoan_thanh) và status (17 bước). // doanh thu ngay - trước đây phải chờ tới lúc "đã thanh lý" hợp đồng (1 mốc rất muộn, sau khi thi // công xong + hết bảo hành) mới được tính hoàn tất, khiến Báo giá/Doanh thu/BI đếm sai rất nhiều // deal đã chốt thật nhưng vẫn bị tính là "báo giá" (chưa chốt). if(thatBaiReason) trangThai = 'that_bai'; let contract = null, processType, status, simpleStatus, expectedDate, accepted=false, isLiquidated=false; if(loai){ contract = { contractNumber: document.getElementById('c_number').value.trim(), signedDate: document.getElementById('c_signed_date').value, liquidatedDate: document.getElementById('c_liquidated_date').value, isLiquidated: document.getElementById('c_is_liquidated').checked, warrantyMonths: parseInt(document.getElementById('c_warranty_months').value)||12, warrantyType: document.getElementById('c_warranty_type').value, retentionPct: parseFloat(document.getElementById('c_retention_pct').value)||0, }; var hopDongNguyenTacId = document.getElementById('c_hdnt_id').value || null; isLiquidated = contract.isLiquidated; processType = document.getElementById('d_process_type').value; status = document.getElementById('d_status').value; simpleStatus = document.getElementById('d_simple_status').value; expectedDate = document.getElementById('c_expected_date').value; accepted = document.getElementById('d_accepted').checked; } const data = { customerCode, customerName, title, quoteType: currentType, vat, cooperationFee, notes, boPhanBaoGia: document.getElementById('d_bophan').value || null, nguoiPhuTrach: document.getElementById('d_nguoiphutrach').value || null, trangThai, loai, lyDoThatBai: thatBaiReason || null, bom: currentBom, groups: JSON.parse(JSON.stringify(groups)), contract, processType, status, simpleStatus, expectedDate, payments: JSON.parse(JSON.stringify(payRows)), paymentPlan: JSON.parse(JSON.stringify(paymentPlanRows)), bocTachVatTu: JSON.parse(JSON.stringify(bocTachRows)), progressLog: JSON.parse(JSON.stringify(progressRows)), stepHistory: JSON.parse(JSON.stringify(stepRows)), accepted, attachments: JSON.parse(JSON.stringify(dinhKemDealList)), hopDongNguyenTacId: typeof hopDongNguyenTacId!=='undefined' ? hopDongNguyenTacId : (editingId ? (deals.find(x=>x.id===editingId)||{}).hopDongNguyenTacId : null), }; let savedId; if(editingId){ const idx = deals.findIndex(d=>d.id===editingId); deals[idx] = {...deals[idx], ...data}; savedId = editingId; } else { seqBG++; savedId = uid(); deals.push({id: savedId, code:'DPBG.'+String(seqBG).padStart(4,'0'), createdAt:new Date().toISOString().slice(0,10), ...data}); } editingId = savedId; // đảm bảo lần lưu tiếp theo (kể cả lưu tự động lúc chốt) cập nhật đúng deal này, không tạo trùng delete document.getElementById('d_code').dataset.loai; delete document.getElementById('d_code').dataset.thatbai; await persistDeals(savedId); loadDealsForRollup(); if(!keepOpen){ closeEditor(); } else { document.getElementById('d_code').value = deals.find(d=>d.id===savedId).code; } renderList(); } // ---- In / Xuất PDF (theo đúng mẫu Bảng Báo Giá đã duyệt - không có mã QR) ---- /* ═══════════ LỊCH SỬ ĐIỀU CHỈNH BÁO GIÁ (v110) ═══════════ Không sửa đè bản cũ. Mỗi lần điều chỉnh lưu thành MỘT PHIÊN BẢN KHÓA, giữ chi tiết từng hạng mục. Giá chốt là phiên bản cuối, khóa lại và là cơ sở tạo Đơn hàng. */ function _dsPhienBan(d){ if(!Array.isArray(d.phienBanBaoGia)) d.phienBanBaoGia = []; return d.phienBanBaoGia; } function _khoaHangMuc(it){ // khóa nhận diện 1 hạng mục để so sánh giữa 2 phiên bản return [(it.desc||'').trim().toLowerCase(), it.d||'', it.r||'', it.c||'', it.unit||''].join('|'); } function _phangHangMuc(groups){ const ds = []; (groups||[]).forEach(g=>(g.items||[]).forEach(it=>{ if(it.isHeader) return; ds.push({ khoa:_khoaHangMuc(it), nhom:g.name||'', desc:it.desc||'', d:it.d||'', r:it.r||'', c:it.c||'', unit:it.unit||'', qty:Number(it.qty)||0, price:Number(it.price)||0, thanhTien:itemTotal(it) }); })); return ds; } function tongTienGroups(groups){ return (groups||[]).reduce((s,g)=>s+(g.items||[]).reduce((s2,it)=>s2+itemTotal(it),0),0); } /* So sánh 2 phiên bản: thêm / bỏ / tăng giá / giảm giá / đổi số lượng / đổi quy cách */ function soSanhPhienBan(groupsCu, groupsMoi){ const cu = _phangHangMuc(groupsCu), moi = _phangHangMuc(groupsMoi); const mapCu = new Map(cu.map(x=>[x.khoa,x])), mapMoi = new Map(moi.map(x=>[x.khoa,x])); const kq = { them:[], bo:[], doi:[], tongCu:cu.reduce((s,x)=>s+x.thanhTien,0), tongMoi:moi.reduce((s,x)=>s+x.thanhTien,0) }; moi.forEach(m=>{ const c = mapCu.get(m.khoa); if(!c){ kq.them.push(m); return; } if(c.qty!==m.qty || c.price!==m.price){ kq.doi.push({ desc:m.desc, qtyCu:c.qty, qtyMoi:m.qty, giaCu:c.price, giaMoi:m.price, lechTien:m.thanhTien-c.thanhTien }); } }); cu.forEach(c=>{ if(!mapMoi.has(c.khoa)) kq.bo.push(c); }); // đổi quy cách: cùng tên hạng mục nhưng kích thước/ĐVT khác -> hiện ở cả thêm và bỏ, gom lại cho dễ đọc kq.doiQuyCach = []; kq.them.slice().forEach(t=>{ const g = kq.bo.find(b=>b.desc.trim().toLowerCase()===t.desc.trim().toLowerCase()); if(g){ kq.doiQuyCach.push({ desc:t.desc, cu:`${g.d||'-'}x${g.r||'-'}x${g.c||'-'} ${g.unit}`, moi:`${t.d||'-'}x${t.r||'-'}x${t.c||'-'} ${t.unit}`, lechTien:t.thanhTien-g.thanhTien }); kq.them = kq.them.filter(x=>x!==t); kq.bo = kq.bo.filter(x=>x!==g); } }); kq.chenhLech = kq.tongMoi - kq.tongCu; return kq; } /* Lưu phiên bản hiện đang mở trên form thành 1 bản KHÓA */ async function luuPhienBanBaoGia(laGiaChot){ if(!editingId){ alert('Cần lưu báo giá trước khi tạo phiên bản.'); return; } const d = deals.find(x=>x.id===editingId); if(!d){ alert('Không tìm thấy báo giá.'); return; } const ds = _dsPhienBan(d); const banCuoi = ds.length ? ds[ds.length-1] : null; if(banCuoi && banCuoi.laGiaChot){ alert('⛔ Báo giá này đã CHỐT GIÁ và bị khóa. Muốn đổi phải mở khóa giá chốt trước.'); return; } const groupsHienTai = JSON.parse(JSON.stringify(groups)); const tong = tongTienGroups(groupsHienTai); const lyDo = laGiaChot ? (prompt('Ghi chú khi chốt giá (có thể để trống):','')||'') : (prompt(`Lý do điều chỉnh lần ${ds.length}:`,'')||''); if(lyDo===null) return; ds.push({ ver: ds.length===0 ? 0 : ds.length, // 0 = giá ban đầu, 1,2,3... = lần điều chỉnh nhan: ds.length===0 ? 'Giá ban đầu' : (laGiaChot ? 'GIÁ CHỐT' : 'Điều chỉnh lần '+ds.length), ngay: new Date().toISOString().slice(0,10), nguoi: nguoiDungHienTai(), lyDo, vat: parseFloat(document.getElementById('d_vat').value)||0, tongTien: tong, groups: groupsHienTai, laGiaChot: !!laGiaChot, khoa: true }); await saveDeal(true); renderLichSuBaoGia(); ghiNhatKy('Giao dịch', laGiaChot?'Chốt giá báo giá':'Lưu phiên bản báo giá', `${d.code} — ${ds[ds.length-1].nhan} — ${fmt(tong)}`); } async function moKhoaGiaChot(){ const d = deals.find(x=>x.id===editingId); if(!d) return; if(vaiTroHienTai()!=='giam_doc'){ alert('⛔ Chỉ Giám đốc được mở khóa giá chốt.'); return; } const ds = _dsPhienBan(d); const ban = ds.length ? ds[ds.length-1] : null; if(!ban || !ban.laGiaChot){ alert('Chưa có giá chốt nào để mở khóa.'); return; } if(!confirm('Mở khóa GIÁ CHỐT để điều chỉnh tiếp? Phiên bản giá chốt cũ vẫn được giữ nguyên trong lịch sử.')) return; ban.laGiaChot = false; ban.nhan = 'Điều chỉnh lần '+ban.ver+' (đã mở khóa giá chốt)'; await saveDeal(true); renderLichSuBaoGia(); ghiNhatKy('Giao dịch','Mở khóa giá chốt', d.code); } function xemPhienBanBaoGia(dealId, ver){ const d = deals.find(x=>x.id===dealId); if(!d) return; const ds = _dsPhienBan(d); const ban = ds.find(x=>x.ver===ver); if(!ban) return; const truoc = ds.filter(x=>x.ver{ t += `${i+1}. ${x.desc}${(x.d||x.r||x.c)?` [${x.d||'-'}x${x.r||'-'}x${x.c||'-'}]`:''} — ${x.qty} ${x.unit} × ${fmt(x.price)} = ${fmt(x.thanhTien)}\n`; }); if(truoc){ const ss = soSanhPhienBan(truoc.groups, ban.groups); t += `\nSO VỚI ${truoc.nhan}: ${ss.chenhLech>0?'TĂNG ':(ss.chenhLech<0?'GIẢM ':'không đổi ')}${fmt(Math.abs(ss.chenhLech))}\n`; ss.them.forEach(x=>t += ` + Thêm hạng mục: ${x.desc} (${fmt(x.thanhTien)})\n`); ss.bo.forEach(x=>t += ` − Bỏ hạng mục: ${x.desc} (${fmt(x.thanhTien)})\n`); ss.doiQuyCach.forEach(x=>t += ` ~ Đổi quy cách: ${x.desc}: ${x.cu} → ${x.moi} (${x.lechTien>=0?'+':''}${fmt(x.lechTien)})\n`); ss.doi.forEach(x=>{ const phan = []; if(x.qtyCu!==x.qtyMoi) phan.push(`số lượng ${x.qtyCu} → ${x.qtyMoi}`); if(x.giaCu!==x.giaMoi) phan.push(`đơn giá ${fmt(x.giaCu)} → ${fmt(x.giaMoi)}`); t += ` ${x.lechTien>0?'▲ Tăng':'▼ Giảm'}: ${x.desc} — ${phan.join(', ')} (${x.lechTien>=0?'+':''}${fmt(x.lechTien)})\n`; }); } alert(t); } function renderLichSuBaoGia(){ const box = document.getElementById('gd_lichSuBaoGia'); if(!box) return; const d = deals.find(x=>x.id===editingId); if(!d){ box.innerHTML=''; return; } const ds = _dsPhienBan(d); const daChot = ds.length && ds[ds.length-1].laGiaChot; const nut = `
${daChot?``:''} ${daChot && !d.loai ? `` : ''}
`; if(ds.length===0){ box.innerHTML = nut + `
Chưa lưu phiên bản nào. Bấm "Lưu giá ban đầu" để khóa bản gốc trước khi đàm phán.
`; return; } box.innerHTML = nut + ` ${ds.map((b,i)=>{ const truoc = i>0 ? ds[i-1] : null; const lech = truoc ? b.tongTien - truoc.tongTien : 0; return ``; }).join('')}
Phiên bản Ngày Tổng giá trị Chênh lệch Lý do
${b.nhan} 🔒 ${b.ngay}
${b.nguoi}
${fmt(b.tongTien)} ${truoc?((lech>0?'+':'')+fmt(lech)):'—'} ${b.lyDo||'—'}
`; } /* ═══════════ HỢP ĐỒNG — PHỤ LỤC — PHÁT SINH TĂNG/GIẢM (v111) ═══════════ Nguyên tắc: KHÔNG sửa đè giá trị gốc. Mọi thay đổi ghi thành bản ghi phát sinh riêng. Giá trị hiện tại = Giá trị ban đầu + Phát sinh tăng − Phát sinh giảm. */ /* Ngưỡng BẮT BUỘC có hợp đồng — v115: không còn CHỈ dựa vào 1 mốc tiền cố định. Quy tắc (Long chốt 16/08): (1) Khách hàng MỚI (chưa từng có đơn nào khác trước đây) → LUÔN bắt buộc, bất kể giá trị. (2) Khách quen thì mới xét ngưỡng tiền NGUONG_BAT_BUOC_HOP_DONG (mặc định 20tr). (3) Sale/Long luôn có thể ghi đè thủ công từng đơn (bật hoặc tắt) qua d.hopDongThuCong ('bat'|'tat'|null), ghi đè có độ ưu tiên cao nhất vì có thể có tình huống đặc biệt hai quy tắc trên không lường hết. */ function khachHangMoiChuaTungGiaoDich(d){ if(!d || !d.customerCode) return false; return !(typeof deals!=='undefined'?deals:[]).some(x => x.id!==d.id && x.customerCode===d.customerCode); } function batBuocCoHopDong(d){ if(!d) return false; if(d.hopDongThuCong==='bat') return true; if(d.hopDongThuCong==='tat') return false; return khachHangMoiChuaTungGiaoDich(d) || giaTriBanDau(d) >= NGUONG_BAT_BUOC_HOP_DONG; } function thieuHopDongBatBuoc(d){ return d.loai && batBuocCoHopDong(d) && !coHopDong(d); } async function ghiDeHopDongThuCong(dealId, giaTri){ const d = deals.find(x=>x.id===dealId); if(!d) return; d.hopDongThuCong = giaTri; // 'bat' | 'tat' | null await persistDeals(dealId); ghiNhatKy('Hợp đồng', 'Ghi đè bắt buộc hợp đồng', d.code, giaTri==='bat'?'Ghi đè: LUÔN bắt buộc':(giaTri==='tat'?'Ghi đè: KHÔNG bắt buộc':'Bỏ ghi đè, quay về tự động')); renderPhatSinhPhuLuc(dealId); } function _dsPhatSinh(d){ if(!Array.isArray(d.phatSinh)) d.phatSinh = []; return d.phatSinh; } function _dsPhuLuc(d){ if(!Array.isArray(d.phuLuc)) d.phuLuc = []; return d.phuLuc; } /* Giá trị BAN ĐẦU: ưu tiên phiên bản GIÁ CHỐT đã khóa, chưa chốt thì lấy tổng bảng hạng mục hiện tại */ function giaTriBanDau(d){ const ds = Array.isArray(d.phienBanBaoGia) ? d.phienBanBaoGia : []; const chot = ds.filter(b=>b.laGiaChot).pop(); if(chot) return chot.tongTien; return dealTotal(d); } function tongPhatSinhTang(d){ return _dsPhatSinh(d).filter(x=>x.loai==='tang' && x.trangThai!=='tu_choi' && x.xacNhanKhach).reduce((s,x)=>s+(x.giaTri||0),0); } function tongPhatSinhGiam(d){ return _dsPhatSinh(d).filter(x=>x.loai==='giam' && x.trangThai!=='tu_choi' && x.xacNhanKhach).reduce((s,x)=>s+(x.giaTri||0),0); } function giaTriHienTai(d){ return giaTriBanDau(d) + tongPhatSinhTang(d) - tongPhatSinhGiam(d); } function phatSinhChoXacNhan(d){ return _dsPhatSinh(d).filter(x=>!x.xacNhanKhach && x.trangThai!=='tu_choi'); } function phatSinhDaXacNhanChuaVaoPhuLuc(d){ return _dsPhatSinh(d).filter(x=>x.xacNhanKhach && !x.phuLucSo && x.trangThai!=='tu_choi'); } /* ---- Ghi nhận phát sinh ---- */ async function themPhatSinh(dealId){ const d = deals.find(x=>x.id===dealId); if(!d) return; const hangMuc = prompt('Hạng mục phát sinh:', ''); if(!hangMuc) return; const loai = confirm('Đây là phát sinh TĂNG?\n\nOK = Tăng Cancel = Giảm') ? 'tang' : 'giam'; const soLuong = parseFloat(prompt('Số lượng:', '1'))||0; const donGia = parseFloat(prompt('Đơn giá (đ):', '0'))||0; const giaTri = Math.round(soLuong*donGia); if(giaTri<=0){ alert('Giá trị phát sinh phải lớn hơn 0.'); return; } const lyDo = prompt('Lý do phát sinh:', '')||''; const taiLieu = prompt('Đường dẫn tài liệu liên quan (Drive), để trống nếu chưa có:', '')||''; _dsPhatSinh(d).push({ id:'PS'+Date.now(), ngay:new Date().toISOString().slice(0,10), hangMuc, soLuong, donGia, giaTri, loai, lyDo, nguoiDeXuat: nguoiDungHienTai(), taiLieu, xacNhanKhach: null, phuLucSo: null, trangThai: 'de_xuat' }); await persistDeals(); renderPhatSinhPhuLuc(dealId); ghiNhatKy('Giao dịch', 'Ghi nhận phát sinh '+(loai==='tang'?'TĂNG':'GIẢM'), `${d.code} — ${hangMuc} — ${fmt(giaTri)}`); } async function xacNhanPhatSinh(dealId, psId){ const d = deals.find(x=>x.id===dealId); if(!d) return; const ps = _dsPhatSinh(d).find(x=>x.id===psId); if(!ps) return; const nguoi = prompt('Người phía khách hàng xác nhận:', ''); if(nguoi===null) return; const taiLieu = prompt('Tài liệu xác nhận của khách (biên bản/email/Drive):', ps.taiLieu||'')||''; ps.xacNhanKhach = { ngay:new Date().toISOString().slice(0,10), nguoi, taiLieu }; ps.trangThai = 'da_xac_nhan'; await persistDeals(); renderPhatSinhPhuLuc(dealId); if(typeof renderCongNo==='function') renderCongNo(); const luong = coHopDong(d) ? 'Bước tiếp: lập Phụ lục Hợp đồng.' : 'Đơn không có hợp đồng — giá trị Đơn hàng đã được cập nhật ngay.'; alert(`✓ Khách đã xác nhận phát sinh.\n${luong}\n\nGiá trị hiện tại: ${fmt(giaTriHienTai(d))}`); ghiNhatKy('Giao dịch', 'Khách xác nhận phát sinh', `${d.code} — ${ps.hangMuc} — ${fmt(ps.giaTri)}`); } async function tuChoiPhatSinh(dealId, psId){ const d = deals.find(x=>x.id===dealId); if(!d) return; const ps = _dsPhatSinh(d).find(x=>x.id===psId); if(!ps) return; if(!confirm('Đánh dấu phát sinh này là KHÔNG được duyệt? Bản ghi vẫn giữ trong lịch sử.')) return; ps.trangThai = 'tu_choi'; await persistDeals(); renderPhatSinhPhuLuc(dealId); } /* ---- Lập phụ lục hợp đồng từ các phát sinh đã được khách xác nhận ---- */ async function lapPhuLuc(dealId){ const d = deals.find(x=>x.id===dealId); if(!d) return; if(!coHopDong(d)){ alert('Đơn này KHÔNG có hợp đồng nên không lập phụ lục. Phát sinh đã xác nhận sẽ cập nhật thẳng vào Đơn hàng.'); return; } const ds = phatSinhDaXacNhanChuaVaoPhuLuc(d); if(!ds.length){ alert('Chưa có phát sinh nào đã được khách xác nhận để đưa vào phụ lục.'); return; } const pl = _dsPhuLuc(d); const so = String(pl.length+1).padStart(2,'0'); const tang = ds.filter(x=>x.loai==='tang').reduce((s,x)=>s+x.giaTri,0); const giam = ds.filter(x=>x.loai==='giam').reduce((s,x)=>s+x.giaTri,0); const truoc = pl.length ? pl[pl.length-1].giaTriSau : giaTriBanDau(d); const sau = truoc + tang - giam; if(!confirm(`Lập PHỤ LỤC ${so} cho hợp đồng ${d.contract.number||d.code}\n\n${ds.length} phát sinh đã xác nhận\nTăng: ${fmt(tang)}\nGiảm: ${fmt(giam)}\n\nGiá trị trước: ${fmt(truoc)}\nGiá trị sau: ${fmt(sau)}\n\nHợp đồng gốc GIỮ NGUYÊN, không sửa đè.`)) return; const ngayKy = prompt('Ngày ký phụ lục (2026-08-20):', new Date().toISOString().slice(0,10)); if(!ngayKy) return; pl.push({ so, ngayKy, ngayLap:new Date().toISOString().slice(0,10), nguoiLap:nguoiDungHienTai(), phatSinhIds: ds.map(x=>x.id), giaTriTang:tang, giaTriGiam:giam, giaTriTruoc:truoc, giaTriSau:sau, khoa:true }); ds.forEach(x=>{ x.phuLucSo = so; x.trangThai = 'da_vao_phu_luc'; }); await persistDeals(); renderPhatSinhPhuLuc(dealId); if(typeof renderDanhSachHopDong==='function') renderDanhSachHopDong(); ghiNhatKy('Hợp đồng', 'Lập Phụ lục '+so, `${d.code} — giá trị ${fmt(truoc)} → ${fmt(sau)}`); } function renderPhatSinhPhuLuc(dealId){ const box = document.getElementById('gd_phatSinhPhuLuc'); if(!box) return; const d = deals.find(x=>x.id===(dealId||editingId)); if(!d){ box.innerHTML=''; return; } const ps = _dsPhatSinh(d), pl = _dsPhuLuc(d); const goc = giaTriBanDau(d), tang = tongPhatSinhTang(d), giam = tongPhatSinhGiam(d), nay = giaTriHienTai(d); const lyDoBatBuocHD = d.hopDongThuCong==='bat' ? 'đã ghi đè thủ công BẮT BUỘC' : khachHangMoiChuaTungGiaoDich(d) ? 'khách hàng MỚI (chưa từng có đơn nào khác trong hệ thống)' : `giá trị ${fmt(goc)} vượt ngưỡng ${fmt(NGUONG_BAT_BUOC_HOP_DONG)}`; const canhBaoHD = thieuHopDongBatBuoc(d) ? `
⚠ ${lyDoBatBuocHD} — theo nghiệp vụ công ty đơn này BẮT BUỘC phải có hợp đồng
` : (d.hopDongThuCong==='bat' ? '' : `
`); box.innerHTML = canhBaoHD + `
Giá trị ban đầu: ${fmt(goc)}
Phát sinh tăng: +${fmt(tang)}
Phát sinh giảm: −${fmt(giam)}
Giá trị hiện tại: ${fmt(nay)}
${coHopDong(d) ? `` : `Đơn KHÔNG có hợp đồng — phát sinh xác nhận xong cập nhật thẳng Đơn hàng`} ${d.loai ? ( !d.quyetToan ? `` : (d.contract && d.contract.isLiquidated ? `✓ Đã quyết toán ${fmt(d.quyetToan.giaTriQuyetToan||0)} ngày ${d.quyetToan.ngay} · đã thanh lý ngày ${d.contract.liquidatedDate}` : `✓ Đã quyết toán ${fmt(d.quyetToan.giaTriQuyetToan||0)} ngày ${d.quyetToan.ngay} `) ) : ''}
${ps.length===0 ? '
Chưa có phát sinh nào.
' : ` ${ps.map(x=>``).join('')}
Ngày Hạng mục SL Đơn giá Giá trị Tăng/Giảm Lý do / Người đề xuất Xác nhận của khách Phụ lục
${x.ngay} ${x.hangMuc}${x.taiLieu?` [tài liệu]`:''} ${x.soLuong} ${fmt(x.donGia)} ${x.loai==='tang'?'+':'−'}${fmt(x.giaTri)} ${x.loai==='tang'?'Tăng':'Giảm'} ${x.lyDo||'—'}
${x.nguoiDeXuat}
${x.xacNhanKhach ? `✓ ${x.xacNhanKhach.ngay}
${x.xacNhanKhach.nguoi||''}
` : (x.trangThai==='tu_choi'?'Không duyệt':'Chờ khách xác nhận')}
${x.phuLucSo?('PL '+x.phuLucSo):'—'} ${!x.xacNhanKhach && x.trangThai!=='tu_choi' ? ` ` : ''}
`} ${pl.length===0 ? '' : `
Phụ lục hợp đồng — hợp đồng gốc giữ nguyên
${pl.map(x=>``).join('')}
Phụ lục Ngày ký Tăng Giảm Giá trị trước Giá trị sau
Hợp đồng gốc 🔒 ${(d.contract&&d.contract.signedDate)||'—'} ${fmt(goc)}
Phụ lục ${x.so} 🔒 ${x.ngayKy}
${x.nguoiLap}
${x.giaTriTang?'+'+fmt(x.giaTriTang):'—'} ${x.giaTriGiam?'−'+fmt(x.giaTriGiam):'—'} ${fmt(x.giaTriTruoc)} ${fmt(x.giaTriSau)}
`}`; } /* ═══════════ QUYẾT TOÁN + TẠO ĐƠN HÀNG TỪ GIÁ CHỐT (v112) ═══════════ Quyết toán KHÔNG phải là đợt thanh toán cuối cùng. Công thức: Giá trị HĐ gốc + Phụ lục tăng − Phụ lục giảm = Giá trị quyết toán cuối cùng. */ function tongPhuLucTang(d){ return _dsPhuLuc(d).reduce((s,x)=>s+(x.giaTriTang||0),0); } function tongPhuLucGiam(d){ return _dsPhuLuc(d).reduce((s,x)=>s+(x.giaTriGiam||0),0); } function giaTriQuyetToan(d){ // Có hợp đồng: chỉ tính phát sinh ĐÃ VÀO PHỤ LỤC (đã ký). Không hợp đồng: tính phát sinh đã được khách xác nhận. return coHopDong(d) ? giaTriBanDau(d) + tongPhuLucTang(d) - tongPhuLucGiam(d) : giaTriHienTai(d); } function daThuCuaDeal(d){ return (d.payments||[]).reduce((s,p)=>s+(p.amount||0),0); } function phatSinhChuaVaoPhuLuc(d){ return phatSinhDaXacNhanChuaVaoPhuLuc(d); } async function moQuyetToan(dealId){ const d = deals.find(x=>x.id===dealId); if(!d) return; if(d.quyetToan){ alert('Đơn này đã quyết toán ngày '+d.quyetToan.ngay+' rồi.'); return; } const con = phatSinhChuaVaoPhuLuc(d); if(coHopDong(d) && con.length){ alert(`⛔ Còn ${con.length} phát sinh khách đã xác nhận nhưng CHƯA đưa vào phụ lục.\nPhải lập phụ lục trước rồi mới quyết toán, nếu không giá trị quyết toán sẽ thiếu.`); return; } if(typeof coNghiemThuToanBoDaKy==='function' && !coNghiemThuToanBoDaKy(d)){ if(!confirm('⚠ Đơn này CHƯA có Biên bản nghiệm thu bàn giao toàn bộ đã ký.\n\nTheo quy trình phải nghiệm thu bàn giao rồi mới thanh lý & quyết toán.\nVẫn tiếp tục quyết toán?')) return; } const goc = giaTriBanDau(d), tang = coHopDong(d)?tongPhuLucTang(d):tongPhatSinhTang(d), giam = coHopDong(d)?tongPhuLucGiam(d):tongPhatSinhGiam(d); const qt = goc + tang - giam; const vatPct = d.vat||0, qtVAT = qt + qt*vatPct/100; const daThu = daThuCuaDeal(d), conThu = qtVAT - daThu; const giuLai = (d.contract && d.contract.retentionPct) ? Math.round(qtVAT*d.contract.retentionPct/100) : 0; const bang = `QUYẾT TOÁN ${d.code} — ${d.customerName}\n` + `${coHopDong(d)?('Hợp đồng: '+((d.contract&&d.contract.number)||'(chưa có số)')):'Đơn hàng KHÔNG có hợp đồng'}\n\n` + `Giá trị ${coHopDong(d)?'hợp đồng gốc':'ban đầu'}: ${fmt(goc)}\n` + `+ ${coHopDong(d)?'Phụ lục tăng':'Phát sinh tăng'} (${coHopDong(d)?_dsPhuLuc(d).length+' phụ lục':''}): ${fmt(tang)}\n` + `− ${coHopDong(d)?'Phụ lục giảm':'Phát sinh giảm'}: ${fmt(giam)}\n` + `────────────────────────────\n` + `= GIÁ TRỊ QUYẾT TOÁN: ${fmt(qt)}\n` + (vatPct?` Thuế ${vatPct}%: ${fmt(qt*vatPct/100)}\n Tổng khách phải trả: ${fmt(qtVAT)}\n`:'') + `\nĐã thu: ${fmt(daThu)}\n` + `Còn phải thu: ${fmt(conThu)}\n` + (giuLai?`Trong đó giữ lại bảo hành ${d.contract.retentionPct}%: ${fmt(giuLai)}\n`:'') + `\nXác nhận THANH LÝ và chốt quyết toán?`; if(!confirm(bang)) return; const ngay = prompt('Ngày quyết toán (2026-08-20):', new Date().toISOString().slice(0,10)); if(!ngay) return; d.quyetToan = { ngay, giaTriGoc:goc, phuLucTang:tang, phuLucGiam:giam, giaTriQuyetToan:qt, vat:vatPct, tongPhaiTra:qtVAT, daThu, conPhaiThu:conThu, giuLaiBaoHanh:giuLai, nguoi:nguoiDungHienTai() }; await persistDeals(); if(typeof renderDanhSachHopDong==='function') renderDanhSachHopDong(); if(typeof renderCongNo==='function') renderCongNo(); renderPhatSinhPhuLuc(d.id); ghiNhatKy('Hợp đồng', 'Quyết toán', `${d.code} — quyết toán ${fmt(qt)} — còn phải thu ${fmt(conThu)}`); alert(`✓ Đã chốt quyết toán ${fmt(qt)}.\nBước tiếp theo: Thanh lý hợp đồng (bảo hành tính từ ngày thanh lý).`); } /* THANH LÝ HỢP ĐỒNG — bước RIÊNG sau Quyết toán (đặc tả: Nghiệm thu → Quyết toán → Thanh lý → Bảo hành) */ async function thanhLyHopDong(dealId){ const d = deals.find(x=>x.id===dealId); if(!d) return; if(d.contract && d.contract.isLiquidated){ alert('Hợp đồng này đã thanh lý rồi.'); return; } if(!d.quyetToan){ alert('⛔ Phải QUYẾT TOÁN trước rồi mới thanh lý hợp đồng.'); return; } if(typeof coNghiemThuToanBoDaKy==='function' && !coNghiemThuToanBoDaKy(d)){ if(!confirm('⚠ Đơn này chưa có Biên bản nghiệm thu bàn giao toàn bộ đã ký.\nVẫn thanh lý hợp đồng?')) return; } const conThu = dealDebt(d); if(!confirm(`THANH LÝ ${d.code}\n\nGiá trị quyết toán: ${fmt(d.quyetToan.giaTriQuyetToan||0)} (ngày ${d.quyetToan.ngay})\nCòn phải thu: ${fmt(conThu)}\n\nThanh lý xong bảo hành bắt đầu tính từ ngày thanh lý. Xác nhận?`)) return; const ngay = prompt('Ngày thanh lý hợp đồng (2026-08-20):', new Date().toISOString().slice(0,10)); if(!ngay) return; if(!d.contract) d.contract = {}; d.contract.isLiquidated = true; d.contract.liquidatedDate = ngay; await persistDeals(d.id); if(typeof renderDanhSachHopDong==='function') renderDanhSachHopDong(); if(typeof renderCongNo==='function') renderCongNo(); renderPhatSinhPhuLuc(d.id); ghiNhatKy('Hợp đồng', 'Thanh lý hợp đồng', `${d.code} — thanh lý ngày ${ngay} — còn phải thu ${fmt(conThu)}`); alert(`✓ Đã thanh lý hợp đồng ngày ${ngay}.\nBảo hành bắt đầu tính từ ngày này.`); } /* Tạo Đơn hàng TỪ GIÁ CHỐT — không nhập lại dữ liệu */ async function taoDonHangTuGiaChot(dealId){ const d = deals.find(x=>x.id===(dealId||editingId)); if(!d) return; const ds = Array.isArray(d.phienBanBaoGia) ? d.phienBanBaoGia : []; const chot = ds.filter(b=>b.laGiaChot).pop(); if(!chot){ alert('⛔ Chưa CHỐT GIÁ. Phải bấm "Chốt giá" ở khối Lịch sử điều chỉnh giá trước, vì giá chốt là cơ sở tạo Đơn hàng.'); return; } if(d.loai){ alert('Báo giá này đã chuyển thành '+(LOAI_LABEL[d.loai]||d.loai)+' rồi.'); return; } const loai = prompt('Chuyển thành loại nào?\n 1 = Đơn hàng (bán thành phẩm)\n 2 = Công trình / May đo\n 3 = Dự án', '1'); const map = {'1':'don_hang','2':'cong_trinh','3':'du_an'}; if(!map[loai]) return; const canHD = batBuocCoHopDong(d); // dùng chung 1 hàm duy nhất (ngưỡng tiền HOẶC khách mới HOẶC ghi đè thủ công) const lyDoBatBuoc = d.hopDongThuCong==='bat' ? 'đã ghi đè thủ công BẮT BUỘC' : khachHangMoiChuaTungGiaoDich(d) ? 'khách hàng MỚI (chưa từng có đơn nào khác)' : `vượt ngưỡng ${fmt(NGUONG_BAT_BUOC_HOP_DONG)}`; const coHD = confirm(`Đơn này CÓ hợp đồng không?\n\nGiá chốt: ${fmt(chot.tongTien)}` + (canHD?`\n⚠ ${lyDoBatBuoc} — theo nghiệp vụ công ty BẮT BUỘC phải có hợp đồng.`:'') + `\n\nOK = Có hợp đồng Cancel = Không hợp đồng`); if(!coHD && canHD){ if(!confirm('Đơn vượt ngưỡng bắt buộc có hợp đồng. Vẫn tạo đơn KHÔNG hợp đồng?')) return; } d.loai = map[loai]; d.trangThai = 'hoan_tat'; if(coHD){ if(!d.contract) d.contract = {}; d.contract.number = prompt('Số hợp đồng:', d.contract.number||'') || d.contract.number || ''; d.contract.signedDate = prompt('Ngày ký hợp đồng (2026-08-20):', d.contract.signedDate||new Date().toISOString().slice(0,10)) || ''; } await saveDeal(true); renderLichSuBaoGia(); renderPhatSinhPhuLuc(d.id); ghiNhatKy('Đơn hàng', 'Tạo Đơn hàng từ giá chốt', `${d.code} — ${LOAI_LABEL[d.loai]} — ${fmt(chot.tongTien)}${coHD?' — có hợp đồng':' — không hợp đồng'}`); alert(`✓ Đã tạo ${LOAI_LABEL[d.loai]} từ giá chốt ${fmt(chot.tongTien)}.\nToàn bộ hạng mục lấy nguyên từ giá chốt, không nhập lại.`); } /* ═══════════ BIÊN BẢN NGHIỆM THU (v114) — mục 6 đặc tả Đơn hàng/Hợp đồng ═══════════ Nguyên tắc: chỉ dựng TRƯỜNG cho người dùng tự điền, hệ thống không tự đặt thêm nội dung. Người ký phía Đức Phong mặc định là Hoàng (sửa được). Ký xong biên bản KHÓA, không sửa đè. */ let ntDealId = null, ntBienBanId = null, ntRows = []; function _dsNghiemThu(d){ if(!Array.isArray(d.nghiemThu)) d.nghiemThu = []; return d.nghiemThu; } function coNghiemThuToanBoDaKy(d){ return _dsNghiemThu(d).some(b=>b.loai==='toan_bo' && b.khoa); } function soBienBanTiepTheo(d){ return 'BBNT ' + String(_dsNghiemThu(d).length + 1).padStart(2,'0'); } /* ═══════════════════════════════════════════════════════════════════════════════════════════ KHỐI HIỆU QUẢ ĐƠN HÀNG/DỰ ÁN (v118, bàn giao 16/08/2026) — 8 dòng: Doanh thu ghi nhận − Vật liệu − Nhân công Đức Phong − Nhân công thuê khoán − Thuê khoán ngoài − Vận chuyển thuê ngoài − Chi phí trực tiếp khác = Tổng chi phí trực tiếp = Lợi nhuận trực tiếp. NGUYÊN TẮC CHỐNG TRÙNG (bắt buộc theo bàn giao): 1) Chi phí hợp tác (d.cooperationFee) đã bị trừ ngay ở "Doanh thu ghi nhận" (đúng công thức đang dùng ở P&L/BI hiện tại: giaTriGhiNhan(d)-cooperationFee) → các dòng chi phí bên dưới KHÔNG được cộng lại khoản này lần nữa (loại trừ hẳn phiếu chi loaiChi==='hop_tac' khỏi mọi dòng). 2) Lương nhân viên Đức Phong CHỈ tính qua attendance_logs (dòng "Nhân công Đức Phong"), KHÔNG lấy qua phiếu chi loaiChi==='luong' nữa dù có gắn mã đơn → tránh tính lương 2 lần. 3) Vật liệu lấy qua phiếu xuất kho (nhapXuatList, đã có chiPhiNVL tính theo giá vốn), KHÔNG lấy qua phiếu chi loaiChi==='ncc' (đó là công nợ trả NCC, không phải giá trị NVL thực xuất dùng). Vì 2 dòng "ncc" và "luong" đều bị loại khỏi "Chi phí trực tiếp khác", dòng đó chỉ còn nhận loaiChi thuộc {van_hanh_xuong, khac} có gắn đúng dealCode - đúng tinh thần "chỉ lấy khoản gắn đúng đơn, không quét cả tháng". */ const HQ_LOAI_CHI_DA_TINH_RIENG = ['hop_tac','ncc','luong','thau_phu','thue_khoan_ngoai','van_tai']; function hqPhieuChiCuaDon(d, loaiChi){ return (typeof phieuThuChi!=='undefined'?phieuThuChi:[]) .filter(p=>p.loai==='chi' && p.dealCode===d.code && p.trangThai!=='huy' && (loaiChi ? p.loaiChi===loaiChi : true)); } // ---- Dòng "Vật liệu": từ phiếu xuất kho gắn WO của đúng deal (workOrders.transactionId === d.code) ---- function hqVatLieu(d){ const woCuaDeal = (typeof workOrders!=='undefined'?workOrders:[]).filter(w=>w.transactionId===d.code).map(w=>w.code); if(woCuaDeal.length===0) return { tien:0, coDuLieu:false }; const phieuXuat = (typeof nhapXuatList!=='undefined'?nhapXuatList:[]).filter(n=>n.loai==='xuat' && woCuaDeal.includes(n.lienQuan)); if(phieuXuat.length===0) return { tien:0, coDuLieu:false }; return { tien: phieuXuat.reduce((s,n)=>s+(n.chiPhiNVL||0),0), coDuLieu:true }; } // ---- Dòng "Vận chuyển thuê ngoài": từ chuyenDiList (module Vận tải), lienQuan === d.code ---- function hqVanChuyen(d){ const cds = (typeof chuyenDiList!=='undefined'?chuyenDiList:[]).filter(c=>c.lienQuan===d.code); if(cds.length===0) return { tien:0, coDuLieu:false }; return { tien: cds.reduce((s,c)=>s+(c.chiPhi||0),0), coDuLieu:true }; } // ---- Dòng "Nhân công thuê khoán": phiếu chi loaiChi==='thau_phu' gắn đúng dealCode ---- function hqNhanCongThueKhoan(d){ const ps = hqPhieuChiCuaDon(d,'thau_phu'); if(ps.length===0) return { tien:0, coDuLieu:false }; return { tien: ps.reduce((s,p)=>s+(p.soTien||0),0), coDuLieu:true }; } // ---- Dòng "Thuê khoán ngoài": phiếu chi loaiChi==='thue_khoan_ngoai' gắn đúng dealCode ---- function hqThueKhoanNgoai(d){ const ps = hqPhieuChiCuaDon(d,'thue_khoan_ngoai'); if(ps.length===0) return { tien:0, coDuLieu:false, chiTiet:[] }; return { tien: ps.reduce((s,p)=>s+(p.soTien||0),0), coDuLieu:true, chiTiet: ps }; } // ---- Dòng "Chi phí trực tiếp khác": phiếu chi gắn đúng dealCode, KHÔNG thuộc các loại đã tính riêng ---- function hqChiPhiKhac(d){ const ps = hqPhieuChiCuaDon(d).filter(p=>!HQ_LOAI_CHI_DA_TINH_RIENG.includes(p.loaiChi)); if(ps.length===0) return { tien:0, coDuLieu:false }; return { tien: ps.reduce((s,p)=>s+(p.soTien||0),0), coDuLieu:true }; } // ---- Dòng "Nhân công Đức Phong": ghép cặp vào/ra ca theo (user_id, order_id) từ attendance_logs. // Async vì cần truy vấn Supabase trực tiếp (bảng thuộc App di động, KHÔNG có sẵn trong bộ nhớ ERP). // Giữ 3 quy tắc đã chốt: OT 150% (lấy ot_pct trong salary_config, không hardcode nếu có cấu hình // riêng), chuyên cần từ 23 công/tháng mới hưởng (tính theo TOÀN BỘ đơn trong tháng của người đó, // phân bổ theo tỷ lệ công làm ở đơn này), đi công trình +phu_cap_luu_dong_gio/giờ (chỉ nếu NV có // bật co_phu_cap_luu_dong trong cấu hình riêng). KHÔNG chia bình quân lương tháng cho các đơn. */ async function hqNhanCongDucPhong(d){ if(!supabaseClient) return { tien:0, coDuLieu:false, ghiChu:'Chưa kết nối Supabase' }; const { data: logsOfDeal, error: err1 } = await supabaseClient.from('attendance_logs').select('*').eq('order_id', d.code); if(err1){ return { tien:0, coDuLieu:false, ghiChu:'Lỗi truy vấn attendance_logs: '+err1.message }; } if(!logsOfDeal || logsOfDeal.length===0) return { tien:0, coDuLieu:false, ghiChu:'Chưa có dữ liệu chấm công (attendance_logs) gắn với đơn này' }; const userIds = [...new Set(logsOfDeal.map(l=>l.user_id).filter(Boolean))]; if(userIds.length===0) return { tien:0, coDuLieu:false, ghiChu:'Có bản ghi chấm công nhưng thiếu user_id, không xác định được nhân viên' }; // Cần cấu hình lương - dùng lại đúng cauHinhMap/nhanVienList của module Cài đặt > Cấu hình lương, // không tạo nguồn tra cứu lương thứ hai. if(typeof cauHinhMap==='undefined' || Object.keys(cauHinhMap).length===0){ if(typeof taiDuLieuCauHinhLuong==='function') await taiDuLieuCauHinhLuong(); } const thieuCauHinh = userIds.filter(uid=>!cauHinhMap[uid]); if(thieuCauHinh.length===userIds.length){ return { tien:0, coDuLieu:false, ghiChu:`Chưa có Cấu hình lương (salary_config) cho ${userIds.length} nhân viên chấm công ở đơn này - vào Cài đặt > Cấu hình lương để nhập trước` }; } // Lấy TOÀN BỘ log trong tháng của các NV này (mọi đơn, không chỉ đơn hiện tại) để: (a) xác định // đúng giờ OT theo tổng giờ/ngày thật (không phải chỉ giờ ở đơn này), (b) tính tổng công/tháng // để xét ngưỡng chuyên cần 23 công. const ngayBatKy = (logsOfDeal[0].check_in||'').slice(0,10) || new Date().toISOString().slice(0,10); const dauThang = ngayBatKy.slice(0,7)+'-01'; const cuoiThangDate = new Date(dauThang); cuoiThangDate.setMonth(cuoiThangDate.getMonth()+1); const cuoiThang = cuoiThangDate.toISOString().slice(0,10); const { data: logsThang, error: err2 } = await supabaseClient.from('attendance_logs').select('*') .in('user_id', userIds).gte('check_in', dauThang).lt('check_in', cuoiThang); if(err2) return { tien:0, coDuLieu:false, ghiChu:'Lỗi truy vấn dữ liệu chấm công cả tháng: '+err2.message }; function ghepCaThanhKhoang(logs){ // Ghép cặp 'vao'->'ra' liên tiếp theo thời gian, trong CÙNG (user_id, order_id). Bản ghi lẻ (vao // không có ra tương ứng, VD quên chấm) bị bỏ qua - không đoán giờ ra, tránh bịa số liệu. const theoNhom = {}; logs.forEach(l=>{ const key = l.user_id+'|'+(l.order_id||''); (theoNhom[key] = theoNhom[key]||[]).push(l); }); const khoang = []; Object.values(theoNhom).forEach(arr=>{ arr.sort((a,b)=>new Date(a.check_in)-new Date(b.check_in)); let dangVao = null; arr.forEach(l=>{ if(l.type==='vao') dangVao = l; else if(l.type==='ra' && dangVao){ khoang.push({ userId: dangVao.user_id, orderId: dangVao.order_id, batDau: new Date(dangVao.check_in), ketThuc: new Date(l.check_in), sanLuongKhoan: l.san_luong_khoan||dangVao.san_luong_khoan||0 }); dangVao = null; } }); }); return khoang; } const khoangThangNay = ghepCaThanhKhoang(logsThang||[]); let tongTien = 0; const ghiChuChiTiet = []; userIds.forEach(uid=>{ const cfg = cauHinhMap[uid]; if(!cfg){ ghiChuChiTiet.push(`NV (user_id ${uid.slice(0,8)}...) chưa có Cấu hình lương - bỏ qua, chưa tính`); return; } const gioLamNgay = 8; // Chưa có cột riêng "giờ công chuẩn/ngày" trong salary_config - dùng quy ước 8h/ngày (Long xác nhận lại nếu khác) const donGiaGio = (cfg.luong_co_ban_ngay||0) / gioLamNgay; const otPct = cfg.ot_pct || 150; const phuCapLuuDongGio = cfg.co_phu_cap_luu_dong ? (cfg.phu_cap_luu_dong_gio||0) : 0; const khoangCuaNV = khoangThangNay.filter(k=>k.userId===uid); // Gom giờ theo ngày (mọi đơn) để biết ngày nào có OT, và theo (ngày, đơn) để biết OT rơi vào đơn nào const theoNgay = {}; khoangCuaNV.forEach(k=>{ const ngay = k.batDau.toISOString().slice(0,10); const gio = (k.ketThuc-k.batDau)/3600000; (theoNgay[ngay] = theoNgay[ngay]||[]).push({...k, gio}); }); let congThangNay = 0; // số ngày có chấm công trong tháng (xét ngưỡng chuyên cần) let congODonNay = 0; // số ngày có làm ở ĐƠN NÀY trong tháng (để phân bổ chuyên cần) let tienDonNay = 0; Object.entries(theoNgay).forEach(([ngay, dsKhoang])=>{ congThangNay++; const tongGioNgay = dsKhoang.reduce((s,k)=>s+k.gio,0); const gioThuong = Math.min(tongGioNgay, gioLamNgay); const gioOT = Math.max(0, tongGioNgay-gioLamNgay); // Giờ OT tính vào khoảng làm SAU CÙNG trong ngày (quy ước thực tế phổ biến) - phân bổ OT theo // đúng đơn diễn ra trong khoảng giờ OT đó, không chia đều cho mọi đơn trong ngày. let gioOTConLai = gioOT; const dsKhoangTheoThoiGian = [...dsKhoang].sort((a,b)=>a.batDau-b.batDau); const gioOTTheoDon = {}; for(let i=dsKhoangTheoThoiGian.length-1; i>=0 && gioOTConLai>0.0001; i--){ const k = dsKhoangTheoThoiGian[i]; const lay = Math.min(k.gio, gioOTConLai); gioOTTheoDon[k.orderId] = (gioOTTheoDon[k.orderId]||0) + lay; gioOTConLai -= lay; } const coLamODonNay = dsKhoang.some(k=>k.orderId===d.code); if(coLamODonNay) congODonNay++; dsKhoang.filter(k=>k.orderId===d.code).forEach(k=>{ const gioOTCuaKhoangNay = Math.min(k.gio, gioOTTheoDon[d.code]||0); // xấp xỉ nếu 1 ngày có >1 khoảng cùng đơn const gioThuongCuaKhoangNay = k.gio - gioOTCuaKhoangNay; tienDonNay += gioThuongCuaKhoangNay*donGiaGio + gioOTCuaKhoangNay*donGiaGio*(otPct/100); tienDonNay += k.gio*phuCapLuuDongGio; // phụ cấp lưu động tính trên MỌI giờ làm ở đơn (không phân biệt OT/thường) }); }); // Chuyên cần: chỉ hưởng nếu công cả tháng (mọi đơn) >= 23, phân bổ theo tỷ lệ công làm ở đơn này if(congThangNay>=23 && (cfg.chuyen_can_don_gia||0)>0 && congODonNay>0){ tienDonNay += (cfg.chuyen_can_don_gia) * congODonNay; } tongTien += tienDonNay; if(thieuCauHinh.includes(uid) === false && congODonNay===0 && khoangCuaNV.some(k=>k.orderId===d.code)===false){ // (không có ca hợp lệ ở đơn này dù có log lẻ) - bỏ qua, không ghi chú thêm để tránh rối } }); if(thieuCauHinh.length>0) ghiChuChiTiet.push(`${thieuCauHinh.length}/${userIds.length} nhân viên chưa có Cấu hình lương, phần của họ CHƯA được tính vào số dưới đây`); return { tien: Math.round(tongTien), coDuLieu:true, ghiChu: ghiChuChiTiet.join('; ')||null }; } /* ---- Tổng hợp 8 dòng - hàm CHÍNH được gọi từ UI. Async vì dòng Nhân công ĐP cần Supabase. ---- */ async function hieuQuaDon(d){ const doanhThu = giaTriGhiNhan(d) - (d.cooperationFee||0); // v114 - đúng công thức đã dùng ở P&L/BI, KHÔNG viết công thức mới const vatLieu = hqVatLieu(d); const nhanCongDP = await hqNhanCongDucPhong(d); const nhanCongThueKhoan = hqNhanCongThueKhoan(d); const thueKhoanNgoai = hqThueKhoanNgoai(d); const vanChuyen = hqVanChuyen(d); const chiPhiKhac = hqChiPhiKhac(d); const tongChiPhiTrucTiep = vatLieu.tien + nhanCongDP.tien + nhanCongThueKhoan.tien + thueKhoanNgoai.tien + vanChuyen.tien + chiPhiKhac.tien; const loiNhuanTrucTiep = doanhThu - tongChiPhiTrucTiep; const bienLoiNhuan = doanhThu>0 ? (loiNhuanTrucTiep/doanhThu*100) : 0; return { doanhThu, vatLieu, nhanCongDP, nhanCongThueKhoan, thueKhoanNgoai, vanChuyen, chiPhiKhac, tongChiPhiTrucTiep, loiNhuanTrucTiep, bienLoiNhuan, }; } /* ---- Hiển thị khối Hiệu quả trong form Giao dịch ---- */ async function renderKhoiHieuQua(d){ const box = document.getElementById('hieuQuaDonBox'); if(!box) return; box.innerHTML = 'Đang tính...'; let hq; try{ hq = await hieuQuaDon(d); } catch(e){ box.innerHTML = `
Lỗi tính Hiệu quả: ${e.message}
`; console.error('[Hiệu quả đơn]', e); return; } const dong = (nhan, val, thongTin) => `
${nhan}${thongTin && !thongTin.coDuLieu ? `
⚠ ${thongTin.ghiChu || 'Chưa có dữ liệu nguồn cho khoản này'}
` : ''}
${thongTin?'− ':''}${fmt(val)}
`; box.innerHTML = ` ${dong('Doanh thu ghi nhận (đã trừ chi phí hợp tác nếu có)', hq.doanhThu, null)} ${dong('Vật liệu', hq.vatLieu.tien, hq.vatLieu)} ${dong('Nhân công Đức Phong', hq.nhanCongDP.tien, hq.nhanCongDP)} ${dong('Nhân công thuê khoán', hq.nhanCongThueKhoan.tien, hq.nhanCongThueKhoan)} ${dong('Thuê khoán ngoài', hq.thueKhoanNgoai.tien, hq.thueKhoanNgoai)} ${dong('Vận chuyển thuê ngoài', hq.vanChuyen.tien, hq.vanChuyen)} ${dong('Chi phí trực tiếp khác', hq.chiPhiKhac.tien, hq.chiPhiKhac)}
Tổng chi phí trực tiếp
${fmt(hq.tongChiPhiTrucTiep)}
Lợi nhuận trực tiếp (biên ${hq.bienLoiNhuan.toFixed(1)}%)
${fmt(hq.loiNhuanTrucTiep)}
`; } function moBienBanNghiemThu(dealId, bbId){ const d = deals.find(x=>x.id===dealId); if(!d){ alert('Lưu Giao dịch trước rồi mới lập biên bản nghiệm thu.'); return; } ntDealId = dealId; ntBienBanId = bbId || null; const bb = bbId ? _dsNghiemThu(d).find(x=>x.id===bbId) : null; const daKhoa = !!(bb && bb.khoa); document.getElementById('nt_dealLabel').innerHTML = `${d.code} — ${d.customerName||''} — ${d.title||''}` + (daKhoa ? ' · biên bản đã ký, chỉ xem' : ''); document.getElementById('nt_loai').value = bb ? bb.loai : 'tung_phan'; document.getElementById('nt_so').value = bb ? bb.so : soBienBanTiepTheo(d); document.getElementById('nt_ngay').value = bb ? (bb.ngay||'') : new Date().toISOString().slice(0,10); document.getElementById('nt_diaDiem').value = bb ? (bb.diaDiem||'') : ''; document.getElementById('nt_ketLuan').value = bb ? (bb.ketLuan||'dat') : 'dat'; document.getElementById('nt_hanKhacPhuc').value = bb ? (bb.hanKhacPhuc||'') : ''; document.getElementById('nt_tonTai').value = bb ? (bb.tonTai||'') : ''; document.getElementById('nt_dpTen').value = bb ? (bb.dpTen||'') : 'Hoàng'; document.getElementById('nt_dpChuc').value = bb ? (bb.dpChuc||'') : ''; document.getElementById('nt_khTen').value = bb ? (bb.khTen||'') : ''; document.getElementById('nt_khChuc').value = bb ? (bb.khChuc||'') : ''; document.getElementById('nt_taiLieu').value = bb ? (bb.taiLieu||'') : ''; document.getElementById('nt_ghiChu').value = bb ? (bb.ghiChu||'') : ''; ntRows = bb ? JSON.parse(JSON.stringify(bb.hangMuc||[])) : []; renderDongNghiemThu(); document.querySelectorAll('#nt_overlay input, #nt_overlay select, #nt_overlay textarea').forEach(el=>{ el.disabled = daKhoa || el.id==='nt_so'; }); document.querySelectorAll('#nt_overlay .modal-actions .btn').forEach((b,i)=>{ if(i>0) b.style.display = daKhoa ? 'none' : 'inline-block'; }); document.getElementById('nt_overlay').classList.add('open'); } function dongBienBanNghiemThu(){ document.getElementById('nt_overlay').classList.remove('open'); ntDealId=null; ntBienBanId=null; } function themDongNghiemThu(){ ntRows.push({id:uid(), hangMuc:'', dvt:'', sl:'', ketQua:''}); renderDongNghiemThu(); } function xoaDongNghiemThu(id){ ntRows = ntRows.filter(r=>r.id!==id); renderDongNghiemThu(); } function suaDongNghiemThu(id, field, value){ const r = ntRows.find(x=>x.id===id); if(r) r[field] = value; } function renderDongNghiemThu(){ const tb = document.getElementById('nt_rows'); if(!tb) return; const khoa = !!(ntDealId && ntBienBanId && (_dsNghiemThu(deals.find(x=>x.id===ntDealId)||{nghiemThu:[]}).find(x=>x.id===ntBienBanId)||{}).khoa); if(!ntRows.length){ tb.innerHTML = 'Chưa có dòng nào — bấm "+ Thêm dòng" hoặc lấy hạng mục từ báo giá.'; return; } tb.innerHTML = ntRows.map(r=>` ${khoa?'':``} `).join(''); } /* Kế thừa dữ liệu giữa các bước — không nhập lại: ưu tiên bảng hạng mục của GIÁ CHỐT đã khóa */ function layHangMucTuBaoGiaVaoNghiemThu(){ const d = deals.find(x=>x.id===ntDealId); if(!d) return; const ds = Array.isArray(d.phienBanBaoGia) ? d.phienBanBaoGia : []; const chot = ds.filter(b=>b.laGiaChot).pop(); const nguon = (chot && chot.groups) ? chot.groups : (d.groups||[]); const them = []; nguon.forEach(g=>(g.items||[]).forEach(it=>{ if(!it.isHeader && (it.desc||'').trim()) them.push({id:uid(), hangMuc:it.desc, dvt:it.unit||'', sl:it.qty!==undefined?String(it.qty):'', ketQua:''}); })); if(!them.length){ alert('Chưa có hạng mục nào trong báo giá để lấy sang.'); return; } ntRows = ntRows.concat(them); renderDongNghiemThu(); } async function luuBienBanNghiemThu(kyLuon){ const d = deals.find(x=>x.id===ntDealId); if(!d) return; const ngay = document.getElementById('nt_ngay').value; if(!ngay){ alert('Nhập Ngày nghiệm thu.'); return; } const loai = document.getElementById('nt_loai').value; const dpTen = document.getElementById('nt_dpTen').value.trim(); const khTen = document.getElementById('nt_khTen').value.trim(); if(kyLuon && (!dpTen || !khTen)){ alert('Ký biên bản thì phải có tên người ký của cả hai bên (Đức Phong và Khách hàng).'); return; } const ds = _dsNghiemThu(d); let bb = ntBienBanId ? ds.find(x=>x.id===ntBienBanId) : null; if(bb && bb.khoa){ alert('Biên bản đã ký và khóa, không sửa đè.'); return; } const duLieu = { loai, ngay, diaDiem: document.getElementById('nt_diaDiem').value.trim(), hangMuc: JSON.parse(JSON.stringify(ntRows)), ketLuan: document.getElementById('nt_ketLuan').value, tonTai: document.getElementById('nt_tonTai').value.trim(), hanKhacPhuc: document.getElementById('nt_hanKhacPhuc').value, dpTen, dpChuc: document.getElementById('nt_dpChuc').value.trim(), khTen, khChuc: document.getElementById('nt_khChuc').value.trim(), taiLieu: document.getElementById('nt_taiLieu').value.trim(), ghiChu: document.getElementById('nt_ghiChu').value.trim(), }; if(!bb){ bb = { id:'BB'+Date.now(), so: soBienBanTiepTheo(d), ngayLap:new Date().toISOString().slice(0,10), nguoiLap:nguoiDungHienTai(), khoa:false, ky:null }; ds.push(bb); } Object.assign(bb, duLieu); if(kyLuon){ if(!confirm(`Ký và KHÓA ${bb.so} (${NT_LOAI_LABEL[loai]})?\n\nKý xong biên bản không sửa đè được nữa.`)) return; bb.khoa = true; bb.ky = { ngay: new Date().toISOString().slice(0,10), nguoi: nguoiDungHienTai() }; if(loai==='toan_bo'){ d.accepted = true; const ck = document.getElementById('d_accepted'); if(ck) ck.checked = true; } } await persistDeals(d.id); dongBienBanNghiemThu(); renderNghiemThu(d.id); if(typeof renderPhatSinhPhuLuc==='function') renderPhatSinhPhuLuc(d.id); ghiNhatKy('Giao dịch', kyLuon ? 'Ký biên bản nghiệm thu' : 'Lưu nháp biên bản nghiệm thu', `${d.code} — ${bb.so} — ${NT_LOAI_LABEL[loai]}`); if(kyLuon) alert(`✓ Đã ký và khóa ${bb.so}.` + (loai==='toan_bo' ? '\nĐơn đã có biên bản bàn giao toàn bộ — mục "Đã ký nghiệm thu" tự tích.' : '')); } async function xoaNhapBienBanNghiemThu(dealId, bbId){ const d = deals.find(x=>x.id===dealId); if(!d) return; const ds = _dsNghiemThu(d); const bb = ds.find(x=>x.id===bbId); if(!bb) return; if(bb.khoa){ alert('Biên bản đã ký, không xóa được.'); return; } if(!confirm(`Hủy bản nháp ${bb.so}? Chỉ bản NHÁP chưa ký mới hủy được.`)) return; d.nghiemThu = ds.filter(x=>x.id!==bbId); await persistDeals(d.id); renderNghiemThu(d.id); } function renderNghiemThu(dealId){ const box = document.getElementById('gd_nghiemThu'); if(!box) return; const d = deals.find(x=>x.id===(dealId||editingId)); if(!d){ box.innerHTML = '
Lưu Giao dịch trước rồi mới lập được biên bản nghiệm thu.
'; return; } const ds = _dsNghiemThu(d); const daToanBo = coNghiemThuToanBoDaKy(d); const nhac = !daToanBo ? '
Chưa có biên bản nghiệm thu bàn giao toàn bộ đã ký — cần có trước khi thanh lý & quyết toán
' : '
✓ Đã có biên bản nghiệm thu bàn giao toàn bộ đã ký
'; box.innerHTML = nhac + `
${ds.length===0 ? '
Chưa có biên bản nào.
' : ` ${ds.map(b=>``).join('')}
Số Loại Ngày Kết luận Đại diện ký Trạng thái
${b.so}${b.khoa?' 🔒':''} ${NT_LOAI_LABEL[b.loai]||b.loai} ${b.ngay||'—'} ${NT_KETLUAN_LABEL[b.ketLuan]||'—'}${b.tonTai?`
Tồn tại: ${b.tonTai}${b.hanKhacPhuc?' — hạn '+b.hanKhacPhuc:''}
`:''}
ĐP: ${b.dpTen||'—'}
KH: ${b.khTen||'—'}
${b.khoa?`Đã ký ${b.ky?b.ky.ngay:''}`:'Nháp — chưa ký'} ${b.taiLieu?`[tài liệu]`:''} ${b.khoa?'':``}
`}`; } function inBienBanNghiemThu(dealId, bbId){ try{ const d = deals.find(x=>x.id===dealId); if(!d) return; const b = _dsNghiemThu(d).find(x=>x.id===bbId); if(!b) return; const cust = (typeof customers!=='undefined'?customers:[]).find(c=>c.code===d.customerCode); const ng = (b.ngay||'').split('-'); const ngayChu = ng.length===3 ? `ngày ${ng[2]} tháng ${ng[1]} năm ${ng[0]}` : ''; const rows = (b.hangMuc||[]).map((r,i)=>` ${i+1}${r.hangMuc||''} ${r.dvt||''}${r.sl||''} ${r.ketQua||''}`).join(''); document.getElementById('gd_printArea').innerHTML = `
[ Chỗ trống cho banner logo công ty ]
BIÊN BẢN NGHIỆM THU
${NT_LOAI_LABEL[b.loai]||''}
Số: ${b.so}
${ngayChu}${b.diaDiem?' — '+b.diaDiem:''}
Công trình / Đơn hàng:${d.title||''} (${d.code})
Hợp đồng:${(d.contract&&d.contract.number)?d.contract.number+((d.contract.signedDate)?' — ký ngày '+d.contract.signedDate:''):'Đơn hàng không có hợp đồng'}
Bên A (Khách hàng):${cust?(cust.company||cust.name):(d.customerName||'')}
Địa chỉ:${cust?(cust.address||''):''}
Bên B:Đức Phong Media
Hai bên cùng tiến hành nghiệm thu các hạng mục sau:
${rows || ''}
STTHẠNG MỤC ĐVTSL KẾT QUẢ KIỂM TRA / GHI CHÚ
 
Kết luận nghiệm thu:${NT_KETLUAN_LABEL[b.ketLuan]||''}
Tồn tại cần khắc phục:${b.tonTai||''}
Hạn khắc phục:${b.hanKhacPhuc||''}
Ghi chú:${b.ghiChu||''}
ĐẠI DIỆN BÊN A (KHÁCH HÀNG)
${b.khChuc||''}
${b.khTen||''}
ĐẠI DIỆN BÊN B (ĐỨC PHONG)
${b.dpChuc||''}
${b.dpTen||''}
ducphongmedia.com — In ngày ${new Date().toLocaleDateString('vi-VN')}
`; window.print(); }catch(err){ alert('⚠ Không in được biên bản. Lỗi: ' + err.message); console.error('[inBienBanNghiemThu] Lỗi:', err); } } function printDealQuote(){ try{ const total = groups.reduce((s,g)=>s+g.items.reduce((s2,it)=>s2+itemTotal(it),0),0); const vat = parseFloat(document.getElementById('d_vat').value)||0; const [customerCode, customerName] = document.getElementById('d_customer').value.split('|'); const cust = customers.find(c=>c.code===customerCode); const title = document.getElementById('d_title').value || '(Chưa đặt tên)'; const code = document.getElementById('d_code').value; const now = new Date(); const ngayIn = `Hà nội, ngày ${String(now.getDate()).padStart(2,'0')}/${String(now.getMonth()+1).padStart(2,'0')}/${now.getFullYear()}`; let stt = 0; let itemsHtml = ''; groups.forEach(g=>{ g.items.forEach(it=>{ if(it.isHeader){ itemsHtml += `${it.desc}`; } else { stt++; itemsHtml += ` ${stt} ${it.desc} ${it.d||''} ${it.r||''} ${it.c||''} ${it.unit} ${it.qty} ${fmt(it.price)} ${fmt(itemTotal(it))} `; } }); }); document.getElementById('gd_printArea').innerHTML = `
[ Chỗ trống cho banner logo công ty — Long gắn lại khi cần ]
${ngayIn}
BẢNG BÁO GIÁ
${title}
${code}
Kính gửi:${cust?.name||customerName||''}
Đơn vị:${cust?.company||''}
Địa chỉ:${cust?.address||''}
Điện thoại:${cust?.phone||''}
Tôi xin gửi tới Quý khách hàng các hạng mục báo giá chi tiết như sau:
${itemsHtml}
STT HẠNG MỤC - MÔ TẢ QUY CÁCH K.THƯỚC (m) ĐVT SL ĐƠN GIÁ THÀNH TIỀN
DRC
Cộng${fmt(total)}
VAT (${vat}%)${fmt(total*vat/100)}
Tổng cộng sau VAT${fmt(total + total*vat/100)}
ducphongmedia.com
`; window.print(); }catch(err){ alert('⚠ Không xuất được PDF. Lỗi: ' + err.message + '\n\nChụp màn hình lỗi này gửi lại để kiểm tra.'); console.error('[printDealQuote] Lỗi:', err); } } // ---- Tải mẫu Excel / Nhập hạng mục từ Excel ---- function downloadExcelTemplate(){ const wsData = [['STT/Nhóm hạng mục', 'Mô tả', 'D', 'R', 'C', 'ĐVT', 'SL', 'Đơn giá'], [1, 'VD: Kệ trưng bày gạch', 1, 1, 1, 'bộ', 1, 1000000], [1, 'VD: dòng thứ 2 cùng sản phẩm trên (để trống cột đầu hoặc lặp lại số 1)', 0.5, 0.5, 0.5, 'cái', 2, 200000]]; const ws = XLSX.utils.aoa_to_sheet(wsData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Hang muc'); XLSX.writeFile(wb, 'mau-nhap-hang-muc-bao-gia.xlsx'); } // Đọc file Excel THEO TÊN TIÊU ĐỀ CỘT (không phụ thuộc thứ tự cột cố định) - vì thực tế mỗi người // dùng file mẫu khác nhau (Long dùng "STT" gộp chung Tên+Mô tả, mẫu gốc tách riêng "Nhóm hạng mục"). // Đọc sai cột là nguyên nhân gây giá tiền/kích thước bị lệch/rác khi nhập trước đây. // Đồng thời: nếu nhiều dòng liên tiếp CÙNG 1 STT/Nhóm (hoặc để trống - coi như merge cell nối dòng // trên), gộp chung thành 1 nhóm hạng mục thay vì tách rời từng dòng thành nhiều mục riêng lẻ. function timChiSoCotTheoTieuDe(headerRow, ...tenCanTim){ for(let i=0;iten.includes(boDauVN(t)))) return i; } return -1; } function importExcelFile(input){ const file = input.files[0]; if(!file) return; const reader = new FileReader(); reader.onload = function(e){ try{ const wb = XLSX.read(new Uint8Array(e.target.result), {type:'array'}); const ws = wb.Sheets[wb.SheetNames[0]]; const allRows = XLSX.utils.sheet_to_json(ws, {header:1}); if(allRows.length<2){ alert('File không có dữ liệu hạng mục nào.'); return; } const header = allRows[0]; const dataRows = allRows.slice(1); const iGroup = timChiSoCotTheoTieuDe(header, 'stt', 'nhom', 'hang muc'); const iDesc = timChiSoCotTheoTieuDe(header, 'mo ta', 'hang muc', 'ten'); const iD = timChiSoCotTheoTieuDe(header, 'd'); const iR = timChiSoCotTheoTieuDe(header, 'r'); const iC = timChiSoCotTheoTieuDe(header, 'c'); const iUnit = timChiSoCotTheoTieuDe(header, 'dvt', 'don vi'); const iQty = timChiSoCotTheoTieuDe(header, 'sl', 'so luong'); const iPrice = timChiSoCotTheoTieuDe(header, 'don gia'); if(iDesc===-1 || iPrice===-1){ alert('Không tìm thấy cột "Mô tả" hoặc "Đơn giá" trong file - kiểm tra lại tiêu đề dòng đầu tiên của file.'); return; } const byGroup = {}; let groupHienTai = 'Hạng mục'; // Dùng lại nhóm của dòng trước nếu dòng này bỏ trống cột Nhóm/STT (merge cell) let soDongDaDoc = 0; dataRows.forEach(r=>{ if(!r || (iDesc>=0 && !r[iDesc])) return; // Bỏ qua dòng trắng hoàn toàn soDongDaDoc++; if(currentType==='project'){ const rawGroup = iGroup>=0 ? r[iGroup] : null; if(rawGroup!==null && rawGroup!==undefined && String(rawGroup).trim()!==''){ groupHienTai = String(rawGroup).trim(); } // Nếu ô Nhóm/STT để trống -> coi như vẫn thuộc nhóm của dòng ngay trước (đúng ý "nhiều dòng chung 1 sản phẩm") } else { groupHienTai = 'Hạng mục'; } if(!byGroup[groupHienTai]) byGroup[groupHienTai] = []; byGroup[groupHienTai].push({ id:uid(), isHeader:false, desc: iDesc>=0 ? (r[iDesc]||'') : '', d: iD>=0 ? (parseFloat(r[iD])||'') : '', r: iR>=0 ? (parseFloat(r[iR])||'') : '', c: iC>=0 ? (parseFloat(r[iC])||'') : '', unit: iUnit>=0 ? (r[iUnit]||'') : '', qty: iQty>=0 ? (parseFloat(r[iQty])||1) : 1, price: parseFloat(r[iPrice])||0, }); }); Object.entries(byGroup).forEach(([groupName, items])=>{ let g = groups.find(x=>x.name===groupName); if(!g){ g = {id:uid(), name:groupName, dimUnit:'m', items:[]}; groups.push(g); } g.items.push(...items); }); renderGroups(); alert(`Đã nhập ${soDongDaDoc} dòng hạng mục từ Excel, gộp vào ${Object.keys(byGroup).length} nhóm.`); }catch(err){ alert('Không đọc được file Excel. Kiểm tra lại dòng đầu tiên có đủ tiêu đề cột (Mô tả, Đơn giá...) chưa.'); } input.value = ''; }; reader.readAsArrayBuffer(file); } loadDealData(); /* ===== Logic Nhà cung cấp ===== */ let suppliers = []; let purchaseOrders = []; let seqPO = 87; let poItems = []; let editingSupplierId = null; let currentMaterialTags = []; let currentGhiChuTags = []; let seqNCC = 0; const RATING_LABEL = {tot:'Tốt', binh_thuong:'Bình thường', can_luu_y:'Cần lưu ý'}; const NCC_TYPE_META = { NM:{nhan:'Nhà máy', mau:'#2E6DA4'}, CH:{nhan:'Cửa hàng', mau:'#C77D1E'}, KS:{nhan:'Kho sỉ', mau:'#3A9D6F'}, DL:{nhan:'Đại lý', mau:'#8a5acd'} }; const TOC_DO_GIAO_LABEL = { nhanh:'nhanh', trung_binh:'TB', cham:'chậm' }; function genCodeNCC(){ seqNCC++; return 'NCC.'+String(seqNCC).padStart(4,'0'); } const seedNCC = []; // SỬA v114: biến này phải khai báo TRƯỚC lệnh loadSupplierData() ở cuối khối NCC. Trước đây nó nằm // SAU lệnh gọi đó, mà loadSupplierData() lại render ngay ở phần đồng bộ (trước await) nên chạm vào // biến khi chưa khởi tạo -> ReferenceError -> hàm dừng giữa chừng, không đọc nổi dữ liệu NCC từ Supabase. let nccDashTypeChartInstance = null; async function loadSupplierData(){ suppliers = seedNCC.map(s => ({id: uid(), code: genCodeNCC(), ...s})); populateMaterialFilter(); renderSuppliers(); try{ const res = await supaGet('suppliers'); if(res && res.value){ suppliers = JSON.parse(res.value); populateMaterialFilter(); renderSuppliers(); } else { await persistSuppliers(); } }catch(e){ /* giữ dữ liệu mẫu */ } } async function persistSuppliers(){ await supaSet('suppliers', JSON.stringify(suppliers)); } function populateMaterialFilter(){ const sel = document.getElementById('ncc_materialFilter'); const all = new Set(); suppliers.forEach(s => (s.tags||[]).forEach(t=>all.add(t))); const current = sel.value; sel.innerHTML = '' + [...all].sort().map(t=>``).join(''); sel.value = current; } function renderSuppliers(){ renderNCCDashboard(); renderPOSupplierOptions(); renderPODealOptions(); const qRaw = (document.getElementById('ncc_searchBox').value||'').trim(); const q = boDauVN(qRaw); const materialFilter = document.getElementById('ncc_materialFilter').value; let filtered = suppliers.filter(s => { const sc = Math.max(matchScoreVN(s.name,q), matchScoreMa(s.phone, qRaw), matchScoreMa(s.taxCode||'', qRaw)); const matchQ = !q || sc>0; const matchMat = !materialFilter || (s.tags||[]).includes(materialFilter); return matchQ && matchMat; }); renderSupplierSuggestions(qRaw); const sortBy = document.getElementById('ncc_sortBy').value; filtered = filtered.slice().sort((a,b)=>{ if(sortBy==='debt_desc') return getSupplierDebt(b)-getSupplierDebt(a); if(sortBy==='contact_oldest'){ const da = daysSinceContact(a.lastContact) ?? -1; const db = daysSinceContact(b.lastContact) ?? -1; return db-da; } return a.name.localeCompare(b.name); }); const stats = document.getElementById('ncc_stats'); const activeCount = suppliers.filter(s=>s.status==='active').length; const attentionCount = suppliers.filter(s=>s.rating==='can_luu_y').length; const totalDebt = suppliers.reduce((s,x)=>s+getSupplierDebt(x),0); const staleCount = suppliers.filter(s => (daysSinceContact(s.lastContact)||0) > 180).length; stats.innerHTML = `
${suppliers.length}
Tổng NCC
${activeCount}
Đang hợp tác
${attentionCount}
Cần lưu ý
${fmt(totalDebt)}
Tổng công nợ phải trả
${staleCount>0 ? `
${staleCount}
Quá 180 ngày chưa liên hệ
` : ''} `; const list = document.getElementById('ncc_list'); if(filtered.length === 0){ list.innerHTML = `
Không tìm thấy nhà cung cấp phù hợp.
`; document.getElementById('nccFooterSummary').innerHTML = ''; return; } list.innerHTML = filtered.map(s => { const initials = s.name.trim().split(' ').slice(-2).map(w=>w[0]).join('').toUpperCase(); const dsc = daysSinceContact(s.lastContact); const contactWarning = (dsc !== null && dsc > 180); const ratingBadge = s.rating ? `${RATING_LABEL[s.rating]}` : ''; const statusBadge = s.status==='inactive' ? `Ngừng hợp tác` : ''; const typeBadge = s.type ? `${NCC_TYPE_META[s.type].nhan}` : ''; const canhBaoChatLuong = [ s.hayCanThieu ? '⚠ Hay cân thiếu' : null, s.khongCoCQ ? '⚠ Không có CQ' : null, s.hayTangGiaDotNgot ? '⚠ Hay tăng giá đột ngột' : null, s.nguonDuyNhat ? '🔴 Nguồn cung DUY NHẤT - rủi ro chuỗi cung ứng' : null, ].filter(Boolean); const muaHang = tinhMuaHangTheoNam(s.code); return `
${initials}
${s.name}${s.code} ${typeBadge}${ratingBadge}${statusBadge} ${contactWarning ? `⚠ ${dsc} ngày chưa liên hệ` : ''}
${s.contact ? `
Liên hệ: ${s.contact}
` : ''}
${s.phone} ${s.taxCode ? `MST: ${s.taxCode}` : ''} ${s.paymentTerms ? `Hạn TT: ${s.paymentTerms}` : ''} ${s.coVAT ? `✓ Có VAT` : ''} ${s.noGoiDau ? `Nợ gối đầu` : ''} ${s.khoangCachKm ? `${s.khoangCachKm}km · Giao ${TOC_DO_GIAO_LABEL[s.tocDoGiao]||''}` : ''} ${s.vungLoiTacDuong ? `🚦 Vùng lõi - hay tắc đường` : ''} ${s.hopDongKhung ? `📄 Hợp đồng khung` : ''}
${canhBaoChatLuong.length ? `
${canhBaoChatLuong.join(' · ')}
` : ''} ${(s.tags&&s.tags.length) ? `
${s.tags.map(t=>tagPillHtml(t)).join('')}
` : ''} ${(s.ghiChuTags&&s.ghiChuTags.length) ? `
${s.ghiChuTags.map(t=>tagPillHtml(t)).join('')}
` : ''} ${muaHang.totalCount ? `
${muaHang.totalCount}lần mua ${fmt(muaHang.totalValue)}tổng giá trị ${getSupplierDebt(s)>0 ? `${fmt(getSupplierDebt(s))}công nợ phải trả` : ''}
` : `
Chưa có giao dịch nào
`}
`; }).join(''); // Footer Summary - tổng theo đúng danh sách đang lọc const filteredDebtNCC = filtered.reduce((s,x)=>s+getSupplierDebt(x),0); const filteredValueNCC = filtered.reduce((s,x)=>s+(x.totalValue||0),0); document.getElementById('nccFooterSummary').innerHTML = `
Đang hiển thị:${filtered.length}/${suppliers.length} NCC
Tổng giá trị đã mua:${fmt(filteredValueNCC)}
Tổng công nợ phải trả:${fmt(filteredDebtNCC)}
`; } function focusMaterialTagInput(){ if(document.querySelector('#ncc_tagbox_material input')) return; addMaterialTagInputField(); } function addMaterialTagInputField(){ const box = document.getElementById('ncc_tagbox_material'); const inp = document.createElement('input'); inp.placeholder = 'Gõ rồi Enter (VD: Thép, Gỗ...)'; inp.onkeydown = (e) => { if(e.key === 'Enter' && inp.value.trim()){ currentMaterialTags.push(inp.value.trim()); inp.value=''; renderMaterialTagBox(); } }; box.appendChild(inp); inp.focus(); } function renderMaterialTagBox(){ const box = document.getElementById('ncc_tagbox_material'); box.innerHTML = ''; currentMaterialTags.forEach((t,i) => { const chip = document.createElement('span'); const c = getTagColor(t); chip.className='tag-chip'; chip.style.background = tagBg(c); chip.style.color = c; chip.innerHTML = `${t} `; box.appendChild(chip); }); addMaterialTagInputField(); } function removeMaterialTag(i){ currentMaterialTags.splice(i,1); renderMaterialTagBox(); } function focusGhiChuTagInput(){ if(document.querySelector('#ncc_tagbox_ghichu input')) return; addGhiChuTagInputField(); } function addGhiChuTagInputField(){ const box = document.getElementById('ncc_tagbox_ghichu'); const inp = document.createElement('input'); inp.placeholder = 'Gõ rồi Enter (VD: Đối tác lâu năm, Ưu tiên...)'; inp.onkeydown = (e) => { if(e.key === 'Enter' && inp.value.trim()){ currentGhiChuTags.push(inp.value.trim()); inp.value=''; renderGhiChuTagBox(); } }; box.appendChild(inp); inp.focus(); } function renderGhiChuTagBox(){ const box = document.getElementById('ncc_tagbox_ghichu'); box.innerHTML = ''; currentGhiChuTags.forEach((t,i) => { const chip = document.createElement('span'); const c = getTagColor(t); chip.className='tag-chip'; chip.style.background = tagBg(c); chip.style.color = c; chip.innerHTML = `${t} `; box.appendChild(chip); }); addGhiChuTagInputField(); } function removeGhiChuTag(i){ currentGhiChuTags.splice(i,1); renderGhiChuTagBox(); } function markSupplierContactedToday(){ document.getElementById('s_last_contact').value = new Date().toISOString().slice(0,10); } function renderDebtBox(s){ const box = document.getElementById('ncc_debtBox'); if(!s){ box.innerHTML = '
Lưu nhà cung cấp trước để thấy lịch sử mua hàng.
'; return; } const muaHang = tinhMuaHangTheoNam(s.code); const bangNam = `
Đã mua theo năm
${renderBangTheoNam(muaHang)}`; if(!muaHang.totalCount){ box.innerHTML = '
Chưa có Đơn mua hàng nào.
' + bangNam; return; } box.innerHTML = `
${muaHang.totalCount}lần mua ${fmt(muaHang.totalValue)}tổng giá trị ${getSupplierDebt(s)>0 ? `${fmt(getSupplierDebt(s))}công nợ phải trả` : ''}
` + bangNam; } function openSupplierModal(id){ editingSupplierId = id || null; currentMaterialTags = []; const s = id ? suppliers.find(x=>x.id===id) : null; document.getElementById('ncc_modalTitle').textContent = s ? 'Sửa nhà cung cấp' : 'Thêm nhà cung cấp mới'; document.getElementById('ncc_printBtn').style.display = s ? 'inline-block' : 'none'; populateTinhThanhOptions('s_tinh'); document.getElementById('s_code').value = s ? s.code : '(tự sinh khi lưu)'; document.getElementById('s_name').value = s ? s.name : ''; document.getElementById('s_contact').value = s ? (s.contact||'') : ''; document.getElementById('s_phone').value = s ? s.phone : ''; document.getElementById('s_taxcode').value = s ? (s.taxCode||'') : ''; document.getElementById('s_zalo').value = s ? (s.zalo||'') : ''; document.getElementById('s_address').value = s ? (s.address||'') : ''; document.getElementById('s_tinh').value = s ? (s.tinhThanh||'') : ''; document.getElementById('s_payment_terms').value = s ? (s.paymentTerms||'') : ''; document.getElementById('s_rating').value = s ? (s.rating||'binh_thuong') : 'binh_thuong'; document.getElementById('s_status').value = s ? s.status : 'active'; document.getElementById('s_last_contact').value = s ? (s.lastContact||'') : ''; document.getElementById('s_notes').value = s ? (s.notes||'') : ''; document.getElementById('s_type').value = s ? (s.type||'NM') : 'NM'; document.getElementById('s_toc_do_giao').value = s ? (s.tocDoGiao||'trung_binh') : 'trung_binh'; document.getElementById('s_khoang_cach_km').value = s ? (s.khoangCachKm||'') : ''; document.getElementById('s_no_goi_dau').value = s && s.noGoiDau ? 'co' : 'khong'; document.getElementById('s_co_vat').checked = s ? !!s.coVAT : false; document.getElementById('s_hay_can_thieu').checked = s ? !!s.hayCanThieu : false; document.getElementById('s_khong_co_cq').checked = s ? !!s.khongCoCQ : false; document.getElementById('s_vung_loi_tac_duong').checked = s ? !!s.vungLoiTacDuong : false; document.getElementById('s_hop_dong_khung').checked = s ? !!s.hopDongKhung : false; document.getElementById('s_hay_tang_gia').checked = s ? !!s.hayTangGiaDotNgot : false; document.getElementById('s_nguon_duy_nhat').checked = s ? !!s.nguonDuyNhat : false; currentMaterialTags = s ? [...(s.tags||[])] : []; currentGhiChuTags = s ? [...(s.ghiChuTags||[])] : []; renderMaterialTagBox(); renderGhiChuTagBox(); renderDebtBox(s); renderChatBox('ncc_chatBox', s ? (s.chat||[]) : []); document.getElementById('ncc_overlay').classList.add('open'); } function closeSupplierModal(){ document.getElementById('ncc_overlay').classList.remove('open'); } function guiChatNCC(){ const input = document.getElementById('ncc_chatInput'); const noiDung = input.value.trim(); if(!noiDung) return; if(!editingSupplierId){ alert('Lưu nhà cung cấp trước khi thêm ghi chú.'); return; } const s = suppliers.find(x=>x.id===editingSupplierId); if(!s.chat) s.chat = []; s.chat.push({nguoi:'Hương', thoiGian: new Date().toLocaleString('vi-VN',{hour:'2-digit',minute:'2-digit',day:'2-digit',month:'2-digit'}), noiDung}); persistSuppliers(); input.value = ''; renderChatBox('ncc_chatBox', s.chat); } async function saveSupplier(){ const name = document.getElementById('s_name').value.trim(); const phone = document.getElementById('s_phone').value.trim(); if(!name || !phone){ alert('Vui lòng nhập Tên nhà cung cấp và Số điện thoại.'); return; } const dup = suppliers.find(s => s.phone === phone && s.id !== editingSupplierId); if(dup){ alert('Số điện thoại này đã tồn tại cho NCC "' + dup.name + '". Vui lòng kiểm tra lại (chống trùng theo SĐT).'); return; } const data = { name, phone, contact: document.getElementById('s_contact').value.trim(), taxCode: document.getElementById('s_taxcode').value.trim(), zalo: document.getElementById('s_zalo').value.trim(), address: document.getElementById('s_address').value.trim(), tinhThanh: document.getElementById('s_tinh').value, paymentTerms: document.getElementById('s_payment_terms').value.trim(), rating: document.getElementById('s_rating').value, status: document.getElementById('s_status').value, lastContact: document.getElementById('s_last_contact').value, notes: document.getElementById('s_notes').value, tags: [...currentMaterialTags], ghiChuTags: [...currentGhiChuTags], type: document.getElementById('s_type').value, tocDoGiao: document.getElementById('s_toc_do_giao').value, khoangCachKm: document.getElementById('s_khoang_cach_km').value ? parseFloat(document.getElementById('s_khoang_cach_km').value) : null, vungLoiTacDuong: document.getElementById('s_vung_loi_tac_duong').checked, noGoiDau: document.getElementById('s_no_goi_dau').value === 'co', coVAT: document.getElementById('s_co_vat').checked, hopDongKhung: document.getElementById('s_hop_dong_khung').checked, hayCanThieu: document.getElementById('s_hay_can_thieu').checked, khongCoCQ: document.getElementById('s_khong_co_cq').checked, hayTangGiaDotNgot: document.getElementById('s_hay_tang_gia').checked, nguonDuyNhat: document.getElementById('s_nguon_duy_nhat').checked, }; if(editingSupplierId){ const idx = suppliers.findIndex(s=>s.id===editingSupplierId); suppliers[idx] = {...suppliers[idx], ...data}; ghiNhatKy('Nhà cung cấp', 'Sửa NCC', suppliers[idx].code, name); } else { const code = genCodeNCC(); suppliers.push({id: uid(), code, orderCount:0, totalValue:0, currentDebt:0, ...data}); ghiNhatKy('Nhà cung cấp', 'Thêm NCC', code, name); } await persistSuppliers(); populateMaterialFilter(); closeSupplierModal(); renderSuppliers(); } function printSupplierProfile(){ if(!editingSupplierId) return; const s = suppliers.find(x=>x.id===editingSupplierId); if(!s) return; const muaHang = tinhMuaHangTheoNam(s.code); document.getElementById('ncc_printArea').innerHTML = ` ${(s.ghiChuTags&&s.ghiChuTags.length) ? `` : ''} ${(s.hayCanThieu||s.khongCoCQ||s.hayTangGiaDotNgot||s.nguonDuyNhat) ? `` : ''} ${s.notes ? `` : ''}
In ngày: ${new Date().toLocaleDateString('vi-VN')}
`; window.print(); } loadSupplierData(); // ===== ĐƠN MUA HÀNG (purchase_orders) - Tầng 1, logic THẬT mới code lần đầu ===== // (trước đây dps-ncc-shell.html chỉ có form tĩnh, không có
🤖 Trợ lý AI — DPS