42d0e2f855
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>
177 lines
7.1 KiB
TypeScript
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
|
|
}
|