accounting workflows
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
class BatchActionManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private int $userId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id, int $user_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$this->userId = $user_id;
|
||||
}
|
||||
|
||||
public function log(array $data): void
|
||||
{
|
||||
$type = trim((string)($data['type'] ?? ''));
|
||||
$doc_type = trim((string)($data['doc_type'] ?? ''));
|
||||
$total = (int)($data['total'] ?? 0);
|
||||
$succeeded = (int)($data['succeeded'] ?? 0);
|
||||
$failed = (int)($data['failed'] ?? 0);
|
||||
$failed_ids = $this->normalizeFailedIds($data['failed_ids'] ?? '[]');
|
||||
|
||||
if ($type === '') {
|
||||
throw new Exception('type is required.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_batch_action
|
||||
(company_id, type, doc_type, total, succeeded, failed, failed_ids, user_id, created_at)
|
||||
VALUES
|
||||
(:company_id, :type, :doc_type, :total, :succeeded, :failed, :failed_ids, :user_id, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':type' => $type,
|
||||
':doc_type' => $doc_type,
|
||||
':total' => $total,
|
||||
':succeeded' => $succeeded,
|
||||
':failed' => $failed,
|
||||
':failed_ids' => $failed_ids,
|
||||
':user_id' => $this->userId,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeFailedIds(mixed $failed_ids): string
|
||||
{
|
||||
if (!is_string($failed_ids)) {
|
||||
return json_encode([]);
|
||||
}
|
||||
|
||||
$decoded = json_decode($failed_ids, true);
|
||||
return json_encode(is_array($decoded) ? $decoded : []);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
class CompanyProfileManager
|
||||
{
|
||||
private const ALLOWED_MIME = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
private const ALLOWED_EXT = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
private const MAX_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getProfile(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
company_id, channel_name,
|
||||
company_name, company_name2,
|
||||
company_logo, company_seal,
|
||||
branch, branch_no,
|
||||
fiscal_year, fx,
|
||||
address, address2,
|
||||
tax_id, prompt_pay, entrepreneur,
|
||||
email, phone, fax,
|
||||
website, facebook_page
|
||||
FROM company_list
|
||||
WHERE company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$company = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$company) {
|
||||
throw new Exception('Company not found.');
|
||||
}
|
||||
|
||||
return $company;
|
||||
}
|
||||
|
||||
public function handleImageSlot(
|
||||
string $slot,
|
||||
string $action,
|
||||
string $current,
|
||||
string $upload_dir,
|
||||
string $prefix
|
||||
): string {
|
||||
if ($action === 'keep') {
|
||||
return $current;
|
||||
}
|
||||
|
||||
if ($action === 'remove') {
|
||||
if ($current && file_exists($upload_dir . $current)) {
|
||||
unlink($upload_dir . $current);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($action === 'replace' && !empty($_FILES[$slot]['tmp_name'])) {
|
||||
$file = $_FILES[$slot];
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
throw new RuntimeException("Upload error on {$slot}: code {$file['error']}.");
|
||||
}
|
||||
if ($file['size'] > self::MAX_SIZE) {
|
||||
throw new RuntimeException('File too large. Maximum size is 2 MB.');
|
||||
}
|
||||
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
|
||||
if (!in_array($mime, self::ALLOWED_MIME, true)) {
|
||||
throw new RuntimeException('Invalid file type. Only JPEG, PNG, GIF, WEBP allowed.');
|
||||
}
|
||||
|
||||
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, self::ALLOWED_EXT, true)) {
|
||||
throw new RuntimeException('Invalid file extension. Only jpg, png, gif, webp allowed.');
|
||||
}
|
||||
|
||||
if ($current && file_exists($upload_dir . $current)) {
|
||||
unlink($upload_dir . $current);
|
||||
}
|
||||
|
||||
$filename = $prefix . uniqid() . '.' . $ext;
|
||||
if (!move_uploaded_file($file['tmp_name'], $upload_dir . $filename)) {
|
||||
throw new RuntimeException("Failed to save {$slot}.");
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
public function saveProfile(array $data, string $company_logo, string $company_seal): void
|
||||
{
|
||||
$channel = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE company_list SET
|
||||
channel_name = :channel_name,
|
||||
company_name = :company_name,
|
||||
company_name2 = :company_name2,
|
||||
company_logo = :company_logo,
|
||||
company_seal = :company_seal,
|
||||
branch = :branch,
|
||||
branch_no = :branch_no,
|
||||
fiscal_year = :fiscal_year,
|
||||
fx = :fx,
|
||||
address = :address,
|
||||
address2 = :address2,
|
||||
tax_id = :tax_id,
|
||||
prompt_pay = :prompt_pay,
|
||||
entrepreneur = :entrepreneur,
|
||||
email = :email,
|
||||
phone = :phone,
|
||||
fax = :fax,
|
||||
website = :website,
|
||||
facebook_page = :facebook_page
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
|
||||
$sth->execute([
|
||||
':channel_name' => $channel,
|
||||
':company_name' => trim($data['company_name'] ?? ''),
|
||||
':company_name2' => trim($data['company_name2'] ?? ''),
|
||||
':company_logo' => $company_logo,
|
||||
':company_seal' => $company_seal,
|
||||
':branch' => trim($data['branch'] ?? 'สำนักงานใหญ่'),
|
||||
':branch_no' => trim($data['branch_no'] ?? ''),
|
||||
':fiscal_year' => trim($data['fiscal_year'] ?? ''),
|
||||
':fx' => trim($data['fx'] ?? 'thb'),
|
||||
':address' => trim($data['address'] ?? ''),
|
||||
':address2' => trim($data['address2'] ?? ''),
|
||||
':tax_id' => trim($data['tax_id'] ?? ''),
|
||||
':prompt_pay' => trim($data['prompt_pay'] ?? ''),
|
||||
':entrepreneur' => trim($data['entrepreneur'] ?? ''),
|
||||
':email' => trim($data['email'] ?? ''),
|
||||
':phone' => trim($data['phone'] ?? ''),
|
||||
':fax' => trim($data['fax'] ?? ''),
|
||||
':website' => trim($data['website'] ?? ''),
|
||||
':facebook_page' => trim($data['facebook_page'] ?? ''),
|
||||
':company_id' => $this->companyId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -30,6 +30,10 @@ class CompanySettingManager
|
||||
'location_label_rack' => 'Location', // single label default
|
||||
'location_label_zone' => 'Zone', // advanced level 1 default
|
||||
'location_label_aisle' => 'Aisle', // advanced level 2 default
|
||||
'posting_open_from' => '', // YYYY-MM-DD; empty = no lower bound
|
||||
'posting_open_to' => '', // YYYY-MM-DD; empty = no upper bound
|
||||
'gl_open_from' => '', // legacy alias; use posting_open_from
|
||||
'gl_open_to' => '', // legacy alias; use posting_open_to
|
||||
];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id, ?PDO $transactionPdo = null)
|
||||
|
||||
@@ -287,6 +287,16 @@ class ContactManager {
|
||||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getContactImage(int $id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT contact_image FROM md_contact
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
return (string)($sth->fetchColumn() ?: '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by name keyword — for live autocomplete on stock forms.
|
||||
*
|
||||
@@ -437,4 +447,4 @@ class ContactManager {
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
class OperationLockManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private int $userId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id, int $user_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$this->userId = $user_id;
|
||||
}
|
||||
|
||||
public function acquire(string $operation_type, int $ttl_minutes = 120): array
|
||||
{
|
||||
$operation_type = trim($operation_type);
|
||||
$ttl_minutes = max(1, min(480, $ttl_minutes));
|
||||
|
||||
if ($operation_type === '') {
|
||||
throw new Exception('operation_type is required.');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expires = date('Y-m-d H:i:s', strtotime("+{$ttl_minutes} minutes"));
|
||||
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
try {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_lock_operation
|
||||
WHERE company_id = :cid AND operation_type = :op_type
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':op_type' => $operation_type]);
|
||||
$lock = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($lock) {
|
||||
$this->pdo->rollBack();
|
||||
return [
|
||||
'success' => 0,
|
||||
'message' => 'Operation "' . $operation_type . '" is already running. Please wait.',
|
||||
'expires_at' => $lock['expires_at'],
|
||||
'locked_at' => $lock['locked_at'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO md_lock_operation (company_id, operation_type, user_id, locked_at, expires_at)
|
||||
VALUES (:cid, :op_type, :uid, :locked_at, :expires_at)"
|
||||
)->execute([
|
||||
':cid' => $this->companyId,
|
||||
':op_type' => $operation_type,
|
||||
':uid' => $this->userId,
|
||||
':locked_at' => $now,
|
||||
':expires_at' => $expires,
|
||||
]);
|
||||
|
||||
$this->pdo->commit();
|
||||
return ['success' => 1, 'message' => 'Lock acquired.'];
|
||||
|
||||
} catch (Throwable $e) {
|
||||
if ($this->pdo->inTransaction()) $this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function release(string $operation_type): void
|
||||
{
|
||||
$operation_type = trim($operation_type);
|
||||
if ($operation_type === '') {
|
||||
throw new Exception('operation_type is required.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM md_lock_operation
|
||||
WHERE company_id = :cid AND operation_type = :op_type"
|
||||
)->execute([':cid' => $this->companyId, ':op_type' => $operation_type]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -9,7 +9,7 @@
|
||||
* Method order:
|
||||
* Transaction basis → getOrderList, getOrderById, generateOrderNumber,
|
||||
* saveOrder, confirmOrder, cancelOrder,
|
||||
* updateFulfillmentStatus
|
||||
* updateShippingTracking
|
||||
*
|
||||
* Key design decisions:
|
||||
* - Order items are stored as a JSON array in td_order.items.
|
||||
@@ -92,7 +92,7 @@ class OrderManager {
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack,
|
||||
"SELECT s.*, r.zone, r.aisle, r.rack, l.expiry_date,
|
||||
(s.`in` - COALESCE((
|
||||
SELECT SUM(o.`out`)
|
||||
FROM `{$table}` o
|
||||
@@ -107,6 +107,11 @@ class OrderManager {
|
||||
AND r.warehouse = :warehouse_id
|
||||
AND r.td_stock_id = s.id
|
||||
AND r.product_sku IS NOT NULL
|
||||
LEFT JOIN md_lot l
|
||||
ON l.company_id = s.company_id
|
||||
AND l.product_sku = s.product_sku
|
||||
AND l.lot_number = s.lot_number
|
||||
AND s.lot_number != ''
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.product_sku = :product_sku
|
||||
AND s.`in` > 0
|
||||
@@ -155,6 +160,96 @@ class OrderManager {
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive fulfillment from linked stock-out rows and tracking.
|
||||
*
|
||||
* 0 = no fulfillment yet
|
||||
* 1 = picking (at least one linked stock-out row is draft)
|
||||
* 2 = packed (all linked stock-out rows are approved, no tracking)
|
||||
* 3 = shipped (all linked stock-out rows are approved, tracking exists)
|
||||
*/
|
||||
private function deriveFulfillmentStatus(array $order): int
|
||||
{
|
||||
$status = (int)($order['status'] ?? 0);
|
||||
if ($status < 1) return 0;
|
||||
|
||||
$order_id = (int)($order['id'] ?? 0);
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT warehouse_id, stock_out_id FROM td_order_item
|
||||
WHERE order_id = :order_id AND company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$linked_count = 0;
|
||||
$draft_count = 0;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
||||
if ($warehouse_id <= 0 || $stock_out_id <= 0) continue;
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
AND type = 'out'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $stock_out_id,
|
||||
]);
|
||||
|
||||
$stock_status = $sth->fetchColumn();
|
||||
if ($stock_status === false) continue;
|
||||
|
||||
$linked_count++;
|
||||
if ((int)$stock_status === 0) {
|
||||
$draft_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($linked_count === 0) return 0;
|
||||
if ($draft_count > 0) return 1;
|
||||
|
||||
return trim((string)($order['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2;
|
||||
}
|
||||
|
||||
private function syncOrderItems(int $order_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_order_item WHERE order_id = :order_id AND company_id = :company_id"
|
||||
)->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_order_item
|
||||
(company_id, order_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, lot_number, invoiced_qty)
|
||||
VALUES
|
||||
(:company_id, :order_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :lot_number, 0)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $order_id,
|
||||
':item_id' => $pos + 1,
|
||||
':product_sku' => $item['product_sku'] ?? '',
|
||||
':product_name' => $item['product_name'] ?? '',
|
||||
':quantity' => (float)($item['quantity'] ?? 0),
|
||||
':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
|
||||
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
|
||||
':lot_number' => $item['lot_number'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -179,7 +274,14 @@ class OrderManager {
|
||||
ORDER BY o.created_at DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,10 +309,145 @@ class OrderManager {
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_order_item
|
||||
WHERE order_id = :order_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]);
|
||||
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function getActiveInvoiceId(int $order_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND order_id = :order_id
|
||||
AND doc_type = 'invoice'
|
||||
AND status != 4
|
||||
ORDER BY id DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getReturnableItems(array $order): array
|
||||
{
|
||||
$order_id = (int)($order['id'] ?? 0);
|
||||
$order_items = $order['items'] ?? [];
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ri.item_id, ri.quantity
|
||||
FROM td_return_item ri
|
||||
JOIN td_return r ON r.id = ri.return_id AND r.company_id = ri.company_id
|
||||
WHERE ri.company_id = :company_id
|
||||
AND r.order_id = :order_id
|
||||
AND r.status = 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
||||
$returned_qty = [];
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $item) {
|
||||
$item_id = (int)($item['item_id'] ?? -1);
|
||||
if ($item_id < 0) continue;
|
||||
$returned_qty[$item_id] = ($returned_qty[$item_id] ?? 0) + (float)$item['quantity'];
|
||||
}
|
||||
|
||||
$returnable = [];
|
||||
foreach ($order_items as $i => $item) {
|
||||
$already_returned = $returned_qty[$i] ?? 0;
|
||||
$remaining = (float)$item['quantity'] - $already_returned;
|
||||
if ($remaining <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stock_out_warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$stock_out_id = (int)($item['stock_out_id'] ?? 0);
|
||||
|
||||
if ($stock_out_warehouse_id > 0 && $stock_out_id > 0) {
|
||||
$table = $this->stockTableNameFromWarehouseId($stock_out_warehouse_id);
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, status, zone, aisle, rack, lot_number, serial_number
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
AND type = 'out'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $stock_out_id,
|
||||
]);
|
||||
$stock_out = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($stock_out) {
|
||||
$item['zone'] = $stock_out['zone'] ?? ($item['zone'] ?? '');
|
||||
$item['aisle'] = $stock_out['aisle'] ?? ($item['aisle'] ?? '');
|
||||
$item['rack'] = $stock_out['rack'] ?? ($item['rack'] ?? '');
|
||||
$item['lot_number'] = $stock_out['lot_number'] ?? ($item['lot_number'] ?? '');
|
||||
$item['serial_number'] = $stock_out['serial_number'] ?? ($item['serial_number'] ?? '');
|
||||
$item['stock_out_status'] = (int)$stock_out['status'];
|
||||
}
|
||||
}
|
||||
|
||||
$returnable[] = array_merge($item, [
|
||||
'item_id' => $i,
|
||||
'original_qty' => (float)$item['quantity'],
|
||||
'returned_qty' => $already_returned,
|
||||
'returnable_qty' => $remaining,
|
||||
'stock_out_warehouse_id' => $stock_out_warehouse_id,
|
||||
'stock_out_status' => (int)($item['stock_out_status'] ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
return $returnable;
|
||||
}
|
||||
|
||||
public function linkQuotationToOrder(int $quotation_id, int $order_id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET order_id = :order_id, status = 5
|
||||
WHERE id = :id AND company_id = :cid AND status = 2"
|
||||
)->execute([':order_id' => $order_id, ':id' => $quotation_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
public function assertRevenueOrderEditable(int $order_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT source, status FROM td_order
|
||||
WHERE id = :id AND company_id = :cid
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $order_id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row || !in_array($row['source'], ['revenue', 'quotation'], true)) {
|
||||
throw new Exception('Revenue sales order not found.');
|
||||
}
|
||||
if ((int)$row['status'] !== -2) {
|
||||
throw new Exception('Only pending sales orders can be edited.');
|
||||
}
|
||||
}
|
||||
|
||||
public function assertRevenueOrder(int $order_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT source FROM td_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $order_id]);
|
||||
$source = (string)$sth->fetchColumn();
|
||||
|
||||
if (!in_array($source, ['revenue', 'quotation'], true)) {
|
||||
throw new Exception("This is not a Revenue sales order.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -242,14 +479,20 @@ class OrderManager {
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
// Calculate totals from items
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||
if (abs($tax_adjustment) > 0.30) {
|
||||
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||
}
|
||||
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||
), 2) + $tax_adjustment;
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$tracking_no = trim((string)($data['shipping_tracking_number'] ?? ''));
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
@@ -287,75 +530,84 @@ class OrderManager {
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
contact_id = :contact_id,
|
||||
order_date = :order_date,
|
||||
items = :items,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
order_date = :order_date,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
tax_adjustment = :tax_adjustment,
|
||||
shipping_fee = :shipping_fee,
|
||||
shipping_tracking_number = :shipping_tracking_number,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
status = :status,
|
||||
`log` = :log
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
status = :status,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':shipping_tracking_number' => $tracking_no,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':status' => $new_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':status' => $new_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$this->syncOrderItems($id, $items);
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
$quotation_id = (int)($data['quotation_id'] ?? 0);
|
||||
// Quotation-converted orders start as Pending (-2); direct orders as Draft (0)
|
||||
$init_status = $quotation_id > 0 ? -2 : 0;
|
||||
$source = trim((string)($data['source'] ?? ''));
|
||||
$source_id = (int)($data['source_id'] ?? 0);
|
||||
|
||||
// Direct WMS orders start as Draft; sourced orders wait for WMS assignment.
|
||||
$init_status = $source !== '' ? -2 : 0;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_order
|
||||
(company_id, uuid, quotation_id, order_number, contact_id, order_date,
|
||||
status, payment_status, subtotal, discount, tax,
|
||||
shipping_fee, shipping_tracking_number, grand_total, items, notes, `log`, created_at)
|
||||
(company_id, uuid, source_id, `source`, order_number, contact_id, department_id, order_date,
|
||||
status, subtotal, discount, tax, tax_adjustment,
|
||||
shipping_fee, shipping_tracking_number, grand_total, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :quotation_id, :order_number, :contact_id, :order_date,
|
||||
:status, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :shipping_tracking_number, :grand_total, :items, :notes, :log, :created_at)"
|
||||
(:company_id, :uuid, :source_id, :source, :order_number, :contact_id, :department_id, :order_date,
|
||||
:status, :subtotal, :discount, :tax, :tax_adjustment,
|
||||
:shipping_fee, :shipping_tracking_number, :grand_total, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':quotation_id' => $quotation_id,
|
||||
':order_number' => $this->generateOrderNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':status' => $init_status,
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':source_id' => $source_id,
|
||||
':source' => $source,
|
||||
':order_number' => $this->generateOrderNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':order_date' => $data['order_date'] ?? date('Y-m-d'),
|
||||
':status' => $init_status,
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':shipping_tracking_number' => $tracking_no,
|
||||
':grand_total' => $grand_total,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
$new_id = (int)$this->pdo->lastInsertId();
|
||||
$this->syncOrderItems($new_id, $items);
|
||||
return $new_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,45 +653,68 @@ class OrderManager {
|
||||
throw new Exception("Only draft orders can be confirmed.");
|
||||
}
|
||||
|
||||
$items = json_decode($order['items'] ?? '[]', true) ?: [];
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_order_item
|
||||
WHERE order_id = :order_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (empty($items)) {
|
||||
throw new Exception("Cannot confirm an order with no items.");
|
||||
}
|
||||
|
||||
// ── Pre-flight: validate ALL items before creating any rows ───────
|
||||
$preflight_errors = [];
|
||||
foreach ($items as $i => $item) {
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
$name = $item['product_name'] ?? $product_sku;
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
$preflight_errors[] = "Item #{$i}: missing warehouse or SKU.";
|
||||
continue;
|
||||
}
|
||||
if ($quantity <= 0) {
|
||||
$preflight_errors[] = "Item #{$i}: quantity must be greater than zero.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
if (!$rack_stock) {
|
||||
$preflight_errors[] = "No available stock for \"{$name}\" in the selected warehouse.";
|
||||
continue;
|
||||
}
|
||||
|
||||
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
|
||||
if ($quantity - $available_qty > 0.000001) {
|
||||
$preflight_errors[] =
|
||||
"Insufficient stock for \"{$name}\": " .
|
||||
"{$available_qty} available, {$quantity} requested.";
|
||||
}
|
||||
|
||||
$expiry = $rack_stock['expiry_date'] ?? null;
|
||||
if ($expiry && strtotime($expiry) < strtotime(date('Y-m-d'))) {
|
||||
$lot = $rack_stock['lot_number'] ?? '';
|
||||
$preflight_errors[] =
|
||||
"Lot {$lot} for \"{$name}\" expired on {$expiry} and cannot be picked.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($preflight_errors)) {
|
||||
throw new Exception(implode("\n", $preflight_errors));
|
||||
}
|
||||
|
||||
foreach ($items as $i => &$item) {
|
||||
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception(
|
||||
"Item #{$i}: missing warehouse_id or product_sku."
|
||||
);
|
||||
}
|
||||
if ($quantity <= 0) {
|
||||
throw new Exception("Item #{$i}: quantity must be greater than zero.");
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
|
||||
if (!$rack_stock) {
|
||||
$name = $item['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"No available stock for \"{$name}\" in the selected warehouse."
|
||||
);
|
||||
}
|
||||
|
||||
$rack_stock = $this->pickFifoRack($warehouse_id, $product_sku);
|
||||
$available_qty = (float)($rack_stock['available_qty'] ?? $rack_stock['in'] ?? 0);
|
||||
if ($quantity - $available_qty > 0.000001) {
|
||||
$name = $item['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"FIFO rack {$rack_stock['zone']}-{$rack_stock['aisle']}-{$rack_stock['rack']} " .
|
||||
"for \"{$name}\" has only {$available_qty} available unit(s), " .
|
||||
"but the order requests {$quantity}."
|
||||
);
|
||||
}
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
$item_uuid = $uuid . '_' . $i;
|
||||
@@ -474,7 +749,7 @@ class OrderManager {
|
||||
':lot_number' => $rack_stock['lot_number'] ?? '',
|
||||
':serial_number' => $rack_stock['serial_number'] ?? '',
|
||||
':source_id' => $order_id,
|
||||
':price' => (float)($item['price'] ?? 0),
|
||||
':price' => (float)($item['unit_price'] ?? 0),
|
||||
]);
|
||||
|
||||
// Write rack context + stock_out_id back into the item object
|
||||
@@ -494,22 +769,38 @@ class OrderManager {
|
||||
}
|
||||
unset($item); // break reference
|
||||
|
||||
// ── Update order: items (with stock_out_id) + status=1 ───────────
|
||||
// ── Update order: status=1 ───────────────────────────────────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
status = 1,
|
||||
items = :items,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$upd = $this->pdo->prepare(
|
||||
"UPDATE td_order_item SET
|
||||
stock_out_id = :stock_out_id,
|
||||
lot_number = :lot_number,
|
||||
warehouse_id = :warehouse_id
|
||||
WHERE order_id = :order_id AND item_id = :item_id AND company_id = :company_id"
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$upd->execute([
|
||||
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
|
||||
':lot_number' => $item['lot_number'] ?? '',
|
||||
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
|
||||
':order_id' => $order_id,
|
||||
':item_id' => (int)($item['item_id'] ?? 0),
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -555,11 +846,6 @@ class OrderManager {
|
||||
if ($status === -1) {
|
||||
throw new Exception("Order is already cancelled.");
|
||||
}
|
||||
if ($status >= 2) {
|
||||
throw new Exception(
|
||||
"Cannot cancel an order that is processing or completed."
|
||||
);
|
||||
}
|
||||
// Pending orders (-2) have no stock rows — skip the stock reversal loop below
|
||||
|
||||
// ── Guard: no active invoice ──────────────────────────────────────
|
||||
@@ -610,7 +896,7 @@ class OrderManager {
|
||||
$warehouse_id = (int)$matches[1];
|
||||
|
||||
$row_sth = $this->pdo->prepare(
|
||||
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status
|
||||
"SELECT id, product_sku, `out`, zone, aisle, rack, ref_id, status, `date`
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'order'
|
||||
@@ -629,6 +915,8 @@ class OrderManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Order stock cancellation');
|
||||
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
(string)$row['zone'],
|
||||
@@ -638,11 +926,9 @@ class OrderManager {
|
||||
(int)$row['ref_id']
|
||||
);
|
||||
$whMgmt->adjustBalance(
|
||||
'out',
|
||||
$warehouse_id,
|
||||
(string)$row['product_sku'],
|
||||
(float)$row['out'],
|
||||
0
|
||||
'out', $warehouse_id, (string)$row['product_sku'],
|
||||
(float)$row['out'], 0,
|
||||
(int)$row['id'], 'order', $order_id
|
||||
);
|
||||
}
|
||||
|
||||
@@ -658,6 +944,38 @@ class OrderManager {
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Release converted_qty on source quotation ─────────────────────────
|
||||
$source = (string)($order['source'] ?? '');
|
||||
$source_id = (int)($order['source_id'] ?? 0);
|
||||
|
||||
if ($source === 'quotation' && $source_id > 0) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity FROM td_order_item
|
||||
WHERE order_id = :order_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':order_id' => $order_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
$dec = $this->pdo->prepare(
|
||||
"UPDATE td_quotation_item
|
||||
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
||||
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$dec->execute([
|
||||
':qty' => (float)($item['quantity'] ?? 0),
|
||||
':qid' => $source_id,
|
||||
':item_id' => (int)($item['item_id'] ?? 0),
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
// Revert quotation from Converted back to Accepted if it was fully done
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET status = 2
|
||||
WHERE id = :id AND company_id = :cid AND status = 5"
|
||||
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
// ── Cancel the order ──────────────────────────────────────────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'cancel']);
|
||||
@@ -675,36 +993,14 @@ class OrderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the fulfillment sub-status of a confirmed order.
|
||||
*
|
||||
* Fulfillment status values:
|
||||
* 1 = Picking — warehouse staff are picking items
|
||||
* 2 = Packed — items are packed, ready to ship
|
||||
* 3 = Shipped — goods have left the warehouse
|
||||
*
|
||||
* Rules:
|
||||
* - Only allowed on orders with status >= 1 (confirmed) and not cancelled.
|
||||
* - Fulfillment must move forward only (no going backwards).
|
||||
* - If auto_complete_on_ship = true AND fulfillment_status = 3 (Shipped),
|
||||
* order status is automatically advanced to 3 (Completed).
|
||||
* - If auto_complete_on_ship = false, order status is set to 2 (Processing)
|
||||
* on first fulfillment update, and stays there until manually completed.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $order_id td_order.id
|
||||
* @param int $fulfillment_status 1=Picking, 2=Packed, 3=Shipped
|
||||
* @param string $tracking_number Shipping carrier tracking number.
|
||||
* @param array $logging Audit entry.
|
||||
* @param bool $auto_complete Whether Shipped auto-drives order to Completed.
|
||||
* @throws Exception
|
||||
* Update shipping tracking number. Fulfillment itself is derived from
|
||||
* linked stock-out rows and tracking, not selected manually.
|
||||
*/
|
||||
public function updateFulfillmentStatus(
|
||||
int $order_id,
|
||||
int $fulfillment_status,
|
||||
public function updateShippingTracking(
|
||||
int $order_id,
|
||||
string $tracking_number,
|
||||
array $logging,
|
||||
bool $auto_complete = true
|
||||
bool $auto_complete = true
|
||||
): void {
|
||||
// ── Load order ────────────────────────────────────────────────────
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -718,64 +1014,37 @@ class OrderManager {
|
||||
throw new Exception("Order not found.");
|
||||
}
|
||||
|
||||
$status = (int)$order['status'];
|
||||
$current_fulfillment = (int)($order['fulfillment_status'] ?? 0);
|
||||
$current_tracking = trim((string)($order['shipping_tracking_number'] ?? ''));
|
||||
$tracking_number = trim($tracking_number);
|
||||
$status = (int)$order['status'];
|
||||
$current_tracking = trim((string)($order['shipping_tracking_number'] ?? ''));
|
||||
$tracking_number = trim($tracking_number);
|
||||
|
||||
if ($status === -1) {
|
||||
throw new Exception("Cannot update a cancelled order.");
|
||||
}
|
||||
if ($status < 1) {
|
||||
throw new Exception("Confirm the order before updating fulfillment.");
|
||||
throw new Exception("Confirm the order before adding a tracking number.");
|
||||
}
|
||||
if ($status === 3) {
|
||||
throw new Exception("Order is already completed.");
|
||||
}
|
||||
if (!in_array($fulfillment_status, [1, 2, 3], true)) {
|
||||
throw new Exception("Invalid fulfillment status.");
|
||||
}
|
||||
if ($fulfillment_status < $current_fulfillment) {
|
||||
throw new Exception("Fulfillment status can only move forward.");
|
||||
}
|
||||
if ($fulfillment_status === $current_fulfillment && $tracking_number === $current_tracking) {
|
||||
throw new Exception("No fulfillment or tracking changes to save.");
|
||||
}
|
||||
|
||||
// ── Determine new order status ────────────────────────────────────
|
||||
$new_order_status = $status;
|
||||
|
||||
if ($fulfillment_status > $current_fulfillment && $fulfillment_status === 3 && $auto_complete) {
|
||||
// Shipped + auto-complete → Completed
|
||||
$new_order_status = 3;
|
||||
} elseif ($fulfillment_status > $current_fulfillment && $status === 1) {
|
||||
// First fulfillment update → Processing
|
||||
$new_order_status = 2;
|
||||
if ($tracking_number === $current_tracking) {
|
||||
throw new Exception("No tracking changes to save.");
|
||||
}
|
||||
|
||||
// ── Persist ───────────────────────────────────────────────────────
|
||||
$log = json_decode($order['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, [
|
||||
'action' => 'update_fulfillment',
|
||||
'fulfillment_status' => $fulfillment_status,
|
||||
'action' => 'update_tracking',
|
||||
'shipping_tracking_number' => $tracking_number,
|
||||
'order_status' => $new_order_status,
|
||||
]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_order SET
|
||||
fulfillment_status = :fulfillment_status,
|
||||
shipping_tracking_number = :shipping_tracking_number,
|
||||
status = :status,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':fulfillment_status' => $fulfillment_status,
|
||||
':shipping_tracking_number' => $tracking_number,
|
||||
':status' => $new_order_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
':log' => json_encode($log),
|
||||
':id' => $order_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
class PaymentBillingManager {
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateBillingNumber(): string
|
||||
{
|
||||
$prefix = 'PB-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT billing_number FROM td_payment_billing
|
||||
WHERE company_id = :company_id
|
||||
AND billing_number LIKE :prefix
|
||||
ORDER BY billing_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function unavailableDocumentAmount(int $invoice_id): float
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(billed.billed_amount, 0) + COALESCE(direct.paid_amount, 0)
|
||||
FROM (
|
||||
SELECT :company_id AS company_id, :invoice_id AS invoice_id
|
||||
) doc
|
||||
LEFT JOIN (
|
||||
SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount
|
||||
FROM td_payment_billing_item ba
|
||||
JOIN td_payment_billing b
|
||||
ON b.company_id = ba.company_id
|
||||
AND b.id = ba.billing_id
|
||||
WHERE ba.company_id = :company_id_billed
|
||||
AND ba.invoice_id = :invoice_id_billed
|
||||
AND b.status IN (1, 2, 3)
|
||||
GROUP BY ba.company_id, ba.invoice_id
|
||||
) billed
|
||||
ON billed.company_id = doc.company_id
|
||||
AND billed.invoice_id = doc.invoice_id
|
||||
LEFT JOIN (
|
||||
SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount
|
||||
FROM td_payment_item ra
|
||||
JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
WHERE ra.company_id = :company_id_direct
|
||||
AND ra.invoice_id = :invoice_id_direct
|
||||
AND r.status = 1
|
||||
AND r.payment_billing_id = 0
|
||||
GROUP BY ra.company_id, ra.invoice_id
|
||||
) direct
|
||||
ON direct.company_id = doc.company_id
|
||||
AND direct.invoice_id = doc.invoice_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':invoice_id' => $invoice_id,
|
||||
':company_id_billed' => $this->company_id,
|
||||
':invoice_id_billed' => $invoice_id,
|
||||
':company_id_direct' => $this->company_id,
|
||||
':invoice_id_direct' => $invoice_id,
|
||||
]);
|
||||
return (float)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getBillableDocuments(int $contact_id = 0): array
|
||||
{
|
||||
$where = [
|
||||
"i.company_id = :company_id",
|
||||
"i.doc_type IN ('purchase_invoice', 'supplier_credit_note')",
|
||||
"i.status = 1"
|
||||
];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
$where[] = 'i.contact_id = :contact_id';
|
||||
$params[':contact_id'] = $contact_id;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
po.po_number AS order_number,
|
||||
COALESCE(payment.paid_amount, 0) AS paid_amount,
|
||||
COALESCE(billed.billed_amount, 0) AS billed_amount
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON po.company_id = i.company_id
|
||||
AND po.id = i.source_id
|
||||
AND i.source = 'po'
|
||||
LEFT JOIN (
|
||||
SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS paid_amount
|
||||
FROM td_payment_item ra
|
||||
JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
WHERE r.status = 1
|
||||
AND r.payment_billing_id = 0
|
||||
GROUP BY ra.company_id, ra.invoice_id
|
||||
) payment
|
||||
ON payment.company_id = i.company_id
|
||||
AND payment.invoice_id = i.id
|
||||
LEFT JOIN (
|
||||
SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount
|
||||
FROM td_payment_billing_item ba
|
||||
JOIN td_payment_billing b
|
||||
ON b.company_id = ba.company_id
|
||||
AND b.id = ba.billing_id
|
||||
WHERE b.status IN (1, 2, 3)
|
||||
GROUP BY ba.company_id, ba.invoice_id
|
||||
) billed
|
||||
ON billed.company_id = i.company_id
|
||||
AND billed.invoice_id = i.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.issued_date ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
|
||||
$open = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$total = abs((float)$row['grand_total']);
|
||||
$used = (float)$row['paid_amount'] + (float)$row['billed_amount'];
|
||||
$balance = max(0, $total - $used);
|
||||
if ($balance <= 0.0001) continue;
|
||||
$row['document_total'] = $total;
|
||||
$row['balance_amount'] = $balance;
|
||||
$open[] = $row;
|
||||
}
|
||||
|
||||
return $open;
|
||||
}
|
||||
|
||||
public function getBillingList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount
|
||||
FROM td_payment_billing b
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = b.company_id
|
||||
AND c.id = b.contact_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.payment_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['balance_amount'] = max(0, (float)$row['amount'] - (float)$row['paid_amount']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getOpenBillingList(int $contact_id = 0): array
|
||||
{
|
||||
$rows = $this->getBillingList();
|
||||
return array_values(array_filter($rows, function($row) use ($contact_id) {
|
||||
if ((int)$row['status'] === 4) return false;
|
||||
if ((int)$row['contact_id'] !== $contact_id) return false;
|
||||
return (float)$row['balance_amount'] > 0.0001;
|
||||
}));
|
||||
}
|
||||
|
||||
public function getBillingById(int $billing_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount
|
||||
FROM td_payment_billing b
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = b.company_id
|
||||
AND c.id = b.contact_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.payment_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing) throw new Exception("Payment billing not found.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*,
|
||||
i.invoice_number,
|
||||
i.order_id,
|
||||
i.department_id,
|
||||
i.grand_total,
|
||||
i.status AS invoice_status,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS paid_line_amount,
|
||||
po.po_number AS order_number
|
||||
FROM td_payment_billing_item a
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.id = a.invoice_id
|
||||
LEFT JOIN td_payment_item ra
|
||||
ON ra.company_id = a.company_id
|
||||
AND ra.invoice_id = a.invoice_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
AND r.payment_billing_id = a.billing_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON po.company_id = i.company_id
|
||||
AND po.id = i.source_id
|
||||
AND i.source = 'po'
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.billing_id = :billing_id
|
||||
GROUP BY a.id
|
||||
ORDER BY a.id ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]);
|
||||
$allocations = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($allocations as &$line) {
|
||||
$line['balance_amount'] = max(0, (float)$line['amount'] - (float)$line['paid_line_amount']);
|
||||
}
|
||||
$billing['allocations'] = $allocations;
|
||||
$billing['balance_amount'] = max(0, (float)$billing['amount'] - (float)$billing['paid_amount']);
|
||||
|
||||
return $billing;
|
||||
}
|
||||
|
||||
public function refreshBillingStatus(int $billing_id): void
|
||||
{
|
||||
$billing = $this->getBillingById($billing_id);
|
||||
if ((int)$billing['status'] === 4) return;
|
||||
if ((float)$billing['balance_amount'] <= 0.0001) {
|
||||
$new_status = 2;
|
||||
} elseif ((float)$billing['paid_amount'] > 0.0001) {
|
||||
$new_status = 3;
|
||||
} else {
|
||||
$new_status = 1;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing
|
||||
SET status = :status
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createBilling(array $data, array $logging): int
|
||||
{
|
||||
$contact_id = (int)($data['contact_id'] ?? 0);
|
||||
$billing_date = $data['billing_date'] ?? date('Y-m-d');
|
||||
$notes = trim((string)($data['notes'] ?? ''));
|
||||
$allocations = $data['allocations'] ?? [];
|
||||
|
||||
if (is_string($allocations)) {
|
||||
$allocations = json_decode($allocations, true) ?: [];
|
||||
}
|
||||
|
||||
if ($contact_id <= 0) throw new Exception("Supplier is required.");
|
||||
if (!$allocations) throw new Exception("Payment billing requires at least one document.");
|
||||
|
||||
$validated = [];
|
||||
$purchase_invoice_total = 0.0;
|
||||
$credit_total = 0.0;
|
||||
|
||||
foreach ($allocations as $line) {
|
||||
$invoice_id = (int)($line['invoice_id'] ?? 0);
|
||||
$amount = (float)($line['amount'] ?? 0);
|
||||
if ($invoice_id <= 0 || $amount <= 0) continue;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, doc_type, invoice_number, contact_id, department_id, grand_total, status
|
||||
FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$doc) throw new Exception("Document not found.");
|
||||
if ((int)$doc['contact_id'] !== $contact_id) {
|
||||
throw new Exception("All billing documents must belong to the selected contact.");
|
||||
}
|
||||
if (!in_array($doc['doc_type'], ['purchase_invoice', 'supplier_credit_note'], true)) {
|
||||
throw new Exception("Payment billing can only include purchase invoices and supplier credit notes.");
|
||||
}
|
||||
if ((int)$doc['status'] !== 1) {
|
||||
throw new Exception($doc['invoice_number'] . " is not open for billing.");
|
||||
}
|
||||
|
||||
$balance = abs((float)$doc['grand_total']) - $this->unavailableDocumentAmount($invoice_id);
|
||||
if ($amount - $balance > 0.0001) {
|
||||
throw new Exception($doc['invoice_number'] . " allocation exceeds remaining billable balance.");
|
||||
}
|
||||
|
||||
if ($doc['doc_type'] === 'supplier_credit_note') $credit_total += $amount;
|
||||
else $purchase_invoice_total += $amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => $invoice_id,
|
||||
'doc_type' => $doc['doc_type'],
|
||||
'department_id' => (int)($doc['department_id'] ?? 0),
|
||||
'amount' => round($amount, 4),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$validated) throw new Exception("Payment billing requires at least one valid document.");
|
||||
$net_amount = round($purchase_invoice_total - $credit_total, 4);
|
||||
if ($net_amount <= 0.0001) {
|
||||
throw new Exception("Payment billing amount must be greater than zero.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_payment_billing'])];
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_payment_billing
|
||||
(company_id, uuid, billing_number, contact_id, billing_date,
|
||||
amount, status, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :billing_number, :contact_id, :billing_date,
|
||||
:amount, 1, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':billing_number' => $this->generateBillingNumber(),
|
||||
':contact_id' => $contact_id,
|
||||
':billing_date' => $billing_date,
|
||||
':amount' => $net_amount,
|
||||
':notes' => $notes,
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$billing_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
foreach ($validated as $line) {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_payment_billing_item
|
||||
(company_id, billing_id, invoice_id, doc_type, amount)
|
||||
VALUES
|
||||
(:company_id, :billing_id, :invoice_id, :doc_type, :amount)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':billing_id' => $billing_id,
|
||||
':invoice_id' => $line['invoice_id'],
|
||||
':doc_type' => $line['doc_type'],
|
||||
':amount' => $line['amount'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $billing_id;
|
||||
}
|
||||
|
||||
public function voidBilling(int $billing_id, array $logging): void
|
||||
{
|
||||
$billing = $this->getBillingById($billing_id);
|
||||
if ((int)$billing['status'] === 4) throw new Exception("Payment billing is already void.");
|
||||
if ((float)$billing['paid_amount'] > 0.0001) {
|
||||
throw new Exception("Cannot void a payment billing with posted payments.");
|
||||
}
|
||||
|
||||
$log = json_decode($billing['log'] ?: '[]', true);
|
||||
if (!is_array($log)) $log = [];
|
||||
$log[] = array_merge($logging, ['action' => 'void_payment_billing']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing
|
||||
SET status = 4, `log` = :log
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,517 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||
|
||||
class PaymentManager {
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generatePaymentNumber(): string
|
||||
{
|
||||
$prefix = 'PY-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT payment_number FROM td_payment
|
||||
WHERE company_id = :company_id
|
||||
AND payment_number LIKE :prefix
|
||||
ORDER BY payment_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function assertPostingWindow(?string $date, string $context): void
|
||||
{
|
||||
global $pdo1;
|
||||
|
||||
if (!isset($pdo1) || !($pdo1 instanceof PDO)) {
|
||||
throw new Exception("Posting-window validation is unavailable.");
|
||||
}
|
||||
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($date ?: date('Y-m-d'), $context);
|
||||
}
|
||||
|
||||
private function allocatedAmount(int $invoice_id): float
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(SUM(a.amount), 0)
|
||||
FROM td_payment_item a
|
||||
JOIN td_payment r
|
||||
ON r.company_id = a.company_id
|
||||
AND r.id = a.payment_id
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.invoice_id = :invoice_id
|
||||
AND r.status = 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':invoice_id' => $invoice_id,
|
||||
]);
|
||||
return (float)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
private function refreshInvoiceSettlementStatus(int $invoice_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT grand_total, status FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || (int)$row['status'] === 4 || (int)$row['status'] === 0) return;
|
||||
|
||||
$target = abs((float)$row['grand_total']);
|
||||
$paid = $this->allocatedAmount($invoice_id);
|
||||
$new_status = ($target > 0 && $paid + 0.0001 >= $target) ? 2 : 1;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET status = :status
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $invoice_id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function refreshPaymentBillingStatus(int $billing_id): void
|
||||
{
|
||||
if ($billing_id <= 0) return;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.amount, b.status,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount
|
||||
FROM td_payment_billing b
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.payment_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing || (int)$billing['status'] === 4) return;
|
||||
|
||||
$amount = (float)$billing['amount'];
|
||||
$payment = (float)$billing['paid_amount'];
|
||||
if ($payment + 0.0001 >= $amount) {
|
||||
$new_status = 2;
|
||||
} elseif ($payment > 0.0001) {
|
||||
$new_status = 3;
|
||||
} else {
|
||||
$new_status = 1;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment_billing
|
||||
SET status = :status
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getOpenDocuments(int $contact_id = 0): array
|
||||
{
|
||||
$where = [
|
||||
"i.company_id = :company_id",
|
||||
"i.doc_type IN ('purchase_invoice', 'supplier_credit_note')",
|
||||
"i.status IN (1, 2)"
|
||||
];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($contact_id > 0) {
|
||||
$where[] = 'i.contact_id = :contact_id';
|
||||
$params[':contact_id'] = $contact_id;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
po.po_number AS order_number,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN a.amount ELSE 0 END), 0) AS allocated_amount
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON po.company_id = i.company_id
|
||||
AND po.id = i.source_id
|
||||
AND i.source = 'po'
|
||||
LEFT JOIN td_payment_item a
|
||||
ON a.company_id = i.company_id
|
||||
AND a.invoice_id = i.id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = a.company_id
|
||||
AND r.id = a.payment_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
GROUP BY i.id
|
||||
ORDER BY i.issued_date ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$open = [];
|
||||
foreach ($rows as $row) {
|
||||
$total = abs((float)$row['grand_total']);
|
||||
$allocated = (float)$row['allocated_amount'];
|
||||
$balance = max(0, $total - $allocated);
|
||||
if ($balance <= 0.0001) continue;
|
||||
$row['document_total'] = $total;
|
||||
$row['balance_amount'] = $balance;
|
||||
$open[] = $row;
|
||||
}
|
||||
|
||||
return $open;
|
||||
}
|
||||
|
||||
public function getPaymentList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(b.billing_number, '') AS billing_number
|
||||
FROM td_payment r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_payment_billing b
|
||||
ON b.company_id = r.company_id
|
||||
AND b.id = r.payment_billing_id
|
||||
WHERE r.company_id = :company_id
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getPaymentById(int $payment_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(b.billing_number, '') AS billing_number
|
||||
FROM td_payment r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_payment_billing b
|
||||
ON b.company_id = r.company_id
|
||||
AND b.id = r.payment_billing_id
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $payment_id,
|
||||
]);
|
||||
$payment = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$payment) throw new Exception("Payment not found.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*,
|
||||
i.invoice_number,
|
||||
i.order_id,
|
||||
i.grand_total,
|
||||
i.status AS invoice_status,
|
||||
po.po_number AS order_number
|
||||
FROM td_payment_item a
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.id = a.invoice_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON po.company_id = i.company_id
|
||||
AND po.id = i.source_id
|
||||
AND i.source = 'po'
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.payment_id = :payment_id
|
||||
ORDER BY a.id ASC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':payment_id' => $payment_id,
|
||||
]);
|
||||
|
||||
$payment['allocations'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
return $payment;
|
||||
}
|
||||
|
||||
public function voidPayment(int $payment_id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, payment_billing_id, payment_date, status, `log`
|
||||
FROM td_payment
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $payment_id,
|
||||
]);
|
||||
$payment = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$payment) throw new Exception("Payment not found.");
|
||||
if ((int)$payment['status'] === 4) throw new Exception("Payment is already void.");
|
||||
$this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment');
|
||||
|
||||
$log = json_decode($payment['log'] ?: '[]', true);
|
||||
if (!is_array($log)) $log = [];
|
||||
$log[] = array_merge($logging, ['action' => 'void_payment']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_payment
|
||||
SET status = 4, `log` = :log
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $payment_id,
|
||||
]);
|
||||
|
||||
global $pdo1;
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$gl = new GlManager($this->pdo, $this->company_id, $guard);
|
||||
$gl->delete('payment', $payment_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT invoice_id
|
||||
FROM td_payment_item
|
||||
WHERE company_id = :company_id
|
||||
AND payment_id = :payment_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':payment_id' => $payment_id,
|
||||
]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $invoice_id) {
|
||||
$this->refreshInvoiceSettlementStatus((int)$invoice_id);
|
||||
}
|
||||
$this->refreshPaymentBillingStatus((int)$payment['payment_billing_id']);
|
||||
}
|
||||
|
||||
public function createPayment(array $data, array $logging): int
|
||||
{
|
||||
$billing_id = (int)($data['payment_billing_id'] ?? $data['billing_id'] ?? 0);
|
||||
$payment_date = $data['payment_date'] ?? date('Y-m-d');
|
||||
$this->assertPostingWindow($payment_date, 'Payment');
|
||||
$payment_method = trim((string)($data['payment_method'] ?? ''));
|
||||
$notes = trim((string)($data['notes'] ?? ''));
|
||||
$cash_amount = round((float)($data['amount'] ?? 0), 4);
|
||||
$allocations = $data['allocations'] ?? [];
|
||||
|
||||
if (is_string($allocations)) {
|
||||
$allocations = json_decode($allocations, true) ?: [];
|
||||
}
|
||||
|
||||
if ($billing_id <= 0) throw new Exception("Payment billing is required.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS paid_amount
|
||||
FROM td_payment_billing b
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.payment_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing) throw new Exception("Payment billing not found.");
|
||||
if ((int)$billing['status'] === 4) throw new Exception("Cannot pay against a void payment billing.");
|
||||
|
||||
$contact_id = (int)$billing['contact_id'];
|
||||
$billing_balance = round((float)$billing['amount'] - (float)$billing['paid_amount'], 4);
|
||||
if ($billing_balance <= 0.0001) throw new Exception("Payment billing is already settled.");
|
||||
if ($cash_amount - $billing_balance > 0.0001) {
|
||||
throw new Exception("Payment amount exceeds payment billing balance.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ba.invoice_id, ba.doc_type, COALESCE(i.department_id, 0) AS department_id, ba.amount,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS paid_line_amount
|
||||
FROM td_payment_billing_item ba
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = ba.company_id
|
||||
AND i.id = ba.invoice_id
|
||||
LEFT JOIN td_payment_item ra
|
||||
ON ra.company_id = ba.company_id
|
||||
AND ra.invoice_id = ba.invoice_id
|
||||
LEFT JOIN td_payment r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.payment_id
|
||||
AND r.payment_billing_id = ba.billing_id
|
||||
WHERE ba.company_id = :company_id
|
||||
AND ba.billing_id = :billing_id
|
||||
GROUP BY ba.id
|
||||
ORDER BY ba.id ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]);
|
||||
$billing_lines = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!$billing_lines) throw new Exception("Payment billing has no documents.");
|
||||
|
||||
$validated = [];
|
||||
$billing_line_map = [];
|
||||
foreach ($billing_lines as $line) {
|
||||
$billing_line_map[(int)$line['invoice_id']] = [
|
||||
'invoice_id' => (int)$line['invoice_id'],
|
||||
'doc_type' => $line['doc_type'],
|
||||
'department_id' => (int)($line['department_id'] ?? 0),
|
||||
'remaining' => max(0, (float)$line['amount'] - (float)$line['paid_line_amount']),
|
||||
];
|
||||
}
|
||||
|
||||
if ($allocations) {
|
||||
$purchase_invoice_total = 0.0;
|
||||
$credit_total = 0.0;
|
||||
|
||||
foreach ($allocations as $line) {
|
||||
$invoice_id = (int)($line['invoice_id'] ?? 0);
|
||||
$amount = abs((float)($line['amount'] ?? 0));
|
||||
if ($invoice_id <= 0 || $amount <= 0) continue;
|
||||
if (!isset($billing_line_map[$invoice_id])) {
|
||||
throw new Exception("Payment allocation document is not part of this billing.");
|
||||
}
|
||||
|
||||
$billing_line = $billing_line_map[$invoice_id];
|
||||
if ($amount - $billing_line['remaining'] > 0.0001) {
|
||||
throw new Exception("Payment allocation exceeds billing line balance.");
|
||||
}
|
||||
|
||||
if ($billing_line['doc_type'] === 'supplier_credit_note') $credit_total += $amount;
|
||||
else $purchase_invoice_total += $amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => $invoice_id,
|
||||
'doc_type' => $billing_line['doc_type'],
|
||||
'department_id' => (int)($line['department_id'] ?? $billing_line['department_id'] ?? 0),
|
||||
'amount' => round($amount, 4),
|
||||
];
|
||||
}
|
||||
|
||||
$cash_amount = round($purchase_invoice_total - $credit_total, 4);
|
||||
if ($cash_amount <= 0) throw new Exception("Cash payment amount must be greater than zero.");
|
||||
if ($cash_amount - $billing_balance > 0.0001) {
|
||||
throw new Exception("Payment amount exceeds payment billing balance.");
|
||||
}
|
||||
} else {
|
||||
if ($cash_amount <= 0) throw new Exception("Payment amount is required.");
|
||||
|
||||
$ratio = $cash_amount / max($billing_balance, 0.0001);
|
||||
foreach ($billing_lines as $line) {
|
||||
$remaining_line_amount = max(0, (float)$line['amount'] - (float)$line['paid_line_amount']);
|
||||
if ($remaining_line_amount <= 0.0001) continue;
|
||||
|
||||
$line_amount = round($remaining_line_amount * $ratio, 4);
|
||||
if ($line_amount <= 0) continue;
|
||||
if ($line_amount - $remaining_line_amount > 0.0001) $line_amount = $remaining_line_amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => (int)$line['invoice_id'],
|
||||
'doc_type' => $line['doc_type'],
|
||||
'department_id' => (int)($line['department_id'] ?? 0),
|
||||
'amount' => $line_amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$validated) throw new Exception("Payment billing has no remaining document balances.");
|
||||
|
||||
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
|
||||
? (int)$data['formula_id'] : null;
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_payment'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_payment
|
||||
(company_id, uuid, payment_number, contact_id, department_id, payment_billing_id, payment_date,
|
||||
payment_method, amount, formula_id, status, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :payment_number, :contact_id, :department_id, :payment_billing_id, :payment_date,
|
||||
:payment_method, :amount, :formula_id, 1, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':payment_number' => $this->generatePaymentNumber(),
|
||||
':contact_id' => $contact_id,
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':payment_billing_id' => $billing_id,
|
||||
':payment_date' => $payment_date,
|
||||
':payment_method' => $payment_method,
|
||||
':amount' => $cash_amount,
|
||||
':formula_id' => $formula_id,
|
||||
':notes' => $notes,
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$payment_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
foreach ($validated as $line) {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_payment_item
|
||||
(company_id, payment_id, invoice_id, doc_type, department_id, amount)
|
||||
VALUES
|
||||
(:company_id, :payment_id, :invoice_id, :doc_type, :department_id, :amount)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':payment_id' => $payment_id,
|
||||
':invoice_id' => $line['invoice_id'],
|
||||
':doc_type' => $line['doc_type'],
|
||||
':department_id' => (int)($line['department_id'] ?? 0),
|
||||
':amount' => $line['amount'],
|
||||
]);
|
||||
|
||||
$this->refreshInvoiceSettlementStatus($line['invoice_id']);
|
||||
}
|
||||
|
||||
$this->refreshPaymentBillingStatus($billing_id);
|
||||
|
||||
return $payment_id;
|
||||
}
|
||||
|
||||
public function saveFormula(int $id, ?int $formula_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT payment_date, status FROM td_payment
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
$payment = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$payment) throw new Exception("Payment not found.");
|
||||
if ((int)$payment['status'] === 4) throw new Exception("Cannot update a void payment.");
|
||||
$this->assertPostingWindow($payment['payment_date'] ?: date('Y-m-d'), 'Payment');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE td_payment SET formula_id = :formula_id
|
||||
WHERE id = :id AND company_id = :company_id AND status != 4"
|
||||
);
|
||||
$sth->execute([
|
||||
':formula_id' => $formula_id,
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -303,6 +303,16 @@ class ProductManager {
|
||||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getProductImage(int $id): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT product_image FROM md_product
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
return (string)($sth->fetchColumn() ?: '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Search products by SKU keyword — for live autocomplete on stock forms.
|
||||
*
|
||||
@@ -368,6 +378,8 @@ class ProductManager {
|
||||
':category' => $data['category'],
|
||||
':product_image' => $product_image,
|
||||
':description' => $data['description'],
|
||||
':sales_account_code' => trim((string)($data['sales_account_code'] ?? '')) ?: null,
|
||||
':purchase_account_code' => trim((string)($data['purchase_account_code'] ?? '')) ?: null,
|
||||
':status' => (int)$data['status'],
|
||||
':log' => json_encode($table_log),
|
||||
];
|
||||
@@ -386,6 +398,8 @@ class ProductManager {
|
||||
reorder_point = :reorder_point,
|
||||
category = :category,
|
||||
product_image = :product_image,
|
||||
sales_account_code = :sales_account_code,
|
||||
purchase_account_code = :purchase_account_code,
|
||||
`description` = :description,
|
||||
`status` = :status,
|
||||
`log` = :log
|
||||
@@ -395,14 +409,52 @@ class ProductManager {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO md_product
|
||||
(company_id, product_name, sku, barcode, uom, price, cost_price, min_stock, reorder_point,
|
||||
category, product_image, `description`, `status`, `log`)
|
||||
category, product_image, sales_account_code, purchase_account_code, `description`, `status`, `log`)
|
||||
VALUES
|
||||
(:company_id, :product_name, :sku, :barcode, :uom, :price, :cost_price, :min_stock, :reorder_point,
|
||||
:category, :product_image, :description, :status, :log)"
|
||||
:category, :product_image, :sales_account_code, :purchase_account_code, :description, :status, :log)"
|
||||
)->execute($params);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the accounting account mapping fields for a product.
|
||||
*
|
||||
* This is used by Accounting so product master data can stay locked while
|
||||
* finance users maintain GL routing for SKU-based posting.
|
||||
*/
|
||||
public function updateAccountMapping(int $id, ?string $sales_account_code, ?string $purchase_account_code, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT `log` FROM md_product
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$existing_log = $sth->fetchColumn();
|
||||
|
||||
if ($existing_log === false) {
|
||||
throw new Exception('Product not found.');
|
||||
}
|
||||
|
||||
$table_log = json_decode($existing_log ?: '[]', true) ?: [];
|
||||
$table_log[] = $logging;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE md_product SET
|
||||
sales_account_code = :sales_account_code,
|
||||
purchase_account_code = :purchase_account_code,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $id,
|
||||
':sales_account_code' => $sales_account_code,
|
||||
':purchase_account_code' => $purchase_account_code,
|
||||
':log' => json_encode($table_log),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a product by negating its company_id.
|
||||
*
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
* in the existing Stock In list automatically under source='po'.
|
||||
* - stock_in_id is written back into the items JSON after each receive call,
|
||||
* same pattern as stock_out_id in OrderManager::confirmOrder().
|
||||
* - Partial receipt is supported: receivePo() can be called multiple times
|
||||
* until all items are fully received, advancing status 1→2 (partial) or 1/2→3 (completed).
|
||||
* - Partial receipt is supported: receivePo() can be called multiple times.
|
||||
* - PO stored statuses: -2 (pending warehouse), -1 (cancelled), 0 (draft), 1 (confirmed).
|
||||
* Receipt progress (pending/partial/received) is derived via deriveReceiptStatus(),
|
||||
* same pattern as fulfillment_status on SO. Never stored in td_purchase_order.
|
||||
* - cancelPo() is blocked if any linked stock-in rows have been approved (status=1),
|
||||
* because those have already modified rack and balance.
|
||||
* - Write methods do NOT manage their own DB transactions.
|
||||
@@ -78,6 +80,77 @@ class PurchaseOrderManager {
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive receipt status from linked stock-in rows — never stored in DB.
|
||||
*
|
||||
* 0 = no stock-in rows linked yet
|
||||
* 1 = pending (stock-in rows exist but none approved)
|
||||
* 2 = partial (some approved, quantities not fully received)
|
||||
* 3 = received (all approved, all quantities received)
|
||||
*/
|
||||
private function deriveReceiptStatus(array $po): int
|
||||
{
|
||||
if ((int)($po['status'] ?? 0) < 1) return 0;
|
||||
|
||||
$po_id = (int)($po['id'] ?? 0);
|
||||
|
||||
// Fetch items from normalized table
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity, received_qty, stock_in_id FROM td_purchase_order_item
|
||||
WHERE order_id = :po_id AND company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$has_any_stock_in = false;
|
||||
foreach ($items as $item) {
|
||||
if ((int)($item['stock_in_id'] ?? 0) > 0) {
|
||||
$has_any_stock_in = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$has_any_stock_in) return 0;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$total = 0;
|
||||
$pending = 0;
|
||||
|
||||
foreach ($tables as $table) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
AND source_id = :po_id
|
||||
AND type = 'in'
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $s) {
|
||||
$total++;
|
||||
if ((int)$s === 0) $pending++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($total === 0) return 0;
|
||||
if ($pending > 0) return 1;
|
||||
|
||||
$all_received = true;
|
||||
foreach ($items as $item) {
|
||||
if ((float)($item['received_qty'] ?? 0) < (float)($item['quantity'] ?? 0)) {
|
||||
$all_received = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $all_received ? 3 : 2;
|
||||
}
|
||||
|
||||
private function stockTableNameFromWarehouseId(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) {
|
||||
@@ -87,6 +160,38 @@ class PurchaseOrderManager {
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
private function syncPoItems(int $po_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_purchase_order_item WHERE order_id = :po_id AND company_id = :company_id"
|
||||
)->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_order_item
|
||||
(company_id, order_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, received_qty, stock_in_id, invoiced_qty)
|
||||
VALUES
|
||||
(:company_id, :order_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :received_qty, :stock_in_id, 0)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':order_id' => $po_id,
|
||||
':item_id' => $pos + 1,
|
||||
':product_sku' => $item['product_sku'] ?? '',
|
||||
':product_name' => $item['product_name'] ?? '',
|
||||
':quantity' => (float)($item['quantity'] ?? 0),
|
||||
':unit_price' => (float)($item['unit_price'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
':received_qty' => (float)($item['received_qty'] ?? 0),
|
||||
':stock_in_id' => (int)($item['stock_in_id'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -108,7 +213,11 @@ class PurchaseOrderManager {
|
||||
ORDER BY p.created_at DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$row) {
|
||||
$row['receipt_status'] = $this->deriveReceiptStatus($row);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,19 +241,86 @@ class PurchaseOrderManager {
|
||||
|
||||
if (!$row) return false;
|
||||
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order_item
|
||||
WHERE order_id = :order_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':order_id' => $id, ':company_id' => $this->company_id]);
|
||||
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$row['receipt_status'] = $this->deriveReceiptStatus($row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function getReceivableItems(array $po): array
|
||||
{
|
||||
$receivable = [];
|
||||
foreach (($po['items'] ?? []) as $i => $item) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
$remaining = $ordered - $received;
|
||||
if ($remaining > 0) {
|
||||
$receivable[] = array_merge($item, [
|
||||
'item_id' => (int)($item['item_id'] ?? $i),
|
||||
'ordered_qty' => $ordered,
|
||||
'received_qty' => $received,
|
||||
'remaining_qty' => $remaining,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $receivable;
|
||||
}
|
||||
|
||||
public function getReturnableItems(array $po): array
|
||||
{
|
||||
$returnable = [];
|
||||
foreach (($po['items'] ?? []) as $i => $item) {
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
if ($received > 0) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$returnable[] = array_merge($item, [
|
||||
'item_id' => (int)($item['item_id'] ?? $i),
|
||||
'ordered_qty' => $ordered,
|
||||
'received_qty' => $received,
|
||||
'returnable_qty' => $received,
|
||||
'warehouse_id' => (int)($item['receive_wh'] ?? 0),
|
||||
'zone' => $item['receive_zone'] ?? '',
|
||||
'aisle' => $item['receive_aisle'] ?? '',
|
||||
'rack' => $item['receive_rack'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $returnable;
|
||||
}
|
||||
|
||||
public function getActivePurchaseInvoiceId(int $po_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'po'
|
||||
AND source_id = :po_id
|
||||
AND doc_type = 'purchase_invoice'
|
||||
AND status != 4
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':po_id' => $po_id]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a new PO (draft) or update metadata on an existing draft.
|
||||
* Insert a new PO or update metadata on an existing pending/draft PO.
|
||||
*
|
||||
* Insert (id=0): generates po_number, sets status=0, payment_status=0.
|
||||
* Update (id>0): only allowed while status=0 (draft).
|
||||
* Insert (id=0): generates po_number. Accounting-sourced POs start
|
||||
* as pending warehouse (-2), direct WMS POs start as draft (0).
|
||||
* Update (id>0): only allowed while status=-2 (pending) or 0 (draft).
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, po_date, expected_date,
|
||||
* warehouse_id, items (array), discount, tax,
|
||||
@@ -157,13 +333,24 @@ class PurchaseOrderManager {
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$skus = array_filter(array_column($items, 'product_sku'));
|
||||
if (count($skus) !== count(array_unique($skus))) {
|
||||
throw new Exception("Duplicate products found — each product may only appear once per PO.");
|
||||
}
|
||||
|
||||
$subtotal = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax = (float)($data['tax'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||
if (abs($tax_adjustment) > 0.30) {
|
||||
throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||
}
|
||||
$tax = round(array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['tax_amount'] ?? 0), 0.0
|
||||
), 2) + $tax_adjustment;
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
$grand_total = $subtotal - $discount + $tax + $shipping_fee;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
@@ -175,78 +362,97 @@ class PurchaseOrderManager {
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Purchase order not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft POs can be edited.");
|
||||
$cur_status = (int)$row['status'];
|
||||
if (!in_array($cur_status, [-2, 0], true)) {
|
||||
throw new Exception("Only pending or draft POs can be edited.");
|
||||
}
|
||||
$warehouse_id = (int)($data['warehouse_id'] ?? 0);
|
||||
$new_status = ($cur_status === -2 && $warehouse_id > 0) ? 0 : $cur_status;
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
contact_id = :contact_id,
|
||||
po_date = :po_date,
|
||||
expected_date = :expected_date,
|
||||
warehouse_id = :warehouse_id,
|
||||
items = :items,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
shipping_fee = :shipping_fee,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
po_date = :po_date,
|
||||
expected_date = :expected_date,
|
||||
warehouse_id = :warehouse_id,
|
||||
subtotal = :subtotal,
|
||||
discount = :discount,
|
||||
tax = :tax,
|
||||
tax_adjustment = :tax_adjustment,
|
||||
shipping_fee = :shipping_fee,
|
||||
grand_total = :grand_total,
|
||||
notes = :notes,
|
||||
status = :status,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':expected_date' => $data['expected_date'] ?: null,
|
||||
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse_id' => $warehouse_id,
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':status' => $new_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$this->syncPoItems($id, $items);
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$log = [$logging];
|
||||
$source = trim((string)($data['source'] ?? ''));
|
||||
$source_id = (int)($data['source_id'] ?? 0);
|
||||
$init_status = $source !== '' ? -2 : 0;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_order
|
||||
(company_id, uuid, po_number, contact_id, po_date, expected_date,
|
||||
warehouse_id, status, payment_status, subtotal, discount, tax,
|
||||
shipping_fee, grand_total, items, notes, `log`, created_at)
|
||||
(company_id, uuid, source_id, `source`, po_number, contact_id, department_id, po_date, expected_date,
|
||||
warehouse_id, status, subtotal, discount, tax, tax_adjustment,
|
||||
shipping_fee, grand_total, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :po_number, :contact_id, :po_date, :expected_date,
|
||||
:warehouse_id, 0, 0, :subtotal, :discount, :tax,
|
||||
:shipping_fee, :grand_total, :items, :notes, :log, :created_at)"
|
||||
(:company_id, :uuid, :source_id, :source, :po_number, :contact_id, :department_id, :po_date, :expected_date,
|
||||
:warehouse_id, :status, :subtotal, :discount, :tax, :tax_adjustment,
|
||||
:shipping_fee, :grand_total, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':source_id' => $source_id,
|
||||
':source' => $source,
|
||||
':po_number' => $this->generatePoNumber(),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':po_date' => $data['po_date'] ?? date('Y-m-d'),
|
||||
':expected_date' => $data['expected_date'] ?: null,
|
||||
':warehouse_id' => (int)($data['warehouse_id'] ?? 0),
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
':status' => $init_status,
|
||||
':subtotal' => $subtotal,
|
||||
':discount' => $discount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':shipping_fee' => $shipping_fee,
|
||||
':grand_total' => $grand_total,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
$new_id = (int)$this->pdo->lastInsertId();
|
||||
$this->syncPoItems($new_id, $items);
|
||||
return $new_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,14 +531,26 @@ class PurchaseOrderManager {
|
||||
$status = (int)$po['status'];
|
||||
if ($status === -1) throw new Exception("Cannot receive against a cancelled PO.");
|
||||
if ($status === 0) throw new Exception("Confirm the PO before receiving goods.");
|
||||
if ($status === 3) throw new Exception("This PO is already fully received.");
|
||||
if (!empty($po['closed_at'])) throw new Exception("This PO has been closed — no further receipts are allowed.");
|
||||
|
||||
// Load PO items from normalized table
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order_item
|
||||
WHERE order_id = :po_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
$po_items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$all_done = !empty($po_items) && array_reduce($po_items, fn($ok, $item) =>
|
||||
$ok && (float)($item['received_qty'] ?? 0) >= (float)($item['quantity'] ?? 0), true
|
||||
);
|
||||
if ($all_done) throw new Exception("This PO is already fully received.");
|
||||
|
||||
if (empty($receive_items)) {
|
||||
throw new Exception("No items provided to receive.");
|
||||
}
|
||||
|
||||
$po_items = json_decode($po['items'] ?? '[]', true) ?: [];
|
||||
|
||||
// Index PO items by item_id for quick lookup
|
||||
$po_items_by_id = [];
|
||||
foreach ($po_items as $i => $item) {
|
||||
@@ -352,6 +570,20 @@ class PurchaseOrderManager {
|
||||
if (!$product_sku) throw new Exception("Item #{$j}: missing product_sku.");
|
||||
if (!$warehouse_id) throw new Exception("Item #{$j}: missing warehouse_id.");
|
||||
|
||||
// Cap against PO quantity — prevent over-receiving
|
||||
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
|
||||
$idx = $po_items_by_id[$item_id];
|
||||
$already_received = (float)($po_items[$idx]['received_qty'] ?? 0);
|
||||
$max_receivable = (float)($po_items[$idx]['quantity'] ?? 0) - $already_received;
|
||||
if ($quantity - $max_receivable > 0.000001) {
|
||||
$name = $po_items[$idx]['product_name'] ?? $product_sku;
|
||||
throw new Exception(
|
||||
"Cannot receive {$quantity} for \"{$name}\": " .
|
||||
"only {$max_receivable} unit(s) remaining on this PO."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$item_uuid = $uuid . '_' . $j;
|
||||
$item_log = array_merge($logging, ['action' => 'receive_item']);
|
||||
|
||||
@@ -414,43 +646,34 @@ class PurchaseOrderManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write stock_in_id + received qty back into PO item
|
||||
// Write stock_in_id + received qty back into td_purchase_order_item
|
||||
if ($item_id >= 0 && isset($po_items_by_id[$item_id])) {
|
||||
$idx = $po_items_by_id[$item_id];
|
||||
$already_received = (float)($po_items[$idx]['received_qty'] ?? 0);
|
||||
$po_items[$idx]['received_qty'] = $already_received + $quantity;
|
||||
$po_items[$idx]['stock_in_id'] = $new_stock_in_id;
|
||||
$po_items[$idx]['receive_wh'] = $warehouse_id;
|
||||
$po_items[$idx]['receive_zone'] = $zone;
|
||||
$po_items[$idx]['receive_aisle'] = $aisle;
|
||||
$po_items[$idx]['receive_rack'] = $rack;
|
||||
}
|
||||
}
|
||||
$new_received_qty = $already_received + $quantity;
|
||||
|
||||
// Determine new PO status
|
||||
$all_received = true;
|
||||
foreach ($po_items as $item) {
|
||||
$ordered = (float)($item['quantity'] ?? 0);
|
||||
$received = (float)($item['received_qty'] ?? 0);
|
||||
if ($received < $ordered) {
|
||||
$all_received = false;
|
||||
break;
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order_item SET
|
||||
stock_in_id = :stock_in_id,
|
||||
received_qty = :received_qty
|
||||
WHERE order_id = :po_id AND item_id = :item_id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':stock_in_id' => $new_stock_in_id,
|
||||
':received_qty' => $new_received_qty,
|
||||
':po_id' => $po_id,
|
||||
':item_id' => $item_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
$new_status = $all_received ? 3 : 2;
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'receive', 'new_status' => $new_status]);
|
||||
$log[] = array_merge($logging, ['action' => 'receive']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
status = :status,
|
||||
items = :items,
|
||||
`log` = :log
|
||||
"UPDATE td_purchase_order SET `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':items' => json_encode($po_items, JSON_UNESCAPED_UNICODE),
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -458,43 +681,40 @@ class PurchaseOrderManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update payment status on a PO.
|
||||
* Allowed for status >= 1 (confirmed, partial, completed).
|
||||
* Close a confirmed PO — sets closed_at to prevent further receipts.
|
||||
*
|
||||
* @param int $po_id td_purchase_order.id
|
||||
* @param int $payment_status 0=unpaid, 1=paid, 2=partial
|
||||
* @param array $logging Audit entry.
|
||||
* Use when the supplier will send no more goods (short-shipped, agreement closed).
|
||||
* Does not affect receipt_status — partial receipt is preserved.
|
||||
* Blocked if PO is cancelled or not yet confirmed.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function updatePaymentStatus(int $po_id, int $payment_status, array $logging): void
|
||||
public function closePo(int $po_id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_purchase_order
|
||||
"SELECT status, closed_at, `log` FROM td_purchase_order
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $po_id]);
|
||||
$po = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$po) throw new Exception("Purchase order not found.");
|
||||
if ((int)$po['status'] < 1) throw new Exception("Confirm the PO before updating payment.");
|
||||
if ((int)$po['status'] === -1) throw new Exception("Cannot update a cancelled PO.");
|
||||
|
||||
if (!in_array($payment_status, [0, 1, 2], true)) {
|
||||
throw new Exception("Invalid payment status.");
|
||||
}
|
||||
if ((int)$po['status'] === -1) throw new Exception("Cannot close a cancelled PO.");
|
||||
if ((int)$po['status'] === 0) throw new Exception("Confirm the PO before closing it.");
|
||||
if (!empty($po['closed_at'])) throw new Exception("This PO is already closed.");
|
||||
|
||||
$log = json_decode($po['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'update_payment', 'payment_status' => $payment_status]);
|
||||
$log[] = array_merge($logging, ['action' => 'close']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_order SET
|
||||
payment_status = :payment_status,
|
||||
`log` = :log
|
||||
closed_at = NOW(),
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':payment_status' => $payment_status,
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
':log' => json_encode($log),
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -521,7 +741,6 @@ class PurchaseOrderManager {
|
||||
|
||||
$status = (int)$po['status'];
|
||||
if ($status === -1) throw new Exception("PO is already cancelled.");
|
||||
if ($status === 3) throw new Exception("Cannot cancel a completed PO.");
|
||||
|
||||
// Guard: block if any approved stock-in rows exist for this PO
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -600,5 +819,36 @@ class PurchaseOrderManager {
|
||||
':id' => $po_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// Release converted_qty on the originating purchase request items
|
||||
$source = (string)($po['source'] ?? '');
|
||||
$source_id = (int)($po['source_id'] ?? 0);
|
||||
if ($source === 'purchase_request' && $source_id > 0) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT item_id, quantity FROM td_purchase_order_item
|
||||
WHERE order_id = :po_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':po_id' => $po_id, ':company_id' => $this->company_id]);
|
||||
$po_items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
$dec = $this->pdo->prepare(
|
||||
"UPDATE td_purchase_request_item
|
||||
SET converted_qty = GREATEST(0, converted_qty - :qty)
|
||||
WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($po_items as $item) {
|
||||
$dec->execute([
|
||||
':qty' => (float)($item['quantity'] ?? 0),
|
||||
':rid' => $source_id,
|
||||
':item_id' => (int)($item['item_id'] ?? 0),
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
// Revert PR from fully-converted (status=5) back to Approved (status=2)
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET status = 2
|
||||
WHERE id = :id AND company_id = :cid AND status = 5"
|
||||
)->execute([':id' => $source_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* PurchaseRequestManager
|
||||
*
|
||||
* All read/write operations for td_purchase_request and td_purchase_request_item.
|
||||
*
|
||||
* Status flow: 0=draft → 1=submitted → 2=approved → 5=converted
|
||||
* 1/2/3 ← reopen 3=rejected
|
||||
* 0/1 → -1=cancelled
|
||||
*
|
||||
* Tax: tax_amount is stored per line at full precision (decimal 18,4).
|
||||
* document.tax = ROUND(SUM(items.tax_amount), 2) — never passed from caller.
|
||||
*
|
||||
* Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*/
|
||||
class PurchaseRequestManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateNumber(): string
|
||||
{
|
||||
$prefix = 'PR-' . date('Ym') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT request_number FROM td_purchase_request
|
||||
WHERE company_id = :cid AND request_number LIKE :prefix
|
||||
ORDER BY request_number DESC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive document-level totals from line items.
|
||||
* tax = ROUND(SUM(items.tax_amount), 2) — never accepted from caller.
|
||||
*/
|
||||
private function computeTotals(array $items, float $discount, float $shipping_fee): array
|
||||
{
|
||||
$subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items));
|
||||
$tax = round(array_sum(array_map(fn($i) => (float)($i['tax_amount'] ?? 0), $items)), 2);
|
||||
$grand = max(0, $subtotal - $discount + $tax + $shipping_fee);
|
||||
return [round($subtotal, 4), $tax, round($grand, 4)];
|
||||
}
|
||||
|
||||
/** DELETE + INSERT all line items. converted_qty is always reset to 0 on save. */
|
||||
private function syncItems(int $request_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_purchase_request_item
|
||||
WHERE request_id = :rid AND company_id = :cid"
|
||||
)->execute([':rid' => $request_id, ':cid' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_request_item
|
||||
(company_id, request_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty)
|
||||
VALUES
|
||||
(:company_id, :request_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, 0)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':request_id' => $request_id,
|
||||
':item_id' => $pos + 1,
|
||||
':product_sku' => $item['product_sku'] ?? '',
|
||||
':product_name'=> $item['product_name'] ?? '',
|
||||
':quantity' => (float)($item['quantity'] ?? 0),
|
||||
':unit_price' => (float)($item['unit_price'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name,
|
||||
(SELECT COUNT(*) FROM td_purchase_order p
|
||||
WHERE p.company_id = r.company_id
|
||||
AND p.source = 'purchase_request'
|
||||
AND p.source_id = r.id
|
||||
AND p.status != -1) AS linked_po_count
|
||||
FROM td_purchase_request r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
WHERE r.company_id = :cid
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_purchase_request r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
WHERE r.company_id = :cid AND r.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) return false;
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT *, (quantity - converted_qty) AS remaining_qty
|
||||
FROM td_purchase_request_item
|
||||
WHERE request_id = :rid AND company_id = :cid
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':rid' => $id, ':cid' => $this->company_id]);
|
||||
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
$row['total_remaining'] = array_sum(array_column($row['items'], 'remaining_qty'));
|
||||
|
||||
$sth3 = $this->pdo->prepare(
|
||||
"SELECT id, po_number, status, grand_total, created_at
|
||||
FROM td_purchase_order
|
||||
WHERE company_id = :cid
|
||||
AND source = 'purchase_request'
|
||||
AND source_id = :rid
|
||||
AND status != -1
|
||||
ORDER BY id"
|
||||
);
|
||||
$sth3->execute([':cid' => $this->company_id, ':rid' => $id]);
|
||||
$row['linked_pos'] = $sth3->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create (id=0) or update an existing draft purchase request.
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, department_id, request_date,
|
||||
* required_date, items (array), discount,
|
||||
* shipping_fee, notes.
|
||||
* Each item must include tax_amount.
|
||||
* @return int The purchase request id (new or existing).
|
||||
*/
|
||||
public function save(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
$shipping_fee = (float)($data['shipping_fee'] ?? 0);
|
||||
|
||||
if (empty($items)) throw new Exception('At least one item is required.');
|
||||
|
||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount, $shipping_fee);
|
||||
|
||||
if ($id === 0) {
|
||||
$number = $this->generateNumber();
|
||||
$log = [array_merge($logging, ['action' => 'create_purchase_request'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_purchase_request
|
||||
(company_id, uuid, request_number, contact_id, department_id,
|
||||
request_date, required_date, status,
|
||||
subtotal, discount, tax, shipping_fee, grand_total,
|
||||
notes, `log`, created_at)
|
||||
VALUES
|
||||
(:cid, :uuid, :num, :contact_id, :department_id,
|
||||
:request_date, :required_date, 0,
|
||||
:sub, :disc, :tax, :ship, :grand,
|
||||
:notes, :log, NOW())"
|
||||
)->execute([
|
||||
':cid' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':num' => $number,
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':request_date' => $data['request_date'] ?: null,
|
||||
':required_date' => $data['required_date'] ?: null,
|
||||
':sub' => $subtotal,
|
||||
':disc' => $discount,
|
||||
':tax' => $tax,
|
||||
':ship' => $shipping_fee,
|
||||
':grand' => $grand,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
]);
|
||||
|
||||
$id = (int)$this->pdo->lastInsertId();
|
||||
$this->syncItems($id, $items);
|
||||
return $id;
|
||||
}
|
||||
|
||||
// Update — only allowed on draft (status = 0)
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_purchase_request
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Purchase request not found.');
|
||||
if ((int)$row['status'] !== 0) throw new Exception('Only draft purchase requests can be edited.');
|
||||
|
||||
$log = json_decode($row['log'] ?: '[]', true);
|
||||
$log[] = array_merge($logging, ['action' => 'update_purchase_request']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
request_date = :request_date,
|
||||
required_date = :required_date,
|
||||
subtotal = :sub,
|
||||
discount = :disc,
|
||||
tax = :tax,
|
||||
shipping_fee = :ship,
|
||||
grand_total = :grand,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':request_date' => $data['request_date'] ?: null,
|
||||
':required_date' => $data['required_date'] ?: null,
|
||||
':sub' => $subtotal,
|
||||
':disc' => $discount,
|
||||
':tax' => $tax,
|
||||
':ship' => $shipping_fee,
|
||||
':grand' => $grand,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':cid' => $this->company_id,
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
$this->syncItems($id, $items);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition purchase request status.
|
||||
* Valid actions: submit, approve, reject, reopen, cancel.
|
||||
*/
|
||||
public function updateStatus(int $id, string $action, array $logging): void
|
||||
{
|
||||
$transitions = [
|
||||
'submit' => ['from' => [0], 'to' => 1, 'label' => 'Submitted'],
|
||||
'approve' => ['from' => [1], 'to' => 2, 'label' => 'Approved'],
|
||||
'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'],
|
||||
'reopen' => ['from' => [1, 2, 3], 'to' => 0, 'label' => 'Draft'],
|
||||
'cancel' => ['from' => [0, 1], 'to' => -1, 'label' => 'Cancelled'],
|
||||
];
|
||||
|
||||
if (!isset($transitions[$action])) throw new Exception('Invalid action.');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_purchase_request
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) throw new Exception('Purchase request not found.');
|
||||
|
||||
$t = $transitions[$action];
|
||||
if (!in_array((int)$row['status'], $t['from'], true)) {
|
||||
throw new Exception('Transition not allowed from current status.');
|
||||
}
|
||||
|
||||
if ($action === 'reopen') {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_purchase_request_item
|
||||
WHERE request_id = :rid AND company_id = :cid AND converted_qty > 0"
|
||||
);
|
||||
$sth2->execute([':rid' => $id, ':cid' => $this->company_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot reopen — one or more items have already been converted to a purchase order.');
|
||||
}
|
||||
}
|
||||
|
||||
$log = json_decode($row['log'] ?: '[]', true);
|
||||
$log[] = array_merge($logging, ['action' => 'purchase_request_' . $action]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET status = :status, `log` = :log
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([
|
||||
':status' => $t['to'],
|
||||
':log' => json_encode($log),
|
||||
':cid' => $this->company_id,
|
||||
':id' => $id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment converted_qty per item after a successful PO conversion.
|
||||
* Automatically sets PR status = 5 when all items are fully converted.
|
||||
*
|
||||
* @param array $validated Each entry: ['item_id' => int, 'quantity' => float]
|
||||
*/
|
||||
public function incrementConvertedQty(int $request_id, array $validated): void
|
||||
{
|
||||
$upd = $this->pdo->prepare(
|
||||
"UPDATE td_purchase_request_item
|
||||
SET converted_qty = converted_qty + :qty
|
||||
WHERE request_id = :rid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($validated as $v) {
|
||||
$upd->execute([
|
||||
':qty' => $v['quantity'],
|
||||
':rid' => $request_id,
|
||||
':item_id' => $v['item_id'],
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_purchase_request_item
|
||||
WHERE request_id = :rid AND company_id = :cid
|
||||
AND converted_qty < quantity - 0.000001"
|
||||
);
|
||||
$sth->execute([':rid' => $request_id, ':cid' => $this->company_id]);
|
||||
if ((int)$sth->fetchColumn() === 0) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_purchase_request SET status = 5
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $request_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* QuotationManager
|
||||
*
|
||||
* All read/write operations for td_quotation and td_quotation_item.
|
||||
*
|
||||
* Status flow: 0=draft → 1=sent → 2=accepted → 5=converted
|
||||
* 1/2/3 ← reopen 3=rejected
|
||||
* 0/1 → -1=cancelled
|
||||
*
|
||||
* Tax: tax_amount is stored per line at full precision (decimal 18,4).
|
||||
* document.tax = ROUND(SUM(items.tax_amount), 2) — never passed from caller.
|
||||
*
|
||||
* Write methods do NOT manage their own DB transactions.
|
||||
* Callers must wrap multi-step operations inside dbTransaction().
|
||||
*/
|
||||
class QuotationManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateNumber(): string
|
||||
{
|
||||
$prefix = 'QT-' . date('Ym') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT quotation_number FROM td_quotation
|
||||
WHERE company_id = :cid AND quotation_number LIKE :prefix
|
||||
ORDER BY id DESC LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive document-level totals from line items.
|
||||
* tax = ROUND(SUM(items.tax_amount), 2) — never accepted from caller.
|
||||
*/
|
||||
private function computeTotals(array $items, float $discount): array
|
||||
{
|
||||
$subtotal = array_sum(array_map(fn($i) => (float)($i['total_price'] ?? 0), $items));
|
||||
$tax = round(array_sum(array_map(fn($i) => (float)($i['tax_amount'] ?? 0), $items)), 2);
|
||||
$grand = max(0, $subtotal - $discount + $tax);
|
||||
return [round($subtotal, 4), $tax, round($grand, 4)];
|
||||
}
|
||||
|
||||
/** DELETE + INSERT all line items. converted_qty is always preserved or reset to 0 on create. */
|
||||
private function syncItems(int $quotation_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_quotation_item
|
||||
WHERE quotation_id = :qid AND company_id = :cid"
|
||||
)->execute([':qid' => $quotation_id, ':cid' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_quotation_item
|
||||
(company_id, quotation_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, converted_qty)
|
||||
VALUES
|
||||
(:company_id, :quotation_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, 0)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':quotation_id' => $quotation_id,
|
||||
':item_id' => $pos + 1,
|
||||
':product_sku' => $item['product_sku'] ?? '',
|
||||
':product_name' => $item['product_name'] ?? '',
|
||||
':quantity' => (float)($item['quantity'] ?? 0),
|
||||
':unit_price' => (float)($item['unit_price'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_quotation q
|
||||
LEFT JOIN md_contact c
|
||||
ON c.id = q.contact_id AND c.company_id = q.company_id
|
||||
WHERE q.company_id = :cid
|
||||
ORDER BY q.id DESC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 0) AS draft,
|
||||
SUM(status = 1) AS sent,
|
||||
SUM(status = 2) AS accepted,
|
||||
SUM(status = 3) AS rejected
|
||||
FROM td_quotation
|
||||
WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->company_id]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function getById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT q.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_quotation q
|
||||
LEFT JOIN md_contact c
|
||||
ON c.id = q.contact_id AND c.company_id = q.company_id
|
||||
WHERE q.id = :id AND q.company_id = :cid
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':id' => $id, ':cid' => $this->company_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row) return false;
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT *, (quantity - converted_qty) AS remaining_qty
|
||||
FROM td_quotation_item
|
||||
WHERE quotation_id = :qid AND company_id = :cid
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':qid' => $id, ':cid' => $this->company_id]);
|
||||
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
$row['total_remaining'] = array_sum(array_column($row['items'], 'remaining_qty'));
|
||||
|
||||
$sth3 = $this->pdo->prepare(
|
||||
"SELECT id, order_number, status, grand_total, created_at
|
||||
FROM td_order
|
||||
WHERE company_id = :cid
|
||||
AND source = 'quotation'
|
||||
AND source_id = :qid
|
||||
AND status != -1
|
||||
ORDER BY id"
|
||||
);
|
||||
$sth3->execute([':cid' => $this->company_id, ':qid' => $id]);
|
||||
$row['linked_orders'] = $sth3->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create (id=0) or update an existing draft quotation.
|
||||
*
|
||||
* @param array $data Keys: id, contact_id, department_id, quotation_date,
|
||||
* valid_until, items (array), discount, notes.
|
||||
* Each item must include tax_amount.
|
||||
* @return int The quotation id (new or existing).
|
||||
*/
|
||||
public function save(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
$discount = (float)($data['discount'] ?? 0);
|
||||
|
||||
[$subtotal, $tax, $grand] = $this->computeTotals($items, $discount);
|
||||
|
||||
if ($id === 0) {
|
||||
$number = $this->generateNumber();
|
||||
$log = [array_merge($logging, ['action' => 'create_quotation'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_quotation
|
||||
(company_id, uuid, quotation_number, contact_id, department_id,
|
||||
quotation_date, valid_until, status,
|
||||
subtotal, discount, tax, grand_total, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:cid, :uuid, :num, :contact_id, :department_id,
|
||||
:qdate, :valid_until, 0,
|
||||
:sub, :disc, :tax, :grand, :notes, :log, NOW())"
|
||||
)->execute([
|
||||
':cid' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':num' => $number,
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':qdate' => $data['quotation_date'] ?: null,
|
||||
':valid_until' => $data['valid_until'] ?: null,
|
||||
':sub' => $subtotal,
|
||||
':disc' => $discount,
|
||||
':tax' => $tax,
|
||||
':grand' => $grand,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
]);
|
||||
|
||||
$id = (int)$this->pdo->lastInsertId();
|
||||
$this->syncItems($id, $items);
|
||||
return $id;
|
||||
}
|
||||
|
||||
// Update — only allowed on draft (status = 0)
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_quotation
|
||||
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('Quotation not found.');
|
||||
if ((int)$row['status'] !== 0) throw new Exception('Only draft quotations can be edited.');
|
||||
|
||||
$log = json_decode($row['log'] ?: '[]', true);
|
||||
$log[] = array_merge($logging, ['action' => 'update_quotation']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
quotation_date = :qdate,
|
||||
valid_until = :valid_until,
|
||||
subtotal = :sub,
|
||||
discount = :disc,
|
||||
tax = :tax,
|
||||
grand_total = :grand,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :cid"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':qdate' => $data['quotation_date'] ?: null,
|
||||
':valid_until' => $data['valid_until'] ?: null,
|
||||
':sub' => $subtotal,
|
||||
':disc' => $discount,
|
||||
':tax' => $tax,
|
||||
':grand' => $grand,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
|
||||
$this->syncItems($id, $items);
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition quotation status.
|
||||
* Valid actions: send, accept, reject, reopen, cancel.
|
||||
*/
|
||||
public function updateStatus(int $id, string $action, array $logging): void
|
||||
{
|
||||
$transitions = [
|
||||
'send' => ['from' => [0], 'to' => 1, 'label' => 'Sent'],
|
||||
'accept' => ['from' => [1], 'to' => 2, 'label' => 'Accepted'],
|
||||
'reject' => ['from' => [1], 'to' => 3, 'label' => 'Rejected'],
|
||||
'reopen' => ['from' => [1, 2, 3], 'to' => 0, 'label' => 'Draft'],
|
||||
'cancel' => ['from' => [0, 1], 'to' => -1, 'label' => 'Cancelled'],
|
||||
];
|
||||
|
||||
if (!isset($transitions[$action])) throw new Exception('Invalid action.');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM td_quotation 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('Quotation not found.');
|
||||
|
||||
$t = $transitions[$action];
|
||||
if (!in_array((int)$row['status'], $t['from'], true)) {
|
||||
throw new Exception('Transition not allowed from current status.');
|
||||
}
|
||||
|
||||
if ($action === 'reopen') {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_quotation_item
|
||||
WHERE quotation_id = :qid AND company_id = :cid AND converted_qty > 0"
|
||||
);
|
||||
$sth2->execute([':qid' => $id, ':cid' => $this->company_id]);
|
||||
if ((int)$sth2->fetchColumn() > 0) {
|
||||
throw new Exception('Cannot reopen — one or more items have already been converted to an order.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET status = :status WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':status' => $t['to'], ':id' => $id, ':cid' => $this->company_id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment converted_qty per item after a successful order conversion.
|
||||
* Automatically sets quotation status = 5 when all items are fully converted.
|
||||
*
|
||||
* @param array $validated Each entry: ['item_id' => int, 'quantity' => float]
|
||||
*/
|
||||
public function incrementConvertedQty(int $quotation_id, array $validated): void
|
||||
{
|
||||
$upd = $this->pdo->prepare(
|
||||
"UPDATE td_quotation_item
|
||||
SET converted_qty = converted_qty + :qty
|
||||
WHERE quotation_id = :qid AND item_id = :item_id AND company_id = :cid"
|
||||
);
|
||||
foreach ($validated as $v) {
|
||||
$upd->execute([
|
||||
':qty' => $v['quantity'],
|
||||
':qid' => $quotation_id,
|
||||
':item_id' => $v['item_id'],
|
||||
':cid' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_quotation_item
|
||||
WHERE quotation_id = :qid AND company_id = :cid
|
||||
AND converted_qty < quantity - 0.000001"
|
||||
);
|
||||
$sth->execute([':qid' => $quotation_id, ':cid' => $this->company_id]);
|
||||
if ((int)$sth->fetchColumn() === 0) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_quotation SET status = 5 WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $quotation_id, ':cid' => $this->company_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
<?php
|
||||
|
||||
class ReceiptBillingManager {
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateBillingNumber(): string
|
||||
{
|
||||
$prefix = 'RB-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT billing_number FROM td_receipt_billing
|
||||
WHERE company_id = :company_id
|
||||
AND billing_number LIKE :prefix
|
||||
ORDER BY billing_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function unavailableDocumentAmount(int $invoice_id): float
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(billed.billed_amount, 0) + COALESCE(direct.received_amount, 0)
|
||||
FROM (
|
||||
SELECT :company_id AS company_id, :invoice_id AS invoice_id
|
||||
) doc
|
||||
LEFT JOIN (
|
||||
SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount
|
||||
FROM td_receipt_billing_item ba
|
||||
JOIN td_receipt_billing b
|
||||
ON b.company_id = ba.company_id
|
||||
AND b.id = ba.billing_id
|
||||
WHERE ba.company_id = :company_id_billed
|
||||
AND ba.invoice_id = :invoice_id_billed
|
||||
AND b.status IN (1, 2, 3)
|
||||
GROUP BY ba.company_id, ba.invoice_id
|
||||
) billed
|
||||
ON billed.company_id = doc.company_id
|
||||
AND billed.invoice_id = doc.invoice_id
|
||||
LEFT JOIN (
|
||||
SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS received_amount
|
||||
FROM td_receipt_item ra
|
||||
JOIN td_receipt r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.receipt_id
|
||||
WHERE ra.company_id = :company_id_direct
|
||||
AND ra.invoice_id = :invoice_id_direct
|
||||
AND r.status = 1
|
||||
AND r.receipt_billing_id = 0
|
||||
GROUP BY ra.company_id, ra.invoice_id
|
||||
) direct
|
||||
ON direct.company_id = doc.company_id
|
||||
AND direct.invoice_id = doc.invoice_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':invoice_id' => $invoice_id,
|
||||
':company_id_billed' => $this->company_id,
|
||||
':invoice_id_billed' => $invoice_id,
|
||||
':company_id_direct' => $this->company_id,
|
||||
':invoice_id_direct' => $invoice_id,
|
||||
]);
|
||||
return (float)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getBillableDocuments(int $contact_id = 0): array
|
||||
{
|
||||
$where = [
|
||||
"i.company_id = :company_id",
|
||||
"i.doc_type IN ('invoice', 'credit_note')",
|
||||
"i.status = 1"
|
||||
];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
$where[] = 'i.contact_id = :contact_id';
|
||||
$params[':contact_id'] = $contact_id;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number,
|
||||
COALESCE(received.received_amount, 0) AS received_amount,
|
||||
COALESCE(billed.billed_amount, 0) AS billed_amount
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
LEFT JOIN (
|
||||
SELECT ra.company_id, ra.invoice_id, SUM(ra.amount) AS received_amount
|
||||
FROM td_receipt_item ra
|
||||
JOIN td_receipt r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.receipt_id
|
||||
WHERE r.status = 1
|
||||
AND r.receipt_billing_id = 0
|
||||
GROUP BY ra.company_id, ra.invoice_id
|
||||
) received
|
||||
ON received.company_id = i.company_id
|
||||
AND received.invoice_id = i.id
|
||||
LEFT JOIN (
|
||||
SELECT ba.company_id, ba.invoice_id, SUM(ba.amount) AS billed_amount
|
||||
FROM td_receipt_billing_item ba
|
||||
JOIN td_receipt_billing b
|
||||
ON b.company_id = ba.company_id
|
||||
AND b.id = ba.billing_id
|
||||
WHERE b.status IN (1, 2, 3)
|
||||
GROUP BY ba.company_id, ba.invoice_id
|
||||
) billed
|
||||
ON billed.company_id = i.company_id
|
||||
AND billed.invoice_id = i.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.issued_date ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
|
||||
$open = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$total = abs((float)$row['grand_total']);
|
||||
$used = (float)$row['received_amount'] + (float)$row['billed_amount'];
|
||||
$balance = max(0, $total - $used);
|
||||
if ($balance <= 0.0001) continue;
|
||||
$row['document_total'] = $total;
|
||||
$row['balance_amount'] = $balance;
|
||||
$open[] = $row;
|
||||
}
|
||||
|
||||
return $open;
|
||||
}
|
||||
|
||||
public function getBillingList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount
|
||||
FROM td_receipt_billing b
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = b.company_id
|
||||
AND c.id = b.contact_id
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.receipt_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
GROUP BY b.id
|
||||
ORDER BY b.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['balance_amount'] = max(0, (float)$row['amount'] - (float)$row['received_amount']);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getOpenBillingList(int $contact_id = 0): array
|
||||
{
|
||||
$rows = $this->getBillingList();
|
||||
return array_values(array_filter($rows, function($row) use ($contact_id) {
|
||||
if ((int)$row['status'] === 4) return false;
|
||||
if ((int)$row['contact_id'] !== $contact_id) return false;
|
||||
return (float)$row['balance_amount'] > 0.0001;
|
||||
}));
|
||||
}
|
||||
|
||||
public function getBillingById(int $billing_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount
|
||||
FROM td_receipt_billing b
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = b.company_id
|
||||
AND c.id = b.contact_id
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.receipt_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing) throw new Exception("Receipt billing not found.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*,
|
||||
i.invoice_number,
|
||||
i.order_id,
|
||||
i.department_id,
|
||||
i.grand_total,
|
||||
i.status AS invoice_status,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS received_line_amount,
|
||||
o.order_number
|
||||
FROM td_receipt_billing_item a
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.id = a.invoice_id
|
||||
LEFT JOIN td_receipt_item ra
|
||||
ON ra.company_id = a.company_id
|
||||
AND ra.invoice_id = a.invoice_id
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.receipt_id
|
||||
AND r.receipt_billing_id = a.billing_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.billing_id = :billing_id
|
||||
GROUP BY a.id
|
||||
ORDER BY a.id ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]);
|
||||
$allocations = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($allocations as &$line) {
|
||||
$line['balance_amount'] = max(0, (float)$line['amount'] - (float)$line['received_line_amount']);
|
||||
}
|
||||
$billing['allocations'] = $allocations;
|
||||
$billing['balance_amount'] = max(0, (float)$billing['amount'] - (float)$billing['received_amount']);
|
||||
|
||||
return $billing;
|
||||
}
|
||||
|
||||
public function refreshBillingStatus(int $billing_id): void
|
||||
{
|
||||
$billing = $this->getBillingById($billing_id);
|
||||
if ((int)$billing['status'] === 4) return;
|
||||
if ((float)$billing['balance_amount'] <= 0.0001) {
|
||||
$new_status = 2;
|
||||
} elseif ((float)$billing['received_amount'] > 0.0001) {
|
||||
$new_status = 3;
|
||||
} else {
|
||||
$new_status = 1;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt_billing
|
||||
SET status = :status
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createBilling(array $data, array $logging): int
|
||||
{
|
||||
$contact_id = (int)($data['contact_id'] ?? 0);
|
||||
$billing_date = $data['billing_date'] ?? date('Y-m-d');
|
||||
$notes = trim((string)($data['notes'] ?? ''));
|
||||
$allocations = $data['allocations'] ?? [];
|
||||
|
||||
if (is_string($allocations)) {
|
||||
$allocations = json_decode($allocations, true) ?: [];
|
||||
}
|
||||
|
||||
if ($contact_id <= 0) throw new Exception("Customer is required.");
|
||||
if (!$allocations) throw new Exception("Receipt billing requires at least one document.");
|
||||
|
||||
$validated = [];
|
||||
$invoice_total = 0.0;
|
||||
$credit_total = 0.0;
|
||||
|
||||
foreach ($allocations as $line) {
|
||||
$invoice_id = (int)($line['invoice_id'] ?? 0);
|
||||
$amount = (float)($line['amount'] ?? 0);
|
||||
if ($invoice_id <= 0 || $amount <= 0) continue;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, doc_type, invoice_number, contact_id, department_id, grand_total, status
|
||||
FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$doc) throw new Exception("Document not found.");
|
||||
if ((int)$doc['contact_id'] !== $contact_id) {
|
||||
throw new Exception("All billing documents must belong to the selected contact.");
|
||||
}
|
||||
if (!in_array($doc['doc_type'], ['invoice', 'credit_note'], true)) {
|
||||
throw new Exception("Receipt billing can only include invoices and credit notes.");
|
||||
}
|
||||
if ((int)$doc['status'] !== 1) {
|
||||
throw new Exception($doc['invoice_number'] . " is not open for billing.");
|
||||
}
|
||||
|
||||
$balance = abs((float)$doc['grand_total']) - $this->unavailableDocumentAmount($invoice_id);
|
||||
if ($amount - $balance > 0.0001) {
|
||||
throw new Exception($doc['invoice_number'] . " allocation exceeds remaining billable balance.");
|
||||
}
|
||||
|
||||
if ($doc['doc_type'] === 'credit_note') $credit_total += $amount;
|
||||
else $invoice_total += $amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => $invoice_id,
|
||||
'doc_type' => $doc['doc_type'],
|
||||
'department_id' => (int)($doc['department_id'] ?? 0),
|
||||
'amount' => round($amount, 4),
|
||||
];
|
||||
}
|
||||
|
||||
if (!$validated) throw new Exception("Receipt billing requires at least one valid document.");
|
||||
$net_amount = round($invoice_total - $credit_total, 4);
|
||||
if ($net_amount <= 0.0001) {
|
||||
throw new Exception("Receipt billing amount must be greater than zero.");
|
||||
}
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_receipt_billing'])];
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_receipt_billing
|
||||
(company_id, uuid, billing_number, contact_id, billing_date,
|
||||
amount, status, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :billing_number, :contact_id, :billing_date,
|
||||
:amount, 1, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':billing_number' => $this->generateBillingNumber(),
|
||||
':contact_id' => $contact_id,
|
||||
':billing_date' => $billing_date,
|
||||
':amount' => $net_amount,
|
||||
':notes' => $notes,
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$billing_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
foreach ($validated as $line) {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_receipt_billing_item
|
||||
(company_id, billing_id, invoice_id, doc_type, amount)
|
||||
VALUES
|
||||
(:company_id, :billing_id, :invoice_id, :doc_type, :amount)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':billing_id' => $billing_id,
|
||||
':invoice_id' => $line['invoice_id'],
|
||||
':doc_type' => $line['doc_type'],
|
||||
':amount' => $line['amount'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $billing_id;
|
||||
}
|
||||
|
||||
public function voidBilling(int $billing_id, array $logging): void
|
||||
{
|
||||
$billing = $this->getBillingById($billing_id);
|
||||
if ((int)$billing['status'] === 4) throw new Exception("Receipt billing is already void.");
|
||||
if ((float)$billing['received_amount'] > 0.0001) {
|
||||
throw new Exception("Cannot void a receipt billing with posted receipts.");
|
||||
}
|
||||
|
||||
$log = json_decode($billing['log'] ?: '[]', true);
|
||||
if (!is_array($log)) $log = [];
|
||||
$log[] = array_merge($logging, ['action' => 'void_receipt_billing']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt_billing
|
||||
SET status = 4, `log` = :log
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,515 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
require_once __DIR__ . '/../classes_ac/GlManager.php';
|
||||
|
||||
class ReceiptManager {
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
private function generateReceiptNumber(): string
|
||||
{
|
||||
$prefix = 'RC-' . date('Ymd') . '-';
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT receipt_number FROM td_receipt
|
||||
WHERE company_id = :company_id
|
||||
AND receipt_number LIKE :prefix
|
||||
ORDER BY receipt_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':prefix' => $prefix . '%',
|
||||
]);
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function assertPostingWindow(?string $date, string $context): void
|
||||
{
|
||||
global $pdo1;
|
||||
|
||||
if (!isset($pdo1) || !($pdo1 instanceof PDO)) {
|
||||
throw new Exception("Posting-window validation is unavailable.");
|
||||
}
|
||||
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($date ?: date('Y-m-d'), $context);
|
||||
}
|
||||
|
||||
private function allocatedAmount(int $invoice_id): float
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COALESCE(SUM(a.amount), 0)
|
||||
FROM td_receipt_item a
|
||||
JOIN td_receipt r
|
||||
ON r.company_id = a.company_id
|
||||
AND r.id = a.receipt_id
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.invoice_id = :invoice_id
|
||||
AND r.status = 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':invoice_id' => $invoice_id,
|
||||
]);
|
||||
return (float)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
private function refreshInvoiceSettlementStatus(int $invoice_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT grand_total, status FROM td_invoice
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $invoice_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$row || (int)$row['status'] === 4 || (int)$row['status'] === 0) return;
|
||||
|
||||
$target = abs((float)$row['grand_total']);
|
||||
$paid = $this->allocatedAmount($invoice_id);
|
||||
$new_status = ($target > 0 && $paid + 0.0001 >= $target) ? 2 : 1;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_invoice SET status = :status
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $invoice_id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function refreshReceiptBillingStatus(int $billing_id): void
|
||||
{
|
||||
if ($billing_id <= 0) return;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.amount, b.status,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount
|
||||
FROM td_receipt_billing b
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.receipt_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing || (int)$billing['status'] === 4) return;
|
||||
|
||||
$amount = (float)$billing['amount'];
|
||||
$received = (float)$billing['received_amount'];
|
||||
if ($received + 0.0001 >= $amount) {
|
||||
$new_status = 2;
|
||||
} elseif ($received > 0.0001) {
|
||||
$new_status = 3;
|
||||
} else {
|
||||
$new_status = 1;
|
||||
}
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt_billing
|
||||
SET status = :status
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':status' => $new_status,
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $billing_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getOpenDocuments(int $contact_id = 0): array
|
||||
{
|
||||
$where = [
|
||||
"i.company_id = :company_id",
|
||||
"i.doc_type IN ('invoice', 'credit_note')",
|
||||
"i.status IN (1, 2)"
|
||||
];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($contact_id > 0) {
|
||||
$where[] = 'i.contact_id = :contact_id';
|
||||
$params[':contact_id'] = $contact_id;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
o.order_number,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN a.amount ELSE 0 END), 0) AS allocated_amount
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id
|
||||
AND c.id = i.contact_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
LEFT JOIN td_receipt_item a
|
||||
ON a.company_id = i.company_id
|
||||
AND a.invoice_id = i.id
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = a.company_id
|
||||
AND r.id = a.receipt_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
GROUP BY i.id
|
||||
ORDER BY i.issued_date ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$open = [];
|
||||
foreach ($rows as $row) {
|
||||
$total = abs((float)$row['grand_total']);
|
||||
$allocated = (float)$row['allocated_amount'];
|
||||
$balance = max(0, $total - $allocated);
|
||||
if ($balance <= 0.0001) continue;
|
||||
$row['document_total'] = $total;
|
||||
$row['balance_amount'] = $balance;
|
||||
$open[] = $row;
|
||||
}
|
||||
|
||||
return $open;
|
||||
}
|
||||
|
||||
public function getReceiptList(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(b.billing_number, '') AS billing_number
|
||||
FROM td_receipt r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_receipt_billing b
|
||||
ON b.company_id = r.company_id
|
||||
AND b.id = r.receipt_billing_id
|
||||
WHERE r.company_id = :company_id
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getReceiptById(int $receipt_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
COALESCE(b.billing_number, '') AS billing_number
|
||||
FROM td_receipt r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_receipt_billing b
|
||||
ON b.company_id = r.company_id
|
||||
AND b.id = r.receipt_billing_id
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $receipt_id,
|
||||
]);
|
||||
$receipt = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$receipt) throw new Exception("Receipt not found.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT a.*,
|
||||
i.invoice_number,
|
||||
i.order_id,
|
||||
i.grand_total,
|
||||
i.status AS invoice_status,
|
||||
o.order_number
|
||||
FROM td_receipt_item a
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.id = a.invoice_id
|
||||
LEFT JOIN td_order o
|
||||
ON o.company_id = i.company_id
|
||||
AND o.id = i.order_id
|
||||
WHERE a.company_id = :company_id
|
||||
AND a.receipt_id = :receipt_id
|
||||
ORDER BY a.id ASC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':receipt_id' => $receipt_id,
|
||||
]);
|
||||
|
||||
$receipt['allocations'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
return $receipt;
|
||||
}
|
||||
|
||||
public function voidReceipt(int $receipt_id, array $logging): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, receipt_billing_id, receipt_date, status, `log`
|
||||
FROM td_receipt
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $receipt_id,
|
||||
]);
|
||||
$receipt = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$receipt) throw new Exception("Receipt not found.");
|
||||
if ((int)$receipt['status'] === 4) throw new Exception("Receipt is already void.");
|
||||
$this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt');
|
||||
|
||||
$log = json_decode($receipt['log'] ?: '[]', true);
|
||||
if (!is_array($log)) $log = [];
|
||||
$log[] = array_merge($logging, ['action' => 'void_receipt']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_receipt
|
||||
SET status = 4, `log` = :log
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $receipt_id,
|
||||
]);
|
||||
|
||||
global $pdo1;
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$gl = new GlManager($this->pdo, $this->company_id, $guard);
|
||||
$gl->delete('receipt', $receipt_id);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT invoice_id
|
||||
FROM td_receipt_item
|
||||
WHERE company_id = :company_id
|
||||
AND receipt_id = :receipt_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':receipt_id' => $receipt_id,
|
||||
]);
|
||||
foreach ($sth->fetchAll(PDO::FETCH_COLUMN) as $invoice_id) {
|
||||
$this->refreshInvoiceSettlementStatus((int)$invoice_id);
|
||||
}
|
||||
$this->refreshReceiptBillingStatus((int)$receipt['receipt_billing_id']);
|
||||
}
|
||||
|
||||
public function createReceipt(array $data, array $logging): int
|
||||
{
|
||||
$billing_id = (int)($data['receipt_billing_id'] ?? $data['billing_id'] ?? 0);
|
||||
$receipt_date = $data['receipt_date'] ?? date('Y-m-d');
|
||||
$this->assertPostingWindow($receipt_date, 'Receipt');
|
||||
$payment_method = trim((string)($data['payment_method'] ?? ''));
|
||||
$notes = trim((string)($data['notes'] ?? ''));
|
||||
$cash_amount = round((float)($data['amount'] ?? 0), 4);
|
||||
$allocations = $data['allocations'] ?? [];
|
||||
|
||||
if (is_string($allocations)) {
|
||||
$allocations = json_decode($allocations, true) ?: [];
|
||||
}
|
||||
|
||||
if ($billing_id <= 0) throw new Exception("Receipt billing is required.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT b.*,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN r.amount ELSE 0 END), 0) AS received_amount
|
||||
FROM td_receipt_billing b
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = b.company_id
|
||||
AND r.receipt_billing_id = b.id
|
||||
WHERE b.company_id = :company_id
|
||||
AND b.id = :id
|
||||
GROUP BY b.id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $billing_id]);
|
||||
$billing = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$billing) throw new Exception("Receipt billing not found.");
|
||||
if ((int)$billing['status'] === 4) throw new Exception("Cannot receive against a void receipt billing.");
|
||||
|
||||
$contact_id = (int)$billing['contact_id'];
|
||||
$billing_balance = round((float)$billing['amount'] - (float)$billing['received_amount'], 4);
|
||||
if ($billing_balance <= 0.0001) throw new Exception("Receipt billing is already settled.");
|
||||
if ($cash_amount - $billing_balance > 0.0001) {
|
||||
throw new Exception("Receipt amount exceeds receipt billing balance.");
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ba.invoice_id, ba.doc_type, COALESCE(i.department_id, 0) AS department_id, ba.amount,
|
||||
COALESCE(SUM(CASE WHEN r.status = 1 THEN ra.amount ELSE 0 END), 0) AS received_line_amount
|
||||
FROM td_receipt_billing_item ba
|
||||
JOIN td_invoice i
|
||||
ON i.company_id = ba.company_id
|
||||
AND i.id = ba.invoice_id
|
||||
LEFT JOIN td_receipt_item ra
|
||||
ON ra.company_id = ba.company_id
|
||||
AND ra.invoice_id = ba.invoice_id
|
||||
LEFT JOIN td_receipt r
|
||||
ON r.company_id = ra.company_id
|
||||
AND r.id = ra.receipt_id
|
||||
AND r.receipt_billing_id = ba.billing_id
|
||||
WHERE ba.company_id = :company_id
|
||||
AND ba.billing_id = :billing_id
|
||||
GROUP BY ba.id
|
||||
ORDER BY ba.id ASC"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':billing_id' => $billing_id]);
|
||||
$billing_lines = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (!$billing_lines) throw new Exception("Receipt billing has no documents.");
|
||||
|
||||
$validated = [];
|
||||
$billing_line_map = [];
|
||||
foreach ($billing_lines as $line) {
|
||||
$billing_line_map[(int)$line['invoice_id']] = [
|
||||
'invoice_id' => (int)$line['invoice_id'],
|
||||
'doc_type' => $line['doc_type'],
|
||||
'department_id' => (int)($line['department_id'] ?? 0),
|
||||
'remaining' => max(0, (float)$line['amount'] - (float)$line['received_line_amount']),
|
||||
];
|
||||
}
|
||||
|
||||
if ($allocations) {
|
||||
$invoice_total = 0.0;
|
||||
$credit_total = 0.0;
|
||||
|
||||
foreach ($allocations as $line) {
|
||||
$invoice_id = (int)($line['invoice_id'] ?? 0);
|
||||
$amount = abs((float)($line['amount'] ?? 0));
|
||||
if ($invoice_id <= 0 || $amount <= 0) continue;
|
||||
if (!isset($billing_line_map[$invoice_id])) {
|
||||
throw new Exception("Receipt allocation document is not part of this billing.");
|
||||
}
|
||||
|
||||
$billing_line = $billing_line_map[$invoice_id];
|
||||
if ($amount - $billing_line['remaining'] > 0.0001) {
|
||||
throw new Exception("Receipt allocation exceeds billing line balance.");
|
||||
}
|
||||
|
||||
if ($billing_line['doc_type'] === 'credit_note') $credit_total += $amount;
|
||||
else $invoice_total += $amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => $invoice_id,
|
||||
'doc_type' => $billing_line['doc_type'],
|
||||
'department_id' => (int)($line['department_id'] ?? $billing_line['department_id'] ?? 0),
|
||||
'amount' => round($amount, 4),
|
||||
];
|
||||
}
|
||||
|
||||
$cash_amount = round($invoice_total - $credit_total, 4);
|
||||
if ($cash_amount <= 0) throw new Exception("Cash receipt amount must be greater than zero.");
|
||||
if ($cash_amount - $billing_balance > 0.0001) {
|
||||
throw new Exception("Receipt amount exceeds receipt billing balance.");
|
||||
}
|
||||
} else {
|
||||
if ($cash_amount <= 0) throw new Exception("Receipt amount is required.");
|
||||
|
||||
$ratio = $cash_amount / max($billing_balance, 0.0001);
|
||||
foreach ($billing_lines as $line) {
|
||||
$remaining_line_amount = max(0, (float)$line['amount'] - (float)$line['received_line_amount']);
|
||||
if ($remaining_line_amount <= 0.0001) continue;
|
||||
|
||||
$line_amount = round($remaining_line_amount * $ratio, 4);
|
||||
if ($line_amount <= 0) continue;
|
||||
if ($line_amount - $remaining_line_amount > 0.0001) $line_amount = $remaining_line_amount;
|
||||
|
||||
$validated[] = [
|
||||
'invoice_id' => (int)$line['invoice_id'],
|
||||
'doc_type' => $line['doc_type'],
|
||||
'department_id' => (int)($line['department_id'] ?? 0),
|
||||
'amount' => $line_amount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$validated) throw new Exception("Receipt billing has no remaining document balances.");
|
||||
|
||||
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
|
||||
? (int)$data['formula_id'] : null;
|
||||
|
||||
$log = [array_merge($logging, ['action' => 'create_receipt'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_receipt
|
||||
(company_id, uuid, receipt_number, contact_id, department_id, receipt_billing_id, receipt_date,
|
||||
payment_method, amount, formula_id, status, notes, `log`, created_at)
|
||||
VALUES
|
||||
(:company_id, :uuid, :receipt_number, :contact_id, :department_id, :receipt_billing_id, :receipt_date,
|
||||
:payment_method, :amount, :formula_id, 1, :notes, :log, :created_at)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':receipt_number' => $this->generateReceiptNumber(),
|
||||
':contact_id' => $contact_id,
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':receipt_billing_id' => $billing_id,
|
||||
':receipt_date' => $receipt_date,
|
||||
':payment_method' => $payment_method,
|
||||
':amount' => $cash_amount,
|
||||
':formula_id' => $formula_id,
|
||||
':notes' => $notes,
|
||||
':log' => json_encode($log),
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
$receipt_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
foreach ($validated as $line) {
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_receipt_item
|
||||
(company_id, receipt_id, invoice_id, doc_type, department_id, amount)
|
||||
VALUES
|
||||
(:company_id, :receipt_id, :invoice_id, :doc_type, :department_id, :amount)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':receipt_id' => $receipt_id,
|
||||
':invoice_id' => $line['invoice_id'],
|
||||
':doc_type' => $line['doc_type'],
|
||||
':department_id' => (int)($line['department_id'] ?? 0),
|
||||
':amount' => $line['amount'],
|
||||
]);
|
||||
|
||||
$this->refreshInvoiceSettlementStatus($line['invoice_id']);
|
||||
}
|
||||
|
||||
$this->refreshReceiptBillingStatus($billing_id);
|
||||
|
||||
return $receipt_id;
|
||||
}
|
||||
|
||||
public function saveFormula(int $id, ?int $formula_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT receipt_date, status FROM td_receipt
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
$receipt = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$receipt) throw new Exception("Receipt not found.");
|
||||
if ((int)$receipt['status'] === 4) throw new Exception("Cannot update a void receipt.");
|
||||
$this->assertPostingWindow($receipt['receipt_date'] ?: date('Y-m-d'), 'Receipt');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE td_receipt SET formula_id = :formula_id
|
||||
WHERE id = :id AND company_id = :company_id AND status != 4"
|
||||
);
|
||||
$sth->execute([
|
||||
':formula_id' => $formula_id,
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -307,6 +307,90 @@ class ReportManager
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function getDashboardStockTotals(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
ROUND(COALESCE(SUM(total_in), 0), 2) AS total_in,
|
||||
ROUND(COALESCE(SUM(total_out), 0), 2) AS total_out
|
||||
FROM warehouse_balance
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
|
||||
}
|
||||
|
||||
public function getDashboardOrderStats(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*), COALESCE(SUM(subtotal), 0)
|
||||
FROM td_order
|
||||
WHERE company_id = :company_id
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
[$total_orders, $total_revenue] = $sth->fetch(PDO::FETCH_NUM) ?: [0, 0];
|
||||
|
||||
return [
|
||||
'total_orders' => (int)$total_orders,
|
||||
'total_revenue' => (float)$total_revenue,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDashboardOpenDocumentCounts(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND doc_type = 'invoice'
|
||||
AND status IN (0, 1, 3)"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$unpaid_invoices = (int)$sth->fetchColumn();
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_return
|
||||
WHERE company_id = :company_id
|
||||
AND status = 0"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$pending_returns = (int)$sth->fetchColumn();
|
||||
|
||||
return [
|
||||
'unpaid_invoices' => $unpaid_invoices,
|
||||
'pending_returns' => $pending_returns,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDashboardStockHealth(): array
|
||||
{
|
||||
$low_stock_items = $this->getLowStockItems();
|
||||
$critical_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'critical'));
|
||||
$warning_count = count(array_filter($low_stock_items, fn($i) => $i['status'] === 'warning'));
|
||||
$total_in_stock = (int)$this->getTotalProductInStock();
|
||||
$healthy_count = max(0, $total_in_stock - $critical_count - $warning_count);
|
||||
|
||||
return [
|
||||
'low_stock' => count($low_stock_items),
|
||||
'stock_health' => [
|
||||
'healthy' => $healthy_count,
|
||||
'warning' => $warning_count,
|
||||
'critical' => $critical_count,
|
||||
'total' => $total_in_stock,
|
||||
],
|
||||
'low_stock_items' => $low_stock_items,
|
||||
];
|
||||
}
|
||||
|
||||
public function getDashboardMasterCounts(): array
|
||||
{
|
||||
return [
|
||||
'total_products' => (int)$this->getTotalProduct(),
|
||||
'total_contacts' => (int)$this->getTotalContact(),
|
||||
'total_warehouses' => (int)$this->getTotalWarehouse(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all SKUs at or below their reorder_point with full product and warehouse details.
|
||||
*
|
||||
@@ -1192,7 +1276,7 @@ class ReportManager
|
||||
rl.td_stock_id,
|
||||
rl.login,
|
||||
u.name AS user_name
|
||||
FROM md_rack_log rl
|
||||
FROM td_rack_log rl
|
||||
LEFT JOIN `{$main_db}`.user u
|
||||
ON u.user_id = rl.user_id
|
||||
WHERE rl.company_id = :company_id
|
||||
|
||||
@@ -81,6 +81,71 @@ class ReturnManager {
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive receipt status from td_stock_* rows linked to this return.
|
||||
* 0 = no stock-in rows yet, 3 = goods restocked (stock-in rows exist).
|
||||
* ReturnManager.confirmReturn() always inserts status=1 rows, so only 0 or 3 is possible.
|
||||
*/
|
||||
private function deriveReceiptStatus(array $ret): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (!preg_match('/^td_stock_\d+$/', $table)) continue;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
AND type = 'in'
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':return_id' => $ret['id']]);
|
||||
if ((int)$sth->fetchColumn() > 0) return 3;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function syncReturnItems(int $return_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_return_item WHERE return_id = :return_id AND company_id = :company_id"
|
||||
)->execute([':return_id' => $return_id, ':company_id' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_return_item
|
||||
(company_id, return_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_out_id, stock_out_warehouse_id)
|
||||
VALUES
|
||||
(:company_id, :return_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_out_id, :stock_out_warehouse_id)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':return_id' => $return_id,
|
||||
':item_id' => $pos + 1,
|
||||
':product_sku' => $item['product_sku'] ?? '',
|
||||
':product_name' => $item['product_name'] ?? '',
|
||||
':quantity' => (float)($item['quantity'] ?? 0),
|
||||
':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
|
||||
':stock_out_id' => (int)($item['stock_out_id'] ?? 0),
|
||||
':stock_out_warehouse_id' => (int)($item['stock_out_warehouse_id'] ?? 0),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -116,7 +181,12 @@ class ReturnManager {
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$row) {
|
||||
$row['receipt_status'] = $this->deriveReceiptStatus($row);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,10 +215,34 @@ class ReturnManager {
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
$row['items'] = json_decode($row['items'] ?? '[]', true) ?: [];
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return_item
|
||||
WHERE return_id = :return_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':return_id' => $id, ':company_id' => $this->company_id]);
|
||||
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$row['receipt_status'] = $this->deriveReceiptStatus($row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function getActiveCreditNoteId(int $return_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
AND doc_type = 'credit_note'
|
||||
AND status != 4
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':return_id' => $return_id]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// TRANSACTION BASIS — Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -168,14 +262,39 @@ class ReturnManager {
|
||||
* @return int New td_return.id on insert, 0 on update.
|
||||
* @throws Exception If updating a non-draft return.
|
||||
*/
|
||||
public function saveFormula(int $id, ?int $formula_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM td_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Return not found.");
|
||||
if ((int)$row['status'] === -1) throw new Exception("Cannot update a cancelled return.");
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET formula_id = :formula_id
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':formula_id' => $formula_id,
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function saveReturn(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$refund_amount = array_reduce($items, fn($carry, $item) =>
|
||||
$carry + (float)($item['total_price'] ?? 0), 0.0
|
||||
);
|
||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||
if (abs($tax_adjustment) > 0.30) throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||
|
||||
$subtotal = array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0);
|
||||
$tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2) + $tax_adjustment;
|
||||
$refund_amount = $subtotal + $tax;
|
||||
|
||||
if ($id > 0) {
|
||||
|
||||
@@ -192,57 +311,85 @@ class ReturnManager {
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
|
||||
? (int)$data['formula_id'] : null;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_return SET
|
||||
contact_id = :contact_id,
|
||||
return_date = :return_date,
|
||||
reason = :reason,
|
||||
items = :items,
|
||||
refund_amount = :refund_amount,
|
||||
notes = :notes,
|
||||
`log` = :log
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
return_date = :return_date,
|
||||
reason = :reason,
|
||||
refund_amount = :refund_amount,
|
||||
tax = :tax,
|
||||
tax_adjustment = :tax_adjustment,
|
||||
notes = :notes,
|
||||
formula_id = :formula_id,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':refund_amount' => $refund_amount,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':formula_id' => $formula_id,
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$this->syncReturnItems($id, $items);
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
|
||||
$formula_id = isset($data['formula_id']) && (int)$data['formula_id'] > 0
|
||||
? (int)$data['formula_id'] : null;
|
||||
|
||||
// Inherit department from parent order if not explicitly provided
|
||||
$dept_id = (int)($data['department_id'] ?? 0);
|
||||
if ($dept_id === 0 && (int)($data['order_id'] ?? 0) > 0) {
|
||||
$sth_dept = $this->pdo->prepare(
|
||||
"SELECT department_id FROM td_order WHERE id = :id AND company_id = :cid"
|
||||
);
|
||||
$sth_dept->execute([':id' => (int)$data['order_id'], ':cid' => $this->company_id]);
|
||||
$dept_id = (int)($sth_dept->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_return
|
||||
(company_id, uuid, return_number, order_id, invoice_id,
|
||||
contact_id, return_date, status,
|
||||
reason, refund_amount, items, notes, `log`)
|
||||
contact_id, department_id, return_date, status,
|
||||
reason, refund_amount, tax, tax_adjustment, notes, formula_id, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, :return_number, :order_id, :invoice_id,
|
||||
:contact_id, :return_date, 0,
|
||||
:reason, :refund_amount, :items, :notes, :log)"
|
||||
:contact_id, :department_id, :return_date, 0,
|
||||
:reason, :refund_amount, :tax, :tax_adjustment, :notes, :formula_id, :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':order_id' => (int)($data['order_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':items' => json_encode($items, JSON_UNESCAPED_UNICODE),
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode([$logging]),
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':order_id' => (int)($data['order_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => $dept_id,
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':formula_id' => $formula_id,
|
||||
':log' => json_encode([$logging]),
|
||||
]);
|
||||
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
$new_id = (int)$this->pdo->lastInsertId();
|
||||
$this->syncReturnItems($new_id, $items);
|
||||
return $new_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,17 +408,19 @@ class ReturnManager {
|
||||
* InvoiceManager::createCreditNote() — auto CN, status=1 (issued).
|
||||
* If false: CN must be created manually from the return detail page.
|
||||
*
|
||||
* Stock-in rows are ALWAYS status=1 regardless of default_stock_status config.
|
||||
* Confirming a return is a final business decision — no warehouse re-approval needed.
|
||||
* Stock-in rows respect $auto_approve (mirrors default_stock_status config).
|
||||
* When false, rows are created as draft (status=0) and warehouse staff approve
|
||||
* them via approve_stock.php; rack occupation and balance are deferred.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $return_id td_return.id to confirm.
|
||||
* @param string $uuid UUID prefix for stock-in rows.
|
||||
* @param array $logging Audit entry.
|
||||
* @param object $whMgmt WarehouseManager instance.
|
||||
* @param object $invMgmt InvoiceManager instance.
|
||||
* @param bool $auto_cn Whether to auto-create credit note.
|
||||
* @param int $return_id td_return.id to confirm.
|
||||
* @param string $uuid UUID prefix for stock-in rows.
|
||||
* @param array $logging Audit entry.
|
||||
* @param object $whMgmt WarehouseManager instance.
|
||||
* @param object $invMgmt InvoiceManager instance.
|
||||
* @param bool $auto_cn Whether to auto-create credit note.
|
||||
* @param bool $auto_approve Whether to auto-approve stock-in rows (status=1) immediately.
|
||||
* @throws Exception
|
||||
*/
|
||||
public function confirmReturn(
|
||||
@@ -280,7 +429,8 @@ class ReturnManager {
|
||||
array $logging,
|
||||
$whMgmt,
|
||||
$invMgmt,
|
||||
bool $auto_cn = false
|
||||
bool $auto_cn = false,
|
||||
bool $auto_approve = false
|
||||
): void {
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -293,9 +443,19 @@ class ReturnManager {
|
||||
if (!$return) throw new Exception("Return request not found.");
|
||||
if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled.");
|
||||
|
||||
$items = json_decode($return['items'] ?? '[]', true) ?: [];
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return_item
|
||||
WHERE return_id = :return_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (empty($items)) throw new Exception("Cannot confirm a return with no items.");
|
||||
|
||||
if ($auto_approve) {
|
||||
$whMgmt->assertStockMovementWindow($return['return_date'] ?? date('Y-m-d'), 'Return stock approval');
|
||||
}
|
||||
|
||||
// ── Per-item: restock ─────────────────────────────────────────────
|
||||
foreach ($items as $i => $item) {
|
||||
|
||||
@@ -340,7 +500,6 @@ class ReturnManager {
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
// INSERT stock-in row — status=1 forced
|
||||
// price comes from the linked SO stock-out row (selling price), not the return form.
|
||||
// ref_id links back to the stock-out row so the report can trace the original sale.
|
||||
$this->pdo->prepare(
|
||||
@@ -355,7 +514,7 @@ class ReturnManager {
|
||||
:zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'in',
|
||||
:lot_number, :serial_number,
|
||||
'return', :source_id, :ref_id, :price, 1)"
|
||||
'return', :source_id, :ref_id, :price, :status)"
|
||||
)->execute([
|
||||
':uuid' => $item_uuid,
|
||||
':company_id' => $this->company_id,
|
||||
@@ -373,21 +532,23 @@ class ReturnManager {
|
||||
':source_id' => $return_id,
|
||||
':ref_id' => $stock_out_id,
|
||||
':price' => (float)$stock_out['price'],
|
||||
':status' => $auto_approve ? 1 : 0,
|
||||
]);
|
||||
|
||||
$stock_in_id = (int)$this->pdo->lastInsertId();
|
||||
|
||||
// Occupy rack + adjust balance — forced since status=1
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$item['zone'] ?? '',
|
||||
$item['aisle'] ?? '',
|
||||
$item['rack'] ?? '',
|
||||
$product_sku,
|
||||
$stock_in_id,
|
||||
true
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
if ($auto_approve) {
|
||||
$whMgmt->occupyRack(
|
||||
$warehouse_id,
|
||||
$item['zone'] ?? '',
|
||||
$item['aisle'] ?? '',
|
||||
$item['rack'] ?? '',
|
||||
$product_sku,
|
||||
$stock_in_id,
|
||||
true
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update return status → 1 (confirmed) ─────────────────────────
|
||||
@@ -411,7 +572,9 @@ class ReturnManager {
|
||||
$invoice_id,
|
||||
$items,
|
||||
(float)$return['refund_amount'],
|
||||
array_merge($logging, ['action' => 'auto_credit_note'])
|
||||
array_merge($logging, ['action' => 'auto_credit_note']),
|
||||
'return',
|
||||
$return_id
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -421,11 +584,11 @@ class ReturnManager {
|
||||
*
|
||||
* Business rule:
|
||||
* A return can be cancelled as long as no active downstream document exists.
|
||||
* Active means status != -1 (not voided).
|
||||
* Active means status != 4 (not voided).
|
||||
*
|
||||
* Downstream document that blocks cancellation:
|
||||
* - td_invoice (credit note) where ref_invoice_id links back via
|
||||
* order_id = return.order_id AND doc_type = 'credit_note' AND status != -1.
|
||||
* order_id = return.order_id AND doc_type = 'credit_note' AND status != 4.
|
||||
* Credit notes store order_id (not return_id), so we match via order_id
|
||||
* and doc_type.
|
||||
*
|
||||
@@ -472,7 +635,7 @@ class ReturnManager {
|
||||
WHERE company_id = :company_id
|
||||
AND order_id = :order_id
|
||||
AND doc_type = 'credit_note'
|
||||
AND status != -1"
|
||||
AND status != 4"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':order_id' => $order_id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
@@ -485,7 +648,13 @@ class ReturnManager {
|
||||
|
||||
// ── If confirmed: reverse stock-in rows + rack/balance ────────────
|
||||
if ($status === 1) {
|
||||
$items = json_decode($return['items'] ?? '[]', true) ?: [];
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_return_item
|
||||
WHERE return_id = :return_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
@@ -496,44 +665,36 @@ class ReturnManager {
|
||||
|
||||
$table = $this->stockTableNameFromWarehouseId($warehouse_id);
|
||||
|
||||
// Find the stock-in row created by confirmReturn()
|
||||
// Fetch ALL non-cancelled stock-in rows (status=1 approved or status=0 draft)
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, zone, aisle, rack FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
"SELECT id, status, zone, aisle, rack, `date` FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'return'
|
||||
AND source_id = :return_id
|
||||
AND product_sku = :sku
|
||||
AND status = 1
|
||||
LIMIT 1"
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':return_id' => $return_id,
|
||||
':sku' => $product_sku,
|
||||
]);
|
||||
$stock_row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$stock_row) continue;
|
||||
foreach ($rows as $row) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $row['id'], ':company_id' => $this->company_id]);
|
||||
|
||||
// Soft-delete the stock-in row
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':id' => $stock_row['id'],
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// Release rack occupation
|
||||
$whMgmt->releaseRack(
|
||||
$warehouse_id,
|
||||
$stock_row['zone'],
|
||||
$stock_row['aisle'],
|
||||
$stock_row['rack']
|
||||
);
|
||||
|
||||
// Reverse balance
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
|
||||
if ((int)$row['status'] === 1) {
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Return stock cancellation');
|
||||
// Approved rows: reverse rack occupation + balance
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
// Draft (status=0) rows: just soft-delete — rack/balance were never applied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../module/mailer.php';
|
||||
|
||||
class SmtpManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private string $method;
|
||||
private string $pinkey;
|
||||
private string $iv;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id, string $method, string $pinkey, string $iv)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$this->method = $method;
|
||||
$this->pinkey = $pinkey;
|
||||
$this->iv = $iv;
|
||||
}
|
||||
|
||||
public function get(): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
server,
|
||||
port,
|
||||
username,
|
||||
from_name,
|
||||
from_email,
|
||||
encryption
|
||||
FROM company_smtp
|
||||
WHERE company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function save(array $data): void
|
||||
{
|
||||
$server = trim((string)($data['smtp_host'] ?? ''));
|
||||
$port = trim((string)($data['smtp_port'] ?? '587'));
|
||||
$username = trim((string)($data['smtp_username'] ?? ''));
|
||||
$raw_pass = (string)($data['smtp_password'] ?? '');
|
||||
$from_name = trim((string)($data['smtp_from_name'] ?? ''));
|
||||
$from_email = trim((string)($data['smtp_from_email'] ?? ''));
|
||||
$encryption = trim((string)($data['smtp_encryption'] ?? 'tls'));
|
||||
|
||||
if ($server === '' || $username === '') {
|
||||
throw new Exception('SMTP host and username are required.');
|
||||
}
|
||||
|
||||
if (!in_array($port, ['25', '465', '587'], true)) $port = '587';
|
||||
if (!in_array($encryption, ['tls', 'ssl', 'none'], true)) $encryption = 'tls';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT smtp_id
|
||||
FROM company_smtp
|
||||
WHERE company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$existing_id = $sth->fetchColumn();
|
||||
|
||||
if ($existing_id) {
|
||||
$this->update((int)$existing_id, $server, $port, $username, $raw_pass, $from_name, $from_email, $encryption);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($raw_pass === '') {
|
||||
throw new Exception('Password is required for a new SMTP configuration.');
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO company_smtp
|
||||
(company_id, server, port, username, password,
|
||||
from_name, from_email, encryption, updated_at)
|
||||
VALUES
|
||||
(:company_id, :server, :port, :username, :password,
|
||||
:from_name, :from_email, :encryption, NOW())"
|
||||
)->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':server' => $server,
|
||||
':port' => $port,
|
||||
':username' => $username,
|
||||
':password' => $this->encryptPassword($raw_pass),
|
||||
':from_name' => $from_name,
|
||||
':from_email' => $from_email,
|
||||
':encryption' => $encryption,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTest(string $to): void
|
||||
{
|
||||
$to = trim($to);
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new Exception('Invalid recipient email address.');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT from_name
|
||||
FROM company_smtp
|
||||
WHERE company_id = :company_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
throw new Exception('No SMTP configuration found. Please save your settings first.');
|
||||
}
|
||||
|
||||
$mailer = new mailer(['pdo1' => $this->pdo]);
|
||||
$mailer->send_email([
|
||||
'company_id' => $this->companyId,
|
||||
'smtp' => [],
|
||||
'to' => $to,
|
||||
'subject' => 'SMTP Test - WMS',
|
||||
'message' => "This is a test email from your WMS SMTP configuration.\n\nIf you received this, your SMTP settings are working correctly.",
|
||||
'channel_name' => $row['from_name'] ?: 'WMS System',
|
||||
'key' => $this->pinkey,
|
||||
]);
|
||||
}
|
||||
|
||||
private function update(int $smtp_id, string $server, string $port, string $username, string $raw_pass, string $from_name, string $from_email, string $encryption): void
|
||||
{
|
||||
if ($raw_pass !== '') {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE company_smtp SET
|
||||
server = :server,
|
||||
port = :port,
|
||||
username = :username,
|
||||
password = :password,
|
||||
from_name = :from_name,
|
||||
from_email = :from_email,
|
||||
encryption = :encryption,
|
||||
updated_at = NOW()
|
||||
WHERE smtp_id = :smtp_id"
|
||||
)->execute([
|
||||
':server' => $server,
|
||||
':port' => $port,
|
||||
':username' => $username,
|
||||
':password' => $this->encryptPassword($raw_pass),
|
||||
':from_name' => $from_name,
|
||||
':from_email' => $from_email,
|
||||
':encryption' => $encryption,
|
||||
':smtp_id' => $smtp_id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE company_smtp SET
|
||||
server = :server,
|
||||
port = :port,
|
||||
username = :username,
|
||||
from_name = :from_name,
|
||||
from_email = :from_email,
|
||||
encryption = :encryption,
|
||||
updated_at = NOW()
|
||||
WHERE smtp_id = :smtp_id"
|
||||
)->execute([
|
||||
':server' => $server,
|
||||
':port' => $port,
|
||||
':username' => $username,
|
||||
':from_name' => $from_name,
|
||||
':from_email' => $from_email,
|
||||
':encryption' => $encryption,
|
||||
':smtp_id' => $smtp_id,
|
||||
]);
|
||||
}
|
||||
|
||||
private function encryptPassword(string $plain): string
|
||||
{
|
||||
return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -244,6 +244,9 @@ class StockManager {
|
||||
$table_log[] = $logging;
|
||||
|
||||
if ($id > 0) {
|
||||
if ((int)($row['status'] ?? 0) === 1) {
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-in edit');
|
||||
}
|
||||
|
||||
// Update: only metadata fields are editable after creation
|
||||
$this->pdo->prepare(
|
||||
@@ -357,6 +360,9 @@ class StockManager {
|
||||
$table_log[] = $logging;
|
||||
|
||||
if ($id > 0) {
|
||||
if ((int)($row['status'] ?? 0) === 1) {
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock-out edit');
|
||||
}
|
||||
|
||||
// Update: only metadata fields are editable after creation
|
||||
$this->pdo->prepare(
|
||||
@@ -500,6 +506,11 @@ class StockManager {
|
||||
throw new Exception("Paired destination record missing — data integrity issue.");
|
||||
}
|
||||
|
||||
if ((int)($from_row['status'] ?? 0) === 1 || (int)($to_row['status'] ?? 0) === 1) {
|
||||
$whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer edit');
|
||||
$whMgmt->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer edit');
|
||||
}
|
||||
|
||||
$raw_from = $from_row['log'] ?? [];
|
||||
$from_log = is_array($raw_from) ? $raw_from : (json_decode($raw_from, true) ?: []);
|
||||
$from_log[] = $logging;
|
||||
@@ -695,6 +706,8 @@ class StockManager {
|
||||
throw new Exception('Already approved.');
|
||||
}
|
||||
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Stock approval');
|
||||
|
||||
// ── Approve this row ──────────────────────────────────────────────
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = 1 WHERE id = :id AND company_id = :company_id"
|
||||
@@ -710,7 +723,8 @@ class StockManager {
|
||||
$row['product_sku'],
|
||||
$id
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in']);
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $row['product_sku'], 0, (float)$row['in'],
|
||||
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
|
||||
|
||||
} elseif ($type === 'out') {
|
||||
|
||||
@@ -723,7 +737,7 @@ class StockManager {
|
||||
ON out_rows.company_id = src.company_id
|
||||
AND out_rows.ref_id = src.id
|
||||
AND out_rows.`out` > 0
|
||||
AND out_rows.status != -1
|
||||
AND out_rows.status = 1
|
||||
WHERE src.id = :ref_id
|
||||
AND src.company_id = :company_id
|
||||
GROUP BY src.id, src.`in`"
|
||||
@@ -739,7 +753,8 @@ class StockManager {
|
||||
if ($remaining_qty <= 0.000001) {
|
||||
$whMgmt->releaseRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack']);
|
||||
}
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out']);
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $row['product_sku'], 0, (float)$row['out'],
|
||||
$id, $row['source'] ?? '', (int)($row['source_id'] ?? 0));
|
||||
|
||||
} elseif ($type === 'transfer') {
|
||||
|
||||
@@ -791,6 +806,13 @@ class StockManager {
|
||||
$from_row = $row;
|
||||
}
|
||||
|
||||
if ($from_row) {
|
||||
$whMgmt->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer approval');
|
||||
}
|
||||
if ($inbound_row) {
|
||||
$whMgmt->assertStockMovementWindow($inbound_row['date'] ?? null, 'Stock transfer approval');
|
||||
}
|
||||
|
||||
// Approve outbound row
|
||||
if ($from_row && (int)$from_row['status'] === 0) {
|
||||
$this->pdo->prepare(
|
||||
@@ -813,7 +835,8 @@ class StockManager {
|
||||
$from_wh_id,
|
||||
$from_row['zone'], $from_row['aisle'], $from_row['rack']
|
||||
);
|
||||
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out']);
|
||||
$whMgmt->adjustBalance('out', $from_wh_id, $from_row['product_sku'], 0, (float)$from_row['out'],
|
||||
(int)$from_row['id'], $from_row['source'] ?? '', (int)($from_row['source_id'] ?? 0));
|
||||
}
|
||||
if ($inbound_row && $paired_wh_id) {
|
||||
$whMgmt->occupyRack(
|
||||
@@ -822,7 +845,8 @@ class StockManager {
|
||||
$inbound_row['product_sku'],
|
||||
$inbound_row['id']
|
||||
);
|
||||
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in']);
|
||||
$whMgmt->adjustBalance('in', $paired_wh_id, $inbound_row['product_sku'], 0, (float)$inbound_row['in'],
|
||||
(int)$inbound_row['id'], $inbound_row['source'] ?? '', (int)($inbound_row['source_id'] ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
class StockSourceManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getBySource(string $source, int $source_id): array
|
||||
{
|
||||
$source = trim($source);
|
||||
if ($source === '' || $source_id <= 0) {
|
||||
throw new Exception('source and source_id are required.');
|
||||
}
|
||||
|
||||
$warehouses = $this->getWarehouseNames();
|
||||
$rows = [];
|
||||
|
||||
foreach ($this->getStockTables() as $table) {
|
||||
if (!preg_match('/^td_stock_(\d+)$/', $table, $match)) continue;
|
||||
|
||||
$warehouse_id = (int)$match[1];
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT s.id, s.type, s.product_sku, s.`in`, s.`out`,
|
||||
s.zone, s.aisle, s.rack, s.lot_number, s.serial_number,
|
||||
s.status, s.date, s.price,
|
||||
p.product_name
|
||||
FROM `{$table}` s
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = s.company_id
|
||||
AND p.sku = s.product_sku
|
||||
WHERE s.company_id = :company_id
|
||||
AND s.source = :source
|
||||
AND s.source_id = :source_id
|
||||
ORDER BY s.date ASC"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->companyId,
|
||||
':source' => $source,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$row['warehouse_id'] = $warehouse_id;
|
||||
$row['warehouse_name'] = $warehouses[$warehouse_id] ?? "WH {$warehouse_id}";
|
||||
$row['quantity'] = $row['type'] === 'out' ? (float)$row['out'] : (float)$row['in'];
|
||||
$rows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function getWarehouseNames(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, warehouse_name
|
||||
FROM md_warehouse
|
||||
WHERE company_id = :company_id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->companyId]);
|
||||
|
||||
$warehouses = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $warehouse) {
|
||||
$warehouses[(int)$warehouse['id']] = $warehouse['warehouse_name'];
|
||||
}
|
||||
return $warehouses;
|
||||
}
|
||||
|
||||
private function getStockTables(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
return $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -0,0 +1,619 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* SupplierReturnManager
|
||||
*
|
||||
* Handles all read and write operations for td_supplier_return.
|
||||
*
|
||||
* Method order:
|
||||
* Transaction basis → getReturnList, getReturnById, generateReturnNumber,
|
||||
* saveReturn, confirmReturn, cancelReturn
|
||||
*
|
||||
* Key design decisions:
|
||||
* - status: -1=cancelled, 0=draft, 1=confirmed (consistent with td_return)
|
||||
* - confirmReturn() does stock-OUT (goods leave warehouse back to supplier).
|
||||
* Mirrors ReturnManager.confirmReturn() which does stock-IN.
|
||||
* - stock_in_id on each item references the original td_stock_* row created
|
||||
* by PurchaseOrderManager.receivePo(). This gives us the lot/serial context.
|
||||
* - Stock-out rows are inserted with status=1 (force-approved) — confirming a
|
||||
* supplier return is a final decision, no warehouse re-approval needed.
|
||||
* - cancelReturn() is blocked if an active debit note exists
|
||||
* (source='supplier_return', source_id=return_id, status!=4).
|
||||
*
|
||||
* Security: All SQL uses PDO prepared statements with bound parameters.
|
||||
* Dynamic stock table names are derived only from md_warehouse.id.
|
||||
*/
|
||||
class SupplierReturnManager {
|
||||
|
||||
private PDO $pdo;
|
||||
private int $company_id;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id) {
|
||||
$this->pdo = $pdo;
|
||||
$this->company_id = $company_id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
private function generateReturnNumber(): string
|
||||
{
|
||||
$prefix = 'SRN-' . date('Ymd') . '-';
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT return_number FROM td_supplier_return
|
||||
WHERE company_id = :company_id
|
||||
AND return_number LIKE :prefix
|
||||
ORDER BY return_number DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':prefix' => $prefix . '%']);
|
||||
|
||||
$last = $sth->fetchColumn();
|
||||
$seq = $last ? ((int)substr($last, -4) + 1) : 1;
|
||||
return $prefix . str_pad($seq, 4, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function stockTableName(int $warehouse_id): string
|
||||
{
|
||||
if ($warehouse_id <= 0) throw new Exception("Invalid warehouse id.");
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive fulfillment status from td_stock_* rows + shipping tracking number.
|
||||
* 0 = no stock-out rows
|
||||
* 1 = stock-out rows exist but at least one is draft (Picking)
|
||||
* 2 = all stock-out rows confirmed, no tracking number (Packed)
|
||||
* 3 = all stock-out rows confirmed + tracking number present (Shipped)
|
||||
*/
|
||||
private function deriveFulfillmentStatus(array $ret): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name LIKE 'td_stock_%'"
|
||||
);
|
||||
$sth->execute();
|
||||
$tables = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
$has_stock_out = false;
|
||||
$has_draft = false;
|
||||
foreach ($tables as $table) {
|
||||
if (!preg_match('/^td_stock_\d+$/', $table)) continue;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'supplier_return'
|
||||
AND source_id = :return_id
|
||||
AND type = 'out'
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':return_id' => $ret['id']]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
if (count($rows) > 0) {
|
||||
$has_stock_out = true;
|
||||
if (in_array(0, array_map('intval', $rows), true)) {
|
||||
$has_draft = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$has_stock_out) return 0;
|
||||
if ($has_draft) return 1;
|
||||
return trim((string)($ret['shipping_tracking_number'] ?? '')) !== '' ? 3 : 2;
|
||||
}
|
||||
|
||||
private function syncSupplierReturnItems(int $return_id, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_supplier_return_item WHERE return_id = :return_id AND company_id = :company_id"
|
||||
)->execute([':return_id' => $return_id, ':company_id' => $this->company_id]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_supplier_return_item
|
||||
(company_id, return_id, item_id, product_sku, product_name,
|
||||
quantity, unit_price, total_price, tax_amount, tax_rate, warehouse_id, stock_in_id, zone, aisle, rack)
|
||||
VALUES
|
||||
(:company_id, :return_id, :item_id, :product_sku, :product_name,
|
||||
:quantity, :unit_price, :total_price, :tax_amount, :tax_rate, :warehouse_id, :stock_in_id, :zone, :aisle, :rack)"
|
||||
);
|
||||
foreach ($items as $pos => $item) {
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':return_id' => $return_id,
|
||||
':item_id' => $pos + 1,
|
||||
':product_sku' => $item['product_sku'] ?? '',
|
||||
':product_name' => $item['product_name'] ?? '',
|
||||
':quantity' => (float)($item['quantity'] ?? 0),
|
||||
':unit_price' => (float)($item['unit_price'] ?? $item['price'] ?? 0),
|
||||
':total_price' => (float)($item['total_price'] ?? 0),
|
||||
':tax_amount' => (float)($item['tax_amount'] ?? 0),
|
||||
':tax_rate' => (float)($item['tax_rate'] ?? 0),
|
||||
':warehouse_id' => (int)($item['warehouse_id'] ?? 0),
|
||||
':stock_in_id' => (int)($item['stock_in_id'] ?? 0),
|
||||
':zone' => $item['zone'] ?? '',
|
||||
':aisle' => $item['aisle'] ?? '',
|
||||
':rack' => $item['rack'] ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Read
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
public function getReturnList(int $po_id = 0): array
|
||||
{
|
||||
$where = ['r.company_id = :company_id'];
|
||||
$params = [':company_id' => $this->company_id];
|
||||
|
||||
if ($po_id > 0) {
|
||||
$where[] = 'r.po_id = :po_id';
|
||||
$params[':po_id'] = $po_id;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
p.po_number
|
||||
FROM td_supplier_return r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_purchase_order p
|
||||
ON p.company_id = r.company_id
|
||||
AND p.id = r.po_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as &$row) {
|
||||
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
|
||||
}
|
||||
unset($row);
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getReturnById(int $id): array|false
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
p.po_number
|
||||
FROM td_supplier_return r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id
|
||||
AND c.id = r.contact_id
|
||||
LEFT JOIN td_purchase_order p
|
||||
ON p.company_id = r.company_id
|
||||
AND p.id = r.po_id
|
||||
WHERE r.company_id = :company_id
|
||||
AND r.id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) return false;
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_supplier_return_item
|
||||
WHERE return_id = :return_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':return_id' => $row['id'], ':company_id' => $this->company_id]);
|
||||
$row['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
$row['fulfillment_status'] = $this->deriveFulfillmentStatus($row);
|
||||
return $row;
|
||||
}
|
||||
|
||||
public function getActiveDebitNoteId(int $return_id): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'supplier_return'
|
||||
AND source_id = :source_id
|
||||
AND doc_type = 'supplier_credit_note'
|
||||
AND status != 4
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':source_id' => $return_id]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function updateTracking(int $return_id, string $tracking_number): void
|
||||
{
|
||||
if ($return_id <= 0) {
|
||||
throw new Exception("Invalid return id.");
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_supplier_return
|
||||
SET shipping_tracking_number = :tracking
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':tracking' => trim($tracking_number),
|
||||
':id' => $return_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Write
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create or update a draft supplier return.
|
||||
*
|
||||
* @param array $data Keys: id, po_id, invoice_id, contact_id,
|
||||
* return_date, reason, items (array), notes.
|
||||
* @param array $logging Audit entry.
|
||||
* @return int New td_supplier_return.id on insert, 0 on update.
|
||||
*/
|
||||
public function saveReturn(array $data, array $logging): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
$tax_adjustment = (float)($data['tax_adjustment'] ?? 0);
|
||||
if (abs($tax_adjustment) > 0.30) throw new Exception("Tax adjustment cannot exceed ±0.30.");
|
||||
|
||||
$subtotal = array_reduce($items, fn($c, $i) => $c + (float)($i['total_price'] ?? 0), 0.0);
|
||||
$tax = round(array_reduce($items, fn($c, $i) => $c + (float)($i['tax_amount'] ?? 0), 0.0), 2) + $tax_adjustment;
|
||||
$refund_amount = $subtotal + $tax;
|
||||
|
||||
if ($id > 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, `log` FROM td_supplier_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) throw new Exception("Supplier return not found.");
|
||||
if ((int)$row['status'] !== 0) throw new Exception("Only draft returns can be edited.");
|
||||
|
||||
$log = json_decode($row['log'] ?? '[]', true) ?: [];
|
||||
$log[] = $logging;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_supplier_return SET
|
||||
contact_id = :contact_id,
|
||||
department_id = :department_id,
|
||||
return_date = :return_date,
|
||||
reason = :reason,
|
||||
refund_amount = :refund_amount,
|
||||
tax = :tax,
|
||||
tax_adjustment = :tax_adjustment,
|
||||
notes = :notes,
|
||||
shipping_tracking_number = :tracking,
|
||||
`log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => (int)($data['department_id'] ?? 0),
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':tracking' => trim($data['shipping_tracking_number'] ?? ''),
|
||||
':log' => json_encode($log),
|
||||
':id' => $id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
$this->syncSupplierReturnItems($id, $items);
|
||||
return 0;
|
||||
|
||||
} else {
|
||||
// Inherit department from parent PO if not explicitly provided
|
||||
$dept_id = (int)($data['department_id'] ?? 0);
|
||||
if ($dept_id === 0 && (int)($data['po_id'] ?? 0) > 0) {
|
||||
$sth_dept = $this->pdo->prepare(
|
||||
"SELECT department_id FROM td_purchase_order WHERE id = :id AND company_id = :cid"
|
||||
);
|
||||
$sth_dept->execute([':id' => (int)$data['po_id'], ':cid' => $this->company_id]);
|
||||
$dept_id = (int)($sth_dept->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO td_supplier_return
|
||||
(company_id, uuid, return_number, po_id, invoice_id,
|
||||
contact_id, department_id, return_date, status,
|
||||
reason, refund_amount, tax, tax_adjustment, notes, `log`)
|
||||
VALUES
|
||||
(:company_id, :uuid, :return_number, :po_id, :invoice_id,
|
||||
:contact_id, :department_id, :return_date, 0,
|
||||
:reason, :refund_amount, :tax, :tax_adjustment, :notes, :log)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':uuid' => bin2hex(random_bytes(16)),
|
||||
':return_number' => $this->generateReturnNumber(),
|
||||
':po_id' => (int)($data['po_id'] ?? 0),
|
||||
':invoice_id' => (int)($data['invoice_id'] ?? 0),
|
||||
':contact_id' => (int)($data['contact_id'] ?? 0),
|
||||
':department_id' => $dept_id,
|
||||
':return_date' => $data['return_date'] ?? date('Y-m-d'),
|
||||
':reason' => $data['reason'] ?? '',
|
||||
':refund_amount' => $refund_amount,
|
||||
':tax' => $tax,
|
||||
':tax_adjustment' => $tax_adjustment,
|
||||
':notes' => $data['notes'] ?? '',
|
||||
':log' => json_encode([$logging]),
|
||||
]);
|
||||
|
||||
$new_id = (int)$this->pdo->lastInsertId();
|
||||
$this->syncSupplierReturnItems($new_id, $items);
|
||||
return $new_id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a supplier return — create stock-out rows (goods leave warehouse).
|
||||
*
|
||||
* Flow per item:
|
||||
* 1. Validate stock-in row (from PO receive) is approved.
|
||||
* 2. INSERT td_stock_<warehouse_id> out row, status respects $auto_approve.
|
||||
* 3. If $auto_approve: releaseRack() + adjustBalance('out').
|
||||
* After all items:
|
||||
* 4. UPDATE td_supplier_return.status = 1.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*
|
||||
* @param int $return_id td_supplier_return.id
|
||||
* @param string $uuid UUID prefix for stock-out rows.
|
||||
* @param array $logging Audit entry.
|
||||
* @param object $whMgmt WarehouseManager instance.
|
||||
* @param bool $auto_approve Whether to auto-approve stock-out rows (status=1) immediately.
|
||||
* @param object $invMgmt InvoiceManager instance (required if $auto_dn=true).
|
||||
* @param bool $auto_dn Whether to auto-create and issue a debit note on confirm.
|
||||
*/
|
||||
public function confirmReturn(
|
||||
int $return_id,
|
||||
string $uuid,
|
||||
array $logging,
|
||||
$whMgmt,
|
||||
bool $auto_approve = false,
|
||||
$invMgmt = null,
|
||||
bool $auto_dn = false
|
||||
): void {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_supplier_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $return_id]);
|
||||
$return = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$return) throw new Exception("Supplier return not found.");
|
||||
if ((int)$return['status'] !== 0) throw new Exception("Return is already confirmed or cancelled.");
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_supplier_return_item
|
||||
WHERE return_id = :return_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
if (empty($items)) throw new Exception("Cannot confirm a return with no items.");
|
||||
|
||||
if ($auto_approve) {
|
||||
$whMgmt->assertStockMovementWindow($return['return_date'] ?? date('Y-m-d'), 'Supplier return stock approval');
|
||||
}
|
||||
|
||||
foreach ($items as $i => $item) {
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
$stock_in_id = (int)($item['stock_in_id'] ?? 0);
|
||||
|
||||
if (!$warehouse_id || !$product_sku) {
|
||||
throw new Exception("Item #{$i}: missing warehouse_id or product_sku.");
|
||||
}
|
||||
if ($quantity <= 0) {
|
||||
throw new Exception("Item #{$i}: quantity must be greater than zero.");
|
||||
}
|
||||
|
||||
// Load original stock-in row for lot/serial context
|
||||
$table = $this->stockTableName($warehouse_id);
|
||||
if ($stock_in_id > 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status, lot_number, serial_number, price
|
||||
FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND id = :id
|
||||
AND product_sku = :sku
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':id' => $stock_in_id,
|
||||
':sku' => $product_sku,
|
||||
]);
|
||||
$stock_in = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$stock_in) throw new Exception("Item #{$i}: original stock-in record not found.");
|
||||
if ((int)$stock_in['status'] !== 1) throw new Exception("Item #{$i}: goods have not been approved into stock yet.");
|
||||
} else {
|
||||
$stock_in = ['lot_number' => '', 'serial_number' => '', 'price' => 0];
|
||||
}
|
||||
|
||||
$zone = $item['zone'] ?? '';
|
||||
$aisle = $item['aisle'] ?? '';
|
||||
$rack = $item['rack'] ?? '';
|
||||
if ($zone === '' && $rack !== '') $zone = $rack;
|
||||
if ($aisle === '' && $rack !== '') $aisle = $rack;
|
||||
|
||||
$item_uuid = $uuid . '_srn_' . $i;
|
||||
$item_log = [array_merge($logging, ['action' => 'confirm_return_item'])];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO `{$table}`
|
||||
(uuid, company_id, `date`, product_sku, `out`,
|
||||
zone, aisle, rack,
|
||||
contact_id, `description`, `log`, `type`,
|
||||
lot_number, serial_number,
|
||||
source, source_id, ref_id, price, status)
|
||||
VALUES
|
||||
(:uuid, :company_id, :date, :product_sku, :quantity,
|
||||
:zone, :aisle, :rack,
|
||||
:contact_id, :description, :log, 'out',
|
||||
:lot_number, :serial_number,
|
||||
'supplier_return', :source_id, :ref_id, :price, :status)"
|
||||
)->execute([
|
||||
':uuid' => $item_uuid,
|
||||
':company_id' => $this->company_id,
|
||||
':date' => date('Y-m-d H:i:s'),
|
||||
':product_sku' => $product_sku,
|
||||
':quantity' => $quantity,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
':rack' => $rack,
|
||||
':contact_id' => (int)$return['contact_id'],
|
||||
':description' => $return['return_number'],
|
||||
':log' => json_encode($item_log),
|
||||
':lot_number' => $stock_in['lot_number'] ?? '',
|
||||
':serial_number' => $stock_in['serial_number'] ?? '',
|
||||
':source_id' => $return_id,
|
||||
':ref_id' => $stock_in_id,
|
||||
':price' => (float)($stock_in['price'] ?? $item['price'] ?? 0),
|
||||
':status' => $auto_approve ? 1 : 0,
|
||||
]);
|
||||
|
||||
if ($auto_approve) {
|
||||
$stock_out_id = (int)$this->pdo->lastInsertId();
|
||||
$whMgmt->releaseRack($warehouse_id, $zone, $aisle, $rack);
|
||||
$whMgmt->adjustBalance('out', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
}
|
||||
|
||||
$log = json_decode($return['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'confirm']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_supplier_return SET status = 1, `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $return_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
|
||||
// ── Auto debit note ───────────────────────────────────────────────
|
||||
if ($auto_dn && $invMgmt) {
|
||||
$dn_id = $invMgmt->createFromSupplierReturn(
|
||||
$return_id,
|
||||
array_merge($logging, ['action' => 'auto_supplier_credit_note'])
|
||||
);
|
||||
$invMgmt->issueInvoice(
|
||||
$dn_id,
|
||||
array_merge($logging, ['action' => 'auto_issue_supplier_credit_note'])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a return (draft or confirmed).
|
||||
*
|
||||
* If confirmed: soft-delete stock-out rows and reverse balance.
|
||||
* Blocked if an active debit note exists for this return.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
*/
|
||||
public function cancelReturn(int $return_id, array $logging, $whMgmt): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_supplier_return
|
||||
WHERE company_id = :company_id AND id = :id"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':id' => $return_id]);
|
||||
$return = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$return) throw new Exception("Supplier return not found.");
|
||||
if ((int)$return['status'] === -1) throw new Exception("Return is already cancelled.");
|
||||
|
||||
// Guard: no active debit note
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) FROM td_invoice
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'supplier_return'
|
||||
AND source_id = :source_id
|
||||
AND doc_type = 'supplier_credit_note'
|
||||
AND status != 4"
|
||||
);
|
||||
$sth->execute([':company_id' => $this->company_id, ':source_id' => $return_id]);
|
||||
if ((int)$sth->fetchColumn() > 0) {
|
||||
throw new Exception("Cannot cancel — an active supplier credit note exists. Void the supplier credit note first.");
|
||||
}
|
||||
|
||||
// If confirmed: reverse stock-out rows
|
||||
if ((int)$return['status'] === 1) {
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_supplier_return_item
|
||||
WHERE return_id = :return_id AND company_id = :company_id
|
||||
ORDER BY item_id"
|
||||
);
|
||||
$sth2->execute([':return_id' => $return_id, ':company_id' => $this->company_id]);
|
||||
$items = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$warehouse_id = (int)($item['warehouse_id'] ?? 0);
|
||||
$product_sku = $item['product_sku'] ?? '';
|
||||
$quantity = (float)($item['quantity'] ?? 0);
|
||||
|
||||
if (!$warehouse_id || !$product_sku) continue;
|
||||
|
||||
$table = $this->stockTableName($warehouse_id);
|
||||
|
||||
// Fetch ALL non-cancelled stock-out rows (status=1 approved or status=0 draft)
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, status, zone, aisle, rack, ref_id, `date` FROM `{$table}`
|
||||
WHERE company_id = :company_id
|
||||
AND source = 'supplier_return'
|
||||
AND source_id = :return_id
|
||||
AND product_sku = :sku
|
||||
AND status != -1"
|
||||
);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':return_id' => $return_id,
|
||||
':sku' => $product_sku,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE `{$table}` SET status = -1
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([':id' => $row['id'], ':company_id' => $this->company_id]);
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
$whMgmt->assertStockMovementWindow($row['date'] ?? null, 'Supplier return stock cancellation');
|
||||
// Approved rows: re-occupy rack (reverse the release) + reverse balance
|
||||
$ref_id = (int)$row['ref_id'];
|
||||
if ($ref_id > 0) {
|
||||
$whMgmt->occupyRack($warehouse_id, $row['zone'], $row['aisle'], $row['rack'], $product_sku, $ref_id, false);
|
||||
}
|
||||
$whMgmt->adjustBalance('in', $warehouse_id, $product_sku, 0, $quantity);
|
||||
}
|
||||
// Draft (status=0) rows: just soft-delete — rack/balance were never applied
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$log = json_decode($return['log'] ?? '[]', true) ?: [];
|
||||
$log[] = array_merge($logging, ['action' => 'cancel']);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_supplier_return SET status = -1, `log` = :log
|
||||
WHERE id = :id AND company_id = :company_id"
|
||||
)->execute([
|
||||
':log' => json_encode($log),
|
||||
':id' => $return_id,
|
||||
':company_id' => $this->company_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,42 @@ class UserManager {
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function getProfilePicture(): string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT profile_picture
|
||||
FROM user
|
||||
WHERE user_id = :user_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':user_id' => $this->user_id]);
|
||||
return (string)($sth->fetchColumn() ?: '');
|
||||
}
|
||||
|
||||
public function updateProfile(array $data, string $profile_picture): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE user SET
|
||||
name = :name,
|
||||
surname = :surname,
|
||||
email = :email,
|
||||
phone = :phone,
|
||||
country = :country,
|
||||
address = :address,
|
||||
profile_picture = :profile_picture
|
||||
WHERE user_id = :user_id"
|
||||
)->execute([
|
||||
':name' => trim($data['name'] ?? ''),
|
||||
':surname' => trim($data['surname'] ?? ''),
|
||||
':email' => trim($data['email'] ?? ''),
|
||||
':phone' => trim($data['phone'] ?? ''),
|
||||
':country' => trim($data['country'] ?? ''),
|
||||
':address' => trim($data['address'] ?? ''),
|
||||
':profile_picture' => $profile_picture,
|
||||
':user_id' => $this->user_id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all companies the current user belongs to, plus their role in each.
|
||||
*
|
||||
@@ -79,6 +115,19 @@ class UserManager {
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getCompanyAccess(int $company_id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT company_id, role
|
||||
FROM company_map_user
|
||||
WHERE company_id = :company_id AND user_id = :user_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':company_id' => $company_id, ':user_id' => $this->user_id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// COMPANY MEMBER BASIS
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes_ac/PostingWindowGuard.php';
|
||||
|
||||
/**
|
||||
* WarehouseManager
|
||||
@@ -42,6 +43,18 @@ class WarehouseManager {
|
||||
return 'td_stock_' . $warehouse_id;
|
||||
}
|
||||
|
||||
public function assertStockMovementWindow(?string $date, string $context = 'Stock movement'): void
|
||||
{
|
||||
global $pdo1;
|
||||
|
||||
if (!isset($pdo1) || !($pdo1 instanceof PDO)) {
|
||||
throw new Exception("Posting-window validation is unavailable.");
|
||||
}
|
||||
|
||||
$guard = new PostingWindowGuard($pdo1, $this->company_id);
|
||||
$guard->assertOpenDate($date ?: date('Y-m-d'), $context);
|
||||
}
|
||||
|
||||
private function resolveWarehouseTable(int $warehouse_id): ?string
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
@@ -307,7 +320,7 @@ class WarehouseManager {
|
||||
*/
|
||||
private function insertRackLog(int $rack_id, string $action, array $extra = []): void {
|
||||
|
||||
$sql = "INSERT INTO md_rack_log
|
||||
$sql = "INSERT INTO td_rack_log
|
||||
(company_id, md_rack_id, user_id, dt, login, action, product_sku, td_stock_id)
|
||||
VALUES
|
||||
(:company_id, :md_rack_id, :user_id, :dt, :login, :action, :product_sku, :td_stock_id)";
|
||||
@@ -330,7 +343,7 @@ class WarehouseManager {
|
||||
/**
|
||||
* Soft-delete rack logs created by a stock movement and its reversal.
|
||||
*
|
||||
* md_rack_log follows the same soft-delete convention as other tenant rows:
|
||||
* td_rack_log follows the same soft-delete convention as other tenant rows:
|
||||
* negate company_id so normal company_id filters hide the records.
|
||||
*/
|
||||
private function softDeleteRackLogs(
|
||||
@@ -399,7 +412,7 @@ class WarehouseManager {
|
||||
$params[':since'] = $since;
|
||||
}
|
||||
|
||||
$sql = "UPDATE md_rack_log
|
||||
$sql = "UPDATE td_rack_log
|
||||
SET company_id = company_id * -1
|
||||
WHERE company_id = :company_id
|
||||
AND md_rack_id = :md_rack_id
|
||||
@@ -893,7 +906,7 @@ class WarehouseManager {
|
||||
* Soft-delete a storage record and its associated rack rows.
|
||||
*
|
||||
* Blocks deletion if any rack under this storage_id is occupied.
|
||||
* Also hard-deletes md_rack_log entries for those racks before soft-deleting
|
||||
* Also hard-deletes td_rack_log entries for those racks before soft-deleting
|
||||
* the md_rack rows themselves, then soft-deletes md_storage.
|
||||
*
|
||||
* Must be called inside dbTransaction() by the caller.
|
||||
@@ -948,11 +961,11 @@ class WarehouseManager {
|
||||
]);
|
||||
$rack_ids = $sth->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
// Hard-delete md_rack_log for these racks (log records have no independent value)
|
||||
// Hard-delete td_rack_log for these racks (log records have no independent value)
|
||||
if (!empty($rack_ids)) {
|
||||
$placeholders = implode(',', array_fill(0, count($rack_ids), '?'));
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM md_rack_log WHERE md_rack_id IN ($placeholders)"
|
||||
"DELETE FROM td_rack_log WHERE md_rack_id IN ($placeholders)"
|
||||
)->execute($rack_ids);
|
||||
}
|
||||
|
||||
@@ -1328,6 +1341,88 @@ class WarehouseManager {
|
||||
$this->insertRacksInRange($storage_id, $range, $advanced_location);
|
||||
}
|
||||
|
||||
private function getRackRow(int $warehouse_id, string $zone, string $aisle, string $rack): array|false
|
||||
{
|
||||
$sql = "SELECT id, product_sku, td_stock_id
|
||||
FROM md_rack
|
||||
WHERE company_id = :company_id
|
||||
AND warehouse = :warehouse
|
||||
AND zone = :zone
|
||||
AND aisle = :aisle
|
||||
AND rack = :rack
|
||||
LIMIT 1";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse' => $warehouse_id,
|
||||
':zone' => $zone,
|
||||
':aisle' => $aisle,
|
||||
':rack' => $rack,
|
||||
]);
|
||||
|
||||
return $sth->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function validateScanLocation(
|
||||
string $mode,
|
||||
int $warehouse_id,
|
||||
string $zone,
|
||||
string $aisle,
|
||||
string $rack,
|
||||
string $product_sku = '',
|
||||
string $lot_number = '',
|
||||
string $serial_number = ''
|
||||
): void {
|
||||
if (!$warehouse_id || $zone === '' || $aisle === '' || $rack === '') {
|
||||
throw new Exception('Location label is incomplete.');
|
||||
}
|
||||
|
||||
$rack_row = $this->getRackRow($warehouse_id, $zone, $aisle, $rack);
|
||||
if (!$rack_row) {
|
||||
throw new Exception("Location {$zone}-{$aisle}-{$rack} does not exist.");
|
||||
}
|
||||
|
||||
if ($mode === 'in' || $mode === 'to') {
|
||||
if ($rack_row['product_sku'] !== null) {
|
||||
throw new Exception("Location {$zone}-{$aisle}-{$rack} is already occupied by {$rack_row['product_sku']}.");
|
||||
}
|
||||
|
||||
if ($mode === 'in' && $product_sku !== '') {
|
||||
$this->validateStockUnique($product_sku, $lot_number ?: null, $serial_number ?: null);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($mode === 'out' || $mode === 'from') {
|
||||
if ($product_sku === '') {
|
||||
throw new Exception('Scan the SKU label first.');
|
||||
}
|
||||
|
||||
$stock = $this->getRackStock($warehouse_id, $zone, $aisle, $rack);
|
||||
if (!$stock) {
|
||||
throw new Exception("Location {$zone}-{$aisle}-{$rack} has no approved stock to move out.");
|
||||
}
|
||||
|
||||
if ($stock['product_sku'] !== $product_sku) {
|
||||
throw new Exception("Location holds {$stock['product_sku']}, not {$product_sku}.");
|
||||
}
|
||||
|
||||
if ($lot_number !== '' && (string)$stock['lot_number'] !== $lot_number) {
|
||||
throw new Exception("Location holds lot '{$stock['lot_number']}', not '{$lot_number}'.");
|
||||
}
|
||||
|
||||
if ($serial_number !== '' && (string)$stock['serial_number'] !== $serial_number) {
|
||||
throw new Exception("Location holds serial '{$stock['serial_number']}', not '{$serial_number}'.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Exception('Invalid scan validation mode.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the stock record currently occupying a rack.
|
||||
*
|
||||
@@ -1754,27 +1849,29 @@ class WarehouseManager {
|
||||
* @param int|float $old_qty Previous quantity (0 for new records).
|
||||
* @param int|float $new_qty New quantity (0 to reverse/delete).
|
||||
*/
|
||||
public function adjustBalance($type, $warehouse_id, $product_sku, $old_qty, $new_qty) {
|
||||
|
||||
public function adjustBalance(
|
||||
$type, $warehouse_id, $product_sku, $old_qty, $new_qty,
|
||||
int $stock_id = 0, string $source = '', int $source_id = 0
|
||||
): void {
|
||||
$column = $type === 'in' ? 'total_in' : 'total_out';
|
||||
$delta = $new_qty - $old_qty;
|
||||
$month = date('Y-m');
|
||||
|
||||
$sql = "INSERT INTO warehouse_balance
|
||||
(company_id, warehouse_id, product_sku, month, `$column`)
|
||||
VALUES
|
||||
(:company_id, :warehouse_id, :product_sku, :month, :delta)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`$column` = `$column` + VALUES(`$column`)";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute([
|
||||
":company_id" => $this->company_id,
|
||||
":warehouse_id" => $warehouse_id,
|
||||
":product_sku" => $product_sku,
|
||||
":month" => $month,
|
||||
":delta" => $delta
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO warehouse_balance
|
||||
(company_id, warehouse_id, product_sku, month, `{$column}`)
|
||||
VALUES
|
||||
(:company_id, :warehouse_id, :product_sku, :month, :delta)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`{$column}` = `{$column}` + VALUES(`{$column}`)"
|
||||
)->execute([
|
||||
':company_id' => $this->company_id,
|
||||
':warehouse_id' => $warehouse_id,
|
||||
':product_sku' => $product_sku,
|
||||
':month' => $month,
|
||||
':delta' => $delta,
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -2050,6 +2147,10 @@ class WarehouseManager {
|
||||
$product_sku = $row['product_sku'];
|
||||
$product_name = $this->getProductName($product_sku);
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-in deletion');
|
||||
}
|
||||
|
||||
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
|
||||
|
||||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||||
@@ -2104,6 +2205,10 @@ class WarehouseManager {
|
||||
$product_sku = $row['product_sku'];
|
||||
$product_name = $this->getProductName($product_sku);
|
||||
|
||||
if ((int)$row['status'] === 1) {
|
||||
$this->assertStockMovementWindow($row['date'] ?? null, 'Stock-out deletion');
|
||||
}
|
||||
|
||||
$this->validateLatestTransaction($product_sku, $row['date'], $product_name);
|
||||
|
||||
// Soft-delete: negate company_id so row is hidden but recoverable
|
||||
@@ -2174,6 +2279,11 @@ class WarehouseManager {
|
||||
throw new Exception("Paired destination record missing — data integrity issue.");
|
||||
}
|
||||
|
||||
if ((int)$from_row['status'] === 1 || (int)$to_row['status'] === 1) {
|
||||
$this->assertStockMovementWindow($from_row['date'] ?? null, 'Stock transfer cancellation');
|
||||
$this->assertStockMovementWindow($to_row['date'] ?? null, 'Stock transfer cancellation');
|
||||
}
|
||||
|
||||
// Single global validation covers both warehouses (same SKU, same date)
|
||||
$this->validateLatestTransaction($product_sku, $from_row['date'], $product_name);
|
||||
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
class AccountFormulaManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
private array $documentTypes = [
|
||||
'invoice',
|
||||
'credit_note',
|
||||
'receipt',
|
||||
'purchase_invoice',
|
||||
'supplier_credit_note',
|
||||
'payment',
|
||||
];
|
||||
|
||||
private array $amountKeys = ['grand_total', 'total', 'tax', 'amount'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT f.*,
|
||||
COUNT(i.id) AS item_count
|
||||
FROM md_account_formula f
|
||||
LEFT JOIN md_account_formula_item i
|
||||
ON i.company_id = f.company_id
|
||||
AND i.formula_id = f.id
|
||||
WHERE f.company_id = :cid
|
||||
GROUP BY f.id
|
||||
ORDER BY f.document_type ASC, f.is_default DESC, f.formula_name ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getByType(string $document_type): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, formula_name, is_default
|
||||
FROM md_account_formula
|
||||
WHERE company_id = :cid
|
||||
AND document_type = :doc_type
|
||||
AND status = 1
|
||||
ORDER BY is_default DESC, formula_name ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':doc_type' => $document_type]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula
|
||||
WHERE company_id = :cid AND id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$formula = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$formula) return null;
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, a.account_name, a.account_type
|
||||
FROM md_account_formula_item i
|
||||
LEFT JOIN md_account a
|
||||
ON a.company_id = i.company_id
|
||||
AND a.account_code = i.account_code
|
||||
WHERE i.company_id = :cid
|
||||
AND i.formula_id = :formula_id
|
||||
ORDER BY i.sort_order ASC, i.id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':formula_id' => $id]);
|
||||
$formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $formula;
|
||||
}
|
||||
|
||||
public function save(array $data): int
|
||||
{
|
||||
$id = (int)($data['id'] ?? 0);
|
||||
$name = trim((string)($data['formula_name'] ?? ''));
|
||||
$documentType = trim((string)($data['document_type'] ?? ''));
|
||||
$description = trim((string)($data['description'] ?? ''));
|
||||
$isDefault = (int)($data['is_default'] ?? 0) === 1 ? 1 : 0;
|
||||
$status = (int)($data['status'] ?? 1) === 1 ? 1 : 0;
|
||||
$items = $data['items'] ?? [];
|
||||
|
||||
if (is_string($items)) {
|
||||
$items = json_decode($items, true) ?: [];
|
||||
}
|
||||
|
||||
if ($name === '') throw new Exception('Formula name is required.');
|
||||
if (!in_array($documentType, $this->documentTypes, true)) {
|
||||
throw new Exception('Invalid document type.');
|
||||
}
|
||||
|
||||
$validated = $this->validateItems($items);
|
||||
if (!$validated) throw new Exception('Formula requires at least one debit and one credit item.');
|
||||
|
||||
$debitKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'D'), 'amount_key'));
|
||||
$creditKeys = array_unique(array_column(array_filter($validated, fn($i) => $i['drcr'] === 'C'), 'amount_key'));
|
||||
if (!$debitKeys || !$creditKeys) {
|
||||
throw new Exception('Formula requires at least one debit and one credit item.');
|
||||
}
|
||||
|
||||
if ($isDefault) {
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_account_formula
|
||||
SET is_default = 0
|
||||
WHERE company_id = :cid AND document_type = :document_type"
|
||||
)->execute([':cid' => $this->companyId, ':document_type' => $documentType]);
|
||||
}
|
||||
|
||||
if ($id > 0) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE md_account_formula SET
|
||||
formula_name = :formula_name,
|
||||
document_type = :document_type,
|
||||
description = :description,
|
||||
is_default = :is_default,
|
||||
status = :status,
|
||||
updated_at = :updated_at
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':formula_name' => $name,
|
||||
':document_type' => $documentType,
|
||||
':description' => $description !== '' ? $description : null,
|
||||
':is_default' => $isDefault,
|
||||
':status' => $status,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
} else {
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO md_account_formula
|
||||
(company_id, formula_name, document_type, description, is_default, status, created_at)
|
||||
VALUES
|
||||
(:cid, :formula_name, :document_type, :description, :is_default, :status, :created_at)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':formula_name' => $name,
|
||||
':document_type' => $documentType,
|
||||
':description' => $description !== '' ? $description : null,
|
||||
':is_default' => $isDefault,
|
||||
':status' => $status,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$id = (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
$this->replaceItems($id, $validated);
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE md_account_formula
|
||||
SET status = 0, is_default = 0, updated_at = :updated_at
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
)->execute([
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateItems(array $items): array
|
||||
{
|
||||
$validated = [];
|
||||
$sort = 1;
|
||||
foreach ($items as $item) {
|
||||
$drcr = strtoupper(trim((string)($item['drcr'] ?? '')));
|
||||
$accountCode = trim((string)($item['account_code'] ?? ''));
|
||||
$amountKey = trim((string)($item['amount_key'] ?? ''));
|
||||
$description = trim((string)($item['description'] ?? ''));
|
||||
|
||||
if (!in_array($drcr, ['D', 'C'], true) || $accountCode === '' || $amountKey === '') continue;
|
||||
if (!in_array($amountKey, $this->amountKeys, true)) throw new Exception('Invalid amount key.');
|
||||
if (!$this->isPostingAccount($accountCode)) {
|
||||
throw new Exception($accountCode . ' is not an active posting account.');
|
||||
}
|
||||
|
||||
$validated[] = [
|
||||
'drcr' => $drcr,
|
||||
'account_code' => $accountCode,
|
||||
'amount_key' => $amountKey,
|
||||
'description' => $description,
|
||||
'sort_order' => $sort++,
|
||||
];
|
||||
}
|
||||
return $validated;
|
||||
}
|
||||
|
||||
private function isPostingAccount(string $accountCode): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM md_account
|
||||
WHERE company_id = :cid
|
||||
AND account_code = :account_code
|
||||
AND is_posting = 1
|
||||
AND status = 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':account_code' => $accountCode]);
|
||||
return (int)$sth->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
private function replaceItems(int $formulaId, array $items): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM md_account_formula_item
|
||||
WHERE company_id = :cid AND formula_id = :formula_id"
|
||||
)->execute([':cid' => $this->companyId, ':formula_id' => $formulaId]);
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO md_account_formula_item
|
||||
(company_id, formula_id, drcr, account_code, amount_key, description, sort_order)
|
||||
VALUES
|
||||
(:cid, :formula_id, :drcr, :account_code, :amount_key, :description, :sort_order)"
|
||||
);
|
||||
|
||||
foreach ($items as $item) {
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':formula_id' => $formulaId,
|
||||
':drcr' => $item['drcr'],
|
||||
':account_code' => $item['account_code'],
|
||||
':amount_key' => $item['amount_key'],
|
||||
':description' => $item['description'] !== '' ? $item['description'] : null,
|
||||
':sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -13,7 +13,7 @@ class ChartOfAccounts
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid
|
||||
ORDER BY account_code ASC"
|
||||
);
|
||||
@@ -24,7 +24,7 @@ class ChartOfAccounts
|
||||
public function getByCode(string $account_code): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -36,7 +36,7 @@ class ChartOfAccounts
|
||||
public function getByType(string $account_type): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND account_type = :type AND status = 1
|
||||
ORDER BY account_code ASC"
|
||||
);
|
||||
@@ -47,7 +47,7 @@ class ChartOfAccounts
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_account
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -59,19 +59,20 @@ class ChartOfAccounts
|
||||
public function create(array $data): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO ac_account
|
||||
(company_id, account_code, account_name, account_type, parent_code, is_posting, status)
|
||||
"INSERT INTO md_account
|
||||
(company_id, account_code, account_name, account_type, account_category, parent_code, is_posting, status)
|
||||
VALUES
|
||||
(:cid, :code, :name, :type, :parent, :posting, :status)"
|
||||
(:cid, :code, :name, :type, :category, :parent, :posting, :status)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
':cid' => $this->companyId,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':category' => !empty($data['account_category']) ? trim($data['account_category']) : '',
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
]);
|
||||
return (int)$this->pdo->lastInsertId();
|
||||
}
|
||||
@@ -79,31 +80,33 @@ class ChartOfAccounts
|
||||
public function update(int $id, array $data): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_account SET
|
||||
account_code = :code,
|
||||
account_name = :name,
|
||||
account_type = :type,
|
||||
parent_code = :parent,
|
||||
is_posting = :posting,
|
||||
status = :status
|
||||
"UPDATE md_account SET
|
||||
account_code = :code,
|
||||
account_name = :name,
|
||||
account_type = :type,
|
||||
account_category = :category,
|
||||
parent_code = :parent,
|
||||
is_posting = :posting,
|
||||
status = :status
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
':cid' => $this->companyId,
|
||||
':id' => $id,
|
||||
':code' => trim($data['account_code']),
|
||||
':name' => trim($data['account_name']),
|
||||
':type' => $data['account_type'],
|
||||
':category' => !empty($data['account_category']) ? trim($data['account_category']) : '',
|
||||
':parent' => !empty($data['parent_code']) ? trim($data['parent_code']) : null,
|
||||
':posting' => (int)($data['is_posting'] ?? 1),
|
||||
':status' => (int)($data['status'] ?? 1),
|
||||
]);
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_account SET status = 0
|
||||
"UPDATE md_account SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
@@ -112,7 +115,7 @@ class ChartOfAccounts
|
||||
public function isPostingAccount(string $account_code): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT is_posting FROM ac_account
|
||||
"SELECT is_posting FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
@@ -124,18 +127,29 @@ class ChartOfAccounts
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 1) AS active,
|
||||
SUM(status = 0) AS inactive,
|
||||
COUNT(*) AS total,
|
||||
SUM(status = 1) AS active,
|
||||
SUM(status = 0) AS inactive,
|
||||
SUM(account_type = 'asset') AS asset,
|
||||
SUM(account_type = 'liability') AS liability,
|
||||
SUM(account_type = 'equity') AS equity,
|
||||
SUM(account_type = 'revenue') AS revenue,
|
||||
SUM(account_type = 'expense') AS expense
|
||||
FROM ac_account
|
||||
FROM md_account
|
||||
WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
|
||||
public function getByCategory(string $category): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account
|
||||
WHERE company_id = :cid AND account_category = :cat AND status = 1
|
||||
ORDER BY account_code ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':cat' => $category]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ class DepartmentManager
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_department
|
||||
"SELECT * FROM md_department
|
||||
WHERE company_id = :cid
|
||||
ORDER BY dept_code ASC"
|
||||
);
|
||||
@@ -24,7 +24,7 @@ class DepartmentManager
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_department
|
||||
"SELECT * FROM md_department
|
||||
WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
@@ -35,7 +35,7 @@ class DepartmentManager
|
||||
public function create(array $data): int
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO ac_department (company_id, dept_code, dept_name, description, status)
|
||||
"INSERT INTO md_department (company_id, dept_code, dept_name, description, status)
|
||||
VALUES (:cid, :code, :name, :desc, :status)"
|
||||
);
|
||||
$sth->execute([
|
||||
@@ -51,7 +51,7 @@ class DepartmentManager
|
||||
public function update(int $id, array $data): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_department SET
|
||||
"UPDATE md_department SET
|
||||
dept_code = :code,
|
||||
dept_name = :name,
|
||||
description = :desc,
|
||||
@@ -71,7 +71,7 @@ class DepartmentManager
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"UPDATE ac_department SET status = 0
|
||||
"UPDATE md_department SET status = 0
|
||||
WHERE company_id = :cid AND id = :id"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
@@ -83,7 +83,7 @@ class DepartmentManager
|
||||
"SELECT COUNT(*) AS total,
|
||||
SUM(status = 1) AS active,
|
||||
SUM(status = 0) AS inactive
|
||||
FROM ac_department WHERE company_id = :cid"
|
||||
FROM md_department WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* FinancialReports
|
||||
*
|
||||
* Generates all financial statements from ac_balance + ac_journal_line.
|
||||
* Read-only — no writes.
|
||||
* Generates all financial statements from td_gl + td_gl_item joined with md_account.
|
||||
* Read-only — no writes. All results are reusable across API endpoints and future reports.
|
||||
*/
|
||||
class FinancialReports
|
||||
{
|
||||
@@ -16,8 +16,476 @@ class FinancialReports
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// TODO: getTrialBalance(string $from_period, string $to_period, ?int $dept_id): array
|
||||
// TODO: getProfitAndLoss(string $from_period, string $to_period, ?int $dept_id): array
|
||||
// TODO: getBalanceSheet(string $as_of_period, ?int $dept_id): array
|
||||
// TODO: getGLMovement(string $account_code, string $from_period, string $to_period): array
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────
|
||||
// Returns every posting account with brought-forward balance, period movement,
|
||||
// and carry-forward balance for the selected date range.
|
||||
|
||||
private function normalizeDate(string $date, bool $end_of_month = false): string
|
||||
{
|
||||
$date = trim($date);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $date)) {
|
||||
return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01';
|
||||
}
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
public function getTrialBalance(string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.debit ELSE 0 END), 0) AS opening_debit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) < :from_date THEN i.credit ELSE 0 END), 0) AS opening_credit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.debit ELSE 0 END), 0) AS period_debit,
|
||||
COALESCE(SUM(CASE WHEN COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date THEN i.credit ELSE 0 END), 0) AS period_credit
|
||||
FROM md_account a
|
||||
LEFT JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
LEFT JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :to_date
|
||||
WHERE a.company_id = :cid AND a.is_posting = 1 AND a.status = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
HAVING opening_debit <> 0
|
||||
OR opening_credit <> 0
|
||||
OR period_debit <> 0
|
||||
OR period_credit <> 0
|
||||
ORDER BY a.account_code
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$total_opening_debit = 0.0;
|
||||
$total_opening_credit = 0.0;
|
||||
$total_period_debit = 0.0;
|
||||
$total_period_credit = 0.0;
|
||||
$total_closing_debit = 0.0;
|
||||
$total_closing_credit = 0.0;
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$opening_balance = (float)$row['opening_debit'] - (float)$row['opening_credit'];
|
||||
$period_debit = (float)$row['period_debit'];
|
||||
$period_credit = (float)$row['period_credit'];
|
||||
$closing_balance = $opening_balance + $period_debit - $period_credit;
|
||||
|
||||
$row['opening_balance'] = $opening_balance;
|
||||
$row['opening_debit'] = $opening_balance > 0 ? $opening_balance : 0.0;
|
||||
$row['opening_credit'] = $opening_balance < 0 ? abs($opening_balance) : 0.0;
|
||||
$row['period_debit'] = $period_debit;
|
||||
$row['period_credit'] = $period_credit;
|
||||
$row['closing_balance'] = $closing_balance;
|
||||
$row['closing_debit'] = $closing_balance > 0 ? $closing_balance : 0.0;
|
||||
$row['closing_credit'] = $closing_balance < 0 ? abs($closing_balance) : 0.0;
|
||||
|
||||
$total_opening_debit += $row['opening_debit'];
|
||||
$total_opening_credit += $row['opening_credit'];
|
||||
$total_period_debit += $row['period_debit'];
|
||||
$total_period_credit += $row['period_credit'];
|
||||
$total_closing_debit += $row['closing_debit'];
|
||||
$total_closing_credit += $row['closing_credit'];
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total_opening_debit' => $total_opening_debit,
|
||||
'total_opening_credit' => $total_opening_credit,
|
||||
'total_period_debit' => $total_period_debit,
|
||||
'total_period_credit' => $total_period_credit,
|
||||
'total_closing_debit' => $total_closing_debit,
|
||||
'total_closing_credit' => $total_closing_credit,
|
||||
'total_debit' => $total_closing_debit,
|
||||
'total_credit' => $total_closing_credit,
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────
|
||||
// Returns revenue and expense accounts for the date range.
|
||||
// revenue net = credit - debit; expense net = debit - credit (normal balances).
|
||||
|
||||
public function getProfitAndLoss(string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue','expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
ORDER BY a.account_type DESC, a.account_code
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$total_revenue = 0.0;
|
||||
$total_expense = 0.0;
|
||||
foreach ($rows as $r) {
|
||||
if ($r['account_type'] === 'revenue') $total_revenue += $r['total_credit'] - $r['total_debit'];
|
||||
if ($r['account_type'] === 'expense') $total_expense += $r['total_debit'] - $r['total_credit'];
|
||||
}
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total_revenue' => $total_revenue,
|
||||
'total_expense' => $total_expense,
|
||||
'net_profit' => $total_revenue - $total_expense,
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Balance Sheet ─────────────────────────────────────────────────────────
|
||||
// Cumulative balances for asset/liability/equity accounts up to as_of_period.
|
||||
// Retained earnings = cumulative net P&L (revenue - expense) through that period.
|
||||
|
||||
public function getBalanceSheet(string $as_of_date, int $dept_id = 0): array
|
||||
{
|
||||
$as_of_date = $this->normalizeDate($as_of_date, true);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('asset','liability','equity')
|
||||
AND a.is_posting = 1 AND a.status = 1
|
||||
GROUP BY a.account_code, a.account_name, a.account_type
|
||||
ORDER BY a.account_type, a.account_code
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'revenue'
|
||||
THEN i.credit - i.debit ELSE 0 END), 0) AS net_revenue,
|
||||
COALESCE(SUM(CASE WHEN a.account_type = 'expense'
|
||||
THEN i.debit - i.credit ELSE 0 END), 0) AS net_expense
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
JOIN td_gl g ON g.id = i.gl_id
|
||||
AND g.company_id = a.company_id
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) <= :as_of
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue','expense')
|
||||
AND a.is_posting = 1
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':as_of' => $as_of_date, ':dept_id' => $dept_id]);
|
||||
$pl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'retained_earnings' => (float)($pl['net_revenue'] ?? 0) - (float)($pl['net_expense'] ?? 0),
|
||||
'as_of_date' => $as_of_date,
|
||||
'as_of_period' => substr($as_of_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
// ── Monthly Trend ─────────────────────────────────────────────────────────
|
||||
// Revenue vs expense per period for the last N months (defaults to 6).
|
||||
// Returns rows: [{period, revenue, expense}]
|
||||
|
||||
public function getMonthlyTrend(int $months = 6): array
|
||||
{
|
||||
$from = date('Y-m', strtotime('-' . ($months - 1) . ' months'));
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.period,
|
||||
SUM(CASE WHEN a.account_type = 'revenue' THEN i.credit - i.debit ELSE 0 END) AS revenue,
|
||||
SUM(CASE WHEN a.account_type = 'expense' THEN i.debit - i.credit ELSE 0 END) AS expense
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
|
||||
WHERE g.company_id = :cid AND g.period >= :from_period
|
||||
GROUP BY g.period
|
||||
ORDER BY g.period ASC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':from_period' => $from]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// ── Dashboard focused methods ─────────────────────────────────────────────
|
||||
// One method per dashboard card/section. Used by separate REST endpoints.
|
||||
|
||||
public function getDashboardPL(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i ON i.company_id = a.company_id AND i.account_code = a.account_code
|
||||
JOIN td_gl g ON g.company_id = i.company_id AND g.id = i.gl_id
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue', 'expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_type
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$revenue = 0.0;
|
||||
$expense = 0.0;
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($row['account_type'] === 'revenue') $revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
|
||||
if ($row['account_type'] === 'expense') $expense += (float)$row['total_debit'] - (float)$row['total_credit'];
|
||||
}
|
||||
|
||||
return ['revenue' => $revenue, 'expense' => $expense, 'net_profit' => $revenue - $expense];
|
||||
}
|
||||
|
||||
public function getDashboardJournalCount(): int
|
||||
{
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return (int)$sth->fetchColumn();
|
||||
}
|
||||
|
||||
public function getDashboardBySource(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT source_type, COUNT(*) AS cnt
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
GROUP BY source_type
|
||||
ORDER BY cnt DESC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getDashboardRecentEntries(int $limit = 10): array
|
||||
{
|
||||
$limit = max(1, min(100, $limit));
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id, g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
COALESCE(g.description, '') AS description,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date,
|
||||
g.period,
|
||||
SUM(i.debit) AS total_debit,
|
||||
SUM(i.credit) AS total_credit
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
WHERE g.company_id = :cid
|
||||
GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT {$limit}
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getDashboardSummary(int $trend_months = 6, int $recent_limit = 10): array
|
||||
{
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
a.account_type,
|
||||
COALESCE(SUM(i.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS total_credit
|
||||
FROM md_account a
|
||||
JOIN td_gl_item i
|
||||
ON i.company_id = a.company_id
|
||||
AND i.account_code = a.account_code
|
||||
JOIN td_gl g
|
||||
ON g.company_id = i.company_id
|
||||
AND g.id = i.gl_id
|
||||
WHERE a.company_id = :cid
|
||||
AND a.account_type IN ('revenue', 'expense')
|
||||
AND a.is_posting = 1
|
||||
GROUP BY a.account_type
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
|
||||
$total_revenue = 0.0;
|
||||
$total_expense = 0.0;
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
if ($row['account_type'] === 'revenue') {
|
||||
$total_revenue += (float)$row['total_credit'] - (float)$row['total_debit'];
|
||||
}
|
||||
if ($row['account_type'] === 'expense') {
|
||||
$total_expense += (float)$row['total_debit'] - (float)$row['total_credit'];
|
||||
}
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare("SELECT COUNT(*) FROM td_gl WHERE company_id = :cid");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$journal_count = (int)$sth->fetchColumn();
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT source_type, COUNT(*) AS cnt
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
GROUP BY source_type
|
||||
ORDER BY cnt DESC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$by_source = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id, g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
COALESCE(g.description, '') AS description,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS journal_date,
|
||||
g.period,
|
||||
SUM(i.debit) AS total_debit,
|
||||
SUM(i.credit) AS total_credit
|
||||
FROM td_gl g
|
||||
JOIN td_gl_item i ON i.gl_id = g.id AND i.company_id = g.company_id
|
||||
WHERE g.company_id = :cid
|
||||
GROUP BY g.id, g.source_type, g.reference, g.description, g.journal_date, g.period, g.created_at
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT {$recent_limit}
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
$recent = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'revenue' => $total_revenue,
|
||||
'expense' => $total_expense,
|
||||
'net_profit' => $total_revenue - $total_expense,
|
||||
'journal_count' => $journal_count,
|
||||
'trend' => $this->getMonthlyTrend($trend_months),
|
||||
'by_source' => $by_source,
|
||||
'recent' => $recent,
|
||||
];
|
||||
}
|
||||
|
||||
// ── GL Movement ───────────────────────────────────────────────────────────
|
||||
// Per-account ledger: opening balance + chronological lines with running balance.
|
||||
|
||||
public function getGLMovement(string $account_code, string $from_date, string $to_date, int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
// Opening balance = all movements before from_date
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
COALESCE(SUM(i.debit), 0) AS opening_debit,
|
||||
COALESCE(SUM(i.credit), 0) AS opening_credit
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
WHERE i.company_id = :cid
|
||||
AND i.account_code = :code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) < :from_date
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':code' => $account_code,
|
||||
':from_date' => $from_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$ob = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$opening_balance = (float)$ob['opening_debit'] - (float)$ob['opening_credit'];
|
||||
|
||||
// Period lines
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id AS gl_id,
|
||||
g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
COALESCE(g.description, '') AS gl_description,
|
||||
g.period,
|
||||
i.department_id,
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS line_description
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id
|
||||
WHERE i.company_id = :cid
|
||||
AND i.account_code = :code
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
ORDER BY g.journal_date ASC, g.id ASC, i.id ASC
|
||||
");
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':code' => $account_code,
|
||||
':from_date' => $from_date,
|
||||
':to_date' => $to_date,
|
||||
':dept_id' => $dept_id,
|
||||
]);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Account metadata
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT account_name, account_type
|
||||
FROM md_account
|
||||
WHERE company_id = :cid AND account_code = :code LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':code' => $account_code]);
|
||||
$acct = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'account_code' => $account_code,
|
||||
'account_name' => $acct['account_name'] ?? '',
|
||||
'account_type' => $acct['account_type'] ?? '',
|
||||
'opening_balance' => $opening_balance,
|
||||
'rows' => $rows,
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
/**
|
||||
* GlManager
|
||||
*
|
||||
* Owns all read/write operations on td_gl and td_gl_item.
|
||||
* Called by PostingEngine after building the journal lines.
|
||||
*
|
||||
* Lifecycle:
|
||||
* post() — first-time GL creation; throws if a record already exists.
|
||||
* replace() — snapshot current lines into td_gl.history, then delete + re-insert.
|
||||
* delete() — hard-delete both td_gl and all its td_gl_item rows (used on void).
|
||||
*/
|
||||
class GlManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
private ?PostingWindowGuard $postingWindowGuard;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id, ?PostingWindowGuard $postingWindowGuard = null)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$this->postingWindowGuard = $postingWindowGuard;
|
||||
}
|
||||
|
||||
// ── public ───────────────────────────────────────────────────────────────
|
||||
|
||||
public function getBySource(string $source_type, int $source_id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||
}
|
||||
|
||||
public function post(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void
|
||||
{
|
||||
$journal_date = $this->resolveJournalDate($meta['journal_date'] ?? null, $period);
|
||||
$this->assertPostingWindow($journal_date, 'GL posting');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_gl
|
||||
(company_id, source_type, source_id, reference, description, journal_date,
|
||||
formula_id, period, current_version, history, created_at, updated_at)
|
||||
VALUES
|
||||
(:cid, :source_type, :source_id, :reference, :description, :journal_date,
|
||||
:formula_id, :period, 1, '[]', :created_at, :updated_at)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
':reference' => $meta['reference'] ?? '',
|
||||
':description' => $meta['description'] ?? '',
|
||||
':journal_date' => $journal_date,
|
||||
':formula_id' => $formula_id,
|
||||
':period' => $period,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
$this->insertLines((int)$this->pdo->lastInsertId(), $lines);
|
||||
}
|
||||
|
||||
public function replaceManual(int $gl_id, string $reference, string $description, string $journal_date, string $period, array $lines): void
|
||||
{
|
||||
$this->assertPostingWindow($journal_date, 'Manual journal');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, current_version, formula_id, history
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid AND id = :gl_id AND source_type = 'manual'
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
$gl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$gl) throw new Exception("Manual GL entry #$gl_id not found.");
|
||||
|
||||
$version = (int)$gl['current_version'];
|
||||
$history = json_decode($gl['history'], true) ?: [];
|
||||
|
||||
$history[] = [
|
||||
'version' => $version,
|
||||
'formula_id' => 0,
|
||||
'replaced_at' => $now,
|
||||
'lines' => $this->getLines($gl_id),
|
||||
];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_gl
|
||||
SET reference = :reference, description = :description,
|
||||
journal_date = :journal_date, period = :period,
|
||||
current_version = :current_version, history = :history,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id"
|
||||
)->execute([
|
||||
':reference' => $reference,
|
||||
':description' => $description,
|
||||
':journal_date' => $journal_date,
|
||||
':period' => $period,
|
||||
':current_version' => $version + 1,
|
||||
':history' => json_encode($history),
|
||||
':updated_at' => $now,
|
||||
':id' => $gl_id,
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
}
|
||||
|
||||
public function postManual(string $reference, string $description, string $journal_date, string $period, array $lines): int
|
||||
{
|
||||
$this->assertPostingWindow($journal_date, 'Manual journal');
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_gl
|
||||
(company_id, source_type, source_id, reference, description, journal_date,
|
||||
formula_id, period, current_version, history, created_at, updated_at)
|
||||
VALUES
|
||||
(:cid, 'manual', 0, :reference, :description, :journal_date,
|
||||
0, :period, 1, '[]', :created_at, :updated_at)"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':reference' => $reference,
|
||||
':description' => $description,
|
||||
':journal_date' => $journal_date,
|
||||
':period' => $period,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
$gl_id = (int)$this->pdo->lastInsertId();
|
||||
$this->insertLines($gl_id, $lines);
|
||||
return $gl_id;
|
||||
}
|
||||
|
||||
public function replace(string $source_type, int $source_id, int $formula_id, string $period, array $lines, array $meta = []): void
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, current_version, formula_id, history, journal_date
|
||||
FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
FOR UPDATE"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
$gl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$gl) throw new Exception("GL record not found for replace.");
|
||||
|
||||
$journal_date = $this->resolveJournalDate($meta['journal_date'] ?? ($gl['journal_date'] ?? null), $period);
|
||||
$this->assertPostingWindow($journal_date, 'GL posting');
|
||||
|
||||
$gl_id = (int)$gl['id'];
|
||||
$version = (int)$gl['current_version'];
|
||||
$history = json_decode($gl['history'], true) ?: [];
|
||||
|
||||
// Snapshot current lines into history
|
||||
$history[] = [
|
||||
'version' => $version,
|
||||
'formula_id' => (int)$gl['formula_id'],
|
||||
'replaced_at' => $now,
|
||||
'lines' => $this->getLines($gl_id),
|
||||
];
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"UPDATE td_gl
|
||||
SET formula_id = :formula_id,
|
||||
journal_date = :journal_date,
|
||||
period = :period,
|
||||
current_version = :current_version,
|
||||
history = :history,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id"
|
||||
)->execute([
|
||||
':formula_id' => $formula_id,
|
||||
':journal_date' => $journal_date,
|
||||
':period' => $period,
|
||||
':current_version' => $version + 1,
|
||||
':history' => json_encode($history),
|
||||
':updated_at' => $now,
|
||||
':id' => $gl_id,
|
||||
]);
|
||||
|
||||
$this->insertLines($gl_id, $lines);
|
||||
}
|
||||
|
||||
public function delete(string $source_type, int $source_id): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT id, journal_date, period FROM td_gl
|
||||
WHERE company_id = :cid
|
||||
AND source_type = :source_type
|
||||
AND source_id = :source_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':source_type' => $source_type,
|
||||
':source_id' => $source_id,
|
||||
]);
|
||||
$gl = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
$gl_id = (int)($gl['id'] ?? 0);
|
||||
if (!$gl_id) return;
|
||||
|
||||
$journal_date = $this->resolveJournalDate($gl['journal_date'] ?? null, (string)($gl['period'] ?? ''));
|
||||
$this->assertPostingWindow($journal_date, 'GL posting deletion');
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl_item WHERE company_id = :cid AND gl_id = :gl_id"
|
||||
)->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
$this->pdo->prepare(
|
||||
"DELETE FROM td_gl WHERE id = :id AND company_id = :cid"
|
||||
)->execute([':id' => $gl_id, ':cid' => $this->companyId]);
|
||||
}
|
||||
|
||||
// ── private ──────────────────────────────────────────────────────────────
|
||||
|
||||
private function getLines(int $gl_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT account_code, department_id, debit, credit, description
|
||||
FROM td_gl_item
|
||||
WHERE company_id = :cid AND gl_id = :gl_id
|
||||
ORDER BY id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function assertPostingWindow(?string $journal_date, string $context): void
|
||||
{
|
||||
if ($this->postingWindowGuard) {
|
||||
$this->postingWindowGuard->assertOpenDate($journal_date, $context);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveJournalDate(?string $journal_date, string $period): string
|
||||
{
|
||||
$journal_date = trim((string)$journal_date);
|
||||
if ($journal_date !== '') {
|
||||
return $journal_date;
|
||||
}
|
||||
|
||||
$period = trim($period);
|
||||
return preg_match('/^\d{4}-\d{2}$/', $period) ? "{$period}-01" : '';
|
||||
}
|
||||
|
||||
private function insertLines(int $gl_id, array $lines): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT INTO td_gl_item
|
||||
(company_id, gl_id, account_code, department_id, debit, credit, description)
|
||||
VALUES
|
||||
(:cid, :gl_id, :account_code, :dept_id, :debit, :credit, :description)"
|
||||
);
|
||||
foreach ($lines as $line) {
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':gl_id' => $gl_id,
|
||||
':account_code' => $line['account_code'],
|
||||
':dept_id' => (int)($line['department_id'] ?? 0),
|
||||
':debit' => (float)($line['debit'] ?? 0),
|
||||
':credit' => (float)($line['credit'] ?? 0),
|
||||
':description' => (string)($line['description'] ?? ''),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
<?php
|
||||
class GlQueryManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
private array $invoiceTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note'];
|
||||
private array $mappingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'purchase_order'];
|
||||
private array $postingTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'purchase_order'];
|
||||
private array $journalTypes = ['invoice', 'credit_note', 'purchase_invoice', 'supplier_credit_note', 'receipt', 'payment', 'manual', 'purchase_order'];
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getPostableDocuments(string $doc_type, string $date_from = '', string $date_to = '', int $formula_id = 0): array
|
||||
{
|
||||
$doc_type = trim($doc_type);
|
||||
$date_from = $this->parseDate($date_from);
|
||||
$date_to = $this->parseDate($date_to);
|
||||
|
||||
if (!in_array($doc_type, $this->postingTypes, true)) {
|
||||
throw new Exception('Invalid doc_type.');
|
||||
}
|
||||
|
||||
if (in_array($doc_type, $this->invoiceTypes, true)) {
|
||||
$rows = $this->getInvoicePostableDocuments($doc_type, $date_from, $date_to, $formula_id);
|
||||
} elseif ($doc_type === 'receipt') {
|
||||
$rows = $this->getReceiptPostableDocuments($date_from, $date_to);
|
||||
} elseif ($doc_type === 'purchase_order') {
|
||||
$rows = $this->getPurchaseOrderPostableDocuments($date_from, $date_to, $formula_id);
|
||||
} else {
|
||||
$rows = $this->getPaymentPostableDocuments($date_from, $date_to);
|
||||
}
|
||||
|
||||
if ($formula_id > 0) {
|
||||
$rows = array_values(array_filter($rows, function($row) use ($formula_id) {
|
||||
return $row['gl_status'] == 0 || (int)$row['gl_formula_id'] === $formula_id;
|
||||
}));
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function getJournalListing(string $source_type = '', string $date_from = '', string $date_to = ''): array
|
||||
{
|
||||
$source_type = trim($source_type);
|
||||
$date_from = $this->parseDate($date_from);
|
||||
$date_to = $this->parseDate($date_to);
|
||||
|
||||
$where = ['g.company_id = :cid'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
|
||||
if ($source_type && in_array($source_type, $this->journalTypes, true)) {
|
||||
$where[] = 'g.source_type = :source_type';
|
||||
$params[':source_type'] = $source_type;
|
||||
}
|
||||
if ($date_from) {
|
||||
$where[] = 'g.journal_date >= :date_from';
|
||||
$params[':date_from'] = $date_from;
|
||||
}
|
||||
if ($date_to) {
|
||||
$where[] = 'g.journal_date <= :date_to';
|
||||
$params[':date_to'] = $date_to;
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
g.id,
|
||||
g.source_type,
|
||||
g.source_id,
|
||||
g.period,
|
||||
g.current_version,
|
||||
g.formula_id,
|
||||
COALESCE(f.formula_name, '') AS formula_name,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at,
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN r.receipt_number
|
||||
WHEN 'payment' THEN p.payment_number
|
||||
WHEN 'manual' THEN IF(g.reference != '', g.reference, CONCAT('MJE-', g.id))
|
||||
WHEN 'purchase_order' THEN po.po_number
|
||||
ELSE i.invoice_number
|
||||
END AS doc_number,
|
||||
COALESCE(
|
||||
CASE g.source_type
|
||||
WHEN 'receipt' THEN cr.contact_name
|
||||
WHEN 'payment' THEN cp.contact_name
|
||||
WHEN 'manual' THEN g.description
|
||||
WHEN 'purchase_order' THEN cpo.contact_name
|
||||
ELSE ci.contact_name
|
||||
END, ''
|
||||
) AS contact_name,
|
||||
COALESCE(SUM(gi.debit), 0) AS total_debit,
|
||||
COALESCE(SUM(gi.credit), 0) AS total_credit
|
||||
FROM td_gl g
|
||||
LEFT JOIN md_account_formula f
|
||||
ON f.company_id = g.company_id AND f.id = g.formula_id
|
||||
LEFT JOIN td_gl_item gi
|
||||
ON gi.company_id = g.company_id AND gi.gl_id = g.id
|
||||
LEFT JOIN td_invoice i
|
||||
ON g.source_type NOT IN ('receipt','payment','manual','purchase_order')
|
||||
AND i.company_id = g.company_id AND i.id = g.source_id
|
||||
LEFT JOIN md_contact ci
|
||||
ON ci.company_id = g.company_id AND ci.id = i.contact_id
|
||||
LEFT JOIN td_receipt r
|
||||
ON g.source_type = 'receipt'
|
||||
AND r.company_id = g.company_id AND r.id = g.source_id
|
||||
LEFT JOIN md_contact cr
|
||||
ON cr.company_id = g.company_id AND cr.id = r.contact_id
|
||||
LEFT JOIN td_payment p
|
||||
ON g.source_type = 'payment'
|
||||
AND p.company_id = g.company_id AND p.id = g.source_id
|
||||
LEFT JOIN md_contact cp
|
||||
ON cp.company_id = g.company_id AND cp.id = p.contact_id
|
||||
LEFT JOIN td_purchase_order po
|
||||
ON g.source_type = 'purchase_order'
|
||||
AND po.company_id = g.company_id AND po.id = g.source_id
|
||||
LEFT JOIN md_contact cpo
|
||||
ON cpo.company_id = g.company_id AND cpo.id = po.contact_id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
GROUP BY
|
||||
g.id, g.source_type, g.source_id, g.reference, g.description, g.period,
|
||||
g.current_version, g.formula_id, f.formula_name, g.created_at, g.updated_at,
|
||||
i.invoice_number, r.receipt_number, p.payment_number, po.po_number,
|
||||
ci.contact_name, cr.contact_name, cp.contact_name, cpo.contact_name
|
||||
ORDER BY g.created_at DESC, g.id DESC
|
||||
";
|
||||
|
||||
$sth = $this->pdo->prepare($sql);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getJournalDetail(int $gl_id): array
|
||||
{
|
||||
if ($gl_id <= 0) {
|
||||
throw new Exception('gl_id is required.');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT g.*,
|
||||
DATE_FORMAT(g.created_at, '%d/%m/%Y %H:%i') AS posted_at,
|
||||
DATE_FORMAT(g.updated_at, '%d/%m/%Y %H:%i') AS updated_at_fmt,
|
||||
DATE_FORMAT(g.journal_date, '%d/%m/%Y') AS journal_date_fmt,
|
||||
COALESCE(f.formula_name, '') AS formula_name
|
||||
FROM td_gl g
|
||||
LEFT JOIN md_account_formula f
|
||||
ON f.company_id = g.company_id AND f.id = g.formula_id
|
||||
WHERE g.company_id = :cid AND g.id = :gl_id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
$header = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$header) {
|
||||
throw new Exception('GL entry not found.');
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT gi.account_code, gi.department_id, gi.debit, gi.credit, gi.description,
|
||||
COALESCE(a.account_name, '') AS account_name,
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name
|
||||
FROM td_gl_item gi
|
||||
LEFT JOIN md_account a
|
||||
ON a.company_id = gi.company_id AND a.account_code = gi.account_code
|
||||
LEFT JOIN md_department d
|
||||
ON d.company_id = gi.company_id AND d.id = gi.department_id
|
||||
WHERE gi.company_id = :cid AND gi.gl_id = :gl_id
|
||||
ORDER BY gi.id ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':gl_id' => $gl_id]);
|
||||
|
||||
return [
|
||||
'header' => $header,
|
||||
'lines' => $sth->fetchAll(PDO::FETCH_ASSOC),
|
||||
];
|
||||
}
|
||||
|
||||
private function getInvoicePostableDocuments(string $doc_type, string $date_from, string $date_to, int $formula_id): array
|
||||
{
|
||||
$requires_product_mapping = $this->formulaRequiresProductMapping($doc_type, $formula_id);
|
||||
$mapping_column = in_array($doc_type, ['invoice', 'credit_note'], true)
|
||||
? 'sales_account_code'
|
||||
: 'purchase_account_code';
|
||||
|
||||
$mapping_select = $requires_product_mapping
|
||||
? ", (
|
||||
SELECT COUNT(*)
|
||||
FROM td_invoice_item ii
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = ii.company_id
|
||||
AND p.sku = ii.product_sku
|
||||
WHERE ii.company_id = i.company_id
|
||||
AND ii.invoice_id = i.id
|
||||
AND ABS(ii.total_price) > 0.0000001
|
||||
AND COALESCE(NULLIF(p.{$mapping_column}, ''), '') = ''
|
||||
) AS product_mapping_missing"
|
||||
: ", 0 AS product_mapping_missing";
|
||||
|
||||
$where = ['i.company_id = :cid', 'i.doc_type = :doc_type', 'i.status != 4'];
|
||||
$params = [':cid' => $this->companyId, ':doc_type' => $doc_type, ':source_type' => $doc_type];
|
||||
if ($date_from) { $where[] = 'i.issued_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'i.issued_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.id,
|
||||
i.invoice_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
i.grand_total,
|
||||
i.issued_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
" . ($requires_product_mapping ? '1' : '0') . " AS product_mapping_checked
|
||||
{$mapping_select}
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = i.company_id
|
||||
AND g.source_type = :source_type
|
||||
AND g.source_id = i.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY i.issued_date DESC, i.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function getReceiptPostableDocuments(string $date_from, string $date_to): array
|
||||
{
|
||||
$where = ['r.company_id = :cid', 'r.status = 1'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($date_from) { $where[] = 'r.receipt_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'r.receipt_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.id,
|
||||
r.receipt_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
r.amount AS grand_total,
|
||||
r.receipt_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
0 AS product_mapping_checked,
|
||||
0 AS product_mapping_missing
|
||||
FROM td_receipt r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = r.company_id
|
||||
AND g.source_type = 'receipt'
|
||||
AND g.source_id = r.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.receipt_date DESC, r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function getPaymentPostableDocuments(string $date_from, string $date_to): array
|
||||
{
|
||||
$where = ['r.company_id = :cid', 'r.status = 1'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($date_from) { $where[] = 'r.payment_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'r.payment_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.id,
|
||||
r.payment_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
r.amount AS grand_total,
|
||||
r.payment_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
0 AS product_mapping_checked,
|
||||
0 AS product_mapping_missing
|
||||
FROM td_payment r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = r.company_id
|
||||
AND g.source_type = 'payment'
|
||||
AND g.source_id = r.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY r.payment_date DESC, r.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function getPurchaseOrderPostableDocuments(string $date_from, string $date_to, int $formula_id): array
|
||||
{
|
||||
$requires_mapping = $this->formulaRequiresProductMapping('purchase_order', $formula_id);
|
||||
|
||||
$mapping_select = $requires_mapping
|
||||
? ", (
|
||||
SELECT COUNT(*)
|
||||
FROM td_purchase_order_item poi
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = poi.company_id
|
||||
AND p.sku = poi.product_sku
|
||||
WHERE poi.company_id = po.company_id
|
||||
AND poi.order_id = po.id
|
||||
AND ABS(poi.total_price) > 0.0000001
|
||||
AND COALESCE(NULLIF(p.purchase_account_code, ''), '') = ''
|
||||
) AS product_mapping_missing"
|
||||
: ", 0 AS product_mapping_missing";
|
||||
|
||||
$where = ['po.company_id = :cid', 'po.status = 1'];
|
||||
$params = [':cid' => $this->companyId];
|
||||
if ($date_from) { $where[] = 'po.po_date >= :date_from'; $params[':date_from'] = $date_from; }
|
||||
if ($date_to) { $where[] = 'po.po_date <= :date_to'; $params[':date_to'] = $date_to; }
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT po.id,
|
||||
po.po_number AS doc_number,
|
||||
COALESCE(c.contact_name, '') AS contact_name,
|
||||
po.grand_total,
|
||||
po.po_date AS doc_date,
|
||||
IF(g.id IS NULL, 0, 1) AS gl_status,
|
||||
COALESCE(g.formula_id, 0) AS gl_formula_id,
|
||||
" . ($requires_mapping ? '1' : '0') . " AS product_mapping_checked
|
||||
{$mapping_select}
|
||||
FROM td_purchase_order po
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = po.company_id AND c.id = po.contact_id
|
||||
LEFT JOIN td_gl g
|
||||
ON g.company_id = po.company_id
|
||||
AND g.source_type = 'purchase_order'
|
||||
AND g.source_id = po.id
|
||||
WHERE " . implode(' AND ', $where) . "
|
||||
ORDER BY po.po_date DESC, po.id DESC"
|
||||
);
|
||||
$sth->execute($params);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function formulaRequiresProductMapping(string $doc_type, int $formula_id): bool
|
||||
{
|
||||
if ($formula_id <= 0 || !in_array($doc_type, $this->mappingTypes, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM md_account_formula_item fi
|
||||
JOIN md_account_formula f
|
||||
ON f.company_id = fi.company_id
|
||||
AND f.id = fi.formula_id
|
||||
WHERE fi.company_id = :cid
|
||||
AND fi.formula_id = :formula_id
|
||||
AND f.document_type = :doc_type
|
||||
AND fi.amount_key = 'total'
|
||||
AND f.status = 1"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':formula_id' => $formula_id,
|
||||
':doc_type' => $doc_type,
|
||||
]);
|
||||
return ((int)$sth->fetchColumn()) > 0;
|
||||
}
|
||||
|
||||
private function parseDate(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') return '';
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $value, $m)) {
|
||||
return "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
?>
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* JournalManager
|
||||
*
|
||||
* Create, read, and void journal entries in ac_journal + ac_journal_line.
|
||||
* Updates ac_balance after each post.
|
||||
* Never called directly by WMS — goes through PostingManager.
|
||||
*/
|
||||
class JournalManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// TODO: create(array $header, array $lines): int — returns journal_id
|
||||
// TODO: void(int $journal_id): void
|
||||
// TODO: getBySource(string $source_type, int $source_id): ?array
|
||||
// TODO: getById(int $journal_id): ?array
|
||||
// TODO: updateBalance(string $period, string $account_code, ?int $dept_id, float $debit, float $credit): void
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
class PeriodManager
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
public function getAll(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_period
|
||||
WHERE company_id = :cid
|
||||
ORDER BY period DESC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function getById(int $id): ?array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_period WHERE company_id = :cid AND id = :id LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
public function isOpen(string $period): bool
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT status FROM ac_period
|
||||
WHERE company_id = :cid AND period = :period LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
$status = $sth->fetchColumn();
|
||||
return $status === 'open';
|
||||
}
|
||||
|
||||
// Get existing period or auto-create it as open
|
||||
public function getOrCreate(string $period): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM ac_period WHERE company_id = :cid AND period = :period LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
$row = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) return $row;
|
||||
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
|
||||
)->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
|
||||
return $this->getOrCreate($period);
|
||||
}
|
||||
|
||||
// Create a single period (manual)
|
||||
public function create(string $period): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
|
||||
)->execute([':cid' => $this->companyId, ':period' => $period]);
|
||||
}
|
||||
|
||||
// Bulk-create all 12 months of a year
|
||||
public function createYear(int $year): void
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"INSERT IGNORE INTO ac_period (company_id, period, status) VALUES (:cid, :period, 'open')"
|
||||
);
|
||||
for ($m = 1; $m <= 12; $m++) {
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':period' => sprintf('%04d-%02d', $year, $m),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function open(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE ac_period SET status = 'open', closed_at = NULL
|
||||
WHERE company_id = :cid AND id = :id AND status = 'closed'"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function close(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE ac_period SET status = 'closed', closed_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id AND status = 'open'"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function lock(int $id): void
|
||||
{
|
||||
$this->pdo->prepare(
|
||||
"UPDATE ac_period SET status = 'locked', locked_at = NOW()
|
||||
WHERE company_id = :cid AND id = :id AND status = 'closed'"
|
||||
)->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
}
|
||||
|
||||
public function getStats(): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT COUNT(*) AS total,
|
||||
SUM(status = 'open') AS open_count,
|
||||
SUM(status = 'closed') AS closed_count,
|
||||
SUM(status = 'locked') AS locked_count
|
||||
FROM ac_period WHERE company_id = :cid"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId]);
|
||||
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../classes/CompanySettingManager.php';
|
||||
|
||||
class PostingWindowGuard
|
||||
{
|
||||
private CompanySettingManager $settings;
|
||||
private ?array $window = null;
|
||||
|
||||
public function __construct(PDO $settingsPdo, int $company_id)
|
||||
{
|
||||
$this->settings = new CompanySettingManager($settingsPdo, $company_id);
|
||||
}
|
||||
|
||||
public function assertOpenDate(?string $posting_date, string $context = 'GL posting'): void
|
||||
{
|
||||
$date = $this->normalizeDate($posting_date);
|
||||
if ($date === '') {
|
||||
throw new Exception("{$context} date is required for posting-window validation.");
|
||||
}
|
||||
|
||||
$window = $this->getWindow();
|
||||
$from = $window['open_from'];
|
||||
$to = $window['open_to'];
|
||||
|
||||
if ($from !== '' && $date < $from) {
|
||||
throw new Exception("{$context} date {$date} is before the open posting window ({$from} to " . ($to ?: 'no upper limit') . ").");
|
||||
}
|
||||
|
||||
if ($to !== '' && $date > $to) {
|
||||
throw new Exception("{$context} date {$date} is after the open posting window (" . ($from ?: 'no lower limit') . " to {$to}).");
|
||||
}
|
||||
}
|
||||
|
||||
private function getWindow(): array
|
||||
{
|
||||
if ($this->window === null) {
|
||||
$open_from = $this->settings->get('posting_open_from');
|
||||
if ($open_from === null || $open_from === '') {
|
||||
$open_from = $this->settings->get('gl_open_from') ?: '';
|
||||
}
|
||||
|
||||
$open_to = $this->settings->get('posting_open_to');
|
||||
if ($open_to === null || $open_to === '') {
|
||||
$open_to = $this->settings->get('gl_open_to') ?: '';
|
||||
}
|
||||
|
||||
$this->window = [
|
||||
'open_from' => $this->normalizeDate($open_from ?: ''),
|
||||
'open_to' => $this->normalizeDate($open_to ?: ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->window;
|
||||
}
|
||||
|
||||
private function normalizeDate(?string $date): string
|
||||
{
|
||||
$date = trim((string)$date);
|
||||
if ($date === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $date, $m)) {
|
||||
$date = "{$m[3]}-{$m[2]}-{$m[1]}";
|
||||
}
|
||||
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
throw new Exception("Invalid posting date {$date}. Use YYYY-MM-DD.");
|
||||
}
|
||||
|
||||
return $date;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
* TaxReportManager
|
||||
*
|
||||
* Generates VAT and withholding tax reports from journal lines
|
||||
* tagged to tax accounts (output VAT, input VAT, WHT).
|
||||
* tagged to tax accounts (output VAT, input VAT, WHT) in md_account.
|
||||
* Matches accounts where account_name contains 'vat' or 'tax' (case-insensitive).
|
||||
* Read-only — no writes.
|
||||
*/
|
||||
class TaxReportManager
|
||||
@@ -17,7 +18,89 @@ class TaxReportManager
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
// TODO: getOutputVAT(string $from_period, string $to_period): array
|
||||
// TODO: getInputVAT(string $from_period, string $to_period): array
|
||||
// TODO: getWithholdingTax(string $from_period, string $to_period): array
|
||||
private function normalizeDate(string $date, bool $end_of_month = false): string
|
||||
{
|
||||
$date = trim($date);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
return $date;
|
||||
}
|
||||
if (preg_match('/^\d{4}-\d{2}$/', $date)) {
|
||||
return $end_of_month ? date('Y-m-t', strtotime($date . '-01')) : $date . '-01';
|
||||
}
|
||||
return date('Y-m-d');
|
||||
}
|
||||
|
||||
// ── VAT Report ────────────────────────────────────────────────────────────
|
||||
// Returns all GL lines for accounts whose account_category is 'sales_tax'
|
||||
// or 'purchase_tax'. Pass $categories = [] to get both; pass one or more
|
||||
// category strings to narrow (e.g. ['sales_tax']).
|
||||
|
||||
public function getVATReport(string $from_date, string $to_date, array $categories = [], int $dept_id = 0): array
|
||||
{
|
||||
$from_date = $this->normalizeDate($from_date);
|
||||
$to_date = $this->normalizeDate($to_date, true);
|
||||
|
||||
$valid = ['sales_tax', 'purchase_tax'];
|
||||
$cats = $categories ? array_intersect($categories, $valid) : $valid;
|
||||
|
||||
if (empty($cats)) {
|
||||
return ['rows' => [], 'total_debit' => 0.0, 'total_credit' => 0.0,
|
||||
'from_date' => $from_date, 'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7), 'to_period' => substr($to_date, 0, 7)];
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_map(fn($i) => ":cat$i", array_keys($cats)));
|
||||
$params = [':cid' => $this->companyId, ':from_date' => $from_date, ':to_date' => $to_date];
|
||||
foreach (array_values($cats) as $i => $c) { $params[":cat$i"] = $c; }
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
g.id AS gl_id,
|
||||
g.source_type,
|
||||
COALESCE(g.reference, '') AS reference,
|
||||
g.period,
|
||||
COALESCE(g.journal_date, DATE(g.created_at)) AS entry_date,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_category,
|
||||
i.department_id,
|
||||
COALESCE(d.dept_code, '') AS dept_code,
|
||||
COALESCE(d.dept_name, '') AS dept_name,
|
||||
i.debit,
|
||||
i.credit,
|
||||
COALESCE(i.description, '') AS description
|
||||
FROM td_gl_item i
|
||||
JOIN td_gl g ON g.id = i.gl_id AND g.company_id = i.company_id
|
||||
JOIN md_account a ON a.company_id = i.company_id AND a.account_code = i.account_code
|
||||
LEFT JOIN md_department d ON d.company_id = i.company_id AND d.id = i.department_id
|
||||
WHERE i.company_id = :cid
|
||||
AND COALESCE(g.journal_date, DATE(g.created_at)) BETWEEN :from_date AND :to_date
|
||||
AND a.account_category IN ($placeholders)
|
||||
AND (:dept_id = 0 OR i.department_id = :dept_id)
|
||||
ORDER BY g.period ASC, g.journal_date ASC, g.id ASC
|
||||
");
|
||||
$params[':dept_id'] = $dept_id;
|
||||
$sth->execute($params);
|
||||
$rows = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'rows' => $rows,
|
||||
'total_debit' => (float)array_sum(array_column($rows, 'debit')),
|
||||
'total_credit' => (float)array_sum(array_column($rows, 'credit')),
|
||||
'from_date' => $from_date,
|
||||
'to_date' => $to_date,
|
||||
'from_period' => substr($from_date, 0, 7),
|
||||
'to_period' => substr($to_date, 0, 7),
|
||||
];
|
||||
}
|
||||
|
||||
public function getOutputVAT(string $from_period, string $to_period, int $dept_id = 0): array
|
||||
{
|
||||
return $this->getVATReport($from_period, $to_period, ['sales_tax'], $dept_id);
|
||||
}
|
||||
|
||||
public function getInputVAT(string $from_period, string $to_period, int $dept_id = 0): array
|
||||
{
|
||||
return $this->getVATReport($from_period, $to_period, ['purchase_tax'], $dept_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
/**
|
||||
* BasePosting
|
||||
*
|
||||
* Shared build() logic for all six posting types.
|
||||
* Subclasses implement fetchDocument(), getAmounts(), getDocType(), getDocDate().
|
||||
*
|
||||
* build() returns:
|
||||
* ['formula_id' => int, 'period' => 'YYYY-MM', 'doc_date' => 'YYYY-MM-DD', 'lines' => [...]]
|
||||
*
|
||||
* Amount keys understood by formulas:
|
||||
* grand_total — full document total (abs value used)
|
||||
* total — pre-tax amount (grand_total - tax)
|
||||
* tax — tax portion
|
||||
* amount — used by receipt and payment
|
||||
*/
|
||||
abstract class BasePosting
|
||||
{
|
||||
protected PDO $pdo;
|
||||
protected int $companyId;
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
}
|
||||
|
||||
abstract protected function fetchDocument(int $id): array;
|
||||
abstract protected function getAmounts(array $doc): array;
|
||||
abstract protected function getDocType(): string;
|
||||
abstract protected function getDocDate(array $doc): string;
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$amounts = $this->getAmounts($doc);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_id = (int)($doc['department_id'] ?? 0);
|
||||
|
||||
$lines = [];
|
||||
foreach ($formula['items'] as $item) {
|
||||
$amount = abs((float)($amounts[$item['amount_key']] ?? 0));
|
||||
if ($amount == 0) continue;
|
||||
|
||||
if ($item['amount_key'] === 'total') {
|
||||
$split_lines = $this->buildSkuTotalLines($doc, $item, $amount, $dept_id);
|
||||
if ($split_lines) {
|
||||
$lines = array_merge($lines, $split_lines);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $item['drcr'] === 'D' ? $amount : 0.0,
|
||||
'credit' => $item['drcr'] === 'C' ? $amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
protected function resolveFormula(string $doc_type, ?int $formula_id): array
|
||||
{
|
||||
if ($formula_id) {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula
|
||||
WHERE company_id = :cid AND id = :id AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $formula_id]);
|
||||
} else {
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula
|
||||
WHERE company_id = :cid
|
||||
AND document_type = :doc_type
|
||||
AND is_default = 1
|
||||
AND status = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':doc_type' => $doc_type]);
|
||||
}
|
||||
|
||||
$formula = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$formula) throw new Exception("No active formula found for '{$doc_type}'.");
|
||||
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT * FROM md_account_formula_item
|
||||
WHERE company_id = :cid AND formula_id = :formula_id
|
||||
ORDER BY sort_order ASC"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':formula_id' => $formula['id']]);
|
||||
$formula['items'] = $sth->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $formula;
|
||||
}
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
private function buildSkuTotalLines(array $doc, array $formula_item, float $amount, int $dept_id): array
|
||||
{
|
||||
$column = $this->getSkuTotalAccountColumn();
|
||||
if ($column === '' || !in_array($column, ['sales_account_code', 'purchase_account_code'], true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$invoice_id = (int)($doc['id'] ?? 0);
|
||||
if ($invoice_id <= 0) return [];
|
||||
|
||||
$sth = $this->pdo->prepare("
|
||||
SELECT
|
||||
i.product_sku,
|
||||
i.product_name,
|
||||
i.total_price,
|
||||
COALESCE(NULLIF(p.{$column}, ''), '') AS mapped_account_code
|
||||
FROM td_invoice_item i
|
||||
LEFT JOIN md_product p
|
||||
ON p.company_id = i.company_id
|
||||
AND p.sku = i.product_sku
|
||||
WHERE i.company_id = :cid
|
||||
AND i.invoice_id = :invoice_id
|
||||
ORDER BY i.item_id ASC, i.id ASC
|
||||
");
|
||||
$sth->execute([':cid' => $this->companyId, ':invoice_id' => $invoice_id]);
|
||||
$items = array_values(array_filter($sth->fetchAll(PDO::FETCH_ASSOC), function($row) {
|
||||
return abs((float)($row['total_price'] ?? 0)) > 0.0000001;
|
||||
}));
|
||||
|
||||
if (!$items) return [];
|
||||
|
||||
$base_total = array_reduce($items, function($carry, $row) {
|
||||
return $carry + abs((float)($row['total_price'] ?? 0));
|
||||
}, 0.0);
|
||||
if ($base_total <= 0) return [];
|
||||
|
||||
$remaining = round($amount, 4);
|
||||
$last = count($items) - 1;
|
||||
$lines = [];
|
||||
|
||||
foreach ($items as $idx => $row) {
|
||||
$line_amount = $idx === $last
|
||||
? $remaining
|
||||
: round($amount * (abs((float)$row['total_price']) / $base_total), 4);
|
||||
$remaining = round($remaining - $line_amount, 4);
|
||||
if (abs($line_amount) <= 0.0000001) continue;
|
||||
|
||||
$account_code = trim((string)($row['mapped_account_code'] ?? ''));
|
||||
if ($account_code === '') {
|
||||
$account_code = (string)$formula_item['account_code'];
|
||||
}
|
||||
|
||||
$desc = trim((string)($formula_item['description'] ?? ''));
|
||||
$sku = trim((string)($row['product_sku'] ?? ''));
|
||||
$name = trim((string)($row['product_name'] ?? ''));
|
||||
$item_desc = trim($sku . ($name !== '' ? ' - ' . $name : ''));
|
||||
if ($item_desc !== '') {
|
||||
$desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc;
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $account_code,
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0,
|
||||
'description' => $desc,
|
||||
];
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
class CreditNotePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'credit_note'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'credit_note'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Credit note #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
// grand_total is negative in DB for credit notes — use abs()
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,42 @@
|
||||
<?php
|
||||
/**
|
||||
* InvoicePosting
|
||||
*
|
||||
* Builds the journal entry for a posted sales invoice.
|
||||
* DR: Accounts Receivable
|
||||
* CR: Revenue + Output VAT
|
||||
*/
|
||||
class InvoicePosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
class InvoicePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'invoice'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'sales_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'invoice'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Invoice #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
// TODO: build(int $invoice_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* PaymentPosting
|
||||
*
|
||||
* Builds the journal entry when a supplier payment is recorded.
|
||||
* DR: Accounts Payable
|
||||
* CR: Bank / Cash
|
||||
*/
|
||||
class PaymentPosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
class PaymentPosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'payment'; }
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_amounts = $this->getDepartmentAmounts($id);
|
||||
|
||||
$lines = [];
|
||||
foreach ($dept_amounts as $dept_id => $amount) {
|
||||
if (abs($amount) <= 0.0001) continue;
|
||||
$is_negative = $amount < 0;
|
||||
$line_amount = abs($amount);
|
||||
|
||||
foreach ($formula['items'] as $item) {
|
||||
if ($item['amount_key'] === 'tax') continue;
|
||||
|
||||
$drcr = $item['drcr'];
|
||||
if ($is_negative) {
|
||||
$drcr = $drcr === 'D' ? 'C' : 'D';
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => (int)$dept_id,
|
||||
'debit' => $drcr === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $drcr === 'C' ? $line_amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: build(int $payment_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_payment r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
WHERE r.company_id = :cid AND r.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Payment #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$amount = abs((float)$doc['amount']);
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'grand_total' => $amount,
|
||||
'total' => $amount,
|
||||
'tax' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['payment_date'] ?? date('Y-m-d');
|
||||
}
|
||||
|
||||
private function getDepartmentAmounts(int $payment_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT pi.department_id,
|
||||
pi.doc_type,
|
||||
pi.amount
|
||||
FROM td_payment_item pi
|
||||
WHERE pi.company_id = :cid
|
||||
AND pi.payment_id = :payment_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':payment_id' => $payment_id,
|
||||
]);
|
||||
|
||||
$dept_amounts = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) {
|
||||
$dept_id = (int)($line['department_id'] ?? 0);
|
||||
$amount = abs((float)($line['amount'] ?? 0));
|
||||
if ($line['doc_type'] === 'supplier_credit_note') {
|
||||
$amount *= -1;
|
||||
}
|
||||
$dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount;
|
||||
}
|
||||
|
||||
if (!$dept_amounts) throw new Exception("Payment #{$payment_id} has no allocation lines for GL posting.");
|
||||
|
||||
return $dept_amounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
class PurchaseInvoicePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'purchase_invoice'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'purchase_invoice'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Purchase invoice #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,162 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
/**
|
||||
* PurchasePosting
|
||||
*
|
||||
* Builds the journal entry when a Purchase Order is received (GRN).
|
||||
* DR: Inventory
|
||||
* CR: Accounts Payable
|
||||
* Builds the GL journal entry for a Purchase Order (GRN).
|
||||
* Typical formula: DR Inventory (per SKU) + DR Input VAT / CR Accounts Payable.
|
||||
*
|
||||
* Amount keys:
|
||||
* grand_total — full PO total (used for AP credit line)
|
||||
* tax — VAT portion
|
||||
* total — pre-tax (grand_total - tax), split per SKU via purchase_account_code
|
||||
* amount — alias of grand_total
|
||||
*/
|
||||
class PurchasePosting
|
||||
class PurchasePosting extends BasePosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
protected function getDocType(): string { return 'purchase_order'; }
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT p.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_purchase_order p
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = p.company_id AND c.id = p.contact_id
|
||||
WHERE p.company_id = :cid AND p.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Purchase order #{$id} not found.");
|
||||
|
||||
$sth2 = $this->pdo->prepare(
|
||||
"SELECT * FROM td_purchase_order_item
|
||||
WHERE company_id = :cid AND order_id = :id
|
||||
ORDER BY item_id ASC"
|
||||
);
|
||||
$sth2->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc['items'] = $sth2->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return $doc;
|
||||
}
|
||||
|
||||
// TODO: build(int $po_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['po_date'] ?? date('Y-m-d');
|
||||
}
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$amounts = $this->getAmounts($doc);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_id = (int)($doc['department_id'] ?? 0);
|
||||
|
||||
$lines = [];
|
||||
foreach ($formula['items'] as $item) {
|
||||
$amount = abs((float)($amounts[$item['amount_key']] ?? 0));
|
||||
if ($amount == 0) continue;
|
||||
|
||||
if ($item['amount_key'] === 'total') {
|
||||
$split_lines = $this->buildSkuSplitLines($doc['items'], $item, $amount, $dept_id);
|
||||
if ($split_lines) {
|
||||
$lines = array_merge($lines, $split_lines);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $item['drcr'] === 'D' ? $amount : 0.0,
|
||||
'credit' => $item['drcr'] === 'C' ? $amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildSkuSplitLines(array $items, array $formula_item, float $amount, int $dept_id): array
|
||||
{
|
||||
$items = array_values(array_filter($items, function($row) {
|
||||
return abs((float)($row['total_price'] ?? 0)) > 0.0000001;
|
||||
}));
|
||||
|
||||
if (!$items) return [];
|
||||
|
||||
$skus = array_values(array_unique(array_filter(array_column($items, 'product_sku'))));
|
||||
$account_map = [];
|
||||
if ($skus) {
|
||||
$placeholders = implode(',', array_fill(0, count($skus), '?'));
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT sku, purchase_account_code FROM md_product
|
||||
WHERE company_id = ? AND sku IN ({$placeholders})"
|
||||
);
|
||||
$sth->execute(array_merge([$this->companyId], $skus));
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$account_map[$row['sku']] = trim((string)($row['purchase_account_code'] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$base_total = array_reduce($items, function($carry, $row) {
|
||||
return $carry + abs((float)($row['total_price'] ?? 0));
|
||||
}, 0.0);
|
||||
if ($base_total <= 0) return [];
|
||||
|
||||
$remaining = round($amount, 4);
|
||||
$last = count($items) - 1;
|
||||
$lines = [];
|
||||
|
||||
foreach ($items as $idx => $row) {
|
||||
$line_amount = $idx === $last
|
||||
? $remaining
|
||||
: round($amount * (abs((float)$row['total_price']) / $base_total), 4);
|
||||
$remaining = round($remaining - $line_amount, 4);
|
||||
if (abs($line_amount) <= 0.0000001) continue;
|
||||
|
||||
$sku = trim((string)($row['product_sku'] ?? ''));
|
||||
$account_code = ($sku !== '' && ($account_map[$sku] ?? '') !== '')
|
||||
? $account_map[$sku]
|
||||
: (string)$formula_item['account_code'];
|
||||
|
||||
$name = trim((string)($row['product_name'] ?? ''));
|
||||
$desc = trim((string)($formula_item['description'] ?? ''));
|
||||
$item_desc = trim($sku . ($name !== '' ? ' - ' . $name : ''));
|
||||
if ($item_desc !== '') {
|
||||
$desc = $desc !== '' ? $desc . ': ' . $item_desc : $item_desc;
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $account_code,
|
||||
'department_id' => $dept_id,
|
||||
'debit' => $formula_item['drcr'] === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $formula_item['drcr'] === 'C' ? $line_amount : 0.0,
|
||||
'description' => $desc,
|
||||
];
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,109 @@
|
||||
<?php
|
||||
/**
|
||||
* ReceiptPosting
|
||||
*
|
||||
* Builds the journal entry when a customer receipt is recorded.
|
||||
* DR: Bank / Cash
|
||||
* CR: Accounts Receivable
|
||||
*/
|
||||
class ReceiptPosting
|
||||
{
|
||||
private PDO $pdo;
|
||||
private int $companyId;
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
public function __construct(PDO $pdo, int $company_id)
|
||||
class ReceiptPosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'receipt'; }
|
||||
|
||||
public function build(int $id, ?int $formula_id = null): array
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$this->companyId = $company_id;
|
||||
$doc = $this->fetchDocument($id);
|
||||
$formula = $this->resolveFormula($this->getDocType(), $formula_id);
|
||||
$doc_date = $this->getDocDate($doc);
|
||||
$period = substr($doc_date, 0, 7);
|
||||
$dept_amounts = $this->getDepartmentAmounts($id);
|
||||
|
||||
$lines = [];
|
||||
foreach ($dept_amounts as $dept_id => $amount) {
|
||||
if (abs($amount) <= 0.0001) continue;
|
||||
$is_negative = $amount < 0;
|
||||
$line_amount = abs($amount);
|
||||
|
||||
foreach ($formula['items'] as $item) {
|
||||
if ($item['amount_key'] === 'tax') continue;
|
||||
|
||||
$drcr = $item['drcr'];
|
||||
if ($is_negative) {
|
||||
$drcr = $drcr === 'D' ? 'C' : 'D';
|
||||
}
|
||||
|
||||
$lines[] = [
|
||||
'account_code' => $item['account_code'],
|
||||
'department_id' => (int)$dept_id,
|
||||
'debit' => $drcr === 'D' ? $line_amount : 0.0,
|
||||
'credit' => $drcr === 'C' ? $line_amount : 0.0,
|
||||
'description' => (string)($item['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'formula_id' => (int)$formula['id'],
|
||||
'period' => $period,
|
||||
'doc_date' => $doc_date,
|
||||
'lines' => $lines,
|
||||
];
|
||||
}
|
||||
|
||||
// TODO: build(int $receipt_id): array — returns [header, lines] ready for JournalManager
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT r.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_receipt r
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = r.company_id AND c.id = r.contact_id
|
||||
WHERE r.company_id = :cid AND r.id = :id
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Receipt #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$amount = abs((float)$doc['amount']);
|
||||
return [
|
||||
'amount' => $amount,
|
||||
'grand_total' => $amount,
|
||||
'total' => $amount,
|
||||
'tax' => 0.0,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['receipt_date'] ?? date('Y-m-d');
|
||||
}
|
||||
|
||||
private function getDepartmentAmounts(int $receipt_id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT ri.department_id,
|
||||
ri.doc_type,
|
||||
ri.amount
|
||||
FROM td_receipt_item ri
|
||||
WHERE ri.company_id = :cid
|
||||
AND ri.receipt_id = :receipt_id"
|
||||
);
|
||||
$sth->execute([
|
||||
':cid' => $this->companyId,
|
||||
':receipt_id' => $receipt_id,
|
||||
]);
|
||||
|
||||
$dept_amounts = [];
|
||||
foreach ($sth->fetchAll(PDO::FETCH_ASSOC) as $line) {
|
||||
$dept_id = (int)($line['department_id'] ?? 0);
|
||||
$amount = abs((float)($line['amount'] ?? 0));
|
||||
if ($line['doc_type'] === 'credit_note') {
|
||||
$amount *= -1;
|
||||
}
|
||||
$dept_amounts[$dept_id] = ($dept_amounts[$dept_id] ?? 0) + $amount;
|
||||
}
|
||||
|
||||
if (!$dept_amounts) throw new Exception("Receipt #{$receipt_id} has no allocation lines for GL posting.");
|
||||
|
||||
return $dept_amounts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/BasePosting.php';
|
||||
|
||||
class SupplierCreditNotePosting extends BasePosting
|
||||
{
|
||||
protected function getDocType(): string { return 'supplier_credit_note'; }
|
||||
|
||||
protected function getSkuTotalAccountColumn(): string { return 'purchase_account_code'; }
|
||||
|
||||
protected function fetchDocument(int $id): array
|
||||
{
|
||||
$sth = $this->pdo->prepare(
|
||||
"SELECT i.*, COALESCE(c.contact_name, '') AS contact_name
|
||||
FROM td_invoice i
|
||||
LEFT JOIN md_contact c
|
||||
ON c.company_id = i.company_id AND c.id = i.contact_id
|
||||
WHERE i.company_id = :cid AND i.id = :id AND i.doc_type = 'supplier_credit_note'
|
||||
LIMIT 1"
|
||||
);
|
||||
$sth->execute([':cid' => $this->companyId, ':id' => $id]);
|
||||
$doc = $sth->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$doc) throw new Exception("Supplier credit note #{$id} not found.");
|
||||
return $doc;
|
||||
}
|
||||
|
||||
protected function getAmounts(array $doc): array
|
||||
{
|
||||
$grand_total = abs((float)$doc['grand_total']);
|
||||
$tax = abs((float)$doc['tax']);
|
||||
return [
|
||||
'grand_total' => $grand_total,
|
||||
'tax' => $tax,
|
||||
'total' => $grand_total - $tax,
|
||||
'amount' => $grand_total,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getDocDate(array $doc): string
|
||||
{
|
||||
return $doc['issued_date'] ?? date('Y-m-d');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user