Files
gestion-materiaux-destock/src/lib/shopifySync.ts
T
C_Ma_For caef95bd55 feat: set stock and weight on Shopify product creation
- inventory_management: shopify + inventory_levels/set.json pour le stock
- weight/weight_unit sur le variant pour l'expédition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:11:17 +02:00

235 lines
6.6 KiB
TypeScript

import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
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',
},
}
}
interface ShopifyVariant {
id: number
price: string
sku: string
inventory_quantity: number
}
interface ShopifyAPIProduct {
id: number
title: string
handle: string
body_html: string
status: string
variants: ShopifyVariant[]
images: Array<{ src: string }>
}
function toSyncProduct(p: ShopifyAPIProduct): ShopifyProductFull {
return {
shopifyId: String(p.id),
ref: p.variants[0]?.sku || p.handle,
handle: p.handle,
title: p.title,
description: p.body_html ?? '',
price: p.variants[0]?.price ?? '0.00',
stock: p.variants[0]?.inventory_quantity ?? 0,
status: p.status === 'active' ? 'active' : 'draft',
images: (p.images ?? []).map(i => i.src),
}
}
function extractNextUrl(linkHeader: string | null): string | null {
if (!linkHeader) return null
const match = linkHeader.match(/<([^>]+)>;\s*rel="next"/)
return match ? match[1] : null
}
/**
* Récupère tous les produits Shopify (actifs + drafts), en paginant.
* 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,images&status=${status}`
while (url) {
const res = await fetch(url, { headers })
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'))
}
return all
}
/**
* Crée un nouveau produit Shopify depuis un SyncProduct.
* Retourne l'ID Shopify créé.
*/
export async function createShopifyProduct(product: SyncProduct): Promise<string> {
const { baseUrl, headers } = getConfig()
const variant: Record<string, unknown> = {
price: product.price,
sku: product.ref,
inventory_management: 'shopify',
}
if (product.weightKg != null && product.weightKg > 0) {
variant.weight = product.weightKg
variant.weight_unit = 'kg'
}
const body = {
product: {
title: product.title,
body_html: product.description,
handle: product.handle,
status: product.status,
variants: [variant],
},
}
const res = await fetch(`${baseUrl}/products.json`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify createProduct error: ${res.status}${err}`)
}
const data = await res.json()
const shopifyId = String(data.product.id)
const variantId = String(data.product.variants[0].id)
// Récupérer le location_id par défaut pour setter le stock
if (product.stock > 0) {
try {
const locRes = await fetch(`${baseUrl}/locations.json`, { headers })
if (locRes.ok) {
const locData = await locRes.json() as { locations: Array<{ id: number }> }
const locationId = locData.locations[0]?.id
if (locationId) {
await fetch(`${baseUrl}/inventory_levels/set.json`, {
method: 'POST',
headers,
body: JSON.stringify({
location_id: locationId,
inventory_item_id: data.product.variants[0].inventory_item_id,
available: product.stock,
}),
})
}
}
} catch {
// Non bloquant : le produit est créé, le stock peut être corrigé manuellement
}
}
void variantId
return shopifyId
}
/**
* Met à jour un produit Shopify existant (titre, description, prix, statut).
*/
export async function updateShopifyProduct(
shopifyId: string,
product: SyncProduct,
variantId: string,
): Promise<void> {
const { baseUrl, headers } = getConfig()
const body = {
product: {
id: shopifyId,
title: product.title,
body_html: product.description,
status: product.status,
variants: [{ id: variantId, price: product.price }],
},
}
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify updateProduct error: ${res.status}${err}`)
}
}
/**
* Passe un produit Shopify en draft (désactivation — jamais de suppression).
*/
export async function deactivateShopifyProduct(shopifyId: string): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'PUT',
headers,
body: JSON.stringify({ product: { id: shopifyId, status: 'draft' } }),
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify deactivateProduct error: ${res.status}${err}`)
}
}
/**
* Supprime définitivement un produit Shopify.
*/
export async function deleteShopifyProduct(shopifyId: string): Promise<void> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}.json`, {
method: 'DELETE',
headers,
})
if (!res.ok) {
const err = await res.text()
throw new Error(`Shopify deleteProduct error: ${res.status}${err}`)
}
}
/**
* Récupère l'ID de la première variante d'un produit Shopify.
* Nécessaire pour mettre à jour le prix (update variant).
*/
export async function getFirstVariantId(shopifyId: string): Promise<string> {
const { baseUrl, headers } = getConfig()
const res = await fetch(`${baseUrl}/products/${shopifyId}/variants.json?limit=1`, { headers })
if (!res.ok) throw new Error(`Shopify getVariants error: ${res.status}`)
const data = await res.json()
const variant = data.variants?.[0]
if (!variant) throw new Error(`Aucune variante pour le produit ${shopifyId}`)
return String(variant.id)
}