diff --git a/docs/inventory-calculation-reference.md b/docs/inventory-calculation-reference.md new file mode 100644 index 0000000..381314c --- /dev/null +++ b/docs/inventory-calculation-reference.md @@ -0,0 +1,1380 @@ +# Inventory Planner Glossary + + +# 7 Days Revenue +Shows revenue for the most recent seven days regardless of the date range of the report. + +For example, if using the Inventory KPIs report for April 1 - 7, the 7 Days Revenue will still show revenue for the last seven days. The Revenue column will show revenue for the date range of the report. + +# 7 Days Sales +The number of units that were sold the previous seven days. + +# 14 Days Revenue +Shows revenue for the most recent 14 days regardless of the date range of the report. + +For example, if using the Inventory KPIs report for April 1 - 7, the 14 Days Revenue will still show revenue for the last 14 days. The Revenue column will show revenue for the date range of the report. + +# 14 Days Sales +The number of units that were sold the previous 14 days. + +# 30 Days Revenue +Shows revenue for the most recent 30 days regardless of the date range of the report. + +For example, if using the Inventory KPIs report for April 1 - 7, the 30 Days Revenue will still show revenue for the last 30 days. The Revenue column will show revenue for the date range of the report. + +# 30 Days Sales +The number of units that were sold in the last 30 days, including stockouts (out of stock days) if applicable. + +# 30 Days Stockouts +The number of days in the last 30 days when a product has been out of stock. + +Stockouts are the days when a product is out of stock. For example, if stockouts shows as '15', then that means the stock level for the variants was 0 or less for 15 days during the date range.  + +# 365 Days Revenue +Shows revenue for the most recent 365 days regardless of the date range of the report. + +For example, if using the Inventory KPIs report for April 1 - 7, the 365 Days Revenue will still show revenue for the last 365 days. The Revenue column will show revenue for the date range of the report. + +# 365 Days Sales +The number of units that were sold in the last 365 days, including stockouts (out of stock days) if applicable. +# A +# ABC Class or “A” Class, “B” Class, “C” Class +Classes are categories of your inventory. By default, the “A” Class includes SKUs comprising the top 80% of revenue, “B” Class is the next 15%, and “C” Class is the final 5%.  + +You can change these percentages in **Account > Settings > Forecasting**. By default, we calculate classes based on revenue within the last 30 days of sales, but the option to calculate it based on the number of units sold and lifetime sales is available. + +# ACP (Average Cost Price) +Average cost price = COGS / number of units sold + +# Actual sales +Shown on the Edit Forecast page, Actual Sales indicates the number of units sold less returns. + +# Adjusted sales velocity +The adjusted sales velocity is based on forecast heuristics such as "excluding holiday sales". Note that it can be different to the sales velocity, calculated as sales divided by the number of days in stock. + +# Age +The length of time since the first order date for a product or variant. If it hasn't yet been sold, then the "created at" date will be used as the start date for calculating age. + +# Alerted (Filter) +The alerted filter (where Alerted = Yes) highlights variants that have either run out of stock or will run out of stock before they can be replenished (stockouts are forecast during the lead time). Alerted items will be indicated with either a red or yellow "Details" icon.  + +![](image.png) +![](image_2.png) +# Annotated (Filter) +The annotated filter (where Annotated = Yes) displays variants that contain information in the Notes field. To view the Notes field, click on the gear icon in the upper right corner of Replenishment.  + +# Approve +Found in the bulk actions menu, "Approve draft" is used to change a purchase order's status from "Draft" to "Open". + +# ASP (Average Sale Price) +Average sale price = Revenue / number of units sold + +# Assembly +An **assembly** must be produced from components (variants) before it can be placed into stock and made available for sale. These goods are pre-assembled before they are sold and fulfilled. + +Assemblies include any produced goods with a bill of materials (BOM), and bundles/kits that must be produced before sending to Amazon (or a different fulfillment warehouse). **All bundles associated with FBA are considered assemblies**, since Amazon only carries finished goods/produced bundles. + +~[Click here to learn more about assemblies and bundles.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Assembly (filter) +Add the **"Assembly"** filter to search for component variants connected to a specific bundle. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Assembly component (filter) +Add the "**Assembly Component**" filter to limit variants in display to component variants that are needed when assemblies are sold. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Assembly orders +Also considered “production orders,” these are orders to build new assemblies from existing (or incoming) component stock. *~[Learn more about Assembly Orders](https://help.inventory-planner.com/en/articles/2601116-assembly-orders)~.* + +# Assembly cycle +An assembly cycle is a production cycle describing how often assemblies are produced (in days). For example, to break down assemblies replenishment into a weekly production schedule, you would input 7 days as the assembly cycle. + +The assembly cycle is configured in the "Assemblies" view of Replenishment as the frequency of production runs in days. + +~[Click here to learn more about assemblies and configuring Assembly time in your account.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Assembly time +The time it takes to produce new assemblies if/when component stock is available. Note that this is separate from the component (variant) lead time which describes how long it takes to order items from a supplier. + +The assembly time is configured in the "Assemblies" view of Replenishment as the time it takes in days to produce new assemblies from existing component variant stock. It defines the "Expected Date" for new assembly orders. + +~[Click here to learn more about assemblies and configuring Assembly time in your account.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Available on hand (filter) +The **"Available On Hand"** filter displays all items in stock (when Available On Hand = Yes). If Available On Hand filter = No, then only items with 0 or negative stock levels will show in the report. + +**Available stock** is inventory on hand at that location *in excess* of what will be needed during the days of stock (planning period). This metric is based on the lowest amount of stock needed (based on the forecast) during the days of stock. Any inventory available over that amount is identified as available stock. + +(Note that Overstock is excess inventory *after* the days of stock, while available stock is excess inventory *during* the days of stock.) + +# AVG cover (in months) +Average cover in months = Number of units in stock / average monthly sales (AVG sales per month) + +... where stock is always the current stock and average monthly sales are always for the Replenishment period. + +![](image_3.png) +![](image_4.png) +# AVG lead time +Average lead time is calculated per variant by looking at the number of days between a purchase order's creation and received date, for every purchase order containing the item. The total is then averaged. + +# AVG PO Cover (in months) +Average purchase order cover in months = Number of units in non-closed purchase orders / average monthly sales + +# AVG Retail Price (Average Retail Price) +Average retail price = Revenue / units sold for the selected period of time + +Where: +Revenue = gross revenue - discounts - returns +Gross revenue = number of units sold × sales order price before discounts and taxes +Discounts = gross revenue - revenue + +Returns are the number of product units included on returns (items sent back to the merchant). Return information is pulled from your connect sales platform and cannot be edited with Inventory Planner. + +# AVG ROS (Average Return on Sales) +Average return on sales = profit / number of units sold + +# AVG Sales / Day +Average sales per day = Sales / time period in days + +There are no adjustments for out of stock days. + +# AVG Sales / Mo +Average sales per month = Sales / time period in months + +There are no adjustments for out of stock days. + +# AVG Stock Cost (Average stock cost) +Average stock cost = Sum of stock cost of inventory during the select time period / number of days in the selected period + +Where: +Stock cost = units in stock × cost price of the item + +Cost price is the variant's price in the supplier catalog. It is the price you pay to a vendor per item as noted on your purchase order.Historical stock information is available as of the date that your Inventory Planner account was created.  + +# AVG Stock Gross (Average stock gross) +Average stock gross = Sum of gross stock cost of inventory during the select time period / number of days in the selected period + +Where: +Stock gross = regular price × stockThe regular price is the product retail price before discount, sometimes called the "compare to" price. This price is set in your sales platform and cannot be edited in Inventory Planner. + +Stock is the number of units of inventory. This number is pulled from your connected platform and cannot be edited in Inventory Planner (unless you have an IP warehouse set up, in which case you will need to control stock manually). Historical stock information is available as of the date that your Inventory Planner account was created.  + +# AVG Stock Retail (Average stock retail) +Average stock retail = sum of gross stock retail of inventory during the select time period / number of days in the selected period + +Where: +Stock retail = Price × stock + +This will be the retail price of a unit of inventory.  + +Price is the current product retail price as set on your sales platform. This price is set in your sales platform and cannot be edited in Inventory Planner.Historical stock information is available as of the date that your Inventory Planner account was created.  + +# AVG Stock Units (Average stock units) +Average stock units = Units in stock during the selected time period / number of days in the selected time period + +Historical stock information is available as of the date that your Inventory Planner account was created. + +# B +# Barcode +The UPC of a product. + +If your connected sales platform does not have a barcode field, you can ~[import this information using a Single or Multiple Vendor Catalog](https://help.inventory-planner.com/en/articles/1832728-uploading-vendor-catalogs)~. + +# Brand +The name a product is sold under on your connected site, e.g. Nike. + +Note: For Shopify users, the "Vendor" field in Shopify maps to the "Brand" field in Inventory Planner. + +# Bundles +A group of variants sold together as a single unit, e.g. a first aid kit. + +Note that bundles are different from Assemblies that need to be pre-manufactured or pre-assembled before they are placed into stock and made available for sale. + +Read more about ~[how to set up bundles and how they will show in Replenishment](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~. + +# Bundle (filter) +Add the **"Bundle"** filter to search for component variants connected to a specific bundle. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Bundle component (filter) +Add the **"Bundle component"** filter to only display component variants which are sold as a part of bundles. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# C +# Category +A label for a group of similar products. For example, skis might be in a winter sports category.  + +Categories are created in your connected sales platform and are not editable within Inventory Planner. + +# CBM +Cubic meters per unit. When this is set, Inventory Planner will show CBM in purchase orders so that you can estimate how many cubic meters the container will be.  + +Replenishment CBM = CBM of an individual item × replenishment amount + +~[Read more about setting CBM.](https://help.inventory-planner.com/en/articles/2556039-how-do-i-add-cbm-or-the-size-of-my-product)~ + +# Class (filter) +The "Class" filter refers to **ABC Class** or “A” Class, “B” Class, “C” Class. + +Classes are categories of your inventory. By default, the “A” Class includes SKUs making up the top 80% of revenue, “B” Class is the next 15%, and the “C” Class is the final 5%. + +You can change these percentages in **Account > Settings > Forecasting**. Classes are determined based on the last 30 days of sales. + +# Closing Stock +The amount of stock on hand for the end of a time period. The closing stock is based on the historical inventory snapshots taken by Inventory Planner. + +~[Read more about how to view closing stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Closing stock cost +The cost value of stock at the end of the selected period. +Closing stock cost = Cost price × stock as of last sync +Where the cost price is the product price in the supplier catalog and the stock is based on the historical inventory snapshots taken by Inventory Planner. *Note: This is always based on the historical cost prices. Contact support to update metrics based on the current cost price.* + +~[Read more about how to view closing stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Closing stock gross +The gross value of stock at the end of the selected period. +Closing stock gross = Regular price × units in stock +Where regular price is the retail price before discounts and the stock is based on the historical inventory snapshots taken by Inventory Planner. *Note: This is always based on the historical regular retail prices.* + +~[Read more about how to view closing stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Closing stock retail +The retail value of stock at the end of the selected period. +Closing stock retail = Price × units in stock +Where the stock is based on the historical inventory snapshots taken by Inventory Planner. *Note: This is always based on the historical retail prices.* + +~[Read more about how to view closing stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Collection +A collection is a set of products presented together, also usually visible on the store's front end. A collection can contain products from different categories and a product can belong to several collections. + +Collections are created in your connected sales platform and are not editable within Inventory Planner. + +# Cost of Goods Sold (COGS) +COGS = Number of units sold × landing cost price +If the "landing cost price" is **not** set, or is set to "0.00", the "Cost Price" field is used. + +The landing cost price is the total unit cost for including cost price and related expenses. In addition to the vendor cost it includes shipping, handling, and discounts. Landing cost is the cost price in addition to extra expenses (not only the extra expenses). + +*Note: For EU merchants, VAT should be included as part of the Cost Price.* + +"Cost price" is the variant price in the supplier catalog. This is the price you pay to a vendor per item as noted on your purchase order. + +# COGS Diff % (Percentage change in cost of goods sold) +Percentage change in COGS = (Cost of goods during current time period - Cost of goods during previous time period) / Cost of goods during the previous time period + +# Combined Warehouse +A combined warehouse can be used to see total demand from more than one warehouse. A combined warehouse allows you to choose which warehouses to include to show total demand (sales information). You also specify which warehouses' stock to consider.  + +~[Read more about combined warehouses, when to use them, and how to handle set-up.](https://help.inventory-planner.com/en/articles/2820340-combined-warehouse-set-up)~ + +# Competitive price +For Amazon sellers, the competitive price is pricing for active offer listings based on two pricing models: New Buy Box Price and Used Buy Box Price. + +~[Learn more about the competitive price on Amazon here.](https://docs.developer.amazonservices.com/en_US/products/Products_GetCompetitivePricingForSKU.html)~ + +# Components lead time +Component variant lead times are configured using the "Variants" view in Replenishment. They will be pulled into the "Assemblies" view automatically, and are considered in the replenishment analysis for new assemblies (above and beyond what can be assembled from existing component stock). + +~[Read more about bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Conversion rate +Conversion rate = Number of units purchased / number of page views + +This information pulls from Google Analytics and cannot be edited in Inventory Planner. + +# Cost compare +The "Cost Compare" metric shows the cost prices from each vendor associated with a variant. + +When you have a variant that can be sourced from different vendors, there could be different cost prices associated with each. The "Cost Compare" feature shows each of these cost prices. + +~[Learn more about how to compare costs when a variant is sourced from more than one vendor.](https://help.inventory-planner.com/en/articles/3280013-compare-costs-from-different-vendors)~ + +# Cost price +Cost price is the variant price in the supplier catalog. It is the price you pay to a vendor per item as noted on your purchase order. If there's more than one vendor, the cost price displayed is an average of them. + +If your connected platform has a cost field, we recommend updating cost prices there to keep all information in sync (as updating costs in Inventory Planner will not push that information to your platform). If your platform does not have a cost field, then you can ~[add costs directly to Inventory Planner](https://help.inventory-planner.com/en/articles/1770885-how-do-you-set-cost-prices-for-products)~.When you have a variant that can be sourced from multiple vendors, there could be different cost prices associated with each. The "Cost compare" feature can display each of these cost prices.~[Learn more about how to compare costs when a variant is sourced from more than one vendor.](https://help.inventory-planner.com/en/articles/3280013-compare-costs-from-different-vendors)~ + +# Created at +The date at which the product was imported or created in your sales platform. + +Note the "created at" date will reflect when the product or variant was set up in your system, and is different from the "published at" date which indicates when a product most recently became visible to your customers. + +# Current stock +The inventory level available as of the last sync between Inventory Planner and your connected platform(s). + +# Custom Alert (filter) +Using the Custom Alert filter (where Custom Alert = Yes) will display variants that have alert settings which differ from your account settings. + +Low stock alerts, email frequency, safety stock, and notify in advance alerts can be set in **Account > Settings > Alerts**. + +Variants can be given different settings by selecting them and using Bulk Actions. + +~[Learn more about setting alerts for your account or custom alerts for variants.](https://help.inventory-planner.com/en/articles/1959514-how-do-i-set-up-a-low-inventory-alert-or-safety-stock)~ + +# D +# Days of Stock +The number of days that inventory should cover. + +Days of Stock are important to determining reasonable replenishment recommendations, and are also used to ~[report on overstocked inventory](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~. Products with stock in excess of forecasted days of stock will show as overstocked. + +~[Read more about how to set the lead time and days of stock.](https://help.inventory-planner.com/en/articles/2218187-how-can-i-set-the-lead-time-and-days-of-stock)~ + +# "Days of Stock" closing stock +The estimated amount of inventory available for sale at the end of the Days of Stock period. The "Days of Stock" figure is the number of days that inventory should cover, starting after the lead time (i.e. the time it takes to order and receive inventory from your vendor). + +The Days of Stock closing stock is calculated by taking the Lead Time closing stock and subtracting the estimated number of units sold during the Days of Stock (i.e. the Days of Stock period forecast). + +For example, a Days of Stock closing stock of 110 on February 13 2021 indicates that the estimated stock level will be 110 units as of February 13 2021, where February 13 2021 is today's date, plus the lead time in days, plus the Days of Stock in days. + +The Lead Time closing stock factors in the current stock level, items on order and transferring in/out of the selected warehouse, and units estimated to be sold during this time (indicated as the Lead Time forecast). + +~[Read more about how to set the lead time and days of stock.](https://help.inventory-planner.com/en/articles/2218187-how-can-i-set-the-lead-time-and-days-of-stock)~ + +# "Days of Stock" period forecast +The number of units that should sell during the "Days of Stock" period. + +For example, a "Days of Stock" period forecast that reads 1010 for 30 days indicates that Inventory Planner calculates 1010 units should sell during the 30 day period (where Days of Stock is set to 30). + +~[Read more about how to set the lead time and days of stock.](https://help.inventory-planner.com/en/articles/2218187-how-can-i-set-the-lead-time-and-days-of-stock)~ + +# Details +![](image_5.png) +By clicking on the "i" icon in the Details column, you can access more information about replenishment, forecasted stock levels, edit the forecast or historical sales information, view stock history, set up bundles, merge or link variants, and customize low stock alerts. + +# Discounts +The discounted price is taken from actual sales orders by comparing the listed price to the price in the sale order. + +Discounts = Gross revenue - revenue +Where: +Gross revenue = number of units sold × sales order price before discounts +Revenue = gross revenue - discounts - returns + +# Discounts % +The discount percentage of gross revenue. + +# Discounted (filter) +Using the Discounted filter (where Discounted = Yes) displays all items that have a planned discount.  + +Planned discount = Regular price - price  + + +# E +# Excluded holiday sales +The number of units sold and the corresponding number of dates removed from the forecast calculation when using non-seasonal forecasting methods. + +For non-seasonal forecasting methods (Recent Sales & Trends and Last Sales), it can be helpful to exclude certain periods of unusual sales from the forecast calculation. For example, by excluding Black Friday and Cyber Monday sales, the forecast the following weeks and months will not be overstated following a spike in sales. ~[Configure excluded sales in Account > Settings > Forecast.](https://help.inventory-planner.com/en/articles/638170-forecast-methods-and-settings-how-to-configure-the-forecast)~ + +# Expected date +The calendar date on which items on a purchase order or transfer are anticipated to arrive. Providing an expected date that is as close to accurate as possible will improve the accuracy of your replenishment forecast.  + +If there are multiple purchase orders, transfers, or both that are open, the earliest expected date will be reflected in the Expected Date column of reporting. + +The expected date on a transfer is calculated as the creation date plus the lead time by default. It can be edited when creating a purchase order or transfer. + +# F +# First 7 days revenue +Revenue made during the first 7 days after the first sale date. If the first sale date is not available, then the product creation date is used. + +# First 7 days sales +The number of units sold during the first 7 days after the first sale date. If the first sale date is not available, then the product creation date is used. + +# First 30 days revenue +Revenue made during the first 30 days after the first sale date. If the first sale date is not available, then the product creation date is used. + +# First 30 days sales +The number of units sold during the first 30 days after the first sale date. If the first sale date is not available, then the product creation date is used. + +# First 60 days revenue +Revenue made during the first 60 days after the first sale date. If the first sale date is not available, then the product creation date is used.  + +# First 60 days sales +The number of units sold during the first 60 days after the first sale date. If the first sale date is not available, then the product creation date is used.  + +# First 90 days revenue +Revenue made during the first 90 days after the first sale date. If the first sale date is not available, then the product creation date is used. + +# First 90 days sales +The number of units sold during the first 90 days after the first sale date. If the first sale date is not available, then the product creation date is used. + +# First received at +The date on which a product was first received on a purchase order or transfer. + +~[Read more about receiving a purchase order.](https://help.inventory-planner.com/en/articles/2893749-purchase-orders-receiving-stock)~ + +# First received qty (quantity) +The number of units received into inventory from the initial vendor purchase order or transfer.~[Read more about receiving a purchase order.](https://help.inventory-planner.com/en/articles/2893749-purchase-orders-receiving-stock)~ + +# First sold at +Shows the first sales for a product. + +This information comes from your sales platform and cannot be edited in Inventory Planner. + +# Forecast +The projected customer demand, displayed as sales in units for the "days of stock" period. + +The forecast is calculated using the sales velocity and the sales trends in recent months (are sales increasing or decreasing?). Sales velocity is the rate of sales excluding out of stock days. Seasonal products emphasize the sales trends from the prior year rather than the most recent months. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Forecast cost +Forecast cost = Forecast units × cost price per unit + +This is the projected cost of forecasted units for the "days of stock" period. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Forecast lost sales +The sales units missed during the lead time due to stockouts. + +Stockouts are the days when a product is out of stock. If there is a stockout during the lead time based on the forecast, current and incoming stock, potential sales will be missed since a new PO can only arrive after the lead time. This metric is an estimation how many sales units will be missed during lead time. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Forecast lost profit +Forecast lost profit = Forecast lost sales × (price - cost price) + +# Forecast lost revenue +Forecast lost revenue = Forecast lost sales × price + +# Forecast profit +Forecast profit = Forecast units × profit per unit + +The forecast profit is the projected profit for the "days of stock" period. + +Profit per unit looks forward to show the listed price and current landed cost. If the landed cost price is not available, Inventory Planner will use the cost price. + +For Amazon sellers, product profit is the competitive price - landed cost price - the estimated platform fee. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Forecast revenue +Forecast revenue = Forecast units × price +The forecast revenue is the projected revenue for the "days of stock" period. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Forecast sales +The forecast sales is the projected customer demand shown as sales in units for the selected period on the **Overview > Forecast** section. + +# Forecasted stock +Forecasted stock is based on the forecasted demand for each product and the current stock on hand. + +Forecasted stock for the lead time and days of stock are available in **Replenishment > Details**. Read more about ~[how different forecasts are calculated](https://help.inventory-planner.com/frequently-asked-questions/forecast/how-is-the-forecast-calculated)~ in Inventory Planner. + +# Forecast wholesale +The total number of units needed for wholesale orders. + +~[Learn more about wholesale planning in Inventory Planner.](https://help.inventory-planner.com/en/articles/2332833-wholesale-planning)~ + +# G +# GMROI (Gross margin return on investment) +GMROI = profit / average stock cost + +Where: +Profit = Revenue - COGS - platform fee +Revenue = Gross revenue - discounts - returns +COGS = Number of units sold × landing cost priceIf landing cost price is not set the cost price field is used. + +Average stock cost = Sum of stock cost of inventory during the selected time period / number of days in the selected period +Stock cost = Units in stock × cost price of the item + +Cost price is the variant price in the supplier catalog. This is the price you pay to a vendor per item as noted on your purchase order.Historical stock information is available as of the date that your Inventory Planner account was created.  +# Gross Regular Revenue +Gross regular revenue = Number of units sold × regular price +Gross regular revenue is calculated using the regular price (sometimes called the "compare to" price). + +# Gross Revenue +Gross revenue = number of units sold × sales order price before discounts and taxes + +Gross revenue does not subtract returns and is same as the gross sales metric in Shopify. + +# Gross Sales +Gross sales = Sales + returns + +Number of units sold during the time period. Returns are not deducted from this number. + +# H +# Hidden in overstock (filter) +Using the "Hidden in Overstock" filter (where Hidden in Overstock = Yes) displays items that won't appear in the ~[Overstock report](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ because a user has hidden them from view (for example, items that are relatively new and you do not consider overstocked).  + +# Holiday excluded sales +When a non-seasonal model is used, Inventory Planner excludes holiday sales such as Black Friday, Cyber Monday and the week before Christmas so that unusually high sales do not skew forecasting calculations.  + +# I +# ID +A unique number assigned to each variant by your eCommerce platform or inventory management system. + +# Image +A photo of the product or variant, pulled from your connected sales platform. + +# In assemblies +When viewing the stock levels of assembly components (usually in the Variant view of Replenishment), this figure represents additional/separate units that have already been assembled into final goods. These units are not available to create new assemblies. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# In-Stock % (In-Stock percentage) +In-Stock % = Days in stock / days in the selected period + +The In-Stock percentage is the inverse of the Stockout percentage metric. + +Stockouts are the days when a product is out of stock. + +# IP tag +IP tags (Inventory Planner tags) allow you to assign tags to variants within your IP account. These allow you to group, filter or search for variants using these labels. ~[Read more about how to set up IP tags here.](https://help.inventory-planner.com/en/articles/2242320-what-are-ip-tags)~ + +# IP variant +A component of a bundle that isn't sold as an individual variant. IP variants only exist in Inventory Planner. ~[Read more about how to set up an IP variant here.](https://help.inventory-planner.com/en/articles/3082838-ip-variant)~ + +# IP variant (filter) +Using the IP variant filter (where IP variant = Yes) will display only IP Variants in your report. ~[Read more about how to set up an IP variant here.](https://help.inventory-planner.com/en/articles/3082838-ip-variant)~ + +# IP warehouse +A manual warehouse used to represent a location containing inventory that customer orders are not directly fulfilled from. This manual warehouse exists only in Inventory Planner and is used to transfer stock to other locations that do handle order fulfillment.  + +~[Read more about IP warehouses here.](https://help.inventory-planner.com/en/articles/2903030-ip-warehouse)~ + +# Is bundle component (filter) +Using the "Is Bundle Component" filter (where "Is Bundle Component" = Yes) will display only variants that are components of bundles in Inventory Planner.  + +~[Learn more about setting up bundles and components.](https://help.inventory-planner.com/en/articles/885104-bundles-and-packs)~ + +# L +# Labels +Noteworthy descriptions for products and variants including "New Product", "Overstocked", and "Discounted". + +# Landing cost price +Landing cost price is the **total unit cost**, including cost price and related expenses. In addition to vendor cost, the landing cost includes shipping and handling and discounts. **Landing cost is the cost price in addition to extra expenses** (and not only the extra expenses).  + +*Note: For EU merchants, VAT should be included as part of the cost price.* + +Landing costs can be imported, which is useful when your Inventory Planner account is new. Over time, Inventory Planner will compute updated landing costs as you create and receive purchase orders, provided you set a shipping cost and choose to update variants on saving the orders. + +# Last received at +The most recent date a product was received and added to inventory. + +~[Read more about receiving a purchase order.](https://help.inventory-planner.com/en/articles/2893749-purchase-orders-receiving-stock)~ + +# Last received qty (quantity) +The number of units received into inventory from the most recent vendor purchase or transfer. + +~[Read more about receiving a purchase order.](https://help.inventory-planner.com/en/articles/2893749-purchase-orders-receiving-stock)~ + +# Last sold at +The most recent date of sale for a product. + +The last sold at date is set in your sales platform and cannot be edited in Inventory Planner. + +# Lead time +The amount of time it takes to order a product from the vendor and receive it into inventory. Lead times are an important factor in forecasting. + +~[Read more about how to set the lead time.](https://help.inventory-planner.com/en/articles/2218187-how-can-i-set-the-lead-time-and-days-of-stock)~ + +# Lead time closing stock +The estimated amount of inventory available for sale at the end of the lead time period, factoring in the current stock level, items on order, items transferring in and out of the selected warehouse, and the number of units estimated to be sold during this time (indicated as the lead time forecast). + +For example, a lead time closing stock of 110 with a date of January 14, 2021 indicates that the estimated stock level will be 110 units as of January 14, 2021, where January 14 2021 is today's date plus the lead time in days. + +# Lead time forecast +The number of units that Inventory Planner estimates will be needed during the specified lead time. + +For example, a lead time forecast of 24 "for 14 days" indicates that 24 units will be needed during a 14 day lead time. + +The forecast is calculated based on sales velocity. Read more about ~[how different forecasts are calculated](https://help.inventory-planner.com/frequently-asked-questions/forecast/how-is-the-forecast-calculated)~ in Inventory Planner. + +# Lifetime revenue +Lifetime revenue = Price × number of units sold for the lifetime of the product + +The lifetime revenue represents all revenue of a product, not limited to the revenue generated in a report's date range. To see the revenue from the report date range, use "revenue".  + +Price and unit sales information comes from your sales platform and cannot be edited in Inventory Planner. + +# Lifetime sales +The number of gross units sold during the lifetime of the product. + +The lifetime sales represents all units sold of a product, not limited to the number of units sold during the date range of the report. To see units sold only during the report's date range, use "sales".  + +Unit sales information comes from your sales platform and cannot be edited in Inventory Planner. + +# Listing +Listings are the items you sell synced from your connected platform. They correspond to variants. + +# Low stock alert +Low stock alerts are created when a variant needs to be reordered, based on the forecasted demand, items in stock and items on order. They are sent on an item's replenish date. + +# LY +Last year. Used to compare metrics for the same period in the prior year. + +# LLY +Last last year. Used to compare metrics for the same period two years prior. + +# M +# Margin +Margin = Profit / revenue + +Where: +Profit = Revenue - COGS - platform feeRevenue = Gross revenue - discounts - returnsCOGS = Number of units sold × landing cost price +If the landing cost price is not set, the cost price field is used instead. + +# Markdown +Markdown = Gross regular revenue - gross revenue + +Where: +Gross regular revenue = Number of units sold × regular price +Gross revenue = Number of units sold × sales order price before discounts and taxes +Gross Regular Revenue is calculated using the regular price (sometimes called the 'compare to' price). + +# Markdown % +Markdown percentage = Markdown / gross regular revenue + +# Markup +Markup = Profit / COGS + +# Merged variants +A new variant that has been merged into an old one, usually done when a product has an updated style or a slightly changed size. Merging variants means the new product has the sales history needed to create a forecast. Learn ~[how to merge variants](https://help.inventory-planner.com/using-inventory-planner/vendor-and-product-information-set-up/merging-variants)~. + +# Modified at +The last time product information was updated on your sales platform. + +When details on a product page are changed, Inventory Planner will reflect the date that those details were updated. + +# MOQ (Minimum Order Quantity) +The lowest amount that you are allowed to order from a vendor. + +When an MOQ is set and it is not met in a purchase order, you will see a warning at the bottom of the purchase order. ~[See more here](https://help.inventory-planner.com/frequently-asked-questions/minimum-order-quantity-and-units-of-measurement)~ about how to set up MOQ. + +# N +# Name +The title of the product or variant. + +When included in reports, this column in Inventory Planner also displays the product or variant's SKU, barcode, and ASIN. If you would like see just the product or variant title, use the 'Title' column instead. + +# Next to assemble +Before purchasing new component stock, you can create new **Assembly Orders** using **existing component stock** based on the recommended **"Next To Assemble"** quantity. + +The replenishment quantity for assemblies is the "Next To Assemble" quantity plus the additional replenishment needed to satisfy forecasted sales during the Days of Stock period. + +If there is not enough existing component stock to assemble any new assemblies, the "Next to Assemble" quantity will be the same as the **Replenishment** quantity. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# No cost price (filter) +Using the "No Cost Price" filter (where "No Cost Price" = Yes) will display variants that do not have a cost price set in Inventory Planner.  + +The cost price is the variant price in the supplier catalog, i.e. the price you pay to a vendor per item as noted on your purchase order. + +If your connected platform has a cost field, we recommend updating cost prices there to keep all information in sync (as updating costs in Inventory Planner will not push that information to your platform). If your platform does not have a cost field, then you can ~[add costs directly to Inventory Planner](https://help.inventory-planner.com/en/articles/1770885-how-do-you-set-cost-prices-for-products)~. + +# Notes +Information you enter about a variant. For example, if an item will be available at a discounted rate later in the year, you could enter a note to indicate that information. + +# O +# On assembly +The number of units in the process of being assembled. + +Any assembly with a status of "Open" or "Partially Received" will be reflected in the "On Assembly" total. + +~[Read more about configuring and forecasting demand here for assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Old stock (filter and label) +Old stock is stock that: +* was created over two months ago +* has not been included on any sales for over two months +* has not been received for over two months +* is not due to be received (on order quantity is zero) + +⠀On order +"On Order" indicates the number of units on active purchase orders. + +Active purchase orders have a status of "open" or "partially received". Purchase orders with the "draft," "closed," or "cancelled" statuses are not considered active. + +Note that warehouse transfers will show quantities in the "transfer in" and "transfer out" columns. Different from purchase orders from vendors, warehouse transfers have a source and destination warehouse. + +# On order % (on order percentage) +On order % = Units on active purchase orders for a particular variant / all units on order  + +# On order cost +The on order cost is the cost value of units "on order", calculated using the cost price from the supplier catalog. + +# On order cost % +On order cost % = Cost value on active purchase orders for a particular variant / the total cost value on order  + +# On order retail +On order retail is the retail value of "on order" units. + +The retail value is the current product retail price as set on your sales platform. It cannot be edited in Inventory Planner. + +# On order retail % +On order retail % = Retail value of a particular variant in active purchase orders / the total retail value on order + +Retail value is the current product retail price as set on your sales platform. This is set in your sales platform and cannot be edited in Inventory Planner. + +# Opening stock +The amount of inventory on hand at the start of the selected time period. The stock is based on the historical inventory snapshots taken by Inventory Planner.  + +~[Read more about how to view opening stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Opening stock cost +The cost value of opening stock based on the historical inventory snapshots taken by Inventory Planner. + +*Note: This is always based on the historical cost prices. Contact support to update metrics based on the current cost price.* + +~[Read more about how to view opening stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Opening stock gross +The gross value of opening stock. +Opening stock gross = Regular price × units on hand at the beginning of a report's time period +The regular price is the retail price before any discounts are applied. The stock is based on the historical inventory snapshots taken by Inventory Planner. *Note: This is always based on the historical regular retail prices.* + +~[Read more about how to view opening stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Opening stock retail +The retail value of opening stock. +Opening stock retail = Price × opening stock +The stock is based on the historical inventory snapshots taken by Inventory Planner. *Note: This is always based on the historical retail prices.* + +~[Read more about how to view opening stock information.](https://help.inventory-planner.com/en/articles/2117379-how-can-i-see-past-stock-levels-and-stock-value)~ + +# Options +Sizes, colors and other variant options that apply to a SKU or product. The options assigned to a SKU or product are set in your sales platform and cannot be edited in Inventory Planner. + +# Order limit +The order limit will reflect the "maximum shipment quantity" from Amazon's restock report in your Seller Central account. + +The order limit will be reflected in the "to order" amount, along with the minimum order quantity and units of measurement amounts. + +~[Learn more about replenishment and to order metrics here.](https://help.inventory-planner.com/en/articles/4388315-how-do-i-see-what-i-m-actually-going-to-order-from-my-vendor)~ + +# Overstocked (filter) +Using the "Overstocked" filter (where "Overstocked" = Yes) will display variants that are forecast to have stock on hand beyond the planning period, provided no new orders have been received in the past two months. + +~[Learn more about the Overstock report and how overstock is calculated.](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ + +# Overstocked cost +The cost value of stock on hand that exceeds the planning period. + +~[Learn more about the Overstock report and how overstock is calculated.](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ + +# Overstocked retail +The retail value of stock on hand that exceeds the planning period. + +~[Learn more about the Overstock report and how overstock is calculated.](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ + +# Overstocked units +The number of units that exceeds the planning period. + +~[Learn more about the Overstock report and how overstock is calculated.](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ + +# Overstocked variants +The number of variants (SKUs) that have units in stock exceeding the planning period. If a single variant has 100 units overstocked, that will count as 1 overstocked variant and 100 overstocked units.  + +Note that this is different than "overstocked units" which will show the sum of all overstocked units. + +~[Learn more about the Overstock report and how overstock is calculated.](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ + +# P +# Page views +The number of times that product page has been viewed. This information is pulled from Google Analytics and cannot be edited in Inventory Planner. + +# Past lost profit +An approximation of the profit lost due to stockouts. Profit is the difference between revenue and cost of goods sold for an item. This is calculated based on the profit during the report date range and stockouts during that period. + +Lost profit = Profit × stockouts % / (100 - stockouts %) + +Where: +Profit = Revenue - COGS - platform fee +Revenue = Gross revenue - discounts - returns +COGS = Number of units sold × landing cost price*If the landing cost price is not set, the cost price field is used.* +Stockouts % = Out of stock days / days in the selected period + +# Past lost revenue +An approximation of the revenue lost due to stockouts. Revenue is how much customers pay for a product less discounts and returns. It's calculated based on the revenue during the report date range and stockouts during that period. + +Lost Revenue = Revenue × stockouts% / (100 - stockouts%) + +Where: +Revenue = Gross revenue - discounts - returns +COGS = Number of units sold × landing cost price*If the landing cost price is not set the cost price field is used.* +Stockouts % = Out of stock days / days in the selected period + +# Past lost sales +An approximation of the number of units not sold due to stockouts, calculated based on the number of units sold during the report date range and stockouts during that period. + +Lost sales = Sales × stockouts% / (100 - stockouts%) + +Where: +Sales are the number of units sold during the report date range. +Stockouts % = Out of stock days / days in the selected period + +# Past revenue +Revenue for prior periods (i.e. previous months). Note the difference compared to "Past Revenue LY", which is the revenue earned during the same month the prior calendar year. Past revenue shows revenue earned in the same year where applicable. + +Revenue = Gross revenue - discounts - returns + +Returns use the sales order return date, not original order date, matching the Net Sales metric in Shopify. + +# Past revenue LY +The revenue earned during the same month the prior calendar year. Note the difference compared to "Past Revenue", which is revenue for the prior period (i.e. previous months). + +Revenue = Gross revenue - discounts - returns + +Returns use the sales order return date, not original order date, matching the Net Sales metric in Shopify. + +# Past sales +Units sold during prior periods (i.e. previous months). Note the difference compared to "Past Sales LY", which denotes the units sold during the same month the prior calendar year. Past sales shows units sold in the same year where applicable. + +Sales are the number of units sold for the selected period (date range of the report) less returns. + +This information comes from your sales platform. + +# Past sales LY +Units sold during the same month in the prior calendar year. Note the difference compared to "Past Sales", which denotes the units sold during the prior period (i.e. previous months). + +Sales are the number of units sold for the selected period (date range of the report) less returns. + +This information comes from your sales platform. + +# Period +The time span or dates during which to display data. + +# Planned discount +Planned discount = Regular price - price + +Price is the current product's retail price as set on your sales platform. Regular price is the product's retail price before discounts, sometimes called the "compare to" price. + +Price and regular price are set in your sales platform and cannot be edited in Inventory Planner. + +# Planned margin +Planned margin = (Historical price -  historical cost price) / historical price + +Price is the current product's retail price as set on your sales platform as of the end of the date period selected in the top right. Price is set in your sales platform and cannot be edited in Inventory Planner. + +Cost price is the variant's price in the supplier catalog. This is the price you pay to a vendor per item as noted on your purchase order. For this metric we look at the cost price that was set as of the period selected in the report. + +If your connected platform has a cost field, we recommend updating cost prices there to keep all information in sync (updating costs in Inventory Planner will not push that information to your platform). If your platform does not have a cost field, then you can ~[add costs directly to Inventory Planner](https://help.inventory-planner.com/en/articles/1770885-how-do-you-set-cost-prices-for-products)~. + +# Planned markup +Planned markup = 100 × (Historical retail price - historical cost price) / historical cost price + +# Planned sales +Planned sales will show units sold for the entire month (not remaining days if viewing the current month). When viewed on the Edit Forecast page of Inventory Planner, it includes any edits to the Inventory Planner forecast and wholesale orders if applicable. + +# Planning period +The planning period is the lead time + days of stock as set in the Replenishment section of Inventory Planner. + +~[Learn more about the Overstock report and how overstock is calculated.](https://help.inventory-planner.com/en/articles/2303075-overstock-report)~ + +# Planning period forecast +The planning period forecast displays the number of units that Inventory Planner estimates will be needed during the specified days of stock. For example, a planning period forecast of "49/30 days" shows that 49 units will be needed during 30 days of stock. The forecast is calculated based on sales velocity. + +Read more about ~[how different forecasts are calculated](https://help.inventory-planner.com/frequently-asked-questions/forecast/how-is-the-forecast-calculated)~ in Inventory Planner. + +# Platform fee +The "platform' fee" is the total fee charged by the connected platform for the date range of the report. Platform fees include referral fees, variable closing fee, per item fee, and FBA fees.  + +Platform fees are currently available only for Amazon. + +# Platform variant fee +The platform variant fee is the fee per unit charged by the connected platform (currently only supported for Amazon connections).  + +# Product ID +The unique ID (identification number) of a product (or parent of a group of SKUs or variants).  + +# PO cover in days +The number of days inventory that has been ordered but not yet received will last, given recent sales trends. + +# PO cover in months +The number of months inventory that has been ordered but not yet received will last, given recent sales trends. + +# Price +The product's current retail price as set on your sales platform. The price is set in your sales platform and cannot be edited in Inventory Planner. + +# Products +A product is the main version of an item you sell on your store. For example, a t-shirt with a graphic design.A product may have different variations (Colour, Size, Weight, etc), which are displayed as "Variants". + +# Product name +The title given to the product in your store. The product name is set on your connected sales platform and cannot be edited in Inventory Planner. + +# Profit +Profit = Revenue - COGS - platform fee + +Where: +Revenue = Gross revenue - discounts - returns +COGS = Number of units sold × landing cost priceIf landing cost price is not set the cost price field is used. + +# Profit/Unit +Profit/Unit is forward-looking to show the listed price and current product landed cost. + +For Amazon sellers, product profit is the competitive price - landed cost price - estimated platform fee. If the landing cost is not set, Inventory Planner will use cost price instead. + +# Profit % +Profit % = Profit for one variant / Profit for all variants + +Where: +Profit = Revenue - COGS - platform fee +Revenue = Gross revenue - discounts - returns +COGS = Number of units sold × landing cost priceIf landing cost price is not set the cost price field is used. + +# Profit diff % (Percentage change in profit) +Percentage change in profit = (Profit during current time period - profit during previous time period) / profit during the previous time period + +Where: +Profit = Revenue - COGS - platform fee +Revenue = Gross revenue - discounts - returns +COGS = Number of units sold × landing cost priceIf landing cost price is not set the cost price field is used. + +# Published at +The date that your product or variant became visible to your customers. + +Note that if a product is published, unpublished, then published again, the "published at" date will be the most recent date when the item became visible on your site. + +# R +# Received cost +The total cost value of the items received on a purchase order during the selected date range on the report. + +Received Cost = Received qty × cost price + +# Received qty +The number of units received during the date range of the report. + +# Received retail +The total retail value of the items received on a purchase order during the selected date range on the report: + +Received retail = Received qty × price + +# Received units cost +The cost price of units that have been received on a purchase order. The cost price of non-received units, called the "Remaining Units Cost", are excluded from this figure. + +~[This article explains the process of receiving inventory on a purchase order.](https://help.inventory-planner.com/en/articles/2893749-purchase-orders-receiving-stock-video)~ + +# Regular price +The product retail price before discount, sometimes called the "compare to" price. This price is set in your sales platform and cannot be edited in Inventory Planner. + +# Remaining units cost +The cost price of units that have not been received on a purchase order. The cost price of received units, called "Received Units Cost", are excluded from this figure. + +~[This article explains the process of receiving inventory on a purchase order.](https://help.inventory-planner.com/en/articles/2893749-purchase-orders-receiving-stock-video)~ + +# Replenish date +The last date to place an order to avoid running out of stock. If stock is predicted to run out before more stock can be received (based on forecasted sales during the product's lead time), the current date will be displayed. + +***Note:*** *If ~[min stock](https://help.inventory-planner.com/en/articles/3496048-min-max-stock)~ is configured, then the replenish date is based on the last date to place an order to avoid going below the min stock level. If there is no min stock, then the minimum is zero.* + +~[How can I see when my stock will run out? When should I order?](https://help.inventory-planner.com/en/articles/3152493-how-can-i-see-when-my-stock-will-run-out-when-should-i-order)~ + +# Replenishable (filter) +When the "Replenishable" filter is "Yes," only items which are marked as replenishable will be included in the report. Items marked as non-replenishable will be excluded. + +Replenishable items are items that should be reordered. When the "Replenishable" filter = "No" then non-replenishable items will be included and replenishable items will be excluded from view.~[Discontinued and dropshipped items should be marked as non-replenishable.](https://help.inventory-planner.com/en/articles/1837067-how-to-remove-dropshipped-or-discontinued-items-from-the-forecast)~ + +# Replenishable (status in columns) +The "Replenishable" column shows whether an item is replenishable or non-replenishable. + +"Yes" in the Replenishable column indicates the item is replenishable. "No" indicates the item is non-replenishable.  + +~[Discontinued and dropshipped items should be marked as non-replenishable.](https://help.inventory-planner.com/en/articles/1837067-how-to-remove-dropshipped-or-discontinued-items-from-the-forecast)~ + +# Replenishment +Replenishment indicates how many more units are needed in order to meet customer demand. It's based on forecasted demand, and takes into consideration current stock, on order or transfer quantities, continued selling during the lead time, and how many units are needed to meet demand during the days of stock. + +~[Read more about how the forecast and replenishment are calculated here.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Replenishment CBM +Replenishment CBM = Volume of an individual item shown as CBM × replenishment + +CBM is cubic meters per unit. When this is set, Inventory Planner will show cumulative CBM in purchase orders so that you can estimate how many cubic meters the container will be. + +~[Read more about setting CBM.](https://help.inventory-planner.com/en/articles/2556039-how-do-i-add-cbm-or-the-size-of-my-product)~ + +# Replenishment cost +Replenishment cost = Replenishment × cost price + +The replenishment cost is the purchase cost to cover the "days of stock" period. Cost price is the product price in the supplier catalog.  + +# Replenishment profit +Replenishment profit = Replenishment × (price - cost) + +The replenishment profit is the expected profit of products needed to cover the "days of stock" period. + +# Replenishment retail +Replenishment retail = Replenishment × retail price + +The replenishment retail is the retail value of products to cover the "days of stock" period. The retail price is the price the customer will pay. + +# Return % +Return % = Returns units / gross sales + +***Note:*** *The "Sales" metric factors in returns. "Gross Sales" does not.* + +# Return units +The number of units of products included on returns (items sent back to the merchant). Return information is pulled from your connected sales platform and cannot be edited with Inventory Planner. + +# Returns revenue +Returns revenue = Returned units × sales order price + +The amount of revenue sent back to customers following returns to the merchant. Return information is pulled from your connect sales platform and cannot be edited with Inventory Planner. + +# Revenue +Revenue = Gross revenue - discounts - returns + +Returns use the sales order return date, not original order date, matching the Net Sales metric in Shopify. + +# Revenue diff % +Revenue difference percentage = (Revenue during current time period - revenue during previous time period) / revenue during the previous time period + +The change in the revenue from the previous time period. + +Where: +Revenue = Gross revenue - discounts - returns + +# S +# Safety stock +The minimum stock level for a product. + +~[Read about how to set safety stock.](https://help.inventory-planner.com/en/articles/1959514-how-do-i-set-up-a-low-inventory-alert-or-safety-stock)~ + +# Sales +Sales are the number of units sold for the selected period (date range of the report), less returns.  + +This is information comes from your sales platform. + +# Sales % +Sales % = Number of units sold for one variant / number of units sold for all variants + +Sales are the number of units sold for the selected period (date range of the report) less returns. Unit sales information comes from your sales platform. + +# Sales diff % (percentage change in the number of units sold) +Sales difference percentage = (Sales during current time period - sales during previous time period) / sales during the previous time period + +The change in the number of units sold from the previous time period. + +Sales are the number of units sold for the selected period (date range of the report) less returns.  + +Sales information comes from your sales platform. + +# Sales for forecast +The number of units purchased by customers during the specified date range, and the corresponding number of days in that range after removing the Excluded Holiday Sales. + +~[Learn more about how the forecast and replenishment are calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-and-replenishment-calculated)~ + +# Sales (period) +The number of units purchased by customers during the specified date range (sales period for forecast), and the corresponding number of days in that range. + +~[Learn more about how the forecast and replenishment are calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-and-replenishment-calculated)~ + +# Sales period for forecast +The date range of customer orders used to calculate the forecasted demand. + +~[Learn more about how the forecast and replenishment are calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-and-replenishment-calculated)~ + +# Sales velocity +Sales velocity = Sales / number of days product was in stock for the selected period + +Out of stock days are excluded from the calculation. In stock means the stock level is at least 1 unit. + +Sales are the number of units sold for the selected period (date range of the report) less returns. This is information comes from your sales platform. + +If the first sale of the variant occurs during the reporting period, then the sales velocity calculation starts with the first sale date by default. Contact the Inventory Planner team if you would like the "created at" date used instead of the "first sold at" date. + +# Sales velocity / day +Sales velocity per day = Sales / (period for forecast in days - stockouts in days) + +Sales velocity/day is expressed in days. + +Out of stock days are excluded from the calculation. In stock means the stock level is at least 1 unit. + +Sales are the number of units sold for the selected period (date range of the report) less returns. This is information comes from your sales platform. + +If the first sale of the variant occurs during the reporting period, then the sales velocity calculation starts with the first sale date by default. Contact the Inventory Planner team if you would like the "created at" date used instead of the "first sold at" date. + +# Sales velocity / mo +Sales velocity per month = (Sales / days with stock in the time period) × 30  + +Sales velocity/month represents sales when the product is in stock averaged for the month. It's expressed in months. + +Out of stock days are excluded from the calculation. In stock means the stock level is at least 1 unit. + +Sales are the number of units sold for the selected period (date range of the report) less returns. This is information comes from your sales platform. + +If the first sale of the variant occurs during the reporting period, then the sales velocity calculation starts with the first sale date by default. Contact the Inventory Planner team if you would like the "created at" date used instead of the "first sold at" date. + +This is information comes from your sales platform. + +# Seasonal (filter) +Using the seasonal filter (where "Seasonal" = Yes) will show only variants that are set to use a seasonal forecast. + +~[Read more about how seasonal and non-seasonal forecasts are calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Sell through +Sell through = Sales for the selected period / (closing stock for the selected period + sales) + +"Sales" in Inventory Planner refers to net sales, and is different to gross sales. + +This metric does not take stockouts into account. + +# Sells out info +This metric indicates when stock of a product will run out, and how much profit would be lost each day if the stockout lasts beyond the planning period (lead time + days of stock). + +If an item sells out within the planning period, 'sells out info' will be blank. + +For example, if a product's planning period is 30 days but the stock runs out in 20 days, the column will not populate. + +# Sells out in +The number of days until a product will be out of stock. + +"Sells out in" starts with your forecast, then considers your current stock and any items on order to calculate when you'll run out. The time expressed is based on calendar weeks. + +"Sells out in first" displays whether stock will run out before a PO arrives, based on its expected date. + +To see when current stock will run out, use "stock cover in days". + +# Sells out in first +If there are products on a purchase order, "Sells out in first" will indicate if there is a predicted stockout before the products arrive. + +"Sells out in" will show when products run out after the PO is delivered. + +To see when current stock will run out, use "stock cover in days".   + +# Sent +"Sent" shows the number of units on a ~[transfer order](https://help.inventory-planner.com/en/articles/2164889-how-can-i-transfer-stock-between-warehouses)~ that have yet to leave the source warehouse. + +Sum of (ordered - sent) for all transfers = "Transfer Out" for source warehouse +Sum of (ordered - received) for all transfers = "Transfer In" for destination warehouse + +~[Read more about transfers here.](https://help.inventory-planner.com/en/articles/2164889-how-can-i-transfer-stock-between-warehouses)~ + +# Shipping +The amount paid by customers on orders to receive their products. + +This figure does not include shipping costs already built into the listed price. It is set in your sales platform and cannot be edited in Inventory Planner. + +Shipping costs on a sales order will apply to items on the order proportionally to their value. + +# SKU +"Stock Keeping Unit", referring to a unique code you assign to your variant or product. This is set in your sales platform and cannot be edited in Inventory Planner. + +# Stock +The number of units of inventory. This number is pulled from your connected platform and cannot be edited in Inventory Planner (unless you have an ~[IP warehouse](https://help.inventory-planner.com/en/articles/2903030-ip-warehouse)~ set up, in which case you will need to control stock manually).  + +# Stock % (stock percentage) +Stock % = Number of units available of one variant / number of all units available across all variants + +# Stock allocated for assemblies +This figure represents the additional components needed to cover the replenishment recommendation of assemblies. + +~[Read more about demand forecasting for bundles and assemblies.](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ + +# Stock begin (opening stock) +The number of units at the beginning of the selected period. + +# Stock cost +Stock cost = Cost × stock +Stock cost is the cost price of inventory on hand in the supplier catalog. + +Cost price is the variant price in the supplier catalog. This is the price you pay to a vendor per item as noted on your purchase order. + +# Stock cost % (stock cost percentage of all stock cost) +Stock cost % = Stock cost of one variants / stock cost of all variants + +Stock cost is the cost price of inventory on hand in the supplier catalog. + +Cost price is the variant price in the supplier catalog. This is the price you pay to a vendor per item as noted on your purchase order. + +# Stock cover in days +The number of days the available inventory will last based on the forecast. Stock currently on order is not counted. + +The forecast is the projected customer demand shown as sales in units to cover the "days of stock" period, calculated using the sales velocity. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Stock cover in weeks +The number of weeks the available inventory will last based on the forecast. Stock currently on order is not counted. + +The forecast is the projected customer demand shown as sales in units to cover the "days of stock" period, calculated using the sales velocity. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Stock cover in months +The number of months the available inventory will last based on the forecast. Stock currently on order is not counted. + +The forecast is the projected customer demand shown as sales in units to cover the "days of stock" period, calculated using the sales velocity. + +~[Read more about how the forecast is calculated.](https://help.inventory-planner.com/en/articles/1959506-how-is-the-forecast-calculated)~ + +# Stock end (closing stock) +The number of units at the end of the selected period. + +# Stock gross +Stock gross = Regular price × stock +Regular price is the product retail price before discounts, sometimes called the "compare to" price, and stock is the number of units of inventory. + +Both figures are set in your sales platform and cannot be edited in Inventory Planner (unless you have an IP warehouse set up, in which case you will need to control stock manually).  + +# Stock in assemblies +The number of units of a component (variant) which are being used in finished assemblies. + +~[Learn more about forecasting demand for assemblies and their components](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ in Inventory Planner. + +# Stock retail +Stock retail = Price × stock + +The retail price of all units in inventory.  + +Price is the current product retail price as set on your sales platform, and stock is the number of units of inventory. + +Both figures are set in your sales platform and cannot be edited in Inventory Planner (unless you have an IP warehouse set up, in which case you will need to control stock manually). + +Note that the stock retail figure is reflective of retail after VAT. Any further tax charged by vendors can be added via the catalog. Furthermore, the stock retail figures does not contain the retail value of products within purchase orders (i.e. the retail value of "on order" units is not included). + +# Stock retail % (Stock retail percentage of all stock retail) +Stock retail % = Stock retail of one variant / stock retail of all variants + +# Stockouts +Stockouts are the days when a product is out of stock. + +For example, if stockouts shows as "15", that means the stock level for the variants was 0 (or less) for 15 days during the date range.  + +# Stockouts % (percentage of stockouts) +Stockouts % = Out of stock days / total days in the selected period + +# Stockturn +Stockturn = Sales / average stock for the selected period.  + +Sales are the number of units sold. Average stock is based on the historical inventory snapshots taken by Inventory Planner during the daily data sync with your connected platform. This metric will not be available immediately when creating an Inventory Planner account. Historical stock information is recorded starting with the date an Inventory Planner account is created.  + +# T +# Tags +Tags are used to help describe and filter products, orders and other information within your store. + +Tags are imported from your store or inventory management system. If you would like to set tags within Inventory Planner, use IP Tags (Inventory Planner tags). IP tags can be set using Bulk Actions.  + +# Tax +The tax amount paid by customers on orders. The tax amount does not include taxes already built into the listed price (i.e. VAT). The tax amount is set in your sales platform and cannot be edited in Inventory Planner. + +# Title +The variant or product title (name) as defined on your connected sales platform.  + +# To order +"To Order" defines how many units need to be ordered. + +It starts with the replenishment number (the units needed in order to meet forecasted demand) and adjusts for shipment limits and vendor considerations, including MOQ (minimum order quantity) and UOM (units of measurement). + +~[Learn more about replenishment and to order metrics here.](https://help.inventory-planner.com/en/articles/4388315-how-do-i-see-what-i-m-actually-going-to-order-from-my-vendor)~ + +# Total available stock +Found on the ~[Compare Warehouse report](https://help.inventory-planner.com/en/articles/2930985-compare-warehouse-report)~, "Total Available Stock" is the sum of available stock from all compared locations. + +**Available stock** is inventory on hand at that location in excess of what will be needed during the days of stock (planning period). This metric is based on the lowest amount of stock needed (based on the forecast) during the days of stock. Any inventory available over that amount is identified as available stock.  + +# Total forecast +Total forecast is the total number of units forecast to be sold after % adjustments are made in the Edit Forecast section of variants. + +# Total forecast revenue +Total forecast revenue is the total revenue forecast after % adjustments are made in the Edit Forecast section of variants. +# Total revenue +Total revenue = Gross revenue - discounts - returns + taxes + shipping charges + +Total revenue will be a positive number for a sale on the date that an order was placed, and a negative number for a return on the date that an order was returned. + +# Total to transfer +From the ~[Compare Warehouse report](https://help.inventory-planner.com/en/articles/2930985-compare-warehouse-report)~, "Total To Transfer" is the replenishment amount for the destination warehouse as long as there is available stock in a source warehouse. + +If there is insufficient stock to make a transfer, then the total to transfer will be reduced to the source stock amount. + +# Tracked inventory (filter) +If an item has tracked inventory, you are monitoring the stock level for that item. Untracked inventory could be drop-shipped or made to order items where you do not need to worry about low or out of stock levels.  + +Tracked inventory is set in your sales platform and cannot be edited in Inventory Planner. + +# Transfer in +When creating a transfer from one warehouse to another, "Transfer In" represents the incoming stock to a warehouse. Unlike purchase orders from vendors, warehouse transfers have a source and destination warehouse. + +"Transfer In" indicates the number of units in active warehouse transfers. Active transfers have a status of "open" or "partially received". Transfers with a status of "draft," "closed," or "cancelled" are not considered active. + +# Transfer out +When creating a transfer from one warehouse to another, "Transfer Out" represents the outgoing stock from a warehouse. Unlike purchase orders from vendors, warehouse transfers have a source and destination warehouse. + +"Transfer Out" indicates the number of units in active warehouse transfers. Active transfers have a status of "open" or "partially received". Transfers with a status of "draft," "closed," or "cancelled" are not considered active. + +# U +# UOM (Units of Measurement) +The **Units of Measurement** (UOM) field specifies the granularity of products and is used when products are purchased in boxes. ~[See more here](https://help.inventory-planner.com/frequently-asked-questions/minimum-order-quantity-and-units-of-measurement)~ about how to set up UOM for a product. + +# Used in assembly orders +The number of component units (variants) that are on assembly orders in progress (status of "open"). + +~[Learn more about creating an assembly orders](https://help.inventory-planner.com/en/articles/885104-bundles-and-assemblies)~ in Inventory Planner. + +# V +# Variants +The most granular level of items for sale in your store. Variants include the type of the item such as the color or size when available. + +Variants are sometimes referred to as the "child" to the "parent" product. For example, one style of dress would the product, and the dress in small is one variant, medium is another variant, and so on. + +# Variants in stock +Recorded at aggregated levels (e.g. KPI reports by Vendor or by Category), this metric records how many tracked variants are currently in stock within the aggregated data set. + +# Vendor +A vendor is the distributor with whom you place orders. For example, Running Shoe Distributors. + +Note that in Shopify, the Vendor field on your product page will show as the Brand in Inventory Planner. + +# Vendor reference +Use this field in your purchase order when the vendor uses a different SKU or code to the one you use in your store. + +If your connected sales platform does not have a vendor reference or supplier SKU field, you can add this in the Vendor section of Inventory Planner or ~[import this information using a Single or Multiple Vendor Catalog](https://help.inventory-planner.com/en/articles/1832728-uploading-vendor-catalogs)~. + +To add Vendor Reference to your purchase order or transfer, click the gear icon while editing your order.  + +![](image_6.png) + Then enable "Vendor Reference". + +![](image_7.png) + +# Vendor product name +A product name given to you by your vendor. This can be noted on purchase orders and is particularly helpful when you and your vendor have different names for the same item. If your connected sales platform does not have a vendor product name or supplier product title field, you can ~[import this information using a Single or Multiple Vendor Catalog](https://help.inventory-planner.com/en/articles/1832728-uploading-vendor-catalogs)~. + +To add Vendor Product Name to your purchase order or transfer, click the gear icon while editing your order.  + +![](image_8.png) + Then enable "Vendor Product Name". + +![](image_9.png) +# Visible (filter) +Visible items display on your website. + +Some merchants will create the product page without making the item live on their sites, so those would not be visible items. + +If an item is visible or not is determined in your sales platform and cannot be edited in Inventory Planner. + +# W +# Warnings +Warnings are notifications which indicate no sales for a product or extended stockouts. + +# Warehouse +A "warehouse" in Inventory Planner is automatically created based on warehouses or locations set up in your connected platform(s). Warehouses will reflect sales and performance metrics for only that location. Settings such as lead time and days of stock can be configured for each warehouse. + +For Amazon connections, you will see an FBA "warehouse" set up for each country in the Amazon region and an FBM warehouse for the entire region. (FBA is "Fulfilled by Amazon," and FBM is "Fulfilled by Merchant".) + +Warehouses cannot be deleted from Inventory Planner. Instead, you should disable any warehouse that you will not need for replenishment, planning, and reporting purposes.  + +Additional warehouses can be configured within Inventory Planner. A **~[combined warehouse](https://help.inventory-planner.com/en/articles/2820340-combined-warehouse-set-up)~** adds together the sales, stock, and/or purchase order information for more than one warehouse.  + +An ~[IP warehouse](https://help.inventory-planner.com/en/articles/2903030-ip-warehouse)~ is used when your store has inventory in a location that does not fulfill customer orders directly. This manual warehouse is used to transfer stock to other locations that do handle order fulfillment.  + +~[Learn more about warehouse set-up here.](https://help.inventory-planner.com/en/articles/1919827-warehouse-set-up)~ + +# Wholesale units +The number of units sold on wholesale orders during the date range of the report. + +~[Learn more about wholesale planning.](https://help.inventory-planner.com/en/articles/2332833-wholesale-planning)~ + +# Y +# Yesterday sales +The number of units sold the previous calendar day. \ No newline at end of file diff --git a/inventory-server/db/config-schema.sql b/inventory-server/db/config-schema.sql index a72539b..ec892fd 100644 --- a/inventory-server/db/config-schema.sql +++ b/inventory-server/db/config-schema.sql @@ -154,6 +154,24 @@ CREATE TRIGGER update_sales_seasonality_updated FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- Create table for financial calculation parameters +CREATE TABLE financial_calc_config ( + id INTEGER NOT NULL PRIMARY KEY, + order_cost DECIMAL(10,2) NOT NULL DEFAULT 25.00, -- The fixed cost per purchase order (used in EOQ) + holding_rate DECIMAL(10,4) NOT NULL DEFAULT 0.25, -- The annual inventory holding cost as a percentage of unit cost (used in EOQ) + service_level_z_score DECIMAL(10,4) NOT NULL DEFAULT 1.96, -- Z-score for ~95% service level (used in Safety Stock) + min_reorder_qty INTEGER NOT NULL DEFAULT 1, -- Minimum reorder quantity + default_reorder_qty INTEGER NOT NULL DEFAULT 5, -- Default reorder quantity when sales data is insufficient + default_safety_stock INTEGER NOT NULL DEFAULT 5, -- Default safety stock when sales data is insufficient + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE TRIGGER update_financial_calc_config_updated + BEFORE UPDATE ON financial_calc_config + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + -- Insert default global thresholds INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days) VALUES (1, NULL, NULL, 7, 14, 90) @@ -203,6 +221,17 @@ VALUES ON CONFLICT (month) DO UPDATE SET last_updated = CURRENT_TIMESTAMP; +-- Insert default values +INSERT INTO financial_calc_config (id, order_cost, holding_rate, service_level_z_score, min_reorder_qty, default_reorder_qty, default_safety_stock) +VALUES (1, 25.00, 0.25, 1.96, 1, 5, 5) +ON CONFLICT (id) DO UPDATE SET + order_cost = EXCLUDED.order_cost, + holding_rate = EXCLUDED.holding_rate, + service_level_z_score = EXCLUDED.service_level_z_score, + min_reorder_qty = EXCLUDED.min_reorder_qty, + default_reorder_qty = EXCLUDED.default_reorder_qty, + default_safety_stock = EXCLUDED.default_safety_stock; + -- View to show thresholds with category names CREATE OR REPLACE VIEW stock_thresholds_view AS SELECT diff --git a/inventory-server/db/metrics-schema.sql b/inventory-server/db/metrics-schema.sql index 6a1808a..99f791f 100644 --- a/inventory-server/db/metrics-schema.sql +++ b/inventory-server/db/metrics-schema.sql @@ -11,15 +11,17 @@ CREATE TABLE temp_sales_metrics ( avg_margin_percent DECIMAL(10,3), first_sale_date DATE, last_sale_date DATE, + stddev_daily_sales DECIMAL(10,3), PRIMARY KEY (pid) ); CREATE TABLE temp_purchase_metrics ( pid BIGINT NOT NULL, - avg_lead_time_days INTEGER, + avg_lead_time_days DECIMAL(10,2), last_purchase_date DATE, first_received_date DATE, last_received_date DATE, + stddev_lead_time_days DECIMAL(10,2), PRIMARY KEY (pid) ); @@ -50,7 +52,7 @@ CREATE TABLE product_metrics ( gross_profit DECIMAL(10,3), gmroi DECIMAL(10,3), -- Purchase metrics - avg_lead_time_days INTEGER, + avg_lead_time_days DECIMAL(10,2), last_purchase_date DATE, first_received_date DATE, last_received_date DATE, diff --git a/inventory-server/db/schema.sql b/inventory-server/db/schema.sql index 65b7c24..ca34b3a 100644 --- a/inventory-server/db/schema.sql +++ b/inventory-server/db/schema.sql @@ -7,7 +7,7 @@ BEGIN -- Check which table is being updated and use the appropriate column IF TG_TABLE_NAME = 'categories' THEN NEW.updated_at = CURRENT_TIMESTAMP; - ELSE + ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders') THEN NEW.updated = CURRENT_TIMESTAMP; END IF; RETURN NEW; @@ -91,6 +91,7 @@ CREATE TABLE categories ( description TEXT, created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, status VARCHAR(20) DEFAULT 'active', FOREIGN KEY (parent_id) REFERENCES categories(cat_id) ); diff --git a/inventory-server/scripts/calculate-metrics.js b/inventory-server/scripts/calculate-metrics.js index adaf7dd..8aaec1f 100644 --- a/inventory-server/scripts/calculate-metrics.js +++ b/inventory-server/scripts/calculate-metrics.js @@ -57,25 +57,16 @@ const TEMP_TABLES = [ 'temp_daily_sales', 'temp_product_stats', 'temp_category_sales', - 'temp_category_stats' + 'temp_category_stats', + 'temp_beginning_inventory', + 'temp_monthly_inventory' ]; // Add cleanup function for temporary tables async function cleanupTemporaryTables(connection) { - // List of possible temporary tables that might exist - const tempTables = [ - 'temp_sales_metrics', - 'temp_purchase_metrics', - 'temp_forecast_dates', - 'temp_daily_sales', - 'temp_product_stats', - 'temp_category_sales', - 'temp_category_stats' - ]; - try { // Drop each temporary table if it exists - for (const table of tempTables) { + for (const table of TEMP_TABLES) { await connection.query(`DROP TABLE IF EXISTS ${table}`); } } catch (err) { @@ -534,7 +525,7 @@ async function calculateMetrics() { await connection.query(` UPDATE calculate_history SET - status = 'error', + status = 'failed', end_time = NOW(), duration_seconds = EXTRACT(EPOCH FROM (NOW() - start_time))::INTEGER, error_message = $1 diff --git a/inventory-server/scripts/import-from-prod.js b/inventory-server/scripts/import-from-prod.js index b0fdbc1..796cc01 100644 --- a/inventory-server/scripts/import-from-prod.js +++ b/inventory-server/scripts/import-from-prod.js @@ -10,9 +10,9 @@ const importPurchaseOrders = require('./import/purchase-orders'); dotenv.config({ path: path.join(__dirname, "../.env") }); // Constants to control which imports run -const IMPORT_CATEGORIES = false; -const IMPORT_PRODUCTS = false; -const IMPORT_ORDERS = false; +const IMPORT_CATEGORIES = true; +const IMPORT_PRODUCTS = true; +const IMPORT_ORDERS = true; const IMPORT_PURCHASE_ORDERS = true; // Add flag for incremental updates @@ -169,8 +169,8 @@ async function main() { if (isImportCancelled) throw new Error("Import cancelled"); completedSteps++; console.log('Categories import result:', results.categories); - totalRecordsAdded += parseInt(results.categories?.recordsAdded || 0) || 0; - totalRecordsUpdated += parseInt(results.categories?.recordsUpdated || 0) || 0; + totalRecordsAdded += parseInt(results.categories?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.categories?.recordsUpdated || 0); } if (IMPORT_PRODUCTS) { @@ -178,8 +178,8 @@ async function main() { if (isImportCancelled) throw new Error("Import cancelled"); completedSteps++; console.log('Products import result:', results.products); - totalRecordsAdded += parseInt(results.products?.recordsAdded || 0) || 0; - totalRecordsUpdated += parseInt(results.products?.recordsUpdated || 0) || 0; + totalRecordsAdded += parseInt(results.products?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.products?.recordsUpdated || 0); } if (IMPORT_ORDERS) { @@ -187,8 +187,8 @@ async function main() { if (isImportCancelled) throw new Error("Import cancelled"); completedSteps++; console.log('Orders import result:', results.orders); - totalRecordsAdded += parseInt(results.orders?.recordsAdded || 0) || 0; - totalRecordsUpdated += parseInt(results.orders?.recordsUpdated || 0) || 0; + totalRecordsAdded += parseInt(results.orders?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.orders?.recordsUpdated || 0); } if (IMPORT_PURCHASE_ORDERS) { @@ -202,8 +202,8 @@ async function main() { if (results.purchaseOrders?.status === 'error') { console.error('Purchase orders import had an error:', results.purchaseOrders.error); } else { - totalRecordsAdded += parseInt(results.purchaseOrders?.recordsAdded || 0) || 0; - totalRecordsUpdated += parseInt(results.purchaseOrders?.recordsUpdated || 0) || 0; + totalRecordsAdded += parseInt(results.purchaseOrders?.recordsAdded || 0); + totalRecordsUpdated += parseInt(results.purchaseOrders?.recordsUpdated || 0); } } catch (error) { console.error('Error during purchase orders import:', error); @@ -242,8 +242,8 @@ async function main() { WHERE id = $12 `, [ totalElapsedSeconds, - parseInt(totalRecordsAdded) || 0, - parseInt(totalRecordsUpdated) || 0, + parseInt(totalRecordsAdded), + parseInt(totalRecordsUpdated), IMPORT_CATEGORIES, IMPORT_PRODUCTS, IMPORT_ORDERS, diff --git a/inventory-server/scripts/import/categories.js b/inventory-server/scripts/import/categories.js index 9c1741d..a124349 100644 --- a/inventory-server/scripts/import/categories.js +++ b/inventory-server/scripts/import/categories.js @@ -15,6 +15,9 @@ async function importCategories(prodConnection, localConnection) { try { // Start a single transaction for the entire import await localConnection.query('BEGIN'); + + // Temporarily disable the trigger that's causing problems + await localConnection.query('ALTER TABLE categories DISABLE TRIGGER update_categories_updated_at'); // Process each type in order with its own savepoint for (const type of typeOrder) { @@ -149,6 +152,9 @@ async function importCategories(prodConnection, localConnection) { ON CONFLICT (table_name) DO UPDATE SET last_sync_timestamp = NOW() `); + + // Re-enable the trigger + await localConnection.query('ALTER TABLE categories ENABLE TRIGGER update_categories_updated_at'); outputProgress({ status: "complete", @@ -178,6 +184,9 @@ async function importCategories(prodConnection, localConnection) { // Only rollback if we haven't committed yet try { await localConnection.query('ROLLBACK'); + + // Make sure we re-enable the trigger even if there was an error + await localConnection.query('ALTER TABLE categories ENABLE TRIGGER update_categories_updated_at'); } catch (rollbackError) { console.error("Error during rollback:", rollbackError); } diff --git a/inventory-server/scripts/import/purchase-orders.js b/inventory-server/scripts/import/purchase-orders.js index 6908df3..0831d10 100644 --- a/inventory-server/scripts/import/purchase-orders.js +++ b/inventory-server/scripts/import/purchase-orders.js @@ -590,7 +590,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental ordered, po_cost_price, supplier_id, date_created, date_ordered ) SELECT - 'R' || r.receiving_id as po_id, + r.receiving_id::text as po_id, r.pid, COALESCE(p.sku, 'NO-SKU') as sku, COALESCE(p.name, 'Unknown Product') as name, @@ -626,7 +626,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by ) SELECT - 'R' || r.receiving_id as po_id, + r.receiving_id::text as po_id, r.pid, r.receiving_id, r.qty_each as allocated_qty, diff --git a/inventory-server/scripts/metrics/financial-metrics.js b/inventory-server/scripts/metrics/financial-metrics.js index 1cba3ff..a683155 100644 --- a/inventory-server/scripts/metrics/financial-metrics.js +++ b/inventory-server/scripts/metrics/financial-metrics.js @@ -56,36 +56,94 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun } }); - // Calculate financial metrics with optimized query + // First, calculate beginning inventory values (12 months ago) + await connection.query(` + CREATE TEMPORARY TABLE IF NOT EXISTS temp_beginning_inventory AS + WITH beginning_inventory_calc AS ( + SELECT + p.pid, + p.stock_quantity as current_quantity, + COALESCE(SUM(o.quantity), 0) as sold_quantity, + COALESCE(SUM(po.received), 0) as received_quantity, + GREATEST(0, (p.stock_quantity + COALESCE(SUM(o.quantity), 0) - COALESCE(SUM(po.received), 0))) as beginning_quantity, + p.cost_price + FROM + products p + LEFT JOIN + orders o ON p.pid = o.pid + AND o.canceled = false + AND o.date >= CURRENT_DATE - INTERVAL '12 months'::interval + LEFT JOIN + purchase_orders po ON p.pid = po.pid + AND po.received_date IS NOT NULL + AND po.received_date >= CURRENT_DATE - INTERVAL '12 months'::interval + GROUP BY + p.pid, p.stock_quantity, p.cost_price + ) + SELECT + pid, + beginning_quantity, + beginning_quantity * cost_price as beginning_value, + current_quantity * cost_price as current_value, + ((beginning_quantity * cost_price) + (current_quantity * cost_price)) / 2 as average_inventory_value + FROM + beginning_inventory_calc + `); + + processedCount = Math.floor(totalProducts * 0.60); + outputProgress({ + status: 'running', + operation: 'Beginning inventory values calculated, computing financial metrics', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + + // Calculate financial metrics with optimized query and standard formulas await connection.query(` WITH product_financials AS ( SELECT p.pid, - p.cost_price * p.stock_quantity as inventory_value, - SUM(o.quantity * o.price) as total_revenue, - SUM(o.quantity * p.cost_price) as cost_of_goods_sold, - SUM(o.quantity * (o.price - p.cost_price)) as gross_profit, + COALESCE(bi.average_inventory_value, p.cost_price * p.stock_quantity) as avg_inventory_value, + p.cost_price * p.stock_quantity as current_inventory_value, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0))) as total_revenue, + SUM(o.quantity * COALESCE(o.costeach, 0)) as cost_of_goods_sold, + SUM(o.quantity * (o.price - COALESCE(o.discount, 0) - COALESCE(o.costeach, 0))) as gross_profit, MIN(o.date) as first_sale_date, MAX(o.date) as last_sale_date, EXTRACT(DAY FROM (MAX(o.date)::timestamp with time zone - MIN(o.date)::timestamp with time zone)) + 1 as calculation_period_days, COUNT(DISTINCT DATE(o.date)) as active_days FROM products p LEFT JOIN orders o ON p.pid = o.pid + LEFT JOIN temp_beginning_inventory bi ON p.pid = bi.pid WHERE o.canceled = false - AND DATE(o.date) >= CURRENT_DATE - INTERVAL '12 months' - GROUP BY p.pid, p.cost_price, p.stock_quantity + AND DATE(o.date) >= CURRENT_DATE - INTERVAL '12 months'::interval + GROUP BY p.pid, p.cost_price, p.stock_quantity, bi.average_inventory_value ) UPDATE product_metrics pm SET - inventory_value = COALESCE(pf.inventory_value, 0), - total_revenue = COALESCE(pf.total_revenue, 0), - cost_of_goods_sold = COALESCE(pf.cost_of_goods_sold, 0), - gross_profit = COALESCE(pf.gross_profit, 0), - gmroi = CASE - WHEN COALESCE(pf.inventory_value, 0) > 0 AND pf.active_days > 0 THEN - (COALESCE(pf.gross_profit, 0) * (365.0 / pf.active_days)) / COALESCE(pf.inventory_value, 0) + inventory_value = COALESCE(pf.current_inventory_value, 0)::decimal(10,3), + total_revenue = COALESCE(pf.total_revenue, 0)::decimal(10,3), + cost_of_goods_sold = COALESCE(pf.cost_of_goods_sold, 0)::decimal(10,3), + gross_profit = COALESCE(pf.gross_profit, 0)::decimal(10,3), + turnover_rate = CASE + WHEN COALESCE(pf.avg_inventory_value, 0) > 0 THEN + COALESCE(pf.cost_of_goods_sold, 0) / NULLIF(pf.avg_inventory_value, 0) ELSE 0 - END, + END::decimal(12,3), + gmroi = CASE + WHEN COALESCE(pf.avg_inventory_value, 0) > 0 THEN + COALESCE(pf.gross_profit, 0) / NULLIF(pf.avg_inventory_value, 0) + ELSE 0 + END::decimal(10,3), last_calculated_at = CURRENT_TIMESTAMP FROM product_financials pf WHERE pm.pid = pf.pid @@ -115,53 +173,8 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun success }; - // Update time-based aggregates with optimized query - await connection.query(` - WITH monthly_financials AS ( - SELECT - p.pid, - EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, - EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, - p.cost_price * p.stock_quantity as inventory_value, - SUM(o.quantity * (o.price - p.cost_price)) as gross_profit, - COUNT(DISTINCT DATE(o.date)) as active_days, - MIN(o.date) as period_start, - MAX(o.date) as period_end - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE o.canceled = false - GROUP BY p.pid, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone), p.cost_price, p.stock_quantity - ) - UPDATE product_time_aggregates pta - SET - inventory_value = COALESCE(mf.inventory_value, 0), - gmroi = CASE - WHEN COALESCE(mf.inventory_value, 0) > 0 AND mf.active_days > 0 THEN - (COALESCE(mf.gross_profit, 0) * (365.0 / mf.active_days)) / COALESCE(mf.inventory_value, 0) - ELSE 0 - END - FROM monthly_financials mf - WHERE pta.pid = mf.pid - AND pta.year = mf.year - AND pta.month = mf.month - `); - - processedCount = Math.floor(totalProducts * 0.70); - outputProgress({ - status: 'running', - operation: 'Time-based aggregates updated', - current: processedCount, - total: totalProducts, - elapsed: formatElapsedTime(startTime), - remaining: estimateRemaining(startTime, processedCount, totalProducts), - rate: calculateRate(startTime, processedCount), - percentage: ((processedCount / totalProducts) * 100).toFixed(1), - timing: { - start_time: new Date(startTime).toISOString(), - end_time: new Date().toISOString(), - elapsed_seconds: Math.round((Date.now() - startTime) / 1000) - } - }); + // Clean up temporary tables + await connection.query('DROP TABLE IF EXISTS temp_beginning_inventory'); // If we get here, everything completed successfully success = true; @@ -187,6 +200,12 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun throw error; } finally { if (connection) { + try { + // Make sure temporary tables are always cleaned up + await connection.query('DROP TABLE IF EXISTS temp_beginning_inventory'); + } catch (err) { + console.error('Error cleaning up temp tables:', err); + } connection.release(); } } diff --git a/inventory-server/scripts/metrics/product-metrics.js b/inventory-server/scripts/metrics/product-metrics.js index 18f703c..405e6d9 100644 --- a/inventory-server/scripts/metrics/product-metrics.js +++ b/inventory-server/scripts/metrics/product-metrics.js @@ -66,8 +66,36 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount WHERE category_id IS NULL AND vendor IS NULL LIMIT 1 `); + + // Check if threshold data was returned + if (!thresholds.rows || thresholds.rows.length === 0) { + console.warn('No default thresholds found in the database. Using explicit type casting in the query.'); + } + const defaultThresholds = thresholds.rows[0]; + // Get financial calculation configuration parameters + const financialConfig = await connection.query(` + SELECT + order_cost, + holding_rate, + service_level_z_score, + min_reorder_qty, + default_reorder_qty, + default_safety_stock + FROM financial_calc_config + WHERE id = 1 + LIMIT 1 + `); + const finConfig = financialConfig.rows[0] || { + order_cost: 25.00, + holding_rate: 0.25, + service_level_z_score: 1.96, + min_reorder_qty: 1, + default_reorder_qty: 5, + default_safety_stock: 5 + }; + // Calculate base product metrics if (!SKIP_PRODUCT_BASE_METRICS) { outputProgress({ @@ -109,6 +137,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount avg_margin_percent DECIMAL(10,3), first_sale_date DATE, last_sale_date DATE, + stddev_daily_sales DECIMAL(10,3), PRIMARY KEY (pid) ) `); @@ -117,10 +146,11 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount await connection.query(` CREATE TEMPORARY TABLE temp_purchase_metrics ( pid BIGINT NOT NULL, - avg_lead_time_days DOUBLE PRECISION, + avg_lead_time_days DECIMAL(10,2), last_purchase_date DATE, first_received_date DATE, last_received_date DATE, + stddev_lead_time_days DECIMAL(10,2), PRIMARY KEY (pid) ) `); @@ -140,11 +170,22 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount ELSE 0 END as avg_margin_percent, MIN(o.date) as first_sale_date, - MAX(o.date) as last_sale_date + MAX(o.date) as last_sale_date, + COALESCE(STDDEV_SAMP(daily_qty.quantity), 0) as stddev_daily_sales FROM products p LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false AND o.date >= CURRENT_DATE - INTERVAL '90 days' + LEFT JOIN ( + SELECT + pid, + DATE(date) as sale_date, + SUM(quantity) as quantity + FROM orders + WHERE canceled = false + AND date >= CURRENT_DATE - INTERVAL '90 days' + GROUP BY pid, DATE(date) + ) daily_qty ON p.pid = daily_qty.pid GROUP BY p.pid `); @@ -163,7 +204,14 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount ) as avg_lead_time_days, MAX(po.date) as last_purchase_date, MIN(po.received_date) as first_received_date, - MAX(po.received_date) as last_received_date + MAX(po.received_date) as last_received_date, + STDDEV_SAMP( + CASE + WHEN po.received_date IS NOT NULL AND po.date IS NOT NULL + THEN EXTRACT(EPOCH FROM (po.received_date::timestamp with time zone - po.date::timestamp with time zone)) / 86400.0 + ELSE NULL + END + ) as stddev_lead_time_days FROM products p LEFT JOIN purchase_orders po ON p.pid = po.pid AND po.received_date IS NOT NULL @@ -184,7 +232,8 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount 30.0 as avg_lead_time_days, NULL as last_purchase_date, NULL as first_received_date, - NULL as last_received_date + NULL as last_received_date, + 0.0 as stddev_lead_time_days FROM products p LEFT JOIN temp_purchase_metrics tpm ON p.pid = tpm.pid WHERE tpm.pid IS NULL @@ -208,6 +257,17 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount if (batch.rows.length === 0) break; // Process the entire batch in a single efficient query + const lowStockThreshold = parseInt(defaultThresholds?.low_stock_threshold) || 5; + const criticalDays = parseInt(defaultThresholds?.critical_days) || 7; + const reorderDays = parseInt(defaultThresholds?.reorder_days) || 14; + const overstockDays = parseInt(defaultThresholds?.overstock_days) || 90; + const serviceLevel = parseFloat(finConfig?.service_level_z_score) || 1.96; + const defaultSafetyStock = parseInt(finConfig?.default_safety_stock) || 5; + const defaultReorderQty = parseInt(finConfig?.default_reorder_qty) || 5; + const orderCost = parseFloat(finConfig?.order_cost) || 25.00; + const holdingRate = parseFloat(finConfig?.holding_rate) || 0.25; + const minReorderQty = parseInt(finConfig?.min_reorder_qty) || 1; + await connection.query(` UPDATE product_metrics pm SET @@ -219,7 +279,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount avg_margin_percent = COALESCE(sm.avg_margin_percent, 0), first_sale_date = sm.first_sale_date, last_sale_date = sm.last_sale_date, - avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30), + avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30.0), days_of_inventory = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN FLOOR(p.stock_quantity / NULLIF(sm.daily_sales_avg, 0)) @@ -232,57 +292,61 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount END, stock_status = CASE WHEN p.stock_quantity <= 0 THEN 'Out of Stock' - WHEN COALESCE(sm.daily_sales_avg, 0) = 0 AND p.stock_quantity <= $1 THEN 'Low Stock' + WHEN COALESCE(sm.daily_sales_avg, 0) = 0 AND p.stock_quantity <= ${lowStockThreshold} THEN 'Low Stock' WHEN COALESCE(sm.daily_sales_avg, 0) = 0 THEN 'In Stock' - WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= $2 THEN 'Critical' - WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= $3 THEN 'Reorder' - WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > $4 THEN 'Overstocked' + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ${criticalDays} THEN 'Critical' + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) <= ${reorderDays} THEN 'Reorder' + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ${overstockDays} THEN 'Overstocked' ELSE 'Healthy' END, safety_stock = CASE - WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN - CEIL(sm.daily_sales_avg * SQRT(ABS(COALESCE(lm.avg_lead_time_days, 30))) * 1.96) - ELSE $5 + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND COALESCE(lm.avg_lead_time_days, 0) > 0 THEN + CEIL( + ${serviceLevel} * SQRT( + GREATEST(0, COALESCE(lm.avg_lead_time_days, 0)) * POWER(COALESCE(sm.stddev_daily_sales, 0), 2) + + POWER(COALESCE(sm.daily_sales_avg, 0), 2) * POWER(COALESCE(lm.stddev_lead_time_days, 0), 2) + ) + ) + ELSE ${defaultSafetyStock} END, reorder_point = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN - CEIL(sm.daily_sales_avg * COALESCE(lm.avg_lead_time_days, 30)) + - CEIL(sm.daily_sales_avg * SQRT(ABS(COALESCE(lm.avg_lead_time_days, 30))) * 1.96) - ELSE $6 + CEIL(sm.daily_sales_avg * GREATEST(0, COALESCE(lm.avg_lead_time_days, 30.0))) + + (CASE + WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND COALESCE(lm.avg_lead_time_days, 0) > 0 THEN + CEIL( + ${serviceLevel} * SQRT( + GREATEST(0, COALESCE(lm.avg_lead_time_days, 0)) * POWER(COALESCE(sm.stddev_daily_sales, 0), 2) + + POWER(COALESCE(sm.daily_sales_avg, 0), 2) * POWER(COALESCE(lm.stddev_lead_time_days, 0), 2) + ) + ) + ELSE ${defaultSafetyStock} + END) + ELSE ${lowStockThreshold} END, reorder_qty = CASE WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND NULLIF(p.cost_price, 0) IS NOT NULL AND NULLIF(p.cost_price, 0) > 0 THEN GREATEST( - CEIL(SQRT(ABS((2 * (sm.daily_sales_avg * 365) * 25) / (NULLIF(p.cost_price, 0) * 0.25)))), - $7 + CEIL(SQRT( + (2 * (sm.daily_sales_avg * 365) * ${orderCost}) / + NULLIF(p.cost_price * ${holdingRate}, 0) + )), + ${minReorderQty} ) - ELSE $8 + ELSE ${defaultReorderQty} END, overstocked_amt = CASE - WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > $9 - THEN GREATEST(0, p.stock_quantity - CEIL(sm.daily_sales_avg * $10)) + WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ${overstockDays} + THEN GREATEST(0, p.stock_quantity - CEIL(sm.daily_sales_avg * ${overstockDays})) ELSE 0 END, last_calculated_at = NOW() FROM products p LEFT JOIN temp_sales_metrics sm ON p.pid = sm.pid LEFT JOIN temp_purchase_metrics lm ON p.pid = lm.pid - WHERE p.pid = ANY($11::bigint[]) + WHERE p.pid = ANY($1::BIGINT[]) AND pm.pid = p.pid - `, - [ - defaultThresholds.low_stock_threshold, - defaultThresholds.critical_days, - defaultThresholds.reorder_days, - defaultThresholds.overstock_days, - defaultThresholds.low_stock_threshold, - defaultThresholds.low_stock_threshold, - defaultThresholds.low_stock_threshold, - defaultThresholds.low_stock_threshold, - defaultThresholds.overstock_days, - defaultThresholds.overstock_days, - batch.rows.map(row => row.pid) - ]); + `, [batch.rows.map(row => row.pid)]); lastPid = batch.rows[batch.rows.length - 1].pid; processedCount += batch.rows.length; @@ -311,25 +375,22 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount } // Calculate forecast accuracy and bias in batches - lastPid = 0; + let forecastPid = 0; while (true) { if (isCancelled) break; - const batch = await connection.query( + const forecastBatch = await connection.query( 'SELECT pid FROM products WHERE pid > $1 ORDER BY pid LIMIT $2', - [lastPid, BATCH_SIZE] + [forecastPid, BATCH_SIZE] ); - if (batch.rows.length === 0) break; + if (forecastBatch.rows.length === 0) break; + const forecastPidArray = forecastBatch.rows.map(row => row.pid); + + // Use array_to_string to convert the array to a string of comma-separated values await connection.query(` - UPDATE product_metrics pm - SET - forecast_accuracy = GREATEST(0, 100 - LEAST(fa.avg_forecast_error, 100)), - forecast_bias = GREATEST(-100, LEAST(fa.avg_forecast_bias, 100)), - last_forecast_date = fa.last_forecast_date, - last_calculated_at = NOW() - FROM ( + WITH forecast_metrics AS ( SELECT sf.pid, AVG(CASE @@ -348,13 +409,20 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount AND DATE(o.date) = sf.forecast_date WHERE o.canceled = false AND sf.forecast_date >= CURRENT_DATE - INTERVAL '90 days' - AND sf.pid = ANY($1::bigint[]) + AND sf.pid = ANY('{${forecastPidArray.join(',')}}'::BIGINT[]) GROUP BY sf.pid - ) fa - WHERE pm.pid = fa.pid - `, [batch.rows.map(row => row.pid)]); + ) + UPDATE product_metrics pm + SET + forecast_accuracy = GREATEST(0, 100 - LEAST(fm.avg_forecast_error, 100)), + forecast_bias = GREATEST(-100, LEAST(fm.avg_forecast_bias, 100)), + last_forecast_date = fm.last_forecast_date, + last_calculated_at = NOW() + FROM forecast_metrics fm + WHERE pm.pid = fm.pid + `); - lastPid = batch.rows[batch.rows.length - 1].pid; + forecastPid = forecastBatch.rows[forecastBatch.rows.length - 1].pid; } // Calculate product time aggregates @@ -375,61 +443,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount } }); - // Calculate time-based aggregates - await connection.query(` - INSERT INTO product_time_aggregates ( - pid, - year, - month, - total_quantity_sold, - total_revenue, - total_cost, - order_count, - avg_price, - profit_margin, - inventory_value, - gmroi - ) - SELECT - p.pid, - EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, - EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, - SUM(o.quantity) as total_quantity_sold, - SUM(o.price * o.quantity) as total_revenue, - SUM(p.cost_price * o.quantity) as total_cost, - COUNT(DISTINCT o.order_number) as order_count, - AVG(o.price) as avg_price, - CASE - WHEN SUM(o.quantity * o.price) > 0 - THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100 - ELSE 0 - END as profit_margin, - p.cost_price * p.stock_quantity as inventory_value, - CASE - WHEN p.cost_price * p.stock_quantity > 0 - THEN (SUM(o.quantity * (o.price - p.cost_price))) / (p.cost_price * p.stock_quantity) - ELSE 0 - END as gmroi - FROM products p - LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false - WHERE o.date >= CURRENT_DATE - INTERVAL '12 months' - GROUP BY p.pid, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone) - ON CONFLICT (pid, year, month) DO UPDATE - SET - total_quantity_sold = EXCLUDED.total_quantity_sold, - total_revenue = EXCLUDED.total_revenue, - total_cost = EXCLUDED.total_cost, - order_count = EXCLUDED.order_count, - avg_price = EXCLUDED.avg_price, - profit_margin = EXCLUDED.profit_margin, - inventory_value = EXCLUDED.inventory_value, - gmroi = EXCLUDED.gmroi - `); - + // Note: The time-aggregates calculation has been moved to time-aggregates.js + // This module will not duplicate that functionality processedCount = Math.floor(totalProducts * 0.6); outputProgress({ status: 'running', - operation: 'Product time aggregates calculated', + operation: 'Product time aggregates calculation delegated to time-aggregates module', current: processedCount || 0, total: totalProducts || 0, elapsed: formatElapsedTime(startTime), @@ -487,6 +506,10 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount const abcConfig = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1'); const abcThresholds = abcConfig.rows[0] || { a_threshold: 20, b_threshold: 50 }; + + // Extract values and ensure they are valid numbers + const aThreshold = parseFloat(abcThresholds.a_threshold) || 20; + const bThreshold = parseFloat(abcThresholds.b_threshold) || 50; // First, create and populate the rankings table with an index await connection.query('DROP TABLE IF EXISTS temp_revenue_ranks'); @@ -557,13 +580,13 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount OR pm.abc_class != CASE WHEN tr.pid IS NULL THEN 'C' - WHEN tr.percentile <= $2 THEN 'A' - WHEN tr.percentile <= $3 THEN 'B' + WHEN tr.percentile <= ${aThreshold} THEN 'A' + WHEN tr.percentile <= ${bThreshold} THEN 'B' ELSE 'C' END) ORDER BY pm.pid - LIMIT $4 - `, [abcProcessedCount, abcThresholds.a_threshold, abcThresholds.b_threshold, batchSize]); + LIMIT $2 + `, [abcProcessedCount, batchSize]); if (pids.rows.length === 0) break; @@ -574,15 +597,15 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount SET abc_class = CASE WHEN tr.pid IS NULL THEN 'C' - WHEN tr.percentile <= $1 THEN 'A' - WHEN tr.percentile <= $2 THEN 'B' + WHEN tr.percentile <= ${aThreshold} THEN 'A' + WHEN tr.percentile <= ${bThreshold} THEN 'B' ELSE 'C' END, last_calculated_at = NOW() FROM (SELECT pid, percentile FROM temp_revenue_ranks) tr - WHERE pm.pid = tr.pid AND pm.pid = ANY($3::bigint[]) - OR (pm.pid = ANY($3::bigint[]) AND tr.pid IS NULL) - `, [abcThresholds.a_threshold, abcThresholds.b_threshold, pidValues]); + WHERE pm.pid = tr.pid AND pm.pid = ANY($1::BIGINT[]) + OR (pm.pid = ANY($1::BIGINT[]) AND tr.pid IS NULL) + `, [pidValues]); // Now update turnover rate with proper handling of zero inventory periods await connection.query(` @@ -610,7 +633,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount JOIN products p ON o.pid = p.pid WHERE o.canceled = false AND o.date >= CURRENT_DATE - INTERVAL '90 days' - AND o.pid = ANY($1::bigint[]) + AND o.pid = ANY($1::BIGINT[]) GROUP BY o.pid ) sales WHERE pm.pid = sales.pid @@ -707,40 +730,7 @@ function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, return 'Healthy'; } -function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_lead_time, config) { - // Calculate safety stock based on service level and lead time - const z_score = 1.96; // 95% service level - const lead_time = avg_lead_time || config.target_days; - const safety_stock = Math.ceil(daily_sales_avg * Math.sqrt(lead_time) * z_score); - - // Calculate reorder point - const lead_time_demand = daily_sales_avg * lead_time; - const reorder_point = Math.ceil(lead_time_demand + safety_stock); - - // Calculate reorder quantity using EOQ formula if we have the necessary data - let reorder_qty = 0; - if (daily_sales_avg > 0) { - const annual_demand = daily_sales_avg * 365; - const order_cost = 25; // Fixed cost per order - const holding_cost = config.cost_price * 0.25; // 25% of unit cost as annual holding cost - - reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost)); - } else { - // If no sales data, use a basic calculation - reorder_qty = Math.max(safety_stock, config.low_stock_threshold); - } - - // Calculate overstocked amount - const overstocked_amt = stock_status === 'Overstocked' ? - stock - Math.ceil(daily_sales_avg * config.overstock_days) : - 0; - - return { - safety_stock, - reorder_point, - reorder_qty, - overstocked_amt - }; -} +// Note: calculateReorderQuantities function has been removed as its logic has been incorporated +// in the main SQL query with configurable parameters module.exports = calculateProductMetrics; \ No newline at end of file diff --git a/inventory-server/scripts/metrics/sales-forecasts.js b/inventory-server/scripts/metrics/sales-forecasts.js index da37557..2734e41 100644 --- a/inventory-server/scripts/metrics/sales-forecasts.js +++ b/inventory-server/scripts/metrics/sales-forecasts.js @@ -216,13 +216,7 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount GREATEST(0, ROUND( ds.avg_daily_qty * - (1 + COALESCE(sf.seasonality_factor, 0)) * - CASE - WHEN ds.std_daily_qty / NULLIF(ds.avg_daily_qty, 0) > 1.5 THEN 0.85 - WHEN ds.std_daily_qty / NULLIF(ds.avg_daily_qty, 0) > 1.0 THEN 0.9 - WHEN ds.std_daily_qty / NULLIF(ds.avg_daily_qty, 0) > 0.5 THEN 0.95 - ELSE 1.0 - END + (1 + COALESCE(sf.seasonality_factor, 0)) ) ) as forecast_quantity, CASE @@ -336,8 +330,8 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount cs.cat_id::bigint as category_id, fd.forecast_date, GREATEST(0, - AVG(cs.daily_quantity) * - (1 + COALESCE(sf.seasonality_factor, 0)) + ROUND(AVG(cs.daily_quantity) * + (1 + COALESCE(sf.seasonality_factor, 0))) ) as forecast_units, GREATEST(0, COALESCE( @@ -345,8 +339,7 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount WHEN SUM(cs.day_count) >= 4 THEN AVG(cs.daily_revenue) ELSE ct.overall_avg_revenue END * - (1 + COALESCE(sf.seasonality_factor, 0)) * - (0.95 + (random() * 0.1)), + (1 + COALESCE(sf.seasonality_factor, 0)), 0 ) ) as forecast_revenue, @@ -427,6 +420,18 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount throw error; } finally { if (connection) { + try { + // Ensure temporary tables are cleaned up + await connection.query(` + DROP TABLE IF EXISTS temp_forecast_dates; + DROP TABLE IF EXISTS temp_daily_sales; + DROP TABLE IF EXISTS temp_product_stats; + DROP TABLE IF EXISTS temp_category_sales; + DROP TABLE IF EXISTS temp_category_stats; + `); + } catch (err) { + console.error('Error cleaning up temporary tables:', err); + } connection.release(); } } diff --git a/inventory-server/scripts/metrics/time-aggregates.js b/inventory-server/scripts/metrics/time-aggregates.js index 492a03e..aeb0179 100644 --- a/inventory-server/scripts/metrics/time-aggregates.js +++ b/inventory-server/scripts/metrics/time-aggregates.js @@ -55,6 +55,93 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount } }); + // Create a temporary table for end-of-month inventory values + await connection.query(` + CREATE TEMPORARY TABLE IF NOT EXISTS temp_monthly_inventory AS + WITH months AS ( + -- Generate all year/month combinations for the last 12 months + SELECT + EXTRACT(YEAR FROM month_date)::INTEGER as year, + EXTRACT(MONTH FROM month_date)::INTEGER as month, + month_date as start_date, + (month_date + INTERVAL '1 month'::interval - INTERVAL '1 day'::interval)::DATE as end_date + FROM ( + SELECT generate_series( + DATE_TRUNC('month', CURRENT_DATE - INTERVAL '12 months'::interval)::DATE, + DATE_TRUNC('month', CURRENT_DATE)::DATE, + INTERVAL '1 month'::interval + ) as month_date + ) dates + ), + monthly_inventory_calc AS ( + SELECT + p.pid, + m.year, + m.month, + m.end_date, + p.stock_quantity as current_quantity, + -- Calculate sold during period (before end_date) + COALESCE(SUM( + CASE + WHEN o.date <= m.end_date THEN o.quantity + ELSE 0 + END + ), 0) as sold_after_end_date, + -- Calculate received during period (before end_date) + COALESCE(SUM( + CASE + WHEN po.received_date <= m.end_date THEN po.received + ELSE 0 + END + ), 0) as received_after_end_date, + p.cost_price + FROM + products p + CROSS JOIN + months m + LEFT JOIN + orders o ON p.pid = o.pid + AND o.canceled = false + AND o.date > m.end_date + AND o.date <= CURRENT_DATE + LEFT JOIN + purchase_orders po ON p.pid = po.pid + AND po.received_date IS NOT NULL + AND po.received_date > m.end_date + AND po.received_date <= CURRENT_DATE + GROUP BY + p.pid, m.year, m.month, m.end_date, p.stock_quantity, p.cost_price + ) + SELECT + pid, + year, + month, + -- End of month quantity = current quantity - sold after + received after + GREATEST(0, current_quantity - sold_after_end_date + received_after_end_date) as end_of_month_quantity, + -- End of month inventory value + GREATEST(0, current_quantity - sold_after_end_date + received_after_end_date) * cost_price as end_of_month_value, + cost_price + FROM + monthly_inventory_calc + `); + + processedCount = Math.floor(totalProducts * 0.40); + outputProgress({ + status: 'running', + operation: 'Monthly inventory values calculated, processing time aggregates', + current: processedCount, + total: totalProducts, + elapsed: formatElapsedTime(startTime), + remaining: estimateRemaining(startTime, processedCount, totalProducts), + rate: calculateRate(startTime, processedCount), + percentage: ((processedCount / totalProducts) * 100).toFixed(1), + timing: { + start_time: new Date(startTime).toISOString(), + end_time: new Date().toISOString(), + elapsed_seconds: Math.round((Date.now() - startTime) / 1000) + } + }); + // Initial insert of time-based aggregates await connection.query(` INSERT INTO product_time_aggregates ( @@ -75,76 +162,67 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount WITH monthly_sales AS ( SELECT o.pid, - EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, - EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, + EXTRACT(YEAR FROM o.date::timestamp with time zone)::INTEGER as year, + EXTRACT(MONTH FROM o.date::timestamp with time zone)::INTEGER as month, SUM(o.quantity) as total_quantity_sold, SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) as total_revenue, - SUM(COALESCE(p.cost_price, 0) * o.quantity) as total_cost, + SUM(COALESCE(o.costeach, 0) * o.quantity) as total_cost, COUNT(DISTINCT o.order_number) as order_count, AVG(o.price - COALESCE(o.discount, 0)) as avg_price, CASE WHEN SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) > 0 - THEN ((SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) - SUM(COALESCE(p.cost_price, 0) * o.quantity)) + THEN ((SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) - SUM(COALESCE(o.costeach, 0) * o.quantity)) / SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100 ELSE 0 END as profit_margin, - p.cost_price * p.stock_quantity as inventory_value, COUNT(DISTINCT DATE(o.date)) as active_days FROM orders o JOIN products p ON o.pid = p.pid WHERE o.canceled = false - GROUP BY o.pid, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone), p.cost_price, p.stock_quantity + GROUP BY o.pid, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone) ), monthly_stock AS ( SELECT pid, - EXTRACT(YEAR FROM date::timestamp with time zone) as year, - EXTRACT(MONTH FROM date::timestamp with time zone) as month, + EXTRACT(YEAR FROM date::timestamp with time zone)::INTEGER as year, + EXTRACT(MONTH FROM date::timestamp with time zone)::INTEGER as month, SUM(received) as stock_received, SUM(ordered) as stock_ordered FROM purchase_orders GROUP BY pid, EXTRACT(YEAR FROM date::timestamp with time zone), EXTRACT(MONTH FROM date::timestamp with time zone) - ), - base_products AS ( - SELECT - p.pid, - p.cost_price * p.stock_quantity as inventory_value - FROM products p ) SELECT - COALESCE(s.pid, ms.pid) as pid, - COALESCE(s.year, ms.year) as year, - COALESCE(s.month, ms.month) as month, - COALESCE(s.total_quantity_sold, 0) as total_quantity_sold, - COALESCE(s.total_revenue, 0) as total_revenue, - COALESCE(s.total_cost, 0) as total_cost, - COALESCE(s.order_count, 0) as order_count, - COALESCE(ms.stock_received, 0) as stock_received, - COALESCE(ms.stock_ordered, 0) as stock_ordered, - COALESCE(s.avg_price, 0) as avg_price, - COALESCE(s.profit_margin, 0) as profit_margin, - COALESCE(s.inventory_value, bp.inventory_value, 0) as inventory_value, + COALESCE(s.pid, ms.pid, mi.pid) as pid, + COALESCE(s.year, ms.year, mi.year) as year, + COALESCE(s.month, ms.month, mi.month) as month, + COALESCE(s.total_quantity_sold, 0)::INTEGER as total_quantity_sold, + COALESCE(s.total_revenue, 0)::DECIMAL(10,3) as total_revenue, + COALESCE(s.total_cost, 0)::DECIMAL(10,3) as total_cost, + COALESCE(s.order_count, 0)::INTEGER as order_count, + COALESCE(ms.stock_received, 0)::INTEGER as stock_received, + COALESCE(ms.stock_ordered, 0)::INTEGER as stock_ordered, + COALESCE(s.avg_price, 0)::DECIMAL(10,3) as avg_price, + COALESCE(s.profit_margin, 0)::DECIMAL(10,3) as profit_margin, + COALESCE(mi.end_of_month_value, 0)::DECIMAL(10,3) as inventory_value, CASE - WHEN COALESCE(s.inventory_value, bp.inventory_value, 0) > 0 - AND COALESCE(s.active_days, 0) > 0 - THEN (COALESCE(s.total_revenue - s.total_cost, 0) * (365.0 / s.active_days)) - / COALESCE(s.inventory_value, bp.inventory_value) + WHEN COALESCE(mi.end_of_month_value, 0) > 0 + THEN (COALESCE(s.total_revenue, 0) - COALESCE(s.total_cost, 0)) + / NULLIF(COALESCE(mi.end_of_month_value, 0), 0) ELSE 0 - END as gmroi + END::DECIMAL(10,3) as gmroi FROM ( SELECT * FROM monthly_sales s UNION ALL SELECT - ms.pid, - ms.year, - ms.month, + pid, + year, + month, 0 as total_quantity_sold, 0 as total_revenue, 0 as total_cost, 0 as order_count, NULL as avg_price, 0 as profit_margin, - NULL as inventory_value, 0 as active_days FROM monthly_stock ms WHERE NOT EXISTS ( @@ -153,50 +231,40 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount AND s2.year = ms.year AND s2.month = ms.month ) + UNION ALL + SELECT + pid, + year, + month, + 0 as total_quantity_sold, + 0 as total_revenue, + 0 as total_cost, + 0 as order_count, + NULL as avg_price, + 0 as profit_margin, + 0 as active_days + FROM temp_monthly_inventory mi + WHERE NOT EXISTS ( + SELECT 1 FROM monthly_sales s3 + WHERE s3.pid = mi.pid + AND s3.year = mi.year + AND s3.month = mi.month + ) + AND NOT EXISTS ( + SELECT 1 FROM monthly_stock ms3 + WHERE ms3.pid = mi.pid + AND ms3.year = mi.year + AND ms3.month = mi.month + ) ) s LEFT JOIN monthly_stock ms ON s.pid = ms.pid AND s.year = ms.year AND s.month = ms.month - JOIN base_products bp ON COALESCE(s.pid, ms.pid) = bp.pid - UNION - SELECT - ms.pid, - ms.year, - ms.month, - 0 as total_quantity_sold, - 0 as total_revenue, - 0 as total_cost, - 0 as order_count, - ms.stock_received, - ms.stock_ordered, - 0 as avg_price, - 0 as profit_margin, - bp.inventory_value, - 0 as gmroi - FROM monthly_stock ms - JOIN base_products bp ON ms.pid = bp.pid - WHERE NOT EXISTS ( - SELECT 1 FROM ( - SELECT * FROM monthly_sales - UNION ALL - SELECT - ms2.pid, - ms2.year, - ms2.month, - 0, 0, 0, 0, NULL, 0, NULL, 0 - FROM monthly_stock ms2 - WHERE NOT EXISTS ( - SELECT 1 FROM monthly_sales s2 - WHERE s2.pid = ms2.pid - AND s2.year = ms2.year - AND s2.month = ms2.month - ) - ) s - WHERE s.pid = ms.pid - AND s.year = ms.year - AND s.month = ms.month - ) + LEFT JOIN temp_monthly_inventory mi + ON s.pid = mi.pid + AND s.year = mi.year + AND s.month = mi.month ON CONFLICT (pid, year, month) DO UPDATE SET total_quantity_sold = EXCLUDED.total_quantity_sold, @@ -214,7 +282,7 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount processedCount = Math.floor(totalProducts * 0.60); outputProgress({ status: 'running', - operation: 'Base time aggregates calculated, updating financial metrics', + operation: 'Base time aggregates calculated', current: processedCount, total: totalProducts, elapsed: formatElapsedTime(startTime), @@ -234,45 +302,9 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount processedPurchaseOrders: 0, success }; - - // Update with financial metrics - await connection.query(` - UPDATE product_time_aggregates pta - SET inventory_value = COALESCE(fin.inventory_value, 0) - FROM ( - SELECT - p.pid, - EXTRACT(YEAR FROM o.date::timestamp with time zone) as year, - EXTRACT(MONTH FROM o.date::timestamp with time zone) as month, - p.cost_price * p.stock_quantity as inventory_value, - SUM(o.quantity * (o.price - p.cost_price)) as gross_profit, - COUNT(DISTINCT DATE(o.date)) as active_days - FROM products p - LEFT JOIN orders o ON p.pid = o.pid - WHERE o.canceled = false - GROUP BY p.pid, EXTRACT(YEAR FROM o.date::timestamp with time zone), EXTRACT(MONTH FROM o.date::timestamp with time zone), p.cost_price, p.stock_quantity - ) fin - WHERE pta.pid = fin.pid - AND pta.year = fin.year - AND pta.month = fin.month - `); - - processedCount = Math.floor(totalProducts * 0.65); - outputProgress({ - status: 'running', - operation: 'Financial metrics updated', - current: processedCount, - total: totalProducts, - elapsed: formatElapsedTime(startTime), - remaining: estimateRemaining(startTime, processedCount, totalProducts), - rate: calculateRate(startTime, processedCount), - percentage: ((processedCount / totalProducts) * 100).toFixed(1), - timing: { - start_time: new Date(startTime).toISOString(), - end_time: new Date().toISOString(), - elapsed_seconds: Math.round((Date.now() - startTime) / 1000) - } - }); + + // Clean up temporary tables + await connection.query('DROP TABLE IF EXISTS temp_monthly_inventory'); // If we get here, everything completed successfully success = true; @@ -298,6 +330,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount throw error; } finally { if (connection) { + try { + // Ensure temporary tables are cleaned up + await connection.query('DROP TABLE IF EXISTS temp_monthly_inventory'); + } catch (err) { + console.error('Error cleaning up temporary tables:', err); + } connection.release(); } } diff --git a/inventory-server/src/routes/csv.js b/inventory-server/src/routes/csv.js index e8a280a..d71d5dd 100644 --- a/inventory-server/src/routes/csv.js +++ b/inventory-server/src/routes/csv.js @@ -779,10 +779,16 @@ router.get('/history/calculate', async (req, res) => { id, start_time, end_time, + duration_minutes, status, error_message, - modules_processed::integer, - total_modules::integer + total_products, + total_orders, + total_purchase_orders, + processed_products, + processed_orders, + processed_purchase_orders, + additional_info FROM calculate_history ORDER BY start_time DESC LIMIT 20 diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index b087063..6275dba 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -65,6 +65,19 @@ router.get('/', async (req, res) => { paramCounter++; } + // Handle text filters for specific fields + if (req.query.barcode) { + conditions.push(`p.barcode ILIKE $${paramCounter}`); + params.push(`%${req.query.barcode}%`); + paramCounter++; + } + + if (req.query.vendor_reference) { + conditions.push(`p.vendor_reference ILIKE $${paramCounter}`); + params.push(`%${req.query.vendor_reference}%`); + paramCounter++; + } + // Handle numeric filters with operators const numericFields = { stock: 'p.stock_quantity', @@ -74,11 +87,22 @@ router.get('/', async (req, res) => { dailySalesAvg: 'pm.daily_sales_avg', weeklySalesAvg: 'pm.weekly_sales_avg', monthlySalesAvg: 'pm.monthly_sales_avg', + avgQuantityPerOrder: 'pm.avg_quantity_per_order', + numberOfOrders: 'pm.number_of_orders', margin: 'pm.avg_margin_percent', gmroi: 'pm.gmroi', + inventoryValue: 'pm.inventory_value', + costOfGoodsSold: 'pm.cost_of_goods_sold', + grossProfit: 'pm.gross_profit', + turnoverRate: 'pm.turnover_rate', leadTime: 'pm.current_lead_time', + currentLeadTime: 'pm.current_lead_time', + targetLeadTime: 'pm.target_lead_time', stockCoverage: 'pm.days_of_inventory', - daysOfStock: 'pm.days_of_inventory' + daysOfStock: 'pm.days_of_inventory', + weeksOfStock: 'pm.weeks_of_inventory', + reorderPoint: 'pm.reorder_point', + safetyStock: 'pm.safety_stock' }; Object.entries(req.query).forEach(([key, value]) => { @@ -102,6 +126,24 @@ router.get('/', async (req, res) => { } }); + // Handle date filters + const dateFields = { + firstSaleDate: 'pm.first_sale_date', + lastSaleDate: 'pm.last_sale_date', + lastPurchaseDate: 'pm.last_purchase_date', + firstReceivedDate: 'pm.first_received_date', + lastReceivedDate: 'pm.last_received_date' + }; + + Object.entries(req.query).forEach(([key, value]) => { + const field = dateFields[key]; + if (field) { + conditions.push(`${field}::TEXT LIKE $${paramCounter}`); + params.push(`${value}%`); // Format like '2023-01%' to match by month or '2023-01-01' for exact date + paramCounter++; + } + }); + // Handle select filters if (req.query.vendor) { conditions.push(`p.vendor = $${paramCounter}`); diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx index c810f57..9632ffe 100644 --- a/inventory/src/components/products/ProductFilters.tsx +++ b/inventory/src/components/products/ProductFilters.tsx @@ -51,7 +51,9 @@ const FILTER_OPTIONS: FilterOption[] = [ // Basic Info Group { id: "search", label: "Search", type: "text", group: "Basic Info" }, { id: "sku", label: "SKU", type: "text", group: "Basic Info" }, + { id: "barcode", label: "UPC/Barcode", type: "text", group: "Basic Info" }, { id: "vendor", label: "Vendor", type: "select", group: "Basic Info" }, + { id: "vendor_reference", label: "Supplier #", type: "text", group: "Basic Info" }, { id: "brand", label: "Brand", type: "select", group: "Basic Info" }, { id: "category", label: "Category", type: "select", group: "Basic Info" }, @@ -84,6 +86,27 @@ const FILTER_OPTIONS: FilterOption[] = [ group: "Inventory", operators: ["=", ">", ">=", "<", "<=", "between"], }, + { + id: "weeksOfStock", + label: "Weeks of Stock", + type: "number", + group: "Inventory", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "reorderPoint", + label: "Reorder Point", + type: "number", + group: "Inventory", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "safetyStock", + label: "Safety Stock", + type: "number", + group: "Inventory", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, { id: "replenishable", label: "Replenishable", @@ -94,6 +117,17 @@ const FILTER_OPTIONS: FilterOption[] = [ ], group: "Inventory", }, + { + id: "abcClass", + label: "ABC Class", + type: "select", + options: [ + { label: "A", value: "A" }, + { label: "B", value: "B" }, + { label: "C", value: "C" }, + ], + group: "Inventory", + }, // Pricing Group { @@ -140,6 +174,32 @@ const FILTER_OPTIONS: FilterOption[] = [ group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"], }, + { + id: "avgQuantityPerOrder", + label: "Avg Qty/Order", + type: "number", + group: "Sales Metrics", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "numberOfOrders", + label: "Order Count", + type: "number", + group: "Sales Metrics", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "firstSaleDate", + label: "First Sale Date", + type: "text", + group: "Sales Metrics", + }, + { + id: "lastSaleDate", + label: "Last Sale Date", + type: "text", + group: "Sales Metrics", + }, // Financial Metrics Group { @@ -156,6 +216,34 @@ const FILTER_OPTIONS: FilterOption[] = [ group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"], }, + { + id: "inventoryValue", + label: "Inventory Value", + type: "number", + group: "Financial Metrics", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "costOfGoodsSold", + label: "COGS", + type: "number", + group: "Financial Metrics", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "grossProfit", + label: "Gross Profit", + type: "number", + group: "Financial Metrics", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "turnoverRate", + label: "Turnover Rate", + type: "number", + group: "Financial Metrics", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, // Lead Time & Stock Coverage Group { @@ -165,6 +253,20 @@ const FILTER_OPTIONS: FilterOption[] = [ group: "Lead Time & Coverage", operators: ["=", ">", ">=", "<", "<=", "between"], }, + { + id: "currentLeadTime", + label: "Current Lead Time", + type: "number", + group: "Lead Time & Coverage", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, + { + id: "targetLeadTime", + label: "Target Lead Time", + type: "number", + group: "Lead Time & Coverage", + operators: ["=", ">", ">=", "<", "<=", "between"], + }, { id: "leadTimeStatus", label: "Lead Time Status", @@ -183,19 +285,26 @@ const FILTER_OPTIONS: FilterOption[] = [ group: "Lead Time & Coverage", operators: ["=", ">", ">=", "<", "<=", "between"], }, - - // Classification Group { - id: "abcClass", - label: "ABC Class", - type: "select", - options: [ - { label: "A", value: "A" }, - { label: "B", value: "B" }, - { label: "C", value: "C" }, - ], - group: "Classification", + id: "lastPurchaseDate", + label: "Last Purchase Date", + type: "text", + group: "Lead Time & Coverage", }, + { + id: "firstReceivedDate", + label: "First Received Date", + type: "text", + group: "Lead Time & Coverage", + }, + { + id: "lastReceivedDate", + label: "Last Received Date", + type: "text", + group: "Lead Time & Coverage", + }, + + // Classification Group { id: "managingStock", label: "Managing Stock", diff --git a/inventory/src/components/settings/DataManagement.tsx b/inventory/src/components/settings/DataManagement.tsx index 2f5593f..27accb7 100644 --- a/inventory/src/components/settings/DataManagement.tsx +++ b/inventory/src/components/settings/DataManagement.tsx @@ -47,17 +47,16 @@ interface HistoryRecord { id: number; start_time: string; end_time: string | null; - duration_minutes: number; + duration_minutes?: number; status: "running" | "completed" | "failed" | "cancelled"; error_message: string | null; additional_info?: Record; } interface ImportHistoryRecord extends HistoryRecord { - table_name: string; records_added: number; records_updated: number; - is_incremental: boolean; + is_incremental?: boolean; } interface CalculateHistoryRecord extends HistoryRecord { @@ -67,6 +66,7 @@ interface CalculateHistoryRecord extends HistoryRecord { processed_products: number; processed_orders: number; processed_purchase_orders: number; + duration_minutes?: number; } interface ModuleStatus { @@ -82,13 +82,14 @@ interface TableStatus { export function DataManagement() { const [isUpdating, setIsUpdating] = useState(false); const [isResetting, setIsResetting] = useState(false); - const [] = useState(null); - const [eventSource, setEventSource] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); const [importHistory, setImportHistory] = useState([]); const [calculateHistory, setCalculateHistory] = useState([]); const [moduleStatus, setModuleStatus] = useState([]); const [tableStatus, setTableStatus] = useState([]); const [scriptOutput, setScriptOutput] = useState([]); + const [eventSource, setEventSource] = useState(null); // Add useRef for scroll handling const terminalRef = useRef(null); @@ -359,11 +360,14 @@ export function DataManagement() { const fetchHistory = async () => { try { + setIsLoading(true); + setHasError(false); + const [importRes, calcRes, moduleRes, tableRes] = await Promise.all([ - fetch(`${config.apiUrl}/csv/history/import`), - fetch(`${config.apiUrl}/csv/history/calculate`), - fetch(`${config.apiUrl}/csv/status/modules`), - fetch(`${config.apiUrl}/csv/status/tables`), + fetch(`${config.apiUrl}/csv/history/import`, { credentials: 'include' }), + fetch(`${config.apiUrl}/csv/history/calculate`, { credentials: 'include' }), + fetch(`${config.apiUrl}/csv/status/modules`, { credentials: 'include' }), + fetch(`${config.apiUrl}/csv/status/tables`, { credentials: 'include' }), ]); if (!importRes.ok || !calcRes.ok || !moduleRes.ok || !tableRes.ok) { @@ -377,18 +381,41 @@ export function DataManagement() { tableRes.json(), ]); - // Ensure we're setting arrays even if the response is empty or invalid - setImportHistory(Array.isArray(importData) ? importData : []); - setCalculateHistory(Array.isArray(calcData) ? calcData : []); - setModuleStatus(Array.isArray(moduleData) ? moduleData : []); - setTableStatus(Array.isArray(tableData) ? tableData : []); + // Process import history to add duration_minutes if it doesn't exist + const processedImportData = (importData || []).map((record: ImportHistoryRecord) => { + if (!record.duration_minutes && record.start_time && record.end_time) { + const start = new Date(record.start_time).getTime(); + const end = new Date(record.end_time).getTime(); + record.duration_minutes = (end - start) / (1000 * 60); + } + return record; + }); + + // Process calculate history to add duration_minutes if it doesn't exist + const processedCalcData = (calcData || []).map((record: CalculateHistoryRecord) => { + if (!record.duration_minutes && record.start_time && record.end_time) { + const start = new Date(record.start_time).getTime(); + const end = new Date(record.end_time).getTime(); + record.duration_minutes = (end - start) / (1000 * 60); + } + return record; + }); + + setImportHistory(processedImportData); + setCalculateHistory(processedCalcData); + setModuleStatus(moduleData || []); + setTableStatus(tableData || []); + setHasError(false); } catch (error) { console.error("Error fetching history:", error); - // Set empty arrays as fallback + setHasError(true); + toast.error("Failed to load data. Please try again."); setImportHistory([]); setCalculateHistory([]); setModuleStatus([]); setTableStatus([]); + } finally { + setIsLoading(false); } }; @@ -398,6 +425,7 @@ export function DataManagement() { if (!response.ok) throw new Error('Failed to fetch table status'); const data = await response.json(); setTableStatus(Array.isArray(data) ? data : []); + toast.success("Table status refreshed"); } catch (error) { toast.error("Failed to refresh table status"); setTableStatus([]); @@ -441,21 +469,26 @@ export function DataManagement() { }; const refreshAllData = async () => { + setIsLoading(true); try { - await Promise.all([ - refreshTableStatus(), - refreshModuleStatus(), - refreshImportHistory(), - refreshCalculateHistory() - ]); + await fetchHistory(); toast.success("All data refreshed"); } catch (error) { - toast.error("Failed to refresh some data"); + toast.error("Failed to refresh data"); + } finally { + setIsLoading(false); } }; useEffect(() => { + // Fetch data immediately on component mount fetchHistory(); + + // Set up periodic refresh every minute + const refreshInterval = setInterval(fetchHistory, 60000); + + // Clean up interval on component unmount + return () => clearInterval(refreshInterval); }, []); // Add useEffect to handle auto-scrolling @@ -607,8 +640,13 @@ export function DataManagement() { size="icon" onClick={refreshAllData} className="h-8 w-8" + disabled={isLoading} > - + {isLoading ? ( + + ) : ( + + )} @@ -620,7 +658,11 @@ export function DataManagement() {
- {tableStatus.length > 0 ? ( + {isLoading ? ( +
+ +
+ ) : tableStatus.length > 0 ? ( tableStatus.map((table) => (
- No imports have been performed yet.
Run a full update or reset to import data. + {hasError ? ( + "Failed to load data. Please try refreshing." + ) : ( + <>No imports have been performed yet.
Run a full update or reset to import data. + )}
)}
+ {/* Module Status */} @@ -647,7 +694,11 @@ export function DataManagement() {
- {moduleStatus.length > 0 ? ( + {isLoading ? ( +
+ +
+ ) : moduleStatus.length > 0 ? ( moduleStatus.map((module) => (
- No metrics have been calculated yet.
Run a full update or reset to calculate metrics. + {hasError ? ( + "Failed to load data. Please try refreshing." + ) : ( + <>No metrics have been calculated yet.
Run a full update or reset to calculate metrics. + )}
)}
+ {/* Recent Import History */} @@ -676,7 +732,16 @@ export function DataManagement() { - {importHistory.length > 0 ? ( + {isLoading ? ( + + +
+ + Loading import history... +
+
+
+ ) : importHistory.length > 0 ? ( importHistory.slice(0, 20).map((record) => ( @@ -686,33 +751,41 @@ export function DataManagement() { className="border-0" > -
- - #{record.id} - - - {formatDate(record.start_time)} - - - {formatDurationWithSeconds( - record.duration_minutes, - record.status === "running", - record.start_time - )} - - - {record.status} - +
+
+ + #{record.id} + +
+
+ + {formatDate(record.start_time)} + +
+
+ + {formatDurationWithSeconds( + record.duration_minutes || 0, + record.status === "running", + record.start_time + )} + +
+
+ + {record.status} + +
@@ -749,7 +822,11 @@ export function DataManagement() { ) : ( - No import history available + {hasError ? ( + "Failed to load import history. Please try refreshing." + ) : ( + "No import history available" + )} )} @@ -766,7 +843,16 @@ export function DataManagement() {
- {calculateHistory.length > 0 ? ( + {isLoading ? ( + + +
+ + Loading calculation history... +
+
+
+ ) : calculateHistory.length > 0 ? ( calculateHistory.slice(0, 20).map((record) => ( @@ -776,34 +862,41 @@ export function DataManagement() { className="border-0" > -
- - #{record.id} - - - {formatDate(record.start_time)} - - - {formatDurationWithSeconds( - record.duration_minutes, - record.status === "running", - record.start_time - )} - - - - {record.status} - +
+
+ + #{record.id} + +
+
+ + {formatDate(record.start_time)} + +
+
+ + {formatDurationWithSeconds( + record.duration_minutes || 0, + record.status === "running", + record.start_time + )} + +
+
+ + {record.status} + +
@@ -817,28 +910,22 @@ export function DataManagement() {
- - Processed Products: - - {record.processed_products} + Products: + {record.processed_products} of {record.total_products}
- - Processed Orders: - - {record.processed_orders} + Orders: + {record.processed_orders} of {record.total_orders}
- - Processed Purchase Orders: - - {record.processed_purchase_orders} + Purchase Orders: + {record.processed_purchase_orders} of {record.total_purchase_orders}
{record.error_message && (
{record.error_message} -
- )} + + )} {record.additional_info && formatJsonData(record.additional_info)} @@ -851,14 +938,18 @@ export function DataManagement() { ) : ( - No calculation history available + {hasError ? ( + "Failed to load calculation history. Please try refreshing." + ) : ( + "No calculation history available" + )} )}
-
+ ); diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index fb2ae65..824cba3 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -55,10 +55,13 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'stock_quantity', label: 'Shelf Count', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'stock_status', label: 'Stock Status', group: 'Stock' }, { key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, + { key: 'weeks_of_inventory', label: 'Weeks of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'abc_class', label: 'ABC Class', group: 'Stock' }, { key: 'replenishable', label: 'Replenishable', group: 'Stock' }, { key: 'moq', label: 'MOQ', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'reorder_qty', label: 'Reorder Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' }, + { key: 'reorder_point', label: 'Reorder Point', group: 'Stock', format: (v) => v?.toString() ?? '-' }, + { key: 'safety_stock', label: 'Safety Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'overstocked_amt', label: 'Overstock Amt', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'regular_price', label: 'Default Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, @@ -67,15 +70,22 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'daily_sales_avg', label: 'Daily Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'weekly_sales_avg', label: 'Weekly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'monthly_sales_avg', label: 'Monthly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' }, + { key: 'avg_quantity_per_order', label: 'Avg Qty/Order', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' }, + { key: 'number_of_orders', label: 'Order Count', group: 'Sales', format: (v) => v?.toString() ?? '-' }, { key: 'first_sale_date', label: 'First Sale', group: 'Sales' }, { key: 'last_sale_date', label: 'Last Sale', group: 'Sales' }, { key: 'gmroi', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'avg_margin_percent', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' }, + { key: 'inventory_value', label: 'Inventory Value', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, + { key: 'gross_profit', label: 'Gross Profit', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'current_lead_time', label: 'Current Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'target_lead_time', label: 'Target Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'lead_time_status', label: 'Lead Time Status', group: 'Lead Time' }, { key: 'last_purchase_date', label: 'Last Purchase', group: 'Lead Time' }, + { key: 'first_received_date', label: 'First Received', group: 'Lead Time' }, + { key: 'last_received_date', label: 'Last Received', group: 'Lead Time' }, ]; // Define default columns for each view @@ -93,14 +103,17 @@ const VIEW_COLUMNS: Record = { 'daily_sales_avg', 'weekly_sales_avg', 'monthly_sales_avg', + 'inventory_value', ], critical: [ 'image', 'title', 'stock_quantity', + 'safety_stock', 'daily_sales_avg', 'weekly_sales_avg', 'reorder_qty', + 'reorder_point', 'vendor', 'last_purchase_date', 'current_lead_time', @@ -109,11 +122,13 @@ const VIEW_COLUMNS: Record = { 'image', 'title', 'stock_quantity', + 'reorder_point', 'daily_sales_avg', 'weekly_sales_avg', 'reorder_qty', 'vendor', 'last_purchase_date', + 'avg_lead_time_days', ], overstocked: [ 'image', @@ -123,15 +138,19 @@ const VIEW_COLUMNS: Record = { 'weekly_sales_avg', 'overstocked_amt', 'days_of_inventory', + 'inventory_value', + 'turnover_rate', ], 'at-risk': [ 'image', 'title', 'stock_quantity', + 'safety_stock', 'daily_sales_avg', 'weekly_sales_avg', 'days_of_inventory', 'last_sale_date', + 'current_lead_time', ], new: [ 'image', @@ -141,6 +160,7 @@ const VIEW_COLUMNS: Record = { 'brand', 'price', 'regular_price', + 'first_received_date', ], healthy: [ 'image', @@ -150,6 +170,8 @@ const VIEW_COLUMNS: Record = { 'weekly_sales_avg', 'monthly_sales_avg', 'days_of_inventory', + 'gross_profit', + 'gmroi', ], };