diff --git a/manufacturing_overview/hooks.py b/manufacturing_overview/hooks.py index 0ea2ed9..347747f 100644 --- a/manufacturing_overview/hooks.py +++ b/manufacturing_overview/hooks.py @@ -15,7 +15,13 @@ required_apps = ["erpnext"] # include js, css files in header of desk.html # app_include_css = "/assets/manufacturing_overview/css/manufacturing_overview.css" -app_include_js = "manufacturing_overview.bundle.js" +app_include_js = [ + "drawer_bootstrap.bundle.js", +] + +app_include_css = [ + "manufacturing_overview_drawer.bundle.css", +] # include js, css files in header of web template # web_include_css = "/assets/manufacturing_overview/css/manufacturing_overview.css" diff --git a/manufacturing_overview/manufacturing_overview/api.py b/manufacturing_overview/manufacturing_overview/api.py index 1870993..bf57d94 100644 --- a/manufacturing_overview/manufacturing_overview/api.py +++ b/manufacturing_overview/manufacturing_overview/api.py @@ -1,130 +1,174 @@ import frappe -from functools import reduce from datetime import date - -# frappe.utils.logger.set_log_level("DEBUG") -# logger = frappe.logger("manufacturing_overview", -# allow_site=True, file_count=100000) - -# logger.debug(f"String") +from typing import Dict, List -def getDueInDays(d): - delta = d - date.today() - return delta.days +def get_due_in_days(d): + return (d - date.today()).days -def getWorkOrders(soname): - return frappe.get_all("Work Order", filters={ - 'sales_order': soname}, fields=['name']) +def format_date(d): + # keep your output format + return frappe.utils.formatdate(d, "dd.MM.yyyy") -def getAmountInWarehouses(item_code): - bins = frappe.get_all("Bin", fields=["actual_qty"], filters=[ - ["actual_qty", ">", 0], ["item_code", "=", item_code]]) - return reduce(lambda x, y: x + y, - map(lambda b: b["actual_qty"], bins), 0) - - -def shortenCustomerName(customer): - shortName = frappe.get_value( - 'Customer', customer, 'short_name') - if shortName is not None: - return shortName +def calculate_status(item): + # item.qty is still the original ordered qty here (before subtracting delivered) + if item.delivered_qty > 0 and item.delivered_qty < item.qty: + return "Partially Delivered" + elif item.delivered_qty >= item.qty: + return "Fully Delivered" + elif item.currentWarehouseQty >= item.qty: + return "In Warehouse" + elif item.work_order_qty > 0: + return "To Produce" else: - return customer + return "No Work Order" + + +def fetch_sales_order_items() -> List[dict]: + # NOTE: your WHERE uses "status = 'To Deliver and Bill'". + # In your SELECT you also include `tabSales Order`.status as parentStatus, + # but the WHERE currently filters on "status" (ambiguous). We'll be explicit. + return frappe.db.sql( + """ + SELECT + soi.name, + soi.item_name, + soi.item_group, + soi.qty, + soi.actual_qty, + soi.delivered_qty, + soi.delivery_date, + soi.item_code, + soi.work_order_qty, + soi.parent, + soi.production_plan_qty, + so.status AS parentStatus, + so.customer AS customer_name + FROM `tabSales Order Item` soi + INNER JOIN `tabSales Order` so ON soi.parent = so.name + WHERE + soi.delivered_qty < soi.qty + AND so.status = 'To Deliver and Bill' + AND soi.item_group = 'Produkte' + ORDER BY + soi.delivery_date, soi.item_code, soi.name + """, + as_dict=True, + ) + + +def fetch_customer_short_names(customers: List[str]) -> Dict[str, str]: + if not customers: + return {} + + rows = frappe.get_all( + "Customer", + filters={"name": ["in", customers]}, + fields=["name", "short_name"], + ) + out = {} + for r in rows: + # fall back to full name if short_name missing/empty + out[r["name"]] = r.get("short_name") or r["name"] + return out + + +def fetch_total_warehouse_qty(item_codes: List[str]) -> Dict[str, float]: + if not item_codes: + return {} + + # Sum actual_qty across all bins with qty > 0 for those item codes + rows = frappe.db.sql( + """ + SELECT + item_code, + SUM(actual_qty) AS total_qty + FROM `tabBin` + WHERE + actual_qty > 0 + AND item_code IN %(item_codes)s + GROUP BY item_code + """, + {"item_codes": tuple(set(item_codes))}, + as_dict=True, + ) + + return {r["item_code"]: float(r["total_qty"] or 0) for r in rows} def generateProductionOverviewCacheData(): - salesOrderItems = frappe.db.sql( - """ - SELECT - `tabSales Order Item`.name, - `tabSales Order Item`.item_name, - `tabSales Order Item`.item_group, - `tabSales Order Item`.qty, - `tabSales Order Item`.actual_qty, - `tabSales Order Item`.delivered_qty, - `tabSales Order Item`.delivery_date, - `tabSales Order Item`.item_code, - `tabSales Order Item`.work_order_qty, - `tabSales Order Item`.parent, - `tabSales Order Item`.production_plan_qty, - `tabSales Order`.status as parentStatus - FROM - `tabSales Order Item` - INNER JOIN `tabSales Order` - ON - `tabSales Order Item`.parent = `tabSales Order`.name - WHERE - delivered_qty < qty and - status = 'To Deliver and Bill' and - item_group = "Produkte" - ORDER BY - delivery_date, item_code, name - """, as_dict=1) + salesOrderItems = fetch_sales_order_items() - currentWarehouseQtyList = [] + if not salesOrderItems: + return [] - for soItem in salesOrderItems: - soItem.currentWarehouseQty = calculateCurrentWarehouseQty( - soItem.item_code, soItem.qty, currentWarehouseQtyList) + # collect uniques + item_codes = [r["item_code"] for r in salesOrderItems if r.get("item_code")] + customers = [r["customer_name"] for r in salesOrderItems if r.get("customer_name")] - soItem.totalWarehouseQty = getAmountInWarehouses(soItem.item_code) + total_wh_qty = fetch_total_warehouse_qty(item_codes) + customer_short = fetch_customer_short_names(customers) - soItem.due_in = getDueInDays(soItem.delivery_date) + # Running "allocation" per item_code (your currentWarehouseQty logic, but O(n)) + # In your old code: + # - first occurrence returns fetchedWarehouseQty + # - then subtract qty from stored qty for subsequent occurrences + remaining_after_current: Dict[str, float] = {} - soItem.customer = shortenCustomerName(frappe.get_value( - 'Sales Order', soItem.parent, 'customer')) + for r in salesOrderItems: + item_code = r.get("item_code") + if not item_code: + r["totalWarehouseQty"] = 0 + r["currentWarehouseQty"] = 0 + else: + fetched = float(total_wh_qty.get(item_code, 0)) - soItem = formatDate(soItem) - soItem = calculateStatus(soItem) - soItem.qty = soItem.qty - soItem.delivered_qty - soItem.link = '/app/sales-order/' + soItem.parent + if item_code in remaining_after_current: + # previously stored "qty" meant: remaining AFTER previous rows consumption + current_qty = remaining_after_current[item_code] + else: + # first time: show full fetched qty + current_qty = fetched + # Update remaining for next occurrence: subtract ordered qty (old behavior) + remaining_after_current[item_code] = current_qty - float(r.get("qty") or 0) + + r["totalWarehouseQty"] = fetched + r["currentWarehouseQty"] = current_qty + + # due_in uses original date (date object) + r["due_in"] = get_due_in_days(r["delivery_date"]) + + # customer short name + cust = r.get("customer_name") or "" + r["customer"] = customer_short.get(cust, cust) + + # format delivery date string + r["delivery_date"] = format_date(r["delivery_date"]) + + # compute status BEFORE qty is reduced by delivered (same as your original) + r["status"] = calculate_status(frappe._dict(r)) + + # now update qty to remaining qty (same as your original) + r["qty"] = (r.get("qty") or 0) - (r.get("delivered_qty") or 0) + + # link + r["link"] = "/app/sales-order/" + r["parent"] + + # keep output compatibility: you previously returned dicts with these keys + # (delivered_qty, customer_name etc are extra; harmless) return salesOrderItems -def calculateCurrentWarehouseQty(item_code, qty, currentWarehouseQtyList): - for warehouseQty in currentWarehouseQtyList: - - if warehouseQty['item_code'] == item_code: - returnQty = warehouseQty['qty'] - warehouseQty['qty'] = warehouseQty['qty'] - qty - return returnQty - - fetchedWarehouseQty = getAmountInWarehouses(item_code) - currentWarehouseQtyList.append( - {'item_code': item_code, 'qty': fetchedWarehouseQty - qty}) - return fetchedWarehouseQty - - -def formatDate(item): - item.delivery_date = frappe.utils.formatdate( - item.delivery_date, 'dd.MM.yyyy') - return item - - -def calculateStatus(item): - if item.delivered_qty > 0 and item.delivered_qty < item.qty: - item.status = 'Partially Delivered' - elif item.delivered_qty >= item.qty: - item.status = 'Fully Delivered' - elif item.currentWarehouseQty >= item.qty: - item.status = 'In Warehouse' - elif item.work_order_qty > 0: - item.status = 'To Produce' - else: - item.status = 'No Work Order' - return item - - @frappe.whitelist() def getSalesorderOverviewList(): - salesOrderItems = frappe.cache().get_value('production_overview', expires=True) + cache_key = "production_overview" + salesOrderItems = frappe.cache().get_value(cache_key, expires=True) if salesOrderItems is None: salesOrderItems = generateProductionOverviewCacheData() - frappe.cache().set_value('production_overview', salesOrderItems, expires_in_sec=60) + frappe.cache().set_value(cache_key, salesOrderItems, expires_in_sec=60) return salesOrderItems diff --git a/manufacturing_overview/public/css/manufacturing_overview_drawer.css b/manufacturing_overview/public/css/manufacturing_overview_drawer.css new file mode 100644 index 0000000..f9a1ad1 --- /dev/null +++ b/manufacturing_overview/public/css/manufacturing_overview_drawer.css @@ -0,0 +1,66 @@ +#mo-drawer { + position: fixed; + top: 0; + right: 0; + height: 100vh; + width: 390px; + + /* collapsed: show thin bar */ + transform: translateX(calc(100% - 12px)); + transition: transform 160ms ease; + + z-index: 1050; +} + +#mo-drawer:hover, +#mo-drawer.mo-open { + transform: translateX(0); +} + +#mo-drawer .mo-hover-bar { + position: absolute; + top: 0; + left: 0; + + width: 12px; + height: 100%; + + cursor: pointer; + + background: var(--primary, #2563eb); + opacity: 0.35; +} + +#mo-drawer:hover .mo-hover-bar { + opacity: 0.55; +} + +#mo-drawer .mo-panel { + position: absolute; + top: 0; + left: 12px; + + width: calc(100% - 12px); + height: 100%; + + background: var(--card-bg, #fff); + border-left: 1px solid var(--border-color, #e5e7eb); + box-shadow: -10px 0 28px rgba(0, 0, 0, 0.16); + + display: flex; + flex-direction: column; + overflow: hidden; +} + +#mo-drawer .mo-content { + padding: 10px; + height: 100%; + overflow: auto; +} + +/* Optional: hide on mobile */ +@media (max-width: 768px) { + #mo-drawer { + display: none; + } +} diff --git a/manufacturing_overview/public/drawer_bootstrap.bundle.js b/manufacturing_overview/public/drawer_bootstrap.bundle.js new file mode 100644 index 0000000..70abb5c --- /dev/null +++ b/manufacturing_overview/public/drawer_bootstrap.bundle.js @@ -0,0 +1,50 @@ +frappe.provide("manufacturing_overview.drawer"); + +manufacturing_overview.drawer._bundle_loaded = false; + +manufacturing_overview.drawer.ensure_drawer = function () { + if (document.getElementById("mo-drawer")) return; + + const el = document.createElement("div"); + el.id = "mo-drawer"; + el.innerHTML = ` +
+
+
+
+
Hover to load…
+
+
+
+ `; + + document.body.appendChild(el); + + const loadBundleOnce = async () => { + if (manufacturing_overview.drawer._bundle_loaded) return; + manufacturing_overview.drawer._bundle_loaded = true; + + // Lazy-load the heavy Vue bundle + await frappe.require("manufacturing_overview.bundle.js"); // bundle name, not a path + + if (manufacturing_overview.drawer.mount_vue) { + manufacturing_overview.drawer.mount_vue("#mo-vue-root"); + } else { + console.error( + "[manufacturing_overview] manufacturing_overview.bundle.js loaded, but mount function is missing." + ); + } + }; + + // Load on first hover + el.addEventListener("mouseenter", loadBundleOnce, { once: true }); + + // Click toggles pinned open/close (also loads bundle) + el.querySelector(".mo-hover-bar").addEventListener("click", async () => { + el.classList.toggle("mo-open"); + await loadBundleOnce(); + }); +}; + +// Ensure injection after desk bootstraps +frappe.after_ajax(() => manufacturing_overview.drawer.ensure_drawer()); diff --git a/manufacturing_overview/public/js/manufacturing_overview.bundle.js b/manufacturing_overview/public/js/manufacturing_overview.bundle.js deleted file mode 100644 index 91fcfad..0000000 --- a/manufacturing_overview/public/js/manufacturing_overview.bundle.js +++ /dev/null @@ -1,4 +0,0 @@ -import './manufacturing_overview_desk.vue'; -import './manufacturing_overview_page.js'; -import './manufacturing_overview_row.vue'; -import './production_plan.js'; \ No newline at end of file diff --git a/manufacturing_overview/public/js/manufacturing_overview_desk.vue b/manufacturing_overview/public/js/manufacturing_overview_desk.vue deleted file mode 100644 index 85ba85c..0000000 --- a/manufacturing_overview/public/js/manufacturing_overview_desk.vue +++ /dev/null @@ -1,88 +0,0 @@ - - - - - \ No newline at end of file diff --git a/manufacturing_overview/public/js/manufacturing_overview_drawer/drawer_bootstrap.js b/manufacturing_overview/public/js/manufacturing_overview_drawer/drawer_bootstrap.js new file mode 100644 index 0000000..f1c3ce1 --- /dev/null +++ b/manufacturing_overview/public/js/manufacturing_overview_drawer/drawer_bootstrap.js @@ -0,0 +1,52 @@ +frappe.provide("manufacturing_overview.drawer"); + +manufacturing_overview.drawer._bundle_loaded = false; + +manufacturing_overview.drawer.ensure_drawer = function () { + if (document.getElementById("mo-drawer")) return; + + const el = document.createElement("div"); + el.id = "mo-drawer"; + el.innerHTML = ` +
+
+
+
+
Hover to load…
+
+
+
+ `; + + document.body.appendChild(el); + + const loadBundleOnce = async () => { + if (manufacturing_overview.drawer._bundle_loaded) return; + manufacturing_overview.drawer._bundle_loaded = true; + + // This must exist at: /assets/manufacturing_overview/js/manufacturing_overview.bundle.js + await frappe.require("manufacturing_overview.bundle.js"); + + if (manufacturing_overview.drawer.mount_vue) { + manufacturing_overview.drawer.mount_vue("#mo-vue-root"); + } else { + console.error( + "[manufacturing_overview] Bundle loaded but mount function missing." + ); + } + }; + + // Load bundle on first hover (once) + el.addEventListener("mouseenter", loadBundleOnce, { once: true }); + + // Allow pin open/close via click on the bar + el.querySelector(".mo-hover-bar").addEventListener("click", async () => { + el.classList.toggle("mo-open"); + await loadBundleOnce(); + }); +}; + +// Ensure injection after desk bootstrap +frappe.after_ajax(() => { + manufacturing_overview.drawer.ensure_drawer(); +}); diff --git a/manufacturing_overview/public/js/manufacturing_overview_drawer/entry.js b/manufacturing_overview/public/js/manufacturing_overview_drawer/entry.js new file mode 100644 index 0000000..685c71a --- /dev/null +++ b/manufacturing_overview/public/js/manufacturing_overview_drawer/entry.js @@ -0,0 +1,14 @@ +import { createApp } from "vue"; +import ManufacturingOverviewDesk from "./manufacturing_overview_desk.vue"; + +manufacturing_overview.drawer.mount_vue = function (selector) { + const mountEl = document.querySelector(selector); + if (!mountEl) return; + + // prevent remount + if (mountEl.__mo_app__) return; + + const app = createApp(ManufacturingOverviewDesk, {}); + mountEl.__mo_app__ = app; + app.mount(mountEl); +}; diff --git a/manufacturing_overview/public/js/manufacturing_overview_drawer/manufacturing_overview_desk.vue b/manufacturing_overview/public/js/manufacturing_overview_drawer/manufacturing_overview_desk.vue new file mode 100644 index 0000000..2d06179 --- /dev/null +++ b/manufacturing_overview/public/js/manufacturing_overview_drawer/manufacturing_overview_desk.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/manufacturing_overview/public/js/manufacturing_overview_row.vue b/manufacturing_overview/public/js/manufacturing_overview_drawer/manufacturing_overview_row.vue similarity index 58% rename from manufacturing_overview/public/js/manufacturing_overview_row.vue rename to manufacturing_overview/public/js/manufacturing_overview_drawer/manufacturing_overview_row.vue index a247c19..76ef7c5 100644 --- a/manufacturing_overview/public/js/manufacturing_overview_row.vue +++ b/manufacturing_overview/public/js/manufacturing_overview_drawer/manufacturing_overview_row.vue @@ -1,28 +1,41 @@