['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.'); } } }