fix: remove manual project creation, smart sequence release, received-invoices schema fix

- Remove ProjectCreate page, POST /projects endpoint, and next-number endpoint
- Projects can only be created through orders (shared numbering sequence)
- Remove dead CreateProjectSchema and createProject service function
- Delete 'order' row from number_sequences (unused; code uses 'shared')
- Smart sequence release: decrement last_number only when deleting the highest number
- Fix received-invoices stats referencing non-existent is_deleted and amount_czk columns
- Update deploy instructions in CLAUDE.md (npm install, prisma migrate deploy)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 11:36:08 +02:00
parent 3481b97d47
commit 82919d39f6
12 changed files with 86 additions and 558 deletions

View File

@@ -493,7 +493,7 @@ export async function deleteInvoice(id: number) {
const year = existing.invoice_number
? Number(existing.invoice_number.split("/")[1]) || new Date().getFullYear()
: new Date().getFullYear();
await releaseInvoiceNumber(year);
await releaseInvoiceNumber(year, existing.invoice_number ?? undefined);
return existing;
}

View File

@@ -110,13 +110,51 @@ async function previewNextSequence(
/**
* Release a sequence number back to the pool.
* NOTE: Blindly decrementing can cause duplicate numbers if numbers were
* allocated after this one. Sequence numbers are consumed but not returned
* to the pool — this is intentionally a no-op.
* Only decrements if the deleted entity held the current highest number,
* preventing gaps while avoiding duplicate re-allocation.
*/
async function releaseSequence(_type: string, _year: number) {
// No-op: decrementing can cause duplicate sequence numbers.
// Sequence numbers are consumed but never returned to the pool.
async function releaseSequence(
type: string,
year: number,
deletedNumber?: string,
) {
if (!deletedNumber) return;
const existing = await prisma.$queryRaw<Array<{ last_number: number }>>`
SELECT last_number FROM number_sequences
WHERE \`type\` = ${type} AND \`year\` = ${year}
`;
if (existing.length === 0) return;
const settings = await getSettings();
const pattern =
type === "shared"
? settings?.order_number_pattern || DEFAULT_ORDER_PATTERN
: type === "invoice"
? settings?.invoice_number_pattern || DEFAULT_INVOICE_PATTERN
: settings?.offer_number_pattern || DEFAULT_OFFER_PATTERN;
const code =
type === "shared"
? settings?.order_type_code || "71"
: type === "invoice"
? settings?.invoice_type_code || "81"
: "";
const prefix = type === "offer" ? settings?.quotation_prefix || "NA" : "";
const highestNumber = applyPattern(pattern, {
year,
prefix,
code,
seq: existing[0].last_number,
});
if (deletedNumber === highestNumber && existing[0].last_number > 0) {
await prisma.$executeRaw`
UPDATE number_sequences
SET \`last_number\` = ${existing[0].last_number - 1}
WHERE \`type\` = ${type} AND \`year\` = ${year}
`;
}
}
/** Verify a shared number is not already used by an order or project. */
@@ -265,19 +303,40 @@ export async function previewInvoiceNumber(
return { number, next_number: number };
}
/** Release an offer number back to the pool (decrement sequence). */
export async function releaseOfferNumber(year?: number) {
await releaseSequence("offer", year || new Date().getFullYear());
/** Release an offer number back to the pool (decrement if highest). */
export async function releaseOfferNumber(
year?: number,
deletedNumber?: string,
) {
await releaseSequence(
"offer",
year || new Date().getFullYear(),
deletedNumber,
);
}
/** Release a shared number back to the pool (decrement sequence). */
export async function releaseSharedNumber(year?: number) {
await releaseSequence("shared", year || new Date().getFullYear());
/** Release a shared number back to the pool (decrement if highest). */
export async function releaseSharedNumber(
year?: number,
deletedNumber?: string,
) {
await releaseSequence(
"shared",
year || new Date().getFullYear(),
deletedNumber,
);
}
/** Release an invoice number back to the pool (decrement sequence). */
export async function releaseInvoiceNumber(year?: number) {
await releaseSequence("invoice", year || new Date().getFullYear());
/** Release an invoice number back to the pool (decrement if highest). */
export async function releaseInvoiceNumber(
year?: number,
deletedNumber?: string,
) {
await releaseSequence(
"invoice",
year || new Date().getFullYear(),
deletedNumber,
);
}
/** Preview what a pattern would produce (for settings UI) */

View File

@@ -307,7 +307,7 @@ export async function deleteOffer(id: number) {
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseOfferNumber(year);
await releaseOfferNumber(year, existing.quotation_number ?? undefined);
return existing;
}

View File

@@ -542,23 +542,10 @@ export async function deleteOrder(id: number) {
await tx.orders.delete({ where: { id } });
});
const releasedYears = new Set<number>();
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(year);
releasedYears.add(year);
// Release the linked project's shared number(s) too
for (const p of linkedProjects) {
const pYear = p.created_at
? new Date(p.created_at).getFullYear()
: new Date().getFullYear();
if (!releasedYears.has(pYear)) {
await releaseSharedNumber(pYear);
releasedYears.add(pYear);
}
}
await releaseSharedNumber(year, existing.order_number ?? undefined);
return { data: { id, order_number: existing.order_number } };
}

View File

@@ -1,9 +1,5 @@
import prisma from "../config/database";
import {
generateSharedNumber,
previewSharedNumber,
releaseSharedNumber,
} from "./numbering.service";
import { releaseSharedNumber } from "./numbering.service";
import { NasFileManager } from "./nas-file-manager";
const nasFileManager = new NasFileManager();
@@ -96,50 +92,6 @@ export async function getProject(id: number) {
};
}
import { isSharedNumberTaken } from "./numbering.service";
export async function createProject(body: Record<string, any>) {
const projectNumber =
body.project_number !== undefined && body.project_number !== null
? String(body.project_number)
: await generateSharedNumber();
if (body.project_number !== undefined && body.project_number !== null) {
const taken = await isSharedNumberTaken(String(body.project_number));
if (taken) {
return { error: "Číslo projektu je již použito", status: 400 };
}
}
const project = await prisma.projects.create({
data: {
project_number: projectNumber,
name: body.name ? String(body.name) : null,
customer_id: body.customer_id != null ? Number(body.customer_id) : null,
responsible_user_id:
body.responsible_user_id != null
? Number(body.responsible_user_id)
: null,
quotation_id:
body.quotation_id != null ? Number(body.quotation_id) : null,
order_id: body.order_id != null ? Number(body.order_id) : null,
status: body.status ? String(body.status) : "aktivni",
start_date: body.start_date ? new Date(String(body.start_date)) : null,
end_date: body.end_date ? new Date(String(body.end_date)) : null,
notes: body.notes ? String(body.notes) : null,
},
});
if (project.project_number && nasFileManager.isConfigured()) {
nasFileManager.createProjectFolder(
project.project_number,
project.name || "",
);
}
return project;
}
export async function updateProject(id: number, body: Record<string, any>) {
const existing = await prisma.projects.findUnique({ where: { id } });
if (!existing) return null;
@@ -206,7 +158,7 @@ export async function deleteProject(id: number, deleteFiles: boolean = false) {
const year = existing.created_at
? new Date(existing.created_at).getFullYear()
: new Date().getFullYear();
await releaseSharedNumber(year);
await releaseSharedNumber(year, existing.project_number ?? undefined);
return existing;
}
@@ -248,7 +200,3 @@ export async function deleteProjectNote(projectId: number, noteId: number) {
await prisma.project_notes.delete({ where: { id: noteId } });
return note;
}
export async function getNextProjectNumber() {
return previewSharedNumber();
}