metatrondelivery/app/services/zones.server.ts
metatroncubeswdev 6598691372 feat: Phase 5 — multi-location, zones, rates, auto-assignment
- Prisma: Zone (postal-code list or radius), Rate (zone- or distance-band
  keyed), GeocodeCache (permanent address->lat/lng cache per
  IMPLEMENTATION_PLAN.md §9), Location.shopifyLocationId (maps to
  Shopify's own Location resource for inventory checks), Booking.zoneId
  (needed for per-zone delivery-density counts, not just per-location).
- app/lib/geo.ts: pure haversine distance + postal-code matching, unit
  tested against known city-to-city distances.
- app/services/zones.server.ts: geocoding (Google Maps Geocoding API,
  cached — never re-geocodes the same address twice), zone eligibility,
  nearest-location auto-assignment ranked by distance, delivery-density
  threshold checks (a sparse zone doesn't unlock until minOrders bookings
  have already routed through it), and inventory-based location exclusion
  via Shopify's InventoryLevel API (locations without a mapped
  shopifyLocationId are left in rather than false-negative excluded).
- app/services/rates.server.ts: pure rate resolution by zone or distance
  band, cheapest-match-wins when bands overlap.
- apps.scheduling.availability.tsx: LOCAL_DELIVERY requests with a
  postalCode/address now auto-assign to the nearest eligible,
  density-qualified zone/location instead of the shop's default location;
  response includes the matched rate. Also fixed a real gap left over from
  Phase 4: this route never actually read Booking counts into
  getAvailability's `consumed` map, so capacity always showed as fully
  available regardless of existing bookings — now it does.
- extensions/datetime-widget: LOCAL_DELIVERY now asks for a postal code
  before showing dates; PICKUP shows a Google Maps pin for the location
  (both gated on an optional Maps API key — a block setting in the theme
  editor, since it needs to be public/client-side, not an app secret);
  confirmation display and cart attributes (dd_zone_id, dd_rate_label)
  carry the resolved zone/rate through to checkout.
- extensions/delivery-customization: now appends the resolved rate to the
  relabeled delivery option ("Local delivery — Aug 25 ($5.99)") when one's
  configured — real Cart Transform-based fee *charging* stays deferred to
  v2 per IMPLEMENTATION_PLAN.md §5.4, this is display-only.
- Admin: /app/zones and /app/rates (Polaris CRUD, mirroring Phase 1's
  patterns), plus shopifyLocationId and auto-geocode-on-save added to the
  location edit form.

Fixed one real bug caught only by `npm run build` (not tsc/vitest, which
both passed clean): app.rates._index.tsx's component called
formatPriceLabel from rates.server.ts, and Remix correctly refuses to
bundle anything imported from a .server.ts path for the client. Moved the
pure (no I/O, no Prisma) formatter to app/lib/currency.ts.

Verified: lint, typecheck, 86 unit tests (+21 new: geo, zones, rates,
delivery-customization's rate-label case with a real WASM fixture run),
16 integration tests against live Postgres (+8 new: geocode caching,
postal/radius zone matching, nearest-first ranking, density thresholds),
both builds, and a live script exercising the full
zone-match -> density-check -> rate-resolve -> availability pipeline
together against the Postgres container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 03:27:21 -04:00

225 lines
7.6 KiB
TypeScript

import db from "../db.server";
import { haversineDistanceKm, isPostalCodeListed, type Coordinates } from "../lib/geo";
// I/O orchestration around the pure math in lib/geo.ts. Geocoding results
// are cached permanently per IMPLEMENTATION_PLAN.md §9 ("cache geocode
// results per address; don't call the maps API on every availability
// request") — addresses don't move, so there's no cache invalidation to
// worry about, only cache growth, which is fine for this volume.
export interface ZoneLike {
id: string;
locationId: string;
type: string; // "postal" | "radius"
postalCodes: string[];
radiusKm: number | null;
minOrders: number | null;
active: boolean;
}
function normalizeAddressKey(address: string): string {
return address.trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* Geocodes an address via the Google Maps Geocoding API, using
* GOOGLE_MAPS_API_KEY. Returns null (rather than throwing) when no key is
* configured or the address can't be resolved — callers should treat that
* as "can't determine eligibility," not a hard error, since a merchant may
* not have set up Maps yet.
*/
export async function geocodeAddress(address: string): Promise<Coordinates | null> {
const key = normalizeAddressKey(address);
const cached = await db.geocodeCache.findUnique({ where: { normalizedKey: key } });
if (cached) return { lat: cached.lat, lng: cached.lng };
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) return null;
const url = new URL("https://maps.googleapis.com/maps/api/geocode/json");
url.searchParams.set("address", address);
url.searchParams.set("key", apiKey);
const res = await fetch(url.toString());
if (!res.ok) return null;
const body = (await res.json()) as {
status: string;
results: Array<{ geometry: { location: { lat: number; lng: number } } }>;
};
if (body.status !== "OK" || body.results.length === 0) return null;
const { lat, lng } = body.results[0].geometry.location;
await db.geocodeCache.upsert({
where: { normalizedKey: key },
create: { normalizedKey: key, lat, lng },
update: { lat, lng },
});
return { lat, lng };
}
/**
* Radius zones use the location's own lat/lng as the center — postal zones
* need no coordinates at all. This is why isZoneEligible takes the
* location's coordinates separately rather than assuming the zone carries
* its own center point.
*/
export function isZoneEligible(
zone: ZoneLike,
locationCoordinates: Coordinates | null,
customer: { coordinates?: Coordinates; postalCode?: string },
): boolean {
if (!zone.active) return false;
if (zone.type === "postal") {
return customer.postalCode ? isPostalCodeListed(customer.postalCode, zone.postalCodes) : false;
}
if (zone.type === "radius") {
if (!locationCoordinates || !customer.coordinates || zone.radiusKm == null) return false;
return haversineDistanceKm(customer.coordinates, locationCoordinates) <= zone.radiusKm;
}
return false;
}
export interface EligibleLocationMatch {
locationId: string;
zoneId: string;
distanceKm: number | null;
}
/**
* Nearest-location auto-assign (PRODUCT_STRATEGY.md §2 "Auto location
* assignment"): given a shopper's address, finds every active zone across
* the shop's locations that covers it, ranked nearest-first when distance
* is known (radius zones) — postal zones without location coordinates sort
* after distance-ranked ones, in the order returned by the query.
*/
export async function findEligibleLocationsForDelivery(
shopDomain: string,
customer: { address?: string; postalCode?: string },
): Promise<EligibleLocationMatch[]> {
const customerCoordinates = customer.address ? await geocodeAddress(customer.address) : null;
const locations = await db.location.findMany({
where: { shopDomain, active: true },
include: { zones: { where: { active: true } } },
});
const matches: EligibleLocationMatch[] = [];
for (const location of locations) {
const locationCoordinates = location.lat != null && location.lng != null ? { lat: location.lat, lng: location.lng } : null;
for (const zone of location.zones) {
const eligible = isZoneEligible(zone, locationCoordinates, {
coordinates: customerCoordinates ?? undefined,
postalCode: customer.postalCode,
});
if (!eligible) continue;
const distanceKm =
locationCoordinates && customerCoordinates ? haversineDistanceKm(customerCoordinates, locationCoordinates) : null;
matches.push({ locationId: location.id, zoneId: zone.id, distanceKm });
}
}
return matches.sort((a, b) => {
if (a.distanceKm == null && b.distanceKm == null) return 0;
if (a.distanceKm == null) return 1;
if (b.distanceKm == null) return -1;
return a.distanceKm - b.distanceKm;
});
}
/**
* Delivery-density threshold (PRODUCT_STRATEGY.md §3.2): a sparse zone
* shouldn't unlock delivery capacity until `minOrders` bookings have
* already routed through it — cuts delivery cost on routes that wouldn't
* be worth a driver trip for just one or two orders. A zone with no
* minOrders set (or 0) always passes.
*/
export async function meetsDeliveryDensity(shopDomain: string, zone: ZoneLike): Promise<boolean> {
if (!zone.minOrders || zone.minOrders <= 0) return true;
const count = await db.booking.count({
where: { shopDomain, zoneId: zone.id, status: { in: ["confirmed", "fulfilled"] } },
});
return count >= zone.minOrders;
}
interface AdminGraphQLClient {
graphql(query: string, options?: { variables?: Record<string, unknown> }): Promise<Response>;
}
/**
* Inventory-based location exclusion (PRODUCT_STRATEGY.md §2): drops any
* candidate location that doesn't stock at least one of the cart's
* products, per Shopify's InventoryLevel API. Locations without a
* shopifyLocationId mapping are left in (can't check what we can't query —
* excluding them would be a false negative, not a safe default) and this
* whole check is a no-op when productIds is empty (nothing to check stock
* for, e.g. availability requests made before a cart exists).
*/
export async function excludeLocationsWithoutStock(
admin: AdminGraphQLClient,
locationIds: string[],
productVariantGids: string[],
): Promise<Set<string>> {
if (productVariantGids.length === 0) return new Set(locationIds);
const locations = await db.location.findMany({
where: { id: { in: locationIds } },
select: { id: true, shopifyLocationId: true },
});
const inStock = new Set<string>();
for (const location of locations) {
if (!location.shopifyLocationId) {
inStock.add(location.id); // unmapped — can't verify, don't exclude
continue;
}
const response = await admin.graphql(
`#graphql
query LocationStock($locationId: ID!, $variantIds: [ID!]!) {
location(id: $locationId) {
id
}
nodes(ids: $variantIds) {
... on ProductVariant {
inventoryItem {
inventoryLevel(locationId: $locationId) {
quantities(names: ["available"]) {
quantity
}
}
}
}
}
}`,
{ variables: { locationId: location.shopifyLocationId, variantIds: productVariantGids } },
);
const body = (await response.json()) as {
data?: {
nodes: Array<{ inventoryItem?: { inventoryLevel?: { quantities: Array<{ quantity: number }> } | null } } | null>;
};
};
const hasStock = (body.data?.nodes ?? []).some((node) =>
(node?.inventoryItem?.inventoryLevel?.quantities ?? []).some((q) => q.quantity > 0),
);
if (hasStock) inStock.add(location.id);
}
return inStock;
}