From 4f617b15b2e100d13a0de9301b739f4adfbcebf9 Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 26 Jun 2026 12:20:59 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20reverse=20stock=20sync=20(Shopify?= =?UTF-8?q?=20=E2=86=92=20Sheets)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/creation/page.tsx | 127 +++++++----- .../api/creation/sync-stock-reverse/route.ts | 181 ++++++++++++++++++ 2 files changed, 263 insertions(+), 45 deletions(-) create mode 100644 src/app/api/creation/sync-stock-reverse/route.ts diff --git a/src/app/(dashboard)/creation/page.tsx b/src/app/(dashboard)/creation/page.tsx index d5008f7..f729473 100644 --- a/src/app/(dashboard)/creation/page.tsx +++ b/src/app/(dashboard)/creation/page.tsx @@ -27,38 +27,49 @@ export default function CreationPage() { // Sync stock const [stockSyncing, setStockSyncing] = useState(false) - const [stockResult, setStockResult] = useState<{ updated: number; errors: number } | null>(null) - const [stockProgress, setStockProgress] = useState<{ current: number; total: number; title: string } | null>(null) + const [stockResult, setStockResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null) + const [stockProgress, setStockProgress] = useState<{ current: number; total: number; title?: string } | null>(null) + + const runStockStream = async (url: string, setter: typeof setStockResult) => { + const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{}' }) + if (!res.body) return + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (!line.trim()) continue + try { + const evt = JSON.parse(line) + if (evt.type === 'progress') setStockProgress({ current: evt.current, total: evt.total, title: evt.title }) + if (evt.type === 'done') setter({ updated: evt.updated, errors: evt.errors, fatalError: evt.fatalError }) + } catch { /* */ } + } + } + } const syncStock = async () => { setStockSyncing(true) setStockResult(null) setStockProgress(null) - try { - const res = await fetch('/api/creation/sync-stock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }) - if (!res.body) return - const reader = res.body.getReader() - const decoder = new TextDecoder() - let buffer = '' - while (true) { - const { done, value } = await reader.read() - if (done) break - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() ?? '' - for (const line of lines) { - if (!line.trim()) continue - try { - const evt = JSON.parse(line) - if (evt.type === 'progress') setStockProgress({ current: evt.current, total: evt.total, title: evt.title }) - if (evt.type === 'done') setStockResult({ updated: evt.updated, errors: evt.errors }) - } catch { /* */ } - } - } - } finally { - setStockSyncing(false) - setStockProgress(null) - } + try { await runStockStream('/api/creation/sync-stock', setStockResult) } + finally { setStockSyncing(false); setStockProgress(null) } + } + + const [stockReverseSyncing, setStockReverseSyncing] = useState(false) + const [stockReverseResult, setStockReverseResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null) + + const syncStockReverse = async () => { + setStockReverseSyncing(true) + setStockReverseResult(null) + setStockProgress(null) + try { await runStockStream('/api/creation/sync-stock-reverse', setStockReverseResult) } + finally { setStockReverseSyncing(false); setStockProgress(null) } } // Collection @@ -215,24 +226,50 @@ export default function CreationPage() {

Choisir une famille

-
-

Synchroniser le stock

-

Met à jour le stock Shopify pour tous les produits existants (ceux avec un ID Shopify dans le Sheets).

- - {stockSyncing && stockProgress && ( -

→ {stockProgress.title}

- )} - {stockResult && ( -

- {stockResult.errors === 0 - ? ✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour - : ✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''} - } -

- )} +
+
+

Sheets → Shopify

+

Met à jour le stock Shopify depuis les valeurs du Sheets.

+ + {stockSyncing && stockProgress?.title && ( +

→ {stockProgress.title}

+ )} + {stockResult && ( +
+ {stockResult.fatalError + ?

✕ {stockResult.fatalError}

+ : stockResult.errors === 0 + ?

✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour

+ :

✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}

+ } +
+ )} +
+
+
+

Shopify → Sheets

+

Écrase les stocks du Sheets avec les valeurs actuelles de Shopify.

+ + {stockReverseSyncing && stockProgress && ( +

→ {stockProgress.current}/{stockProgress.total}

+ )} + {stockReverseResult && ( +
+ {stockReverseResult.fatalError + ?

✕ {stockReverseResult.fatalError}

+ : stockReverseResult.errors === 0 + ?

✓ {stockReverseResult.updated} produit{stockReverseResult.updated > 1 ? 's' : ''} mis à jour dans le Sheets

+ :

✓ {stockReverseResult.updated} mis à jour — ⚠ {stockReverseResult.errors} erreur{stockReverseResult.errors > 1 ? 's' : ''}

+ } +
+ )} +
)} diff --git a/src/app/api/creation/sync-stock-reverse/route.ts b/src/app/api/creation/sync-stock-reverse/route.ts new file mode 100644 index 0000000..5871914 --- /dev/null +++ b/src/app/api/creation/sync-stock-reverse/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { findColIndex, writeShopifyIdToSheet } from '@/lib/creationSheets' +import { getConfig } from '@/lib/shopifySync' + +void writeShopifyIdToSheet // réutilise le même mécanisme d'auth Google + +export async function POST() { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 }) + + const stream = new ReadableStream({ + async start(controller) { + const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n')) + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) + + const shopifyFetch = async (url: string, opts: RequestInit = {}, retries = 3): Promise => { + const res = await fetch(url, opts) + if (res.status === 429 && retries > 0) { + const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10) + await sleep(retryAfter * 1000) + return shopifyFetch(url, opts, retries - 1) + } + return res + } + + try { + const { baseUrl, headers: shopifyHeaders } = getConfig() + + // Récupérer le location_id + const locRes = await shopifyFetch(`${baseUrl}/locations.json`, { headers: shopifyHeaders }) + if (!locRes.ok) throw new Error(`Shopify locations error: ${locRes.status}`) + const locData = await locRes.json() as { locations: Array<{ id: number }> } + const locationId = locData.locations[0]?.id + if (!locationId) throw new Error('Aucune location Shopify trouvée') + + // Auth Google Sheets + 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) throw new Error('Credentials Google manquants') + const apiKey = process.env.GOOGLE_API_KEY + if (!apiKey) throw new Error('GOOGLE_API_KEY manquant') + + 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) throw new Error(`Google token error: ${tokenRes.status}`) + const { access_token } = await tokenRes.json() as { access_token: string } + + // Lire tous les onglets + 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 = (meta.sheets as Array<{ properties: { title: string } }>).map(s => s.properties.title) + + type Entry = { shopifyId: string; tab: string; row: number; stockColLetter: string } + const entries: Entry[] = [] + + 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 headerRowIdx = rows.findIndex(r => (r[0] ?? '').trim() === 'ID') + if (headerRowIdx === -1) continue + const hdrs = rows[headerRowIdx] + if (findColIndex(hdrs, 'Publié') === -1) continue + + const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné') + const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1 + ? findColIndex(hdrs, 'Stock (en pièce)') + : findColIndex(hdrs, 'Stock (pièce)') + + // Choisir la colonne cible (Stock conditionné en priorité) + const targetColIdx = stockCdtIdx !== -1 ? stockCdtIdx : stockIdx + if (targetColIdx === -1) continue + + const colLetter = targetColIdx < 26 + ? String.fromCharCode(65 + targetColIdx) + : String.fromCharCode(64 + Math.floor(targetColIdx / 26)) + String.fromCharCode(65 + (targetColIdx % 26)) + + const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '') + + for (let ri = 0; ri < rows.slice(headerRowIdx + 1).length; ri++) { + const row = rows[headerRowIdx + 1 + ri] + const shopifyId = get(row, 0) + if (!shopifyId || !/^\d+$/.test(shopifyId)) continue + entries.push({ shopifyId, tab, row: headerRowIdx + 2 + ri, stockColLetter: colLetter }) + } + } + + send({ type: 'total', total: entries.length }) + + let updated = 0 + let errors = 0 + const batchByTab: Record> = {} + + for (let i = 0; i < entries.length; i++) { + const { shopifyId, tab, row, stockColLetter } = entries[i] + send({ type: 'progress', current: i + 1, total: entries.length }) + try { + await sleep(300) + const pRes = await shopifyFetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers: shopifyHeaders }) + if (pRes.status === 404) continue + if (!pRes.ok) { errors++; continue } + + const pData = await pRes.json() as { product: { variants: Array<{ inventory_item_id: number }> } } + const inventoryItemId = pData.product.variants[0]?.inventory_item_id + if (!inventoryItemId) { errors++; continue } + + await sleep(300) + const invRes = await shopifyFetch( + `${baseUrl}/inventory_levels.json?inventory_item_ids=${inventoryItemId}&location_ids=${locationId}`, + { headers: shopifyHeaders } + ) + if (!invRes.ok) { errors++; continue } + + const invData = await invRes.json() as { inventory_levels: Array<{ available: number }> } + const available = invData.inventory_levels[0]?.available ?? 0 + + if (!batchByTab[tab]) batchByTab[tab] = [] + batchByTab[tab].push({ range: `${tab}!${stockColLetter}${row}`, values: [[String(available)]] }) + updated++ + } catch { + errors++ + } + } + + // Écrire tous les stocks dans le Sheet en batch par tab + const allData = Object.values(batchByTab).flat() + if (allData.length > 0) { + const batchRes = await fetch( + `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values:batchUpdate`, + { + method: 'POST', + headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ valueInputOption: 'RAW', data: allData }), + } + ) + if (!batchRes.ok) { + const txt = await batchRes.text() + throw new Error(`Sheets batchUpdate error: ${batchRes.status} — ${txt}`) + } + } + + send({ type: 'done', updated, errors }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Erreur inconnue' + console.error('[sync-stock-reverse] fatal:', message) + send({ type: 'done', updated: 0, errors: 1, fatalError: message }) + } finally { + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' }, + }) +}