document number sequence
This commit is contained in:
+3
-7
@@ -2,17 +2,13 @@
|
|||||||
app/config.php
|
app/config.php
|
||||||
app/uploads
|
app/uploads
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# PHP dependencies
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
# Node dependencies
|
# Node dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
nodejs/.env
|
nodejs/.env
|
||||||
|
|
||||||
|
# zxcvbn dev dependencies (not needed at runtime)
|
||||||
|
lib/zxcvbn-php-master/vendor/sebastian/
|
||||||
|
|
||||||
# custom files
|
# custom files
|
||||||
notes/
|
notes/
|
||||||
docs/
|
docs/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
session_start();
|
session_start();
|
||||||
require_once '../../../assets/utils/db_auth.php';
|
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/PostingWindowGuard.php';
|
||||||
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
require_once '../../../assets/utils/classes_ac/GlManager.php';
|
||||||
require_once '../../../assets/utils/notify_node.php';
|
require_once '../../../assets/utils/notify_node.php';
|
||||||
@@ -68,6 +69,12 @@ try {
|
|||||||
$gl->replaceManual($gl_id, $reference, $description, $journal_date, $period, $lines);
|
$gl->replaceManual($gl_id, $reference, $description, $journal_date, $period, $lines);
|
||||||
$answer['message'] = 'Journal entry updated.';
|
$answer['message'] = 'Journal entry updated.';
|
||||||
} else {
|
} 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);
|
$gl_id = $gl->postManual($reference, $description, $journal_date, $period, $lines);
|
||||||
$answer['message'] = 'Journal entry saved.';
|
$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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||||
|
|
||||||
@@ -70,37 +71,13 @@ class InvoiceManager {
|
|||||||
/**
|
/**
|
||||||
* Generate next sequential document number.
|
* Generate next sequential document number.
|
||||||
*
|
*
|
||||||
* @param string $doc_type 'invoice' | 'credit_note' | 'supplier_credit_note'
|
* @param string $doc_type 'invoice' | 'credit_note' | 'purchase_invoice' | 'supplier_credit_note'
|
||||||
* @return string e.g. "INV-20260502-0001" | "CN-20260502-0001" | "DN-20260502-0001"
|
* @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 = [
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
'invoice' => 'INV',
|
->resolveNumber($data, $doc_type, 'td_invoice', 'invoice_number');
|
||||||
'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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function addSettlementFields(array $row): array
|
private function addSettlementFields(array $row): array
|
||||||
@@ -1013,7 +990,7 @@ class InvoiceManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':invoice_number' => $this->generateInvoiceNumber('credit_note'),
|
':invoice_number' => $this->generateInvoiceNumber('credit_note', $data),
|
||||||
':ref_invoice_id' => $ref_invoice_id,
|
':ref_invoice_id' => $ref_invoice_id,
|
||||||
':order_id' => $order_id,
|
':order_id' => $order_id,
|
||||||
':contact_id' => $contact_id,
|
':contact_id' => $contact_id,
|
||||||
@@ -1132,7 +1109,7 @@ class InvoiceManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':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,
|
':ref_invoice_id' => $ref_invoice_id,
|
||||||
':contact_id' => $contact_id,
|
':contact_id' => $contact_id,
|
||||||
':subtotal' => $subtotal,
|
':subtotal' => $subtotal,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,26 +140,10 @@ class OrderManager {
|
|||||||
*
|
*
|
||||||
* @return string e.g. "ORD-20260502-0001"
|
* @return string e.g. "ORD-20260502-0001"
|
||||||
*/
|
*/
|
||||||
private function generateOrderNumber(): string
|
private function generateOrderNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'ORD-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
|
->resolveNumber($data, 'sales_order', 'td_order', 'order_number');
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -589,7 +574,7 @@ class OrderManager {
|
|||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':source_id' => $source_id,
|
':source_id' => $source_id,
|
||||||
':source' => $source,
|
':source' => $source,
|
||||||
':order_number' => $this->generateOrderNumber(),
|
':order_number' => $this->generateOrderNumber($data),
|
||||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||||
':department_id' => (int)($data['department_id'] ?? 0),
|
':department_id' => (int)($data['department_id'] ?? 0),
|
||||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
class PaymentBillingManager {
|
class PaymentBillingManager {
|
||||||
@@ -11,23 +12,10 @@ class PaymentBillingManager {
|
|||||||
$this->company_id = $company_id;
|
$this->company_id = $company_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateBillingNumber(): string
|
private function generateBillingNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'PB-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
$sth = $this->pdo->prepare(
|
->resolveNumber($data, 'payment_billing', 'td_payment_billing', 'billing_number');
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unavailableDocumentAmount(int $invoice_id): float
|
private function unavailableDocumentAmount(int $invoice_id): float
|
||||||
@@ -340,7 +328,7 @@ class PaymentBillingManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':billing_number' => $this->generateBillingNumber(),
|
':billing_number' => $this->generateBillingNumber($data),
|
||||||
':contact_id' => $contact_id,
|
':contact_id' => $contact_id,
|
||||||
':billing_date' => $billing_date,
|
':billing_date' => $billing_date,
|
||||||
':amount' => $net_amount,
|
':amount' => $net_amount,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||||
|
|
||||||
@@ -12,23 +13,10 @@ class PaymentManager {
|
|||||||
$this->company_id = $company_id;
|
$this->company_id = $company_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generatePaymentNumber(): string
|
private function generatePaymentNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'PY-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
$sth = $this->pdo->prepare(
|
->resolveNumber($data, 'payment', 'td_payment', 'payment_number');
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertPostingWindow(?string $date, string $context): void
|
private function assertPostingWindow(?string $date, string $context): void
|
||||||
@@ -450,7 +438,7 @@ class PaymentManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':payment_number' => $this->generatePaymentNumber(),
|
':payment_number' => $this->generatePaymentNumber($data),
|
||||||
':contact_id' => $contact_id,
|
':contact_id' => $contact_id,
|
||||||
':department_id' => (int)($data['department_id'] ?? 0),
|
':department_id' => (int)($data['department_id'] ?? 0),
|
||||||
':payment_billing_id' => $billing_id,
|
':payment_billing_id' => $billing_id,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -59,26 +60,10 @@ class PurchaseOrderManager {
|
|||||||
/**
|
/**
|
||||||
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
|
* Generate next sequential PO number in PO-YYYYMMDD-XXXX format.
|
||||||
*/
|
*/
|
||||||
private function generatePoNumber(): string
|
private function generatePoNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'PO-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
|
->resolveNumber($data, 'purchase_order', 'td_purchase_order', 'po_number');
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -433,7 +418,7 @@ class PurchaseOrderManager {
|
|||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':source_id' => $source_id,
|
':source_id' => $source_id,
|
||||||
':source' => $source,
|
':source' => $source,
|
||||||
':po_number' => $this->generatePoNumber(),
|
':po_number' => $this->generatePoNumber($data),
|
||||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||||
':department_id' => (int)($data['department_id'] ?? 0),
|
':department_id' => (int)($data['department_id'] ?? 0),
|
||||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,18 +32,10 @@ class PurchaseRequestManager
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function generateNumber(): string
|
private function generateNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'PR-' . date('Ym') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
$sth = $this->pdo->prepare(
|
->resolveNumber($data, 'purchase_request', 'td_purchase_request', 'request_number');
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,7 +169,7 @@ class PurchaseRequestManager
|
|||||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
||||||
|
|
||||||
if ($id === 0) {
|
if ($id === 0) {
|
||||||
$number = $this->generateNumber();
|
$number = $this->generateNumber($data);
|
||||||
$log = [array_merge($logging, ['action' => 'create_purchase_request'])];
|
$log = [array_merge($logging, ['action' => 'create_purchase_request'])];
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,18 +32,10 @@ class QuotationManager
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function generateNumber(): string
|
private function generateNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'QT-' . date('Ym') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
$sth = $this->pdo->prepare(
|
->resolveNumber($data, 'quotation', 'td_quotation', 'quotation_number');
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,7 +176,7 @@ class QuotationManager
|
|||||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
||||||
|
|
||||||
if ($id === 0) {
|
if ($id === 0) {
|
||||||
$number = $this->generateNumber();
|
$number = $this->generateNumber($data);
|
||||||
$log = [array_merge($logging, ['action' => 'create_quotation'])];
|
$log = [array_merge($logging, ['action' => 'create_quotation'])];
|
||||||
|
|
||||||
$this->pdo->prepare(
|
$this->pdo->prepare(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
class ReceiptBillingManager {
|
class ReceiptBillingManager {
|
||||||
@@ -11,23 +12,10 @@ class ReceiptBillingManager {
|
|||||||
$this->company_id = $company_id;
|
$this->company_id = $company_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateBillingNumber(): string
|
private function generateBillingNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'RB-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
$sth = $this->pdo->prepare(
|
->resolveNumber($data, 'receipt_billing', 'td_receipt_billing', 'billing_number');
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unavailableDocumentAmount(int $invoice_id): float
|
private function unavailableDocumentAmount(int $invoice_id): float
|
||||||
@@ -338,7 +326,7 @@ class ReceiptBillingManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':billing_number' => $this->generateBillingNumber(),
|
':billing_number' => $this->generateBillingNumber($data),
|
||||||
':contact_id' => $contact_id,
|
':contact_id' => $contact_id,
|
||||||
':billing_date' => $billing_date,
|
':billing_date' => $billing_date,
|
||||||
':amount' => $net_amount,
|
':amount' => $net_amount,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||||
|
|
||||||
@@ -12,23 +13,10 @@ class ReceiptManager {
|
|||||||
$this->company_id = $company_id;
|
$this->company_id = $company_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generateReceiptNumber(): string
|
private function generateReceiptNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'RC-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
$sth = $this->pdo->prepare(
|
->resolveNumber($data, 'receipt', 'td_receipt', 'receipt_number');
|
||||||
"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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertPostingWindow(?string $date, string $context): void
|
private function assertPostingWindow(?string $date, string $context): void
|
||||||
@@ -448,7 +436,7 @@ class ReceiptManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':receipt_number' => $this->generateReceiptNumber(),
|
':receipt_number' => $this->generateReceiptNumber($data),
|
||||||
':contact_id' => $contact_id,
|
':contact_id' => $contact_id,
|
||||||
':department_id' => (int)($data['department_id'] ?? 0),
|
':department_id' => (int)($data['department_id'] ?? 0),
|
||||||
':receipt_billing_id' => $billing_id,
|
':receipt_billing_id' => $billing_id,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.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') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
|
->resolveNumber($data, 'return', 'td_return', 'return_number');
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||||
@@ -373,7 +362,7 @@ class ReturnManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':return_number' => $this->generateReturnNumber(),
|
':return_number' => $this->generateReturnNumber($data),
|
||||||
':order_id' => (int)($data['order_id'] ?? 0),
|
':order_id' => (int)($data['order_id'] ?? 0),
|
||||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ class StockManager {
|
|||||||
return 'td_stock_' . $warehouse_id;
|
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
|
// TRANSACTION BASIS — Read
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
@@ -66,12 +71,13 @@ class StockManager {
|
|||||||
{
|
{
|
||||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||||
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
|
$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
|
// Transfer list: show only the outbound side (out > 0) to avoid duplicate display
|
||||||
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$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
|
FROM `{$table}` a
|
||||||
LEFT JOIN md_product b
|
LEFT JOIN md_product b
|
||||||
ON a.company_id = b.company_id
|
ON a.company_id = b.company_id
|
||||||
@@ -102,9 +108,10 @@ class StockManager {
|
|||||||
public function getStockInById(int $warehouse_id, int $id): array|false
|
public function getStockInById(int $warehouse_id, int $id): array|false
|
||||||
{
|
{
|
||||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||||
|
$stock_ref = $this->stockReferenceSql();
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT a.*, a.in AS quantity,
|
"SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity,
|
||||||
b.contact_name,
|
b.contact_name,
|
||||||
c.product_name, c.uom,
|
c.product_name, c.uom,
|
||||||
d.expiry_date
|
d.expiry_date
|
||||||
@@ -137,9 +144,10 @@ class StockManager {
|
|||||||
public function getStockOutById(int $warehouse_id, int $id): array|false
|
public function getStockOutById(int $warehouse_id, int $id): array|false
|
||||||
{
|
{
|
||||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||||
|
$stock_ref = $this->stockReferenceSql();
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT a.*, a.out AS quantity,
|
"SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity,
|
||||||
b.contact_name,
|
b.contact_name,
|
||||||
c.product_name, c.uom
|
c.product_name, c.uom
|
||||||
FROM `{$table}` a
|
FROM `{$table}` a
|
||||||
@@ -170,10 +178,11 @@ class StockManager {
|
|||||||
public function getTransferById(int $warehouse_id, int $id): array|false
|
public function getTransferById(int $warehouse_id, int $id): array|false
|
||||||
{
|
{
|
||||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||||
|
$stock_ref = $this->stockReferenceSql();
|
||||||
|
|
||||||
// Fetch the outbound (from) row
|
// Fetch the outbound (from) row
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT a.*, a.out AS quantity,
|
"SELECT a.*, {$stock_ref} AS stock_reference, a.out AS quantity,
|
||||||
b.contact_name,
|
b.contact_name,
|
||||||
c.product_name, c.uom
|
c.product_name, c.uom
|
||||||
FROM `{$table}` a
|
FROM `{$table}` a
|
||||||
@@ -193,7 +202,7 @@ class StockManager {
|
|||||||
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
|
$to_table = $this->stockTableNameFromWarehouseId($to_warehouse_id);
|
||||||
|
|
||||||
$sth = $this->pdo->prepare(
|
$sth = $this->pdo->prepare(
|
||||||
"SELECT a.*, a.in AS quantity,
|
"SELECT a.*, {$stock_ref} AS stock_reference, a.in AS quantity,
|
||||||
b.contact_name,
|
b.contact_name,
|
||||||
c.product_name, c.uom
|
c.product_name, c.uom
|
||||||
FROM `{$to_table}` a
|
FROM `{$to_table}` a
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
require_once __DIR__ . '/DocumentNumberManager.php';
|
||||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,22 +39,10 @@ class SupplierReturnManager {
|
|||||||
// Private helpers
|
// Private helpers
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function generateReturnNumber(): string
|
private function generateReturnNumber(array $data = []): string
|
||||||
{
|
{
|
||||||
$prefix = 'SRN-' . date('Ymd') . '-';
|
return (new DocumentNumberManager($this->pdo, $this->company_id))
|
||||||
|
->resolveNumber($data, 'supplier_return', 'td_supplier_return', 'return_number');
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function stockTableName(int $warehouse_id): string
|
private function stockTableName(int $warehouse_id): string
|
||||||
@@ -333,7 +322,7 @@ class SupplierReturnManager {
|
|||||||
)->execute([
|
)->execute([
|
||||||
':company_id' => $this->company_id,
|
':company_id' => $this->company_id,
|
||||||
':uuid' => bin2hex(random_bytes(16)),
|
':uuid' => bin2hex(random_bytes(16)),
|
||||||
':return_number' => $this->generateReturnNumber(),
|
':return_number' => $this->generateReturnNumber($data),
|
||||||
':po_id' => (int)($data['po_id'] ?? 0),
|
':po_id' => (int)($data['po_id'] ?? 0),
|
||||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<thead class="table-primary border-light">
|
<thead class="table-primary border-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Reference</th>
|
||||||
<th>Product</th>
|
<th>Product</th>
|
||||||
<th>Lot Number</th>
|
<th>Lot Number</th>
|
||||||
<th>Serial Number</th>
|
<th>Serial Number</th>
|
||||||
@@ -214,6 +215,7 @@
|
|||||||
$.each(page_data, function(key, item) {
|
$.each(page_data, function(key, item) {
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
<td class="py-3">${format_date(item["date"])}</td>
|
<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["product_sku"]}: ${item['product_name']}</td>
|
||||||
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</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>
|
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
<thead class="table-primary border-light">
|
<thead class="table-primary border-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Reference</th>
|
||||||
<th>Product</th>
|
<th>Product</th>
|
||||||
<th>Lot Number</th>
|
<th>Lot Number</th>
|
||||||
<th>Serial Number</th>
|
<th>Serial Number</th>
|
||||||
@@ -241,6 +242,7 @@
|
|||||||
$.each(page_data, function(key, item) {
|
$.each(page_data, function(key, item) {
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
<td class="py-3">${format_date(item["date"])}</td>
|
<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["product_sku"]}: ${item['product_name']}</td>
|
||||||
<td class="py-3">${item['lot_number'] || '<span class="text-muted">—</span>'}</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>
|
<td class="py-3">${item['serial_number'] || '<span class="text-muted">—</span>'}</td>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
<thead class="table-primary border-light">
|
<thead class="table-primary border-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
<th>Reference</th>
|
||||||
<th>Product</th>
|
<th>Product</th>
|
||||||
<th style="text-align: right;">Quantity</th>
|
<th style="text-align: right;">Quantity</th>
|
||||||
<th class="th_loc_zone">Zone</th>
|
<th class="th_loc_zone">Zone</th>
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
$.each(page_data, function(key, item) {
|
$.each(page_data, function(key, item) {
|
||||||
body += `<tr>
|
body += `<tr>
|
||||||
<td class="py-3">${format_date(item["date"])}</td>
|
<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["product_sku"]}: ${item['product_name']}</td>
|
||||||
<td class="py-3">
|
<td class="py-3">
|
||||||
<div class="d-flex justify-content-end align-items-baseline gap-1">
|
<div class="d-flex justify-content-end align-items-baseline gap-1">
|
||||||
|
|||||||
@@ -61,6 +61,14 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-text-space">
|
||||||
<small class="nav-text text-muted">Maintenance</small>
|
<small class="nav-text text-muted">Maintenance</small>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
!/**
|
||||||
@@ -1128,6 +1128,38 @@ CREATE TABLE IF NOT EXISTS `td_payment_item` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
", 'td_payment_item');
|
", '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 ───────────────────────────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
$total = $ok_count + $skip_count + $err_count;
|
$total = $ok_count + $skip_count + $err_count;
|
||||||
echo "\n\033[1m=== Done ===\033[0m\n";
|
echo "\n\033[1m=== Done ===\033[0m\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user