Add order PDF/Excel import + merge

#30
by bep40 - opened

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
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

  1. Open space → Đơn hàng modal (admin) → button "Import PDF/Excel".
  2. 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ú).
  3. 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;')}
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.

Publish this branch
This branch is in draft mode, publish it to be able to merge.

Sign up or log in to comment