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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user