Files
itflow/agent/modals/product/product_stock.php
johnnyq 359851c876 Security sweep of the 26.11 work: API contact lookup by phone or mobile kept to the key's clients; API credentials refuse a wrong decrypt password instead of saving unreadable usernames and passwords, link credential_vendor_id and credential_software_id, and drop another client's contact or asset; Products Receive window lists expenses only with Financial access and only for clients the agent can open; account ledger, reconcile page and ledger export show lines for clients the agent can't open as Another client with only date, kind and amount, with search and the client filter kept to reachable clients; Tax Collected folds unreachable exempt clients into one line; client-limited agents no longer see other clients' names in the export window client filters, Transfer to Client, company vendor expense lists, My Activity or the New Client duplicate check; Stripe switched off stops guest pay links and saving cards, and the Stripe return never books a payment twice; Admin > Cron job window is admins only; records per page needs the CSRF token and takes only the listed sizes; uploads/canned_responses closed to direct access; Admin > Debug no longer stops at the footer; report export audit log no longer double-escapes names; bulk interface network refuses a deleted network; API assets/update keeps the primary interface IP, MAC and network, tickets/resolve reports success, software/read by name works, clients/create takes the company currency, certificates/create returns its error, clients/archive logs as Client, and the 405 response no longer echoes the method; changelog database step count corrected to sixty
2026-10-23 12:09:07 -04:00

229 lines
12 KiB
PHP

<?php
/*
* Stock for a product that tracks it. ?id= and ?mode= picks where it opens:
* receive it came in - how many, at what unit cost, on which expense
* count what is on the shelf now - the difference is recorded
* write_off damaged, lost, used by us or sent back
* Posts product_stock_save.
*/
require_once '../../../includes/modal_header.php';
enforceUserPermission('module_sales', 2);
$product = productRow(intval($_GET['id'] ?? 0));
if (!$product) {
exit(json_encode(['error' => 'That product no longer exists.']));
}
if (!intval($product['product_track_stock'])) {
exit(json_encode(['error' => escapeHtml($product['product_name']) . " doesn't track stock. Turn on Track stock in Edit to count it."]));
}
$product_id = intval($product['product_id']);
$on_hand = floatval($product['product_stock']);
$vendor_id = intval($product['product_vendor_id']);
$mode = in_array($_GET['mode'] ?? '', ['receive', 'count', 'write_off'], true) ? $_GET['mode'] : 'receive';
$currency = (string) $product['product_currency_code'];
$cost = floatval($product['product_cost']) > 0 ? number_format(floatval($product['product_cost']), 2, '.', '') : '';
$level = floatval($product['product_reorder_level']);
// Recent expenses to link - the product's vendor's first. Expenses are Financial's,
// so only for a role that can see them, and only the clients' it can open
$expenses = [];
$can_expenses = lookupUserPermission('module_financial') >= 1;
if ($can_expenses) {
$sql = mysqli_query($mysqli, "SELECT expense_id, expense_description, expense_date, expense_amount, expense_currency_code, expense_vendor_id, vendor_name
FROM expenses
LEFT JOIN vendors ON vendor_id = expense_vendor_id
WHERE expense_archived_at IS NULL AND expense_date >= CURDATE() - INTERVAL 1 YEAR " . clientScopeSql('expense_client_id') . "
ORDER BY (expense_vendor_id = $vendor_id AND $vendor_id > 0) DESC, expense_date DESC, expense_id DESC
LIMIT 150");
while ($row = mysqli_fetch_assoc($sql)) {
$expenses[] = $row;
}
}
$modes = [
'receive' => ['label' => 'Receive', 'icon' => 'fa-dolly', 'note' => 'It came in'],
'count' => ['label' => 'Count', 'icon' => 'fa-clipboard-check', 'note' => 'What is on the shelf'],
'write_off' => ['label' => 'Write off', 'icon' => 'fa-minus-circle', 'note' => 'Damaged, lost, used'],
];
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fas fa-fw fa-warehouse me-2"></i>Stock <span class="fw-normal text-white-50 ms-1"><?= escapeHtml($product['product_name']) ?></span></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off" id="productStock<?= $product_id ?>" data-on-hand="<?= $on_hand ?>" data-level="<?= $level ?>">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="product_id" value="<?= $product_id ?>">
<div class="modal-body">
<div class="d-flex align-items-center gap-3 mb-3 p-3 rounded bg-body-tertiary">
<?= productTile('product') ?>
<div class="small flex-grow-1">
<div><strong><?= escapeHtml(billingQuantity($on_hand)) ?></strong> on the shelf<?= $product['product_location'] ? ' at ' . escapeHtml($product['product_location']) : '' ?><?= $level > 0 ? ' &middot; reorder at ' . escapeHtml(billingQuantity($level)) : '' ?></div>
<div class="text-body-secondary" data-stock-after>&nbsp;</div>
</div>
</div>
<div class="btn-group w-100 itflow-product-type mb-3" role="group" aria-label="What happened">
<?php foreach ($modes as $mode_key => $m) { ?>
<input type="radio" class="btn-check" name="mode" value="<?= $mode_key ?>" id="productStock<?= $product_id . $mode_key ?>" autocomplete="off" <?= $mode === $mode_key ? 'checked' : '' ?> data-stock-mode>
<label class="btn btn-outline-secondary text-start d-flex align-items-center gap-2" for="productStock<?= $product_id . $mode_key ?>">
<i class="fas fa-fw <?= $m['icon'] ?> fa-lg"></i>
<span>
<span class="fw-semibold d-block"><?= $m['label'] ?></span>
<span class="small opacity-75 d-none d-sm-block"><?= $m['note'] ?></span>
</span>
</label>
<?php } ?>
</div>
<div class="row g-3">
<!-- Receive -->
<div class="col-sm-5" data-stock-for="receive">
<label class="form-label">Came in <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-plus"></i></span>
<input type="text" class="form-control text-end" name="qty" inputmode="decimal" placeholder="How many" required data-stock-qty>
</div>
</div>
<div class="col-sm-7" data-stock-for="receive">
<label class="form-label">Unit cost</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-shopping-cart"></i></span>
<input type="text" class="form-control text-end" name="unit_cost" inputmode="decimal" value="<?= $cost ?>" placeholder="0.00" data-stock-cost>
</div>
</div>
<div class="col-12" data-stock-for="receive">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="update_cost" value="1" id="productStockCost<?= $product_id ?>" data-stock-update-cost>
<label class="form-check-label" for="productStockCost<?= $product_id ?>">Make this the product's cost</label>
<div class="form-text">Its cost is <?= $cost !== '' ? escapeHtml(billingMoney($cost, $currency)) : 'not set' ?> - margins and the stock's value use it.</div>
</div>
</div>
<?php if ($can_expenses) { ?>
<div class="col-12" data-stock-for="receive">
<label class="form-label">Bought on</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-receipt"></i></span>
<select class="form-select select2" name="expense">
<option value="0">- An expense from the last year -</option>
<?php foreach ($expenses as $expense) { ?>
<option value="<?= intval($expense['expense_id']) ?>"><?= escapeHtml($expense['expense_date'] . ' - ' . ($expense['vendor_name'] ? $expense['vendor_name'] . ' - ' : '') . mb_strimwidth((string) $expense['expense_description'], 0, 60, '...') . ' - ' . billingMoney($expense['expense_amount'], $expense['expense_currency_code'])) ?></option>
<?php } ?>
</select>
</div>
</div>
<?php } ?>
<!-- Count -->
<div class="col-sm-6" data-stock-for="count">
<label class="form-label">On the shelf <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-clipboard-check"></i></span>
<input type="text" class="form-control text-end" name="counted" inputmode="decimal" placeholder="What you counted" required data-stock-counted>
</div>
</div>
<!-- Write off -->
<div class="col-sm-5" data-stock-for="write_off">
<label class="form-label">How many <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-minus"></i></span>
<input type="text" class="form-control text-end" name="qty" inputmode="decimal" placeholder="Off the shelf" required data-stock-qty>
</div>
</div>
<div class="col-sm-7" data-stock-for="write_off">
<label class="form-label">Why</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-question-circle"></i></span>
<select class="form-select" name="reason">
<?php foreach (productWriteOffReasons() as $reason) { ?>
<option><?= escapeHtml($reason) ?></option>
<?php } ?>
</select>
</div>
</div>
<div class="col-12">
<label class="form-label">Note</label>
<div class="input-group">
<span class="input-group-text align-items-start pt-2"><i class="fas fa-fw fa-sticky-note"></i></span>
<textarea class="form-control" name="note" rows="2" placeholder="Order number, serials, who - kept with the move"></textarea>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="product_stock_save" class="btn btn-primary text-bold"><i class="fas fa-check me-2"></i><span data-stock-submit>Save</span></button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fas fa-times me-2"></i>Cancel</button>
</div>
</form>
<script>
(function () {
var form = document.getElementById('productStock<?= $product_id ?>');
if (!form || form.dataset.wired) {
return;
}
form.dataset.wired = '1';
var onHand = Number(form.dataset.onHand);
var level = Number(form.dataset.level);
var after = form.querySelector('[data-stock-after]');
var submit = form.querySelector('[data-stock-submit]');
var labels = { receive: 'Receive', count: 'Save Count', write_off: 'Write Off' };
function number(input) {
var text = String(input ? input.value : '').replace(/[\s,]/g, '');
return text === '' ? NaN : Number(text);
}
function fmt(n) {
return String(Math.round(n * 100) / 100);
}
function update() {
var mode = (form.querySelector('[data-stock-mode]:checked') || {}).value;
form.querySelectorAll('[data-stock-for]').forEach(function (section) {
var on = section.dataset.stockFor === mode;
section.hidden = !on;
section.querySelectorAll('input, select, textarea').forEach(function (input) {
input.disabled = !on;
});
});
submit.textContent = labels[mode] || 'Save';
var text = '';
var section = form.querySelector('[data-stock-for="' + mode + '"]');
if (mode === 'receive' || mode === 'write_off') {
var q = number(section.querySelector('[data-stock-qty]'));
if (q > 0) {
var next = mode === 'receive' ? onHand + q : onHand - q;
text = next < 0 ? 'Only ' + fmt(onHand) + ' on the shelf' : fmt(next) + ' on the shelf after this';
}
} else if (mode === 'count') {
var c = number(form.querySelector('[data-stock-counted]'));
if (!isNaN(c) && c >= 0) {
var diff = Math.round((c - onHand) * 100) / 100;
text = diff === 0 ? 'The same as ITFlow has' : (diff > 0 ? '+' : '') + fmt(diff) + ' from what ITFlow has';
}
}
if (text && level > 0 && mode !== 'receive') {
var left = mode === 'count' ? number(form.querySelector('[data-stock-counted]')) : onHand - number(section.querySelector('[data-stock-qty]'));
if (left <= level) {
text += ' - at or under the reorder level';
}
}
after.innerHTML = text ? text.replace(/&/g, '&amp;').replace(/</g, '&lt;') : '&nbsp;';
}
form.addEventListener('input', update);
form.addEventListener('change', update);
update();
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';