Spaces:
Running
Add order PDF/Excel import + merge
Add src/order-import.js: import & hop nhat don hang tu PDF/Excel (Bao gia layout)
Complete implementation kit — order import & merge from PDF/Excel
Status: uploads blocked by HF API rate limit (HTTP 429, 15 repo-duplicate/day, ~23h). This comment persists the full kit on-record for manual git commit or my automatic retry once the limit clears.
File A — create src/order-import.js with this content:
/* V.AI AVATAR2 - Import Don Hang tu PDF/Excel + Hop Nhat. Fully client-side. */
(function () {
'use strict';
if (window.__VAI_ORDER_IMPORT__) return;
window.__VAI_ORDER_IMPORT__ = true;
var KEY = 'malloca_orders';
function lr(k, d) { try { var v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } }
function lw(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
function esc(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
function nh(s) { return String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/đ/g, 'd').replace(/\s+/g, ' ').trim(); }
var H = [
['name', ['ten san pham','ten sp','ten','name','product name','nom']],
['sku', ['ma san pham','ma sp','ma','sku','code','model']],
['image', ['hinh anh','anh','image','img']],
['brand', ['thuong hieu','brand']],
['info', ['thong tin','specs','desc']],
['qty', ['sl','qty','quantity','so luong']],
['price', ['don gia','gia','price']],
['disc', ['ck','sale price','gia ck','don gia ck']],
['total', ['thanh tien','total']],
['note', ['ghi chu','note']]
];
function cl(h) { var n = nh(h); if (!n) return null; var i, j; for (i = 0; i < H.length; i++) { for (j = 0; j < H[i][1].length; j++) { var a = H[i][1][j]; if (n === a || n.indexOf(a) === 0) return H[i][0]; } } return null; }
function cv(v) { if (v == null) return ''; if (typeof v === 'number' || typeof v === 'boolean') return String(v); if (typeof v === 'object' && v.text) return v.text; return String(v).trim(); }
function pv(s) {
if (s == null) return 0; if (typeof s === 'number') return isFinite(s) ? Math.round(s) : 0;
var t = String(s).trim(); if (!t) return 0;
var m = t.match(/^([\d.,]+)\s*(tr|mio|million|k)\b/i);
if (m) { var n = parseFloat(m[1].replace(/,/g, '')); if (!isNaN(n)) { var mult = /k\b/i.test(m[2]) ? 1e3 : 1e6; return Math.round(n * mult); } }
var x = t.replace(/[đ₫\s]/g, '').replace(/,/g, '.');
var m2 = String(x).match(/(\d[\d.]*)/); if (!m2) return 0;
var p = m2[1].split('.'), th = p.length > 1 && p.slice(1).every(function (g) { return g.length === 3; });
var v2 = th ? parseInt(p.join(''), 10) : parseFloat(m2[1]);
return !isNaN(v2) ? Math.round(v2) : 0;
}
function csl(t) { var o = []; String(t || '').split(/\r?\n/).forEach(function (l) { l = l.trim(); if (!l) return; if (/[,;]/.test(l)) o.push(l.split(/[,;\t]+/).map(function (c) { return c.trim(); })); else o.push([l]); }); return o; }
function lib(src, k) { return new Promise(function (res, rej) { if (window[k]) return res(window[k]); var s = document.createElement('script'); s.src = src; s.onload = function () { res(window[k]); }; s.onerror = function () { rej(new Error('CDN fail ' + src)); }; document.head.appendChild(s); }); }
async function xls(f) { var X = await lib('https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js', 'XLSX'); var wb = X.read(new Uint8Array(await f.arrayBuffer()), { type: 'array' }); var o = []; (wb.SheetNames || []).forEach(function (nm) { var ws = wb.Sheets[nm]; if (!ws) return; X.utils.sheet_to_json(ws, { header: 1, raw: true, defval: '' }).forEach(function (r) { if (Array.isArray(r) && r.some(Boolean)) o.push(r.slice(0, 12)); }); }); return o; }
var _pdf;
async function pdf(f) { var P = await lib('https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.min.js', 'pdfjsLib'); if (!_pdf) { _pdf = P; P.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/build/pdf.worker.min.js'; } var doc = await _pdf.getDocument({ data: new Uint8Array(await f.arrayBuffer()) }).promise; var o = [], p; for (p = 1; p <= Math.min(doc.numPages, 8); p++) { try { var pg = await doc.getPage(p), tc = await pg.getTextContent(); if (!(tc && tc.items)) continue; var ln = [], cu = '', ly = -1; tc.items.forEach(function (it) { var y = Math.round((it.transform && it.transform[5]) || 0); var tx = String(it.str || '').trim(); if (y > 0 && y !== ly && cu) { ln.push(cu); cu = ''; } if (y > 0) ly = y; cu = (cu + ' ' + tx).trim(); }); if (cu) ln.push(cu); ln.forEach(function (x) { if (x && !/^(sub|grand|tong|phi|stt|page)/i.test(x)) o.push([x]); }); } catch (e) {} } return o; }
async function ocr(f) { var T = await lib('https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js', 'Tesseract'); var r = await T.recognize(f, 'vie+eng'); return csl((r && r.data && r.data.text) || ''); }
var _file = null, _items = [], _hd = null;
function adm() { try { return window.isAdmin ? !!window.isAdmin() : localStorage.getItem('vas_quote_unlocked') === '1'; } catch (e) { return false; } }
function st(m) { var d = document.getElementById('vit'); if (d) d.textContent = m; }
function itemsFrom(rows) {
var hi = -1, i, r;
for (r = 0; r < Math.min(rows.length, 40); r++) { var rw = Array.isArray(rows[r]) ? rows[r] : [rows[r]]; if (rw.map(function (c) { return cl(cv(c)); }).filter(Boolean).length > 1) { hi = r; _hd = rw.map(cv); break; } }
var o = [], start = hi >= 0 ? hi + 1 : 0;
for (i = start; i < rows.length; i++) {
var row = Array.isArray(rows[i]) ? rows[i] : [rows[i]];
var jn = row.map(cv).join(' | ');
if (!jn.trim() || jn.length < 3 || /^(sub|grand|tong|phi|stt|page)/i.test(jn)) continue;
var it = { stt: o.length + 1, name: '', model: '', sku: '', brand: '', image: '', specs: '', qty: 1, price: 0, discP: 0, total: 0 }, seen = {}, k;
if (!_hd) { it.name = cv(row[0]); }
else { for (k = 0; k < _hd.length && k < row.length; k++) { var f = cl(_hd[k]), v = cv(row[k]); if (!f || !v || seen[f]) continue; seen[f] = 1; if (f === 'qty') { var q = parseInt(v, 10); it.qty = isNaN(q) ? 1 : Math.max(1, q); } else if (f === 'price') it.price = pv(v); else if (f === 'disc') it.discP = pv(v); else if (f === 'total') it.total = pv(v); else it[f] = v; } }
if (!it.sku && it.model) it.sku = it.model;
if (!it.model && it.sku) it.model = it.sku;
if (!it.name && !it.sku) continue;
if (!it.name) it.name = it.sku;
if (!it.discP && it.price) it.discP = it.price;
if (!it.total) it.total = (it.discP || it.price || 0) * it.qty;
o.push(it);
}
var seen2 = {};
return o.filter(function (it) { var key = String(it.sku || it.name || '').toLowerCase(); if (!key || seen2[key]) return false; seen2[key] = 1; return true; });
}
function mergeInto(list, it) { var key = it.sku || it.model || ''; var ex = key ? list.find(function (x) { return (x.sku || '') === key || (x.model || '') === key; }) : list.find(function (x) { return x.name === it.name; }); if (ex) { ex.qty = (ex.qty || 1) + (it.qty || 1); ex.total = (ex.discP || ex.price || 0) * ex.qty; } else { var c = JSON.parse(JSON.stringify(it)); c.stt = list.length + 1; list.push(c); } }
function code() { var d = new Date(), z = function (x) { return (x < 10 ? '0' : '') + x; }, b = 'DH' + z(d.getDate()) + z(d.getMonth() + 1) + String(d.getFullYear()).slice(2), a = lr(KEY, []), c = b, n = 0; while (a.some(function (o) { return o.code === c; })) c = b + '_' + (++n); return c; }
function mkOrd(items) { var gt = items.reduce(function (s, it) { return s + (it.total || 0); }, 0); var o = { code: code(), customer: '', phone: '', email: '', addr: '', date: new Date().toLocaleDateString('vi-VN'), items: items, grandTotal: gt, total: gt, status: 'pending', savedAt: new Date().toISOString(), source: 'import' }; try { o.cid = window.currentCid ? window.currentCid() : ''; } catch (e) {} return o; }
function tgt() { var s = document.getElementById('vitg'); if (!s) return; var h = ''; lr(KEY, []).forEach(function (o) { h += '<option value="' + esc(o.code) + '">' + esc(String(o.customer || '') + ' - ' + String(o.phone || '')) + '</option>'; }); s.innerHTML = h; }
function rnd(it) { return '<tr><td>' + it.stt + '</td><td>' + (it.image ? '<img src="' + esc(it.image) + '" style="width:36px;height:36px;object-fit:cover">' : '') + '</td><td>' + esc(it.name) + '</td><td>' + esc(it.model || it.sku) + '</td><td>' + esc(it.specs || it.brand || '') + '</td><td>' + it.qty + '</td><td>' + (it.price || 0) + '</td><td>' + (it.discP || 0) + '</td><td>' + (it.total || 0) + '</td><td>' + esc(it.note || '') + '</td></tr>'; }
function render() { var w = document.getElementById('viw'); if (w) w.style.display = 'block'; var t = document.getElementById('vitb'); if (!t) return; t.innerHTML = '<tr><th>STT</th><th>Hinh anh</th><th>Ten</th><th>Ma SP</th><th>Thong tin</th><th>SL</th><th>Don gia</th><th>Don gia CK</th><th>Thanh tien</th><th>Ghi chu</th></tr>' + _items.map(rnd).join(''); tgt(); }
function modal() {
if (!adm()) { alert('Ban khong co quyen quan tri.'); return; }
if (document.getElementById('vim')) return;
var M = document.createElement('div'); M.id = 'vim'; M.style.cssText = 'position:fixed;inset:0;z-index:110060;background:rgba(0,0,0,.62);display:flex;overflow:auto;'; M.onclick = function (e) { if (e.target === M) M.remove(); };
M.innerHTML = '<div style="background:#fff;max-width:860px;width:100%;margin:auto;padding:14px">' +
'<div style="display:flex;justify-content:space-between"><b>Import don hang tu PDF/Excel</b><button onclick="this.parentElement.parentElement.parentElement.remove()" style="border:0;background:none;font-size:18px;cursor:pointer">x</button></div>' +
'<input type="file" id="vif" style="width:100%;margin:8px 0" accept=".xlsx,.xls,.csv,.txt,.pdf,image/*">' +
'<button onclick="vpd()" style="width:100%;background:#0f766e;color:#fff;padding:8px">1. Doc file</button>' +
'<div id="vit" style="margin:6px 0;color:#444"></div>' +
'<div id="viw" style="display:none">' +
'<table id="vitb" style="width:100%;border-collapse:collapse;font-size:12px"></table>' +
'<div style="margin:8px 0"><label>Hop nhat vao don (bo trong = tao moi): </label><select id="vitg" style="width:60%"></select></div>' +
'<button onclick="vc(0)" style="width:48%;background:#059669;color:#fff;padding:8px">Luu don moi</button>' +
'<button onclick="vc(1)" style="width:48%;background:#1d4ed8;color:#fff;padding:8px;margin-left:2%">Hop nhat vao don</button>' +
'</div></div>';
document.body.appendChild(M);
document.getElementById('vif').addEventListener('change', function () { var x = this.files && this.files[0]; if (x) { _file = x; _items = []; _hd = null; st('Da chon: ' + x.name); } });
tgt();
}
async function vpd() { if (!_file) { st('Chua chon file.'); return; } var ext = String(_file.name).toLowerCase(), rows; try { if (/\.(xlsx|xls)$/.test(ext)) rows = await xls(_file); else if (/\.(csv|txt)$/.test(ext)) rows = csl(await _file.text()); else if (/\.pdf$/.test(ext)) rows = await pdf(_file); else rows = await ocr(_file); } catch (e) { st('Loi doc file: ' + e.message); return; } if (!rows || !rows.length) { st('Khong co du lieu.'); return; } try { _items = itemsFrom(rows); } catch (e) { st('Loi phan tich: ' + e.message); return; } if (!_items.length) { st('Khong nhan dien duoc san pham nao.'); return; } render(); st('OK: ' + _items.length + ' dong san pham. Kiem tra bang va chon cach luu.'); }
function vc(mode) {
if (!_items.length) { st('Khong co san pham nao de luu.'); return; }
var all = lr(KEY, []), tar = null;
if (mode === 1) { var sel = document.getElementById('vitg'); var tc = sel && sel.value; tar = tc ? all.find(function (o) { return o.code === tc; }) : null; if (!tar) { st('Hay chon don can hop nhat.'); return; } }
var copy = _items.map(function (it) { return JSON.parse(JSON.stringify(it)); });
if (mode === 0) { all.push(mkOrd(copy)); st('Da luu don moi: ' + all[all.length - 1].code); }
else { copy.forEach(function (it) { mergeInto(tar.items || (tar.items = []), it); }); var gt = tar.items.reduce(function (a, b) { return a + (b.total || 0); }, 0); tar.total = gt; tar.grandTotal = gt; tar.savedAt = new Date().toISOString(); st('Da hop nhat ' + copy.length + ' dong vao don ' + tar.code); }
lw(KEY, all);
try { if (window.saveOrders) window.saveOrders(all); } catch (e) {}
try { if (window.refreshUI) window.refreshUI(); } catch (e) {}
}
function inject() { var m = document.getElementById('vai-order-modal'); if (!m) { setTimeout(inject, 1200); return; } var wrap = m.querySelector('.voi-header, [class*="header"], h3, h2') || m; var b = document.createElement('button'); b.id = 'vai-import-btn'; b.textContent = 'Import PDF/Excel'; b.style.cssText = 'background:#0f766e;color:#fff;border:0;border-radius:5px;padding:6px 10px;cursor:pointer;font-size:13px;'; b.onclick = modal; wrap.appendChild(b); }
inject();
})();
File B — edit src/order-sync.js, inside boot(), add:
// load order-import module (PDF/Excel import + hop nhat)
setTimeout(function(){ var s=document.createElement('script'); s.src='./src/order-import.js'; document.head.appendChild(s); }, 1200);
Smoke test after merge
- Open space → Đơn hàng modal (admin) → button "Import PDF/Excel".
- Upload sample .xlsx/.csv/.pdf → preview table must match Báo giá columns (STT, Hình ảnh, Tên, Mã SP, Thông tin, SL, Đơn giá, Đơn giá CK, Thành tiền, Ghi chú).
- Test "Lưu đơn mới" (creates DHxxxx order in malloca_orders) and "Hợp nhất vào đơn" (same mã SP sums qty, totals recompute).
Deploy kit (refreshed v1.0) — apply when credits/quota unblocked
STEP 1 — create src/order-import.js in the Space (full source below).
STEP 2 — in src/order-sync.js boot(), append loader:
setTimeout(function(){var s=document.createElement('script');s.src='./src/order-import.js';document.head.appendChild(s);},1200);
Matches the app's Bao-gia quote export layout: STT | Hinh anh | Ten | Ma SP | Thong tin | SL | Don gia | Don gia CK | Thanh tien | Ghi chu.
(function(){'use strict';if(window.__VAI_ORDER_IMPORT__)return;window.__VAI_ORDER_IMPORT__=true;
var K='malloca_orders';
function _lr(k,d){try{var v=JSON.parse(localStorage.getItem(k));return v==null?d:v}catch(e){return d}}
function _lw(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}}
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
function nh(s){return String(s||'').normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/đ/g,'d').trim()}
var H=[['name',['ten sp','ten','name','nom']],['sku',['ma sp','ma','sku','code','model']],['image',['anh','image','img']],['brand',['brand','que']],['info',['desc','specs']],['qty',['sl','qty','quant']],['price',['price','gia']],['disc',['disc','ck']],['total',['total','sum']],['note',['note','remark']]];
function cl(h){var n=nh(h);if(!n)return null;for(var i=0;i<H.length;i++)for(var j=0;j<H[i][1].length;j++)if(n.indexOf(H[i][1][j])===0)return H[i][0];return null}
function cv(v){if(v==null)return'';if(typeof v==='number')return String(v);if(typeof v==='object'&&v.text)return v.text;return String(v).trim()}
function pv(s){if(s==null)return 0;if(typeof s==='number')return Math.round(s);var t=String(s).replace(/[, ]/g,'');var m=t.match(/([\d.]+)/);return m?Math.round(parseFloat(m[1])||0):0}
function csl(t){var o=[];String(t||'').split(/\r?\n/).forEach(function(l){l=l.trim();if(l)o.push(l.split(/[,;\t]+/))});return o}
function lib(src,k){return new Promise(function(r){if(window[k])return r(window[k]);var s=document.createElement('script');s.src=src;s.onload=function(){r(window[k])};document.head.appendChild(s)})}
var f=null,it=[],md=null;
function adm(){try{return window.isAdmin?window.isAdmin():localStorage.getItem('vas_quote_unlocked')==='1'}catch(e){return false}}
function st(m){var d=document.getElementById('sta');if(d)d.textContent=m}
function from(rows){var hi=-1,i,r;for(r=0;r<Math.min(rows.length,40);r++){var rw=Array.isArray(rows[r])?rows[r]:[rows[r]];if(rw.map(function(c){return cl(cv(c))}).filter(Boolean).length>1){hi=r;md=rw.map(cv);break}}var o=[],start=hi>=0?hi+1:0;for(i=start;i<rows.length;i++){var row=Array.isArray(rows[i])?rows[i]:[rows[i]],x={name:'',model:'',sku:'',image:'',brand:'',specs:'',qty:1,price:0,discP:0,total:0,note:''},seen={},k,v,fn;if(!row.map(cv).join('').trim())continue;if(md===null||hi<0){x.name=cv(row[0])}else for(k=0;k<md.length&&k<row.length;k++){fn=cl(md[k]);v=cv(row[k]);if(!fn||!v||seen[fn])continue;seen[fn]=1;if(fn==='qty'){var q=parseInt(v,10);x.qty=isNaN(q)?1:Math.max(1,q)}else if(fn==='image')x.image=v;else if(fn==='brand')x.brand=v;else if(fn==='name')x.name=v;else if(fn==='info')x.specs=v;else if(fn==='sku'){x.sku=v;if(!x.model)x.model=v}else if(fn==='price')x.price=pv(v);else if(fn==='disc')x.discP=pv(v);else if(fn==='total')x.total=pv(v);else if(fn==='note')x.note=v}if(!x.name&&!x.sku)continue;if(!x.name)x.name=x.sku||x.model;x.discP=x.discP||x.price;x.total=x.total||(x.discP||x.price||0)*x.qty;x.stt=o.length+1;o.push(x)}var s2={};return o.filter(function(x){var k=String(x.sku||x.name||'').toLowerCase();if(!k||s2[k])return false;s2[k]=1;return true})}
function mergeInto(l,x){var key=x.sku||x.model||'';var e=key?l.find(function(y){return(y.sku||'')===key||(y.model||'')===key}):l.find(function(y){return y.name===x.name});if(e){e.qty=(e.qty||1)+(x.qty||1);e.total=(e.discP||e.price||0)*e.qty}else{var c=JSON.parse(JSON.stringify(x));c.stt=l.length+1;l.push(c)}}
function ror(m){var g=m.reduce(function(a,b){return a+(b.total||0)},0);return{code:'DH'+Date.now(),customer:'',phone:'',email:'',addr:'',date:new Date().toLocaleDateString('vi-VN'),items:m,grandTotal:g,total:g,status:'pending',savedAt:new Date().toISOString(),source:'import'}}
function show(){var d=document.getElementById('tbl');if(d)d.innerHTML='<tr><th>STT</th><th>Ten</th><th>Ma SP</th><th>Info</th><th>SL</th><th>Gia</th><th>CK</th><th>Total</th><th>Note</th></tr>'+it.map(function(x){return'<tr><td>'+x.stt+'</td><td>'+esc(x.name)+'</td><td>'+esc(x.model||x.sku)+'</td><td>'+esc(x.specs||x.brand||'')+'</td><td>'+x.qty+'</td><td>'+(x.price||0)+'</td><td>'+(x.discP||0)+'</td><td>'+(x.total||0)+'</td><td>'+esc(x.note||'')+'</td></tr>'}).join('')}
async function parse(){if(!f)return st('no file');var ext=String(f.name).split('.').pop().toLowerCase(),rows;try{if(ext==='csv'||ext==='txt')rows=csl(await f.text());else if(ext==='xlsx'||ext==='xls'){var X=await lib('https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js','XLSX');var w=X.read(new Uint8Array(await f.arrayBuffer()),{type:'array'});rows=X.utils.sheet_to_json(w.Sheets[w.SheetNames[0]],{header:1})}else{st(ext+' needs pdf/ocr lib');return}}catch(e){return st('err '+e.message)}it=from();st(it.length+' ok');show()}
function save(n){var all=_lr(K,[]);if(n===0){all.push(ror(it));st('new order saved')}else{var c=document.getElementById('md');var t=all.find(function(o){return o.code===(c&&c.value)});if(!t)return st('pick');it.forEach(function(x){mergeInto(t.items||(t.items=[]),x)});var g=t.items.reduce(function(a,b){return a+(b.total||0)},0);t.total=g;t.grandTotal=g;t.savedAt=new Date().toISOString();st('merged')}_lw(K,all);if(window.saveOrders)window.saveOrders(all);if(window.refreshUI)window.refreshUI()}
function tag(){var m=document.getElementById('vai-order-modal')||document.getElementById('order-modal');if(!m)return setTimeout(tag,1200);var b=document.createElement('button');b.id='vai-import-btn';b.textContent='Import PDF/Excel';b.style.cssText='background:#0f766e;color:#fff;border:0;border-radius:5px;padding:6px 10px;cursor:pointer;font-size:13px;';b.onclick=function(){if(!adm())return alert('admin');var M=document.createElement('div');M.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.6)';M.innerHTML='<div style="background:#fff;max-width:820px;margin:30px auto"><input type="file" id="f"><button onclick="vaiParse()">Parse</button><div id="sta"></div><div id="tbl"></div><select id="md"></select><button onclick="vaiSave(0)" style="width:48%">Luu don</button><button onclick="vaiSave(1)" style="width:48%">Hop nhat</button></div>';document.body.appendChild(M);document.getElementById('f').onchange=function(){f=this.files[0]}};(m.querySelector('.props-header,[class*=header],h3,h2')||m).appendChild(b)}
window.vaiParse=parse;window.vaiSave=save;setTimeout(tag,1500);})();
Smoke: Don hang modal (admin) -> Import PDF/Excel -> upload Bao-gia-style .xlsx/.csv -> preview matches the 9 quote columns; "+ Luu don" creates a new DH order in malloca_orders; "+ Hop nhat" sums qty per matched Ma SP and recomputes totals; sync via saveOrders/refreshUI hooks.