Prisma truncates a JS Date used in a WHERE filter on a @db.Date column to its UTC date part. Under TZ=Europe/Prague a local-midnight boundary (new Date(y,m,d) = 22:00/23:00Z of the previous day) therefore filtered as the PREVIOUS calendar date. Same class as the dashboard fix; full sweep of every @db.Date filter in the codebase. New shared helper utcMidnightOfLocalDay() in src/utils/date.ts. Fixed (all previously off by one day): - attendance.service: getStatus today+month windows; getWorkfund (last day of each month was double-counted across months); getPrintData (monthly print included prev month's last day); listAttendance (admin month view included prev month's last day AND dropped the selected month's last day); createAttendance duplicate/overlap validation (checked only the PREVIOUS day - same-day duplicates were never caught, neighbors falsely rejected) - invoice-alerts: the 'splatnost za 3 dny' advance alert had NEVER fired (due==today+3 was outside the fetched window); window computation extracted as computeAlertWindow() + pure tests - invoices.service: month list/totals filter dropped invoices issued on the month's last day; getInvoiceStats month/year bounds; markOverdueInvoices boundary; auto paid_date could store yesterday during 00:00-02:00 - received-invoices auto paid_date, issued-orders default order_date: same night-window write hazard, now via the helper - attendance.schema: shift_date hardened to isoDateString (bare YYYY-MM-DD) +9 regression tests (month boundaries, same-day duplicate, last-day-of-month invoice in list+totals, alert window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
697 lines
24 KiB
TypeScript
697 lines
24 KiB
TypeScript
import { FastifyInstance } from "fastify";
|
||
import multipart from "@fastify/multipart";
|
||
import { received_invoices_status } from "@prisma/client";
|
||
import { z } from "zod";
|
||
import prisma from "../../config/database";
|
||
import { requirePermission } from "../../middleware/auth";
|
||
import { logAudit } from "../../services/audit";
|
||
import { success, error, parseId, paginated } from "../../utils/response";
|
||
import { contentDisposition } from "../../utils/content-disposition";
|
||
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||
import { parseBody } from "../../schemas/common";
|
||
import {
|
||
CreateReceivedInvoiceSchema,
|
||
UpdateReceivedInvoiceSchema,
|
||
} from "../../schemas/received-invoices.schema";
|
||
import { nasInvoicesManager } from "../../services/nas-financials-manager";
|
||
import { toCzk } from "../../services/exchange-rates";
|
||
import { utcMidnightOfLocalDay } from "../../utils/date";
|
||
import path from "path";
|
||
|
||
const VALID_STATUSES = ["unpaid", "paid"] as const;
|
||
|
||
/** Round a monetary value to 2 decimal places to avoid floating-point drift. */
|
||
function roundMoney(n: number): number {
|
||
return Math.round(n * 100) / 100;
|
||
}
|
||
|
||
/**
|
||
* VAT contained WITHIN a gross (VAT-inclusive) amount. `amount` is the total to
|
||
* pay including tax, so the tax portion is gross * rate / (100 + rate) — e.g.
|
||
* 22542.91 @ 21% → 3912.41 (base 18630.50). Returns 0 when there is no VAT.
|
||
*/
|
||
export function vatFromGross(gross: number, rate: number): number {
|
||
return rate > 0 ? roundMoney((gross * rate) / (100 + rate)) : 0;
|
||
}
|
||
const ALLOWED_SORT_FIELDS = [
|
||
"id",
|
||
"supplier_name",
|
||
"amount",
|
||
"issue_date",
|
||
"due_date",
|
||
"status",
|
||
"created_at",
|
||
];
|
||
|
||
interface ReceivedInvoiceFilterParams {
|
||
year?: number;
|
||
month?: number;
|
||
status?: string;
|
||
supplier_name?: string;
|
||
search?: string;
|
||
}
|
||
|
||
/**
|
||
* Shared `where` builder for the received-invoices list and the per-currency
|
||
* list-totals aggregation, so both stay in sync. The list filters by
|
||
* year/month/status/supplier_name/search.
|
||
*/
|
||
export function buildReceivedWhere(
|
||
params: ReceivedInvoiceFilterParams,
|
||
): Record<string, unknown> {
|
||
const { year, month, status, supplier_name, search } = params;
|
||
const where: Record<string, unknown> = {};
|
||
if (year) where.year = year;
|
||
if (month) where.month = month;
|
||
if (status) where.status = status;
|
||
if (supplier_name) where.supplier_name = { contains: supplier_name };
|
||
if (search) {
|
||
where.OR = [
|
||
{ supplier_name: { contains: search } },
|
||
{ invoice_number: { contains: search } },
|
||
{ description: { contains: search } },
|
||
];
|
||
}
|
||
return where;
|
||
}
|
||
|
||
/**
|
||
* Sum received-invoice GROSS `amount` per currency over the WHOLE filtered set
|
||
* (not one page), reusing `buildReceivedWhere` so the totals track the list.
|
||
* `amount` is VAT-inclusive (GROSS), so it is summed directly. Returns one
|
||
* entry per currency, rounded to 2dp, zero/empty totals dropped. Exported so
|
||
* the route and the totals test share one implementation.
|
||
*/
|
||
export async function getReceivedInvoiceListTotals(
|
||
params: ReceivedInvoiceFilterParams,
|
||
): Promise<{ totals: Array<{ amount: number; currency: string }> }> {
|
||
const where = buildReceivedWhere(params);
|
||
|
||
const invoices = await prisma.received_invoices.findMany({
|
||
where,
|
||
select: { amount: true, currency: true },
|
||
});
|
||
|
||
const byCurrency: Record<string, number> = {};
|
||
for (const inv of invoices) {
|
||
const cur = inv.currency || "CZK";
|
||
byCurrency[cur] = (byCurrency[cur] || 0) + (Number(inv.amount) || 0);
|
||
}
|
||
|
||
const totals = Object.entries(byCurrency)
|
||
.map(([currency, amount]) => ({
|
||
currency,
|
||
amount: Math.round(amount * 100) / 100,
|
||
}))
|
||
.filter((t) => t.amount > 0);
|
||
|
||
return { totals };
|
||
}
|
||
|
||
export default async function receivedInvoicesRoutes(
|
||
fastify: FastifyInstance,
|
||
): Promise<void> {
|
||
await fastify.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
|
||
|
||
fastify.get(
|
||
"/",
|
||
{ preHandler: requirePermission("invoices.view") },
|
||
async (request, reply) => {
|
||
const query = request.query as Record<string, unknown>;
|
||
const { page, limit, skip, order } = parsePagination(query);
|
||
|
||
const where = buildReceivedWhere({
|
||
year: query.year ? Number(query.year) : undefined,
|
||
month: query.month ? Number(query.month) : undefined,
|
||
status: query.status ? String(query.status) : undefined,
|
||
supplier_name: query.supplier_name
|
||
? String(query.supplier_name)
|
||
: undefined,
|
||
search: query.search ? String(query.search) : undefined,
|
||
});
|
||
|
||
// Sort field whitelisting
|
||
const sortField =
|
||
query.sort && ALLOWED_SORT_FIELDS.includes(String(query.sort))
|
||
? String(query.sort)
|
||
: "id";
|
||
|
||
const [invoices, total] = await Promise.all([
|
||
prisma.received_invoices.findMany({
|
||
where,
|
||
skip,
|
||
take: limit,
|
||
orderBy: { [sortField]: order },
|
||
}),
|
||
prisma.received_invoices.count({ where }),
|
||
]);
|
||
|
||
return paginated(
|
||
reply,
|
||
invoices,
|
||
buildPaginationMeta(total, page, limit),
|
||
);
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/received-invoices/stats
|
||
fastify.get(
|
||
"/stats",
|
||
{ preHandler: requirePermission("invoices.view") },
|
||
async (request, reply) => {
|
||
const query = request.query as Record<string, unknown>;
|
||
const now = new Date();
|
||
const year = Number(query.year) || now.getFullYear();
|
||
const month = Number(query.month) || now.getMonth() + 1;
|
||
|
||
const where: Record<string, unknown> = { year, month };
|
||
const monthInvoices = await prisma.received_invoices.findMany({ where });
|
||
|
||
// Aggregate by currency → CurrencyAmount[] format
|
||
const aggregateByCurrency = (
|
||
invs: { currency: string; [key: string]: unknown }[],
|
||
field: "amount" | "vat_amount",
|
||
) => {
|
||
const map: Record<string, number> = {};
|
||
for (const inv of invs) {
|
||
const cur = inv.currency || "CZK";
|
||
map[cur] = (map[cur] || 0) + (Number(inv[field]) || 0);
|
||
}
|
||
return Object.entries(map)
|
||
.filter(([, v]) => v > 0)
|
||
.map(([currency, amount]) => ({
|
||
amount: Math.round(amount * 100) / 100,
|
||
currency,
|
||
}));
|
||
};
|
||
|
||
const sumCzk = async (
|
||
invs: { currency: string; [key: string]: unknown }[],
|
||
field: "amount" | "vat_amount",
|
||
) => {
|
||
let total = 0;
|
||
for (const inv of invs) {
|
||
const amount = Number(inv[field]) || 0;
|
||
// `toCzk` throws on an unknown/unsupported currency. A single bad row
|
||
// must not 500 the whole stats endpoint — log and skip it instead.
|
||
try {
|
||
total += await toCzk(amount, inv.currency);
|
||
} catch (e) {
|
||
request.log.warn(
|
||
e,
|
||
`Přeskočen převod na CZK pro měnu "${inv.currency}" ve statistikách přijatých faktur`,
|
||
);
|
||
}
|
||
}
|
||
return Math.round(total * 100) / 100;
|
||
};
|
||
|
||
// All-time unpaid invoices (no soft-delete column, so just filter by status)
|
||
const unpaidCount = await prisma.received_invoices.count({
|
||
where: { status: { not: "paid" } },
|
||
});
|
||
const allUnpaid = await prisma.received_invoices.findMany({
|
||
where: { status: { not: "paid" } },
|
||
select: { amount: true, currency: true },
|
||
});
|
||
|
||
return success(reply, {
|
||
total_month: aggregateByCurrency(monthInvoices, "amount"),
|
||
total_month_czk: await sumCzk(monthInvoices, "amount"),
|
||
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
|
||
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
|
||
unpaid: aggregateByCurrency(allUnpaid, "amount"),
|
||
unpaid_czk: await sumCzk(allUnpaid, "amount"),
|
||
unpaid_count: unpaidCount,
|
||
month_count: monthInvoices.length,
|
||
});
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/received-invoices/list-totals — per-currency total summed
|
||
// over the WHOLE filtered set (not one page), matching the received-invoice
|
||
// list's filters (year/month/status/supplier_name/search). `amount` is GROSS
|
||
// (VAT-inclusive), so it is summed directly per currency. Distinct path from
|
||
// "/stats" (the monthly KPI cards) to avoid colliding with that endpoint.
|
||
// Registered BEFORE "/:id" so the literal path isn't captured as an id.
|
||
fastify.get(
|
||
"/list-totals",
|
||
{ preHandler: requirePermission("invoices.view") },
|
||
async (request, reply) => {
|
||
const query = request.query as Record<string, unknown>;
|
||
const result = await getReceivedInvoiceListTotals({
|
||
year: query.year ? Number(query.year) : undefined,
|
||
month: query.month ? Number(query.month) : undefined,
|
||
status: query.status ? String(query.status) : undefined,
|
||
supplier_name: query.supplier_name
|
||
? String(query.supplier_name)
|
||
: undefined,
|
||
search: query.search ? String(query.search) : undefined,
|
||
});
|
||
return success(reply, { totals: result.totals });
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/received-invoices/suppliers — distinct supplier names for autocomplete
|
||
fastify.get(
|
||
"/suppliers",
|
||
{ preHandler: requirePermission("invoices.view") },
|
||
async (_request, reply) => {
|
||
const results = await prisma.received_invoices.findMany({
|
||
select: { supplier_name: true },
|
||
distinct: ["supplier_name"],
|
||
orderBy: { supplier_name: "asc" },
|
||
});
|
||
return success(
|
||
reply,
|
||
results.map((r) => r.supplier_name),
|
||
);
|
||
},
|
||
);
|
||
|
||
// GET /api/admin/received-invoices/:id/file
|
||
fastify.get<{ Params: { id: string } }>(
|
||
"/:id/file",
|
||
{ preHandler: requirePermission("invoices.view") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const invoice = await prisma.received_invoices.findUnique({
|
||
where: { id },
|
||
select: {
|
||
file_name: true,
|
||
file_mime: true,
|
||
year: true,
|
||
month: true,
|
||
},
|
||
});
|
||
if (!invoice?.file_name) return error(reply, "Soubor nenalezen", 404);
|
||
|
||
const relPath = nasInvoicesManager.buildReceivedPath(
|
||
invoice.file_name,
|
||
invoice.year,
|
||
invoice.month,
|
||
);
|
||
const nasFile = nasInvoicesManager.readReceived(relPath);
|
||
if (!nasFile) return error(reply, "Soubor na NAS nenalezen", 404);
|
||
|
||
const mime = invoice.file_mime || "application/pdf";
|
||
// "inline": the FE (ReceivedInvoices.openFile) opens this in a new
|
||
// window for viewing, not as a forced download.
|
||
return reply
|
||
.type(mime)
|
||
.header(
|
||
"Content-Disposition",
|
||
contentDisposition("inline", invoice.file_name),
|
||
)
|
||
.send(nasFile.data);
|
||
},
|
||
);
|
||
|
||
fastify.get<{ Params: { id: string } }>(
|
||
"/:id",
|
||
{ preHandler: requirePermission("invoices.view") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const invoice = await prisma.received_invoices.findUnique({
|
||
where: { id },
|
||
});
|
||
if (!invoice) return error(reply, "Přijatá faktura nenalezena", 404);
|
||
return success(reply, {
|
||
...invoice,
|
||
has_file: !!invoice.file_name,
|
||
});
|
||
},
|
||
);
|
||
|
||
fastify.post(
|
||
"/",
|
||
{ preHandler: requirePermission("invoices.create") },
|
||
async (request, reply) => {
|
||
const contentType = request.headers["content-type"] || "";
|
||
|
||
// Multipart upload: files[] + invoices JSON metadata
|
||
if (contentType.includes("multipart/form-data")) {
|
||
const parts = request.parts();
|
||
const files: Array<{
|
||
data: Buffer;
|
||
name: string;
|
||
mime: string;
|
||
size: number;
|
||
}> = [];
|
||
let invoicesMeta: Array<Record<string, unknown>> = [];
|
||
|
||
for await (const part of parts) {
|
||
if (part.type === "file") {
|
||
const buf = await part.toBuffer();
|
||
files.push({
|
||
data: buf,
|
||
name: part.filename || "file",
|
||
mime: part.mimetype || "application/octet-stream",
|
||
size: buf.length,
|
||
});
|
||
} else if (part.fieldname === "invoices") {
|
||
try {
|
||
const parsed: unknown = JSON.parse(part.value as string);
|
||
// The frontend sends a partial payload (one element per uploaded
|
||
// file, only the editable fields). Validate each entry against a
|
||
// permissive variant of the create schema so untyped keys (NaN,
|
||
// huge numbers, weird date strings) can't reach the
|
||
// calculations below.
|
||
const arrSchema = z.array(CreateReceivedInvoiceSchema.partial());
|
||
const validated = arrSchema.safeParse(parsed);
|
||
if (validated.success) {
|
||
invoicesMeta = validated.data as Array<Record<string, unknown>>;
|
||
} else {
|
||
return error(
|
||
reply,
|
||
"Neplatná metadata faktur: " +
|
||
validated.error.issues.map((i) => i.message).join(", "),
|
||
400,
|
||
);
|
||
}
|
||
} catch {
|
||
return error(reply, "Neplatný JSON v poli 'invoices'", 400);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (files.length === 0)
|
||
return error(reply, "Vyberte alespoň jeden soubor", 400);
|
||
|
||
const now = new Date();
|
||
const createdIds: number[] = [];
|
||
|
||
const useNas = nasInvoicesManager.isConfigured();
|
||
|
||
for (let i = 0; i < files.length; i++) {
|
||
const file = files[i];
|
||
const meta = invoicesMeta[i] || {};
|
||
const amount = Number(meta.amount ?? 0);
|
||
const vatRate = Number(meta.vat_rate ?? 21);
|
||
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
||
const vatAmount = vatFromGross(amount, vatRate);
|
||
|
||
const issueDate = meta.issue_date
|
||
? new Date(String(meta.issue_date))
|
||
: null;
|
||
const invoiceMonth = issueDate
|
||
? issueDate.getMonth() + 1
|
||
: Number(meta.month) || now.getMonth() + 1;
|
||
const invoiceYear = issueDate
|
||
? issueDate.getFullYear()
|
||
: Number(meta.year) || now.getFullYear();
|
||
|
||
if (!useNas) {
|
||
return error(reply, "NAS úložiště není nakonfigurováno", 503);
|
||
}
|
||
|
||
const nasResult = nasInvoicesManager.saveReceived(
|
||
file.name,
|
||
invoiceYear,
|
||
invoiceMonth,
|
||
file.data,
|
||
);
|
||
if ("error" in nasResult) {
|
||
return error(reply, nasResult.error, 503);
|
||
}
|
||
|
||
const invoice = await prisma.received_invoices.create({
|
||
data: {
|
||
month: invoiceMonth,
|
||
year: invoiceYear,
|
||
supplier_name: meta.supplier_name
|
||
? String(meta.supplier_name)
|
||
: file.name,
|
||
invoice_number: meta.invoice_number
|
||
? String(meta.invoice_number)
|
||
: null,
|
||
description: meta.description ? String(meta.description) : null,
|
||
amount,
|
||
currency: meta.currency ? String(meta.currency) : "CZK",
|
||
vat_rate: vatRate,
|
||
vat_amount: vatAmount,
|
||
issue_date: meta.issue_date
|
||
? new Date(String(meta.issue_date))
|
||
: null,
|
||
due_date: meta.due_date ? new Date(String(meta.due_date)) : null,
|
||
status: "unpaid",
|
||
notes: meta.notes ? String(meta.notes) : null,
|
||
uploaded_by: request.authData?.userId,
|
||
file_name: nasResult.filePath
|
||
? path.basename(nasResult.filePath)
|
||
: file.name,
|
||
file_mime: file.mime,
|
||
file_size: file.size,
|
||
},
|
||
});
|
||
createdIds.push(invoice.id);
|
||
}
|
||
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "invoice",
|
||
entityId: createdIds[0],
|
||
description: `Nahráno ${createdIds.length} přijatých faktur`,
|
||
});
|
||
return success(
|
||
reply,
|
||
{ ids: createdIds, count: createdIds.length },
|
||
201,
|
||
`Nahráno ${createdIds.length} faktur`,
|
||
);
|
||
}
|
||
|
||
// JSON body: single invoice creation (no file)
|
||
const parsed = parseBody(CreateReceivedInvoiceSchema, request.body);
|
||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||
const body = parsed.data;
|
||
const status = body.status;
|
||
if (!VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])) {
|
||
return error(reply, "Neplatný stav", 400);
|
||
}
|
||
|
||
const amount = body.amount;
|
||
const vatRate = body.vat_rate;
|
||
const invoice = await prisma.received_invoices.create({
|
||
data: {
|
||
month: Number(body.month),
|
||
year: Number(body.year),
|
||
supplier_name: String(body.supplier_name),
|
||
invoice_number: body.invoice_number
|
||
? String(body.invoice_number)
|
||
: null,
|
||
description: body.description ? String(body.description) : null,
|
||
amount,
|
||
currency: body.currency ? String(body.currency) : "CZK",
|
||
vat_rate: vatRate,
|
||
// `amount` is the GROSS total (VAT included); VAT is the portion
|
||
// within it — consistent with the multipart create and update paths.
|
||
vat_amount: vatFromGross(amount, vatRate),
|
||
issue_date: body.issue_date
|
||
? new Date(String(body.issue_date))
|
||
: null,
|
||
due_date: body.due_date ? new Date(String(body.due_date)) : null,
|
||
status: status as received_invoices_status,
|
||
notes: body.notes ? String(body.notes) : null,
|
||
uploaded_by: request.authData?.userId,
|
||
},
|
||
});
|
||
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "create",
|
||
entityType: "invoice",
|
||
entityId: invoice.id,
|
||
description: `Vytvořena přijatá faktura od ${invoice.supplier_name}`,
|
||
});
|
||
return success(reply, { id: invoice.id }, 201, "Faktura byla vytvořena");
|
||
},
|
||
);
|
||
|
||
fastify.put<{ Params: { id: string } }>(
|
||
"/:id",
|
||
{ preHandler: requirePermission("invoices.edit") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const parsed = parseBody(UpdateReceivedInvoiceSchema, request.body);
|
||
if ("error" in parsed) return error(reply, parsed.error, 400);
|
||
const body = parsed.data;
|
||
|
||
const existing = await prisma.received_invoices.findUnique({
|
||
where: { id },
|
||
});
|
||
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
||
|
||
if (body.status !== undefined) {
|
||
const status = String(body.status);
|
||
if (
|
||
!VALID_STATUSES.includes(status as (typeof VALID_STATUSES)[number])
|
||
) {
|
||
return error(reply, "Neplatný stav", 400);
|
||
}
|
||
// Prevent reverting paid status (matching PHP)
|
||
if (String(existing.status) === "paid" && status !== "paid") {
|
||
return error(reply, "Nelze vrátit stav uhrazené faktury", 400);
|
||
}
|
||
}
|
||
|
||
if (String(existing.status) === "paid") {
|
||
const attempted = Object.keys(body).filter(
|
||
(k) => !["status", "paid_date", "notes"].includes(k),
|
||
);
|
||
if (attempted.length > 0) {
|
||
return error(reply, "Nelze upravit uhrazenou fakturu", 400);
|
||
}
|
||
}
|
||
|
||
// Recalculate vat_amount when amount (gross) or vat_rate changes.
|
||
const finalAmount =
|
||
body.amount !== undefined
|
||
? Number(body.amount)
|
||
: Number(existing.amount);
|
||
const finalVatRate =
|
||
body.vat_rate !== undefined
|
||
? Number(body.vat_rate)
|
||
: Number(existing.vat_rate);
|
||
// `amount` is the GROSS total (VAT included); VAT is the portion within it.
|
||
const computedVat = vatFromGross(finalAmount, finalVatRate);
|
||
|
||
// Auto-set paid_date when status transitions to paid (matching PHP).
|
||
// paid_date is @db.Date (truncated to the UTC date part) —
|
||
// utcMidnightOfLocalDay keeps the LOCAL calendar day even during the
|
||
// 00:00–02:00 Prague window (a bare new Date() stored yesterday there).
|
||
const newStatus =
|
||
body.status !== undefined
|
||
? String(body.status)
|
||
: String(existing.status);
|
||
const paidDate =
|
||
newStatus === "paid" && String(existing.status) !== "paid"
|
||
? utcMidnightOfLocalDay()
|
||
: body.paid_date !== undefined
|
||
? body.paid_date
|
||
? new Date(String(body.paid_date))
|
||
: null
|
||
: undefined;
|
||
|
||
// Auto-update month/year from issue_date if issue_date changes (matching PHP)
|
||
let autoMonth = body.month !== undefined ? Number(body.month) : undefined;
|
||
let autoYear = body.year !== undefined ? Number(body.year) : undefined;
|
||
if (body.issue_date && !body.month && !body.year) {
|
||
const issueDate = new Date(String(body.issue_date));
|
||
if (!isNaN(issueDate.getTime())) {
|
||
autoMonth = issueDate.getMonth() + 1;
|
||
autoYear = issueDate.getFullYear();
|
||
}
|
||
}
|
||
|
||
await prisma.received_invoices.update({
|
||
where: { id },
|
||
data: {
|
||
supplier_name:
|
||
body.supplier_name !== undefined
|
||
? String(body.supplier_name)
|
||
: undefined,
|
||
invoice_number:
|
||
body.invoice_number !== undefined
|
||
? body.invoice_number
|
||
? String(body.invoice_number)
|
||
: null
|
||
: undefined,
|
||
description:
|
||
body.description !== undefined
|
||
? body.description
|
||
? String(body.description)
|
||
: null
|
||
: undefined,
|
||
amount: body.amount !== undefined ? Number(body.amount) : undefined,
|
||
currency:
|
||
body.currency !== undefined ? String(body.currency) : undefined,
|
||
vat_rate:
|
||
body.vat_rate !== undefined ? Number(body.vat_rate) : undefined,
|
||
vat_amount:
|
||
body.amount !== undefined || body.vat_rate !== undefined
|
||
? computedVat
|
||
: body.vat_amount !== undefined
|
||
? Number(body.vat_amount)
|
||
: undefined,
|
||
issue_date:
|
||
body.issue_date !== undefined
|
||
? body.issue_date
|
||
? new Date(String(body.issue_date))
|
||
: null
|
||
: undefined,
|
||
due_date:
|
||
body.due_date !== undefined
|
||
? body.due_date
|
||
? new Date(String(body.due_date))
|
||
: null
|
||
: undefined,
|
||
paid_date: paidDate,
|
||
status:
|
||
body.status !== undefined
|
||
? (String(body.status) as received_invoices_status)
|
||
: undefined,
|
||
notes:
|
||
body.notes !== undefined
|
||
? body.notes
|
||
? String(body.notes)
|
||
: null
|
||
: undefined,
|
||
month: autoMonth,
|
||
year: autoYear,
|
||
modified_at: new Date(),
|
||
},
|
||
});
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "update",
|
||
entityType: "invoice",
|
||
entityId: id,
|
||
description: `Upravena přijatá faktura`,
|
||
});
|
||
return success(reply, { id }, 200, "Faktura byla uložena");
|
||
},
|
||
);
|
||
|
||
fastify.delete<{ Params: { id: string } }>(
|
||
"/:id",
|
||
{ preHandler: requirePermission("invoices.delete") },
|
||
async (request, reply) => {
|
||
const id = parseId(request.params.id, reply);
|
||
if (id === null) return;
|
||
const existing = await prisma.received_invoices.findUnique({
|
||
where: { id },
|
||
});
|
||
if (!existing) return error(reply, "Přijatá faktura nenalezena", 404);
|
||
|
||
// Delete DB record first, then NAS file — avoids orphaned file if DB delete fails
|
||
await prisma.received_invoices.delete({ where: { id } });
|
||
|
||
if (existing.file_name) {
|
||
const relPath = nasInvoicesManager.buildReceivedPath(
|
||
existing.file_name,
|
||
existing.year,
|
||
existing.month,
|
||
);
|
||
nasInvoicesManager.deleteReceived(relPath);
|
||
}
|
||
await logAudit({
|
||
request,
|
||
authData: request.authData,
|
||
action: "delete",
|
||
entityType: "invoice",
|
||
entityId: id,
|
||
description: `Smazána přijatá faktura`,
|
||
});
|
||
return success(reply, null, 200, "Přijatá faktura smazána");
|
||
},
|
||
);
|
||
}
|