Chuẩn bị trước khi bắt đầu
Bạn nên làm trên máy tính và chuẩn bị đủ 4 thứ sau:
- Một tài khoản Google mở được Google Sheet.
- Shop đã kết nối trong portal Logidata. Vào mục Kết nối → Thêm kết nối, chọn nền tảng rồi đăng nhập Shopee theo hướng dẫn. Muốn lấy quảng cáo và affiliate, hãy kết nối thêm Shopee Ads và Shopee Affiliate.
- API key (bắt đầu bằng
ld_live_). Vào mục API key → Tạo API key, chọn mức quyền Đầy đủ và tích các kết nối của shop, rồi bấm Copy key và cất lại trước khi bấm Tôi đã copy - đóng. - ID shop: dãy số ở cột Tài khoản trong mục Kết nối của portal, ví dụ
28461937. Trong Google Sheet, ô nhập ID shop có tên Account ID. Xem cách lấy API key và ID shop.
Logidata không xem lại được key sau khi bạn đóng cửa sổ. Hãy lưu vào nơi an toàn và không gửi key cho người khác.
Chọn cách cài
Cách nhanh: tạo bản sao file mẫu
Script đã có sẵn trong file, bạn không cần vào Apps Script để dán code.
- Đăng nhập đúng tài khoản Google bạn muốn dùng, rồi bấm nút bên dưới.
- Google mở trang Sao chép tài liệu, bấm Tạo một bản sao.
- Khi file mới mở ra, bấm vào tên file ở góc trên bên trái và đổi theo nhu cầu của bạn, ví dụ "Dữ liệu Shopee - Tên shop". Nên đổi trước khi kéo dữ liệu, vì file JSON gốc sẽ lấy tên theo file này.
- Làm tiếp từ bước 3.
Cách thủ công: tự dán code
Phù hợp khi bạn cần cập nhật bản mới cho file đang dùng, hoặc không tạo được bản sao.
Làm từ bước 1Bản sao thuộc về tài khoản Google của bạn và nằm trong Drive của bạn. Bản sao đã có sẵn tab Hướng dẫn nên có thể khác ảnh ở bước 3 một chút. Bạn vẫn cần cấp quyền và điền API key như các bước 4 đến 7, và tên dự án trong cảnh báo của Google có thể khác ảnh ở bước 5.
1Tạo Sheet mới và mở Apps Script
- Vào Google Drive, tạo một Google Sheet mới. Bạn có thể đặt tên file tuỳ ý.
- Trên thanh menu của Sheet, bấm Tiện ích (một số máy hiện là Tiện ích mở rộng).
- Chọn Apps Script. Một tab mới sẽ mở ra.
2Xoá code cũ, dán code Logidata và lưu
- Bấm nút Copy toàn bộ code bên dưới.
- Quay lại tab Apps Script, bấm vào vùng soạn thảo rồi nhấn Ctrl + A và Delete để xoá đoạn code có sẵn.
- Nhấn Ctrl + V để dán.
- Nhấn Ctrl + S để lưu. Trên máy Mac, dùng Cmd thay cho Ctrl.
/**
* KÉO DỮ LIỆU SHOPEE QUA REST LOGIDATA VÀO GOOGLE SHEET (phiên bản 2.5)
*
* Kéo 9 nhóm: orders, order_items, escrow, escrow_items, ads_daily, returns, wallet, danh_gia, affiliate. Gồm cả thông tin
* người mua và người nhận mà Shopee trả về (Shopee tự che một phần ở đơn đã hoàn tất, script không can thiệp được).
* Phản hồi gốc (JSON) của mọi API lưu tách khỏi các tab chính để file nhẹ.
* Phát triển bởi Logidata · https://logidata.vn/ · Hotline 0936.1688.92 / 090.2109.991
*
* CÀI ĐẶT (chỉ làm một lần)
* 1. Mở Google Sheet → Tiện ích mở rộng → Apps Script. Xoá code cũ, dán toàn bộ file này, bấm Lưu.
* 2. Quay lại Sheet, tải lại trang (F5). Trên thanh menu có thêm mục "🛒 Shopee".
* 3. 🛒 Shopee → 🔑 Cài đặt kết nối. Mọi thao tác sau đó đều làm trên Sheet, xem tab "Hướng dẫn".
*/
const PHIEN_BAN = '2.5';
const HOST = 'https://api.logidata.vn/v1';
const TZ = 'Asia/Ho_Chi_Minh';
const TAB_HD = 'Hướng dẫn';
const LOGIDATA = {
web: 'https://logidata.vn/', hotline: ['0936.1688.92', '090.2109.991'],
email: '', // email liên hệ; để trống thì không hiện
giaiPhap: ['Dịch vụ Dữ liệu Toàn diện - Xây dựng Data Warehouse', 'Công cụ Trí tuệ Nhân tạo & Tự động hóa', 'Tư vấn Chuyển đổi số',
'Tư vấn Growth Marketing và các chiến dịch làm giàu data'],
};
// Dòng giới thiệu Logidata dùng chung: dải đầu tab Hướng dẫn, chân hộp thoại, tab cai_dat / nhat_ky, thông báo sau mỗi lần kéo
function dongLogidata() {
return '💼 Phát triển bởi LOGIDATA · 🌐 logidata.vn · ☎ ' + LOGIDATA.hotline.join(' / ') + (LOGIDATA.email ? ' · ✉ ' + LOGIDATA.email : '');
}
// Ghi dòng Logidata vào hàng tiêu đề của tab (hàng 1 luôn được cố định nên luôn nhìn thấy), chỉ ghi khi nội dung khác
function datThuongHieu(sh, cot) {
const o = sh.getRange(1, cot), s = dongLogidata();
if (o.getValue() === s) return;
o.setValue(s).setBackground('#0B3B75').setFontColor('#FFFFFF').setFontWeight('bold');
sh.setColumnWidth(cot, 560);
}
const CFG = { // giá trị mặc định, bị ghi đè bởi tab cai_dat
SO_NGAY_KEO_BU: 30, // lần đầu chạy kéo bấy nhiêu ngày gần nhất
NGAY_LAM_MOI: 3, // sau đó mỗi lần chạy kéo lại các đơn thay đổi trong N ngày
JSON: 'File riêng',
SO_NGAY_KHOANG: 30, // "Kéo theo khoảng ngày" để trống thì kéo bấy nhiêu ngày gần nhất
NGAY_LAM_MOI_HOAN: 30, // mỗi lần làm mới, cập nhật lại các yêu cầu trả hàng tạo trong N ngày gần nhất
NGAN_SACH_PHUT: 4, // quá mốc này thì không bắt đầu việc mới
GIOI_HAN_PHUT: 4.8, // Google dừng script ở 6 phút: quá mốc này thì dừng ngay, phần đang dở kéo lại lần sau
};
const CAI_DAT = [ // [nhãn trong tab cai_dat, khoá CFG, giải thích]
['Số ngày kéo lần đầu', 'SO_NGAY_KEO_BU', 'Lần đầu chạy, script kéo bấy nhiêu ngày gần nhất (30 = 1 tháng). Muốn lấy một khoảng bất kỳ: menu 🛒 Shopee → 📅 Kéo theo khoảng ngày.'],
['Số ngày làm mới', 'NGAY_LAM_MOI', 'Mỗi lần chạy, cập nhật lại các đơn thay đổi trong bấy nhiêu ngày gần nhất. Thường không cần sửa.'],
['Lưu JSON gốc', 'JSON', 'File riêng (khuyên dùng, file chính nhẹ) / Tab riêng / Không lưu.'],
['File JSON gốc', '', 'Script tự điền đường dẫn khi tạo file riêng.'],
];
const CHE_DO_JSON = ['File riêng', 'Tab riêng', 'Không lưu'];
const HET_GIO = 'HET_GIO';
const T0 = Date.now();
const NK = [], DEM = {}; // ghi chú và số dòng ghi của lần chạy này, đưa vào tab nhat_ky
const daChay = () => Date.now() - T0;
const hetGio = () => daChay() > CFG.NGAN_SACH_PHUT * 60000;
function kiemTraGio() { if (daChay() > CFG.GIOI_HAN_PHUT * 60000) throw new Error(HET_GIO); }
// Thử lần lượt: đầy đủ nhất, rồi mức vừa, rồi mức đã kiểm chứng chạy được.
const TRUONG_CHI_TIET = [
'buyer_user_id,buyer_username,estimated_shipping_fee,recipient_address,actual_shipping_fee,goods_to_declare,note,note_update_time,item_list,pay_time,dropshipper,dropshipper_phone,split_up,buyer_cancel_reason,cancel_by,cancel_reason,actual_shipping_fee_confirmed,buyer_cpf_id,fulfillment_flag,pickup_done_time,package_list,shipping_carrier,payment_method,total_amount,invoice_data,order_chargeable_weight_gram,return_request_due_date,edt,payment_info',
'buyer_user_id,buyer_username,estimated_shipping_fee,recipient_address,actual_shipping_fee,note,item_list,pay_time,buyer_cancel_reason,cancel_by,cancel_reason,package_list,shipping_carrier,payment_method,total_amount',
'item_list,cancel_reason,cancel_by,buyer_cancel_reason,shipping_carrier,payment_method,estimated_shipping_fee,actual_shipping_fee,total_amount,pay_time',
];
// Kiểu cột sau dấu ":": n = số, p = tỉ lệ %, f = số thập phân, d = ngày giờ, ngay = ngày. Không ghi kiểu = văn bản.
// Cột mới luôn thêm vào CUỐI danh sách của mỗi tab: script tự chèn vào tab cũ, không phải tạo tab mới.
const COT = {
orders: ['order_sn', 'tao_luc:d', 'cap_nhat_luc:d', 'trang_thai', 'cod', 'thanh_toan', 'tong_tien:n', 'van_chuyen',
'phi_ship_du_kien:n', 'phi_ship_thuc:n', 'huy_boi', 'ly_do_huy', 'ly_do_huy_nguoi_mua', 'thanh_toan_luc:d', 'so_dong_hang:n',
'ma_nguoi_mua', 'ten_dang_nhap', 'nguoi_nhan', 'dien_thoai', 'dia_chi_day_du', 'phuong_xa', 'quan_huyen', 'tinh_thanh', 'ghi_chu_shop',
'loi_nhan_khach', 'han_giao:d', 'ban_giao_vc_luc:d', 'trang_thai_van_chuyen',
'so_ngay_chuan_bi:n', 'giao_du_kien_tu:d', 'giao_du_kien_den:d', 'han_yeu_cau_tra:d', 'phi_ship_hoan:n', 'can_nang_tinh_phi:n',
'tach_kien', 'ma_kien', 'ma_van_don'],
order_items: ['order_sn', 'stt:n', 'item_id', 'model_id', 'item_sku', 'model_sku', 'ten_san_pham', 'so_luong:n', 'gia_goc:n', 'gia_sau_giam:n',
'ten_phan_loai', 'loai_khuyen_mai', 'ma_khuyen_mai', 'can_nang:n', 'la_hang_chinh', 'mua_kem', 'anh', 'ma_dong_hang', 'sl_da_huy:n', 'sl_da_tra:n'],
escrow: ['order_sn', 'trang_thai_khi_lay', 'gia_ban:n', 'thuc_nhan:n', 'hoa_hong:n', 'phi_dich_vu:n', 'phi_giao_dich:n', 'affiliate:n',
'phi_ads_ky_thuat:n', 'thue_khau_tru:n', 'voucher_shop:n', 'ship_thuc:n', 'khach_tra_ship:n', 'shopee_ho_tro_ship:n', 'hoan_tien:n',
'co_hoan', 'lay_luc:d', 'ten_nguoi_mua', 'khach_tra:n', 'gia_goc_don:n', 'gia_sau_giam_don:n', 'shop_giam:n', 'shopee_giam:n',
'voucher_shopee:n', 'xu:n', 'phi_ship_hoan:n', 'dieu_chinh:n', 'thuc_nhan_sau_dieu_chinh:n',
'phi_ship_tra_hang:n', 'den_bu:n', 'hoan_dieu_chinh:n', 'ly_do_dieu_chinh',
'thanh_toan', 'phi_ship_cuoi:n', 'phi_ship_du_kien:n', 'shop_ho_tro_ship:n', 'dvvc_giam_ship:n', 'phi_xu_ly_don:n', 'hoa_hong_thuc:n',
'phi_dich_vu_thuc:n', 'phi_chien_dich:n', 'phi_the_tin_dung:n', 'xu_hoan_shop:n', 'phi_giao_dich_khach:n', 'voucher_ben_thu_ba:n'],
escrow_items: ['order_sn', 'stt:n', 'item_id', 'model_id', 'item_sku', 'model_sku', 'ten_san_pham', 'ten_phan_loai', 'so_luong:n',
'gia_goc:n', 'gia_ban:n', 'gia_sau_giam:n', 'giam_tu_xu:n', 'giam_voucher_shopee:n', 'giam_voucher_shop:n', 'hoa_hong_affiliate:n',
'loai_khuyen_mai', 'ma_khuyen_mai', 'la_hang_chinh'],
ads_daily: ['ngay:ngay', 'hien_thi:n', 'click:n', 'chi_tieu:n', 'don_gop:n', 'don_truc_tiep:n', 'gmv_gop:n', 'gmv_truc_tiep:n', 'ctr:p', 'cpc:n', 'roas_gop:f', 'roas_truc_tiep:f',
'chuyen_doi_gop:n', 'chuyen_doi_truc_tiep:n', 'sp_ban_gop:n', 'sp_ban_truc_tiep:n', 'chi_phi_moi_chuyen_doi:n'],
returns: ['return_sn', 'order_sn', 'trang_thai', 'ly_do', 'so_tien_hoan:n', 'tao_luc:d', 'cap_nhat_luc:d', 'ten_nguoi_mua', 'email_nguoi_mua', 'noi_dung_ly_do',
'giai_phap', 'loai_hoan', 'han_xu_ly:d', 'ma_van_don_hoan', 'trang_thai_thuong_luong', 'trang_thai_bang_chung', 'trang_thai_den_bu',
'tien_truoc_giam:n', 'can_gui_hang', 'da_ve_kho', 'ben_chiu_phi_ship', 'ly_do_khieu_nai', 'han_gui_hang:d', 'han_shop_xu_ly:d', 'san_pham_tra',
// các cột sau lấy từ get_return_detail
'tien_den_bu:n', 'giai_phap_de_nghi', 'tien_de_nghi:n', 'han_de_nghi:d', 'trang_thai_vc_hoan', 'han_nop_bang_chung:d', 'chi_tiet_luc:d'],
// Cột đầu đổi tên vì cách tạo mã đã đổi (thêm refund_sn, withdrawal_id): tab wallet cũ được giữ lại, kéo mới vào tab mới
wallet: ['ma_giao_dich', 'tao_luc:d', 'loai', 'so_tien:n', 'trang_thai', 'order_sn', 'ten_nguoi_mua', 'mo_ta', 'so_du_sau:n', 'ma_hoan', 'ma_rut_tien',
'phi_giao_dich:n', 'dong_tien', 'ly_do', 'loai_rut_tien'],
danh_gia: ['comment_id', 'order_sn', 'item_id', 'ten_san_pham', 'phan_loai', 'so_sao:n', 'noi_dung', 'nguoi_mua', 'danh_gia_luc:d',
'bi_an', 'sua_duoc', 'anh', 'video', 'shop_tra_loi', 'tra_loi_luc:d', 'tra_loi_bi_an'],
affiliate: ['ma_chuyen_doi', 'order_sn', 'trang_thai', 'xac_minh', 'dat_hang_luc:d', 'hoan_tat_luc:d', 'chot_luc:d', 'ma_affiliate',
'ten_affiliate', 'username_affiliate', 'mcn', 'kenh', 'loai_don', 'loai_khach', 'gia_tri:n', 'hoan_tien:n', 'hoa_hong:n',
'hoa_hong_du_kien:n', 'ti_le_hoa_hong', 'phi_dich_vu_shop:n', 'loai_chien_dich', 'so_luong:n', 'san_pham'],
};
// Cột đổi tên ở bản mới: đổi tiêu đề tại chỗ, dữ liệu giữ nguyên
const DOI_TEN = {orders: {ghi_chu_khach: 'ghi_chu_shop'}};
const TEN_TAB = {orders: 'Đơn hàng', order_items: 'Sản phẩm trong đơn', escrow: 'Tiền đối soát', ads_daily: 'Quảng cáo theo ngày',
returns: 'Trả hàng / hoàn tiền', wallet: 'Giao dịch ví', danh_gia: 'Đánh giá sản phẩm', escrow_items: 'Tiền đối soát theo sản phẩm',
affiliate: 'Đơn affiliate'};
const DINH_DANG = {n: '#,##0', p: '0.00%', f: '0.00', d: 'yyyy-mm-dd hh:mm:ss', ngay: 'yyyy-mm-dd', '': '@'};
// order_items dùng số thứ tự dòng trong đơn: cùng một phân loại có thể xuất hiện hai lần (ví dụ hàng tặng kèm)
const KHOA = {
orders: r => chuan(r[0]), order_items: r => r[0] + '|' + r[1], escrow: r => chuan(r[0]),
ads_daily: r => chuan(r[0]), returns: r => chuan(r[0]), wallet: r => chuan(r[0]), danh_gia: r => chuan(r[0]),
escrow_items: r => r[0] + '|' + r[1], affiliate: r => chuan(r[0]),
};
const BO_QUA_ESCROW = new Set(['CANCELLED', 'UNPAID', 'IN_CANCEL']);
// Dòng escrow có ô này trống là dòng của bản cũ, thiếu các cột tiền mới: lấy lại để điền đủ
const ESCROW_COT_MOI = COT.escrow.findIndex(c => c.indexOf('voucher_ben_thu_ba') === 0);
const IDX_VAN_DON = COT.orders.indexOf('ma_van_don');
const IDX_CT_HOAN = COT.returns.findIndex(c => c.indexOf('tien_den_bu') === 0);
// Đơn ở các trạng thái này đã có (hoặc sắp có) mã vận đơn
const CO_VAN_DON = new Set(['PROCESSED', 'SHIPPED', 'TO_CONFIRM_RECEIVE', 'COMPLETED', 'TO_RETURN', 'RETRY_SHIP']);
// ---------- tiện ích ----------
const so = v => Number(v) || 0;
const ngayGio = s => s ? new Date(s * 1000) : '';
const ddmm = s => Utilities.formatDate(new Date(s * 1000), TZ, 'dd/MM/yyyy');
const thoiGian = v => v instanceof Date ? v.getTime() : 0;
const yn = v => v === true ? 'co' : v === false ? 'khong' : '';
const chu = v => v && typeof v === 'object' ? JSON.stringify(v) : v;
const esc = s => String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
function ngayTuChuoi(d) { // 'dd-mm-yyyy' hoặc 'yyyy-mm-dd' → 00:00 giờ VN
const p = String(d).split('-');
const v = new Date((p.length === 3 && p[0].length === 2 ? p[2] + '-' + p[1] + '-' + p[0] : String(d)) + 'T00:00:00+07:00');
return isNaN(v) ? '' : v;
}
function chuan(v) { // dạng chuỗi để so sánh và làm khoá, ngày giờ làm tròn về giây
if (v instanceof Date) return isNaN(v) ? '' : Utilities.formatDate(new Date(Math.round(v.getTime() / 1000) * 1000), TZ, 'yyyy-MM-dd HH:mm:ss');
return String(v);
}
function ep(v, kieu) { // ép giá trị về đúng kiểu cột để so sánh với dữ liệu đọc từ Sheet
if (kieu === 'n' || kieu === 'p' || kieu === 'f') return so(v);
if (kieu === 'd' || kieu === 'ngay') return v instanceof Date && !isNaN(v) ? v : '';
return v === null || v === undefined ? '' : String(v);
}
function json(v) { const s = JSON.stringify(v); return s.length > 49000 ? s.slice(0, 48900) + '…[CAT]' : s; } // ô Sheets tối đa 50.000 ký tự
function bam(s) { return Utilities.base64Encode(Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, s, Utilities.Charset.UTF_8)); }
function mocVN(s, cuoi) { return Math.floor(new Date(s + (cuoi ? 'T23:59:59+07:00' : 'T00:00:00+07:00')).getTime() / 1000); }
function ghiChu(s) { console.log(s); NK.push(s); }
const tong = i => Object.keys(DEM).reduce((s, k) => s + DEM[k][i], 0);
const soDep = n => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, '.'); // 12345 → 12.345
function layKey() { // key riêng từng tài khoản Google; key lưu chung từ bản cũ vẫn dùng được
const u = PropertiesService.getUserProperties(), s = PropertiesService.getScriptProperties();
return {key: u.getProperty('LOGIDATA_API_KEY') || s.getProperty('LOGIDATA_API_KEY'),
acc: u.getProperty('LOGIDATA_ACCOUNT_ID') || s.getProperty('LOGIDATA_ACCOUNT_ID')};
}
function goi(provider, path, params) {
const k = layKey();
if (!k.key || !k.acc) throw new Error('Chưa có API key: vào menu 🛒 Shopee → 🔑 Cài đặt kết nối');
const qs = Object.keys(params || {}).map(x => encodeURIComponent(x) + '=' + encodeURIComponent(params[x])).join('&');
const url = HOST + '/' + provider + path + (qs ? '?' + qs : '');
let res = null;
for (let i = 0; i < 3; i++) {
res = UrlFetchApp.fetch(url, {headers: {'X-API-Key': k.key, 'X-Account-Id': k.acc}, muteHttpExceptions: true});
const c = res.getResponseCode();
if (c === 429 || c >= 500) { Utilities.sleep(1500 * (i + 1)); continue; }
break;
}
let body = null;
try { body = JSON.parse(res.getContentText()); } catch (e) { body = {raw: res.getContentText().slice(0, 200)}; }
return {code: res.getResponseCode(), body: body};
}
function lop(b) { if (!b) return {}; if (b.response) return b.response; if (b.data && b.data.response) return b.data.response; return b; }
function loi(b) {
const d = (b && b.data) || {};
const e = (b && b.error) || d.error || '';
return e ? e + ': ' + String((b && b.message) || d.message || '').slice(0, 150) : '';
}
function chac(r, ten) {
if (r.code !== 200 || loi(r.body)) throw new Error(ten + ' HTTP ' + r.code + ' ' + (loi(r.body) || JSON.stringify(r.body).slice(0, 150)));
return lop(r.body);
}
// Đổi lỗi kỹ thuật thành lời dễ hiểu cho người dùng
function goiYLoi(msg) {
msg = String(msg || '');
const ct = ' (Chi tiết: ' + msg.slice(0, 160) + ')';
if (/Chưa có API key/.test(msg)) return {nhan: 'Bạn chưa cài kết nối. Bấm "Cài đặt kết nối" để nhập API key và Account ID.', caiDat: true};
if (/HTTP 40[13]\b|unauthori|forbidden/i.test(msg)) return {nhan: 'API key hoặc Account ID không đúng, đã hết hạn, hoặc shop chưa được cấp quyền trong portal Logidata.' + ct, caiDat: true};
if (/HTTP 429|rate.?limit|quota/i.test(msg)) return {nhan: 'Đã chạm giới hạn số lần gọi API của gói Logidata.' + ct};
return {nhan: 'Lỗi tạm thời từ máy chủ hoặc mạng.' + ct};
}
// ---------- tab cài đặt và nhật ký ----------
function moCaiDat() {
const ss = SpreadsheetApp.getActive();
let sh = ss.getSheetByName('cai_dat');
const cu = {};
if (sh) {
const v = sh.getDataRange().getValues(), nhan = v.map(r => String(r[0]).trim());
if (CAI_DAT.every(c => nhan.indexOf(c[0]) >= 0)) return sh;
v.forEach(r => { cu[String(r[0]).trim()] = r[1]; }); // bản cũ: dựng lại tab, giữ giá trị người dùng đã chọn
sh.getRange(1, 1, sh.getMaxRows(), sh.getMaxColumns()).clearDataValidations();
sh.clear();
} else {
sh = ss.insertSheet('cai_dat');
}
const gt = c => cu[c[0]] !== undefined && cu[c[0]] !== '' ? cu[c[0]] : (c[1] ? CFG[c[1]] : '');
sh.getRange(1, 1, 1, 3).setValues([['Cài đặt', 'Giá trị', 'Giải thích']]).setFontWeight('bold').setFontColor('#FFFFFF').setBackground('#EE4D2D');
sh.getRange(2, 1, CAI_DAT.length, 3).setValues(CAI_DAT.map(c => [c[0], gt(c), c[2]])).setWrap(true).setVerticalAlignment('middle');
sh.getRange(2, 2, CAI_DAT.length - 1, 1).setBackground('#FFF8E1'); // ô người dùng được sửa
sh.getRange(2 + CAI_DAT.findIndex(c => c[1] === 'JSON'), 2)
.setDataValidation(SpreadsheetApp.newDataValidation().requireValueInList(CHE_DO_JSON, true).build());
sh.getRange(CAI_DAT.length + 3, 1).setValue('Chỉ sửa các ô màu vàng. Thay đổi có hiệu lực ở lần chạy tiếp theo.').setFontStyle('italic').setFontColor('#5F6368');
sh.setFrozenRows(1);
sh.setColumnWidth(1, 170); sh.setColumnWidth(2, 220); sh.setColumnWidth(3, 620);
sh.setTabColor('#9AA0A6');
return sh;
}
function napCaiDat() {
const sh = moCaiDat();
datThuongHieu(sh, 5);
const v = sh.getRange(2, 1, Math.max(sh.getLastRow() - 1, 1), 2).getValues();
CAI_DAT.forEach(c => {
const r = c[1] && v.find(x => String(x[0]).trim() === c[0]);
if (!r) return;
const g = String(r[1]).trim();
if (typeof CFG[c[1]] === 'number') { if (g !== '') CFG[c[1]] = Math.max(1, Math.floor(so(g)) || CFG[c[1]]); }
else CFG[c[1]] = g;
});
if (CHE_DO_JSON.indexOf(CFG.JSON) < 0) CFG.JSON = CHE_DO_JSON[0];
}
function ghiLinkJson(url) {
const sh = moCaiDat();
const v = sh.getRange(1, 1, sh.getLastRow(), 1).getValues();
const i = v.findIndex(x => String(x[0]).trim() === 'File JSON gốc');
if (i >= 0) sh.getRange(i + 1, 2).setValue(url);
}
function ghiNhatKy(ketQua) {
try {
const ss = SpreadsheetApp.getActive();
let sh = ss.getSheetByName('nhat_ky');
if (!sh) {
sh = ss.insertSheet('nhat_ky');
sh.getRange(1, 1, 1, 5).setValues([['thoi_gian', 'ket_qua', 'dong_moi', 'dong_cap_nhat', 'ghi_chu']])
.setFontWeight('bold').setBackground('#FDE7E2');
sh.setFrozenRows(1);
sh.setColumnWidth(1, 150); sh.setColumnWidth(5, 600);
sh.setTabColor('#9AA0A6');
}
datThuongHieu(sh, 7);
const tom = i => Object.keys(DEM).filter(k => DEM[k][i]).map(k => k + ' ' + DEM[k][i]).join(', ') || '0';
sh.insertRowBefore(2);
sh.getRange(2, 1, 1, sh.getMaxColumns()).setBackground(null).setFontColor(null).setFontWeight('normal'); // dòng mới không mang màu hàng tiêu đề
sh.getRange(2, 1, 1, 5).setValues([[new Date(T0), ketQua, tom(0), tom(1), NK.join('\n').slice(0, 45000)]])
.setFontWeight('normal').setBackground(null).setWrap(true).setVerticalAlignment('top');
sh.getRange(2, 1).setNumberFormat('yyyy-mm-dd hh:mm:ss');
if (sh.getMaxRows() > 1001) sh.deleteRows(1002, sh.getMaxRows() - 1001); // giữ 1.000 lần chạy gần nhất
} catch (e) { console.error('Không ghi được nhật ký: ' + e); }
}
// ---------- Sheet: chỉ đọc các cột cần, chỉ ghi các dòng mới hoặc thay đổi ----------
function docBang(sh, soCotDoc, khoa) {
const n = sh.getLastRow(), map = new Map(), rowOf = new Map();
if (n > 1) sh.getRange(2, 1, n - 1, soCotDoc).getValues().forEach((r, i) => { const k = khoa(r); map.set(k, r); rowOf.set(k, i + 2); });
return {sh: sh, map: map, rowOf: rowOf, moi: new Map()};
}
function mo(ten, J) {
const ss = SpreadsheetApp.getActive();
const cot = COT[ten].map(c => c.split(':')), h = cot.map(c => c[0]), kieu = cot.map(c => c[1] || '');
let sh = ss.getSheetByName(ten), moiTao = false, themCot = 0;
if (sh && sh.getLastRow() >= 1) {
const cu = sh.getRange(1, 1, 1, Math.max(sh.getLastColumn(), 1)).getValues()[0].map(x => String(x).trim());
const doi = DOI_TEN[ten] || {};
cu.forEach((c, i) => { if (doi[c] && h[i] === doi[c]) { sh.getRange(1, i + 1).setValue(doi[c]); cu[i] = doi[c]; } });
let k = 0;
while (k < h.length && cu[k] === h[k]) k++;
if (k < h.length && (k < 2 || h.indexOf(cu[k]) >= 0)) {
const tenCu = ten + '_cu_' + Utilities.formatDate(new Date(), TZ, 'MMddHHmm');
sh.setName(tenCu); // cấu trúc khác hẳn: giữ tab cũ, tạo tab mới
ghiChu('Tab ' + ten + ' có cấu trúc cũ, đã đổi tên thành ' + tenCu + ' (dữ liệu giữ nguyên)');
sh = null;
} else if (k < h.length) {
// Bản cũ thiếu cột: chèn ngay sau các cột của script, cột riêng của người dùng bên phải được đẩy sang, không bị ghi đè
if (sh.getLastColumn() > k) sh.insertColumnsAfter(k, h.length - k);
else if (sh.getMaxColumns() < h.length) sh.insertColumnsAfter(sh.getMaxColumns(), h.length - sh.getMaxColumns());
sh.getRange(1, k + 1, 1, h.length - k).setValues([h.slice(k)]).setFontWeight('bold').setBackground('#FDE7E2');
kieu.slice(k).forEach((x, i) => sh.getRange(2, k + i + 1, Math.max(sh.getMaxRows() - 1, 1), 1).setNumberFormat(DINH_DANG[x]));
themCot = h.length - k;
ghiChu('Tab ' + ten + ': đã thêm ' + themCot + ' cột mới (' + h.slice(k).join(', ') + ')');
}
}
if (!sh || sh.getLastRow() === 0) {
sh = sh || ss.insertSheet(ten);
moiTao = true;
sh.getRange(1, 1, 1, h.length).setValues([h]).setFontWeight('bold').setBackground('#FDE7E2');
sh.setFrozenRows(1);
kieu.forEach((k, i) => sh.getRange(2, i + 1, sh.getMaxRows() - 1, 1).setNumberFormat(DINH_DANG[k]));
}
const t = docBang(sh, h.length, KHOA[ten]);
return Object.assign(t, {ten: ten, rong: h.length, kieu: kieu, dd: kieu.map(k => DINH_DANG[k]), moiTao: moiTao || themCot > 0, j: J ? moJson(J, ten) : null});
}
// JSON gốc lưu theo khoá kèm mã băm: chỉ đọc khoá và mã băm, chỉ ghi lại khi nội dung đổi
function moKhoJson() {
if (CFG.JSON === 'Không lưu') return null;
if (CFG.JSON === 'Tab riêng') return {ss: SpreadsheetApp.getActive(), tienTo: 'json_'};
const p = PropertiesService.getScriptProperties(), id = p.getProperty('JSON_FILE_ID');
let ss = null;
if (id) { try { ss = SpreadsheetApp.openById(id); } catch (e) { ss = null; } }
if (!ss) {
ss = SpreadsheetApp.create(SpreadsheetApp.getActive().getName() + ' - JSON gốc');
ss.setSpreadsheetTimeZone(TZ);
ss.getSheets()[0].setName('orders');
p.setProperty('JSON_FILE_ID', ss.getId());
ghiLinkJson(ss.getUrl());
ghiChu('Đã tạo file JSON gốc: ' + ss.getUrl());
}
return {ss: ss, tienTo: ''};
}
function moJson(J, ten) {
const tenTab = J.tienTo + ten;
const sh = J.ss.getSheetByName(tenTab) || J.ss.insertSheet(tenTab);
if (sh.getLastRow() === 0) {
sh.getRange(1, 1, sh.getMaxRows(), 3).setNumberFormat('@');
sh.getRange(1, 1, 1, 3).setValues([['khoa', 'ma_hash', 'json_goc']]).setFontWeight('bold');
sh.setFrozenRows(1);
}
return Object.assign(docBang(sh, 2, r => String(r[0])), {ten: tenTab, rong: 3, dd: ['@', '@', '@']});
}
function dat(t, row, goc) {
row = row.map((v, i) => ep(v, t.kieu[i]));
const k = KHOA[t.ten](row), cu = t.map.get(k);
if (!cu || row.some((v, i) => chuan(v) !== chuan(cu[i]))) { t.map.set(k, row); t.moi.set(k, row); }
if (!t.j || goc === undefined) return;
const s = json(goc), m = bam(s), cuJ = t.j.map.get(k);
if (!cuJ || cuJ[1] !== m) { t.j.map.set(k, [k, m]); t.j.moi.set(k, [k, m, s]); }
}
function luu(t) {
if (!t || !t.moi.size) return;
const sua = [], them = [];
t.moi.forEach((row, k) => { const r = t.rowOf.get(k); if (r) sua.push([r, row]); else them.push([k, row]); });
sua.sort((a, b) => a[0] - b[0]);
for (let i = 0; i < sua.length;) {
let j = i;
while (j + 1 < sua.length && sua[j + 1][0] === sua[j][0] + 1 && j - i < 99) j++;
t.sh.getRange(sua[i][0], 1, j - i + 1, t.rong).setValues(sua.slice(i, j + 1).map(x => x[1]));
i = j + 1;
}
let dau = Math.max(t.sh.getLastRow(), 1) + 1;
for (let a = 0; a < them.length; a += 100) {
const phan = them.slice(a, a + 100);
t.sh.getRange(dau, 1, phan.length, t.rong).setNumberFormats(phan.map(() => t.dd)).setValues(phan.map(x => x[1]));
phan.forEach((x, i) => t.rowOf.set(x[0], dau + i)); // nhớ vị trí để lần ghi sau sửa tại chỗ, không nối thêm
dau += phan.length;
}
t.moi.clear();
if (t.kieu) { const d = DEM[t.ten] || (DEM[t.ten] = [0, 0]); d[0] += them.length; d[1] += sua.length; }
}
function luuTatCa(T) {
Object.keys(T).forEach(k => { luu(T[k]); luu(T[k].j); });
SpreadsheetApp.flush();
}
// ---------- kéo từng nhóm ----------
function keoDon(tu, den, truong, T) {
let cursor = '', trang = 0;
const sns = [];
do {
kiemTraGio();
const p = {time_range_field: truong, time_from: tu, time_to: den, page_size: 50};
if (cursor) p.cursor = cursor;
const x = chac(goi('shopee', '/api/v2/order/get_order_list', p), 'order_list');
(x.order_list || []).forEach(o => sns.push(o.order_sn));
const tiep = x.more ? String(x.next_cursor || '') : '';
cursor = (tiep && tiep !== cursor) ? tiep : '';
trang++;
} while (cursor && trang < 400);
for (let i = 0; i < sns.length; i += 50) { kiemTraGio(); chiTiet(sns.slice(i, i + 50), T); }
return sns.length;
}
function layChiTiet(sns) {
const p = PropertiesService.getScriptProperties();
for (let m = Number(p.getProperty('MUC_TRUONG') || 0); m < TRUONG_CHI_TIET.length; m++) {
const r = goi('shopee', '/api/v2/order/get_order_detail', {order_sn_list: sns.join(','), response_optional_fields: TRUONG_CHI_TIET[m]});
if (r.code === 200 && !loi(r.body)) {
if (String(m) !== (p.getProperty('MUC_TRUONG') || '0')) { p.setProperty('MUC_TRUONG', String(m)); ghiChu('Chi tiết đơn dùng danh sách trường mức ' + (m + 1) + '/' + TRUONG_CHI_TIET.length); }
return lop(r.body);
}
ghiChu('Danh sách trường mức ' + (m + 1) + ' bị từ chối (' + (loi(r.body) || r.code) + '), thử mức tiếp theo');
}
throw new Error('get_order_detail: mọi danh sách trường đều bị từ chối');
}
function chiTiet(sns, T) {
const x = layChiTiet(sns);
(x.order_list || []).forEach(o => {
const items = o.item_list || [], dc = o.recipient_address || {}, goi = o.package_list || [];
const cu = T.orders.map.get(String(o.order_sn)); // giữ mã vận đơn đã lấy ở lần trước
dat(T.orders, [o.order_sn, ngayGio(o.create_time), ngayGio(o.update_time), o.order_status, o.cod ? 'COD' : 'tra truoc',
o.payment_method, o.total_amount, o.shipping_carrier, o.estimated_shipping_fee, o.actual_shipping_fee,
o.cancel_by, o.cancel_reason, o.buyer_cancel_reason, ngayGio(o.pay_time), items.length,
o.buyer_user_id, o.buyer_username, dc.name, dc.phone, dc.full_address, dc.town, dc.district, dc.city, o.note,
o.message_to_seller, ngayGio(o.ship_by_date), ngayGio(o.pickup_done_time),
Array.from(new Set(goi.map(k => k.logistics_status).filter(Boolean))).join(', '),
o.days_to_ship, ngayGio(o.edt_from), ngayGio(o.edt_to), ngayGio(o.return_request_due_date), o.reverse_shipping_fee,
o.order_chargeable_weight_gram, yn(o.split_up), goi.map(k => k.package_number).filter(Boolean).join(', '),
cu ? cu[IDX_VAN_DON] : ''], o);
items.forEach((i, n) => dat(T.items, [o.order_sn, n + 1, i.item_id, i.model_id, i.item_sku, i.model_sku,
String(i.item_name || '').slice(0, 120), i.model_quantity_purchased, i.model_original_price, i.model_discounted_price,
i.model_name, i.promotion_type, i.promotion_id || '', i.weight, yn(i.main_item), yn(i.add_on_deal),
(i.image_info || {}).image_url, i.order_item_id, i.cancelled_qty, i.returned_qty], i));
});
}
function keoAds(tu, den, T) {
const d = s => Utilities.formatDate(new Date(s * 1000), TZ, 'dd-MM-yyyy');
const x = chac(goi('shopee-ads', '/api/v2/ads/get_all_cpc_ads_daily_performance', {start_date: d(tu), end_date: d(den)}), 'ads');
const rows = Array.isArray(x) ? x : (x.list || []);
rows.forEach(r => dat(T.ads, [ngayTuChuoi(r.date), r.impression, r.clicks, r.expense, r.broad_order, r.direct_order, r.broad_gmv, r.direct_gmv,
r.ctr, r.cpc !== undefined ? r.cpc : (so(r.clicks) ? Math.round(so(r.expense) / so(r.clicks)) : 0), r.broad_roas, r.direct_roas,
r.broad_conversions, r.direct_conversions, r.broad_item_sold, r.direct_item_sold, r.cost_per_conversion], r));
return rows.length;
}
// Một dòng trả hàng. ct là phản hồi get_return_detail (null khi chỉ có danh sách), luc là lúc lấy chi tiết.
// Danh sách để các trạng thái ở ngoài cùng, chi tiết để trong negotiation / seller_proof / seller_compensation.
function dongHoan(r, ct, luc) {
const u = r.user || {}, c = ct || {}, bt = c.seller_compensation || {}, tl = c.negotiation || {}, bc = c.seller_proof || {};
return [r.return_sn, r.order_sn, r.status, r.reason, r.refund_amount, ngayGio(r.create_time), ngayGio(r.update_time),
u.username, u.email, r.text_reason, r.return_solution, r.return_refund_type, ngayGio(r.due_date), r.tracking_number,
r.negotiation_status || tl.negotiation_status, r.seller_proof_status || bc.seller_proof_status,
r.seller_compensation_status || bt.seller_compensation_status,
r.amount_before_discount, yn(r.needs_logistics), yn(r.is_arrived_at_warehouse), r.shipping_fee_responsibility,
[].concat(r.dispute_reason || []).join(', '), ngayGio(r.return_ship_due_date), ngayGio(r.return_seller_due_date),
(r.item || []).map(i => (i.name || i.item_id) + ' x' + (i.amount || 1)).join('; '),
bt.compensation_amount, tl.latest_solution, tl.latest_offer_amount, ngayGio(tl.offer_due_date),
c.reverse_logistics_status || c.logistics_status, ngayGio(bc.seller_evidence_deadline), luc];
}
function keoHoan(tu, den, T, truong) {
const loc = truong === 'update_time' ? 'update_time' : 'create_time';
let trang = 0, n = 0, more = false;
do {
kiemTraGio();
const q = {page_no: trang, page_size: 50};
q[loc + '_from'] = tu; q[loc + '_to'] = den;
const x = chac(goi('shopee', '/api/v2/returns/get_return_list', q), 'return_list');
(x['return'] || x.returns || []).forEach(r => {
n++;
const row = dongHoan(r, null, ''), cu = T.ret.map.get(String(r.return_sn));
// Giữ phần chi tiết đã lấy, và các trạng thái danh sách không trả về nhưng chi tiết đã có
if (cu) for (let i = 14; i < row.length; i++) if (i >= IDX_CT_HOAN || row[i] === undefined || row[i] === '') row[i] = cu[i];
dat(T.ret, row, r);
});
more = !!x.more; trang++;
} while (more && trang < 100);
return n;
}
// Trả hàng có thể đổi trạng thái lâu sau khi tạo: khi làm mới, lọc theo thời gian cập nhật.
// Nếu API không nhận bộ lọc này, dự phòng kéo lại các yêu cầu tạo trong N ngày gần nhất (một lần mỗi lần chạy).
let HOAN_DU_PHONG = false;
function keoHoanCapNhat(tu, den, T) {
try {
return keoHoan(tu, den, T, 'update_time');
} catch (e) {
if (e.message === HET_GIO) throw e;
if (HOAN_DU_PHONG) return 0;
HOAN_DU_PHONG = true;
ghiChu('Trả hàng: không lọc được theo thời gian cập nhật (' + String(e.message).slice(0, 120) + '), dùng cách dự phòng');
return lamMoiHoan(Math.floor(Date.now() / 1000), T);
}
}
// Lấy chi tiết cho yêu cầu trả hàng mới hoặc vừa cập nhật: mỗi yêu cầu một lần gọi, chỉ gọi lại khi có cập nhật mới
function keoChiTietHoan(T) {
let dem = 0, loiN = 0;
for (const [k, r] of Array.from(T.ret.map)) {
const luc = r[r.length - 1];
if (luc instanceof Date && thoiGian(r[6]) <= luc.getTime()) continue;
if (hetGio()) { ghiChu('Chi tiết trả hàng: hết giờ, lấy tiếp ở lần chạy sau'); return 'gio'; }
const res = goi('shopee', '/api/v2/returns/get_return_detail', {return_sn: k}), x = lop(res.body);
if (res.code !== 200 || loi(res.body) || !x.return_sn) {
const row = r.slice(); row[row.length - 1] = new Date(); // đánh dấu đã thử, chỉ thử lại khi yêu cầu có cập nhật mới
dat(T.ret, row);
if (++loiN >= 20) { ghiChu('Chi tiết trả hàng: quá nhiều lỗi, dừng. Lỗi cuối: ' + (loi(res.body) || res.code)); return 'loi'; }
continue;
}
dat(T.ret, dongHoan(x, x, new Date()), x);
dem++;
}
if (dem) ghiChu('Chi tiết trả hàng: đã lấy ' + dem + ' yêu cầu');
return 'het';
}
// Mã vận đơn không đổi: mỗi đơn chỉ gọi một lần, khi đơn đã giao cho đơn vị vận chuyển
function keoVanDon(T) {
let dem = 0, loiN = 0;
for (const o of Array.from(T.orders.map.values())) {
if (o[IDX_VAN_DON] || !CO_VAN_DON.has(o[3])) continue;
if (hetGio()) { ghiChu('Mã vận đơn: hết giờ, đã lấy ' + dem + ' đơn, lấy tiếp ở lần chạy sau'); return 'gio'; }
const r = goi('shopee', '/api/v2/logistics/get_tracking_number', {order_sn: o[0]}), x = lop(r.body);
if (r.code !== 200 || loi(r.body)) {
if (++loiN >= 20) { ghiChu('Mã vận đơn: quá nhiều lỗi, dừng. Lỗi cuối: ' + (loi(r.body) || r.code)); return 'loi'; }
continue;
}
if (!x.tracking_number) continue; // chưa có mã, lần sau thử lại
const row = o.slice();
row[IDX_VAN_DON] = x.tracking_number;
dat(T.orders, row);
if (++dem % 100 === 0) luu(T.orders);
}
if (dem) ghiChu('Mã vận đơn: đã lấy ' + dem + ' đơn');
return 'het';
}
function lamMoiHoan(now, T) {
let n = 0;
for (let a = now - CFG.NGAY_LAM_MOI_HOAN * 86400; a < now; a += 14 * 86400) n += keoHoan(a, Math.min(a + 14 * 86400 - 1, now), T);
return n;
}
function keoVi(tu, den, T) {
let trang = 0, n = 0, more = false;
do {
kiemTraGio();
const x = chac(goi('shopee', '/api/v2/payment/get_wallet_transaction_list', {page_no: trang, page_size: 100, create_time_from: tu, create_time_to: den}), 'wallet');
(x.transaction_list || []).forEach(r => { n++;
// Tài liệu Shopee không có transaction_id: tự tạo mã từ các trường không đổi của giao dịch
const ma = r.transaction_id ? String(r.transaction_id)
: [r.create_time, r.transaction_type, r.amount, r.order_sn || '', r.refund_sn || '', r.withdrawal_id || ''].join('|');
dat(T.wal, [ma, ngayGio(r.create_time), r.transaction_type, r.amount, r.status, r.order_sn, r.buyer_name,
r.description, r.current_balance, r.refund_sn, r.withdrawal_id || '', r.transaction_fee, r.money_flow, r.reason, r.withdrawal_type], r); });
more = !!x.more; trang++;
} while (more && trang < 100);
return n;
}
// Shopee không lọc đánh giá theo ngày và mỗi lần chỉ trả tối đa khoảng 500–1.000 đánh giá gần nhất của shop.
// Lấy lại tối đa 6 giờ một lần (mỗi lần tới 10 lượt gọi) để cập nhật đánh giá mới và phản hồi của shop mà không tốn hạn mức.
function keoDanhGia(T) {
const p = PropertiesService.getScriptProperties(), now = Date.now();
if (!T.dg.moiTao && now - so(p.getProperty('DG_LUC')) < 6 * 3600000) return;
const tenSp = new Map(), tenPl = new Map(); // tra tên sản phẩm và tên phân loại từ tab order_items
T.items.map.forEach(r => { tenSp.set(String(r[2]), r[6]); if (r[10]) tenPl.set(r[2] + '|' + r[3], r[10]); });
let cursor = '', trang = 0, n = 0;
try {
do {
kiemTraGio();
const x = chac(goi('shopee', '/api/v2/product/get_comment', {cursor: cursor, page_size: 100}), 'get_comment');
(x.item_comment_list || []).forEach(c => {
const tl = c.comment_reply || c.cmt_reply || {}, md = c.media || {}; // tài liệu Shopee ghi cả hai tên
const pl = (c.model_id_list || []).map(m => tenPl.get(c.item_id + '|' + m) || m).join(', ');
dat(T.dg, [c.comment_id, c.order_sn, c.item_id, tenSp.get(String(c.item_id)) || '', pl, c.rating_star, c.comment, c.buyer_username,
ngayGio(c.create_time), c.hidden ? 'co' : 'khong', c.editable, (md.image_url_list || []).join('\n'), (md.video_url_list || []).join('\n'),
tl.reply, ngayGio(tl.create_time), tl.reply ? (tl.hidden ? 'co' : 'khong') : ''], c);
n++;
});
const tiep = x.more ? String(x.next_cursor || '') : '';
cursor = tiep && tiep !== cursor ? tiep : '';
trang++;
} while (cursor && trang < 10);
p.setProperty('DG_LUC', String(now));
ghiChu('Đánh giá: đã lấy ' + n + ' đánh giá gần nhất');
} catch (e) {
if (e.message === HET_GIO) throw e;
ghiChu('Đánh giá: chưa lấy được (' + String(e.message).slice(0, 150) + ')'); // không làm hỏng cả lần chạy
}
}
// Một dòng affiliate. Đọc được cả cấu trúc của Shopee (order_sn, items...) lẫn dữ liệu mẫu của Logidata (conversion_id, order_id, gmv...)
function dongAff(c) {
const it = c.items || [], cong = k => it.reduce((s, i) => s + so(i[k]), 0);
return [c.conversion_id || [c.order_sn, c.affiliate_id].join('|'), c.order_sn || c.order_id, c.order_status, c.verified_status,
ngayGio(c.place_order_time || c.purchase_time), ngayGio(c.order_completed_time), ngayGio(c.conversion_completed_time),
c.affiliate_id, c.affiliate_name, c.affiliate_username, chu(c.linked_mcn), c.channel, c.order_type, c.buyer_status || c.buyer_type,
it.length ? cong('purchase_value') : c.gmv, cong('refund_amount'),
c.order_brand_commission !== undefined ? c.order_brand_commission : c.actual_commission, c.estimated_commission,
c.commission_rate !== undefined ? c.commission_rate : (it[0] || {}).item_brand_commission_rate_to_affiliate,
cong('seller_service_fee'), Array.from(new Set(it.map(i => i.seller_campaign_type).filter(Boolean))).join(', '),
cong('qty'), it.map(i => (i.item_name || i.item_id) + ' x' + (i.qty || 1)).join('; ')];
}
// Lọc theo thời gian đặt hàng. Shop chưa cấp quyền Shopee Affiliate thì chỉ ghi chú, không làm hỏng cả lần chạy.
let AFF_LOI = false;
function keoAff(tu, den, T) {
if (AFF_LOI) return 0;
let trang = 1, n = 0, more = false;
try {
do {
kiemTraGio();
const x = chac(goi('shopee-affiliate', '/api/v2/ams/get_conversion_report',
{page_no: trang, page_size: 500, place_order_time_start: tu, place_order_time_end: den}), 'affiliate');
(x.list || []).forEach(c => { n++; dat(T.aff, dongAff(c), c); });
more = !!(x.has_more || x.has_next_page); trang++;
} while (more && trang <= 20); // Shopee: page_no x page_size tối đa 10.000
} catch (e) {
if (e.message === HET_GIO) throw e;
AFF_LOI = true;
ghiChu('Affiliate: chưa lấy được (' + String(e.message).slice(0, 150) + '). Kiểm tra đã bấm Cấp quyền Shopee Affiliate trong portal Logidata chưa.');
}
return n;
}
// Hoa hồng affiliate được chốt sau khi đơn hoàn tất: cập nhật lại 30 ngày gần nhất, tối đa 6 giờ một lần
function lamMoiAff(T, now) {
const p = PropertiesService.getScriptProperties();
if (!T.aff.moiTao && Date.now() - so(p.getProperty('AFF_LUC')) < 6 * 3600000) return;
const soNgay = T.aff.moiTao ? Math.max(30, CFG.SO_NGAY_KEO_BU) : 30;
let n = 0;
for (let a = now - soNgay * 86400; a < now && !AFF_LOI; a += 14 * 86400) n += keoAff(a, Math.min(a + 14 * 86400 - 1, now), T);
p.setProperty('AFF_LUC', String(Date.now()));
if (!AFF_LOI) ghiChu('Affiliate: cập nhật ' + soNgay + ' ngày gần nhất, ' + n + ' đơn');
}
// Trả về 'het' (hàng đợi đã hết), 'gio' (hết giờ, còn đơn chưa kéo) hoặc 'loi' (dừng vì nhiều lỗi liên tiếp)
function keoEscrow(T) {
const ds = Array.from(T.orders.map.values()).sort((a, b) => thoiGian(b[1]) - thoiGian(a[1]));
const hoanLuc = new Map(); // order_sn → lần cập nhật gần nhất của yêu cầu trả hàng
T.ret.map.forEach(r => { const sn = String(r[1]); hoanLuc.set(sn, Math.max(hoanLuc.get(sn) || 0, thoiGian(r[6]))); });
let dem = 0, loiN = 0;
for (let k = 0; k < ds.length; k++) {
if (hetGio()) { ghiChu('Tiền đối soát: hết giờ, đã lấy ' + dem + ' đơn, lấy tiếp ở lần chạy sau'); return 'gio'; }
const o = ds[k], sn = String(o[0]), tt = o[3];
if (BO_QUA_ESCROW.has(tt)) continue;
const e = T.escrow.map.get(sn), lay = e ? thoiGian(e[16]) : 0;
// Lấy lại khi: chưa có; đơn đổi trạng thái (hoặc đã hoàn tất mà vẫn có cập nhật) sau lần lấy trước; trả hàng có cập nhật mới
const can = !e || e[ESCROW_COT_MOI] === '' || (thoiGian(o[2]) > lay && (tt !== e[1] || tt === 'COMPLETED')) || (hoanLuc.get(sn) || 0) > lay;
if (!can) continue;
const r = goi('shopee', '/api/v2/payment/get_escrow_detail', {order_sn: sn});
const x = lop(r.body), inc = x.order_income;
if (r.code !== 200 || loi(r.body) || !inc) { loiN++; if (loiN >= 20) { ghiChu('Tiền đối soát: quá nhiều lỗi, dừng. Lỗi cuối: ' + (loi(r.body) || r.code)); return 'loi'; } continue; }
const g = k => inc[k] !== undefined ? inc[k] : x[k];
const hoan = (inc.return_order_sn_list || x.return_order_sn_list || []).length > 0 || so(inc.seller_return_refund) !== 0;
dat(T.escrow, [sn, tt, inc.order_selling_price, inc.escrow_amount, inc.commission_fee, inc.service_fee,
inc.seller_transaction_fee, inc.order_ams_commission_fee, inc.ads_escrow_top_up_fee_or_technical_support_fee,
so(inc.withholding_tax) + so(inc.withholding_vat_tax) + so(inc.withholding_pit_tax) + so(inc.withholding_cit_tax),
inc.voucher_from_seller, inc.actual_shipping_fee, inc.buyer_paid_shipping_fee, inc.shopee_shipping_rebate,
Math.abs(so(inc.seller_return_refund)), hoan ? 'co' : 'khong', new Date(),
inc.buyer_user_name || x.buyer_user_name, g('buyer_total_amount'), g('order_original_price'), g('order_discounted_price'),
g('seller_discount'), g('shopee_discount'), g('voucher_from_shopee'), g('coins'), g('reverse_shipping_fee'), g('total_adjustment_amount'),
g('escrow_amount_after_adjustment') !== undefined ? g('escrow_amount_after_adjustment') : so(inc.escrow_amount) + so(g('total_adjustment_amount')),
g('final_return_to_seller_shipping_fee'), g('seller_lost_compensation'), g('drc_adjustable_refund'),
(g('order_adjustment') || []).map(a => a.adjustment_reason).filter(Boolean).join('; '),
g('buyer_payment_method'), g('final_shipping_fee'), g('estimated_shipping_fee'), g('seller_shipping_discount'),
g('shipping_fee_discount_from_3pl'), g('seller_order_processing_fee'), g('net_commission_fee'), g('net_service_fee'),
g('campaign_fee'), g('credit_card_transaction_fee'), g('seller_coin_cash_back'), g('buyer_transaction_fee'),
g('voucher_from_external_party')], x);
(inc.items || []).forEach((i, n) => dat(T.ei, [sn, n + 1, i.item_id, i.model_id, i.item_sku, i.model_sku, i.item_name, i.model_name,
i.quantity_purchased, i.original_price, i.selling_price, i.discounted_price, i.discount_from_coin, i.discount_from_voucher_shopee,
i.discount_from_voucher_seller, i.ams_commission_fee, i.activity_type, i.activity_id || '', yn(i.is_main_item)], i));
dem++;
if (dem % 100 === 0) [T.escrow, T.escrow.j, T.ei, T.ei.j].forEach(luu);
}
ghiChu('Tiền đối soát: đã lấy ' + dem + ' đơn, hàng đợi đã hết');
return 'het';
}
// ---------- điều phối ----------
// Trả về {ketQua: 'Xong' | 'Còn tiếp' | 'Lỗi' | 'Bỏ qua', moi, sua, loi, thuLai}
function chay() {
const p = PropertiesService.getScriptProperties();
const khoa = LockService.getScriptLock();
if (!khoa.tryLock(5000)) { ghiChu('Đang có một lần chạy khác'); ghiNhatKy('Bỏ qua'); return {ketQua: 'Bỏ qua', moi: 0, sua: 0}; }
let tiepNua = false, ketQua = '', loiMsg = '';
try {
tiepNua = chayTrongKhoa() || !!p.getProperty('KHOANG_RIENG'); // có yêu cầu khoảng ngày mới gửi lúc đang chạy
ketQua = tiepNua ? 'Còn tiếp' : 'Xong';
p.deleteProperty('SO_LOI');
} catch (e) {
const n = so(p.getProperty('SO_LOI')) + 1;
p.setProperty('SO_LOI', String(n));
loiMsg = String(e.message || e);
ghiChu('LỖI: ' + loiMsg);
tiepNua = n < 3; // lỗi tạm thời (mạng, máy chủ) thì thử lại, tối đa 3 lần liên tiếp
if (tiepNua) ghiChu('Sẽ thử lại sau 1 phút (lần ' + n + '/3)');
ketQua = 'Lỗi';
} finally {
khoa.releaseLock();
}
if (tiepNua) henTiepTuc(); else xoaTiepTuc();
ghiNhatKy(ketQua);
try { // chỉ hiện khi đang mở file; lịch tự động chạy nền thì bỏ qua
SpreadsheetApp.getActive().toast(ketQua + ': ' + tong(0) + ' dòng mới, ' + tong(1) + ' dòng cập nhật. ' + dongLogidata(), '🛒 Shopee · LOGIDATA', 10);
} catch (e) {}
return {ketQua: ketQua, moi: tong(0), sua: tong(1), loi: loiMsg, thuLai: ketQua === 'Lỗi' && tiepNua};
}
function tiepTuc() { chay(); }
function henTiepTuc() {
xoaTiepTuc();
ScriptApp.newTrigger('tiepTuc').timeBased().after(60 * 1000).create();
ghiChu('Còn việc chưa xong, sẽ tự chạy tiếp sau 1 phút');
}
function xoaTiepTuc() {
ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === 'tiepTuc').forEach(t => ScriptApp.deleteTrigger(t));
}
function xoaTrangThai(p) { ['KEO_BU_TU', 'KEO_BU_XONG', 'CON_TRO', 'LAN_CUOI_OK'].forEach(k => p.deleteProperty(k)); }
function docKhoangRieng() {
try { return JSON.parse(PropertiesService.getScriptProperties().getProperty('KHOANG_RIENG') || 'null'); } catch (e) { return null; }
}
// Kéo [t, den] theo từng khúc ngày. Ghi Sheet xong mới gọi luuConTro: bị dừng giữa chừng cũng không hổng dữ liệu.
// Trả về true nếu đã kéo hết, false nếu hết giờ (lần sau kéo tiếp từ vị trí đã lưu).
function keoKhoang(T, t, den, truong, luuConTro) {
const p = PropertiesService.getScriptProperties();
let khuc = so(p.getProperty('KHUC_NGAY')) || 7, mat = 0;
while (t < den) {
if (daChay() + mat * 1.5 > CFG.NGAN_SACH_PHUT * 60000) return false;
const bd = Date.now(), t2 = Math.min(t + khuc * 86400 - 1, den);
try {
const moi = truong === 'update_time';
const a = keoDon(t, t2, truong, T), b = keoAds(t, t2, T),
c = moi ? keoHoanCapNhat(t, t2, T) : keoHoan(t, t2, T), d = keoVi(t, t2, T),
f = moi ? 0 : keoAff(t, t2, T); // khi làm mới, affiliate cập nhật riêng (lamMoiAff)
ghiChu(ddmm(t) + ' đến ' + ddmm(t2) + (moi ? ' (đơn thay đổi)' : '') + ': đơn ' + a + ', ngày ads ' + b + ', trả hàng ' + c
+ ', giao dịch ví ' + d + (moi ? '' : ', affiliate ' + f));
} catch (e) {
if (e.message !== HET_GIO) throw e;
p.setProperty('KHUC_NGAY', String(Math.max(1, Math.floor(khuc / 2))));
ghiChu(ddmm(t) + ' đến ' + ddmm(t2) + ' nhiều dữ liệu, chưa kịp xong: lần sau kéo lại với khoảng ngắn hơn');
return false;
}
luuTatCa(T);
t = t2 + 1;
luuConTro(t);
mat = Date.now() - bd; // tự điều chỉnh độ dài mỗi khúc theo lượng đơn của shop
khuc = mat > 60000 ? Math.max(1, Math.floor(khuc / 2)) : mat < 15000 ? Math.min(14, khuc * 2) : khuc;
p.setProperty('KHUC_NGAY', String(khuc));
}
return true;
}
// Trả về true nếu còn việc chưa xong (cần chạy tiếp)
function chayTrongKhoa() {
xoaTiepTuc();
napCaiDat();
const p = PropertiesService.getScriptProperties(), ss = SpreadsheetApp.getActive();
if (ss.getSpreadsheetTimeZone() !== TZ) { ss.setSpreadsheetTimeZone(TZ); ghiChu('Đã đặt múi giờ của file về ' + TZ); }
if (!ss.getSheetByName(TAB_HD) || p.getProperty('HD_PHIEN_BAN') !== PHIEN_BAN) { // bản mới: làm mới trang hướng dẫn
taoHuongDan(false);
p.setProperty('HD_PHIEN_BAN', PHIEN_BAN);
}
const now = Math.floor(Date.now() / 1000);
const sig = [CFG.SO_NGAY_KEO_BU, CFG.JSON].join('|');
if (p.getProperty('CAU_HINH') !== sig) {
xoaTrangThai(p);
p.setProperty('CAU_HINH', sig);
ghiChu('Cài đặt mới: kéo lần đầu ' + CFG.SO_NGAY_KEO_BU + ' ngày gần nhất');
}
const J = moKhoJson();
const T = {orders: mo('orders', J), items: mo('order_items', J), escrow: mo('escrow', J), ads: mo('ads_daily', J), ret: mo('returns', J), wal: mo('wallet', J), dg: mo('danh_gia', J),
ei: mo('escrow_items', J), aff: mo('affiliate', J)};
// Các tab này tự điền lại riêng, không cần kéo lại đơn hàng
const TU_DIEN = ['escrow', 'ei', 'dg', 'aff'];
if (Object.keys(T).some(k => TU_DIEN.indexOf(k) < 0 && T[k].moiTao) && (p.getProperty('CON_TRO') || p.getProperty('KEO_BU_XONG'))) {
xoaTrangThai(p);
ghiChu('Có tab mới hoặc cột mới: kéo lại ' + CFG.SO_NGAY_KEO_BU + ' ngày gần nhất để điền đủ dữ liệu');
}
try {
// 1. Khoảng ngày người dùng chọn trong menu (ưu tiên trước)
const kr = docKhoangRieng();
if (kr) {
if (!keoKhoang(T, kr.t, kr.den, 'create_time', x => { kr.t = x; p.setProperty('KHOANG_RIENG', JSON.stringify(kr)); })) return true;
p.deleteProperty('KHOANG_RIENG');
ghiChu('Đã kéo xong khoảng ' + ddmm(kr.tu) + ' đến ' + ddmm(kr.den));
}
// 2. Đồng bộ thường xuyên: lần đầu kéo N ngày gần nhất, sau đó kéo các đơn thay đổi.
// CON_TRO giữ vị trí đang kéo dở để lần sau kéo tiếp chứ không làm lại từ đầu.
const xong = p.getProperty('KEO_BU_XONG') === 'co', lan = so(p.getProperty('LAN_CUOI_OK'));
const t = so(p.getProperty('CON_TRO')) || (xong ? Math.min(now - CFG.NGAY_LAM_MOI * 86400, lan ? lan - 86400 : now) : now - CFG.SO_NGAY_KEO_BU * 86400);
if (!keoKhoang(T, t, now, xong ? 'update_time' : 'create_time', x => p.setProperty('CON_TRO', String(x)))) return true;
p.deleteProperty('CON_TRO');
if (!xong) { p.setProperty('KEO_BU_XONG', 'co'); ghiChu('KÉO LẦN ĐẦU XONG'); }
p.setProperty('LAN_CUOI_OK', String(now));
// 3. Tiền đối soát cho các đơn mới hoặc vừa thay đổi
if (hetGio() || keoEscrow(T) === 'gio') return true;
// 4. Mã vận đơn và chi tiết trả hàng: chỉ gọi cho đơn / yêu cầu cần lấy
if (hetGio() || keoVanDon(T) === 'gio') return true;
if (hetGio() || keoChiTietHoan(T) === 'gio') return true;
// 5. Đánh giá và affiliate: tối đa 6 giờ một lần để tiết kiệm hạn mức
if (!hetGio()) keoDanhGia(T);
if (!hetGio()) lamMoiAff(T, now);
return false;
} catch (e) {
if (e.message !== HET_GIO) throw e;
return true;
} finally {
luuTatCa(T);
}
}
// ---------- lệnh nền (cũng chạy được từ Apps Script) ----------
function datLich() {
huyLich();
ScriptApp.newTrigger('chay').timeBased().everyHours(1).create();
}
function huyLich() {
ScriptApp.getProjectTriggers().filter(t => t.getHandlerFunction() === 'chay').forEach(t => ScriptApp.deleteTrigger(t));
xoaTiepTuc();
}
function datLaiKeoBu() {
const p = PropertiesService.getScriptProperties();
xoaTrangThai(p);
['CAU_HINH', 'MUC_TRUONG', 'KHUC_NGAY', 'SO_LOI'].forEach(k => p.deleteProperty(k));
}
function moTab(ten) { const sh = SpreadsheetApp.getActive().getSheetByName(ten); if (sh) SpreadsheetApp.getActive().setActiveSheet(sh); }
// ---------- menu ----------
function onOpen() {
const ui = SpreadsheetApp.getUi();
ui.createMenu('🛒 Shopee')
.addItem('📖 Hướng dẫn sử dụng', 'menuHuongDan')
.addItem('🔑 Cài đặt kết nối', 'menuCaiDat')
.addSeparator()
.addItem('⬇️ Kéo dữ liệu mới nhất', 'menuKeo')
.addItem('📅 Kéo theo khoảng ngày…', 'menuKeoKhoang')
.addItem('📊 Xem tình trạng', 'menuTinhTrang')
.addSeparator()
.addItem('⏰ Bật tự động mỗi giờ', 'menuBatLich')
.addItem('⏸️ Tắt tự động', 'menuTatLich')
.addSeparator()
.addSubMenu(ui.createMenu('⚙️ Nâng cao')
.addItem('Mở tab cài đặt', 'menuMoCaiDat')
.addItem('Mở file JSON gốc', 'menuMoJson')
.addItem('Kéo lại từ đầu', 'menuKeoLai'))
.addSeparator()
.addItem('💼 Liên hệ Logidata', 'menuLienHe')
.addToUi();
}
function menuLienHe() {
hopThoai('💼 Liên hệ Logidata',
'<div class="giua"><div class="icon">💼</div><h2>LOGIDATA</h2><p class="phu">Đơn vị phát triển và vận hành giải pháp kéo dữ liệu này</p></div>'
+ '<div class="dong"><b>🌐 Website</b><span><a href="' + LOGIDATA.web + '" target="_blank">logidata.vn</a></span></div>'
+ '<div class="dong"><b>☎ Hotline</b><span>' + LOGIDATA.hotline.join(' / ') + '</span></div>'
+ (LOGIDATA.email ? '<div class="dong"><b>✉ Email</b><span>' + esc(LOGIDATA.email) + '</span></div>' : '')
+ '<label>Giải pháp của Logidata</label><ul style="margin:4px 0 0 18px;padding:0;line-height:1.8">'
+ LOGIDATA.giaiPhap.map(x => '<li>' + esc(x) + '</li>').join('') + '</ul>'
+ '<div class="nut"><button class="phu2" onclick="google.script.host.close()">Đóng</button>'
+ '<a class="chinh" href="' + LOGIDATA.web + '" target="_blank">Truy cập logidata.vn</a></div>', 470);
}
function menuHuongDan() { taoHuongDan(true); }
function menuMoCaiDat() { SpreadsheetApp.getActive().setActiveSheet(moCaiDat()); }
function menuBatLich() {
datLich();
SpreadsheetApp.getActive().toast('Script sẽ tự kéo dữ liệu mỗi giờ, kể cả khi bạn đóng file.', '⏰ Đã bật tự động', 8);
}
function menuTatLich() {
huyLich();
SpreadsheetApp.getActive().toast('Script sẽ không tự chạy nữa. Bạn vẫn kéo bằng tay được.', '⏸️ Đã tắt tự động', 8);
}
function menuKeo() { hopThoai('Kéo dữ liệu mới nhất', '<script>' + JS_CHUNG + 'keoNgay();</script>', 380); }
function menuKeoLai() {
napCaiDat();
const ui = SpreadsheetApp.getUi();
const r = ui.alert('Kéo lại từ đầu?', 'Script kéo lại ' + CFG.SO_NGAY_KEO_BU + ' ngày gần nhất. Dữ liệu đang có được giữ nguyên và cập nhật, không sinh dòng trùng.', ui.ButtonSet.YES_NO);
if (r !== ui.Button.YES) return;
datLaiKeoBu();
menuKeo();
}
function menuMoJson() {
const id = PropertiesService.getScriptProperties().getProperty('JSON_FILE_ID');
if (!id) { SpreadsheetApp.getActive().toast('File JSON gốc sẽ được tạo ở lần kéo dữ liệu đầu tiên (nếu chọn "File riêng" trong tab cai_dat).', 'Chưa có file JSON', 8); return; }
const url = 'https://docs.google.com/spreadsheets/d/' + id + '/edit';
hopThoai('File JSON gốc', '<p>File chứa phản hồi gốc của Shopee để đối chiếu khi cần. File nằm trong Google Drive của bạn.</p>' +
'<div class="nut"><button class="phu2" onclick="google.script.host.close()">Đóng</button>' +
'<a class="chinh" href="' + esc(url) + '" target="_blank" onclick="google.script.host.close()">Mở file</a></div>', 200);
}
// Lưu key cho riêng tài khoản đang dùng rồi thử gọi API để báo ngay kết nối có đúng không
function luuKetNoi(key, acc) {
key = String(key || '').trim(); acc = String(acc || '').trim();
if (!key || !acc) return {ok: false, msg: 'Vui lòng nhập đủ API key và Account ID.'};
PropertiesService.getUserProperties().setProperties({LOGIDATA_API_KEY: key, LOGIDATA_ACCOUNT_ID: acc});
const s = PropertiesService.getScriptProperties();
['LOGIDATA_API_KEY', 'LOGIDATA_ACCOUNT_ID'].forEach(k => s.deleteProperty(k)); // bỏ key lưu chung của bản cũ
moCaiDat();
if (!SpreadsheetApp.getActive().getSheetByName(TAB_HD)) taoHuongDan(false);
const now = Math.floor(Date.now() / 1000);
try {
chac(goi('shopee', '/api/v2/order/get_order_list', {time_range_field: 'create_time', time_from: now - 86400, time_to: now, page_size: 1}), 'Kiểm tra kết nối');
return {ok: true};
} catch (e) {
return {ok: false, msg: 'Đã lưu nhưng chưa kết nối được. ' + goiYLoi(e.message || e).nhan};
}
}
// Kết quả của chay() đổi sang lời dễ hiểu để hộp thoại hiển thị
function ketQuaHienThi(r) {
const nhan = {
'Xong': 'Dữ liệu đã được cập nhật vào bảng tính.',
'Còn tiếp': 'Dữ liệu còn nhiều. Script tự chạy tiếp mỗi phút cho tới khi xong, bạn có thể đóng file. Xem tiến độ ở 🛒 Shopee → 📊 Xem tình trạng.',
'Bỏ qua': 'Một lần kéo khác đang chạy (có thể là lịch tự động). Vui lòng đợi vài phút rồi xem tab nhat_ky.',
};
const g = r.ketQua === 'Lỗi' ? goiYLoi(r.loi) : null;
return {ketQua: r.ketQua, moi: r.moi, sua: r.sua, canCaiDat: !!(g && g.caiDat),
loiNhan: g ? g.nhan + (r.thuLai ? ' Script sẽ tự thử lại sau 1 phút.' : '') : nhan[r.ketQua]};
}
function chayTuMenu() { return ketQuaHienThi(chay()); }
function batDauKeoKhoang(tu, den) {
const now = Math.floor(Date.now() / 1000), hop = s => /^\d{4}-\d{2}-\d{2}$/.test(String(s || ''));
const a = hop(tu) ? mocVN(tu, false) : now - CFG.SO_NGAY_KHOANG * 86400; // để trống: 30 ngày gần nhất
const b = hop(den) ? Math.min(mocVN(den, true), now) : now;
if (!(a < b)) return {ketQua: 'Lỗi', moi: 0, sua: 0, loiNhan: 'Khoảng ngày không hợp lệ: "Từ ngày" phải trước "Đến ngày" và không sau hôm nay.'};
PropertiesService.getScriptProperties().setProperty('KHOANG_RIENG', JSON.stringify({tu: a, den: b, t: a}));
ghiChu('Yêu cầu kéo khoảng ' + ddmm(a) + ' đến ' + ddmm(b));
const r = ketQuaHienThi(chay());
if (r.ketQua === 'Bỏ qua') r.loiNhan = 'Đã nhận yêu cầu. Một lần kéo khác đang chạy, khoảng ngày bạn chọn sẽ được kéo ngay sau đó.';
if (r.ketQua === 'Xong') r.loiNhan = 'Đã kéo xong dữ liệu từ ' + ddmm(a) + ' đến ' + ddmm(b) + '.';
return r;
}
// ---------- hộp thoại ----------
const CSS = `
*{box-sizing:border-box}body{font-family:Arial,Helvetica,sans-serif;margin:0;padding:2px 4px;color:#202124;font-size:14px;line-height:1.5}
h2{font-size:18px;margin:6px 0 6px}p{margin:6px 0}.phu{color:#5f6368;font-size:13px}
label{display:block;font-weight:bold;margin:12px 0 4px}
input{width:100%;padding:9px 10px;border:1px solid #dadce0;border-radius:8px;font-size:14px;font-family:inherit}
input:focus{outline:none;border-color:#EE4D2D;box-shadow:0 0 0 3px #EE4D2D22}
.hang{display:flex;gap:10px}.hang>*{flex:1}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
.chip{border:1px solid #dadce0;background:#fff;border-radius:16px;padding:6px 12px;cursor:pointer;font-size:13px;font-family:inherit}
.chip:hover{background:#f8f9fa}.chip.on{background:#FDE7E2;border-color:#EE4D2D;color:#B7361C;font-weight:bold}
.nut{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;flex-wrap:wrap}
.chinh{background:#EE4D2D;color:#fff;border:0;border-radius:8px;padding:10px 18px;font-size:14px;font-weight:bold;cursor:pointer;text-decoration:none;font-family:inherit}
.chinh:hover{background:#D73F21}.chinh:disabled{opacity:.5;cursor:default}
.phu2{background:#fff;color:#3c4043;border:1px solid #dadce0;border-radius:8px;padding:10px 16px;font-size:14px;cursor:pointer;font-family:inherit}
.phu2:hover{background:#f8f9fa}
.hop{border-radius:10px;padding:10px 12px;margin-top:12px;font-size:13px}
.xanh{background:#E6F4EA;color:#137333}.vang{background:#FEF7E0;color:#8A5A00}.do{background:#FCE8E6;color:#B3261E}.xam{background:#F1F3F4;color:#3c4043}
.quay{width:40px;height:40px;border:4px solid #FDE7E2;border-top-color:#EE4D2D;border-radius:50%;animation:q 1s linear infinite;margin:28px auto 14px}
@keyframes q{to{transform:rotate(360deg)}}
.giua{text-align:center}.icon{font-size:40px;margin-top:10px}
.the{border:1px solid #e8eaed;border-radius:10px;padding:10px 12px;text-align:center}
.so{font-size:26px;font-weight:bold;color:#EE4D2D;display:block}
.logi{margin-top:16px;padding-top:10px;border-top:1px solid #e8eaed;font-size:12px;color:#5f6368;text-align:center}
.logi b{color:#0B3B75}.logi a{color:#0B3B75}
.dong{display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid #f1f3f4}
.dong b{color:#5f6368;font-weight:normal;flex:0 0 42%}.dong span{text-align:right}
table{width:100%;border-collapse:collapse;font-size:13px;margin-top:6px}td{padding:5px 2px;border-bottom:1px solid #f1f3f4}td.r{text-align:right;font-weight:bold}
`;
// Dùng chung cho các hộp thoại: màn hình đang chạy và màn hình kết quả
const JS_CHUNG = `
function noiDung(){return document.getElementById('nd')||document.body;}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
function hienDangChay(ten){noiDung().innerHTML='<div class="giua"><div class="quay"></div><h2>'+esc(ten)+'</h2><p class="phu">Có thể mất tới 5 phút. Bạn có thể đóng hộp này: việc kéo vẫn tiếp tục và kết quả được ghi ở tab nhat_ky.</p></div>';}
function hienKetQua(r){
var m={'Xong':['✅','Hoàn tất','xanh'],'Còn tiếp':['⏳','Đã kéo một phần','vang'],'Lỗi':['⚠️','Có lỗi xảy ra','do'],'Bỏ qua':['⏸️','Đang có một lần kéo khác','xam']}[r.ketQua]||['ℹ️',r.ketQua,'xam'];
var h='<div class="giua"><div class="icon">'+m[0]+'</div><h2>'+m[1]+'</h2></div>';
if(r.ketQua==='Xong'||r.ketQua==='Còn tiếp')h+='<div class="hang"><div class="the"><span class="so">'+Number(r.moi||0).toLocaleString('vi-VN')+'</span>dòng mới</div><div class="the"><span class="so">'+Number(r.sua||0).toLocaleString('vi-VN')+'</span>dòng cập nhật</div></div>';
h+='<div class="hop '+m[2]+'">'+esc(r.loiNhan)+'</div><div class="nut">';
if(r.canCaiDat)h+='<button class="phu2" onclick="google.script.run.menuCaiDat()">🔑 Cài đặt kết nối</button>';
h+='<button class="phu2" onclick="google.script.run.withSuccessHandler(function(){google.script.host.close();}).moTab(\\'nhat_ky\\')">Xem nhật ký</button><button class="chinh" onclick="google.script.host.close()">Đóng</button></div>';
noiDung().innerHTML=h;
}
function chayVaHien(ten,goiServer){
hienDangChay(ten);
goiServer(google.script.run.withSuccessHandler(hienKetQua).withFailureHandler(function(e){hienKetQua({ketQua:'Lỗi',loiNhan:'Không chạy được: '+(e&&e.message||e)});}));
}
function keoNgay(){chayVaHien('Đang kéo dữ liệu mới nhất...',function(g){g.chayTuMenu();});}
`;
// Nội dung nằm trong #nd để các màn hình đang chạy / kết quả thay nội dung mà vẫn giữ chân Logidata
function hopThoai(tieuDe, than, cao) {
const chan = '<div class="logi">Phát triển bởi <b>LOGIDATA</b> · <a href="' + LOGIDATA.web + '" target="_blank">logidata.vn</a> · ☎ '
+ LOGIDATA.hotline.join(' / ') + (LOGIDATA.email ? ' · ✉ ' + esc(LOGIDATA.email) : '') + '</div>';
const html = '<!DOCTYPE html><html><head><base target="_top"><meta charset="utf-8"><style>' + CSS + '</style></head><body><div id="nd">'
+ than + '</div>' + chan + '</body></html>';
SpreadsheetApp.getUi().showModalDialog(HtmlService.createHtmlOutput(html).setWidth(500).setHeight(cao + 45), tieuDe);
}
function menuCaiDat() {
napCaiDat();
const k = layKey();
const daCo = k.key && k.acc
? '<div class="hop xanh">✅ Bạn đã có kết nối (API key kết thúc bằng …' + esc(String(k.key).slice(-4)) + '). Chỉ nhập lại nếu muốn đổi.</div>' : '';
hopThoai('🔑 Cài đặt kết nối', `
<p class="phu">Lấy <b>API key</b> và <b>Account ID</b> trong portal Logidata rồi dán vào đây.</p>
<div class="hop xam">🔒 Thông tin chỉ lưu cho tài khoản Google của bạn, không ghi vào ô nào của bảng tính. ⚠ Người có quyền chỉnh sửa file có thể mở và chạy Script, chỉ cấp quyền này cho người bạn tin tưởng.</div>
${daCo}
<label for="key">API key</label>
<div class="hang"><input id="key" type="password" placeholder="ld_live_..." autocomplete="off" style="flex:5">
<button class="phu2" type="button" style="flex:1" title="Hiện / ẩn" onclick="var i=document.getElementById('key');i.type=i.type==='password'?'text':'password'">👁</button></div>
<label for="acc">Account ID</label>
<input id="acc" placeholder="ldc_..." autocomplete="off">
<div id="tb"></div>
<div class="nut"><button class="phu2" onclick="google.script.host.close()">Đóng</button><button class="chinh" id="luu">Lưu và kiểm tra</button></div>
<script>${JS_CHUNG}
document.getElementById('luu').onclick=function(){
var key=document.getElementById('key').value.trim(),acc=document.getElementById('acc').value.trim(),tb=document.getElementById('tb'),b=this;
if(!key||!acc){tb.innerHTML='<div class="hop do">Vui lòng nhập đủ API key và Account ID.</div>';return;}
b.disabled=true;b.textContent='Đang kiểm tra...';tb.innerHTML='';
google.script.run.withSuccessHandler(function(r){
if(r.ok){noiDung().innerHTML='<div class="giua"><div class="icon">✅</div><h2>Kết nối thành công!</h2><p class="phu">Bước tiếp theo: kéo dữ liệu về bảng tính. Lần đầu script lấy ${CFG.SO_NGAY_KEO_BU} ngày gần nhất.</p></div>'+
'<div class="nut" style="justify-content:center"><button class="phu2" onclick="google.script.run.menuKeoKhoang()">📅 Chọn khoảng ngày</button><button class="chinh" onclick="keoNgay()">⬇️ Kéo dữ liệu ngay</button></div>';return;}
tb.innerHTML='<div class="hop do">'+esc(r.msg)+'</div>';b.disabled=false;b.textContent='Lưu và kiểm tra';
}).withFailureHandler(function(e){tb.innerHTML='<div class="hop do">'+esc(e.message||e)+'</div>';b.disabled=false;b.textContent='Lưu và kiểm tra';}).luuKetNoi(key,acc);
};
</script>`, 470);
}
function menuKeoKhoang() {
const kr = docKhoangRieng();
const dangKeo = kr ? '<div class="hop vang">⏳ Đang kéo dở khoảng ' + ddmm(kr.tu) + ' đến ' + ddmm(kr.den) + ' (đã tới ' + ddmm(kr.t) + '). Chọn khoảng mới sẽ thay thế khoảng này.</div>' : '';
hopThoai('📅 Kéo theo khoảng ngày', `
<p class="phu">Lấy dữ liệu của một khoảng ngày bất kỳ, ví dụ tháng trước. Việc cập nhật tự động không bị ảnh hưởng.</p>
${dangKeo}
<label>Chọn nhanh</label>
<div class="chips">
<button class="chip" data-k="7">7 ngày qua</button><button class="chip" data-k="30">30 ngày qua</button>
<button class="chip" data-k="thang">Tháng này</button><button class="chip" data-k="truoc">Tháng trước</button><button class="chip" data-k="90">90 ngày qua</button>
</div>
<div class="hang"><div><label for="tu">Từ ngày</label><input type="date" id="tu"></div><div><label for="den">Đến ngày</label><input type="date" id="den"></div></div>
<div id="tom" class="hop xam"></div>
<div class="nut"><button class="phu2" onclick="google.script.host.close()">Huỷ</button><button class="chinh" id="ok">Bắt đầu kéo</button></div>
<script>${JS_CHUNG}
var tu=document.getElementById('tu'),den=document.getElementById('den'),tom=document.getElementById('tom'),ok=document.getElementById('ok');
function iso(d){return d.getFullYear()+'-'+('0'+(d.getMonth()+1)).slice(-2)+'-'+('0'+d.getDate()).slice(-2);}
function vn(s){var p=s.split('-');return p[2]+'/'+p[1]+'/'+p[0];}
var homNay=new Date();homNay.setHours(0,0,0,0);tu.max=den.max=iso(homNay);
function chon(k){
var a=new Date(homNay),b=new Date(homNay);
if(k==='thang')a.setDate(1);
else if(k==='truoc'){a=new Date(homNay.getFullYear(),homNay.getMonth()-1,1);b=new Date(homNay.getFullYear(),homNay.getMonth(),0);}
else a.setDate(a.getDate()-Number(k)+1);
tu.value=iso(a);den.value=iso(b);capNhat(k);
}
function capNhat(k){
[].forEach.call(document.querySelectorAll('.chip'),function(c){c.classList.toggle('on',c.getAttribute('data-k')===k);});
var a=tu.value,b=den.value;ok.disabled=false;
if(!a&&!b){tom.className='hop xam';tom.textContent='Để trống: script kéo ${CFG.SO_NGAY_KHOANG} ngày gần nhất.';return;}
if(!a||!b){tom.className='hop do';tom.textContent='Vui lòng chọn cả "Từ ngày" và "Đến ngày", hoặc để trống cả hai.';ok.disabled=true;return;}
if(a>b){tom.className='hop do';tom.textContent='"Từ ngày" phải trước hoặc bằng "Đến ngày".';ok.disabled=true;return;}
if(b>iso(homNay)){tom.className='hop do';tom.textContent='"Đến ngày" không được sau hôm nay.';ok.disabled=true;return;}
var n=Math.round((new Date(b)-new Date(a))/86400000)+1;
tom.className='hop '+(n>92?'vang':'xanh');
tom.textContent='Sẽ kéo '+n+' ngày: từ '+vn(a)+' đến '+vn(b)+'. Đơn đã có được cập nhật, không sinh dòng trùng.'+(n>92?' Khoảng dài nên script sẽ tự chạy nền nhiều lượt, xem tiến độ ở 📊 Xem tình trạng.':'');
}
[].forEach.call(document.querySelectorAll('.chip'),function(c){c.onclick=function(){chon(c.getAttribute('data-k'));};});
tu.onchange=den.onchange=function(){capNhat('');};
chon('30');
ok.onclick=function(){var a=tu.value,b=den.value;chayVaHien('Đang kéo dữ liệu theo khoảng ngày...',function(g){g.batDauKeoKhoang(a,b);});};
</script>`, 500);
}
function menuTinhTrang() {
const p = PropertiesService.getScriptProperties(), ss = SpreadsheetApp.getActive();
const k = layKey(), xong = p.getProperty('KEO_BU_XONG') === 'co', cp = so(p.getProperty('CON_TRO')), kr = docKhoangRieng();
const tg = ScriptApp.getProjectTriggers().map(t => t.getHandlerFunction());
const tuDong = tg.indexOf('chay') >= 0, chayNen = tg.indexOf('tiepTuc') >= 0;
const nk = ss.getSheetByName('nhat_ky');
const cuoi = nk && nk.getLastRow() > 1 ? nk.getRange(2, 1, 1, 2).getValues()[0] : null;
const dong = (nhan, gt) => '<div class="dong"><b>' + nhan + '</b><span>' + gt + '</span></div>';
const dl = xong ? '✅ Đã có dữ liệu, đang cập nhật thường xuyên'
: cp ? '⏳ Đang kéo lần đầu (đã tới ' + ddmm(cp) + ')' : '⏸️ Chưa kéo lần nào';
let h = dong('Kết nối', k.key && k.acc ? '✅ Đã cài' : '⚠️ Chưa cài')
+ dong('Dữ liệu', dl)
+ (kr ? dong('Khoảng ngày đang kéo', '⏳ ' + ddmm(kr.tu) + ' → ' + ddmm(kr.den) + '<br><span class="phu">đã tới ' + ddmm(kr.t) + '</span>') : '')
+ dong('Đang chạy nền', chayNen ? '⏳ Có, tự chạy tiếp mỗi phút' : 'Không')
+ dong('Tự động mỗi giờ (do bạn bật)', tuDong ? '✅ Đang bật' : '⏸️ Đang tắt')
+ dong('Lần chạy gần nhất', cuoi && cuoi[0] instanceof Date ? esc(Utilities.formatDate(cuoi[0], TZ, 'HH:mm dd/MM/yyyy')) + ' · ' + esc(cuoi[1]) : 'Chưa có');
// /v1/me của Logidata không tính vào hạn mức
let hm = '';
try {
const m = chac(goi('me', '', {}), 'me'), ds = Array.isArray(m.providers) ? m.providers : [];
hm = soDep(so(m.quota_used)) + ' / ' + soDep(so(m.monthly_quota)) + ' lượt' + (m.plan ? ' (gói ' + esc(m.plan) + ')' : '')
+ (ds.length ? ['shopee', 'shopee-ads', 'shopee-affiliate'].filter(x => ds.indexOf(x) < 0)
.map(x => '<br><span class="phu">⚠️ Gói chưa có ' + x + '</span>').join('') : '');
} catch (e) { hm = '⚠️ ' + esc(goiYLoi(e.message).nhan); }
h += dong('Hạn mức API Logidata', hm);
h += '<table>' + Object.keys(COT).map(t => {
const sh = ss.getSheetByName(t);
return '<tr><td>' + TEN_TAB[t] + ' <span class="phu">(' + t + ')</span></td><td class="r">' + soDep(sh ? Math.max(0, sh.getLastRow() - 1) : 0) + ' dòng</td></tr>';
}).join('') + '</table>';
h += '<div class="nut">'
+ '<button class="phu2" onclick="this.disabled=true;google.script.run.withSuccessHandler(function(){google.script.run.menuTinhTrang();}).' + (tuDong ? 'huyLich' : 'datLich') + '()">'
+ (tuDong ? '⏸️ Tắt tự động' : '⏰ Bật tự động mỗi giờ') + '</button>'
+ '<button class="phu2" onclick="google.script.run.withSuccessHandler(function(){google.script.host.close();}).moTab(\'nhat_ky\')">Xem nhật ký</button>'
+ '<button class="chinh" onclick="keoNgay()">⬇️ Kéo dữ liệu ngay</button></div>'
+ '<script>' + JS_CHUNG + '</script>';
hopThoai('📊 Tình trạng', h, 680);
}
// ---------- tab Hướng dẫn ----------
// Mỗi dòng: ['h1' | 'sub' | 'banner' | 'h2' | 'row' | 'note' | 'gap', cột B, cột C hoặc màu của mục (h2)]
const HUONG_DAN = [
// Hai dòng đầu được cố định: luôn nhìn thấy tên và liên hệ Logidata khi cuộn
['h1', '🛒 Kéo dữ liệu Shopee về Google Sheet'],
['banner', '💼 Giải pháp do LOGIDATA phát triển và vận hành · 🌐 logidata.vn · ☎ ' + LOGIDATA.hotline.join(' / ')
+ (LOGIDATA.email ? ' · ✉ ' + LOGIDATA.email : '')],
['sub', 'Tự động lấy đơn hàng, tiền đối soát, quảng cáo, trả hàng, giao dịch ví, đánh giá và đơn affiliate từ Shopee (qua Logidata) về bảng tính này. Mọi thao tác đều làm trong menu 🛒 Shopee trên thanh công cụ, không cần biết lập trình.'],
['gap'],
['h2', '🚀 Bắt đầu trong 3 bước', 'cam'],
['row', 'Bước 1 · Kết nối', 'Menu 🛒 Shopee → 🔑 Cài đặt kết nối. Dán API key và Account ID lấy từ portal Logidata. Script kiểm tra kết nối ngay và báo cho bạn.'],
['row', 'Bước 2 · Kéo dữ liệu', 'Bấm ⬇️ Kéo dữ liệu mới nhất. Lần đầu script lấy 30 ngày gần nhất. Nếu dữ liệu nhiều, script tự chạy tiếp mỗi phút ở chế độ nền, bạn đóng file cũng được.'],
['row', 'Bước 3 · Bật tự động', 'Bấm ⏰ Bật tự động mỗi giờ. Từ đó đơn mới và đơn đổi trạng thái tự được cập nhật, bạn không cần làm gì thêm.'],
['note', '💡 Không thấy menu 🛒 Shopee? Tải lại trang (F5) và đợi vài giây. Lần đầu bấm, Google hỏi cấp quyền: chọn tài khoản → Nâng cao → Đi tới dự án → Cho phép (xem giải thích ở mục An toàn).'],
['gap'],
['h2', '💼 Về Logidata · Liên hệ và giải pháp', 'logi'],
['row', '🌐 Website', LOGIDATA.web],
['row', '☎ Hotline', LOGIDATA.hotline.join(' · ')],
...(LOGIDATA.email ? [['row', '✉ Email', LOGIDATA.email]] : []),
['row', '🧩 Giải pháp', LOGIDATA.giaiPhap.map(x => '• ' + x).join('\n')],
['row', '🤝 Cần hỗ trợ thêm?', 'Liên hệ Logidata qua hotline hoặc website để được tư vấn: kết nối thêm nền tảng, dựng báo cáo, tự động hoá quy trình dữ liệu cho doanh nghiệp của bạn. Bấm 🛒 Shopee → 💼 Liên hệ Logidata để xem nhanh.'],
['gap'],
['h2', '🧭 Các nút trong menu 🛒 Shopee', 'cam'],
['row', '🔑 Cài đặt kết nối', 'Nhập hoặc đổi API key. Mỗi người dùng nhập key của riêng mình.'],
['row', '⬇️ Kéo dữ liệu mới nhất', 'Lấy đơn mới và cập nhật các đơn vừa đổi trạng thái.'],
['row', '📅 Kéo theo khoảng ngày…', 'Chọn một khoảng bất kỳ (7 ngày, tháng này, tháng trước, 90 ngày hoặc tự chọn trên lịch) để lấy thêm dữ liệu. Để trống thì lấy 30 ngày gần nhất. Không ảnh hưởng việc cập nhật tự động.'],
['row', '📊 Xem tình trạng', 'Xem script đã kéo tới đâu, có đang tự động không, lần chạy gần nhất ra sao, mỗi tab có bao nhiêu dòng.'],
['row', '⏰ Bật / ⏸️ Tắt tự động', 'Bật để script tự chạy mỗi giờ, kể cả khi bạn tắt máy. Mỗi file chỉ cần một người bật.'],
['row', '⚙️ Nâng cao', 'Mở tab cài đặt, mở file JSON gốc, hoặc kéo lại từ đầu (dữ liệu cũ được giữ và cập nhật, không sinh dòng trùng).'],
['row', '💼 Liên hệ Logidata', 'Thông tin liên hệ và các giải pháp dữ liệu của Logidata.'],
['gap'],
['h2', '📑 Các tab trong file', 'cam'],
['row', 'orders', 'Đơn hàng: trạng thái, tiền, vận chuyển, mã vận đơn, mã kiện, hạn giao, ngày giao dự kiến, trạng thái vận chuyển, cân nặng tính phí, lý do huỷ, lời nhắn của khách, ghi chú của shop, thông tin người mua và người nhận. Mỗi đơn một dòng.'],
['row', 'order_items', 'Từng sản phẩm trong đơn: SKU, tên, tên phân loại, ảnh, cân nặng, số lượng (kể cả đã huỷ, đã trả), giá gốc, giá sau giảm, khuyến mãi, hàng mua kèm.'],
['row', 'escrow', 'Tiền đối soát từng đơn: tổng khách trả, số tiền thực nhận (trước và sau điều chỉnh), giảm giá của shop và Shopee, voucher, xu, hoa hồng, các loại phí, thuế, phí ship, phí ship hoàn, phí ship trả hàng, đền bù, lý do điều chỉnh, hoàn tiền. phương thức thanh toán, các phí thực sau ưu đãi. Lưu ý: hoàn tiền đã được Shopee trừ sẵn trong giá bán, không trừ thêm.'],
['row', 'escrow_items', 'Tiền đối soát theo từng sản phẩm trong đơn: giá gốc, giá bán, giá sau giảm, phần giảm từ xu và voucher, hoa hồng affiliate.'],
['row', 'ads_daily', 'Quảng cáo theo ngày: hiển thị, click, CTR, CPC, chi tiêu, số đơn, chuyển đổi, số sản phẩm bán, doanh thu, ROAS, chi phí mỗi chuyển đổi.'],
['row', 'returns', 'Yêu cầu trả hàng / hoàn tiền: lý do, số tiền, trạng thái, giải pháp, hạn xử lý, mã vận đơn hoàn, sản phẩm bị trả, bên chịu phí ship, tiền đền bù, đề nghị thương lượng, trạng thái vận chuyển hàng hoàn, hạn nộp bằng chứng.'],
['row', 'wallet', 'Giao dịch ví người bán: loại, số tiền, tiền vào hay ra, phí giao dịch, mô tả, lý do, số dư sau giao dịch.'],
['row', 'danh_gia', 'Đánh giá sản phẩm: số sao, nội dung, ảnh và video của khách, phản hồi của shop. Shopee chỉ cho lấy khoảng 500–1.000 đánh giá gần nhất của shop.'],
['row', 'affiliate', 'Đơn từ tiếp thị liên kết (affiliate): nhà tiếp thị, giá trị đơn, hoa hồng, tỉ lệ, trạng thái xác minh. Cần bấm Cấp quyền Shopee Affiliate trong portal Logidata.'],
['row', 'nhat_ky', 'Nhật ký mỗi lần chạy: thời gian, kết quả, số dòng mới, số dòng cập nhật, lỗi nếu có. Dòng mới nhất ở trên cùng.'],
['row', 'cai_dat', 'Vài tuỳ chọn đơn giản (số ngày kéo lần đầu, cách lưu JSON gốc). Thường không cần sửa.'],
['note', 'ℹ️ Shopee tự che một phần thông tin người mua ở đơn đã hoàn tất. Script lấy đúng những gì Shopee trả về. Số liệu trong ngày hôm nay Shopee chưa chốt, script tự cập nhật lại ở các lần chạy sau.'],
['gap'],
['h2', '🔒 An toàn và bảo mật', 'xanh'],
['row', 'Chỉ đọc, không đụng tới shop', 'Script chỉ dùng các lệnh LẤY dữ liệu. Script không huỷ, sửa, xác nhận đơn hay thay đổi bất cứ thứ gì trên shop Shopee của bạn.'],
['row', 'API key được giữ kín', 'Key lưu trong vùng riêng của tài khoản Google của bạn, không nằm trong ô nào của bảng tính. Lưu ý: người có quyền chỉnh sửa file có thể mở và chạy Script, nên chỉ cấp quyền này cho người bạn tin tưởng.'],
['row', 'Dữ liệu ở trong Drive của bạn', 'Dữ liệu đi thẳng từ Logidata về bảng tính này. Script chỉ kết nối tới một địa chỉ duy nhất là api.logidata.vn và không gửi dữ liệu đi nơi nào khác.'],
['row', 'Mã nguồn minh bạch', 'Toàn bộ mã nằm trong menu Tiện ích (Tiện ích mở rộng) → Apps Script. Ai có quyền chỉnh sửa file đều xem và kiểm tra được.'],
['row', 'Về cảnh báo của Google', 'Khi cấp quyền, Google báo "ứng dụng chưa được xác minh". Google hiện cảnh báo này cho mọi script tự viết chưa đăng ký với Google. Các quyền script xin: xem và sửa bảng tính (để ghi dữ liệu và tạo file JSON gốc; script chỉ mở file này và file JSON gốc do chính nó tạo), kết nối dịch vụ bên ngoài (để gọi Logidata), chạy theo lịch (để tự cập nhật), hiển thị hộp thoại.'],
['row', 'Thông tin khách hàng', 'File chứa tên, số điện thoại, địa chỉ khách. Chỉ chia sẻ cho người thật sự cần và không bật chế độ "Bất kỳ ai có đường liên kết".'],
['gap'],
['h2', '🛡️ Đảm bảo dữ liệu đầy đủ và chính xác', 'lam'],
['row', 'Không bao giờ trùng dòng', 'Mỗi đơn, mỗi giao dịch có một mã riêng. Chạy bao nhiêu lần cũng vậy: đơn đã có thì cập nhật đúng dòng của nó, đơn mới thì thêm dòng mới.'],
['row', 'Trạng thái luôn mới', 'Mỗi lần chạy, script lấy lại các đơn vừa thay đổi (kể cả đơn tạo từ lâu), cùng tiền đối soát và yêu cầu trả hàng liên quan.'],
['row', 'Không mất dữ liệu khi bị ngắt', 'Google giới hạn mỗi lần chạy tối đa 6 phút. Script ghi dữ liệu vào bảng xong mới đánh dấu tiến độ, rồi tự chạy tiếp từ đúng chỗ đã dừng.'],
['row', 'Không xoá dữ liệu cũ', 'Script không xoá dòng nào. Khi nâng cấp có thêm cột, script chèn cột mới ngay sau các cột của nó, cột riêng của bạn ở bên phải được giữ nguyên. Chỉ khi cấu trúc thay đổi hẳn, tab cũ mới được giữ lại dưới tên mới (ví dụ wallet_cu_10301530).'],
['row', 'Tự thử lại khi có lỗi', 'Lỗi mạng hoặc máy chủ: script tự thử lại. Mọi lần chạy và mọi lỗi đều được ghi ở tab nhat_ky.'],
['row', 'Không chạy chồng', 'Nhiều người cùng bấm, hoặc lịch tự động trùng lúc: chỉ một lần chạy được thực hiện, các lần còn lại tự bỏ qua.'],
['row', 'Lưu bản gốc để đối chiếu', 'Phản hồi gốc của Shopee được lưu ở file JSON gốc (hoặc tab riêng) để kiểm tra khi cần.'],
['gap'],
['h2', '✋ Để dữ liệu luôn đúng', 'cam'],
['row', '👍 Nên', 'Thêm cột tính toán hoặc ghi chú ở BÊN PHẢI các cột của script. Làm báo cáo, bảng tổng hợp (Pivot), biểu đồ ở tab khác.'],
['row', '👎 Không nên', 'Sửa tay hoặc xoá dòng trong các tab dữ liệu. Sắp xếp dòng khi script đang chạy. Đổi tên các tab dữ liệu (script sẽ tạo tab mới và kéo lại từ đầu).'],
['gap'],
['h2', '❓ Câu hỏi thường gặp', 'cam'],
['row', 'Chạy nhiều lần có bị trùng không?', 'Không. Xem mục "Đảm bảo dữ liệu đầy đủ và chính xác".'],
['row', 'Tắt máy hoặc đóng file thì sao?', 'Không sao. Script chạy trên máy chủ của Google, không cần mở file hay bật máy.'],
['row', 'Lần đầu kéo mất bao lâu?', 'Tuỳ số đơn: vài phút với shop nhỏ, có thể vài chục phút với shop lớn. Xem tiến độ ở 📊 Xem tình trạng.'],
['row', 'Muốn lấy dữ liệu tháng trước?', '📅 Kéo theo khoảng ngày → chọn "Tháng trước" → Bắt đầu kéo.'],
['row', 'Người khác dùng chung file được không?', 'Người chỉ cần xem số liệu thì nên để quyền Người xem. Người có quyền chỉnh sửa có thể mở và chạy Script, nên chỉ cấp cho người bạn tin tưởng. Nếu họ muốn tự bấm kéo, họ cần nhập key của riêng họ. Lịch tự động chạy bằng tài khoản của người đã bật.'],
['row', 'Gặp lỗi thì làm gì?', 'Hộp thoại kết quả sẽ giải thích lỗi và cách xử lý. Lỗi về key: vào 🔑 Cài đặt kết nối nhập lại. Lỗi khác: script tự thử lại; nếu vẫn lỗi, gửi dòng trên cùng của tab nhat_ky cho Logidata (hotline ' + LOGIDATA.hotline.join(' / ') + ').'],
['gap'],
['h2', '🔧 Chi tiết cách script hoạt động', 'cam'],
['row', 'Lần đầu', 'Kéo các đơn tạo trong 30 ngày gần nhất (đổi được ở tab cai_dat), cùng quảng cáo, trả hàng và giao dịch ví cùng khoảng thời gian.'],
['row', 'Các lần sau', 'Kéo các đơn và các yêu cầu trả hàng có thay đổi kể từ lần chạy trước (luôn lùi thêm ít nhất 3 ngày để không sót).'],
['row', 'Tiền đối soát', 'Lấy cho đơn mới, và lấy lại khi đơn đổi trạng thái hoặc có trả hàng mới. Bỏ qua đơn đã huỷ hoặc chưa thanh toán.'],
['row', 'Mã vận đơn', 'Lấy một lần cho mỗi đơn, khi đơn đã giao cho đơn vị vận chuyển.'],
['row', 'Chi tiết trả hàng', 'Lấy cho yêu cầu trả hàng mới, và lấy lại khi yêu cầu có cập nhật.'],
['row', 'Đánh giá và affiliate', 'Cập nhật tối đa 6 giờ một lần. Affiliate cập nhật lại 30 ngày gần nhất vì hoa hồng được chốt sau khi đơn hoàn tất.'],
['row', 'Hạn mức API', 'Mỗi lần gọi API tính vào hạn mức gói Logidata. Tiền đối soát, mã vận đơn, chi tiết trả hàng tốn 1 lượt cho mỗi đơn. Xem hạn mức đã dùng ở 📊 Xem tình trạng.'],
['row', 'Chia nhỏ để chạy bền', 'Mỗi lần chạy tối đa khoảng 5 phút, kéo theo từng khoảng vài ngày. Script tự rút ngắn khoảng khi shop nhiều đơn, rồi tự hẹn chạy tiếp sau 1 phút cho tới khi xong.'],
['gap'],
['sub', 'Phiên bản ' + PHIEN_BAN + ' · Phát triển bởi Logidata · logidata.vn · Hotline ' + LOGIDATA.hotline.join(' / ') + ' · Bấm 📖 Hướng dẫn sử dụng trong menu để cập nhật trang này.'],
];
function taoHuongDan(kichHoat) {
const ss = SpreadsheetApp.getActive();
let sh = ss.getSheetByName(TAB_HD);
if (sh) {
sh.getRange(1, 1, sh.getMaxRows(), sh.getMaxColumns()).breakApart();
sh.clear();
sh.getProtections(SpreadsheetApp.ProtectionType.SHEET).forEach(x => x.remove());
} else {
sh = ss.insertSheet(TAB_HD, 0);
}
const D = HUONG_DAN, n = D.length;
sh.getRange(1, 1, n, 3).setValues(D.map(d => ['', d[1] || '', d[0] === 'row' ? d[2] : '']));
sh.getRange(1, 2, n, 2).setWrap(true).setVerticalAlignment('middle').setFontFamily('Arial').setFontSize(11).setFontColor('#202124');
const MAU = {cam: ['#FEF3F0', '#C2410C'], xanh: ['#E6F4EA', '#137333'], lam: ['#E8F0FE', '#1967D2'], logi: ['#EAF0F8', '#0B3B75']};
const nhom = {}, them = (k, a1) => (nhom[k] = nhom[k] || []).push(a1);
let mau = 'cam';
D.forEach((d, i) => {
const r = i + 1, bc = 'B' + r + ':C' + r;
if (d[0] === 'h2') mau = d[2] || 'cam';
if (d[0] === 'gap') { sh.setRowHeight(r, 10); return; }
if (d[0] === 'row') { them('nen_' + mau, bc); them('nhan_' + mau, 'B' + r); return; }
sh.getRange(bc).merge();
them(d[0] === 'h2' ? 'h2_' + mau : d[0], bc);
if (d[0] === 'h1') sh.setRowHeight(r, 56);
if (d[0] === 'banner') sh.setRowHeight(r, 36);
if (d[0] === 'h2') sh.setRowHeight(r, 38);
});
const L = k => nhom[k] ? sh.getRangeList(nhom[k]) : null;
if (L('h1')) L('h1').setFontSize(20).setFontWeight('bold').setFontColor('#FFFFFF').setBackground('#EE4D2D').setHorizontalAlignment('left');
if (L('sub')) L('sub').setFontColor('#5F6368').setFontSize(11);
if (L('banner')) L('banner').setBackground('#0B3B75').setFontColor('#FFFFFF').setFontWeight('bold').setFontSize(12);
if (L('note')) L('note').setBackground('#FFF8E1').setFontColor('#6D4C00').setFontSize(10);
Object.keys(MAU).forEach(m => {
if (L('h2_' + m)) L('h2_' + m).setFontSize(14).setFontWeight('bold').setFontColor(MAU[m][1]).setVerticalAlignment('bottom')
.setBorder(null, null, true, null, null, null, MAU[m][1], SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
if (L('nen_' + m)) L('nen_' + m).setBackground(MAU[m][0])
.setBorder(null, null, true, null, null, null, '#FFFFFF', SpreadsheetApp.BorderStyle.SOLID_MEDIUM);
if (L('nhan_' + m)) L('nhan_' + m).setFontWeight('bold').setFontColor(MAU[m][1]).setVerticalAlignment('top');
});
sh.setColumnWidth(1, 24); sh.setColumnWidth(2, 250); sh.setColumnWidth(3, 680);
if (sh.getMaxColumns() > 3) sh.hideColumns(4, sh.getMaxColumns() - 3);
sh.setHiddenGridlines(true);
sh.setFrozenRows(2); // tiêu đề và dải Logidata luôn hiện khi cuộn
sh.setTabColor('#EE4D2D');
sh.protect().setDescription('Trang hướng dẫn, do script tạo').setWarningOnly(true);
if (kichHoat) { ss.setActiveSheet(sh); sh.getRange('A1').activate(); }
}
Sau khi lưu, bạn chỉ cần quay lại Sheet. Các thao tác còn lại đều làm trong menu trên Sheet.
3Quay lại Sheet, tải lại trang và mở menu Shopee
Trang sao chép đã mở ở tab mới. Bấm Tạo một bản sao, đổi tên file nếu muốn, rồi làm bước này trên file mới. Bản sao đã có sẵn tab Hướng dẫn nên có thể khác ảnh một chút.
- Quay lại tab Google Sheet và nhấn F5 để tải lại trang.
- Đợi menu 🛒 Shopee hiện ở cuối thanh menu, thường chỉ vài giây. Nếu chưa thấy, hãy đợi thêm 1 đến 3 phút rồi nhấn F5 lần nữa.
- Bấm 🛒 Shopee → 🔑 Cài đặt kết nối.
Lúc này Sheet vẫn trống là bình thường. Tab Hướng dẫn sẽ xuất hiện sau khi bạn lưu API key ở bước 7.
4Bấm OK để uỷ quyền
Lần đầu dùng menu, Google hiện hộp thoại Phải uỷ quyền. Bấm OK.
Google sẽ mở một cửa sổ mới. Nếu được hỏi tài khoản, hãy chọn đúng tài khoản Google đang mở Sheet.
Trình duyệt có thể đã chặn cửa sổ bật lên. Hãy cho phép cửa sổ bật lên với docs.google.com rồi bấm lại Cài đặt kết nối.
5Qua màn hình cảnh báo của Google
Google sẽ báo "Google chưa xác minh ứng dụng này". Đây là bước bình thường, xuất hiện với các script tự cài chưa đăng ký với Google.
- Bấm Cài đặt nâng cao ở góc dưới bên trái. Sau khi bấm, chữ này đổi thành "Ẩn cài đặt nâng cao".
- Bấm dòng Đi tới Dự án không có tiêu đề (không an toàn) vừa hiện ra.
Bạn có thể tự kiểm tra toàn bộ code tại bước 2 trước khi tiếp tục. Xem thêm ở mục An toàn và bảo mật.
6Chọn tất cả quyền và bấm Tiếp tục
- Tích ô Chọn tất cả.
- Kéo xuống cuối và bấm Tiếp tục.
Script cần đủ 4 quyền sau để chạy:
| Quyền Google hỏi | Script dùng để làm gì |
|---|---|
| Xem, chỉnh sửa, tạo và xoá bảng tính | Ghi dữ liệu vào file này và tạo file JSON gốc. Script chỉ mở file này và file JSON do chính nó tạo. |
| Kết nối với dịch vụ bên ngoài | Gọi API Logidata để lấy dữ liệu. |
| Chạy khi bạn không có mặt | Tự cập nhật theo lịch và chạy tiếp khi dữ liệu nhiều. |
| Hiển thị nội dung trong lời nhắc và thanh bên | Hiện các hộp thoại như Cài đặt kết nối. |
7Điền API key và ID shop
- Quay lại Sheet, bấm lại 🛒 Shopee → 🔑 Cài đặt kết nối nếu hộp thoại chưa hiện.
- Dán API key (
ld_live_...) vào ô đầu tiên. Bấm biểu tượng con mắt nếu muốn xem lại. - Dán ID shop vào ô thứ hai (ô tên Account ID). Đây là dãy số ở cột Tài khoản trong mục Kết nối của portal, ví dụ
28461937. Bỏ qua chữ mờldc_...gợi ý trong ô. - Bấm Lưu và kiểm tra.
Hộp thoại báo Kết nối thành công! và có sẵn hai nút để kéo dữ liệu. File cũng có thêm tab cai_dat và tab Hướng dẫn.
Kiểm tra lại key và ID shop (dãy số ở cột Tài khoản, không phải mã bắt đầu bằng ldc_) có đúng, còn hạn và đã được cấp quyền trong portal Logidata chưa, rồi nhập lại.
8Kéo dữ liệu về Sheet
Bấm ngay nút trong hộp thoại "Kết nối thành công", hoặc vào lại menu 🛒 Shopee và chọn một trong hai cách:
| Cách kéo | Nên dùng khi |
|---|---|
| ⬇️ Kéo dữ liệu mới nhất | Lần đầu lấy 30 ngày gần nhất. Những lần sau lấy đơn mới và cập nhật đơn vừa đổi trạng thái. |
| 📅 Kéo theo khoảng ngày… | Cần một giai đoạn cụ thể. Chọn nhanh 7 ngày, 30 ngày, tháng này, tháng trước, 90 ngày, hoặc tự chọn trên lịch. |
Dù chọn khoảng ngày nào, lần đầu script vẫn lấy thêm 30 ngày gần nhất. Muốn lấy ít hơn, hãy vào tab cai_dat, giảm ô Số ngày kéo lần đầu rồi mới bấm kéo. Mỗi lần gọi API đều tính vào hạn mức gói Logidata, và tiền đối soát tốn 1 lượt cho mỗi đơn.
Trong lúc kéo
- Mỗi lượt có thể mất tới 5 phút. Bạn có thể đóng hộp thoại hoặc đóng file, việc kéo vẫn tiếp tục.
- Nếu hộp thoại báo Đã kéo một phần, dữ liệu còn nhiều và script sẽ tự chạy tiếp mỗi phút cho tới khi xong.
- Xem tiến độ ở 📊 Xem tình trạng, xem chi tiết từng lần chạy ở tab
nhat_ky. - Thời gian tuỳ số đơn: vài phút với shop nhỏ, có thể vài chục phút với shop lớn.
9Bật tự động và xem tab Hướng dẫn
- Bấm 🛒 Shopee → ⏰ Bật tự động mỗi giờ. Từ đó đơn mới và đơn đổi trạng thái tự được cập nhật, kể cả khi bạn tắt máy.
- Mở tab Hướng dẫn trong file khi cần tra cứu. Tab này có giải thích từng nút, từng tab và các câu hỏi thường gặp.
Dữ liệu Shopee giờ sẽ tự về file này. Mỗi file chỉ cần một người bật tự động.
Các tab dữ liệu trong file
| Tab | Nội dung |
|---|---|
| orders | Đơn hàng, mỗi đơn một dòng: trạng thái, tiền, vận chuyển, mã vận đơn, lý do huỷ, thông tin người mua và người nhận. |
| order_items | Từng sản phẩm trong đơn: SKU, tên, phân loại, số lượng, giá gốc, giá sau giảm, khuyến mãi. |
| escrow | Tiền đối soát từng đơn: khách trả, thực nhận, giảm giá, voucher, hoa hồng, các loại phí, thuế, hoàn tiền. |
| escrow_items | Tiền đối soát theo từng sản phẩm trong đơn. |
| ads_daily | Quảng cáo theo ngày: hiển thị, click, CTR, CPC, chi tiêu, đơn, doanh thu, ROAS. |
| returns | Yêu cầu trả hàng và hoàn tiền: lý do, số tiền, trạng thái, hạn xử lý, đền bù. |
| wallet | Giao dịch ví người bán: loại, số tiền, phí, số dư sau giao dịch. |
| danh_gia | Đánh giá sản phẩm: số sao, nội dung, ảnh, video, phản hồi của shop. Shopee chỉ cho lấy khoảng 500–1.000 đánh giá gần nhất. |
| affiliate | Đơn tiếp thị liên kết: nhà tiếp thị, giá trị đơn, hoa hồng. Cần bấm Cấp quyền Shopee Affiliate trong portal Logidata. |
| nhat_ky | Nhật ký mỗi lần chạy: kết quả, số dòng mới, số dòng cập nhật, lỗi nếu có. |
| cai_dat | Số ngày kéo lần đầu, số ngày làm mới và cách lưu JSON gốc. |
Script cũng tạo một file riêng trong Drive tên "[tên file] - JSON gốc" để lưu phản hồi gốc, giúp file chính nhẹ hơn. Tab Trang tính1 trống có sẵn của Google, script không dùng đến.
An toàn và bảo mật
- Chỉ đọc: script chỉ dùng các lệnh lấy dữ liệu, không thay đổi gì trên shop Shopee của bạn.
- API key được giữ kín: key lưu trong vùng riêng của tài khoản Google của bạn, không nằm trong ô nào của bảng tính.
- Một địa chỉ duy nhất: script chỉ kết nối tới
api.logidata.vnvà không gửi dữ liệu đi nơi nào khác. - Mã nguồn minh bạch: toàn bộ code nằm ở bước 2 và trong Apps Script của file, ai có quyền chỉnh sửa đều kiểm tra được.
Người có quyền chỉnh sửa file có thể mở và chạy Script, sửa code và sửa dữ liệu. Chỉ cấp quyền này cho người bạn tin tưởng. Người chỉ cần xem số liệu thì nên để quyền Người xem.
File chứa tên, số điện thoại và địa chỉ khách. Chỉ chia sẻ cho người thật sự cần và không bật chế độ "Bất kỳ ai có đường liên kết".
Xử lý sự cố
Không thấy menu 🛒 Shopee
Kiểm tra code đã được lưu ở bước 2, sau đó nhấn F5 trên tab Sheet và đợi thêm một lúc. Menu chỉ hiện trong đúng file đã mở Apps Script ở bước 1.
Không tạo được bản sao file mẫu
Kiểm tra bạn đã đăng nhập Google. Tài khoản công ty có thể bị quản trị viên giới hạn việc sao chép file hoặc chạy script chưa xác minh; hãy nhờ quản trị viên cho phép hoặc dùng tài khoản khác. Bạn cũng có thể cài thủ công từ bước 1.
Lỗi khi cấp quyền, hoặc bấm menu mà không có gì xảy ra
Lỗi này hay gặp khi trình duyệt đăng nhập nhiều tài khoản Google cùng lúc. Hãy mở file trong cửa sổ ẩn danh hoặc một hồ sơ trình duyệt chỉ đăng nhập tài khoản đang dùng, rồi thử lại.
Báo "API key hoặc Account ID không đúng, đã hết hạn..."
Vào 🔑 Cài đặt kết nối và nhập lại. Kiểm tra key còn hoạt động và đã được tích kết nối của shop trong portal Logidata. Nếu đã làm mất key, hãy tạo key mới.
Báo "Đã chạm giới hạn số lần gọi API của gói Logidata"
Xem hạn mức đã dùng ở 📊 Xem tình trạng. Nếu cần thêm hạn mức, hãy liên hệ Logidata.
Báo "Lỗi tạm thời từ máy chủ hoặc mạng"
Script tự thử lại tối đa 3 lần liên tiếp. Nếu vẫn lỗi, gửi dòng trên cùng của tab nhat_ky cho Logidata.
Báo "Đang có một lần kéo khác"
Lịch tự động hoặc người khác đang chạy. Đợi vài phút rồi xem tab nhat_ky. Nếu bạn vừa chọn khoảng ngày, khoảng đó sẽ được kéo ngay sau lần chạy hiện tại.
Tab affiliate hoặc ads_daily không có dữ liệu
Kiểm tra đã kết nối Shopee Ads và Shopee Affiliate, đã bấm Cấp quyền Shopee Affiliate trong portal, và key đã được tích các kết nối này chưa. 📊 Xem tình trạng cũng báo nếu gói chưa có nền tảng tương ứng. Lý do cụ thể được ghi ở tab nhat_ky.
Thông tin người mua bị che một phần
Shopee tự che một phần thông tin người mua ở đơn đã hoàn tất. Script lấy đúng những gì Shopee trả về.
Chạy nhiều lần có bị trùng dòng không?
Không. Đơn đã có sẽ được cập nhật đúng dòng của nó, đơn mới được thêm dòng mới.
Người khác dùng chung file được không?
Người chỉ cần xem số liệu thì nên để quyền Người xem. Người có quyền chỉnh sửa có thể mở và chạy Script, nên chỉ cấp cho người bạn tin tưởng. Nếu họ muốn tự bấm kéo, họ cần nhập key của riêng họ. Lịch tự động chạy bằng tài khoản của người đã bật.
Cập nhật khi có bản mới
Bản sao không tự cập nhật theo file mẫu. Khi có bản mới, hãy làm theo các bước sau trên file của bạn, không cần tạo lại bản sao.
- Copy code mới ở bước 2.
- Mở Tiện ích → Apps Script, xoá toàn bộ code cũ, dán code mới và lưu.
- Quay lại Sheet, nhấn F5, rồi bấm 📖 Hướng dẫn sử dụng để làm mới tab Hướng dẫn.
Dữ liệu cũ vẫn được giữ, API key đã lưu và lịch tự động không cần cài lại.
Cần Logidata hỗ trợ?
Gửi cho chúng tôi dòng trên cùng của tab nhat_ky nếu gặp lỗi. Logidata cũng hỗ trợ kết nối thêm nền tảng, dựng báo cáo và tự động hoá quy trình dữ liệu.