13 Commits

Author SHA1 Message Date
C_Ma_For bb596c7e32 feat(plan5): page /creation stepper 4 étapes + lien nav + fix TS shopifyMetafields
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:20:36 +02:00
C_Ma_For 1ed8c055d7 feat(plan5): composants PendingRowsTable + MetafieldResolver + CreationProgress 2026-06-09 10:14:12 +02:00
C_Ma_For 40d537efb6 feat(plan5): API POST /api/creation/execute streaming NDJSON 2026-06-09 10:09:27 +02:00
C_Ma_For 015833150a feat(plan5): API GET /api/creation/metafields (TDD) 2026-06-09 09:58:14 +02:00
C_Ma_For 04833b490c feat(plan5): API GET /api/creation/preview (TDD) 2026-06-09 09:57:29 +02:00
C_Ma_For 072110e655 feat(plan5): shopifyCollections — ajout produit à collection 2026-06-09 09:55:57 +02:00
C_Ma_For 421c275d48 feat(plan5): shopifyMetafields — matching + gestion définitions (TDD)
Implémente Task 4 du Plan 5:
- normalizeMetafieldKey: minuscules, accents retirés, espaces/parenth→_, max 30 chars
- matchMetafields: compare clé normalisée d'une colonne avec les clés existantes
- fetchMetafieldDefinitions: récupère les définitions Shopify existantes
- ensureMetafieldDefinition: crée une définition si manquante
- setProductMetafield: définit un métachamp sur un produit

7 tests passants (4 normalizeMetafieldKey + 3 matchMetafields).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:52:23 +02:00
C_Ma_For ed091711c8 feat(plan5): openaiClient — génération descriptions GPT-4o (TDD) 2026-06-09 09:42:26 +02:00
C_Ma_For 3aa770321c feat(plan5): creationSheets — lecture lignes en attente (TDD) 2026-06-09 09:40:23 +02:00
C_Ma_For 2e9df3659f feat(plan5): types création + modèle Prisma ProductCreation 2026-06-09 09:33:11 +02:00
C_Ma_For 1e48ca142f fix: remplacer status=any par deux appels active+draft (API REST Shopify) 2026-06-09 00:05:53 +02:00
C_Ma_For c89ab1c818 feat: lire tous les onglets Google Sheets (un onglet par famille de produits) 2026-06-08 23:56:22 +02:00
C_Ma_For 4263b8afd0 fix: onglet Google Sheets par défaut MATRICE au lieu de Produits 2026-06-08 23:52:09 +02:00
22 changed files with 1247 additions and 16 deletions
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "ProductCreation" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"sheetTab" TEXT NOT NULL,
"sheetRow" INTEGER NOT NULL,
"shopifyId" TEXT NOT NULL,
"sku" TEXT NOT NULL,
"title" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'success',
"errorMessage" TEXT NOT NULL DEFAULT '',
CONSTRAINT "ProductCreation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
+15
View File
@@ -14,6 +14,7 @@ model User {
role String @default("gestionnaire")
createdAt DateTime @default(now())
syncRuns SyncRun[]
productCreations ProductCreation[]
}
model SyncRun {
@@ -28,3 +29,17 @@ model SyncRun {
errors Int @default(0)
log String @default("[]")
}
model ProductCreation {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String
user User @relation(fields: [userId], references: [id])
sheetTab String
sheetRow Int
shopifyId String
sku String
title String
status String @default("success")
errorMessage String @default("")
}
+166
View File
@@ -0,0 +1,166 @@
'use client'
import { useState, useCallback } from 'react'
import { Header } from '@/components/layout/Header'
import { PendingRowsTable } from '@/components/creation/PendingRowsTable'
import { MetafieldResolver } from '@/components/creation/MetafieldResolver'
import { CreationProgress } from '@/components/creation/CreationProgress'
import type { PendingProduct, MetafieldResolution, CreationStreamEvent } from '@/types/creation'
type Step = 'analyse' | 'metafields' | 'confirmation' | 'execution'
export default function CreationPage() {
const [step, setStep] = useState<Step>('analyse')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [byTab, setByTab] = useState<Record<string, PendingProduct[]>>({})
const [total, setTotal] = useState(0)
const [specificColumns, setSpecificColumns] = useState<string[]>([])
const [resolutions, setResolutions] = useState<MetafieldResolution[]>([])
const [events, setEvents] = useState<CreationStreamEvent[]>([])
const [done, setDone] = useState(false)
const analyse = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch('/api/creation/preview')
const data = await res.json()
if (data.error) throw new Error(data.error)
setByTab(data.byTab)
setTotal(data.total)
setSpecificColumns(data.specificColumns)
setStep('metafields')
} catch (e) {
setError(e instanceof Error ? e.message : 'Erreur')
} finally {
setLoading(false)
}
}
const handleResolved = useCallback((r: MetafieldResolution[]) => { setResolutions(r) }, [])
const execute = async () => {
setStep('execution')
setEvents([])
setDone(false)
const res = await fetch('/api/creation/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolutions }),
})
if (!res.body) return
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done: readerDone, value } = await reader.read()
if (readerDone) 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 event = JSON.parse(line) as CreationStreamEvent
setEvents(prev => [...prev, event])
if (event.type === 'done') setDone(true)
} catch { /* ligne incomplète */ }
}
}
setDone(true)
}
const STEPS: { id: Step; label: string }[] = [
{ id: 'analyse', label: 'Analyse' },
{ id: 'metafields', label: 'Métachamps' },
{ id: 'confirmation', label: 'Confirmation' },
{ id: 'execution', label: 'Exécution' },
]
const currentIdx = STEPS.findIndex(s => s.id === step)
return (
<>
<Header title="Création produits" />
<div className="p-6 space-y-6">
<div className="flex gap-2">
{STEPS.map((s, i) => (
<div key={s.id} className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${step === s.id ? 'bg-indigo-600 text-white' : i < currentIdx ? 'bg-green-700 text-white' : 'bg-slate-700 text-gray-400'}`}>
{i + 1}. {s.label}
</span>
{i < STEPS.length - 1 && <span className="text-gray-600"></span>}
</div>
))}
</div>
<div className="bg-slate-800 rounded-xl p-6">
{error && <div className="mb-4 p-3 bg-red-900/30 border border-red-700 rounded-lg text-red-300 text-sm">{error}</div>}
{step === 'analyse' && (
<div className="space-y-4">
<p className="text-gray-300 text-sm">
Lit tous les onglets du Google Sheets et détecte les lignes <code className="text-indigo-300">Publié=1</code> et <code className="text-indigo-300">ID vide</code>.
</p>
<button onClick={analyse} disabled={loading}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium transition-colors">
{loading ? 'Analyse en cours…' : 'Analyser le Sheets'}
</button>
</div>
)}
{step === 'metafields' && (
<div className="space-y-6">
<PendingRowsTable byTab={byTab} total={total} />
{total > 0 && (
<>
<hr className="border-slate-700" />
<div>
<h2 className="text-base font-semibold text-white mb-3">Résolution des métachamps</h2>
<MetafieldResolver specificColumns={specificColumns} onResolved={handleResolved} />
</div>
<button onClick={() => setStep('confirmation')}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Continuer
</button>
</>
)}
</div>
)}
{step === 'confirmation' && (
<div className="space-y-4">
<div className="space-y-2 text-sm">
<p className="text-gray-300"><span className="text-white font-medium">{total} produit{total > 1 ? 's' : ''}</span> à créer dans Shopify</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action !== 'skip').length}</span> type{resolutions.filter(r => r.action !== 'skip').length > 1 ? 's' : ''} de métachamps à assigner</p>
<p className="text-gray-300"><span className="text-white font-medium">{resolutions.filter(r => r.action === 'create').length}</span> nouvelle{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} définition{resolutions.filter(r => r.action === 'create').length > 1 ? 's' : ''} à créer dans Shopify</p>
<p className="text-yellow-300 text-xs mt-2"> Les descriptions seront générées par OpenAI GPT-4o (~0.01$ par produit)</p>
</div>
<div className="flex gap-3">
<button onClick={() => setStep('metafields')}
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-gray-300 rounded-lg text-sm font-medium transition-colors">
Retour
</button>
<button onClick={execute}
className="px-4 py-2 bg-green-700 hover:bg-green-600 text-white rounded-lg text-sm font-medium transition-colors">
Lancer la création ({total} produits)
</button>
</div>
</div>
)}
{step === 'execution' && (
<div className="space-y-4">
<CreationProgress events={events} done={done} />
{done && (
<button onClick={() => { setStep('analyse'); setEvents([]); setDone(false); setByTab({}); setTotal(0) }}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium transition-colors">
Nouvelle session
</button>
)}
</div>
)}
</div>
</div>
</>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { generateDescriptions } from '@/lib/openaiClient'
import { ensureMetafieldDefinition, setProductMetafield } from '@/lib/shopifyMetafields'
import { addProductToCollection } from '@/lib/shopifyCollections'
import { createShopifyProduct } from '@/lib/shopifySync'
import { prisma } from '@/lib/prisma'
import type { MetafieldResolution, CreationStreamEvent } from '@/types/creation'
function encode(event: CreationStreamEvent): string {
return JSON.stringify(event) + '\n'
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) {
return new Response(JSON.stringify({ error: 'Non authentifié' }), { status: 401 })
}
const userId = (session.user as { id: string }).id
const body = await req.json().catch(() => ({})) as { resolutions?: MetafieldResolution[] }
const resolutions: MetafieldResolution[] = body.resolutions ?? []
const resolutionMap = new Map<string, MetafieldResolution>(resolutions.map(r => [r.columnHeader, r]))
const stream = new ReadableStream({
async start(controller) {
const send = (event: CreationStreamEvent) => controller.enqueue(encode(event))
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated)
const total = pending.length
if (total === 0) {
send({ type: 'done', summary: { created: 0, errors: 0, status: 'success' } })
controller.close()
return
}
// Crée les définitions manquantes en amont
for (const resolution of resolutions) {
if (resolution.action === 'create') await ensureMetafieldDefinition(resolution)
}
let created = 0
let errors = 0
for (let i = 0; i < pending.length; i++) {
const product = pending[i]
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Génération descriptions…' })
try {
const { longDesc } = await generateDescriptions({
title: product.title,
rawDescription: '',
comment: product.comment,
vendor: product.vendor,
famille: product.famille,
})
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Création produit Shopify…' })
const shopifyId = await createShopifyProduct({
ref: product.sku,
handle: product.sku.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
title: product.title,
description: longDesc,
price: product.priceActual || '0.00',
stock: product.stock,
status: 'active',
})
if (product.collectionId) await addProductToCollection(shopifyId, product.collectionId)
if (product.subCollectionId) await addProductToCollection(shopifyId, product.subCollectionId)
send({ type: 'progress', current: i + 1, total, tab: product.tab, title: product.title, step: 'Métachamps…' })
for (const [colHeader, value] of Object.entries(product.specificFields)) {
const resolution = resolutionMap.get(colHeader)
if (!resolution || resolution.action === 'skip') continue
await setProductMetafield(shopifyId, resolution, value)
}
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId, sku: product.sku, title: product.title, status: 'success' },
})
created++
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'error', title: product.title, message })
await prisma.productCreation.create({
data: { userId, sheetTab: product.tab, sheetRow: product.rowNumber, shopifyId: '', sku: product.sku, title: product.title, status: 'error', errorMessage: message },
})
errors++
}
}
const status = errors === 0 ? 'success' : created === 0 ? 'error' : 'partial'
send({ type: 'done', summary: { created, errors, status } })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
send({ type: 'done', summary: { created: 0, errors: 1, status: 'error' } })
console.error('[creation/execute]', message)
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: { 'Content-Type': 'application/x-ndjson', 'Transfer-Encoding': 'chunked' },
})
}
@@ -0,0 +1,37 @@
import { GET } from './route'
import * as shopifyMetafields from '@/lib/shopifyMetafields'
import { getServerSession } from 'next-auth'
jest.mock('next-auth')
jest.mock('@/lib/shopifyMetafields')
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFetchDefs = shopifyMetafields.fetchMetafieldDefinitions as jest.MockedFunction<typeof shopifyMetafields.fetchMetafieldDefinitions>
const mockMatch = shopifyMetafields.matchMetafields as jest.MockedFunction<typeof shopifyMetafields.matchMetafields>
describe('GET /api/creation/metafields', () => {
beforeEach(() => {
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
mockFetchDefs.mockResolvedValue([
{ id: '1', namespace: 'custom', key: 'puissance', name: 'Puissance', type: 'single_line_text_field' },
])
mockMatch.mockReturnValue({
matched: { 'Puissance (AN)': { columnHeader: 'Puissance (AN)', action: 'map', namespace: 'custom', key: 'puissance', existingDefinitionId: '1' } },
unmatched: ['Batterie (AP)'],
})
})
it('retourne 401 sans session', async () => {
mockSession.mockResolvedValue(null)
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance') as never)
expect(res.status).toBe(401)
})
it('retourne définitions + matching', async () => {
const res = await GET(new Request('http://localhost/api/creation/metafields?columns=Puissance+%28AN%29%2CBatterie+%28AP%29') as never)
const body = await res.json()
expect(body.definitions).toHaveLength(1)
expect(body.matched['Puissance (AN)'].key).toBe('puissance')
expect(body.unmatched).toContain('Batterie (AP)')
})
})
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchMetafieldDefinitions, matchMetafields } from '@/lib/shopifyMetafields'
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const { searchParams } = new URL(req.url)
const columnsParam = searchParams.get('columns') ?? ''
const columns = columnsParam ? columnsParam.split(',').map(c => c.trim()).filter(Boolean) : []
const definitions = await fetchMetafieldDefinitions()
const { matched, unmatched } = matchMetafields(columns, definitions)
return NextResponse.json({ definitions, matched, unmatched })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,50 @@
import { GET } from './route'
import * as creationSheets from '@/lib/creationSheets'
import { getServerSession } from 'next-auth'
jest.mock('next-auth')
jest.mock('@/lib/creationSheets')
jest.mock('@/lib/prisma', () => ({
prisma: { productCreation: { findMany: jest.fn().mockResolvedValue([]) } },
}))
const mockSession = getServerSession as jest.MockedFunction<typeof getServerSession>
const mockFetch = creationSheets.fetchPendingProducts as jest.MockedFunction<typeof creationSheets.fetchPendingProducts>
const makePending = (tab: string, row: number, specific: Record<string, string> = {}) => ({
tab, rowNumber: row, sku: `SKU-${row}`, title: `Produit ${row}`,
priceMarket: '99', priceActual: '79', stock: 5, weightKg: 2,
vendor: 'BOSCH', famille: tab, sousFamille: '', comment: '',
collectionId: '10', subCollectionId: '', hasPhoto: false,
specificFields: specific,
})
describe('GET /api/creation/preview', () => {
beforeEach(() => {
mockSession.mockResolvedValue({ user: { id: 'u1', role: 'admin' } } as never)
mockFetch.mockResolvedValue([
makePending('OUTILLAGE', 2, { 'Puissance (AN)': '1500W' }),
makePending('CARRELAGE', 3),
])
})
it('retourne 401 sans session', async () => {
mockSession.mockResolvedValue(null)
const res = await GET(new Request('http://localhost') as never)
expect(res.status).toBe(401)
})
it('retourne les produits groupés par onglet', async () => {
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body.byTab['OUTILLAGE']).toHaveLength(1)
expect(body.byTab['CARRELAGE']).toHaveLength(1)
expect(body.total).toBe(2)
})
it('liste les colonnes spécifiques uniques', async () => {
const res = await GET(new Request('http://localhost') as never)
const body = await res.json()
expect(body.specificColumns).toContain('Puissance (AN)')
})
})
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { fetchPendingProducts } from '@/lib/creationSheets'
import { prisma } from '@/lib/prisma'
import type { PendingProduct } from '@/types/creation'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export async function GET(_req: NextRequest) {
const session = await getServerSession(authOptions)
if (!session) return NextResponse.json({ error: 'Non authentifié' }, { status: 401 })
try {
const existing = await prisma.productCreation.findMany({ select: { sheetTab: true, sheetRow: true } })
const alreadyCreated = new Set(existing.map(e => `${e.sheetTab}:${e.sheetRow}`))
const pending = await fetchPendingProducts(alreadyCreated)
const byTab: Record<string, PendingProduct[]> = {}
const specificColumns = new Set<string>()
for (const p of pending) {
if (!byTab[p.tab]) byTab[p.tab] = []
byTab[p.tab].push(p)
Object.keys(p.specificFields).forEach(k => specificColumns.add(k))
}
return NextResponse.json({ byTab, total: pending.length, specificColumns: Array.from(specificColumns).sort() })
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,53 @@
'use client'
import type { CreationStreamEvent } from '@/types/creation'
interface Props {
events: CreationStreamEvent[]
done: boolean
}
export function CreationProgress({ events, done }: Props) {
const lastProgress = [...events].reverse().find(e => e.type === 'progress') as Extract<CreationStreamEvent, { type: 'progress' }> | undefined
const doneEvent = events.find(e => e.type === 'done') as Extract<CreationStreamEvent, { type: 'done' }> | undefined
const errors = events.filter(e => e.type === 'error') as Array<Extract<CreationStreamEvent, { type: 'error' }>>
return (
<div className="space-y-4">
{lastProgress && !done && (
<div>
<div className="flex justify-between text-sm text-gray-300 mb-1">
<span>{lastProgress.step}</span>
<span>{lastProgress.current}/{lastProgress.total}</span>
</div>
<div className="w-full bg-slate-700 rounded-full h-2">
<div className="bg-indigo-600 h-2 rounded-full transition-all"
style={{ width: `${(lastProgress.current / lastProgress.total) * 100}%` }} />
</div>
<p className="text-xs text-gray-400 mt-1 truncate">{lastProgress.tab} {lastProgress.title}</p>
</div>
)}
{errors.length > 0 && (
<div className="space-y-1">
{errors.map((e, i) => (
<div key={i} className="flex gap-2 p-2 bg-red-900/30 rounded text-xs text-red-300">
<span className="font-medium">{e.title}</span>
<span className="text-red-400"> {e.message}</span>
</div>
))}
</div>
)}
{doneEvent && (
<div className={`p-4 rounded-lg ${doneEvent.summary.status === 'success' ? 'bg-green-900/30 border border-green-700' : doneEvent.summary.status === 'partial' ? 'bg-yellow-900/30 border border-yellow-700' : 'bg-red-900/30 border border-red-700'}`}>
<p className={`font-semibold ${doneEvent.summary.status === 'success' ? 'text-green-300' : doneEvent.summary.status === 'partial' ? 'text-yellow-300' : 'text-red-300'}`}>
{doneEvent.summary.status === 'success' ? '✓ Terminé' : doneEvent.summary.status === 'partial' ? '⚠ Partiel' : '✗ Erreur'}
</p>
<p className="text-sm text-gray-300 mt-1">
{doneEvent.summary.created} créé{doneEvent.summary.created > 1 ? 's' : ''}
{doneEvent.summary.errors > 0 && `${doneEvent.summary.errors} erreur${doneEvent.summary.errors > 1 ? 's' : ''}`}
</p>
</div>
)}
{!done && !lastProgress && <p className="text-gray-400 text-sm">Démarrage</p>}
</div>
)
}
@@ -0,0 +1,117 @@
'use client'
import { useState, useEffect } from 'react'
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
import { normalizeMetafieldKey } from '@/lib/shopifyMetafields'
interface Props {
specificColumns: string[]
onResolved: (resolutions: MetafieldResolution[]) => void
}
export function MetafieldResolver({ specificColumns, onResolved }: Props) {
const [definitions, setDefinitions] = useState<MetafieldDefinition[]>([])
const [matched, setMatched] = useState<Record<string, MetafieldResolution>>({})
const [unmatched, setUnmatched] = useState<string[]>([])
const [resolutions, setResolutions] = useState<Record<string, MetafieldResolution>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (specificColumns.length === 0) { setLoading(false); onResolved([]); return }
const params = specificColumns.map(c => encodeURIComponent(c)).join(',')
fetch(`/api/creation/metafields?columns=${params}`)
.then(r => r.json())
.then(data => {
if (data.error) throw new Error(data.error)
setDefinitions(data.definitions)
setMatched(data.matched)
setUnmatched(data.unmatched)
const init: Record<string, MetafieldResolution> = { ...data.matched }
data.unmatched.forEach((col: string) => {
init[col] = { columnHeader: col, action: 'create', namespace: 'custom', key: normalizeMetafieldKey(col) }
})
setResolutions(init)
})
.catch(e => setError(e.message))
.finally(() => setLoading(false))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [specificColumns.join(',')])
useEffect(() => {
if (!loading) onResolved(Object.values(resolutions))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolutions, loading])
const updateResolution = (col: string, partial: Partial<MetafieldResolution>) => {
setResolutions(prev => ({ ...prev, [col]: { ...prev[col], ...partial } }))
}
if (loading) return <p className="text-gray-400 text-sm">Analyse des métachamps Shopify</p>
if (error) return <p className="text-red-400 text-sm">{error}</p>
if (specificColumns.length === 0) return <p className="text-gray-400 text-sm">Aucune colonne spécifique détectée.</p>
return (
<div className="space-y-4">
{Object.entries(matched).length > 0 && (
<div>
<h3 className="text-sm font-medium text-green-400 mb-2">
{Object.entries(matched).length} colonne{Object.entries(matched).length > 1 ? 's' : ''} avec correspondance automatique
</h3>
<div className="space-y-1">
{Object.entries(matched).map(([col, res]) => (
<div key={col} className="flex items-center gap-3 px-3 py-2 bg-slate-700/50 rounded text-sm">
<span className="text-gray-300 flex-1">{col}</span>
<span className="text-green-400 text-xs"> {res.namespace}.{res.key}</span>
</div>
))}
</div>
</div>
)}
{unmatched.length > 0 && (
<div>
<h3 className="text-sm font-medium text-yellow-400 mb-2">
{unmatched.length} colonne{unmatched.length > 1 ? 's' : ''} sans correspondance action requise
</h3>
<div className="space-y-2">
{unmatched.map(col => {
const res = resolutions[col]
if (!res) return null
return (
<div key={col} className="p-3 bg-slate-700 rounded-lg space-y-2">
<p className="text-sm text-white font-medium">{col}</p>
<div className="flex gap-2">
{(['create', 'map', 'skip'] as const).map(action => (
<button
key={action}
onClick={() => updateResolution(col, { action })}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${res.action === action ? 'bg-indigo-600 text-white' : 'bg-slate-600 text-gray-300 hover:bg-slate-500'}`}
>
{action === 'create' ? 'Créer nouveau' : action === 'map' ? 'Associer existant' : 'Ignorer'}
</button>
))}
</div>
{res.action === 'create' && (
<div className="flex gap-2">
<input value={res.key} onChange={e => updateResolution(col, { key: e.target.value })} placeholder="clé (ex: puissance)"
className="flex-1 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
<input value={res.namespace} onChange={e => updateResolution(col, { namespace: e.target.value })} placeholder="namespace"
className="w-24 bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white" />
</div>
)}
{res.action === 'map' && (
<select value={res.existingDefinitionId ?? ''}
onChange={e => { const def = definitions.find(d => d.id === e.target.value); if (def) updateResolution(col, { existingDefinitionId: def.id, key: def.key, namespace: def.namespace }) }}
className="w-full bg-slate-600 border border-slate-500 rounded px-2 py-1 text-xs text-white">
<option value="">-- Choisir un métachamp existant --</option>
{definitions.map(d => <option key={d.id} value={d.id}>{d.name} ({d.namespace}.{d.key})</option>)}
</select>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,54 @@
'use client'
import type { PendingProduct } from '@/types/creation'
interface Props {
byTab: Record<string, PendingProduct[]>
total: number
}
export function PendingRowsTable({ byTab, total }: Props) {
if (total === 0) {
return (
<div className="text-center py-12 text-gray-400 text-sm">
Aucun produit en attente (Publié=1 et ID vide).
</div>
)
}
return (
<div className="space-y-6">
{Object.entries(byTab).map(([tab, products]) => (
<div key={tab}>
<h3 className="text-sm font-semibold text-indigo-300 mb-2 uppercase tracking-wide">
{tab} {products.length} produit{products.length > 1 ? 's' : ''}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-400 border-b border-slate-700">
<th className="pb-2 pr-4 font-medium">SKU</th>
<th className="pb-2 pr-4 font-medium">Titre</th>
<th className="pb-2 pr-4 font-medium">Prix</th>
<th className="pb-2 pr-4 font-medium">Stock</th>
<th className="pb-2 font-medium">Métachamps</th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={`${p.tab}-${p.rowNumber}`} className="border-b border-slate-700/50">
<td className="py-2 pr-4 font-mono text-indigo-300 text-xs">{p.sku}</td>
<td className="py-2 pr-4 text-white">{p.title}</td>
<td className="py-2 pr-4 text-gray-300">{p.priceActual} </td>
<td className="py-2 pr-4 text-gray-300">{p.stock}</td>
<td className="py-2 text-gray-400 text-xs">
{Object.keys(p.specificFields).length} champ{Object.keys(p.specificFields).length > 1 ? 's' : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)
}
+1
View File
@@ -15,6 +15,7 @@ const NAV_ITEMS: NavItem[] = [
{ href: '/produits', label: 'Produits', icon: '📦' },
{ href: '/images', label: 'Images', icon: '🖼️' },
{ href: '/sync', label: 'Sync Sheets', icon: '🔄' },
{ href: '/creation', label: 'Création', icon: '✨' },
{ href: '/parametres', label: 'Paramètres', icon: '⚙️', adminOnly: true },
]
+103
View File
@@ -0,0 +1,103 @@
import { parseTabRows, findColIndex, extractSpecificFields } from './creationSheets'
const HEADERS = [
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?',
"Commentaire\nexemple : Modèle d'expo (lègères rayures)",
'Longueur (cm)', 'Largeur (cm)', 'Hauteur (cm)', 'Épaisseur (cm)',
'Poids par carton', 'Stock conditionné (en carton)',
'Fabrication', 'Garantie', 'Coloris',
'Puissance (AN)', 'Batterie (AP)', 'Couple (AQ)',
'PHOTO ?', 'ID FAMILLE', 'ID SOUS FAMILLE',
]
const makeRow = (overrides: Record<number, string> = {}): string[] => {
const base: Record<number, string> = {
0: '', // ID vide = à créer
2: 'SKU-001', // UGS
3: 'Perceuse Test',
4: '1', // Publié
13: '5', // Stock
15: '99.99', // Prix marché
16: '79.99', // Prix remisé
19: 'OUTILLAGE',
24: '2.5', // Poids
27: 'BOSCH', // Marque
39: '1500W', // Puissance
40: 'Li-Ion', // Batterie
// Couple (index 41) laissé vide
[HEADERS.length - 2]: '10', // ID FAMILLE
[HEADERS.length - 1]: '20', // ID SOUS FAMILLE
}
return HEADERS.map((_, i) => overrides[i] ?? base[i] ?? '')
}
describe('findColIndex', () => {
it('trouve un en-tête exact', () => {
expect(findColIndex(HEADERS, 'UGS')).toBe(2)
})
it('trouve un en-tête commençant par Commentaire', () => {
const idx = findColIndex(HEADERS, 'Commentaire')
expect(idx).toBeGreaterThan(-1)
})
it('retourne -1 si non trouvé', () => {
expect(findColIndex(HEADERS, 'Inexistant')).toBe(-1)
})
})
describe('extractSpecificFields', () => {
it('exclut les colonnes communes', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(Object.keys(specific)).not.toContain('Nom')
expect(Object.keys(specific)).not.toContain('UGS')
})
it('inclut les colonnes spécifiques non vides', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(specific['Puissance (AN)']).toBe('1500W')
expect(specific['Batterie (AP)']).toBe('Li-Ion')
})
it('exclut les colonnes spécifiques vides', () => {
const row = makeRow()
const specific = extractSpecificFields(HEADERS, row)
expect(Object.keys(specific)).not.toContain('Couple (AQ)')
})
})
describe('parseTabRows', () => {
it('ignore les lignes avec ID déjà rempli', () => {
const row = makeRow({ 0: '12345' })
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(0)
})
it('ignore les lignes avec Publié ≠ 1', () => {
const row = makeRow({ 4: '0' })
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(0)
})
it('retourne un PendingProduct pour une ligne valide', () => {
const row = makeRow()
const result = parseTabRows('OUTILLAGE', HEADERS, [row])
expect(result).toHaveLength(1)
expect(result[0].sku).toBe('SKU-001')
expect(result[0].title).toBe('Perceuse Test')
expect(result[0].priceActual).toBe('79.99')
expect(result[0].collectionId).toBe('10')
expect(result[0].specificFields['Puissance (AN)']).toBe('1500W')
})
it('attribue le bon numéro de ligne (1-indexé depuis row 2)', () => {
const rows = [makeRow(), makeRow({ 3: 'Deuxième' })]
const result = parseTabRows('OUTILLAGE', HEADERS, rows)
expect(result[0].rowNumber).toBe(2)
expect(result[1].rowNumber).toBe(3)
})
})
+116
View File
@@ -0,0 +1,116 @@
import type { PendingProduct } from '@/types/creation'
const COMMON_HEADERS = new Set([
'ID', 'Type', 'UGS', 'Nom', 'Publié', 'Mis en avant ?',
'Description courte', 'Description longue',
'Date de début de promo', 'Date de fin de promo',
'État de la TVA', 'Classe de TVA', 'En stock ?',
'Stock (en pièce)', 'Vendre individuellement ?',
'Prix marché', 'Prix remisé', 'Code ecopart', 'Ecopart montant',
'Famille', 'Sous famille',
"Classe d'expédition (en retrait, livraison)",
'Conditionnement', 'Surface (m2)', 'Poids (kg) fonction unité',
'Images', 'Unité', 'Marque', 'Neuf ?', 'PHOTO ?',
'ID FAMILLE', 'ID SOUS FAMILLE',
])
export function findColIndex(headers: string[], search: string): number {
const exact = headers.findIndex(h => h === search)
if (exact !== -1) return exact
return headers.findIndex(h => h.startsWith(search))
}
export function extractSpecificFields(headers: string[], row: string[]): Record<string, string> {
const result: Record<string, string> = {}
headers.forEach((header, i) => {
if (!header) return
const isCommon = COMMON_HEADERS.has(header) || header.startsWith('Commentaire')
if (isCommon) return
const val = (row[i] ?? '').trim()
if (val) result[header] = val
})
return result
}
export function parseTabRows(tab: string, headers: string[], rows: string[][]): PendingProduct[] {
const idx = {
id: findColIndex(headers, 'ID'),
sku: findColIndex(headers, 'UGS'),
nom: findColIndex(headers, 'Nom'),
publie: findColIndex(headers, 'Publié'),
stock: findColIndex(headers, 'Stock (en pièce)'),
prixMarche: findColIndex(headers, 'Prix marché'),
prixRemise: findColIndex(headers, 'Prix remisé'),
poids: findColIndex(headers, 'Poids (kg) fonction unité'),
marque: findColIndex(headers, 'Marque'),
famille: findColIndex(headers, 'Famille'),
sousFamille: findColIndex(headers, 'Sous famille'),
commentaire: findColIndex(headers, 'Commentaire'),
photo: findColIndex(headers, 'PHOTO ?'),
collectionId: findColIndex(headers, 'ID FAMILLE'),
subCollectionId: findColIndex(headers, 'ID SOUS FAMILLE'),
}
const get = (row: string[], i: number) => (i >= 0 ? (row[i] ?? '').trim() : '')
return rows
.map((row, rowIdx): PendingProduct | null => {
const id = get(row, idx.id)
const publie = get(row, idx.publie)
if (id !== '' || publie !== '1') return null
return {
tab,
rowNumber: rowIdx + 2,
sku: get(row, idx.sku),
title: get(row, idx.nom),
priceMarket: get(row, idx.prixMarche).replace(',', '.'),
priceActual: get(row, idx.prixRemise).replace(',', '.'),
stock: parseInt(get(row, idx.stock) || '0', 10) || 0,
weightKg: parseFloat(get(row, idx.poids).replace(',', '.') || '0') || 0,
vendor: get(row, idx.marque),
famille: get(row, idx.famille),
sousFamille: get(row, idx.sousFamille),
comment: get(row, idx.commentaire),
collectionId: get(row, idx.collectionId),
subCollectionId: get(row, idx.subCollectionId),
hasPhoto: get(row, idx.photo) !== '',
specificFields: extractSpecificFields(headers, row),
}
})
.filter((p): p is PendingProduct => p !== null)
}
export async function fetchPendingProducts(
alreadyCreatedRows: Set<string>,
): Promise<PendingProduct[]> {
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')
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: string[] = (meta.sheets as Array<{ properties: { title: string } }>).map(
s => s.properties.title,
)
const all: PendingProduct[] = []
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 [headers, ...dataRows] = rows
const pending = parseTabRows(tab, headers, dataRows).filter(
p => !alreadyCreatedRows.has(`${p.tab}:${p.rowNumber}`),
)
all.push(...pending)
}
return all
}
+28 -6
View File
@@ -36,27 +36,49 @@ export function parseSheetRows(rows: string[][]): SyncProduct[] {
/**
* Lit tous les produits depuis Google Sheets via l'API v4 (API Key publique).
* Si GOOGLE_SHEETS_TAB est défini, lit uniquement cet onglet.
* Sinon, lit tous les onglets du fichier et combine les produits.
* Le fichier Sheets doit être partagé en lecture ("Toute personne avec le lien").
*/
export async function readSheetProducts(): Promise<SyncProduct[]> {
const apiKey = process.env.GOOGLE_API_KEY
const sheetId = process.env.GOOGLE_SHEETS_ID
const sheetName = process.env.GOOGLE_SHEETS_TAB ?? 'Produits'
const singleTab = process.env.GOOGLE_SHEETS_TAB
if (!apiKey || !sheetId) {
throw new Error('Variables GOOGLE_API_KEY et GOOGLE_SHEETS_ID requises')
}
const range = encodeURIComponent(`${sheetName}!A2:F`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const sheetNames = singleTab ? [singleTab] : await listSheetTabs(sheetId, apiKey)
const results = await Promise.all(
sheetNames.map((name) => readOneTab(sheetId, apiKey, name)),
)
return results.flat()
}
async function listSheetTabs(sheetId: string, apiKey: string): Promise<string[]> {
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}?key=${apiKey}&fields=sheets.properties.title`
const res = await fetch(url)
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error: ${res.status}${err}`)
}
const data = await res.json()
const rows = (data.values ?? []) as string[][]
return parseSheetRows(rows)
return (data.sheets as Array<{ properties: { title: string } }>).map(
(s) => s.properties.title,
)
}
async function readOneTab(sheetId: string, apiKey: string, sheetName: string): Promise<SyncProduct[]> {
const range = encodeURIComponent(`${sheetName}!A2:F`)
const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/${range}?key=${apiKey}`
const res = await fetch(url)
if (!res.ok) {
const err = await res.text()
throw new Error(`Google Sheets API error (onglet "${sheetName}"): ${res.status}${err}`)
}
const data = await res.json()
return parseSheetRows((data.values ?? []) as string[][])
}
+46
View File
@@ -0,0 +1,46 @@
import { generateDescriptions } from './openaiClient'
const mockFetch = jest.fn()
global.fetch = mockFetch
const mockResponse = (content: string) => ({
ok: true,
json: async () => ({ choices: [{ message: { content } }] }),
})
describe('generateDescriptions', () => {
beforeEach(() => {
process.env.OPENAI_API_KEY = 'sk-test'
mockFetch.mockReset()
})
it('retourne shortDesc et longDesc depuis OpenAI', async () => {
mockFetch
.mockResolvedValueOnce(mockResponse('<p>Description courte</p>'))
.mockResolvedValueOnce(mockResponse('<p>Description longue</p>'))
const result = await generateDescriptions({
title: 'Perceuse', rawDescription: 'Une perceuse', comment: '', vendor: 'BOSCH', famille: 'OUTILLAGE',
})
expect(result.shortDesc).toBe('<p>Description courte</p>')
expect(result.longDesc).toBe('<p>Description longue</p>')
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('lance les deux requêtes en parallèle', async () => {
mockFetch.mockResolvedValue(mockResponse('<p>ok</p>'))
await generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' })
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('lance une erreur si OPENAI_API_KEY manquant', async () => {
delete process.env.OPENAI_API_KEY
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
.rejects.toThrow('OPENAI_API_KEY')
})
it('lance une erreur si la réponse OpenAI est non-ok', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 429, text: async () => 'rate limited' })
await expect(generateDescriptions({ title: 'T', rawDescription: '', comment: '', vendor: '', famille: '' }))
.rejects.toThrow('429')
})
})
+46
View File
@@ -0,0 +1,46 @@
interface DescriptionInput {
title: string
rawDescription: string
comment: string
vendor: string
famille: string
}
interface Descriptions {
shortDesc: string
longDesc: string
}
async function callOpenAI(prompt: string): Promise<string> {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY requis')
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: 'gpt-4o', messages: [{ role: 'user', content: prompt }], temperature: 0.7 }),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`OpenAI API error: ${res.status}${err}`)
}
const data = await res.json()
return (data.choices?.[0]?.message?.content ?? '').trim()
}
function buildPrompt(input: DescriptionInput, length: number): string {
return `A partir des caractéristiques suivantes, crée un texte descriptif de ${length} caractères en faisant attention au SEO pour qu'il soit en rapport avec la catégorie ${input.famille} et le produit ${input.title} :
Nom = ${input.title}
Description : ${input.rawDescription}
Commentaire : ${input.comment}
Marque : ${input.vendor}
Formate ce texte en HTML.
Ta réponse doit commencer directement par une balise HTML (comme <p> ou <h1>), sans bloc \`\`\`html, sans aucun mot ou encadré autour.`
}
export async function generateDescriptions(input: DescriptionInput): Promise<Descriptions> {
const [shortDesc, longDesc] = await Promise.all([
callOpenAI(buildPrompt(input, 400)),
callOpenAI(buildPrompt(input, 1000)),
])
return { shortDesc, longDesc }
}
+27
View File
@@ -0,0 +1,27 @@
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export async function addProductToCollection(shopifyProductId: string, collectionId: string): Promise<void> {
if (!collectionId?.trim()) return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/collects.json`, {
method: 'POST',
headers,
body: JSON.stringify({ collect: { product_id: shopifyProductId, collection_id: collectionId } }),
})
if (!res.ok) {
const err = await res.text()
if (!err.includes('already') && !err.includes('taken')) {
throw new Error(`Shopify addToCollection error: ${res.status}${err}`)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { normalizeMetafieldKey, matchMetafields } from './shopifyMetafields'
import type { MetafieldDefinition } from '@/types/creation'
describe('normalizeMetafieldKey', () => {
it('minuscules + accents retirés', () => {
expect(normalizeMetafieldKey('Éclairage LED')).toBe('eclairage_led')
})
it('parenthèses et espaces → underscore', () => {
expect(normalizeMetafieldKey('Puissance (AN)')).toBe('puissance_an')
})
it('limité à 30 caractères', () => {
const long = 'a'.repeat(40)
expect(normalizeMetafieldKey(long)).toHaveLength(30)
})
it('underscores multiples réduits à un', () => {
expect(normalizeMetafieldKey('Poids par carton')).toBe('poids_par_carton')
})
})
describe('matchMetafields', () => {
const definitions: MetafieldDefinition[] = [
{ id: '1', namespace: 'custom', key: 'puissance_an', name: 'Puissance (AN)', type: 'single_line_text_field' },
{ id: '2', namespace: 'custom', key: 'coloris', name: 'Coloris', type: 'single_line_text_field' },
]
it('trouve une correspondance exacte sur la clé normalisée', () => {
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
expect(result.matched['Puissance (AN)'].existingDefinitionId).toBe('1')
})
it('met les colonnes sans correspondance dans unmatched', () => {
const result = matchMetafields(['Puissance (AN)', 'Batterie (AP)'], definitions)
expect(result.unmatched).toContain('Batterie (AP)')
})
it('ne met pas en unmatched les colonnes qui ont un match', () => {
const result = matchMetafields(['Puissance (AN)'], definitions)
expect(result.unmatched).not.toContain('Puissance (AN)')
})
})
+104
View File
@@ -0,0 +1,104 @@
import type { MetafieldDefinition, MetafieldResolution } from '@/types/creation'
const API_VERSION = '2024-01'
function getConfig() {
const domain = process.env.SHOPIFY_STORE_DOMAIN
const token = process.env.SHOPIFY_ADMIN_API_TOKEN
if (!domain || !token) throw new Error('SHOPIFY_STORE_DOMAIN et SHOPIFY_ADMIN_API_TOKEN requis')
return {
baseUrl: `https://${domain}/admin/api/${API_VERSION}`,
headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' },
}
}
export function normalizeMetafieldKey(header: string): string {
return header
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // retire les accents
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_')
.substring(0, 30)
}
export interface MatchResult {
matched: Record<string, MetafieldResolution>
unmatched: string[]
}
export function matchMetafields(columnHeaders: string[], definitions: MetafieldDefinition[]): MatchResult {
const matched: Record<string, MetafieldResolution> = {}
const unmatched: string[] = []
for (const header of columnHeaders) {
const key = normalizeMetafieldKey(header)
const def = definitions.find(d => d.key === key)
if (def) {
matched[header] = { columnHeader: header, action: 'map', namespace: def.namespace, key: def.key, existingDefinitionId: def.id }
} else {
unmatched.push(header)
}
}
return { matched, unmatched }
}
export async function fetchMetafieldDefinitions(): Promise<MetafieldDefinition[]> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/metafield_definitions.json?owner_type=PRODUCT&limit=250`, { headers })
if (!res.ok) throw new Error(`Shopify metafield definitions error: ${res.status}`)
const data = await res.json()
return (data.metafield_definitions ?? []).map((d: {
id: string | number; namespace: string; key: string; name: string; type: { name: string } | string
}) => ({
id: String(d.id),
namespace: d.namespace,
key: d.key,
name: d.name,
type: typeof d.type === 'string' ? d.type : d.type?.name ?? 'single_line_text_field',
}))
}
export async function ensureMetafieldDefinition(resolution: MetafieldResolution): Promise<void> {
if (resolution.action !== 'create') return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/metafield_definitions.json`, {
method: 'POST',
headers,
body: JSON.stringify({
metafield_definition: {
namespace: resolution.namespace,
key: resolution.key,
name: resolution.columnHeader,
type: 'single_line_text_field',
owner_type: 'PRODUCT',
},
}),
})
if (!res.ok) {
const err = await res.text()
if (!err.includes('taken') && !err.includes('already')) {
throw new Error(`Shopify create metafield definition error: ${res.status}${err}`)
}
}
}
export async function setProductMetafield(
shopifyProductId: string,
resolution: MetafieldResolution,
value: string,
): Promise<void> {
if (resolution.action === 'skip' || !value.trim()) return
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyProductId}/metafields.json`, {
method: 'POST',
headers,
body: JSON.stringify({
metafield: { namespace: resolution.namespace, key: resolution.key, value, type: 'single_line_text_field' },
}),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify set metafield error: ${res.status}${err}`)
}
}
+11 -3
View File
@@ -54,18 +54,26 @@ function extractNextUrl(linkHeader: string | null): string | null {
/**
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
* Limite : 250 par page via cursor-based pagination.
* L'API REST ne supporte pas status=any — on fait deux passes (active + draft).
*/
export async function fetchAllShopifyProducts(): Promise<ShopifyProductFull[]> {
const [active, draft] = await Promise.all([
fetchByStatus('active'),
fetchByStatus('draft'),
])
return [...active, ...draft]
}
async function fetchByStatus(status: 'active' | 'draft'): Promise<ShopifyProductFull[]> {
const { baseUrl, headers } = getConfig()
const all: ShopifyProductFull[] = []
let url: string | null =
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=any`
`${baseUrl}/products.json?limit=250&fields=id,title,handle,status,body_html,variants&status=${status}`
while (url) {
const res = await fetch(url, { headers })
if (!res.ok) throw new Error(`Shopify fetch error: ${res.status}`)
if (!res.ok) throw new Error(`Shopify fetch error (${status}): ${res.status}`)
const data = await res.json()
all.push(...(data.products as ShopifyAPIProduct[]).map(toSyncProduct))
url = extractNextUrl(res.headers.get('Link'))
+57
View File
@@ -0,0 +1,57 @@
/** Un produit en attente de création, extrait d'un onglet Sheets */
export interface PendingProduct {
tab: string // nom de l'onglet (famille)
rowNumber: number // numéro de ligne 1-indexé dans le sheet
sku: string // UGS
title: string // Nom
priceMarket: string // Prix marché (compare_at_price), ex: "29.99"
priceActual: string // Prix remisé (price), ex: "19.99"
stock: number
weightKg: number
vendor: string // Marque
famille: string // Famille (contexte IA)
sousFamille: string
comment: string // Commentaire
collectionId: string // ID FAMILLE → collection Shopify
subCollectionId: string // ID SOUS FAMILLE
hasPhoto: boolean
specificFields: Record<string, string> // header → valeur pour les métachamps
}
/** Définition de métachamp existante dans Shopify */
export interface MetafieldDefinition {
id: string // ex: "123"
namespace: string // ex: "custom"
key: string // ex: "puissance"
name: string // ex: "Puissance"
type: string // ex: "single_line_text_field"
}
/** Résolution d'une colonne spécifique vers un métachamp */
export interface MetafieldResolution {
columnHeader: string // en-tête colonne Sheets, ex: "Puissance (AN)"
action: 'create' | 'map' | 'skip'
namespace: string // "custom" par défaut
key: string // clé normalisée
existingDefinitionId?: string // si action === 'map'
}
/** Événement streamé par /api/creation/execute */
export type CreationStreamEvent =
| { type: 'progress'; current: number; total: number; tab: string; title: string; step: string }
| { type: 'error'; title: string; message: string }
| { type: 'done'; summary: { created: number; errors: number; status: 'success' | 'partial' | 'error' } }
/** Résumé d'une création (stocké en DB) */
export interface CreationRecord {
id: string
createdAt: string
userName: string
sheetTab: string
sheetRow: number
shopifyId: string
sku: string
title: string
status: 'success' | 'error'
errorMessage?: string
}