feat: system settings, dynamic logos, template numbering, permission consolidation
- 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>
This commit is contained in:
@@ -1,19 +1,122 @@
|
||||
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}";
|
||||
|
||||
/**
|
||||
* Shared number generator for orders and projects.
|
||||
* Format: YYtypeCode + 4-digit sequence (e.g., 26710003)
|
||||
* Queries MAX from both orders and projects tables.
|
||||
* 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 prisma.company_settings.findFirst({
|
||||
select: { order_type_code: true },
|
||||
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 typeCode = settings?.order_type_code || "71";
|
||||
const yy = String(new Date().getFullYear()).slice(-2);
|
||||
const prefix = `${yy}${typeCode}`;
|
||||
const prefixLen = prefix.length;
|
||||
const likePattern = `${prefix}%`;
|
||||
|
||||
const result = await prisma.$queryRaw<[{ max_seq: bigint | null }]>`
|
||||
SELECT COALESCE(MAX(seq), 0) as max_seq FROM (
|
||||
@@ -25,51 +128,53 @@ export async function generateSharedNumber(): Promise<string> {
|
||||
) combined
|
||||
`;
|
||||
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
||||
return `${prefix}${String(nextNum).padStart(4, "0")}`;
|
||||
|
||||
return applyPattern(pattern, { year, prefix: "", code, seq: nextNum });
|
||||
}
|
||||
|
||||
/**
|
||||
* Next offer number. Queries MAX from quotations table.
|
||||
* Format: YEAR/PREFIX/NNN (e.g., 2026/NA/008)
|
||||
* Next invoice number.
|
||||
*/
|
||||
export async function generateOfferNumber(): Promise<string> {
|
||||
const settings = await prisma.company_settings.findFirst({
|
||||
select: { quotation_prefix: true },
|
||||
});
|
||||
const prefix = settings?.quotation_prefix || "NA";
|
||||
const year = new Date().getFullYear();
|
||||
const likePattern = `${year}/${prefix}/%`;
|
||||
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 result = await prisma.$queryRaw<[{ max_num: bigint | null }]>`
|
||||
SELECT COALESCE(MAX(CAST(SUBSTRING_INDEX(quotation_number, '/', -1) AS UNSIGNED)), 0) as max_num
|
||||
FROM quotations
|
||||
WHERE quotation_number LIKE ${likePattern}
|
||||
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_num ?? 0) + 1;
|
||||
return `${year}/${prefix}/${String(nextNum).padStart(3, "0")}`;
|
||||
const nextNum = Number(result[0]?.max_seq ?? 0) + 1;
|
||||
|
||||
const number = applyPattern(pattern, {
|
||||
year,
|
||||
prefix: "",
|
||||
code,
|
||||
seq: nextNum,
|
||||
});
|
||||
return { number, next_number: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Next invoice number via atomic sequence table.
|
||||
*/
|
||||
export async function generateInvoiceNumber(year: number): Promise<number> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.number_sequences.findFirst({
|
||||
where: { type: "invoice", year },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const nextNum = (existing.last_number ?? 0) + 1;
|
||||
await tx.number_sequences.update({
|
||||
where: { id: existing.id },
|
||||
data: { last_number: nextNum },
|
||||
});
|
||||
return nextNum;
|
||||
}
|
||||
|
||||
await tx.number_sequences.create({
|
||||
data: { type: "invoice", year, last_number: 1 },
|
||||
});
|
||||
return 1;
|
||||
/** 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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user