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
+3 -7
View File
@@ -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/
@@ -1,6 +1,7 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/DocumentNumberManager.php';
require_once '../../../assets/utils/classes_ac/PostingWindowGuard.php';
require_once '../../../assets/utils/classes_ac/GlManager.php';
require_once '../../../assets/utils/notify_node.php';
@@ -68,6 +69,12 @@ try {
$gl->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.';
}
@@ -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.');
}
}
}
+8 -31
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/../classes_ac/GlManager.php';
@@ -70,37 +71,13 @@ class InvoiceManager {
/**
* Generate next sequential document number.
*
* @param string $doc_type 'invoice' | 'credit_note' | 'supplier_credit_note'
* @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001"
* @param string $doc_type 'invoice' | 'credit_note' | 'purchase_invoice' | 'supplier_credit_note'
* @return string e.g. "INV-2605-00001" | "CN-2605-00001" | "PI-2605-00001" | "SCN-2605-00001"
*/
private function generateInvoiceNumber(string $doc_type): string
private function generateInvoiceNumber(string $doc_type, array $data = []): string
{
$prefix_map = [
'invoice' => '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,
+5 -20
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -139,26 +140,10 @@ class OrderManager {
*
* @return string e.g. "ORD-20260502-0001"
*/
private function generateOrderNumber(): string
private function generateOrderNumber(array $data = []): string
{
$prefix = 'ORD-' . date('Ymd') . '-';
$sth = $this->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'),
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
class PaymentBillingManager {
@@ -11,23 +12,10 @@ class PaymentBillingManager {
$this->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,
+5 -17
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/../classes_ac/GlManager.php';
@@ -12,23 +13,10 @@ class PaymentManager {
$this->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,
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -59,26 +60,10 @@ class PurchaseOrderManager {
/**
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
*/
private function generatePoNumber(): string
private function generatePoNumber(array $data = []): string
{
$prefix = 'PO-' . date('Ymd') . '-';
$sth = $this->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'),
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -31,18 +32,10 @@ class PurchaseRequestManager
// Private helpers
// ─────────────────────────────────────────────────────────────
private function generateNumber(): string
private function generateNumber(array $data = []): string
{
$prefix = 'PR-' . date('Ym') . '-';
$sth = $this->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(
+5 -12
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -31,18 +32,10 @@ class QuotationManager
// Private helpers
// ─────────────────────────────────────────────────────────────
private function generateNumber(): string
private function generateNumber(array $data = []): string
{
$prefix = 'QT-' . date('Ym') . '-';
$sth = $this->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(
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
class ReceiptBillingManager {
@@ -11,23 +12,10 @@ class ReceiptBillingManager {
$this->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,
+5 -17
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
require_once __DIR__ . '/../classes_ac/GlManager.php';
@@ -12,23 +13,10 @@ class ReceiptManager {
$this->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,
+5 -16
View File
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -55,22 +56,10 @@ class ReturnManager {
];
}
private function generateReturnNumber(): string
private function generateReturnNumber(array $data = []): string
{
$prefix = 'RET-' . date('Ymd') . '-';
$sth = $this->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),
+14 -5
View File
@@ -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
@@ -1,4 +1,5 @@
<?php
require_once __DIR__ . '/DocumentNumberManager.php';
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
/**
@@ -38,22 +39,10 @@ class SupplierReturnManager {
// Private helpers
// ─────────────────────────────────────────────────────────────
private function generateReturnNumber(): string
private function generateReturnNumber(array $data = []): string
{
$prefix = 'SRN-' . date('Ymd') . '-';
$sth = $this->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),
+2
View File
@@ -55,6 +55,7 @@
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Reference</th>
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
@@ -214,6 +215,7 @@
$.each(page_data, function(key, item) {
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
+2
View File
@@ -55,6 +55,7 @@
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Reference</th>
<th>Product</th>
<th>Lot Number</th>
<th>Serial Number</th>
@@ -241,6 +242,7 @@
$.each(page_data, function(key, item) {
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</td>
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
+2
View File
@@ -47,6 +47,7 @@
<thead class="table-primary border-light">
<tr>
<th>Date</th>
<th>Reference</th>
<th>Product</th>
<th style="text-align: right;">Quantity</th>
<th class="th_loc_zone">Zone</th>
@@ -148,6 +149,7 @@
$.each(page_data, function(key, item) {
body += `<tr>
<td class="py-3">${format_date(item["date"])}</td>
<td class="py-3 fw-semibold">${item["stock_reference"] || "—"}</td>
<td class="py-3">${item["product_sku"]}: ${item['product_name']}</td>
<td class="py-3">
<div class="d-flex justify-content-end align-items-baseline gap-1">
+8
View File
@@ -61,6 +61,14 @@
</a>
</li>
<li>
<a class="nav-link <?php echo $current_page === 'document_types.php' ? 'active' : ''; ?>"
href="<?php echo $server_url?>setting/document_types.php">
<i class="ti ti-hash"></i>
<span class="nav-text">Document Numbering</span>
</a>
</li>
<li class="nav-text-space">
<small class="nav-text text-muted">Maintenance</small>
</li>
+47
View File
@@ -0,0 +1,47 @@
<?php
session_start();
require_once '../../../assets/utils/db_auth.php';
require_once '../../../assets/utils/classes/DocumentNumberManager.php';
$mgr = new DocumentNumberManager($pdo2, $company_id);
$action = $data['action'] ?? '';
try {
switch ($action) {
case 'list':
$answer['data'] = $mgr->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));
+254
View File
@@ -0,0 +1,254 @@
<?php
session_start();
require '../config.php';
require '../include_header.php';
?>
<body>
<?php require '../include_topbar.php'; ?>
<?php require '../include_setting_sidebar.php'; ?>
<main id="content" class="content py-15">
<div class="container-fluid">
<!-- Page header -->
<div class="row">
<div class="col-12">
<div class="d-flex flex-column flex-md-row justify-content-between align-items-md-center mb-6 gap-3">
<div>
<h1 class="fs-3 mb-1">Document Numbering</h1>
<p class="mb-0">Configure prefix, format, and sequence digits for each document type</p>
</div>
<div>
<button class="btn btn-primary" onclick="open_add_modal()">
<i class="ti ti-plus me-1"></i>Add Prefix
</button>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0" id="dt_table">
<thead class="table-light">
<tr>
<th>Document Type</th>
<th>Prefix</th>
<th>Format</th>
<th>Digits</th>
<th>Manual Entry</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody id="dt_tbody">
<tr><td colspan="6" class="text-center py-4 text-muted">Loading…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- Add / Edit modal -->
<div class="modal fade" id="dt_modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dt_modal_title">Add Prefix</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="dt_id">
<div class="mb-3">
<label class="form-label">Document Type <span class="text-danger">*</span></label>
<select class="form-select" id="dt_key">
<option value="quotation">Quotation</option>
<option value="sales_order">Sales Order</option>
<option value="invoice">Sales Invoice</option>
<option value="credit_note">Sales Credit Note</option>
<option value="purchase_request">Purchase Request</option>
<option value="purchase_order">Purchase Order</option>
<option value="purchase_invoice">Purchase Invoice</option>
<option value="supplier_credit_note">Supplier Credit Note</option>
<option value="receipt">Receipt</option>
<option value="receipt_billing">Receipt Billing</option>
<option value="payment">Payment</option>
<option value="payment_billing">Payment Billing</option>
<option value="manual">Manual Journal</option>
<option value="return">Customer Return</option>
<option value="supplier_return">Supplier Return</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Prefix <span class="text-danger">*</span></label>
<input type="text" class="form-control text-uppercase" id="dt_prefix" maxlength="20" placeholder="e.g. INV">
<div class="form-text">Uppercase letters and numbers only. Will be auto-uppercased on save.</div>
</div>
<div class="mb-3">
<label class="form-label">Format</label>
<select class="form-select" id="dt_format">
<option value="YYMM">YYMM — e.g. INV-2605-00001</option>
<option value="YY">YY — e.g. INV-26-00001</option>
<option value="YYMMDD">YYMMDD — e.g. INV-260525-00001</option>
<option value="none">None — e.g. INV-00001</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Sequence Digits</label>
<input type="number" class="form-control" id="dt_digits" min="5" max="20" value="5">
<div class="form-text">Minimum 5. Zero-padded running number length.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="dt_manual" checked>
<label class="form-check-label" for="dt_manual">Allow manual number entry</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="save_config()">Save</button>
</div>
</div>
</div>
</div>
<script>
const API = '<?php echo $server_url; ?>setting/api/engine/document_types.php';
const FORMAT_LABELS = {
YY: 'YY',
YYMM: 'YYMM',
YYMMDD: 'YYMMDD',
none: 'None',
};
const KEY_LABELS = {
quotation: 'Quotation',
sales_order: 'Sales Order',
invoice: 'Sales Invoice',
credit_note: 'Sales Credit Note',
purchase_request: 'Purchase Request',
purchase_order: 'Purchase Order',
purchase_invoice: 'Purchase Invoice',
supplier_credit_note: 'Supplier Credit Note',
receipt: 'Receipt',
receipt_billing: 'Receipt Billing',
payment: 'Payment',
payment_billing: 'Payment Billing',
manual: 'Manual Journal',
return: 'Customer Return',
supplier_return: 'Supplier Return',
};
function load_configs() {
ajax_request({
url: API,
autoPrepare: true,
action: 'list',
onSuccess: function(res) {
render_table(res.data || []);
}
});
}
function render_table(rows) {
const tbody = document.getElementById('dt_tbody');
if (!rows.length) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center py-4 text-muted">No configurations found.</td></tr>';
return;
}
tbody.innerHTML = rows.map(r => `
<tr>
<td>${KEY_LABELS[r.doc_type_key] || r.doc_type_key}</td>
<td><span class="badge bg-secondary">${r.prefix}</span></td>
<td>${FORMAT_LABELS[r.format_type] || r.format_type}</td>
<td>${r.sequence_digits}</td>
<td>${r.allow_manual ? '<span class="badge bg-success-subtle text-success">Yes</span>' : '<span class="badge bg-secondary-subtle text-secondary">No</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" onclick='open_edit_modal(${JSON.stringify(r)})'>
<i class="ti ti-pencil"></i>
</button>
${r.id ? `<button class="btn btn-sm btn-outline-danger" onclick="delete_config(${r.id})">
<i class="ti ti-trash"></i>
</button>` : ''}
</td>
</tr>
`).join('');
}
function open_add_modal() {
document.getElementById('dt_modal_title').textContent = 'Add Prefix';
document.getElementById('dt_id').value = '';
document.getElementById('dt_key').value = 'quotation';
document.getElementById('dt_key').disabled = false;
document.getElementById('dt_prefix').value = '';
document.getElementById('dt_format').value = 'YYMM';
document.getElementById('dt_digits').value = '5';
document.getElementById('dt_manual').checked = true;
bootstrap.Modal.getOrCreateInstance(document.getElementById('dt_modal')).show();
}
function open_edit_modal(r) {
document.getElementById('dt_modal_title').textContent = 'Edit Prefix';
document.getElementById('dt_id').value = r.id || '';
document.getElementById('dt_key').value = r.doc_type_key;
document.getElementById('dt_key').disabled = true;
document.getElementById('dt_prefix').value = r.prefix;
document.getElementById('dt_format').value = r.format_type;
document.getElementById('dt_digits').value = r.sequence_digits;
document.getElementById('dt_manual').checked = r.allow_manual == 1;
bootstrap.Modal.getOrCreateInstance(document.getElementById('dt_modal')).show();
}
function save_config() {
const id = document.getElementById('dt_id').value;
const key = document.getElementById('dt_key').value;
const prefix = document.getElementById('dt_prefix').value.trim().toUpperCase();
const format = document.getElementById('dt_format').value;
const digits = parseInt(document.getElementById('dt_digits').value, 10);
const manual = document.getElementById('dt_manual').checked ? 1 : 0;
if (!prefix) { bootbox.alert('Prefix is required.'); return; }
ajax_request({
url: API,
autoPrepare: true,
action: 'save',
data: { id: id || null, doc_type_key: key, prefix, format_type: format, sequence_digits: digits, allow_manual: manual },
onSuccess: function(res) {
bootstrap.Modal.getOrCreateInstance(document.getElementById('dt_modal')).hide();
bootbox.alert(res.message || 'Saved.');
load_configs();
}
});
}
function delete_config(id) {
bootbox.confirm('Delete this prefix configuration?', function(ok) {
if (!ok) return;
ajax_request({
url: API,
autoPrepare: true,
action: 'delete',
data: { id },
onSuccess: function(res) {
bootbox.alert(res.message || 'Deleted.');
load_configs();
}
});
});
}
load_configs();
</script>
</body>
</html>
-9
View File
@@ -1,9 +0,0 @@
<?php
/**
* Compatibility entry point for the previous docs/tests/doc_flow_test.php path.
*
* The current document-flow risk area is soft-delete compliance, so this wrapper
* delegates to the focused soft-delete suite.
*/
require __DIR__ . '/soft_delete_test.php';
@@ -0,0 +1 @@
root = true
@@ -0,0 +1,2 @@
# reset all ignore rules to create sandbox for integration test
!/**
+32
View File
@@ -1128,6 +1128,38 @@ CREATE TABLE IF NOT EXISTS `td_payment_item` (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_payment_item');
run_ddl("
CREATE TABLE IF NOT EXISTS `document_types` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`doc_type_key` varchar(50) NOT NULL DEFAULT '',
`name` varchar(100) NOT NULL DEFAULT '',
`prefix` varchar(20) NOT NULL DEFAULT '',
`format_type` enum('YY','YYMM','YYMMDD','none') NOT NULL DEFAULT 'YYMM',
`sequence_digits` tinyint(2) unsigned NOT NULL DEFAULT 5,
`allow_manual` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_company_doctype_prefix` (`company_id`, `doc_type_key`, `prefix`),
KEY `company_id` (`company_id`),
KEY `doc_type_key` (`doc_type_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'document_types');
run_ddl("
CREATE TABLE IF NOT EXISTS `document_number_sequences` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`prefix` varchar(20) NOT NULL DEFAULT '',
`format_type` enum('YY','YYMM','YYMMDD','none') NOT NULL DEFAULT 'YYMM',
`year` smallint(4) unsigned NOT NULL DEFAULT 0,
`month` tinyint(2) unsigned NOT NULL DEFAULT 0,
`day` tinyint(2) unsigned NOT NULL DEFAULT 0,
`last_sequence` int(11) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_scope` (`company_id`, `prefix`, `format_type`, `year`, `month`, `day`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'document_number_sequences');
// ── Summary ───────────────────────────────────────────────────────────────────
$total = $ok_count + $skip_count + $err_count;
echo "\n\033[1m=== Done ===\033[0m\n";