feat: add reverse stock sync (Shopify → Sheets)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,38 +27,49 @@ export default function CreationPage() {
|
|||||||
|
|
||||||
// Sync stock
|
// Sync stock
|
||||||
const [stockSyncing, setStockSyncing] = useState(false)
|
const [stockSyncing, setStockSyncing] = useState(false)
|
||||||
const [stockResult, setStockResult] = useState<{ updated: number; errors: number } | 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 [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 () => {
|
const syncStock = async () => {
|
||||||
setStockSyncing(true)
|
setStockSyncing(true)
|
||||||
setStockResult(null)
|
setStockResult(null)
|
||||||
setStockProgress(null)
|
setStockProgress(null)
|
||||||
try {
|
try { await runStockStream('/api/creation/sync-stock', setStockResult) }
|
||||||
const res = await fetch('/api/creation/sync-stock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
finally { setStockSyncing(false); setStockProgress(null) }
|
||||||
if (!res.body) return
|
}
|
||||||
const reader = res.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
const [stockReverseSyncing, setStockReverseSyncing] = useState(false)
|
||||||
let buffer = ''
|
const [stockReverseResult, setStockReverseResult] = useState<{ updated: number; errors: number; fatalError?: string } | null>(null)
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
const syncStockReverse = async () => {
|
||||||
if (done) break
|
setStockReverseSyncing(true)
|
||||||
buffer += decoder.decode(value, { stream: true })
|
setStockReverseResult(null)
|
||||||
const lines = buffer.split('\n')
|
setStockProgress(null)
|
||||||
buffer = lines.pop() ?? ''
|
try { await runStockStream('/api/creation/sync-stock-reverse', setStockReverseResult) }
|
||||||
for (const line of lines) {
|
finally { setStockReverseSyncing(false); setStockProgress(null) }
|
||||||
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
|
// Collection
|
||||||
@@ -215,24 +226,50 @@ export default function CreationPage() {
|
|||||||
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||||
<FamilySelector onSelect={handleSelectTab} />
|
<FamilySelector onSelect={handleSelectTab} />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-800 rounded-xl p-6">
|
<div className="bg-slate-800 rounded-xl p-6 space-y-6">
|
||||||
<h2 className="text-sm font-semibold text-white mb-1">Synchroniser le stock</h2>
|
<div>
|
||||||
<p className="text-xs text-gray-400 mb-4">Met à jour le stock Shopify pour tous les produits existants (ceux avec un ID Shopify dans le Sheets).</p>
|
<h2 className="text-sm font-semibold text-white mb-1">Sheets → Shopify</h2>
|
||||||
<button onClick={syncStock} disabled={stockSyncing}
|
<p className="text-xs text-gray-400 mb-3">Met à jour le stock Shopify depuis les valeurs du Sheets.</p>
|
||||||
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
<button onClick={syncStock} disabled={stockSyncing || stockReverseSyncing}
|
||||||
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser le stock depuis Sheets'}
|
className="px-4 py-2 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
</button>
|
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Sheets → Shopify'}
|
||||||
{stockSyncing && stockProgress && (
|
</button>
|
||||||
<p className="mt-2 text-xs text-gray-400 truncate">→ {stockProgress.title}</p>
|
{stockSyncing && stockProgress?.title && (
|
||||||
)}
|
<p className="mt-2 text-xs text-gray-400 truncate">→ {stockProgress.title}</p>
|
||||||
{stockResult && (
|
)}
|
||||||
<p className="mt-3 text-sm">
|
{stockResult && (
|
||||||
{stockResult.errors === 0
|
<div className="mt-2 text-sm">
|
||||||
? <span className="text-green-400">✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</span>
|
{stockResult.fatalError
|
||||||
: <span className="text-amber-400">✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</span>
|
? <p className="text-red-400">✕ {stockResult.fatalError}</p>
|
||||||
}
|
: stockResult.errors === 0
|
||||||
</p>
|
? <p className="text-green-400">✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</p>
|
||||||
)}
|
: <p className="text-amber-400">✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<hr className="border-slate-700" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white mb-1">Shopify → Sheets</h2>
|
||||||
|
<p className="text-xs text-gray-400 mb-3">Écrase les stocks du Sheets avec les valeurs actuelles de Shopify.</p>
|
||||||
|
<button onClick={syncStockReverse} disabled={stockSyncing || stockReverseSyncing}
|
||||||
|
className="px-4 py-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
|
||||||
|
{stockReverseSyncing ? (stockProgress ? `Lecture… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser Shopify → Sheets'}
|
||||||
|
</button>
|
||||||
|
{stockReverseSyncing && stockProgress && (
|
||||||
|
<p className="mt-2 text-xs text-gray-400">→ {stockProgress.current}/{stockProgress.total}</p>
|
||||||
|
)}
|
||||||
|
{stockReverseResult && (
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
{stockReverseResult.fatalError
|
||||||
|
? <p className="text-red-400">✕ {stockReverseResult.fatalError}</p>
|
||||||
|
: stockReverseResult.errors === 0
|
||||||
|
? <p className="text-green-400">✓ {stockReverseResult.updated} produit{stockReverseResult.updated > 1 ? 's' : ''} mis à jour dans le Sheets</p>
|
||||||
|
: <p className="text-amber-400">✓ {stockReverseResult.updated} mis à jour — ⚠ {stockReverseResult.errors} erreur{stockReverseResult.errors > 1 ? 's' : ''}</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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<Response> => {
|
||||||
|
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<string, Array<{ range: string; values: string[][] }>> = {}
|
||||||
|
|
||||||
|
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' },
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user