Files
gestion-materiaux-destock/src/lib/creationSheets.ts
T
C_Ma_For 42d0e2f855 feat: écriture de l'ID Shopify en col A du Sheets après création produit
Utilise un compte de service Google avec JWT pour authentifier les écritures
dans le Sheets — sans dépendance npm supplémentaire (crypto natif Node.js).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 12:42:17 +02:00

177 lines
7.1 KiB
TypeScript

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[][], dataStartRow = 2): 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: dataStartRow + rowIdx,
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)
}
/**
* Écrit l'ID Shopify en colonne A de la ligne correspondante dans Google Sheets.
* Utilise un compte de service avec accès éditeur sur la feuille.
*/
export async function writeShopifyIdToSheet(tab: string, rowNumber: number, shopifyId: string): Promise<void> {
const sheetId = process.env.GOOGLE_SHEETS_ID
const email = process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL
const rawKey = process.env.GOOGLE_SERVICE_ACCOUNT_KEY
if (!sheetId || !email || !rawKey) return
// Génère un JWT pour l'authentification Google
const privateKey = rawKey.replace(/\\n/g, '\n')
const now = Math.floor(Date.now() / 1000)
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')
const payload = Buffer.from(JSON.stringify({
iss: email,
scope: 'https://www.googleapis.com/auth/spreadsheets',
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
})).toString('base64url')
const { createSign } = await import('crypto')
const sign = createSign('RSA-SHA256')
sign.update(`${header}.${payload}`)
const signature = sign.sign(privateKey, 'base64url')
const jwt = `${header}.${payload}.${signature}`
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
})
if (!tokenRes.ok) return
const { access_token } = await tokenRes.json() as { access_token: string }
const range = encodeURIComponent(`${tab}!A${rowNumber}`)
await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?valueInputOption=RAW`,
{
method: 'PUT',
headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ range: `${tab}!A${rowNumber}`, majorDimension: 'ROWS', values: [[shopifyId]] }),
},
)
}
export async function fetchPendingProducts(
alreadyCreatedRows: Set<string>,
tabFilter?: 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')
let tabs: string[]
if (tabFilter) {
tabs = [tabFilter]
} else {
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()
tabs = (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
// La ligne d'en-têtes est la première ligne qui contient "ID" en col A
// (certains onglets ont une ligne de section en row 1, les vrais en-têtes sont en row 2)
const headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID')
if (headerRowIdx === -1) continue // pas un onglet produit
const headers = rows[headerRowIdx]
const dataRows = rows.slice(headerRowIdx + 1)
// Ignorer les onglets sans colonne "Publié" (COLLECTIONS, LIVRAISONS, etc.)
if (findColIndex(headers, 'Publié') === -1) continue
const pending = parseTabRows(tab, headers, dataRows, headerRowIdx + 2).filter(
p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`),
)
all.push(...pending)
}
return all
}