diff --git a/.gitignore b/.gitignore index 00faa53..8ebc07b 100755 --- a/.gitignore +++ b/.gitignore @@ -2,17 +2,13 @@ app/config.php app/uploads -# Logs -logs -*.log - -# PHP dependencies -vendor/ - # Node dependencies node_modules/ nodejs/.env +# zxcvbn dev dependencies (not needed at runtime) +lib/zxcvbn-php-master/vendor/sebastian/ + # custom files notes/ docs/ diff --git a/app/accounting/api/engine/save_manual_journal.php b/app/accounting/api/engine/save_manual_journal.php index 5183216..ae47565 100644 --- a/app/accounting/api/engine/save_manual_journal.php +++ b/app/accounting/api/engine/save_manual_journal.php @@ -1,6 +1,7 @@ replaceManual($gl_id, $reference, $description, $journal_date, $period, $lines); $answer['message'] = 'Journal entry updated.'; } else { + $number_mgr = new DocumentNumberManager($pdo2, $company_id); + if ($reference === '') { + $reference = $number_mgr->generate('manual', trim((string)($data['doc_number_prefix'] ?? $data['document_prefix'] ?? ''))); + } else { + $reference = $number_mgr->validateManual('manual', $reference, 'td_gl', 'reference'); + } $gl_id = $gl->postManual($reference, $description, $journal_date, $period, $lines); $answer['message'] = 'Journal entry saved.'; } diff --git a/app/assets/utils/classes/DocumentNumberManager.php b/app/assets/utils/classes/DocumentNumberManager.php new file mode 100644 index 0000000..0c1c248 --- /dev/null +++ b/app/assets/utils/classes/DocumentNumberManager.php @@ -0,0 +1,414 @@ + ['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.'); + } + } + +} + diff --git a/app/assets/utils/classes/InvoiceManager.php b/app/assets/utils/classes/InvoiceManager.php index f2df951..a9b55f2 100644 --- a/app/assets/utils/classes/InvoiceManager.php +++ b/app/assets/utils/classes/InvoiceManager.php @@ -1,4 +1,5 @@ 'INV', - 'credit_note' => 'CN', - 'supplier_credit_note' => 'DN', - 'purchase_invoice' => 'PINV', - ]; - $prefix = ($prefix_map[$doc_type] ?? 'INV') . '-' . date('Ymd') . '-'; - - $sth = $this->pdo->prepare( - "SELECT invoice_number FROM td_invoice - WHERE company_id = :company_id - AND doc_type = :doc_type - AND invoice_number LIKE :prefix - ORDER BY invoice_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':doc_type' => $doc_type, - ':prefix' => $prefix . '%', - ]); - - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, $doc_type, 'td_invoice', 'invoice_number'); } private function addSettlementFields(array $row): array @@ -1013,7 +990,7 @@ class InvoiceManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':invoice_number' => $this->generateInvoiceNumber('credit_note'), + ':invoice_number' => $this->generateInvoiceNumber('credit_note', $data), ':ref_invoice_id' => $ref_invoice_id, ':order_id' => $order_id, ':contact_id' => $contact_id, @@ -1132,7 +1109,7 @@ class InvoiceManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note'), + ':invoice_number' => $this->generateInvoiceNumber('supplier_credit_note', $data), ':ref_invoice_id' => $ref_invoice_id, ':contact_id' => $contact_id, ':subtotal' => $subtotal, diff --git a/app/assets/utils/classes/OrderManager.php b/app/assets/utils/classes/OrderManager.php index 0adfdb7..d5b029b 100644 --- a/app/assets/utils/classes/OrderManager.php +++ b/app/assets/utils/classes/OrderManager.php @@ -1,4 +1,5 @@ pdo->prepare( - "SELECT order_number FROM td_order - WHERE company_id = :company_id - AND order_number LIKE :prefix - ORDER BY order_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':prefix' => $prefix . '%', - ]); - - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'sales_order', 'td_order', 'order_number'); } /** @@ -589,7 +574,7 @@ class OrderManager { ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, ':source' => $source, - ':order_number' => $this->generateOrderNumber(), + ':order_number' => $this->generateOrderNumber($data), ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':order_date' => $data['order_date'] ?? date('Y-m-d'), diff --git a/app/assets/utils/classes/PaymentBillingManager.php b/app/assets/utils/classes/PaymentBillingManager.php index e0c2cfc..e244034 100644 --- a/app/assets/utils/classes/PaymentBillingManager.php +++ b/app/assets/utils/classes/PaymentBillingManager.php @@ -1,4 +1,5 @@ company_id = $company_id; } - private function generateBillingNumber(): string + private function generateBillingNumber(array $data = []): string { - $prefix = 'PB-' . date('Ymd') . '-'; - $sth = $this->pdo->prepare( - "SELECT billing_number FROM td_payment_billing - WHERE company_id = :company_id - AND billing_number LIKE :prefix - ORDER BY billing_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':prefix' => $prefix . '%', - ]); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'payment_billing', 'td_payment_billing', 'billing_number'); } private function unavailableDocumentAmount(int $invoice_id): float @@ -340,7 +328,7 @@ class PaymentBillingManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':billing_number' => $this->generateBillingNumber(), + ':billing_number' => $this->generateBillingNumber($data), ':contact_id' => $contact_id, ':billing_date' => $billing_date, ':amount' => $net_amount, diff --git a/app/assets/utils/classes/PaymentManager.php b/app/assets/utils/classes/PaymentManager.php index 6eb2abc..cce07c5 100644 --- a/app/assets/utils/classes/PaymentManager.php +++ b/app/assets/utils/classes/PaymentManager.php @@ -1,4 +1,5 @@ company_id = $company_id; } - private function generatePaymentNumber(): string + private function generatePaymentNumber(array $data = []): string { - $prefix = 'PY-' . date('Ymd') . '-'; - $sth = $this->pdo->prepare( - "SELECT payment_number FROM td_payment - WHERE company_id = :company_id - AND payment_number LIKE :prefix - ORDER BY payment_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':prefix' => $prefix . '%', - ]); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'payment', 'td_payment', 'payment_number'); } private function assertPostingWindow(?string $date, string $context): void @@ -450,7 +438,7 @@ class PaymentManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':payment_number' => $this->generatePaymentNumber(), + ':payment_number' => $this->generatePaymentNumber($data), ':contact_id' => $contact_id, ':department_id' => (int)($data['department_id'] ?? 0), ':payment_billing_id' => $billing_id, diff --git a/app/assets/utils/classes/PurchaseOrderManager.php b/app/assets/utils/classes/PurchaseOrderManager.php index a840e70..3f93ea1 100644 --- a/app/assets/utils/classes/PurchaseOrderManager.php +++ b/app/assets/utils/classes/PurchaseOrderManager.php @@ -1,4 +1,5 @@ pdo->prepare( - "SELECT po_number FROM td_purchase_order - WHERE company_id = :company_id - AND po_number LIKE :prefix - ORDER BY po_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':prefix' => $prefix . '%', - ]); - - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'purchase_order', 'td_purchase_order', 'po_number'); } /** @@ -433,7 +418,7 @@ class PurchaseOrderManager { ':uuid' => bin2hex(random_bytes(16)), ':source_id' => $source_id, ':source' => $source, - ':po_number' => $this->generatePoNumber(), + ':po_number' => $this->generatePoNumber($data), ':contact_id' => (int)($data['contact_id'] ?? 0), ':department_id' => (int)($data['department_id'] ?? 0), ':po_date' => $data['po_date'] ?? date('Y-m-d'), diff --git a/app/assets/utils/classes/PurchaseRequestManager.php b/app/assets/utils/classes/PurchaseRequestManager.php index 2067fe0..597d407 100644 --- a/app/assets/utils/classes/PurchaseRequestManager.php +++ b/app/assets/utils/classes/PurchaseRequestManager.php @@ -1,4 +1,5 @@ pdo->prepare( - "SELECT request_number FROM td_purchase_request - WHERE company_id = :cid AND request_number LIKE :prefix - ORDER BY request_number DESC LIMIT 1" - ); - $sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'purchase_request', 'td_purchase_request', 'request_number'); } /** @@ -176,7 +169,7 @@ class PurchaseRequestManager [$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee); if ($id === 0) { - $number = $this->generateNumber(); + $number = $this->generateNumber($data); $log = [array_merge($logging, ['action' => 'create_purchase_request'])]; $this->pdo->prepare( diff --git a/app/assets/utils/classes/QuotationManager.php b/app/assets/utils/classes/QuotationManager.php index e4e3e58..7dd4690 100644 --- a/app/assets/utils/classes/QuotationManager.php +++ b/app/assets/utils/classes/QuotationManager.php @@ -1,4 +1,5 @@ pdo->prepare( - "SELECT quotation_number FROM td_quotation - WHERE company_id = :cid AND quotation_number LIKE :prefix - ORDER BY id DESC LIMIT 1" - ); - $sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'quotation', 'td_quotation', 'quotation_number'); } /** @@ -183,7 +176,7 @@ class QuotationManager [$subtotal, $tax, $grand] = $this->computeTotals($items, $discount); if ($id === 0) { - $number = $this->generateNumber(); + $number = $this->generateNumber($data); $log = [array_merge($logging, ['action' => 'create_quotation'])]; $this->pdo->prepare( diff --git a/app/assets/utils/classes/ReceiptBillingManager.php b/app/assets/utils/classes/ReceiptBillingManager.php index d102f75..88f3b60 100644 --- a/app/assets/utils/classes/ReceiptBillingManager.php +++ b/app/assets/utils/classes/ReceiptBillingManager.php @@ -1,4 +1,5 @@ company_id = $company_id; } - private function generateBillingNumber(): string + private function generateBillingNumber(array $data = []): string { - $prefix = 'RB-' . date('Ymd') . '-'; - $sth = $this->pdo->prepare( - "SELECT billing_number FROM td_receipt_billing - WHERE company_id = :company_id - AND billing_number LIKE :prefix - ORDER BY billing_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':prefix' => $prefix . '%', - ]); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'receipt_billing', 'td_receipt_billing', 'billing_number'); } private function unavailableDocumentAmount(int $invoice_id): float @@ -338,7 +326,7 @@ class ReceiptBillingManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':billing_number' => $this->generateBillingNumber(), + ':billing_number' => $this->generateBillingNumber($data), ':contact_id' => $contact_id, ':billing_date' => $billing_date, ':amount' => $net_amount, diff --git a/app/assets/utils/classes/ReceiptManager.php b/app/assets/utils/classes/ReceiptManager.php index d8a3fe0..53c34e3 100644 --- a/app/assets/utils/classes/ReceiptManager.php +++ b/app/assets/utils/classes/ReceiptManager.php @@ -1,4 +1,5 @@ company_id = $company_id; } - private function generateReceiptNumber(): string + private function generateReceiptNumber(array $data = []): string { - $prefix = 'RC-' . date('Ymd') . '-'; - $sth = $this->pdo->prepare( - "SELECT receipt_number FROM td_receipt - WHERE company_id = :company_id - AND receipt_number LIKE :prefix - ORDER BY receipt_number DESC - LIMIT 1" - ); - $sth->execute([ - ':company_id' => $this->company_id, - ':prefix' => $prefix . '%', - ]); - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'receipt', 'td_receipt', 'receipt_number'); } private function assertPostingWindow(?string $date, string $context): void @@ -448,7 +436,7 @@ class ReceiptManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':receipt_number' => $this->generateReceiptNumber(), + ':receipt_number' => $this->generateReceiptNumber($data), ':contact_id' => $contact_id, ':department_id' => (int)($data['department_id'] ?? 0), ':receipt_billing_id' => $billing_id, diff --git a/app/assets/utils/classes/ReturnManager.php b/app/assets/utils/classes/ReturnManager.php index a9fe44f..fca3b21 100644 --- a/app/assets/utils/classes/ReturnManager.php +++ b/app/assets/utils/classes/ReturnManager.php @@ -1,4 +1,5 @@ pdo->prepare( - "SELECT return_number FROM td_return - WHERE company_id = :company_id - AND return_number LIKE :prefix - ORDER BY return_number DESC - LIMIT 1" - ); - $sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']); - - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'return', 'td_return', 'return_number'); } private function stockTableNameFromWarehouseId(int $warehouse_id): string @@ -373,7 +362,7 @@ class ReturnManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':return_number' => $this->generateReturnNumber(), + ':return_number' => $this->generateReturnNumber($data), ':order_id' => (int)($data['order_id'] ?? 0), ':invoice_id' => (int)($data['invoice_id'] ?? 0), ':contact_id' => (int)($data['contact_id'] ?? 0), diff --git a/app/assets/utils/classes/StockManager.php b/app/assets/utils/classes/StockManager.php index 006e790..8696502 100644 --- a/app/assets/utils/classes/StockManager.php +++ b/app/assets/utils/classes/StockManager.php @@ -47,6 +47,11 @@ class StockManager { return 'td_stock_' . $warehouse_id; } + private function stockReferenceSql(): string + { + return "CONCAT(DATE_FORMAT(COALESCE(a.`date`, a.updated_at), '%Y%m%d%H%i%s'), '-', LPAD(a.id, 11, '0'))"; + } + // ───────────────────────────────────────────────────────────── // TRANSACTION BASIS — Read // ───────────────────────────────────────────────────────────── @@ -66,12 +71,13 @@ class StockManager { { $table = $this->stockTableNameFromWarehouseId($warehouse_id); $column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; + $stock_ref = $this->stockReferenceSql(); // Transfer list: show only the outbound side (out > 0) to avoid duplicate display $extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : ''; $sth = $this->pdo->prepare( - "SELECT a.*, {$column} AS quantity, b.product_name, b.uom + "SELECT a.*, {$stock_ref} AS stock_reference, {$column} AS quantity, b.product_name, b.uom FROM `{$table}` a LEFT JOIN md_product b ON a.company_id = b.company_id @@ -102,9 +108,10 @@ class StockManager { public function getStockInById(int $warehouse_id, int $id): array|false { $table = $this->stockTableNameFromWarehouseId($warehouse_id); + $stock_ref = $this->stockReferenceSql(); $sth = $this->pdo->prepare( - "SELECT a.*, a.in AS quantity, + "SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity, b.contact_name, c.product_name, c.uom, d.expiry_date @@ -137,9 +144,10 @@ class StockManager { public function getStockOutById(int $warehouse_id, int $id): array|false { $table = $this->stockTableNameFromWarehouseId($warehouse_id); + $stock_ref = $this->stockReferenceSql(); $sth = $this->pdo->prepare( - "SELECT a.*, a.out AS quantity, + "SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity, b.contact_name, c.product_name, c.uom FROM `{$table}` a @@ -170,10 +178,11 @@ class StockManager { public function getTransferById(int $warehouse_id, int $id): array|false { $table = $this->stockTableNameFromWarehouseId($warehouse_id); + $stock_ref = $this->stockReferenceSql(); // Fetch the outbound (from) row $sth = $this->pdo->prepare( - "SELECT a.*, a.out AS quantity, + "SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity, b.contact_name, c.product_name, c.uom FROM `{$table}` a @@ -193,7 +202,7 @@ class StockManager { $to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id); $sth = $this->pdo->prepare( - "SELECT a.*, a.in AS quantity, + "SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity, b.contact_name, c.product_name, c.uom FROM `{$to_table}` a diff --git a/app/assets/utils/classes/SupplierReturnManager.php b/app/assets/utils/classes/SupplierReturnManager.php index bd43275..08e4ded 100644 --- a/app/assets/utils/classes/SupplierReturnManager.php +++ b/app/assets/utils/classes/SupplierReturnManager.php @@ -1,4 +1,5 @@ pdo->prepare( - "SELECT return_number FROM td_supplier_return - WHERE company_id = :company_id - AND return_number LIKE :prefix - ORDER BY return_number DESC - LIMIT 1" - ); - $sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']); - - $last = $sth->fetchColumn(); - $seq = $last ? ((int)substr($last, -4) + 1) : 1; - return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT); + return (new DocumentNumberManager($this->pdo, $this->company_id)) + ->resolveNumber($data, 'supplier_return', 'td_supplier_return', 'return_number'); } private function stockTableName(int $warehouse_id): string @@ -333,7 +322,7 @@ class SupplierReturnManager { )->execute([ ':company_id' => $this->company_id, ':uuid' => bin2hex(random_bytes(16)), - ':return_number' => $this->generateReturnNumber(), + ':return_number' => $this->generateReturnNumber($data), ':po_id' => (int)($data['po_id'] ?? 0), ':invoice_id' => (int)($data['invoice_id'] ?? 0), ':contact_id' => (int)($data['contact_id'] ?? 0), diff --git a/app/ics/stock_in.php b/app/ics/stock_in.php index c98971a..f987de6 100644 --- a/app/ics/stock_in.php +++ b/app/ics/stock_in.php @@ -55,6 +55,7 @@ Date + Reference Product Lot Number Serial Number @@ -214,6 +215,7 @@ $.each(page_data, function(key, item) { body += ` ${format_date(item["date"])} + ${item["stock_reference"] || "—"} ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'} diff --git a/app/ics/stock_out.php b/app/ics/stock_out.php index 97199ac..b2be933 100644 --- a/app/ics/stock_out.php +++ b/app/ics/stock_out.php @@ -55,6 +55,7 @@ Date + Reference Product Lot Number Serial Number @@ -241,6 +242,7 @@ $.each(page_data, function(key, item) { body += ` ${format_date(item["date"])} + ${item["stock_reference"] || "—"} ${item["product_sku"]}: ${item['product_name']} ${item['lot_number'] || '—'} ${item['serial_number'] || '—'} diff --git a/app/ics/stock_transfer.php b/app/ics/stock_transfer.php index 43f9fd7..1fb62e4 100644 --- a/app/ics/stock_transfer.php +++ b/app/ics/stock_transfer.php @@ -47,6 +47,7 @@ Date + Reference Product Quantity Zone @@ -148,6 +149,7 @@ $.each(page_data, function(key, item) { body += ` ${format_date(item["date"])} + ${item["stock_reference"] || "—"} ${item["product_sku"]}: ${item['product_name']}
diff --git a/app/include_setting_sidebar.php b/app/include_setting_sidebar.php index 0ddb82d..e0d100d 100644 --- a/app/include_setting_sidebar.php +++ b/app/include_setting_sidebar.php @@ -61,6 +61,14 @@ +
  • + + + Document Numbering + +
  • + diff --git a/app/setting/api/engine/document_types.php b/app/setting/api/engine/document_types.php new file mode 100644 index 0000000..aa901db --- /dev/null +++ b/app/setting/api/engine/document_types.php @@ -0,0 +1,47 @@ +getAllConfigs(); + $answer['success'] = 1; + break; + + case 'options': + $key = trim((string)($data['doc_type_key'] ?? '')); + if ($key === '') throw new Exception('doc_type_key is required.'); + $answer['data'] = $mgr->getConfigsForType($key); + $answer['success'] = 1; + break; + + case 'save': + require_role($user_role, ['owner', 'admin']); + $id = isset($data['id']) && (int)$data['id'] > 0 ? (int)$data['id'] : null; + $new_id = $mgr->saveConfig($data, $id); + $answer['id'] = $new_id; + $answer['message'] = $id ? 'Configuration updated.' : 'Configuration saved.'; + $answer['success'] = 1; + break; + + case 'delete': + require_role($user_role, ['owner', 'admin']); + $mgr->deleteConfig((int)($data['id'] ?? 0)); + $answer['message'] = 'Configuration deleted.'; + $answer['success'] = 1; + break; + + default: + $answer['message'] = 'Unknown action.'; + } +} catch (Exception $e) { + $answer['message'] = $e->getMessage(); +} + +exit(json_encode($answer)); diff --git a/app/setting/document_types.php b/app/setting/document_types.php new file mode 100644 index 0000000..0a6b4df --- /dev/null +++ b/app/setting/document_types.php @@ -0,0 +1,254 @@ + + + + + + +
    +
    + + +
    +
    +
    +
    +

    Document Numbering

    +

    Configure prefix, format, and sequence digits for each document type

    +
    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + +
    Document TypePrefixFormatDigitsManual EntryActions
    Loading…
    +
    +
    +
    +
    +
    + +
    +
    + + + + + + + + diff --git a/docs/tests/doc_flow_test.php b/docs/tests/doc_flow_test.php deleted file mode 100644 index 4e1e418..0000000 --- a/docs/tests/doc_flow_test.php +++ /dev/null @@ -1,9 +0,0 @@ -