accounting workflows

This commit is contained in:
Thanakorn S
2026-05-20 10:17:02 +07:00
parent 04a683bd02
commit 7396db6ffc
199 changed files with 23539 additions and 5345 deletions
@@ -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)
+11 -1
View File
@@ -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]);
}
}
?>
+435 -166
View File
@@ -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,
]);
}
}
?>
+517
View File
@@ -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,
]);
}
}
?>
+54 -2
View File
@@ -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.
*
+355 -105
View File
@@ -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,
]);
}
}
?>
+515
View File
@@ -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,
]);
}
}
?>
+85 -1
View File
@@ -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
+255 -94
View File
@@ -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
}
}
}
+179
View File
@@ -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);
}
}
?>
+29 -5
View File
@@ -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,
]);
}
}
+49
View File
@@ -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
// ─────────────────────────────────────────────────────────────
+132 -22
View File
@@ -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);