caef95bd55
- 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>
235 lines
6.6 KiB
TypeScript
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)
|
|
}
|