415 lines
18 KiB
PHP
415 lines
18 KiB
PHP
<?php
|
|
/**
|
|
* DocumentNumberManager
|
|
*
|
|
* Central running-number generator for all transactional documents.
|
|
* Config (prefix, format, digits) is read from `document_types` per company;
|
|
* falls back to built-in defaults when no row exists so existing companies
|
|
* work without any migration or seed data.
|
|
*
|
|
* Sequence counters are stored in `document_number_sequences` using an atomic
|
|
* INSERT ... ON DUPLICATE KEY UPDATE so concurrent saves never produce the
|
|
* same number.
|
|
*/
|
|
class DocumentNumberManager
|
|
{
|
|
private PDO $pdo;
|
|
private int $company_id;
|
|
|
|
// Built-in defaults — used when no document_types row exists for a company
|
|
private const DEFAULTS = [
|
|
'quotation' => ['name' => 'Quotation', 'prefix' => 'QT', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'sales_order' => ['name' => 'Sales Order', 'prefix' => 'SO', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'invoice' => ['name' => 'Sales Invoice', 'prefix' => 'INV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'credit_note' => ['name' => 'Sales Credit Note', 'prefix' => 'CN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'purchase_request' => ['name' => 'Purchase Request', 'prefix' => 'PR', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'purchase_order' => ['name' => 'Purchase Order', 'prefix' => 'PO', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'purchase_invoice' => ['name' => 'Purchase Invoice', 'prefix' => 'PI', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'supplier_credit_note' => ['name' => 'Supplier Credit Note', 'prefix' => 'SCN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'receipt' => ['name' => 'Receipt', 'prefix' => 'RCV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'receipt_billing' => ['name' => 'Receipt Billing', 'prefix' => 'RCVB', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'payment' => ['name' => 'Payment', 'prefix' => 'PMT', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'payment_billing' => ['name' => 'Payment Billing', 'prefix' => 'PMTB', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'manual' => ['name' => 'Manual Journal', 'prefix' => 'JV', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'return' => ['name' => 'Customer Return', 'prefix' => 'RET', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
'supplier_return' => ['name' => 'Supplier Return', 'prefix' => 'SRN', 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1],
|
|
];
|
|
|
|
public function __construct(PDO $pdo, int $company_id)
|
|
{
|
|
$this->pdo = $pdo;
|
|
$this->company_id = $company_id;
|
|
}
|
|
|
|
/**
|
|
* Generate the next number for a doc type using its configured (or default) prefix.
|
|
*/
|
|
public function generate(string $doc_type_key, string $prefix = ''): string
|
|
{
|
|
$cfg = $this->getConfig($doc_type_key, $prefix);
|
|
return $this->generateWithPrefix($cfg['prefix'], $cfg['format_type'], (int)$cfg['sequence_digits']);
|
|
}
|
|
|
|
/**
|
|
* Resolve a submitted document number. Manual numbers are validated without
|
|
* touching the sequence counter; otherwise the next automatic number is reserved.
|
|
*/
|
|
public function resolveNumber(array $data, string $doc_type_key, string $table, string $column): string
|
|
{
|
|
$manual = trim((string)($data[$column] ?? $data['document_number'] ?? $data['manual_number'] ?? ''));
|
|
if ($manual !== '') {
|
|
return $this->validateManual($doc_type_key, $manual, $table, $column);
|
|
}
|
|
|
|
$prefix = trim((string)($data['doc_number_prefix'] ?? $data['document_prefix'] ?? $data[$column . '_prefix'] ?? ''));
|
|
return $this->generate($doc_type_key, $prefix);
|
|
}
|
|
|
|
/**
|
|
* Generate using an explicit prefix and format — used when the user has
|
|
* selected a specific prefix from a multi-prefix doc type.
|
|
*/
|
|
public function generateWithPrefix(string $prefix, string $format_type, int $digits = 5): string
|
|
{
|
|
$digits = max(5, min(20, $digits));
|
|
[$year, $month, $day, $period] = $this->periodParts($format_type);
|
|
$seq = $this->nextSequence($prefix, $format_type, $year, $month, $day);
|
|
return $this->format($prefix, $period, $seq, $digits);
|
|
}
|
|
|
|
/**
|
|
* Throw if $number already exists in $table.$column for this company.
|
|
* On duplicate, throws with a suggestion for the next available number.
|
|
*/
|
|
public function assertUnique(string $number, string $table, string $column, string $doc_type_key = ''): void
|
|
{
|
|
$this->assertIdentifier($table);
|
|
$this->assertIdentifier($column);
|
|
if (!$this->numberExists($table, $column, $number)) return;
|
|
|
|
$hint = '';
|
|
if ($doc_type_key !== '') {
|
|
$parsed = $this->parseManualNumber($doc_type_key, $number);
|
|
$hint = ' Suggested: ' . $this->suggestManualNumber($table, $column, $parsed);
|
|
}
|
|
throw new Exception("Document number '{$number}' already exists.{$hint}");
|
|
}
|
|
|
|
public function validateManual(string $doc_type_key, string $number, string $table, string $column): string
|
|
{
|
|
$number = strtoupper(trim($number));
|
|
$parsed = $this->parseManualNumber($doc_type_key, $number);
|
|
|
|
if ((int)$parsed['config']['allow_manual'] !== 1) {
|
|
throw new Exception("Manual numbering is disabled for {$doc_type_key}.");
|
|
}
|
|
|
|
if ($this->numberExists($table, $column, $number)) {
|
|
$hint = $this->suggestManualNumber($table, $column, $parsed);
|
|
throw new Exception("Document number '{$number}' already exists. Suggested: {$hint}");
|
|
}
|
|
|
|
return $number;
|
|
}
|
|
|
|
/**
|
|
* Return the first configured prefix row for a doc type (DB row or built-in default).
|
|
*/
|
|
public function getConfig(string $doc_type_key, string $prefix = ''): array
|
|
{
|
|
$prefix = strtoupper(trim($prefix));
|
|
if ($prefix !== '') {
|
|
foreach ($this->getConfigsForType($doc_type_key) as $cfg) {
|
|
if (strtoupper((string)$cfg['prefix']) === $prefix) return $cfg;
|
|
}
|
|
throw new Exception("Prefix '{$prefix}' is not configured for {$doc_type_key}.");
|
|
}
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT prefix, format_type, sequence_digits, allow_manual
|
|
FROM document_types
|
|
WHERE company_id = :cid AND doc_type_key = :key
|
|
ORDER BY id ASC LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':key' => $doc_type_key]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if ($row) return $row;
|
|
return self::DEFAULTS[$doc_type_key]
|
|
?? ['prefix' => strtoupper($doc_type_key), 'format_type' => 'YYMM', 'sequence_digits' => 5, 'allow_manual' => 1];
|
|
}
|
|
|
|
public function getConfigsForType(string $doc_type_key): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id, doc_type_key, name, prefix, format_type, sequence_digits, allow_manual
|
|
FROM document_types
|
|
WHERE company_id = :cid AND doc_type_key = :key
|
|
ORDER BY id ASC"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':key' => $doc_type_key]);
|
|
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
if ($rows) return $rows;
|
|
|
|
if (isset(self::DEFAULTS[$doc_type_key])) {
|
|
return [array_merge(['id' => null, 'doc_type_key' => $doc_type_key], self::DEFAULTS[$doc_type_key])];
|
|
}
|
|
|
|
return [[
|
|
'id' => null,
|
|
'doc_type_key' => $doc_type_key,
|
|
'name' => $doc_type_key,
|
|
'prefix' => strtoupper($doc_type_key),
|
|
'format_type' => 'YYMM',
|
|
'sequence_digits' => 5,
|
|
'allow_manual' => 1,
|
|
]];
|
|
}
|
|
|
|
/**
|
|
* Return all configured prefix rows for every doc type — used by the settings UI.
|
|
* Merges DB rows over DEFAULTS; doc types with no DB row appear with the default.
|
|
*/
|
|
public function getAllConfigs(): array
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT id, doc_type_key, name, prefix, format_type, sequence_digits, allow_manual
|
|
FROM document_types
|
|
WHERE company_id = :cid
|
|
ORDER BY doc_type_key, id"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id]);
|
|
$db_rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
|
|
|
// Index existing DB rows by doc_type_key
|
|
$by_key = [];
|
|
foreach ($db_rows as $r) {
|
|
$by_key[$r['doc_type_key']][] = $r;
|
|
}
|
|
|
|
// Merge defaults for any key not in DB
|
|
$result = [];
|
|
foreach (self::DEFAULTS as $key => $def) {
|
|
if (isset($by_key[$key])) {
|
|
foreach ($by_key[$key] as $row) {
|
|
$result[] = array_merge(['doc_type_key' => $key, 'id' => $row['id']], $row);
|
|
}
|
|
} else {
|
|
$result[] = [
|
|
'id' => null,
|
|
'doc_type_key' => $key,
|
|
'name' => $def['name'],
|
|
'prefix' => $def['prefix'],
|
|
'format_type' => $def['format_type'],
|
|
'sequence_digits' => $def['sequence_digits'],
|
|
'allow_manual' => $def['allow_manual'],
|
|
];
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Save (insert or update) a document_types row.
|
|
* $id = null → insert; $id > 0 → update.
|
|
*/
|
|
public function saveConfig(array $data, ?int $id): int
|
|
{
|
|
$key = trim((string)($data['doc_type_key'] ?? ''));
|
|
$name = trim((string)($data['name'] ?? ''));
|
|
$prefix = strtoupper(trim((string)($data['prefix'] ?? '')));
|
|
$fmt = $data['format_type'] ?? 'YYMM';
|
|
$digits = max(5, min(20, (int)($data['sequence_digits'] ?? 5)));
|
|
$manual = (int)(bool)($data['allow_manual'] ?? 1);
|
|
|
|
if (!$key || !$prefix) throw new Exception('doc_type_key and prefix are required.');
|
|
if (!in_array($fmt, ['YY', 'YYMM', 'YYMMDD', 'none'], true)) throw new Exception('Invalid format_type.');
|
|
|
|
if ($id) {
|
|
$this->pdo->prepare(
|
|
"UPDATE document_types SET prefix = :prefix, format_type = :fmt,
|
|
sequence_digits = :digits, allow_manual = :manual
|
|
WHERE id = :id AND company_id = :cid"
|
|
)->execute([':prefix' => $prefix, ':fmt' => $fmt, ':digits' => $digits,
|
|
':manual' => $manual, ':id' => $id, ':cid' => $this->company_id]);
|
|
return $id;
|
|
}
|
|
|
|
$this->pdo->prepare(
|
|
"INSERT INTO document_types (company_id, doc_type_key, name, prefix, format_type, sequence_digits, allow_manual)
|
|
VALUES (:cid, :key, :name, :prefix, :fmt, :digits, :manual)"
|
|
)->execute([':cid' => $this->company_id, ':key' => $key, ':name' => $name,
|
|
':prefix' => $prefix, ':fmt' => $fmt, ':digits' => $digits, ':manual' => $manual]);
|
|
return (int)$this->pdo->lastInsertId();
|
|
}
|
|
|
|
/**
|
|
* Delete a document_types row. Refuses to delete the last row for a doc type.
|
|
*/
|
|
public function deleteConfig(int $id): void
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT doc_type_key FROM document_types WHERE id = :id AND company_id = :cid LIMIT 1"
|
|
);
|
|
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
|
if (!$row) throw new Exception('Config not found.');
|
|
|
|
$count_sth = $this->pdo->prepare(
|
|
"SELECT COUNT(*) FROM document_types WHERE company_id = :cid AND doc_type_key = :key"
|
|
);
|
|
$count_sth->execute([':cid' => $this->company_id, ':key' => $row['doc_type_key']]);
|
|
if ((int)$count_sth->fetchColumn() <= 1) {
|
|
throw new Exception('Cannot delete the last prefix for a document type. Edit it instead.');
|
|
}
|
|
|
|
$this->pdo->prepare("DELETE FROM document_types WHERE id = :id AND company_id = :cid")
|
|
->execute([':id' => $id, ':cid' => $this->company_id]);
|
|
}
|
|
|
|
// ── Private helpers ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Atomically reserve the next sequence number.
|
|
* Uses INSERT...ON DUPLICATE KEY so two concurrent saves never share a number.
|
|
*/
|
|
private function nextSequence(string $prefix, string $format_type, int $year, int $month, int $day): int
|
|
{
|
|
$sth = $this->pdo->prepare(
|
|
"INSERT INTO document_number_sequences
|
|
(company_id, prefix, format_type, year, month, day, last_sequence)
|
|
VALUES
|
|
(:cid, :prefix, :fmt, :year, :month, :day, 1)
|
|
ON DUPLICATE KEY UPDATE
|
|
last_sequence = LAST_INSERT_ID(last_sequence + 1)"
|
|
);
|
|
$sth->execute([
|
|
':cid' => $this->company_id,
|
|
':prefix' => $prefix,
|
|
':fmt' => $format_type,
|
|
':year' => $year,
|
|
':month' => $month,
|
|
':day' => $day,
|
|
]);
|
|
|
|
// rowCount() = 1 → fresh INSERT (sequence = 1)
|
|
// rowCount() = 2 → ON DUPLICATE UPDATE fired → LAST_INSERT_ID holds new value
|
|
if ($sth->rowCount() === 1) {
|
|
return 1;
|
|
}
|
|
return (int)$this->pdo->lastInsertId();
|
|
}
|
|
|
|
/**
|
|
* Derive (year, month, day, period_string) from today given format_type.
|
|
* Unused parts are stored as 0 to avoid NULL-comparison issues in the unique key.
|
|
*/
|
|
private function periodParts(string $format_type): array
|
|
{
|
|
$now = new DateTimeImmutable('now');
|
|
$yy = (int)$now->format('y'); // 2-digit year
|
|
$mm = (int)$now->format('m');
|
|
$dd = (int)$now->format('d');
|
|
$yy_s = $now->format('y');
|
|
$mm_s = $now->format('m');
|
|
$dd_s = $now->format('d');
|
|
|
|
switch ($format_type) {
|
|
case 'YY': return [$yy, 0, 0, $yy_s];
|
|
case 'YYMM': return [$yy, $mm, 0, $yy_s . $mm_s];
|
|
case 'YYMMDD': return [$yy, $mm, $dd, $yy_s . $mm_s . $dd_s];
|
|
default: return [0, 0, 0, '']; // 'none'
|
|
}
|
|
}
|
|
|
|
private function format(string $prefix, string $period, int $seq, int $digits): string
|
|
{
|
|
$padded = str_pad((string)$seq, $digits, '0', STR_PAD_LEFT);
|
|
return $period === '' ? "{$prefix}-{$padded}" : "{$prefix}-{$period}-{$padded}";
|
|
}
|
|
|
|
private function parseManualNumber(string $doc_type_key, string $number): array
|
|
{
|
|
$configs = $this->getConfigsForType($doc_type_key);
|
|
usort($configs, fn($a, $b) => strlen((string)$b['prefix']) <=> strlen((string)$a['prefix']));
|
|
|
|
foreach ($configs as $cfg) {
|
|
$prefix = strtoupper((string)$cfg['prefix']);
|
|
$digits = max(5, min(20, (int)$cfg['sequence_digits']));
|
|
$quoted = preg_quote($prefix, '/');
|
|
$fmt = (string)$cfg['format_type'];
|
|
|
|
if ($fmt === 'none') {
|
|
$regex = "/^{$quoted}-(\d{{$digits}})$/";
|
|
} else {
|
|
$period_len = ['YY' => 2, 'YYMM' => 4, 'YYMMDD' => 6][$fmt] ?? 4;
|
|
$regex = "/^{$quoted}-(\d{{$period_len}})-(\d{{$digits}})$/";
|
|
}
|
|
|
|
if (!preg_match($regex, $number, $m)) continue;
|
|
|
|
return [
|
|
'config' => $cfg,
|
|
'prefix' => $prefix,
|
|
'format_type' => $fmt,
|
|
'period' => $fmt === 'none' ? '' : $m[1],
|
|
'sequence' => (int)($fmt === 'none' ? $m[1] : $m[2]),
|
|
'digits' => $digits,
|
|
];
|
|
}
|
|
|
|
throw new Exception("Document number '{$number}' does not match a configured {$doc_type_key} format.");
|
|
}
|
|
|
|
private function suggestManualNumber(string $table, string $column, array $parsed): string
|
|
{
|
|
$this->assertIdentifier($table);
|
|
$this->assertIdentifier($column);
|
|
|
|
$prefix = $parsed['prefix'];
|
|
$period = $parsed['period'];
|
|
$digits = (int)$parsed['digits'];
|
|
$pattern = $period === '' ? "{$prefix}-%" : "{$prefix}-{$period}-%";
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT `{$column}` FROM `{$table}`
|
|
WHERE company_id = :cid AND `{$column}` LIKE :pattern"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':pattern' => $pattern]);
|
|
|
|
$max = 0;
|
|
while (($value = $sth->fetchColumn()) !== false) {
|
|
try {
|
|
$candidate = $this->parseManualNumber((string)$parsed['config']['doc_type_key'], (string)$value);
|
|
if ($candidate['prefix'] === $prefix && $candidate['period'] === $period) {
|
|
$max = max($max, (int)$candidate['sequence']);
|
|
}
|
|
} catch (Exception $e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
return $this->format($prefix, $period, $max + 1, $digits);
|
|
}
|
|
|
|
private function numberExists(string $table, string $column, string $number): bool
|
|
{
|
|
$this->assertIdentifier($table);
|
|
$this->assertIdentifier($column);
|
|
|
|
$sth = $this->pdo->prepare(
|
|
"SELECT 1 FROM `{$table}` WHERE company_id = :cid AND `{$column}` = :num LIMIT 1"
|
|
);
|
|
$sth->execute([':cid' => $this->company_id, ':num' => $number]);
|
|
return $sth->fetchColumn() !== false;
|
|
}
|
|
|
|
private function assertIdentifier(string $identifier): void
|
|
{
|
|
if (!preg_match('/^[A-Za-z0-9_]+$/', $identifier)) {
|
|
throw new Exception('Invalid document number lookup target.');
|
|
}
|
|
}
|
|
|
|
}
|
|
|