fix(erp-export): strict seit format, DB clock for settle window, movement index, one scope per key
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
53fab102f2
commit
25d95b0473
19 files changed
+426
-57
No files matched your search
@@ -322,6 +322,28 @@ describe('ApiKeysService.create', () => {
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a new key with more than one scope, even if all of them read', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||
),
|
||||
);
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats the same scope given twice as one', async () => {
|
||||
const created = await service.create(
|
||||
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
|
||||
'user-1',
|
||||
);
|
||||
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
|
||||
});
|
||||
|
||||
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
|
||||
'still creates a key with only %s',
|
||||
async (scope) => {
|
||||
|
||||
@@ -104,14 +104,19 @@ export class ApiKeysService {
|
||||
dto: CreateApiKeyDto,
|
||||
actingUserId: string,
|
||||
): Promise<CreatedApiKey> {
|
||||
// Reading and booking are separate keys: a leaked read key cannot book,
|
||||
// a leaked booking key cannot read. Existing combined keys stay valid.
|
||||
// erp-inventory:read is a read scope too.
|
||||
// One scope per new key: a leaked read key cannot book, a leaked booking
|
||||
// key cannot read, and each connected system gets its own key.
|
||||
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
|
||||
if (combinesReadAndWrite(dto.scopes)) {
|
||||
throw new BadRequestException(
|
||||
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||
);
|
||||
}
|
||||
if (new Set(dto.scopes).size > 1) {
|
||||
throw new BadRequestException(
|
||||
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||
);
|
||||
}
|
||||
// Nobody hands a machine more than they may do themselves.
|
||||
const callerPermissions =
|
||||
await this.permissionResolver.getPermissions(actingUserId);
|
||||
|
||||
@@ -50,21 +50,46 @@ describe('ERP-Abfrageparameter', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('nimmt einen ISO-Zeitpunkt für seit an', async () => {
|
||||
expect(
|
||||
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01T00:00:00Z' }),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01' }),
|
||||
).toEqual([]);
|
||||
it.each([
|
||||
'2026-01-01',
|
||||
'2026-01-01T00:00Z',
|
||||
'2026-01-01T00:00:00Z',
|
||||
'2026-01-01T00:00:00.123Z',
|
||||
'2026-01-01T01:00:00+01:00',
|
||||
'2025-12-31T22:30:00.5-02:30',
|
||||
'2000-01-01',
|
||||
'2100-12-31T23:59:59Z',
|
||||
])('nimmt seit=%s an', async (seit) => {
|
||||
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(['gestern', '2026-13-01', '01.01.2026'])(
|
||||
'lehnt seit=%s ab',
|
||||
async (seit) => {
|
||||
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
'gestern',
|
||||
'2026-13-01',
|
||||
'01.01.2026',
|
||||
// Andere ISO-8601-Formen: führten zu 500 oder wurden falsch gelesen.
|
||||
'2026-W01',
|
||||
'20260101',
|
||||
'-2026-01-01',
|
||||
'+002026-01-01',
|
||||
'2026-001',
|
||||
// Uhrzeit ohne Zone wäre Ortszeit des Servers.
|
||||
'2026-01-01T00:00',
|
||||
'2026-01-01T24:00',
|
||||
'2026-01-01T24:00Z',
|
||||
'2026-01-01T23:60Z',
|
||||
'2026-01-01T23:59:60Z',
|
||||
'2026-01-01T00:00:00+24:00',
|
||||
'2026-01-01 00:00:00Z',
|
||||
'2026-02-30',
|
||||
// Gleiche Jahresgrenzen wie der Cursor: sonst 500 in der Datenbank.
|
||||
'1999-12-31',
|
||||
'2101-01-01',
|
||||
'2100-12-31T23:00:00-02:00',
|
||||
'9999-12-31T00:00:00Z',
|
||||
])('lehnt seit=%s ab', async (seit) => {
|
||||
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
|
||||
});
|
||||
|
||||
it.each(['1899', '2101', '2026.5', 'dieses'])(
|
||||
'lehnt jahr=%s ab',
|
||||
|
||||
@@ -2,14 +2,20 @@ import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateBy,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
ERP_MAX_YEAR,
|
||||
ERP_MIN_YEAR,
|
||||
parseSeit,
|
||||
SEIT_PATTERN,
|
||||
} from '../erp-time';
|
||||
|
||||
export const DEFAULT_PAGE_SIZE = 100;
|
||||
export const MAX_PAGE_SIZE = 500;
|
||||
@@ -45,12 +51,24 @@ export class ErpPageQueryDto {
|
||||
|
||||
export class ErpMovementsQueryDto extends ErpPageQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Nur Bewegungen ab diesem Zeitpunkt (ISO 8601, einschließlich; UTC mit "Z" empfohlen). Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".',
|
||||
description: `Nur Bewegungen ab diesem Zeitpunkt (einschließlich): JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm, Jahr ${ERP_MIN_YEAR}–${ERP_MAX_YEAR}. Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".`,
|
||||
example: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601({ strict: true })
|
||||
@IsString()
|
||||
@Matches(SEIT_PATTERN, {
|
||||
message:
|
||||
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
|
||||
})
|
||||
@ValidateBy({
|
||||
name: 'isErpSeit',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && parseSeit(value) !== null,
|
||||
defaultMessage: () =>
|
||||
`seit muss ein gültiger Zeitpunkt in den Jahren ${ERP_MIN_YEAR} bis ${ERP_MAX_YEAR} sein`,
|
||||
},
|
||||
})
|
||||
seit?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { isPlausibleErpDate } from './erp-time';
|
||||
|
||||
/**
|
||||
* Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten
|
||||
@@ -23,11 +24,6 @@ type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
|
||||
// etwas anderes bedeuten könnten.
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
||||
// Zeitstempel eines Bewegungs-Cursors: nur plausible Jahre. JavaScript
|
||||
// kennt Jahre bis +275760, die Datenbankabfrage nicht - das wäre ein 500.
|
||||
const MIN_CURSOR_YEAR = 2000;
|
||||
const MAX_CURSOR_YEAR = 2100;
|
||||
|
||||
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
|
||||
produkte: ['id', 'k'],
|
||||
chargen: ['id', 'k', 't'],
|
||||
@@ -59,12 +55,9 @@ function parse(raw: string): Record<string, unknown> {
|
||||
|
||||
function isCanonicalTimestamp(value: unknown): value is string {
|
||||
if (typeof value !== 'string') return false;
|
||||
// Nur die eigene Form (toISOString) und nur plausible Jahre (erp-time.ts).
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime()) || date.toISOString() !== value) {
|
||||
return false;
|
||||
}
|
||||
const year = date.getUTCFullYear();
|
||||
return year >= MIN_CURSOR_YEAR && year <= MAX_CURSOR_YEAR;
|
||||
return isPlausibleErpDate(date) && date.toISOString() === value;
|
||||
}
|
||||
|
||||
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */
|
||||
|
||||
@@ -16,6 +16,9 @@ type FindManyArgs = {
|
||||
};
|
||||
|
||||
const NOW = new Date('2026-10-12T02:00:00.000Z');
|
||||
// Die Datenbank-Uhr geht hier bewusst anders als die Uhr des Pods (NOW):
|
||||
// die Wartezeit muss von der Datenbank kommen.
|
||||
const DB_SETTLED_UNTIL = new Date('2026-10-12T01:52:30.000Z');
|
||||
|
||||
function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -69,6 +72,7 @@ describe('ErpExportService', () => {
|
||||
productionBatch: { findMany: jest.Mock };
|
||||
batchMovement: { findMany: jest.Mock };
|
||||
harvest: { findMany: jest.Mock };
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
let stock: {
|
||||
remainingByBottling: jest.Mock;
|
||||
@@ -84,6 +88,9 @@ describe('ErpExportService', () => {
|
||||
productionBatch: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
batchMovement: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
harvest: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$queryRaw: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ settledUntil: DB_SETTLED_UNTIL }]),
|
||||
};
|
||||
stock = {
|
||||
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
|
||||
@@ -339,7 +346,48 @@ describe('ErpExportService', () => {
|
||||
});
|
||||
|
||||
describe('movements', () => {
|
||||
const settledUntil = new Date(NOW.getTime() - MOVEMENT_SETTLE_MS);
|
||||
const settledUntil = DB_SETTLED_UNTIL;
|
||||
|
||||
it('nimmt die Wartezeit von der Datenbank-Uhr, nicht von der Uhr des Pods', async () => {
|
||||
const page = await service.movements({});
|
||||
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [
|
||||
readonly string[],
|
||||
...unknown[],
|
||||
];
|
||||
expect(strings.join('?')).toContain('now()');
|
||||
expect(values).toEqual([MOVEMENT_SETTLE_MS]);
|
||||
expect(values.some((value) => value instanceof Date)).toBe(false);
|
||||
expect(page.settledUntil).toEqual(DB_SETTLED_UNTIL);
|
||||
expect(page.settledUntil).not.toEqual(
|
||||
new Date(NOW.getTime() - MOVEMENT_SETTLE_MS),
|
||||
);
|
||||
const where = lastArgs(prisma.batchMovement.findMany).where as {
|
||||
AND: unknown[];
|
||||
};
|
||||
expect(where.AND[1]).toEqual({ createdAt: { lte: DB_SETTLED_UNTIL } });
|
||||
});
|
||||
|
||||
it('liest "seit" mit Zone als UTC-Zeitpunkt', async () => {
|
||||
await service.movements({ seit: '2026-01-01T01:00:00+01:00' });
|
||||
const where = lastArgs(prisma.batchMovement.findMany).where as {
|
||||
AND: unknown[];
|
||||
};
|
||||
expect(where.AND[1]).toEqual({
|
||||
createdAt: {
|
||||
lte: settledUntil,
|
||||
gte: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('lehnt ein "seit" außerhalb der Vertragsformen ab, ohne die Datenbank zu fragen', async () => {
|
||||
await expect(
|
||||
service.movements({ seit: '2026-W01' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('liefert nur Bewegungen von Abfüllungen und Herstellungschargen, die älter als die Wartezeit sind', async () => {
|
||||
await service.movements({});
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ErpPageQueryDto,
|
||||
} from './dto/erp-query.dto';
|
||||
import { summarizeHarvestYears, type HarvestYear } from './harvest-years';
|
||||
import { parseSeit } from './erp-time';
|
||||
|
||||
/**
|
||||
* Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in
|
||||
@@ -30,7 +31,9 @@ export const ERP_INVENTORY_CONTRACT_VERSION = 1;
|
||||
* ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit
|
||||
* ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung
|
||||
* schon sehen und den Cursor über eine frühere, noch offene hinwegschieben -
|
||||
* die frühere ginge dem Spiegel dann für immer verloren.
|
||||
* die frühere ginge dem Spiegel dann für immer verloren. Die Grenze rechnet
|
||||
* die Datenbank-Uhr (now()), nicht die Uhr des Pods: mehrere api-Pods geben
|
||||
* so dieselbe Grenze, auch wenn ihre Uhren auseinanderlaufen.
|
||||
*/
|
||||
export const MOVEMENT_SETTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
@@ -317,7 +320,8 @@ export class ErpExportService {
|
||||
}
|
||||
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
|
||||
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
|
||||
const settledUntil = new Date(Date.now() - MOVEMENT_SETTLE_MS);
|
||||
const since = query.seit !== undefined ? seitDate(query.seit) : undefined;
|
||||
const settledUntil = await this.settledUntil();
|
||||
|
||||
const afterCursor: Prisma.BatchMovementWhereInput[] = after
|
||||
? [
|
||||
@@ -340,7 +344,7 @@ export class ErpExportService {
|
||||
{
|
||||
createdAt: {
|
||||
lte: settledUntil,
|
||||
...(query.seit !== undefined ? { gte: new Date(query.seit) } : {}),
|
||||
...(since !== undefined ? { gte: since } : {}),
|
||||
},
|
||||
},
|
||||
...afterCursor,
|
||||
@@ -386,6 +390,14 @@ export class ErpExportService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Bis hierhin ist das Bewegungsbuch vollständig: DB-Uhr minus Wartezeit. */
|
||||
private async settledUntil(): Promise<Date> {
|
||||
const [row] = await this.prisma.$queryRaw<{ settledUntil: Date }[]>`
|
||||
SELECT now() - (${MOVEMENT_SETTLE_MS}::double precision * interval '1 millisecond') AS "settledUntil"
|
||||
`;
|
||||
return row.settledUntil;
|
||||
}
|
||||
|
||||
private envelope<T>(
|
||||
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
|
||||
): ErpPage<T> {
|
||||
@@ -421,3 +433,14 @@ export class ErpExportService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** "seit" ist im DTO schon geprüft; hier nur noch die Umwandlung, sicherheitshalber mit 400. */
|
||||
function seitDate(seit: string): Date {
|
||||
const date = parseSeit(seit);
|
||||
if (!date) {
|
||||
throw new BadRequestException(
|
||||
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
|
||||
);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isPlausibleErpDate, parseSeit } from './erp-time';
|
||||
|
||||
describe('parseSeit', () => {
|
||||
it.each([
|
||||
['2026-01-01', '2026-01-01T00:00:00.000Z'],
|
||||
['2026-01-01T00:00Z', '2026-01-01T00:00:00.000Z'],
|
||||
['2026-01-01T08:15:30Z', '2026-01-01T08:15:30.000Z'],
|
||||
['2026-01-01T08:15:30.1Z', '2026-01-01T08:15:30.100Z'],
|
||||
['2026-01-01T08:15:30.123Z', '2026-01-01T08:15:30.123Z'],
|
||||
['2026-01-01T01:00:00+01:00', '2026-01-01T00:00:00.000Z'],
|
||||
['2025-12-31T21:30:00-02:30', '2026-01-01T00:00:00.000Z'],
|
||||
])('liest %s als %s (UTC)', (seit, iso) => {
|
||||
expect(parseSeit(seit)?.toISOString()).toBe(iso);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2026-W01',
|
||||
'20260101',
|
||||
'-2026-01-01',
|
||||
'2026-001',
|
||||
'2026-01-01T00:00',
|
||||
'2026-01-01T24:00Z',
|
||||
'2026-02-29',
|
||||
'2026-04-31',
|
||||
'1999-12-31T23:59:59Z',
|
||||
'2101-01-01',
|
||||
'2100-12-31T23:00:00-02:00',
|
||||
])('lehnt %s ab', (seit) => {
|
||||
expect(parseSeit(seit)).toBeNull();
|
||||
});
|
||||
|
||||
it('kennt Schalttage', () => {
|
||||
expect(parseSeit('2028-02-29')?.toISOString()).toBe(
|
||||
'2028-02-29T00:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlausibleErpDate', () => {
|
||||
it('lässt nur die Jahre 2000 bis 2100 (UTC) zu', () => {
|
||||
expect(isPlausibleErpDate(new Date('2000-01-01T00:00:00.000Z'))).toBe(true);
|
||||
expect(isPlausibleErpDate(new Date('2100-12-31T23:59:59.999Z'))).toBe(true);
|
||||
expect(isPlausibleErpDate(new Date('1999-12-31T23:59:59.999Z'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPlausibleErpDate(new Date('2101-01-01T00:00:00.000Z'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPlausibleErpDate(new Date(Number.NaN))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Zeitpunkte, die der ERP-Export aus Anfragen übernimmt (`seit`, Cursor).
|
||||
* Nur plausible Jahre: JavaScript kennt Jahre bis +275760, die
|
||||
* Datenbankabfrage nicht - das wäre ein 500 statt eines 400.
|
||||
*/
|
||||
export const ERP_MIN_YEAR = 2000;
|
||||
export const ERP_MAX_YEAR = 2100;
|
||||
|
||||
export function isPlausibleErpDate(date: Date): boolean {
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
const year = date.getUTCFullYear();
|
||||
return year >= ERP_MIN_YEAR && year <= ERP_MAX_YEAR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die einzigen Formen für `seit`: ein Kalendertag (`JJJJ-MM-TT`, gilt als
|
||||
* Mitternacht UTC) oder ein Zeitpunkt mit ausdrücklicher Zone
|
||||
* (`JJJJ-MM-TTThh:mm[:ss[.sss]]` mit `Z` oder `±hh:mm`). Andere ISO-8601-
|
||||
* Formen (Wochen, Ordinaltage, Kurzform ohne Striche, erweiterte Jahre,
|
||||
* Uhrzeit ohne Zone = Ortszeit des Servers) werden abgelehnt, statt falsch
|
||||
* gelesen zu werden oder als 500 in der Datenbank zu enden.
|
||||
*/
|
||||
export const SEIT_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|([+-])(\d{2}):(\d{2})))?$/;
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
/** Der Zeitpunkt zu `seit`, oder null, wenn Form, Kalender oder Jahr nicht passen. */
|
||||
export function parseSeit(value: string): Date | null {
|
||||
const match = SEIT_PATTERN.exec(value);
|
||||
if (!match) return null;
|
||||
// Optionale Gruppen ohne Treffer sind undefined.
|
||||
const groups: (string | undefined)[] = [...match];
|
||||
const [, y, mo, d, h, mi, s, frac, , sign, oh, om] = groups;
|
||||
const year = Number(y);
|
||||
const month = Number(mo) - 1;
|
||||
const day = Number(d);
|
||||
const hours = Number(h ?? 0);
|
||||
const minutes = Number(mi ?? 0);
|
||||
const seconds = Number(s ?? 0);
|
||||
const offsetHours = Number(oh ?? 0);
|
||||
const offsetMinutes = Number(om ?? 0);
|
||||
if (
|
||||
hours > 23 ||
|
||||
minutes > 59 ||
|
||||
seconds > 59 ||
|
||||
offsetHours > 23 ||
|
||||
offsetMinutes > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const calendarDay = new Date(Date.UTC(year, month, day));
|
||||
if (
|
||||
calendarDay.getUTCFullYear() !== year ||
|
||||
calendarDay.getUTCMonth() !== month ||
|
||||
calendarDay.getUTCDate() !== day
|
||||
) {
|
||||
return null; // z. B. 2026-02-30
|
||||
}
|
||||
const millis = Number((frac ?? '').padEnd(3, '0'));
|
||||
const offset =
|
||||
(sign === '-' ? -1 : 1) *
|
||||
(offsetHours * 60 + offsetMinutes) *
|
||||
MS_PER_MINUTE;
|
||||
const date = new Date(
|
||||
Date.UTC(year, month, day, hours, minutes, seconds, millis) - offset,
|
||||
);
|
||||
return isPlausibleErpDate(date) ? date : null;
|
||||
}
|
||||
Reference in new issue
Block a user