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?
|
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())
|
||||||
|
|||||||
@ -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 })
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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