Add Azure Blob Storage archive & email safety net

- Add AzureBlobStorageService, DocumentArchiveSyncService, and related config for secondary PDF archiving of invoices/reminders
- Add SQL procs and schema changes for archive backfill
- Update invoice/reminder services to upload PDFs to blob storage
- Add telemetry counters and unit tests for blob storage/archive logic
- Add Fuchs:Email:OverrideRecipient config and enforce dev/test email redirect in ProcessWebComService, with tests
- Improve JS date parsing (German formats), stricter JSON date detection
- Increase widget SQL timeouts, update dependencies, docs, and project files
This commit is contained in:
Stefan
2026-07-03 10:15:04 +02:00
parent 3035ef1719
commit c3395bc83c
47 changed files with 1723 additions and 101 deletions
+25 -8
View File
@@ -403,21 +403,34 @@ function ne(inp, alt) {
return (inp || '') === '' ? (alt || '') : inp;
}
function pad(i, n) { return (i || '').toString().padStart(n, '0').substr(-1 * n); }
function twoDigitYear(y) { var n = parseInt(y, 10); return n + (n < 70 ? 2000 : 1900); }
/* Parses dot-separated German short dates (dd.MM.yyyy / dd.MM.yy, optionally with a time part).
Returns null if the string does not match, so callers can fall back to other parsing. */
function parseGermanDate(si) {
var g = si.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[\sT](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
if (g === null) { return null; }
var yr = g[3].length === 2 ? twoDigitYear(g[3]) : parseInt(g[3], 10);
return new Date(yr, parseInt(g[2], 10) - 1, parseInt(g[1], 10), parseInt(g[4] || '0', 10), parseInt(g[5] || '0', 10), parseInt(g[6] || '0', 10));
}
function parseISO(s) {
let si = s || '';
if (si === '') { return null };
if (/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(si) === true) {
return new Date(si);
} else {
var b = s.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
var gd = parseGermanDate(si);
if (gd !== null) { return gd; }
var b = si.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
}
}
function parseISOLocal(s) {
let si = s || '';
if (si === '') { return null };
var b = s.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
var gd = parseGermanDate(si);
if (gd !== null) { return gd; }
var b = si.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
}
function fnum(i, style) {
/* { style: 'decimal/currency/percent', currency: 'USD/EUR', currencyDisplay: 'symbol/code/name', minimumIntegerDigits: 1, minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: true } */
@@ -1775,9 +1788,13 @@ class NumArray extends Array {
$(this).remove();
api.rendered = false;
};
$ocms.isDateString = function (inp) {
/* Tests whether a string is a JSON/ISO-8601 date(-time) value as emitted by the
backend's JSON serializer (e.g. Newtonsoft "2021-09-09T00:00:00[.fff][Z|+hh:mm]").
Deliberately NOT based on the native Date constructor, which guesses ambiguous
formats (e.g. dotted dd.MM.yy strings) heuristically and inconsistently. */
$ocms.isJSONDateString = function (inp) {
if (typeof inp !== 'string') { return false; } else {
return isNaN(new Date(inp)) === false;
return /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(inp);
}
}
$ocms.failure = function (jqXHR) {
@@ -2975,7 +2992,7 @@ $fis.resetPass = function (id, fds) {
$fis.wdg = function (options) {
let wf = $(this).empty();
$ocms.postXT({
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, success: function (response, textStatus, jqXHR) {
url: $ocms.url('wdg/one'), data: { short_name: options.wdg }, timeout: 90000, success: function (response, textStatus, jqXHR) {
let wi = options.wdg, wx = response[wi];
if (!wx) { wf.ldng(0); return; }
let dbl = $.inArrayRegEx('dblwidth', wx.rendering_options) > -1, tiny = $.inArrayRegEx('tiny', wx.rendering_options) > -1;
@@ -3000,7 +3017,7 @@ $fis.wdg = function (options) {
var tdr = $$.tr().appendTo(tblset.bdy);
$.each(wx.columns, function (ci, col) {
var tdc = $$.td().appendTo(tdr);
if (dx[col] instanceof Date || $ocms.isDateString(dx[col]) === true) {
if (dx[col] instanceof Date || $ocms.isJSONDateString(dx[col]) === true) {
tdc.text(fdt(dx[col], $t.dateformat));
} else {
tdc.rwText(dx[col]);
+1 -1
View File
File diff suppressed because one or more lines are too long
+17 -4
View File
@@ -222,21 +222,34 @@ function ne(inp, alt) {
return (inp || '') === '' ? (alt || '') : inp;
}
function pad(i, n) { return (i || '').toString().padStart(n, '0').substr(-1 * n); }
function twoDigitYear(y) { var n = parseInt(y, 10); return n + (n < 70 ? 2000 : 1900); }
/* Parses dot-separated German short dates (dd.MM.yyyy / dd.MM.yy, optionally with a time part).
Returns null if the string does not match, so callers can fall back to other parsing. */
function parseGermanDate(si) {
var g = si.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})(?:[\sT](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
if (g === null) { return null; }
var yr = g[3].length === 2 ? twoDigitYear(g[3]) : parseInt(g[3], 10);
return new Date(yr, parseInt(g[2], 10) - 1, parseInt(g[1], 10), parseInt(g[4] || '0', 10), parseInt(g[5] || '0', 10), parseInt(g[6] || '0', 10));
}
function parseISO(s) {
let si = s || '';
if (si === '') { return null };
if (/\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z)/.test(si) === true) {
return new Date(si);
} else {
var b = s.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
var gd = parseGermanDate(si);
if (gd !== null) { return gd; }
var b = si.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
}
}
function parseISOLocal(s) {
let si = s || '';
if (si === '') { return null };
var b = s.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
var gd = parseGermanDate(si);
if (gd !== null) { return gd; }
var b = si.split(/\D/);
return new Date(b[0], b[1] - 1, b[2], b[3] || 0, b[4] || 0, b[5] || 0);
}
function fnum(i, style) {
/* { style: 'decimal/currency/percent', currency: 'USD/EUR', currencyDisplay: 'symbol/code/name', minimumIntegerDigits: 1, minimumFractionDigits: 2, maximumFractionDigits: 3, useGrouping: true } */
+1 -1
View File
File diff suppressed because one or more lines are too long