feat: add bulk stock sync from Sheets to Shopify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,42 @@ export default function CreationPage() {
|
||||
const [stopped, setStopped] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(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<ShopifyCollection[]>([])
|
||||
const [detectedCollectionId, setDetectedCollectionId] = useState<string>('')
|
||||
@@ -174,10 +210,31 @@ export default function CreationPage() {
|
||||
|
||||
{/* Étape 0 — Sélection famille */}
|
||||
{step === 'famille' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-4">Choisir une famille</h2>
|
||||
<FamilySelector onSelect={handleSelectTab} />
|
||||
</div>
|
||||
<div className="bg-slate-800 rounded-xl p-6">
|
||||
<h2 className="text-sm font-semibold text-white mb-1">Synchroniser le stock</h2>
|
||||
<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>
|
||||
<button onClick={syncStock} disabled={stockSyncing}
|
||||
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">
|
||||
{stockSyncing ? (stockProgress ? `Mise à jour… ${stockProgress.current}/${stockProgress.total}` : 'Chargement…') : 'Synchroniser le stock depuis Sheets'}
|
||||
</button>
|
||||
{stockSyncing && stockProgress && (
|
||||
<p className="mt-2 text-xs text-gray-400 truncate">→ {stockProgress.title}</p>
|
||||
)}
|
||||
{stockResult && (
|
||||
<p className="mt-3 text-sm">
|
||||
{stockResult.errors === 0
|
||||
? <span className="text-green-400">✓ {stockResult.updated} produit{stockResult.updated > 1 ? 's' : ''} mis à jour</span>
|
||||
: <span className="text-amber-400">✓ {stockResult.updated} mis à jour — ⚠ {stockResult.errors} erreur{stockResult.errors > 1 ? 's' : ''}</span>
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-slate-800 rounded-xl p-6" style={{ display: step === 'famille' ? 'none' : undefined }}>
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user