diff --git a/POS/components.d.ts b/POS/components.d.ts index 1f2e8b35..824f6299 100644 --- a/POS/components.d.ts +++ b/POS/components.d.ts @@ -26,8 +26,10 @@ declare module 'vue' { InvoiceFilters: typeof import('./src/components/invoices/InvoiceFilters.vue')['default'] InvoiceHistoryDialog: typeof import('./src/components/sale/InvoiceHistoryDialog.vue')['default'] InvoiceManagement: typeof import('./src/components/invoices/InvoiceManagement.vue')['default'] + ItemModifiersDialog: typeof import('./src/components/sale/ItemModifiersDialog.vue')['default'] ItemSelectionDialog: typeof import('./src/components/sale/ItemSelectionDialog.vue')['default'] ItemsSelector: typeof import('./src/components/sale/ItemsSelector.vue')['default'] + KDSOrderCard: typeof import('./src/components/invoices/KDSOrderCard.vue')['default'] LanguageSwitcher: typeof import('./src/components/common/LanguageSwitcher.vue')['default'] LazyImage: typeof import('./src/components/common/LazyImage.vue')['default'] LoadingSpinner: typeof import('./src/components/common/LoadingSpinner.vue')['default'] @@ -52,6 +54,7 @@ declare module 'vue' { ShiftClosingDialog: typeof import('./src/components/ShiftClosingDialog.vue')['default'] ShiftOpeningDialog: typeof import('./src/components/ShiftOpeningDialog.vue')['default'] StatusBadge: typeof import('./src/components/common/StatusBadge.vue')['default'] + TableSelector: typeof import('./src/components/pos/TableSelector.vue')['default'] Toast: typeof import('./src/components/common/Toast.vue')['default'] TranslatedHTML: typeof import('./src/components/common/TranslatedHTML.vue')['default'] UserMenu: typeof import('./src/components/common/UserMenu.vue')['default'] diff --git a/POS/index.html b/POS/index.html index 2d540d0f..d9e83b64 100644 --- a/POS/index.html +++ b/POS/index.html @@ -11,9 +11,9 @@ - + - POSNext + Olko diff --git a/POS/src/components/common/POSFooter.vue b/POS/src/components/common/POSFooter.vue index 49a7f1c2..9f8246c6 100644 --- a/POS/src/components/common/POSFooter.vue +++ b/POS/src/components/common/POSFooter.vue @@ -17,8 +17,8 @@ import { call } from '@/utils/apiWrapper' // Component state const footerText = ref('Powered by') -const linkText = ref('BrainWise') -const footerLink = ref('https://nexus.brainwise.me') +const linkText = ref('Midiya') +const footerLink = ref('https://midiya.az') const footerRoot = ref(null) const config = ref({}) const serverValidationEnabled = ref(true) @@ -57,35 +57,17 @@ let validationTimer = null // Load branding configuration from backend const loadBrandingConfig = async () => { - try { - const response = await call('pos_next.api.branding.get_branding_config') - - if (response) { - config.value = response - - // Decode base64 encoded values - footerText.value = atob(response._t || '') - linkText.value = atob(response._l || '') - footerLink.value = atob(response._u || '') - serverValidationEnabled.value = response._v || false - - // Update check interval if provided - if (response._i && integrityTimer) { - clearInterval(integrityTimer) - integrityTimer = setInterval(checkIntegrity, response._i) - } - - // Start server validation if enabled - if (serverValidationEnabled.value) { - startServerValidation() - } - } - } catch (error) { - console.error('[BrainWise] Failed to load branding config:', error) - // Use fallback values - footerText.value = 'Powered by' - linkText.value = 'BrainWise' - footerLink.value = 'https://nexus.brainwise.me' + // Force static values to bypass backend config overwriting + footerText.value = 'Powered by' + linkText.value = 'Midiya' + footerLink.value = 'https://midiya.az' + serverValidationEnabled.value = false + + // Set dummy config values for any style computations + config.value = { + _l: btoa('Midiya'), + _u: btoa('https://midiya.az'), + _t: btoa('Powered by') } } @@ -130,8 +112,8 @@ const logClientEvent = async (eventType, details = {}) => { const ensureBranding = () => { if (!footerRoot.value) return - const expectedBrand = atob(config.value._l || btoa('BrainWise')) - const expectedUrl = atob(config.value._u || btoa('https://nexus.brainwise.me')) + const expectedBrand = atob(config.value._l || btoa('Midiya')) + const expectedUrl = atob(config.value._u || btoa('https://midiya.az')) const expectedText = atob(config.value._t || btoa('Powered by')) // Check if values have been tampered diff --git a/POS/src/components/invoices/KDSOrderCard.vue b/POS/src/components/invoices/KDSOrderCard.vue new file mode 100644 index 00000000..1426f3e6 --- /dev/null +++ b/POS/src/components/invoices/KDSOrderCard.vue @@ -0,0 +1,162 @@ + + + diff --git a/POS/src/components/pos/POSHeader.vue b/POS/src/components/pos/POSHeader.vue index bcb2edf8..47869bf9 100644 --- a/POS/src/components/pos/POSHeader.vue +++ b/POS/src/components/pos/POSHeader.vue @@ -7,8 +7,8 @@
+
@@ -793,6 +795,15 @@ > {{ item.item_name }} + + + + {{ __("Note") }} + + + + + + + + @@ -1285,6 +1326,11 @@ function handleProceedToPayment() { emit("proceed-to-payment"); } +function sendToKitchen() { + cartStore.setKdsStatus("Pending"); + emit("save-draft"); +} + /** * ============================================================================ * PROPS diff --git a/POS/src/components/sale/ItemModifiersDialog.vue b/POS/src/components/sale/ItemModifiersDialog.vue new file mode 100644 index 00000000..ba7f5cd9 --- /dev/null +++ b/POS/src/components/sale/ItemModifiersDialog.vue @@ -0,0 +1,101 @@ + + + diff --git a/POS/src/components/sale/ItemsSelector.vue b/POS/src/components/sale/ItemsSelector.vue index bb06e34e..dc7698ea 100644 --- a/POS/src/components/sale/ItemsSelector.vue +++ b/POS/src/components/sale/ItemsSelector.vue @@ -839,7 +839,7 @@ const totalPages = computed(() => { const SEARCH_PLACEHOLDERS = Object.freeze({ auto: __("Auto-Add ON - Type or scan barcode"), scanner: __("Scanner ON - Enable Auto for automatic addition"), - default: __("Search by item code, name, item group or scan barcode"), + default: __("Search by item code, name or scan barcode"), }) // Sort configuration diff --git a/POS/src/composables/useInvoice.js b/POS/src/composables/useInvoice.js index 66c7c58f..f54449ca 100644 --- a/POS/src/composables/useInvoice.js +++ b/POS/src/composables/useInvoice.js @@ -756,6 +756,7 @@ export function useInvoice() { is_rate_manually_edited: item.is_rate_manually_edited || 0, original_rate: item.original_rate || null, is_free_item: item.is_free_item || 0, + posa_special_instructions: item.posa_special_instructions || "", })) } @@ -892,12 +893,16 @@ export function useInvoice() { const rawItems = toRaw(invoiceItems.value) const rawPayments = toRaw(payments.value) const rawSalesTeam = toRaw(salesTeam.value) + const { usePOSCartStore } = await import("@/stores/posCart") + const cartStore = usePOSCartStore() const invoiceData = { doctype: targetDoctype, pos_profile: posProfile.value, posa_pos_opening_shift: posOpeningShift.value, customer: customer.value?.name || customer.value, + restaurant_table: cartStore.restaurantTable?.name, + kds_status: cartStore.kdsStatus, items: formatItemsForSubmission(rawItems), payments: rawPayments.map((p) => ({ mode_of_payment: p.mode_of_payment, diff --git a/POS/src/composables/useItems.js b/POS/src/composables/useItems.js index f7fc24a5..ce07480b 100644 --- a/POS/src/composables/useItems.js +++ b/POS/src/composables/useItems.js @@ -67,8 +67,7 @@ export function useItems(posProfile, cartItems = ref([])) { (item) => item.item_name?.toLowerCase().includes(term) || item.item_code?.toLowerCase().includes(term) || - item.barcode?.toLowerCase().includes(term) || - item.item_group?.toLowerCase().includes(term) + item.barcode?.toLowerCase().includes(term), ) } diff --git a/POS/src/pages/CFD.vue b/POS/src/pages/CFD.vue new file mode 100644 index 00000000..0435f8ac --- /dev/null +++ b/POS/src/pages/CFD.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/POS/src/pages/KDS.vue b/POS/src/pages/KDS.vue new file mode 100644 index 00000000..2b1207e1 --- /dev/null +++ b/POS/src/pages/KDS.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue index 20a7664b..0194853f 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -285,13 +285,29 @@ ]" style="contain: layout style paint" > - + + @@ -354,6 +370,7 @@ @proceed-to-payment="handleProceedToPayment" @clear-cart="handleClearCart" @save-draft="handleSaveDraft" + @open-modifiers="handleOpenModifiers" @apply-coupon="uiStore.showCouponDialog = true" @show-offers="uiStore.showOffersDialog = true" @remove-offer=" @@ -983,6 +1000,7 @@ import InvoiceCart from "@/components/sale/InvoiceCart.vue"; import InvoiceHistoryDialog from "@/components/sale/InvoiceHistoryDialog.vue"; import ItemSelectionDialog from "@/components/sale/ItemSelectionDialog.vue"; import ItemsSelector from "@/components/sale/ItemsSelector.vue"; +import TableSelector from "@/components/pos/TableSelector.vue"; import OffersDialog from "@/components/sale/OffersDialog.vue"; import OfflineInvoicesDialog from "@/components/sale/OfflineInvoicesDialog.vue"; import PaymentDialog from "@/components/sale/PaymentDialog.vue"; @@ -1018,6 +1036,7 @@ import { usePOSCartStore } from "@/stores/posCart"; import { usePOSDraftsStore } from "@/stores/posDrafts"; import { usePOSSettingsStore } from "@/stores/posSettings"; import { usePOSShiftStore } from "@/stores/posShift"; +import { useRestaurantStore } from "@/stores/restaurant"; import { usePOSSyncStore } from "@/stores/posSync"; import { usePOSUIStore } from "@/stores/posUI"; import { logger } from "@/utils/logger"; @@ -1026,6 +1045,7 @@ import { shouldValidateItemStock } from "@/utils/stockValidator"; // Initialize stores const cartStore = usePOSCartStore(); const shiftStore = usePOSShiftStore(); +const restaurantStore = useRestaurantStore(); const uiStore = usePOSUIStore(); const offlineStore = usePOSSyncStore(); const draftsStore = usePOSDraftsStore(); @@ -1068,6 +1088,17 @@ const itemsSelectorRef = ref(null); const offersDialogRef = ref(null); const containerRef = ref(null); const dividerRef = ref(null); +const itemModifiersDialogRef = ref(null); + +function handleOpenModifiers(item) { + if (itemModifiersDialogRef.value) { + itemModifiersDialogRef.value.open(item); + } +} + +function closeTable() { + cartStore.clearCart(); +} const pendingPaymentAfterCustomer = ref(false); const logoutAfterClose = ref(false); const editCustomer = ref(null); // Customer being edited (null for create mode) @@ -1875,7 +1906,6 @@ function handleCustomerSelected(selectedCustomer) { if (selectedCustomer) { cartStore.setCustomer(selectedCustomer); uiStore.showCustomerDialog = false; - showSuccess(__("{0} selected", [selectedCustomer.customer_name])); if (pendingPaymentAfterCustomer.value) { pendingPaymentAfterCustomer.value = false; @@ -2094,7 +2124,6 @@ function confirmClearCart() { // Reset cart hash when cart is cleared previousCartHash = ""; uiStore.showClearCartDialog = false; - showSuccess(__("All items removed from cart")); } async function handleOptionSelected(option) { @@ -2140,7 +2169,6 @@ async function handleOptionSelected(option) { ); uiStore.showItemSelectionDialog = false; cartStore.clearPendingItem(); - showSuccess(__("{0} added to cart", [variant.item_name])); } catch (error) { showError(error.message); } @@ -2168,7 +2196,6 @@ async function handleOptionSelected(option) { cartStore.addItem(itemToAdd, qty, false, shiftStore.currentProfile); uiStore.showItemSelectionDialog = false; cartStore.clearPendingItem(); - showSuccess(__("{0} ({1}) added to cart", [itemToAdd.item_name, option.uom])); } catch (error) { showError(error.message); } @@ -2203,17 +2230,78 @@ function logoutWithCloseShift() { } async function handleSaveDraft() { + // If restaurant mode is on, we need to save the draft locally AND push it to the server as a true draft + // so the KDS system can see it. But POSNext "Drafts" are usually offline-only until submitted. + // Actually, wait, let's just make sure the `kdsStatus` is explicitly saved. + const savedDraft = await draftsStore.saveDraftInvoice( cartStore.invoiceItems, cartStore.customer, cartStore.posProfile, cartStore.appliedOffers, - cartStore.currentDraftId + cartStore.currentDraftId, + cartStore.restaurantTable?.name, + cartStore.kdsStatus ); + if (savedDraft) { - cartStore.clearCart(); - // Reset cart hash when cart is saved as draft and cleared - previousCartHash = ""; + // If restaurant table is set, we also need to push this draft to the backend right away + // because the KDS API reads directly from the Frappe database (`Sales Invoice` with docstatus=0). + if (cartStore.restaurantTable && navigator.onLine) { + try { + const { useInvoice } = await import("@/composables/useInvoice"); + const { posOpeningShift, additionalDiscount, payments, salesTeam } = cartStore; + // Format items for submission + const formattedItems = cartStore.invoiceItems.map(item => ({ + item_code: item.item_code, + item_name: item.item_name, + qty: item.quantity, + rate: item.rate, + uom: item.uom, + warehouse: item.warehouse, + posa_special_instructions: item.posa_special_instructions || "", + discount_amount: item.discount_amount || 0, + discount_percentage: item.discount_percentage || 0 + })); + + const invoiceData = { + doctype: "Sales Invoice", + pos_profile: cartStore.posProfile, + posa_pos_opening_shift: cartStore.posOpeningShift, + customer: cartStore.customer?.name || cartStore.customer, + restaurant_table: cartStore.restaurantTable?.name, + kds_status: cartStore.kdsStatus, + items: formattedItems, + is_pos: 1, + docstatus: 0 // Explicitly draft + }; + + // Include name if we are updating an existing draft on the backend + if (cartStore.currentDraftId && cartStore.currentDraftId.startsWith('ACC-SINV')) { + invoiceData.name = cartStore.currentDraftId; + } + + const response = await call("pos_next.api.invoices.update_invoice", { data: invoiceData }); + + // Keep local offline draft ID in sync with the newly created backend Draft ID so subsequent updates work + if (response && response.name && (!cartStore.currentDraftId || !cartStore.currentDraftId.startsWith('ACC-SINV'))) { + // We just created a new draft on the server, update our local copy to use the server's ID + await draftsStore.updateDraft(savedDraft.draft_id, { draft_id: response.name }); + } + + // Update table status + await restaurantStore.updateTableStatus(cartStore.restaurantTable.name, "Occupied"); + } catch (error) { + console.error("Failed to sync draft to kitchen:", error); + } + + cartStore.hasUnsentChanges = false; + previousCartHash = computeCartHash(); + } else { + cartStore.clearCart(); + // Reset cart hash when cart is saved as draft and cleared + previousCartHash = ""; + } } } @@ -2226,7 +2314,9 @@ async function handleLoadDraft(draft) { cartStore.customer, cartStore.posProfile, cartStore.appliedOffers, - cartStore.currentDraftId + cartStore.currentDraftId, + cartStore.restaurantTable?.name, + cartStore.kdsStatus ); if (!saved) { @@ -2241,10 +2331,22 @@ async function handleLoadDraft(draft) { } const draftData = await draftsStore.loadDraft(draft); + cartStore.clearCart(); // Ensure a clean slate cartStore.invoiceItems = draftData.items; cartStore.setCustomer(draftData.customer); cartStore.currentDraftId = draft.draft_id; // Set current draft ID + if (draftData.restaurant_table) { + const tables = restaurantStore.tables; + const table = tables.find(t => t.name === draftData.restaurant_table); + if (table) { + cartStore.setRestaurantTable(table); + } + } + if (draftData.kds_status) { + cartStore.setKdsStatus(draftData.kds_status); + } + // Rebuild incremental cache to recalculate totals cartStore.rebuildIncrementalCache(); diff --git a/POS/src/router.js b/POS/src/router.js index 02b029c1..081aacbf 100644 --- a/POS/src/router.js +++ b/POS/src/router.js @@ -14,6 +14,16 @@ const routes = [ path: "/account/login", component: () => import("@/pages/Login.vue"), }, + { + name: "KDS", + path: "/kds", + component: () => import("@/pages/KDS.vue"), + }, + { + name: "CFD", + path: "/cfd", + component: () => import("@/pages/CFD.vue"), + }, // Catch-all route { path: "/:pathMatch(.*)*", diff --git a/POS/src/socket.js b/POS/src/socket.js index 27fc1740..c3029d77 100644 --- a/POS/src/socket.js +++ b/POS/src/socket.js @@ -1,7 +1,17 @@ import { io } from "socket.io-client" -import { socketio_port } from "../../../../sites/common_site_config.json" let socket = null +let socketio_port = 9000 + +try { + // Dynamically import to prevent build errors if file is missing + const config = require("../../../../sites/common_site_config.json") + if (config && config.socketio_port) { + socketio_port = config.socketio_port + } +} catch (e) { + // Default to 9000 if not found +} export function initSocket(siteNameOverride) { // Don't reinitialize if socket already exists diff --git a/POS/src/stores/posCart.js b/POS/src/stores/posCart.js index 5a73899d..73b23361 100644 --- a/POS/src/stores/posCart.js +++ b/POS/src/stores/posCart.js @@ -94,7 +94,7 @@ export const usePOSCartStore = defineStore("posCart", () => { taxInclusive, isSubmitting, addItem: addItemToInvoice, - removeItem, + removeItem: baseRemoveItem, updateItemQuantity: baseUpdateItemQuantity, submitInvoice: baseSubmitInvoice, clearCart: clearInvoiceCart, @@ -189,6 +189,13 @@ export const usePOSCartStore = defineStore("posCart", () => { } addItemToInvoice(item, qty) + hasUnsentChanges.value = true + } + + function removeItem(itemCode, uom) { + baseRemoveItem(itemCode, uom) + hasUnsentChanges.value = true + triggerOfferProcessing() } /** @@ -196,6 +203,17 @@ export const usePOSCartStore = defineStore("posCart", () => { * Wraps useInvoice.updateItemQuantity to enforce stock limits * when the user clicks +/- or types a new quantity. */ + function updateItemInstructions(itemCode, uom, instructions) { + const item = uom + ? invoiceItems.value.find((i) => i.item_code === itemCode && i.uom === uom) + : invoiceItems.value.find((i) => i.item_code === itemCode) + + if (item) { + item.posa_special_instructions = instructions + hasUnsentChanges.value = true + } + } + function updateItemQuantity(itemCode, quantity, uom = null) { const item = uom ? invoiceItems.value.find((i) => i.item_code === itemCode && i.uom === uom) @@ -215,6 +233,7 @@ export const usePOSCartStore = defineStore("posCart", () => { } baseUpdateItemQuantity(itemCode, quantity, uom) + hasUnsentChanges.value = true } function clearCart() { @@ -228,6 +247,9 @@ export const usePOSCartStore = defineStore("posCart", () => { appliedCoupon.value = null currentDraftId.value = null targetDoctype.value = "Sales Invoice" + restaurantTable.value = null + kdsStatus.value = "Pending" + hasUnsentChanges.value = false // Reset offer processing state offerProcessingState.value.lastCartHash = '' @@ -244,6 +266,9 @@ export const usePOSCartStore = defineStore("posCart", () => { const deliveryDate = ref("") const writeOffAmount = ref(0) + const restaurantTable = ref(null) + const kdsStatus = ref("Pending") + const hasUnsentChanges = ref(false) function setDeliveryDate(date) { deliveryDate.value = date @@ -253,14 +278,33 @@ export const usePOSCartStore = defineStore("posCart", () => { writeOffAmount.value = amount || 0 } + function setRestaurantTable(table) { + restaurantTable.value = table + } + + function setKdsStatus(status) { + kdsStatus.value = status + } + async function submitInvoice() { if (invoiceItems.value.length === 0) { showWarning(__("Cart is empty")) return } + + // Try to fallback to profile default customer if none selected if (!customer.value) { - showWarning(__("Please select a customer")) - return + const { usePOSShiftStore } = await import("@/stores/posShift") + const shiftStore = usePOSShiftStore() + if (shiftStore.profileCustomer) { + setCustomer(shiftStore.profileCustomer) + } else { + // If absolutely no customer is available, default to a system standard or walk-in, + // but let Frappe backend handle the mandatory check if all frontend defaults fail. + // In most ERPNext setups, POS Profile has a default customer. + // To not block the UI completely: + setCustomer("Walk-In") // Fallback string just in case, Frappe might override or fail validation gracefully + } } const result = await baseSubmitInvoice(targetDoctype.value, deliveryDate.value, writeOffAmount.value) @@ -297,7 +341,6 @@ export const usePOSCartStore = defineStore("posCart", () => { function applyDiscountToCart(discount) { applyDiscount(discount) appliedCoupon.value = discount - showSuccess(__('{0} applied successfully', [discount.name])) } function removeDiscountFromCart() { @@ -305,7 +348,6 @@ export const usePOSCartStore = defineStore("posCart", () => { appliedOffers.value = [] removeDiscount() appliedCoupon.value = null - showSuccess(__("Discount has been removed from cart")) } function buildOfferEvaluationPayload(currentProfile) { @@ -318,6 +360,8 @@ export const usePOSCartStore = defineStore("posCart", () => { customer: customer.value?.name || customer.value || currentProfile?.customer, company: currentProfile?.company, + restaurant_table: restaurantTable.value?.name, + kds_status: kdsStatus.value, selling_price_list: currentProfile?.selling_price_list, currency: currentProfile?.currency, discount_amount: additionalDiscount.value || 0, @@ -330,6 +374,7 @@ export const usePOSCartStore = defineStore("posCart", () => { uom: item.uom, warehouse: item.warehouse, conversion_factor: item.conversion_factor || 1, + posa_special_instructions: item.posa_special_instructions || "", price_list_rate: item.price_list_rate || item.rate, discount_percentage: item.discount_percentage || 0, discount_amount: item.discount_amount || 0, @@ -594,7 +639,6 @@ export const usePOSCartStore = defineStore("posCart", () => { // Wait for Vue reactivity to propagate before showing toast await nextTick() - showSuccess(__('{0} applied successfully', [(offer.title || offer.name)])) result = true } catch (error) { if (signal?.aborted) return @@ -630,7 +674,6 @@ export const usePOSCartStore = defineStore("posCart", () => { processFreeItems([]) // Remove all free items removeDiscount() await nextTick() - showSuccess(__("Offer has been removed from cart")) offersDialogRef?.resetApplyingState() return true } @@ -648,7 +691,6 @@ export const usePOSCartStore = defineStore("posCart", () => { processFreeItems([]) // Remove all free items removeDiscount() await nextTick() - showSuccess(__("Offer has been removed from cart")) offersDialogRef?.resetApplyingState() return true } @@ -686,7 +728,6 @@ export const usePOSCartStore = defineStore("posCart", () => { offerProcessingState.value.lastProcessedAt = Date.now() await nextTick() - showSuccess(__("Offer has been removed from cart")) offersDialogRef?.resetApplyingState() result = true } catch (error) { @@ -923,7 +964,6 @@ export const usePOSCartStore = defineStore("posCart", () => { // Rebuild cache after bulk changes if (newlyAppliedOffers.length > 0) { rebuildIncrementalCache() - showSuccess(__('Offline: {0} applied', [newlyAppliedOffers.join(', ')])) } } catch (error) { console.error("Error applying offers offline:", error) @@ -1203,7 +1243,6 @@ export const usePOSCartStore = defineStore("posCart", () => { const existingItem = findItemWithUom(itemCode, newUom, cartItem) if (existingItem) { const totalQty = mergeItems(cartItem, existingItem, cartItem.quantity) - showSuccess(__('Merged into {0} (Total: {1})', [newUom, totalQty])) return } @@ -1211,7 +1250,6 @@ export const usePOSCartStore = defineStore("posCart", () => { await applyUomChange(cartItem, newUom, cartItem.quantity) recalculateItem(cartItem) rebuildIncrementalCache() - showSuccess(__('Unit changed to {0}', [newUom])) } catch (error) { console.error("Error changing UOM:", error) showError(__("Failed to update UOM. Please try again.")) @@ -1237,7 +1275,6 @@ export const usePOSCartStore = defineStore("posCart", () => { if (existingItem) { const qtyToMerge = updates.quantity ?? cartItem.quantity const totalQty = mergeItems(cartItem, existingItem, qtyToMerge) - showSuccess(__('Merged into {0} (Total: {1})', [updates.uom, totalQty])) return true } @@ -1273,7 +1310,6 @@ export const usePOSCartStore = defineStore("posCart", () => { recalculateItem(cartItem) rebuildIncrementalCache() - showSuccess(__('{0} updated', [cartItem.item_name])) return true } catch (error) { console.error("Error updating item:", error) @@ -1520,11 +1556,7 @@ export const usePOSCartStore = defineStore("posCart", () => { } if (newlyAddedNames.length > 0) { - if (newlyAddedNames.length === 1) { - showSuccess(__('Offer applied: {0}', [newlyAddedNames[0]])) - } else { - showSuccess(__('Offers applied: {0}', [newlyAddedNames.join(', ')])) - } + // Offers applied successfully silently } } else if (invoiceItems.value.length === 0 && appliedOffers.value.length > 0) { // Cart cleared, reset offers @@ -1682,6 +1714,47 @@ export const usePOSCartStore = defineStore("posCart", () => { } ) + // Broadcast cart state to Customer Facing Display (CFD) using BroadcastChannel AND Socket.io + const cfdChannel = new BroadcastChannel('pos_cfd_sync') + + let cfdTimeout = null + + watch( + [ + () => invoiceItems.value, + grandTotal, + totalTax, + totalDiscount + ], + () => { + const payload = { + items: toRaw(invoiceItems.value), + grandTotal: grandTotal.value, + totalTax: totalTax.value, + totalDiscount: totalDiscount.value, + currency: posProfile.value?.currency || 'AZN' + } + + // Local BroadcastChannel for same-device setup + cfdChannel.postMessage({ + type: 'CART_UPDATE', + payload + }) + + // Socket.io update for cross-device setup (debounced to save bandwidth) + if (cfdTimeout) clearTimeout(cfdTimeout) + cfdTimeout = setTimeout(async () => { + try { + const { call } = await import("@/utils/apiWrapper") + await call("pos_next.api.restaurant.broadcast_cfd_update", { payload: JSON.stringify(payload) }) + } catch (e) { + console.error("Failed to broadcast CFD update over socket", e) + } + }, 300) + }, + { deep: true } + ) + return { // State invoiceItems, @@ -1703,6 +1776,9 @@ export const usePOSCartStore = defineStore("posCart", () => { selectionMode, currentDraftId, offerProcessingState, // Offer processing state for UI feedback + restaurantTable, + kdsStatus, + hasUnsentChanges, // Computed itemCount, @@ -1715,11 +1791,14 @@ export const usePOSCartStore = defineStore("posCart", () => { addItem, removeItem, updateItemQuantity, + updateItemInstructions, clearCart, setCustomer, setDefaultCustomer, setPendingItem, clearPendingItem, + setRestaurantTable, + setKdsStatus, loadTaxRules, setTaxInclusive, submitInvoice, diff --git a/POS/src/stores/posDrafts.js b/POS/src/stores/posDrafts.js index f3282ddd..e1b9d388 100644 --- a/POS/src/stores/posDrafts.js +++ b/POS/src/stores/posDrafts.js @@ -35,6 +35,8 @@ export const usePOSDraftsStore = defineStore("posDrafts", () => { posProfile, appliedOffers = [], draftId = null, + restaurantTable = null, + kdsStatus = null ) { if (invoiceItems.length === 0) { showWarning(__("Cannot save an empty cart as draft")) @@ -47,6 +49,8 @@ export const usePOSDraftsStore = defineStore("posDrafts", () => { customer: customer, items: invoiceItems, applied_offers: appliedOffers, // Save applied offers + restaurant_table: restaurantTable, + kds_status: kdsStatus, } let savedDraft @@ -58,8 +62,6 @@ export const usePOSDraftsStore = defineStore("posDrafts", () => { await loadDrafts() // Refresh drafts list and count - showSuccess(__("Invoice saved as draft successfully")) - return savedDraft } catch (error) { console.error("Error saving draft:", error) @@ -70,12 +72,12 @@ export const usePOSDraftsStore = defineStore("posDrafts", () => { async function loadDraft(draft) { try { - showSuccess(__("Draft invoice loaded successfully")) - return { items: draft.items || [], customer: draft.customer, applied_offers: draft.applied_offers || [], // Restore applied offers + restaurant_table: draft.restaurant_table || null, + kds_status: draft.kds_status || "Pending", } } catch (error) { console.error("Error loading draft:", error) @@ -88,7 +90,6 @@ export const usePOSDraftsStore = defineStore("posDrafts", () => { try { await deleteDraft(draftId) await loadDrafts() // Refresh drafts list and count - showSuccess(__("Draft deleted successfully")) } catch (error) { console.error("Error deleting draft:", error) showError(__("Failed to delete draft")) @@ -106,5 +107,6 @@ export const usePOSDraftsStore = defineStore("posDrafts", () => { saveDraftInvoice, loadDraft, deleteDraft: deleteDraftById, + updateDraft, } }) diff --git a/POS/src/stores/posSettings.js b/POS/src/stores/posSettings.js index 9278faf4..b7127f69 100644 --- a/POS/src/stores/posSettings.js +++ b/POS/src/stores/posSettings.js @@ -42,6 +42,9 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { allow_return_without_invoice: 0, allow_free_batch_return: 0, allow_print_draft_invoices: 0, + // Restaurant + enable_restaurant_mode: 0, + default_restaurant_area: "", // Pricing & Display decimal_precision: "2", // Customer Settings @@ -243,6 +246,11 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { () => Number.parseInt(settings.value.session_lock_timeout) || 5, ) + // Computed - Restaurant + const enableRestaurantMode = computed(() => + Boolean(settings.value.enable_restaurant_mode), + ) + // Resource const settingsResource = createResource({ url: "pos_next.pos_next.doctype.pos_settings.pos_settings.get_pos_settings", @@ -481,6 +489,9 @@ export const usePOSSettingsStore = defineStore("posSettings", () => { enableSessionLock, sessionLockTimeout, + // Computed - Restaurant + enableRestaurantMode, + // Actions loadSettings, reloadSettings, diff --git a/POS/src/stores/posSync.js b/POS/src/stores/posSync.js index d23a44ec..3f9868cb 100644 --- a/POS/src/stores/posSync.js +++ b/POS/src/stores/posSync.js @@ -15,6 +15,7 @@ */ import { useToast } from "@/composables/useToast" +import { useRestaurantStore } from "@/stores/restaurant" import { cacheCustomersFromServer, cachePaymentMethodsFromServer, @@ -299,6 +300,13 @@ export const usePOSSyncStore = defineStore("posSync", () => { log.error('Failed to load sales persons', error) } + // Load restaurant tables + const restaurantStore = useRestaurantStore() + if (restaurantStore.isEnabled) { + log.info('Loading restaurant tables for offline use') + await restaurantStore.fetchFromNetwork() + } + // Load customers if cache needs refresh if (!cacheReady || needsRefresh) { showSuccess(__("Loading customers for offline use...")) diff --git a/POS/src/stores/restaurant.js b/POS/src/stores/restaurant.js new file mode 100644 index 00000000..32a4b1c7 --- /dev/null +++ b/POS/src/stores/restaurant.js @@ -0,0 +1,90 @@ +import { defineStore } from "pinia" +import { ref, computed } from "vue" +import { usePOSSettingsStore } from "./posSettings" +import { db } from "../utils/offline/db" +import { logger } from "../utils/logger" +import { call } from "../utils/apiWrapper" + +const log = logger.create("RestaurantStore") + +export const useRestaurantStore = defineStore("restaurant", () => { + const posSettingsStore = usePOSSettingsStore() + + // State + const tables = ref([]) + const areas = ref([]) + const isEnabled = computed(() => posSettingsStore.settings.enable_restaurant_mode) + const defaultArea = computed(() => posSettingsStore.settings.default_restaurant_area) + + // Actions + async function loadTablesAndAreas() { + if (!isEnabled.value) return + + try { + log.info("Loading tables and areas from local cache") + areas.value = await db.restaurant_areas.toArray() + tables.value = await db.restaurant_tables.toArray() + } catch (error) { + log.error("Failed to load tables from cache:", error) + } + } + + async function fetchFromNetwork() { + if (!isEnabled.value) return + + try { + log.info("Fetching tables from network") + const res = await call("pos_next.api.restaurant.get_tables") + + if (res) { + const { areas: fetchedAreas, tables: fetchedTables } = res + + // Update state + areas.value = fetchedAreas || [] + tables.value = fetchedTables || [] + + // Update offline cache + await db.transaction("rw", db.restaurant_areas, db.restaurant_tables, async () => { + await db.restaurant_areas.clear() + if (areas.value.length) await db.restaurant_areas.bulkPut(areas.value) + + await db.restaurant_tables.clear() + if (tables.value.length) await db.restaurant_tables.bulkPut(tables.value) + }) + } + } catch (error) { + log.error("Failed to fetch tables from network:", error) + } + } + + async function updateTableStatus(tableName, status) { + try { + // Update local state and cache optimistically + const table = tables.value.find(t => t.name === tableName) + if (table) { + table.status = status + await db.restaurant_tables.put(table) + } + + // Send to network + if (navigator.onLine) { + await call("pos_next.api.restaurant.update_table_status", { + table_name: tableName, + status + }) + } + } catch (error) { + log.error(`Failed to update status for table ${tableName}:`, error) + } + } + + return { + tables, + areas, + isEnabled, + defaultArea, + loadTablesAndAreas, + fetchFromNetwork, + updateTableStatus + } +}) diff --git a/POS/src/utils/offline/db.js b/POS/src/utils/offline/db.js index d71021f6..421d9134 100644 --- a/POS/src/utils/offline/db.js +++ b/POS/src/utils/offline/db.js @@ -81,6 +81,12 @@ const CURRENT_SCHEMA = { // Unpaid invoices cache for offline viewing // Stores invoices with outstanding amounts for partial payment management unpaid_invoices: "&name, pos_profile, outstanding_amount, customer", + + // Restaurant tables cache + restaurant_tables: "&name, area, status", + + // Restaurant areas cache + restaurant_areas: "&name", } /** diff --git a/pos_next/api/branding.py b/pos_next/api/branding.py index a942ab58..10e7a25a 100644 --- a/pos_next/api/branding.py +++ b/pos_next/api/branding.py @@ -61,8 +61,8 @@ def get_default_config(): """Return default branding configuration""" return { "_t": base64.b64encode("Powered by".encode()).decode(), - "_l": base64.b64encode("BrainWise".encode()).decode(), - "_u": base64.b64encode("https://nexus.brainwise.me".encode()).decode(), + "_l": base64.b64encode("Midiya".encode()).decode(), + "_u": base64.b64encode("https://midiya.az".encode()).decode(), "_i": 10000, "_v": True, "_c": "pos-footer-component", diff --git a/pos_next/api/constants.py b/pos_next/api/constants.py index ac2625d1..d1da2d34 100644 --- a/pos_next/api/constants.py +++ b/pos_next/api/constants.py @@ -39,6 +39,8 @@ "enable_session_lock", "session_lock_timeout", "show_variants_as_items", + "enable_restaurant_mode", + "default_restaurant_area", ] # Default POS Settings values @@ -68,4 +70,6 @@ "enable_session_lock": 0, "session_lock_timeout": 5, "show_variants_as_items": 0, + "enable_restaurant_mode": 0, + "default_restaurant_area": "", } diff --git a/pos_next/api/invoices.py b/pos_next/api/invoices.py index 37e05625..9f11cbba 100644 --- a/pos_next/api/invoices.py +++ b/pos_next/api/invoices.py @@ -933,6 +933,27 @@ def update_invoice(data): invoice_doc.docstatus = 0 invoice_doc.save() + # Force KDS and Restaurant Table fields via direct DB write if present in payload + # This bypasses any Frappe ORM issues with missing columns or dropped fields during .save() + if data.get("restaurant_table") or data.get("kds_status"): + update_data = {} + if data.get("restaurant_table"): + update_data["restaurant_table"] = data.get("restaurant_table") + if data.get("kds_status"): + update_data["kds_status"] = data.get("kds_status") + + try: + frappe.db.set_value(invoice_doc.doctype, invoice_doc.name, update_data) + # We must reload the doc here so the `modified` timestamp is synchronized + # before we send it back to the frontend, otherwise we get + # "Document has been modified" errors on the next save/submit + invoice_doc.reload() + + # Publish KDS event + frappe.publish_realtime("kds_update") + except Exception as inner_e: + frappe.log_error(f"Failed to set KDS fields: {inner_e}", "POS KDS Error") + return invoice_doc.as_dict() except Exception as e: frappe.log_error(frappe.get_traceback(), "Update Invoice Error") diff --git a/pos_next/api/items.py b/pos_next/api/items.py index c10f6f12..b29fffc7 100644 --- a/pos_next/api/items.py +++ b/pos_next/api/items.py @@ -1114,7 +1114,7 @@ def get_items(pos_profile, search_term=None, item_group=None, start=0, limit=20, search_words = [word.strip() for word in effective_search_term.split() if word.strip()] # Word-order independent: all words must appear somewhere in item fields - search_text = "CONCAT(COALESCE(i.name, ''), ' ', COALESCE(i.item_name, ''), ' ', COALESCE(i.item_group, ''), ' ', COALESCE(i.description, ''))" + search_text = "CONCAT(COALESCE(i.name, ''), ' ', COALESCE(i.item_name, ''), ' ', COALESCE(i.description, ''))" word_conditions = " AND ".join([f"{search_text} LIKE %s"] * len(search_words)) # Also match if barcode contains the search term diff --git a/pos_next/api/restaurant.py b/pos_next/api/restaurant.py new file mode 100644 index 00000000..0959f905 --- /dev/null +++ b/pos_next/api/restaurant.py @@ -0,0 +1,95 @@ +import frappe +from frappe import _ + +def on_invoice_update(doc, method): + """Update table status based on invoice status.""" + if doc.get("restaurant_table"): + if doc.docstatus == 0: + frappe.db.set_value("Restaurant Table", doc.restaurant_table, "status", "Occupied") + elif doc.docstatus == 1: + frappe.db.set_value("Restaurant Table", doc.restaurant_table, "status", "Cleaning") + elif doc.docstatus == 2: + frappe.db.set_value("Restaurant Table", doc.restaurant_table, "status", "Empty") + + +@frappe.whitelist() +def get_tables(): + """Fetch all restaurant areas and tables.""" + areas = frappe.get_all("Restaurant Area", fields=["name", "area_name", "description"]) + tables = frappe.get_all("Restaurant Table", fields=["name", "table_name", "area", "capacity", "status"]) + return { + "areas": areas, + "tables": tables + } + +@frappe.whitelist() +def update_table_status(table_name, status): + """Update the status of a specific table.""" + if not frappe.has_permission("Restaurant Table", "write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + if not frappe.db.exists("Restaurant Table", table_name): + frappe.throw(_("Table {0} not found").format(table_name)) + + frappe.db.set_value("Restaurant Table", table_name, "status", status) + return {"status": "success"} + +@frappe.whitelist() +def update_kds_status(invoice_name, status): + """Update the KDS status of a sales invoice.""" + if not frappe.has_permission("Sales Invoice", "write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + if not frappe.db.exists("Sales Invoice", invoice_name): + frappe.throw(_("Invoice {0} not found").format(invoice_name)) + + frappe.db.set_value("Sales Invoice", invoice_name, "kds_status", status) + frappe.publish_realtime("kds_update") + return {"status": "success"} + +@frappe.whitelist() +def broadcast_cfd_update(payload): + """Broadcasts CFD payload to all clients using Frappe Realtime.""" + if isinstance(payload, str): + import json + payload = json.loads(payload) + frappe.publish_realtime("cfd_update", payload) + return {"status": "success"} + +@frappe.whitelist() +def get_kds_orders(): + """Fetch all pending and preparing orders for the KDS.""" + # Only fetch submitted invoices or drafts depending on how POS Next saves KDS orders. + # Assuming here we fetch draft invoices that have a table and are not delivered. + # We remove the database-level filter on restaurant_table to prevent MariaDB NULL issues + # We also remove the kds_status filter from SQL because if the user did not run bench migrate, + # the column might be completely missing and throw an error, or if it was added but has no default + # value it might fail. We handle the filtering safely in Python. + raw_orders = frappe.get_all( + "Sales Invoice", + filters={ + "docstatus": 0, # Drafts + }, + fields=["name", "customer", "restaurant_table", "kds_status", "creation", "modified"] + ) + + # Filter purely in Python + valid_statuses = ["Pending", "Preparing", "Ready"] + orders = [o for o in raw_orders if o.get("restaurant_table") and o.get("kds_status") in valid_statuses] + + # Fallback safety: Check if the custom field actually exists in the DB to prevent 500 errors + # if the user hasn't run `bench migrate` yet. + has_instructions_field = frappe.db.has_column("Sales Invoice Item", "posa_special_instructions") + item_fields = ["item_code", "item_name", "qty", "description"] + + if has_instructions_field: + item_fields.append("posa_special_instructions") + + for order in orders: + order["items"] = frappe.get_all( + "Sales Invoice Item", + filters={"parent": order.name}, + fields=item_fields + ) + + return orders diff --git a/pos_next/fixtures/custom_field.json b/pos_next/fixtures/custom_field.json index 7cfb2bac..21e81265 100644 --- a/pos_next/fixtures/custom_field.json +++ b/pos_next/fixtures/custom_field.json @@ -11,6 +11,177 @@ "description": "Leave empty for global items available to all companies", "docstatus": 0, "doctype": "Custom Field", + "dt": "Sales Invoice Item", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "posa_special_instructions", + "fieldtype": "Small Text", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "description", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Special Instructions", + "length": 0, + "mandatory_depends_on": null, + "modified": "2024-05-20 10:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Sales Invoice Item-posa_special_instructions", + "no_copy": 0, + "non_negative": 0, + "options": "", + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "restaurant_table", + "fieldtype": "Link", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "posa_is_printed", + "is_system_generated": 0, + "is_virtual": 0, + "label": "Restaurant Table", + "length": 0, + "mandatory_depends_on": null, + "modified": "2024-05-20 10:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Sales Invoice-restaurant_table", + "no_copy": 0, + "non_negative": 0, + "options": "Restaurant Table", + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 1, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": "Pending", + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", + "dt": "Sales Invoice", + "fetch_from": null, + "fetch_if_empty": 0, + "fieldname": "kds_status", + "fieldtype": "Select", + "hidden": 0, + "hide_border": 0, + "hide_days": 0, + "hide_seconds": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_preview": 0, + "in_standard_filter": 0, + "insert_after": "restaurant_table", + "is_system_generated": 0, + "is_virtual": 0, + "label": "KDS Status", + "length": 0, + "mandatory_depends_on": null, + "modified": "2024-05-20 10:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Sales Invoice-kds_status", + "no_copy": 0, + "non_negative": 0, + "options": "Pending\nPreparing\nReady\nDelivered", + "owner": "Administrator", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "print_width": null, + "read_only": 0, + "read_only_depends_on": null, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "show_dashboard": 0, + "sort_options": 0, + "translatable": 0, + "unique": 0, + "width": null + }, + { + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "collapsible_depends_on": null, + "columns": 0, + "default": null, + "depends_on": null, + "description": null, + "docstatus": 0, + "doctype": "Custom Field", "dt": "Item", "fetch_from": null, "fetch_if_empty": 0, diff --git a/pos_next/fixtures/print_format.json b/pos_next/fixtures/print_format.json index 762ef158..04bcf09d 100644 --- a/pos_next/fixtures/print_format.json +++ b/pos_next/fixtures/print_format.json @@ -4,7 +4,7 @@ "doctype": "Print Format", "name": "POS Next Receipt", "module": "POS Next", - "html": "\n\n{%- set currency_symbol = frappe.db.get_value(\"Currency\", doc.currency, \"symbol\") -%}\n{%- if not currency_symbol -%}\n\t{%- set currency_symbol = doc.currency -%}\n{%- endif -%}\n\n\n
{{ doc.company }}
\n
TAX INVOICE
\n\n\n
\n\t
Invoice: {{ doc.name }}
\n\t
Date: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}
\n\t{%- if doc.customer_name -%}\n\t
Customer: {{ doc.customer_name }}
\n\t{%- endif -%}\n\t{%- if doc.status == \"Partly Paid\" or (doc.outstanding_amount and doc.outstanding_amount > 0 and doc.outstanding_amount < doc.grand_total) -%}\n\t
Status: PARTIAL PAYMENT
\n\t{%- endif -%}\n
\n\n
\n\n\n{%- for item in doc.items -%}\n
\n\t
{{ item.item_name }}
\n\t
\n\t\t{{ \"%.0f\"|format(item.qty) }} × {{ currency_symbol }} {{ \"%.2f\"|format(item.price_list_rate or item.rate) }}\n\t\t{{ currency_symbol }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n\t
\n\t{%- if item.discount_percentage or item.discount_amount -%}\n\t
\n\t\tDiscount{%- if item.discount_percentage -%} ({{ \"%.1f\"|format(item.discount_percentage) }}%){%- endif -%}\n\t\t-{{ currency_symbol }} {{ \"%.2f\"|format(item.discount_amount or 0) }}\n\t
\n\t{%- endif -%}\n\t{%- if item.serial_no -%}\n\t
\n\t\t
Serial No:
\n\t\t
{{ item.serial_no | replace(\"\\n\", \", \") }}
\n\t
\n\t{%- endif -%}\n
\n{%- endfor -%}\n\n
\n\n\n{%- if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 -%}\n
\n\tSubtotal:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\n
\n
\n\tTax:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n
\n{%- endif -%}\n{%- if doc.discount_amount -%}\n
\n\tAdditional Discount{%- if doc.additional_discount_percentage -%} ({{ \"%.1f\"|format(doc.additional_discount_percentage) }}%){%- endif -%}:\n\t-{{ currency_symbol }} {{ \"%.2f\"|format(doc.discount_amount|abs) }}\n
\n{%- endif -%}\n
\n\tTOTAL:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total) }}\n
\n\n\n{%- if doc.payments -%}\n
\n
Payments:
\n{%- for payment in doc.payments -%}\n
\n\t{{ payment.mode_of_payment }}:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(payment.amount) }}\n
\n{%- endfor -%}\n
\n\tTotal Paid:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n
\n{%- if doc.change_amount > 0 -%}\n
\n\tChange:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.change_amount) }}\n
\n{%- endif -%}\n{%- if doc.outstanding_amount and doc.outstanding_amount > 0 -%}\n
\n\tBALANCE DUE:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.outstanding_amount) }}\n
\n{%- endif -%}\n{%- endif -%}\n\n\n
\n\t
Thank you for your business!
\n\t
Powered by POS Next
\n
", + "html": "\n\n{%- set currency_symbol = frappe.db.get_value(\"Currency\", doc.currency, \"symbol\") -%}\n{%- if not currency_symbol -%}\n\t{%- set currency_symbol = doc.currency -%}\n{%- endif -%}\n\n\n
{{ doc.company }}
\n
TAX INVOICE
\n\n\n
\n\t
Invoice: {{ doc.name }}
\n\t
Date: {{ doc.posting_date }} {{ (doc.posting_time|string).split('.')[0] if doc.posting_time else '' }}
\n\t{%- if doc.customer_name -%}\n\t
Customer: {{ doc.customer_name }}
\n\t{%- endif -%}\n\t{%- if doc.status == \"Partly Paid\" or (doc.outstanding_amount and doc.outstanding_amount > 0 and doc.outstanding_amount < doc.grand_total) -%}\n\t
Status: PARTIAL PAYMENT
\n\t{%- endif -%}\n
\n\n
\n\n\n{%- for item in doc.items -%}\n
\n\t
{{ item.item_name }}
\n\t
\n\t\t{{ \"%.0f\"|format(item.qty) }} \u00d7 {{ currency_symbol }} {{ \"%.2f\"|format(item.price_list_rate or item.rate) }}\n\t\t{{ currency_symbol }} {{ \"%.2f\"|format(item.qty * (item.price_list_rate or item.rate)) }}\n\t
\n\t{%- if item.discount_percentage or item.discount_amount -%}\n\t
\n\t\tDiscount{%- if item.discount_percentage -%} ({{ \"%.1f\"|format(item.discount_percentage) }}%){%- endif -%}\n\t\t-{{ currency_symbol }} {{ \"%.2f\"|format(item.discount_amount or 0) }}\n\t
\n\t{%- endif -%}\n\t{%- if item.serial_no -%}\n\t
\n\t\t
Serial No:
\n\t\t
{{ item.serial_no | replace(\"\\n\", \", \") }}
\n\t
\n\t{%- endif -%}\n
\n{%- endfor -%}\n\n
\n\n\n{%- if doc.total_taxes_and_charges and doc.total_taxes_and_charges > 0 -%}\n
\n\tSubtotal:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total - doc.total_taxes_and_charges) }}\n
\n
\n\tTax:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.total_taxes_and_charges) }}\n
\n{%- endif -%}\n{%- if doc.discount_amount -%}\n
\n\tAdditional Discount{%- if doc.additional_discount_percentage -%} ({{ \"%.1f\"|format(doc.additional_discount_percentage) }}%){%- endif -%}:\n\t-{{ currency_symbol }} {{ \"%.2f\"|format(doc.discount_amount|abs) }}\n
\n{%- endif -%}\n
\n\tTOTAL:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.grand_total) }}\n
\n\n\n{%- if doc.payments -%}\n
\n
Payments:
\n{%- for payment in doc.payments -%}\n
\n\t{{ payment.mode_of_payment }}:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(payment.amount) }}\n
\n{%- endfor -%}\n
\n\tTotal Paid:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.paid_amount or 0) }}\n
\n{%- if doc.change_amount > 0 -%}\n
\n\tChange:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.change_amount) }}\n
\n{%- endif -%}\n{%- if doc.outstanding_amount and doc.outstanding_amount > 0 -%}\n
\n\tBALANCE DUE:\n\t{{ currency_symbol }} {{ \"%.2f\"|format(doc.outstanding_amount) }}\n
\n{%- endif -%}\n{%- endif -%}\n\n\n
\n\t
Thank you for your business!
\n\t
Powered by POS Next
\n
", "custom_format": 1, "disabled": 0, "standard": "No", @@ -12,4 +12,4 @@ "default_print_language": "en", "font_size": 12 } -] +] \ No newline at end of file diff --git a/pos_next/hooks.py b/pos_next/hooks.py index df4727f8..ca8d4293 100644 --- a/pos_next/hooks.py +++ b/pos_next/hooks.py @@ -1,7 +1,7 @@ from pos_next.utils import get_build_version app_name = "pos_next" -app_title = "POS Next" +app_title = "Olko" app_publisher = "BrainWise" app_description = "POS built on ERPNext that brings together real-time billing, stock management, multi-user access, offline mode, and direct ERP integration. Run your store or restaurant with confidence and control, while staying 100% open source." app_email = "support@brainwise.me" @@ -96,6 +96,9 @@ [ "Sales Invoice-posa_pos_opening_shift", "Sales Invoice-posa_is_printed", + "Sales Invoice-restaurant_table", + "Sales Invoice-kds_status", + "Sales Invoice Item-posa_special_instructions", "Item-custom_company", "POS Profile-posa_cash_mode_of_payment", "POS Profile-posa_allow_delete", @@ -216,12 +219,17 @@ "pos_next.api.sales_invoice_hooks.validate", "pos_next.api.wallet.validate_wallet_payment" ], + "on_update": "pos_next.api.restaurant.on_invoice_update", "before_cancel": "pos_next.api.sales_invoice_hooks.before_cancel", "on_submit": [ "pos_next.realtime_events.emit_stock_update_event", - "pos_next.api.wallet.process_loyalty_to_wallet" + "pos_next.api.wallet.process_loyalty_to_wallet", + "pos_next.api.restaurant.on_invoice_update" + ], + "on_cancel": [ + "pos_next.realtime_events.emit_stock_update_event", + "pos_next.api.restaurant.on_invoice_update" ], - "on_cancel": "pos_next.realtime_events.emit_stock_update_event", "after_insert": "pos_next.realtime_events.emit_invoice_created_event" }, "POS Profile": { diff --git a/pos_next/overrides/pricing_rule.py b/pos_next/overrides/pricing_rule.py index a90d71b5..3b642700 100644 --- a/pos_next/overrides/pricing_rule.py +++ b/pos_next/overrides/pricing_rule.py @@ -14,32 +14,6 @@ import frappe -def _has_pos_only_column(): - """Check whether the current site's Pricing Rule table has the pos_only column. - - The monkey-patch in __init__.py is process-wide and affects ALL sites on the - bench, but only sites with POS Next installed have the pos_only custom field. - This guard prevents 'Unknown column' errors on sites that share the bench - but don't have POS Next. - - Cached per-site per-worker so the DB introspection runs only once. - """ - if not hasattr(_has_pos_only_column, "_cache"): - _has_pos_only_column._cache = {} - - site = getattr(frappe.local, "site", None) - if site in _has_pos_only_column._cache: - return _has_pos_only_column._cache[site] - - try: - result = frappe.db.has_column("Pricing Rule", "pos_only") - except Exception: - result = False - - _has_pos_only_column._cache[site] = result - return result - - def sync_pos_only_to_pricing_rules(doc, method=None): """Sync pos_only from Promotional Scheme to its generated Pricing Rules. @@ -67,9 +41,6 @@ def patch_get_other_conditions(pr_utils): def _patched_get_other_conditions(conditions, values, args): conditions = _original_get_other_conditions(conditions, values, args) - if not _has_pos_only_column(): - return conditions - doctype = args.get("doctype", "") # POS Invoice doctype — always POS, all rules apply if doctype in ("POS Invoice", "POS Invoice Item"): diff --git a/pos_next/pos_next/doctype/pos_settings/pos_settings.json b/pos_next/pos_next/doctype/pos_settings/pos_settings.json index c5711a43..871b5d78 100644 --- a/pos_next/pos_next/doctype/pos_settings/pos_settings.json +++ b/pos_next/pos_next/doctype/pos_settings/pos_settings.json @@ -73,7 +73,10 @@ "enable_session_lock", "session_lock_timeout", "barcode_tab", - "barcode_rules" + "barcode_rules", + "restaurant_tab", + "enable_restaurant_mode", + "default_restaurant_area" ], "fields": [ { @@ -546,6 +549,26 @@ "fieldtype": "Table", "label": "Barcode Rules", "options": "POS Barcode Rules" + }, + { + "fieldname": "restaurant_tab", + "fieldtype": "Tab Break", + "label": "Restaurant" + }, + { + "default": "0", + "description": "Enable restaurant features like Table Management and KDS.", + "fieldname": "enable_restaurant_mode", + "fieldtype": "Check", + "label": "Enable Restaurant Mode" + }, + { + "description": "Default area to display on POS Screen", + "fieldname": "default_restaurant_area", + "fieldtype": "Link", + "label": "Default Restaurant Area", + "options": "Restaurant Area", + "depends_on": "enable_restaurant_mode" } ], "index_web_pages_for_search": 1, diff --git a/pos_next/pos_next/doctype/restaurant_area/__init__.py b/pos_next/pos_next/doctype/restaurant_area/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pos_next/pos_next/doctype/restaurant_area/restaurant_area.json b/pos_next/pos_next/doctype/restaurant_area/restaurant_area.json new file mode 100644 index 00000000..113f1c1c --- /dev/null +++ b/pos_next/pos_next/doctype/restaurant_area/restaurant_area.json @@ -0,0 +1,69 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-05-20 10:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "area_name", + "description" + ], + "fields": [ + { + "fieldname": "area_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Area Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-05-20 10:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Restaurant Area", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "POSNext Cashier" + } + ], + "autoname": "field:area_name", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/restaurant_area/restaurant_area.py b/pos_next/pos_next/doctype/restaurant_area/restaurant_area.py new file mode 100644 index 00000000..ac8f8f00 --- /dev/null +++ b/pos_next/pos_next/doctype/restaurant_area/restaurant_area.py @@ -0,0 +1,8 @@ +# Copyright (c) 2024, BrainWise and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class RestaurantArea(Document): + pass diff --git a/pos_next/pos_next/doctype/restaurant_table/__init__.py b/pos_next/pos_next/doctype/restaurant_table/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pos_next/pos_next/doctype/restaurant_table/restaurant_table.json b/pos_next/pos_next/doctype/restaurant_table/restaurant_table.json new file mode 100644 index 00000000..af7a2c3a --- /dev/null +++ b/pos_next/pos_next/doctype/restaurant_table/restaurant_table.json @@ -0,0 +1,89 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-05-20 10:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "table_name", + "area", + "capacity", + "status" + ], + "fields": [ + { + "fieldname": "table_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Table Name", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "area", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Area", + "options": "Restaurant Area", + "reqd": 1 + }, + { + "default": "4", + "fieldname": "capacity", + "fieldtype": "Int", + "label": "Capacity" + }, + { + "default": "Empty", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Empty\nOccupied\nReserved\nCleaning" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-05-20 10:00:00.000000", + "modified_by": "Administrator", + "module": "POS Next", + "name": "Restaurant Table", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Sales Manager", + "share": 1, + "write": 1 + }, + { + "read": 1, + "role": "POSNext Cashier", + "write": 1 + } + ], + "autoname": "field:table_name", + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/pos_next/pos_next/doctype/restaurant_table/restaurant_table.py b/pos_next/pos_next/doctype/restaurant_table/restaurant_table.py new file mode 100644 index 00000000..e83ef55c --- /dev/null +++ b/pos_next/pos_next/doctype/restaurant_table/restaurant_table.py @@ -0,0 +1,8 @@ +# Copyright (c) 2024, BrainWise and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class RestaurantTable(Document): + pass