feat(freight-api): seal EDR side of contracts with the one global stamp

This commit is contained in:
ghost2023
2026-08-12 06:26:46 +03:00
parent e52d666647
commit a603807e8e
21 changed files with 679 additions and 183 deletions

View File

@@ -2,6 +2,7 @@ import { Injectable } from "@nestjs/common";
import { StampSettingsService } from "../../stamp-settings/stamp-settings.service";
import { PdfRenderService } from "./pdf-render.service";
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
import {
PdfColor,
assembleSinglePagePdf,
@@ -239,10 +240,8 @@ export class InvoiceDocumentService {
const showCategory = Boolean(model.categoryHeader);
const sealText =
model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR");
const sealMarkup = model.stampImageUrl
? `<img src="${esc(model.stampImageUrl)}" alt="Company stamp" />`
: esc(sealText);
const sealClass = model.stampImageUrl ? "seal seal-image" : "seal";
const sealInner = sealMarkup(model.stampImageUrl, sealText);
const sealCssClass = sealClass(model.stampImageUrl);
const summaryRows = model.summary
.map((row) => `<div><span>${esc(row.label)}</span>${esc(row.value)}</div>`)
@@ -281,8 +280,7 @@ export class InvoiceDocumentService {
.meta { text-align: right; font-size: 12px; color: #475569; }
.meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; }
.seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; }
.seal.seal-image { border: none; border-radius: 0; opacity: 1; transform: none; }
.seal img { max-width: 100%; max-height: 100%; object-fit: contain; }
${sealImageCss()}
.summary { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 28px; margin: 24px 150px 16px 0; font-size: 13px; }
.summary div { border-bottom: 1px solid #e2e8f0; padding: 7px 0; }
.summary span { color: #64748b; display: block; font-size: 11px; margin-bottom: 3px; }
@@ -310,7 +308,7 @@ export class InvoiceDocumentService {
Issued: ${esc(date(model.issuedAt))}
</div>
</div>
<div class="${sealClass}">${sealMarkup}</div>
<div class="${sealCssClass}">${sealInner}</div>
<div class="summary">${summaryRows}</div>
<table>
<thead>

View File

@@ -0,0 +1,78 @@
import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util";
/**
* These three helpers are the single image-vs-text branch shared by every
* EDR document's round seal, so a regression here silently unstamps invoices,
* warehouse release papers and handover papers at once.
*/
describe("seal markup helpers", () => {
const STAMP = "data:image/png;base64,QUJD";
describe("sealMarkup", () => {
it("renders the stamp image when one is configured", () => {
expect(sealMarkup(STAMP, ["EDR", "Warehouse"])).toBe(
`<img src="${STAMP}" alt="Company stamp" />`,
);
});
it("falls back to text rings when no stamp is configured", () => {
expect(sealMarkup(null, ["EDR", "Warehouse", "Cleared"])).toBe(
"<span>EDR<br />Warehouse<br />Cleared</span>",
);
});
it("treats undefined as unset", () => {
expect(sealMarkup(undefined, "EDR")).toBe("<span>EDR</span>");
});
it("accepts a bare string as a single line", () => {
expect(sealMarkup(null, "EDR")).toBe("<span>EDR</span>");
});
it("escapes text lines so document data cannot inject markup", () => {
expect(sealMarkup(null, ['<script>alert("x")</script>'])).toBe(
"<span>&lt;script&gt;alert(&quot;x&quot;)&lt;/script&gt;</span>",
);
});
it("escapes the image src so it cannot break out of the attribute", () => {
expect(sealMarkup('data:image/png;base64,A" onerror="x', "EDR")).toBe(
'<img src="data:image/png;base64,A&quot; onerror=&quot;x" alt="Company stamp" />',
);
});
});
describe("sealClass", () => {
it("adds the image modifier only when stamped", () => {
expect(sealClass(STAMP)).toBe("seal seal-image");
expect(sealClass(null)).toBe("seal");
});
it("honours a document's own seal selector", () => {
expect(sealClass(STAMP, "sig-stamp-box")).toBe(
"sig-stamp-box sig-stamp-box-image",
);
expect(sealClass(null, "sig-stamp-box")).toBe("sig-stamp-box");
});
});
describe("sealImageCss", () => {
it("neutralizes the drawn ring and rotation for a real stamp image", () => {
const css = sealImageCss();
expect(css).toContain(".seal.seal-image { border: none;");
expect(css).toContain("transform: none;");
// The ::before pseudo-element draws the inner ring of the text seal.
expect(css).toContain(".seal.seal-image::before { content: none; }");
expect(css).toContain(".seal img { max-width: 100%;");
});
it("scopes every rule to the given selector", () => {
const css = sealImageCss("sig-stamp-box");
expect(css).not.toContain(".seal");
expect(css).toContain(".sig-stamp-box.sig-stamp-box-image");
expect(css).toContain(".sig-stamp-box img");
});
});
});

View File

@@ -0,0 +1,64 @@
/**
* The single decision every EDR document makes about its round seal: draw the
* one uploaded company stamp when one is configured (StampSettingsService), or
* fall back to the plain text rings the document styles itself.
*
* Only the image-vs-text branch and the image overrides live here — each
* document keeps its own `.seal` geometry (the invoice's seal is absolutely
* positioned top-right, the warehouse papers' sit inline above the signature
* lines), so centralizing the source of the stamp does not relayout anything.
*
* These helpers are for the HTML/Chromium render path. The hand-built vector
* fallbacks in styled-pdf.util.ts cannot embed a raster image and continue to
* draw their vector seal — see InvoiceDocumentService for that caveat.
*/
/** Escape a value for interpolation into HTML text or a quoted attribute. */
function escapeHtml(value: unknown): string {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
/**
* CSS overrides that neutralize a document's own ring/rotation styling when the
* seal is a real stamp image. Append inside a document's <style> block, after
* its own `.seal` rules. `selector` is the document's seal class ("seal").
*/
export function sealImageCss(selector = "seal"): string {
return [
`.${selector}.${selector}-image { border: none; border-radius: 0; opacity: 1; transform: none; }`,
`.${selector}.${selector}-image::before { content: none; }`,
`.${selector} img { max-width: 100%; max-height: 100%; object-fit: contain; }`,
].join("\n ");
}
/**
* Inner markup for the seal element: the stamp image, or the given text lines
* wrapped in a <span> (matching the `.seal span { position: relative }` rule
* the ring-drawing documents rely on).
*
* `stampImageUrl` is expected to be a data URL from
* StampSettingsService.getStampImageUrl(); null renders the text fallback.
*/
export function sealMarkup(
stampImageUrl: string | null | undefined,
textLines: string | string[],
): string {
if (stampImageUrl) {
return `<img src="${escapeHtml(stampImageUrl)}" alt="Company stamp" />`;
}
const lines = Array.isArray(textLines) ? textLines : [textLines];
return `<span>${lines.map(escapeHtml).join("<br />")}</span>`;
}
/** Class attribute for the seal element — adds the image modifier when stamped. */
export function sealClass(
stampImageUrl: string | null | undefined,
selector = "seal",
): string {
return stampImageUrl ? `${selector} ${selector}-image` : selector;
}