From 3aa770321cb2d767b5b36f5a58feecb0db72b08b Mon Sep 17 00:00:00 2001 From: Mathieu Date: Tue, 9 Jun 2026 09:40:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(plan5):=20creationSheets=20=E2=80=94=20lec?= =?UTF-8?q?ture=20lignes=20en=20attente=20(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/creationSheets.test.ts | 103 +++++++++++++++++++++++++++++ src/lib/creationSheets.ts | 116 +++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 src/lib/creationSheets.test.ts create mode 100644 src/lib/creationSheets.ts diff --git a/src/lib/creationSheets.test.ts b/src/lib/creationSheets.test.ts new file mode 100644 index 0000000..aadeb74 --- /dev/null +++ b/src/lib/creationSheets.test.ts @@ -0,0 +1,103 @@ +import { parseTabRows, findColIndex, extractSpecificFields } from './creationSheets' + +const HEADERS = [ + 'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?', + 'Description courte', 'Description longue', + 'Date de début de promo', 'Date de fin de promo', + 'État de la TVA', 'Classe de TVA', 'En stock ?', + 'Stock (en pièce)', 'Vendre individuellement ?', + 'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant', + 'Famille', 'Sous famille', + "Classe d'expédition (en retrait, livraison)", + 'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité', + 'Images', 'Unité', 'Marque', 'Neuf ?', + "Commentaire\nexemple : Modèle d'expo (lègères rayures)", + 'Longueur (cm)', 'Largeur (cm)', 'Hauteur (cm)', 'Épaisseur (cm)', + 'Poids par carton', 'Stock conditionné (en carton)', + 'Fabrication', 'Garantie', 'Coloris', + 'Puissance (AN)', 'Batterie (AP)', 'Couple (AQ)', + 'PHOTO ?', 'ID FAMILLE', 'ID SOUS FAMILLE', +] + +const makeRow = (overrides: Record = {}): string[] => { + const base: Record = { + 0: '', // ID vide = à créer + 2: 'SKU-001', // UGS + 3: 'Perceuse Test', + 4: '1', // Publié + 13: '5', // Stock + 15: '99.99', // Prix marché + 16: '79.99', // Prix remisé + 19: 'OUTILLAGE', + 24: '2.5', // Poids + 27: 'BOSCH', // Marque + 39: '1500W', // Puissance + 40: 'Li-Ion', // Batterie + // Couple (index 41) laissé vide + [HEADERS.length - 2]: '10', // ID FAMILLE + [HEADERS.length - 1]: '20', // ID SOUS FAMILLE + } + return HEADERS.map((_, i) => overrides[i] ?? base[i] ?? '') +} + +describe('findColIndex', () => { + it('trouve un en-tête exact', () => { + expect(findColIndex(HEADERS, 'UGS')).toBe(2) + }) + it('trouve un en-tête commençant par Commentaire', () => { + const idx = findColIndex(HEADERS, 'Commentaire') + expect(idx).toBeGreaterThan(-1) + }) + it('retourne -1 si non trouvé', () => { + expect(findColIndex(HEADERS, 'Inexistant')).toBe(-1) + }) +}) + +describe('extractSpecificFields', () => { + it('exclut les colonnes communes', () => { + const row = makeRow() + const specific = extractSpecificFields(HEADERS, row) + expect(Object.keys(specific)).not.toContain('Nom') + expect(Object.keys(specific)).not.toContain('UGS') + }) + it('inclut les colonnes spécifiques non vides', () => { + const row = makeRow() + const specific = extractSpecificFields(HEADERS, row) + expect(specific['Puissance (AN)']).toBe('1500W') + expect(specific['Batterie (AP)']).toBe('Li-Ion') + }) + it('exclut les colonnes spécifiques vides', () => { + const row = makeRow() + const specific = extractSpecificFields(HEADERS, row) + expect(Object.keys(specific)).not.toContain('Couple (AQ)') + }) +}) + +describe('parseTabRows', () => { + it('ignore les lignes avec ID déjà rempli', () => { + const row = makeRow({ 0: '12345' }) + const result = parseTabRows('OUTILLAGE', HEADERS, [row]) + expect(result).toHaveLength(0) + }) + it('ignore les lignes avec Publié ≠ 1', () => { + const row = makeRow({ 4: '0' }) + const result = parseTabRows('OUTILLAGE', HEADERS, [row]) + expect(result).toHaveLength(0) + }) + it('retourne un PendingProduct pour une ligne valide', () => { + const row = makeRow() + const result = parseTabRows('OUTILLAGE', HEADERS, [row]) + expect(result).toHaveLength(1) + expect(result[0].sku).toBe('SKU-001') + expect(result[0].title).toBe('Perceuse Test') + expect(result[0].priceActual).toBe('79.99') + expect(result[0].collectionId).toBe('10') + expect(result[0].specificFields['Puissance (AN)']).toBe('1500W') + }) + it('attribue le bon numéro de ligne (1-indexé depuis row 2)', () => { + const rows = [makeRow(), makeRow({ 3: 'Deuxième' })] + const result = parseTabRows('OUTILLAGE', HEADERS, rows) + expect(result[0].rowNumber).toBe(2) + expect(result[1].rowNumber).toBe(3) + }) +}) diff --git a/src/lib/creationSheets.ts b/src/lib/creationSheets.ts new file mode 100644 index 0000000..4064fb3 --- /dev/null +++ b/src/lib/creationSheets.ts @@ -0,0 +1,116 @@ +import type { PendingProduct } from '@/types/creation' + +const COMMON_HEADERS = new Set([ + 'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?', + 'Description courte', 'Description longue', + 'Date de début de promo', 'Date de fin de promo', + 'État de la TVA', 'Classe de TVA', 'En stock ?', + 'Stock (en pièce)', 'Vendre individuellement ?', + 'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant', + 'Famille', 'Sous famille', + "Classe d'expédition (en retrait, livraison)", + 'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité', + 'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?', + 'ID FAMILLE', 'ID SOUS FAMILLE', +]) + +export function findColIndex(headers: string[], search: string): number { + const exact = headers.findIndex(h => h === search) + if (exact !== -1) return exact + return headers.findIndex(h => h.startsWith(search)) +} + +export function extractSpecificFields(headers: string[], row: string[]): Record { + const result: Record = {} + headers.forEach((header, i) => { + if (!header) return + const isCommon = COMMON_HEADERS.has(header) || header.startsWith('Commentaire') + if (isCommon) return + const val = (row[i] ?? '').trim() + if (val) result[header] = val + }) + return result +} + +export function parseTabRows(tab: string, headers: string[], rows: string[][]): PendingProduct[] { + const idx = { + id: findColIndex(headers, 'ID'), + sku: findColIndex(headers, 'UGS'), + nom: findColIndex(headers, 'Nom'), + publie: findColIndex(headers, 'Publié'), + stock: findColIndex(headers, 'Stock (en pièce)'), + prixMarche: findColIndex(headers, 'Prix marché'), + prixRemise: findColIndex(headers, 'Prix remisé'), + poids: findColIndex(headers, 'Poids (kg) fonction unité'), + marque: findColIndex(headers, 'Marque'), + famille: findColIndex(headers, 'Famille'), + sousFamille: findColIndex(headers, 'Sous famille'), + commentaire: findColIndex(headers, 'Commentaire'), + photo: findColIndex(headers, 'PHOTO ?'), + collectionId: findColIndex(headers, 'ID FAMILLE'), + subCollectionId: findColIndex(headers, 'ID SOUS FAMILLE'), + } + + const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '') + + return rows + .map((row, rowIdx): PendingProduct | null => { + const id = get(row, idx.id) + const publie = get(row, idx.publie) + if (id !== '' || publie !== '1') return null + return { + tab, + rowNumber: rowIdx + 2, + sku: get(row, idx.sku), + title: get(row, idx.nom), + priceMarket: get(row, idx.prixMarche).replace(',', '.'), + priceActual: get(row, idx.prixRemise).replace(',', '.'), + stock: parseInt(get(row, idx.stock) || '0', 10) || 0, + weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0, + vendor: get(row, idx.marque), + famille: get(row, idx.famille), + sousFamille: get(row, idx.sousFamille), + comment: get(row, idx.commentaire), + collectionId: get(row, idx.collectionId), + subCollectionId: get(row, idx.subCollectionId), + hasPhoto: get(row, idx.photo) !== '', + specificFields: extractSpecificFields(headers, row), + } + }) + .filter((p): p is PendingProduct => p !== null) +} + +export async function fetchPendingProducts( + alreadyCreatedRows: Set, +): Promise { + const apiKey = process.env.GOOGLE_API_KEY + const sheetId = process.env.GOOGLE_SHEETS_ID + if (!apiKey || !sheetId) throw new Error('GOOGLE_API_KEY et GOOGLE_SHEETS_ID requis') + + const metaRes = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`, + ) + if (!metaRes.ok) throw new Error(`Sheets metadata error: ${metaRes.status}`) + const meta = await metaRes.json() + const tabs: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map( + s => s.properties.title, + ) + + const all: PendingProduct[] = [] + for (const tab of tabs) { + const range = encodeURIComponent(`${tab}!A1:BZ`) + const res = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`, + ) + if (!res.ok) continue + const data = await res.json() + const rows = (data.values ?? []) as string[][] + if (rows.length < 2) continue + const [headers, ...dataRows] = rows + const pending = parseTabRows(tab, headers, dataRows).filter( + p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`), + ) + all.push(...pending) + } + return all +}