document number sequence

This commit is contained in:
Thanakorn S
2026-05-26 08:19:40 +07:00
parent 4f884177a5
commit 1904fea84c
25 changed files with 846 additions and 216 deletions
@@ -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.');
}
}
}