Files
gestion-materiaux-destock/src/lib/shopifySync.ts
T
C_Ma_For 60dc9cc0ec feat: map prix marché as compare_at_price (barré) on Shopify
When Prix remisé is set: price = remisé, compare_at_price = marché
When no remise: price = marché, compare_at_price = null
Diff now detects compareAtPrice changes to trigger updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 14:19:37 +02:00

247 lines
7.2 KiB
TypeScript

import type { ShopifyProductFull, SyncProduct } from '@/types/sync'
const API_VERSION = '2024-01'
export 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
compare_at_price: string | null
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',
compareAtPrice: p.variants[0]?.compare_at_price ?? undefined,
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,
compare_at_price: product.compareAtPrice ?? null,
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
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
const inventoryItemId = data.product.variants[0].inventory_item_id
if (locationId && inventoryItemId) {
// Connect l'inventory item à la location avant de setter la quantité
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: product.stock ?? 0,
}),
})
if (!setRes.ok) {
const errText = await setRes.text()
console.error(`[shopify] inventory_levels/set failed: ${setRes.status}${errText}`)
}
}
}
} catch (err) {
console.error('[shopify] stock set error:', err)
}
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, compare_at_price: product.compareAtPrice ?? null }],
},
}
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)
}