diff --git a/CHANGELOG.md b/CHANGELOG.md index a0f06357c..f3dc6da2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 2.9.4) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 2.9.5) in the same pass. Nothing else is required. -This is a big release, with fifteen database steps in one go, so take a backup before you update. +This is a big release, with sixteen database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -17,7 +17,7 @@ The update also clears out data that deletes in older versions left behind, all - the tasks, approvals and history of deleted tickets, the projects of deleted clients, and tickets still pointing at a deleted project - project template entries whose ticket template is gone, and the same service link saved twice -It also gives every existing network a type (LAN, WAN, Guest and so on) guessed from its name or description, which you can change afterwards, and fills in the issue date, names covered and fingerprint of every certificate already on file. Invoices made from a quote before the update are linked to that quote, so each one shows where it came from. Products that ever had stock added keep tracking their stock, and the rest are set to ordered as needed (see Breaking Changes, Products). +It also gives every existing network a type (LAN, WAN, Guest and so on) guessed from its name or description, which you can change afterwards, and fills in the issue date, names covered and fingerprint of every certificate already on file. Invoices made from a quote before the update are linked to that quote, so each one shows where it came from. Products that ever had stock added keep tracking their stock, and the rest are set to ordered as needed (see Breaking Changes, Products). Their past stock changes are sorted into received, sold and returned from their notes, and linked to the invoice they name. If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. @@ -61,6 +61,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The Products page shows everything, with tabs for Services, Products, Recurring and Stocked. It used to open on Services. Old links to `products.php?type=service` and `?type=product` still open those tabs. - Clicking a product opens its new page instead of the Edit window. - Import finds columns by their heading, so a file can have any of the fields in any order. The old three column file (Product Name, Description, Price) still works. A row whose code is already in the catalog is skipped unless Update products with the same code is on. + - Add Stock is replaced by one Stock window with Receive, Count and Write off, on the product's page and in the list's menu. Count records the difference from what you counted, and Write off asks why. + - Cancelling or deleting an invoice puts its tracked products back on the shelf. Deleting one used to leave the stock gone for good. + - Copying an invoice, or making an invoice from a quote, is refused when a tracked product on it is short, the same as adding the line by hand. Add the stock first. + - Invoices made from recurring invoices are now linked to their products, so they count as sold on the product's page and in the reports. They don't move stock. + - Stock can be counted in fractions (up to two decimal places), such as cable by the metre. - Notifications now follow access too. An agent only sees notifications for clients and areas they can open, so someone without sales access no longer sees invoice and quote notifications. - Drop-downs: Tom Select is gone, replaced by ITFlow's own picker. It works the way you are used to: type to search, tick several in a multi-select, and type a new value in fields that allow it, such as ticket watchers. See Developer Updates if you have custom pages that used Tom Select. - Several small windows are gone because the change is now made where the value is shown: @@ -119,7 +124,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - **Dashboard** (rebuilt) - Needs attention along the top: - tickets past their SLA, unassigned tickets and overdue projects - - overdue and draft invoices, and resolved tickets not invoiced yet + - overdue and draft invoices, resolved tickets not invoiced yet, and stock to reorder - domains, certificates, licenses and warranties expiring in the next 30 days - services needing attention and service reviews due @@ -148,6 +153,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Quotes Pipeline: quotes won, lost and open, the win rate by count and by value, and open quotes about to expire. - Mileage: trips by agent and by client, valued at your rate per mile. - Budget vs Actual: set a monthly budget for each expense category right on the report, or copy last year's, and see spending against it by month and category. + - Inventory: what is on the shelf and what it is worth at cost, what came in, and the cost of what was sold and written off, by month. Every tracked product with its stock, value and reorder level, and every stock move in the period. - Reworked reports: - Profit & Loss: monthly or quarterly, with last year and the change beside each line, and the margin. - Income and Expenses: by category across the year, with last year drawn as a dashed line. @@ -244,17 +250,21 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Track stock, for products: a tracked product comes off the shelf as it is invoiced and can't be sold past what is there. The rest are ordered as needed, such as drop-shipped hardware. - The list: - The top shows what catalog lines bring in each month on recurring invoices, what was sold over the last 90 days with the top seller, and what the stock on the shelf is worth at cost. - - Tabs for Services, Products, Recurring and Stocked, a vendor filter, and search by part number and vendor too. + - Tabs for Services, Products, Recurring, Stocked and To reorder, a vendor filter, and search by part number and vendor too. - Each row shows how it is billed, the stock and where it is kept, the vendor and part number, and the price with its cost and margin. - Each product has a page: - Its price and margin, what it sold over 12 months, and what it brings in a month on recurring invoices. - - Its stock, where it is kept, what that is worth and the last moves, each with the expense it came in on. + - Its stock, where it is kept and what that is worth, and a Stock tab with every move and what was on the shelf after it. - Where it is used: the recurring invoices it is on, open quotes, the invoices it was sold on, and its history. - Notes save as you click away. Copy makes a new one to start from. - New and Edit are one window, with Service or Product at the top and the margin worked out as you type the price and cost. - - Add Stock shows what will be on the shelf, and links the purchase to an expense from the last year, the vendor's first. - - Import takes every field. Categories, taxes and vendors are matched by name. A new price list can update products by their code, and a file with only Code and Price will do. Download the template for the layout. - - Export adds cost, margin, how it is billed, vendor, part number and stock. + - Stock, for products that track it: + - Every change is recorded as Received, Sold, Returned, Counted or Written off, with the unit cost, the invoice or expense behind it, a note and who made it. + - One Stock window to Receive (at a unit cost, which can become the product's cost, linked to an expense from the last year, the vendor's first), Count (type what is on the shelf and the difference is recorded) or Write off (damaged, lost or stolen, used by us, or sent back to the vendor). It shows what will be on the shelf as you type. + - Reorder at and Order on each product. Products at or under their level show on the To reorder tab, with a badge on the list, on Products in the menu and in Needs attention on the dashboard. A Low Stock notification goes out when stock drops to the level or runs out, saying how many to order and from whom. + - Count Stock lists every tracked product, or one location's, with what ITFlow has and a box for what you counted. The difference shows as you type, Enter moves to the next box, and blank ones are left alone. Print it as a count sheet to take to the shelf. + - Import takes every field, including Reorder At and Order. Categories, taxes and vendors are matched by name. A new price list can update products by their code, and a file with only Code and Price will do. Download the template for the layout. + - Export adds cost, margin, how it is billed, vendor, part number, stock and its value, and the reorder level, and can export just what is to reorder. - Invoices, quotes and recurring invoices show agents their margin on lines from products with a cost, at today's cost. A quote shows it up front and each month. - Our vendors' pages have a Products tab listing what we buy from them. - Search finds products by part number and opens their page. @@ -434,6 +444,8 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Billing: copying an invoice logged its old number with the prefix twice. - Products: Restore in the archived list's bulk actions did nothing. Deleting a product left its stock records behind. - Products: Add Stock could record zero units, or link an expense that no longer existed. +- Products: deleting an invoice did not put its stock back. Copying an invoice or making one from a quote lost the link to the product, so the copy took no stock and the product did not count it as sold. +- Products: a stock change for a fraction of a unit, such as 1.5, was rounded to a whole number. - Billing: Make Invoice on a quote could make a second invoice when pressed twice or from two tabs, and could invoice a quote that was never accepted or had been declined. Its audit log entry named the quote with the current quote prefix instead of the quote's own. - Recurring invoices: if cron missed a night, that recurring invoice stopped billing until someone moved its date. The missed run is now billed the next night, up to 7 days late. - Invoicing a ticket: on an invoice that already had lines, its line went in near the top instead of at the end. A ticket already on an invoice could be billed again from a second window, and a ticket with 100 hours or more logged had no hours filled in. @@ -498,6 +510,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - 2.9.2: `settings.config_time_increment` (15), `config_time_rounding` ('nearest'), `config_time_minimum` (0) and `config_time_item_name` ('Support [Hourly]'). - 2.9.3: `quote_items.item_frequency` (NULL for one time, or `month`, `quarter` or `year`), `quotes.quote_monthly_amount`, `invoices.invoice_quote_id` and `recurring_invoices.recurring_invoice_quote_id`. Invoices are linked to their quote from the "Invoice created from quote QUO-n" history the old Make Invoice wrote, matched on the client too. - 2.9.4: `products.product_cost`, `product_vendor_id`, `product_vendor_part`, `product_notes`, `product_frequency` (NULL once, or `month`, `quarter`, `year`), `product_seat_source` (NULL, `contacts` or `assets`) and `product_track_stock`, set for products with any `product_stock` rows. Keys on `item_product_id` in `invoice_items`, `quote_items` and `recurring_invoice_items`, and on `product_stock.stock_product_id`. + - 2.9.5: `product_stock.stock_qty` becomes `decimal(15,2)`, with `stock_type` (`received`, `sold`, `returned`, `adjusted` or `written_off`, default `adjusted`), `stock_unit_cost`, `stock_invoice_id` (keyed) and `stock_user_id`. Existing rows take their invoice from a note ending "Invoice n" and their type from the sign and the note. `products.product_reorder_level` and `product_reorder_qty`. - New shared function files, loaded by `functions.php`: `functions/asset.php`, `asset_interface.php`, `certificate.php`, `client_overview.php`, `contact.php`, `credential.php`, `dashboard.php`, `links.php`, `location.php`, `notifications.php`, `project.php`, `rack.php`, `search.php`, `service.php`, `software.php`, `tags.php`, `ticket.php`, `ticket_fields.php`, `user.php` and `vendor.php`. They hold the queries and rules, so the pages, the API and cron share one definition. - Removed post models: `agent/post/{asset_interface,certificate,credential,domain,event,location,network_ip,network,recurring_ticket,vendor}_model.php`, `admin/post/user_model.php` and `api/v1/certificates/certificate_model.php`. Their handlers call the shared functions above. Interface handlers moved to `agent/post/asset_interface.php`. - New scripts: `js/itflow_select.js`, `quick_pick.js`, `tag_picker.js`, `global_search.js`, `notifications.js`, `dashboard.js`, `client_overview.js`, `client_switcher.js`, `credentials.js` and `autosubmit.js`, and `agent/js/calendar.js`, `calendar_event_modal.js`, `files.js`, `interface_form.js`, `network_form.js`, `project_modal.js` and `ticket_schedule_modal.js`. @@ -591,7 +604,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - PDFs: `billingPdf($type, $row, $variant)` returns the file name and data, and `billingPdfSend()` sends it. The drawing is in `functions/billing_pdf.php` (the `BillingPdf` TCPDF class and `billingPdfRender()`), loaded only when a PDF is made. `export_invoice_pdf`, `export_invoice_packing_slip`, `export_quote_pdf` and the guest downloads use it. The guest downloads compare the URL key with `hash_equals()` and return 404 when it is wrong. - Guest pages: `billingGuestViewed()` marks a sent document viewed, logs the view in plain text and notifies. `billingClientOpenInvoices()` is the client's statement. - CSS: the `.itflow-paper-*` and `.itflow-billing-*` classes in `css/itflow_custom.css`, with print rules that print only the document. - - Making invoices from recurring: `recurringInvoiceGenerate($id, $by, $early, $run)` is the one path, used by `cron/nightly_tasks.php` and `force_recurring`. It refuses when `$run` (the run date the caller means) is no longer the next date, or, without `$early`, when that date is still to come. It claims the run with a conditional `UPDATE` on the next date, then refreshes the lines, applies the period words, copies the lines (without `item_product_id`), logs history on both documents and emails when set to. It returns the new invoice id, or 0. + - Making invoices from recurring: `recurringInvoiceGenerate($id, $by, $early, $run)` is the one path, used by `cron/nightly_tasks.php` and `force_recurring`. It refuses when `$run` (the run date the caller means) is no longer the next date, or, without `$early`, when that date is still to come. It claims the run with a conditional `UPDATE` on the next date, then refreshes the lines, applies the period words, copies the lines (with `item_product_id`, but no stock moves), logs history on both documents and emails when set to. It returns the new invoice id, or 0. - Cron bills active, unarchived recurring invoices due from `RECURRING_INVOICE_GRACE_DAYS` (7) ago up to today, passing each one's next date as `$run`. `force_recurring` takes `run` from the page's link, and a link without it bills the current next date. - Schedules: `recurringInvoiceFrequencies()` (month, quarter, year), `recurringInvoiceMonths()`, `recurringInvoicePeriodWord()`, `recurringInvoiceMonthly()` and `recurringInvoiceMonthlySql()` (use it in SQL sums), `recurringInvoiceAfter()`, `recurringInvoicePeriod()` and `recurringInvoiceTokens()`. `validateRecurringFrequency()` accepts `quarter`. The edit handler keeps `recurring_invoice_anchor_day` unless the next date changes, and the add and edit handlers run their dates through `validateDate()`. - Seats: `recurringSeatSources()`, `recurringSeatFilter()`, `recurringSeatCount()` (null when a license is gone or isn't the client's), `recurringSeatDescribe()`, `recurringSeatOptions()`, `recurringSeatLines()` and `recurringInvoiceRefresh()`, which syncs the seat quantities and takes each line's tax at its current rate. `billing_item_save` takes `seat_source` (empty for typed), `seat_tag`, `seat_types[]`, `seat_software_id`, `seat_count` (`used` or `total`) and `seat_minimum` on recurring lines. A form without `seat_source` leaves the seat settings alone. @@ -613,6 +626,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - New page `agent/product.php?product_id=`. `agent/modals/product/product_form.php` holds the fields of `product_add.php` and `product_edit.php`. Handlers: `copy_product` (GET), and `product_set_notes` in `agent/ajax.php` for the page's notes. `import_products_csv` maps columns by heading and takes `update_existing`. `agent/post/product_model.php` is removed. The list's bulk Restore posts `bulk_restore_products`. - Export columns for products: type, code, cost, margin, billed, vendor, part number, stock, location and notes. The products export takes `vendor` and the list's `type` tabs. - Seeds: `demoEnsureProducts($mysqli, $currency, $vendors)` with `demoProductExtras()`, and a seventh column on `starterContentProducts()` for how each is billed (`month`, `month:contacts`). + - Stock ledger, in `functions/products.php`: `productStockMove($product_id, $qty, $type, $opts)` is the one path that writes `product_stock` (`$opts`: `note`, `unit_cost`, `invoice_id`, `item_id`, `expense_id`). The unit cost defaults to the product's cost for every type but `received`, and it records `stock_user_id` and calls `productReorderCheck()`, which raises a `Low Stock` notification (a `module_sales` type) when a move takes stock down to the reorder level or out. Also `productStockTypes()`, `productWriteOffReasons()`, `productOnHand()`, `productReorderSql()` (the one definition of to reorder), `productReorderCount()`, `productStockQty()`, `productStockShortfall($items)` (a message for the first short product, or '') and `productStockReturnInvoice($invoice_id, $why)`, which returns what an invoice still has out from the net of its moves, so it can't return twice. `productStockMoves($id, $limit)` adds the running balance, the invoice, expense and agent. + - Callers: `billingItemSave()` and `billingItemDelete()` record `sold` and `returned` with the invoice and line. `billingCopyLines()` carries `item_product_id` (dropped when the product is gone) and records `sold` when copying onto an invoice. `quoteConvert()` and `add_invoice_copy` check `productStockShortfall()` before creating anything. `cancel_invoice` and `delete_invoice` call `productStockReturnInvoice()`, and `api/v1/invoice_items/create.php` links its move to the invoice and line. + - Handlers: `product_stock_save` (POST `product_id`, `mode` and `note`, with `qty`, `unit_cost`, `update_cost` and `expense` to receive, `counted` to count, or `qty` and `reason` to write off) from `agent/modals/product/product_stock.php?id=&mode=`, and `product_stock_count` (POST `counted[product_id]` and `note`) from the new page `agent/products_count.php`. Removed: `agent/modals/product/product_stock_add.php` and `add_product_stock`. + - `productSave()` and `productCopy()` take `reorder_level` and `reorder_qty`, with import aliases and export columns for them and the stock value. The products list and export take `type=reorder`. + - `$num_products_reorder` in `agent/includes/get_side_nav_counts.php`, `Stock to reorder` in `dashboardAttention()`, and `inventory` in `reportCatalog()` (Sales & Billing, `module_sales`, accounting) for `agent/reports/inventory.php`. - The billing helpers (`billingTimeInvoice()`, `quoteConvert()`, `recurringInvoiceGenerate()` and the rest) take the agent's name as plain text. Their handlers now pass `stripslashes($session_name)`, so a name with an apostrophe no longer shows a backslash in history and the audit log. - Sidebar brand: `agent/css/sidebar_brand_fix.css` and `agent/js/sidebar_brand_fix.js` from 26.10 are replaced by CSS in `css/itflow_custom.css`. `includes/load_company_settings.php` now sets `$session_company_logo`. - `alertMessageHtml()` no longer double-encodes, and save messages built from `escapeSql()` values use `stripslashes()` at the flash. diff --git a/admin/database_updates/2.9.5.php b/admin/database_updates/2.9.5.php new file mode 100644 index 000000000..36aa4f8a4 --- /dev/null +++ b/admin/database_updates/2.9.5.php @@ -0,0 +1,36 @@ + 0 THEN 'sold' + WHEN `stock_qty` > 0 AND `stock_invoice_id` > 0 AND `stock_note` LIKE 'Returned%' THEN 'returned' + WHEN `stock_qty` > 0 THEN 'received' + ELSE 'adjusted' END"); + + // When to buy more, and how many - a product at or under its reorder level is + // listed to reorder, and agents are told as it gets there + mysqli_query($mysqli, "ALTER TABLE `products` + ADD COLUMN `product_reorder_level` decimal(15,2) NOT NULL DEFAULT 0.00 AFTER `product_track_stock`, + ADD COLUMN `product_reorder_qty` decimal(15,2) NOT NULL DEFAULT 0.00 AFTER `product_reorder_level`"); diff --git a/agent/includes/get_side_nav_counts.php b/agent/includes/get_side_nav_counts.php index c973c54f5..94294c066 100644 --- a/agent/includes/get_side_nav_counts.php +++ b/agent/includes/get_side_nav_counts.php @@ -28,6 +28,9 @@ $num_recurring_invoices = $row['num']; // Unbilled tickets - billable, done and on no invoice (Billing > Unbilled Time) $num_unbilled_tickets = $config_module_enable_accounting == 1 && $config_module_enable_ticketing == 1 && lookupUserPermission('module_sales') >= 1 ? billingUnbilledCount() : 0; +// Products whose stock is at or under the reorder level, or out +$num_products_reorder = $config_module_enable_accounting == 1 && lookupUserPermission('module_sales') >= 1 ? productReorderCount() : 0; + // Open Quotes Count $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT('quote_id') AS num FROM quotes WHERE (quote_status = 'Sent' OR quote_status = 'Viewed') AND quote_archived_at IS NULL " . clientScopeSql('quote_client_id') . "")); $num_open_quotes = $row['num']; diff --git a/agent/includes/side_nav.php b/agent/includes/side_nav.php index 6a162b6f6..2cbe5132a 100644 --- a/agent/includes/side_nav.php +++ b/agent/includes/side_nav.php @@ -175,9 +175,14 @@ diff --git a/agent/modals/product/product_export.php b/agent/modals/product/product_export.php index e1b5bb7ca..fe76b6763 100644 --- a/agent/modals/product/product_export.php +++ b/agent/modals/product/product_export.php @@ -62,6 +62,7 @@ ob_start(); + diff --git a/agent/modals/product/product_form.php b/agent/modals/product/product_form.php index d2fd50ae8..704f9e276 100644 --- a/agent/modals/product/product_form.php +++ b/agent/modals/product/product_form.php @@ -29,7 +29,9 @@ $form_frequency = quoteFrequency($form_value('product_frequency')); $form_seat = $form_value('product_seat_source'); // A new product tracks its stock unless told otherwise $form_track = $form_edit ? intval($form_product['product_track_stock']) === 1 : true; -$form_stock = $form_edit ? intval($form_product['product_stock']) : 0; +$form_stock = $form_edit ? floatval($form_product['product_stock']) : 0; +$form_reorder_level = $form_edit && floatval($form_product['product_reorder_level']) > 0 ? billingQuantity($form_product['product_reorder_level']) : ''; +$form_reorder_qty = $form_edit && floatval($form_product['product_reorder_qty']) > 0 ? billingQuantity($form_product['product_reorder_qty']) : ''; $form_price = $form_edit ? number_format(floatval($form_product['product_price']), 2, '.', '') : ''; $form_cost = $form_edit && floatval($form_product['product_cost']) > 0 ? number_format(floatval($form_product['product_cost']), 2, '.', '') : ''; $form_currency = $form_edit ? (string) $form_product['product_currency_code'] : (string) $session_company_currency; @@ -186,9 +188,26 @@ if ($form_vendor_id && !isset($form_vendors[$form_vendor_id]) && $form_value('ve
Invoicing it takes it off the shelf, and it can't be sold past what is there. Add stock as it comes in.
-
Ordered as needed - invoicing it doesn't touch stock.
+
Ordered as needed - invoicing it doesn't touch stock.
-
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
Agents are told when it gets down to this, and it is listed under Reorder with the vendor and part number.
+
+
@@ -245,7 +264,9 @@ if ($form_vendor_id && !isset($form_vendors[$form_vendor_id]) && $form_value('ve el.hidden = type !== 'product'; }); if (type === 'product') { - form.querySelector('[data-product-location]').hidden = !track.checked; + form.querySelectorAll('[data-product-tracked]').forEach(function (el) { + el.hidden = !track.checked; + }); form.querySelector('[data-product-track-on]').hidden = !track.checked; form.querySelector('[data-product-track-off]').hidden = track.checked; } diff --git a/agent/modals/product/product_import.php b/agent/modals/product/product_import.php index 4b7d8d296..a2df5a648 100644 --- a/agent/modals/product/product_import.php +++ b/agent/modals/product/product_import.php @@ -21,7 +21,7 @@ ob_start();

A CSV with a heading row. Columns can be in any order, and only Name is needed:

Name, Type (Service or Product), Code, Category, Description, Price, Cost, Tax, Billed (One time, Monthly, Quarterly, Yearly), - Counted (Per user, Per device), Vendor, Part Number, Track Stock (Yes or No), Location, Notes. + Counted (Per user, Per device), Vendor, Part Number, Track Stock (Yes or No), Reorder At, Order, Location, Notes. Categories, taxes and vendors are matched by name to the ones you have.

diff --git a/agent/modals/product/product_stock.php b/agent/modals/product/product_stock.php new file mode 100644 index 000000000..ef2d447c1 --- /dev/null +++ b/agent/modals/product/product_stock.php @@ -0,0 +1,222 @@ + '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 = []; +$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 + 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(); + +?> + +
+ + + + + + +
+ + + + '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 = intval($product['product_stock']); -$vendor_id = intval($product['product_vendor_id']); - -// Recent expenses to link - the product's vendor's first -$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 - 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; -} - -ob_start(); - -?> - -
- - - - - - -
- - - -$invoice_prefix$invoice_number cancelled", 'error'); @@ -279,6 +276,9 @@ if (isset($_GET['delete_invoice'])) { enforceClientAccess(); + // What it took off the shelf goes back before its lines go + productStockReturnInvoice($invoice_id, stripslashes("$invoice_prefix$invoice_number deleted")); + mysqli_query($mysqli,"DELETE FROM invoices WHERE invoice_id = $invoice_id"); //Delete Items Associated with the Invoice diff --git a/agent/post/product.php b/agent/post/product.php index cceba3f92..9e342227b 100644 --- a/agent/post/product.php +++ b/agent/post/product.php @@ -12,7 +12,7 @@ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); /** The product fields from a posted form, raw. */ function productFormInput() { $in = []; - foreach (['name', 'type', 'category', 'price', 'cost', 'tax', 'frequency', 'seat_source', 'code', 'vendor', 'vendor_part', 'location', 'description', 'notes'] as $key) { + foreach (['name', 'type', 'category', 'price', 'cost', 'tax', 'frequency', 'seat_source', 'code', 'vendor', 'vendor_part', 'reorder_level', 'reorder_qty', 'location', 'description', 'notes'] as $key) { if (array_key_exists($key, $_POST)) { $in[$key] = (string) $_POST[$key]; } @@ -316,6 +316,7 @@ if (isExportRequest('export_products')) { 'product' => ["AND product_type = 'product'", 'Products'], 'recurring' => ["AND product_frequency IS NOT NULL", 'Billed each month, quarter or year'], 'stocked' => ["AND product_track_stock = 1", 'Stock tracked'], + 'reorder' => ["AND " . productReorderSql(), 'To reorder'], ]; $type_query = ''; if (isset($type_queries[$type_filter])) { @@ -378,7 +379,10 @@ if (isExportRequest('export_products')) { $row['product_billed'] = productBilledText($row['product_frequency'], $row['product_seat_source']); $row['product_margin'] = $margin ? $margin['percent'] . '%' : ''; $row['product_tax'] = $row['tax_name'] ? $row['tax_name'] . ' ' . floatval($row['tax_percent']) . '%' : ''; - $row['product_stock_display'] = intval($row['product_track_stock']) ? $row['product_stock'] : ''; + $row['product_stock_display'] = intval($row['product_track_stock']) ? billingQuantity($row['product_stock']) : ''; + $row['product_reorder_display'] = intval($row['product_track_stock']) && floatval($row['product_reorder_level']) > 0 ? billingQuantity($row['product_reorder_level']) : ''; + $row['product_order_display'] = intval($row['product_track_stock']) && floatval($row['product_reorder_qty']) > 0 ? billingQuantity($row['product_reorder_qty']) : ''; + $row['product_stock_value'] = intval($row['product_track_stock']) && floatval($row['product_stock']) > 0 ? round(floatval($row['product_stock']) * floatval($row['product_cost']), 2) : ''; addExportRow($export, $row); } @@ -391,50 +395,159 @@ if (isExportRequest('export_products')) { } -if (isset($_POST['add_product_stock'])) { +/** A stock quantity from a form - a number above zero (zero or more with $zero), up to 2 places, or null. */ +function productStockInput($value, $zero = false) { + $raw = trim(str_replace([',', ' '], '', (string) $value)); + if ($raw === '' || !is_numeric($raw) || floatval($raw) >= 1e9 || round(floatval($raw), 2) != floatval($raw)) { + return null; + } + $qty = round(floatval($raw), 2); + return $qty > 0 || ($zero && $qty == 0) ? $qty : null; +} + +/* + * The Stock window - mode receive (it came in, at a unit cost, on an + * expense), count (what is on the shelf now, the difference recorded) or + * write_off (damaged, lost, used by us, sent back). + */ +if (isset($_POST['product_stock_save'])) { validateCSRFToken(); enforceUserPermission('module_sales', 2); - $product_id = intval($_POST['product_id'] ?? 0); - $product = productRow($product_id); - $qty = intval($_POST['qty'] ?? 0); - $expense = intval($_POST['expense'] ?? 0); - $note = escapeSql(trim((string) ($_POST['note'] ?? ''))); - + $product = productRow(intval($_POST['product_id'] ?? 0)); if (!$product) { flashAlert('That product no longer exists.', 'error'); redirect('products.php'); } + $product_id = intval($product['product_id']); + $name_html = '' . escapeHtml($product['product_name']) . ''; if (!intval($product['product_track_stock'])) { - flashAlert(escapeHtml($product['product_name']) . " doesn't track stock - turn on Track stock in Edit first.", 'error'); + flashAlert("$name_html doesn't track stock - turn on Track stock in Edit first.", 'error'); redirect(); } - if ($qty <= 0) { - flashAlert('Enter how many came in - a whole number above zero.', 'error'); - redirect(); - } - if ($expense && !getFieldById('expenses', $expense, 'expense_id')) { - $expense = 0; - } + $mode = (string) ($_POST['mode'] ?? 'receive'); + $on_hand = productOnHand($product_id); + $note = trim((string) ($_POST['note'] ?? '')); $product_name = escapeSql($product['product_name']); - mysqli_query($mysqli,"INSERT INTO product_stock SET stock_qty = $qty, stock_expense_id = " . ($expense ?: 'NULL') . ", stock_note = '$note', stock_product_id = $product_id"); + if ($mode === 'receive') { + $qty = productStockInput($_POST['qty'] ?? ''); + if ($qty === null) { + flashAlert('Enter how many came in - a number above zero.', 'error'); + redirect(); + } + $unit_cost = trim(str_replace([',', '$', ' '], '', (string) ($_POST['unit_cost'] ?? ''))); + if ($unit_cost !== '' && (!is_numeric($unit_cost) || floatval($unit_cost) < 0 || floatval($unit_cost) >= 1e13)) { + flashAlert('The unit cost has to be a number, zero or more.', 'error'); + redirect(); + } + $expense = intval($_POST['expense'] ?? 0); + if ($expense && !getFieldById('expenses', $expense, 'expense_id')) { + $expense = 0; + } + productStockMove($product_id, $qty, 'received', ['unit_cost' => $unit_cost === '' ? null : $unit_cost, 'expense_id' => $expense, 'note' => $note]); + $cost_note = ''; + if ($unit_cost !== '' && !empty($_POST['update_cost']) && round(floatval($unit_cost), 2) != round(floatval($product['product_cost']), 2)) { + productSave(['cost' => $unit_cost], $product_id); + $cost_note = ', and its cost is now ' . billingMoney($unit_cost, $product['product_currency_code']); + logAudit("Product", "Edit", "$session_name changed the cost of product $product_name to " . escapeSql(billingMoney($unit_cost, $product['product_currency_code'])) . " from a delivery", 0, $product_id); + } + logAudit("Product", "Stock", "$session_name received " . billingQuantity($qty) . " of product $product_name", 0, $product_id); + flashAlert(billingQuantity($qty) . " of $name_html received - " . billingQuantity($on_hand + $qty) . " on the shelf" . escapeHtml($cost_note)); - logAudit("Product", "Stock", "$session_name added $qty units to stock for product $product_name", 0, $product_id); + } elseif ($mode === 'count') { + $counted = productStockInput($_POST['counted'] ?? '', true); + if ($counted === null) { + flashAlert('Enter what is on the shelf - zero or more.', 'error'); + redirect(); + } + $difference = round($counted - $on_hand, 2); + if ($difference == 0) { + flashAlert(billingQuantity($counted) . " of $name_html on the shelf - that is what ITFlow had, nothing to change"); + redirect(); + } + productStockMove($product_id, $difference, 'adjusted', ['note' => 'Counted ' . billingQuantity($counted) . ($note !== '' ? " - $note" : '')]); + logAudit("Product", "Stock", "$session_name counted product $product_name - " . billingQuantity($counted) . ' on the shelf, ' . productStockQty($difference), 0, $product_id); + flashAlert("$name_html counted - " . billingQuantity($counted) . ' on the shelf, ' . productStockQty($difference) . ' from what ITFlow had'); - flashAlert("Added $qty to " . escapeHtml($product['product_name']) . " - " . (intval($product['product_stock']) + $qty) . " on the shelf"); + } elseif ($mode === 'write_off') { + $qty = productStockInput($_POST['qty'] ?? ''); + if ($qty === null) { + flashAlert('Enter how many to write off - a number above zero.', 'error'); + redirect(); + } + if ($qty > $on_hand) { + flashAlert("Only " . billingQuantity($on_hand) . " of $name_html on the shelf - count it first if there are more.", 'error'); + redirect(); + } + $reason = in_array($_POST['reason'] ?? '', productWriteOffReasons(), true) ? $_POST['reason'] : 'Other'; + productStockMove($product_id, -$qty, 'written_off', ['note' => $reason . ($note !== '' ? " - $note" : '')]); + logAudit("Product", "Stock", "$session_name wrote off " . billingQuantity($qty) . " of product $product_name - " . escapeSql($reason), 0, $product_id); + flashAlert(billingQuantity($qty) . " of $name_html written off - " . billingQuantity($on_hand - $qty) . ' on the shelf'); + + } else { + flashAlert('That is not something the Stock window does.', 'error'); + } redirect(); } +/* + * Count Stock - what is on the shelf for each product counted (counted[id]), + * the differences recorded together. Blank ones are left alone. + */ +if (isset($_POST['product_stock_count'])) { + + validateCSRFToken(); + + enforceUserPermission('module_sales', 2); + + $note = trim((string) ($_POST['note'] ?? '')); + $changed = $same = 0; + $bad = []; + foreach ((array) ($_POST['counted'] ?? []) as $product_id => $value) { + if (trim((string) $value) === '') { + continue; + } + $product = productRow(intval($product_id)); + if (!$product || !intval($product['product_track_stock'])) { + continue; + } + $counted = productStockInput($value, true); + if ($counted === null) { + $bad[] = $product['product_name']; + continue; + } + $difference = round($counted - productOnHand($product['product_id']), 2); + if ($difference == 0) { + $same++; + continue; + } + productStockMove($product['product_id'], $difference, 'adjusted', ['note' => 'Stock count - ' . billingQuantity($counted) . ($note !== '' ? " - $note" : '')]); + logAudit("Product", "Stock", "$session_name counted product " . escapeSql($product['product_name']) . ' - ' . billingQuantity($counted) . ' on the shelf, ' . productStockQty($difference), 0, intval($product['product_id'])); + $changed++; + } + + logAudit("Product", "Stock", "$session_name saved a stock count - $changed changed, $same as expected"); + + $message = "Stock count saved - $changed changed, $same as expected"; + if ($bad) { + $message .= '. Not a number, left alone: ' . escapeHtml(implode(', ', $bad)); + } + flashAlert($message, $bad && !$changed ? 'error' : 'success'); + + redirect('products.php?type=stocked'); + +} + /* * Import from a CSV with a header row. Columns are found by their heading, * in any order - only Name is needed: * Name, Type, Code, Category, Description, Price, Cost, Tax, Billed, Counted, - * Vendor, Part Number, Track Stock, Location, Notes + * Vendor, Part Number, Track Stock, Reorder At, Order, Location, Notes * The old three column file (Product Name, Description, Price) still works. * A row whose code matches a product already here updates it when asked to. */ @@ -476,6 +589,8 @@ if (isset($_POST["import_products_csv"])) { 'vendor' => ['vendor', 'supplier'], 'vendor_part' => ['part number', 'vendor part', 'mpn', 'part'], 'track_stock' => ['track stock', 'tracked', 'stock tracked'], + 'reorder_level' => ['reorder at', 'reorder level', 'reorder point', 'minimum stock'], + 'reorder_qty' => ['order', 'order qty', 'reorder qty', 'reorder quantity'], 'location' => ['location'], 'notes' => ['notes', 'internal notes'], ]; @@ -593,9 +708,9 @@ if (isset($_POST["import_products_csv"])) { if (isset($_GET['download_products_csv_template'])) { $f = fopen('php://memory', 'w'); - fputcsv($f, ['Name', 'Type', 'Code', 'Category', 'Description', 'Price', 'Cost', 'Tax', 'Billed', 'Counted', 'Vendor', 'Part Number', 'Track Stock', 'Location', 'Notes'], ',', '"', '\\'); - fputcsv($f, ['Managed Workstation', 'Service', 'MS-WKS', 'Managed Services', 'Per workstation, per month. Monitoring, patching and support.', '65.00', '18.00', '', 'Monthly', 'Per device', '', '', '', '', ''], ',', '"', '\\'); - fputcsv($f, ['24 Port PoE Switch', 'Product', 'HW-SW24', 'Hardware Sales', '24 port managed PoE switch.', '785.00', '520.00', '', 'One time', '', '', 'USW-PRO-24-POE', 'Yes', 'Shelf B2', 'Order from the distributor, 2 day lead time'], ',', '"', '\\'); + fputcsv($f, ['Name', 'Type', 'Code', 'Category', 'Description', 'Price', 'Cost', 'Tax', 'Billed', 'Counted', 'Vendor', 'Part Number', 'Track Stock', 'Reorder At', 'Order', 'Location', 'Notes'], ',', '"', '\\'); + fputcsv($f, ['Managed Workstation', 'Service', 'MS-WKS', 'Managed Services', 'Per workstation, per month. Monitoring, patching and support.', '65.00', '18.00', '', 'Monthly', 'Per device', '', '', '', '', '', '', ''], ',', '"', '\\'); + fputcsv($f, ['24 Port PoE Switch', 'Product', 'HW-SW24', 'Hardware Sales', '24 port managed PoE switch.', '785.00', '520.00', '', 'One time', '', '', 'USW-PRO-24-POE', 'Yes', '2', '5', 'Shelf B2', 'Order from the distributor, 2 day lead time'], ',', '"', '\\'); fseek($f, 0); header('Content-Type: text/csv'); diff --git a/agent/product.php b/agent/product.php index cf111a49f..d2ad7ad86 100644 --- a/agent/product.php +++ b/agent/product.php @@ -5,12 +5,12 @@ * * What it sells for and costs us, so its margin; how it is billed by default; * who we buy it from and their part number; for a product, its stock - on the - * shelf, where, what that is worth and the last moves - or that it is ordered - * as needed. Internal notes save as you click away. + * shelf, where, what that is worth and when to reorder - or that it is + * ordered as needed. Internal notes save as you click away. * - * Then where it is used, in tabs with counts: the recurring invoices it is on - * (what that brings in a month), open quotes, the invoices it was sold on, - * and its history. Figures are for the clients the agent can see. + * Then in tabs with counts: its stock moves with what was on the shelf after + * each, the recurring invoices it is on (what that brings in a month), open + * quotes, the invoices it was sold on, and its history. Figures are for the clients the agent can see. */ require_once "includes/inc_all.php"; @@ -35,7 +35,10 @@ if (!$product) { }; $archived_at = $product['product_archived_at']; $tracked = intval($product['product_track_stock']) === 1; - $on_hand = intval($product['product_stock']); + $on_hand = floatval($product['product_stock']); + $reorder_level = floatval($product['product_reorder_level']); + $reorder_qty = floatval($product['product_reorder_qty']); + $needs_reorder = $tracked && !$archived_at && $on_hand <= max($reorder_level, 0); $margin = productMargin($product['product_price'], $product['product_cost']); $billed = productBilledText($product['product_frequency'], $product['product_seat_source']); $period = $product['product_frequency'] ? ' a ' . recurringInvoicePeriodWord($product['product_frequency']) : ''; @@ -44,7 +47,7 @@ if (!$product) { $csrf = $_SESSION['csrf_token']; $usage = productUsage($product_id); - $moves = $type === 'product' ? productStockMoves($product_id, 15) : []; + $moves = $type === 'product' ? productStockMoves($product_id, 200) : []; $history = productHistory($product_id, 100); $sold = $usage['sold']; @@ -58,6 +61,14 @@ if (!$product) { if (!$product['product_frequency']) { $tabs = ['invoices' => $tabs['invoices'], 'quotes' => $tabs['quotes'], 'recurring' => $tabs['recurring'], 'history' => $tabs['history']]; } + // Its stock moves - first for a product that tracks stock + if ($tracked || $moves) { + $stock_tab = ['stock' => ['label' => 'Stock', 'icon' => 'fa-warehouse', 'count' => count($moves)]]; + $tabs = $tracked ? $stock_tab + $tabs : $tabs + $stock_tab; + } + $stock_url = function ($mode) use ($product_id) { + return "modals/product/product_stock.php?id=$product_id&mode=$mode"; + }; // Override Tab Title // No Sanitizing needed as this var will only be used in the tab title $page_title = $product['product_name']; @@ -92,9 +103,16 @@ if (!$product) {
- +
+ + + +
- +
+ + Reorder 0 ? ' ' . escapeHtml(billingQuantity($reorder_qty)) : '' ?> +
+
0 && $on_hand > 0 ? $money($on_hand * floatval($product['product_cost'])) . ' at cost' : ($product['product_location'] ? escapeHtml($product['product_location']) : 'Stock tracked') ?> 0 ? ' · reorder at ' . escapeHtml(billingQuantity($reorder_level)) : '' ?>
+
Open quotes
@@ -234,43 +255,39 @@ if (!$product) {

Stock

- - - + To reorder
-
+
-
On shelf
+
On shelf
+
Reorder
+
+ 0) { ?> + At 0 ? ', order ' . escapeHtml(billingQuantity($reorder_qty)) : '' ?> + + When it is out + +
Location
Not set' ?>
0) { ?>
Worth
at cost
+ + +
- Ordered as needed - invoicing it doesn't touch stock. + Ordered as needed - invoicing it doesn't touch stock. Turn on Track stock in Edit to count it.
- -
    - -
  • - 0 ? '+' . $qty : $qty ?> -
    -
    0 ? 'Added' : 'Taken') ?>
    - -
    - -
    - -
  • - -
-
@@ -405,6 +422,52 @@ if (!$product) {
+ + +
+ +
+ + + + + + + + + + + + + + + + + +
WhenWhatQtyAfterUnit costForBy
-' ?> + + + + +
+ + +
+ +
-' ?>
+
+ = 200) { ?> +
The last 200 moves - the Inventory report has them all.
+ + +
Nothing in or out yet. Receive stock as it comes in.
+ +
+ +
diff --git a/agent/products.php b/agent/products.php index 582027b8c..f61ac08e5 100644 --- a/agent/products.php +++ b/agent/products.php @@ -7,7 +7,7 @@ * At the top: what the catalog bills each month on recurring invoices, what * it sold over the last 90 days, and what the stock on the shelf is worth. * Tabs for services, products, what is billed each month, quarter or year, - * and what has its stock tracked (?type=, which old links to ?type=service + * what has its stock tracked and what needs reordering (?type=, which old links to ?type=service * and ?type=product still use). Each row: what it is and its code, how it is * billed by default, the stock on the shelf, who we buy it from, and the * price with its margin. The name opens the product's page. @@ -31,6 +31,7 @@ $views = [ 'product' => ['label' => 'Products', 'icon' => 'fa-box-open', 'sql' => "AND product_type = 'product'"], 'recurring' => ['label' => 'Recurring', 'icon' => 'fa-redo-alt', 'sql' => "AND product_frequency IS NOT NULL", 'title' => 'Billed each month, quarter or year by default'], 'stocked' => ['label' => 'Stocked', 'icon' => 'fa-warehouse', 'sql' => "AND product_track_stock = 1", 'title' => 'Stock tracked'], + 'reorder' => ['label' => 'Reorder', 'icon' => 'fa-shopping-basket', 'sql' => "AND " . productReorderSql(), 'title' => 'Stock at or under its reorder level, or out'], ]; $view = (string) ($_GET['type'] ?? 'all'); if (!isset($views[$view])) { @@ -77,7 +78,8 @@ while ($row = mysqli_fetch_assoc($sql)) { // Each tab's count, with the same search and filters $counts = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS `all`, COALESCE(SUM(product_type = 'service'), 0) AS service, COALESCE(SUM(product_type = 'product'), 0) AS product, - COALESCE(SUM(product_frequency IS NOT NULL), 0) AS recurring, COALESCE(SUM(product_track_stock = 1), 0) AS stocked + COALESCE(SUM(product_frequency IS NOT NULL), 0) AS recurring, COALESCE(SUM(product_track_stock = 1), 0) AS stocked, + COALESCE(SUM(" . productReorderSql() . "), 0) AS reorder $from $where")); // The catalog at work - recurring, sold, on the shelf @@ -104,6 +106,7 @@ $stock_stat = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COALESCE(SUM(GREA JOIN (SELECT stock_product_id, SUM(stock_qty) AS on_hand FROM product_stock GROUP BY stock_product_id) s ON s.stock_product_id = product_id WHERE product_track_stock = 1 AND product_archived_at IS NULL")); $tracked_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products WHERE product_track_stock = 1 AND product_archived_at IS NULL"))[0]); +$reorder_count = productReorderCount(); $catalog_count = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products"))[0]); $filter_url = function ($changes) { @@ -114,7 +117,7 @@ $filter_url = function ($changes) { $money = function ($amount, $currency = null) use ($session_company_currency) { return escapeHtml(billingMoney($amount, $currency ?: $session_company_currency)); }; -$add_type = $view === 'product' || $view === 'stocked' ? 'product' : 'service'; +$add_type = in_array($view, ['product', 'stocked', 'reorder'], true) ? 'product' : 'service'; ?> @@ -148,22 +151,22 @@ $add_type = $view === 'product' || $view === 'stocked' ? 'product' : 'service';
- +
On the shelf
-
unit
+
unit
On product - add a cost to value them - - · out + + · to reorder
- unit on product - - · out + unit on product + + · to reorder
@@ -193,6 +196,11 @@ $add_type = $view === 'product' || $view === 'stocked' ? 'product' : 'service';
New Product + + + Count Stock + + Import @@ -317,7 +325,9 @@ $add_type = $view === 'product' || $view === 'stocked' ? 'product' : 'service'; $product_id = intval($row['product_id']); $row_type = $row['product_type']; $row_tracked = intval($row['product_track_stock']) === 1; - $row_stock = intval($row['product_stock']); + $row_stock = floatval($row['product_stock']); + $row_level = floatval($row['product_reorder_level']); + $row_reorder = $row_tracked && !$row['product_archived_at'] && $row_stock <= max($row_level, 0); $row_margin = productMargin($row['product_price'], $row['product_cost']); $row_currency = (string) $row['product_currency_code']; $row_url = "product.php?product_id=$product_id"; @@ -350,8 +360,13 @@ $add_type = $view === 'product' || $view === 'stocked' ? 'product' : 'service'; - in stock -
+ in stock + +
Reorder 0 ? ' ' . escapeHtml(billingQuantity($row['product_reorder_qty'])) : '' ?>
+ +
+ As needed @@ -383,8 +398,11 @@ $add_type = $view === 'product' || $view === 'stocked' ? 'product' : 'service'; Edit
- - Add Stock + + Receive + + + Count diff --git a/agent/products_count.php b/agent/products_count.php new file mode 100644 index 000000000..c70b3206c --- /dev/null +++ b/agent/products_count.php @@ -0,0 +1,181 @@ + '' ORDER BY product_location"); +while ($row = mysqli_fetch_row($sql)) { + $locations[] = $row[0]; +} + +$page_title = 'Count Stock'; + +?> + + + +
+ + +
+
+

Count Stock' . escapeHtml($location) . '' : '' ?>

+
+ +
+
+ +
+
+
+
+ + +
+
+
+ Count what is on the shelf and type it in. Leave a product blank to skip it. Each one counted is recorded as a stock move. +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
ProductLocationITFlow hasCountedDifference
+ +
+
-' ?> + +
No product here tracks its stock. Turn on Track stock on a product to count it.
+
+ + + + +
+
+ + + + 0 + LEFT JOIN clients ON client_id = invoice_client_id + LEFT JOIN expenses ON expense_id = stock_expense_id + LEFT JOIN users ON user_id = stock_user_id AND stock_user_id > 0 + WHERE $range_sql + ORDER BY stock_created_at DESC, stock_id DESC"); +while ($row = mysqli_fetch_assoc($sql)) { + $row['type_label'] = productStockTypes()[$row['stock_type']]['label'] ?? $row['stock_type']; + $row['invoice_open'] = $row['invoice_number'] !== null && hasClientAccess(intval($row['invoice_client_id'])); + $moves[] = $row; +} + +$product_columns = [ + 'product_name' => ['label' => 'Product', 'html' => function ($row) { + return '' . escapeHtml($row['product_name']) . '' + . ($row['product_code'] || $row['vendor_name'] ? '
' . escapeHtml(implode(' · ', array_filter([$row['product_code'], $row['vendor_name']]))) . '
' : ''); + }], + 'product_code' => ['label' => 'Code', 'screen' => false], + 'vendor_name' => ['label' => 'Vendor', 'screen' => false], + 'product_location' => ['label' => 'Location', 'class' => 'small'], + 'on_hand' => ['label' => 'On shelf', 'type' => 'decimal', 'decimals' => 2, 'html' => function ($row) { + $tone = $row['status'] === 'Out' ? 'text-danger-emphasis fw-semibold' : ($row['status'] === 'Reorder' ? 'text-warning-emphasis fw-semibold' : ''); + return '' . escapeHtml(billingQuantity($row['on_hand'])) . ''; + }], + 'product_reorder_level' => ['label' => 'Reorder at', 'type' => 'decimal', 'decimals' => 2, 'html' => function ($row) { + return floatval($row['product_reorder_level']) > 0 ? escapeHtml(billingQuantity($row['product_reorder_level'])) : '-'; + }], + 'value' => ['label' => 'Value', 'type' => 'money', 'title' => 'On the shelf, at its cost today'], + 'received' => ['label' => 'In', 'type' => 'decimal', 'decimals' => 2, 'title' => 'Received in the period', 'html' => function ($row) { + return floatval($row['received']) ? escapeHtml(billingQuantity($row['received'])) : '-'; + }], + 'sold' => ['label' => 'Sold', 'type' => 'decimal', 'decimals' => 2, 'title' => 'Invoiced in the period, less what came back', 'html' => function ($row) { + return floatval($row['sold']) ? escapeHtml(billingQuantity($row['sold'])) : '-'; + }], + 'written_off' => ['label' => 'Written off', 'type' => 'decimal', 'decimals' => 2, 'html' => function ($row) { + return floatval($row['written_off']) ? escapeHtml(billingQuantity($row['written_off'])) : '-'; + }], + 'sold_value' => ['label' => 'Cost of sales', 'type' => 'money', 'title' => 'What what was sold cost you'], + 'status' => ['label' => 'Status', 'html' => function ($row) { + $tones = ['Out' => 'danger', 'Reorder' => 'warning', 'In stock' => 'success', 'Not tracked' => 'secondary']; + $tone = $tones[$row['status']] ?? 'secondary'; + return '' . escapeHtml($row['status']) + . ($row['needs_reorder'] && floatval($row['product_reorder_qty']) > 0 ? ' ' . escapeHtml(billingQuantity($row['product_reorder_qty'])) : '') . ''; + }], +]; +$move_columns = [ + 'stock_created_at' => ['label' => 'When', 'type' => 'date', 'class' => 'small text-nowrap', 'html' => function ($row) { + return '' . escapeHtml(date('M j, Y', strtotime($row['stock_created_at']))) . ''; + }], + 'product_name' => ['label' => 'Product', 'html' => function ($row) { + return '' . escapeHtml($row['product_name']) . ''; + }], + 'type_label' => ['label' => 'What', 'html' => function ($row) { + $type = productStockTypes()[$row['stock_type']] ?? productStockTypes()['adjusted']; + return '' . escapeHtml($type['label']) . ''; + }], + 'stock_qty' => ['label' => 'Qty', 'type' => 'decimal', 'decimals' => 2, 'html' => function ($row) { + return '' . escapeHtml(productStockQty($row['stock_qty'])) . ''; + }], + 'unit_cost' => ['label' => 'Unit cost', 'type' => 'money'], + 'value' => ['label' => 'Value', 'type' => 'money', 'title' => 'The quantity at the unit cost'], + 'reference' => ['label' => 'For', 'class' => 'small', 'csv' => function ($row) { + return trim(implode(' - ', array_filter([$row['invoice_open'] ? $row['invoice_prefix'] . $row['invoice_number'] . ' ' . $row['client_name'] : '', + $row['expense_description'] !== null ? $row['expense_date'] . ' ' . $row['expense_description'] : '', (string) $row['stock_note']]))); + }, 'html' => function ($row) { + $html = []; + if ($row['invoice_open']) { + $html[] = '' . escapeHtml($row['invoice_prefix'] . $row['invoice_number']) . ' ' . escapeHtml($row['client_name']); + } + if ($row['expense_description'] !== null) { + $html[] = '' . escapeHtml(mb_strimwidth($row['expense_date'] . ' - ' . $row['expense_description'], 0, 70, '...')); + } + if (trim((string) $row['stock_note']) !== '' && !($row['invoice_open'] && trim($row['stock_note']) === $row['invoice_prefix'] . $row['invoice_number'])) { + $html[] = '' . escapeHtml($row['stock_note']) . ''; + } + return implode('
', $html) ?: '-'; + }], + 'user_name' => ['label' => 'By', 'class' => 'small text-nowrap'], +]; + +if (reportExporting('moves')) { + reportCsv('Stock Moves ' . $period['start'] . ' to ' . $period['end'], $move_columns, $moves); +} +if (reportExporting()) { + reportCsv('Inventory ' . $period['start'] . ' to ' . $period['end'], $product_columns, $products, ['product_name' => 'Total', 'value' => round($value_now, 2), + 'received' => $in_units, 'sold' => $out_units, 'sold_value' => round($out_value, 2)]); +} + +$buckets = reportBuckets($period); +$bucket_sql = function ($types, $sign) use ($buckets, $range_sql, $value_sql) { + return "SELECT " . reportBucketSql('stock_created_at', $buckets['unit']) . " AS b, $sign SUM($value_sql) AS v + FROM product_stock JOIN products ON product_id = stock_product_id + WHERE stock_type IN ($types) AND $range_sql GROUP BY b"; +}; +$series = [ + ['label' => 'Received', 'tone' => 'success', 'data' => reportSeries($bucket_sql("'received'", ''), $buckets['keys'])], + ['label' => 'Cost of sales', 'tone' => 'accent', 'data' => reportSeries($bucket_sql("'sold', 'returned'", '-'), $buckets['keys'])], + ['label' => 'Written off', 'tone' => 'danger', 'data' => reportSeries($bucket_sql("'written_off'", '-'), $buckets['keys'])], +]; + +require_once "includes/inc_report_layout.php"; + +?> + +
+ + $period, + 'export' => ['products' => 'Products', 'moves' => 'Stock moves'], + ]); ?> + + 'On the shelf', 'value' => reportMoney($value_now), 'title' => 'At each product\'s cost today', + 'sub' => escapeHtml(billingQuantity($units_now)) . ' units'], + ['label' => 'Received', 'value' => reportMoney($in_value), 'tone' => $in_value > 0 ? 'success' : '', + 'sub' => escapeHtml(billingQuantity($in_units)) . ' units in the period'], + ['label' => 'Cost of sales', 'value' => reportMoney($out_value), 'title' => 'What the stock invoiced in the period cost you, less what came back', + 'sub' => escapeHtml(billingQuantity($out_units)) . ' units sold' . ($off_value > 0 ? ' · ' . escapeHtml(reportMoney($off_value)) . ' written off' : '')], + ['label' => 'To reorder', 'value' => reportCount($reorder), 'tone' => $reorder ? 'warning' : '', 'url' => '/agent/products.php?type=reorder', + 'sub' => $reorder ? 'at or under their reorder level' : 'nothing low'], + ]) ?> + + +
+ 'bar', 'labels' => $buckets['labels'], 'series' => $series, 'format' => 'money'], '', 'Stock received, sold and written off over the period, at cost') ?> +
+
+ + + reportEmpty('No product tracks its stock. Turn on Track stock on a product to see it here.', 'fa-warehouse', 'secondary'), + 'export_table' => 'products', + 'totals' => ['product_name' => 'Total', 'value' => round($value_now, 2), 'received' => $in_units, 'sold' => $out_units, 'sold_value' => round($out_value, 2)], + 'totals_html' => ['received' => escapeHtml(billingQuantity($in_units)), 'sold' => escapeHtml(billingQuantity($out_units))], + ]) ?> + +
+ + + reportEmpty('Nothing came in or went out in this period.', 'fa-warehouse', 'secondary'), + 'limit' => 300, 'export_table' => 'moves', 'sortable' => true, + ]) ?> +
+ +
+ + + diff --git a/agent/vendor.php b/agent/vendor.php index 97ad97c00..41211eaaf 100644 --- a/agent/vendor.php +++ b/agent/vendor.php @@ -776,7 +776,7 @@ if (!$vendor || (isset($_GET['client_id']) && $vendor_client_id !== intval($clie
- -' ?> + -' ?> diff --git a/api/v1/invoice_items/create.php b/api/v1/invoice_items/create.php index 504733340..c0794e6c5 100644 --- a/api/v1/invoice_items/create.php +++ b/api/v1/invoice_items/create.php @@ -75,6 +75,7 @@ if ( $invoice_discount = floatval($invoice_row['invoice_discount_amount']); $subtotal = $price * $qty; + $take_stock = false; // Product inventory if ($product_id) { @@ -94,13 +95,8 @@ if ( if ($available_stock >= $qty) { - mysqli_query( - $mysqli, - "INSERT INTO product_stock - SET stock_qty = -$qty, - stock_note = 'QTY $qty - Invoice $invoice_id', - stock_product_id = $product_id" - ); + // Taken off the shelf once the line is in + $take_stock = true; } else { @@ -157,6 +153,10 @@ if ( $insert_id = mysqli_insert_id($mysqli); + if ($take_stock) { + productStockMove($product_id, -$qty, 'sold', ['invoice_id' => $invoice_id, 'item_id' => $insert_id, 'note' => stripslashes($invoice_prefix) . "$invoice_number - API"]); + } + // Recalculate invoice total $items_sql = mysqli_query( $mysqli, diff --git a/db.sql b/db.sql index f822d9a13..ce2ac373c 100644 --- a/db.sql +++ b/db.sql @@ -1760,14 +1760,19 @@ DROP TABLE IF EXISTS `product_stock`; /*!40101 SET character_set_client = utf8mb4 */; CREATE TABLE `product_stock` ( `stock_id` int(11) NOT NULL AUTO_INCREMENT, - `stock_qty` int(11) NOT NULL, + `stock_qty` decimal(15,2) NOT NULL, + `stock_type` varchar(20) NOT NULL DEFAULT 'adjusted', + `stock_unit_cost` decimal(15,2) DEFAULT NULL, `stock_note` text DEFAULT NULL, `stock_created_at` datetime NOT NULL DEFAULT current_timestamp(), `stock_expense_id` int(11) DEFAULT NULL, + `stock_invoice_id` int(11) NOT NULL DEFAULT 0, `stock_item_id` int(11) DEFAULT NULL, + `stock_user_id` int(11) NOT NULL DEFAULT 0, `stock_product_id` int(11) NOT NULL, PRIMARY KEY (`stock_id`), - KEY `stock_product_id` (`stock_product_id`) + KEY `stock_product_id` (`stock_product_id`), + KEY `stock_invoice_id` (`stock_invoice_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -1793,6 +1798,8 @@ CREATE TABLE `products` ( `product_frequency` varchar(10) DEFAULT NULL, `product_seat_source` varchar(20) DEFAULT NULL, `product_track_stock` tinyint(1) NOT NULL DEFAULT 0, + `product_reorder_level` decimal(15,2) NOT NULL DEFAULT 0.00, + `product_reorder_qty` decimal(15,2) NOT NULL DEFAULT 0.00, `product_created_at` datetime NOT NULL DEFAULT current_timestamp(), `product_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(), `product_archived_at` datetime DEFAULT NULL, diff --git a/functions/billing.php b/functions/billing.php index a06c6587d..e28687713 100644 --- a/functions/billing.php +++ b/functions/billing.php @@ -284,19 +284,16 @@ function billingItemSave($type, $doc_id, array $in, $item_id = 0, &$error = '') } // Stock moves only with invoices, and only for products whose stock is tracked + $stock_change = 0; if ($type === 'invoice' && $product_id && intval(getFieldById('products', $product_id, 'product_track_stock')) === 1) { - $change = round($quantity - ($old ? floatval($old['item_quantity']) : 0), 2); - if ($change > 0) { - $available = floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(stock_qty), 0) FROM product_stock WHERE stock_product_id = $product_id"))[0]); - if ($available < $change) { + $stock_change = round($quantity - ($old ? floatval($old['item_quantity']) : 0), 2); + if ($stock_change > 0) { + $available = productOnHand($product_id); + if ($available < $stock_change) { $error = 'Not enough ' . $name . ' in stock - ' . billingQuantity($available) . ' left.'; return null; } } - if ($change != 0) { - $note = escapeSql(($change > 0 ? 'QTY ' . billingQuantity($change) . ' to' : 'Returned QTY ' . billingQuantity(-$change) . ' from') . " Invoice $doc_id"); - mysqli_query($mysqli, "INSERT INTO product_stock SET stock_qty = " . (-$change) . ", stock_note = '$note', stock_product_id = $product_id"); - } } $set = "item_name = '" . escapeSql($name) . "', item_description = '" . escapeSql($description) . "', @@ -308,6 +305,11 @@ function billingItemSave($type, $doc_id, array $in, $item_id = 0, &$error = '') mysqli_query($mysqli, "INSERT INTO {$t['items']} SET $set, item_order = $order, item_product_id = $product_id, {$t['item_fk']} = $doc_id"); $item_id = mysqli_insert_id($mysqli); } + if ($stock_change != 0) { + productStockMove($product_id, -$stock_change, $stock_change > 0 ? 'sold' : 'returned', [ + 'invoice_id' => $doc_id, 'item_id' => $item_id, 'note' => billingNumber('invoice', billingRow('invoice', $doc_id, false)) . ($old ? ' - quantity changed' : ''), + ]); + } billingRecalc($type, $doc_id); return $item_id; @@ -326,8 +328,9 @@ function billingItemDelete($type, $doc_id, $item_id) { mysqli_query($mysqli, "DELETE FROM {$t['items']} WHERE item_id = $item_id"); $product_id = intval($item['item_product_id']); if ($type === 'invoice' && $product_id && intval(getFieldById('products', $product_id, 'product_track_stock')) === 1 && floatval($item['item_quantity']) > 0) { - $quantity = floatval($item['item_quantity']); - mysqli_query($mysqli, "INSERT INTO product_stock SET stock_qty = $quantity, stock_note = 'Returned QTY " . billingQuantity($quantity) . " back to stock from Invoice $doc_id', stock_product_id = $product_id"); + productStockMove($product_id, floatval($item['item_quantity']), 'returned', [ + 'invoice_id' => $doc_id, 'item_id' => $item_id, 'note' => billingNumber('invoice', billingRow('invoice', $doc_id, false)) . ' - line taken off', + ]); } billingRecalc($type, $doc_id); return $item; @@ -1109,20 +1112,32 @@ function quoteGroups(array $quote) { /** * Put lines on a document after the ones already there - their stored - * amounts as they are. $suffix goes on the end of each description. + * amounts as they are, linked to the same products. $suffix goes on the end + * of each description. On an invoice, tracked products come off the shelf; + * check productStockShortfall() first. */ function billingCopyLines(array $items, $to_type, $to_id, $suffix = '') { global $mysqli; $t = billingType($to_type); $to_id = intval($to_id); $order = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(MAX(item_order), 0) FROM {$t['items']} WHERE {$t['item_fk']} = $to_id"))[0]); + $number = $to_type === 'invoice' ? billingNumber('invoice', billingRow('invoice', $to_id, false)) : ''; foreach ($items as $item) { $order++; $name = escapeSql($item['item_name']); $description = escapeSql(trim(trim((string) $item['item_description']) . ($suffix !== '' ? "\n$suffix" : ''))); + $product_id = intval($item['item_product_id'] ?? 0); + if ($product_id && !getFieldById('products', $product_id, 'product_id')) { + $product_id = 0; + } mysqli_query($mysqli, "INSERT INTO {$t['items']} SET item_name = '$name', item_description = '$description', item_quantity = " . floatval($item['item_quantity']) . ", item_price = " . floatval($item['item_price']) . ", item_subtotal = " . floatval($item['item_subtotal']) . ", item_tax = " . floatval($item['item_tax']) . ", - item_total = " . floatval($item['item_total']) . ", item_order = $order, item_tax_id = " . intval($item['item_tax_id']) . ", {$t['item_fk']} = $to_id"); + item_total = " . floatval($item['item_total']) . ", item_order = $order, item_tax_id = " . intval($item['item_tax_id']) . ", item_product_id = $product_id, {$t['item_fk']} = $to_id"); + $new_item_id = intval(mysqli_insert_id($mysqli)); + // On an invoice, a tracked product comes off the shelf - check first with productStockShortfall() + if ($to_type === 'invoice' && $product_id && floatval($item['item_quantity']) > 0 && intval(getFieldById('products', $product_id, 'product_track_stock')) === 1) { + productStockMove($product_id, -floatval($item['item_quantity']), 'sold', ['invoice_id' => $to_id, 'item_id' => $new_item_id, 'note' => $number]); + } } } @@ -1193,6 +1208,21 @@ function quoteConvert($quote_id, array $opts, &$error = '') { } } + // What goes on the invoice has to be on the shelf, for products that track stock + $invoice_lines = $groups['']['items'] ?? []; + if ($first_period) { + foreach ($groups as $frequency => $group) { + if ($frequency !== '') { + $invoice_lines = array_merge($invoice_lines, $group['items']); + } + } + } + $shortfall = productStockShortfall($invoice_lines); + if ($shortfall !== '') { + $error = "$shortfall Add stock before invoicing $number."; + return null; + } + mysqli_begin_transaction($mysqli); try { // Claim it @@ -1706,9 +1736,11 @@ function recurringInvoiceGenerate($recurring_invoice_id, $by = 'Cron', $early = foreach (billingItems('recurring', $recurring_invoice_id) as $item) { $name = escapeSql(recurringInvoiceTokens($item['item_name'], $period)); $description = escapeSql(recurringInvoiceTokens($item['item_description'], $period)); + // Linked to its product, so it counts as sold - recurring invoices never move stock mysqli_query($mysqli, "INSERT INTO invoice_items SET item_name = '$name', item_description = '$description', item_quantity = " . floatval($item['item_quantity']) . ", item_price = " . floatval($item['item_price']) . ", item_subtotal = " . floatval($item['item_subtotal']) . ", item_tax = " . floatval($item['item_tax']) . ", - item_total = " . floatval($item['item_total']) . ", item_order = " . intval($item['item_order']) . ", item_tax_id = " . intval($item['item_tax_id']) . ", item_invoice_id = $invoice_id"); + item_total = " . floatval($item['item_total']) . ", item_order = " . intval($item['item_order']) . ", item_tax_id = " . intval($item['item_tax_id']) . ", + item_product_id = " . intval($item['item_product_id']) . ", item_invoice_id = $invoice_id"); } billingRecalc('invoice', $invoice_id); diff --git a/functions/dashboard.php b/functions/dashboard.php index 010a16288..cead0d431 100644 --- a/functions/dashboard.php +++ b/functions/dashboard.php @@ -263,6 +263,12 @@ function dashboardAttention($access) { 'label' => 'Draft invoices', 'icon' => 'pen', 'tone' => 'secondary', 'url' => '/agent/invoices.php?status=Draft', ]; + // The same rule as products.php?type=reorder + $items[] = [ + 'count' => productReorderCount(), + 'label' => 'Stock to reorder', 'icon' => 'shopping-basket', 'tone' => 'warning', 'detail' => 'At or under the reorder level', + 'url' => '/agent/products.php?type=reorder', + ]; if ($access['tickets']) { $items[] = [ 'count' => dashboardCount("SELECT COUNT(ticket_id) FROM tickets WHERE " . billingUnbilledSql() . " $ticket_scope"), diff --git a/functions/export.php b/functions/export.php index 734fb2fc0..c052f3f93 100644 --- a/functions/export.php +++ b/functions/export.php @@ -364,7 +364,10 @@ function getExportColumns($export_type) { 'product_tax' => ['label' => 'Tax'], 'vendor_name' => ['label' => 'Vendor', 'default' => false], 'product_vendor_part' => ['label' => 'Part Number', 'default' => false], - 'product_stock_display' => ['label' => 'In Stock', 'format' => 'number', 'default' => false], + 'product_stock_display' => ['label' => 'In Stock', 'default' => false], + 'product_stock_value' => ['label' => 'Stock Value', 'format' => 'money', 'default' => false], + 'product_reorder_display' => ['label' => 'Reorder At', 'default' => false], + 'product_order_display' => ['label' => 'Order', 'default' => false], 'product_location' => ['label' => 'Location', 'default' => false], 'product_notes' => ['label' => 'Notes', 'weight' => 3, 'default' => false], ], diff --git a/functions/notifications.php b/functions/notifications.php index fca3b336a..82ab777aa 100644 --- a/functions/notifications.php +++ b/functions/notifications.php @@ -27,7 +27,7 @@ function notificationTypeModules() { 'module_sales' => [ 'Invoice', 'Invoice Paid', 'Invoice Viewed', 'Invoice Overdue', 'Invoice Late Charge', 'Draft Invoices', 'Recurring Sent', 'Recurring Generated', - 'Quote Viewed', 'Quote Accepted', 'Quote Declined', 'Quote File', + 'Quote Viewed', 'Quote Accepted', 'Quote Declined', 'Quote File', 'Low Stock', ], 'module_financial' => ['Expense', 'Expense Created'], 'module_support' => [ @@ -70,6 +70,7 @@ function notificationTypeStyle($type) { [['accepted'], 'check-circle', 'success'], [['overdue', 'late charge'], 'exclamation-triangle', 'warning'], [['expiring'], 'hourglass-half', 'warning'], + [['stock'], 'boxes', 'warning'], [['viewed'], 'eye', 'info'], [['quote'], 'comment-dollar', 'primary'], [['invoice', 'recurring sent', 'recurring generated'], 'file-invoice-dollar', 'primary'], diff --git a/functions/products.php b/functions/products.php index 70bc1bd62..cd3f85646 100644 --- a/functions/products.php +++ b/functions/products.php @@ -108,7 +108,8 @@ function productVendors() { * cost, tax (a tax id), frequency ('' once, month, quarter, year), * seat_source ('' as typed, contacts, assets - only when billed recurring), * code, vendor (one of our vendors), vendor_part, track_stock (products - * only), location, description, notes. + * only), reorder_level and reorder_qty (when to buy more, how many), + * location, description, notes. * Returns the product id, or 0 with $error. */ function productSave(array $in, $product_id = 0, &$error = '') { @@ -207,6 +208,22 @@ function productSave(array $in, $product_id = 0, &$error = '') { $set[] = 'product_track_stock = ' . ($type === 'product' && $track ? 1 : 0); } + // When to buy more, and how many + foreach (['reorder_level' => 'product_reorder_level', 'reorder_qty' => 'product_reorder_qty'] as $key => $column) { + if (!$has($key)) { + continue; + } + $raw = trim(str_replace([',', ' '], '', (string) ($in[$key] ?? ''))); + if ($raw === '') { + $raw = '0'; + } + if (!is_numeric($raw) || floatval($raw) < 0 || floatval($raw) >= 1e9) { + $error = ($key === 'reorder_level' ? 'Reorder at' : 'Order') . ' has to be a number, zero or more.'; + return 0; + } + $set[] = "$column = " . round(floatval($raw), 2); + } + foreach (['code' => ['product_code', 200], 'vendor_part' => ['product_vendor_part', 200], 'location' => ['product_location', 250]] as $key => [$column, $length]) { if ($has($key)) { $value = trim((string) ($in[$key] ?? '')); @@ -245,6 +262,7 @@ function productCopy($product_id) { 'price' => $product['product_price'], 'cost' => $product['product_cost'], 'tax' => $product['product_tax_id'], 'frequency' => $product['product_frequency'], 'seat_source' => $product['product_seat_source'], 'code' => '', 'vendor' => $product['product_vendor_id'], 'vendor_part' => $product['product_vendor_part'], 'track_stock' => $product['product_track_stock'], + 'reorder_level' => $product['product_reorder_level'], 'reorder_qty' => $product['product_reorder_qty'], 'location' => $product['product_location'], 'description' => $product['product_description'], 'notes' => $product['product_notes'], ], 0, $error); } @@ -342,23 +360,181 @@ function productHistory($product_id, $limit = 100) { return $rows; } -/** A product's recent stock moves, newest first, with the expense each came in on. */ +/* ------------------------------------------------------------- Stock ledger */ + +/** Kinds of stock move => label, icon and tone. */ +function productStockTypes() { + return [ + 'received' => ['label' => 'Received', 'icon' => 'fa-dolly', 'tone' => 'success'], + 'sold' => ['label' => 'Sold', 'icon' => 'fa-file-invoice-dollar', 'tone' => 'primary'], + 'returned' => ['label' => 'Returned', 'icon' => 'fa-undo', 'tone' => 'info'], + 'adjusted' => ['label' => 'Counted', 'icon' => 'fa-clipboard-check', 'tone' => 'secondary'], + 'written_off' => ['label' => 'Written off', 'icon' => 'fa-minus-circle', 'tone' => 'danger'], + ]; +} + +/** Why stock is written off. */ +function productWriteOffReasons() { + return ['Damaged', 'Lost or stolen', 'Used by us', 'Sent back to the vendor', 'Other']; +} + +/** What is on the shelf now. */ +function productOnHand($product_id) { + global $mysqli; + return round(floatval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(stock_qty), 0) FROM product_stock WHERE stock_product_id = " . intval($product_id)))[0]), 2); +} + +/** Tracked products at or under their reorder level, or out - in SQL, with products in the FROM. */ +function productReorderSql() { + return "(products.product_track_stock = 1 AND products.product_archived_at IS NULL AND " . productStockSql() . " <= GREATEST(products.product_reorder_level, 0))"; +} + +/** How many tracked products need ordering. */ +function productReorderCount() { + global $mysqli; + return intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(*) FROM products WHERE " . productReorderSql()))[0]); +} + +/** + * Record a stock move. $qty is signed - in above zero, out below. $type is one + * of productStockTypes(). $opts: note (raw), unit_cost (anything but a + * delivery takes the product's cost when not given), invoice_id, item_id, + * expense_id. Tells + * agents when it takes the product to its reorder level or out. The move's id, + * or 0 when there is nothing to record. + */ +function productStockMove($product_id, $qty, $type, array $opts = []) { + global $mysqli, $session_user_id; + $product_id = intval($product_id); + $qty = round(floatval($qty), 2); + if (!$product_id || $qty == 0 || !isset(productStockTypes()[$type])) { + return 0; + } + $before = productOnHand($product_id); + $unit_cost = $opts['unit_cost'] ?? null; + // What it cost - given for a delivery; anything else is at the product's cost now + if (($unit_cost === null || $unit_cost === '') && $type !== 'received') { + $unit_cost = getFieldById('products', $product_id, 'product_cost'); + } + $unit_cost = $unit_cost === null || $unit_cost === '' ? 'NULL' : round(floatval($unit_cost), 2); + $note = escapeSql(mb_substr(trim((string) ($opts['note'] ?? '')), 0, 1000)); + $invoice_id = intval($opts['invoice_id'] ?? 0); + $item_id = intval($opts['item_id'] ?? 0); + $expense_id = intval($opts['expense_id'] ?? 0); + $user_id = intval($session_user_id ?? 0); + mysqli_query($mysqli, "INSERT INTO product_stock SET stock_qty = $qty, stock_type = '$type', stock_unit_cost = $unit_cost, stock_note = '$note', + stock_expense_id = " . ($expense_id ?: 'NULL') . ", stock_invoice_id = $invoice_id, stock_item_id = " . ($item_id ?: 'NULL') . ", + stock_user_id = $user_id, stock_product_id = $product_id"); + $stock_id = intval(mysqli_insert_id($mysqli)); + productReorderCheck($product_id, $before, round($before + $qty, 2)); + return $stock_id; +} + +/** Tell agents when stock going out reaches the reorder level, or runs out. */ +function productReorderCheck($product_id, $before, $after) { + if ($after >= $before) { + return; + } + $product = productRow($product_id); + if (!$product || !intval($product['product_track_stock'])) { + return; + } + $level = floatval($product['product_reorder_level']); + $name = $product['product_name']; + if ($after <= 0 && $before > 0) { + $text = "$name is out of stock"; + } elseif ($level > 0 && $before > $level && $after <= $level) { + $text = "$name is down to " . billingQuantity($after) . " - reorder at " . billingQuantity($level); + } else { + return; + } + if (floatval($product['product_reorder_qty']) > 0) { + $text .= ', order ' . billingQuantity($product['product_reorder_qty']); + } + if ($product['vendor_name']) { + $text .= ' from ' . $product['vendor_name']; + } + appNotify('Low Stock', escapeSql($text), 'product.php?product_id=' . intval($product['product_id']), 0, intval($product['product_id'])); +} + +/** + * Put back what an invoice took off the shelf and still has - when it is + * cancelled or deleted. $why is the note, raw. The number of products returned. + */ +function productStockReturnInvoice($invoice_id, $why) { + global $mysqli; + $invoice_id = intval($invoice_id); + $returned = 0; + $sql = mysqli_query($mysqli, "SELECT stock_product_id, SUM(stock_qty) AS net FROM product_stock WHERE stock_invoice_id = $invoice_id GROUP BY stock_product_id HAVING net < 0"); + while ($row = mysqli_fetch_assoc($sql)) { + if (productStockMove($row['stock_product_id'], -floatval($row['net']), 'returned', ['invoice_id' => $invoice_id, 'note' => $why])) { + $returned++; + } + } + return $returned; +} + +/** + * Lines about to go on an invoice that take tracked products past what is on + * the shelf - the first one as a message, or '' when there is enough of all. + */ +function productStockShortfall(array $items) { + global $mysqli; + $needed = []; + foreach ($items as $item) { + $id = intval($item['item_product_id'] ?? 0); + if ($id && floatval($item['item_quantity']) > 0) { + $needed[$id] = ($needed[$id] ?? 0) + floatval($item['item_quantity']); + } + } + if (!$needed) { + return ''; + } + $sql = mysqli_query($mysqli, "SELECT product_id, product_name, " . productStockSql() . " AS on_hand FROM products + WHERE product_track_stock = 1 AND product_id IN (" . implode(',', array_keys($needed)) . ")"); + while ($row = mysqli_fetch_assoc($sql)) { + $want = round($needed[intval($row['product_id'])], 2); + if (floatval($row['on_hand']) < $want) { + return 'Not enough ' . $row['product_name'] . ' in stock - ' . billingQuantity($row['on_hand']) . ' left, ' . billingQuantity($want) . ' needed.'; + } + } + return ''; +} + +/** + * A product's stock moves, newest first, with the balance after each, the + * invoice or expense behind it and who made it. Invoices of clients the agent + * can't open have no link ('invoice_open' 0). + */ function productStockMoves($product_id, $limit = 25) { global $mysqli; $product_id = intval($product_id); $limit = max(1, intval($limit)); + $balance = productOnHand($product_id); $rows = []; - $sql = mysqli_query($mysqli, "SELECT stock_id, stock_qty, stock_note, stock_created_at, stock_expense_id, expense_description, expense_date, expense_amount, expense_currency_code + $sql = mysqli_query($mysqli, "SELECT stock_id, stock_qty, stock_type, stock_unit_cost, stock_note, stock_created_at, stock_expense_id, stock_invoice_id, + expense_description, expense_date, expense_amount, expense_currency_code, invoice_prefix, invoice_number, invoice_client_id, user_name FROM product_stock LEFT JOIN expenses ON expense_id = stock_expense_id + LEFT JOIN invoices ON invoice_id = stock_invoice_id AND stock_invoice_id > 0 + LEFT JOIN users ON user_id = stock_user_id AND stock_user_id > 0 WHERE stock_product_id = $product_id ORDER BY stock_id DESC LIMIT $limit"); while ($row = mysqli_fetch_assoc($sql)) { + $row['balance'] = $balance; + $balance = round($balance - floatval($row['stock_qty']), 2); + $row['invoice_open'] = $row['invoice_number'] !== null && hasClientAccess(intval($row['invoice_client_id'])) ? 1 : 0; $rows[] = $row; } return $rows; } +/** A move's quantity with its sign - "+4", "-2", "+1.5". */ +function productStockQty($qty) { + $qty = floatval($qty); + return ($qty > 0 ? '+' : '') . billingQuantity($qty); +} + /** * The margin on a document's lines that come from a product with a cost, at * today's cost - for the agent's eyes only. By how often each line is billed diff --git a/functions/reports.php b/functions/reports.php index 72179af42..d578c427a 100644 --- a/functions/reports.php +++ b/functions/reports.php @@ -101,6 +101,10 @@ function reportCatalog() { 'title' => 'Quotes Pipeline', 'icon' => 'fa-comment-dollar', 'module' => 'module_sales', 'feature' => ['accounting'], 'description' => 'Open quotes, your win rate, and the value won and lost.', ], + 'inventory' => [ + 'title' => 'Inventory', 'icon' => 'fa-warehouse', 'module' => 'module_sales', 'feature' => ['accounting'], + 'description' => 'Stock on the shelf and what it is worth, what came in and went out at cost, and what to reorder.', + ], 'tickets_unbilled' => [ 'title' => 'Unbilled Work', 'icon' => 'fa-receipt', 'module' => 'module_sales', 'feature' => ['accounting', 'ticketing'], 'description' => 'Billable tickets that are done but not invoiced yet, with the time and value on them.',