- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
181 lines
5.1 KiB
TypeScript
181 lines
5.1 KiB
TypeScript
import prisma from "../config/database";
|
|
|
|
// Default patterns (backward compatible with existing numbers)
|
|
const DEFAULT_OFFER_PATTERN = "{YYYY}/{PREFIX}/{NNN}";
|
|
const DEFAULT_ORDER_PATTERN = "{YY}{CODE}{NNNN}";
|
|
const DEFAULT_INVOICE_PATTERN = "{YY}{CODE}{NNNN}";
|
|
|
|
/**
|
|
* Apply a numbering pattern template.
|
|
* Placeholders: {YYYY}, {YY}, {PREFIX}, {CODE}, {N+} (padding = count of N's)
|
|
*/
|
|
function applyPattern(
|
|
pattern: string,
|
|
vars: { year: number; prefix: string; code: string; seq: number },
|
|
): string {
|
|
const yyyy = String(vars.year);
|
|
const yy = yyyy.slice(-2);
|
|
|
|
return pattern.replace(/\{(\w+)\}/g, (match, key: string) => {
|
|
if (key === "YYYY") return yyyy;
|
|
if (key === "YY") return yy;
|
|
if (key === "PREFIX") return vars.prefix;
|
|
if (key === "CODE") return vars.code;
|
|
if (/^N+$/.test(key)) return String(vars.seq).padStart(key.length, "0");
|
|
return match;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Extract the static prefix and sequence position from a pattern.
|
|
* Used to build SQL LIKE patterns for MAX(seq) queries.
|
|
*/
|
|
function buildLikePattern(
|
|
pattern: string,
|
|
vars: { year: number; prefix: string; code: string },
|
|
): { likePattern: string; prefixLen: number } {
|
|
const yyyy = String(vars.year);
|
|
const yy = yyyy.slice(-2);
|
|
|
|
let staticPrefix = "";
|
|
let foundSeq = false;
|
|
const parts = pattern.split(/(\{[^}]+\})/);
|
|
|
|
for (const part of parts) {
|
|
const m = part.match(/^\{(\w+)\}$/);
|
|
if (!m) {
|
|
staticPrefix += part;
|
|
continue;
|
|
}
|
|
const key = m[1];
|
|
if (/^N+$/.test(key)) {
|
|
foundSeq = true;
|
|
break;
|
|
}
|
|
if (key === "YYYY") staticPrefix += yyyy;
|
|
else if (key === "YY") staticPrefix += yy;
|
|
else if (key === "PREFIX") staticPrefix += vars.prefix;
|
|
else if (key === "CODE") staticPrefix += vars.code;
|
|
}
|
|
|
|
if (!foundSeq) {
|
|
return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length };
|
|
}
|
|
|
|
return { likePattern: staticPrefix + "%", prefixLen: staticPrefix.length };
|
|
}
|
|
|
|
async function getSettings() {
|
|
return prisma.company_settings.findFirst({
|
|
select: {
|
|
quotation_prefix: true,
|
|
order_type_code: true,
|
|
invoice_type_code: true,
|
|
offer_number_pattern: true,
|
|
order_number_pattern: true,
|
|
invoice_number_pattern: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Next offer/quotation number.
|
|
*/
|
|
export async function generateOfferNumber(): Promise<string> {
|
|
const settings = await getSettings();
|
|
const pattern = settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
|
|
const prefix = settings?.quotation_prefix || "NA";
|
|
const year = new Date().getFullYear();
|
|
|
|
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
|
year,
|
|
prefix,
|
|
code: "",
|
|
});
|
|
|
|
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
|
SELECT COALESCE(MAX(CAST(SUBSTRING(quotation_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq
|
|
FROM quotations
|
|
WHERE quotation_number LIKE ${likePattern}
|
|
`;
|
|
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
|
|
|
return applyPattern(pattern, { year, prefix, code: "", seq: nextNum });
|
|
}
|
|
|
|
/**
|
|
* Shared number for orders and projects.
|
|
*/
|
|
export async function generateSharedNumber(): Promise<string> {
|
|
const settings = await getSettings();
|
|
const pattern = settings?.order_number_pattern || DEFAULT_ORDER_PATTERN;
|
|
const code = settings?.order_type_code || "71";
|
|
const year = new Date().getFullYear();
|
|
|
|
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
|
year,
|
|
prefix: "",
|
|
code,
|
|
});
|
|
|
|
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
|
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
|
|
SELECT CAST(SUBSTRING(order_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
|
|
FROM orders WHERE order_number LIKE ${likePattern}
|
|
UNION ALL
|
|
SELECT CAST(SUBSTRING(project_number, ${prefixLen} + 1) AS UNSIGNED) AS seq
|
|
FROM projects WHERE project_number LIKE ${likePattern}
|
|
) combined
|
|
`;
|
|
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
|
|
|
return applyPattern(pattern, { year, prefix: "", code, seq: nextNum });
|
|
}
|
|
|
|
/**
|
|
* Next invoice number.
|
|
*/
|
|
export async function generateInvoiceNumber(
|
|
_year?: number,
|
|
): Promise<{ number: string; next_number: string }> {
|
|
const settings = await getSettings();
|
|
const pattern = settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN;
|
|
const code = settings?.invoice_type_code || "81";
|
|
const year = _year || new Date().getFullYear();
|
|
|
|
const { likePattern, prefixLen } = buildLikePattern(pattern, {
|
|
year,
|
|
prefix: "",
|
|
code,
|
|
});
|
|
|
|
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
|
SELECT COALESCE(MAX(CAST(SUBSTRING(invoice_number, ${prefixLen} + 1) AS UNSIGNED)), 0) as max_seq
|
|
FROM invoices
|
|
WHERE invoice_number LIKE ${likePattern}
|
|
`;
|
|
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
|
|
|
const number = applyPattern(pattern, {
|
|
year,
|
|
prefix: "",
|
|
code,
|
|
seq: nextNum,
|
|
});
|
|
return { number, next_number: number };
|
|
}
|
|
|
|
/** Preview what a pattern would produce (for settings UI) */
|
|
export function previewPattern(
|
|
pattern: string,
|
|
prefix: string,
|
|
code: string,
|
|
): string {
|
|
return applyPattern(pattern, {
|
|
year: new Date().getFullYear(),
|
|
prefix,
|
|
code,
|
|
seq: 1,
|
|
});
|
|
}
|