feat(suppliers)!: full customers model - structured address + custom fields

The dodavatele modal is now an exact mirror of the customers modal
(minus the PDF field-order picker): Nazev+ARES, Ulice, Mesto+PSC, Zeme,
ICO+ARES, DIC, and the same "Vlastni pole" editor (maxWidth md).

Two migrations on sklad_suppliers:
- structured street/city/postal_code/country replace the free-text
  address blob (best-effort split: street / PSC regex / city)
- custom_fields LONGTEXT replaces contact_person/email/phone/notes;
  existing values are preserved as labeled custom fields

The encode/decode of the custom_fields blob is shared with customers
via the new utils/custom-fields.ts. The PO PDF supplier block renders
the structured address + custom-field lines like the customer block;
the supplier picker/detail selects and types follow. Legacy clients
sending the dropped keys are silently stripped (tested). Suppliers
list shows Ulice/Mesto instead of the dropped contact columns; search
covers name/ico/city.

BREAKING CHANGE: sklad_suppliers.address, contact_person, email,
phone, notes columns dropped (data migrated); deploy must run
prisma migrate deploy + generate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-10 20:11:22 +02:00
parent f1ce76d21d
commit db7a5c3d15
16 changed files with 578 additions and 232 deletions

View File

@@ -450,9 +450,20 @@ describe("GET /api/admin/issued-orders/suppliers", () => {
const row = body.data.find((s: { id: number }) => s.id === active.id);
expect(row.name).toBe("io_test_Aktivní dodavatel");
expect(row.ico).toBe("12345678");
// Lightweight lookup shape — all picker fields present.
// Lightweight lookup shape — all picker fields present (structured
// address since the 2026-06 supplier customers-model split; the
// email/phone columns were dropped in favour of custom_fields).
expect(Object.keys(row).sort()).toEqual(
["address", "dic", "email", "ico", "id", "name", "phone"].sort(),
[
"city",
"country",
"dic",
"ico",
"id",
"name",
"postal_code",
"street",
].sort(),
);
});
@@ -656,12 +667,15 @@ describe("renderIssuedOrderHtml", () => {
const issuer = { name: "Jan Novák" };
// sklad_suppliers shape: single Text address blob + ico/dic columns.
// sklad_suppliers shape: structured address + ico/dic columns.
const supplier = {
name: "Dodavatel s.r.o.",
ico: "12345678",
dic: "CZ12345678",
address: "Průmyslová 5\n190 00 Praha",
street: "Průmyslová 5",
city: "Praha",
postal_code: "190 00",
country: "Česká republika",
};
it("renders the PO number, items, and both party names (PO direction)", () => {
@@ -685,7 +699,7 @@ describe("renderIssuedOrderHtml", () => {
expect(html).toContain("Odběratel");
});
it("renders the supplier address (split on newlines) plus IČO and DIČ", () => {
it("renders the structured supplier address plus IČO and DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
@@ -694,25 +708,26 @@ describe("renderIssuedOrderHtml", () => {
"cs",
issuer,
);
// The single Text address blob becomes one address line per newline.
// street / "PSČ Město" / country — one address line each.
expect(html).toContain('<div class="address-line">Průmyslová 5</div>');
expect(html).toContain('<div class="address-line">190 00 Praha</div>');
expect(html).toContain('<div class="address-line">Česká republika</div>');
expect(html).toContain("IČ: 12345678");
expect(html).toContain("DIČ: CZ12345678");
});
it("renders a no-newline address as a single line and skips missing IČO/DIČ", () => {
it("skips empty address parts and missing IČO/DIČ", () => {
const html = renderIssuedOrderHtml(
order,
items,
{ name: "Jednořádkový", address: "Ulice 1, 100 00 Praha" },
{ name: "Jednořádkový", street: "Ulice 1", city: "Praha" },
null,
"cs",
issuer,
);
expect(html).toContain(
'<div class="address-line">Ulice 1, 100 00 Praha</div>',
);
expect(html).toContain('<div class="address-line">Ulice 1</div>');
// City without PSČ renders alone, no stray separator.
expect(html).toContain('<div class="address-line">Praha</div>');
expect(html).not.toContain("IČ: ");
expect(html).not.toContain("DIČ: ");
});

View File

@@ -240,18 +240,25 @@ describe("schema hardening — does not reject previously-valid input", () => {
).toBe(false);
});
it("warehouse supplier: empty email accepted, valid accepted, bad rejected", () => {
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "" }).success,
).toBe(true);
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "x@y.cz" })
.success,
).toBe(true);
expect(
CreateSupplierSchema.safeParse({ name: "Dodavatel", email: "nope" })
.success,
).toBe(false);
it("warehouse supplier: dropped legacy contact/address keys are silently stripped", () => {
// email/phone/contact_person/notes/address columns were replaced by the
// customers-model custom_fields blob (2026-06); legacy clients still
// sending them must not 400 — z.object strips unknown keys.
const parsed = CreateSupplierSchema.safeParse({
name: "Dodavatel",
email: "x@y.cz",
phone: "123",
contact_person: "Jan",
notes: "n",
address: "Ulice 1",
custom_fields: [{ name: "Kontakt", value: "Jan", showLabel: true }],
});
expect(parsed.success).toBe(true);
if (parsed.success) {
expect("email" in parsed.data).toBe(false);
expect("address" in parsed.data).toBe(false);
expect(parsed.data.custom_fields).toHaveLength(1);
}
});
it("trips: trip_date accepts a date-only AND a datetime round-trip from @db.Date", () => {