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' },
+ })
+}