feat(supplier): on-hold reasons, Shopify fulfillment holds, tag + metafield
- Add CachedOrder holdReasonCode/holdNote and ON_HOLD fulfillment status - Supplier UI: modal for hold reasons; vendor dashboard shows hold details - Sync to Shopify: native fulfillmentOrderHold (GraphQL) plus JUNO_ON_HOLD tag and juno.supplier_hold metafield; release holds when clearing - Preserve pending supplier fulfillment on webhook cache updates - Docker volume for gmail-reply-logos; settings note for API scopes Made-with: Cursor
This commit is contained in:
parent
f3cedae713
commit
6d45f6ec66
@ -36,6 +36,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/uploads/supplier-products:/app/public/uploads/supplier-products
|
- ./public/uploads/supplier-products:/app/public/uploads/supplier-products
|
||||||
|
- ./public/uploads/gmail-reply-logos:/app/public/uploads/gmail-reply-logos
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CachedOrder" ADD COLUMN IF NOT EXISTS "holdReasonCode" TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS "holdNote" TEXT;
|
||||||
@ -189,6 +189,9 @@ model CachedOrder {
|
|||||||
currency String?
|
currency String?
|
||||||
financialStatus String?
|
financialStatus String?
|
||||||
fulfillmentStatus String?
|
fulfillmentStatus String?
|
||||||
|
/** When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES). */
|
||||||
|
holdReasonCode String?
|
||||||
|
holdNote String? @db.Text
|
||||||
trackingNumber String?
|
trackingNumber String?
|
||||||
trackingCompany String?
|
trackingCompany String?
|
||||||
trackingUrl String?
|
trackingUrl String?
|
||||||
|
|||||||
@ -3,6 +3,10 @@ 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"
|
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
import {
|
||||||
|
SUPPLIER_HOLD_REASON_LABELS,
|
||||||
|
isSupplierHoldReasonCode,
|
||||||
|
} from "@/lib/orders/supplierHoldReasons"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
@ -74,6 +78,8 @@ export async function GET(
|
|||||||
email: true,
|
email: true,
|
||||||
customer: true,
|
customer: true,
|
||||||
fulfillmentStatus: true,
|
fulfillmentStatus: true,
|
||||||
|
holdReasonCode: true,
|
||||||
|
holdNote: true,
|
||||||
trackingNumber: true,
|
trackingNumber: true,
|
||||||
trackingCompany: true,
|
trackingCompany: true,
|
||||||
trackingUrl: true,
|
trackingUrl: true,
|
||||||
@ -118,6 +124,12 @@ export async function GET(
|
|||||||
order.last_supplier_update_at = cached.lastSupplierUpdateAt?.toISOString() || null
|
order.last_supplier_update_at = cached.lastSupplierUpdateAt?.toISOString() || null
|
||||||
order.last_shopify_sync_at = cached.lastShopifySyncAt?.toISOString() || null
|
order.last_shopify_sync_at = cached.lastShopifySyncAt?.toISOString() || null
|
||||||
order.last_shopify_sync_error = cached.lastShopifySyncError || null
|
order.last_shopify_sync_error = cached.lastShopifySyncError || null
|
||||||
|
order.hold_reason_code = cached.holdReasonCode || null
|
||||||
|
order.hold_note = cached.holdNote || null
|
||||||
|
order.hold_reason_label =
|
||||||
|
cached.holdReasonCode && isSupplierHoldReasonCode(cached.holdReasonCode)
|
||||||
|
? SUPPLIER_HOLD_REASON_LABELS[cached.holdReasonCode]
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ order })
|
return NextResponse.json({ order })
|
||||||
|
|||||||
@ -2,7 +2,12 @@ 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"
|
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
import {
|
||||||
|
SUPPLIER_HOLD_REASON_LABELS,
|
||||||
|
isSupplierHoldReasonCode,
|
||||||
|
} from "@/lib/orders/supplierHoldReasons"
|
||||||
import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas"
|
import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas"
|
||||||
|
import { runSupplierOrderShopifySync } from "@/lib/shopify/supplierOrderShopifySync"
|
||||||
|
|
||||||
async function getSupplierContext(userId: string) {
|
async function getSupplierContext(userId: string) {
|
||||||
const profile = await prisma.supplierProfile.findUnique({
|
const profile = await prisma.supplierProfile.findUnique({
|
||||||
@ -54,242 +59,6 @@ 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 updateTrackingOnExistingFulfillment = async (): Promise<{ ok: boolean; error?: string }> => {
|
|
||||||
const listRes = await fetch(
|
|
||||||
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!listRes.ok) {
|
|
||||||
const details = await listRes.text().catch(() => "Unknown Shopify response")
|
|
||||||
return { ok: false, error: `Read existing fulfillments failed: ${details}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const listPayload = (await listRes.json()) as {
|
|
||||||
fulfillments?: Array<{ id: number; created_at?: string }>
|
|
||||||
}
|
|
||||||
const fulfillments = listPayload.fulfillments || []
|
|
||||||
if (fulfillments.length === 0) {
|
|
||||||
return { ok: false, error: "No existing fulfillment found to update tracking." }
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...fulfillments].sort((a, b) => {
|
|
||||||
const aTs = a.created_at ? new Date(a.created_at).getTime() : 0
|
|
||||||
const bTs = b.created_at ? new Date(b.created_at).getTime() : 0
|
|
||||||
return bTs - aTs
|
|
||||||
})
|
|
||||||
const latest = sorted[0]
|
|
||||||
|
|
||||||
const updateRes = await fetch(
|
|
||||||
`https://${cleanDomain}/admin/api/2024-01/fulfillments/${latest.id}/update_tracking.json`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
fulfillment: {
|
|
||||||
notify_customer: false,
|
|
||||||
tracking_info: {
|
|
||||||
number: trackingNumber,
|
|
||||||
company: trackingCompany,
|
|
||||||
url: trackingUrl || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (!updateRes.ok) {
|
|
||||||
const details = await updateRes.text().catch(() => "Unknown Shopify response")
|
|
||||||
return { ok: false, error: `Update tracking on existing fulfillment failed: ${details}` }
|
|
||||||
}
|
|
||||||
return { ok: true }
|
|
||||||
}
|
|
||||||
|
|
||||||
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; status?: string; request_status?: string }>
|
|
||||||
}
|
|
||||||
const allFulfillmentOrders = foJson.fulfillment_orders || []
|
|
||||||
const fulfillmentOrders = allFulfillmentOrders.filter((fo) => {
|
|
||||||
const status = String(fo.status || "").toLowerCase()
|
|
||||||
// Only try fulfillment orders that are still actionable.
|
|
||||||
return status && status !== "closed" && status !== "cancelled" && status !== "incomplete"
|
|
||||||
})
|
|
||||||
if (fulfillmentOrders.length === 0) {
|
|
||||||
const closedCount = allFulfillmentOrders.filter((fo) => String(fo.status || "").toLowerCase() === "closed").length
|
|
||||||
if (closedCount > 0) {
|
|
||||||
// Closed fulfillment orders are usually already fulfilled: update tracking on existing fulfillment.
|
|
||||||
const updated = await updateTrackingOnExistingFulfillment()
|
|
||||||
if (updated.ok) return updated
|
|
||||||
return { ok: false, error: `All fulfillment orders are closed. ${updated.error}` }
|
|
||||||
}
|
|
||||||
return { ok: false, error: "No actionable 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 updated = await updateTrackingOnExistingFulfillment()
|
|
||||||
if (updated.ok) return updated
|
|
||||||
const fallback = await createLegacyFulfillmentFallback()
|
|
||||||
if (fallback.ok) return fallback
|
|
||||||
const scopes = await fetchScopes()
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: `Shopify fulfillment sync failed: ${details}. Existing fulfillment tracking update failed: ${updated.error}. 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 }> },
|
||||||
@ -372,9 +141,15 @@ export async function GET(
|
|||||||
financialStatus: order.financialStatus,
|
financialStatus: order.financialStatus,
|
||||||
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||||
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
|
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
|
||||||
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
trackingNumber: order.trackingNumber,
|
||||||
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
trackingCompany: order.trackingCompany,
|
||||||
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
trackingUrl: order.trackingUrl,
|
||||||
|
holdReasonCode: order.holdReasonCode,
|
||||||
|
holdNote: order.holdNote,
|
||||||
|
holdReasonLabel:
|
||||||
|
order.holdReasonCode && isSupplierHoldReasonCode(order.holdReasonCode)
|
||||||
|
? SUPPLIER_HOLD_REASON_LABELS[order.holdReasonCode]
|
||||||
|
: null,
|
||||||
createdAt: order.shopifyCreatedAt || order.createdAt,
|
createdAt: order.shopifyCreatedAt || order.createdAt,
|
||||||
lineItems,
|
lineItems,
|
||||||
customer,
|
customer,
|
||||||
@ -404,7 +179,14 @@ export async function PATCH(
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return validationErrorResponse(parsed.error)
|
return validationErrorResponse(parsed.error)
|
||||||
}
|
}
|
||||||
const { fulfillmentStatus: fs, trackingNumber, trackingCompany, trackingUrl } = parsed.data
|
const {
|
||||||
|
fulfillmentStatus: fs,
|
||||||
|
trackingNumber: trIn,
|
||||||
|
trackingCompany: tcIn,
|
||||||
|
trackingUrl: tuIn,
|
||||||
|
holdReasonCode: hrIn,
|
||||||
|
holdNote: hnIn,
|
||||||
|
} = parsed.data
|
||||||
|
|
||||||
const fulfillmentStatus = normalizeFulfillmentStatus(
|
const fulfillmentStatus = normalizeFulfillmentStatus(
|
||||||
fs === undefined || fs === null ? undefined : String(fs),
|
fs === undefined || fs === null ? undefined : String(fs),
|
||||||
@ -417,17 +199,41 @@ export async function PATCH(
|
|||||||
|
|
||||||
const order = await prisma.cachedOrder.findFirst({
|
const order = await prisma.cachedOrder.findFirst({
|
||||||
where: { shopifyOrderId: orderId },
|
where: { shopifyOrderId: orderId },
|
||||||
select: { id: true, storeId: true, lineItems: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
storeId: true,
|
||||||
|
lineItems: true,
|
||||||
|
fulfillmentStatus: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (!order) return NextResponse.json({ message: "Order not found" }, { status: 404 })
|
if (!order) return NextResponse.json({ message: "Order not found" }, { status: 404 })
|
||||||
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
|
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
|
||||||
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
|
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousCanonical = normalizeFulfillmentStatus(order.fulfillmentStatus)
|
||||||
|
|
||||||
|
let trackingNumber = trIn
|
||||||
|
let trackingCompany = tcIn
|
||||||
|
let trackingUrl = tuIn
|
||||||
|
let holdReasonCode: string | null = hrIn ?? null
|
||||||
|
let holdNote: string | null = hnIn ?? null
|
||||||
|
|
||||||
|
if (fulfillmentStatus === "ON_HOLD") {
|
||||||
|
trackingNumber = null
|
||||||
|
trackingCompany = null
|
||||||
|
trackingUrl = null
|
||||||
|
} else {
|
||||||
|
holdReasonCode = null
|
||||||
|
holdNote = null
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await prisma.cachedOrder.update({
|
const updated = await prisma.cachedOrder.update({
|
||||||
where: { id: order.id },
|
where: { id: order.id },
|
||||||
data: {
|
data: {
|
||||||
fulfillmentStatus,
|
fulfillmentStatus,
|
||||||
|
holdReasonCode,
|
||||||
|
holdNote,
|
||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingCompany,
|
trackingCompany,
|
||||||
trackingUrl,
|
trackingUrl,
|
||||||
@ -435,21 +241,29 @@ export async function PATCH(
|
|||||||
lastSupplierUpdateAt: new Date(),
|
lastSupplierUpdateAt: new Date(),
|
||||||
lastSupplierUpdatedBy: session.user.id,
|
lastSupplierUpdatedBy: session.user.id,
|
||||||
lastShopifySyncError: null,
|
lastShopifySyncError: null,
|
||||||
} as unknown as Record<string, unknown>,
|
},
|
||||||
select: {
|
select: {
|
||||||
shopifyOrderId: true,
|
shopifyOrderId: true,
|
||||||
fulfillmentStatus: true,
|
fulfillmentStatus: true,
|
||||||
|
holdReasonCode: true,
|
||||||
|
holdNote: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const shopifySync = await syncOrderToShopify(
|
const shopifySync = await runSupplierOrderShopifySync({
|
||||||
order.storeId,
|
storeId: order.storeId,
|
||||||
orderId,
|
shopifyOrderId: orderId,
|
||||||
fulfillmentStatus,
|
previousCanonical,
|
||||||
|
newCanonical: fulfillmentStatus,
|
||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingCompany,
|
trackingCompany,
|
||||||
trackingUrl,
|
trackingUrl,
|
||||||
)
|
holdCode:
|
||||||
|
fulfillmentStatus === "ON_HOLD" && holdReasonCode && isSupplierHoldReasonCode(holdReasonCode)
|
||||||
|
? holdReasonCode
|
||||||
|
: null,
|
||||||
|
holdNote,
|
||||||
|
})
|
||||||
|
|
||||||
if (shopifySync.ok) {
|
if (shopifySync.ok) {
|
||||||
await prisma.cachedOrder.update({
|
await prisma.cachedOrder.update({
|
||||||
@ -458,7 +272,7 @@ export async function PATCH(
|
|||||||
pendingVendorSync: false,
|
pendingVendorSync: false,
|
||||||
lastShopifySyncAt: new Date(),
|
lastShopifySyncAt: new Date(),
|
||||||
lastShopifySyncError: null,
|
lastShopifySyncError: null,
|
||||||
} as unknown as Record<string, unknown>,
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await prisma.cachedOrder.update({
|
await prisma.cachedOrder.update({
|
||||||
@ -466,7 +280,7 @@ export async function PATCH(
|
|||||||
data: {
|
data: {
|
||||||
pendingVendorSync: true,
|
pendingVendorSync: true,
|
||||||
lastShopifySyncError: shopifySync.error || "Unknown sync error",
|
lastShopifySyncError: shopifySync.error || "Unknown sync error",
|
||||||
} as unknown as Record<string, unknown>,
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -476,6 +290,10 @@ export async function PATCH(
|
|||||||
trackingNumber,
|
trackingNumber,
|
||||||
trackingCompany,
|
trackingCompany,
|
||||||
trackingUrl,
|
trackingUrl,
|
||||||
|
holdReasonLabel:
|
||||||
|
holdReasonCode && isSupplierHoldReasonCode(holdReasonCode)
|
||||||
|
? SUPPLIER_HOLD_REASON_LABELS[holdReasonCode]
|
||||||
|
: null,
|
||||||
pendingVendorSync: !shopifySync.ok,
|
pendingVendorSync: !shopifySync.ok,
|
||||||
lastShopifySyncError: shopifySync.ok ? null : shopifySync.error || "Unknown sync error",
|
lastShopifySyncError: shopifySync.ok ? null : shopifySync.error || "Unknown sync error",
|
||||||
fulfillmentStatusDisplay: toVendorDisplayStatus(updated.fulfillmentStatus),
|
fulfillmentStatusDisplay: toVendorDisplayStatus(updated.fulfillmentStatus),
|
||||||
|
|||||||
@ -2,6 +2,10 @@ 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"
|
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
import {
|
||||||
|
SUPPLIER_HOLD_REASON_LABELS,
|
||||||
|
isSupplierHoldReasonCode,
|
||||||
|
} from "@/lib/orders/supplierHoldReasons"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/supplier/orders
|
* GET /api/supplier/orders
|
||||||
@ -158,10 +162,16 @@ export async function GET() {
|
|||||||
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||||
status: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
status: normalizeFulfillmentStatus(order.fulfillmentStatus),
|
||||||
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
|
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
|
||||||
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
|
trackingNumber: order.trackingNumber,
|
||||||
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
|
trackingCompany: order.trackingCompany,
|
||||||
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
|
trackingUrl: order.trackingUrl,
|
||||||
pendingVendorSync: (order as unknown as { pendingVendorSync?: boolean }).pendingVendorSync ?? false,
|
pendingVendorSync: order.pendingVendorSync,
|
||||||
|
holdReasonCode: order.holdReasonCode,
|
||||||
|
holdNote: order.holdNote,
|
||||||
|
holdReasonLabel:
|
||||||
|
order.holdReasonCode && isSupplierHoldReasonCode(order.holdReasonCode)
|
||||||
|
? SUPPLIER_HOLD_REASON_LABELS[order.holdReasonCode]
|
||||||
|
: null,
|
||||||
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,
|
||||||
|
|||||||
@ -63,6 +63,9 @@ interface OrderDetails {
|
|||||||
tracking_url?: string | null
|
tracking_url?: string | null
|
||||||
pending_vendor_sync?: boolean
|
pending_vendor_sync?: boolean
|
||||||
last_shopify_sync_error?: string | null
|
last_shopify_sync_error?: string | null
|
||||||
|
hold_reason_code?: string | null
|
||||||
|
hold_note?: string | null
|
||||||
|
hold_reason_label?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OrderDetailsPage() {
|
export default function OrderDetailsPage() {
|
||||||
@ -224,7 +227,9 @@ export default function OrderDetailsPage() {
|
|||||||
className={`px-3 py-1.5 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5 whitespace-nowrap ${
|
className={`px-3 py-1.5 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5 whitespace-nowrap ${
|
||||||
order.fulfillment_status === "fulfilled"
|
order.fulfillment_status === "fulfilled"
|
||||||
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
|
||||||
: "bg-blue-500/20 text-blue-400 border border-blue-500/30"
|
: order.fulfillment_status === "on_hold"
|
||||||
|
? "bg-amber-500/20 text-amber-300 border border-amber-500/35"
|
||||||
|
: "bg-blue-500/20 text-blue-400 border border-blue-500/30"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{order.fulfillment_status === "fulfilled" ? (
|
{order.fulfillment_status === "fulfilled" ? (
|
||||||
@ -232,7 +237,9 @@ export default function OrderDetailsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Clock className="w-3 h-3" />
|
<Clock className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
{order.fulfillment_status || "Unfulfilled"}
|
{order.fulfillment_status === "on_hold"
|
||||||
|
? "On hold"
|
||||||
|
: order.fulfillment_status || "Unfulfilled"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -266,6 +273,22 @@ export default function OrderDetailsPage() {
|
|||||||
{order.last_shopify_sync_error && (
|
{order.last_shopify_sync_error && (
|
||||||
<p className="text-xs text-red-300 mt-2">{order.last_shopify_sync_error}</p>
|
<p className="text-xs text-red-300 mt-2">{order.last_shopify_sync_error}</p>
|
||||||
)}
|
)}
|
||||||
|
{(order.fulfillment_status === "on_hold" || order.hold_reason_label) && (
|
||||||
|
<div className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-2">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-wider text-amber-200">
|
||||||
|
Supplier hold
|
||||||
|
</p>
|
||||||
|
{order.hold_reason_label ? (
|
||||||
|
<p className="text-sm text-white font-semibold">{order.hold_reason_label}</p>
|
||||||
|
) : null}
|
||||||
|
{order.hold_note?.trim() ? (
|
||||||
|
<p className="text-sm text-amber-100/90 whitespace-pre-wrap">{order.hold_note}</p>
|
||||||
|
) : null}
|
||||||
|
{!order.hold_reason_label && !order.hold_note?.trim() ? (
|
||||||
|
<p className="text-sm text-slate-400">Reason details not available.</p>
|
||||||
|
) : null}
|
||||||
|
</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">
|
||||||
|
|||||||
@ -354,7 +354,16 @@ export default function AccountSettingsPage() {
|
|||||||
Reinstalled your app in Shopify or added API permissions? Shopify issues a
|
Reinstalled your app in Shopify or added API permissions? Shopify issues a
|
||||||
new <strong className="text-slate-300">Admin API access token</strong>.
|
new <strong className="text-slate-300">Admin API access token</strong>.
|
||||||
Paste it here—your store domain stays the same; we verify the token with
|
Paste it here—your store domain stays the same; we verify the token with
|
||||||
Shopify before saving.
|
Shopify before saving. Supplier fulfillment sync needs fulfillment scopes;
|
||||||
|
<strong className="text-slate-300"> write_orders </strong>
|
||||||
|
is required for order tags and the hold metafield. Native{" "}
|
||||||
|
<strong className="text-slate-300">On hold</strong> in Shopify admin also
|
||||||
|
needs{" "}
|
||||||
|
<strong className="text-slate-300">
|
||||||
|
write_merchant_managed_fulfillment_orders
|
||||||
|
</strong>{" "}
|
||||||
|
(or <strong className="text-slate-300">write_third_party_fulfillment_orders</strong>{" "}
|
||||||
|
if applicable).
|
||||||
</p>
|
</p>
|
||||||
{tokenSuccess ? (
|
{tokenSuccess ? (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-400">
|
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-400">
|
||||||
|
|||||||
@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin } from "lucide-react"
|
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin, X } from "lucide-react"
|
||||||
import toast from "react-hot-toast"
|
import toast from "react-hot-toast"
|
||||||
|
import {
|
||||||
|
SUPPLIER_HOLD_REASON_OPTIONS,
|
||||||
|
type SupplierHoldReasonCode,
|
||||||
|
} from "@/lib/orders/supplierHoldReasons"
|
||||||
|
|
||||||
const SHOPIFY_TRACKING_COMPANIES = [
|
const SHOPIFY_TRACKING_COMPANIES = [
|
||||||
"4PX",
|
"4PX",
|
||||||
@ -50,6 +54,9 @@ type SupplierOrder = {
|
|||||||
shippingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
shippingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
||||||
billingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
billingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
|
||||||
store: { id: string; businessName: string; shopifyStoreName: string }
|
store: { id: string; businessName: string; shopifyStoreName: string }
|
||||||
|
holdReasonCode?: string | null
|
||||||
|
holdNote?: string | null
|
||||||
|
holdReasonLabel?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SupplierOrderDetailsPage() {
|
export default function SupplierOrderDetailsPage() {
|
||||||
@ -66,6 +73,12 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
const [trackingNumber, setTrackingNumber] = useState("")
|
const [trackingNumber, setTrackingNumber] = useState("")
|
||||||
const [trackingCompany, setTrackingCompany] = useState("")
|
const [trackingCompany, setTrackingCompany] = useState("")
|
||||||
const [trackingUrl, setTrackingUrl] = useState("")
|
const [trackingUrl, setTrackingUrl] = useState("")
|
||||||
|
const [holdReasonCode, setHoldReasonCode] = useState<SupplierHoldReasonCode | "">("")
|
||||||
|
const [holdNote, setHoldNote] = useState("")
|
||||||
|
const [holdModalOpen, setHoldModalOpen] = useState(false)
|
||||||
|
const [statusBeforeHoldModal, setStatusBeforeHoldModal] = useState<string | null>(null)
|
||||||
|
const [modalReason, setModalReason] = useState<SupplierHoldReasonCode | "">("")
|
||||||
|
const [modalNote, setModalNote] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderId) return
|
if (!orderId) return
|
||||||
@ -82,6 +95,17 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
setTrackingNumber(o.trackingNumber || "")
|
setTrackingNumber(o.trackingNumber || "")
|
||||||
setTrackingCompany(o.trackingCompany || "")
|
setTrackingCompany(o.trackingCompany || "")
|
||||||
setTrackingUrl(o.trackingUrl || "")
|
setTrackingUrl(o.trackingUrl || "")
|
||||||
|
const hc = o.holdReasonCode
|
||||||
|
setHoldReasonCode(
|
||||||
|
hc === "inventory_out_of_stock" ||
|
||||||
|
hc === "address_incorrect" ||
|
||||||
|
hc === "high_risk_fraud" ||
|
||||||
|
hc === "awaiting_payment" ||
|
||||||
|
hc === "other"
|
||||||
|
? hc
|
||||||
|
: "",
|
||||||
|
)
|
||||||
|
setHoldNote(o.holdNote || "")
|
||||||
} catch {
|
} catch {
|
||||||
setError("Failed to load order details.")
|
setError("Failed to load order details.")
|
||||||
} finally {
|
} finally {
|
||||||
@ -93,16 +117,73 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
|
|
||||||
const changed = useMemo(() => {
|
const changed = useMemo(() => {
|
||||||
if (!order) return false
|
if (!order) return false
|
||||||
|
const origHold =
|
||||||
|
order.holdReasonCode === "inventory_out_of_stock" ||
|
||||||
|
order.holdReasonCode === "address_incorrect" ||
|
||||||
|
order.holdReasonCode === "high_risk_fraud" ||
|
||||||
|
order.holdReasonCode === "awaiting_payment" ||
|
||||||
|
order.holdReasonCode === "other"
|
||||||
|
? order.holdReasonCode
|
||||||
|
: ""
|
||||||
return (
|
return (
|
||||||
fulfillmentStatus !== (order.fulfillmentStatus || "UNFULFILLED") ||
|
fulfillmentStatus !== (order.fulfillmentStatus || "UNFULFILLED") ||
|
||||||
trackingNumber !== (order.trackingNumber || "") ||
|
trackingNumber !== (order.trackingNumber || "") ||
|
||||||
trackingCompany !== (order.trackingCompany || "") ||
|
trackingCompany !== (order.trackingCompany || "") ||
|
||||||
trackingUrl !== (order.trackingUrl || "")
|
trackingUrl !== (order.trackingUrl || "") ||
|
||||||
|
holdReasonCode !== origHold ||
|
||||||
|
holdNote !== (order.holdNote || "")
|
||||||
)
|
)
|
||||||
}, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl])
|
}, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl, holdReasonCode, holdNote])
|
||||||
|
|
||||||
|
const trackingLocked = fulfillmentStatus === "ON_HOLD"
|
||||||
|
const holdIncomplete = fulfillmentStatus === "ON_HOLD" && !holdReasonCode
|
||||||
|
const canSave = changed && !holdIncomplete && !holdModalOpen
|
||||||
|
|
||||||
|
const openHoldModal = (revertTo: string) => {
|
||||||
|
setStatusBeforeHoldModal(revertTo)
|
||||||
|
setModalReason(holdReasonCode || "")
|
||||||
|
setModalNote(holdReasonCode === "other" ? holdNote : "")
|
||||||
|
setHoldModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmHoldModal = () => {
|
||||||
|
if (!modalReason) {
|
||||||
|
toast.error("Select a hold reason")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (modalReason === "other" && !modalNote.trim()) {
|
||||||
|
toast.error("Enter a note for Other")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setHoldReasonCode(modalReason)
|
||||||
|
setHoldNote(modalReason === "other" ? modalNote.trim() : "")
|
||||||
|
setFulfillmentStatus("ON_HOLD")
|
||||||
|
setHoldModalOpen(false)
|
||||||
|
setStatusBeforeHoldModal(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelHoldModal = () => {
|
||||||
|
setHoldModalOpen(false)
|
||||||
|
if (statusBeforeHoldModal !== null) {
|
||||||
|
setFulfillmentStatus(statusBeforeHoldModal)
|
||||||
|
setStatusBeforeHoldModal(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStatusChange = (value: string) => {
|
||||||
|
if (value === "ON_HOLD") {
|
||||||
|
const prev = fulfillmentStatus
|
||||||
|
setFulfillmentStatus("ON_HOLD")
|
||||||
|
openHoldModal(prev)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setFulfillmentStatus(value)
|
||||||
|
setHoldReasonCode("")
|
||||||
|
setHoldNote("")
|
||||||
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!order) return
|
if (!order || holdIncomplete) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/supplier/orders/${order.id}`, {
|
const res = await fetch(`/api/supplier/orders/${order.id}`, {
|
||||||
@ -110,22 +191,42 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fulfillmentStatus,
|
fulfillmentStatus,
|
||||||
trackingNumber,
|
trackingNumber: trackingLocked ? null : trackingNumber || null,
|
||||||
trackingCompany,
|
trackingCompany: trackingLocked ? null : trackingCompany || null,
|
||||||
trackingUrl,
|
trackingUrl: trackingLocked ? null : trackingUrl || null,
|
||||||
|
holdReasonCode: fulfillmentStatus === "ON_HOLD" ? holdReasonCode : null,
|
||||||
|
holdNote:
|
||||||
|
fulfillmentStatus === "ON_HOLD" && holdReasonCode === "other"
|
||||||
|
? holdNote.trim()
|
||||||
|
: fulfillmentStatus === "ON_HOLD"
|
||||||
|
? null
|
||||||
|
: null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Failed to save")
|
const data = (await res.json().catch(() => ({}))) as {
|
||||||
|
message?: string
|
||||||
|
order?: SupplierOrder & { fulfillmentStatusDisplay?: string }
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(data.message || "Failed to save")
|
||||||
toast.success("Order updated successfully")
|
toast.success("Order updated successfully")
|
||||||
|
const next = data.order
|
||||||
setOrder({
|
setOrder({
|
||||||
...order,
|
...order,
|
||||||
fulfillmentStatus,
|
fulfillmentStatus: next?.fulfillmentStatus ?? fulfillmentStatus,
|
||||||
trackingNumber,
|
trackingNumber: trackingLocked ? null : trackingNumber,
|
||||||
trackingCompany,
|
trackingCompany: trackingLocked ? null : trackingCompany,
|
||||||
trackingUrl,
|
trackingUrl: trackingLocked ? null : trackingUrl,
|
||||||
|
holdReasonCode: fulfillmentStatus === "ON_HOLD" ? holdReasonCode : null,
|
||||||
|
holdNote:
|
||||||
|
fulfillmentStatus === "ON_HOLD" && holdReasonCode === "other"
|
||||||
|
? holdNote.trim()
|
||||||
|
: fulfillmentStatus === "ON_HOLD"
|
||||||
|
? null
|
||||||
|
: null,
|
||||||
|
holdReasonLabel: next?.holdReasonLabel ?? order.holdReasonLabel,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch (e) {
|
||||||
toast.error("Failed to update order")
|
toast.error(e instanceof Error ? e.message : "Failed to update order")
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@ -163,7 +264,7 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={save}
|
onClick={save}
|
||||||
disabled={!changed || saving}
|
disabled={!canSave || saving}
|
||||||
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-bold inline-flex items-center gap-2"
|
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-bold inline-flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
@ -205,23 +306,45 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Order Status</label>
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Order Status</label>
|
||||||
<select
|
<select
|
||||||
value={fulfillmentStatus}
|
value={fulfillmentStatus}
|
||||||
onChange={(e) => setFulfillmentStatus(e.target.value)}
|
onChange={(e) => onStatusChange(e.target.value)}
|
||||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
||||||
>
|
>
|
||||||
<option value="UNFULFILLED">Unfulfilled</option>
|
<option value="UNFULFILLED">Unfulfilled</option>
|
||||||
<option value="IN_PROGRESS">In Progress</option>
|
<option value="IN_PROGRESS">In Progress</option>
|
||||||
|
<option value="ON_HOLD">On hold</option>
|
||||||
<option value="SHIPPED">Shipped</option>
|
<option value="SHIPPED">Shipped</option>
|
||||||
<option value="FULFILLED">Fulfilled</option>
|
<option value="FULFILLED">Fulfilled</option>
|
||||||
<option value="CANCELLED">Cancelled</option>
|
<option value="CANCELLED">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fulfillmentStatus === "ON_HOLD" && holdReasonCode ? (
|
||||||
|
<div className="rounded-xl border border-amber-500/35 bg-amber-500/10 p-3 space-y-1">
|
||||||
|
<p className="text-[11px] font-bold uppercase text-amber-200">Hold reason</p>
|
||||||
|
<p className="text-sm text-white font-semibold">
|
||||||
|
{SUPPLIER_HOLD_REASON_OPTIONS.find((o) => o.value === holdReasonCode)?.label ||
|
||||||
|
holdReasonCode}
|
||||||
|
</p>
|
||||||
|
{holdReasonCode === "other" && holdNote.trim() ? (
|
||||||
|
<p className="text-xs text-amber-100/90 whitespace-pre-wrap">{holdNote}</p>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openHoldModal(fulfillmentStatus)}
|
||||||
|
className="text-xs font-bold text-amber-200 underline mt-1"
|
||||||
|
>
|
||||||
|
Change reason
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking Platform</label>
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking Platform</label>
|
||||||
<select
|
<select
|
||||||
value={trackingCompany}
|
value={trackingCompany}
|
||||||
onChange={(e) => setTrackingCompany(e.target.value)}
|
onChange={(e) => setTrackingCompany(e.target.value)}
|
||||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
disabled={trackingLocked}
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<option value="">Select platform</option>
|
<option value="">Select platform</option>
|
||||||
{SHOPIFY_TRACKING_COMPANIES.map((c) => (
|
{SHOPIFY_TRACKING_COMPANIES.map((c) => (
|
||||||
@ -236,7 +359,8 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
value={trackingNumber}
|
value={trackingNumber}
|
||||||
onChange={(e) => setTrackingNumber(e.target.value)}
|
onChange={(e) => setTrackingNumber(e.target.value)}
|
||||||
placeholder="e.g. 1Z999AA10123456784"
|
placeholder="e.g. 1Z999AA10123456784"
|
||||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
disabled={trackingLocked}
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -246,10 +370,15 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
value={trackingUrl}
|
value={trackingUrl}
|
||||||
onChange={(e) => setTrackingUrl(e.target.value)}
|
onChange={(e) => setTrackingUrl(e.target.value)}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
|
disabled={trackingLocked}
|
||||||
|
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{holdIncomplete && (
|
||||||
|
<p className="text-amber-300 text-xs font-semibold">Choose a hold reason (use On hold or Change reason).</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{changed && (
|
{changed && (
|
||||||
<p className="text-emerald-400 text-xs font-semibold inline-flex items-center gap-1">
|
<p className="text-emerald-400 text-xs font-semibold inline-flex items-center gap-1">
|
||||||
<CheckCircle2 className="w-3 h-3" /> Unsaved changes
|
<CheckCircle2 className="w-3 h-3" /> Unsaved changes
|
||||||
@ -342,6 +471,84 @@ export default function SupplierOrderDetailsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{holdModalOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="hold-modal-title"
|
||||||
|
>
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-white/15 bg-slate-900 p-5 shadow-xl space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h2 id="hold-modal-title" className="text-lg font-bold text-white">
|
||||||
|
On hold reason
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelHoldModal}
|
||||||
|
className="p-1 rounded-lg text-slate-400 hover:bg-white/10 hover:text-white"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Select why this order is on hold. Required before you can save.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
|
||||||
|
{SUPPLIER_HOLD_REASON_OPTIONS.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
className={`flex items-start gap-3 rounded-xl border p-3 cursor-pointer ${
|
||||||
|
modalReason === opt.value
|
||||||
|
? "border-indigo-500/60 bg-indigo-500/15"
|
||||||
|
: "border-white/10 bg-white/5"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="holdReason"
|
||||||
|
className="mt-1"
|
||||||
|
checked={modalReason === opt.value}
|
||||||
|
onChange={() => setModalReason(opt.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-white font-medium">{opt.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{modalReason === "other" ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Note</label>
|
||||||
|
<textarea
|
||||||
|
value={modalNote}
|
||||||
|
onChange={(e) => setModalNote(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder="Describe the hold…"
|
||||||
|
className="w-full rounded-xl bg-white/10 border border-white/10 text-white px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelHoldModal}
|
||||||
|
className="h-10 px-4 rounded-xl bg-white/10 text-white font-semibold"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={confirmHoldModal}
|
||||||
|
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -135,10 +135,15 @@ export default function SupplierOrdersPage() {
|
|||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||||
(order.status || "").toUpperCase() === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
|
(order.status || "").toUpperCase() === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
|
||||||
(order.status || "").toUpperCase() === "SHIPPED" || (order.status || "").toUpperCase() === "IN_PROGRESS" ? "bg-yellow-500/20 text-yellow-400" :
|
(order.status || "").toUpperCase() === "SHIPPED" || (order.status || "").toUpperCase() === "IN_PROGRESS" ? "bg-yellow-500/20 text-yellow-400" :
|
||||||
|
(order.status || "").toUpperCase() === "ON_HOLD" ? "bg-amber-500/20 text-amber-300" :
|
||||||
(order.status || "").toUpperCase() === "CANCELLED" ? "bg-red-500/20 text-red-400" :
|
(order.status || "").toUpperCase() === "CANCELLED" ? "bg-red-500/20 text-red-400" :
|
||||||
"bg-blue-500/20 text-blue-400"
|
"bg-blue-500/20 text-blue-400"
|
||||||
}`}>
|
}`}>
|
||||||
{order.status || "UNFULFILLED"}
|
{(order.status || "").toUpperCase() === "ON_HOLD"
|
||||||
|
? order.holdReasonLabel
|
||||||
|
? `On hold · ${order.holdReasonLabel}`
|
||||||
|
: "On hold"
|
||||||
|
: order.status || "UNFULFILLED"}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm font-medium text-white">${Number(order.total || 0).toFixed(2)}</td>
|
<td className="px-6 py-4 text-sm font-medium text-white">${Number(order.total || 0).toFixed(2)}</td>
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { emailSchema } from "@/lib/validation"
|
import { emailSchema } from "@/lib/validation"
|
||||||
|
import { normalizeFulfillmentStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
import { isSupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
|
||||||
|
|
||||||
const totpCodeSchema = z
|
const totpCodeSchema = z
|
||||||
.string()
|
.string()
|
||||||
@ -171,10 +173,56 @@ function optionalTrackingField(maxLen: number) {
|
|||||||
.refine((v) => v === null || v.length <= maxLen, "Tracking value too long")
|
.refine((v) => v === null || v.length <= maxLen, "Tracking value too long")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function optionalHoldNoteField() {
|
||||||
|
return z
|
||||||
|
.union([z.string(), z.null(), z.undefined()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === null) return null
|
||||||
|
const t = String(v).trim()
|
||||||
|
return t === "" ? null : t.slice(0, 2000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalHoldReasonCodeField() {
|
||||||
|
return z
|
||||||
|
.union([z.string(), z.null(), z.undefined()])
|
||||||
|
.transform((v) => {
|
||||||
|
if (v === undefined || v === null) return null
|
||||||
|
const t = String(v).trim()
|
||||||
|
return t === "" ? null : t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** PATCH /api/supplier/orders/[orderId] */
|
/** PATCH /api/supplier/orders/[orderId] */
|
||||||
export const supplierOrderPatchSchema = z.object({
|
export const supplierOrderPatchSchema = z
|
||||||
fulfillmentStatus: z.union([z.string(), z.number(), z.null(), z.undefined()]).optional(),
|
.object({
|
||||||
trackingNumber: optionalTrackingField(500),
|
fulfillmentStatus: z.union([z.string(), z.number(), z.null(), z.undefined()]).optional(),
|
||||||
trackingCompany: optionalTrackingField(500),
|
trackingNumber: optionalTrackingField(500),
|
||||||
trackingUrl: optionalTrackingField(2000),
|
trackingCompany: optionalTrackingField(500),
|
||||||
})
|
trackingUrl: optionalTrackingField(2000),
|
||||||
|
holdReasonCode: optionalHoldReasonCodeField(),
|
||||||
|
holdNote: optionalHoldNoteField(),
|
||||||
|
})
|
||||||
|
.superRefine((data, ctx) => {
|
||||||
|
const fsRaw = data.fulfillmentStatus
|
||||||
|
if (fsRaw === undefined || fsRaw === null) return
|
||||||
|
|
||||||
|
const canonical = normalizeFulfillmentStatus(String(fsRaw))
|
||||||
|
if (canonical !== "ON_HOLD") return
|
||||||
|
|
||||||
|
if (!data.holdReasonCode || !isSupplierHoldReasonCode(data.holdReasonCode)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "holdReasonCode is required for On hold status",
|
||||||
|
path: ["holdReasonCode"],
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (data.holdReasonCode === "other" && !data.holdNote?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Note is required when reason is Other",
|
||||||
|
path: ["holdNote"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@ -188,6 +188,44 @@ describe("supplierOrderPatchSchema", () => {
|
|||||||
it("allows empty patch", () => {
|
it("allows empty patch", () => {
|
||||||
expect(supplierOrderPatchSchema.safeParse({}).success).toBe(true)
|
expect(supplierOrderPatchSchema.safeParse({}).success).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("requires holdReasonCode when status is On hold", () => {
|
||||||
|
expect(
|
||||||
|
supplierOrderPatchSchema.safeParse({ fulfillmentStatus: "ON_HOLD" }).success,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
supplierOrderPatchSchema.safeParse({
|
||||||
|
fulfillmentStatus: "ON_HOLD",
|
||||||
|
holdReasonCode: "inventory_out_of_stock",
|
||||||
|
}).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requires holdNote when reason is other", () => {
|
||||||
|
expect(
|
||||||
|
supplierOrderPatchSchema.safeParse({
|
||||||
|
fulfillmentStatus: "ON_HOLD",
|
||||||
|
holdReasonCode: "other",
|
||||||
|
}).success,
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
supplierOrderPatchSchema.safeParse({
|
||||||
|
fulfillmentStatus: "ON_HOLD",
|
||||||
|
holdReasonCode: "other",
|
||||||
|
holdNote: "Custom explanation",
|
||||||
|
}).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not require hold fields for shipped", () => {
|
||||||
|
expect(
|
||||||
|
supplierOrderPatchSchema.safeParse({
|
||||||
|
fulfillmentStatus: "SHIPPED",
|
||||||
|
trackingNumber: "1Z",
|
||||||
|
trackingCompany: "UPS",
|
||||||
|
}).success,
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("vendorInvitePostSchema", () => {
|
describe("vendorInvitePostSchema", () => {
|
||||||
|
|||||||
@ -517,7 +517,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,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
export type CanonicalFulfillmentStatus =
|
export type CanonicalFulfillmentStatus =
|
||||||
| "UNFULFILLED"
|
| "UNFULFILLED"
|
||||||
| "IN_PROGRESS"
|
| "IN_PROGRESS"
|
||||||
|
| "ON_HOLD"
|
||||||
| "SHIPPED"
|
| "SHIPPED"
|
||||||
| "FULFILLED"
|
| "FULFILLED"
|
||||||
| "CANCELLED"
|
| "CANCELLED"
|
||||||
@ -8,6 +9,7 @@ export type CanonicalFulfillmentStatus =
|
|||||||
const SHOPIFY_FULFILLMENT_MAP: Record<CanonicalFulfillmentStatus, string> = {
|
const SHOPIFY_FULFILLMENT_MAP: Record<CanonicalFulfillmentStatus, string> = {
|
||||||
UNFULFILLED: "unfulfilled",
|
UNFULFILLED: "unfulfilled",
|
||||||
IN_PROGRESS: "in_progress",
|
IN_PROGRESS: "in_progress",
|
||||||
|
ON_HOLD: "on_hold",
|
||||||
SHIPPED: "shipped",
|
SHIPPED: "shipped",
|
||||||
FULFILLED: "fulfilled",
|
FULFILLED: "fulfilled",
|
||||||
CANCELLED: "cancelled",
|
CANCELLED: "cancelled",
|
||||||
@ -25,6 +27,9 @@ export function normalizeFulfillmentStatus(input: string | null | undefined): Ca
|
|||||||
return "IN_PROGRESS"
|
return "IN_PROGRESS"
|
||||||
}
|
}
|
||||||
if (raw === "CANCELLED" || raw === "CANCELED") return "CANCELLED"
|
if (raw === "CANCELLED" || raw === "CANCELED") return "CANCELLED"
|
||||||
|
if (raw === "ON_HOLD" || raw === "ONHOLD" || raw === "HOLD" || raw === "PENDING_HOLD") {
|
||||||
|
return "ON_HOLD"
|
||||||
|
}
|
||||||
return "UNFULFILLED"
|
return "UNFULFILLED"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
src/lib/orders/supplierHoldReasons.ts
Normal file
31
src/lib/orders/supplierHoldReasons.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
/** Stable codes stored in DB and Shopify metafield JSON. */
|
||||||
|
export const SUPPLIER_HOLD_REASON_CODES = [
|
||||||
|
"inventory_out_of_stock",
|
||||||
|
"address_incorrect",
|
||||||
|
"high_risk_fraud",
|
||||||
|
"awaiting_payment",
|
||||||
|
"other",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SupplierHoldReasonCode = (typeof SUPPLIER_HOLD_REASON_CODES)[number]
|
||||||
|
|
||||||
|
export const SUPPLIER_HOLD_REASON_LABELS: Record<SupplierHoldReasonCode, string> = {
|
||||||
|
inventory_out_of_stock: "Inventory out of stock",
|
||||||
|
address_incorrect: "Address incorrect",
|
||||||
|
high_risk_fraud: "High risk of fraud",
|
||||||
|
awaiting_payment: "Awaiting payment",
|
||||||
|
other: "Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupplierHoldReasonCode(v: string): v is SupplierHoldReasonCode {
|
||||||
|
return (SUPPLIER_HOLD_REASON_CODES as readonly string[]).includes(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UI / API: fixed ordering for selects and modals. */
|
||||||
|
export const SUPPLIER_HOLD_REASON_OPTIONS: ReadonlyArray<{
|
||||||
|
value: SupplierHoldReasonCode
|
||||||
|
label: string
|
||||||
|
}> = SUPPLIER_HOLD_REASON_CODES.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: SUPPLIER_HOLD_REASON_LABELS[value],
|
||||||
|
}))
|
||||||
26
src/lib/shopify/fulfillmentOrderHoldGraphql.test.ts
Normal file
26
src/lib/shopify/fulfillmentOrderHoldGraphql.test.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from "vitest"
|
||||||
|
import {
|
||||||
|
shopifyFulfillmentHoldReason,
|
||||||
|
shopifyFulfillmentHoldNotes,
|
||||||
|
JUNO_FULFILLMENT_HOLD_HANDLE,
|
||||||
|
} from "@/lib/shopify/fulfillmentOrderHoldGraphql"
|
||||||
|
|
||||||
|
describe("fulfillmentOrderHoldGraphql", () => {
|
||||||
|
it("maps supplier codes to Shopify FulfillmentHoldReason", () => {
|
||||||
|
expect(shopifyFulfillmentHoldReason("inventory_out_of_stock")).toBe("INVENTORY_OUT_OF_STOCK")
|
||||||
|
expect(shopifyFulfillmentHoldReason("address_incorrect")).toBe("INCORRECT_ADDRESS")
|
||||||
|
expect(shopifyFulfillmentHoldReason("high_risk_fraud")).toBe("HIGH_RISK_OF_FRAUD")
|
||||||
|
expect(shopifyFulfillmentHoldReason("awaiting_payment")).toBe("AWAITING_PAYMENT")
|
||||||
|
expect(shopifyFulfillmentHoldReason("other")).toBe("OTHER")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds reason notes for Shopify admin", () => {
|
||||||
|
expect(shopifyFulfillmentHoldNotes("inventory_out_of_stock", null)).toContain("Inventory out of stock")
|
||||||
|
expect(shopifyFulfillmentHoldNotes("other", " custom ")).toBe("custom")
|
||||||
|
expect(shopifyFulfillmentHoldNotes("awaiting_payment", "extra")).toContain("Awaiting payment")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses stable hold handle for release targeting", () => {
|
||||||
|
expect(JUNO_FULFILLMENT_HOLD_HANDLE.length).toBeLessThanOrEqual(64)
|
||||||
|
})
|
||||||
|
})
|
||||||
265
src/lib/shopify/fulfillmentOrderHoldGraphql.ts
Normal file
265
src/lib/shopify/fulfillmentOrderHoldGraphql.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Native Shopify fulfillment holds (Admin GraphQL) so orders show "On hold" in admin,
|
||||||
|
* not only the JUNO_ON_HOLD tag.
|
||||||
|
*
|
||||||
|
* Requires scope: write_merchant_managed_fulfillment_orders (or third_party variant) +
|
||||||
|
* fulfill_and_ship_orders staff permission on the token.
|
||||||
|
*/
|
||||||
|
import type { SupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
|
||||||
|
import { SUPPLIER_HOLD_REASON_LABELS } from "@/lib/orders/supplierHoldReasons"
|
||||||
|
|
||||||
|
/** Same handle every time so we can release/update without touching merchant holds. */
|
||||||
|
export const JUNO_FULFILLMENT_HOLD_HANDLE = "juno_supplier_hold"
|
||||||
|
|
||||||
|
const GRAPHQL_API = "2024-10"
|
||||||
|
|
||||||
|
const FO_SKIP_HOLD = new Set(["CLOSED", "CANCELLED", "INCOMPLETE"])
|
||||||
|
|
||||||
|
type GraphqlResponse<T> = {
|
||||||
|
data?: T
|
||||||
|
errors?: Array<{ message?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shopifyAdminGraphql<T>(
|
||||||
|
cleanDomain: string,
|
||||||
|
accessToken: string,
|
||||||
|
query: string,
|
||||||
|
variables?: Record<string, unknown>,
|
||||||
|
): Promise<{ ok: boolean; data: T | null; error?: string; raw: string }> {
|
||||||
|
const res = await fetch(`https://${cleanDomain}/admin/api/${GRAPHQL_API}/graphql.json`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
})
|
||||||
|
const raw = await res.text().catch(() => "")
|
||||||
|
let parsed: GraphqlResponse<T> = {}
|
||||||
|
try {
|
||||||
|
parsed = raw ? (JSON.parse(raw) as GraphqlResponse<T>) : {}
|
||||||
|
} catch {
|
||||||
|
return { ok: false, data: null, error: "Invalid JSON from Shopify GraphQL", raw }
|
||||||
|
}
|
||||||
|
if (parsed.errors?.length) {
|
||||||
|
const msg = parsed.errors.map((e) => e.message).filter(Boolean).join("; ")
|
||||||
|
return { ok: false, data: parsed.data ?? null, error: msg || "GraphQL error", raw }
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
return { ok: false, data: null, error: `HTTP ${res.status}: ${raw.slice(0, 400)}`, raw }
|
||||||
|
}
|
||||||
|
return { ok: true, data: parsed.data ?? null, raw }
|
||||||
|
}
|
||||||
|
|
||||||
|
type FoNode = {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
fulfillmentHolds?: Array<{ id: string; handle?: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderFoQuery = {
|
||||||
|
order: {
|
||||||
|
fulfillmentOrders: {
|
||||||
|
edges: Array<{ node: FoNode }>
|
||||||
|
} | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORDER_FULFILLMENT_ORDERS = `
|
||||||
|
query OrderFulfillmentOrders($orderId: ID!) {
|
||||||
|
order(id: $orderId) {
|
||||||
|
fulfillmentOrders(first: 25) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
fulfillmentHolds {
|
||||||
|
id
|
||||||
|
handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
export function shopifyFulfillmentHoldReason(code: SupplierHoldReasonCode): string {
|
||||||
|
const m: Record<SupplierHoldReasonCode, string> = {
|
||||||
|
inventory_out_of_stock: "INVENTORY_OUT_OF_STOCK",
|
||||||
|
address_incorrect: "INCORRECT_ADDRESS",
|
||||||
|
high_risk_fraud: "HIGH_RISK_OF_FRAUD",
|
||||||
|
awaiting_payment: "AWAITING_PAYMENT",
|
||||||
|
other: "OTHER",
|
||||||
|
}
|
||||||
|
return m[code]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text shown on the hold in Shopify admin (reason notes). */
|
||||||
|
export function shopifyFulfillmentHoldNotes(code: SupplierHoldReasonCode, note: string | null): string {
|
||||||
|
const label = SUPPLIER_HOLD_REASON_LABELS[code]
|
||||||
|
if (code === "other") {
|
||||||
|
return (note?.trim() || label).slice(0, 500)
|
||||||
|
}
|
||||||
|
const extra = note?.trim()
|
||||||
|
const base = `Supplier (JUNO): ${label}`
|
||||||
|
return extra ? `${base} — ${extra}`.slice(0, 500) : base
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listFulfillmentOrdersForOrder(
|
||||||
|
cleanDomain: string,
|
||||||
|
accessToken: string,
|
||||||
|
orderNumericId: string,
|
||||||
|
): Promise<{ ok: boolean; nodes: FoNode[]; error?: string }> {
|
||||||
|
const orderGid = `gid://shopify/Order/${orderNumericId}`
|
||||||
|
const r = await shopifyAdminGraphql<OrderFoQuery>(
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
ORDER_FULFILLMENT_ORDERS,
|
||||||
|
{ orderId: orderGid },
|
||||||
|
)
|
||||||
|
if (!r.ok || !r.data?.order) {
|
||||||
|
return { ok: false, nodes: [], error: r.error || "Order not found in GraphQL" }
|
||||||
|
}
|
||||||
|
const edges = r.data.order.fulfillmentOrders?.edges || []
|
||||||
|
return { ok: true, nodes: edges.map((e) => e.node) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const MUTATION_RELEASE = `
|
||||||
|
mutation ReleaseHolds($id: ID!, $holdIds: [ID!]!) {
|
||||||
|
fulfillmentOrderReleaseHold(id: $id, holdIds: $holdIds) {
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const MUTATION_HOLD = `
|
||||||
|
mutation ApplyHold($id: ID!, $hold: FulfillmentOrderHoldInput!) {
|
||||||
|
fulfillmentOrderHold(id: $id, fulfillmentHold: $hold) {
|
||||||
|
userErrors { field message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
async function releaseJunoHoldsOnFo(
|
||||||
|
cleanDomain: string,
|
||||||
|
accessToken: string,
|
||||||
|
foId: string,
|
||||||
|
holds: Array<{ id: string; handle?: string | null }>,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const ids = holds
|
||||||
|
.filter((h) => (h.handle || "") === JUNO_FULFILLMENT_HOLD_HANDLE)
|
||||||
|
.map((h) => h.id)
|
||||||
|
if (ids.length === 0) return { ok: true }
|
||||||
|
const r = await shopifyAdminGraphql<{ fulfillmentOrderReleaseHold: { userErrors: Array<{ message: string }> } }>(
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
MUTATION_RELEASE,
|
||||||
|
{ id: foId, holdIds: ids },
|
||||||
|
)
|
||||||
|
if (!r.ok) return { ok: false, error: r.error }
|
||||||
|
const errs = r.data?.fulfillmentOrderReleaseHold?.userErrors || []
|
||||||
|
if (errs.length) {
|
||||||
|
return { ok: false, error: errs.map((e) => e.message).join("; ") }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyJunoHoldOnFo(
|
||||||
|
cleanDomain: string,
|
||||||
|
accessToken: string,
|
||||||
|
foId: string,
|
||||||
|
reason: string,
|
||||||
|
reasonNotes: string,
|
||||||
|
): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const r = await shopifyAdminGraphql<{ fulfillmentOrderHold: { userErrors: Array<{ message: string }> } }>(
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
MUTATION_HOLD,
|
||||||
|
{
|
||||||
|
id: foId,
|
||||||
|
hold: {
|
||||||
|
handle: JUNO_FULFILLMENT_HOLD_HANDLE,
|
||||||
|
reason,
|
||||||
|
reasonNotes,
|
||||||
|
notifyMerchant: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!r.ok) return { ok: false, error: r.error }
|
||||||
|
const errs = r.data?.fulfillmentOrderHold?.userErrors || []
|
||||||
|
if (errs.length) {
|
||||||
|
return { ok: false, error: errs.map((e) => e.message).join("; ") }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places Shopify-native fulfillment holds on all actionable fulfillment orders for this order.
|
||||||
|
*/
|
||||||
|
export async function applyNativeFulfillmentHoldsForOrder(params: {
|
||||||
|
cleanDomain: string
|
||||||
|
accessToken: string
|
||||||
|
orderNumericId: string
|
||||||
|
code: SupplierHoldReasonCode
|
||||||
|
note: string | null
|
||||||
|
}): Promise<{ ok: boolean; error?: string; appliedCount: number }> {
|
||||||
|
const { cleanDomain, accessToken, orderNumericId, code, note } = params
|
||||||
|
const listed = await listFulfillmentOrdersForOrder(cleanDomain, accessToken, orderNumericId)
|
||||||
|
if (!listed.ok) {
|
||||||
|
return { ok: false, error: listed.error, appliedCount: 0 }
|
||||||
|
}
|
||||||
|
const reason = shopifyFulfillmentHoldReason(code)
|
||||||
|
const reasonNotes = shopifyFulfillmentHoldNotes(code, note)
|
||||||
|
|
||||||
|
for (const fo of listed.nodes) {
|
||||||
|
const rel = await releaseJunoHoldsOnFo(cleanDomain, accessToken, fo.id, fo.fulfillmentHolds || [])
|
||||||
|
if (!rel.ok) {
|
||||||
|
return { ok: false, error: rel.error, appliedCount: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let appliedCount = 0
|
||||||
|
for (const fo of listed.nodes) {
|
||||||
|
if (FO_SKIP_HOLD.has(String(fo.status || "").toUpperCase())) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const app = await applyJunoHoldOnFo(cleanDomain, accessToken, fo.id, reason, reasonNotes)
|
||||||
|
if (!app.ok) {
|
||||||
|
return { ok: false, error: app.error, appliedCount }
|
||||||
|
}
|
||||||
|
appliedCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedCount === 0 && listed.nodes.length === 0) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
appliedCount: 0,
|
||||||
|
error: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, appliedCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases JUNO fulfillment holds only (leaves other holds intact).
|
||||||
|
*/
|
||||||
|
export async function releaseNativeJunoHoldsForOrder(params: {
|
||||||
|
cleanDomain: string
|
||||||
|
accessToken: string
|
||||||
|
orderNumericId: string
|
||||||
|
}): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const { cleanDomain, accessToken, orderNumericId } = params
|
||||||
|
const listed = await listFulfillmentOrdersForOrder(cleanDomain, accessToken, orderNumericId)
|
||||||
|
if (!listed.ok) {
|
||||||
|
return { ok: false, error: listed.error }
|
||||||
|
}
|
||||||
|
for (const fo of listed.nodes) {
|
||||||
|
const rel = await releaseJunoHoldsOnFo(cleanDomain, accessToken, fo.id, fo.fulfillmentHolds || [])
|
||||||
|
if (!rel.ok) {
|
||||||
|
return { ok: false, error: rel.error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
36
src/lib/shopify/orderHoldSync.test.ts
Normal file
36
src/lib/shopify/orderHoldSync.test.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
import {
|
||||||
|
JUNO_ON_HOLD_TAG,
|
||||||
|
mergeTagsAddHold,
|
||||||
|
mergeTagsRemoveHold,
|
||||||
|
buildSupplierHoldMetafieldJson,
|
||||||
|
} from "@/lib/shopify/orderHoldSync"
|
||||||
|
|
||||||
|
describe("orderHoldSync", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z"))
|
||||||
|
})
|
||||||
|
|
||||||
|
it("mergeTagsAddHold appends tag once", () => {
|
||||||
|
expect(mergeTagsAddHold("foo, bar")).toBe(`foo, bar, ${JUNO_ON_HOLD_TAG}`)
|
||||||
|
expect(mergeTagsAddHold(`foo, ${JUNO_ON_HOLD_TAG}`)).toBe(`foo, ${JUNO_ON_HOLD_TAG}`)
|
||||||
|
expect(mergeTagsAddHold(`foo, ${JUNO_ON_HOLD_TAG.toLowerCase()}`)).toBe(`foo, ${JUNO_ON_HOLD_TAG.toLowerCase()}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("mergeTagsRemoveHold strips tag case-insensitively", () => {
|
||||||
|
expect(mergeTagsRemoveHold(`a, ${JUNO_ON_HOLD_TAG}, b`)).toBe("a, b")
|
||||||
|
expect(mergeTagsRemoveHold("a, b")).toBe("a, b")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("buildSupplierHoldMetafieldJson stringifies payload", () => {
|
||||||
|
const s = buildSupplierHoldMetafieldJson("inventory_out_of_stock", null)
|
||||||
|
expect(JSON.parse(s)).toEqual({
|
||||||
|
code: "inventory_out_of_stock",
|
||||||
|
note: "",
|
||||||
|
updatedAt: "2026-04-12T12:00:00.000Z",
|
||||||
|
})
|
||||||
|
const s2 = buildSupplierHoldMetafieldJson("other", " hi ")
|
||||||
|
expect(JSON.parse(s2).note).toBe("hi")
|
||||||
|
})
|
||||||
|
})
|
||||||
253
src/lib/shopify/orderHoldSync.ts
Normal file
253
src/lib/shopify/orderHoldSync.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import type { SupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
|
||||||
|
import {
|
||||||
|
applyNativeFulfillmentHoldsForOrder,
|
||||||
|
releaseNativeJunoHoldsForOrder,
|
||||||
|
} from "@/lib/shopify/fulfillmentOrderHoldGraphql"
|
||||||
|
|
||||||
|
export const JUNO_ON_HOLD_TAG = "JUNO_ON_HOLD"
|
||||||
|
|
||||||
|
export const SUPPLIER_HOLD_METAFIELD = {
|
||||||
|
namespace: "juno",
|
||||||
|
key: "supplier_hold",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
/** Shopify order tags are comma-separated; preserve casing of existing tags except our token. */
|
||||||
|
export function mergeTagsAddHold(tagsCsv: string | null | undefined): string {
|
||||||
|
const parts = (tagsCsv ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const upper = new Set(parts.map((p) => p.toUpperCase()))
|
||||||
|
if (!upper.has(JUNO_ON_HOLD_TAG.toUpperCase())) {
|
||||||
|
parts.push(JUNO_ON_HOLD_TAG)
|
||||||
|
}
|
||||||
|
return parts.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeTagsRemoveHold(tagsCsv: string | null | undefined): string {
|
||||||
|
const parts = (tagsCsv ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return parts.filter((p) => p.toUpperCase() !== JUNO_ON_HOLD_TAG.toUpperCase()).join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSupplierHoldMetafieldJson(code: SupplierHoldReasonCode, note: string | null): string {
|
||||||
|
const payload = {
|
||||||
|
code,
|
||||||
|
note: note?.trim() ?? "",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
return JSON.stringify(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShopifyMetafieldRow = { id?: number; namespace?: string; key?: string }
|
||||||
|
|
||||||
|
async function shopifyJson<T>(
|
||||||
|
url: string,
|
||||||
|
accessToken: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<{ ok: boolean; status: number; data: T | null; text: string }> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": accessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers as Record<string, string>),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const text = await res.text().catch(() => "")
|
||||||
|
let data: T | null = null
|
||||||
|
try {
|
||||||
|
data = text ? (JSON.parse(text) as T) : null
|
||||||
|
} catch {
|
||||||
|
data = null
|
||||||
|
}
|
||||||
|
return { ok: res.ok, status: res.status, data, text }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set hold tag + `juno.supplier_hold` JSON metafield on the order (REST Admin API 2024-01).
|
||||||
|
*/
|
||||||
|
export async function pushOnHoldToShopify(params: {
|
||||||
|
cleanDomain: string
|
||||||
|
accessToken: string
|
||||||
|
shopifyOrderId: string
|
||||||
|
code: SupplierHoldReasonCode
|
||||||
|
note: string | null
|
||||||
|
}): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const { cleanDomain, accessToken, shopifyOrderId, code, note } = params
|
||||||
|
|
||||||
|
const native = await applyNativeFulfillmentHoldsForOrder({
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
orderNumericId: shopifyOrderId,
|
||||||
|
code,
|
||||||
|
note,
|
||||||
|
})
|
||||||
|
if (!native.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Fulfillment hold (Shopify): ${native.error || "unknown error"}. Ensure the Admin API token includes write_merchant_managed_fulfillment_orders (or write_third_party_fulfillment_orders).`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = `https://${cleanDomain}/admin/api/2024-01`
|
||||||
|
|
||||||
|
const orderRes = await shopifyJson<{ order?: { id?: number; tags?: string } }>(
|
||||||
|
`${base}/orders/${shopifyOrderId}.json`,
|
||||||
|
accessToken,
|
||||||
|
)
|
||||||
|
if (!orderRes.ok || !orderRes.data?.order?.id) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to read Shopify order: ${orderRes.status} ${orderRes.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const numericId = orderRes.data.order.id
|
||||||
|
const newTags = mergeTagsAddHold(orderRes.data.order.tags)
|
||||||
|
|
||||||
|
const putOrder = await shopifyJson<{ order?: { id?: number } }>(`${base}/orders/${shopifyOrderId}.json`, accessToken, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ order: { id: numericId, tags: newTags } }),
|
||||||
|
})
|
||||||
|
if (!putOrder.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to update order tags: ${putOrder.status} ${putOrder.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mfValue = buildSupplierHoldMetafieldJson(code, note)
|
||||||
|
|
||||||
|
const listMf = await shopifyJson<{ metafields?: ShopifyMetafieldRow[] }>(
|
||||||
|
`${base}/orders/${shopifyOrderId}/metafields.json`,
|
||||||
|
accessToken,
|
||||||
|
)
|
||||||
|
if (!listMf.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to list order metafields: ${listMf.status} ${listMf.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existing = (listMf.data?.metafields || []).find(
|
||||||
|
(m) => m.namespace === SUPPLIER_HOLD_METAFIELD.namespace && m.key === SUPPLIER_HOLD_METAFIELD.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing?.id) {
|
||||||
|
const putMf = await shopifyJson<{ metafield?: { id?: number } }>(
|
||||||
|
`${base}/metafields/${existing.id}.json`,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
metafield: {
|
||||||
|
id: existing.id,
|
||||||
|
type: "json",
|
||||||
|
value: mfValue,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!putMf.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to update hold metafield: ${putMf.status} ${putMf.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const postMf = await shopifyJson<{ metafield?: { id?: number } }>(
|
||||||
|
`${base}/orders/${shopifyOrderId}/metafields.json`,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
metafield: {
|
||||||
|
namespace: SUPPLIER_HOLD_METAFIELD.namespace,
|
||||||
|
key: SUPPLIER_HOLD_METAFIELD.key,
|
||||||
|
type: "json",
|
||||||
|
value: mfValue,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!postMf.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to create hold metafield: ${postMf.status} ${postMf.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove hold tag and delete `juno.supplier_hold` metafield if present. */
|
||||||
|
export async function clearOnHoldOnShopify(params: {
|
||||||
|
cleanDomain: string
|
||||||
|
accessToken: string
|
||||||
|
shopifyOrderId: string
|
||||||
|
}): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const { cleanDomain, accessToken, shopifyOrderId } = params
|
||||||
|
|
||||||
|
const released = await releaseNativeJunoHoldsForOrder({
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
orderNumericId: shopifyOrderId,
|
||||||
|
})
|
||||||
|
if (!released.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Release fulfillment hold: ${released.error || "unknown error"}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = `https://${cleanDomain}/admin/api/2024-01`
|
||||||
|
|
||||||
|
const orderRes = await shopifyJson<{ order?: { id?: number; tags?: string } }>(
|
||||||
|
`${base}/orders/${shopifyOrderId}.json`,
|
||||||
|
accessToken,
|
||||||
|
)
|
||||||
|
if (!orderRes.ok || !orderRes.data?.order?.id) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to read Shopify order: ${orderRes.status} ${orderRes.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const numericId = orderRes.data.order.id
|
||||||
|
const newTags = mergeTagsRemoveHold(orderRes.data.order.tags)
|
||||||
|
|
||||||
|
const putOrder = await shopifyJson<{ order?: { id?: number } }>(`${base}/orders/${shopifyOrderId}.json`, accessToken, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ order: { id: numericId, tags: newTags } }),
|
||||||
|
})
|
||||||
|
if (!putOrder.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to update order tags: ${putOrder.status} ${putOrder.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listMf = await shopifyJson<{ metafields?: ShopifyMetafieldRow[] }>(
|
||||||
|
`${base}/orders/${shopifyOrderId}/metafields.json`,
|
||||||
|
accessToken,
|
||||||
|
)
|
||||||
|
if (listMf.ok && listMf.data?.metafields) {
|
||||||
|
const existing = listMf.data.metafields.find(
|
||||||
|
(m) => m.namespace === SUPPLIER_HOLD_METAFIELD.namespace && m.key === SUPPLIER_HOLD_METAFIELD.key,
|
||||||
|
)
|
||||||
|
if (existing?.id) {
|
||||||
|
const del = await shopifyJson<unknown>(`${base}/metafields/${existing.id}.json`, accessToken, {
|
||||||
|
method: "DELETE",
|
||||||
|
})
|
||||||
|
if (!del.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Failed to delete hold metafield: ${del.status} ${del.text.slice(0, 300)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
239
src/lib/shopify/supplierOrderFulfillmentSync.ts
Normal file
239
src/lib/shopify/supplierOrderFulfillmentSync.ts
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { normalizeFulfillmentStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create/update Shopify fulfillment + tracking for SHIPPED / FULFILLED only.
|
||||||
|
* Other statuses no-op with ok: true.
|
||||||
|
*/
|
||||||
|
export async function syncOrderFulfillmentToShopify(
|
||||||
|
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") {
|
||||||
|
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 updateTrackingOnExistingFulfillment = async (): Promise<{ ok: boolean; error?: string }> => {
|
||||||
|
const listRes = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!listRes.ok) {
|
||||||
|
const details = await listRes.text().catch(() => "Unknown Shopify response")
|
||||||
|
return { ok: false, error: `Read existing fulfillments failed: ${details}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPayload = (await listRes.json()) as {
|
||||||
|
fulfillments?: Array<{ id: number; created_at?: string }>
|
||||||
|
}
|
||||||
|
const fulfillments = listPayload.fulfillments || []
|
||||||
|
if (fulfillments.length === 0) {
|
||||||
|
return { ok: false, error: "No existing fulfillment found to update tracking." }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...fulfillments].sort((a, b) => {
|
||||||
|
const aTs = a.created_at ? new Date(a.created_at).getTime() : 0
|
||||||
|
const bTs = b.created_at ? new Date(b.created_at).getTime() : 0
|
||||||
|
return bTs - aTs
|
||||||
|
})
|
||||||
|
const latest = sorted[0]
|
||||||
|
|
||||||
|
const updateRes = await fetch(
|
||||||
|
`https://${cleanDomain}/admin/api/2024-01/fulfillments/${latest.id}/update_tracking.json`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"X-Shopify-Access-Token": store.shopifyAccessToken,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
fulfillment: {
|
||||||
|
notify_customer: false,
|
||||||
|
tracking_info: {
|
||||||
|
number: trackingNumber,
|
||||||
|
company: trackingCompany,
|
||||||
|
url: trackingUrl || undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!updateRes.ok) {
|
||||||
|
const details = await updateRes.text().catch(() => "Unknown Shopify response")
|
||||||
|
return { ok: false, error: `Update tracking on existing fulfillment failed: ${details}` }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
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; status?: string; request_status?: string }>
|
||||||
|
}
|
||||||
|
const allFulfillmentOrders = foJson.fulfillment_orders || []
|
||||||
|
const fulfillmentOrders = allFulfillmentOrders.filter((fo) => {
|
||||||
|
const status = String(fo.status || "").toLowerCase()
|
||||||
|
return status && status !== "closed" && status !== "cancelled" && status !== "incomplete"
|
||||||
|
})
|
||||||
|
if (fulfillmentOrders.length === 0) {
|
||||||
|
const closedCount = allFulfillmentOrders.filter((fo) => String(fo.status || "").toLowerCase() === "closed").length
|
||||||
|
if (closedCount > 0) {
|
||||||
|
const updated = await updateTrackingOnExistingFulfillment()
|
||||||
|
if (updated.ok) return updated
|
||||||
|
return { ok: false, error: `All fulfillment orders are closed. ${updated.error}` }
|
||||||
|
}
|
||||||
|
return { ok: false, error: "No actionable 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 updated = await updateTrackingOnExistingFulfillment()
|
||||||
|
if (updated.ok) return updated
|
||||||
|
const fallback = await createLegacyFulfillmentFallback()
|
||||||
|
if (fallback.ok) return fallback
|
||||||
|
const scopes = await fetchScopes()
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Shopify fulfillment sync failed: ${details}. Existing fulfillment tracking update failed: ${updated.error}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
62
src/lib/shopify/supplierOrderShopifySync.ts
Normal file
62
src/lib/shopify/supplierOrderShopifySync.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import type { CanonicalFulfillmentStatus } from "@/lib/orders/fulfillmentStatus"
|
||||||
|
import type { SupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
|
||||||
|
import { clearOnHoldOnShopify, pushOnHoldToShopify } from "@/lib/shopify/orderHoldSync"
|
||||||
|
import { syncOrderFulfillmentToShopify } from "@/lib/shopify/supplierOrderFulfillmentSync"
|
||||||
|
|
||||||
|
export async function runSupplierOrderShopifySync(params: {
|
||||||
|
storeId: string
|
||||||
|
shopifyOrderId: string
|
||||||
|
previousCanonical: CanonicalFulfillmentStatus
|
||||||
|
newCanonical: CanonicalFulfillmentStatus
|
||||||
|
trackingNumber: string | null
|
||||||
|
trackingCompany: string | null
|
||||||
|
trackingUrl: string | null
|
||||||
|
holdCode: SupplierHoldReasonCode | null
|
||||||
|
holdNote: string | null
|
||||||
|
}): Promise<{ ok: boolean; error?: string }> {
|
||||||
|
const store = await prisma.store.findUnique({
|
||||||
|
where: { id: params.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 accessToken = store.shopifyAccessToken
|
||||||
|
|
||||||
|
if (params.newCanonical === "ON_HOLD") {
|
||||||
|
if (!params.holdCode) {
|
||||||
|
return { ok: false, error: "Hold reason is required for On hold status." }
|
||||||
|
}
|
||||||
|
return pushOnHoldToShopify({
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
shopifyOrderId: params.shopifyOrderId,
|
||||||
|
code: params.holdCode,
|
||||||
|
note: params.holdNote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.previousCanonical === "ON_HOLD") {
|
||||||
|
const cleared = await clearOnHoldOnShopify({
|
||||||
|
cleanDomain,
|
||||||
|
accessToken,
|
||||||
|
shopifyOrderId: params.shopifyOrderId,
|
||||||
|
})
|
||||||
|
if (!cleared.ok) return cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.newCanonical === "SHIPPED" || params.newCanonical === "FULFILLED") {
|
||||||
|
return syncOrderFulfillmentToShopify(
|
||||||
|
params.storeId,
|
||||||
|
params.shopifyOrderId,
|
||||||
|
params.newCanonical,
|
||||||
|
params.trackingNumber,
|
||||||
|
params.trackingCompany,
|
||||||
|
params.trackingUrl,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user