fefef038c5
* fix(sales-orders): pin the sales_order_items embed FK and teach the embed guard composite keys Migration 20260902180000_sales_orders_hardening added a composite (sales_order_id, company_id) foreign key from sales_order_items to sales_orders next to the original single-column one. PostgREST then saw two relationships and answered every `items:sales_order_items(*)` embed with HTTP 300 / PGRST201, so kundorder list, detail, create and the MCP list tool all failed on prod and staging with "Oväntat serverfel". - Hint the three embeds with `!sales_order_items_sales_order_id_fkey` (route, load service, MCP list tool). - scripts/checks/ambiguous-embed.mjs only parsed single-column `FOREIGN KEY (col)`, which is why the ratchet reported 0 for this pair. It now reads composite column lists (named or default constraint name) in both CREATE TABLE and ALTER TABLE, derives the same 17 ambiguous pairs prod's pg_constraint reports, and flags all three shipped sites on main. - Unit tests for the composite shapes: alongside a single-column key, replacing one, and inline in CREATE TABLE. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7xJQL2aZo6iRHxCZNntUq * fix(checks): drop composite embed edges when DROP COLUMN removes a member column Postgres drops every foreign key a column takes part in, so the ambiguous-embed parser must release a composite edge (and its constraint name) when one of its columns is dropped, not only the single-column key. Otherwise a later migration would keep a pair armed for a relationship that no longer exists and reject valid embeds. Regression case added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7xJQL2aZo6iRHxCZNntUq --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
63 lines
2.4 KiB
TypeScript
63 lines
2.4 KiB
TypeScript
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
import type { SalesOrder, SalesOrderItem } from '@/types'
|
|
import { maskEmbeddedCustomer } from '@/lib/customers/protect-personal-number'
|
|
import { deliveryProgress, invoicingProgress, withInvoicedQuantities } from './progress'
|
|
import { fail, failDb, type ServiceResult } from './result'
|
|
|
|
/**
|
|
* Invoiced quantity per order line for a set of orders, from the
|
|
* SECURITY INVOKER RPC (RLS applies). Returns an empty map for no ids.
|
|
*/
|
|
export async function fetchInvoicedQuantities(
|
|
supabase: SupabaseClient,
|
|
orderIds: string[],
|
|
): Promise<{ ok: true; byItem: Map<string, number> } | { ok: false; dbError: unknown }> {
|
|
if (orderIds.length === 0) return { ok: true, byItem: new Map() }
|
|
const { data, error } = await supabase.rpc('sales_order_invoiced_quantities', {
|
|
p_order_ids: orderIds,
|
|
})
|
|
if (error) return { ok: false, dbError: error }
|
|
const byItem = new Map<string, number>()
|
|
for (const row of (data ?? []) as Array<{ sales_order_item_id: string; invoiced_qty: number | string }>) {
|
|
byItem.set(row.sales_order_item_id, Number(row.invoiced_qty))
|
|
}
|
|
return { ok: true, byItem }
|
|
}
|
|
|
|
/**
|
|
* One order with its customer (masked), its lines in sort order, the
|
|
* derived invoiced/remaining quantities and both progress axes.
|
|
*/
|
|
export async function loadSalesOrder(
|
|
supabase: SupabaseClient,
|
|
companyId: string,
|
|
orderId: string,
|
|
): Promise<ServiceResult<{ order: SalesOrder }>> {
|
|
const { data, error } = await supabase
|
|
.from('sales_orders')
|
|
.select('*, customer:customers(*), items:sales_order_items!sales_order_items_sales_order_id_fkey(*)')
|
|
.eq('id', orderId)
|
|
.eq('company_id', companyId)
|
|
.maybeSingle()
|
|
if (error) return failDb(error)
|
|
if (!data) return fail('SALES_ORDER_NOT_FOUND')
|
|
|
|
const invoiced = await fetchInvoicedQuantities(supabase, [orderId])
|
|
if (!invoiced.ok) return failDb(invoiced.dbError)
|
|
|
|
return { ok: true, order: decorate(maskEmbeddedCustomer(data as SalesOrder), invoiced.byItem) }
|
|
}
|
|
|
|
export function decorate(order: SalesOrder, invoiced: Map<string, number>): SalesOrder {
|
|
const items = withInvoicedQuantities(
|
|
[...((order.items ?? []) as SalesOrderItem[])].sort((a, b) => a.sort_order - b.sort_order),
|
|
invoiced,
|
|
)
|
|
return {
|
|
...order,
|
|
items,
|
|
delivery_progress: deliveryProgress(items),
|
|
invoicing_progress: invoicingProgress(items),
|
|
}
|
|
}
|