From a9850c8efd948c0f2b9d04e230d39c9d2481c3ec Mon Sep 17 00:00:00 2001 From: Mathieu Date: Fri, 26 Jun 2026 10:35:52 +0200 Subject: [PATCH] feat: add bulk stock sync from Sheets to Shopify Co-Authored-By: Claude Sonnet 4.6 --- src/app/(dashboard)/creation/page.tsx | 63 ++++++++++- src/app/api/creation/sync-stock/route.ts | 134 +++++++++++++++++++++++ src/lib/shopifySync.ts | 2 +- 3 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/app/api/creation/sync-stock/route.ts diff --git a/src/app/(dashboard)/creation/page.tsx b/src/app/(dashboard)/creation/page.tsx index 5cd78fa..d5008f7 100644 --- a/src/app/(dashboard)/creation/page.tsx +++ b/src/app/(dashboard)/creation/page.tsx @@ -25,6 +25,42 @@ export default function CreationPage() { const [stopped, setStopped] = useState(false) const abortRef = useRef(null) + // 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 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) + } + } + // Collection const [collections, setCollections] = useState([]) const [detectedCollectionId, setDetectedCollectionId] = useState('') @@ -174,9 +210,30 @@ export default function CreationPage() { {/* Étape 0 — Sélection famille */} {step === 'famille' && ( -
-

Choisir une famille

- +
+
+

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' : ''} + } +

+ )} +
)} diff --git a/src/app/api/creation/sync-stock/route.ts b/src/app/api/creation/sync-stock/route.ts new file mode 100644 index 0000000..6dc30bb --- /dev/null +++ b/src/app/api/creation/sync-stock/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { fetchPendingProducts, findColIndex } from '@/lib/creationSheets' +import { getConfig } from '@/lib/shopifySync' + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions) + if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 }) + + const { tab } = await req.json().catch(() => ({})) as { tab?: string } + + const stream = new ReadableStream({ + async start(controller) { + const send = (obj: object) => controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n')) + + try { + const { baseUrl, headers } = getConfig() + + // Récupérer le location_id une seule fois + const locRes = await fetch(`${baseUrl}/locations.json`, { headers }) + 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') + + // Lire tous les onglets avec un ID Shopify en colonne A + 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 (tab) { + tabs = [tab] + } 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) + } + + type StockEntry = { shopifyId: string; stock: number; title: string; tab: string } + const entries: StockEntry[] = [] + + for (const t of tabs) { + const range = encodeURIComponent(`${t}!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 idIdx = 0 + const nomIdx = findColIndex(hdrs, 'Nom') + const stockIdx = findColIndex(hdrs, 'Stock (en pièce)') !== -1 + ? findColIndex(hdrs, 'Stock (en pièce)') + : findColIndex(hdrs, 'Stock (pièce)') + const stockCdtIdx = findColIndex(hdrs, 'Stock conditionné') + + const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '') + + for (const row of rows.slice(headerRowIdx + 1)) { + const shopifyId = get(row, idIdx) + if (!shopifyId || !/^\d+$/.test(shopifyId)) continue + const rawStock = get(row, stockCdtIdx) || get(row, stockIdx) + const stock = parseInt(rawStock || '0', 10) || 0 + entries.push({ shopifyId, stock, title: get(row, nomIdx), tab: t }) + } + } + + send({ type: 'total', total: entries.length }) + + let updated = 0 + let errors = 0 + + for (let i = 0; i < entries.length; i++) { + const { shopifyId, stock, title } = entries[i] + send({ type: 'progress', current: i + 1, total: entries.length, title }) + try { + // Récupérer le variant pour obtenir inventory_item_id + const pRes = await fetch(`${baseUrl}/products/${shopifyId}.json?fields=variants`, { headers }) + if (!pRes.ok) { + send({ type: 'error', title, message: `Produit ${shopifyId} introuvable (${pRes.status})` }) + 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 } + + // Connect (idempotent) + await fetch(`${baseUrl}/inventory_levels/connect.json`, { + method: 'POST', headers, + body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId }), + }) + + const setRes = await fetch(`${baseUrl}/inventory_levels/set.json`, { + method: 'POST', headers, + body: JSON.stringify({ location_id: locationId, inventory_item_id: inventoryItemId, available: stock }), + }) + if (!setRes.ok) { + const txt = await setRes.text() + send({ type: 'error', title, message: `stock set failed: ${setRes.status} — ${txt}` }) + errors++ + } else { + updated++ + } + } catch (err) { + send({ type: 'error', title, message: err instanceof Error ? err.message : 'Erreur inconnue' }) + errors++ + } + } + + send({ type: 'done', updated, errors }) + } catch (err) { + const message = err instanceof Error ? err.message : 'Erreur inconnue' + 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' }, + }) +} diff --git a/src/lib/shopifySync.ts b/src/lib/shopifySync.ts index 2acd386..49185a5 100644 --- a/src/lib/shopifySync.ts +++ b/src/lib/shopifySync.ts @@ -2,7 +2,7 @@ import type { ShopifyProductFull, SyncProduct } from '@/types/sync' const API_VERSION = '2024-01' -function getConfig() { +export function getConfig() { const domain = process.env.SHOPIFY_STORE_DOMAIN const token = process.env.SHOPIFY_ADMIN_API_TOKEN if (!domain || !token) {