Files
wms-app/setup.php
T
Thanakorn c89b28da4c Fix QA review findings: server-side validation, notes encoding, dashboard totals
Validate document lines on the server and recompute their totals, store notes with quotes/markup/emoji (utf8mb4, idempotent escaping, decode in form fields), exclude transfers from company-wide stock in/out, count revenue from confirmed orders only, one low-stock rule everywhere, list unapproved lots, natural bin sort, stable order/PO sort, status tiles that add up.
2026-09-19 10:58:42 +07:00

1290 lines
59 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* Production Setup Script
* Run once on a fresh server via CLI: php setup.php
*
* What it does:
* 1. Reads DB credentials from app/config.php
* 2. Creates wms + wms2 databases if they don't exist
* 3. Creates all tables (CREATE TABLE IF NOT EXISTS — safe to re-run)
* 4. Prints a status report
*
* Dynamic td_stock_<warehouse_id> tables are created automatically by the app
* the first time a warehouse is used; they are NOT listed here.
*/
if (PHP_SAPI !== 'cli') {
http_response_code(403);
exit('Run via CLI only: php setup.php');
}
// ── Load config ───────────────────────────────────────────────────────────────
$config = __DIR__ . '/app/config.php';
if (!file_exists($config)) {
exit("ERROR: app/config.php not found. Configure it before running setup.\n");
}
require $config;
$host = $db_server ?? 'localhost';
$user = $db_user ?? 'root';
$pass = $db_pass ?? '';
$db1 = $db_database ?? 'wms';
$db2 = $db_database2 ?? 'wms2';
// ── Helpers ───────────────────────────────────────────────────────────────────
$ok_count = 0;
$skip_count = 0;
$err_count = 0;
function info(string $msg): void { echo "\033[36m •\033[0m {$msg}\n"; }
function ok(string $msg): void { global $ok_count; $ok_count++; echo "\033[32m ✓\033[0m {$msg}\n"; }
function skip(string $msg): void { global $skip_count; $skip_count++; echo "\033[33m –\033[0m {$msg}\n"; }
function fail(string $msg): void { global $err_count; $err_count++; echo "\033[31m ✗\033[0m {$msg}\n"; }
function run(PDO $pdo, string $sql, string $label): void {
try {
$pdo->exec($sql);
ok($label);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'already exists') ||
str_contains($e->getMessage(), 'Duplicate column name')) {
skip($label . ' (already exists)');
} else {
fail($label . ': ' . $e->getMessage());
}
}
}
// ── Connect (no DB selected) ──────────────────────────────────────────────────
echo "\n\033[1m=== brnwms Production Setup ===\033[0m\n\n";
echo "Host : {$host}\n";
echo "DB1 : {$db1} (auth / identity)\n";
echo "DB2 : {$db2} (business data)\n\n";
try {
$pdo = new PDO("mysql:host={$host};charset=utf8", $user, $pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
ok("Connected to MySQL as {$user}@{$host}");
} catch (PDOException $e) {
fail("Cannot connect: " . $e->getMessage());
exit(1);
}
// ── Create databases ──────────────────────────────────────────────────────────
echo "\n--- Databases ---\n";
run($pdo, "CREATE DATABASE IF NOT EXISTS `{$db1}` CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci", "Database `{$db1}`");
run($pdo, "CREATE DATABASE IF NOT EXISTS `{$db2}` CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci", "Database `{$db2}`");
// ─────────────────────────────────────────────────────────────────────────────
// WMS (auth / identity) tables
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- {$db1} tables ---\n";
$pdo->exec("USE `{$db1}`");
run($pdo, "
CREATE TABLE IF NOT EXISTS `user` (
`user_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`password` varchar(255) NOT NULL,
`email` varchar(255) NOT NULL,
`status` varchar(40) NOT NULL DEFAULT 'not activated',
`name` tinytext NOT NULL,
`surname` tinytext NOT NULL,
`address` tinytext DEFAULT NULL,
`country` varchar(50) NOT NULL DEFAULT '',
`profile_picture` tinytext NOT NULL,
`default_company` int(11) DEFAULT 0,
`language` varchar(3) NOT NULL DEFAULT 'th',
`regis_date` timestamp NOT NULL DEFAULT current_timestamp(),
`license` enum('owner','user') NOT NULL DEFAULT 'owner',
`app_access` enum('all','wms','accounting') NOT NULL DEFAULT 'all',
`active` varchar(255) NOT NULL DEFAULT 'yes',
`phone` varchar(50) NOT NULL DEFAULT '',
`verify_token` varchar(64) DEFAULT NULL,
`verify_expires_at` datetime DEFAULT NULL,
`login_attempts` int(11) NOT NULL DEFAULT 0,
`locked_until` datetime DEFAULT NULL,
`session_token` varchar(64) DEFAULT NULL,
`session_token_at` datetime DEFAULT NULL,
`session_last_seen` datetime DEFAULT NULL,
PRIMARY KEY (`user_id`),
UNIQUE KEY `username` (`username`),
UNIQUE KEY `email` (`email`),
KEY `idx_user_verify_token` (`verify_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'user');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_list` (
`company_id` int(11) NOT NULL AUTO_INCREMENT,
`channel_name` varchar(100) NOT NULL DEFAULT '' COMMENT 'Unique slug used for login routing',
`company_name` varchar(255) NOT NULL DEFAULT '',
`company_name2` varchar(255) NOT NULL DEFAULT '' COMMENT 'Secondary / English name',
`company_logo` varchar(255) NOT NULL DEFAULT '',
`company_seal` varchar(255) NOT NULL DEFAULT '',
`branch` varchar(255) NOT NULL DEFAULT 'สำนักงานใหญ่',
`branch_no` varchar(50) NOT NULL DEFAULT '',
`fiscal_year` varchar(10) NOT NULL DEFAULT '',
`fx` varchar(10) NOT NULL DEFAULT 'thb',
`address` text NOT NULL DEFAULT '',
`address2` text NOT NULL DEFAULT '',
`tax_id` varchar(50) NOT NULL DEFAULT '',
`prompt_pay` varchar(50) NOT NULL DEFAULT '',
`entrepreneur` varchar(100) NOT NULL DEFAULT '',
`email` varchar(255) NOT NULL DEFAULT '',
`phone` varchar(50) NOT NULL DEFAULT '',
`fax` varchar(50) NOT NULL DEFAULT '',
`website` varchar(255) NOT NULL DEFAULT '',
`facebook_page` varchar(255) NOT NULL DEFAULT '',
`package` varchar(50) NOT NULL DEFAULT 'starter',
PRIMARY KEY (`company_id`),
UNIQUE KEY `channel_name` (`channel_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'company_list');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_map_user` (
`map_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) DEFAULT NULL,
`user_id` int(11) DEFAULT NULL,
`role` varchar(15) NOT NULL DEFAULT 'user',
`app_access` varchar(15) NOT NULL DEFAULT 'wms',
`invite_token` varchar(64) DEFAULT NULL,
`invite_expires_at` datetime DEFAULT NULL,
`invite_resent_at` datetime DEFAULT NULL,
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`map_id`),
KEY `user_id` (`user_id`),
KEY `company_id` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'company_map_user');
// Column added for explicit-consent invite flow (existing users)
run($pdo, "ALTER TABLE `company_map_user` ADD COLUMN `invite_expires_at` DATETIME DEFAULT NULL AFTER `invite_token`", 'company_map_user.invite_expires_at');
run($pdo, "ALTER TABLE `company_map_user` ADD COLUMN `invite_resent_at` DATETIME DEFAULT NULL AFTER `invite_expires_at`", 'company_map_user.invite_resent_at');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_setting` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`setting_key` varchar(64) NOT NULL,
`value` varchar(255) NOT NULL DEFAULT '',
`updated_at` datetime DEFAULT NULL,
`log` mediumtext DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `company_key` (`company_id`,`setting_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'company_setting');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_smtp` (
`smtp_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(10) unsigned NOT NULL,
`server` varchar(255) NOT NULL,
`port` varchar(10) NOT NULL DEFAULT '587',
`username` varchar(255) NOT NULL,
`password` text NOT NULL,
`from_name` varchar(255) NOT NULL DEFAULT '',
`from_email` varchar(255) NOT NULL DEFAULT '',
`encryption` enum('tls','ssl','none') NOT NULL DEFAULT 'tls',
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`smtp_id`),
UNIQUE KEY `uq_company_smtp` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'company_smtp');
run($pdo, "
CREATE TABLE IF NOT EXISTS `company_usage` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`day_date` date NOT NULL,
`daily_count` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_company_day` (`company_id`,`day_date`),
KEY `idx_company_id` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'company_usage');
run($pdo, "
CREATE TABLE IF NOT EXISTS `schema_migrations` (
`version` varchar(64) NOT NULL,
`applied_at` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'schema_migrations');
run($pdo, "
CREATE TABLE IF NOT EXISTS `whitelist` (
`list_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`cookie` varchar(32) NOT NULL,
`status` int(11) NOT NULL DEFAULT 1 COMMENT '0=block 1=wait 2=allow',
`ip` varchar(20) NOT NULL,
`dt` timestamp NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`list_id`),
UNIQUE KEY `cookie` (`cookie`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'whitelist');
// ─────────────────────────────────────────────────────────────────────────────
// WMS2 (business data) tables
// ─────────────────────────────────────────────────────────────────────────────
echo "\n--- {$db2} tables ---\n";
$pdo->exec("USE `{$db2}`");
// ── Master data ───────────────────────────────────────────────────────────────
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_warehouse` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`warehouse_name` varchar(255) NOT NULL DEFAULT '',
`location` text NOT NULL DEFAULT '',
`manager` int(11) NOT NULL DEFAULT 0,
`description` text NOT NULL DEFAULT '',
`status` int(1) NOT NULL DEFAULT 1,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_warehouse_name` (`company_id`,`warehouse_name`),
KEY `company_id` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_warehouse');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_storage` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`warehouse` varchar(255) NOT NULL DEFAULT '',
`zone` text NOT NULL DEFAULT '',
`aisle_from` varchar(255) NOT NULL DEFAULT '',
`aisle_to` varchar(255) NOT NULL DEFAULT '',
`bin_from` varchar(255) NOT NULL DEFAULT '',
`bin_to` varchar(255) NOT NULL DEFAULT '',
`description` text NOT NULL DEFAULT '',
`status` int(1) NOT NULL DEFAULT 1,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_storage');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_bin` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`td_stock_id` int(11) DEFAULT NULL,
`warehouse` int(11) NOT NULL,
`storage_id` int(11) NOT NULL DEFAULT 0,
`zone` varchar(10) NOT NULL,
`aisle` varchar(10) NOT NULL,
`bin` varchar(10) NOT NULL,
`product_sku` varchar(50) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_bin_location` (`company_id`,`warehouse`,`zone`,`aisle`,`bin`),
KEY `idx_storage` (`storage_id`),
KEY `idx_sku` (`company_id`,`product_sku`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'md_bin');
// Column made nullable — WarehouseManager::releaseBin() does `SET td_stock_id =
// NULL` to mark a bin empty (mirroring product_sku on the same row), but the
// column was NOT NULL DEFAULT 0. Under strict SQL mode this made releaseBin()
// fail with "Column 'td_stock_id' cannot be null" on every supplier return,
// every fully-depleted stock-out, and every stock transfer. Every read site
// (getBinStock(), occupyBin()) already treats 0 and NULL as equivalent "empty"
// (`if (!$td_stock_id)`), so this is a safe, behavior-preserving widening.
run($pdo, "ALTER TABLE `md_bin` MODIFY `td_stock_id` int(11) DEFAULT NULL", 'md_bin.td_stock_id nullable');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_bin_log` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(10) NOT NULL,
`md_bin_id` int(10) unsigned NOT NULL,
`user_id` int(10) unsigned DEFAULT NULL,
`dt` datetime NOT NULL DEFAULT current_timestamp(),
`login` datetime DEFAULT NULL,
`action` enum('occupy','release','transfer_in','transfer_out') NOT NULL,
`product_sku` varchar(100) DEFAULT NULL,
`td_stock_id` int(10) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_bin` (`company_id`,`md_bin_id`),
KEY `idx_user` (`company_id`,`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_bin_log');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_product_category` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`category` varchar(255) NOT NULL DEFAULT '',
`slug` varchar(255) NOT NULL DEFAULT '',
`description` text NOT NULL DEFAULT '',
`status` int(1) NOT NULL DEFAULT 1,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_product_id` (`company_id`,`category`),
KEY `company_id` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_product_category');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_product` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`product_name` varchar(255) NOT NULL DEFAULT '',
`sku` varchar(255) NOT NULL DEFAULT '',
`barcode` varchar(64) DEFAULT NULL,
`price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`cost_price` decimal(15,2) NOT NULL DEFAULT 0.00,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`sales_account_code` varchar(20) DEFAULT NULL,
`purchase_account_code` varchar(20) DEFAULT NULL,
`uom` varchar(20) NOT NULL DEFAULT 'pcs',
`min_stock` decimal(18,4) NOT NULL DEFAULT 0.0000,
`reorder_point` decimal(18,4) NOT NULL DEFAULT 0.0000,
`category` int(11) NOT NULL DEFAULT 0,
`product_image` text NOT NULL DEFAULT '',
`description` text NOT NULL DEFAULT '',
`status` int(1) NOT NULL DEFAULT 1,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_product_id` (`company_id`,`product_name`),
KEY `company_id` (`company_id`),
KEY `idx_product_barcode` (`company_id`,`barcode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_product');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_lot` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(10) unsigned NOT NULL,
`product_sku` varchar(100) NOT NULL,
`lot_number` varchar(100) NOT NULL,
`expiry_date` date NOT NULL,
`description` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_product_sku_lot_number` (`company_id`,`product_sku`,`lot_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_lot');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_contact_type` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`contact_type` varchar(255) NOT NULL DEFAULT '',
`description` text NOT NULL DEFAULT '',
`status` int(1) NOT NULL DEFAULT 1,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_product_id` (`company_id`,`contact_type`),
KEY `company_id` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_contact_type');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_contact` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`contact_name` varchar(255) NOT NULL,
`tax_id` varchar(255) NOT NULL,
`organization` varchar(255) NOT NULL,
`branch` varchar(255) NOT NULL,
`contact_type` int(11) unsigned NOT NULL,
`billing_address` text NOT NULL,
`shipping_location` varchar(255) NOT NULL,
`shipping_address` text NOT NULL,
`remark` text NOT NULL,
`contact_image` mediumtext NOT NULL,
`status` int(1) unsigned NOT NULL DEFAULT 1,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_contact');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_barcode` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(10) unsigned NOT NULL,
`barcode_type` varchar(20) NOT NULL,
`barcode` varchar(320) NOT NULL,
`product_sku` varchar(100) DEFAULT NULL,
`lot_number` varchar(100) DEFAULT NULL,
`serial_number` varchar(100) DEFAULT NULL,
`warehouse_id` int(10) unsigned DEFAULT NULL,
`zone` varchar(100) DEFAULT NULL,
`aisle` varchar(100) DEFAULT NULL,
`bin` varchar(100) DEFAULT NULL,
`print_count` int(10) unsigned NOT NULL DEFAULT 0,
`last_printed_dt` datetime DEFAULT NULL,
`created_dt` datetime NOT NULL DEFAULT current_timestamp(),
`status` tinyint(4) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_barcode` (`company_id`,`barcode`),
KEY `company_id_type` (`company_id`,`barcode_type`),
KEY `company_id_product_lot` (`company_id`,`product_sku`,`lot_number`),
KEY `company_id_location` (`company_id`,`warehouse_id`,`zone`,`aisle`,`bin`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_barcode');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_sku_barcode_label` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(10) unsigned NOT NULL,
`product_sku` varchar(100) NOT NULL,
`lot_number` varchar(100) NOT NULL,
`serial_number` varchar(100) NOT NULL,
`barcode` varchar(320) NOT NULL,
`created_dt` datetime NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_barcode` (`company_id`,`barcode`),
KEY `company_id_product_lot` (`company_id`,`product_sku`,`lot_number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_sku_barcode_label');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_lock_operation` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`operation_type` varchar(50) NOT NULL DEFAULT '',
`user_id` int(11) NOT NULL DEFAULT 0,
`locked_at` datetime NOT NULL,
`expires_at` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `op` (`company_id`,`operation_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_lock_operation');
// ── Accounting master ─────────────────────────────────────────────────────────
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_department` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`dept_code` varchar(20) NOT NULL,
`dept_name` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_company_dept` (`company_id`,`dept_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_department');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`account_code` varchar(20) NOT NULL,
`account_name` varchar(255) NOT NULL,
`account_type` enum('asset','liability','equity','revenue','expense') NOT NULL,
`account_category` varchar(50) NOT NULL DEFAULT '',
`parent_code` varchar(20) DEFAULT NULL,
`is_posting` tinyint(1) NOT NULL DEFAULT 1,
`status` tinyint(1) NOT NULL DEFAULT 1,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_company_account` (`company_id`,`account_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_account');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_account_formula` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`formula_name` varchar(100) NOT NULL,
`document_type` varchar(50) NOT NULL,
`description` text DEFAULT NULL,
`is_default` tinyint(1) NOT NULL DEFAULT 0,
`status` tinyint(1) NOT NULL DEFAULT 1,
`created_at` datetime NOT NULL DEFAULT current_timestamp(),
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_company_doc` (`company_id`,`document_type`),
KEY `idx_status` (`company_id`,`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_account_formula');
run($pdo, "
CREATE TABLE IF NOT EXISTS `md_account_formula_item` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`formula_id` int(11) NOT NULL,
`drcr` enum('D','C') NOT NULL,
`account_code` varchar(20) NOT NULL,
`amount_key` enum('grand_total','total','tax','amount') NOT NULL,
`description` varchar(255) DEFAULT NULL,
`sort_order` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_formula` (`company_id`,`formula_id`),
KEY `idx_account` (`company_id`,`account_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'md_account_formula_item');
// ── Stock (template + base) ───────────────────────────────────────────────────
// td_stock_<warehouse_id> tables are created automatically by the app on first use.
// This base table matches the template used by WarehouseManager::createStockTableForWarehouse().
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_stock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`ref_id` int(11) unsigned NOT NULL DEFAULT 0,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(64) NOT NULL,
`type` varchar(20) NOT NULL,
`ref_warehouse` varchar(20) NOT NULL DEFAULT '',
`date` datetime DEFAULT NULL,
`product_sku` varchar(255) NOT NULL DEFAULT '0',
`lot_number` varchar(255) NOT NULL DEFAULT '',
`serial_number` varchar(255) NOT NULL DEFAULT '',
`source` varchar(255) NOT NULL DEFAULT '',
`source_id` int(11) NOT NULL DEFAULT 0,
`in` decimal(18,4) NOT NULL DEFAULT 0.0000,
`out` decimal(18,4) NOT NULL DEFAULT 0.0000,
`price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`zone` varchar(255) NOT NULL DEFAULT '',
`aisle` varchar(255) NOT NULL DEFAULT '',
`bin` varchar(255) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`description` text NOT NULL DEFAULT '',
`status` int(3) NOT NULL DEFAULT 0,
`log` mediumtext NOT NULL DEFAULT '{}',
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `product_id` (`product_sku`),
KEY `updated_at` (`updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_stock (base template)');
// Column widened for db_auth.php's 32-char $uuid (bin2hex(random_bytes(16))) plus
// manager-appended suffixes (e.g. "_ret_0", "_srn_0", "_3") — varchar(20) was too
// narrow and caused "Data too long for column 'uuid'" on every real stock movement
// (PO receipt, order confirm, return confirm, stock transfer) under strict SQL mode.
run($pdo, "ALTER TABLE `td_stock` MODIFY `uuid` varchar(64) NOT NULL", 'td_stock.uuid width');
// Per-warehouse td_stock_<id> tables are cloned from td_stock at warehouse-creation
// time (WarehouseManager::createStockTableForWarehouse()) and are NOT otherwise
// tracked by this script — widen uuid on every one that already exists so
// previously-created warehouses get the same fix. New warehouses get it for free
// from the template above.
$stockTables = $pdo->query("SHOW TABLES LIKE 'td\\_stock\\_%'")->fetchAll(PDO::FETCH_COLUMN);
foreach ($stockTables as $t) {
run($pdo, "ALTER TABLE `{$t}` MODIFY `uuid` varchar(64) NOT NULL", "{$t}.uuid width");
}
run($pdo, "
CREATE TABLE IF NOT EXISTS `etl_stock_summary` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) unsigned NOT NULL DEFAULT 0,
`warehouse_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(255) NOT NULL DEFAULT '',
`month` varchar(10) NOT NULL,
`total_in` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_out` decimal(18,4) NOT NULL DEFAULT 0.0000,
`source_updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_warehouse_id_product_sku_month` (`company_id`,`warehouse_id`,`product_sku`,`month`),
KEY `company_id` (`company_id`),
KEY `product_id` (`warehouse_id`),
KEY `source_updated_at` (`source_updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'etl_stock_summary');
run($pdo, "
CREATE TABLE IF NOT EXISTS `etl_gl_summary` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) unsigned NOT NULL DEFAULT 0,
`acc_code` int(11) NOT NULL DEFAULT 0,
`period` varchar(7) NOT NULL,
`debit` decimal(18,4) NOT NULL DEFAULT 0.0000,
`credit` decimal(18,4) NOT NULL DEFAULT 0.0000,
`count` int(11) NOT NULL DEFAULT 0,
`source_updated_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `company_id_acc_code_period` (`company_id`,`acc_code`,`period`),
KEY `company_id` (`company_id`),
KEY `period` (`period`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'etl_gl_summary');
// ── Transactions ──────────────────────────────────────────────────────────────
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_gl` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`source_type` varchar(50) NOT NULL DEFAULT '',
`source_id` int(11) NOT NULL DEFAULT 0,
`reference` varchar(100) NOT NULL DEFAULT '',
`description` varchar(255) NOT NULL DEFAULT '',
`journal_date` date DEFAULT NULL,
`formula_id` int(11) NOT NULL DEFAULT 0,
`period` varchar(7) NOT NULL DEFAULT '',
`current_version` tinyint(4) NOT NULL DEFAULT 1,
`history` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '[]' CHECK (json_valid(`history`)),
`created_at` datetime NOT NULL,
`updated_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `period` (`period`),
KEY `formula_id` (`formula_id`),
KEY `source` (`company_id`,`source_type`,`source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_gl');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_gl_item` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`gl_id` int(10) unsigned NOT NULL,
`account_code` varchar(20) NOT NULL DEFAULT '',
`department_id` int(11) NOT NULL DEFAULT 0,
`debit` decimal(18,4) NOT NULL DEFAULT 0.0000,
`credit` decimal(18,4) NOT NULL DEFAULT 0.0000,
`description` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `gl_id` (`gl_id`),
KEY `company_id` (`company_id`),
KEY `account_code` (`account_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_gl_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_batch_action` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`type` varchar(50) NOT NULL DEFAULT '',
`doc_type` varchar(50) NOT NULL DEFAULT '',
`total` int(11) NOT NULL DEFAULT 0,
`succeeded` int(11) NOT NULL DEFAULT 0,
`failed` int(11) NOT NULL DEFAULT 0,
`failed_ids` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '[]' CHECK (json_valid(`failed_ids`)),
`user_id` int(11) NOT NULL DEFAULT 0,
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `type` (`type`),
KEY `doc_type` (`doc_type`),
KEY `user_id` (`user_id`),
KEY `created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_batch_action');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_quotation` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`quotation_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`quotation_date` date DEFAULT NULL,
`valid_until` date DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`subtotal` decimal(18,4) NOT NULL DEFAULT 0.0000,
`discount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax` decimal(18,4) NOT NULL DEFAULT 0.0000,
`grand_total` decimal(18,4) NOT NULL DEFAULT 0.0000,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `quotation_number` (`quotation_number`),
KEY `status` (`status`),
KEY `contact_id` (`contact_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_quotation');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_quotation_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`quotation_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`converted_qty` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_qt_item` (`quotation_id`,`item_id`),
KEY `idx_quotation` (`company_id`,`quotation_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_quotation_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_order` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`source_id` int(11) NOT NULL DEFAULT 0,
`source` varchar(20) NOT NULL DEFAULT '',
`order_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`order_date` date DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`subtotal` decimal(18,4) NOT NULL DEFAULT 0.0000,
`discount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_adjustment` decimal(8,2) NOT NULL DEFAULT 0.00,
`shipping_fee` decimal(18,4) NOT NULL DEFAULT 0.0000,
`shipping_tracking_number` varchar(100) DEFAULT NULL,
`grand_total` decimal(18,4) NOT NULL DEFAULT 0.0000,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `contact_id` (`contact_id`),
KEY `order_number` (`order_number`),
KEY `status` (`status`),
KEY `order_date` (`order_date`),
KEY `quotation_id` (`source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_order');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_order_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`order_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`warehouse_id` int(11) NOT NULL DEFAULT 0,
`stock_out_id` int(11) NOT NULL DEFAULT 0,
`lot_number` varchar(100) NOT NULL DEFAULT '',
`invoiced_qty` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_order_item` (`order_id`,`item_id`),
KEY `idx_order` (`company_id`,`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_order_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_invoice` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`source_id` int(11) NOT NULL DEFAULT 0,
`source` varchar(20) NOT NULL DEFAULT '',
`doc_type` varchar(20) NOT NULL DEFAULT 'invoice',
`invoice_number` varchar(50) NOT NULL DEFAULT '',
`ref_invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`order_id` int(11) unsigned NOT NULL DEFAULT 0,
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`issued_date` date DEFAULT NULL,
`due_date` date DEFAULT NULL,
`subtotal` decimal(18,4) NOT NULL DEFAULT 0.0000,
`discount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_adjustment` decimal(8,2) NOT NULL DEFAULT 0.00,
`shipping_fee` decimal(18,4) NOT NULL DEFAULT 0.0000,
`grand_total` decimal(18,4) NOT NULL DEFAULT 0.0000,
`formula_id` int(11) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `order_id` (`order_id`),
KEY `contact_id` (`contact_id`),
KEY `invoice_number` (`invoice_number`),
KEY `ref_invoice_id` (`ref_invoice_id`),
KEY `doc_type` (`doc_type`),
KEY `status` (`status`),
KEY `due_date` (`due_date`),
KEY `source_id` (`source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_invoice');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_invoice_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_invoice_item` (`invoice_id`,`item_id`),
KEY `idx_invoice` (`company_id`,`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_invoice_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_return` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`return_number` varchar(50) NOT NULL DEFAULT '',
`order_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`return_date` date DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`reason` text NOT NULL DEFAULT '',
`refund_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_adjustment` decimal(8,2) NOT NULL DEFAULT 0.00,
`notes` text NOT NULL DEFAULT '',
`formula_id` int(11) DEFAULT NULL,
`log` mediumtext NOT NULL DEFAULT '{}',
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `order_id` (`order_id`),
KEY `invoice_id` (`invoice_id`),
KEY `contact_id` (`contact_id`),
KEY `return_number` (`return_number`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_return');
// Column added for ReturnManager::saveFormula() / custom credit-note GL formula
// selection — was missing from the original table (unlike td_invoice/td_receipt/
// td_payment, which all have formula_id). Without it, ReturnManager::saveReturn()
// fails on every insert.
run($pdo, "ALTER TABLE `td_return` ADD COLUMN `formula_id` int(11) DEFAULT NULL AFTER `notes`", 'td_return.formula_id');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_return_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`return_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`warehouse_id` int(11) NOT NULL DEFAULT 0,
`stock_out_id` int(11) NOT NULL DEFAULT 0,
`stock_out_warehouse_id` int(11) NOT NULL DEFAULT 0,
`zone` varchar(255) NOT NULL DEFAULT '',
`aisle` varchar(255) NOT NULL DEFAULT '',
`bin` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_return_item` (`return_id`,`item_id`),
KEY `idx_return` (`company_id`,`return_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_return_item');
// Put-away location of each returned line. The Customer Return form has always
// asked for it and ReturnManager::confirmReturn() has always read it, but the
// table had nowhere to keep it: the choice was dropped on save and every
// confirmation then failed in WarehouseManager::occupyBin() with
// "Bin -- does not exist". Same widths as td_stock's location columns.
run($pdo, "ALTER TABLE `td_return_item` ADD COLUMN `zone` varchar(255) NOT NULL DEFAULT '' AFTER `stock_out_warehouse_id`", 'td_return_item.zone');
run($pdo, "ALTER TABLE `td_return_item` ADD COLUMN `aisle` varchar(255) NOT NULL DEFAULT '' AFTER `zone`", 'td_return_item.aisle');
run($pdo, "ALTER TABLE `td_return_item` ADD COLUMN `bin` varchar(255) NOT NULL DEFAULT '' AFTER `aisle`", 'td_return_item.bin');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_purchase_request` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`request_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`request_date` date DEFAULT NULL,
`required_date` date DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`subtotal` decimal(18,4) NOT NULL DEFAULT 0.0000,
`discount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax` decimal(18,4) NOT NULL DEFAULT 0.0000,
`shipping_fee` decimal(18,4) NOT NULL DEFAULT 0.0000,
`grand_total` decimal(18,4) NOT NULL DEFAULT 0.0000,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `request_number` (`request_number`),
KEY `status` (`status`),
KEY `contact_id` (`contact_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_purchase_request');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_purchase_request_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`request_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`converted_qty` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_pr_item` (`request_id`,`item_id`),
KEY `idx_pr_item` (`company_id`,`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_purchase_request_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_purchase_order` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL,
`uuid` varchar(64) NOT NULL,
`source_id` int(11) NOT NULL DEFAULT 0,
`source` varchar(32) NOT NULL DEFAULT '',
`po_number` varchar(32) NOT NULL,
`contact_id` int(11) DEFAULT NULL,
`department_id` int(11) NOT NULL DEFAULT 0,
`po_date` date NOT NULL,
`expected_date` date DEFAULT NULL,
`closed_at` datetime DEFAULT NULL,
`warehouse_id` int(11) NOT NULL DEFAULT 0,
`status` tinyint(4) NOT NULL DEFAULT 0,
`subtotal` decimal(15,2) NOT NULL DEFAULT 0.00,
`discount` decimal(15,2) NOT NULL DEFAULT 0.00,
`tax` decimal(15,2) NOT NULL DEFAULT 0.00,
`tax_adjustment` decimal(8,2) NOT NULL DEFAULT 0.00,
`shipping_fee` decimal(15,2) NOT NULL DEFAULT 0.00,
`grand_total` decimal(15,2) NOT NULL DEFAULT 0.00,
`notes` text DEFAULT NULL,
`log` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`log`)),
`created_at` datetime NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_po_company` (`company_id`),
KEY `idx_po_status` (`company_id`,`status`),
KEY `idx_po_contact` (`company_id`,`contact_id`),
KEY `idx_po_date` (`company_id`,`po_date`),
KEY `idx_po_source` (`company_id`,`source`,`source_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_purchase_order');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_purchase_order_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`order_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`received_qty` decimal(18,4) NOT NULL DEFAULT 0.0000,
`stock_in_id` int(11) NOT NULL DEFAULT 0,
`invoiced_qty` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_po_item` (`order_id`,`item_id`),
KEY `idx_order` (`company_id`,`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_purchase_order_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_supplier_return` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`return_number` varchar(32) NOT NULL DEFAULT '',
`po_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`return_date` date NOT NULL,
`status` tinyint(1) NOT NULL DEFAULT 0,
`reason` text NOT NULL DEFAULT '',
`refund_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_adjustment` decimal(8,2) NOT NULL DEFAULT 0.00,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`shipping_tracking_number` varchar(100) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `idx_company_id` (`company_id`),
KEY `idx_po_id` (`po_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_supplier_return');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_supplier_return_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`return_id` int(11) unsigned NOT NULL DEFAULT 0,
`item_id` int(11) unsigned NOT NULL DEFAULT 0,
`product_sku` varchar(100) NOT NULL DEFAULT '',
`product_name` varchar(255) NOT NULL DEFAULT '',
`quantity` decimal(18,4) NOT NULL DEFAULT 0.0000,
`unit_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`total_price` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`tax_rate` decimal(5,2) NOT NULL DEFAULT 0.00,
`warehouse_id` int(11) NOT NULL DEFAULT 0,
`stock_in_id` int(11) NOT NULL DEFAULT 0,
`zone` varchar(50) NOT NULL DEFAULT '',
`aisle` varchar(50) NOT NULL DEFAULT '',
`bin` varchar(50) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_sr_item` (`return_id`,`item_id`),
KEY `idx_return` (`company_id`,`return_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
", 'td_supplier_return_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_receipt_billing` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`billing_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`billing_date` date DEFAULT NULL,
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`status` tinyint(1) NOT NULL DEFAULT 1,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `contact_id` (`contact_id`),
KEY `billing_number` (`billing_number`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_receipt_billing');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_receipt_billing_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`billing_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`doc_type` varchar(20) NOT NULL DEFAULT '',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `billing_id` (`billing_id`),
KEY `invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_receipt_billing_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_receipt` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`receipt_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`receipt_billing_id` int(11) unsigned NOT NULL DEFAULT 0,
`receipt_date` date DEFAULT NULL,
`payment_method` varchar(50) NOT NULL DEFAULT '',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`formula_id` int(11) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 1,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `contact_id` (`contact_id`),
KEY `receipt_number` (`receipt_number`),
KEY `status` (`status`),
KEY `receipt_billing_id` (`receipt_billing_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_receipt');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_receipt_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`receipt_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`doc_type` varchar(20) NOT NULL DEFAULT '',
`department_id` int(11) NOT NULL DEFAULT 0,
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `receipt_id` (`receipt_id`),
KEY `invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_receipt_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_payment_billing` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`billing_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`billing_date` date DEFAULT NULL,
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`status` tinyint(1) NOT NULL DEFAULT 1,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `contact_id` (`contact_id`),
KEY `billing_number` (`billing_number`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_payment_billing');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_payment_billing_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`billing_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`doc_type` varchar(20) NOT NULL DEFAULT '',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `billing_id` (`billing_id`),
KEY `invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_payment_billing_item');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_payment` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`uuid` varchar(32) NOT NULL DEFAULT '',
`payment_number` varchar(50) NOT NULL DEFAULT '',
`contact_id` int(11) NOT NULL DEFAULT 0,
`department_id` int(11) NOT NULL DEFAULT 0,
`payment_billing_id` int(11) unsigned NOT NULL DEFAULT 0,
`payment_date` date DEFAULT NULL,
`payment_method` varchar(50) NOT NULL DEFAULT '',
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
`formula_id` int(11) DEFAULT NULL,
`status` tinyint(1) NOT NULL DEFAULT 1,
`notes` text NOT NULL DEFAULT '',
`log` mediumtext NOT NULL DEFAULT '{}',
`created_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `contact_id` (`contact_id`),
KEY `payment_billing_id` (`payment_billing_id`),
KEY `payment_number` (`payment_number`),
KEY `status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_payment');
run($pdo, "
CREATE TABLE IF NOT EXISTS `td_payment_item` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`company_id` int(11) NOT NULL DEFAULT 0,
`payment_id` int(11) unsigned NOT NULL DEFAULT 0,
`invoice_id` int(11) unsigned NOT NULL DEFAULT 0,
`doc_type` varchar(20) NOT NULL DEFAULT '',
`department_id` int(11) NOT NULL DEFAULT 0,
`amount` decimal(18,4) NOT NULL DEFAULT 0.0000,
PRIMARY KEY (`id`),
KEY `company_id` (`company_id`),
KEY `payment_id` (`payment_id`),
KEY `invoice_id` (`invoice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
", 'td_payment_item');
run($pdo, "
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($pdo, "
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');
// ── utf8mb4 ───────────────────────────────────────────────────────────────────
// Older tables were created as utf8mb3, which cannot hold 4-byte characters
// (emoji, some CJK). With the connection on utf8mb4 a note containing one was
// rejected by the server and the whole save rolled back. Convert whatever is
// still utf8mb3, including the per-warehouse td_stock_<id> tables, to the
// collation the existing utf8mb4 tables already use so joins keep working.
foreach ([$db1, $db2] as $db_name) {
try {
$st = $pdo->prepare("SELECT TABLE_COLLATION FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' AND TABLE_COLLATION LIKE 'utf8mb4\\_%'
GROUP BY TABLE_COLLATION ORDER BY COUNT(*) DESC LIMIT 1");
$st->execute([$db2]);
$target = $st->fetchColumn();
if (!$target) {
$target = $pdo->query("SELECT COLLATION_NAME FROM information_schema.COLLATIONS
WHERE CHARACTER_SET_NAME = 'utf8mb4' AND IS_DEFAULT = 'Yes' LIMIT 1")->fetchColumn() ?: 'utf8mb4_general_ci';
}
if (!preg_match('/^utf8mb4_[a-z0-9_]+$/', $target)) $target = 'utf8mb4_general_ci';
$st = $pdo->prepare("SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE'
AND (TABLE_COLLATION LIKE 'utf8mb3\\_%' OR TABLE_COLLATION LIKE 'utf8\\_%')
ORDER BY TABLE_NAME");
$st->execute([$db_name]);
$legacy = $st->fetchAll(PDO::FETCH_COLUMN);
if (!$legacy) { skip("utf8mb4: `{$db_name}` (already converted)"); }
foreach ($legacy as $tbl) {
run($pdo, "ALTER TABLE `{$db_name}`.`{$tbl}` CONVERT TO CHARACTER SET utf8mb4 COLLATE {$target}", "utf8mb4: {$db_name}.{$tbl}");
}
run($pdo, "ALTER DATABASE `{$db_name}` CHARACTER SET utf8mb4 COLLATE {$target}", "utf8mb4: database `{$db_name}` default");
} catch (PDOException $e) {
fail("utf8mb4 conversion of `{$db_name}`: " . $e->getMessage());
}
}
$pdo->exec("USE `{$db2}`");
// td_purchase_order totals were decimal(15,2) while its lines are decimal(18,4);
// match the other document headers.
foreach (['subtotal', 'discount', 'tax', 'shipping_fee', 'grand_total'] as $col) {
run($pdo, "ALTER TABLE `{$db2}`.`td_purchase_order` MODIFY `{$col}` decimal(18,4) NOT NULL DEFAULT 0.0000", "td_purchase_order.{$col} decimal(18,4)");
}
// ── Summary ───────────────────────────────────────────────────────────────────
$total = $ok_count + $skip_count + $err_count;
echo "\n\033[1m=== Done ===\033[0m\n";
printf(" Created : %d\n", $ok_count);
printf(" Skipped : %d\n", $skip_count);
printf(" Errors : %d\n", $err_count);
echo PHP_EOL;
if ($err_count > 0) {
echo "\033[31mSetup completed with errors. Review the output above.\033[0m\n\n";
exit(1);
}
echo "\033[32mSetup complete. Both databases are ready.\033[0m\n";
echo "Next step: visit /login/index.php and register the first owner account.\n\n";
exit(0);