document number sequence
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user