feat(jobs/new): clinic picks a lab catalog product, not a raw type

What the clinic sees has changed from a fixed 6-item Protez Türü dropdown
to the actual products that lab has published in its catalog. Picking 'Premium
Zirkonyum (1500 TRY / diş)' is now an option; picking the generic
'Zirkonyum' bucket is not.

Wiring
  - DB: jobs.prostheticId string column (optional — legacy jobs stay valid).
    The denormalised prostheticType is still written, sourced server-side
    from the chosen catalog row, so reports/aggregates keep working.
  - validation/job.ts: prostheticId required, prostheticType removed from
    the form payload (computed instead).
  - job-actions.ts: looks up the catalog row, verifies tenantId match +
    not archived, then pulls .type and .unitPrice from it to drive
    calculateJobPriceForProsthetic.

Pricing helper
  - New calculateJobPriceForProsthetic(prosthetic, clinicTenantId,
    teethCount). Reuses the same clinic_pricing cascade
    (custom price wins, otherwise discountPercent off catalog) but skips
    the type-based catalog lookup entirely — it already has the row.
  - /api/pricing/quote rewritten to accept { prostheticId, teethCount },
    still gated on an approved connection between clinic and the lab that
    owns the product.

UI
  - jobs/new page server-loads each connected lab's active prosthetics
    once and hands them to NewJobForm as prostheticsByLab.
  - NewJobForm: 'Ürün *' Select replaces 'Protez Türü *'. The list filters
    by the currently selected lab; switching labs clears the chosen
    product. Each option shows name + 'unitPrice / diş' on the right.
    Once selected, a small caption surfaces the underlying category
    label ('Kategori: Zirkonyum') so the clinic still understands what
    bucket the product falls into.
  - PriceQuoteCard's hasInputs gating moved off prostheticType to
    prostheticId.
This commit is contained in:
kovakmedya
2026-05-22 01:01:35 +03:00
parent dfd30ef239
commit 97a6031992
7 changed files with 224 additions and 68 deletions
+39 -6
View File
@@ -7,7 +7,7 @@ import { z } from "zod";
import { logAudit } from "./audit";
import { syncFinanceForJob } from "./finance-sync";
import { createNotification } from "./notification-helpers";
import { calculateJobPrice } from "./pricing";
import { calculateJobPriceForProsthetic } from "./pricing";
import {
DATABASE_ID,
TABLES,
@@ -15,6 +15,7 @@ import {
type Job,
type JobStep,
type Patient,
type Prosthetic,
} from "./schema";
import { createAdminClient } from "./server";
import { requireRole, requireTenant, requireTenantKind } from "./tenant-guard";
@@ -43,7 +44,7 @@ function pickFields(formData: FormData) {
labTenantId: String(formData.get("labTenantId") ?? "").trim(),
patientId: String(formData.get("patientId") ?? "").trim(),
patientCode: String(formData.get("patientCode") ?? "").trim(),
prostheticType: String(formData.get("prostheticType") ?? "").trim(),
prostheticId: String(formData.get("prostheticId") ?? "").trim(),
teeth: formData.getAll("teeth").map((v) => String(v).trim()).filter(Boolean),
color: String(formData.get("color") ?? "").trim(),
description: String(formData.get("description") ?? "").trim(),
@@ -137,12 +138,43 @@ export async function createJobAction(
};
}
// Resolve the chosen catalog product. It must belong to the selected lab
// and still be active — anything else is rejected with a clear error.
let prosthetic: Prosthetic;
try {
const row = await tablesDB.getRow(
DATABASE_ID,
TABLES.prosthetics,
parsed.data.prostheticId,
);
prosthetic = row as unknown as Prosthetic;
} catch {
return {
ok: false,
error: "Seçilen ürün bulunamadı.",
fieldErrors: { prostheticId: "Ürün bulunamadı." },
};
}
if (prosthetic.tenantId !== parsed.data.labTenantId) {
return {
ok: false,
error: "Seçilen ürün bu laboratuvara ait değil.",
fieldErrors: { prostheticId: "Ürün bu lab'a ait değil." },
};
}
if (prosthetic.archived) {
return {
ok: false,
error: "Seçilen ürün arşivlenmiş.",
fieldErrors: { prostheticId: "Bu ürün artık aktif değil." },
};
}
try {
// Server-side price calculation — clinic never sets the price.
const quote = await calculateJobPrice({
labTenantId: parsed.data.labTenantId,
const quote = await calculateJobPriceForProsthetic({
prosthetic,
clinicTenantId: ctx.tenantId,
prostheticType: parsed.data.prostheticType,
teethCount: parsed.data.teeth.length,
});
@@ -156,7 +188,8 @@ export async function createJobAction(
createdBy: ctx.user.id,
patientId: parsed.data.patientId,
patientCode,
prostheticType: parsed.data.prostheticType,
prostheticType: prosthetic.type,
prostheticId: prosthetic.$id,
memberCount: parsed.data.teeth.length,
teeth: parsed.data.teeth,
color: parsed.data.color,
+81
View File
@@ -28,6 +28,87 @@ export type JobPriceQuote = {
discountPercent?: number;
};
/**
* Price an explicit catalog row for a clinic. Use this when the clinic picked
* a specific product from the lab catalog — the catalog row is the source of
* truth for the unit price, clinic_pricing only adds an override.
*/
export async function calculateJobPriceForProsthetic(opts: {
prosthetic: Prosthetic;
clinicTenantId: string;
teethCount: number;
}): Promise<JobPriceQuote | null> {
if (opts.teethCount <= 0) return null;
const { tablesDB } = createAdminClient();
const [pricingRes, labSettingsRes] = await Promise.all([
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.clinicPricing,
queries: [
Query.equal("labTenantId", opts.prosthetic.tenantId),
Query.equal("clinicTenantId", opts.clinicTenantId),
Query.equal("prostheticType", opts.prosthetic.type),
Query.limit(1),
],
}),
tablesDB.listRows({
databaseId: DATABASE_ID,
tableId: TABLES.tenantSettings,
queries: [Query.equal("tenantId", opts.prosthetic.tenantId), Query.limit(1)],
}),
]);
const override = pricingRes.rows[0] as unknown as ClinicPricing | undefined;
const labSettings = labSettingsRes.rows[0] as unknown as TenantSettings | undefined;
const fallbackCurrency = labSettings?.defaultCurrency ?? "TRY";
const catalogUnitPrice = opts.prosthetic.unitPrice;
const catalogTotal = catalogUnitPrice * opts.teethCount;
if (override?.customUnitPrice !== undefined && override.customUnitPrice !== null) {
const unit = override.customUnitPrice;
const amount = unit * opts.teethCount;
return {
amount,
currency: override.currency || opts.prosthetic.currency || fallbackCurrency,
source: "clinic_custom",
unitPrice: unit,
catalogUnitPrice,
catalogTotal,
savings: catalogTotal > amount ? catalogTotal - amount : undefined,
teethCount: opts.teethCount,
};
}
if (
override?.discountPercent !== undefined &&
override.discountPercent !== null &&
override.discountPercent > 0
) {
const discounted = catalogUnitPrice * (1 - override.discountPercent / 100);
const amount = discounted * opts.teethCount;
return {
amount,
currency: override.currency || opts.prosthetic.currency || fallbackCurrency,
source: "clinic_discount",
unitPrice: discounted,
catalogUnitPrice,
catalogTotal,
savings: catalogTotal - amount,
discountPercent: override.discountPercent,
teethCount: opts.teethCount,
};
}
return {
amount: catalogTotal,
currency: opts.prosthetic.currency || fallbackCurrency,
source: "catalog",
unitPrice: catalogUnitPrice,
catalogUnitPrice,
catalogTotal,
teethCount: opts.teethCount,
};
}
/**
* Resolves the price a clinic should be charged for a job, given the lab's
* catalog and any clinic-specific pricing overrides.
+1
View File
@@ -100,6 +100,7 @@ export interface Job extends Row {
patientCode: string;
patientId?: string;
prostheticType: ProstheticType;
prostheticId?: string;
memberCount: number;
teeth?: string[];
color?: string;