feat: automate supplier fulfillment sync with vendor readonly tracking
Make supplier tracking/status updates the single source of truth, show them as readonly in vendor views, and sync fulfillment to Shopify from the backend with status normalization and fallback handling. Made-with: Cursor
This commit is contained in:
parent
164df26310
commit
b67ef3190c
@ -0,0 +1,7 @@
|
||||
ALTER TABLE "CachedOrder"
|
||||
ADD COLUMN IF NOT EXISTS "pendingVendorSync" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "lastSupplierUpdateAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "lastSupplierUpdatedBy" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "lastShopifySyncAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "lastShopifySyncError" TEXT;
|
||||
|
||||
@ -157,6 +157,11 @@ model CachedOrder {
|
||||
trackingNumber String?
|
||||
trackingCompany String?
|
||||
trackingUrl String?
|
||||
pendingVendorSync Boolean @default(false)
|
||||
lastSupplierUpdateAt DateTime?
|
||||
lastSupplierUpdatedBy String?
|
||||
lastShopifySyncAt DateTime?
|
||||
lastShopifySyncError String?
|
||||
lineItems Json // Store line items as JSON
|
||||
customer Json? // Store customer info as JSON
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { handleApiError } from "@/lib/errors"
|
||||
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
@ -69,7 +70,18 @@ export async function GET(
|
||||
storeId: store.id,
|
||||
},
|
||||
},
|
||||
select: { email: true, customer: true },
|
||||
select: {
|
||||
email: true,
|
||||
customer: true,
|
||||
fulfillmentStatus: true,
|
||||
trackingNumber: true,
|
||||
trackingCompany: true,
|
||||
trackingUrl: true,
|
||||
pendingVendorSync: true,
|
||||
lastSupplierUpdateAt: true,
|
||||
lastShopifySyncAt: true,
|
||||
lastShopifySyncError: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (cached) {
|
||||
@ -96,6 +108,16 @@ export async function GET(
|
||||
order.customer = { ...cust, email: cachedEmail }
|
||||
}
|
||||
}
|
||||
|
||||
order.fulfillment_status = toVendorDisplayStatus(cached.fulfillmentStatus)
|
||||
order.fulfillment_status_canonical = normalizeFulfillmentStatus(cached.fulfillmentStatus)
|
||||
order.tracking_number = cached.trackingNumber || null
|
||||
order.tracking_company = cached.trackingCompany || null
|
||||
order.tracking_url = cached.trackingUrl || null
|
||||
order.pending_vendor_sync = cached.pendingVendorSync
|
||||
order.last_supplier_update_at = cached.lastSupplierUpdateAt?.toISOString() || null
|
||||
order.last_shopify_sync_at = cached.lastShopifySyncAt?.toISOString() || null
|
||||
order.last_shopify_sync_error = cached.lastShopifySyncError || null
|
||||
}
|
||||
|
||||
return NextResponse.json({ order })
|
||||
|
||||
@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { isCacheFresh, getCachedOrders, updateOrderCache } from "@/lib/cache"
|
||||
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -40,9 +41,17 @@ export async function GET() {
|
||||
total_price: order.totalPrice,
|
||||
currency: order.currency,
|
||||
financial_status: order.financialStatus,
|
||||
fulfillment_status: order.fulfillmentStatus,
|
||||
fulfillment_status: toVendorDisplayStatus(order.fulfillmentStatus),
|
||||
fulfillment_status_canonical: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||
customer: order.customer,
|
||||
line_items: order.lineItems
|
||||
line_items: order.lineItems,
|
||||
tracking_number: order.trackingNumber || null,
|
||||
tracking_company: order.trackingCompany || null,
|
||||
tracking_url: order.trackingUrl || null,
|
||||
pending_vendor_sync: order.pendingVendorSync || false,
|
||||
last_supplier_update_at: order.lastSupplierUpdateAt?.toISOString() || null,
|
||||
last_shopify_sync_at: order.lastShopifySyncAt?.toISOString() || null,
|
||||
last_shopify_sync_error: order.lastShopifySyncError || null,
|
||||
}))
|
||||
return NextResponse.json({
|
||||
orders: transformedOrders,
|
||||
@ -87,7 +96,39 @@ export async function GET() {
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...data, cached: false })
|
||||
const cachedOrders = await prisma.cachedOrder.findMany({
|
||||
where: { storeId: store.id },
|
||||
select: {
|
||||
shopifyOrderId: true,
|
||||
fulfillmentStatus: true,
|
||||
trackingNumber: true,
|
||||
trackingCompany: true,
|
||||
trackingUrl: true,
|
||||
pendingVendorSync: true,
|
||||
lastSupplierUpdateAt: true,
|
||||
lastShopifySyncAt: true,
|
||||
lastShopifySyncError: true,
|
||||
},
|
||||
})
|
||||
const byId = new Map(cachedOrders.map((o) => [o.shopifyOrderId, o]))
|
||||
const mergedOrders = (data.orders || []).map((o: Record<string, unknown>) => {
|
||||
const local = byId.get(String(o.id))
|
||||
if (!local) return o
|
||||
return {
|
||||
...o,
|
||||
fulfillment_status: toVendorDisplayStatus(local.fulfillmentStatus),
|
||||
fulfillment_status_canonical: normalizeFulfillmentStatus(local.fulfillmentStatus),
|
||||
tracking_number: local.trackingNumber || null,
|
||||
tracking_company: local.trackingCompany || null,
|
||||
tracking_url: local.trackingUrl || null,
|
||||
pending_vendor_sync: local.pendingVendorSync || false,
|
||||
last_supplier_update_at: local.lastSupplierUpdateAt?.toISOString() || null,
|
||||
last_shopify_sync_at: local.lastShopifySyncAt?.toISOString() || null,
|
||||
last_shopify_sync_error: local.lastShopifySyncError || null,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ ...data, orders: mergedOrders, cached: false })
|
||||
} catch (fetchError: unknown) {
|
||||
clearTimeout(timeoutId)
|
||||
if (fetchError instanceof Error && fetchError.name === "AbortError") {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||
|
||||
async function getSupplierContext(userId: string) {
|
||||
const profile = await prisma.supplierProfile.findUnique({
|
||||
@ -52,6 +53,168 @@ function isSupplierAllowedForOrder(
|
||||
})
|
||||
}
|
||||
|
||||
async function syncOrderToShopify(
|
||||
storeId: string,
|
||||
shopifyOrderId: string,
|
||||
fulfillmentStatus: string,
|
||||
trackingNumber: string | null,
|
||||
trackingCompany: string | null,
|
||||
trackingUrl: string | null,
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
const canonical = normalizeFulfillmentStatus(fulfillmentStatus)
|
||||
if (canonical !== "SHIPPED" && canonical !== "FULFILLED") {
|
||||
// No fulfillment push needed for non-shipped statuses.
|
||||
return { ok: true }
|
||||
}
|
||||
if (!trackingNumber || !trackingCompany) {
|
||||
return { ok: false, error: "Tracking platform and tracking number are required for shipped/fulfilled sync." }
|
||||
}
|
||||
|
||||
const store = await prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { shopifyDomain: true, shopifyAccessToken: true },
|
||||
})
|
||||
if (!store?.shopifyDomain || !store?.shopifyAccessToken) {
|
||||
return { ok: false, error: "Shopify store not connected." }
|
||||
}
|
||||
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "")
|
||||
|
||||
const fetchScopes = async (): Promise<string[]> => {
|
||||
try {
|
||||
const res = await fetch(`https://${cleanDomain}/admin/oauth/access_scopes.json`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const payload = (await res.json()) as { access_scopes?: Array<{ handle?: string }> }
|
||||
return (payload.access_scopes || []).map((s) => s.handle || "").filter(Boolean)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const createLegacyFulfillmentFallback = async (): Promise<{ ok: boolean; error?: string }> => {
|
||||
const orderRes = await fetch(
|
||||
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}.json?status=any`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!orderRes.ok) {
|
||||
const details = await orderRes.text().catch(() => "Unknown Shopify response")
|
||||
return { ok: false, error: `Fallback fetch order failed: ${details}` }
|
||||
}
|
||||
|
||||
const orderPayload = (await orderRes.json()) as {
|
||||
order?: {
|
||||
line_items?: Array<{ id: number; fulfillable_quantity?: number; quantity?: number }>
|
||||
}
|
||||
}
|
||||
const lineItems = (orderPayload.order?.line_items || [])
|
||||
.filter((li) => (li.fulfillable_quantity ?? li.quantity ?? 0) > 0)
|
||||
.map((li) => ({
|
||||
id: li.id,
|
||||
quantity: li.fulfillable_quantity ?? li.quantity ?? 1,
|
||||
}))
|
||||
|
||||
const legacyPayload = {
|
||||
fulfillment: {
|
||||
notify_customer: false,
|
||||
tracking_number: trackingNumber,
|
||||
tracking_company: trackingCompany,
|
||||
tracking_urls: trackingUrl ? [trackingUrl] : undefined,
|
||||
line_items: lineItems.length > 0 ? lineItems : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
const legacyRes = await fetch(
|
||||
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(legacyPayload),
|
||||
},
|
||||
)
|
||||
if (!legacyRes.ok) {
|
||||
const details = await legacyRes.text().catch(() => "Unknown Shopify response")
|
||||
return { ok: false, error: `Legacy fulfillment failed: ${details}` }
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const foRes = await fetch(
|
||||
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillment_orders.json`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
if (!foRes.ok) {
|
||||
const details = await foRes.text().catch(() => "Unknown Shopify response")
|
||||
const fallback = await createLegacyFulfillmentFallback()
|
||||
if (fallback.ok) return fallback
|
||||
const scopes = await fetchScopes()
|
||||
return {
|
||||
ok: false,
|
||||
error: `Failed to read fulfillment orders: ${details}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
|
||||
}
|
||||
}
|
||||
|
||||
const foJson = (await foRes.json()) as { fulfillment_orders?: Array<{ id: number }> }
|
||||
const fulfillmentOrders = foJson.fulfillment_orders || []
|
||||
if (fulfillmentOrders.length === 0) {
|
||||
return { ok: false, error: "No fulfillment orders available for this order." }
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fulfillment: {
|
||||
notify_customer: false,
|
||||
tracking_info: {
|
||||
number: trackingNumber,
|
||||
company: trackingCompany,
|
||||
url: trackingUrl || undefined,
|
||||
},
|
||||
line_items_by_fulfillment_order: fulfillmentOrders.map((f) => ({
|
||||
fulfillment_order_id: f.id,
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
const syncRes = await fetch(`https://${cleanDomain}/admin/api/2024-01/fulfillments.json`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!syncRes.ok) {
|
||||
const details = await syncRes.text().catch(() => "Unknown Shopify response")
|
||||
const fallback = await createLegacyFulfillmentFallback()
|
||||
if (fallback.ok) return fallback
|
||||
const scopes = await fetchScopes()
|
||||
return {
|
||||
ok: false,
|
||||
error: `Shopify fulfillment sync failed: ${details}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ orderId: string }> },
|
||||
@ -132,7 +295,8 @@ export async function GET(
|
||||
totalPrice: order.totalPrice,
|
||||
currency: order.currency,
|
||||
financialStatus: order.financialStatus,
|
||||
fulfillmentStatus: order.fulfillmentStatus || "UNFULFILLED",
|
||||
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
|
||||
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
||||
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
||||
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
||||
@ -162,11 +326,7 @@ export async function PATCH(
|
||||
const { orderId } = await params
|
||||
const body = await req.json()
|
||||
|
||||
const allowedStatuses = new Set(["UNFULFILLED", "IN_PROGRESS", "SHIPPED", "FULFILLED", "CANCELLED"])
|
||||
const fulfillmentStatus = String(body.fulfillmentStatus || "").toUpperCase()
|
||||
if (!allowedStatuses.has(fulfillmentStatus)) {
|
||||
return NextResponse.json({ message: "Invalid fulfillment status" }, { status: 400 })
|
||||
}
|
||||
const fulfillmentStatus = normalizeFulfillmentStatus(body.fulfillmentStatus)
|
||||
|
||||
const trackingNumber = String(body.trackingNumber || "").trim() || null
|
||||
const trackingCompany = String(body.trackingCompany || "").trim() || null
|
||||
@ -193,6 +353,10 @@ export async function PATCH(
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
pendingVendorSync: true,
|
||||
lastSupplierUpdateAt: new Date(),
|
||||
lastSupplierUpdatedBy: session.user.id,
|
||||
lastShopifySyncError: null,
|
||||
} as unknown as Record<string, unknown>,
|
||||
select: {
|
||||
shopifyOrderId: true,
|
||||
@ -200,12 +364,43 @@ export async function PATCH(
|
||||
},
|
||||
})
|
||||
|
||||
const shopifySync = await syncOrderToShopify(
|
||||
order.storeId,
|
||||
orderId,
|
||||
fulfillmentStatus,
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
)
|
||||
|
||||
if (shopifySync.ok) {
|
||||
await prisma.cachedOrder.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
pendingVendorSync: false,
|
||||
lastShopifySyncAt: new Date(),
|
||||
lastShopifySyncError: null,
|
||||
} as unknown as Record<string, unknown>,
|
||||
})
|
||||
} else {
|
||||
await prisma.cachedOrder.update({
|
||||
where: { id: order.id },
|
||||
data: {
|
||||
pendingVendorSync: true,
|
||||
lastShopifySyncError: shopifySync.error || "Unknown sync error",
|
||||
} as unknown as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
order: {
|
||||
...updated,
|
||||
trackingNumber,
|
||||
trackingCompany,
|
||||
trackingUrl,
|
||||
pendingVendorSync: !shopifySync.ok,
|
||||
lastShopifySyncError: shopifySync.ok ? null : shopifySync.error || "Unknown sync error",
|
||||
fulfillmentStatusDisplay: toVendorDisplayStatus(updated.fulfillmentStatus),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { auth } from "@/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||
|
||||
/**
|
||||
* GET /api/supplier/orders
|
||||
@ -154,11 +155,13 @@ export async function GET() {
|
||||
total: Number.parseFloat(order.totalPrice || "0"),
|
||||
currency: order.currency,
|
||||
financialStatus: order.financialStatus,
|
||||
fulfillmentStatus: order.fulfillmentStatus,
|
||||
status: order.fulfillmentStatus || "UNFULFILLED",
|
||||
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||
status: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
|
||||
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
||||
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
||||
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
||||
pendingVendorSync: (order as unknown as { pendingVendorSync?: boolean }).pendingVendorSync ?? false,
|
||||
vendorName: order.store.businessName || order.store.shopifyStoreName,
|
||||
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||
store: order.store,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import { ArrowLeft, ShoppingBag, User, Calendar, DollarSign, Package, MapPin, Phone, Mail, CheckCircle2, Clock, Loader2, AlertCircle } from "lucide-react"
|
||||
import { ArrowLeft, ShoppingBag, User, Calendar, DollarSign, Package, MapPin, Phone, Mail, CheckCircle2, Clock, Loader2, AlertCircle, Truck } from "lucide-react"
|
||||
|
||||
interface LineItem {
|
||||
id: number
|
||||
@ -58,6 +58,11 @@ interface OrderDetails {
|
||||
line_items: LineItem[]
|
||||
note?: string
|
||||
tags?: string
|
||||
tracking_number?: string | null
|
||||
tracking_company?: string | null
|
||||
tracking_url?: string | null
|
||||
pending_vendor_sync?: boolean
|
||||
last_shopify_sync_error?: string | null
|
||||
}
|
||||
|
||||
export default function OrderDetailsPage() {
|
||||
@ -234,6 +239,35 @@ export default function OrderDetailsPage() {
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="bg-white/5 rounded-xl p-5 border border-white/10">
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<h3 className="text-white font-bold inline-flex items-center gap-2">
|
||||
<Truck className="w-4 h-4 text-indigo-400" />
|
||||
Fulfillment & Tracking
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div className="h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 text-sm flex items-center">
|
||||
{order.fulfillment_status || "unfulfilled"}
|
||||
</div>
|
||||
<div className="h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 text-sm flex items-center">
|
||||
{order.tracking_company || "Tracking platform not set"}
|
||||
</div>
|
||||
<div className="h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 text-sm flex items-center">
|
||||
{order.tracking_number || "Tracking number not set"}
|
||||
</div>
|
||||
<div className="h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 text-sm flex items-center truncate">
|
||||
{order.tracking_url || "Tracking URL not set"}
|
||||
</div>
|
||||
</div>
|
||||
{order.pending_vendor_sync && (
|
||||
<p className="text-xs text-yellow-300 mt-3">Supplier update is syncing to Shopify...</p>
|
||||
)}
|
||||
{order.last_shopify_sync_error && (
|
||||
<p className="text-xs text-red-300 mt-2">{order.last_shopify_sync_error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Customer & Shipping */}
|
||||
<div className="lg:col-span-1 space-y-6 min-w-0">
|
||||
|
||||
@ -32,6 +32,7 @@ interface ShopifyOrder {
|
||||
currency: string
|
||||
financial_status: string
|
||||
fulfillment_status: string | null
|
||||
pending_vendor_sync?: boolean
|
||||
customer: {
|
||||
first_name: string
|
||||
last_name: string
|
||||
@ -342,6 +343,11 @@ export default function OrdersPage() {
|
||||
{order.fulfillment_status === "fulfilled" ? <CheckCircle2 className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
|
||||
{order.fulfillment_status || "Unfulfilled"}
|
||||
</span>
|
||||
{order.pending_vendor_sync && (
|
||||
<span className="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider inline-flex items-center gap-1.5 bg-yellow-500/20 text-yellow-300 border border-yellow-500/30">
|
||||
Pending Sync
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -171,9 +171,9 @@ export default function SupplierOrderDetailsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6 items-stretch">
|
||||
<div className="xl:col-span-2 space-y-6">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 h-full">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-indigo-400" />
|
||||
Order Snapshot
|
||||
@ -195,7 +195,7 @@ export default function SupplierOrderDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 space-y-4">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 space-y-4 h-full">
|
||||
<h2 className="text-white font-bold inline-flex items-center gap-2">
|
||||
<Truck className="w-4 h-4 text-indigo-400" />
|
||||
Fulfillment Update
|
||||
@ -258,8 +258,8 @@ export default function SupplierOrderDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-stretch">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 h-full min-h-[190px]">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-indigo-400" />
|
||||
Customer
|
||||
@ -279,7 +279,7 @@ export default function SupplierOrderDetailsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 h-full min-h-[190px]">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||
Shipping Address
|
||||
@ -299,7 +299,7 @@ export default function SupplierOrderDetailsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5">
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-5 h-full min-h-[190px]">
|
||||
<h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-indigo-400" />
|
||||
Billing Address
|
||||
|
||||
@ -190,6 +190,21 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
|
||||
*/
|
||||
export async function updateOrderCache(storeId: string, orders: ShopifyOrderRecord[]) {
|
||||
try {
|
||||
const orderIds = orders.map((order) => order.id.toString())
|
||||
const pendingRows = await prisma.$queryRaw<Array<{ shopifyOrderId: string }>>`
|
||||
SELECT "shopifyOrderId"
|
||||
FROM "CachedOrder"
|
||||
WHERE "storeId" = ${storeId}
|
||||
AND "pendingVendorSync" = true
|
||||
AND "shopifyOrderId" = ANY(${orderIds}::text[])
|
||||
`
|
||||
const pendingIds = new Set(pendingRows.map((r) => r.shopifyOrderId))
|
||||
const existingRows = await prisma.cachedOrder.findMany({
|
||||
where: { storeId, shopifyOrderId: { in: orderIds } },
|
||||
select: { shopifyOrderId: true, fulfillmentStatus: true },
|
||||
})
|
||||
const existingById = new Map(existingRows.map((r) => [r.shopifyOrderId, r.fulfillmentStatus]))
|
||||
|
||||
// Use upsert for each order to handle duplicates gracefully
|
||||
// This ensures webhooks can update orders concurrently without conflicts
|
||||
const upsertPromises = orders.map((order) =>
|
||||
@ -219,7 +234,9 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
|
||||
totalPrice: order.total_price || "0",
|
||||
currency: order.currency || null,
|
||||
financialStatus: order.financial_status || null,
|
||||
fulfillmentStatus: order.fulfillment_status || null,
|
||||
fulfillmentStatus: pendingIds.has(order.id.toString())
|
||||
? existingById.get(order.id.toString()) || null
|
||||
: order.fulfillment_status || null,
|
||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||
@ -397,6 +414,15 @@ export async function updateSingleProduct(storeId: string, product: ShopifyProdu
|
||||
* Update a single order in cache (for webhook updates)
|
||||
*/
|
||||
export async function updateSingleOrder(storeId: string, order: ShopifyOrderRecord) {
|
||||
const pendingRows = await prisma.$queryRaw<Array<{ pendingVendorSync: boolean; fulfillmentStatus: string | null }>>`
|
||||
SELECT "pendingVendorSync", "fulfillmentStatus"
|
||||
FROM "CachedOrder"
|
||||
WHERE "storeId" = ${storeId} AND "shopifyOrderId" = ${order.id.toString()}
|
||||
LIMIT 1
|
||||
`
|
||||
const shouldPreserve = pendingRows[0]?.pendingVendorSync === true
|
||||
const preservedStatus = pendingRows[0]?.fulfillmentStatus ?? null
|
||||
|
||||
await prisma.cachedOrder.upsert({
|
||||
where: {
|
||||
shopifyOrderId_storeId: {
|
||||
@ -412,7 +438,7 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
|
||||
totalPrice: order.total_price || "0",
|
||||
currency: order.currency || null,
|
||||
financialStatus: order.financial_status || null,
|
||||
fulfillmentStatus: order.fulfillment_status || null,
|
||||
fulfillmentStatus: shouldPreserve ? preservedStatus : order.fulfillment_status || null,
|
||||
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
|
||||
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,
|
||||
|
||||
38
src/lib/orders/fulfillmentStatus.ts
Normal file
38
src/lib/orders/fulfillmentStatus.ts
Normal file
@ -0,0 +1,38 @@
|
||||
export type CanonicalFulfillmentStatus =
|
||||
| "UNFULFILLED"
|
||||
| "IN_PROGRESS"
|
||||
| "SHIPPED"
|
||||
| "FULFILLED"
|
||||
| "CANCELLED"
|
||||
|
||||
const SHOPIFY_FULFILLMENT_MAP: Record<CanonicalFulfillmentStatus, string> = {
|
||||
UNFULFILLED: "unfulfilled",
|
||||
IN_PROGRESS: "in_progress",
|
||||
SHIPPED: "shipped",
|
||||
FULFILLED: "fulfilled",
|
||||
CANCELLED: "cancelled",
|
||||
}
|
||||
|
||||
export function normalizeFulfillmentStatus(input: string | null | undefined): CanonicalFulfillmentStatus {
|
||||
const raw = String(input ?? "")
|
||||
.trim()
|
||||
.replace(/[\s-]+/g, "_")
|
||||
.toUpperCase()
|
||||
|
||||
if (raw === "FULFILLED") return "FULFILLED"
|
||||
if (raw === "SHIPPED") return "SHIPPED"
|
||||
if (raw === "IN_PROGRESS" || raw === "INPROGRESS" || raw === "PARTIAL" || raw === "PARTIALLY_FULFILLED") {
|
||||
return "IN_PROGRESS"
|
||||
}
|
||||
if (raw === "CANCELLED" || raw === "CANCELED") return "CANCELLED"
|
||||
return "UNFULFILLED"
|
||||
}
|
||||
|
||||
export function toVendorDisplayStatus(input: string | null | undefined): string {
|
||||
return SHOPIFY_FULFILLMENT_MAP[normalizeFulfillmentStatus(input)]
|
||||
}
|
||||
|
||||
export function isFulfilledLike(input: string | null | undefined): boolean {
|
||||
return normalizeFulfillmentStatus(input) === "FULFILLED"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user