feat(plan5): creationSheets — lecture lignes en attente (TDD)
This commit is contained in:
@@ -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<number, string> = {}): string[] => {
|
||||
const base: Record<number, string> = {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
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<string>,
|
||||
): Promise<PendingProduct[]> {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user