Audit stati ordine + fix P1 (finestra su consegna, ordini chiusi)
Audit AUDIT-STATI-ORDINE.md: matrice stato ordine x normativa (Art. 52/56/57) x comportamento attuale x gap, con fix prioritizzati. Fix P1 (correttezza legale): - G1+G4: la finestra 14gg decorre dalla CONSEGNA (evento fulfillment DELIVERED), non dalla spedizione (Art. 52 = possesso fisico). Se non consegnato, la finestra non e' iniziata -> computeDeadline null -> non blocca mai. - G5: ordini annullati (cancelledAt) o rimborsati/voided (displayFinancialStatus) -> skip creazione reso (evita doppio reso/rimborso), audit shopify_return_skipped. - lookupOrder esteso: deliveredAt (da eventi fulfillment), cancelledAt, financialStatus. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Mv83a29B4eFv5ixoj6PoE1
This commit is contained in:
@@ -123,22 +123,25 @@ export interface MatchedOrder {
|
||||
email: string; // email dell'ordine (per precompilazione)
|
||||
createdAt: string;
|
||||
orderUrl: string; // URL pagina di stato dell'ordine (link per il cliente)
|
||||
fulfilledAt: string | null; // data ultima evasione (riferimento finestra), null se non evaso
|
||||
fulfilledAt: string | null; // data ultima evasione (spedizione), null se non evaso
|
||||
deliveredAt: string | null; // data di consegna (possesso fisico, Art. 52), null se non consegnato
|
||||
cancelledAt: string | null; // data annullamento ordine, null altrimenti
|
||||
financialStatus: string | null; // displayFinancialStatus (REFUNDED/VOIDED/PAID/...)
|
||||
lineItems: Array<{ productId: string | null; tags: string[] }>;
|
||||
}
|
||||
|
||||
// --- A6: finestra di recesso (deadline engine) ---------------------------
|
||||
// Riferimento = data di evasione (consegna ~ ricezione beni) se disponibile,
|
||||
// altrimenti data ordine (fallback conservativo). Scadenza = riferimento + giorni.
|
||||
// Riferimento = data di CONSEGNA (possesso fisico, Art. 52), NON la spedizione.
|
||||
// Se non consegnato -> la finestra non e' iniziata -> nessuna scadenza (non si
|
||||
// blocca mai): il recesso resta ammesso (nasce dalla conclusione del contratto).
|
||||
// NB: l'estensione a 12 mesi + 14gg per mancata informativa (Art. 49) NON e'
|
||||
// gestita qui: l'app FORNISCE l'informativa, quindi vale il termine ordinario.
|
||||
export function computeDeadline(
|
||||
match: MatchedOrder,
|
||||
windowDays: number,
|
||||
): Date | null {
|
||||
const ref = match.fulfilledAt || match.createdAt;
|
||||
if (!ref) return null;
|
||||
const d = new Date(ref);
|
||||
if (!match.deliveredAt) return null;
|
||||
const d = new Date(match.deliveredAt);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
d.setUTCDate(d.getUTCDate() + windowDays);
|
||||
return d;
|
||||
@@ -210,8 +213,20 @@ interface OrderLookupGraphQL {
|
||||
email?: string | null;
|
||||
createdAt?: string | null;
|
||||
statusPageUrl?: string | null;
|
||||
cancelledAt?: string | null;
|
||||
displayFulfillmentStatus?: string | null;
|
||||
fulfillments?: Array<{ createdAt?: string | null } | null> | null;
|
||||
displayFinancialStatus?: string | null;
|
||||
fulfillments?: Array<{
|
||||
createdAt?: string | null;
|
||||
events?: {
|
||||
edges?: Array<{
|
||||
node?: {
|
||||
status?: string | null;
|
||||
happenedAt?: string | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
} | null;
|
||||
} | null> | null;
|
||||
lineItems?: {
|
||||
edges?: Array<{
|
||||
node?: {
|
||||
@@ -236,9 +251,19 @@ const ORDER_LOOKUP_QUERY = `#graphql
|
||||
email
|
||||
createdAt
|
||||
statusPageUrl
|
||||
cancelledAt
|
||||
displayFulfillmentStatus
|
||||
displayFinancialStatus
|
||||
fulfillments(first: 10) {
|
||||
createdAt
|
||||
events(first: 25) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
happenedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
lineItems(first: 50) {
|
||||
edges {
|
||||
@@ -503,6 +528,13 @@ export async function lookupOrder(
|
||||
.map((f) => f?.createdAt)
|
||||
.filter((d): d is string => !!d)
|
||||
.sort();
|
||||
const deliveryDates = (node.fulfillments ?? [])
|
||||
.flatMap((f) => f?.events?.edges ?? [])
|
||||
.map((e) => e?.node)
|
||||
.filter((n): n is NonNullable<typeof n> => !!n)
|
||||
.filter((n) => n.status === "DELIVERED" && !!n.happenedAt)
|
||||
.map((n) => n.happenedAt as string)
|
||||
.sort();
|
||||
const lineItems = (node.lineItems?.edges ?? [])
|
||||
.map((e) => e?.node)
|
||||
.filter((n): n is NonNullable<typeof n> => !!n)
|
||||
@@ -519,6 +551,11 @@ export async function lookupOrder(
|
||||
fulfilledAt: fulfillmentDates.length
|
||||
? fulfillmentDates[fulfillmentDates.length - 1]
|
||||
: null,
|
||||
deliveredAt: deliveryDates.length
|
||||
? deliveryDates[deliveryDates.length - 1]
|
||||
: null,
|
||||
cancelledAt: node.cancelledAt ?? null,
|
||||
financialStatus: node.displayFinancialStatus ?? null,
|
||||
lineItems,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,44 +401,60 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
// Integrazione Resi Shopify: crea un reso nativo per gli ordini evasi
|
||||
// (best-effort; il recesso legale e' gia' registrato). Non evaso -> il
|
||||
// (best-effort; il recesso legale e' gia' registrato). Ordini annullati/
|
||||
// rimborsati -> skip (G5: evita doppio reso/rimborso). Non evaso -> il
|
||||
// merchant gestisce annullo/rimborso.
|
||||
let returnStatus: "created" | "no_returnable" | "error" = "error";
|
||||
try {
|
||||
const ret = await createShopifyReturn(admin, match.orderId);
|
||||
returnStatus = ret.status;
|
||||
if (ret.status === "created") {
|
||||
await db.withdrawalRequest.update({
|
||||
where: { id: created.id },
|
||||
data: { shopifyReturnId: ret.returnId },
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_created",
|
||||
detail: match.orderName,
|
||||
},
|
||||
});
|
||||
} else if (ret.status === "no_returnable") {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_skipped",
|
||||
detail: "ordine non evaso o nulla da rendere",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error("[recesso] returnCreate:", ret.error);
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_failed",
|
||||
detail: ret.error.slice(0, 200),
|
||||
},
|
||||
});
|
||||
const orderClosed =
|
||||
!!match.cancelledAt ||
|
||||
match.financialStatus === "REFUNDED" ||
|
||||
match.financialStatus === "VOIDED";
|
||||
if (orderClosed) {
|
||||
returnStatus = "no_returnable";
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_skipped",
|
||||
detail: "ordine annullato o rimborsato",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const ret = await createShopifyReturn(admin, match.orderId);
|
||||
returnStatus = ret.status;
|
||||
if (ret.status === "created") {
|
||||
await db.withdrawalRequest.update({
|
||||
where: { id: created.id },
|
||||
data: { shopifyReturnId: ret.returnId },
|
||||
});
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_created",
|
||||
detail: match.orderName,
|
||||
},
|
||||
});
|
||||
} else if (ret.status === "no_returnable") {
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_skipped",
|
||||
detail: "ordine non evaso o nulla da rendere",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error("[recesso] returnCreate:", ret.error);
|
||||
await db.auditLog.create({
|
||||
data: {
|
||||
shop,
|
||||
event: "shopify_return_failed",
|
||||
detail: ret.error.slice(0, 200),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[recesso] integrazione reso fallita:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[recesso] integrazione reso fallita:", e);
|
||||
}
|
||||
|
||||
// Tag "Recesso" sull'ordine (se abilitato nei Settings). Richiede write_orders.
|
||||
|
||||
Reference in New Issue
Block a user