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:
talatops 2026-03-31 21:44:33 +05:00
parent 164df26310
commit b67ef3190c
11 changed files with 399 additions and 22 deletions

View File

@ -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;

View File

@ -157,6 +157,11 @@ model CachedOrder {
trackingNumber String? trackingNumber String?
trackingCompany String? trackingCompany String?
trackingUrl String? trackingUrl String?
pendingVendorSync Boolean @default(false)
lastSupplierUpdateAt DateTime?
lastSupplierUpdatedBy String?
lastShopifySyncAt DateTime?
lastShopifySyncError String?
lineItems Json // Store line items as JSON lineItems Json // Store line items as JSON
customer Json? // Store customer info as JSON customer Json? // Store customer info as JSON
createdAt DateTime @default(now()) createdAt DateTime @default(now())

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { handleApiError } from "@/lib/errors" import { handleApiError } from "@/lib/errors"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
export async function GET( export async function GET(
request: Request, request: Request,
@ -69,7 +70,18 @@ export async function GET(
storeId: store.id, 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) { if (cached) {
@ -96,6 +108,16 @@ export async function GET(
order.customer = { ...cust, email: cachedEmail } 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 }) return NextResponse.json({ order })

View File

@ -2,6 +2,7 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { isCacheFresh, getCachedOrders, updateOrderCache } from "@/lib/cache" import { isCacheFresh, getCachedOrders, updateOrderCache } from "@/lib/cache"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
export async function GET() { export async function GET() {
try { try {
@ -40,9 +41,17 @@ export async function GET() {
total_price: order.totalPrice, total_price: order.totalPrice,
currency: order.currency, currency: order.currency,
financial_status: order.financialStatus, financial_status: order.financialStatus,
fulfillment_status: order.fulfillmentStatus, fulfillment_status: toVendorDisplayStatus(order.fulfillmentStatus),
fulfillment_status_canonical: normalizeFulfillmentStatus(order.fulfillmentStatus),
customer: order.customer, 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({ return NextResponse.json({
orders: transformedOrders, 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) { } catch (fetchError: unknown) {
clearTimeout(timeoutId) clearTimeout(timeoutId)
if (fetchError instanceof Error && fetchError.name === "AbortError") { if (fetchError instanceof Error && fetchError.name === "AbortError") {

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
async function getSupplierContext(userId: string) { async function getSupplierContext(userId: string) {
const profile = await prisma.supplierProfile.findUnique({ 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( export async function GET(
_req: Request, _req: Request,
{ params }: { params: Promise<{ orderId: string }> }, { params }: { params: Promise<{ orderId: string }> },
@ -132,7 +295,8 @@ export async function GET(
totalPrice: order.totalPrice, totalPrice: order.totalPrice,
currency: order.currency, currency: order.currency,
financialStatus: order.financialStatus, financialStatus: order.financialStatus,
fulfillmentStatus: order.fulfillmentStatus || "UNFULFILLED", fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber, trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany, trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl, trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
@ -162,11 +326,7 @@ export async function PATCH(
const { orderId } = await params const { orderId } = await params
const body = await req.json() const body = await req.json()
const allowedStatuses = new Set(["UNFULFILLED", "IN_PROGRESS", "SHIPPED", "FULFILLED", "CANCELLED"]) const fulfillmentStatus = normalizeFulfillmentStatus(body.fulfillmentStatus)
const fulfillmentStatus = String(body.fulfillmentStatus || "").toUpperCase()
if (!allowedStatuses.has(fulfillmentStatus)) {
return NextResponse.json({ message: "Invalid fulfillment status" }, { status: 400 })
}
const trackingNumber = String(body.trackingNumber || "").trim() || null const trackingNumber = String(body.trackingNumber || "").trim() || null
const trackingCompany = String(body.trackingCompany || "").trim() || null const trackingCompany = String(body.trackingCompany || "").trim() || null
@ -193,6 +353,10 @@ export async function PATCH(
trackingNumber, trackingNumber,
trackingCompany, trackingCompany,
trackingUrl, trackingUrl,
pendingVendorSync: true,
lastSupplierUpdateAt: new Date(),
lastSupplierUpdatedBy: session.user.id,
lastShopifySyncError: null,
} as unknown as Record<string, unknown>, } as unknown as Record<string, unknown>,
select: { select: {
shopifyOrderId: true, 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({ return NextResponse.json({
order: { order: {
...updated, ...updated,
trackingNumber, trackingNumber,
trackingCompany, trackingCompany,
trackingUrl, trackingUrl,
pendingVendorSync: !shopifySync.ok,
lastShopifySyncError: shopifySync.ok ? null : shopifySync.error || "Unknown sync error",
fulfillmentStatusDisplay: toVendorDisplayStatus(updated.fulfillmentStatus),
}, },
}) })
} catch (error) { } catch (error) {

View File

@ -1,6 +1,7 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import { auth } from "@/auth" import { auth } from "@/auth"
import { prisma } from "@/lib/prisma" import { prisma } from "@/lib/prisma"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
/** /**
* GET /api/supplier/orders * GET /api/supplier/orders
@ -154,11 +155,13 @@ export async function GET() {
total: Number.parseFloat(order.totalPrice || "0"), total: Number.parseFloat(order.totalPrice || "0"),
currency: order.currency, currency: order.currency,
financialStatus: order.financialStatus, financialStatus: order.financialStatus,
fulfillmentStatus: order.fulfillmentStatus, fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
status: order.fulfillmentStatus || "UNFULFILLED", status: normalizeFulfillmentStatus(order.fulfillmentStatus),
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber, trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany, trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl, 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, vendorName: order.store.businessName || order.store.shopifyStoreName,
createdAt: order.shopifyCreatedAt || order.createdAt, createdAt: order.shopifyCreatedAt || order.createdAt,
store: order.store, store: order.store,

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { useRouter, useParams } from "next/navigation" 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 { interface LineItem {
id: number id: number
@ -58,6 +58,11 @@ interface OrderDetails {
line_items: LineItem[] line_items: LineItem[]
note?: string note?: string
tags?: 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() { export default function OrderDetailsPage() {
@ -234,6 +239,35 @@ export default function OrderDetailsPage() {
</div> </div>
{/* Main Content Grid */} {/* 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"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Customer & Shipping */} {/* Left Column - Customer & Shipping */}
<div className="lg:col-span-1 space-y-6 min-w-0"> <div className="lg:col-span-1 space-y-6 min-w-0">

View File

@ -32,6 +32,7 @@ interface ShopifyOrder {
currency: string currency: string
financial_status: string financial_status: string
fulfillment_status: string | null fulfillment_status: string | null
pending_vendor_sync?: boolean
customer: { customer: {
first_name: string first_name: string
last_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 === "fulfilled" ? <CheckCircle2 className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{order.fulfillment_status || "Unfulfilled"} {order.fulfillment_status || "Unfulfilled"}
</span> </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> </div>
</div> </div>

View File

@ -171,9 +171,9 @@ export default function SupplierOrderDetailsPage() {
</button> </button>
</div> </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="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"> <h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
<Package className="w-4 h-4 text-indigo-400" /> <Package className="w-4 h-4 text-indigo-400" />
Order Snapshot Order Snapshot
@ -195,7 +195,7 @@ export default function SupplierOrderDetailsPage() {
</div> </div>
</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"> <h2 className="text-white font-bold inline-flex items-center gap-2">
<Truck className="w-4 h-4 text-indigo-400" /> <Truck className="w-4 h-4 text-indigo-400" />
Fulfillment Update Fulfillment Update
@ -258,8 +258,8 @@ export default function SupplierOrderDetailsPage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <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"> <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"> <h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
<User className="w-4 h-4 text-indigo-400" /> <User className="w-4 h-4 text-indigo-400" />
Customer Customer
@ -279,7 +279,7 @@ export default function SupplierOrderDetailsPage() {
</div> </div>
</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"> <h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
<MapPin className="w-4 h-4 text-indigo-400" /> <MapPin className="w-4 h-4 text-indigo-400" />
Shipping Address Shipping Address
@ -299,7 +299,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"> <h2 className="text-white font-bold mb-4 inline-flex items-center gap-2">
<MapPin className="w-4 h-4 text-indigo-400" /> <MapPin className="w-4 h-4 text-indigo-400" />
Billing Address Billing Address

View File

@ -190,6 +190,21 @@ export async function updateProductCache(storeId: string, products: ShopifyProdu
*/ */
export async function updateOrderCache(storeId: string, orders: ShopifyOrderRecord[]) { export async function updateOrderCache(storeId: string, orders: ShopifyOrderRecord[]) {
try { 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 // Use upsert for each order to handle duplicates gracefully
// This ensures webhooks can update orders concurrently without conflicts // This ensures webhooks can update orders concurrently without conflicts
const upsertPromises = orders.map((order) => const upsertPromises = orders.map((order) =>
@ -219,7 +234,9 @@ export async function updateOrderCache(storeId: string, orders: ShopifyOrderReco
totalPrice: order.total_price || "0", totalPrice: order.total_price || "0",
currency: order.currency || null, currency: order.currency || null,
financialStatus: order.financial_status || 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, lineItems: (order.line_items || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined, customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null, 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) * Update a single order in cache (for webhook updates)
*/ */
export async function updateSingleOrder(storeId: string, order: ShopifyOrderRecord) { 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({ await prisma.cachedOrder.upsert({
where: { where: {
shopifyOrderId_storeId: { shopifyOrderId_storeId: {
@ -412,7 +438,7 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
totalPrice: order.total_price || "0", totalPrice: order.total_price || "0",
currency: order.currency || null, currency: order.currency || null,
financialStatus: order.financial_status || 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, lineItems: (order.line_items || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined, customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null, shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,

View 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"
}