diff --git a/.github/helper/merge_po_files.py b/.github/helper/merge_po_files.py new file mode 100644 index 00000000000..185afa160a2 --- /dev/null +++ b/.github/helper/merge_po_files.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Overlay develop's .po translations onto hotfix's .po files. + +Called by sync_hotfix_translations.sh before `bench update-po-files`. +Merge rules: + a. msgid absent from develop → keep hotfix's existing msgstr + b. language not yet in hotfix → copy file as-is (bench will filter to main.pot) + c. msgid present in both → use develop's msgstr +""" +from datetime import datetime, timezone +from pathlib import Path + +from babel.messages.pofile import read_po, write_po + +DEVELOP = Path("/tmp/develop-po/erpnext/locale/") +LOCALE = Path("./apps/erpnext/erpnext/locale/") + +added = updated = 0 + +for src in sorted(DEVELOP.glob("*.po")): + dst = LOCALE / src.name + + with src.open("rb") as f: + dev = read_po(f) + + if not dst.exists(): + dev.revision_date = datetime.now(timezone.utc) + with dst.open("wb") as f: + write_po(f, dev) + added += 1 + print(f" [new] {src.name}") + continue + + with dst.open("rb") as f: + hf = read_po(f) + + changes = 0 + for msg in hf: + if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string: + msg.string = dev[msg.id].string + changes += 1 + + if changes: + hf.revision_date = datetime.now(timezone.utc) + with dst.open("wb") as f: + write_po(f, hf) + updated += 1 + print(f" [updated] {src.name} ({changes} msgstr(s) from develop)") + else: + print(f" [no-op] {src.name}") + +print(f"\n{added} new language(s), {updated} updated.") diff --git a/.github/helper/sync_hotfix_translations.sh b/.github/helper/sync_hotfix_translations.sh new file mode 100644 index 00000000000..86f6bb65f6a --- /dev/null +++ b/.github/helper/sync_hotfix_translations.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Syncs Crowdin translations from develop to a hotfix branch. +# Merge logic: see merge_po_files.py. +# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY +# (all set by Actions). + +set -e + +HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}" +APP_NAME="${APP_NAME:?APP_NAME env var is required}" + +cd ~ || exit + +echo "=== Setting up bench ===" +pip install frappe-bench +bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" +cd ./frappe-bench || exit +bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}" + +echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ===" +cd "./apps/${APP_NAME}" || exit +git config user.email "developers@erpnext.com" +git config user.name "frappe-pr-bot" +git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git" +git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*" +gh auth setup-git +git fetch upstream "${HOTFIX_BRANCH}" + +if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then + git fetch upstream "sync_translations_${HOTFIX_BRANCH}" + git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}" + git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit +else + git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}" +fi +cd ../.. || exit + +echo "=== Fetching develop's .po files ===" +mkdir -p /tmp/develop-po +git -C "${GITHUB_WORKSPACE}" fetch origin develop +git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \ + | tar -xf - -C /tmp/develop-po/ + +po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l) +if [ "${po_count}" -eq 0 ]; then + echo "ERROR: No .po files found in develop's archive. Aborting." >&2 + exit 1 +fi +echo "Extracted ${po_count} .po file(s) from develop." + +echo "=== Merging and reconciling ===" +env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py" +bench update-po-files --app "${APP_NAME}" + +cd "./apps/${APP_NAME}" || exit + +if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then + echo "Translations are already up to date. No PR needed." + exit 0 +fi + +echo "Changed files:" +git diff --name-only "${APP_NAME}/locale/" +git ls-files --others --exclude-standard "${APP_NAME}/locale/" + +echo "=== Committing ===" +while IFS= read -r file; do + git add "${file}" + lang=$(basename "${file}" .po) + git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}" +done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort) + +while IFS= read -r file; do + git add "${file}" + if ! git diff --staged --quiet -- "${file}"; then + lang=$(basename "${file}" .po) + git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}" + else + git restore --staged -- "${file}" + fi +done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort) + +if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then + git fetch upstream "sync_translations_${HOTFIX_BRANCH}" + git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit +fi +git push -u upstream sync_translations_${HOTFIX_BRANCH} + +echo "=== Opening PR (if not already open) ===" +existing_pr=$(gh pr list \ + --base "${HOTFIX_BRANCH}" \ + --head "sync_translations_${HOTFIX_BRANCH}" \ + --state open \ + --json number \ + --jq 'length' \ + -R "${GITHUB_REPOSITORY}") + +if [ "${existing_pr}" -gt 0 ]; then + echo "PR already open — branch updated in place. No new PR needed." + exit 0 +fi + +gh pr create \ + --base "${HOTFIX_BRANCH}" \ + --head "sync_translations_${HOTFIX_BRANCH}" \ + --title "chore: sync translations to ${HOTFIX_BRANCH}" \ + --body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`. + +A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`: + +| Case | Condition | Result | +|------|-----------|--------| +| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) | +| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` | +| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) | + +Generated by the \`sync-hotfix-translations\` workflow." \ + --label "translation" \ + --label "skip-release-notes" \ + --reviewer "${PR_REVIEWER}" \ + -R "${GITHUB_REPOSITORY}" diff --git a/.github/workflows/build-and-commit-assets.yml b/.github/workflows/build-and-commit-assets.yml new file mode 100644 index 00000000000..5b95b74fe8a --- /dev/null +++ b/.github/workflows/build-and-commit-assets.yml @@ -0,0 +1,70 @@ +name: Build and Upload Assets + +on: + push: + branches: + - develop + - 'version-*' + +concurrency: + group: build-assets-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + build-assets: + name: Build JS/CSS and upload to release + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + repository: frappe/frappe + path: apps/frappe + ref: ${{ github.ref_name }} + + - uses: actions/checkout@v4 + with: + path: apps/erpnext + + - name: Create bench structure + run: | + mkdir -p sites + printf "frappe\nerpnext\n" > sites/apps.txt + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: yarn + cache-dependency-path: apps/frappe/yarn.lock + + - name: Install frappe JS dependencies + working-directory: apps/frappe + run: yarn install --frozen-lockfile + + - name: Install erpnext JS dependencies + working-directory: apps/erpnext + run: yarn install --frozen-lockfile --ignore-scripts + + - name: Link node_modules into public/ + working-directory: apps/frappe + run: ln -s "$PWD/node_modules" frappe/public/node_modules + + - name: Build assets (production) + working-directory: apps/frappe + run: yarn run production + + - name: Package assets + working-directory: apps/erpnext + run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist + + - name: Upload to rolling release + working-directory: apps/erpnext + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG="assets-${GITHUB_REF_NAME//\//-}" + gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true + gh release upload "$TAG" erpnext-assets.tar.gz --clobber diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index d42b89e998e..77fe1b06ac4 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -15,4 +15,4 @@ jobs: - name: curl run: | apk add curl bash - curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}' + curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/core-build-stable.yml/dispatches -d '{"ref":"main"}' diff --git a/.github/workflows/run-hotfix-translation-sync.yml b/.github/workflows/run-hotfix-translation-sync.yml new file mode 100644 index 00000000000..8d0d13fca4a --- /dev/null +++ b/.github/workflows/run-hotfix-translation-sync.yml @@ -0,0 +1,52 @@ +# Runner — maintain this file on each hotfix branch, not on develop. +# +# Fires when main.pot changes on this branch (i.e. after a POT update PR +# merges), or when dispatched by the orchestrator on develop (weekly schedule). +# +# Uses github.ref_name so the file is identical across all hotfix branches +# with no branch-specific edits required. + +name: Run hotfix translation sync + +on: + workflow_dispatch: + +# One run at a time per branch. cancel-in-progress: false to avoid leaving +# an orphaned remote branch from a mid-flight git push + gh pr create. +concurrency: + group: sync-hotfix-translations-${{ github.ref_name }} + cancel-in-progress: false + +jobs: + sync-translations: + name: Sync translations from develop into ${{ github.ref_name }} + runs-on: ubuntu-latest + permissions: + contents: write + env: + HOTFIX_BRANCH: ${{ github.ref_name }} + APP_NAME: ${{ github.event.repository.name }} + + steps: + - name: Checkout ${{ env.HOTFIX_BRANCH }} + uses: actions/checkout@v6 + with: + ref: ${{ env.HOTFIX_BRANCH }} + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Run sync script + run: | + bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh" + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} + PR_REVIEWER: diptanilsaha diff --git a/.github/workflows/sync-hotfix-translations.yml b/.github/workflows/sync-hotfix-translations.yml new file mode 100644 index 00000000000..8fcd07e0ab2 --- /dev/null +++ b/.github/workflows/sync-hotfix-translations.yml @@ -0,0 +1,40 @@ +# Orchestrator — lives on develop only. +# +# Triggers on the weekly schedule and dispatches the runner workflow on each +# hotfix branch listed in the matrix. To add or remove a branch, edit the +# matrix below. +# +# POT-change triggers are handled by the runner on each hotfix branch +# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow +# from the branch that receives the push. + +name: Sync translations to hotfix branches + +on: + schedule: + # 10:00 UTC Monday + - cron: "0 10 * * 1" + workflow_dispatch: + +# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN, +# so no GITHUB_TOKEN permissions are required. +permissions: {} + +jobs: + trigger-runners: + name: Trigger sync → ${{ matrix.hotfix_branch }} + runs-on: ubuntu-latest + strategy: + matrix: + hotfix_branch: + - version-16-hotfix + fail-fast: false + + steps: + - name: Dispatch runner on ${{ matrix.hotfix_branch }} + run: | + gh workflow run run-hotfix-translation-sync.yml \ + --repo "${{ github.repository }}" \ + --ref "${{ matrix.hotfix_branch }}" + env: + GH_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/banking/eslint.config.js b/banking/eslint.config.js index 9cc2a204656..c5ac24c55e1 100644 --- a/banking/eslint.config.js +++ b/banking/eslint.config.js @@ -9,16 +9,18 @@ export default defineConfig([ globalIgnores(["dist"]), { files: ["**/*.{ts,tsx}"], - extends: [ - js.configs.recommended, - tseslint.configs.recommended, - reactHooks.configs.flat.recommended, - reactRefresh.configs.vite, - ], + extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite], + plugins: { + "react-hooks": reactHooks, + }, languageOptions: { ecmaVersion: 2020, globals: globals.browser, }, - onlyExportComponents: false, + rules: { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react-refresh/only-export-components": "off", + }, }, ]); diff --git a/banking/package.json b/banking/package.json index af48320c76f..f6c5971cd03 100644 --- a/banking/package.json +++ b/banking/package.json @@ -41,7 +41,6 @@ "react-markdown": "^10.1.0", "react-router": "^7.15.0", "react-router-dom": "^7.15.0", - "react-virtuoso": "^4.18.6", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", diff --git a/banking/proxyOptions.ts b/banking/proxyOptions.ts index e1aeca81094..f549deebec2 100644 --- a/banking/proxyOptions.ts +++ b/banking/proxyOptions.ts @@ -1,13 +1,17 @@ -const common_site_config = require('../../../sites/common_site_config.json'); +import { readFileSync } from 'node:fs'; + +const common_site_config = JSON.parse( + readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8') +) as { webserver_port: string | number }; const { webserver_port } = common_site_config; export default { '^/(app|api|assets|files|private)': { target: `http://127.0.0.1:${webserver_port}`, ws: true, - router: function(req) { - const site_name = req.headers.host.split(':')[0]; - return `http://${site_name}:${webserver_port}`; + router: function (req) { + const site_name = req.headers?.host?.split(':')[0]; + return `http://${site_name ?? 'localhost'}:${webserver_port}`; } } }; diff --git a/banking/src/App.tsx b/banking/src/App.tsx index 2e6f8339ce9..b46c5ba4233 100644 --- a/banking/src/App.tsx +++ b/banking/src/App.tsx @@ -1,14 +1,15 @@ -import { useEffect } from 'react' +import { lazy, useEffect } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import { FrappeProvider } from 'frappe-react-sdk' import { Toaster } from '@/components/ui/sonner' import BankReconciliation from '@/pages/BankReconciliation' +import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer' import { TooltipProvider } from './components/ui/tooltip' -import BankStatementImporter from '@/pages/BankStatementImporter' import { LucideProvider } from 'lucide-react' import { ThemeProvider } from './components/ui/theme-provider' -import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog' -import BankStatementImporterContainer from './pages/BankStatementImporterContainer' + +const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter')) +const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog')) function App() { useEffect(() => { @@ -43,7 +44,6 @@ function App() { > {window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' && - } /> }> diff --git a/banking/src/components/features/ActionLog/ActionLog.tsx b/banking/src/components/features/ActionLog/ActionLog.tsx index e8ed9ae234a..b260a4b6cca 100644 --- a/banking/src/components/features/ActionLog/ActionLog.tsx +++ b/banking/src/components/features/ActionLog/ActionLog.tsx @@ -1,475 +1,42 @@ import { Button } from '@/components/ui/button' -import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { Dialog, DialogTrigger } from '@/components/ui/dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import _ from '@/lib/translate' -import { useAtomValue, useSetAtom } from 'jotai' -import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react' -import { useMemo, useState } from 'react' -import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms' +import { HistoryIcon } from 'lucide-react' +import { useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' -import { useGetBankAccounts } from '../BankReconciliation/utils' -import { getCompanyCurrency } from '@/lib/company' -import { formatCurrency } from '@/lib/numbers' -import dayjs from 'dayjs' -import { cn } from '@/lib/utils' -import { formatDate } from '@/lib/date' -import { Separator } from '@/components/ui/separator' -import { slug } from '@/lib/frappe' -import { PaymentEntry } from '@/types/Accounts/PaymentEntry' -import { JournalEntry } from '@/types/Accounts/JournalEntry' -import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' -import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table' -import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' -import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk' -import { toast } from 'sonner' -import { getErrorMessage } from '@/lib/frappe' -import ErrorBanner from '@/components/ui/error-banner' -import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails' -import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty' -import BankLogo from '@/components/common/BankLogo' +import ActionLogDialog from './ActionLogDialog' const ActionLog = () => { + const [isOpen, setIsOpen] = useState(false) - const [isOpen, setIsOpen] = useState(false) + useHotkeys('meta+z', () => { + setIsOpen(true) + }, { + enabled: true, + enableOnFormTags: false, + preventDefault: true + }) - useHotkeys('meta+z', () => { - setIsOpen(true) - }, { - enabled: true, - enableOnFormTags: false, - preventDefault: true - }) - - return ( - - - - - - - - - {_("Reconciliation History")} - - - - - {_("Reconciliation History")} - {_("View all reconciliation actions taken in this session.")} - - - - - - - - - - ) + return ( + + + + + + + + + {_("Reconciliation History")} + + + {isOpen && ( + setIsOpen(false)} /> + )} + + ) } -const ActionLogDialogContent = () => { - - const actionLog = useAtomValue(bankRecActionLog) - - return
- {actionLog.map((action) => ( -
- -
-
-
- {action.items.map((item, index) => ( - - ))} -
-
-
-
- ))} - - {actionLog.length === 0 && - - - - - {_("No reconciliation actions found")} - {_("You have not performed any reconciliations in this session yet.")} - - } -
-} - - - -const ActionGroupHeader = ({ action }: { action: ActionLogType }) => { - - const label = useMemo(() => { - switch (action.type) { - case 'match': - return _("Matched") - case 'payment': - if (action.isBulk) { - return _("Bulk Payment") - } - return _("Payment") - - case 'transfer': - if (action.isBulk) { - return _("Bulk Transfer") - } - return _("Transfer") - - case 'bank_entry': - if (action.isBulk) { - return _("Bulk Bank Entry") - } - return _("Bank Entry") - - default: - return _("Action") - } - }, [action]) - - return
- {action.type === 'match' && } - {action.type === 'payment' && } - {action.type === 'transfer' && } - {action.type === 'bank_entry' && } - - {label} - {dayjs(action.timestamp).fromNow()} - -
-} - -const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => { - - const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 - - const { banks } = useGetBankAccounts() - - const bank = useMemo(() => { - if (item.bankTransaction.bank_account) { - return banks?.find((bank) => bank.name === item.bankTransaction.bank_account) - } - return null - }, [item.bankTransaction.bank_account, banks]) - - const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit - - const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '') - - return
-
-
-
-

{item.bankTransaction.description}

-
-
- - {item.bankTransaction.bank_account} -
- -
- - {formatDate(item.bankTransaction.date, 'Do MMM YYYY')} -
- -
-
- {isWithdrawal ? : } - {formatCurrency(amount, currency)} -
-
-
-
-
-
- - {["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name} - - {item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && } - {item.voucher.reference_doctype === "Journal Entry" && } -
-
-
-
-
- -
-
-} - -const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { - - return
- - -
-} - -const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { - - const accounts = useMemo(() => { - - const allAccounts = (item.voucher.doc as JournalEntry).accounts - - return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true) - - }, [item, bank]) - - return <> - {accounts.length === 1 ? {accounts[0].account} : - - - {_("Split across {} accounts", [accounts.length.toString()])} - - - - - - {_("Account")} - {_("Debit")} - {_("Credit")} - - - - {accounts.map((account) => ( - - {account.account} - {formatCurrency(account.debit ?? 0, account.account_currency ?? '')} - {formatCurrency(account.credit ?? 0, account.account_currency ?? '')} - - ))} - -
-
-
- } -} - -const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { - if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") { - return - } - - const invoices = (item.voucher.doc as PaymentEntry).references ?? [] - - const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency - - return
-
- - {(item.voucher.doc as PaymentEntry).party_name} -
- - - -
- - {invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])} -
-
- -
- {invoices.map((invoice) => ( - - - - {_("Document")} - {_("Invoice No")} - {_("Due Date")} - {_("Grand Total")} - {_("Allocated")} - - - - - {invoice.reference_doctype}: {invoice.reference_name} - {invoice.bill_no ?? "-"} - {formatDate(invoice.due_date)} - {formatCurrency(invoice.total_amount, currency ?? '')} - {formatCurrency(invoice.allocated_amount, currency ?? '')} - - -
- ))} -
-
-
- -
-} - -const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { - - const { banks } = useGetBankAccounts() - - const bank = useMemo(() => { - - const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 - - let transferAccount = "" - - if (isWithdrawal) { - transferAccount = (item.voucher.doc as PaymentEntry).paid_to - } else { - transferAccount = (item.voucher.doc as PaymentEntry).paid_from - } - - const transferBankAccount = banks?.find((bank) => bank.account === transferAccount) - - return transferBankAccount - - }, [banks, item]) - - return
- - {bank?.account} -
-} - -const ACTION_TYPE_MAP = { - 'bank_entry': _("Bank Entry"), - 'payment': _("Payment"), - 'transfer': _("Transfer"), - 'match': _("Match"), -} - -const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => { - - const [isOpen, setIsOpen] = useState(false) - - const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry') - const { mutate } = useSWRConfig() - const actionLog = useSetAtom(bankRecActionLog) - const dates = useAtomValue(bankRecDateAtom) - const matchFilters = useAtomValue(bankRecMatchFilters) - const selectedBank = useAtomValue(selectedBankAccountAtom) - - const onUndo = () => { - call({ - bank_transaction_id: item.bankTransaction.name, - voucher_type: item.voucher.reference_doctype, - voucher_id: item.voucher.reference_name, - }).then(() => { - toast.success(type === 'match' ? _("Unmatched") : _("Cancelled")) - - if (selectedBank?.name === item.bankTransaction.bank_account) { - mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) - mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) - // Update the matching vouchers for the selected transaction - mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`) - } - - setTimeout(() => { - actionLog((prev) => { - // Find the action and then remove the item from the action. If the action is empty, remove the action from the array - const action = prev.find((action) => action.timestamp === timestamp) - - if (action) { - action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name) - } - // If the action is empty, remove the action from the array - if (action && action.items.length === 0) { - return prev.filter((a) => a.timestamp !== timestamp) - } else { - return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a) - } - }) - }, 100) - - setIsOpen(false) - - }).catch((error) => { - toast.error(_("There was an error while performing the action."), { - duration: 5000, - description: getErrorMessage(error), - }) - }) - } - - return - - - - - - - - {_("Cancel")} - - - - - {type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])} - {type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])} - - {error && } -
- - - - {_("Action Type")} - {ACTION_TYPE_MAP[type]} - - - {_("Voucher Type")} - {_(item.voucher.reference_doctype)} - - - {_("Voucher Name")} - {item.voucher.reference_name} - - - {_("Posting Date")} - {formatDate(item.voucher.posting_date, 'Do MMM YYYY')} - - {type === 'transfer' && item.voucher.doc && - {_("Transfer Account")} - - - - } - {type === 'payment' && item.voucher.doc && - {_("Payment Details")} - - - - } - {type === 'bank_entry' && item.voucher.doc && - {_("Account")} - - } -
-
- - - {_("Close")} - - - -
-
-} - -export default ActionLog \ No newline at end of file +export default ActionLog diff --git a/banking/src/components/features/ActionLog/ActionLogDialog.tsx b/banking/src/components/features/ActionLog/ActionLogDialog.tsx new file mode 100644 index 00000000000..a4eebee9f1d --- /dev/null +++ b/banking/src/components/features/ActionLog/ActionLogDialog.tsx @@ -0,0 +1,34 @@ +import { Button } from '@/components/ui/button' +import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import _ from '@/lib/translate' +import { Loader2Icon } from 'lucide-react' +import { lazy, Suspense } from 'react' + +const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody')) + +const ActionLogDialogFallback = () => ( +
+ +
+) + +const ActionLogDialog = ({ onClose }: { onClose: () => void }) => { + return ( + + + {_("Reconciliation History")} + {_("View all reconciliation actions taken in this session.")} + + }> + + + + + + + + + ) +} + +export default ActionLogDialog diff --git a/banking/src/components/features/ActionLog/ActionLogDialogBody.tsx b/banking/src/components/features/ActionLog/ActionLogDialogBody.tsx new file mode 100644 index 00000000000..d0ec5fa428e --- /dev/null +++ b/banking/src/components/features/ActionLog/ActionLogDialogBody.tsx @@ -0,0 +1,431 @@ +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import _ from '@/lib/translate' +import { useAtomValue, useSetAtom } from 'jotai' +import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react' +import { useMemo, useState } from 'react' +import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms' +import { useGetBankAccounts } from '../BankReconciliation/utils' +import { getCompanyCurrency } from '@/lib/company' +import { formatCurrency } from '@/lib/numbers' +import dayjs from 'dayjs' +import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/date' +import { Separator } from '@/components/ui/separator' +import { slug } from '@/lib/frappe' +import { PaymentEntry } from '@/types/Accounts/PaymentEntry' +import { JournalEntry } from '@/types/Accounts/JournalEntry' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog' +import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk' +import { toast } from 'sonner' +import { getErrorMessage } from '@/lib/frappe' +import ErrorBanner from '@/components/ui/error-banner' +import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails' +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty' +import BankLogo from '@/components/common/BankLogo' + +const ActionLogDialogBody = () => { + + const actionLog = useAtomValue(bankRecActionLog) + + return
+ {actionLog.map((action) => ( +
+ +
+
+
+ {action.items.map((item, index) => ( + + ))} +
+
+
+
+ ))} + + {actionLog.length === 0 && + + + + + {_("No reconciliation actions found")} + {_("You have not performed any reconciliations in this session yet.")} + + } +
+} + + + +const ActionGroupHeader = ({ action }: { action: ActionLogType }) => { + + const label = useMemo(() => { + switch (action.type) { + case 'match': + return _("Matched") + case 'payment': + if (action.isBulk) { + return _("Bulk Payment") + } + return _("Payment") + + case 'transfer': + if (action.isBulk) { + return _("Bulk Transfer") + } + return _("Transfer") + + case 'bank_entry': + if (action.isBulk) { + return _("Bulk Bank Entry") + } + return _("Bank Entry") + + default: + return _("Action") + } + }, [action]) + + return
+ {action.type === 'match' && } + {action.type === 'payment' && } + {action.type === 'transfer' && } + {action.type === 'bank_entry' && } + + {label} - {dayjs(action.timestamp).fromNow()} + +
+} + +const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => { + + const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (item.bankTransaction.bank_account) { + return banks?.find((bank) => bank.name === item.bankTransaction.bank_account) + } + return null + }, [item.bankTransaction.bank_account, banks]) + + const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit + + const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '') + + return
+
+
+
+

{item.bankTransaction.description}

+
+
+ + {item.bankTransaction.bank_account} +
+ +
+ + {formatDate(item.bankTransaction.date, 'Do MMM YYYY')} +
+ +
+
+ {isWithdrawal ? : } + {formatCurrency(amount, currency)} +
+
+
+
+
+
+ + {["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name} + + {item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && } + {item.voucher.reference_doctype === "Journal Entry" && } +
+
+
+
+
+ +
+
+} + +const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { + + return
+ + +
+} + +const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => { + + const accounts = useMemo(() => { + + const allAccounts = (item.voucher.doc as JournalEntry).accounts + + return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true) + + }, [item, bank]) + + return <> + {accounts.length === 1 ? {accounts[0].account} : + + + {_("Split across {} accounts", [accounts.length.toString()])} + + + + + + {_("Account")} + {_("Debit")} + {_("Credit")} + + + + {accounts.map((account) => ( + + {account.account} + {formatCurrency(account.debit ?? 0, account.account_currency ?? '')} + {formatCurrency(account.credit ?? 0, account.account_currency ?? '')} + + ))} + +
+
+
+ } +} + +const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { + if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") { + return + } + + const invoices = (item.voucher.doc as PaymentEntry).references ?? [] + + const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency + + return
+
+ + {(item.voucher.doc as PaymentEntry).party_name} +
+ + + +
+ + {invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])} +
+
+ +
+ {invoices.map((invoice) => ( + + + + {_("Document")} + {_("Invoice No")} + {_("Due Date")} + {_("Grand Total")} + {_("Allocated")} + + + + + {invoice.reference_doctype}: {invoice.reference_name} + {invoice.bill_no ?? "-"} + {formatDate(invoice.due_date)} + {formatCurrency(invoice.total_amount, currency ?? '')} + {formatCurrency(invoice.allocated_amount, currency ?? '')} + + +
+ ))} +
+
+
+ +
+} + +const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => { + + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + + const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 + + let transferAccount = "" + + if (isWithdrawal) { + transferAccount = (item.voucher.doc as PaymentEntry).paid_to + } else { + transferAccount = (item.voucher.doc as PaymentEntry).paid_from + } + + const transferBankAccount = banks?.find((bank) => bank.account === transferAccount) + + return transferBankAccount + + }, [banks, item]) + + return
+ + {bank?.account} +
+} + +const ACTION_TYPE_MAP = { + 'bank_entry': _("Bank Entry"), + 'payment': _("Payment"), + 'transfer': _("Transfer"), + 'match': _("Match"), +} + +const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => { + + const [isOpen, setIsOpen] = useState(false) + + const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry') + const { mutate } = useSWRConfig() + const actionLog = useSetAtom(bankRecActionLog) + const dates = useAtomValue(bankRecDateAtom) + const matchFilters = useAtomValue(bankRecMatchFilters) + const selectedBank = useAtomValue(selectedBankAccountAtom) + + const onUndo = () => { + call({ + bank_transaction_id: item.bankTransaction.name, + voucher_type: item.voucher.reference_doctype, + voucher_id: item.voucher.reference_name, + }).then(() => { + toast.success(type === 'match' ? _("Unmatched") : _("Cancelled")) + + if (selectedBank?.name === item.bankTransaction.bank_account) { + mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`) + // Update the matching vouchers for the selected transaction + mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`) + } + + setTimeout(() => { + actionLog((prev) => { + // Find the action and then remove the item from the action. If the action is empty, remove the action from the array + const action = prev.find((action) => action.timestamp === timestamp) + + if (action) { + action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name) + } + // If the action is empty, remove the action from the array + if (action && action.items.length === 0) { + return prev.filter((a) => a.timestamp !== timestamp) + } else { + return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a) + } + }) + }, 100) + + setIsOpen(false) + + }).catch((error) => { + toast.error(_("There was an error while performing the action."), { + duration: 5000, + description: getErrorMessage(error), + }) + }) + } + + return + + + + + + + + {_("Cancel")} + + + + + {type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])} + {type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])} + + {error && } +
+ + + + {_("Action Type")} + {ACTION_TYPE_MAP[type]} + + + {_("Voucher Type")} + {_(item.voucher.reference_doctype)} + + + {_("Voucher Name")} + {item.voucher.reference_name} + + + {_("Posting Date")} + {formatDate(item.voucher.posting_date, 'Do MMM YYYY')} + + {type === 'transfer' && item.voucher.doc && + {_("Transfer Account")} + + + + } + {type === 'payment' && item.voucher.doc && + {_("Payment Details")} + + + + } + {type === 'bank_entry' && item.voucher.doc && + {_("Account")} + + } +
+
+ + + {_("Close")} + + + +
+
+} + +export default ActionLogDialogBody diff --git a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx index 077ee41ccd2..dd248d31092 100644 --- a/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx +++ b/banking/src/components/features/BankReconciliation/BankClearanceSummary.tsx @@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => { toast.success(_("Copied to clipboard")) }) }, - [copyToClipboard, _], + [copyToClipboard], ) const accountCurrency = useMemo( @@ -200,7 +200,7 @@ const BankClearanceSummaryView = () => { }, }, ], - [_, accountCurrency, bankAccount, companyID, mutate, onCopy], + [accountCurrency, bankAccount, companyID, mutate, onCopy], ) return
diff --git a/banking/src/components/features/BankReconciliation/BankEntryModal.tsx b/banking/src/components/features/BankReconciliation/BankEntryModal.tsx index e6514e5d19c..f0a29455bae 100644 --- a/banking/src/components/features/BankReconciliation/BankEntryModal.tsx +++ b/banking/src/components/features/BankReconciliation/BankEntryModal.tsx @@ -1,831 +1,32 @@ -import { useAtom, useAtomValue, useSetAtom } from "jotai" -import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" -import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog" +import { useAtom } from "jotai" +import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog" +import { ModalContentFallback } from "@/components/ui/modal-content-fallback" import _ from "@/lib/translate" -import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" -import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" -import { JournalEntry } from "@/types/Accounts/JournalEntry" -import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" -import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk" -import { toast } from "sonner" -import ErrorBanner from "@/components/ui/error-banner" -import { Button } from "@/components/ui/button" -import SelectedTransactionDetails from "./SelectedTransactionDetails" -import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" -import { Form } from "@/components/ui/form" -import { useCallback, useContext, useMemo, useRef, useState } from "react" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { Checkbox } from "@/components/ui/checkbox" -import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react" -import { flt, formatCurrency } from "@/lib/numbers" -import { cn } from "@/lib/utils" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import SelectedTransactionsTable from "./SelectedTransactionsTable" -import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount" -import { BankTransaction } from "@/types/Accounts/BankTransaction" -import FileUploadBanner from "@/components/common/FileUploadBanner" -import { Label } from "@/components/ui/label" -import { FileDropzone } from "@/components/ui/file-dropzone" -import { useGetAccounts } from "@/components/common/AccountsDropdown" -import { useHotkeys } from "react-hotkeys-hook" +import { lazy, Suspense } from "react" + +const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent')) const BankEntryModal = () => { + const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom) - const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom) - - return ( - - - - {_("Bank Entry")} - - {_("Record a journal entry for expenses, income or split transactions.")} - - - - - - ) + return ( + + + + {_("Bank Entry")} + + {_("Record a journal entry for expenses, income or split transactions.")} + + + {isOpen && ( + }> + + + )} + + + ) } -const RecordBankEntryModalContent = () => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) - - if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { - return
- {_("No transaction selected")} -
- } - - if (selectedTransaction.length === 1) { - return - } - - return - -} - -const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => { - - const form = useForm<{ - account: string - }>({ - defaultValues: { - account: '' - } - }) - - const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile') - - const onReconcile = useRefreshUnreconciledTransactions() - const addToActionLog = useUpdateActionLog() - - const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) - - const onSubmit = (data: { account: string }) => { - - call({ - bank_transactions: selectedTransactions.map(transaction => transaction.name), - account: data.account - }).then(({ message }) => { - - addToActionLog({ - type: 'bank_entry', - timestamp: (new Date()).getTime(), - isBulk: true, - items: message.map((item) => ({ - bankTransaction: item.transaction, - voucher: { - reference_doctype: "Journal Entry", - reference_name: item.journal_entry.name, - doc: item.journal_entry, - posting_date: item.journal_entry.posting_date, - } - })), - bulkCommonData: { - account: data.account, - } - }) - - toast.success(_("Bank Entries Created"), { - duration: 4000, - }) - - // Set this to the last selected transaction - onReconcile(selectedTransactions[selectedTransactions.length - 1]) - setIsOpen(false) - }) - } - - return
- -
- {error && } - - -
- { - // Do not allow payable and receivable accounts - return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable' - }} - label={_('Account')} - isRequired - /> -
- - - - - - - -
-
- -} - - -interface BankEntryFormData extends Pick { - entries: JournalEntry['accounts'] -} - - -const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const { data: rule } = useGetRuleForTransaction(selectedTransaction) - - const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) - - const onClose = () => { - setIsOpen(false) - } - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const defaultAccounts = useMemo(() => { - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const accounts: Partial[] = [ - { - account: selectedBankAccount?.account ?? '', - bank_account: selectedTransaction.bank_account, - // Bank is debited if it's a deposit - debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, - credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, - party_type: '', - party: '', - cost_center: '' - }] - - // If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse - if (!rule) { - accounts.push( - { - account: '', - // Amounts will be the reverse of the bank account transaction - debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, - credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - } - ) - } else { - // Rule exists, so we need to check the type of rule - if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") { - // Only a single account needs to be added - accounts.push({ - account: rule.account ?? '', - // Amounts will be the reverse of the bank account transaction - debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, - credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - }) - } else { - // For multiple accounts, we need to loop over and add entries for each - // The last row will just be the remaining amount - let hasTotallyEmptyRowEarlier = false; - - let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0 - let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0 - - for (let i = 0; i < (rule.accounts?.length ?? 0); i++) { - - const acc = rule.accounts?.[i] - // If it's the last row, add the difference amount - if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) { - - const differenceAmount = flt(totalDebits - totalCredits, 2) - accounts.push({ - account: acc?.account ?? '', - debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount), - credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - user_remark: acc?.user_remark ?? '', - }) - } else { - - /** - * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" - * So we need to compute the value of the expression - * We can use the eval function to do this. But we need to expose certain variables to the expression. - * One of them is transaction_amount which is the unallocated amount of the selected transaction - * @param expression - The expression to compute - * @returns The computed value - */ - const computeExpression = (expression: string) => { - - const script = ` - const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} - ${expression}; - ` - - let value = 0; - - try { - value = window.eval(script); - } catch (error: unknown) { - console.error(error); - value = 0; - } - - return value; - } - if (!acc?.debit && !acc?.credit) { - hasTotallyEmptyRowEarlier = true; - } - - const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 - const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 - - totalDebits = flt(totalDebits + computedDebit, 2) - totalCredits = flt(totalCredits + computedCredit, 2) - accounts.push({ - account: acc?.account ?? '', - debit: computedDebit, - credit: computedCredit, - cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', - user_remark: acc?.user_remark ?? '', - }) - } - } - } - } - - return accounts - - }, [rule, selectedTransaction, selectedBankAccount]) - - const form = useForm({ - defaultValues: { - voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry', - cheque_date: selectedTransaction.date, - posting_date: selectedTransaction.date, - cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), - user_remark: selectedTransaction.description, - entries: defaultAccounts, - } - }) - - const onReconcile = useRefreshUnreconciledTransactions() - - const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile') - - const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) - const addToActionLog = useUpdateActionLog() - - const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig - - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - const [files, setFiles] = useState([]) - - const onSubmit = (data: BankEntryFormData) => { - - createBankEntry({ - bank_transaction_name: selectedTransaction.name, - ...data - }).then(async ({ message }) => { - - addToActionLog({ - type: 'bank_entry', - isBulk: false, - timestamp: (new Date()).getTime(), - items: [ - { - bankTransaction: message.transaction, - voucher: { - reference_doctype: "Journal Entry", - reference_name: message.journal_entry.name, - reference_no: message.journal_entry.cheque_no, - reference_date: message.journal_entry.cheque_date, - posting_date: message.journal_entry.posting_date, - doc: message.journal_entry, - } - } - ] - }) - toast.success(_("Bank Entry Created"), { - duration: 4000, - closeButton: true, - action: { - label: _("Undo"), - onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) - }, - actionButtonStyle: { - backgroundColor: "rgb(0, 138, 46)" - } - }) - - if (files.length > 0) { - setIsUploading(true) - - const uploadPromises = files.map(f => { - return frappeFile.uploadFile(f, { - isPrivate: true, - doctype: "Journal Entry", - docname: message.journal_entry.name, - }, (_bytesUploaded, _totalBytes, progress) => { - - setUploadProgress((currentProgress) => { - //If there are multiple files, we need to add the progress to the current progress - return currentProgress + ((progress?.progress ?? 0) / files.length) - }) - - }) - }) - - return Promise.all(uploadPromises).then(() => { - setUploadProgress(0) - setIsUploading(false) - }).catch((error) => { - console.error(error) - toast.error(_("Error uploading attachments"), { - duration: 4000, - }) - setIsUploading(false) - }) - } else { - return Promise.resolve() - } - - }).then(() => { - onReconcile(selectedTransaction) - onClose() - }) - } - - - useHotkeys('meta+s', () => { - form.handleSubmit(onSubmit)() - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: true - }) - - if (isUploading && isCompleted) { - return - } - - return
- -
- {error && } -
- - -
-
- - -
- -
-
- -
- -
-
-
- -
- - -
-
-
- - - - - - - -
-
- - -} - -const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => { - - const { getValues, setValue, control } = useFormContext() - - const { call } = useContext(FrappeContext) as FrappeConfig - - const partyMapRef = useRef>({}) - - const onPartyChange = (value: string, index: number) => { - // Get the account for the party type - if (value) { - if (partyMapRef.current[value]) { - setValue(`entries.${index}.account`, partyMapRef.current[value]) - } else { - call.get('erpnext.accounts.party.get_party_account', { - party: value, - party_type: getValues(`entries.${index}.party_type`), - company: company - }).then((result: { message: string }) => { - setValue(`entries.${index}.account`, result.message) - partyMapRef.current[value] = result.message - }) - } - } else { - setValue(`entries.${index}.account`, '') - } - } - - const { data: accounts } = useGetAccounts() - - const onAccountChange = (value: string, index: number) => { - // If it's an income or expense account, get the default cost center - if (value) { - const account = accounts?.find((acc) => acc.name === value) - if (account && account.report_type === "Profit and Loss") { - // Set the default company cost center - setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '') - return - } - } - - setValue(`entries.${index}.cost_center`, '') - } - - const { fields, append, remove } = useFieldArray({ - control: control, - name: 'entries' - }) - - const onAdd = useCallback(() => { - const existingEntries = getValues('entries') - const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) - const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) - - const remainingAmount = flt(totalDebits - totalCredits, 2) - - // Remaining amount is credit if it's positive - since some debit is pending to be cleared. - const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) - const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 - - append({ - party_type: '', - party: '', - account: '', - debit: debitAmount, - credit: creditAmount, - cost_center: getCompanyCostCenter(company) ?? '' - } as JournalEntryAccount, { - focusName: `entries.${existingEntries.length}.account` - }) - }, [company, append, getValues]) - - const [selectedRows, setSelectedRows] = useState([]) - - const onSelectRow = useCallback((index: number) => { - setSelectedRows(prev => { - if (prev.includes(index)) { - return prev.filter(i => i !== index) - } - return [...prev, index] - }) - }, []) - - const onSelectAll = useCallback(() => { - setSelectedRows(prev => { - if (prev.length === fields.length) { - return [] - } - return [...fields.map((_, index) => index)] - }) - }, [fields]) - - const onRemove = useCallback(() => { - remove(selectedRows) - setSelectedRows([]) - }, [remove, selectedRows]) - - /** - * When add difference is clicked, check if the last row has nothing filled in. - * If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount. - */ - const onAddDifferenceClicked = () => { - - const existingEntries = getValues('entries') - const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) - const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) - - const lastIndex = existingEntries.length - 1 - - const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined) - - const remainingAmount = flt(totalDebits - totalCredits, 2) - - // Remaining amount is credit if it's positive - since some debit is pending to be cleared. - const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) - const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 - - if (isLastRowEmpty) { - setValue(`entries.${lastIndex}.debit`, debitAmount) - setValue(`entries.${lastIndex}.credit`, creditAmount) - } else { - append({ - party_type: '', - party: '', - account: '', - debit: debitAmount, - credit: creditAmount, - cost_center: getCompanyCostCenter(company) ?? '' - } as JournalEntryAccount, { - focusName: `entries.${existingEntries.length}.account` - }) - } - } - - - - return
- - - - 0 && selectedRows.length === fields.length} - onCheckedChange={onSelectAll} /> - {_("Party")} - {_("Account")} - {_("Cost Center")} - {_("Remarks")} - {_("Debit")} - {_("Credit")} - - - - {fields.map((field, index) => ( - - - onSelectRow(index)} - // Make this accessible to screen readers - aria-label={_("Select row {0}", [String(index + 1)])} - disabled={index === 0} - /> - - - -
- - -
- -
- - { - onAccountChange(event.target.value, index) - } - }} - buttonClassName="min-w-64" - readOnly={index === 0} - isRequired - hideLabel - /> - - - - - - - - - - - {_("Bank account debit for deposit")} - : undefined} - /> - - - - - {_("Bank account credit for withdrawal")} - : undefined} - /> - -
- ))} -
-
-
-
-
- -
- {selectedRows.length > 0 &&
- -
} -
- -
-
- -} - -const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => { - - const { control } = useFormContext() - - const party_type = useWatch({ - control, - name: `entries.${index}.party_type` - }) - - if (!party_type) { - return - } - - return { - onChange(event.target.value, index) - }, - }} - hideLabel - readOnly={readOnly} - buttonClassName="rounded-s-none border-s-0 min-w-64" - doctype={party_type} - - /> -} - -const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => { - - const { control } = useFormContext() - - const entries = useWatch({ control, name: 'entries' }) - - const { total, totalCredits, totalDebits } = useMemo(() => { - // Do a total debits - total credits - const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) - const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) - return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits } - }, [entries]) - - const onAddRow = useCallback(() => { - addRow() - }, [addRow]) - - const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { - return {children} - } - - return
-
- {_("Total Debit")} - {formatCurrency(totalDebits, currency)} -
-
- {_("Total Credit")} - {formatCurrency(totalCredits, currency)} -
- {total !== 0 &&
- {_("Difference")} - - - - - - {_("Add a row with the difference amount")} - - -
} - -
- -} - - export default BankEntryModal diff --git a/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx new file mode 100644 index 00000000000..17ef3314a1f --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankEntryModalContent.tsx @@ -0,0 +1,811 @@ +import { useAtomValue, useSetAtom } from "jotai" +import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { DialogFooter, DialogClose } from "@/components/ui/dialog" +import _ from "@/lib/translate" +import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" +import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" +import { JournalEntry } from "@/types/Accounts/JournalEntry" +import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" +import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" +import { Button } from "@/components/ui/button" +import SelectedTransactionDetails from "./SelectedTransactionDetails" +import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" +import { Form } from "@/components/ui/form" +import { useCallback, useContext, useMemo, useRef, useState } from "react" +import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react" +import { flt, formatCurrency } from "@/lib/numbers" +import { cn } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import SelectedTransactionsTable from "./SelectedTransactionsTable" +import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import FileUploadBanner from "@/components/common/FileUploadBanner" +import { Label } from "@/components/ui/label" +import { FileDropzone } from "@/components/ui/file-dropzone" +import { useGetAccounts } from "@/components/common/AccountsDropdown" +import { useHotkeys } from "react-hotkeys-hook" +const RecordBankEntryModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => { + + const form = useForm<{ + account: string + }>({ + defaultValues: { + account: '' + } + }) + + const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile') + + const onReconcile = useRefreshUnreconciledTransactions() + const addToActionLog = useUpdateActionLog() + + const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + const onSubmit = (data: { account: string }) => { + + call({ + bank_transactions: selectedTransactions.map(transaction => transaction.name), + account: data.account + }).then(({ message }) => { + + addToActionLog({ + type: 'bank_entry', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Journal Entry", + reference_name: item.journal_entry.name, + doc: item.journal_entry, + posting_date: item.journal_entry.posting_date, + } + })), + bulkCommonData: { + account: data.account, + } + }) + + toast.success(_("Bank Entries Created"), { + duration: 4000, + }) + + // Set this to the last selected transaction + onReconcile(selectedTransactions[selectedTransactions.length - 1]) + setIsOpen(false) + }) + } + + return
+ +
+ {error && } + + +
+ { + // Do not allow payable and receivable accounts + return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable' + }} + label={_('Account')} + isRequired + /> +
+ + + + + + + +
+
+ +} + + +interface BankEntryFormData extends Pick { + entries: JournalEntry['accounts'] +} + + +const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const defaultAccounts = useMemo(() => { + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const accounts: Partial[] = [ + { + account: selectedBankAccount?.account ?? '', + bank_account: selectedTransaction.bank_account, + // Bank is debited if it's a deposit + debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + party_type: '', + party: '', + cost_center: '' + }] + + // If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse + if (!rule) { + accounts.push( + { + account: '', + // Amounts will be the reverse of the bank account transaction + debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + } + ) + } else { + // Rule exists, so we need to check the type of rule + if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") { + // Only a single account needs to be added + accounts.push({ + account: rule.account ?? '', + // Amounts will be the reverse of the bank account transaction + debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0, + credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + }) + } else { + // For multiple accounts, we need to loop over and add entries for each + // The last row will just be the remaining amount + let hasTotallyEmptyRowEarlier = false; + + let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0 + let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0 + + for (let i = 0; i < (rule.accounts?.length ?? 0); i++) { + + const acc = rule.accounts?.[i] + // If it's the last row, add the difference amount + if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) { + + const differenceAmount = flt(totalDebits - totalCredits, 2) + accounts.push({ + account: acc?.account ?? '', + debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount), + credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + user_remark: acc?.user_remark ?? '', + }) + } else { + + /** + * The debit and credit amounts can also be expressions - like "transaction_amount * 0.5" + * So we need to compute the value of the expression + * We can use the eval function to do this. But we need to expose certain variables to the expression. + * One of them is transaction_amount which is the unallocated amount of the selected transaction + * @param expression - The expression to compute + * @returns The computed value + */ + const computeExpression = (expression: string) => { + + const script = ` + const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0} + ${expression}; + ` + + let value = 0; + + try { + value = window.eval(script); + } catch (error: unknown) { + console.error(error); + value = 0; + } + + return value; + } + if (!acc?.debit && !acc?.credit) { + hasTotallyEmptyRowEarlier = true; + } + + const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0 + const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0 + + totalDebits = flt(totalDebits + computedDebit, 2) + totalCredits = flt(totalCredits + computedCredit, 2) + accounts.push({ + account: acc?.account ?? '', + debit: computedDebit, + credit: computedCredit, + cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '', + user_remark: acc?.user_remark ?? '', + }) + } + } + } + } + + return accounts + + }, [rule, selectedTransaction, selectedBankAccount]) + + const form = useForm({ + defaultValues: { + voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry', + cheque_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + user_remark: selectedTransaction.description, + entries: defaultAccounts, + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress() + + const [files, setFiles] = useState([]) + + const onSubmit = (data: BankEntryFormData) => { + + createBankEntry({ + bank_transaction_name: selectedTransaction.name, + ...data + }).then(async ({ message }) => { + + addToActionLog({ + type: 'bank_entry', + isBulk: false, + timestamp: (new Date()).getTime(), + items: [ + { + bankTransaction: message.transaction, + voucher: { + reference_doctype: "Journal Entry", + reference_name: message.journal_entry.name, + reference_no: message.journal_entry.cheque_no, + reference_date: message.journal_entry.cheque_date, + posting_date: message.journal_entry.posting_date, + doc: message.journal_entry, + } + } + ] + }) + toast.success(_("Bank Entry Created"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + + if (files.length > 0) { + setIsUploading(true) + startTracking(files.length) + + const uploadPromises = files.map((f, fileIndex) => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Journal Entry", + docname: message.journal_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + updateFileProgress(fileIndex, progress?.progress ?? 0) + }) + }) + + return Promise.all(uploadPromises).then(() => { + resetProgress() + setIsUploading(false) + }).catch((error) => { + console.error(error) + toast.error(_("Error uploading attachments"), { + duration: 4000, + }) + resetProgress() + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + + }).then(() => { + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ + +
+
+ + +
+ +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+ + + + + + + +
+
+ + +} + +const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => { + + const { getValues, setValue, control } = useFormContext() + + const { call } = useContext(FrappeContext) as FrappeConfig + + const partyMapRef = useRef>({}) + + const onPartyChange = (value: string, index: number) => { + // Get the account for the party type + if (value) { + if (partyMapRef.current[value]) { + setValue(`entries.${index}.account`, partyMapRef.current[value]) + } else { + call.get('erpnext.accounts.party.get_party_account', { + party: value, + party_type: getValues(`entries.${index}.party_type`), + company: company + }).then((result: { message: string }) => { + setValue(`entries.${index}.account`, result.message) + partyMapRef.current[value] = result.message + }) + } + } else { + setValue(`entries.${index}.account`, '') + } + } + + const { data: accounts } = useGetAccounts() + + const onAccountChange = (value: string, index: number) => { + // If it's an income or expense account, get the default cost center + if (value) { + const account = accounts?.find((acc) => acc.name === value) + if (account && account.report_type === "Profit and Loss") { + // Set the default company cost center + setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '') + return + } + } + + setValue(`entries.${index}.cost_center`, '') + } + + const { fields, append, remove } = useFieldArray({ + control: control, + name: 'entries' + }) + + const onAdd = useCallback(() => { + const existingEntries = getValues('entries') + const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + + const remainingAmount = flt(totalDebits - totalCredits, 2) + + // Remaining amount is credit if it's positive - since some debit is pending to be cleared. + const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) + const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 + + append({ + party_type: '', + party: '', + account: '', + debit: debitAmount, + credit: creditAmount, + cost_center: getCompanyCostCenter(company) ?? '' + } as JournalEntryAccount, { + focusName: `entries.${existingEntries.length}.account` + }) + }, [company, append, getValues]) + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + // Do not remove the first row + remove(selectedRows.filter(index => index !== 0)) + setSelectedRows([]) + }, [remove, selectedRows]) + + /** + * When add difference is clicked, check if the last row has nothing filled in. + * If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount. + */ + const onAddDifferenceClicked = () => { + + const existingEntries = getValues('entries') + const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + + const lastIndex = existingEntries.length - 1 + + const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined) + + const remainingAmount = flt(totalDebits - totalCredits, 2) + + // Remaining amount is credit if it's positive - since some debit is pending to be cleared. + const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount) + const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0 + + if (isLastRowEmpty) { + setValue(`entries.${lastIndex}.debit`, debitAmount) + setValue(`entries.${lastIndex}.credit`, creditAmount) + } else { + append({ + party_type: '', + party: '', + account: '', + debit: debitAmount, + credit: creditAmount, + cost_center: getCompanyCostCenter(company) ?? '' + } as JournalEntryAccount, { + focusName: `entries.${existingEntries.length}.account` + }) + } + } + + + + return
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Party")} + {_("Account")} + {_("Cost Center")} + {_("Remarks")} + {_("Debit")} + {_("Credit")} + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + disabled={index === 0} + /> + + + +
+ + +
+ +
+ + { + onAccountChange(event.target.value, index) + } + }} + buttonClassName="min-w-64" + readOnly={index === 0} + isRequired + hideLabel + /> + + + + + + + + + + + {_("Bank account debit for deposit")} + : undefined} + /> + + + + + {_("Bank account credit for withdrawal")} + : undefined} + /> + +
+ ))} +
+
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+ +
+
+ +} + +const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => { + + const { control } = useFormContext() + + const party_type = useWatch({ + control, + name: `entries.${index}.party_type` + }) + + if (!party_type) { + return + } + + return { + onChange(event.target.value, index) + }, + }} + hideLabel + readOnly={readOnly} + buttonClassName="rounded-s-none border-s-0 min-w-64" + doctype={party_type} + + /> +} + +const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => { + + const { control } = useFormContext() + + const entries = useWatch({ control, name: 'entries' }) + + const { total, totalCredits, totalDebits } = useMemo(() => { + // Do a total debits - total credits + const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0) + const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0) + return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits } + }, [entries]) + + const onAddRow = useCallback(() => { + addRow() + }, [addRow]) + + const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { + return {children} + } + + return
+
+ {_("Total Debit")} + {formatCurrency(totalDebits, currency)} +
+
+ {_("Total Credit")} + {formatCurrency(totalCredits, currency)} +
+ {total !== 0 &&
+ {_("Difference")} + + + + + + {_("Add a row with the difference amount")} + + +
} + +
+ +} + + + +export default RecordBankEntryModalContent diff --git a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx index acfed95aa15..7b505efadc3 100644 --- a/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx +++ b/banking/src/components/features/BankReconciliation/BankReconciliationStatement.tsx @@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => { toast.success(_("Copied to clipboard")) }) }, - [copyToClipboard, _], + [copyToClipboard], ) const statementColumns = useMemo[]>( @@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => { cell: ({ row }) => formatDate(row.original.clearance_date), }, ], - [_, onCopy], + [onCopy], ) const statementRows = useMemo(() => { diff --git a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx index c37165349fe..17f231a0833 100644 --- a/banking/src/components/features/BankReconciliation/BankTransactionList.tsx +++ b/banking/src/components/features/BankReconciliation/BankTransactionList.tsx @@ -176,7 +176,7 @@ const BankTransactionListView = () => { ), }, ], - [_, accountCurrency, onUndo], + [accountCurrency, onUndo], ) const [search, setSearch] = useDebounceValue('', 250) diff --git a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx index a9996a2ddef..3f43c987dbf 100644 --- a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx +++ b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModal.tsx @@ -1,125 +1,52 @@ -import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog" -import { useAtom, useAtomValue } from "jotai" -import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" -import { useMemo } from "react" -import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" -import { BankTransaction } from "@/types/Accounts/BankTransaction" -import { toast } from "sonner" -import ErrorBanner from "@/components/ui/error-banner" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { formatCurrency } from "@/lib/numbers" -import { Badge } from "@/components/ui/badge" -import { slug } from "@/lib/frappe" -import SelectedTransactionDetails from "./SelectedTransactionDetails" +import { + AlertDialog, + AlertDialogContent, + AlertDialogDescription, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { useAtom } from "jotai" +import { Loader2Icon } from "lucide-react" +import { lazy, Suspense } from "react" +import { bankRecUnreconcileModalAtom } from "./bankRecAtoms" import _ from "@/lib/translate" +const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody')) + +const BankTransactionUnreconcileModalFallback = () => ( +
+ +
+) + const BankTransactionUnreconcileModal = () => { + const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) - const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) - - const onOpenChange = (v: boolean) => { - if (!v) { - setBankRecUnreconcileModal('') - } - } - - return - - - - {_("Undo Transaction Reconciliation")} - - {_("Are you sure you want to unreconcile this transaction?")} - - - - - + const onOpenChange = (v: boolean) => { + if (!v) { + setBankRecUnreconcileModal('') + } + } + if (!unreconcileModal) { + return null + } + return ( + + + + {_("Undo Transaction Reconciliation")} + + {_("Are you sure you want to unreconcile this transaction?")} + + + }> + + + + + ) } -const BankTransactionUnreconcileModalContent = () => { - const bankAccount = useAtomValue(selectedBankAccountAtom) - const dates = useAtomValue(bankRecDateAtom) - - const { mutate } = useSWRConfig() - - const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) - - const { data: transaction, error } = useFrappeGetDoc('Bank Transaction', unreconcileModal) - - const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction') - - const onUnreconcile = (event: React.MouseEvent) => { - call({ - transaction_name: unreconcileModal - }).then(() => { - // Mutate the transactions list, unreconciled transactions list and account closing balance - mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) - mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) - mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`) - toast.success(_("Transaction Unreconciled")) - setBankRecUnreconcileModal('') - }) - - event.preventDefault() - } - - const vouchersWhichWillBeCancelled = useMemo(() => { - return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created') - }, [transaction]) - - return
-
- {error && } - {unreconcileError && } - {transaction && } - {_("This transaction has been reconciled with the following document(s):")} - - - - {_("Document")} - {_("Amount")} - {_("Reconciliation Type")} - - - - {transaction?.payment_entries?.map((voucher) => { - return - - - {`${_(voucher.payment_document)}: ${voucher.payment_entry}`} - - - {formatCurrency(voucher.allocated_amount)} - {voucher.reconciliation_type === 'Voucher Created' ? - {_(voucher.reconciliation_type)} : - {_(voucher.reconciliation_type ?? "Matched")}} - - })} - -
-
- {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && The following documents will be cancelled:} - {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 &&
    - {vouchersWhichWillBeCancelled?.map((voucher) => { - return
  1. {_(voucher.payment_document)}: {voucher.payment_entry}
  2. - })} -
} -
-
- - {_("Cancel")} - - {_("Unreconcile")} - - -
-} - -export default BankTransactionUnreconcileModal \ No newline at end of file +export default BankTransactionUnreconcileModal diff --git a/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModalBody.tsx b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModalBody.tsx new file mode 100644 index 00000000000..6cb9da1e36e --- /dev/null +++ b/banking/src/components/features/BankReconciliation/BankTransactionUnreconcileModalBody.tsx @@ -0,0 +1,109 @@ +import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from "@/components/ui/alert-dialog" +import { useAtom, useAtomValue } from "jotai" +import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms" +import { useMemo } from "react" +import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { formatCurrency } from "@/lib/numbers" +import { Badge } from "@/components/ui/badge" +import { slug } from "@/lib/frappe" +import SelectedTransactionDetails from "./SelectedTransactionDetails" +import _ from "@/lib/translate" + +const BankTransactionUnreconcileModalBody = () => { + const bankAccount = useAtomValue(selectedBankAccountAtom) + const dates = useAtomValue(bankRecDateAtom) + + const { mutate } = useSWRConfig() + + const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom) + + const { data: transaction, error, isLoading } = useFrappeGetDoc('Bank Transaction', unreconcileModal) + + const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction') + + const onUnreconcile = (event: React.MouseEvent) => { + call({ + transaction_name: unreconcileModal + }).then(() => { + mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`) + mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`) + toast.success(_("Transaction Unreconciled")) + setBankRecUnreconcileModal('') + }) + + event.preventDefault() + } + + const vouchersWhichWillBeCancelled = useMemo(() => { + return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created') + }, [transaction]) + + return ( + <> +
+ {error && } + {unreconcileError && } + {transaction && } + {_("This transaction has been reconciled with the following document(s):")} + + + + {_("Document")} + {_("Amount")} + {_("Reconciliation Type")} + + + + {transaction?.payment_entries?.map((voucher) => { + return ( + + + + {`${_(voucher.payment_document)}: ${voucher.payment_entry}`} + + + {formatCurrency(voucher.allocated_amount)} + + {voucher.reconciliation_type === 'Voucher Created' ? + {_(voucher.reconciliation_type)} : + {_(voucher.reconciliation_type ?? "Matched")}} + + + ) + })} + +
+
+ {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && ( + The following documents will be cancelled: + )} + {vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && ( +
    + {vouchersWhichWillBeCancelled?.map((voucher) => { + return
  1. {_(voucher.payment_document)}: {voucher.payment_entry}
  2. + })} +
+ )} +
+
+ + {_("Cancel")} + + {_("Unreconcile")} + + + + ) +} + +export default BankTransactionUnreconcileModalBody diff --git a/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx b/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx index 58940aa6f91..aba419f1445 100644 --- a/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx +++ b/banking/src/components/features/BankReconciliation/IncorrectlyClearedEntries.tsx @@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => { }) }) }, - [clearClearingDate, mutate, _], + [clearClearingDate, mutate], ) const accountCurrency = useMemo( @@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => { ), }, ], - [_, accountCurrency, onClearClick], + [accountCurrency, onClearClick], ) return
diff --git a/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx b/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx index 32eeb672fcd..7549cf74150 100644 --- a/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx +++ b/banking/src/components/features/BankReconciliation/MatchAndReconcile.tsx @@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte import { Button } from "@/components/ui/button" import CurrencyInput from 'react-currency-input-field' import { getCurrencySymbol } from "@/lib/currency" -import { Virtuoso } from 'react-virtuoso' +import { useVirtualizer } from '@tanstack/react-virtual' import { formatDate } from "@/lib/date" import { Badge } from "@/components/ui/badge" import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers" @@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp import { Skeleton } from "@/components/ui/skeleton" import { slug } from "@/lib/frappe" import _ from "@/lib/translate" +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import TransferModal from "./TransferModal" import BankEntryModal from "./BankEntryModal" import RecordPaymentModal from "./RecordPaymentModal" -import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import SelectedTransactionsTable from "./SelectedTransactionsTable" import MatchFilters from "./MatchFilters" import { useHotkeys } from "react-hotkeys-hook" @@ -69,6 +69,59 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => { } +/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */ +function VirtualizedListBody({ + items, + height, + getItemKey, + children, + estimateSize = 74, +}: { + items: T[] + height: number + getItemKey: (item: T, index: number) => string | number + children: (item: T, index: number) => React.ReactNode + estimateSize?: number +}) { + const scrollRef = useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => estimateSize, + overscan: 8, + getItemKey: (index) => String(getItemKey(items[index], index)), + }) + + if (items.length === 0) { + return null + } + + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => ( +
+ {children(items[virtualRow.index], virtualRow.index)} +
+ ))} +
+
+ ) +} const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => { const bankAccount = useAtomValue(selectedBankAccountAtom) @@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) } const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0 + const listHeight = contentHeight - 72 if (isLoading) { return @@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")} description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />} - ( - - )} - style={{ minHeight: Math.max(contentHeight - 80, 400) }} - totalCount={results?.length} - /> + transaction.name} + > + {(transaction) => } +
} @@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom) const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom) - if (!rule) { - return null - } - const getActionIcon = () => { + if (!rule) return null switch (rule.classify_as) { case "Bank Entry": return @@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } const getActionStyles = () => { + if (!rule) return {} switch (rule.classify_as) { case "Bank Entry": return { @@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } const handleActionClick = () => { + if (!rule) return switch (rule.classify_as) { case "Bank Entry": setRecordJournalEntryModalOpen(true) @@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } const getActionDescription = () => { + if (!rule) return "" switch (rule.classify_as) { case "Bank Entry": return _("Create a journal entry for expenses, income or split transactions") @@ -636,8 +690,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = } } - useHotkeys('meta+r', () => { - // + useHotkeys('alt+r', () => { handleActionClick() }, { enabled: true, @@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) = const styles = getActionStyles() + if (!rule) { + return null + } + return ( @@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction) + const voucherList = vouchers?.message ?? [] + const listHeight = contentHeight - 120 + if (error) { return } @@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U or
- {vouchers?.message.length === 0 && + {voucherList.length === 0 && @@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U {_("No vouchers found for this transaction")} } - ( - - )} - style={{ height: contentHeight }} - totalCount={vouchers?.message.length} - /> + voucher.name} + > + {(voucher, index) => } + } diff --git a/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx b/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx index cebb82cd640..01bfee059d7 100644 --- a/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx +++ b/banking/src/components/features/BankReconciliation/RecordPaymentModal.tsx @@ -1,1301 +1,32 @@ -import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" -import { bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" -import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose, DialogTrigger } from "@/components/ui/dialog" +import { useAtom } from "jotai" +import { bankRecRecordPaymentModalAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog" +import { ModalContentFallback } from "@/components/ui/modal-content-fallback" import _ from "@/lib/translate" -import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" -import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" -import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" -import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk" -import { toast } from "sonner" -import ErrorBanner from "@/components/ui/error-banner" -import { Button } from "@/components/ui/button" -import SelectedTransactionDetails from "./SelectedTransactionDetails" -import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" -import { Form } from "@/components/ui/form" -import { ChangeEvent, useCallback, useContext, useEffect, useMemo, useState } from "react" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { Checkbox } from "@/components/ui/checkbox" -import { AlertCircleIcon, Plus, Trash2 } from "lucide-react" -import { flt, formatCurrency } from "@/lib/numbers" -import { cn } from "@/lib/utils" -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { PaymentEntry } from "@/types/Accounts/PaymentEntry" -import { H4 } from "@/components/ui/typography" -import { usePaymentEntryCalculations } from "@/hooks/usePaymentEntryCalculations" -import { MissingFiltersBanner } from "./MissingFiltersBanner" -import { formatDate, today } from "@/lib/date" -import { slug } from "@/lib/frappe" -import MarkdownRenderer from "@/components/ui/markdown" -import { Separator } from "@/components/ui/separator" -import { PaymentEntryDeduction } from "@/types/Accounts/PaymentEntryDeduction" -import { TableLoader } from "@/components/ui/loaders" -import SelectedTransactionsTable from "./SelectedTransactionsTable" -import { useCurrentCompany } from "@/hooks/useCurrentCompany" -import { Label } from "@/components/ui/label" -import { FileDropzone } from "@/components/ui/file-dropzone" -import { BankTransaction } from "@/types/Accounts/BankTransaction" -import FileUploadBanner from "@/components/common/FileUploadBanner" -import { useHotkeys } from "react-hotkeys-hook" +import { lazy, Suspense } from "react" + +const RecordPaymentModalContent = lazy(() => import('./RecordPaymentModalContent')) const RecordPaymentModal = () => { + const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom) - const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom) - - return ( - - - - {_("Record Payment")} - - {_("Record a payment entry against a customer or supplier")} - - - - - - ) + return ( + + + + {_("Record Payment")} + + {_("Record a payment entry against a customer or supplier")} + + + {isOpen && ( + }> + + + )} + + + ) } - -const RecordPaymentModalContent = () => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) - - if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { - return
- {_("No transaction selected")} -
- } - - if (selectedTransaction.length === 1) { - return - } - - return - -} - -const BulkPaymentEntryForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { - - - const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) - - const form = useForm<{ - party_type: PaymentEntry['party_type'], - party: PaymentEntry['party'], - party_name: PaymentEntry['party_name'], - /** GL account that's paid from or paid to */ - account: string - mode_of_payment: PaymentEntry['mode_of_payment'] - }>() - - const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_payment_entry_and_reconcile') - - const onReconcile = useRefreshUnreconciledTransactions() - - const addToActionLog = useUpdateActionLog() - - const onSubmit = (data: { party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], account: string, mode_of_payment: PaymentEntry['mode_of_payment'] }) => { - - createPaymentEntry({ - bank_transaction_names: transactions.map((transaction) => transaction.name), - party_type: data.party_type, - party: data.party, - account: data.account - }).then(({ message }) => { - - addToActionLog({ - type: 'payment', - timestamp: (new Date()).getTime(), - isBulk: true, - items: message.map((item) => ({ - bankTransaction: item.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: item.payment_entry.name, - reference_no: item.payment_entry.reference_no, - reference_date: item.payment_entry.reference_date, - posting_date: item.payment_entry.posting_date, - party_type: item.payment_entry.party_type, - party: item.payment_entry.party, - doc: item.payment_entry, - } - })), - bulkCommonData: { - party_type: data.party_type, - party: data.party, - account: data.account, - } - }) - - toast.success(_("Payment Recorded"), { - duration: 4000, - closeButton: true, - }) - onReconcile(transactions[transactions.length - 1]) - setIsOpen(false) - }) - } - - const party_type = useWatch({ control: form.control, name: 'party_type' }) - - const party_name = useWatch({ control: form.control, name: 'party_name' }) - - const party = useWatch({ control: form.control, name: 'party' }) - - const { call } = useContext(FrappeContext) as FrappeConfig - - const currentCompany = useCurrentCompany() - - const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') - - const onPartyChange = (event: ChangeEvent) => { - // Fetch the party and account - if (event.target.value) { - call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { - company: company, - party_type: party_type, - party: event.target.value, - date: today() - }).then((res) => { - form.setValue('party_name', res.message.party_name) - form.setValue('account', res.message.party_account) - }) - } else { - // Clear the party and account - form.setValue('party_name', '') - form.setValue('account', '') - } - - } - - return
- -
- - {error && } - - - -
-
- -
-
- {party_type ? : - } - - -
- -
- { - if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { - return acc.account_type === 'Payable' - } else if (party_type === 'Customer') { - return acc.account_type === 'Receivable' - } - return true - }} - /> -
- -
- -
- -
- - - - - - - - -
-
- - -} - -const PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => { - - const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) - - const onClose = () => { - setIsOpen(false) - } - - const { data: rule } = useGetRuleForTransaction(selectedTransaction) - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const form = useForm({ - defaultValues: { - payment_type: isWithdrawal ? 'Pay' : 'Receive', - bank_account: selectedTransaction.bank_account, - company: selectedTransaction?.company, - // If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer - party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'), - party: rule?.party ?? '', - // If the transaction is a withdrawal, set the paid from to the selected bank account - paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // If the transaction is a deposit, set the paid to to the selected bank account - paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // Set the amount to the amount of the selected transaction - paid_amount: selectedTransaction.unallocated_amount, - base_paid_amount: selectedTransaction.unallocated_amount, - received_amount: selectedTransaction.unallocated_amount, - base_received_amount: selectedTransaction.unallocated_amount, - reference_date: selectedTransaction.date, - posting_date: selectedTransaction.date, - reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), - target_exchange_rate: 1, - source_exchange_rate: 1, - } - }) - - const onReconcile = useRefreshUnreconciledTransactions() - - const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen) - - useEffect(() => { - if (rule && rule.party && rule.party_type && rule.account) { - setUnpaidInvoiceOpen(true) - } - - }, [rule, setUnpaidInvoiceOpen]) - - const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_and_reconcile') - - const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) - - const addToActionLog = useUpdateActionLog() - - const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig - - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - const [files, setFiles] = useState([]) - - const onSubmit = (data: PaymentEntry) => { - - createPaymentEntry({ - bank_transaction_name: selectedTransaction.name, - payment_entry_doc: { - ...data, - custom_remarks: data.remarks ? true : false - } - }).then(async ({ message }) => { - addToActionLog({ - type: 'payment', - timestamp: (new Date()).getTime(), - isBulk: false, - items: [ - { - bankTransaction: message.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: message.payment_entry.name, - reference_no: message.payment_entry.reference_no, - reference_date: message.payment_entry.reference_date, - posting_date: message.payment_entry.posting_date, - doc: message.payment_entry, - } - } - ] - }) - toast.success(_("Payment Entry Created"), { - duration: 4000, - closeButton: true, - action: { - label: _("Undo"), - onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) - }, - actionButtonStyle: { - backgroundColor: "rgb(0, 138, 46)" - } - }) - - if (files.length > 0) { - setIsUploading(true) - - const uploadPromises = files.map(f => { - return frappeFile.uploadFile(f, { - isPrivate: true, - doctype: "Payment Entry", - docname: message.payment_entry.name, - }, (_bytesUploaded, _totalBytes, progress) => { - - setUploadProgress((currentProgress) => { - //If there are multiple files, we need to add the progress to the current progress - return currentProgress + ((progress?.progress ?? 0) / files.length) - }) - - }) - }) - - return Promise.all(uploadPromises).then(() => { - setUploadProgress(0) - setIsUploading(false) - }) - } else { - return Promise.resolve() - } - - }).then(() => { - setUploadProgress(0) - setIsUploading(false) - onReconcile(selectedTransaction) - onClose() - }) - } - - - useHotkeys('meta+s', () => { - form.handleSubmit(onSubmit)() - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: true - }) - - if (isUploading && isCompleted) { - return - } - - return
- -
- {error && } -
- -
-

{isWithdrawal ? _("Paid to") : _("Received from")}

-
-
- -
-
- -
- -
- -
- -
- -
- -
- -
-
- - - - - - - - - - - -
-
- -
- - -
- -
- - -
-
- - -
- - - - - - -
-
- -} - -const isUnpaidInvoicesButtonOpen = atom(false) - -const PartyField = () => { - - const { control, setValue } = useFormContext() - - const party_type = useWatch({ - control, - name: `party_type` - }) - - const { call } = useContext(FrappeContext) as FrappeConfig - - const company = useWatch({ control, name: 'company' }) - - const party_name = useWatch({ control, name: 'party_name' }) - - const type = useWatch({ control, name: 'payment_type' }) - - const party = useWatch({ control, name: 'party' }) - - const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) - - const onChange = (event: ChangeEvent) => { - // Fetch the party and account - if (event.target.value) { - call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { - company: company, - party_type: party_type, - party: event.target.value, - date: today() - }).then((res) => { - setValue('party_name', res.message.party_name) - if (type === 'Pay') { - setValue('paid_to', res.message.party_account) - } else { - setValue('paid_from', res.message.party_account) - } - setIsOpen(true) - }) - } else { - // Clear the party and account - setValue('party_name', '') - if (type === 'Pay') { - setValue('paid_to', '') - } else { - setValue('paid_from', '') - } - } - - } - - if (!party_type) { - return - } - - return -} - - -const AccountDropdown = ({ isWithdrawal }: { isWithdrawal: boolean }) => { - - // If it's a withdrawal, then we need to show the "Paid to" account - // If it's a deposit, then we need to show the "Paid from" account - - const { control, setValue } = useFormContext() - - const party_type = useWatch({ control, name: 'party_type' }) - - const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) - - const accountTypes: string[] | undefined = useMemo(() => { - if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { - return ['Payable'] - } else if (party_type === 'Customer') { - return ['Receivable'] - } - return undefined - }, [party_type]) - - const onAccountChange = (event: ChangeEvent) => { - if (event.target.value) { - setValue('unallocated_amount', 0) - setValue('total_allocated_amount', 0) - setValue('difference_amount', 0) - setValue('references', []) - setIsOpen(true) - } - } - - - if (isWithdrawal) { - return - - } else { - return - } - -} - - -const InvoicesSection = ({ currency }: { currency: string }) => { - - const { setTotalAllocatedAmount } = usePaymentEntryCalculations() - - const { control } = useFormContext() - const { fields, remove } = useFieldArray({ - control, - name: 'references' - }) - - const [selectedRows, setSelectedRows] = useState([]) - - const onSelectRow = useCallback((index: number) => { - setSelectedRows(prev => { - if (prev.includes(index)) { - return prev.filter(i => i !== index) - } - return [...prev, index] - }) - }, []) - - const onSelectAll = useCallback(() => { - setSelectedRows(prev => { - if (prev.length === fields.length) { - return [] - } - return [...fields.map((_, index) => index)] - }) - }, [fields]) - - const onRemove = useCallback(() => { - remove(selectedRows) - setSelectedRows([]) - }, [remove, selectedRows]) - - return
-
-

{_("Invoices")}

- -
- - - - 0 && selectedRows.length === fields.length} - onCheckedChange={onSelectAll} /> - {_("Reference Document")} - {_("Invoice No")} - {_("Due Date")} - {_("Grand Total")} - {_("Outstanding")} - {_("Allocated")} - - - - - {fields.map((field, index) => ( - - - onSelectRow(index)} - // Make this accessible to screen readers - aria-label={_("Select row {0}", [String(index + 1)])} - /> - - - - {field.reference_doctype}: {field.reference_name} - - - {field.bill_no ?? "-"} - - - {formatDate(field.due_date)} - - - {formatCurrency(field.total_amount, currency)} - - - {formatCurrency(field.outstanding_amount, currency)} - - - setTotalAllocatedAmount() - }} - hideLabel - currency={currency} - /> - - - - - - ))} - -
-
-
- {selectedRows.length > 0 &&
- -
} -
- -
-
- -} - -const DifferenceButton = ({ index, currency }: { index: number, currency: string }) => { - - const { setTotalAllocatedAmount } = usePaymentEntryCalculations() - - const { control, setValue } = useFormContext() - - const outstandingAmount = useWatch({ - control, - name: `references.${index}.outstanding_amount` - }) ?? 0 - - const allocatedAmount = useWatch({ - control, - name: `references.${index}.allocated_amount` - }) ?? 0 - - const difference = flt(outstandingAmount - allocatedAmount, 2) - - const onPayInFull = useCallback(() => { - setValue(`references.${index}.allocated_amount`, outstandingAmount, { shouldDirty: true }) - setTotalAllocatedAmount() - }, [outstandingAmount, index, setValue, setTotalAllocatedAmount]) - - if (difference !== 0) { - - return - - - - - {_("The invoice is not fully allocated as there is a difference of {0}.", [formatCurrency(difference, currency) ?? ''])} -
- {_("Click to pay in full.")} -
-
- - } - - return null -} - -const Summary = ({ currency }: { currency: string }) => { - - const { control, setValue, getValues } = useFormContext() - - const { setUnallocatedAmount } = usePaymentEntryCalculations() - - const amount = useWatch({ - control, - name: 'paid_amount' - }) - - const unallocatedAmount = useWatch({ - control, - name: 'unallocated_amount' - }) - - const allocatedAmount = useWatch({ - control, - name: 'total_allocated_amount' - }) - - const differenceAmount = useWatch({ - control, - name: 'difference_amount' - }) - - const onAddRow = useCallback((amount?: number) => { - if (amount) { - const deductions = getValues('deductions') ?? [] - - setValue('deductions', [...deductions, { - amount: amount, - account: '', - cost_center: getCompanyCostCenter(getValues('company')), - description: '' - } as PaymentEntryDeduction]) - - setUnallocatedAmount() - } - }, [setUnallocatedAmount, getValues, setValue]) - - const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { - return {children} - } - - return
-
- {_("Total Amount")} - {formatCurrency(amount, currency)} -
-
- {_("Allocated")} - {formatCurrency(allocatedAmount, currency)} -
- - {(unallocatedAmount && unallocatedAmount !== 0) ?
- {_("Unallocated")} - - - - - - {_("Add a charge to the payment entry with the unallocated amount")} - - - - -
: null} - - {(differenceAmount && differenceAmount !== 0) ?
- {_("Difference")} - - - - - - {_("Add a charge to the payment entry with the difference amount")} - - - - -
: null} - -
-} -const GetUnpaidInvoicesButton = () => { - - const [isOpen, setIsOpen] = useAtom(isUnpaidInvoicesButtonOpen) - - const { control } = useFormContext() - - const partyType = useWatch({ control, name: 'party_type' }) - const party = useWatch({ control, name: 'party' }) - const partyName = useWatch({ control, name: 'party_name' }) - const amount = useWatch({ control, name: 'paid_amount' }) - - return <> - - - {partyType && party && - - } - - - Select Invoices - Unpaid invoices from {partyName} for {formatCurrency(amount)}. - - setIsOpen(false)} /> - - - -} - -interface OutstandingInvoice { - voucher_type: string - voucher_no: string - bill_no?: string - due_date: string - invoice_amount: number - outstanding_amount: number, - payment_term?: string, - payment_term_outstanding?: string, - account?: string, - allocated_amount?: number, -} -const FetchInvoicesModal = ({ onClose }: { onClose: () => void }) => { - - const { getValues, setValue } = useFormContext() - - const { allocatePartyAmount } = usePaymentEntryCalculations() - - const { data, isLoading, error } = useFrappeGetCall<{ - message: OutstandingInvoice[], - _server_messages?: string - }>('erpnext.accounts.doctype.payment_entry.payment_entry.get_outstanding_reference_documents', { - args: { - company: getValues('company'), - posting_date: getValues('posting_date'), - party_type: getValues('party_type'), - party: getValues('party'), - party_account: getValues('payment_type') === 'Pay' ? getValues('paid_to') : getValues('paid_from'), - get_outstanding_invoices: true, - allocate_payment_amount: 1 - } - }) - - const message = useMemo(() => { - if (data && data._server_messages) { - const message = JSON.parse(JSON.parse(data._server_messages)[0]) - - return message.message - } - return '' - }, [data]) - - const [selectedInvoices, setSelectedInvoices] = useState([]) - - const onSelectRow = (row: OutstandingInvoice) => { - if (selectedInvoices.includes(row)) { - setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== row)) - } else { - setSelectedInvoices([...selectedInvoices, row]) - } - } - - const { call: allocateAmountToReferences, loading: allocateAmountToReferencesLoading, error: allocateAmountToReferencesError } = useFrappePostCall('run_doc_method') - - const onSelect = () => { - - allocateAmountToReferences({ - args: { - paid_amount: getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount"), - allocate_payment_amount: 1, - paid_amount_change: false - }, - method: 'allocate_amount_to_references', - docs: { - doctype: 'Payment Entry', - ...getValues(), - name: "new-payment-entry-1", - __unsaved: 1, - __islocal: 1, - references: selectedInvoices.map((ref: OutstandingInvoice) => ({ - reference_doctype: ref.voucher_type, - reference_name: ref.voucher_no, - due_date: ref.due_date, - total_amount: ref.invoice_amount, - outstanding_amount: ref.outstanding_amount, - bill_no: ref.bill_no, - payment_term: ref.payment_term, - payment_term_outstanding: ref.payment_term_outstanding, - allocated_amount: ref.allocated_amount, - account: ref.account, - exchange_rate: 1, - })) - } - }).then((res) => { - const doc = res.docs[0] - setValue('references', doc.references) - setValue('unallocated_amount', doc.unallocated_amount) - setValue('total_allocated_amount', doc.total_allocated_amount) - setValue('difference_amount', doc.difference_amount) - - allocatePartyAmount(getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount")) - - onClose() - }) - } - return
- {isLoading ? : null} - {error && } - {error && } - {message ? } /> : null} - - {data?.message && data?.message?.length > 0 ? - - - - { - if (checked) { - setSelectedInvoices(data?.message) - } else { - setSelectedInvoices([]) - } - }} /> - - - Type - - - Name - - - Invoice No - - - Due Date - - - Grand Total - - - Outstanding - - - - - {data.message.map((ref) => ( - { - const target = e.target as HTMLElement - // Do not select the checkbox if the user clicks on the checkbox or the link - if (target.tagName !== 'INPUT' && !target.className.includes('chakra-checkbox') && !target.className.includes('chakra-link')) { - onSelectRow(ref) - } - }} - className="cursor-pointer"> - - { - if (checked) { - setSelectedInvoices([...selectedInvoices, ref]) - } else { - setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== ref)) - } - }} - /> - - - {ref.voucher_type} - - - {ref.voucher_no} - - - {ref.bill_no ?? "-"} - - - {formatDate(ref.due_date)} - - - {formatCurrency(ref.invoice_amount)} - - - {formatCurrency(ref.outstanding_amount)} - - - ))} - -
: null} -
-
- Invoices: {selectedInvoices.length} / - Total: {formatCurrency(selectedInvoices.reduce((acc, invoice) => acc + invoice.outstanding_amount, 0))} -
- - - - - - -
- -
-} - - - -const OtherChargesSection = ({ currency }: { currency: string }) => { - - const { setTotalAllocatedAmount } = usePaymentEntryCalculations() - const { getValues, control } = useFormContext() - - const { fields, append, remove } = useFieldArray({ - control: control, - name: 'deductions' - }) - - - const [selectedRows, setSelectedRows] = useState([]) - - const onSelectRow = useCallback((index: number) => { - setSelectedRows(prev => { - if (prev.includes(index)) { - return prev.filter(i => i !== index) - } - return [...prev, index] - }) - }, []) - - const onSelectAll = useCallback(() => { - setSelectedRows(prev => { - if (prev.length === fields.length) { - return [] - } - return [...fields.map((_, index) => index)] - }) - }, [fields]) - - const onRemove = useCallback(() => { - remove(selectedRows) - setSelectedRows([]) - setTotalAllocatedAmount() - }, [remove, selectedRows, setTotalAllocatedAmount]) - - const onAdd = () => { - - append({ - account: '', - cost_center: getCompanyCostCenter(getValues('company')), - description: '', - amount: 0 - } as PaymentEntryDeduction) - - - } - - return
-
-

Other Charges / Deductions

- -
- - - - 0 && selectedRows.length === fields.length} - onCheckedChange={onSelectAll} /> - {_("Account")} * - {_("Cost Center")} * - {_("Description")} - {_("Amount")} * - - - - {fields.map((field, index) => ( - - - onSelectRow(index)} - // Make this accessible to screen readers - aria-label={_("Select row {0}", [String(index + 1)])} - /> - - - - - - - - - - - - - { - setTotalAllocatedAmount() - } - }} - /> - - - ))} - -
-
-
-
- -
- {selectedRows.length > 0 &&
- -
} -
-
-
-} - -const TotalDeductions = ({ currency }: { currency: string }) => { - - const { control } = useFormContext() - - const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0 - - return ({formatCurrency(total_deductions, currency)}) -} -export default RecordPaymentModal \ No newline at end of file +export default RecordPaymentModal diff --git a/banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx b/banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx new file mode 100644 index 00000000000..bb42e85b17b --- /dev/null +++ b/banking/src/components/features/BankReconciliation/RecordPaymentModalContent.tsx @@ -0,0 +1,1279 @@ +import { atom, useAtom, useAtomValue, useSetAtom } from "jotai" +import { bankRecRecordPaymentModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms" +import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose, DialogTrigger } from "@/components/ui/dialog" +import _ from "@/lib/translate" +import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils" +import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form" +import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company" +import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk" +import { toast } from "sonner" +import ErrorBanner from "@/components/ui/error-banner" +import { Button } from "@/components/ui/button" +import SelectedTransactionDetails from "./SelectedTransactionDetails" +import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements" +import { Form } from "@/components/ui/form" +import { ChangeEvent, useCallback, useContext, useEffect, useMemo, useState } from "react" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Checkbox } from "@/components/ui/checkbox" +import { AlertCircleIcon, Plus, Trash2 } from "lucide-react" +import { flt, formatCurrency } from "@/lib/numbers" +import { cn } from "@/lib/utils" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" +import { PaymentEntry } from "@/types/Accounts/PaymentEntry" +import { H4 } from "@/components/ui/typography" +import { usePaymentEntryCalculations } from "@/hooks/usePaymentEntryCalculations" +import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress" +import { MissingFiltersBanner } from "./MissingFiltersBanner" +import { formatDate, today } from "@/lib/date" +import { slug } from "@/lib/frappe" +import MarkdownRenderer from "@/components/ui/markdown" +import { Separator } from "@/components/ui/separator" +import { PaymentEntryDeduction } from "@/types/Accounts/PaymentEntryDeduction" +import { TableLoader } from "@/components/ui/loaders" +import SelectedTransactionsTable from "./SelectedTransactionsTable" +import { useCurrentCompany } from "@/hooks/useCurrentCompany" +import { Label } from "@/components/ui/label" +import { FileDropzone } from "@/components/ui/file-dropzone" +import { BankTransaction } from "@/types/Accounts/BankTransaction" +import FileUploadBanner from "@/components/common/FileUploadBanner" +import { useHotkeys } from "react-hotkeys-hook" +const RecordPaymentModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkPaymentEntryForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { + + + const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) + + const form = useForm<{ + party_type: PaymentEntry['party_type'], + party: PaymentEntry['party'], + party_name: PaymentEntry['party_name'], + /** GL account that's paid from or paid to */ + account: string + mode_of_payment: PaymentEntry['mode_of_payment'] + }>() + + const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_payment_entry_and_reconcile') + + const onReconcile = useRefreshUnreconciledTransactions() + + const addToActionLog = useUpdateActionLog() + + const onSubmit = (data: { party_type: PaymentEntry['party_type'], party: PaymentEntry['party'], account: string, mode_of_payment: PaymentEntry['mode_of_payment'] }) => { + + createPaymentEntry({ + bank_transaction_names: transactions.map((transaction) => transaction.name), + party_type: data.party_type, + party: data.party, + account: data.account, + mode_of_payment: data.mode_of_payment + }).then(({ message }) => { + + addToActionLog({ + type: 'payment', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: item.payment_entry.name, + reference_no: item.payment_entry.reference_no, + reference_date: item.payment_entry.reference_date, + posting_date: item.payment_entry.posting_date, + party_type: item.payment_entry.party_type, + party: item.payment_entry.party, + doc: item.payment_entry, + } + })), + bulkCommonData: { + party_type: data.party_type, + party: data.party, + account: data.account, + } + }) + + toast.success(_("Payment Recorded"), { + duration: 4000, + closeButton: true, + }) + onReconcile(transactions[transactions.length - 1]) + setIsOpen(false) + }) + } + + const party_type = useWatch({ control: form.control, name: 'party_type' }) + + const party_name = useWatch({ control: form.control, name: 'party_name' }) + + const party = useWatch({ control: form.control, name: 'party' }) + + const { call } = useContext(FrappeContext) as FrappeConfig + + const currentCompany = useCurrentCompany() + + const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') + + const onPartyChange = (event: ChangeEvent) => { + // Fetch the party and account + if (event.target.value) { + call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { + company: company, + party_type: party_type, + party: event.target.value, + date: today() + }).then((res) => { + form.setValue('party_name', res.message.party_name) + form.setValue('account', res.message.party_account) + }) + } else { + // Clear the party and account + form.setValue('party_name', '') + form.setValue('account', '') + } + + } + + return
+ +
+ + {error && } + + + +
+
+ +
+
+ {party_type ? : + } + + +
+ +
+ { + if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { + return acc.account_type === 'Payable' + } else if (party_type === 'Customer') { + return acc.account_type === 'Receivable' + } + return true + }} + /> +
+ +
+ +
+ +
+ + + + + + + + +
+
+ + +} + +const PaymentEntryForm = ({ selectedTransaction, selectedBankAccount }: { selectedTransaction: UnreconciledTransaction, selectedBankAccount: SelectedBank }) => { + + const setIsOpen = useSetAtom(bankRecRecordPaymentModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const form = useForm({ + defaultValues: { + payment_type: isWithdrawal ? 'Pay' : 'Receive', + bank_account: selectedTransaction.bank_account, + company: selectedTransaction?.company, + // If the money is paid, it's usually to a supplier. If it's received, it's usually from a customer + party_type: rule?.party_type ?? (isWithdrawal ? 'Supplier' : 'Customer'), + party: rule?.party ?? '', + // If the transaction is a withdrawal, set the paid from to the selected bank account + paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // If the transaction is a deposit, set the paid to to the selected bank account + paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // Set the amount to the amount of the selected transaction + paid_amount: selectedTransaction.unallocated_amount, + base_paid_amount: selectedTransaction.unallocated_amount, + received_amount: selectedTransaction.unallocated_amount, + base_received_amount: selectedTransaction.unallocated_amount, + reference_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + target_exchange_rate: 1, + source_exchange_rate: 1, + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const setUnpaidInvoiceOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + useEffect(() => { + if (rule && rule.party && rule.party_type && rule.account) { + setUnpaidInvoiceOpen(true) + } + + }, [rule, setUnpaidInvoiceOpen]) + + const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_and_reconcile') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress() + + const [files, setFiles] = useState([]) + + const onSubmit = (data: PaymentEntry) => { + + createPaymentEntry({ + bank_transaction_name: selectedTransaction.name, + payment_entry_doc: { + ...data, + custom_remarks: data.remarks ? true : false + } + }).then(async ({ message }) => { + addToActionLog({ + type: 'payment', + timestamp: (new Date()).getTime(), + isBulk: false, + items: [ + { + bankTransaction: message.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: message.payment_entry.name, + reference_no: message.payment_entry.reference_no, + reference_date: message.payment_entry.reference_date, + posting_date: message.payment_entry.posting_date, + doc: message.payment_entry, + } + } + ] + }) + toast.success(_("Payment Entry Created"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + + if (files.length > 0) { + setIsUploading(true) + startTracking(files.length) + + const uploadPromises = files.map((f, fileIndex) => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Payment Entry", + docname: message.payment_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + updateFileProgress(fileIndex, progress?.progress ?? 0) + }) + }) + + return Promise.all(uploadPromises).then(() => { + resetProgress() + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + + }).then(() => { + resetProgress() + setIsUploading(false) + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ +
+

{isWithdrawal ? _("Paid to") : _("Received from")}

+
+
+ +
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ + + + + + + + + + + +
+
+ +
+ + +
+ +
+ + +
+
+ + +
+ + + + + + +
+
+ +} + +const isUnpaidInvoicesButtonOpen = atom(false) + +const PartyField = () => { + + const { control, setValue } = useFormContext() + + const party_type = useWatch({ + control, + name: `party_type` + }) + + const { call } = useContext(FrappeContext) as FrappeConfig + + const company = useWatch({ control, name: 'company' }) + + const party_name = useWatch({ control, name: 'party_name' }) + + const type = useWatch({ control, name: 'payment_type' }) + + const party = useWatch({ control, name: 'party' }) + + const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + const onChange = (event: ChangeEvent) => { + // Fetch the party and account + if (event.target.value) { + call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', { + company: company, + party_type: party_type, + party: event.target.value, + date: today() + }).then((res) => { + setValue('party_name', res.message.party_name) + if (type === 'Pay') { + setValue('paid_to', res.message.party_account) + } else { + setValue('paid_from', res.message.party_account) + } + setIsOpen(true) + }) + } else { + // Clear the party and account + setValue('party_name', '') + if (type === 'Pay') { + setValue('paid_to', '') + } else { + setValue('paid_from', '') + } + } + + } + + if (!party_type) { + return + } + + return +} + + +const AccountDropdown = ({ isWithdrawal }: { isWithdrawal: boolean }) => { + + // If it's a withdrawal, then we need to show the "Paid to" account + // If it's a deposit, then we need to show the "Paid from" account + + const { control, setValue } = useFormContext() + + const party_type = useWatch({ control, name: 'party_type' }) + + const setIsOpen = useSetAtom(isUnpaidInvoicesButtonOpen) + + const accountTypes: string[] | undefined = useMemo(() => { + if (party_type === 'Supplier' || party_type === 'Employee' || party_type === 'Shareholder') { + return ['Payable'] + } else if (party_type === 'Customer') { + return ['Receivable'] + } + return undefined + }, [party_type]) + + const onAccountChange = (event: ChangeEvent) => { + if (event.target.value) { + setValue('unallocated_amount', 0) + setValue('total_allocated_amount', 0) + setValue('difference_amount', 0) + setValue('references', []) + setIsOpen(true) + } + } + + + if (isWithdrawal) { + return + + } else { + return + } + +} + + +const InvoicesSection = ({ currency }: { currency: string }) => { + + const { setTotalAllocatedAmount } = usePaymentEntryCalculations() + + const { control } = useFormContext() + const { fields, remove } = useFieldArray({ + control, + name: 'references' + }) + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + remove(selectedRows) + setSelectedRows([]) + }, [remove, selectedRows]) + + return
+
+

{_("Invoices")}

+ +
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Reference Document")} + {_("Invoice No")} + {_("Due Date")} + {_("Grand Total")} + {_("Outstanding")} + {_("Allocated")} + + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + /> + + + + {field.reference_doctype}: {field.reference_name} + + + {field.bill_no ?? "-"} + + + {formatDate(field.due_date)} + + + {formatCurrency(field.total_amount, currency)} + + + {formatCurrency(field.outstanding_amount, currency)} + + + setTotalAllocatedAmount() + }} + hideLabel + currency={currency} + /> + + + + + + ))} + +
+
+
+ {selectedRows.length > 0 &&
+ +
} +
+ +
+
+ +} + +const DifferenceButton = ({ index, currency }: { index: number, currency: string }) => { + + const { setTotalAllocatedAmount } = usePaymentEntryCalculations() + + const { control, setValue } = useFormContext() + + const outstandingAmount = useWatch({ + control, + name: `references.${index}.outstanding_amount` + }) ?? 0 + + const allocatedAmount = useWatch({ + control, + name: `references.${index}.allocated_amount` + }) ?? 0 + + const difference = flt(outstandingAmount - allocatedAmount, 2) + + const onPayInFull = useCallback(() => { + setValue(`references.${index}.allocated_amount`, outstandingAmount, { shouldDirty: true }) + setTotalAllocatedAmount() + }, [outstandingAmount, index, setValue, setTotalAllocatedAmount]) + + if (difference !== 0) { + + return + + + + + {_("The invoice is not fully allocated as there is a difference of {0}.", [formatCurrency(difference, currency) ?? ''])} +
+ {_("Click to pay in full.")} +
+
+ + } + + return null +} + +const Summary = ({ currency }: { currency: string }) => { + + const { control, setValue, getValues } = useFormContext() + + const { setUnallocatedAmount } = usePaymentEntryCalculations() + + const amount = useWatch({ + control, + name: 'paid_amount' + }) + + const unallocatedAmount = useWatch({ + control, + name: 'unallocated_amount' + }) + + const allocatedAmount = useWatch({ + control, + name: 'total_allocated_amount' + }) + + const differenceAmount = useWatch({ + control, + name: 'difference_amount' + }) + + const onAddRow = useCallback((amount?: number) => { + if (amount) { + const deductions = getValues('deductions') ?? [] + + setValue('deductions', [...deductions, { + amount: amount, + account: '', + cost_center: getCompanyCostCenter(getValues('company')), + description: '' + } as PaymentEntryDeduction]) + + setUnallocatedAmount() + } + }, [setUnallocatedAmount, getValues, setValue]) + + const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => { + return {children} + } + + return
+
+ {_("Total Amount")} + {formatCurrency(amount, currency)} +
+
+ {_("Allocated")} + {formatCurrency(allocatedAmount, currency)} +
+ + {(unallocatedAmount && unallocatedAmount !== 0) ?
+ {_("Unallocated")} + + + + + + {_("Add a charge to the payment entry with the unallocated amount")} + + + + +
: null} + + {(differenceAmount && differenceAmount !== 0) ?
+ {_("Difference")} + + + + + + {_("Add a charge to the payment entry with the difference amount")} + + + + +
: null} + +
+} +const GetUnpaidInvoicesButton = () => { + + const [isOpen, setIsOpen] = useAtom(isUnpaidInvoicesButtonOpen) + + const { control } = useFormContext() + + const partyType = useWatch({ control, name: 'party_type' }) + const party = useWatch({ control, name: 'party' }) + const partyName = useWatch({ control, name: 'party_name' }) + const amount = useWatch({ control, name: 'paid_amount' }) + + return <> + + + {partyType && party && + + } + + + Select Invoices + Unpaid invoices from {partyName} for {formatCurrency(amount)}. + + setIsOpen(false)} /> + + + +} + +interface OutstandingInvoice { + voucher_type: string + voucher_no: string + bill_no?: string + due_date: string + invoice_amount: number + outstanding_amount: number, + payment_term?: string, + payment_term_outstanding?: string, + account?: string, + allocated_amount?: number, +} +const FetchInvoicesModal = ({ onClose }: { onClose: () => void }) => { + + const { getValues, setValue } = useFormContext() + + const { allocatePartyAmount } = usePaymentEntryCalculations() + + const { data, isLoading, error } = useFrappeGetCall<{ + message: OutstandingInvoice[], + _server_messages?: string + }>('erpnext.accounts.doctype.payment_entry.payment_entry.get_outstanding_reference_documents', { + args: { + company: getValues('company'), + posting_date: getValues('posting_date'), + party_type: getValues('party_type'), + party: getValues('party'), + party_account: getValues('payment_type') === 'Pay' ? getValues('paid_to') : getValues('paid_from'), + get_outstanding_invoices: true, + allocate_payment_amount: 1 + } + }) + + const message = useMemo(() => { + if (data && data._server_messages) { + const message = JSON.parse(JSON.parse(data._server_messages)[0]) + + return message.message + } + return '' + }, [data]) + + const [selectedInvoices, setSelectedInvoices] = useState([]) + + const onSelectRow = (row: OutstandingInvoice) => { + if (selectedInvoices.includes(row)) { + setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== row)) + } else { + setSelectedInvoices([...selectedInvoices, row]) + } + } + + const { call: allocateAmountToReferences, loading: allocateAmountToReferencesLoading, error: allocateAmountToReferencesError } = useFrappePostCall('run_doc_method') + + const onSelect = () => { + + allocateAmountToReferences({ + args: { + paid_amount: getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount"), + allocate_payment_amount: 1, + paid_amount_change: false + }, + method: 'allocate_amount_to_references', + docs: { + doctype: 'Payment Entry', + ...getValues(), + name: "new-payment-entry-1", + __unsaved: 1, + __islocal: 1, + references: selectedInvoices.map((ref: OutstandingInvoice) => ({ + reference_doctype: ref.voucher_type, + reference_name: ref.voucher_no, + due_date: ref.due_date, + total_amount: ref.invoice_amount, + outstanding_amount: ref.outstanding_amount, + bill_no: ref.bill_no, + payment_term: ref.payment_term, + payment_term_outstanding: ref.payment_term_outstanding, + allocated_amount: ref.allocated_amount, + account: ref.account, + exchange_rate: 1, + })) + } + }).then((res) => { + const doc = res.docs[0] + setValue('references', doc.references) + setValue('unallocated_amount', doc.unallocated_amount) + setValue('total_allocated_amount', doc.total_allocated_amount) + setValue('difference_amount', doc.difference_amount) + + allocatePartyAmount(getValues("payment_type") === "Pay" ? getValues("paid_amount") : getValues("received_amount")) + + onClose() + }) + } + return
+ {isLoading ? : null} + {error && } + {allocateAmountToReferencesError && } + {message ? } /> : null} + + {data?.message && data?.message?.length > 0 ? + + + + { + if (checked) { + setSelectedInvoices(data?.message) + } else { + setSelectedInvoices([]) + } + }} /> + + + Type + + + Name + + + Invoice No + + + Due Date + + + Grand Total + + + Outstanding + + + + + {data.message.map((ref) => ( + { + const target = e.target as HTMLElement + // Do not select the checkbox if the user clicks on the checkbox or the link + if (target.tagName !== 'INPUT' && !target.className.includes('chakra-checkbox') && !target.className.includes('chakra-link')) { + onSelectRow(ref) + } + }} + className="cursor-pointer"> + + { + if (checked) { + setSelectedInvoices([...selectedInvoices, ref]) + } else { + setSelectedInvoices(selectedInvoices.filter((invoice) => invoice !== ref)) + } + }} + /> + + + {ref.voucher_type} + + + {ref.voucher_no} + + + {ref.bill_no ?? "-"} + + + {formatDate(ref.due_date)} + + + {formatCurrency(ref.invoice_amount)} + + + {formatCurrency(ref.outstanding_amount)} + + + ))} + +
: null} +
+
+ Invoices: {selectedInvoices.length} / + Total: {formatCurrency(selectedInvoices.reduce((acc, invoice) => acc + invoice.outstanding_amount, 0))} +
+ + + + + + +
+ +
+} + + + +const OtherChargesSection = ({ currency }: { currency: string }) => { + + const { setTotalAllocatedAmount } = usePaymentEntryCalculations() + const { getValues, control } = useFormContext() + + const { fields, append, remove } = useFieldArray({ + control: control, + name: 'deductions' + }) + + + const [selectedRows, setSelectedRows] = useState([]) + + const onSelectRow = useCallback((index: number) => { + setSelectedRows(prev => { + if (prev.includes(index)) { + return prev.filter(i => i !== index) + } + return [...prev, index] + }) + }, []) + + const onSelectAll = useCallback(() => { + setSelectedRows(prev => { + if (prev.length === fields.length) { + return [] + } + return [...fields.map((_, index) => index)] + }) + }, [fields]) + + const onRemove = useCallback(() => { + remove(selectedRows) + setSelectedRows([]) + setTotalAllocatedAmount() + }, [remove, selectedRows, setTotalAllocatedAmount]) + + const onAdd = () => { + + append({ + account: '', + cost_center: getCompanyCostCenter(getValues('company')), + description: '', + amount: 0 + } as PaymentEntryDeduction) + + + } + + return
+
+

Other Charges / Deductions

+ +
+ + + + 0 && selectedRows.length === fields.length} + onCheckedChange={onSelectAll} /> + {_("Account")} * + {_("Cost Center")} * + {_("Description")} + {_("Amount")} * + + + + {fields.map((field, index) => ( + + + onSelectRow(index)} + // Make this accessible to screen readers + aria-label={_("Select row {0}", [String(index + 1)])} + /> + + + + + + + + + + + + + { + setTotalAllocatedAmount() + } + }} + /> + + + ))} + +
+
+
+
+ +
+ {selectedRows.length > 0 &&
+ +
} +
+
+
+} + +const TotalDeductions = ({ currency }: { currency: string }) => { + + const { control } = useFormContext() + + const total_deductions = useWatch({ control, name: 'deductions' })?.reduce((acc: number, row: PaymentEntryDeduction) => acc + row.amount, 0) ?? 0 + + return ({formatCurrency(total_deductions, currency)}) +} + +export default RecordPaymentModalContent diff --git a/banking/src/components/features/BankReconciliation/TransferModal.tsx b/banking/src/components/features/BankReconciliation/TransferModal.tsx index fb824dbc6f7..7411abb3df6 100644 --- a/banking/src/components/features/BankReconciliation/TransferModal.tsx +++ b/banking/src/components/features/BankReconciliation/TransferModal.tsx @@ -1,555 +1,32 @@ -import { useAtom, useAtomValue, useSetAtom } from 'jotai' -import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms' -import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { useAtom } from 'jotai' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { ModalContentFallback } from '@/components/ui/modal-content-fallback' import _ from '@/lib/translate' -import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils' -import { Button } from '@/components/ui/button' -import SelectedTransactionDetails from './SelectedTransactionDetails' -import { PaymentEntry } from '@/types/Accounts/PaymentEntry' -import { useForm, useFormContext, useWatch } from 'react-hook-form' -import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk' -import { toast } from 'sonner' -import ErrorBanner from '@/components/ui/error-banner' -import { H4 } from '@/components/ui/typography' -import { cn } from '@/lib/utils' -import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react' -import { Separator } from '@/components/ui/separator' -import { Form } from '@/components/ui/form' -import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements' -import SelectedTransactionsTable from './SelectedTransactionsTable' -import { useCurrentCompany } from '@/hooks/useCurrentCompany' -import { formatDate } from '@/lib/date' -import { useContext, useMemo, useState } from 'react' -import { formatCurrency } from '@/lib/numbers' -import { Label } from '@/components/ui/label' -import { FileDropzone } from '@/components/ui/file-dropzone' -import FileUploadBanner from '@/components/common/FileUploadBanner' -import { BankTransaction } from '@/types/Accounts/BankTransaction' -import { useHotkeys } from 'react-hotkeys-hook' -import { useDirection } from '@/components/ui/direction' -import BankLogo from '@/components/common/BankLogo' +import { lazy, Suspense } from 'react' +import { bankRecTransferModalAtom } from './bankRecAtoms' + +const TransferModalContent = lazy(() => import('./TransferModalContent')) const TransferModal = () => { + const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom) - const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom) - - return ( - - - - {_("Transfer")} - - {_("Record an internal transfer to another bank/credit card/cash account.")} - - - - - - ) + return ( + + + + {_("Transfer")} + + {_("Record an internal transfer to another bank/credit card/cash account.")} + + + {isOpen && ( + }> + + + )} + + + ) } -const TransferModalContent = () => { - - const selectedBankAccount = useAtomValue(selectedBankAccountAtom) - - const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) - - if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { - return
- {_("No transaction selected")} -
- } - - if (selectedTransaction.length === 1) { - return - } - - return - -} - -const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { - - const form = useForm<{ - bank_account: string - }>() - - const setIsOpen = useSetAtom(bankRecTransferModalAtom) - - const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer') - - const onReconcile = useRefreshUnreconciledTransactions() - const addToActionLog = useUpdateActionLog() - - const onSubmit = (data: { bank_account: string }) => { - - createPaymentEntry({ - bank_transaction_names: transactions.map((transaction) => transaction.name), - bank_account: data.bank_account - }).then(({ message }) => { - addToActionLog({ - type: 'transfer', - timestamp: (new Date()).getTime(), - isBulk: true, - items: message.map((item) => ({ - bankTransaction: item.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: item.payment_entry.name, - posting_date: item.payment_entry.posting_date, - doc: item.payment_entry, - } - })), - bulkCommonData: { - bank_account: data.bank_account, - } - }) - toast.success(_("Transfer Recorded"), { - duration: 4000, - closeButton: true, - }) - onReconcile(transactions[transactions.length - 1]) - setIsOpen(false) - }) - - } - - const onAccountChange = (account: string) => { - form.setValue('bank_account', account) - } - - const selectedAccount = useWatch({ control: form.control, name: 'bank_account' }) - - const currentCompany = useCurrentCompany() - - const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') - - console.log("This is here", transactions) - - return
- -
- - {error && } - - - - - - - - - - - -
-
- - -} - -interface InternalTransferFormFields extends PaymentEntry { - mirror_transaction_name?: string -} - -const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => { - - - const setIsOpen = useSetAtom(bankRecTransferModalAtom) - - const onClose = () => { - setIsOpen(false) - } - - const { data: rule } = useGetRuleForTransaction(selectedTransaction) - - const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false - - const form = useForm({ - defaultValues: { - payment_type: 'Internal Transfer', - company: selectedTransaction?.company, - // If the transaction is a withdrawal, set the paid from to the selected bank account - paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // If the transaction is a deposit, set the paid to to the selected bank account - paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), - // Set the amount to the amount of the selected transaction - paid_amount: selectedTransaction.unallocated_amount, - received_amount: selectedTransaction.unallocated_amount, - reference_date: selectedTransaction.date, - posting_date: selectedTransaction.date, - reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), - } - }) - - const onReconcile = useRefreshUnreconciledTransactions() - - const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer') - - const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) - const addToActionLog = useUpdateActionLog() - - const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig - - const [isUploading, setIsUploading] = useState(false) - const [uploadProgress, setUploadProgress] = useState(0) - - const [files, setFiles] = useState([]) - - const onSubmit = (data: InternalTransferFormFields) => { - - createPaymentEntry({ - bank_transaction_name: selectedTransaction.name, - ...data, - custom_remarks: data.remarks ? true : false, - // Pass this to reconcile both at the same time - mirror_transaction_name: data.mirror_transaction_name - }).then(async ({ message }) => { - addToActionLog({ - type: 'transfer', - timestamp: (new Date()).getTime(), - isBulk: false, - items: [ - { - bankTransaction: message.transaction, - voucher: { - reference_doctype: "Payment Entry", - reference_name: message.payment_entry.name, - reference_no: message.payment_entry.reference_no, - reference_date: message.payment_entry.reference_date, - posting_date: message.payment_entry.posting_date, - doc: message.payment_entry, - } - } - ] - }) - toast.success(_("Transfer Recorded"), { - duration: 4000, - closeButton: true, - action: { - label: _("Undo"), - onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) - }, - actionButtonStyle: { - backgroundColor: "rgb(0, 138, 46)" - } - }) - - if (files.length > 0) { - setIsUploading(true) - - const uploadPromises = files.map(f => { - return frappeFile.uploadFile(f, { - isPrivate: true, - doctype: "Payment Entry", - docname: message.payment_entry.name, - }, (_bytesUploaded, _totalBytes, progress) => { - - setUploadProgress((currentProgress) => { - //If there are multiple files, we need to add the progress to the current progress - return currentProgress + ((progress?.progress ?? 0) / files.length) - }) - - }) - }) - - return Promise.all(uploadPromises).then(() => { - setUploadProgress(0) - setIsUploading(false) - }) - } else { - return Promise.resolve() - } - }).then(() => { - setUploadProgress(0) - setIsUploading(false) - onReconcile(selectedTransaction) - onClose() - }) - } - - - useHotkeys('meta+s', () => { - form.handleSubmit(onSubmit)() - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: true - }) - - const onAccountChange = (account: string, is_mirror: boolean = false) => { - //If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into - if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) { - form.setValue('paid_to', account) - } else { - form.setValue('paid_from', account) - } - - if (!is_mirror) { - // Reset the mirror transaction name - form.setValue('mirror_transaction_name', '') - } - } - - const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' }) - - const direction = useDirection() - - if (isUploading && isCompleted) { - return - } - - return
- -
- {error && } -
- - -
-
- - -
- -
-
- -
-

{isWithdrawal ? _('Transferred to') : _('Transferred from')}

- - -
-
-
-
- account.name !== selectedBankAccount.account} - isRequired - /> -
- -
- {direction === 'ltr' ? : } -
-
- account.name !== selectedBankAccount.account} - /> -
-
-
- -
-
- - - -
- - -
-
-
- - - - - - -
-
- -} - - -const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company: string }) => { - - const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount) - - return
- {banks.map((bank) => ( -
onAccountChange(bank.account ?? '')} - > - -
- {bank.account_name} {bank.bank_account_no && ({bank.bank_account_no})} - {bank.account} -
-
- ))} - -
- -} - -const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => { - - const { data } = useFrappeGetCall('frappe.client.get_value', { - doctype: 'Company', - filters: company, - fieldname: 'default_cash_account' - }, undefined, { - revalidateOnFocus: false, - revalidateIfStale: false, - }) - - const account = data?.message?.default_cash_account - - if (account) { - return
setSelectedAccount(account ?? '')} - > -
- -
-
- Cash - {data?.message?.default_cash_account} -
-
- } - - return null -} - - -const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => { - - const { setValue, watch } = useFormContext() - - const mirrorTransactionName = watch('mirror_transaction_name') - const paid_from = watch('paid_from') - const paid_to = watch('paid_to') - - const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', { - transaction_id: transaction.name - }, undefined, { - revalidateOnFocus: false, - revalidateIfStale: false, - }) - - // Get bank accounts to find the logo - const { banks } = useGetBankAccounts() - - const bank = useMemo(() => { - if (data?.message?.bank_account && banks) { - return banks.find(bank => bank.name === data.message.bank_account) - } - return null - }, [data?.message?.bank_account, banks]) - - const selectTransaction = () => { - if (data?.message) { - setValue('mirror_transaction_name', data.message.name) - onAccountChange(data.message.account, true) - } - } - - if (data?.message) { - - const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0 - - const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit - const currency = data.message.currency - - const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account - - const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected - - return (
-
-
-
-
- - {_("Suggested Transfer to {0}", [data.message.account])} -
-
- {_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])} - {_("Accepting the suggestion will reconcile both transactions.")} -
- -
-
- - {formatDate(data.message.date, 'Do MMM YYYY')} -
- {data.message.description} -
-
-
-
-
- -
-
-
- {isWithdrawal ? : } - {isWithdrawal ? _('Transferred Out') : _('Received')} -
-
- {formatCurrency(amount, currency)} -
- -
-
-
-
- ) - } - - return null -} - -export default TransferModal \ No newline at end of file +export default TransferModal diff --git a/banking/src/components/features/BankReconciliation/TransferModalContent.tsx b/banking/src/components/features/BankReconciliation/TransferModalContent.tsx new file mode 100644 index 00000000000..d24cafebe40 --- /dev/null +++ b/banking/src/components/features/BankReconciliation/TransferModalContent.tsx @@ -0,0 +1,530 @@ +import { useAtomValue, useSetAtom } from 'jotai' +import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms' +import { DialogFooter, DialogClose } from '@/components/ui/dialog' +import _ from '@/lib/translate' +import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils' +import { Button } from '@/components/ui/button' +import SelectedTransactionDetails from './SelectedTransactionDetails' +import { PaymentEntry } from '@/types/Accounts/PaymentEntry' +import { useForm, useFormContext, useWatch } from 'react-hook-form' +import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk' +import { toast } from 'sonner' +import ErrorBanner from '@/components/ui/error-banner' +import { H4 } from '@/components/ui/typography' +import { cn } from '@/lib/utils' +import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react' +import { Separator } from '@/components/ui/separator' +import { Form } from '@/components/ui/form' +import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements' +import SelectedTransactionsTable from './SelectedTransactionsTable' +import { useCurrentCompany } from '@/hooks/useCurrentCompany' +import { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress' +import { formatDate } from '@/lib/date' +import { useContext, useMemo, useState } from 'react' +import { formatCurrency } from '@/lib/numbers' +import { Label } from '@/components/ui/label' +import { FileDropzone } from '@/components/ui/file-dropzone' +import FileUploadBanner from '@/components/common/FileUploadBanner' +import { BankTransaction } from '@/types/Accounts/BankTransaction' +import { useHotkeys } from 'react-hotkeys-hook' +import { useDirection } from '@/components/ui/direction' +import BankLogo from '@/components/common/BankLogo' +const TransferModalContent = () => { + + const selectedBankAccount = useAtomValue(selectedBankAccountAtom) + + const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? '')) + + if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) { + return
+ {_("No transaction selected")} +
+ } + + if (selectedTransaction.length === 1) { + return + } + + return + +} + +const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => { + + const form = useForm<{ + bank_account: string + }>() + + const setIsOpen = useSetAtom(bankRecTransferModalAtom) + + const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer') + + const onReconcile = useRefreshUnreconciledTransactions() + const addToActionLog = useUpdateActionLog() + + const onSubmit = (data: { bank_account: string }) => { + + createPaymentEntry({ + bank_transaction_names: transactions.map((transaction) => transaction.name), + bank_account: data.bank_account + }).then(({ message }) => { + addToActionLog({ + type: 'transfer', + timestamp: (new Date()).getTime(), + isBulk: true, + items: message.map((item) => ({ + bankTransaction: item.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: item.payment_entry.name, + posting_date: item.payment_entry.posting_date, + doc: item.payment_entry, + } + })), + bulkCommonData: { + bank_account: data.bank_account, + } + }) + toast.success(_("Transfer Recorded"), { + duration: 4000, + closeButton: true, + }) + onReconcile(transactions[transactions.length - 1]) + setIsOpen(false) + }) + + } + + const onAccountChange = (account: string) => { + form.setValue('bank_account', account) + } + + const selectedAccount = useWatch({ control: form.control, name: 'bank_account' }) + + const currentCompany = useCurrentCompany() + + const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '') + + return
+ +
+ + {error && } + + + + + + + + + + + +
+
+ + +} + +interface InternalTransferFormFields extends PaymentEntry { + mirror_transaction_name?: string +} + +const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => { + + + const setIsOpen = useSetAtom(bankRecTransferModalAtom) + + const onClose = () => { + setIsOpen(false) + } + + const { data: rule } = useGetRuleForTransaction(selectedTransaction) + + const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false + + const form = useForm({ + defaultValues: { + payment_type: 'Internal Transfer', + company: selectedTransaction?.company, + // If the transaction is a withdrawal, set the paid from to the selected bank account + paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // If the transaction is a deposit, set the paid to to the selected bank account + paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''), + // Set the amount to the amount of the selected transaction + paid_amount: selectedTransaction.unallocated_amount, + received_amount: selectedTransaction.unallocated_amount, + reference_date: selectedTransaction.date, + posting_date: selectedTransaction.date, + reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140), + } + }) + + const onReconcile = useRefreshUnreconciledTransactions() + + const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer') + + const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom) + const addToActionLog = useUpdateActionLog() + + const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig + + const [isUploading, setIsUploading] = useState(false) + const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress() + + const [files, setFiles] = useState([]) + + const onSubmit = (data: InternalTransferFormFields) => { + + createPaymentEntry({ + bank_transaction_name: selectedTransaction.name, + ...data, + custom_remarks: data.remarks ? true : false, + // Pass this to reconcile both at the same time + mirror_transaction_name: data.mirror_transaction_name + }).then(async ({ message }) => { + addToActionLog({ + type: 'transfer', + timestamp: (new Date()).getTime(), + isBulk: false, + items: [ + { + bankTransaction: message.transaction, + voucher: { + reference_doctype: "Payment Entry", + reference_name: message.payment_entry.name, + reference_no: message.payment_entry.reference_no, + reference_date: message.payment_entry.reference_date, + posting_date: message.payment_entry.posting_date, + doc: message.payment_entry, + } + } + ] + }) + toast.success(_("Transfer Recorded"), { + duration: 4000, + closeButton: true, + action: { + label: _("Undo"), + onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name) + }, + actionButtonStyle: { + backgroundColor: "rgb(0, 138, 46)" + } + }) + + if (files.length > 0) { + setIsUploading(true) + startTracking(files.length) + + const uploadPromises = files.map((f, fileIndex) => { + return frappeFile.uploadFile(f, { + isPrivate: true, + doctype: "Payment Entry", + docname: message.payment_entry.name, + }, (_bytesUploaded, _totalBytes, progress) => { + updateFileProgress(fileIndex, progress?.progress ?? 0) + }) + }) + + return Promise.all(uploadPromises).then(() => { + resetProgress() + setIsUploading(false) + }) + } else { + return Promise.resolve() + } + }).then(() => { + resetProgress() + setIsUploading(false) + onReconcile(selectedTransaction) + onClose() + }) + } + + + useHotkeys('meta+s', () => { + form.handleSubmit(onSubmit)() + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: true + }) + + const onAccountChange = (account: string, is_mirror: boolean = false) => { + //If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into + if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) { + form.setValue('paid_to', account) + } else { + form.setValue('paid_from', account) + } + + if (!is_mirror) { + // Reset the mirror transaction name + form.setValue('mirror_transaction_name', '') + } + } + + const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' }) + + const direction = useDirection() + + if (isUploading && isCompleted) { + return + } + + return
+ +
+ {error && } +
+ + +
+
+ + +
+ +
+
+ +
+

{isWithdrawal ? _('Transferred to') : _('Transferred from')}

+ + +
+
+
+
+ account.name !== selectedBankAccount.account} + isRequired + /> +
+ +
+ {direction === 'ltr' ? : } +
+
+ account.name !== selectedBankAccount.account} + /> +
+
+
+ +
+
+ + + +
+ + +
+
+
+ + + + + + +
+
+ +} + + +const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => { + + const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount) + + return
+ {banks.map((bank) => ( + + ))} + +
+ +} + +const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => { + + const { data } = useFrappeGetCall('frappe.client.get_value', { + doctype: 'Company', + filters: company, + fieldname: 'default_cash_account' + }, undefined, { + revalidateOnFocus: false, + revalidateIfStale: false, + }) + + const account = data?.message?.default_cash_account + + if (account) { + return + } + + return null +} + + +const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => { + + const { setValue, watch } = useFormContext() + + const mirrorTransactionName = watch('mirror_transaction_name') + const paid_from = watch('paid_from') + const paid_to = watch('paid_to') + + const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', { + transaction_id: transaction.name + }, undefined, { + revalidateOnFocus: false, + revalidateIfStale: false, + }) + + // Get bank accounts to find the logo + const { banks } = useGetBankAccounts() + + const bank = useMemo(() => { + if (data?.message?.bank_account && banks) { + return banks.find(bank => bank.name === data.message.bank_account) + } + return null + }, [data?.message?.bank_account, banks]) + + const selectTransaction = () => { + if (data?.message) { + setValue('mirror_transaction_name', data.message.name) + onAccountChange(data.message.account, true) + } + } + + if (data?.message) { + + const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0 + + const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit + const currency = data.message.currency + + const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account + + const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected + + return (
+
+
+
+
+ + {_("Suggested Transfer to {0}", [data.message.account])} +
+
+ {_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])} + {_("Accepting the suggestion will reconcile both transactions.")} +
+ +
+
+ + {formatDate(data.message.date, 'Do MMM YYYY')} +
+ {data.message.description} +
+
+
+
+
+ +
+
+
+ {isWithdrawal ? : } + {isWithdrawal ? _('Transferred Out') : _('Received')} +
+
+ {formatCurrency(amount, currency)} +
+ +
+
+
+
+ ) + } + + return null +} + +export default TransferModalContent diff --git a/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx b/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx index 23edf987404..63b7df824d0 100644 --- a/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx +++ b/banking/src/components/features/BankStatementImporter/CSV/CSVImport.tsx @@ -1,6 +1,5 @@ import CSVRawDataPreview from './CSVRawDataPreview' import StatementDetails from './StatementDetails' -import _ from '@/lib/translate' import { GetStatementDetailsResponse } from '../import_utils' const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => { diff --git a/banking/src/components/features/Settings/KeyboardShortcuts.tsx b/banking/src/components/features/Settings/KeyboardShortcuts.tsx index 435a0ac2ab0..71feaf3afc9 100644 --- a/banking/src/components/features/Settings/KeyboardShortcuts.tsx +++ b/banking/src/components/features/Settings/KeyboardShortcuts.tsx @@ -4,7 +4,7 @@ import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys' import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import _ from '@/lib/translate' -import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react' +import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react' const Shortcuts = [ { @@ -32,7 +32,7 @@ const Shortcuts = [ } }, { - shortcut: R, + shortcut: R, action: { icon: , label: _("Accept Matching Rule"), diff --git a/banking/src/components/features/Settings/Preferences.tsx b/banking/src/components/features/Settings/Preferences.tsx index b182485a7ec..5db0e886fe9 100644 --- a/banking/src/components/features/Settings/Preferences.tsx +++ b/banking/src/components/features/Settings/Preferences.tsx @@ -20,7 +20,7 @@ export const Preferences = () => { const { updateDoc, error } = useFrappeUpdateDoc() - const onUpdate = (field: keyof AccountsSettings, value: any) => { + const onUpdate = (field: K, value: AccountsSettings[K]) => { mutate(updateDoc("Accounts Settings", "Accounts Settings", { [field]: value }), { diff --git a/banking/src/components/features/Settings/Settings.tsx b/banking/src/components/features/Settings/Settings.tsx index 623fceea4ac..acdb4e39dc1 100644 --- a/banking/src/components/features/Settings/Settings.tsx +++ b/banking/src/components/features/Settings/Settings.tsx @@ -1,95 +1,42 @@ import { Button } from '@/components/ui/button' import { Dialog, DialogTrigger } from '@/components/ui/dialog' -import { - SettingsDialog, - SettingsPanel, - SettingsPanels, - SettingsTabGroup, - SettingsTabItem, - SettingsTabs, -} from '@/components/ui/settings-dialog' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import _ from '@/lib/translate' -import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react' +import { SettingsIcon } from 'lucide-react' import { useState } from 'react' -import { Preferences } from './Preferences' -import MatchingRules from './MatchingRules' -import KeyboardShortcuts from './KeyboardShortcuts' import { useHotkeys } from 'react-hotkeys-hook' +import SettingsDialogContent from './SettingsDialogContent' const Settings = () => { + const [isOpen, setIsOpen] = useState(false) - const [isOpen, setIsOpen] = useState(false) + useHotkeys('shift+meta+g', () => { + setIsOpen(x => !x) + }, { + enabled: true, + preventDefault: true, + enableOnFormTags: false + }) - useHotkeys('shift+meta+g', () => { - setIsOpen(x => !x) - }, { - enabled: true, - preventDefault: true, - enableOnFormTags: false - }) - - return ( - - - - - - - - - {_("Settings")} - - - setIsOpen(false)}> - - - } - label={_("Preferences")} - value="preferences" - /> - } - label={_("Matching Rules")} - value="rules" - /> - {/* } - label={_("Bank Accounts")} - value="bank-accounts" - /> - } - label={_("Masters")} - value="masters" - /> */} - } - label={_("Keyboard Shortcuts")} - value="keyboard-shortcuts" - /> - - - - - - - - - - - - - - - - - - - ) + return ( + + + + + + + + + {_("Settings")} + + + {isOpen && ( + setIsOpen(false)} /> + )} + + ) } export default Settings diff --git a/banking/src/components/features/Settings/SettingsDialogContent.tsx b/banking/src/components/features/Settings/SettingsDialogContent.tsx new file mode 100644 index 00000000000..433ecb8691c --- /dev/null +++ b/banking/src/components/features/Settings/SettingsDialogContent.tsx @@ -0,0 +1,52 @@ +import { + SettingsDialog, + SettingsPanels, + SettingsTabGroup, + SettingsTabItem, + SettingsTabs, +} from '@/components/ui/settings-dialog' +import _ from '@/lib/translate' +import { KeyboardIcon, Loader2Icon, SlidersVerticalIcon, ZapIcon } from 'lucide-react' +import { lazy, Suspense } from 'react' + +const SettingsPanelsContent = lazy(() => import('./SettingsPanelsContent')) + +const SettingsPanelsFallback = () => ( +
+ +
+) + +const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => { + return ( + + + + } + label={_("Preferences")} + value="preferences" + /> + } + label={_("Matching Rules")} + value="rules" + /> + } + label={_("Keyboard Shortcuts")} + value="keyboard-shortcuts" + /> + + + + + }> + + + + + ) +} + +export default SettingsDialogContent diff --git a/banking/src/components/features/Settings/SettingsPanelsContent.tsx b/banking/src/components/features/Settings/SettingsPanelsContent.tsx new file mode 100644 index 00000000000..ac60de5c421 --- /dev/null +++ b/banking/src/components/features/Settings/SettingsPanelsContent.tsx @@ -0,0 +1,24 @@ +import { SettingsPanel } from '@/components/ui/settings-dialog' +import { Preferences } from './Preferences' +import MatchingRules from './MatchingRules' +import KeyboardShortcuts from './KeyboardShortcuts' + +const SettingsPanelsContent = () => { + return ( + <> + + + + + + + + + + + + + ) +} + +export default SettingsPanelsContent diff --git a/banking/src/components/ui/alert-dialog.tsx b/banking/src/components/ui/alert-dialog.tsx index cbab3099bb3..625f0b5eb0f 100644 --- a/banking/src/components/ui/alert-dialog.tsx +++ b/banking/src/components/ui/alert-dialog.tsx @@ -170,7 +170,7 @@ function AlertDialogCancel({ }: React.ComponentProps & Pick, "variant" | "size" | "theme">) { return ( - `; + + const icons = { + play: { d: '', fill: "currentColor", stroke: "none" }, + pause: { + d: '', + fill: "currentColor", + stroke: "none", + }, + check: { d: '', sw: 3 }, + }; + + const buttons_html = [ + show_start && btn("btn-primary jcd-btn-start", "play", __("Start Job")), + show_resume && btn("btn-primary jcd-btn-resume", "play", __("Resume Job")), + show_pause && btn("btn-default jcd-btn-pause", "pause", __("Pause Job")), + show_complete && btn("btn-primary jcd-btn-complete", "check", __("Complete Job"), "white"), + ] + .filter(Boolean) + .join(""); + + // ── Render widget ───────────────────────────────────────────────── + wrapper.append(` +
+
+
+
+ ${__("Elapsed Time")} +
+
+ ${frappe.utils.icon("clock-4", "md", "", "", "", "", timer_color)} + + 00:00:00 + +
+
+
+ ${buttons_html} +
+
+
`); + + // ── Wire up button click handlers ───────────────────────────────── + if (show_start) { + wrapper.find(".jcd-btn-start").on("click", () => { + const from_time = frappe.datetime.now_datetime(); + const has_no_employee = !frm.doc.employee || !frm.doc.employee.length; + + if (has_no_employee) { + frappe.prompt( + { + fieldtype: "Table MultiSelect", + label: __("Select Employees"), + options: "Job Card Time Log", + fieldname: "employees", + reqd: 1, + filters: { status: "Active" }, + }, + (d) => frm.events.start_timer(frm, from_time, d.employees), + __("Assign Job to Employee") + ); + } else { + frm.events.start_timer(frm, from_time, frm.doc.employee); + } + }); + } + + if (show_resume) { + wrapper.find(".jcd-btn-resume").on("click", () => { + frm.call({ + method: "resume_job", + doc: frm.doc, + args: { start_time: frappe.datetime.now_datetime() }, + callback() { + frm.reload_doc(); + }, + }); + }); + } + + if (show_pause) { + wrapper.find(".jcd-btn-pause").on("click", () => { + frm.call({ + method: "pause_job", + doc: frm.doc, + args: { end_time: frappe.datetime.now_datetime() }, + callback() { + frm.reload_doc(); + }, + }); + }); + } + + if (show_complete) { + wrapper.find(".jcd-btn-complete").on("click", () => { + frm.trigger("complete_job_card"); + }); + } + + // ── Timer tick ──────────────────────────────────────────────────── + const timer_el = wrapper.find(".jcd-stopwatch"); + const pad = (n) => String(n).padStart(2, "0"); + const update_stopwatch = (secs) => { + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + const s = Math.floor(secs % 60); + timer_el.text(`${pad(h)}:${pad(m)}:${pad(s)}`); + }; + + let current_increment = frm.events.get_current_time(frm); + update_stopwatch(current_increment); + + if (is_actively_running) { + frm._jcd_timer_interval = setInterval(() => { + current_increment += 1; + update_stopwatch(current_increment); }, 1000); } - frm.dashboard.refresh(); - const timer = ` -
- 00 - : - 00 - : - 00 -
`; - - if (frappe.utils.is_xs()) { - frm.dashboard.add_comment(timer, "white", true); - section = frm.layout.wrapper.find(".form-message-container"); - } else { - section = frm.toolbar.page.add_inner_message(timer); + // Demote Submit to btn-default when an action button is already primary. + const has_action_button = show_start || show_resume || show_complete; + if (frm.page.btn_primary) { + frm.page.btn_primary + .toggleClass("btn-primary", !has_action_button) + .toggleClass("btn-default", has_action_button); } - let currentIncrement = frm.events.get_current_time(frm); - if (frm.doc.time_logs?.length && frm.doc.time_logs[cint(frm.doc.time_logs.length) - 1].to_time) { - updateStopwatch(currentIncrement); - } else if (frm.doc.status == "On Hold") { - updateStopwatch(currentIncrement); - } else { - initialiseTimer(); - } + return is_timer_running; }, get_current_time(frm) { @@ -719,22 +795,26 @@ frappe.ui.form.on("Job Card", { return current_time; }, - hide_timer: function (frm) { - frm.toolbar.page.inner_toolbar.find(".stopwatch").remove(); + hide_timer(frm) { + if (frm._jcd_timer_interval) { + clearInterval(frm._jcd_timer_interval); + frm._jcd_timer_interval = null; + } + $(frm.fields_dict["job_card_dashboard"].wrapper).empty(); }, - for_quantity: function (frm) { + for_quantity(frm) { frm.doc.items = []; frm.call({ method: "get_required_items", doc: frm.doc, - callback: function () { + callback() { refresh_field("items"); }, }); }, - make_material_request: function (frm) { + make_material_request(frm) { frappe.model.open_mapped_doc({ method: "erpnext.manufacturing.doctype.job_card.job_card.make_material_request", frm: frm, @@ -742,7 +822,7 @@ frappe.ui.form.on("Job Card", { }); }, - make_stock_entry: function (frm) { + make_stock_entry(frm) { frappe.model.open_mapped_doc({ method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry", frm: frm, @@ -750,11 +830,7 @@ frappe.ui.form.on("Job Card", { }); }, - timer: function (frm) { - return ``; - }, - - set_total_completed_qty: function (frm) { + set_total_completed_qty(frm) { frm.doc.total_completed_qty = 0; frm.doc.time_logs.forEach((d) => { if (d.completed_qty) { @@ -763,10 +839,9 @@ frappe.ui.form.on("Job Card", { }); if (frm.doc.total_completed_qty && frm.doc.for_quantity > frm.doc.total_completed_qty) { - let flt_precision = precision("for_quantity", frm.doc); - let process_loss_qty = + const flt_precision = precision("for_quantity", frm.doc); + const process_loss_qty = flt(frm.doc.for_quantity, flt_precision) - flt(frm.doc.total_completed_qty, flt_precision); - frm.set_value("process_loss_qty", process_loss_qty); } @@ -783,8 +858,8 @@ frappe.ui.form.on("Job Card", { }); frappe.ui.form.on("Job Card Time Log", { - completed_qty: function (frm, cdt, cdn) { - let row = locals[cdt][cdn]; + completed_qty(frm, cdt, cdn) { + const row = locals[cdt][cdn]; if (!row.completed_qty) { frappe.model.set_value(row.doctype, row.name, { time_in_mins: 0, @@ -801,12 +876,8 @@ function get_seconds_diff(d1, d2) { } function get_last_completed_row(time_logs) { - let completed_rows = time_logs.filter((d) => d.to_time); - - if (completed_rows?.length) { - let last_completed_row = completed_rows[completed_rows.length - 1]; - return last_completed_row; - } + const completed_rows = time_logs.filter((d) => d.to_time); + return completed_rows[completed_rows.length - 1]; } function get_last_row(time_logs) { diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 21f0adbdd74..69a156009b1 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -1,30 +1,40 @@ { "actions": [], + "allow_bulk_edit": 1, "autoname": "naming_series:", "creation": "2026-03-31 21:06:16.282931", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "section_break_smqo", + "job_card_dashboard", + "section_break_fsba", + "work_order", + "column_break_uqjq", + "production_item", + "column_break_qrpg", + "for_quantity", + "column_break_yecz", + "bom_no", + "section_break_oisd", "company", "naming_series", - "work_order", - "employee", "column_break_4", "posting_date", - "project", - "bom_no", - "is_subcontracted", "semi_finished_good__finished_good_section", "finished_good", - "production_item", - "semi_fg_bom", - "total_completed_qty", "column_break_mcnb", - "for_quantity", - "transferred_qty", - "manufactured_qty", + "semi_fg_bom", + "section_break_folk", + "pending_qty", + "column_break_cyjw", "process_loss_qty", + "total_completed_qty", + "section_break_wpjf", + "transferred_qty", + "column_break_lgte", + "manufactured_qty", "production_section", "operation", "source_warehouse", @@ -35,6 +45,7 @@ "workstation_type", "workstation", "target_warehouse", + "employee", "section_break_8", "items", "quality_inspection_section", @@ -71,8 +82,10 @@ "item_name", "requested_qty", "is_paused", + "is_subcontracted", "track_semi_finished_goods", "column_break_20", + "project", "remarks", "section_break_dfoc", "status", @@ -155,6 +168,7 @@ "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "WIP Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "mandatory_depends_on": "eval:!doc.finished_good || doc.skip_material_transfer === 0 || (doc.skip_material_transfer && doc.backflush_from_wip_warehouse)", "options": "Warehouse" }, @@ -506,6 +520,7 @@ "fieldname": "target_warehouse", "fieldtype": "Link", "label": "Target Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "mandatory_depends_on": "eval:doc.track_semi_finished_goods", "options": "Warehouse" }, @@ -518,6 +533,7 @@ "fieldname": "source_warehouse", "fieldtype": "Link", "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -622,12 +638,64 @@ "fieldname": "secondary_items_section", "fieldtype": "Tab Break", "label": "Secondary Items" + }, + { + "fieldname": "section_break_folk", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "column_break_cyjw", + "fieldtype": "Column Break" + }, + { + "allow_on_submit": 1, + "fieldname": "pending_qty", + "fieldtype": "Float", + "label": "Pending Qty" + }, + { + "fieldname": "section_break_wpjf", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_lgte", + "fieldtype": "Column Break" + }, + { + "fieldname": "job_card_dashboard", + "fieldtype": "HTML" + }, + { + "fieldname": "section_break_oisd", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_uqjq", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_qrpg", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_yecz", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_smqo", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "section_break_fsba", + "fieldtype": "Section Break" } ], "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2026-03-31 21:06:48.987740", + "modified": "2026-05-21 18:37:05.688342", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index b3d4301addf..9d2270f2ce5 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -104,6 +104,7 @@ class JobCard(Document): operation_id: DF.Data | None operation_row_id: DF.Int operation_row_number: DF.Literal[None] + pending_qty: DF.Float posting_date: DF.Date | None process_loss_qty: DF.Float production_item: DF.Link | None @@ -176,7 +177,7 @@ class JobCard(Document): self.validate_semi_finished_goods() def validate_semi_finished_goods(self): - if not self.track_semi_finished_goods: + if not self.track_semi_finished_goods or self.is_subcontracted: return if self.items and not self.transferred_qty and not self.skip_material_transfer: @@ -294,7 +295,7 @@ class JobCard(Document): "stock_qty": values.qty, "item_name": values.item_name, "stock_uom": values.stock_uom, - "type": values.type, + "secondary_item_type": values.secondary_item_type, "bom_secondary_item": values.name, } @@ -882,7 +883,9 @@ class JobCard(Document): precision = self.precision("total_completed_qty") total_completed_qty = flt( - flt(self.total_completed_qty, precision) + flt(self.process_loss_qty, precision) + flt(self.total_completed_qty, precision) + + flt(self.process_loss_qty, precision) + + flt(self.pending_qty, precision) ) if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision): @@ -929,8 +932,10 @@ class JobCard(Document): self.process_loss_qty = 0.0 if self.total_completed_qty and self.for_quantity > self.total_completed_qty: - self.process_loss_qty = flt(self.for_quantity, precision) - flt( - self.total_completed_qty, precision + self.process_loss_qty = ( + flt(self.for_quantity, precision) + - flt(self.total_completed_qty, precision) + - flt(self.pending_qty, precision) ) def update_work_order(self): @@ -944,13 +949,14 @@ class JobCard(Document): ): return - for_quantity, time_in_mins, process_loss_qty = 0, 0, 0 + for_quantity, time_in_mins, process_loss_qty, pending_qty = 0, 0, 0, 0 data = self.get_current_operation_data() if data and len(data) > 0: for_quantity = flt(data[0].completed_qty) time_in_mins = flt(data[0].time_in_mins) process_loss_qty = flt(data[0].process_loss_qty) + pending_qty = flt(data[0].pending_qty) wo = frappe.get_doc("Work Order", self.work_order) @@ -958,8 +964,8 @@ class JobCard(Document): self.update_corrective_in_work_order(wo) elif self.operation_id: - self.validate_produced_quantity(for_quantity, process_loss_qty, wo) - self.update_work_order_data(for_quantity, process_loss_qty, time_in_mins, wo) + self.validate_produced_quantity(for_quantity, process_loss_qty, pending_qty, wo) + self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo) def update_semi_finished_good_details(self): if self.operation_id: @@ -988,11 +994,11 @@ class JobCard(Document): wo.flags.ignore_validate_update_after_submit = True wo.save() - def validate_produced_quantity(self, for_quantity, process_loss_qty, wo): + def validate_produced_quantity(self, for_quantity, process_loss_qty, pending_qty, wo): if self.docstatus < 2: return - if wo.produced_qty > for_quantity + process_loss_qty: + if wo.produced_qty > for_quantity + process_loss_qty + pending_qty: first_part_msg = _( "The {0} {1} is used to calculate the valuation cost for the finished good {2}." ).format(frappe.bold(_("Job Card")), frappe.bold(self.name), frappe.bold(self.production_item)) @@ -1005,7 +1011,7 @@ class JobCard(Document): _("{0} {1}").format(first_part_msg, second_part_msg), JobCardCancelError, title=_("Error") ) - def update_work_order_data(self, for_quantity, process_loss_qty, time_in_mins, wo): + def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo): workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate") jc = frappe.qb.DocType("Job Card") jctl = frappe.qb.DocType("Job Card Time Log") @@ -1027,6 +1033,7 @@ class JobCard(Document): if data.get("name") == self.operation_id: data.completed_qty = for_quantity data.process_loss_qty = process_loss_qty + data.pending_qty = pending_qty data.actual_operation_time = time_in_mins data.actual_start_time = time_data[0].start_time if time_data else None data.actual_end_time = time_data[0].end_time if time_data else None @@ -1052,6 +1059,7 @@ class JobCard(Document): {"SUM": "total_time_in_mins", "as": "time_in_mins"}, {"SUM": "total_completed_qty", "as": "completed_qty"}, {"SUM": "process_loss_qty", "as": "process_loss_qty"}, + {"SUM": "pending_qty", "as": "pending_qty"}, ], filters={ "docstatus": 1, @@ -1446,10 +1454,19 @@ class JobCard(Document): if isinstance(kwargs, dict): kwargs = frappe._dict(kwargs) - if kwargs.end_time: - if kwargs.for_quantity: - self.for_quantity = kwargs.for_quantity + if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0: + frappe.throw(_("Pending quantity cannot be negative.")) + if flt(kwargs.process_loss_qty) and flt(kwargs.process_loss_qty) < 0: + frappe.throw(_("Process loss quantity cannot be negative.")) + + if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity: + frappe.throw(_("Pending quantity cannot be greater than the for quantity.")) + + self.pending_qty = flt(kwargs.pending_qty) + self.process_loss_qty = flt(kwargs.process_loss_qty) + + if kwargs.end_time: self.add_time_logs( to_time=kwargs.end_time, completed_qty=kwargs.qty, @@ -1476,6 +1493,8 @@ class JobCard(Document): @frappe.whitelist() def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ManufactureStockEntry + def get_consumed_process_loss(): table = frappe.qb.DocType("Stock Entry") query = ( @@ -1511,11 +1530,9 @@ class JobCard(Document): ste.stock_entry.flags.ignore_mandatory = True wo_doc = frappe.get_doc("Work Order", self.work_order) add_additional_cost(ste.stock_entry, wo_doc, self) - - ste.stock_entry.pro_doc = frappe.get_doc("Work Order", self.work_order) - ste.stock_entry.set_secondary_items_from_job_card() + ManufactureStockEntry(ste.stock_entry).add_secondary_items_from_job_card() for row in ste.stock_entry.items: - if (row.type or row.is_legacy_scrap_item) and not row.t_warehouse: + if (row.secondary_item_type or row.is_legacy_scrap_item) and not row.t_warehouse: row.t_warehouse = self.target_warehouse if auto_submit: @@ -1562,9 +1579,7 @@ def make_subcontracting_po(source_name: str, target_doc: Document | str | None = "Job Card", source_name, { - "Job Card": { - "doctype": "Purchase Order", - }, + "Job Card": {"doctype": "Purchase Order", "field_no_map": ["naming_series"]}, }, target_doc, set_missing_values, diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py index a6ac3f0a79a..e7316259d01 100644 --- a/erpnext/manufacturing/doctype/job_card/test_job_card.py +++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py @@ -721,6 +721,7 @@ class TestJobCard(ERPNextTestSuite): ) jc.time_logs[0].completed_qty = 8 + jc.pending_qty = 0.0 jc.save() jc.submit() @@ -912,7 +913,7 @@ class TestJobCard(ERPNextTestSuite): "qty": 1, "process_loss_per": 10, "cost_allocation_per": 5, - "type": "Scrap", + "secondary_item_type": "Scrap", }, ) if submit: @@ -995,7 +996,8 @@ class TestJobCard(ERPNextTestSuite): }, ) job_card.append( - "secondary_items", {"item_code": scrap_extra.name, "stock_qty": 5, "type": "Co-Product"} + "secondary_items", + {"item_code": scrap_extra.name, "stock_qty": 5, "secondary_item_type": "Co-Product"}, ) job_card.submit() @@ -1014,7 +1016,7 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(manufacturing_entry.items[2].qty, 9) self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556) self.assertEqual(manufacturing_entry.items[3].item_code, scrap_extra.name) - self.assertEqual(manufacturing_entry.items[3].type, "Co-Product") + self.assertEqual(manufacturing_entry.items[3].secondary_item_type, "Co-Product") self.assertEqual(manufacturing_entry.items[3].qty, 5) self.assertEqual(manufacturing_entry.items[3].basic_rate, 0) @@ -1059,7 +1061,9 @@ class TestJobCard(ERPNextTestSuite): ) job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name}) - job_card.append("secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "type": "Scrap"}) + job_card.append( + "secondary_items", {"item_code": "_Test Item", "stock_qty": 2, "secondary_item_type": "Scrap"} + ) job_card.append( "time_logs", { @@ -1081,6 +1085,243 @@ class TestJobCard(ERPNextTestSuite): self.assertEqual(s.items[3].item_code, "_Test Item") self.assertEqual(s.items[3].transfer_qty, 2) + @ERPNextTestSuite.change_settings( + "Manufacturing Settings", {"overproduction_percentage_for_work_order": 100} + ) + def test_operating_cost_with_overproduction(self): + from erpnext.manufacturing.doctype.routing.test_routing import ( + create_routing, + setup_bom, + setup_operations, + ) + from erpnext.manufacturing.doctype.work_order.work_order import make_job_card + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as make_stock_entry_for_wo, + ) + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + workstation = make_workstation( + workstation_name="Test Workstation for Overproduction", hour_rate_rent=10, hour_rate_labour=10 + ) + operations = [ + {"operation": "Test Operation 1", "workstation": workstation.name, "time_in_mins": 30}, + {"operation": "Test Operation 2", "workstation": workstation.name, "time_in_mins": 30}, + ] + warehouse = create_warehouse("Test Warehouse for Overproduction") + setup_operations(operations) + + fg = make_item("Test FG for Overproduction", {"stock_uom": "Nos", "is_stock_item": 1}) + rm = make_item("Test RM for Overproduction", {"stock_uom": "Nos", "is_stock_item": 1}) + + routing_doc = create_routing(routing_name="Testing Route", operations=operations) + bom_doc = setup_bom( + item_code=fg.name, + routing=routing_doc.name, + raw_materials=[rm.name], + source_warehouse=warehouse, + ) + + for row in bom_doc.items: + make_stock_entry( + item_code=row.item_code, + target=row.source_warehouse, + qty=100, + basic_rate=100, + ) + + wo_doc = make_wo_order_test_record( + production_item=fg.name, + bom_no=bom_doc.name, + qty=10, + skip_transfer=1, + source_warehouse=warehouse, + ) + + first_operation = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 1}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", first_operation) + from_time = add_to_date(now(), days=1) + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=1), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + second_operation = frappe.get_all( + "Job Card", + filters={"work_order": wo_doc.name, "sequence_id": 2}, + fields=["name"], + order_by="sequence_id", + limit=1, + )[0].name + + jc = frappe.get_doc("Job Card", second_operation) + from_time = add_to_date(now(), days=2) + for _ in jc.scheduled_time_logs: + jc.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=2), + "completed_qty": 4, + }, + ) + jc.for_quantity = 4 + jc.save() + jc.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) # overproduction + s.submit() + + self.assertEqual(s.additional_costs[0].amount, 240) + self.assertEqual(s.additional_costs[1].amount, 240) + self.assertEqual(s.additional_costs[2].amount, 480) + self.assertEqual(s.additional_costs[3].amount, 480) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": "Test Operation 1", + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = add_to_date(now(), days=4) + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=1), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[1].name, + "operation": "Test Operation 2", + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = add_to_date(now(), days=5) + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=2), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 1)) + s2.submit() + + self.assertEqual(s2.additional_costs[0].amount, 120) + self.assertEqual(s2.additional_costs[1].amount, 120) + self.assertEqual(s2.additional_costs[2].amount, 240) + self.assertEqual(s2.additional_costs[3].amount, 240) + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[0].name, + "operation": "Test Operation 1", + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = add_to_date(now(), days=7) + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=1), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + make_job_card( + wo_doc.name, + [ + { + "name": wo_doc.operations[1].name, + "operation": "Test Operation 2", + "qty": 2, + "pending_qty": 2, + } + ], + ) + + job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name}) + from_time = add_to_date(now(), days=8) + job_card.append( + "time_logs", + { + "from_time": from_time, + "to_time": add_to_date(from_time, days=2), + "completed_qty": 2, + }, + ) + job_card.for_quantity = 2 + job_card.save() + job_card.submit() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 2)) + s.submit() + + self.assertEqual(s.additional_costs[0].amount, 240) + self.assertEqual(s.additional_costs[1].amount, 240) + self.assertEqual(s.additional_costs[2].amount, 480) + self.assertEqual(s.additional_costs[3].amount, 480) + + s2.cancel() + + s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 3)) + s.submit() + + self.assertEqual(s.additional_costs[0].amount, 240) + self.assertEqual(s.additional_costs[1].amount, 240) + self.assertEqual(s.additional_costs[2].amount, 480) + self.assertEqual(s.additional_costs[3].amount, 480) + def create_bom_with_multiple_operations(): "Create a BOM with multiple operations and Material Transfer against Job Card" diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index 69a7428f218..c8dd6403321 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2018-07-09 17:20:44.737289", "doctype": "DocType", "editable_grid": 1, @@ -34,6 +35,7 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -113,7 +115,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-12-04 14:30:19.472294", + "modified": "2026-05-12 12:22:18.506904", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Item", diff --git a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json index d9ac0e08ced..d367d7e308c 100644 --- a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json +++ b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.json @@ -5,7 +5,7 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "type", + "secondary_item_type", "description", "column_break_3", "item_code", @@ -69,7 +69,7 @@ "read_only": 1 }, { - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "in_list_view": 1, "label": "Type", @@ -87,7 +87,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-06 13:51:00.492621", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Secondary Item", diff --git a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py index 3a71ab9d755..db61f3cad48 100644 --- a/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py +++ b/erpnext/manufacturing/doctype/job_card_secondary_item/job_card_secondary_item.py @@ -22,7 +22,7 @@ class JobCardSecondaryItem(Document): parenttype: DF.Data stock_qty: DF.Float stock_uom: DF.Link | None - type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] # end: auto-generated types pass diff --git a/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py index ee5b1b96abe..48e04ebf050 100644 --- a/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py +++ b/erpnext/manufacturing/doctype/master_production_schedule/master_production_schedule.py @@ -289,7 +289,7 @@ class MasterProductionSchedule(Document): return item_wise_data def add_mps_data(self, data): - data = frappe._dict(sorted(data.items(), key=lambda x: x[0][1])) + data = frappe._dict(sorted(data.items(), key=lambda x: x[0][1] or "")) for key in data: row = data[key] diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 5c345666df0..ed502057349 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -1314,6 +1314,7 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p item_uom.conversion_factor, item.safety_stock, bom.item.as_("main_bom_item"), + bom.name.as_("main_bom"), ) .where( (bei.docstatus < 2) @@ -1383,6 +1384,7 @@ def get_subitems( item.purchase_uom, item_uom.conversion_factor, bom.item.as_("main_bom_item"), + bom.name.as_("main_bom"), bom_item.is_phantom_item, ) .where( @@ -1893,8 +1895,6 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company): precision = frappe.get_precision("Material Request Plan Item", "quantity") if flt(required_qty, precision) > 0: - required_qty = required_qty - if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"): required_qty = ceil(required_qty) diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 19b04032a3b..e7cab6deff5 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -900,8 +900,9 @@ class TestProductionPlan(ERPNextTestSuite): missing_warehouse = expected_warehouses - warehouses - self.assertTrue( - len(missing_warehouse) == 0, + self.assertEqual( + len(missing_warehouse), + 0, msg=f"Following warehouses were expected {', '.join(missing_warehouse)}", ) @@ -1392,7 +1393,7 @@ class TestProductionPlan(ERPNextTestSuite): validate_mr_items = [d.get("item_code") for d in items] for item_code in mr_items: - self.assertTrue(item_code in validate_mr_items) + self.assertIn(item_code, validate_mr_items) def test_reserved_qty_for_production_plan_for_material_requests(self): from erpnext.stock.utils import get_or_make_bin @@ -1510,7 +1511,7 @@ class TestProductionPlan(ERPNextTestSuite): non_completed_plans = get_non_completed_production_plans() for plan in plans: - self.assertTrue(plan in non_completed_plans) + self.assertIn(plan, non_completed_plans) def test_reserved_qty_for_production_plan_for_material_requests_with_multi_UOM(self): from erpnext.stock.utils import get_or_make_bin @@ -1721,13 +1722,13 @@ class TestProductionPlan(ERPNextTestSuite): for row in items: row = frappe._dict(row) if row.material_request_type == "Material Transfer": - self.assertTrue(row.uom == row.stock_uom) - self.assertTrue(row.from_warehouse in [wh1, wh2]) + self.assertEqual(row.uom, row.stock_uom) + self.assertIn(row.from_warehouse, [wh1, wh2]) self.assertEqual(row.quantity, 2) if row.material_request_type == "Purchase": - self.assertTrue(row.uom != row.stock_uom) - self.assertTrue(row.warehouse == mrp_warhouse) + self.assertNotEqual(row.uom, row.stock_uom) + self.assertEqual(row.warehouse, mrp_warhouse) self.assertEqual(row.quantity, 12.0) def test_mr_qty_for_complex_bom(self): @@ -2257,12 +2258,12 @@ class TestProductionPlan(ERPNextTestSuite): plan.save() - self.assertTrue(len(plan.sub_assembly_items) == 3) + self.assertEqual(len(plan.sub_assembly_items), 3) for row in plan.sub_assembly_items: self.assertEqual(row.required_qty, 15.0) self.assertEqual(row.qty, 10.0) - self.assertTrue(len(plan.mr_items) == 3) + self.assertEqual(len(plan.mr_items), 3) for row in plan.mr_items: self.assertEqual(row.required_bom_qty, 10.0) self.assertEqual(row.quantity, 5.0) @@ -2271,7 +2272,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 6) + self.assertEqual(len(reserved_entries), 6) for row in reserved_entries: self.assertEqual(row.reserved_qty, 5.0) @@ -2284,7 +2285,7 @@ class TestProductionPlan(ERPNextTestSuite): "Material Request", filters={"production_plan": plan.name}, pluck="name" ) - self.assertTrue(len(material_requests) > 0) + self.assertGreater(len(material_requests), 0) for mr_name in list(set(material_requests)): po = make_purchase_order(mr_name) po.supplier = "_Test Supplier" @@ -2295,7 +2296,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 9) + self.assertEqual(len(reserved_entries), 9) work_orders = frappe.get_all("Work Order", filters={"production_plan": plan.name}, pluck="name") for wo_name in list(set(work_orders)): @@ -2318,7 +2319,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 0) + self.assertEqual(len(reserved_entries), 0) frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) def test_stock_reservation_of_serial_nos_against_production_plan(self): @@ -2374,12 +2375,12 @@ class TestProductionPlan(ERPNextTestSuite): plan.save() - self.assertTrue(len(plan.sub_assembly_items) == 3) + self.assertEqual(len(plan.sub_assembly_items), 3) for row in plan.sub_assembly_items: self.assertEqual(row.required_qty, 15.0) self.assertEqual(row.qty, 10.0) - self.assertTrue(len(plan.mr_items) == 3) + self.assertEqual(len(plan.mr_items), 3) for row in plan.mr_items: self.assertEqual(row.required_bom_qty, 10.0) self.assertEqual(row.quantity, 5.0) @@ -2388,7 +2389,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 30) + self.assertEqual(len(reserved_entries), 30) for row in reserved_entries: self.assertEqual(row.reserved_qty, 5.0) @@ -2416,7 +2417,7 @@ class TestProductionPlan(ERPNextTestSuite): self.assertTrue(additional_serial_nos) - self.assertTrue(len(material_requests) > 0) + self.assertGreater(len(material_requests), 0) for mr_name in list(set(material_requests)): po = make_purchase_order(mr_name) po.supplier = "_Test Supplier" @@ -2427,7 +2428,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 45) + self.assertEqual(len(reserved_entries), 45) serial_nos_res_for_pp = frappe.get_all( "Serial and Batch Entry", filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, @@ -2453,8 +2454,8 @@ class TestProductionPlan(ERPNextTestSuite): ) for serial_no in serial_nos_res_for_wo: - self.assertTrue(serial_no in serial_nos_res_for_pp) - self.assertFalse(serial_no in additional_serial_nos) + self.assertIn(serial_no, serial_nos_res_for_pp) + self.assertNotIn(serial_no, additional_serial_nos) if wo_doc.production_item == "Finished Good For SR": self.assertEqual(len(reserved_entries), 15) @@ -2465,7 +2466,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 0) + self.assertEqual(len(reserved_entries), 0) frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) def test_stock_reservation_of_batch_nos_against_production_plan(self): @@ -2522,12 +2523,12 @@ class TestProductionPlan(ERPNextTestSuite): plan.save() - self.assertTrue(len(plan.sub_assembly_items) == 3) + self.assertEqual(len(plan.sub_assembly_items), 3) for row in plan.sub_assembly_items: self.assertEqual(row.required_qty, 15.0) self.assertEqual(row.qty, 10.0) - self.assertTrue(len(plan.mr_items) == 3) + self.assertEqual(len(plan.mr_items), 3) for row in plan.mr_items: self.assertEqual(row.required_bom_qty, 10.0) self.assertEqual(row.quantity, 5.0) @@ -2536,7 +2537,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 6) + self.assertEqual(len(reserved_entries), 6) for row in reserved_entries: self.assertEqual(row.reserved_qty, 5.0) @@ -2565,7 +2566,7 @@ class TestProductionPlan(ERPNextTestSuite): self.assertTrue(additional_batches) - self.assertTrue(len(material_requests) > 0) + self.assertGreater(len(material_requests), 0) for mr_name in list(set(material_requests)): po = make_purchase_order(mr_name) po.supplier = "_Test Supplier" @@ -2576,7 +2577,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 9) + self.assertEqual(len(reserved_entries), 9) batches_reserved_for_pp = frappe.get_all( "Serial and Batch Entry", filters={"parent": ("in", [x.name for x in reserved_entries]), "docstatus": 1}, @@ -2602,8 +2603,8 @@ class TestProductionPlan(ERPNextTestSuite): ) for batch_no in batches_reserved_for_wo: - self.assertTrue(batch_no in batches_reserved_for_pp) - self.assertFalse(batch_no in additional_batches) + self.assertIn(batch_no, batches_reserved_for_pp) + self.assertNotIn(batch_no, additional_batches) if wo_doc.production_item == "Finished Good For SR": self.assertEqual(len(reserved_entries), 3) @@ -2614,7 +2615,7 @@ class TestProductionPlan(ERPNextTestSuite): sre = StockReservation(plan) reserved_entries = sre.get_reserved_entries("Production Plan", plan.name) - self.assertTrue(len(reserved_entries) == 0) + self.assertEqual(len(reserved_entries), 0) frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0) def test_production_plan_for_partial_sub_assembly_items(self): @@ -2903,7 +2904,7 @@ def make_bom(**args): bom.append( "secondary_items", { - "type": "Scrap", + "secondary_item_type": "Scrap", "item_code": item, "item_name": item, "uom": item_doc.stock_uom, diff --git a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js index a7acec295a9..340b35852b1 100644 --- a/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js +++ b/erpnext/manufacturing/doctype/sales_forecast/sales_forecast.js @@ -47,11 +47,3 @@ frappe.ui.form.on("Sales Forecast", { } }, }); - -frappe.ui.form.on("Sales Forecast Item", { - adjust_qty(frm, cdt, cdn) { - let row = locals[cdt][cdn]; - row.demand_qty = row.forecast_qty + row.adjust_qty; - frappe.model.set_value(cdt, cdn, "demand_qty", row.demand_qty); - }, -}); diff --git a/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json index 16c5275c0b8..96cec964beb 100644 --- a/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json +++ b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.json @@ -10,8 +10,6 @@ "item_name", "uom", "delivery_date", - "forecast_qty", - "adjust_qty", "demand_qty", "warehouse" ], @@ -55,22 +53,6 @@ "label": "Delivery Date", "read_only": 1 }, - { - "columns": 2, - "fieldname": "forecast_qty", - "fieldtype": "Float", - "in_list_view": 1, - "label": "Forecast Qty", - "non_negative": 1, - "read_only": 1 - }, - { - "columns": 2, - "fieldname": "adjust_qty", - "fieldtype": "Float", - "in_list_view": 1, - "label": "Adjust Qty" - }, { "columns": 3, "fieldname": "demand_qty", @@ -94,7 +76,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-08-18 21:59:38.859082", + "modified": "2026-05-21 12:38:47.636301", "modified_by": "Administrator", "module": "Manufacturing", "name": "Sales Forecast Item", diff --git a/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py index 5fcb718fb50..cebb6004158 100644 --- a/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py +++ b/erpnext/manufacturing/doctype/sales_forecast_item/sales_forecast_item.py @@ -14,10 +14,8 @@ class SalesForecastItem(Document): if TYPE_CHECKING: from frappe.types import DF - adjust_qty: DF.Float delivery_date: DF.Date | None demand_qty: DF.Float - forecast_qty: DF.Float item_code: DF.Link item_name: DF.Data | None parent: DF.Data diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index 4e48224032b..6293aad86e5 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -683,7 +683,10 @@ class TestWorkOrder(ERPNextTestSuite): def test_cost_center_for_manufacture(self): wo_order = make_wo_order_test_record() - ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) + ste = frappe.get_doc( + make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty) + ) + ste.save() self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC") def test_operation_time_with_batch_size(self): @@ -803,7 +806,7 @@ class TestWorkOrder(ERPNextTestSuite): bundle_id = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) for bundle_row in bundle_id.get("entries"): - self.assertTrue(bundle_row.batch_no in batches) + self.assertIn(bundle_row.batch_no, batches) batches.remove(bundle_row.batch_no) ste1.submit() @@ -817,7 +820,7 @@ class TestWorkOrder(ERPNextTestSuite): bundle_id = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) for bundle_row in bundle_id.get("entries"): - self.assertTrue(bundle_row.batch_no in batches) + self.assertIn(bundle_row.batch_no, batches) remaining_batches.append(bundle_row.batch_no) self.assertEqual(sorted(remaining_batches), sorted(batches)) @@ -1092,7 +1095,7 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) for row in stock_entry.items: - if row.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: self.assertEqual(row.qty, 1) # Partial Job Card 1 with qty 10 @@ -1104,7 +1107,7 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) for row in stock_entry.items: - if row.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: self.assertEqual(row.qty, 2) # Partial Job Card 2 with qty 10 @@ -1319,7 +1322,6 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) stock_entry.set_work_order_details() - stock_entry.set_serial_no_batch_for_finished_good() for row in stock_entry.items: if row.item_code == fg_item: self.assertTrue(row.serial_and_batch_bundle) @@ -1360,7 +1362,6 @@ class TestWorkOrder(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) stock_entry.set_work_order_details() - stock_entry.set_serial_no_batch_for_finished_good() for row in stock_entry.items: if row.item_code == fg_item: self.assertTrue(row.serial_and_batch_bundle) @@ -2191,7 +2192,7 @@ class TestWorkOrder(ERPNextTestSuite): self.assertTrue(se_doc.additional_costs) secondary_items = [] for item in se_doc.items: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: secondary_items.append(item.item_code) self.assertEqual( @@ -2656,7 +2657,7 @@ class TestWorkOrder(ERPNextTestSuite): # Secondary/Scrap item: should be taken from scrap warehouse in disassembly scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None) self.assertIsNotNone(scrap_row) - self.assertEqual(scrap_row.type, "Scrap") + self.assertEqual(scrap_row.secondary_item_type, "Scrap") self.assertTrue(scrap_row.s_warehouse) self.assertFalse(scrap_row.t_warehouse) self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse) @@ -3171,12 +3172,12 @@ class TestWorkOrder(ERPNextTestSuite): transfer_entry.items[0].original_item = raw_materials[0] transfer_entry.submit() - self.assertTrue(transfer_entry.docstatus == 1) + self.assertEqual(transfer_entry.docstatus, 1) manufacture_entry = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 10)) manufacture_entry.save() - self.assertTrue(manufacture_entry.items[0].item_code == alternate_item[0]) - self.assertTrue(manufacture_entry.items[0].original_item == raw_materials[0]) + self.assertEqual(manufacture_entry.items[0].item_code, alternate_item[0]) + self.assertEqual(manufacture_entry.items[0].original_item, raw_materials[0]) manufacture_entry.submit() @@ -3880,7 +3881,7 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(sorted(serial_nos), sorted(value.serial_nos)) if value.batch_nos: - self.assertTrue(row.batch_no in value.batch_nos) + self.assertIn(row.batch_no, value.batch_nos) _before_reserved_item = get_reserved_entries(wo.name, mt_stock_entry.items[0].t_warehouse) @@ -3896,16 +3897,16 @@ class TestWorkOrder(ERPNextTestSuite): if row.serial_no: serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle) for sn in serial_nos: - self.assertTrue(sn in value.serial_nos) + self.assertIn(sn, value.serial_nos) value.serial_nos.remove(sn) if row.batch_no: - self.assertTrue(row.batch_no in value.batch_nos) + self.assertIn(row.batch_no, value.batch_nos) value.batch_nos[row.batch_no] -= row.qty if row.serial_no: sns = get_serial_nos_from_bundle(row.serial_and_batch_bundle) for sn in sns: - self.assertTrue(sn in value.serial_batches[row.batch_no]) + self.assertIn(sn, value.serial_batches[row.batch_no]) value.serial_batches[row.batch_no].remove(sn) # Manufacture 3 qty @@ -3923,7 +3924,7 @@ class TestWorkOrder(ERPNextTestSuite): self.assertEqual(sorted(serial_nos), sorted(value.serial_nos)) if row.batch_no: - self.assertTrue(row.batch_no in value.batch_nos) + self.assertIn(row.batch_no, value.batch_nos) self.assertEqual(value.batch_nos[row.batch_no], row.qty) if row.serial_no: sns = get_serial_nos_from_bundle(row.serial_and_batch_bundle) @@ -4223,6 +4224,7 @@ class TestWorkOrder(ERPNextTestSuite): "operations", { "operation": fg_operation.name, + "batch_size": fg_operation.batch_size, "time_in_mins": 60, "workstation": workstation.name, }, @@ -4231,6 +4233,7 @@ class TestWorkOrder(ERPNextTestSuite): fg_bom.items[0].bom_no = subassembly_bom.name fg_bom.save() fg_bom.submit() + self.assertEqual(fg_bom.operations[0].batch_size, 25) wo_order = make_wo_order_test_record( item=fg_item.name, @@ -4291,7 +4294,6 @@ class TestWorkOrder(ERPNextTestSuite): ) material_transfer_entry.submit() - manufacture_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 1)) manufacture_entry.save() diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index d26f6040f4c..5131b30c889 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -12,21 +12,10 @@ frappe.ui.form.on("Work Order", { frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"]; // Set query for warehouses - frm.set_query("wip_warehouse", function () { - return { - filters: { - company: frm.doc.company, - }, - }; - }); - - frm.set_query("source_warehouse", function () { - return { - filters: { - company: frm.doc.company, - }, - }; - }); + frm.events.set_company_filters(frm, "wip_warehouse"); + frm.events.set_company_filters(frm, "source_warehouse"); + frm.events.set_company_filters(frm, "fg_warehouse"); + frm.events.set_company_filters(frm, "scrap_warehouse"); frm.set_query("source_warehouse", "required_items", function () { return { @@ -44,24 +33,6 @@ frappe.ui.form.on("Work Order", { }; }); - frm.set_query("fg_warehouse", function () { - return { - filters: { - company: frm.doc.company, - is_group: 0, - }, - }; - }); - - frm.set_query("scrap_warehouse", function () { - return { - filters: { - company: frm.doc.company, - is_group: 0, - }, - }; - }); - // Set query for BOM frm.set_query("bom_no", function () { if (frm.doc.production_item) { @@ -118,6 +89,16 @@ frappe.ui.form.on("Work Order", { }); }, + set_company_filters(frm, fieldname) { + frm.set_query(fieldname, () => { + return { + filters: { + company: frm.doc.company, + }, + }; + }); + }, + onload: function (frm) { if (!frm.doc.status) frm.doc.status = "Draft"; @@ -348,7 +329,7 @@ frappe.ui.form.on("Work Order", { { fieldtype: "Data", fieldname: "name", - label: __("Operation Id"), + label: __("Operation ID"), }, { fieldtype: "Float", @@ -425,6 +406,7 @@ frappe.ui.form.on("Work Order", { if (pending_qty) { dialog.fields_dict.operations.df.data.push({ + __checked: 1, name: data.name, operation: data.operation, workstation: data.workstation, diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 2d5145141ae..9dc57a3b99c 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_import": 1, "autoname": "naming_series:", "creation": "2025-04-09 12:09:40.634472", @@ -266,6 +267,7 @@ "fieldname": "wip_warehouse", "fieldtype": "Link", "label": "Work-in-Progress Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "mandatory_depends_on": "eval:(!doc.skip_transfer || doc.from_wip_warehouse) && !doc.track_semi_finished_goods", "options": "Warehouse" }, @@ -274,6 +276,7 @@ "fieldname": "fg_warehouse", "fieldtype": "Link", "label": "Target Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse", "read_only_depends_on": "subcontracting_inward_order" }, @@ -286,6 +289,7 @@ "fieldname": "scrap_warehouse", "fieldtype": "Link", "label": "Scrap Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse" }, { @@ -513,6 +517,7 @@ "fieldname": "source_warehouse", "fieldtype": "Link", "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse", "read_only_depends_on": "eval:doc.subcontracting_inward_order" }, @@ -706,7 +711,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2026-04-17 13:42:12.374055", + "modified": "2026-05-19 12:20:38.102403", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 70d9863b876..9c2ed1a2255 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -25,6 +25,7 @@ from frappe.utils import ( ) from pypika import functions as fn +from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.manufacturing.doctype.bom.bom import ( get_bom_item_rate, get_bom_items_as_dict, @@ -167,18 +168,19 @@ class WorkOrder(Document): self.set_onload("backflush_raw_materials_based_on", based_on) def show_create_job_card_button(self): - operation_details = frappe._dict( - frappe.get_all( - "Job Card", - fields=["operation", {"SUM": "for_quantity"}], - filters={"docstatus": ("<", 2), "work_order": self.name}, - as_list=1, - group_by="operation_id", - ) + jc_doctype = frappe.qb.DocType("Job Card") + query = ( + frappe.qb.from_(jc_doctype) + .select(jc_doctype.operation_id, Sum(jc_doctype.for_quantity - IfNull(jc_doctype.pending_qty, 0))) + .where((jc_doctype.docstatus < 2) & (jc_doctype.work_order == self.name)) + .groupby(jc_doctype.operation_id) ) + operation_details = query.run(as_list=1) + operation_details = frappe._dict(operation_details) + for d in self.operations: - job_card_qty = self.qty - flt(operation_details.get(d.operation)) + job_card_qty = self.qty - flt(operation_details.get(d.name)) if job_card_qty > 0: return True @@ -201,7 +203,7 @@ class WorkOrder(Document): self.calculate_operating_cost() self.validate_qty() self.validate_transfer_against() - self.validate_operation_time() + self.validate_operations() self.status = self.get_status() self.validate_workstation_type() self.reset_use_multi_level_bom() @@ -438,7 +440,7 @@ class WorkOrder(Document): production_item = main_item_code if self.sales_order: - self.check_sales_order_on_hold_or_close() + check_on_hold_or_closed_status("Sales Order", self.sales_order) SalesOrder = frappe.qb.DocType("Sales Order") SalesOrderItem = frappe.qb.DocType("Sales Order Item") @@ -494,11 +496,6 @@ class WorkOrder(Document): else: frappe.throw(_("Sales Order {0} is not valid").format(self.sales_order)) - def check_sales_order_on_hold_or_close(self): - status = frappe.db.get_value("Sales Order", self.sales_order, "status") - if status in ("Closed", "On Hold"): - frappe.throw(_("Sales Order {0} is {1}").format(self.sales_order, status)) - def set_default_warehouse(self): if not self.wip_warehouse and not self.skip_transfer: self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse") @@ -1498,8 +1495,11 @@ class WorkOrder(Document): title=_("Missing value"), ) - def validate_operation_time(self): + def validate_operations(self): for d in self.operations: + if not d.batch_size or d.batch_size <= 0: + d.batch_size = 1 + if d.time_in_mins <= 0: frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation)) @@ -2087,8 +2087,9 @@ class WorkOrder(Document): additional_items = frappe._dict() for row in stock_entry.items: - if row.item_code not in required_items: - additional_items.setdefault(row.item_code, []).append(row) + item_code = row.original_item if row.original_item else row.item_code + if item_code not in required_items: + additional_items.setdefault(item_code, []).append(row) self.flags.ignore_validate_update_after_submit = True @@ -2453,10 +2454,6 @@ def make_stock_entry( stock_entry.set_stock_entry_type() stock_entry.is_additional_transfer_entry = is_additional_transfer_entry stock_entry.get_items() - stock_entry.set_secondary_items_from_job_card() - - if purpose != "Disassemble": - stock_entry.set_serial_no_batch_for_finished_good() return stock_entry.as_dict() @@ -2817,11 +2814,9 @@ def get_reserved_qty_for_production( @frappe.whitelist() def make_stock_return_entry(work_order: str): - from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials - - non_consumed_items = get_available_materials(work_order) - if not non_consumed_items: - return + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + ManufactureStockEntry, + ) wo_doc = frappe.get_cached_doc("Work Order", work_order) @@ -2831,9 +2826,11 @@ def make_stock_return_entry(work_order: str): stock_entry.work_order = work_order stock_entry.purpose = "Material Transfer for Manufacture" stock_entry.bom_no = wo_doc.bom_no - stock_entry.add_transfered_raw_materials_in_items() stock_entry.set_stock_entry_type() + ste_cls = ManufactureStockEntry(stock_entry) + ste_cls.add_raw_materials_based_on_transfer() + ste_cls.return_available_materials_in_source_wh() return stock_entry diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index e5f322ca367..dec4934ea4f 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2016-04-18 07:38:26.314642", "doctype": "DocType", "editable_grid": 1, @@ -53,6 +54,7 @@ "ignore_user_permissions": 1, "in_list_view": 1, "label": "Source Warehouse", + "link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]", "options": "Warehouse", "read_only_depends_on": "eval:parent.subcontracting_inward_order && doc.is_customer_provided_item" }, @@ -207,7 +209,7 @@ "grid_page_length": 50, "istable": 1, "links": [], - "modified": "2025-12-02 11:16:05.081613", + "modified": "2026-05-12 12:05:16.687866", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 89ed830116b..d0ef7f257a6 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2025-04-09 12:12:19.824560", "doctype": "DocType", "editable_grid": 1, @@ -10,6 +11,7 @@ "status", "completed_qty", "process_loss_qty", + "pending_qty", "column_break_4", "bom", "workstation_type", @@ -194,10 +196,11 @@ "read_only": 1 }, { + "default": "1", "fieldname": "batch_size", "fieldtype": "Float", "label": "Batch Size", - "read_only": 1 + "non_negative": 1 }, { "fieldname": "sequence_id", @@ -301,13 +304,20 @@ "fieldname": "quality_inspection_required", "fieldtype": "Check", "label": "Quality Inspection Required" + }, + { + "fieldname": "pending_qty", + "fieldtype": "Float", + "label": "Pending Qty", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-30 17:20:08.874381", + "modified": "2026-05-25 17:15:12.038470", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py index 2e45434f94b..8950fd6b320 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.py @@ -32,6 +32,7 @@ class WorkOrderOperation(Document): parent: DF.Data parentfield: DF.Data parenttype: DF.Data + pending_qty: DF.Float planned_end_time: DF.Datetime | None planned_operating_cost: DF.Currency planned_start_time: DF.Datetime | None diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py index 59578127f9f..568fdf90054 100644 --- a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py +++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.py @@ -4,7 +4,7 @@ import frappe from frappe import _ from frappe.query_builder.functions import Floor, IfNull, Sum -from frappe.utils import flt, fmt_money +from frappe.utils import flt from frappe.utils.data import comma_and from pypika.terms import ExistsCriterion diff --git a/erpnext/patches.txt b/erpnext/patches.txt index d8f432f1d96..9aa01b7c630 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -481,4 +481,7 @@ erpnext.patches.v16_0.set_root_type_in_account_categories erpnext.patches.v16_0.scr_inv_dimension erpnext.patches.v16_0.packed_item_inv_dimen erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates -erpnext.patches.v16_0.set_default_letter_head_for_doctype_and_report \ No newline at end of file +erpnext.patches.v16_0.set_default_letter_head_for_doctype_and_report +erpnext.patches.v16_0.clear_procedures_from_receivable_report +erpnext.patches.v16_0.migrate_address_contact_custom_fields +erpnext.patches.v16_0.rename_secondary_item_type_field diff --git a/erpnext/patches/v16_0/clear_procedures_from_receivable_report.py b/erpnext/patches/v16_0/clear_procedures_from_receivable_report.py new file mode 100644 index 00000000000..c362d4ef605 --- /dev/null +++ b/erpnext/patches/v16_0/clear_procedures_from_receivable_report.py @@ -0,0 +1,12 @@ +import frappe + + +def execute(): + frappe.db.sql("drop function if exists ar_genkey") + frappe.db.sql("drop procedure if exists ar_init_tmp_table") + frappe.db.sql("drop procedure if exists ar_allocate_to_tmp_table") + + if frappe.db.get_single_value("Accounts Settings", "receivable_payable_fetch_method") == "Raw SQL": + frappe.db.set_single_value( + "Accounts Settings", "receivable_payable_fetch_method", "UnBuffered Cursor" + ) diff --git a/erpnext/patches/v16_0/co_by_product_patch.py b/erpnext/patches/v16_0/co_by_product_patch.py index 63f43e85b9e..5baca92b31a 100644 --- a/erpnext/patches/v16_0/co_by_product_patch.py +++ b/erpnext/patches/v16_0/co_by_product_patch.py @@ -41,7 +41,7 @@ def insert_into_bom(): "conversion_factor": 1, "qty": item.stock_qty, "is_legacy": 1, - "type": "Scrap", + "secondary_item_type": "Scrap", } ) secondary_item.insert() @@ -49,7 +49,14 @@ def insert_into_bom(): def insert_into_job_card(): fields = ["item_code", "item_name", "description", "stock_qty", "stock_uom"] - bulk_insert("Job Card", "Job Card Scrap Item", "Job Card Secondary Item", fields, ["type"], ["Scrap"]) + bulk_insert( + "Job Card", + "Job Card Scrap Item", + "Job Card Secondary Item", + fields, + ["secondary_item_type"], + ["Scrap"], + ) def insert_into_subcontracting_inward(): @@ -67,7 +74,7 @@ def insert_into_subcontracting_inward(): "Subcontracting Inward Order Scrap Item", "Subcontracting Inward Order Secondary Item", fields, - ["type"], + ["secondary_item_type"], ["Scrap"], ) diff --git a/erpnext/patches/v16_0/migrate_address_contact_custom_fields.py b/erpnext/patches/v16_0/migrate_address_contact_custom_fields.py new file mode 100644 index 00000000000..6edce540eff --- /dev/null +++ b/erpnext/patches/v16_0/migrate_address_contact_custom_fields.py @@ -0,0 +1,16 @@ +import frappe + +from erpnext.setup.install import create_address_and_contact_custom_fields + + +def execute(): + """Replace fixture-based custom fields on Address and Contact with programmatic ones.""" + for custom_field in ( + "Address-tax_category", + "Address-is_your_company_address", + "Contact-is_billing_contact", + ): + if frappe.db.exists("Custom Field", custom_field): + frappe.delete_doc("Custom Field", custom_field, ignore_missing=True, force=True) + + create_address_and_contact_custom_fields() diff --git a/erpnext/patches/v16_0/rename_secondary_item_type_field.py b/erpnext/patches/v16_0/rename_secondary_item_type_field.py new file mode 100644 index 00000000000..41b264b7ccb --- /dev/null +++ b/erpnext/patches/v16_0/rename_secondary_item_type_field.py @@ -0,0 +1,18 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + doctypes = [ + "BOM Secondary Item", + "Job Card Secondary Item", + "Stock Entry Detail", + "Subcontracting Inward Order Secondary Item", + "Subcontracting Receipt Item", + ] + + for doctype in doctypes: + if not frappe.db.has_column(doctype, "type"): + continue + + rename_field(doctype, "type", "secondary_item_type") diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index 95bf037c5e5..8f5a9b03813 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -181,6 +181,7 @@ "fieldtype": "Link", "in_global_search": 1, "label": "Customer", + "no_copy": 1, "oldfieldname": "customer", "oldfieldtype": "Link", "options": "Customer", @@ -195,6 +196,7 @@ "fieldname": "sales_order", "fieldtype": "Link", "label": "Sales Order", + "no_copy": 1, "options": "Sales Order" }, { @@ -480,7 +482,7 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2026-04-14 18:17:40.676750", + "modified": "2026-05-22 16:45:50.762759", "modified_by": "Administrator", "module": "Projects", "name": "Project", diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 92d677e6c60..da05691d3fa 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -91,14 +91,14 @@ class Project(Document): def validate(self): if not self.is_new(): - self.copy_from_template() # nosemgrep + self.copy_from_template() self.send_welcome_email() self.update_costing() self.update_percent_complete() self.validate_from_to_dates("expected_start_date", "expected_end_date") self.validate_from_to_dates("actual_start_date", "actual_end_date") - def copy_from_template(self): # nosemgrep + def copy_from_template(self, trigger=None): """ Copy tasks from template """ @@ -107,11 +107,15 @@ class Project(Document): if not self.expected_start_date: # project starts today self.expected_start_date = today() + if trigger == "after_insert": + self.db_set("expected_start_date", self.expected_start_date) template = frappe.get_doc("Project Template", self.project_template) if not self.project_type: self.project_type = template.project_type + if trigger == "after_insert": + self.db_set("project_type", self.project_type) # create tasks from template project_tasks = [] @@ -164,6 +168,40 @@ class Project(Document): self.check_depends_on_value(template_task, project_task, project_tasks) self.check_for_parent_tasks(template_task, project_task, project_tasks) + def set_consumed_material_cost(self): + parent_doc = frappe.qb.DocType("Stock Entry") + child_doc = frappe.qb.DocType("Stock Entry Detail") + lcv_doc = frappe.qb.DocType("Landed Cost Taxes and Charges") + + amount = ( + qb.from_(child_doc) + .select(Sum(child_doc.amount)) + .where( + (child_doc.project == self.name) + & (child_doc.docstatus == 1) + & ((child_doc.t_warehouse.isnull()) | (child_doc.t_warehouse == "")) + ) + ).run(as_list=1) + + amount = flt(amount[0][0]) if amount else 0 + + additional_costs = ( + qb.from_(parent_doc) + .join(lcv_doc) + .on(parent_doc.name == lcv_doc.parent) + .select(Sum(lcv_doc.base_amount)) + .where( + (parent_doc.project == self.name) + & (parent_doc.docstatus == 1) + & (parent_doc.purpose == "Manufacture") + ) + ).run(as_list=1) + + additional_cost_amt = flt(additional_costs[0][0]) if additional_costs else 0 + + amount += additional_cost_amt + self.total_consumed_material_cost = amount + def check_depends_on_value(self, template_task, project_task, project_tasks): if template_task.get("depends_on") and not project_task.get("depends_on"): project_template_map = {pt.template_task: pt for pt in project_tasks} @@ -201,7 +239,7 @@ class Project(Document): self.db_update() def after_insert(self): - self.copy_from_template() # nosemgrep + self.copy_from_template("after_insert") if self.sales_order: frappe.db.set_value("Sales Order", self.sales_order, "project", self.name) diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py index aa37c34ef89..06f7c1c04b4 100644 --- a/erpnext/projects/doctype/project/test_project.py +++ b/erpnext/projects/doctype/project/test_project.py @@ -152,7 +152,7 @@ class TestProject(ERPNextTestSuite): self.assertEqual(tasks[1].subject, "Test Template Task with Dependency") self.assertEqual(getdate(tasks[1].exp_end_date), calculate_end_date(project, 2, 2)) - self.assertTrue(tasks[1].depends_on_tasks.find(tasks[0].name) >= 0) + self.assertGreaterEqual(tasks[1].depends_on_tasks.find(tasks[0].name), 0) self.assertEqual(tasks[0].subject, "Test Template Task for Dependency") self.assertEqual(getdate(tasks[0].exp_end_date), calculate_end_date(project, 3, 1)) diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js index e1ab20f2ae4..d8d812e7116 100644 --- a/erpnext/public/js/controllers/buying.js +++ b/erpnext/public/js/controllers/buying.js @@ -174,14 +174,9 @@ erpnext.buying = { callback: (r) => { if (!r.message) return; - if (!this.frm.doc.billing_address) { - this.frm.set_value("billing_address", r.message.primary_address || ""); - } + this.frm.set_value("billing_address", r.message.primary_address || ""); - if ( - frappe.meta.has_field(this.frm.doc.doctype, "shipping_address") && - !this.frm.doc.shipping_address - ) { + if (frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) { this.frm.set_value("shipping_address", r.message.shipping_address || ""); } }, diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index e3074f711ef..cb6c32a41b0 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -258,6 +258,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (has_inclusive_tax == false) return; $.each(this.frm.doc.items || [], function (n, item) { + item._unrounded_net_amount = null; var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); var cumulated_tax_fraction = 0.0; var total_inclusive_tax_amount_per_qty = 0; @@ -284,7 +285,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { (total_inclusive_tax_amount_per_qty || cumulated_tax_fraction) ) { var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty; - item.net_amount = flt(amount / (1 + cumulated_tax_fraction), precision("net_amount", item)); + item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction); + item.net_amount = flt(item._unrounded_net_amount, precision("net_amount", item)); item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0; me.set_in_company_currency(item, ["net_rate", "net_amount"]); @@ -567,7 +569,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (tax.account_head in item_tax_map) { current_net_amount = item.net_amount; } - current_tax_amount = (tax_rate / 100.0) * item.net_amount; + // Use unrounded net for inclusive taxes to avoid double rounding + var net_for_tax = + cint(tax.included_in_print_rate) && + !this.discount_amount_applied && + item._unrounded_net_amount !== null + ? item._unrounded_net_amount + : item.net_amount; + current_tax_amount = (tax_rate / 100.0) * net_for_tax; } else if (tax.charge_type == "On Previous Row Amount") { current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; current_tax_amount = diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 6e1084e3408..60462148223 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -459,19 +459,22 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe reference_name: frm.doc.name, }, }); + + if (!schedules?.length) { + this.make_payment_request(); + return; + } + const value = await frappe.db.get_single_value( "Accounts Settings", "fetch_payment_schedule_in_payment_request" ); - if (!value || !schedules.length) { + if (!value) { this.make_payment_request(); return; } - if (!schedules || !schedules.length) { - frappe.msgprint(__("No pending payment schedules available.")); - return; - } + schedules.forEach((schedule) => (schedule.__checked = 1)); const dialog = new frappe.ui.Dialog({ @@ -833,26 +836,24 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe }, async () => { // for internal customer instead of pricing rule directly apply valuation rate on item - const fetch_valuation_rate_for_internal_transactions = - await frappe.db.get_single_value( - "Accounts Settings", - "fetch_valuation_rate_for_internal_transaction" - ); - if ( - (me.frm.doc.is_internal_customer || - me.frm.doc.is_internal_supplier) && - fetch_valuation_rate_for_internal_transactions - ) { - me.get_incoming_rate( - item, - me.frm.posting_date, - me.frm.posting_time, - me.frm.doc.doctype, - me.frm.doc.company - ); - } else { - me.frm.script_manager.trigger("price_list_rate", cdt, cdn); + if (me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) { + const fetch_valuation_rate_for_internal_transactions = + await frappe.db.get_single_value( + "Accounts Settings", + "fetch_valuation_rate_for_internal_transaction" + ); + if (fetch_valuation_rate_for_internal_transactions) { + me.get_incoming_rate( + item, + me.frm.posting_date, + me.frm.posting_time, + me.frm.doc.doctype, + me.frm.doc.company + ); + return; + } } + me.frm.script_manager.trigger("price_list_rate", cdt, cdn); }, () => { if (me.frm.doc.is_internal_customer || me.frm.doc.is_internal_supplier) { @@ -2260,6 +2261,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe for (const [key, value] of Object.entries(child)) { if (!["doctype", "name"].includes(key)) { if (key === "price_list_rate") { + const doc = frappe.get_doc(child.doctype, child.name); + if (doc) doc.price_list_rate = value; // silent update so rate trigger uses correct value frappe.model.set_value(child.doctype, child.name, "rate", value); } @@ -2921,10 +2924,28 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe method: "erpnext.controllers.stock_controller.check_item_quality_inspection", args: { doctype: this.frm.doc.doctype, + docstatus: this.frm.doc.docstatus, items: this.frm.doc.items, }, freeze: true, callback: function (r) { + if (r.message.length == 0) { + let type = inspection_type === "Incoming" ? "Purchase" : "Delivery"; + let fieldname = + inspection_type === "Incoming" + ? "Inspection Required before Purchase" + : "Inspection Required before Delivery"; + + frappe.msgprint({ + title: __("Quality Inspection Not Configured"), + message: __(`Enable {0} on the Item master to proceed with {1} inspection.`, [ + fieldname, + type, + ]), + }); + return; + } + r.message.forEach((item) => { if (me.has_inspection_required(item)) { let dialog_items = dialog.fields_dict.items; diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 34326ad16dc..fd1e0c4167f 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -484,6 +484,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { warehouse: this.item.s_warehouse || this.item.t_warehouse || this.item.warehouse, is_inward: is_inward, + posting_date: this.frm.doc.posting_date, + posting_time: this.frm.doc.posting_time, include_expired_batches: include_expired_batches, }, }; diff --git a/erpnext/public/js/utils/unreconcile.js b/erpnext/public/js/utils/unreconcile.js index 1f9ffda7c24..4ccbf0106d7 100644 --- a/erpnext/public/js/utils/unreconcile.js +++ b/erpnext/public/js/utils/unreconcile.js @@ -140,8 +140,7 @@ erpnext.accounts.unreconcile_payment = { selected_allocations ); erpnext.accounts.unreconcile_payment.create_unreconcile_docs( - selection_map, - frm + selection_map ); d.hide(); } else { @@ -157,23 +156,12 @@ erpnext.accounts.unreconcile_payment = { } }, - create_unreconcile_docs(selection_map, frm) { + create_unreconcile_docs(selection_map) { frappe.call({ method: "erpnext.accounts.doctype.unreconcile_payment.unreconcile_payment.create_unreconcile_doc_for_selection", args: { selections: selection_map, }, - callback: function (r) { - if (r.exc) { - return; - } - - if (frm && !frm.is_new()) { - frm.reload_doc(); - } - - frappe.show_alert({ message: __("Unreconciled successfully"), indicator: "green" }); - }, }); }, }; diff --git a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py index 0f2c4906e56..f45cc840fa8 100644 --- a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py @@ -4,6 +4,7 @@ import erpnext from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.regional.report.uae_vat_201.uae_vat_201 import ( + execute, get_exempt_total, get_standard_rated_expenses_tax, get_standard_rated_expenses_total, @@ -30,6 +31,13 @@ class TestUaeVat201(ERPNextTestSuite): make_item("_Test UAE VAT Zero Rated Item", properties={"is_zero_rated": 1, "is_exempt": 0}) make_item("_Test UAE VAT Exempt Item", properties={"is_zero_rated": 0, "is_exempt": 1}) + def test_validate_company_region(self): + self.assertRaises( + frappe.exceptions.ValidationError, + execute, + {"company": "_Test Company"}, + ) + def test_uae_vat_201_report(self): make_sales_invoices() create_purchase_invoices() diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.js b/erpnext/regional/report/uae_vat_201/uae_vat_201.js index 49060fdf66a..e62d3395f20 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.js +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.js @@ -10,6 +10,13 @@ frappe.query_reports["UAE VAT 201"] = { options: "Company", reqd: 1, default: frappe.defaults.get_user_default("Company"), + get_query: function () { + return { + filters: { + country: "United Arab Emirates", + }, + }; + }, }, { fieldname: "from_date", diff --git a/erpnext/regional/report/uae_vat_201/uae_vat_201.py b/erpnext/regional/report/uae_vat_201/uae_vat_201.py index fa4b2dc6693..4942bc4801f 100644 --- a/erpnext/regional/report/uae_vat_201/uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/uae_vat_201.py @@ -5,13 +5,25 @@ import frappe from frappe import _ +from erpnext import get_region + def execute(filters=None): + validate_company_region(filters) columns = get_columns() data, emirates, amounts_by_emirate = get_data(filters) return columns, data +def validate_company_region(filters): + if filters.get("company") and get_region(filters.get("company")) != "United Arab Emirates": + frappe.throw( + _( + "The company {0} is not in United Arab Emirates. UAE VAT 201 report is only available for companies in United Arab Emirates." + ).format(frappe.bold(filters.get("company"))) + ) + + def get_columns(): """Creates a list of dictionaries that are used to generate column headers of the data table.""" return [ diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js index a2abaa5527d..aac09bbd663 100644 --- a/erpnext/selling/doctype/customer/customer.js +++ b/erpnext/selling/doctype/customer/customer.js @@ -38,7 +38,7 @@ frappe.ui.form.on("Customer", { frm.add_fetch("lead_name", "company_name", "customer_name"); frm.add_fetch("default_sales_partner", "commission_rate", "default_commission_rate"); - frm.set_query("default_price_list", { selling: 1 }); + frm.set_query("default_price_list", () => ({ filters: { selling: 1 } })); frm.set_query("account", "accounts", function (doc, cdt, cdn) { let d = locals[cdt][cdn]; let filters = { @@ -185,13 +185,15 @@ frappe.ui.form.on("Customer", { frm.add_custom_button(__(doctype), frm.make_methods[doctype], __("Create")); } - frm.add_custom_button( - __("Get Customer Group Details"), - function () { - frm.trigger("get_customer_group_details"); - }, - __("Actions") - ); + if (frm.doc.customer_group) { + frm.add_custom_button( + __("Get Customer Group Details"), + function () { + frm.trigger("get_customer_group_details"); + }, + __("Actions") + ); + } if ( cint(frappe.defaults.get_default("enable_common_party_accounting")) && diff --git a/erpnext/selling/doctype/customer/customer.json b/erpnext/selling/doctype/customer/customer.json index 95c5fb772e3..f60bdce0969 100644 --- a/erpnext/selling/doctype/customer/customer.json +++ b/erpnext/selling/doctype/customer/customer.json @@ -12,18 +12,23 @@ "field_order": [ "basic_info", "naming_series", - "customer_type", "customer_name", + "customer_type", "gender", "column_break0", "customer_group", "territory", "image", - "defaults_tab", + "section_break_hwkr", "default_currency", "default_bank_account", - "column_break_14", + "column_break_yvyu", "default_price_list", + "payment_terms", + "loyalty_points_tab", + "loyalty_program", + "column_break_54", + "loyalty_program_tier", "contact_and_address_tab", "address_contacts", "address_html", @@ -39,28 +44,31 @@ "email_id", "first_name", "last_name", + "accounting_tab", + "default_receivable_accounts", + "default_accounts_column", + "accounts", + "credit_limit_section", + "credit_limit_column", + "credit_limits", + "internal_customer_section", + "is_internal_customer", + "represents_company", + "section_break_nrvh", + "companies", "tax_tab", "taxation_section", "tax_id", "tax_category", "column_break_21", - "tax_withholding_category", "tax_withholding_group", - "accounting_tab", - "default_receivable_accounts", - "accounts", - "credit_limit_section", - "payment_terms", - "credit_limits", - "internal_customer_section", - "is_internal_customer", - "represents_company", - "column_break_70", - "companies", - "loyalty_points_tab", - "loyalty_program", - "column_break_54", - "loyalty_program_tier", + "tax_withholding_category", + "settings_tab", + "so_required", + "dn_required", + "column_break_53", + "disabled", + "is_frozen", "sales_team_tab", "account_manager", "sales_team", @@ -68,12 +76,6 @@ "default_sales_partner", "column_break_66", "default_commission_rate", - "settings_tab", - "so_required", - "dn_required", - "column_break_53", - "disabled", - "is_frozen", "portal_users_tab", "portal_users", "more_info_tab", @@ -91,6 +93,7 @@ "column_break_hdmn", "customer_details", "supplier_numbers_section", + "supplier_numbers_column", "supplier_numbers", "connections_tab" ], @@ -132,6 +135,7 @@ "default": "Company", "fieldname": "customer_type", "fieldtype": "Select", + "in_list_view": 1, "label": "Customer Type", "oldfieldname": "customer_type", "oldfieldtype": "Select", @@ -139,10 +143,12 @@ "reqd": 1 }, { + "description": "Pre-filled on payment entries for this customer. Must be a company account.", "fieldname": "default_bank_account", "fieldtype": "Link", - "label": "Default Company Bank Account", - "options": "Bank Account" + "label": "Company Bank Account", + "options": "Bank Account", + "show_description_on_click": 1 }, { "fieldname": "lead_name", @@ -203,6 +209,7 @@ "label": "Tax ID" }, { + "description": "Controls which tax template is auto-applied when this customer is selected on a transaction.", "fieldname": "tax_category", "fieldtype": "Link", "label": "Tax Category", @@ -210,12 +217,15 @@ }, { "default": "0", + "description": "Blocks this customer from being used on any new transaction.", "fieldname": "disabled", "fieldtype": "Check", - "label": "Disabled" + "label": "Disabled", + "show_description_on_click": 1 }, { "default": "0", + "description": "Mark if this customer represents an internal company. Enables inter-company transactions.", "fieldname": "is_internal_customer", "fieldtype": "Check", "label": "Is Internal Customer" @@ -230,31 +240,32 @@ "unique": 1 }, { - "depends_on": "represents_company", + "depends_on": "eval: doc.is_internal_customer && doc.represents_company", "fieldname": "companies", "fieldtype": "Table", - "label": "Allowed To Transact With", + "label": "Allowed to transact with", "options": "Allowed To Transact With" }, { + "description": "All invoices and orders for this customer will be created in this currency.", "fieldname": "default_currency", "fieldtype": "Link", "ignore_user_permissions": 1, "in_list_view": 1, "label": "Billing Currency", "no_copy": 1, - "options": "Currency" + "options": "Currency", + "show_description_on_click": 1 }, { + "description": "Fetched automatically on sales orders and invoices for this customer.", "fieldname": "default_price_list", "fieldtype": "Link", "ignore_user_permissions": 1, - "label": "Default Price List", - "options": "Price List" - }, - { - "fieldname": "column_break_14", - "fieldtype": "Column Break" + "label": "Price List", + "link_filters": "[[\"Price List\", \"selling\", \"=\", 1]]", + "options": "Price List", + "show_description_on_click": 1 }, { "fieldname": "language", @@ -340,29 +351,30 @@ }, { "fieldname": "default_receivable_accounts", - "fieldtype": "Section Break", - "label": "Default Accounts" + "fieldtype": "Section Break" }, { - "description": "Mention if non-standard Receivable account", + "description": "If set, accounting entries for this customer will post to these accounts instead of the company default.", "fieldname": "accounts", "fieldtype": "Table", - "label": "Accounts", - "options": "Party Account" + "label": "Default Accounts", + "options": "Party Account", + "show_description_on_click": 1 }, { "fieldname": "credit_limit_section", - "fieldtype": "Section Break", - "label": "Credit Limit and Payment Terms" + "fieldtype": "Section Break" }, { + "description": "Defines when payment is due (e.g. Net 30, 50% advance). Applied automatically on invoices for this customer.", "fieldname": "payment_terms", "fieldtype": "Link", - "label": "Default Payment Terms Template", - "options": "Payment Terms Template" + "label": "Payment Terms Template", + "options": "Payment Terms Template", + "show_description_on_click": 1 }, { - "description": "Additional information regarding the customer.", + "description": "Internal notes about this customer. Not visible on transactions or the portal.", "fieldname": "customer_details", "fieldtype": "Text", "label": "Customer Details", @@ -370,10 +382,12 @@ "oldfieldtype": "Code" }, { + "description": "Classify the type of market this customer belongs to, used for sales analysis and targeting.", "fieldname": "market_segment", "fieldtype": "Link", "label": "Market Segment", - "options": "Market Segment" + "options": "Market Segment", + "show_description_on_click": 1 }, { "fieldname": "industry", @@ -383,11 +397,14 @@ }, { "default": "0", + "description": "Blocks all further accounting entries on this customer's account. Only users with the frozen-entries role can override.\n", "fieldname": "is_frozen", "fieldtype": "Check", - "label": "Is Frozen" + "label": "Is Frozen", + "show_description_on_click": 1 }, { + "description": "Loyalty scheme this customer earns points under. Auto-assigned if a matching program exists.", "fieldname": "loyalty_program", "fieldtype": "Link", "label": "Loyalty Program", @@ -395,6 +412,7 @@ "options": "Loyalty Program" }, { + "description": "Current tier based on accumulated points. Updated automatically on each invoice.", "fieldname": "loyalty_program_tier", "fieldtype": "Data", "label": "Loyalty Program Tier", @@ -418,12 +436,14 @@ "oldfieldtype": "Currency" }, { - "collapsible": 1, "collapsible_depends_on": "sales_team", + "description": "Commission paid to the Sales Partner on transactions with this customer.", "fieldname": "sales_team_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Sales Partner" }, { + "description": "Split commission credit across multiple sales persons.", "fieldname": "sales_team", "fieldtype": "Table", "label": "Sales Team", @@ -441,24 +461,27 @@ "report_hide": 1 }, { + "description": "Transactions are blocked or warned when outstanding balance exceeds this amount.", "fieldname": "credit_limits", "fieldtype": "Table", "label": "Credit Limit", - "options": "Customer Credit Limit" + "options": "Customer Credit Limit", + "show_description_on_click": 1 }, { "default": "0", "fieldname": "so_required", "fieldtype": "Check", - "label": "Allow Sales Invoice Creation Without Sales Order" + "label": "Allow sales invoice creation without sales order" }, { "default": "0", "fieldname": "dn_required", "fieldtype": "Check", - "label": "Allow Sales Invoice Creation Without Delivery Note" + "label": "Allow sales invoice creation without delivery note" }, { + "description": "TDS/TCS is calculated at the rate defined here on every payment from this customer.", "fieldname": "tax_withholding_category", "fieldtype": "Link", "label": "Tax Withholding Category", @@ -478,11 +501,6 @@ "fieldtype": "Tab Break", "label": "Address & Contact" }, - { - "fieldname": "defaults_tab", - "fieldtype": "Section Break", - "label": "Defaults" - }, { "fieldname": "settings_tab", "fieldtype": "Tab Break", @@ -511,6 +529,7 @@ }, { "collapsible": 1, + "collapsible_depends_on": "loyalty_program", "fieldname": "loyalty_points_tab", "fieldtype": "Section Break", "label": "Loyalty Points" @@ -530,21 +549,17 @@ "label": "Tax" }, { - "collapsible": 1, - "collapsible_depends_on": "is_internal_customer", "fieldname": "internal_customer_section", "fieldtype": "Section Break", + "hide_border": 1, "label": "Internal Customer Accounting" }, - { - "fieldname": "column_break_70", - "fieldtype": "Column Break" - }, { "fieldname": "column_break_54", "fieldtype": "Column Break" }, { + "description": "Users listed here can log into the customer portal to view their orders, invoices, and deliveries.", "fieldname": "portal_users_tab", "fieldtype": "Tab Break", "label": "Portal Users" @@ -583,13 +598,14 @@ "label": "Last Name" }, { - "description": "Supplier numbers assigned by the customer", + "description": "Numbers this customer uses to identify your company in their own system.", "fieldname": "supplier_numbers", "fieldtype": "Table", "label": "Supplier Numbers", "options": "Supplier Number At Customer" }, { + "description": "Select the group first to filter the applicable withholding categories below.", "fieldname": "tax_withholding_group", "fieldtype": "Link", "label": "Tax Withholding Group", @@ -625,8 +641,32 @@ }, { "fieldname": "supplier_numbers_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "section_break_hwkr", "fieldtype": "Section Break", - "label": "Supplier Numbers" + "label": "Defaults" + }, + { + "fieldname": "column_break_yvyu", + "fieldtype": "Column Break" + }, + { + "fieldname": "default_accounts_column", + "fieldtype": "Column Break" + }, + { + "fieldname": "supplier_numbers_column", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_nrvh", + "fieldtype": "Section Break" + }, + { + "fieldname": "credit_limit_column", + "fieldtype": "Column Break" } ], "icon": "fa fa-user", @@ -640,7 +680,7 @@ "link_fieldname": "party" } ], - "modified": "2026-03-09 17:15:26.040050", + "modified": "2026-05-29 02:21:41.089319", "modified_by": "Administrator", "module": "Selling", "name": "Customer", diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index f7a9a6d21f1..8368fd90ee9 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -15,7 +15,8 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options from frappe.model.utils.rename_doc import update_linked_doctypes -from frappe.query_builder import Field, functions +from frappe.query_builder import CustomFunction, Field, functions +from frappe.query_builder.functions import Cast, Coalesce, Max, Substring from frappe.utils import cint, cstr, flt, get_formatted_email, today from frappe.utils.user import get_users_with_role @@ -120,36 +121,25 @@ class Customer(TransactionBase): self.customer_name = self.customer_name.strip() if frappe.db.get_value("Customer", self.customer_name) and not frappe.flags.in_import: name_prefix = f"{self.customer_name} - %" + Customer = frappe.qb.DocType("Customer") if frappe.db.db_type == "postgres": # Postgres: extract trailing digits (e.g. "Customer - 3") and cast to int. # NOTE: PostgreSQL is strict about types; MySQL's UNSIGNED cast does not exist. - count = frappe.db.sql( - """ - SELECT COALESCE( - MAX(CAST(SUBSTRING(name FROM '\\d+$') AS INTEGER)), - 0 - ) - FROM tabCustomer - WHERE name LIKE %(name_prefix)s - """, - {"name_prefix": name_prefix}, - as_list=1, - )[0][0] + extracted_part = Substring(Customer.name, r"\d+$") + casted_part = Cast(extracted_part, "INTEGER") else: # MariaDB/MySQL: keep existing behavior. - count = frappe.db.sql( - """ - SELECT COALESCE( - MAX(CAST(SUBSTRING_INDEX(name, ' ', -1) AS UNSIGNED)), - 0 - ) - FROM tabCustomer - WHERE name LIKE %(name_prefix)s - """, - {"name_prefix": name_prefix}, - as_list=1, - )[0][0] + SubstringIndex = CustomFunction("SUBSTRING_INDEX", ["str", "delim", "count"]) + extracted_part = SubstringIndex(Customer.name, " ", -1) + casted_part = Cast(extracted_part, "UNSIGNED") + + query = ( + frappe.qb.from_(Customer) + .select(Coalesce(Max(casted_part), 0)) + .where(Customer.name.like(name_prefix)) + ) + count = query.run()[0][0] count = cint(count) + 1 new_customer_name = f"{self.customer_name} - {cstr(count)}" @@ -415,7 +405,7 @@ class Customer(TransactionBase): delete_contact_and_address("Customer", self.name) if self.lead_name: - frappe.db.sql("update `tabLead` set status='Interested' where name=%s", self.lead_name) + frappe.db.set_value("Lead", self.lead_name, "status", "Interested") def before_rename(self, olddn, newdn, merge=False): if merge: @@ -677,85 +667,97 @@ def send_emails( def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None): - # Outstanding based on GL Entries - cond = "" + from frappe.query_builder import Criterion + from frappe.query_builder.functions import Coalesce, IfNull, Sum + + GLEntry = frappe.qb.DocType("GL Entry") + gle_query = ( + frappe.qb.from_(GLEntry) + .select(Sum(GLEntry.debit) - Sum(GLEntry.credit)) + .where(GLEntry.party_type == "Customer") + .where(GLEntry.party == customer) + .where(GLEntry.company == company) + .where(GLEntry.is_cancelled == 0) + ) + if cost_center: lft, rgt = frappe.get_cached_value("Cost Center", cost_center, ["lft", "rgt"]) + CostCenter = frappe.qb.DocType("Cost Center") + cost_center_subquery = ( + frappe.qb.from_(CostCenter) + .select(CostCenter.name) + .where(CostCenter.lft >= lft) + .where(CostCenter.rgt <= rgt) + ) + gle_query = gle_query.where(GLEntry.cost_center.isin(cost_center_subquery)) - cond = f""" and cost_center in (select name from `tabCost Center` where - lft >= {lft} and rgt <= {rgt})""" + gle_res = gle_query.run() + outstanding_based_on_gle = flt(gle_res[0][0]) if gle_res and gle_res[0][0] is not None else 0.0 - outstanding_based_on_gle = frappe.db.sql( - f""" - select sum(debit) - sum(credit) - from `tabGL Entry` where party_type = 'Customer' - and is_cancelled = 0 and party = %s - and company=%s {cond}""", - (customer, company), - ) - - outstanding_based_on_gle = flt(outstanding_based_on_gle[0][0]) if outstanding_based_on_gle else 0 - - # Outstanding based on Sales Order - outstanding_based_on_so = 0 - - # if credit limit check is bypassed at sales order level, - # we should not consider outstanding Sales Orders, when customer credit balance report is run + outstanding_based_on_so = 0.0 if not ignore_outstanding_sales_order: - outstanding_based_on_so = frappe.db.sql( - """ - select sum(base_grand_total*(100 - per_billed)/100) - from `tabSales Order` - where customer=%s and docstatus = 1 and company=%s - and per_billed < 100 and status != 'Closed'""", - (customer, company), + SalesOrder = frappe.qb.DocType("Sales Order") + so_query = ( + frappe.qb.from_(SalesOrder) + .select(Sum(SalesOrder.base_grand_total * (100 - SalesOrder.per_billed) / 100)) + .where(SalesOrder.customer == customer) + .where(SalesOrder.company == company) + .where(SalesOrder.docstatus == 1) + .where(SalesOrder.per_billed < 100) + .where(SalesOrder.status != "Closed") ) + so_res = so_query.run() + outstanding_based_on_so = flt(so_res[0][0]) if so_res and so_res[0][0] is not None else 0.0 - outstanding_based_on_so = flt(outstanding_based_on_so[0][0]) if outstanding_based_on_so else 0 + DeliveryNote = frappe.qb.DocType("Delivery Note") + DeliveryNoteItem = frappe.qb.DocType("Delivery Note Item") + SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item") - # Outstanding based on Delivery Note, which are not created against Sales Order - outstanding_based_on_dn = 0 - - unmarked_delivery_note_items = frappe.db.sql( - """select - dn_item.name, dn_item.amount, dn.base_net_total, dn.base_grand_total - from `tabDelivery Note` dn, `tabDelivery Note Item` dn_item - where - dn.name = dn_item.parent - and dn.customer=%s and dn.company=%s - and dn.docstatus = 1 and dn.status not in ('Closed', 'Stopped') - and ifnull(dn_item.against_sales_order, '') = '' - and ifnull(dn_item.against_sales_invoice, '') = '' - """, - (customer, company), - as_dict=True, + si_subquery = ( + frappe.qb.from_(SalesInvoiceItem) + .select(SalesInvoiceItem.dn_detail, Sum(SalesInvoiceItem.amount).as_("billed_amount")) + .where(SalesInvoiceItem.docstatus == 1) + .groupby(SalesInvoiceItem.dn_detail) ) - if not unmarked_delivery_note_items: - return outstanding_based_on_gle + outstanding_based_on_so - - si_amounts = frappe.db.sql( - """ - SELECT - dn_detail, sum(amount) from `tabSales Invoice Item` - WHERE - docstatus = 1 - and dn_detail in ({}) - GROUP BY dn_detail""".format( - ", ".join(frappe.db.escape(dn_item.name) for dn_item in unmarked_delivery_note_items) + dn_query = ( + frappe.qb.from_(DeliveryNote) + .join(DeliveryNoteItem) + .on(DeliveryNote.name == DeliveryNoteItem.parent) + .left_join(si_subquery) + .on(DeliveryNoteItem.name == si_subquery.dn_detail) + .select( + Sum( + ( + (DeliveryNoteItem.amount - IfNull(si_subquery.billed_amount, 0.0)) + / DeliveryNote.base_net_total + ) + * DeliveryNote.base_grand_total + ) + ) + .where(DeliveryNote.customer == customer) + .where(DeliveryNote.company == company) + .where(DeliveryNote.docstatus == 1) + .where(DeliveryNote.base_net_total > 0) + .where(DeliveryNote.status.notin(["Closed", "Stopped"])) + .where(DeliveryNoteItem.amount > IfNull(si_subquery.billed_amount, 0.0)) + .where( + Criterion.any( + [DeliveryNoteItem.against_sales_order.isnull(), DeliveryNoteItem.against_sales_order == ""] + ) + ) + .where( + Criterion.any( + [ + DeliveryNoteItem.against_sales_invoice.isnull(), + DeliveryNoteItem.against_sales_invoice == "", + ] + ) ) ) - si_amounts = {si_item[0]: si_item[1] for si_item in si_amounts} - - for dn_item in unmarked_delivery_note_items: - dn_amount = flt(dn_item.amount) - si_amount = flt(si_amounts.get(dn_item.name)) - - if dn_amount > si_amount and dn_item.base_net_total: - outstanding_based_on_dn += ( - (dn_amount - si_amount) / dn_item.base_net_total - ) * dn_item.base_grand_total + dn_res = dn_query.run() + outstanding_based_on_dn = flt(dn_res[0][0]) if dn_res and dn_res[0][0] is not None else 0.0 return outstanding_based_on_gle + outstanding_based_on_so + outstanding_based_on_dn diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 4bd26408c67..ba17f9ed4a8 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -53,7 +53,7 @@ class TestCustomer(ERPNextTestSuite): doc.delete() def test_party_details(self): - from erpnext.accounts.party import get_party_details + from erpnext.accounts.party import _get_party_details to_check = { "selling_price_list": None, @@ -75,7 +75,7 @@ class TestCustomer(ERPNextTestSuite): "Contact", "_Test Contact for _Test Customer-_Test Customer", "is_primary_contact", 1 ) - details = get_party_details("_Test Customer") + details = _get_party_details("_Test Customer") for key, value in to_check.items(): val = details.get(key) @@ -85,10 +85,10 @@ class TestCustomer(ERPNextTestSuite): self.assertEqual(value, val) def test_party_details_tax_category(self): - from erpnext.accounts.party import get_party_details + from erpnext.accounts.party import _get_party_details # Tax Category without Address - details = get_party_details("_Test Customer With Tax Category") + details = _get_party_details("_Test Customer With Tax Category") self.assertEqual(details.tax_category, "_Test Tax Category 1") frappe.get_doc( @@ -120,13 +120,13 @@ class TestCustomer(ERPNextTestSuite): # Tax Category from Billing Address settings.determine_address_tax_category_from = "Billing Address" settings.save() - details = get_party_details("_Test Customer With Tax Category") + details = _get_party_details("_Test Customer With Tax Category") self.assertEqual(details.tax_category, "_Test Tax Category 2") # Tax Category from Shipping Address settings.determine_address_tax_category_from = "Shipping Address" settings.save() - details = get_party_details("_Test Customer With Tax Category") + details = _get_party_details("_Test Customer With Tax Category") self.assertEqual(details.tax_category, "_Test Tax Category 3") # Rollback diff --git a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json index 790cb40eeeb..f738b3629fa 100644 --- a/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json +++ b/erpnext/selling/doctype/customer_credit_limit/customer_credit_limit.json @@ -36,19 +36,20 @@ "fieldname": "bypass_credit_limit_check", "fieldtype": "Check", "in_list_view": 1, - "label": "Bypass Credit Limit Check at Sales Order" + "label": "Bypass credit limit check at sales order" } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:06:48.432478", + "modified": "2026-05-27 00:26:50.904565", "modified_by": "Administrator", "module": "Selling", "name": "Customer Credit Limit", "owner": "Administrator", "permissions": [], "quick_entry": 1, + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/erpnext/selling/doctype/party_specific_item/party_specific_item.js b/erpnext/selling/doctype/party_specific_item/party_specific_item.js index 0decc70d7ac..779f2683102 100644 --- a/erpnext/selling/doctype/party_specific_item/party_specific_item.js +++ b/erpnext/selling/doctype/party_specific_item/party_specific_item.js @@ -2,6 +2,21 @@ // For license information, please see license.txt frappe.ui.form.on("Party Specific Item", { - // refresh: function(frm) { - // } + setup: function (frm) { + frm.trigger("party_type"); + }, + + party_type: function (frm) { + if (["Customer Group", "Supplier Group"].includes(frm.doc.party_type)) { + frm.set_query("party", function () { + return { + filters: { + is_group: 0, + }, + }; + }); + } else { + frm.set_query("party", null); + } + }, }); diff --git a/erpnext/selling/doctype/party_specific_item/party_specific_item.json b/erpnext/selling/doctype/party_specific_item/party_specific_item.json index 999cb61af61..b1aa5bfa59b 100644 --- a/erpnext/selling/doctype/party_specific_item/party_specific_item.json +++ b/erpnext/selling/doctype/party_specific_item/party_specific_item.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_import": 1, "creation": "2021-08-27 19:28:07.559978", "doctype": "DocType", @@ -18,7 +19,7 @@ "fieldtype": "Select", "in_list_view": 1, "label": "Party Type", - "options": "Customer\nSupplier", + "options": "Customer\nCustomer Group\nSupplier\nSupplier Group", "reqd": 1 }, { @@ -52,7 +53,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-27 13:10:08.752476", + "modified": "2026-05-17 11:46:17.634855", "modified_by": "Administrator", "module": "Selling", "name": "Party Specific Item", @@ -71,9 +72,10 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], "title_field": "party", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/selling/doctype/party_specific_item/party_specific_item.py b/erpnext/selling/doctype/party_specific_item/party_specific_item.py index d1775c2467c..77eb9095305 100644 --- a/erpnext/selling/doctype/party_specific_item/party_specific_item.py +++ b/erpnext/selling/doctype/party_specific_item/party_specific_item.py @@ -17,7 +17,7 @@ class PartySpecificItem(Document): based_on_value: DF.DynamicLink party: DF.DynamicLink - party_type: DF.Literal["Customer", "Supplier"] + party_type: DF.Literal["Customer", "Customer Group", "Supplier", "Supplier Group"] restrict_based_on: DF.Literal["Item", "Item Group", "Brand"] # end: auto-generated types diff --git a/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py b/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py index a94837452e6..e555901965d 100644 --- a/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py +++ b/erpnext/selling/doctype/party_specific_item/test_party_specific_item.py @@ -31,7 +31,7 @@ class TestPartySpecificItem(ERPNextTestSuite): items = item_query( doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters=filters, as_dict=False ) - self.assertTrue(item in flatten(items)) + self.assertIn(item, flatten(items)) def test_item_query_for_supplier(self): supplier = "_Test Supplier With Template 1" @@ -47,7 +47,24 @@ class TestPartySpecificItem(ERPNextTestSuite): items = item_query( doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters=filters, as_dict=False ) - self.assertTrue(item in flatten(items)) + self.assertIn(item, flatten(items)) + + def test_party_group(self): + customer = "_Test Customer With Template" + item = "_Test Item" + frappe.set_value("Customer", customer, "customer_group", "Government") + + create_party_specific_item( + party_type="Customer Group", + party="Government", + restrict_based_on="Item", + based_on_value=item, + ) + filters = {"is_sales_item": 1, "customer": customer} + items = item_query( + doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters=filters, as_dict=False + ) + self.assertIn(item, flatten(items)) def flatten(lst): diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index d70475eeb0a..24da8f332b9 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -22,8 +22,6 @@ "company", "has_unit_price_items", "amended_from", - "section_break_cojf", - "title", "currency_and_price_list", "currency", "conversion_rate", @@ -137,6 +135,7 @@ "status", "customer_group", "territory", + "title", "column_break_108", "opportunity", "enq_det", @@ -306,7 +305,6 @@ "read_only": 1 }, { - "depends_on": "eval:(doc.quotation_to=='Customer' && doc.party_name)", "fieldname": "col_break98", "fieldtype": "Column Break", "width": "50%" @@ -1133,10 +1131,6 @@ "fieldtype": "Section Break", "label": "Auto Repeat" }, - { - "fieldname": "section_break_cojf", - "fieldtype": "Section Break" - }, { "allow_on_submit": 1, "fieldname": "title", @@ -1150,7 +1144,7 @@ "idx": 82, "is_submittable": 1, "links": [], - "modified": "2026-03-30 12:19:04.589592", + "modified": "2026-05-30 17:40:02.667637", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 2e0507ab4ca..739b91f18b1 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -26,8 +26,6 @@ "has_unit_price_items", "is_subcontracted", "amended_from", - "section_break_umok", - "title", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -176,6 +174,7 @@ "po_no", "po_date", "represents_company", + "title", "column_break_yvzv", "inter_company_order_reference", "party_account_currency", @@ -1747,10 +1746,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "section_break_umok", - "fieldtype": "Section Break" - }, { "allow_on_submit": 1, "fieldname": "title", @@ -1765,7 +1760,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2026-05-01 02:37:30.937916", + "modified": "2026-05-28 11:41:11.823034", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 321515252bb..3b4160be17e 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -21,7 +21,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( update_linked_doc, validate_inter_company_party, ) -from erpnext.accounts.party import get_party_account +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account from erpnext.controllers.selling_controller import SellingController from erpnext.manufacturing.doctype.blanket_order.blanket_order import ( validate_against_blanket_order, @@ -337,20 +337,24 @@ class SalesOrder(SellingController): ) if self.po_no and self.customer and not self.skip_delivery_note: - so = frappe.db.sql( - "select name from `tabSales Order` \ - where ifnull(po_no, '') = %s and name != %s and docstatus < 2\ - and customer = %s", - (self.po_no, self.name, self.customer), + so = frappe.db.get_value( + "Sales Order", + filters={ + "po_no": self.po_no, + "name": ["!=", self.name], + "docstatus": ["<", 2], + "customer": self.customer, + }, + fieldname="name", ) - if so and so[0][0]: + if so: if cint( frappe.get_single_value("Selling Settings", "allow_against_multiple_purchase_orders") ): frappe.msgprint( _( "Warning: Sales Order {0} already exists against Customer's Purchase Order {1}" - ).format(frappe.bold(so[0][0]), frappe.bold(self.po_no)), + ).format(frappe.bold(so), frappe.bold(self.po_no)), alert=True, ) else: @@ -358,7 +362,7 @@ class SalesOrder(SellingController): _( "Sales Order {0} already exists against Customer's Purchase Order {1}. To allow multiple Sales Orders, Enable {2} in {3}" ).format( - frappe.bold(so[0][0]), + frappe.bold(so), frappe.bold(self.po_no), frappe.bold( _("'Allow Multiple Sales Orders Against a Customer's Purchase Order'") @@ -368,39 +372,49 @@ class SalesOrder(SellingController): ) def validate_for_items(self): - for d in self.get("items"): - # used for production plan - d.transaction_date = self.transaction_date + item_warehouse_pairs = [ + (d.item_code, d.warehouse) for d in self.get("items") if d.item_code and d.warehouse + ] - tot_avail_qty = frappe.db.sql( - "select projected_qty from `tabBin` \ - where item_code = %s and warehouse = %s", - (d.item_code, d.warehouse), + bin_data = {} + if item_warehouse_pairs: + bins = frappe.get_all( + "Bin", + fields=["item_code", "warehouse", "projected_qty"], + filters={"item_code": ["in", [p[0] for p in item_warehouse_pairs]]}, ) - d.projected_qty = tot_avail_qty and flt(tot_avail_qty[0][0]) or 0 + bin_data = {(b.item_code, b.warehouse): flt(b.projected_qty) for b in bins} + + for d in self.get("items"): + d.transaction_date = self.transaction_date + d.projected_qty = bin_data.get((d.item_code, d.warehouse), 0.0) def product_bundle_has_stock_item(self, product_bundle): """Returns true if product bundle has stock item""" - ret = len( - frappe.db.sql( - """select i.name from tabItem i, `tabProduct Bundle Item` pbi - where pbi.parent = %s and pbi.item_code = i.name and i.is_stock_item = 1""", - product_bundle, - ) + bundle_items = frappe.get_all( + "Product Bundle Item", filters={"parent": product_bundle}, pluck="item_code" ) - return ret + + if not bundle_items: + return False + + return frappe.db.exists("Item", {"name": ["in", bundle_items], "is_stock_item": 1}) is not None def validate_sales_mntc_quotation(self): + quotation_names = [d.prevdoc_docname for d in self.get("items") if d.prevdoc_docname] + + if not quotation_names: + return + + valid_quotations = frappe.get_all( + "Quotation", + filters={"name": ["in", quotation_names], "order_type": self.order_type}, + pluck="name", + ) + for d in self.get("items"): - if d.prevdoc_docname: - res = frappe.db.sql( - "select name from `tabQuotation` where name=%s and order_type = %s", - (d.prevdoc_docname, self.order_type), - ) - if not res: - frappe.msgprint( - _("Quotation {0} not of type {1}").format(d.prevdoc_docname, self.order_type) - ) + if d.prevdoc_docname and d.prevdoc_docname not in valid_quotations: + frappe.msgprint(_("Quotation {0} not of type {1}").format(d.prevdoc_docname, self.order_type)) def validate_delivery_date(self): if self.order_type == "Sales" and not self.skip_delivery_note: @@ -428,12 +442,10 @@ class SalesOrder(SellingController): def validate_proj_cust(self): if self.project and self.customer_name: - res = frappe.db.sql( - """select name from `tabProject` where name = %s - and (customer = %s or ifnull(customer,'')='')""", - (self.project, self.customer), + project_has_valid_customer = frappe.db.exists( + "Project", {"name": self.project, "customer": ["in", [self.customer, "", None]]} ) - if not res: + if not project_has_valid_customer: frappe.throw( _("Customer {0} does not belong to project {1}").format(self.customer, self.project) ) @@ -454,7 +466,7 @@ class SalesOrder(SellingController): and not cint(d.delivered_by_supplier) ): frappe.throw( - _("Delivery warehouse required for stock item {0}").format(d.item_code), WarehouseRequired + _("Source warehouse required for stock item {0}").format(d.item_code), WarehouseRequired ) def validate_with_previous_doc(self): @@ -474,12 +486,9 @@ class SalesOrder(SellingController): self.validate_rate_with_reference_doc([["Quotation", "prevdoc_docname", "quotation_item"]]) def update_enquiry_status(self, prevdoc, flag): - enq = frappe.db.sql( - "select t2.prevdoc_docname from `tabQuotation` t1, `tabQuotation Item` t2 where t2.parent = t1.name and t1.name=%s", - prevdoc, - ) - if enq: - frappe.db.sql("update `tabOpportunity` set status = %s where name=%s", (flag, enq[0][0])) + opportunity_name = frappe.db.get_value("Quotation Item", {"parent": prevdoc}, "prevdoc_docname") + if opportunity_name: + frappe.db.set_value("Opportunity", opportunity_name, "status", flag) def update_prevdoc_status(self, flag=None): for quotation in set(d.prevdoc_docname for d in self.get("items")): @@ -580,13 +589,12 @@ class SalesOrder(SellingController): check_credit_limit(self.customer, self.company) def check_nextdoc_docstatus(self): - linked_invoices = frappe.db.sql_list( - """select distinct t1.name - from `tabSales Invoice` t1,`tabSales Invoice Item` t2 - where t1.name = t2.parent and t2.sales_order = %s and t1.docstatus = 0""", - self.name, + linked_invoices = frappe.get_all( + "Sales Invoice Item", + filters={"sales_order": self.name, "docstatus": 0}, + pluck="parent", + distinct=True, ) - if linked_invoices: linked_invoices = [get_link_to_form("Sales Invoice", si) for si in linked_invoices] frappe.throw( @@ -597,8 +605,7 @@ class SalesOrder(SellingController): def check_modified_date(self): mod_db = frappe.db.get_value("Sales Order", self.name, "modified") - date_diff = frappe.db.sql(f"select TIMEDIFF('{mod_db}', '{cstr(self.modified)}')") - if date_diff and date_diff[0][0]: + if mod_db and cstr(mod_db) != cstr(self.modified): frappe.throw(_("{0} {1} has been modified. Please refresh.").format(self.doctype, self.name)) def update_status(self, status): @@ -686,18 +693,12 @@ class SalesOrder(SellingController): for item in self.items: if item.delivered_by_supplier: - item_delivered_qty = frappe.db.sql( - """select sum(qty) - from `tabPurchase Order Item` poi, `tabPurchase Order` po - where poi.sales_order_item = %s - and poi.item_code = %s - and poi.parent = po.name - and po.docstatus = 1 - and po.status = 'Delivered'""", - (item.name, item.item_code), - ) - - item_delivered_qty = item_delivered_qty[0][0] if item_delivered_qty else 0 + item_delivered_qty = frappe.get_all( + "Purchase Order Item", + {"sales_order_item": item.name, "docstatus": 1}, + [{"SUM": "received_qty", "AS": "received_qty"}], + pluck="received_qty", + )[0] item.db_set("delivered_qty", flt(item_delivered_qty), update_modified=False) delivered_qty += min(item.delivered_qty, item.qty) @@ -855,9 +856,13 @@ class SalesOrder(SellingController): packed_items = [] if items_details: - for idx, item in enumerate(items_details): + for item in items_details: if not frappe.db.exists("Sales Order Item", item.get("sales_order_item")): - packed_items.append(items_details.pop(idx)) + item["qty"] = item.pop("qty_to_reserve") + packed_items.append(item) + + for item in packed_items: + items_details.remove(item) sre_count = 0 if items_details != []: @@ -868,7 +873,13 @@ class SalesOrder(SellingController): notify=notify, ) - if items := packed_items or [item for item in self.packed_items if item.reserve_stock]: + items = [] + if packed_items: + items = packed_items + elif not items_details: + items = [item for item in self.packed_items if item.reserve_stock] + + if items: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation stock_reservation = StockReservation(doc=self, items=items) @@ -1397,10 +1408,9 @@ def make_delivery_note( dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name target_doc.append("items", dn_item) - else: - # Correct rows index. - for idx, item in enumerate(target_doc.items): - item.idx = idx + 1 + # Correct rows index. + for idx, item in enumerate(target_doc.items): + item.idx = idx + 1 if not kwargs.skip_item_mapping and frappe.flags.bulk_transaction and not target_doc.items: # the (date) condition filter resulted in an unintendedly created empty DN; remove it @@ -1602,11 +1612,8 @@ def make_sales_invoice( @frappe.whitelist() def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None): - maint_schedule = frappe.db.sql( - """select t1.name - from `tabMaintenance Schedule` t1, `tabMaintenance Schedule Item` t2 - where t2.parent=t1.name and t2.sales_order=%s and t1.docstatus=1""", - source_name, + maint_schedule = frappe.db.exists( + "Maintenance Schedule Item", {"sales_order": source_name, "docstatus": 1} ) if not maint_schedule: @@ -1628,15 +1635,20 @@ def make_maintenance_schedule(source_name: str, target_doc: str | Document | Non @frappe.whitelist() def make_maintenance_visit(source_name: str, target_doc: str | Document | None = None): - visit = frappe.db.sql( - """select t1.name - from `tabMaintenance Visit` t1, `tabMaintenance Visit Purpose` t2 - where t2.parent=t1.name and t2.prevdoc_docname=%s - and t1.docstatus=1 and t1.completion_status='Fully Completed'""", - source_name, + MaintenanceVisit = frappe.qb.DocType("Maintenance Visit") + MaintenanceVisitPurpose = frappe.qb.DocType("Maintenance Visit Purpose") + + query = ( + frappe.qb.from_(MaintenanceVisit) + .join(MaintenanceVisitPurpose) + .on(MaintenanceVisitPurpose.parent == MaintenanceVisit.name) + .select(MaintenanceVisit.name) + .where(MaintenanceVisitPurpose.prevdoc_docname == source_name) + .where(MaintenanceVisit.docstatus == 1) + .where(MaintenanceVisit.completion_status == "Fully Completed") ) - if not visit: + if not query.run(): doclist = get_mapped_doc( "Sales Order", source_name, @@ -1661,32 +1673,39 @@ def get_events(start: str, end: str, filters: str | dict | None = None): :param end: End date-time. :param filters: Filters (JSON). """ - from frappe.desk.calendar import get_event_conditions - conditions = get_event_conditions("Sales Order", filters) + SalesOrder = frappe.qb.DocType("Sales Order") + SalesOrderItem = frappe.qb.DocType("Sales Order Item") - data = frappe.db.sql( - f""" - select - distinct `tabSales Order`.name, `tabSales Order`.customer_name, `tabSales Order`.status, - `tabSales Order`.delivery_status, `tabSales Order`.billing_status, - `tabSales Order Item`.delivery_date - from - `tabSales Order`, `tabSales Order Item` - where `tabSales Order`.name = `tabSales Order Item`.parent - and `tabSales Order`.skip_delivery_note = 0 - and (ifnull(`tabSales Order Item`.delivery_date, '0000-00-00')!= '0000-00-00') \ - and (`tabSales Order Item`.delivery_date between %(start)s and %(end)s) - and `tabSales Order`.docstatus < 2 - {conditions} - """, - {"start": start, "end": end}, - as_dict=True, - update={ - "allDay": 0, - "convertToUserTz": 0, - }, + query = ( + frappe.get_query("Sales Order", filters=filters, ignore_permissions=False) + .join(SalesOrderItem) + .on(SalesOrder.name == SalesOrderItem.parent) + .select( + SalesOrder.name, + SalesOrder.customer_name, + SalesOrder.status, + SalesOrder.delivery_status, + SalesOrder.billing_status, + SalesOrderItem.delivery_date, + ) + .distinct() + .where(SalesOrder.skip_delivery_note == 0) + .where(SalesOrder.docstatus < 2) + .where(SalesOrderItem.delivery_date.between(start, end)) + .where(SalesOrderItem.delivery_date.isnotnull()) ) + + data = query.run(as_dict=True) + + for row in data: + row.update( + { + "allDay": 0, + "convertToUserTz": 0, + } + ) + return data @@ -1724,7 +1743,6 @@ def make_purchase_order( target.shipping_rule = "" target.tc_name = "" target.terms = "" - target.payment_terms_template = "" target.payment_schedule = [] default_price_list = frappe.get_value("Supplier", supplier, "default_price_list") @@ -1791,16 +1809,7 @@ def make_purchase_order( { "Sales Order": { "doctype": "Purchase Order", - "field_no_map": [ - "address_display", - "contact_display", - "contact_mobile", - "contact_email", - "contact_person", - "taxes_and_charges", - "shipping_address", - "dispatch_address", - ], + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP], "validation": {"docstatus": ["=", 1]}, }, "Sales Order Item": { diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 3dcea77fb5a..8000fb368bb 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -25,6 +25,7 @@ from erpnext.selling.doctype.sales_order.sales_order import ( make_delivery_note, make_material_request, make_production_plan, + make_purchase_order, make_raw_material_request, make_sales_invoice, make_work_orders, @@ -986,8 +987,8 @@ class TestSalesOrder(ERPNextTestSuite): so = make_sales_order(item_code="_Test Service Product Bundle", warehouse=None) - self.assertTrue("_Test Service Product Bundle Item 1" in [d.item_code for d in so.packed_items]) - self.assertTrue("_Test Service Product Bundle Item 2" in [d.item_code for d in so.packed_items]) + self.assertIn("_Test Service Product Bundle Item 1", [d.item_code for d in so.packed_items]) + self.assertIn("_Test Service Product Bundle Item 2", [d.item_code for d in so.packed_items]) def test_mix_type_product_bundle(self): make_item("_Test Mix Product Bundle", {"is_stock_item": 0}) @@ -1163,9 +1164,6 @@ class TestSalesOrder(ERPNextTestSuite): def test_drop_shipping(self): from erpnext.buying.doctype.purchase_order.purchase_order import update_status - from erpnext.selling.doctype.sales_order.sales_order import ( - make_purchase_order, - ) from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status # make items @@ -1224,9 +1222,14 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(abs(flt(reserved_qty)), 0) # test per_delivered status - update_status("Delivered", po.name) + self.assertEqual(po.status, "To Receive and Bill") + self.assertEqual(so.status, "To Deliver and Bill") + po.update_dropship_received_qty([{"name": po.items[0].name, "qty_change": 2}]) self.assertEqual(flt(frappe.db.get_value("Sales Order", so.name, "per_delivered"), 2), 100.00) po.load_from_db() + so.reload() + self.assertEqual(po.status, "To Bill") + self.assertEqual(so.status, "To Bill") # test after closing so so.db_set("status", "Closed") @@ -1254,9 +1257,6 @@ class TestSalesOrder(ERPNextTestSuite): so.cancel() def test_drop_shipping_partial_order(self): - from erpnext.selling.doctype.sales_order.sales_order import ( - make_purchase_order, - ) from erpnext.selling.doctype.sales_order.sales_order import update_status as so_update_status # make items @@ -1314,10 +1314,6 @@ class TestSalesOrder(ERPNextTestSuite): def test_drop_shipping_full_for_default_suppliers(self): """Test if multiple POs are generated in one go against different default suppliers.""" - from erpnext.selling.doctype.sales_order.sales_order import ( - make_purchase_order, - ) - if not frappe.db.exists("Item", "_Test Item for Drop Shipping 1"): make_item("_Test Item for Drop Shipping 1", {"is_stock_item": 1, "delivered_by_supplier": 1}) @@ -1358,8 +1354,6 @@ class TestSalesOrder(ERPNextTestSuite): Tests if the the Product Bundles in the Items table of Sales Orders are replaced with their child items(from the Packed Items table) on creating a Purchase Order from it. """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -1388,8 +1382,6 @@ class TestSalesOrder(ERPNextTestSuite): """ Tests if the packed item's `ordered_qty` is updated with the quantity of the Purchase Order """ - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - product_bundle = make_item("_Test Product Bundle", {"is_stock_item": 0}) make_item("_Test Bundle Item 1", {"is_stock_item": 1}) make_item("_Test Bundle Item 2", {"is_stock_item": 1}) @@ -2337,8 +2329,8 @@ class TestSalesOrder(ERPNextTestSuite): pick_list.save() for row in pick_list.locations: self.assertEqual(row.qty, 1.0) - self.assertFalse(row.warehouse == rejected_warehouse) - self.assertTrue(row.warehouse == warehouse) + self.assertNotEqual(row.warehouse, rejected_warehouse) + self.assertEqual(row.warehouse, warehouse) def test_pick_list_for_batch(self): from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note @@ -2366,16 +2358,16 @@ class TestSalesOrder(ERPNextTestSuite): for row in pick_list.locations: self.assertEqual(row.qty, 10.0) - self.assertTrue(row.warehouse == warehouse) - self.assertTrue(row.batch_no == batch_no) + self.assertEqual(row.warehouse, warehouse) + self.assertEqual(row.batch_no, batch_no) pick_list.submit() dn = create_delivery_note(pick_list.name) for row in dn.items: self.assertEqual(row.qty, 10.0) - self.assertTrue(row.warehouse == warehouse) - self.assertTrue(row.batch_no == batch_no) + self.assertEqual(row.warehouse, warehouse) + self.assertEqual(row.batch_no, batch_no) dn.submit() dn.reload() @@ -2433,7 +2425,7 @@ class TestSalesOrder(ERPNextTestSuite): so.items[0].rate = 90 so.save() - self.assertTrue(so.items[0].discount_amount == 27558.0) + self.assertEqual(so.items[0].discount_amount, 27558.0) so.submit() warehouse = create_warehouse("NW Warehouse FOR Rate", company=so.company) @@ -2579,13 +2571,13 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(len(sres), 1) sre_doc = frappe.get_doc("Stock Reservation Entry", sres[0].name) - self.assertFalse(sre_doc.status == "Delivered") + self.assertNotEqual(sre_doc.status, "Delivered") si = make_sales_invoice(so.name) si.update_stock = 1 si.submit() sre_doc.reload() - self.assertTrue(sre_doc.status == "Delivered") + self.assertEqual(sre_doc.status, "Delivered") @ERPNextTestSuite.change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}) def test_deliver_zero_qty_purchase_order(self): @@ -2659,8 +2651,6 @@ class TestSalesOrder(ERPNextTestSuite): self.assertEqual(so.status, "To Deliver and Bill") def test_item_tax_transfer_from_sales_to_purchase(self): - from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order - item_tax = frappe.new_doc("Item Tax Template") item_tax.title = "Test Item Tax Template" item_tax.company = "_Test Company" @@ -2690,6 +2680,33 @@ class TestSalesOrder(ERPNextTestSuite): po.submit() self.assertEqual(po.taxes[0].tax_amount, 2) + def test_make_purchase_order_does_not_inherit_party_fields(self): + """ + Customer-derived fields must not leak from a drop-ship SO into the PO. + """ + so_items = [ + { + "item_code": "_Test Item", + "warehouse": "", + "qty": 1, + "rate": 100, + "delivered_by_supplier": 1, + "supplier": "_Test Supplier", + } + ] + so = make_sales_order(item_list=so_items, do_not_submit=True) + so.tax_category = "_Test Tax Category 1" + so.language = "ar" + so.payment_terms_template = "_Test Payment Term Template" + so.submit() + + po = make_purchase_order(so.name, selected_items=so_items)[0] + + supplier = frappe.get_doc("Supplier", "_Test Supplier") + self.assertEqual(po.tax_category or None, supplier.tax_category or None) + self.assertEqual(po.language or None, supplier.language or None) + self.assertEqual(po.payment_terms_template or None, supplier.payment_terms or None) + def test_pending_quantity_after_update_item_during_invoice_creation(self): so = make_sales_order(qty=30, rate=100) diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 0e7b174668d..df675272c68 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -427,42 +427,80 @@ def get_past_order_list(search_term: str, status: str, limit: int = 20): @frappe.whitelist() def set_customer_info(fieldname: str, customer: str, value: str = ""): + customer_doc = frappe.get_doc("Customer", customer) + customer_doc.check_permission("write") + if fieldname == "loyalty_program": - frappe.db.set_value("Customer", customer, "loyalty_program", value) + customer_doc.loyalty_program = value + else: + contact = customer_doc.get("customer_primary_contact") + if not contact: + Contact = DocType("Contact") + DynamicLink = DocType("Dynamic Link") - contact = frappe.get_cached_value("Customer", customer, "customer_primary_contact") - if not contact: - contact = frappe.db.sql( - """ - SELECT parent FROM `tabDynamic Link` - WHERE - parenttype = 'Contact' AND - parentfield = 'links' AND - link_doctype = 'Customer' AND - link_name = %s - """, - (customer), - as_dict=1, - ) - contact = contact[0].get("parent") if contact else None + # Inner join with Contact DocType, to priorities records that have is_primary_contact set. + query = ( + frappe.qb.from_(DynamicLink) + .join(Contact) + .on(DynamicLink.parent == Contact.name) + .select(DynamicLink.parent) + .where( + (DynamicLink.link_name == customer) + & (DynamicLink.parentfield == "links") + & (DynamicLink.parenttype == "Contact") + & (DynamicLink.link_doctype == "Customer") + ) + .orderby(Contact.is_primary_contact, order=Order.desc) + ) - if not contact: - new_contact = frappe.new_doc("Contact") - new_contact.is_primary_contact = 1 - new_contact.first_name = customer - new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}]) - new_contact.save() - contact = new_contact.name - frappe.db.set_value("Customer", customer, "customer_primary_contact", contact) + contacts = query.run(pluck=DynamicLink.parent) - contact_doc = frappe.get_doc("Contact", contact) - if fieldname == "email_id": - contact_doc.set("email_ids", [{"email_id": value, "is_primary": 1}]) - frappe.db.set_value("Customer", customer, "email_id", value) - elif fieldname == "mobile_no": - contact_doc.set("phone_nos", [{"phone": value, "is_primary_mobile_no": 1}]) - frappe.db.set_value("Customer", customer, "mobile_no", value) - contact_doc.save() + contact = contacts[0] if contacts else None + + if not contact: + new_contact = frappe.new_doc("Contact") + new_contact.is_primary_contact = 1 + new_contact.first_name = customer + new_contact.set("links", [{"link_doctype": "Customer", "link_name": customer}]) + new_contact.save() + contact = new_contact.name + + def set_primary_phone_no_email(field, value): + # Create new record instead deleting existing email or phone_no and setting the new row as primary. + field_mapper = { + "email_ids": {"field": "email_id", "primary": "is_primary"}, + "phone_nos": {"field": "phone", "primary": "is_primary_mobile_no"}, + } + + value_already_exists = False + for d in contact_doc.get(field): + if d.get(field_mapper[field].get("field")) == value and not value_already_exists: + d.set(field_mapper[field]["primary"], 1) + value_already_exists = True + continue + d.set(field_mapper[field]["primary"], 0) + + if not value_already_exists: + contact_doc.append( + field, {field_mapper[field]["field"]: value, field_mapper[field]["primary"]: 1} + ) + + contact_doc = frappe.get_doc("Contact", contact) + # setting is_primary_contact = 1 on Contact to refetch the same contact incase it's removed from Customer records. + contact_doc.set("is_primary_contact", 1) + if fieldname == "email_id": + set_primary_phone_no_email("email_ids", value) + elif fieldname == "mobile_no": + set_primary_phone_no_email("phone_nos", value) + # Saving contact_doc to set mobile_no and email. + contact_doc.save() + + # Auto-fetches from Contact DocType, no need to set values separately. + customer_doc.customer_primary_contact = contact + + # using save method instead db.set_value which bypasses the validation for loyalty program + # and auto sets the mobile_no and email field on customer records. + customer_doc.save() @frappe.whitelist() diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index eefc932bcc1..070d4288147 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -217,7 +217,7 @@ erpnext.PointOfSale.Controller = class { set_opening_entry_status() { this.page.set_title_sub( ` - + Opened at ${frappe.datetime.str_to_user(this.pos_opening_time)} ` diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 857189ab6d0..950377f1b36 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -184,7 +184,7 @@ erpnext.PointOfSale.ItemCart = class { me.$totals_section.find(".edit-cart-btn").click(); } - const item_row_name = unescape($cart_item.attr("data-row-name")); + const item_row_name = $cart_item.attr("data-row-name"); me.events.cart_item_clicked({ name: item_row_name }); this.numpad_value = ""; }); @@ -464,10 +464,10 @@ erpnext.PointOfSale.ItemCart = class {
${this.get_customer_image()}
-
${customer_name}
+
${frappe.utils.escape_html(customer_name)}
${get_customer_description()}
-
+
@@ -484,11 +484,13 @@ erpnext.PointOfSale.ItemCart = class { if (!email_id && !mobile_no) { return `
${__("Click to add email / phone")}
`; } else if (email_id && !mobile_no) { - return `
${email_id}
`; + return `
${frappe.utils.escape_html(email_id)}
`; } else if (mobile_no && !email_id) { - return `
${mobile_no}
`; + return `
${frappe.utils.escape_html(mobile_no)}
`; } else { - return `
${email_id} - ${mobile_no}
`; + return `
${frappe.utils.escape_html( + email_id + )} - ${frappe.utils.escape_html(mobile_no)}
`; } } } @@ -496,9 +498,13 @@ erpnext.PointOfSale.ItemCart = class { get_customer_image() { const { customer, image } = this.customer_info || {}; if (image) { - return `
${image}
`; + return `
${frappe.utils.escape_html(image)}
`; } else { - return `
${frappe.get_abbr(customer)}
`; + return `
${frappe.utils.escape_html( + frappe.get_abbr(customer) + )}
`; } } @@ -559,7 +565,7 @@ erpnext.PointOfSale.ItemCart = class { .map((t) => { if (t.tax_amount_after_discount_amount == 0.0) return; return `
-
${t.description}
+
${frappe.utils.escape_html(t.description)}
${format_currency(t.tax_amount_after_discount_amount, currency)}
`; }) @@ -571,8 +577,9 @@ erpnext.PointOfSale.ItemCart = class { } get_cart_item({ name }) { - const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`; - return this.$cart_items_wrapper.find(item_selector); + return this.$cart_items_wrapper.find(".cart-item-wrapper").filter(function () { + return $(this).attr("data-row-name") === name; + }); } get_item_from_frm(item) { @@ -602,7 +609,9 @@ erpnext.PointOfSale.ItemCart = class { if (!$item_to_update.length) { this.$cart_items_wrapper.append( - `
+ `
` ); $item_to_update = this.get_cart_item(item_data); @@ -612,7 +621,7 @@ erpnext.PointOfSale.ItemCart = class { `${get_item_image_html()}
- ${item_data.item_name} + ${frappe.utils.escape_html(item_data.item_name)}
${get_description_html()}
@@ -641,7 +650,7 @@ erpnext.PointOfSale.ItemCart = class { if (item_data.rate && item_data.amount && item_data.rate !== item_data.amount) { return `
-
${item_data.qty || 0} ${item_data.uom}
+
${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}
${format_currency(item_data.amount, currency)}
${format_currency(item_data.rate, currency)}
@@ -650,7 +659,7 @@ erpnext.PointOfSale.ItemCart = class { } else { return `
-
${item_data.qty || 0} ${item_data.uom}
+
${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}
${format_currency(item_data.rate, currency)}
@@ -671,7 +680,7 @@ erpnext.PointOfSale.ItemCart = class { } } item_data.description = frappe.ellipsis(item_data.description, 45); - return `
${item_data.description}
`; + return `
${frappe.utils.escape_html(item_data.description)}
`; } return ``; } @@ -683,22 +692,24 @@ erpnext.PointOfSale.ItemCart = class {
${frappe.get_abbr(item_name)} + src="${frappe.utils.escape_html(image)}" alt="${frappe.utils.escape_html(frappe.get_abbr(item_name))}">
`; } else { - return `
${frappe.get_abbr(item_name)}
`; + return `
${frappe.utils.escape_html( + frappe.get_abbr(item_name) + )}
`; } } } handle_broken_image($img) { - const item_abbr = $($img).attr("alt"); + const item_abbr = frappe.utils.escape_html($($img).attr("alt")); $($img).parent().replaceWith(`
${item_abbr}
`); } update_selector_value_in_cart_item(selector, value, item) { const $item_to_update = this.get_cart_item(item); - $item_to_update.attr(`data-${selector}`, escape(value)); + $item_to_update.attr(`data-${selector}`, value); } toggle_checkout_btn(show_checkout) { @@ -899,8 +910,8 @@ erpnext.PointOfSale.ItemCart = class {
${this.get_customer_image()}
-
${customer_name}
-
${customer}
+
${frappe.utils.escape_html(customer_name)}
+
${frappe.utils.escape_html(customer)}
@@ -987,6 +998,7 @@ erpnext.PointOfSale.ItemCart = class { customer: current_customer, value: this.value, }, + freeze: true, callback: (r) => { if (!r.exc) { me.customer_info[this.df.fieldname] = this.value; @@ -1040,9 +1052,11 @@ erpnext.PointOfSale.ItemCart = class { }; transaction_container.append( - `
+ `
-
${invoice.name}
+
${frappe.utils.escape_html(invoice.name)}
${posting_datetime}
@@ -1050,7 +1064,7 @@ erpnext.PointOfSale.ItemCart = class { ${format_currency(invoice.grand_total, invoice.currency, frappe.sys_defaults.currency_precision) || 0}
- + ${__(invoice.status)}
diff --git a/erpnext/selling/page/point_of_sale/pos_item_details.js b/erpnext/selling/page/point_of_sale/pos_item_details.js index 51ef0df8c2c..322c82384fa 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_details.js +++ b/erpnext/selling/page/point_of_sale/pos_item_details.js @@ -130,24 +130,26 @@ erpnext.PointOfSale.ItemDetails = class { return ``; } - this.$item_name.html(item_name); + this.$item_name.html(frappe.utils.escape_html(item_name)); this.$item_description.html(get_description_html()); this.$item_price.html(format_currency(price_list_rate, this.currency)); if (!this.hide_images && image) { this.$item_image.html( `${frappe.get_abbr(item_name)}` ); } else { - this.$item_image.html(`
${frappe.get_abbr(item_name)}
`); + this.$item_image.html( + `
${frappe.utils.escape_html(frappe.get_abbr(item_name))}
` + ); } } handle_broken_image($img) { - const item_abbr = $($img).attr("alt"); + const item_abbr = frappe.utils.escape_html($($img).attr("alt")); $($img).replaceWith(`
${item_abbr}
`); } diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index 69ec1e56934..f05040c6a08 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -112,17 +112,37 @@ erpnext.PointOfSale.ItemSelector = class { render_item_list_column_header() { return `
-
Name
-
Price
-
UOM
-
Quantity Available
+
${__("Name")}
+
${__("Price")}
+
${__("UOM")}
+
${__("Quantity Available")}
`; } get_item_html(item) { const me = this; // eslint-disable-next-line no-unused-vars - const { item_image, serial_no, batch_no, barcode, actual_qty, uom, price_list_rate } = item; + function sanitize_item_data(item) { + return Object.fromEntries( + Object.entries(item).map(([key, value]) => [ + key, + typeof value === "string" ? frappe.utils.escape_html(value) : value, + ]) + ); + } + const sanitize_item = sanitize_item_data(item); + const { + item_code, + stock_uom, + item_name, + item_image, + serial_no, + batch_no, + barcode, + actual_qty, + uom, + price_list_rate, + } = sanitize_item; const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0; let indicator_color; let qty_to_display = actual_qty; @@ -149,37 +169,41 @@ erpnext.PointOfSale.ItemSelector = class { ${item.item_name}
`; } else { return `
${qty_to_display}
-
${frappe.get_abbr(item.item_name)}
`; +
${frappe.get_abbr(item_name)}
`; } } return `
+ data-item-code="${item_code}" data-serial-no="${serial_no}" + data-batch-no="${batch_no}" data-uom="${uom}" + data-rate="${price_list_rate || 0}" + data-stock-uom="${stock_uom}" + title="${item_name}"> ${get_item_image_html()}
- ${!me.hide_images ? frappe.ellipsis(item.item_name, 18) : item.item_name} + ${!me.hide_images ? frappe.ellipsis(item_name, 18) : item_name}
${ !me.hide_images ? `
- ${format_currency(price_list_rate, item.currency, precision) || 0} / ${uom} + ${frappe.utils.escape_html(format_currency(price_list_rate, item.currency, precision)) || 0} / ${uom}
` : ` -
${format_currency(price_list_rate, item.currency, precision) || 0}
+
${ + frappe.utils.escape_html( + format_currency(price_list_rate, item.currency, precision) + ) || 0 + }
${uom}
${qty_to_display || "Non stock item"}
` @@ -189,7 +213,7 @@ erpnext.PointOfSale.ItemSelector = class { } handle_broken_image($img) { - const item_abbr = $($img).attr("alt"); + const item_abbr = frappe.utils.escape_html($($img).attr("alt")); $($img).parent().replaceWith(`
${item_abbr}
`); } @@ -244,7 +268,7 @@ erpnext.PointOfSale.ItemSelector = class { set_item_selector_filter_label(value) { const $filter_label = this.$component.find(".label"); - $filter_label.html(value ? __(value) : __("All Items")); + $filter_label.html(value ? frappe.utils.escape_html(__(value)) : __("All Items")); } hide_open_link_btn() { @@ -329,12 +353,12 @@ erpnext.PointOfSale.ItemSelector = class { this.$component.on("click", ".item-wrapper", function () { const $item = $(this); - const item_code = unescape($item.attr("data-item-code")); - let batch_no = unescape($item.attr("data-batch-no")); - let serial_no = unescape($item.attr("data-serial-no")); - let uom = unescape($item.attr("data-uom")); - let rate = unescape($item.attr("data-rate")); - let stock_uom = unescape($item.attr("data-stock-uom")); + const item_code = $item.attr("data-item-code"); + let batch_no = $item.attr("data-batch-no"); + let serial_no = $item.attr("data-serial-no"); + let uom = $item.attr("data-uom"); + let rate = $item.attr("data-rate"); + let stock_uom = $item.attr("data-stock-uom"); // escape(undefined) returns "undefined" then unescape returns "undefined" batch_no = batch_no === "undefined" ? undefined : batch_no; diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_list.js b/erpnext/selling/page/point_of_sale/pos_past_order_list.js index 89bda039536..7eb5e16b2d6 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_list.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_list.js @@ -42,7 +42,7 @@ erpnext.PointOfSale.PastOrderList = class { this.$invoices_container.on("click", ".invoice-wrapper", function () { const invoice_clicked = $(this); const invoice_doctype = invoice_clicked.attr("data-invoice-doctype"); - const invoice_name = unescape(invoice_clicked.attr("data-invoice-name")); + const invoice_name = invoice_clicked.attr("data-invoice-name"); $(".invoice-wrapper").removeClass("invoice-selected"); invoice_clicked.addClass("invoice-selected"); @@ -108,15 +108,15 @@ erpnext.PointOfSale.PastOrderList = class { ); return `
+ }" data-invoice-name="${frappe.utils.escape_html(invoice.name)}">
- ${frappe.ellipsis(invoice.customer_name, 20)} + ${frappe.utils.escape_html(frappe.ellipsis(invoice.customer_name, 20))}
-
${invoice.name}
+
${frappe.utils.escape_html(invoice.name)}
${format_currency(invoice.grand_total, invoice.currency) || 0}
diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js index 4585b3307b2..d59b50c60ad 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js @@ -82,15 +82,19 @@ erpnext.PointOfSale.PastOrderSummary = class { return `
-
${doc.customer_name}
- ${is_customer_naming_by_customer_name ? `
${doc.customer}
` : ""} -
${this.customer_email}
+
${frappe.utils.escape_html(doc.customer_name)}
+ ${ + is_customer_naming_by_customer_name + ? `
${frappe.utils.escape_html(doc.customer)}
` + : "" + } +
${frappe.utils.escape_html(this.customer_email)}
-
${__("Sold by")}: ${doc.owner}
+
${__("Sold by")}: ${frappe.utils.escape_html(doc.owner)}
-
${doc.name}
+
${frappe.utils.escape_html(doc.name)}
${__(doc.status)}
`; } @@ -100,8 +104,8 @@ erpnext.PointOfSale.PastOrderSummary = class { return `
-
${item_data.item_name}
-
${item_data.qty || 0} ${item_data.uom}
+
${frappe.utils.escape_html(item_data.item_name)}
+
${item_data.qty || 0} ${frappe.utils.escape_html(item_data.uom)}
${get_rate_discount_html()}
@@ -166,7 +170,7 @@ erpnext.PointOfSale.PastOrderSummary = class { .map((t) => { return `
-
${t.description}
+
${frappe.utils.escape_html(t.description)}
${format_currency(t.tax_amount_after_discount_amount, doc.currency)}
`; @@ -185,7 +189,7 @@ erpnext.PointOfSale.PastOrderSummary = class { get_payment_html(doc, payment) { return `
-
${__(payment.mode_of_payment)}
+
${frappe.utils.escape_html(__(payment.mode_of_payment))}
${format_currency(payment.amount, doc.currency)}
`; } diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index a92c8958917..bf8c9f44049 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -519,7 +519,7 @@ erpnext.PointOfSale.Payment = class { return `
- ${p.mode_of_payment} + ${frappe.utils.escape_html(p.mode_of_payment)}
${amount}
@@ -603,7 +603,7 @@ erpnext.PointOfSale.Payment = class {
Redeem Loyalty Points
${amount}
-
${loyalty_program}
+
${frappe.utils.escape_html(loyalty_program)}
` diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.py b/erpnext/selling/page/sales_funnel/sales_funnel.py index 300142d8814..11fd698c27b 100644 --- a/erpnext/selling/page/sales_funnel/sales_funnel.py +++ b/erpnext/selling/page/sales_funnel/sales_funnel.py @@ -102,6 +102,7 @@ def get_opp_by(by_field, from_date, to_date, company): }, ) for x in opportunities + if x.get(by_field) ] summary = {} diff --git a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py index d9caa9b8bad..f6783abfbe5 100644 --- a/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py +++ b/erpnext/selling/report/customer_wise_item_price/customer_wise_item_price.py @@ -7,7 +7,7 @@ from frappe import _, qb from frappe.query_builder import Criterion from erpnext import get_default_company -from erpnext.accounts.party import get_party_details +from erpnext.accounts.party import _get_party_details def execute(filters=None): @@ -125,7 +125,7 @@ def get_data(filters=None): def get_customer_details(filters): - customer_details = get_party_details(party=filters.get("customer"), party_type="Customer") + customer_details = _get_party_details(party=filters.get("customer"), party_type="Customer") customer_details.update( {"company": get_default_company(), "price_list": customer_details.get("selling_price_list")} ) diff --git a/erpnext/selling/report/inactive_customers/inactive_customers.py b/erpnext/selling/report/inactive_customers/inactive_customers.py index 7e4ddc128ac..d21d11b2447 100644 --- a/erpnext/selling/report/inactive_customers/inactive_customers.py +++ b/erpnext/selling/report/inactive_customers/inactive_customers.py @@ -14,6 +14,9 @@ def execute(filters=None): days_since_last_order = filters.get("days_since_last_order") doctype = filters.get("doctype") + if doctype not in ("Sales Order", "Sales Invoice"): + frappe.throw(_("Invalid value {0} for 'Doctype'").format(doctype)) + if cint(days_since_last_order) <= 0: frappe.throw(_("'Days Since Last Order' must be greater than or equal to zero")) diff --git a/erpnext/selling/report/sales_analytics/sales_analytics.py b/erpnext/selling/report/sales_analytics/sales_analytics.py index f20f78d741d..e36690b4384 100644 --- a/erpnext/selling/report/sales_analytics/sales_analytics.py +++ b/erpnext/selling/report/sales_analytics/sales_analytics.py @@ -191,12 +191,30 @@ class Analytics: self.get_sales_transactions_based_on_project() self.get_rows() + def _get_permitted_parent_names(self): + return frappe.qb.get_query( + table=self.filters.doc_type, + fields=["name"], + filters={ + "docstatus": 1, + "company": ["in", self.filters.company], + self.date_field: ("between", [self.filters.from_date, self.filters.to_date]), + }, + ignore_permissions=False, + ).run(pluck="name") + def get_sales_transactions_based_on_order_type(self): if self.filters["value_quantity"] == "Value": value_field = "base_net_total" else: value_field = "total_qty" + permitted_names = self._get_permitted_parent_names() + if not permitted_names: + self.entries = [] + self.get_teams() + return + doctype = DocType(self.filters.doc_type) self.entries = ( @@ -206,12 +224,7 @@ class Analytics: doctype[self.date_field], doctype[value_field].as_("value_field"), ) - .where( - (doctype.docstatus == 1) - & (doctype.company.isin(self.filters.company)) - & (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date)) - & (IfNull(doctype.order_type, "") != "") - ) + .where((doctype.name.isin(permitted_names)) & (IfNull(doctype.order_type, "") != "")) .orderby(doctype.order_type) ).run(as_dict=True) @@ -250,9 +263,12 @@ class Analytics: if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: filters.update({"is_opening": "No"}) - self.entries = frappe.get_all( - self.filters.doc_type, fields=[entity, entity_name, value_field, self.date_field], filters=filters - ) + self.entries = frappe.qb.get_query( + table=self.filters.doc_type, + fields=[entity, entity_name, value_field, self.date_field], + filters=filters, + ignore_permissions=False, + ).run(as_dict=True) self.entity_names = {} for d in self.entries: @@ -264,6 +280,12 @@ class Analytics: else: value_field = "stock_qty" + permitted_names = self._get_permitted_parent_names() + if not permitted_names: + self.entries = [] + self.entity_names = {} + return + doctype = DocType(self.filters.doc_type) doctype_item = DocType(f"{self.filters.doc_type} Item") @@ -278,11 +300,7 @@ class Analytics: doctype_item[value_field].as_("value_field"), doctype[self.date_field], ) - .where( - (doctype_item.docstatus == 1) - & (doctype.company.isin(self.filters.company)) - & (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date)) - ) + .where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names))) ).run(as_dict=True) self.entity_names = {} @@ -312,11 +330,12 @@ class Analytics: if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: filters.update({"is_opening": "No"}) - self.entries = frappe.get_all( - self.filters.doc_type, + self.entries = frappe.qb.get_query( + table=self.filters.doc_type, fields=[entity_field, value_field, self.date_field], filters=filters, - ) + ignore_permissions=False, + ).run(as_dict=True) self.get_groups() def get_sales_transactions_based_on_item_group(self): @@ -325,6 +344,12 @@ class Analytics: else: value_field = "qty" + permitted_names = self._get_permitted_parent_names() + if not permitted_names: + self.entries = [] + self.get_groups() + return + doctype = DocType(self.filters.doc_type) doctype_item = DocType(f"{self.filters.doc_type} Item") @@ -337,11 +362,7 @@ class Analytics: doctype_item[value_field].as_("value_field"), doctype[self.date_field], ) - .where( - (doctype_item.docstatus == 1) - & (doctype.company.isin(self.filters.company)) - & (doctype[self.date_field].between(self.filters.from_date, self.filters.to_date)) - ) + .where((doctype_item.docstatus == 1) & (doctype.name.isin(permitted_names))) ).run(as_dict=True) self.get_groups() @@ -367,9 +388,12 @@ class Analytics: if self.filters.doc_type in ["Sales Invoice", "Purchase Invoice", "Payment Entry"]: filters.update({"is_opening": "No"}) - self.entries = frappe.get_all( - self.filters.doc_type, fields=[entity, value_field, self.date_field], filters=filters - ) + self.entries = frappe.qb.get_query( + table=self.filters.doc_type, + fields=[entity, value_field, self.date_field], + filters=filters, + ignore_permissions=False, + ).run(as_dict=True) def get_rows(self): self.data = [] @@ -473,14 +497,16 @@ class Analytics: break def get_groups(self): - if self.filters.tree_type == "Territory": - parent = "parent_territory" - if self.filters.tree_type == "Customer Group": - parent = "parent_customer_group" - if self.filters.tree_type == "Item Group": - parent = "parent_item_group" - if self.filters.tree_type == "Supplier Group": - parent = "parent_supplier_group" + parent_field_map = { + "Territory": "parent_territory", + "Customer Group": "parent_customer_group", + "Item Group": "parent_item_group", + "Supplier Group": "parent_supplier_group", + } + if self.filters.tree_type not in parent_field_map: + frappe.throw(_("Invalid Tree Type {0}").format(self.filters.tree_type)) + + parent = parent_field_map[self.filters.tree_type] self.depth_map = frappe._dict() @@ -499,6 +525,9 @@ class Analytics: def get_teams(self): self.depth_map = frappe._dict() + if not frappe.db.exists("DocType", self.filters.doc_type): + frappe.throw(_("Invalid Document Type {0}").format(self.filters.doc_type)) + self.group_entries = frappe.db.sql( f""" select * from (select "Order Types" as name, 0 as lft, 2 as rgt, '' as parent union select distinct order_type as name, 1 as lft, 1 as rgt, "Order Types" as parent diff --git a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py index f8cde141fe4..ceb637e8078 100644 --- a/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py +++ b/erpnext/selling/report/sales_person_wise_transaction_summary/sales_person_wise_transaction_summary.py @@ -4,7 +4,7 @@ import frappe from frappe import _, msgprint, qb -from frappe.query_builder import Criterion +from frappe.query_builder import Case, Criterion from erpnext import get_company_currency @@ -146,50 +146,60 @@ def get_columns(filters): def get_entries(filters): - date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date" - if filters["doc_type"] == "Sales Order": - qty_field = "delivered_qty" - else: - qty_field = "qty" - conditions, values = get_conditions(filters, date_field) + doc_type = filters["doc_type"] - entries = frappe.db.sql( - """ - SELECT - dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code, - st.sales_person, st.allocated_percentage, dt_item.warehouse, - CASE - WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor - ELSE dt_item.stock_qty - END as stock_qty, - CASE - WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) - ELSE dt_item.base_net_amount - END as base_net_amount, - CASE - WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100) - ELSE dt_item.base_net_amount * st.allocated_percentage/100 - END as contribution_amt - FROM - `tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st - WHERE - st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {} - and dt.docstatus = 1 {} order by st.sales_person, dt.name desc - """.format( - date_field, - qty_field, - qty_field, - qty_field, - filters["doc_type"], - filters["doc_type"], - "%s", - conditions, - ), - tuple([filters["doc_type"], *values]), - as_dict=1, + date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date" + qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty" + + dt = frappe.qb.DocType(doc_type) + dt_item = frappe.qb.DocType(f"{doc_type} Item") + st = frappe.qb.DocType("Sales Team") + + calc_qty = dt_item[qty_field] * dt_item.conversion_factor + calc_net_amount = dt_item.base_net_rate * calc_qty + + stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty") + + base_net_amount_case = ( + Case() + .when(dt.status == "Closed", calc_net_amount) + .else_(dt_item.base_net_amount) + .as_("base_net_amount") ) - return entries + contribution_amt_case = ( + Case() + .when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100)) + .else_(dt_item.base_net_amount * st.allocated_percentage / 100) + .as_("contribution_amt") + ) + + query = ( + frappe.get_query(dt, filters=filters, ignore_permissions=False) + .join(dt_item) + .on(dt.name == dt_item.parent) + .join(st) + .on(dt.name == st.parent) + .select( + dt.name, + dt.customer, + dt.territory, + dt[date_field].as_("posting_date"), + dt_item.item_code, + st.sales_person, + st.allocated_percentage, + dt_item.warehouse, + stock_qty_case, + base_net_amount_case, + contribution_amt_case, + ) + .where(st.parenttype == doc_type) + .where(dt.docstatus == 1) + ) + + query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc) + + return query.run(as_dict=True) def get_conditions(filters, date_field): @@ -250,8 +260,5 @@ def get_items(filters): def get_item_details(): - item_details = {} - for d in frappe.db.sql("""SELECT `name`, `item_group`, `brand` FROM `tabItem`""", as_dict=1): - item_details.setdefault(d.name, d) - - return item_details + items = frappe.get_all("Item", fields=["name", "item_group", "brand"]) + return {d.name: d for d in items} diff --git a/erpnext/selling/report/territory_wise_sales/territory_wise_sales.py b/erpnext/selling/report/territory_wise_sales/territory_wise_sales.py index e31b259d731..a134bd2915a 100644 --- a/erpnext/selling/report/territory_wise_sales/territory_wise_sales.py +++ b/erpnext/selling/report/territory_wise_sales/territory_wise_sales.py @@ -104,80 +104,75 @@ def get_data(filters=None): def get_opportunities(filters): - conditions = "" + orm_filters = {} if filters.get("transaction_date"): - conditions = " WHERE transaction_date between {} and {}".format( - frappe.db.escape(filters["transaction_date"][0]), - frappe.db.escape(filters["transaction_date"][1]), - ) + orm_filters["transaction_date"] = [ + "between", + [filters["transaction_date"][0], filters["transaction_date"][1]], + ] - if filters.company: - if conditions: - conditions += " AND" - else: - conditions += " WHERE" - conditions += " company = %(company)s" + if filters.get("company"): + orm_filters["company"] = filters["company"] - return frappe.db.sql( - f""" - SELECT name, territory, opportunity_amount - FROM `tabOpportunity` {conditions} - """, - filters, - as_dict=1, - ) # nosec + return frappe.get_all( + "Opportunity", fields=["name", "territory", "opportunity_amount"], filters=orm_filters + ) def get_quotations(opportunities): if not opportunities: return [] - opportunity_names = [o.name for o in opportunities] + opportunity_names = [o.get("name") for o in opportunities] - return frappe.db.sql( - """ - SELECT `name`,`base_grand_total`, `opportunity` - FROM `tabQuotation` - WHERE docstatus=1 AND opportunity in ({}) - """.format(", ".join(["%s"] * len(opportunity_names))), - tuple(opportunity_names), - as_dict=1, - ) # nosec + return frappe.get_all( + "Quotation", + fields=["name", "base_grand_total", "opportunity"], + filters={"docstatus": 1, "opportunity": ["in", opportunity_names]}, + ) def get_sales_orders(quotations): if not quotations: return [] - quotation_names = [q.name for q in quotations] + quotation_names = [q.get("name") for q in quotations] - return frappe.db.sql( - """ - SELECT so.`name`, so.`base_grand_total`, soi.prevdoc_docname as quotation - FROM `tabSales Order` so, `tabSales Order Item` soi - WHERE so.docstatus=1 AND so.name = soi.parent AND soi.prevdoc_docname in ({}) - """.format(", ".join(["%s"] * len(quotation_names))), - tuple(quotation_names), - as_dict=1, - ) # nosec + SalesOrder = frappe.qb.DocType("Sales Order") + SalesOrderItem = frappe.qb.DocType("Sales Order Item") + + query = ( + frappe.qb.from_(SalesOrder) + .join(SalesOrderItem) + .on(SalesOrder.name == SalesOrderItem.parent) + .select(SalesOrder.name, SalesOrder.base_grand_total, SalesOrderItem.prevdoc_docname.as_("quotation")) + .where(SalesOrder.docstatus == 1) + .where(SalesOrderItem.prevdoc_docname.isin(quotation_names)) + ) + + return query.run(as_dict=True) def get_sales_invoice(sales_orders): if not sales_orders: return [] - so_names = [so.name for so in sales_orders] + so_names = [so.get("name") for so in sales_orders] - return frappe.db.sql( - """ - SELECT si.name, si.base_grand_total, sii.sales_order - FROM `tabSales Invoice` si, `tabSales Invoice Item` sii - WHERE si.docstatus=1 AND si.name = sii.parent AND sii.sales_order in ({}) - """.format(", ".join(["%s"] * len(so_names))), - tuple(so_names), - as_dict=1, - ) # nosec + SalesInvoice = frappe.qb.DocType("Sales Invoice") + SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item") + + query = ( + frappe.qb.from_(SalesInvoice) + .join(SalesInvoiceItem) + .on(SalesInvoice.name == SalesInvoiceItem.parent) + .select(SalesInvoice.name, SalesInvoice.base_grand_total, SalesInvoiceItem.sales_order) + .where(SalesInvoice.docstatus == 1) + .where(SalesInvoiceItem.sales_order.isin(so_names)) + ) + + return query.run(as_dict=True) def _get_total(doclist, amount_field="base_grand_total"): diff --git a/erpnext/setup/doctype/authorization_control/authorization_control.py b/erpnext/setup/doctype/authorization_control/authorization_control.py index ef703de698f..98bc2aa7d9f 100644 --- a/erpnext/setup/doctype/authorization_control/authorization_control.py +++ b/erpnext/setup/doctype/authorization_control/authorization_control.py @@ -120,7 +120,9 @@ class AuthorizationControl(TransactionBase): if val == 1: add_cond += " and system_user = {}".format(frappe.db.escape(session["user"])) elif val == 2: - add_cond += " and system_role IN %s" % ("('" + "','".join(frappe.get_roles()) + "')") + add_cond += " and system_role IN (%s)" % ", ".join( + frappe.db.escape(r) for r in frappe.get_roles() + ) else: add_cond += " and ifnull(system_user,'') = '' and ifnull(system_role,'') = ''" @@ -206,8 +208,8 @@ class AuthorizationControl(TransactionBase): and docstatus != 2 """.format( "%s", - "'" + "','".join(frappe.get_roles()) + "'", - "'" + "','".join(final_based_on) + "'", + ", ".join(frappe.db.escape(r) for r in frappe.get_roles()), + ", ".join(frappe.db.escape(b) for b in final_based_on), "%s", ), (doctype_name, company), diff --git a/erpnext/setup/doctype/company/company.js b/erpnext/setup/doctype/company/company.js index f8daf3c6f31..66acff63c0d 100644 --- a/erpnext/setup/doctype/company/company.js +++ b/erpnext/setup/doctype/company/company.js @@ -69,6 +69,22 @@ frappe.ui.form.on("Company", { }, }; }); + + frm.set_query("default_letter_head", function () { + return { + filters: { + letter_head_for: "DocType", + }, + }; + }); + + frm.set_query("default_letter_head_report", function () { + return { + filters: { + letter_head_for: "Report", + }, + }; + }); }, company_name: function (frm) { @@ -320,6 +336,10 @@ erpnext.company.setup_queries = function (frm) { "stock_received_but_not_billed", { root_type: "Liability", account_type: "Stock Received But Not Billed" }, ], + [ + "stock_delivered_but_not_billed", + { root_type: "Liability", account_type: "Stock Delivered But Not Billed" }, + ], [ "service_received_but_not_billed", { root_type: "Liability", account_type: "Service Received But Not Billed" }, diff --git a/erpnext/setup/doctype/company/company.json b/erpnext/setup/doctype/company/company.json index 37eda038e3b..49f61238839 100644 --- a/erpnext/setup/doctype/company/company.json +++ b/erpnext/setup/doctype/company/company.json @@ -17,12 +17,15 @@ "is_group", "default_holiday_list", "cb0", - "default_letter_head", "tax_id", "domain", "date_of_establishment", "parent_company", "reporting_currency", + "section_break_soma", + "default_letter_head", + "column_break_zqmp", + "default_letter_head_report", "company_info", "company_logo", "date_of_incorporation", @@ -127,6 +130,8 @@ "column_break_32", "stock_adjustment_account", "stock_received_but_not_billed", + "stock_delivered_but_not_billed", + "disable_sdbnb_in_sr", "default_provisional_account", "default_in_transit_warehouse", "manufacturing_section", @@ -253,7 +258,7 @@ { "fieldname": "default_letter_head", "fieldtype": "Link", - "label": "Default Letter Head", + "label": "Default Letter Head (DocType)", "options": "Letter Head" }, { @@ -962,6 +967,35 @@ { "fieldname": "accounts_closing_section", "fieldtype": "Section Break" + }, + { + "fieldname": "default_letter_head_report", + "fieldtype": "Link", + "label": "Default Letter Head (Report)", + "options": "Letter Head" + }, + { + "fieldname": "section_break_soma", + "fieldtype": "Section Break" + }, + { + "fieldname": "column_break_zqmp", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "disable_sdbnb_in_sr", + "fieldtype": "Check", + "label": "Disable Stock Delivered But Not Billed in Sales Return", + "no_copy": 1 + }, + { + "fieldname": "stock_delivered_but_not_billed", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Stock Delivered But Not Billed", + "no_copy": 1, + "options": "Account" } ], "grid_page_length": 50, @@ -970,7 +1004,7 @@ "image_field": "company_logo", "is_tree": 1, "links": [], - "modified": "2026-04-17 17:11:46.586135", + "modified": "2026-05-14 16:50:34.132345", "modified_by": "Administrator", "module": "Setup", "name": "Company", diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index d30b082e557..8e4071de24b 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -76,6 +76,7 @@ class Company(NestedSet): default_income_account: DF.Link | None default_inventory_account: DF.Link | None default_letter_head: DF.Link | None + default_letter_head_report: DF.Link | None default_operating_cost_account: DF.Link | None default_payable_account: DF.Link | None default_provisional_account: DF.Link | None @@ -87,6 +88,7 @@ class Company(NestedSet): default_wip_warehouse: DF.Link | None depreciation_cost_center: DF.Link | None depreciation_expense_account: DF.Link | None + disable_sdbnb_in_sr: DF.Check disposal_account: DF.Link | None domain: DF.Data | None email: DF.Data | None @@ -121,6 +123,7 @@ class Company(NestedSet): series_for_depreciation_entry: DF.Data | None service_expense_account: DF.Link | None stock_adjustment_account: DF.Link | None + stock_delivered_but_not_billed: DF.Link | None stock_received_but_not_billed: DF.Link | None submit_err_jv: DF.Check tax_id: DF.Data | None @@ -250,6 +253,7 @@ class Company(NestedSet): ["Default Expense Account", "default_expense_account"], ["Default Income Account", "default_income_account"], ["Stock Received But Not Billed Account", "stock_received_but_not_billed"], + ["Stock Delivered But Not Billed Account", "stock_delivered_but_not_billed"], ["Stock Adjustment Account", "stock_adjustment_account"], ["Write Off Account", "write_off_account"], ["Default Payment Discount Account", "default_discount_account"], @@ -631,6 +635,7 @@ class Company(NestedSet): default_accounts.update( { "stock_received_but_not_billed": "Stock Received But Not Billed", + "stock_delivered_but_not_billed": "Stock Delivered But Not Billed", "default_inventory_account": "Stock", "stock_adjustment_account": "Stock Adjustment", } @@ -1087,7 +1092,6 @@ def create_transaction_deletion_request(company: str): tdr.reload() tdr.submit() - tdr.start_deletion_tasks() frappe.msgprint( _("Transaction Deletion Document {0} has been triggered for company {1}").format( diff --git a/erpnext/setup/doctype/company/test_company.py b/erpnext/setup/doctype/company/test_company.py index db4e8c07482..4bada0b4e6e 100644 --- a/erpnext/setup/doctype/company/test_company.py +++ b/erpnext/setup/doctype/company/test_company.py @@ -79,6 +79,7 @@ class TestCompany(ERPNextTestSuite): "Receivable", "Stock Adjustment", "Stock Received But Not Billed", + "Stock Delivered But Not Billed", "Bank", "Cash", "Stock", @@ -118,12 +119,12 @@ class TestCompany(ERPNextTestSuite): self.assertTrue(lft) self.assertTrue(rgt) - self.assertTrue(lft < rgt) - self.assertTrue(parent_lft < parent_rgt) - self.assertTrue(lft > parent_lft) - self.assertTrue(rgt < parent_rgt) - self.assertTrue(lft >= min_lft) - self.assertTrue(rgt <= max_rgt) + self.assertLess(lft, rgt) + self.assertLess(parent_lft, parent_rgt) + self.assertGreater(lft, parent_lft) + self.assertLess(rgt, parent_rgt) + self.assertGreaterEqual(lft, min_lft) + self.assertLessEqual(rgt, max_rgt) def test_primary_address(self): company = "_Test Company" diff --git a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py index 68cd796318e..87b46d60e72 100644 --- a/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py +++ b/erpnext/setup/doctype/currency_exchange/test_currency_exchange.py @@ -104,11 +104,11 @@ class TestCurrencyExchange(ERPNextTestSuite): # Exchange rate as on 15th Dec, 2015 self.clear_cache() exchange_rate = get_exchange_rate("USD", "INR", "2015-12-15", "for_selling") - self.assertFalse(exchange_rate == 60) + self.assertNotEqual(exchange_rate, 60) self.assertEqual(flt(exchange_rate, 3), 66.999) exchange_rate = get_exchange_rate("USD", "INR", "2016-01-20", "for_buying") - self.assertFalse(exchange_rate == 60) + self.assertNotEqual(exchange_rate, 60) self.assertEqual(flt(exchange_rate, 3), 65.1) def test_exchange_rate_via_exchangerate_host(self, mock_get): @@ -134,11 +134,11 @@ class TestCurrencyExchange(ERPNextTestSuite): # Exchange rate as on 15th Dec, 2015 self.clear_cache() exchange_rate = get_exchange_rate("USD", "INR", "2015-12-15", "for_selling") - self.assertFalse(exchange_rate == 60) + self.assertNotEqual(exchange_rate, 60) self.assertEqual(flt(exchange_rate, 3), 66.999) exchange_rate = get_exchange_rate("USD", "INR", "2016-01-20", "for_buying") - self.assertFalse(exchange_rate == 60) + self.assertNotEqual(exchange_rate, 60) self.assertEqual(flt(exchange_rate, 3), 65.1) settings = frappe.get_single("Currency Exchange Settings") @@ -175,5 +175,5 @@ class TestCurrencyExchange(ERPNextTestSuite): self.clear_cache() exchange_rate = get_exchange_rate("USD", "INR", "2016-01-30", "for_buying") - self.assertFalse(exchange_rate == 65) + self.assertNotEqual(exchange_rate, 65) self.assertEqual(flt(exchange_rate, 3), 62.9) diff --git a/erpnext/setup/doctype/customer_group/customer_group.py b/erpnext/setup/doctype/customer_group/customer_group.py index 9a05760ec29..d0af3af7ba3 100644 --- a/erpnext/setup/doctype/customer_group/customer_group.py +++ b/erpnext/setup/doctype/customer_group/customer_group.py @@ -75,13 +75,11 @@ class CustomerGroup(NestedSet): def get_parent_customer_groups(customer_group): lft, rgt = frappe.db.get_value("Customer Group", customer_group, ["lft", "rgt"]) - - return frappe.db.sql( - """select name from `tabCustomer Group` - where lft <= %s and rgt >= %s - order by lft asc""", - (lft, rgt), - as_dict=True, + return frappe.get_all( + "Customer Group", + filters=[["lft", "<=", lft], ["rgt", ">=", rgt]], + fields=["name"], + order_by="lft asc", ) diff --git a/erpnext/setup/doctype/employee/employee.js b/erpnext/setup/doctype/employee/employee.js index 847922dba33..7f1260fdc6e 100755 --- a/erpnext/setup/doctype/employee/employee.js +++ b/erpnext/setup/doctype/employee/employee.js @@ -44,7 +44,7 @@ frappe.ui.form.on("Employee", { }, refresh: function (frm) { - frm.fields_dict.date_of_birth.datepicker.update({ maxDate: new Date() }); + frm.fields_dict.date_of_birth.datepicker?.update({ maxDate: new Date() }); if (!frm.is_new() && !frm.doc.user_id) { frm.add_custom_button(__("Create User"), () => { diff --git a/erpnext/setup/doctype/employee/test_employee.py b/erpnext/setup/doctype/employee/test_employee.py index 801a08ae5b6..c1616aa0d58 100644 --- a/erpnext/setup/doctype/employee/test_employee.py +++ b/erpnext/setup/doctype/employee/test_employee.py @@ -28,10 +28,10 @@ class TestEmployee(ERPNextTestSuite): employee = make_employee("test_emp_user_creation@company.com", company="_Test Company") employee_doc = frappe.get_doc("Employee", employee) user = employee_doc.user_id - self.assertTrue("Employee" in frappe.get_roles(user)) + self.assertIn("Employee", frappe.get_roles(user)) employee_doc.user_id = "" employee_doc.save() - self.assertTrue("Employee" not in frappe.get_roles(user)) + self.assertNotIn("Employee", frappe.get_roles(user)) def test_employee_user_permission(self): employee1 = make_employee( diff --git a/erpnext/setup/doctype/supplier_group/supplier_group.py b/erpnext/setup/doctype/supplier_group/supplier_group.py index 5e1cab40450..a5ff5b8b6b4 100644 --- a/erpnext/setup/doctype/supplier_group/supplier_group.py +++ b/erpnext/setup/doctype/supplier_group/supplier_group.py @@ -70,3 +70,13 @@ class SupplierGroup(NestedSet): def on_trash(self): NestedSet.validate_if_child_exists(self) frappe.utils.nestedset.update_nsm(self) + + +def get_parent_supplier_groups(supplier_group): + lft, rgt = frappe.db.get_value("Supplier Group", supplier_group, ["lft", "rgt"]) + return frappe.get_all( + "Supplier Group", + filters=[["lft", "<=", lft], ["rgt", ">=", rgt]], + fields=["name"], + order_by="lft asc", + ) diff --git a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py index 5c716279b89..23683c3d6bb 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/test_transaction_deletion_record.py @@ -396,7 +396,6 @@ def create_and_submit_transaction_deletion_doc(company): tdr.process_in_single_transaction = True tdr.submit() - tdr.start_deletion_tasks() return tdr diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py index 9f68967392a..25f5d06d8b4 100644 --- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py +++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py @@ -738,10 +738,11 @@ class TransactionDeletionRecord(Document): self.enqueue_task(task="Clear Notifications") return - company_obj = frappe.get_doc("Company", self.company) - company_obj.total_monthly_sales = 0 - company_obj.sales_monthly_history = None - company_obj.save() + frappe.db.set_value( + "Company", + self.company, + {"total_monthly_sales": 0, "sales_monthly_history": None}, + ) self.db_set("reset_company_default_values_status", "Completed") self.enqueue_task(task="Clear Notifications") diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index 3ae247fe810..1bbeff753df 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -25,6 +25,7 @@ def after_install(): setup_repost_defaults() create_print_setting_custom_fields() create_marketing_campaign_custom_fields() + create_address_and_contact_custom_fields() create_custom_company_links() add_all_roles_to("Administrator") create_default_success_action() @@ -144,6 +145,37 @@ def create_marketing_campaign_custom_fields(): ) +def create_address_and_contact_custom_fields(): + create_custom_fields( + { + "Address": [ + { + "label": _("Tax Category"), + "fieldname": "tax_category", + "fieldtype": "Link", + "options": "Tax Category", + "insert_after": "fax", + }, + { + "label": _("Is Your Company Address"), + "fieldname": "is_your_company_address", + "fieldtype": "Check", + "default": "0", + "insert_after": "linked_with", + }, + ], + "Contact": [ + { + "label": _("Is Billing Contact"), + "fieldname": "is_billing_contact", + "fieldtype": "Check", + "insert_after": "is_primary_contact", + }, + ], + } + ) + + def create_default_success_action(): for success_action in get_default_success_action(): if not frappe.db.exists("Success Action", success_action.get("ref_doctype")): diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index a83884d3ac1..469b5c7baed 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -262,7 +262,15 @@ }, "Belgium VAT 12%": { "account_name": "VAT 12%", - "tax_rate": 12 + "tax_rate": 12.00 + }, + "Belgium VAT 6%": { + "account_name": "VAT 6%", + "tax_rate": 6.00 + }, + "Belgium VAT 0%": { + "account_name": "VAT 0%", + "tax_rate": 0.00 } }, @@ -4219,9 +4227,14 @@ }, "Japan": { - "Japan Tax": { - "account_name": "CT", - "tax_rate": 5.00 + "Japan Tax 10%": { + "account_name": "CT 10%", + "tax_rate": 10.00, + "default": 1 + }, + "Japan Tax 8%": { + "account_name": "CT 8%", + "tax_rate": 8.00 } }, diff --git a/erpnext/startup/boot.py b/erpnext/startup/boot.py index 08cf9154c2b..e9f6a5c9641 100644 --- a/erpnext/startup/boot.py +++ b/erpnext/startup/boot.py @@ -49,7 +49,7 @@ def boot_session(bootinfo): bootinfo.docs += frappe.db.sql( """select name, default_currency, cost_center, default_selling_terms, default_buying_terms, - default_letter_head, default_bank_account, enable_perpetual_inventory, country, exchange_gain_loss_account from `tabCompany`""", + default_letter_head, default_letter_head_report, default_bank_account, enable_perpetual_inventory, country, exchange_gain_loss_account from `tabCompany`""", as_dict=1, update={"doctype": ":Company"}, ) diff --git a/erpnext/startup/leaderboard.py b/erpnext/startup/leaderboard.py deleted file mode 100644 index 22cf179d2c0..00000000000 --- a/erpnext/startup/leaderboard.py +++ /dev/null @@ -1,237 +0,0 @@ -import frappe - -from erpnext.deprecation_dumpster import deprecated - - -def get_leaderboards(): - leaderboards = { - "Customer": { - "fields": [ - {"fieldname": "total_sales_amount", "fieldtype": "Currency"}, - "total_qty_sold", - {"fieldname": "outstanding_amount", "fieldtype": "Currency"}, - ], - "method": "erpnext.startup.leaderboard.get_all_customers", - "icon": "customer", - }, - "Item": { - "fields": [ - {"fieldname": "total_sales_amount", "fieldtype": "Currency"}, - "total_qty_sold", - {"fieldname": "total_purchase_amount", "fieldtype": "Currency"}, - "total_qty_purchased", - "available_stock_qty", - {"fieldname": "available_stock_value", "fieldtype": "Currency"}, - ], - "method": "erpnext.startup.leaderboard.get_all_items", - "icon": "stock", - }, - "Supplier": { - "fields": [ - {"fieldname": "total_purchase_amount", "fieldtype": "Currency"}, - "total_qty_purchased", - {"fieldname": "outstanding_amount", "fieldtype": "Currency"}, - ], - "method": "erpnext.startup.leaderboard.get_all_suppliers", - "icon": "buying", - }, - "Sales Partner": { - "fields": [ - {"fieldname": "total_sales_amount", "fieldtype": "Currency"}, - {"fieldname": "total_commission", "fieldtype": "Currency"}, - ], - "method": "erpnext.startup.leaderboard.get_all_sales_partner", - "icon": "hr", - }, - "Sales Person": { - "fields": [{"fieldname": "total_sales_amount", "fieldtype": "Currency"}], - "method": "erpnext.startup.leaderboard.get_all_sales_person", - "icon": "customer", - }, - } - - return leaderboards - - -@frappe.whitelist() -def get_all_customers(date_range: str, company: str, field: str, limit: int | None = None): - filters = [["docstatus", "=", "1"], ["company", "=", company]] - from_date, to_date = parse_date_range(date_range) - if field == "outstanding_amount": - if from_date and to_date: - filters.append(["posting_date", "between", [from_date, to_date]]) - - return frappe.get_list( - "Sales Invoice", - fields=["customer as name", {"SUM": "outstanding_amount", "as": "value"}], - filters=filters, - group_by="customer", - order_by="value desc", - limit=limit, - ) - else: - if field == "total_sales_amount": - select_field = "base_net_total" - elif field == "total_qty_sold": - select_field = "total_qty" - - if from_date and to_date: - filters.append(["transaction_date", "between", [from_date, to_date]]) - - return frappe.get_list( - "Sales Order", - fields=["customer as name", {"SUM": select_field, "as": "value"}], - filters=filters, - group_by="customer", - order_by="value desc", - limit=limit, - ) - - -@frappe.whitelist() -def get_all_items(date_range: str, company: str, field: str, limit: int | None = None): - if field in ("available_stock_qty", "available_stock_value"): - sum_field = "actual_qty" if field == "available_stock_qty" else "stock_value" - results = frappe.db.get_all( - "Bin", - fields=["item_code as name", {"SUM": sum_field, "as": "value"}], - group_by="item_code", - order_by="value desc", - limit=limit, - ) - readable_active_items = set(frappe.get_list("Item", filters={"disabled": 0}, pluck="name")) - return [item for item in results if item["name"] in readable_active_items] - else: - if field == "total_sales_amount": - select_field = "base_net_amount" - select_doctype = "Sales Order" - elif field == "total_purchase_amount": - select_field = "base_net_amount" - select_doctype = "Purchase Order" - elif field == "total_qty_sold": - select_field = "stock_qty" - select_doctype = "Sales Order" - elif field == "total_qty_purchased": - select_field = "stock_qty" - select_doctype = "Purchase Order" - - filters = [["docstatus", "=", "1"], ["company", "=", company]] - from_date, to_date = parse_date_range(date_range) - if from_date and to_date: - filters.append(["transaction_date", "between", [from_date, to_date]]) - - child_doctype = f"{select_doctype} Item" - return frappe.get_list( - select_doctype, - fields=[ - f"`tab{child_doctype}`.item_code as name", - {"SUM": f"`tab{child_doctype}`.{select_field}", "as": "value"}, - ], - filters=filters, - order_by="value desc", - group_by=f"`tab{child_doctype}`.item_code", - limit=limit, - ) - - -@frappe.whitelist() -def get_all_suppliers(date_range: str, company: str, field: str, limit: int | None = None): - filters = [["docstatus", "=", "1"], ["company", "=", company]] - from_date, to_date = parse_date_range(date_range) - - if field == "outstanding_amount": - if from_date and to_date: - filters.append(["posting_date", "between", [from_date, to_date]]) - - return frappe.get_list( - "Purchase Invoice", - fields=["supplier as name", {"SUM": "outstanding_amount", "as": "value"}], - filters=filters, - group_by="supplier", - order_by="value desc", - limit=limit, - ) - else: - if field == "total_purchase_amount": - select_field = "base_net_total" - elif field == "total_qty_purchased": - select_field = "total_qty" - - if from_date and to_date: - filters.append(["transaction_date", "between", [from_date, to_date]]) - - return frappe.get_list( - "Purchase Order", - fields=["supplier as name", {"SUM": select_field, "as": "value"}], - filters=filters, - group_by="supplier", - order_by="value desc", - limit=limit, - ) - - -@frappe.whitelist() -def get_all_sales_partner(date_range: str, company: str, field: str, limit: int | None = None): - if field == "total_sales_amount": - select_field = "base_net_total" - elif field == "total_commission": - select_field = "total_commission" - - filters = [["docstatus", "=", "1"], ["company", "=", company], ["sales_partner", "is", "set"]] - from_date, to_date = parse_date_range(date_range) - if from_date and to_date: - filters.append(["transaction_date", "between", [from_date, to_date]]) - - return frappe.get_list( - "Sales Order", - fields=[ - "sales_partner as name", - {"SUM": select_field, "as": "value"}, - ], - filters=filters, - group_by="sales_partner", - order_by="value DESC", - limit=limit, - ) - - -@frappe.whitelist() -def get_all_sales_person(date_range: str, company: str, field: str | None = None, limit: int | None = None): - filters = [ - ["docstatus", "=", "1"], - ["company", "=", company], - ["Sales Team", "sales_person", "is", "set"], - ] - from_date, to_date = parse_date_range(date_range) - if from_date and to_date: - filters.append(["transaction_date", "between", [from_date, to_date]]) - - return frappe.get_list( - "Sales Order", - fields=[ - "`tabSales Team`.sales_person as name", - {"SUM": "`tabSales Team`.allocated_amount", "as": "value"}, - ], - filters=filters, - group_by="`tabSales Team`.sales_person", - order_by="value desc", - limit=limit, - ) - - -@deprecated(f"{__name__}.get_date_condition", "unknown", "v16", "No known instructions.") -def get_date_condition(date_range, field): - date_condition = "" - if date_range: - date_range = frappe.parse_json(date_range) - from_date, to_date = date_range - date_condition = f"and {field} between {frappe.db.escape(from_date)} and {frappe.db.escape(to_date)}" - return date_condition - - -def parse_date_range(date_range): - if date_range: - date_range = frappe.parse_json(date_range) - return date_range[0], date_range[1] - - return None, None diff --git a/erpnext/stock/deprecated_serial_batch.py b/erpnext/stock/deprecated_serial_batch.py index 47767d7c713..50bcd8416a8 100644 --- a/erpnext/stock/deprecated_serial_batch.py +++ b/erpnext/stock/deprecated_serial_batch.py @@ -48,9 +48,18 @@ class DeprecatedSerialNoValuation: if not posting_datetime and self.sle.posting_date: posting_datetime = get_combine_datetime(self.sle.posting_date, self.sle.posting_time) + do_not_fetch_rate = frappe.db.get_single_value( + "Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no" + ) + for serial_no in serial_nos: sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1) - if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company: + if ( + sn_details + and sn_details.purchase_rate + and sn_details.company == self.sle.company + and (not frappe.flags.through_repost_item_valuation or not do_not_fetch_rate) + ): self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate) incoming_values += self.serial_no_incoming_rate[serial_no] continue diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py index 1ba25cb44f3..ed4a8c5509c 100644 --- a/erpnext/stock/doctype/batch/test_batch.py +++ b/erpnext/stock/doctype/batch/test_batch.py @@ -543,6 +543,7 @@ class TestBatch(ERPNextTestSuite): "plc_conversion_rate": 1, "customer": "_Test Customer", "name": None, + "qty": 1, } ) diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py index 6252ec48572..b7393b49cba 100644 --- a/erpnext/stock/doctype/bin/bin.py +++ b/erpnext/stock/doctype/bin/bin.py @@ -255,8 +255,9 @@ def update_qty(bin_name, args): # actual qty is already updated by processing current voucher actual_qty = bin_details.actual_qty or 0.0 - # actual qty is not up to date in case of backdated transaction - if future_sle_exists(args): + # actual qty is not up to date in case of backdated transactions + # or when cancellations are the most recent SLE + if future_sle_exists(args) or args.get("is_cancelled"): actual_qty = get_actual_qty(args.get("item_code"), args.get("warehouse")) ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty")) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 5482d12579b..6cb8e707449 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -22,8 +22,6 @@ "is_return", "issue_credit_note", "return_against", - "section_break_bxkw", - "title", "accounting_dimensions_section", "cost_center", "column_break_18", @@ -170,6 +168,7 @@ "inter_company_reference", "customer_group", "territory", + "title", "column_break5", "excise_page", "instructions", @@ -1454,10 +1453,6 @@ "fieldtype": "Section Break", "label": "Auto Repeat" }, - { - "fieldname": "section_break_bxkw", - "fieldtype": "Section Break" - }, { "allow_on_submit": 1, "fieldname": "title", @@ -1471,7 +1466,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2026-05-01 02:37:31.430649", + "modified": "2026-05-28 11:44:37.286743", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index fe03e3218b7..1b51949a4c0 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -16,7 +16,7 @@ from frappe.query_builder import DocType from frappe.query_builder.functions import Abs, Sum from frappe.utils import cint, flt -from erpnext.accounts.party import get_due_date +from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes from erpnext.controllers.selling_controller import SellingController from erpnext.stock.doctype.packed_item.packed_item import make_packing_list @@ -289,6 +289,7 @@ class DeliveryNote(SellingController): self.validate_posting_time() super().validate() self.validate_references() + self.validate_expense_account() self.set_status() self.so_required() self.validate_proj_cust() @@ -461,6 +462,46 @@ class DeliveryNote(SellingController): d.actual_qty = flt(bin_qty.actual_qty) d.projected_qty = flt(bin_qty.projected_qty) + def validate_expense_account(self): + company_values = frappe.get_cached_value( + "Company", + self.company, + [ + "stock_delivered_but_not_billed", + "disable_sdbnb_in_sr", + "default_expense_account", + ], + as_dict=True, + ) + + sdbnb_account = company_values.stock_delivered_but_not_billed + disable_sdbnb_in_sr = company_values.disable_sdbnb_in_sr + default_expense_account = company_values.default_expense_account + + for item in self.items: + if item.get("against_sales_invoice"): + if sdbnb_account and item.expense_account == sdbnb_account: + frappe.throw( + _( + "Row #{0}: Stock Delivered But Not Billed account cannot be used for items linked to a Sales Invoice" + ).format(item.idx) + ) + else: + is_stock_item = frappe.get_cached_value("Item", item.item_code, "is_stock_item") + # Only stock items + if is_stock_item and not item.get("is_fixed_asset") and not item.get("is_subcontracted"): + # Sales Return handling + if self.is_return and disable_sdbnb_in_sr: + if default_expense_account and ( + not item.expense_account or item.expense_account == sdbnb_account + ): + item.expense_account = default_expense_account + + elif sdbnb_account: + item.expense_account = sdbnb_account + if not item.expense_account and default_expense_account: + item.expense_account = default_expense_account + def on_submit(self): self.validate_packed_qty() self.update_pick_list_status() @@ -1349,8 +1390,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None): doctype: { "doctype": target_doctype, "postprocess": update_details, - "field_no_map": ["taxes_and_charges", "set_warehouse"], - "field_map": {"shipping_address_name": "shipping_address"}, + "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"], }, doctype + " Item": { "doctype": target_doctype + " Item", diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 23594265c01..1b4d32f89d8 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -10,6 +10,7 @@ from frappe.utils import add_days, cstr, flt, getdate, nowdate, nowtime, today from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.utils import get_balance_on from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle @@ -45,8 +46,35 @@ from erpnext.tests.utils import ERPNextTestSuite class TestDeliveryNote(ERPNextTestSuite): + SDBNB_COMPANY_NAME = "_Test SDBNB Company" + SDBNB_COMPANY_ABBR = "_TSDBNB" + def setUp(self): self.load_test_records("Stock Entry") + self.setup_sdbnb_company() + + def setup_sdbnb_company(self): + if frappe.db.exists("Company", self.SDBNB_COMPANY_NAME): + company = frappe.get_doc("Company", self.SDBNB_COMPANY_NAME) + else: + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": self.SDBNB_COMPANY_NAME, + "abbr": self.SDBNB_COMPANY_ABBR, + "country": "India", + "default_currency": "INR", + "enable_perpetual_inventory": 1, + } + ).insert() + + self.sdbnb_company = company.name + self.sdbnb_account = company.stock_delivered_but_not_billed + self.sdbnb_cost_center = company.cost_center + self.sdbnb_warehouse = f"Stores - {self.SDBNB_COMPANY_ABBR}" + self.sdbnb_expense_account = f"Cost of Goods Sold - {self.SDBNB_COMPANY_ABBR}" + self.sdbnb_income_account = f"Sales - {self.SDBNB_COMPANY_ABBR}" + self.sdbnb_debit_to = f"Debtors - {self.SDBNB_COMPANY_ABBR}" def test_delivery_note_qty(self): dn = create_delivery_note(qty=0, do_not_save=True) @@ -279,7 +307,7 @@ class TestDeliveryNote(ERPNextTestSuite): returned_serial_nos1 = get_serial_nos_from_bundle(dn1.items[0].serial_and_batch_bundle) for serial_no in returned_serial_nos1: - self.assertTrue(serial_no in serial_nos) + self.assertIn(serial_no, serial_nos) dn2 = make_sales_return(dn.name) @@ -290,8 +318,8 @@ class TestDeliveryNote(ERPNextTestSuite): returned_serial_nos2 = get_serial_nos_from_bundle(dn2.items[0].serial_and_batch_bundle) for serial_no in returned_serial_nos2: - self.assertTrue(serial_no in serial_nos) - self.assertFalse(serial_no in returned_serial_nos1) + self.assertIn(serial_no, serial_nos) + self.assertNotIn(serial_no, returned_serial_nos1) def test_sales_return_for_non_bundled_items_partial(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -1196,7 +1224,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertTrue(gl_entries) expected_values = { - "Cost of Goods Sold - TCP1": {"cost_center": cost_center}, + dn.items[0].expense_account: {"cost_center": cost_center}, stock_in_hand_account: {"cost_center": cost_center}, } for _i, gle in enumerate(gl_entries): @@ -1225,7 +1253,7 @@ class TestDeliveryNote(ERPNextTestSuite): self.assertTrue(gl_entries) expected_values = { - "Cost of Goods Sold - TCP1": {"cost_center": cost_center}, + dn.items[0].expense_account: {"cost_center": cost_center}, stock_in_hand_account: {"cost_center": cost_center}, } for _i, gle in enumerate(gl_entries): @@ -1529,7 +1557,7 @@ class TestDeliveryNote(ERPNextTestSuite): return_dn = make_return_doc(dn.doctype, dn.name) return_dn.save().submit() - self.assertTrue(return_dn.docstatus == 1) + self.assertEqual(return_dn.docstatus, 1) def test_reserve_qty_on_sales_return(self): frappe.db.set_single_value("Selling Settings", "dont_reserve_sales_order_qty_on_sales_return", 0) @@ -2744,7 +2772,7 @@ class TestDeliveryNote(ERPNextTestSuite): doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) for entry in doc.entries: if entry.serial_no: - self.assertTrue(entry.serial_no in serial_batch_map[row.item_code].serial_nos) + self.assertIn(entry.serial_no, serial_batch_map[row.item_code].serial_nos) self.assertEqual( entry.incoming_rate, serial_batch_map[row.item_code].serial_no_valuation[entry.serial_no], @@ -2754,7 +2782,7 @@ class TestDeliveryNote(ERPNextTestSuite): elif entry.batch_no: serial_batch_map[row.item_code].batches[entry.batch_no] += entry.qty - self.assertTrue(entry.batch_no in serial_batch_map[row.item_code].batches) + self.assertIn(entry.batch_no, serial_batch_map[row.item_code].batches) self.assertEqual(entry.qty, 2.0) self.assertEqual( entry.incoming_rate, @@ -2770,7 +2798,7 @@ class TestDeliveryNote(ERPNextTestSuite): doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) for entry in doc.entries: if entry.serial_no: - self.assertTrue(entry.serial_no in serial_batch_map[row.item_code].serial_nos) + self.assertIn(entry.serial_no, serial_batch_map[row.item_code].serial_nos) self.assertEqual( entry.incoming_rate, serial_batch_map[row.item_code].serial_no_valuation[entry.serial_no], @@ -2782,7 +2810,7 @@ class TestDeliveryNote(ERPNextTestSuite): serial_batch_map[row.item_code].batches[entry.batch_no] += entry.qty self.assertEqual(serial_batch_map[row.item_code].batches[entry.batch_no], 0.0) - self.assertTrue(entry.batch_no in serial_batch_map[row.item_code].batches) + self.assertIn(entry.batch_no, serial_batch_map[row.item_code].batches) self.assertEqual(entry.qty, 3.0) self.assertEqual( @@ -2865,6 +2893,478 @@ class TestDeliveryNote(ERPNextTestSuite): for entry in sabb.entries: self.assertEqual(entry.incoming_rate, 200) + def test_sdbnb_gl_entry_on_delivery_note(self): + """Test that DN GL entries use SDBNB account when configured on the company.""" + item_code = make_item("SDBNB Test Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + dn = create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + # DN expense_account should be overridden to SDBNB + dn.reload() + self.assertEqual(dn.items[0].expense_account, self.sdbnb_account) + + # Verify DN GL entries use SDBNB account (not COGS) + gl_entries = get_gl_entries("Delivery Note", dn.name) + self.assertTrue(gl_entries) + + stock_in_hand_account = get_inventory_account(self.sdbnb_company) + expected_values = { + self.sdbnb_account: {"debit": True}, + stock_in_hand_account: {"credit": True}, + } + for gle in gl_entries: + self.assertIn(gle.account, expected_values) + if expected_values[gle.account].get("debit"): + self.assertGreater(gle.debit, 0) + if expected_values[gle.account].get("credit"): + self.assertGreater(gle.credit, 0) + + def test_sdbnb_reversal_on_sales_invoice(self): + """Test that SI created from DN reverses SDBNB entries (credits SDBNB, debits COGS).""" + item_code = make_item("SDBNB Reversal Test Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + dn = create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + si = make_sales_invoice(dn.name) + si.submit() + + # Get the stock value difference from the DN's SLE + sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Delivery Note", + "voucher_no": dn.name, + "item_code": item_code, + "is_cancelled": 0, + }, + ["stock_value_difference", "actual_qty"], + as_dict=True, + ) + + valuation_rate = abs(flt(sle.stock_value_difference) / flt(sle.actual_qty)) + expected_amount = flt(valuation_rate * 5) # SI qty = 5 + + # SI GL entries should have SDBNB reversal + si_gl_entries = get_gl_entries("Sales Invoice", si.name) + self.assertTrue(si_gl_entries) + self.assertGreater( + sum(gle.debit for gle in si_gl_entries if gle.account == self.sdbnb_expense_account), 0 + ) + sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.sdbnb_account) + cogs_debit = sum(gle.debit for gle in si_gl_entries if gle.account == self.sdbnb_expense_account) + + self.assertEqual(flt(sdbnb_credit, 2), flt(expected_amount, 2)) + self.assertEqual(flt(cogs_debit, 2), flt(expected_amount, 2)) + + def test_sdbnb_partial_billing(self): + """Test SDBNB reversal for partial invoicing - only billed qty should be reversed.""" + item_code = make_item("SDBNB Partial Bill Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + dn = create_delivery_note( + item_code=item_code, + qty=10, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + # Create SI from DN and reduce qty to 4 (partial billing) + si = make_sales_invoice(dn.name) + si.items[0].qty = 4 + si.save() + si.submit() + + # Get valuation rate from DN's SLE + sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Delivery Note", + "voucher_no": dn.name, + "item_code": item_code, + "is_cancelled": 0, + }, + ["stock_value_difference", "actual_qty"], + as_dict=True, + ) + + valuation_rate = abs(flt(sle.stock_value_difference) / flt(sle.actual_qty)) + expected_amount = flt(valuation_rate * 4) # Only 4 out of 10 + + si_gl_entries = get_gl_entries("Sales Invoice", si.name) + sdbnb_credit = sum(gle.credit for gle in si_gl_entries if gle.account == self.sdbnb_account) + + self.assertEqual(flt(sdbnb_credit, 2), flt(expected_amount, 2)) + + def test_sdbnb_disabled_for_sales_return(self): + """Test that sales return DN uses default expense account when disable_sdbnb_in_sr is enabled.""" + frappe.db.set_value("Company", self.sdbnb_company, "disable_sdbnb_in_sr", 1) + + try: + item_code = make_item("SDBNB Return Disable Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + dn = create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + # Original DN should use SDBNB + dn.reload() + self.assertEqual(dn.items[0].expense_account, self.sdbnb_account) + + return_dn = create_delivery_note( + item_code=item_code, + qty=-3, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + is_return=1, + return_against=dn.name, + ) + + # Return DN should not use SDBNB (disable_sdbnb_in_sr is on) + return_dn.reload() + self.assertNotEqual(return_dn.items[0].expense_account, self.sdbnb_account) + finally: + frappe.db.set_value("Company", self.sdbnb_company, "disable_sdbnb_in_sr", 0) + + def test_sdbnb_enabled_for_sales_return(self): + """Test that sales return DN uses SDBNB account when disable_sdbnb_in_sr is off.""" + item_code = make_item("SDBNB Return Enable Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + dn = create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + return_dn = create_delivery_note( + item_code=item_code, + qty=-3, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + is_return=1, + return_against=dn.name, + ) + + # Return DN should also use SDBNB since disable flag is off by default + return_dn.reload() + self.assertEqual(return_dn.items[0].expense_account, self.sdbnb_account) + + def test_sdbnb_no_reversal_with_update_stock(self): + """Test that SI with update_stock=1 (standalone, no DN link) does NOT create SDBNB GL entries.""" + item_code = make_item("SDBNB Update Stock Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + # Create standalone SI with update_stock=1 (no DN link) + si = create_sales_invoice( + company=self.sdbnb_company, + currency="INR", + debit_to=self.sdbnb_debit_to, + income_account=self.sdbnb_income_account, + update_stock=1, + item_code=item_code, + qty=5, + rate=150, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + # SI GL entries should not have SDBNB account + si_gl_entries = get_gl_entries("Sales Invoice", si.name) + sdbnb_entries = [gle for gle in si_gl_entries if gle.account == self.sdbnb_account] + self.assertEqual(len(sdbnb_entries), 0) + + def test_sdbnb_skip_for_dn_against_sales_invoice(self): + """Test that DN items with against_sales_invoice reference skips SDBNB account assignment.""" + from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( + make_delivery_note as make_dn_from_si, + ) + + item_code = make_item("SDBNB Against SI Item", properties={"is_stock_item": 1}).name + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + ) + + si = create_sales_invoice( + company=self.sdbnb_company, + currency="INR", + debit_to=self.sdbnb_debit_to, + income_account=self.sdbnb_income_account, + update_stock=0, + item_code=item_code, + qty=5, + rate=150, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + ) + + dn = make_dn_from_si(si.name) + self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account) + dn.submit() + + # DN items created from SI have against_sales_invoice set, + # so SDBNB should be skipped + dn.reload() + self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account) + + def test_sdbnb_non_stock_item_skipped(self): + """Test that non-stock items are not assigned SDBNB account.""" + non_stock_item = make_item( + "SDBNB Non Stock Item", + properties={"is_stock_item": 0}, + ).name + + dn = create_delivery_note( + company=self.sdbnb_company, + item_code=non_stock_item, + warehouse=self.sdbnb_warehouse, + qty=5, + rate=150, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + do_not_submit=True, + ) + + # Non-stock item should retain original expense_account, not SDBNB + self.assertNotEqual(dn.items[0].expense_account, self.sdbnb_account) + self.assertEqual(dn.items[0].expense_account, self.sdbnb_expense_account) + + def test_sdbnb_reposting_with_fifo(self): + """Test that backdated inward entry triggers reposting and updates SDBNB GL entries (FIFO).""" + item_code = make_item( + "SDBNB Repost FIFO Item", properties={"is_stock_item": 1, "valuation_method": "FIFO"} + ).name + + posting_date = add_days(nowdate(), -1) + + # Inward 10 qty @ 100 + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + posting_date=posting_date, + ) + + # DN 5 qty → FIFO consumes 5 @ 100 → stock_value_diff = -500 + dn = create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + posting_date=posting_date, + ) + + # Verify initial DN GL: SDBNB Dr 500, Stock In Hand Cr 500 + dn_gl = get_gl_entries("Delivery Note", dn.name) + sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account) + self.assertEqual(flt(sdbnb_debit, 2), 500.0) + + # SI from DN + si = make_sales_invoice(dn.name) + si.set_posting_time = 1 + si.posting_date = posting_date + si.submit() + + # Verify initial SI GL: SDBNB Cr 500, COGS Dr 500 + si_gl = get_gl_entries("Sales Invoice", si.name) + sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account) + cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account) + self.assertEqual(flt(sdbnb_credit, 2), 500.0) + self.assertEqual(flt(cogs_debit, 2), 500.0) + + # Backdated inward: 5 qty @ 50 → FIFO queue becomes [[5,50],[10,100]] + # DN now consumes 5@50 from front → stock_value_diff = -250 + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=5, + basic_rate=50, + company=self.sdbnb_company, + posting_date=add_days(posting_date, -1), + ) + + # After repost: DN GL should reflect new valuation (250 instead of 500) + dn_gl = get_gl_entries("Delivery Note", dn.name) + sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account) + self.assertEqual(flt(sdbnb_debit, 2), 250.0) + + # After repost: SI GL should also reflect new valuation + si_gl = get_gl_entries("Sales Invoice", si.name) + sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account) + cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account) + self.assertEqual(flt(sdbnb_credit, 2), 250.0) + self.assertEqual(flt(cogs_debit, 2), 250.0) + + def test_sdbnb_reposting_with_moving_average(self): + """Test that backdated inward entry triggers reposting and updates SDBNB GL entries (Moving Average).""" + item_code = make_item( + "SDBNB Repost MA Item", properties={"is_stock_item": 1, "valuation_method": "Moving Average"} + ).name + + posting_date = add_days(nowdate(), -1) + + # Inward 10 qty @ 100 → avg = 100 + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=10, + basic_rate=100, + company=self.sdbnb_company, + posting_date=posting_date, + ) + + # DN 5 qty → avg = 100 → stock_value_diff = -500 + dn = create_delivery_note( + item_code=item_code, + qty=5, + rate=150, + company=self.sdbnb_company, + warehouse=self.sdbnb_warehouse, + cost_center=self.sdbnb_cost_center, + expense_account=self.sdbnb_expense_account, + posting_date=posting_date, + ) + + # Verify initial DN GL: SDBNB Dr 500, Stock In Hand Cr 500 + dn_gl = get_gl_entries("Delivery Note", dn.name) + sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account) + self.assertEqual(flt(sdbnb_debit, 2), 500.0) + + # SI from DN + si = make_sales_invoice(dn.name) + si.set_posting_time = 1 + si.posting_date = posting_date + si.submit() + + # Verify initial SI GL: SDBNB Cr 500, COGS Dr 500 + si_gl = get_gl_entries("Sales Invoice", si.name) + sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account) + cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account) + self.assertEqual(flt(sdbnb_credit, 2), 500.0) + self.assertEqual(flt(cogs_debit, 2), 500.0) + + # Backdated inward: 5 qty @ 50 + # Moving avg becomes: (5*50 + 10*100) / 15 = 1250/15 ≈ 83.33 + # DN 5 qty → reposted stock_value_diff ≈ -416.67 + make_stock_entry( + item_code=item_code, + target=self.sdbnb_warehouse, + qty=5, + basic_rate=50, + company=self.sdbnb_company, + posting_date=add_days(posting_date, -1), + ) + + # Read actual stock_value_difference from reposted SLE + sle = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Delivery Note", + "voucher_no": dn.name, + "item_code": item_code, + "is_cancelled": 0, + }, + "stock_value_difference", + ) + expected_amount = abs(flt(sle, 2)) + + # DN GL should reflect new moving average valuation + dn_gl = get_gl_entries("Delivery Note", dn.name) + sdbnb_debit = sum(gle.debit for gle in dn_gl if gle.account == self.sdbnb_account) + self.assertEqual(flt(sdbnb_debit, 2), expected_amount) + self.assertLess(expected_amount, 500.0) + + # SI GL should also reflect new valuation + si_gl = get_gl_entries("Sales Invoice", si.name) + sdbnb_credit = sum(gle.credit for gle in si_gl if gle.account == self.sdbnb_account) + cogs_debit = sum(gle.debit for gle in si_gl if gle.account == self.sdbnb_expense_account) + self.assertEqual(flt(sdbnb_credit, 2), expected_amount) + self.assertEqual(flt(cogs_debit, 2), expected_amount) + @ERPNextTestSuite.change_settings("Selling Settings", {"validate_selling_price": 1}) def test_validate_selling_price(self): item_code = make_item("VSP Item", properties={"is_stock_item": 1}).name diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index c03199e4348..2261cb7733f 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -286,14 +286,6 @@ frappe.ui.form.on("Item", { frm.set_df_property(fieldname, "read_only", stock_exists); }); frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0); - frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0); - frm.set_query("item_group", () => { - return { - filters: { - is_group: 0, - }, - }; - }); }, validate: function (frm) { @@ -304,10 +296,6 @@ frappe.ui.form.on("Item", { refresh_field("image_view"); }, - is_customer_provided_item: function (frm) { - frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0); - }, - is_fixed_asset: function (frm) { // set serial no to false & toggles its visibility frm.set_value("has_serial_no", 0); @@ -548,12 +536,6 @@ $.extend(erpnext.item, { }; }; - frm.fields_dict["item_group"].get_query = function (doc, cdt, cdn) { - return { - filters: [["Item Group", "docstatus", "!=", 2]], - }; - }; - frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function ( doc, cdt, @@ -790,11 +772,10 @@ $.extend(erpnext.item, { default: 0, onchange: function () { let selected_attributes = get_selected_attributes(); - let lengths = []; - Object.keys(selected_attributes).map((key) => { - lengths.push(selected_attributes[key].length); + let lengths = Object.keys(selected_attributes).map((key) => { + return selected_attributes[key].length; }); - if (lengths.includes(0)) { + if (!lengths.length) { me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants")); me.multiple_variant_dialog.disable_primary_action(); } else { @@ -831,7 +812,7 @@ $.extend(erpnext.item, { fieldtype: "HTML", fieldname: "help", options: ``, }, ] @@ -893,6 +874,9 @@ $.extend(erpnext.item, { selected_attributes[attribute_name].push($(opt).attr("data-fieldname")); } }); + if (!selected_attributes[attribute_name].length) { + delete selected_attributes[attribute_name]; + } }); return selected_attributes; @@ -951,11 +935,17 @@ $.extend(erpnext.item, { if (!row.disabled) { if (row.numeric_values) { - fieldtype = "Float"; + const all_are_int = + flt(row.from_range) === cint(row.from_range) && + flt(row.to_range) === cint(row.to_range) && + flt(row.increment) === cint(row.increment); + fieldtype = all_are_int ? "Int" : "Float"; + const df = { fieldtype }; + const options = all_are_int ? { inline: 1 } : { always_show_decimals: true, inline: 1 }; desc = __("Min Value: {0}, Max Value: {1}, in Increments of: {2}", [ - frappe.format(row.from_range, { fieldtype: "Float" }, { always_show_decimals: true }), - frappe.format(row.to_range, { fieldtype: "Float" }, { always_show_decimals: true }), - frappe.format(row.increment, { fieldtype: "Float" }, { always_show_decimals: true }), + frappe.format(row.from_range, df, options), + frappe.format(row.to_range, df, options), + frappe.format(row.increment, df, options), ]); } else { fieldtype = "Data"; diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 1f2581a4981..8b4cef2b75c 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -31,7 +31,6 @@ "column_break_kpmi", "is_purchase_item", "is_customer_provided_item", - "customer", "section_break_gjns", "standard_rate", "column_break_ixrh", @@ -237,7 +236,7 @@ "description": "ERPNext will make a stock ledger entry for each transaction of this item. Keep unchecked for non-stock or service items.", "fieldname": "is_stock_item", "fieldtype": "Check", - "in_list_view": 1, + "in_list_view": 0, "label": "Maintain Stock", "oldfieldname": "is_stock_item", "oldfieldtype": "Select", @@ -280,7 +279,8 @@ "fieldname": "is_fixed_asset", "fieldtype": "Check", "label": "Is Fixed Asset", - "read_only_depends_on": "eval:doc.is_stock_item" + "read_only_depends_on": "eval:doc.is_stock_item", + "in_list_view": 1 }, { "allow_in_quick_entry": 1, @@ -593,7 +593,7 @@ "oldfieldtype": "Currency" }, { - "description": "Minimum stock level to maintain as a buffer. Used to calculate recommended reorder level: Reorder Level = Safety Stock + (Average Daily Consumption \u00d7 Lead Time).", + "description": "Minimum stock level to maintain as a buffer. Used to calculate recommended reorder level: Reorder Level = Safety Stock + (Average Daily Consumption × Lead Time).", "fieldname": "safety_stock", "fieldtype": "Float", "label": "Safety Stock", @@ -632,13 +632,6 @@ "read_only_depends_on": "eval: doc.is_purchase_item", "show_description_on_click": 1 }, - { - "depends_on": "eval:doc.is_customer_provided_item", - "fieldname": "customer", - "fieldtype": "Link", - "label": "Customer", - "options": "Customer" - }, { "depends_on": "eval:!doc.is_fixed_asset", "fieldname": "supplier_details", @@ -648,7 +641,7 @@ }, { "default": "0", - "description": "Enable for drop shipping - supplier delivers directly to the customer without passing through your warehouse.", + "description": "If checked, this item is treated as drop-shipped by default in Sales Orders, Sales Invoices and Purchase Orders. The flag can be overridden on each transaction line.", "fieldname": "delivered_by_supplier", "fieldtype": "Check", "label": "Delivered by Supplier (Drop Ship)", @@ -704,7 +697,8 @@ "fieldname": "is_sales_item", "fieldtype": "Check", "label": "Allow Sales", - "show_description_on_click": 1 + "show_description_on_click": 1, + "in_list_view": 1 }, { "fieldname": "column_break3", @@ -1077,7 +1071,7 @@ "image_field": "image", "links": [], "make_attachments_public": 1, - "modified": "2026-04-28 17:31:47.613279", + "modified": "2026-05-27 10:18:46.862670", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 21cdad65980..386877496f8 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -79,7 +79,6 @@ class Item(Document): brand: DF.Link | None country_of_origin: DF.Link | None create_new_batch: DF.Check - customer: DF.Link | None customer_code: DF.SmallText | None customer_items: DF.Table[ItemCustomerDetail] customs_tariff_number: DF.Link | None @@ -889,8 +888,13 @@ class Item(Document): if disabled: frappe.throw(_("Attribute {0} is disabled.").format(frappe.bold(d.attribute))) - if not numeric_values and not frappe.db.exists( - "Item Attribute Value", {"parent": d.attribute, "attribute_value": d.attribute_value} + if ( + not numeric_values + and d.attribute_value + and not frappe.db.exists( + "Item Attribute Value", + {"parent": d.attribute, "attribute_value": d.attribute_value}, + ) ): frappe.throw( _("Attribute Value {0} is not valid for the selected attribute {1}.").format( diff --git a/erpnext/stock/doctype/item/item_list.js b/erpnext/stock/doctype/item/item_list.js index e8d886a9c24..34e0fae07d0 100644 --- a/erpnext/stock/doctype/item/item_list.js +++ b/erpnext/stock/doctype/item/item_list.js @@ -8,9 +8,46 @@ frappe.listview_settings["Item"] = { "end_of_life", "disabled", "variant_of", + "is_stock_item", + "is_fixed_asset", + "is_sales_item", + "is_purchase_item", ], filters: [["disabled", "=", "0"]], + formatters: { + is_fixed_asset: function (value, df, doc) { + if (doc.is_fixed_asset) return __("Fixed Asset"); + if (doc.is_stock_item) return __("Stock"); + return __("Service"); + }, + + is_sales_item: function (value, df, doc) { + const sales = cint(doc.is_sales_item); + const purchases = cint(doc.is_purchase_item); + if (sales && purchases) return __("Sales & Purchase"); + if (sales) return __("Sales"); + if (purchases) return __("Purchase"); + return "—"; + }, + }, + + onload: function (listview) { + listview.columns = listview.columns.map((col) => { + if (!col.df) return col; + const renames = { + is_fixed_asset: __("Item Type"), + is_sales_item: __("Purpose"), + stock_uom: __("UOM"), + }; + if (col.df.fieldname in renames) { + return { ...col, df: { ...col.df, label: renames[col.df.fieldname] } }; + } + return col; + }); + listview.render_header(true); + }, + get_indicator: function (doc) { if (doc.disabled) { return [__("Disabled"), "grey", "disabled,=,Yes"]; diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 5404c58fb07..5dd4da05768 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -161,6 +161,7 @@ class TestItem(ERPNextTestSuite): "conversion_factor": 1, "price_list_uom_dependant": 1, "ignore_pricing_rule": 1, + "qty": 1, } ) ) @@ -390,8 +391,9 @@ class TestItem(ERPNextTestSuite): }, ) - self.assertTrue( - "belong to company" in str(ve.exception).lower(), + self.assertIn( + "belong to company", + str(ve.exception).lower(), msg="Mismatching company entities in item defaults should not be allowed.", ) @@ -675,7 +677,7 @@ class TestItem(ERPNextTestSuite): self.assertIsInstance(timestamp, int) self.assertTrue(one_year_ago <= timestamp <= now) self.assertIsInstance(count, int) - self.assertTrue(count >= 0) + self.assertGreaterEqual(count, 0) def test_index_creation(self): "check if index is getting created in db" @@ -848,7 +850,7 @@ class TestItem(ERPNextTestSuite): for _row in range(3): item.append("customer_items", {"ref_code": frappe.generate_hash("", 120)}) item.save() - self.assertTrue(len(item.customer_code) > 140) + self.assertGreater(len(item.customer_code), 140) def test_update_is_stock_item(self): # Step - 1: Create an Item with Maintain Stock enabled @@ -889,7 +891,7 @@ class TestItem(ERPNextTestSuite): data = item_query("Item", "Test Item", "", 0, 20, filters={"item_name": "Test Item"}, as_dict=True) self.assertEqual(data[0].name, item.name) self.assertEqual(data[0].item_name, item.item_name) - self.assertTrue("description" not in data[0]) + self.assertNotIn("description", data[0]) make_property_setter( "Item", None, "search_fields", "item_name, description", "Data", for_doctype="Doctype" @@ -898,7 +900,7 @@ class TestItem(ERPNextTestSuite): self.assertEqual(data[0].name, item.name) self.assertEqual(data[0].item_name, item.item_name) self.assertEqual(data[0].description, item.description) - self.assertTrue("description" in data[0]) + self.assertIn("description", data[0]) def test_group_warehouse_for_reorder_item(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -955,8 +957,9 @@ class TestItem(ERPNextTestSuite): } ).insert() - self.assertTrue( - "must be same as in Template" in str(ve.exception), + self.assertIn( + "must be same as in Template", + str(ve.exception), msg="Different Variant UOM should not be allowed when `allow_different_uom` is disabled.", ) diff --git a/erpnext/stock/doctype/item_price/test_item_price.py b/erpnext/stock/doctype/item_price/test_item_price.py index 7a1400863bf..d98339bb0c7 100644 --- a/erpnext/stock/doctype/item_price/test_item_price.py +++ b/erpnext/stock/doctype/item_price/test_item_price.py @@ -54,7 +54,7 @@ class TestItemPrice(ERPNextTestSuite): doc_fields = frappe.copy_doc(self.globalTestRecords["Item Price"][1]).__dict__.keys() for test_field in test_fields_existance: - self.assertTrue(test_field in doc_fields) + self.assertIn(test_field, doc_fields) def test_dates_validation_error(self): doc = frappe.copy_doc(self.globalTestRecords["Item Price"][1]) diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json index 1bbafc08446..6d638b6e59b 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "creation": "2014-07-11 11:51:00.453717", "doctype": "DocType", "editable_grid": 1, @@ -13,7 +14,10 @@ "amount", "base_amount", "has_corrective_cost", - "has_operating_cost" + "has_operating_cost", + "operation_id", + "qty", + "operating_component" ], "fields": [ { @@ -78,13 +82,38 @@ "fieldtype": "Check", "label": "Has Operating Cost", "read_only": 1 + }, + { + "fieldname": "operation_id", + "fieldtype": "Data", + "hidden": 1, + "label": "Operation ID", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "qty", + "fieldtype": "Float", + "hidden": 1, + "label": "Qty", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 + }, + { + "fieldname": "operating_component", + "fieldtype": "Data", + "hidden": 1, + "label": "Operating Component", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-07-16 15:27:59.175530", + "modified": "2026-05-19 12:21:07.953801", "modified_by": "Administrator", "module": "Stock", "name": "Landed Cost Taxes and Charges", diff --git a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py index a4fb129a7ae..e6a9c0535cf 100644 --- a/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py +++ b/erpnext/stock/doctype/landed_cost_taxes_and_charges/landed_cost_taxes_and_charges.py @@ -22,9 +22,12 @@ class LandedCostTaxesandCharges(Document): expense_account: DF.Link | None has_corrective_cost: DF.Check has_operating_cost: DF.Check + operating_component: DF.Data | None + operation_id: DF.Data | None parent: DF.Data parentfield: DF.Data parenttype: DF.Data + qty: DF.Float # end: auto-generated types pass diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 2a0d85f66b4..40cd7df0a44 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -251,7 +251,7 @@ class MaterialRequest(BuyingController): def check_modified_date(self): mod_db = frappe.db.sql("""select modified from `tabMaterial Request` where name = %s""", self.name) - date_diff = frappe.db.sql(f"""select TIMEDIFF('{mod_db[0][0]}', '{cstr(self.modified)}')""") + date_diff = frappe.db.sql("""select TIMEDIFF(%s, %s)""", (mod_db[0][0], cstr(self.modified))) if date_diff and date_diff[0][0]: frappe.throw(_("{0} {1} has been modified. Please refresh.").format(_(self.doctype), self.name)) @@ -768,7 +768,6 @@ def make_stock_entry(source_name: str, target_doc: str | Document | None = None) target.set_actual_qty() target.calculate_rate_and_amount(raise_error_if_no_rate=False) target.stock_entry_type = target.purpose - target.set_job_card_data() if source.job_card: job_card_details = frappe.get_all( diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index c25a6ecd62d..66a627d05da 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -915,7 +915,7 @@ class TestMaterialRequest(ERPNextTestSuite): for company, _mr_list in comapnywise_mr_list.items(): emails = get_email_list(company) - self.assertTrue(comapnywise_users[company] in emails) + self.assertIn(comapnywise_users[company], emails) for perm in permissions: perm.delete() diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index 85a45f1686b..dfc81d5c9cf 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -876,7 +876,7 @@ class TestPickList(ERPNextTestSuite): ) for d in data: - self.assertTrue(d.batch_no in ["PICKLT-000001", "PICKLT-000002"]) + self.assertIn(d.batch_no, ["PICKLT-000001", "PICKLT-000002"]) if d.batch_no == "PICKLT-000001": self.assertEqual(d.qty, 5.0 * -1) elif d.batch_no == "PICKLT-000002": @@ -927,7 +927,7 @@ class TestPickList(ERPNextTestSuite): self.assertEqual(len(data), 10) for d in data: - self.assertTrue(d.serial_no not in picked_serial_nos) + self.assertNotIn(d.serial_no, picked_serial_nos) pl1.cancel() pl.cancel() @@ -1311,7 +1311,7 @@ class TestPickList(ERPNextTestSuite): self.assertEqual(len(new_serial_nos), 110) for sn in serial_nos: - self.assertFalse(sn in new_serial_nos) + self.assertNotIn(sn, new_serial_nos) pl1.submit() @@ -1765,5 +1765,5 @@ class TestPickList(ERPNextTestSuite): else: self.assertEqual(doc.shipping_address_name, customer_shipping_address_1.name) item_codes = [item.item_code for item in doc.items] - self.assertTrue(item1 in item_codes) - self.assertTrue(item2 in item_codes) + self.assertIn(item1, item_codes) + self.assertIn(item2, item_codes) diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js index 5ed90fca743..4e959229e15 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js @@ -342,7 +342,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend make_retention_stock_entry() { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse", args: { company: cur_frm.doc.company, items: cur_frm.doc.items, @@ -455,7 +455,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; if (d.sample_quantity && d.qty) { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.validate_sample_quantity", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity", args: { batch_no: d.batch_no, item_code: d.item_code, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index c891b6df2eb..ecb44ee544f 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -26,8 +26,6 @@ "apply_putaway_rule", "is_return", "return_against", - "section_break_zwvg", - "title", "accounting_dimensions_section", "cost_center", "dimension_col_break", @@ -151,6 +149,7 @@ "instructions", "is_internal_supplier", "represents_company", + "title", "inter_company_reference", "column_break_131", "remarks", @@ -1287,10 +1286,6 @@ { "fieldname": "column_break_ugyv", "fieldtype": "Column Break" - }, - { - "fieldname": "section_break_zwvg", - "fieldtype": "Section Break" } ], "grid_page_length": 50, @@ -1298,7 +1293,7 @@ "idx": 261, "is_submittable": 1, "links": [], - "modified": "2026-05-04 10:19:44.638858", + "modified": "2026-05-28 12:38:05.907578", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 335a77e3963..8f3df98bd7d 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -16,7 +16,6 @@ from pypika import functions as fn import erpnext from erpnext.accounts.utils import get_account_currency from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled -from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.accounts_controller import merge_taxes from erpnext.controllers.buying_controller import BuyingController from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction @@ -265,7 +264,7 @@ class PurchaseReceipt(BuyingController): self.validate_cwip_accounts() self.validate_provisional_expense_account() - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order") if getdate(self.posting_date) > getdate(nowdate()): throw(_("Posting Date cannot be future date")) @@ -373,14 +372,6 @@ class PurchaseReceipt(BuyingController): po_qty, po_warehouse = frappe.db.get_value("Purchase Order Item", po_detail, ["qty", "warehouse"]) return po_qty, po_warehouse - # Check for Closed status - def check_on_hold_or_closed_status(self): - check_list = [] - for d in self.get("items"): - if d.meta.get_field("purchase_order") and d.purchase_order and d.purchase_order not in check_list: - check_list.append(d.purchase_order) - check_on_hold_or_closed_status("Purchase Order", d.purchase_order) - # on submit def on_submit(self): super().on_submit() @@ -456,7 +447,7 @@ class PurchaseReceipt(BuyingController): def on_cancel(self): super().on_cancel() - self.check_on_hold_or_closed_status() + self.check_for_on_hold_or_closed_status("Purchase Order", "purchase_order") # Check if Purchase Invoice has been submitted against current Purchase Order submitted = frappe.db.sql( """select t1.name @@ -1375,7 +1366,7 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc): .on(parent_table.name == table.parent) .select( table.pr_detail, - fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + fn.Sum(table.base_net_amount).as_("amount"), fn.Sum(table.qty).as_("qty"), ) .where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1)) @@ -1421,7 +1412,7 @@ def get_billed_qty_amount_against_purchase_order(pr_doc): .select( table.po_detail, fn.Sum(table.qty).as_("qty"), - fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + fn.Sum(table.base_net_amount).as_("amount"), ) .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull())) .groupby(table.po_detail) diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 142378124b4..6cc1bbc7c86 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -57,13 +57,13 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) mr.insert() mr.submit() - frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 200) + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 200) po = make_purchase_order(mr.name) po.supplier = "_Test Supplier" po.items[0].qty = 300 po.save() po.submit() - frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 20) + frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0) pr = make_purchase_receipt(qty=300, item_code=item.name, do_not_save=True) pr.save() pr.submit() @@ -1051,6 +1051,40 @@ class TestPurchaseReceipt(ERPNextTestSuite): pr.cancel() + def test_inter_company_purchase_receipt_does_not_inherit_party_fields(self): + """ + Party-derived fields on DN (from Customer) must not leak into the mapped PR. + """ + from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + prepare_data_for_internal_transfer() + + customer = "_Test Internal Customer 2" + company = "_Test Company with perpetual inventory" + + dn = create_delivery_note( + company=company, + customer=customer, + cost_center="Main - TCP1", + expense_account="Cost of Goods Sold - TCP1", + qty=1, + rate=100, + warehouse="Stores - TCP1", + target_warehouse="Work In Progress - TCP1", + do_not_submit=True, + ) + # Stamp customer-side party fields onto the DN + dn.tax_category = "_Test Tax Category 2" + dn.language = "ar" + dn.submit() + + pr = make_inter_company_purchase_receipt(dn.name) + + supplier = frappe.get_doc("Supplier", "_Test Internal Supplier 2") + self.assertEqual(pr.tax_category or None, supplier.tax_category or None) + self.assertEqual(pr.language or None, supplier.language or None) + def test_lcv_for_internal_transfer(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note @@ -1145,7 +1179,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): new_cost = frappe.db.get_value("Serial and Batch Bundle", new_inward_sabb[0], "total_amount") self.assertEqual(new_cost, original_cost + 100) - self.assertTrue(new_inward_sabb[0] == inward_sabb[0]) + self.assertEqual(new_inward_sabb[0], inward_sabb[0]) def test_stock_transfer_from_purchase_receipt_with_valuation(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt @@ -1797,7 +1831,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): return_pi = make_return_doc(pi.doctype, pi.name) return_pi.save().submit() - self.assertTrue(return_pi.docstatus == 1) + self.assertEqual(return_pi.docstatus, 1) def test_disable_last_purchase_rate(self): from erpnext.stock.get_item_details import ItemDetailsCtx, get_item_details @@ -2504,7 +2538,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): sbb_doc = frappe.get_doc("Serial and Batch Bundle", pr.items[0].serial_and_batch_bundle) for row in sbb_doc.entries: - self.assertTrue(row.serial_no in serial_nos) + self.assertIn(row.serial_no, serial_nos) serial_nos.remove("SNU-TSFISI-000015") @@ -2537,7 +2571,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): serial_no_status = frappe.db.get_value("Serial No", "SNU-TSFISI-000015", "status") - self.assertTrue(serial_no_status != "Active") + self.assertNotEqual(serial_no_status, "Active") dn = create_delivery_note( item_code=item_code, @@ -2550,11 +2584,11 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(dn.items[0].qty, 4) doc = frappe.get_doc("Serial and Batch Bundle", dn.items[0].serial_and_batch_bundle) for row in doc.entries: - self.assertTrue(row.serial_no in new_serial_nos) + self.assertIn(row.serial_no, new_serial_nos) for sn in new_serial_nos: serial_no_status = frappe.db.get_value("Serial No", sn, "status") - self.assertTrue(serial_no_status != "Active") + self.assertNotEqual(serial_no_status, "Active") frappe.db.set_single_value( "Stock Settings", "do_not_update_serial_batch_on_creation_of_auto_bundle", 1 @@ -2965,7 +2999,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): serial_no_details = frappe.db.get_value( "Serial No", sn, ["status", "warehouse"], as_dict=1 ) - self.assertTrue(serial_no_details.status == "Active") + self.assertEqual(serial_no_details.status, "Active") self.assertEqual(serial_no_details.warehouse, "Work In Progress - TCP1") inter_transfer_dn_return = make_return_doc("Delivery Note", inter_transfer_dn.name) @@ -3104,7 +3138,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): serial_no_details = frappe.db.get_value( "Serial No", sn, ["status", "warehouse"], as_dict=1 ) - self.assertTrue(serial_no_details.status == "Active") + self.assertEqual(serial_no_details.status, "Active") self.assertEqual(serial_no_details.warehouse, "Work In Progress - TCP1") inter_transfer_dn_return = make_return_doc("Delivery Note", inter_transfer_dn.name) @@ -4236,7 +4270,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): serial_no = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0] status = frappe.db.get_value("Serial No", serial_no, "status") - self.assertTrue(status == "Active") + self.assertEqual(status, "Active") make_stock_entry( item_code=item_code, @@ -4247,7 +4281,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): ) status = frappe.db.get_value("Serial No", serial_no, "status") - self.assertFalse(status == "Active") + self.assertNotEqual(status, "Active") pr = make_purchase_receipt( item_code=item_code, qty=1, rate=100, use_serial_batch_fields=1, do_not_submit=1 @@ -4759,8 +4793,8 @@ class TestPurchaseReceipt(ERPNextTestSuite): gl_entries = get_gl_entries(pr.doctype, pr.name) accounts = [d.account for d in gl_entries] - self.assertTrue(expense_account in accounts) - self.assertTrue(expense_contra_account in accounts) + self.assertIn(expense_account, accounts) + self.assertIn(expense_contra_account, accounts) for row in gl_entries: if row.account == expense_account: @@ -4798,7 +4832,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): gl_entries = get_gl_entries(se.doctype, se.name) for row in gl_entries: - self.assertTrue(row.account in ["Stock In Hand - TCP1", "Stock Adjustment - TCP1"]) + self.assertIn(row.account, ["Stock In Hand - TCP1", "Stock Adjustment - TCP1"]) se.items[0].db_set("expense_account", account) se.reload() @@ -4820,7 +4854,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): gl_entries = get_gl_entries(se.doctype, se.name) for row in gl_entries: - self.assertTrue(row.account in ["Stock In Hand - TCP1", account]) + self.assertIn(row.account, ["Stock In Hand - TCP1", account]) def test_lcv_for_repack_entry(self): from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import ( @@ -5056,7 +5090,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): doc.db_set("use_batchwise_valuation", 0) doc.reload() - self.assertTrue(doc.use_batchwise_valuation == 0) + self.assertEqual(doc.use_batchwise_valuation, 0) doc = frappe.new_doc("Batch") doc.update( @@ -5066,7 +5100,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): } ).insert() - self.assertTrue(doc.use_batchwise_valuation == 1) + self.assertEqual(doc.use_batchwise_valuation, 1) warehouse = "_Test Warehouse - _TC" make_stock_entry( @@ -5458,7 +5492,7 @@ class TestPurchaseReceipt(ERPNextTestSuite): self.assertEqual(pr.conversion_rate, 80) gl_entries = get_gl_entries(pr.doctype, pr.name) - self.assertTrue(len(gl_entries) == 2) + self.assertEqual(len(gl_entries), 2) for row in gl_entries: amount = row.credit or row.debit self.assertEqual(amount, 8000.0) @@ -5471,18 +5505,310 @@ class TestPurchaseReceipt(ERPNextTestSuite): pi.submit() gl_entries = get_gl_entries(pi.doctype, pi.name) - self.assertTrue(len(gl_entries) == 2) + self.assertEqual(len(gl_entries), 2) accounts = ["USD Party Account Creditors - TCP1", "Stock Received But Not Billed - TCP1"] for row in gl_entries: amount = row.credit or row.debit self.assertEqual(amount, 9000.0) - self.assertTrue(row.account in accounts) + self.assertIn(row.account, accounts) frappe.db.set_single_value( "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value ) + def test_purchase_receipt_gl_entries_for_asset_item(self): + from erpnext.assets.doctype.asset.test_asset import create_fixed_asset_item + + # Create a Company without Stock Accounts Linked. + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": "Asset Company", + "country": "India", + "default_currency": "INR", + } + ).insert() + + stock_accounts = ( + company.default_inventory_account, + company.stock_adjustment_account, + company.stock_received_but_not_billed, + ) + + company.update( + {"stock_in_hand_account": "", "stock_adjustment_account": "", "stock_received_but_not_billed": ""} + ).save() + + for account in stock_accounts: + frappe.db.delete("Account", account) + + asset_category = create_asset_category_for_pr_test() + asset_item = create_fixed_asset_item( + item_code="Test Fixed Asset Item for PR GL Test", asset_category=asset_category.name + ) + arnb_account = frappe.db.get_value("Company", company.name, "asset_received_but_not_billed") + + # Purchase Receipt should be able to create even without any stock accounts linked to company + pr = make_purchase_receipt( + item_code=asset_item.name, warehouse="Stores - AC", qty=1, rate=10000, company=company.name + ) + + gl_entries = get_gl_entries("Purchase Receipt", pr.name) + + self.assertTrue(gl_entries) + gl_accounts = [d.account for d in gl_entries] + + # The fixed asset account set on the item row must be debited + asset_expense_account = pr.items[0].expense_account + self.assertIn(asset_expense_account, gl_accounts) + + # Asset Received But Not Billed must be credited + self.assertIn(arnb_account, gl_accounts) + + # No Stock-type account should appear — the inventory account map is not + # needed and must not be consulted for an asset-only receipt + for entry in gl_entries: + account_type = frappe.db.get_value("Account", entry.account, "account_type") + self.assertNotEqual(account_type, "Stock") + + pr.cancel() + + def test_purchase_receipt_gl_entries_with_mixed_asset_and_stock_items(self): + from erpnext.assets.doctype.asset.test_asset import create_fixed_asset_item + + company = frappe.get_doc( + { + "doctype": "Company", + "company_name": "Asset Company", + "country": "India", + "default_currency": "INR", + } + ).insert() + + asset_category = create_asset_category_for_pr_test() + asset_item = create_fixed_asset_item( + item_code="Test Fixed Asset Item for PR GL Test", asset_category=asset_category.name + ) + arnb_account = frappe.db.get_value("Company", company.name, "asset_received_but_not_billed") + + pr = make_purchase_receipt( + item_code=asset_item.name, + qty=1, + rate=10000, + warehouse="Stores - AC", + do_not_save=True, + company=company.name, + ) + pr.append( + "items", + { + "item_code": "_Test Item", + "warehouse": "Stores - AC", + "qty": 5, + "received_qty": 5, + "rejected_qty": 0, + "rate": 50, + "uom": "_Test UOM", + "stock_uom": "_Test UOM", + "conversion_factor": 1.0, + "cost_center": frappe.get_cached_value("Company", pr.company, "cost_center"), + }, + ) + pr.insert() + pr.submit() + + gl_entries = get_gl_entries("Purchase Receipt", pr.name) + self.assertTrue(gl_entries) + + gl_accounts = [d.account for d in gl_entries] + self.assertIn(arnb_account, gl_accounts) + + # The fixed asset account set on the item row must be debited + asset_expense_account = pr.items[0].expense_account + self.assertIn(asset_expense_account, gl_accounts) + + # Asset Received But Not Billed must be credited + self.assertIn(asset_category.accounts[0].fixed_asset_account, gl_accounts) + + # Stock Accounts should be used for Stock Items + self.assertIn(company.stock_received_but_not_billed, gl_accounts) + self.assertIn(company.default_inventory_account, gl_accounts) + pr.cancel() + + @ERPNextTestSuite.change_settings( + "Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0} + ) + def test_srbnb_with_inclusive_tax_and_rate_change_in_pi(self): + """ + When 'Set Landed Cost Based on PI Rate' is enabled and PI has an inclusive tax: + - PR: qty=2, rate=1000 INR → base_net_amount=2000 + - PI: rate changed to 2000, 5% tax included in basic rate + → PI base_net_amount = 2 * 2000 / 1.05 ≈ 3809.52 + + The system must use PI's base_net_amount (not amount=4000) so that + SRBNB credit on PR = 3809.52, not 4000. + """ + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + cost_center = "Main - TCP1" + + item_code = make_item( + "Test Item for SRBNB Inclusive Tax Rate Change", + {"is_stock_item": 1}, + ).name + + pr = make_purchase_receipt( + item_code=item_code, + qty=2, + rate=1000, + company=company, + warehouse=warehouse, + cost_center=cost_center, + ) + + pi = make_purchase_invoice(pr.name) + pi.items[0].rate = 2000 + pi.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - TCP1", + "category": "Total", + "add_deduct_tax": "Add", + "included_in_print_rate": 1, + "rate": 5, + "description": "Test Inclusive Tax", + "cost_center": cost_center, + }, + ) + pi.save() + pi.submit() + + pr.reload() + + # PI base_net_amount = qty * (rate / (1 + tax_rate/100)) = 2 * (2000 / 1.05) + pi_base_net_amount = flt(2 * 2000 / 1.05, 2) + pr_base_net_amount = flt(pr.items[0].amount, 2) # 2 * 1000 = 2000 + expected_diff = flt(pi_base_net_amount - pr_base_net_amount, 2) + + self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2) + + # Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount + srbnb_account = "Stock Received But Not Billed - TCP1" + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True) + srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account) + self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) + + @ERPNextTestSuite.change_settings( + "Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0} + ) + def test_srbnb_with_inclusive_tax_and_exchange_rate_change_in_pi(self): + """ + When 'Set Landed Cost Based on PI Rate' is enabled, PI has an inclusive tax, and only + the exchange rate changes on the PI (rate stays the same): + - PR: qty=2, rate=100 USD, conversion_rate=70 → base_net_amount=14000 INR + - PI: same rate=100 USD, conversion_rate changed to 90, 5% tax included in basic rate + → PI base_net_amount = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR + + The system must use PI's base_net_amount (not amount = 2*100*90 = 18000) so that + SRBNB credit on PR = 17142.86, not 18000. + """ + from erpnext.accounts.doctype.account.test_account import create_account + + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + cost_center = "Main - TCP1" + + party_account = create_account( + account_name="USD Payable For SRBNB Exchange Rate Test", + parent_account="Accounts Payable - TCP1", + account_type="Payable", + company=company, + account_currency="USD", + ) + + supplier = create_supplier( + supplier_name="_Test USD Supplier for SRBNB Exchange Rate", + default_currency="USD", + party_account=party_account, + ).name + + item_code = make_item( + "Test Item for SRBNB Inclusive Tax Exchange Rate Change", + {"is_stock_item": 1}, + ).name + + pr = make_purchase_receipt( + item_code=item_code, + qty=2, + rate=100, + currency="USD", + conversion_rate=70, + company=company, + warehouse=warehouse, + supplier=supplier, + ) + + pi = make_purchase_invoice(pr.name) + pi.conversion_rate = 90 + pi.append( + "taxes", + { + "charge_type": "On Net Total", + "account_head": "_Test Account VAT - TCP1", + "category": "Total", + "add_deduct_tax": "Add", + "included_in_print_rate": 1, + "rate": 5, + "description": "Test Inclusive Tax", + "cost_center": cost_center, + }, + ) + pi.save() + pi.submit() + + pr.reload() + + # PI base_net_amount = qty * (rate / (1 + tax_rate/100)) * new_conversion_rate + # = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR + # PR base_net_amount = qty * rate * pr_conversion_rate = 2 * 100 * 70 = 14000 INR + tax_amount_pr = (200 - flt(200 / 1.05, 2)) * 90 + + pi_base_net_amount = flt(2 * 100 * 90) - flt(tax_amount_pr) + pr_base_net_amount = flt(2 * 100 * 70) + expected_diff = flt(pi_base_net_amount - pr_base_net_amount) + + self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2) + + # Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount + srbnb_account = "Stock Received But Not Billed - TCP1" + gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True) + srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account) + self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2) + + +def create_asset_category_for_pr_test(): + category_name = "Test Asset Category for PR" + + asset_category = frappe.get_doc( + { + "doctype": "Asset Category", + "asset_category_name": category_name, + "enable_cwip_accounting": 0, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "accounts": [ + { + "company_name": "Asset Company", + "fixed_asset_account": "Electronic Equipment - AC", + } + ], + } + ).insert() + return asset_category + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier diff --git a/erpnext/stock/doctype/quality_inspection/quality_inspection.py b/erpnext/stock/doctype/quality_inspection/quality_inspection.py index 4df99dd21a6..dac3ca21038 100644 --- a/erpnext/stock/doctype/quality_inspection/quality_inspection.py +++ b/erpnext/stock/doctype/quality_inspection/quality_inspection.py @@ -8,7 +8,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, get_link_to_form, get_number_format_info +from frappe.utils import cint, flt, get_link_to_form, get_number_format_info from erpnext.stock.doctype.quality_inspection_template.quality_inspection_template import ( get_template_details, diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index ce92dec82da..6a8d3cc7ffa 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -493,6 +493,11 @@ def repost_gl_entries(doc): repost_affected_transaction = get_affected_transactions(doc) transactions = directly_dependent_transactions + list(repost_affected_transaction) + + # handle stock delivered but not billed ledger entries + if frappe.get_cached_value("Company", doc.company, "stock_delivered_but_not_billed"): + _update_post_delivery_billed_vouchers(transactions) + enable_separate_reposting_for_gl = frappe.db.get_single_value( "Stock Reposting Settings", "enable_separate_reposting_for_gl" ) @@ -548,6 +553,44 @@ def _get_directly_dependent_vouchers(doc): return affected_vouchers +def _update_post_delivery_billed_vouchers(transactions: list) -> None: + """ + Fetch the delivery notes from dependant transactions, + and repost the Sales Invoice vouchers created post delivery note. + To match the Stock Delivered But Not Billed ledger entries. + """ + dn_vouchers = set() + + for voucher_type, voucher_no in transactions: + if voucher_type == "Delivery Note": + dn_vouchers.add(voucher_no) + + if not dn_vouchers: + return + + sii = DocType("Sales Invoice Item") + si = DocType("Sales Invoice") + dni = DocType("Delivery Note Item") + + query = ( + frappe.qb.from_(sii) + .inner_join(si) + .on(si.name == sii.parent) + .left_join(dni) + .on(dni.name == sii.dn_detail) + .select(sii.parenttype, sii.parent) + .where((sii.delivery_note.isin(dn_vouchers) | dni.parent.isin(dn_vouchers)) & (si.docstatus == 1)) + .groupby(sii.parenttype, sii.parent) + ) + + result = query.run(as_dict=True) + + si_vouchers = {(d.parenttype, d.parent) for d in result} + existing = set(transactions) + + transactions.extend(list(si_vouchers - existing)) + + def notify_error_to_stock_managers(doc, traceback): recipients = get_recipients() diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index e0ddd6faa8e..82b2dbe6e45 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -100,14 +100,14 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin): repost_doc.db_update_all() logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"}) - self.assertTrue(len(logs) > 10) + self.assertGreater(len(logs), 10) from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import RepostItemValuation RepostItemValuation.clear_old_logs(days=1) logs = frappe.get_all("Repost Item Valuation", filters={"status": "Skipped"}) - self.assertTrue(len(logs) == 0) + self.assertEqual(len(logs), 0) def test_create_item_wise_repost_item_valuation_entries(self): pr = make_purchase_receipt( @@ -379,13 +379,13 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin): get_multiple_items=True, ) - self.assertTrue(pr.docstatus == 1) + self.assertEqual(pr.docstatus, 1) self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": pr.name})) pr.load_from_db() pr.cancel() - self.assertTrue(pr.docstatus == 2) + self.assertEqual(pr.docstatus, 2) self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": pr.name})) def test_repost_item_valuation_for_closing_stock_balance(self): diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 43f67c64eb0..daf978c8c6c 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -2268,25 +2268,6 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh return doc -@frappe.whitelist() -def update_serial_or_batch(bundle_id: str, serial_no: str | None = None, batch_no: str | None = None): - if batch_no and not serial_no: - if qty := frappe.db.get_value( - "Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty" - ): - frappe.db.set_value( - "Serial and Batch Entry", {"parent": bundle_id, "batch_no": batch_no}, "qty", qty + 1 - ) - return - - doc = frappe.get_cached_doc("Serial and Batch Bundle", bundle_id) - if not serial_no and not batch_no: - return - - doc.append("entries", {"serial_no": serial_no, "batch_no": batch_no, "qty": 1}) - doc.save(ignore_permissions=True) - - def get_serial_and_batch_ledger(**kwargs): kwargs = frappe._dict(kwargs) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 37d4a45f954..9939df10835 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1070,11 +1070,11 @@ class TestSerialandBatchBundle(ERPNextTestSuite): se.remove(se.items[1]) se.save() - self.assertTrue(len(se.items) == 1) + self.assertEqual(len(se.items), 1) se.submit() bundle_doc.reload() - self.assertTrue(bundle_doc.docstatus == 0) + self.assertEqual(bundle_doc.docstatus, 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) def test_reference_voucher_on_cancel(self): diff --git a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py index eff86d41c55..9ac9280f056 100644 --- a/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py +++ b/erpnext/stock/doctype/stock_closing_entry/stock_closing_entry.py @@ -137,8 +137,7 @@ class StockClosingEntry(Document): attached_file = frappe.get_doc("File", attachment.name) data = gzip.decompress(attached_file.get_content()) - if data := json.loads(data.decode("utf-8")): - data = data + data = json.loads(data.decode("utf-8")) return parse_json(data) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index c5db730fdfb..3e5a8a10a8d 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -496,7 +496,7 @@ frappe.ui.form.on("Stock Entry", { __("Expired Batches"), function () { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.get_expired_batch_items", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch.get_expired_batch_items", freeze: true, callback: function (r) { if (!r.exc && r.message) { @@ -670,7 +670,7 @@ frappe.ui.form.on("Stock Entry", { make_retention_stock_entry: function (frm) { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.move_sample_to_retention_warehouse", args: { company: frm.doc.company, items: frm.doc.items, @@ -939,7 +939,7 @@ frappe.ui.form.on("Stock Entry", { if (frm.doc.purchase_order) { frm.set_value("subcontracting_order", ""); erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.get_items_from_subcontract_order", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order", source_name: frm.doc.purchase_order, target_doc: frm, freeze: true, @@ -951,7 +951,7 @@ frappe.ui.form.on("Stock Entry", { if (frm.doc.subcontracting_order) { frm.set_value("purchase_order", ""); erpnext.utils.map_current_doc({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.get_items_from_subcontract_order", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.subcontracting.get_items_from_subcontract_order", source_name: frm.doc.subcontracting_order, target_doc: frm, freeze: true, @@ -1150,7 +1150,7 @@ var validate_sample_quantity = function (frm, cdt, cdn) { var d = locals[cdt][cdn]; if (d.sample_quantity && d.transfer_qty && frm.doc.purpose == "Material Receipt") { frappe.call({ - method: "erpnext.stock.doctype.stock_entry.stock_entry.validate_sample_quantity", + method: "erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing.validate_sample_quantity", args: { batch_no: d.batch_no, item_code: d.item_code, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 81cbad37c24..f6f2d821cc8 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -46,6 +46,7 @@ "target_address_display", "sb0", "scan_barcode", + "column_break_menu", "last_scanned_warehouse", "items_section", "items", @@ -369,6 +370,7 @@ { "fieldname": "sb0", "fieldtype": "Section Break", + "hide_border": 1, "options": "Simple" }, { @@ -646,7 +648,7 @@ "depends_on": "eval:in_list([\"Material Issue\", \"Manufacture\", \"Repack\", \"Send to Subcontractor\", \"Material Transfer for Manufacture\", \"Material Consumption for Manufacture\", \"Disassemble\"], doc.purpose)", "fieldname": "bom_info_section", "fieldtype": "Section Break", - "label": "BOM Info" + "label": "Bill of Materials" }, { "collapsible": 1, @@ -695,8 +697,7 @@ }, { "fieldname": "items_section", - "fieldtype": "Section Break", - "label": "Items" + "fieldtype": "Section Break" }, { "depends_on": "eval:doc.asset_repair", @@ -761,6 +762,10 @@ "fieldtype": "Link", "label": "Cost Center", "options": "Cost Center" + }, + { + "fieldname": "column_break_menu", + "fieldtype": "Column Break" } ], "grid_page_length": 50, @@ -769,7 +774,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2026-03-04 19:03:23.426082", + "modified": "2026-04-21 13:31:48.817309", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 2951b7272b3..e080365fe38 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -6,20 +6,15 @@ import json from collections import defaultdict import frappe -from frappe import _, bold +from frappe import _ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc -from frappe.query_builder import DocType from frappe.query_builder.functions import Sum from frappe.utils import ( cint, - comma_or, cstr, flt, - format_time, - formatdate, get_link_to_form, - getdate, nowdate, ) @@ -29,19 +24,11 @@ from erpnext.accounts.utils import get_account_currency from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import ( - add_additional_cost, get_op_cost_from_sub_assemblies, - get_secondary_items_from_sub_assemblies, validate_bom_no, ) from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults -from erpnext.stock.doctype.batch.batch import get_batch_qty -from erpnext.stock.doctype.item.item import get_item_defaults -from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos -from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( - OpeningEntryAccountError, -) from erpnext.stock.get_item_details import ( ItemDetailsCtx, get_barcode_data, @@ -49,13 +36,23 @@ from erpnext.stock.get_item_details import ( get_conversion_factor, get_default_cost_center, ) -from erpnext.stock.serial_batch_bundle import ( - SerialBatchCreation, - get_empty_batches_based_work_order, - get_serial_or_batch_items, +from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate +from erpnext.stock.utils import get_incoming_rate + +from .stock_entry_handler.disassemble import DisassembleStockEntry +from .stock_entry_handler.manufacturing import ( + ManufactureStockEntry, + MaterialConsumptionForManufactureStockEntry, + RepackStockEntry, ) -from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate -from erpnext.stock.utils import get_bin, get_combine_datetime, get_incoming_rate +from .stock_entry_handler.material_receipt_issue import MaterialIssueStockEntry, MaterialReceiptStockEntry +from .stock_entry_handler.material_transfer import ( + MaterialRequestStockEntry, + MaterialTransferForManufactureStockEntry, + MaterialTransferStockEntry, +) +from .stock_entry_handler.serial_batch import StockEntrySABB +from .stock_entry_handler.subcontracting import SendToSubcontractorStockEntry class FinishedGoodError(frappe.ValidationError): @@ -66,14 +63,6 @@ class IncorrectValuationRateError(frappe.ValidationError): pass -class DuplicateEntryForWorkOrderError(frappe.ValidationError): - pass - - -class OperationsNotCompleteError(frappe.ValidationError): - pass - - class MaxSampleAlreadyRetainedError(frappe.ValidationError): pass @@ -170,8 +159,15 @@ class StockEntry(StockController, SubcontractingInwardController): work_order: DF.Link | None # end: auto-generated types + def __setattr__(self, name, value): + super().__setattr__(name, value) + if name == "purpose": + self._configure_purpose_class() + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._configure_purpose_class() + if self.subcontracting_inward_order: self.subcontract_data = frappe._dict( { @@ -191,6 +187,36 @@ class StockEntry(StockController, SubcontractingInwardController): } ) + def _configure_purpose_class(self): + purpose_map = { + "Manufacture": ManufactureStockEntry, + "Repack": RepackStockEntry, + "Material Transfer": MaterialTransferStockEntry, + "Material Transfer for Manufacture": MaterialTransferForManufactureStockEntry, + "Material Consumption for Manufacture": MaterialConsumptionForManufactureStockEntry, + "Disassemble": DisassembleStockEntry, + "Send to Subcontractor": SendToSubcontractorStockEntry, + "Material Issue": MaterialIssueStockEntry, + "Material Receipt": MaterialReceiptStockEntry, + } + + self.purpose_cls = purpose_map.get(self.purpose) + + if self.purpose == "Material Transfer" and self.transfer_for_material_request(): + self.purpose_cls = MaterialRequestStockEntry + + def transfer_for_material_request(self): + if self.outgoing_stock_entry and frappe.get_all( + "Stock Entry Detail", + filters={"parent": self.outgoing_stock_entry, "material_request": ("is", "set")}, + pluck="name", + ): + return True + + for item in self.items: + if item.material_request: + return True + def onload(self): self.update_items_from_bin_details() @@ -211,6 +237,11 @@ class StockEntry(StockController, SubcontractingInwardController): def before_validate(self): from erpnext.stock.doctype.putaway_rule.putaway_rule import apply_putaway_rule + if self.purpose_cls and hasattr(self.purpose_cls, "before_validate"): + self.purpose_cls(self).before_validate() + + self.set_default_cost_center() + apply_rule = self.apply_putaway_rule and (self.purpose in ["Material Transfer", "Material Receipt"]) if self.get("items") and apply_rule: @@ -224,21 +255,29 @@ class StockEntry(StockController, SubcontractingInwardController): if not item.project: item.project = self.project + def set_default_cost_center(self): + for row in self.items: + if not row.cost_center: + row.cost_center = get_default_cost_center( + row, + row, + get_item_group_defaults(row.item_code, self.company), + get_brand_defaults(row.item_code, self.company), + self.company, + ) + def validate(self): - self.pro_doc = frappe._dict() - if self.work_order: - self.pro_doc = frappe.get_doc("Work Order", self.work_order) + if self.purpose_cls: + self.purpose_cls(self).validate() self.validate_duplicate_serial_and_batch_bundle("items") self.validate_posting_time() - self.validate_purpose() self.validate_item() self.validate_customer_provided_item() self.set_transfer_qty() self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("stock_uom", "transfer_qty") self.validate_warehouse_of_sabb() - self.validate_work_order() self.validate_source_stock_entry() self.validate_bom() self.set_process_loss_qty() @@ -251,201 +290,37 @@ class StockEntry(StockController, SubcontractingInwardController): else: self.validate_job_card_fg_item() - self.validate_warehouse() - self.validate_with_material_request() self.validate_batch() self.validate_inspection() self.validate_fg_completed_qty() self.validate_difference_account() - self.set_job_card_data() self.validate_job_card_item() self.set_purpose_for_stock_entry() self.clean_serial_nos() - self.validate_repack_entry() - - if not self.from_bom: - self.fg_completed_qty = 0.0 - - self.make_serial_and_batch_bundle_for_outward() + self.remove_fg_completed_qty() self.validate_serialized_batch() self.calculate_rate_and_amount() self.validate_putaway_capacity() - self.validate_component_and_quantities() - - if self.get("purpose") != "Manufacture": - # ignore other item wh difference and empty source/target wh - # in Manufacture Entry - self.reset_default_field_value("from_warehouse", "items", "s_warehouse") - self.reset_default_field_value("to_warehouse", "items", "t_warehouse") - - self.validate_same_source_target_warehouse_during_material_transfer() - self.validate_closed_subcontracting_order() - self.validate_subcontract_order() - self.validate_raw_materials_exists() - super().validate_subcontracting_inward() - def validate_repack_entry(self): - if self.purpose != "Repack": - return + def remove_fg_completed_qty(self): + if not self.from_bom and self.fg_completed_qty: + self.fg_completed_qty = 0.0 - fg_items = {row.item_code: row for row in self.items if row.is_finished_item} - - if len(fg_items) > 1 and not all(row.set_basic_rate_manually for row in fg_items.values()): - frappe.throw( - _( - "When there are multiple finished goods ({0}) in a Repack stock entry, the basic rate for all finished goods must be set manually. To set rate manually, enable the checkbox 'Set Basic Rate Manually' in the respective finished good row." - ).format(", ".join(fg_items)), - title=_("Set Basic Rate Manually"), - ) - - def validate_raw_materials_exists(self): - if self.purpose not in ["Manufacture", "Repack", "Disassemble"]: - return - - if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): - return - - raw_materials = [] - for row in self.items: - if row.s_warehouse: - raw_materials.append(row.item_code) - - if not raw_materials: - frappe.throw( - _( - "At least one raw material item must be present in the stock entry for the type {0}" - ).format(bold(self.purpose)), - title=_("Raw Materials Missing"), - ) - - def set_serial_batch_for_disassembly(self): - if self.purpose != "Disassemble": - return - - if self.get("source_stock_entry"): - self._set_serial_batch_for_disassembly_from_stock_entry() - else: - self._set_serial_batch_for_disassembly_from_available_materials() - - def _set_serial_batch_for_disassembly_from_stock_entry(self): - from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( - get_voucher_wise_serial_batch_from_bundle, - ) - - source_fg_qty = flt(frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")) - scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0 - - bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry]) - source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()} - - for row in self.items: - if not row.ste_detail: - continue - - source_row = source_rows_by_name.get(row.ste_detail) - if not source_row: - continue - - source_warehouse = source_row.s_warehouse or source_row.t_warehouse - key = (source_row.item_code, source_warehouse, self.source_stock_entry) - source_bundle = bundle_data.get(key, {}) - - batches = defaultdict(float) - serial_nos = [] - - if source_bundle.get("batch_nos"): - qty_remaining = row.transfer_qty - for batch_no, batch_qty in source_bundle["batch_nos"].items(): - if qty_remaining <= 0: - break - alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining) - batches[batch_no] = alloc - qty_remaining -= alloc - elif source_row.batch_no: - batches[source_row.batch_no] = row.transfer_qty - - if source_bundle.get("serial_nos"): - serial_nos = get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)] - elif source_row.serial_no: - serial_nos = get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)] - - self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) - - def _set_serial_batch_for_disassembly_from_available_materials(self): - available_materials = get_available_materials(self.work_order, self) - for row in self.items: - if row.serial_no or row.batch_no or row.serial_and_batch_bundle: - continue - - warehouse = row.s_warehouse or row.t_warehouse - materials = available_materials.get((row.item_code, warehouse)) - if not materials: - continue - - batches = defaultdict(float) - serial_nos = [] - qty = row.transfer_qty - for batch_no, batch_qty in materials.batch_details.items(): - if qty <= 0: - break - - batch_qty = abs(batch_qty) - if batch_qty <= qty: - batches[batch_no] = batch_qty - qty -= batch_qty - else: - batches[batch_no] = qty - qty = 0 - - if materials.serial_nos: - serial_nos = materials.serial_nos[: int(row.transfer_qty)] - - self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) - - def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches): - if not serial_nos and not batches: - return - - warehouse = row.s_warehouse or row.t_warehouse - bundle_doc = SerialBatchCreation( - { - "item_code": row.item_code, - "warehouse": warehouse, - "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time), - "voucher_type": self.doctype, - "voucher_no": self.name, - "voucher_detail_no": row.name, - "qty": row.transfer_qty, - "type_of_transaction": "Inward" if row.t_warehouse else "Outward", - "company": self.company, - "do_not_submit": True, - } - ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) - - row.serial_and_batch_bundle = bundle_doc.name - row.use_serial_batch_fields = 0 - - row.db_set( - { - "serial_and_batch_bundle": bundle_doc.name, - "use_serial_batch_fields": 0, - } - ) + def before_submit(self): + StockEntrySABB(self).make_serial_and_batch_bundle_for_outward() def on_submit(self): - self.set_serial_batch_for_disassembly() + if self.purpose_cls and hasattr(self.purpose_cls, "on_submit"): + self.purpose_cls(self).on_submit() + self.make_bundle_using_old_serial_batch_fields() - self.update_work_order() - self.update_disassembled_order() self.adjust_stock_reservation_entries_for_return() self.update_stock_reservation_entries() self.update_stock_ledger() self.make_stock_reserve_for_wip_and_fg() self.reserve_stock_for_subcontracting() - - self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() self.update_pick_list_status() @@ -453,28 +328,21 @@ class StockEntry(StockController, SubcontractingInwardController): self.repost_future_sle_and_gle() self.update_cost_in_project() - self.update_transferred_qty() self.update_quality_inspection() - - if self.purpose == "Material Transfer" and self.add_to_transit: - self.set_material_request_transfer_status("In Transit") - if self.purpose == "Material Transfer" and self.outgoing_stock_entry: - self.set_material_request_transfer_status("Completed") - super().on_submit_subcontracting_inward() def on_cancel(self): + if self.purpose_cls and hasattr(self.purpose_cls, "on_cancel"): + self.purpose_cls(self).on_cancel() + self.delink_asset_repair_sabb() self.validate_closed_subcontracting_order() - self.update_subcontract_order_supplied_items() self.update_subcontracting_order_status() self.cancel_stock_reserve_for_wip_and_fg() if self.work_order and self.purpose == "Material Consumption for Manufacture": self.validate_work_order_status() - self.update_work_order() - self.update_disassembled_order() self.cancel_stock_reservation_entries_for_inward() self.update_stock_ledger() @@ -488,34 +356,17 @@ class StockEntry(StockController, SubcontractingInwardController): self.make_gl_entries_on_cancel() self.repost_future_sle_and_gle() self.update_cost_in_project() - self.update_transferred_qty() self.update_quality_inspection() self.adjust_stock_reservation_entries_for_return() self.update_stock_reservation_entries() self.delete_auto_created_batches() self.delete_linked_stock_entry() - - if self.purpose == "Material Transfer" and self.add_to_transit: - self.set_material_request_transfer_status("Not Started") - if self.purpose == "Material Transfer" and self.outgoing_stock_entry: - self.set_material_request_transfer_status("In Transit") - super().on_cancel_subcontracting_inward() def on_update(self): super().on_update() self.set_serial_and_batch_bundle() - def set_job_card_data(self): - if self.job_card and not self.work_order: - data = frappe.db.get_value( - "Job Card", self.job_card, ["for_quantity", "work_order", "bom_no", "semi_fg_bom"], as_dict=1 - ) - self.fg_completed_qty = data.for_quantity - self.work_order = data.work_order - self.from_bom = 1 - self.bom_no = data.semi_fg_bom or data.bom_no - def validate_job_card_fg_item(self): if not self.job_card: return @@ -526,9 +377,7 @@ class StockEntry(StockController, SubcontractingInwardController): for row in self.items: if row.is_finished_item and row.item_code != job_card.finished_good: - frappe.throw( - _("Row #{0}: Finished Good must be {1}").format(row.idx, job_card.fininshed_good) - ) + frappe.throw(_("Row #{0}: Finished Good must be {1}").format(row.idx, job_card.finished_good)) def validate_job_card_item(self): if not self.job_card or self.purpose == "Manufacture": @@ -553,28 +402,6 @@ class StockEntry(StockController, SubcontractingInwardController): if pro_doc.status == "Completed": frappe.throw(_("Cannot cancel transaction for Completed Work Order.")) - def validate_purpose(self): - valid_purposes = [ - "Material Issue", - "Material Receipt", - "Material Transfer", - "Material Transfer for Manufacture", - "Manufacture", - "Repack", - "Send to Subcontractor", - "Material Consumption for Manufacture", - "Disassemble", - "Receive from Customer", - "Return Raw Material to Customer", - "Subcontracting Delivery", - "Subcontracting Return", - ] - - if self.purpose not in valid_purposes: - frappe.throw(_("Purpose must be one of {0}").format(comma_or(valid_purposes))) - - super().validate_purpose() - def delete_linked_stock_entry(self): if self.purpose == "Send to Warehouse": for d in frappe.get_all( @@ -592,34 +419,12 @@ class StockEntry(StockController, SubcontractingInwardController): return for row in self.items: - if row.serial_and_batch_bundle: - voucher_detail_no = frappe.db.get_value( - "Asset Repair Consumed Item", - {"parent": self.asset_repair, "serial_and_batch_bundle": row.serial_and_batch_bundle}, - "name", - ) - - doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle) - doc.db_set( - { - "voucher_type": "Asset Repair", - "voucher_no": self.asset_repair, - "voucher_detail_no": voucher_detail_no, - } - ) + row.delink_asset_repair_sabb(self.asset_repair) def set_transfer_qty(self): self.validate_qty_is_not_zero() for item in self.get("items"): - if not flt(item.conversion_factor): - frappe.throw(_("Row {0}: UOM Conversion Factor is mandatory").format(item.idx)) - item.transfer_qty = flt( - flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) - ) - if not flt(item.transfer_qty): - frappe.throw( - _("Row {0}: Qty in Stock UOM can not be zero.").format(item.idx), title=_("Zero quantity") - ) + item.set_transfer_qty() def update_cost_in_project(self): if self.work_order and not frappe.db.get_value( @@ -629,49 +434,12 @@ class StockEntry(StockController, SubcontractingInwardController): projects = set(item.project for item in self.items if item.project) for project in projects: - amount = frappe.db.sql( - """ select ifnull(sum(amount), 0) - from - `tabStock Entry Detail` - where - docstatus = 1 and project = %s - and (t_warehouse is null or t_warehouse = '')""", - project, - as_list=1, - ) - - amount = amount[0][0] if amount else 0 - additional_costs = frappe.db.sql( - """ select ifnull(sum(sed.base_amount), 0) - from - `tabStock Entry` se, `tabLanded Cost Taxes and Charges` sed - where - se.docstatus = 1 and se.project = %s and sed.parent = se.name - and se.purpose = 'Manufacture'""", - project, - as_list=1, - ) - - additional_cost_amt = additional_costs[0][0] if additional_costs else 0 - - amount += additional_cost_amt - project = frappe.get_doc("Project", project) - project.total_consumed_material_cost = amount - project.save() + project_doc = frappe.get_doc("Project", project) + project_doc.set_consumed_material_cost() + project_doc.save() def validate_item(self): - stock_items = self.get_stock_items() for item in self.get("items"): - if flt(item.qty) and flt(item.qty) < 0: - frappe.throw( - _("Row {0}: The item {1}, quantity must be positive number").format( - item.idx, frappe.bold(item.item_code) - ) - ) - - if item.item_code not in stock_items: - frappe.throw(_("{0} is not a stock Item").format(item.item_code)) - item_details = self.get_item_details( frappe._dict( { @@ -686,215 +454,49 @@ class StockEntry(StockController, SubcontractingInwardController): for_update=True, ) - reset_fields = ("stock_uom", "item_name") - for field in reset_fields: - item.set(field, item_details.get(field)) - - update_fields = ( - "uom", - "description", - "expense_account", - "cost_center", - "conversion_factor", - "barcode", - ) - - for field in update_fields: - if not item.get(field): - item.set(field, item_details.get(field)) - if field == "conversion_factor" and item.uom == item_details.get("stock_uom"): - item.set(field, item_details.get(field)) - - if not item.transfer_qty and item.qty: - item.transfer_qty = flt( - flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) - ) - - if self.purpose == "Subcontracting Delivery": - item.expense_account = frappe.get_value("Company", self.company, "default_expense_account") + item.validate_and_update_item_details(item_details, self.company, self.purpose) def validate_fg_completed_qty(self): if self.purpose != "Manufacture" or not self.from_bom: return + fg_qty = self._aggregate_fg_qty() + if fg_qty: + self._check_process_loss_qty(fg_qty) + def _aggregate_fg_qty(self): fg_qty = defaultdict(float) for d in self.items: if d.is_finished_item: fg_qty[d.item_code] += flt(d.qty) + return fg_qty - if not fg_qty: - return - + def _check_process_loss_qty(self, fg_qty): precision = frappe.get_precision("Stock Entry Detail", "qty") fg_item = next(iter(fg_qty.keys())) fg_item_qty = flt(fg_qty[fg_item], precision) fg_completed_qty = flt(self.fg_completed_qty, precision) - for d in self.items: - if not fg_qty.get(d.item_code): - continue + if fg_qty.get(d.item_code): + self._validate_fg_qty_with_process_loss(d, fg_item_qty, fg_completed_qty, precision) - if (fg_completed_qty - fg_item_qty) > 0: - self.process_loss_qty = fg_completed_qty - fg_item_qty - - if not self.process_loss_qty: - continue - - if fg_completed_qty != (flt(fg_item_qty, precision) + flt(self.process_loss_qty, precision)): - frappe.throw( - _( - "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table." - ).format(frappe.bold(self.process_loss_qty), frappe.bold(d.item_code)) - ) + def _validate_fg_qty_with_process_loss(self, d, fg_item_qty, fg_completed_qty, precision): + if (fg_completed_qty - fg_item_qty) > 0: + self.process_loss_qty = fg_completed_qty - fg_item_qty + if not self.process_loss_qty: + return + if fg_completed_qty != (flt(fg_item_qty, precision) + flt(self.process_loss_qty, precision)): + frappe.throw( + _( + "Since there is a process loss of {0} units for the finished good {1}, you should reduce the quantity by {0} units for the finished good {1} in the Items Table." + ).format(frappe.bold(self.process_loss_qty), frappe.bold(d.item_code)) + ) def validate_difference_account(self): if not cint(erpnext.is_perpetual_inventory_enabled(self.company)): return for d in self.get("items"): - if not d.expense_account: - frappe.throw( - _( - "Please enter Difference Account or set default Stock Adjustment Account for company {0}" - ).format(frappe.bold(self.company)) - ) - - acc_details = frappe.get_cached_value( - "Account", - d.expense_account, - ["account_type", "report_type"], - as_dict=True, - ) - - if self.is_opening == "Yes" and acc_details.report_type == "Profit and Loss": - frappe.throw( - _( - "Difference Account must be a Asset/Liability type account (Temporary Opening), since this Stock Entry is an Opening Entry" - ), - OpeningEntryAccountError, - ) - - if acc_details.account_type == "Stock": - frappe.throw( - _( - "At row #{0}: the Difference Account must not be a Stock type account, please change the Account Type for the account {1} or select a different account" - ).format(d.idx, get_link_to_form("Account", d.expense_account)), - title=_("Difference Account in Items Table"), - ) - - if ( - self.purpose not in ["Material Issue", "Subcontracting Delivery"] - and acc_details.account_type == "Cost of Goods Sold" - ): - frappe.msgprint( - _( - "At row #{0}: you have selected the Difference Account {1}, which is a Cost of Goods Sold type account. Please select a different account" - ).format(d.idx, bold(get_link_to_form("Account", d.expense_account))), - title=_("Cost of Goods Sold Account in Items Table"), - indicator="orange", - alert=1, - ) - - def validate_warehouse(self): - """perform various (sometimes conditional) validations on warehouse""" - - source_mandatory = [ - "Material Issue", - "Material Transfer", - "Send to Subcontractor", - "Material Transfer for Manufacture", - "Material Consumption for Manufacture", - "Return Raw Material to Customer", - "Subcontracting Delivery", - ] - - target_mandatory = [ - "Material Receipt", - "Material Transfer", - "Send to Subcontractor", - "Material Transfer for Manufacture", - "Receive from Customer", - "Subcontracting Return", - ] - - has_bom = any([d.bom_no for d in self.get("items")]) - - if self.purpose in source_mandatory and self.purpose not in target_mandatory: - self.to_warehouse = None - for d in self.get("items"): - d.t_warehouse = None - elif self.purpose in target_mandatory and self.purpose not in source_mandatory: - self.from_warehouse = None - for d in self.get("items"): - d.s_warehouse = None - - for d in self.get("items"): - if not d.s_warehouse and not d.t_warehouse: - d.s_warehouse = self.from_warehouse - d.t_warehouse = self.to_warehouse - - if self.purpose in source_mandatory and not d.s_warehouse: - if self.from_warehouse: - d.s_warehouse = self.from_warehouse - else: - frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) - - if self.purpose in target_mandatory and not d.t_warehouse: - if self.to_warehouse: - d.t_warehouse = self.to_warehouse - else: - frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) - - if self.purpose in ["Manufacture", "Repack"]: - if d.is_finished_item or d.type or d.is_legacy_scrap_item: - d.s_warehouse = None - if not d.t_warehouse: - frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) - else: - d.t_warehouse = None - if not d.s_warehouse: - frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) - - if self.purpose == "Disassemble": - if has_bom: - if d.is_finished_item or d.type or d.is_legacy_scrap_item: - d.t_warehouse = None - if not d.s_warehouse: - frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) - else: - d.s_warehouse = None - if not d.t_warehouse: - frappe.throw(_("Target warehouse is mandatory for row {0}").format(d.idx)) - - if cstr(d.s_warehouse) == cstr(d.t_warehouse) and self.purpose not in [ - "Material Transfer for Manufacture", - "Material Transfer", - ]: - frappe.throw(_("Source and target warehouse cannot be same for row {0}").format(d.idx)) - - if not (d.s_warehouse or d.t_warehouse): - frappe.throw(_("At least one warehouse is mandatory")) - - def validate_work_order(self): - if self.purpose in ( - "Manufacture", - "Material Transfer for Manufacture", - "Material Consumption for Manufacture", - "Disassemble", - ): - # check if work order is entered - - if ( - (self.purpose == "Manufacture" or self.purpose == "Material Consumption for Manufacture") - and self.work_order - and frappe.get_cached_value("Work Order", self.work_order, "track_semi_finished_goods") != 1 - ): - if not self.fg_completed_qty: - frappe.throw(_("For Quantity (Manufactured Qty) is mandatory")) - self.check_if_operations_completed() - self.check_duplicate_entry_for_work_order() - elif self.purpose != "Material Transfer": - self.work_order = None + d.validate_expense_account(self.is_opening, self.purpose) def validate_source_stock_entry(self): if not self.get("source_stock_entry"): @@ -910,261 +512,12 @@ class StockEntry(StockController, SubcontractingInwardController): title=_("Work Order Mismatch"), ) - from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty - - available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name) - - if flt(self.fg_completed_qty) > available_qty: - frappe.throw( - _( - "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble." - ).format( - self.fg_completed_qty, - self.source_stock_entry, - available_qty, - ), - title=_("Excess Disassembly"), - ) - - def check_if_operations_completed(self): - """Check if Time Sheets are completed against before manufacturing to capture operating costs.""" - prod_order = frappe.get_doc("Work Order", self.work_order) - allowance_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") - ) - - for d in prod_order.get("operations"): - total_completed_qty = flt(self.fg_completed_qty) + flt(prod_order.produced_qty) - completed_qty = ( - d.completed_qty + d.process_loss_qty + (allowance_percentage / 100 * d.completed_qty) - ) - if flt(total_completed_qty, self.precision("fg_completed_qty")) > flt( - completed_qty, self.precision("fg_completed_qty") - ): - job_card = frappe.db.get_value("Job Card", {"operation_id": d.name}, "name") - if not job_card: - frappe.throw( - _("Work Order {0}: Job Card not found for the operation {1}").format( - self.work_order, d.operation - ) - ) - - work_order_link = get_link_to_form("Work Order", self.work_order) - job_card_link = get_link_to_form("Job Card", job_card) - frappe.throw( - _( - "Row #{0}: Operation {1} is not completed for {2} qty of finished goods in Work Order {3}. Please update operation status via Job Card {4}." - ).format( - d.idx, - frappe.bold(d.operation), - frappe.bold(total_completed_qty), - work_order_link, - job_card_link, - ), - OperationsNotCompleteError, - ) - - def check_duplicate_entry_for_work_order(self): - other_ste = [ - t[0] - for t in frappe.db.get_values( - "Stock Entry", - { - "work_order": self.work_order, - "purpose": self.purpose, - "docstatus": ["!=", 2], - "name": ["!=", self.name], - }, - "name", - ) - ] - - if other_ste: - production_item, qty = frappe.db.get_value( - "Work Order", self.work_order, ["production_item", "qty"] - ) - args = [*other_ste, production_item] - fg_qty_already_entered = frappe.db.sql( - """select sum(transfer_qty) - from `tabStock Entry Detail` - where parent in ({}) - and item_code = {} - and ifnull(s_warehouse,'')='' """.format(", ".join(["%s" * len(other_ste)]), "%s"), - args, - )[0][0] - if fg_qty_already_entered and fg_qty_already_entered >= qty: - frappe.throw( - _("Stock Entries already created for Work Order {0}: {1}").format( - self.work_order, ", ".join(other_ste) - ), - DuplicateEntryForWorkOrderError, - ) - def set_actual_qty(self): - from erpnext.stock.stock_ledger import is_negative_stock_allowed - for d in self.get("items"): - allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code) - previous_sle = get_previous_sle( - { - "item_code": d.item_code, - "warehouse": d.s_warehouse or d.t_warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - } - ) - - # get actual stock at source warehouse - d.actual_qty = previous_sle.get("qty_after_transaction") or 0 - - # validate qty during submit - if ( - d.docstatus == 1 - and d.s_warehouse - and not allow_negative_stock - and flt(d.actual_qty, d.precision("actual_qty")) - < flt(d.transfer_qty, d.precision("actual_qty")) - ): - frappe.throw( - _( - "Row {0}: Quantity not available for {4} in warehouse {1} at posting time of the entry ({2} {3})" - ).format( - d.idx, - frappe.bold(d.s_warehouse), - formatdate(self.posting_date), - format_time(self.posting_time), - frappe.bold(d.item_code), - ) - + "

" - + _("Available quantity is {0}, you need {1}").format( - frappe.bold(flt(d.actual_qty, d.precision("actual_qty"))), frappe.bold(d.transfer_qty) - ), - NegativeStockError, - title=_("Insufficient Stock"), - ) - - def validate_component_and_quantities(self): - if self.purpose not in ["Manufacture", "Material Transfer for Manufacture"]: - return - - if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): - return - - if not self.fg_completed_qty: - return - - raw_materials = self.get_bom_raw_materials(self.fg_completed_qty) - - precision = frappe.get_precision("Stock Entry Detail", "qty") - for item_code, details in raw_materials.items(): - item_code = item_code[0] if type(item_code) == tuple else item_code - if matched_item := self.get_matched_items(item_code): - if flt(details.get("qty"), precision) != flt(matched_item.qty, precision): - frappe.throw( - _( - "For the item {0}, the consumed quantity should be {1} according to the BOM {2}." - ).format( - frappe.bold(item_code), - flt(details.get("qty")), - get_link_to_form("BOM", self.bom_no), - ), - title=_("Incorrect Component Quantity"), - ) - else: - frappe.throw( - _("According to the BOM {0}, the Item '{1}' is missing in the stock entry.").format( - get_link_to_form("BOM", self.bom_no), frappe.bold(item_code) - ), - title=_("Missing Item"), - ) - - def validate_same_source_target_warehouse_during_material_transfer(self): - """ - Validate Material Transfer entries where source and target warehouses are identical. - - For Material Transfer purpose, if an item has the same source and target warehouse, - require that at least one inventory dimension (if configured) differs between source - and target to ensure a meaningful transfer is occurring. - - Raises: - frappe.ValidationError: If warehouses are same and no inventory dimensions differ - """ - - if frappe.get_single_value("Stock Settings", "validate_material_transfer_warehouses"): - from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions - - inventory_dimensions = get_inventory_dimensions() - if self.purpose == "Material Transfer": - for item in self.items: - if cstr(item.s_warehouse) == cstr(item.t_warehouse): - if not inventory_dimensions: - frappe.throw( - _( - "Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer" - ).format(item.idx), - title=_("Invalid Source and Target Warehouse"), - ) - else: - difference_found = False - for dimension in inventory_dimensions: - fieldname = ( - dimension.source_fieldname - if dimension.source_fieldname.startswith("to_") - else f"to_{dimension.source_fieldname}" - ) - if ( - item.get(dimension.source_fieldname) - and item.get(fieldname) - and item.get(dimension.source_fieldname) != item.get(fieldname) - ): - difference_found = True - break - if not difference_found: - frappe.throw( - _( - "Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer" - ).format(item.idx), - title=_("Invalid Source and Target Warehouse"), - ) - - def get_matched_items(self, item_code): - items = [item for item in self.items if item.s_warehouse] - for row in items or self.get_consumed_items(): - if row.item_code == item_code or row.original_item == item_code: - return row - - return {} - - def get_consumed_items(self): - """Get all raw materials consumed through consumption entries""" - parent = frappe.qb.DocType("Stock Entry") - child = frappe.qb.DocType("Stock Entry Detail") - - query = ( - frappe.qb.from_(parent) - .join(child) - .on(parent.name == child.parent) - .select( - child.item_code, - Sum(child.qty).as_("qty"), - child.original_item, - ) - .where( - (parent.docstatus == 1) - & (parent.purpose == "Material Consumption for Manufacture") - & (parent.work_order == self.work_order) - ) - .groupby(child.item_code, child.original_item) - ) - - return query.run(as_dict=True) + d.set_actual_qty(self.posting_date, self.posting_time) @frappe.whitelist() def get_stock_and_rate(self): - """ - Updates rate and availability of all the items. - Called from Update Rate and Availability button. - """ self.set_work_order_details() self.set_transfer_qty() self.set_actual_qty() @@ -1179,71 +532,66 @@ class StockEntry(StockController, SubcontractingInwardController): self.set_total_amount() def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): - """ - Set rate for outgoing, secondary and finished items - """ - # Set rate for outgoing items + """Set rate for outgoing, secondary and finished items.""" outgoing_items_cost = self.set_rate_for_outgoing_items(reset_outgoing_rate, raise_error_if_no_rate) + raise_error_if_no_rate = raise_error_if_no_rate and not self.is_new() - items = [] - # Set basic rate for incoming items + zero_valuation_items = [] for d in self.get("items"): if d.s_warehouse or d.set_basic_rate_manually: continue + self._set_incoming_item_rate(d, outgoing_items_cost, raise_error_if_no_rate, zero_valuation_items) - if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": - d.basic_rate = 0.0 - items.append(d.item_code) - elif d.is_finished_item: - if self.purpose == "Manufacture": - d.basic_rate = self.get_basic_rate_for_manufactured_item( - d.transfer_qty, outgoing_items_cost - ) - elif self.purpose == "Repack": - d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) + if zero_valuation_items: + self._notify_zero_valuation_rate(zero_valuation_items) - if self.bom_no: - d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100 - elif d.type and d.bom_secondary_item: - cost_allocation_per = frappe.get_value( - "BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per" - ) - d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty + def _set_incoming_item_rate(self, d, outgoing_items_cost, raise_error_if_no_rate, zero_valuation_items): + if d.allow_zero_valuation_rate and d.basic_rate and self.purpose != "Receive from Customer": + d.basic_rate = 0.0 + zero_valuation_items.append(d.item_code) + elif d.is_finished_item: + if self.purpose == "Manufacture": + d.basic_rate = self.get_basic_rate_for_manufactured_item(d.transfer_qty, outgoing_items_cost) + elif self.purpose == "Repack": + d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) - if not d.basic_rate and not d.allow_zero_valuation_rate: - if self.is_new(): - raise_error_if_no_rate = False + if self.bom_no: + d.basic_rate *= frappe.get_value("BOM", self.bom_no, "cost_allocation_per") / 100 + elif d.secondary_item_type and d.bom_secondary_item: + cost_allocation_per = frappe.get_value( + "BOM Secondary Item", d.bom_secondary_item, "cost_allocation_per" + ) + d.basic_rate = (outgoing_items_cost * (cost_allocation_per / 100)) / d.transfer_qty - d.basic_rate = get_valuation_rate( - d.item_code, - d.t_warehouse, - self.doctype, - self.name, - d.allow_zero_valuation_rate, - currency=erpnext.get_company_currency(self.company), - company=self.company, - raise_error_if_no_rate=raise_error_if_no_rate, - batch_no=d.batch_no, - serial_and_batch_bundle=d.serial_and_batch_bundle, - ) + if not d.basic_rate and not d.allow_zero_valuation_rate: + d.basic_rate = get_valuation_rate( + d.item_code, + d.t_warehouse, + self.doctype, + self.name, + d.allow_zero_valuation_rate, + currency=erpnext.get_company_currency(self.company), + company=self.company, + raise_error_if_no_rate=raise_error_if_no_rate, + batch_no=d.batch_no, + serial_and_batch_bundle=d.serial_and_batch_bundle, + ) - # do not round off basic rate to avoid precision loss - d.basic_rate = flt(d.basic_rate) - d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) + # do not round off basic rate to avoid precision loss + d.basic_rate = flt(d.basic_rate) + d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) - if items: - message = "" + def _notify_zero_valuation_rate(self, items): + if len(items) > 1: + message = _( + "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}" + ).format(", ".join(frappe.bold(item) for item in items)) + else: + message = _( + "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}" + ).format(frappe.bold(items[0])) - if len(items) > 1: - message = _( - "Items rate has been updated to zero as Allow Zero Valuation Rate is checked for the following items: {0}" - ).format(", ".join(frappe.bold(item) for item in items)) - else: - message = _( - "Item rate has been updated to zero as Allow Zero Valuation Rate is checked for item {0}" - ).format(frappe.bold(items[0])) - - frappe.msgprint(message, alert=True) + frappe.msgprint(message, alert=True) def set_rate_for_outgoing_items(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): outgoing_items_cost = 0.0 @@ -1281,13 +629,21 @@ class StockEntry(StockController, SubcontractingInwardController): ) def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost): - finished_items = [d.item_code for d in self.get("items") if d.is_finished_item] + finished_items = [ + d.item_code for d in self.get("items") if d.is_finished_item and not d.set_basic_rate_manually + ] if len(finished_items) == 1: return flt(outgoing_items_cost / finished_item_qty) else: unique_finished_items = set(finished_items) - if len(unique_finished_items) == 1: - total_fg_qty = sum([flt(d.transfer_qty) for d in self.items if d.is_finished_item]) + if unique_finished_items: + total_fg_qty = sum( + [ + flt(d.transfer_qty) + for d in self.items + if d.is_finished_item and not d.set_basic_rate_manually + ] + ) return flt(outgoing_items_cost / total_fg_qty) def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float: @@ -1295,68 +651,78 @@ class StockEntry(StockController, SubcontractingInwardController): scrap_items_cost = sum([flt(d.basic_amount) for d in self.get("items") if d.is_legacy_scrap_item]) if settings.material_consumption: - if settings.get_rm_cost_from_consumption_entry and self.work_order: - # Validate only if Material Consumption Entry exists for the Work Order. - if frappe.db.exists( - "Stock Entry", - { - "docstatus": 1, - "work_order": self.work_order, - "purpose": "Material Consumption for Manufacture", - }, - ): - for item in self.items: - if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item: - label = frappe.get_meta(settings.doctype).get_label( - "get_rm_cost_from_consumption_entry" - ) - frappe.throw( - _( - "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials." - ).format( - item.idx, - frappe.bold(label), - frappe.bold(_("Manufacture")), - frappe.bold(_("Material Consumption for Manufacture")), - ) - ) - - if frappe.db.exists( - "Stock Entry", - { - "docstatus": 1, - "work_order": self.work_order, - "purpose": "Manufacture", - "name": ("!=", self.name), - }, - ): - frappe.throw( - _("Only one {0} entry can be created against the Work Order {1}").format( - frappe.bold(_("Manufacture")), frappe.bold(self.work_order) - ) - ) - - SE = frappe.qb.DocType("Stock Entry") - SE_ITEM = frappe.qb.DocType("Stock Entry Detail") - - outgoing_items_cost = ( - frappe.qb.from_(SE) - .left_join(SE_ITEM) - .on(SE.name == SE_ITEM.parent) - .select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty)) - .where( - (SE.docstatus == 1) - & (SE.work_order == self.work_order) - & (SE.purpose == "Material Consumption for Manufacture") - ) - ).run()[0][0] or 0 - - elif not outgoing_items_cost: - bom_items = self.get_bom_raw_materials(finished_item_qty) - outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()]) + outgoing_items_cost = self._get_rm_cost_for_manufacture( + settings, finished_item_qty, outgoing_items_cost + ) return flt((outgoing_items_cost - scrap_items_cost) / finished_item_qty) + def _get_rm_cost_for_manufacture(self, settings, finished_item_qty, outgoing_items_cost): + if settings.get_rm_cost_from_consumption_entry and self.work_order: + if frappe.db.exists( + "Stock Entry", + { + "docstatus": 1, + "work_order": self.work_order, + "purpose": "Material Consumption for Manufacture", + }, + ): + self._validate_no_raw_materials_in_manufacture_entry(settings) + self._validate_single_manufacture_entry() + return self._fetch_consumption_entry_cost() + elif not outgoing_items_cost: + bom_items = self.get_bom_raw_materials(finished_item_qty) + outgoing_items_cost = sum([flt(row.qty) * flt(row.rate) for row in bom_items.values()]) + + return outgoing_items_cost + + def _validate_no_raw_materials_in_manufacture_entry(self, settings): + for item in self.items: + if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item: + label = frappe.get_meta(settings.doctype).get_label("get_rm_cost_from_consumption_entry") + frappe.throw( + _( + "Row {0}: As {1} is enabled, raw materials cannot be added to {2} entry. Use {3} entry to consume raw materials." + ).format( + item.idx, + frappe.bold(label), + frappe.bold(_("Manufacture")), + frappe.bold(_("Material Consumption for Manufacture")), + ) + ) + + def _validate_single_manufacture_entry(self): + if frappe.db.exists( + "Stock Entry", + { + "docstatus": 1, + "work_order": self.work_order, + "purpose": "Manufacture", + "name": ("!=", self.name), + }, + ): + frappe.throw( + _("Only one {0} entry can be created against the Work Order {1}").format( + frappe.bold(_("Manufacture")), frappe.bold(self.work_order) + ) + ) + + def _fetch_consumption_entry_cost(self): + SE = frappe.qb.DocType("Stock Entry") + SE_ITEM = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(SE) + .left_join(SE_ITEM) + .on(SE.name == SE_ITEM.parent) + .select(Sum(SE_ITEM.valuation_rate * SE_ITEM.transfer_qty)) + .where( + (SE.docstatus == 1) + & (SE.work_order == self.work_order) + & (SE.purpose == "Material Consumption for Manufacture") + ) + ).run()[0][0] or 0 + def distribute_additional_costs(self): # If no incoming items, set additional costs blank if not any(d.item_code for d in self.items if d.t_warehouse): @@ -1421,266 +787,6 @@ class StockEntry(StockController, SubcontractingInwardController): if self.stock_entry_type and not self.purpose: self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose") - def make_serial_and_batch_bundle_for_outward(self): - serial_or_batch_items = get_serial_or_batch_items(self.items) - if not serial_or_batch_items: - return - - serial_nos, batch_nos = self.set_serial_batch_fields_for_subcontracting_inward() - - if self.docstatus == 0: - return - - already_picked_serial_nos = [] - - for row in self.items: - if row.use_serial_batch_fields: - continue - - if not row.s_warehouse: - continue - - if row.item_code not in serial_or_batch_items: - continue - - bundle_doc = None - if row.serial_and_batch_bundle and abs(row.transfer_qty) != abs( - frappe.get_cached_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty") - ): - bundle_doc = SerialBatchCreation( - { - "item_code": row.item_code, - "warehouse": row.s_warehouse, - "serial_and_batch_bundle": row.serial_and_batch_bundle, - "type_of_transaction": "Outward", - "ignore_serial_nos": already_picked_serial_nos, - "qty": row.transfer_qty * -1, - } - ).update_serial_and_batch_entries( - serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name) - ) - elif not row.serial_and_batch_bundle and frappe.get_single_value( - "Stock Settings", "auto_create_serial_and_batch_bundle_for_outward" - ): - bundle_doc = SerialBatchCreation( - { - "item_code": row.item_code, - "warehouse": row.s_warehouse, - "posting_datetime": get_combine_datetime(self.posting_date, self.posting_time), - "voucher_type": self.doctype, - "voucher_detail_no": row.name, - "qty": row.transfer_qty * -1, - "ignore_serial_nos": already_picked_serial_nos, - "type_of_transaction": "Outward", - "company": self.company, - "do_not_submit": True, - } - ).make_serial_and_batch_bundle( - serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name) - ) - - if not bundle_doc: - continue - - for entry in bundle_doc.entries: - if not entry.serial_no: - continue - - already_picked_serial_nos.append(entry.serial_no) - - row.serial_and_batch_bundle = bundle_doc.name - - def set_serial_batch_fields_for_subcontracting_inward(self): - serial_nos, batch_nos = frappe._dict(), frappe._dict() - for row in self.items: - if self.purpose in [ - "Return Raw Material to Customer", - "Subcontracting Delivery", - "Subcontracting Return", - ]: - if not row.serial_and_batch_bundle: - serial_nos_list, batch_nos_list = self.get_serial_nos_and_batches_from_sres( - row.scio_detail, only_pending=self.purpose != "Subcontracting Return" - ) - - if len(batch_nos_list) > 1: - row.use_serial_batch_fields = 0 - - if row.use_serial_batch_fields: - if serial_nos_list and not row.serial_no: - row.serial_no = "\n".join(serial_nos_list) - if batch_nos_list and not row.batch_no: - row.batch_no = next(iter(batch_nos_list.keys())) - - serial_nos[row.name], batch_nos[row.name] = serial_nos_list, batch_nos_list - - return serial_nos, batch_nos - - def validate_subcontract_order(self): - """Throw exception if more raw material is transferred against Subcontract Order than in - the raw materials supplied table""" - backflush_raw_materials_based_on = frappe.db.get_single_value( - "Buying Settings", "backflush_raw_materials_of_subcontract_based_on" - ) - - qty_allowance = flt(frappe.db.get_single_value("Buying Settings", "over_transfer_allowance")) - - if not (self.purpose == "Send to Subcontractor" and self.get(self.subcontract_data.order_field)): - return - - if backflush_raw_materials_based_on == "BOM": - subcontract_order = frappe.get_doc( - self.subcontract_data.order_doctype, self.get(self.subcontract_data.order_field) - ) - for se_item in self.items: - item_code = se_item.original_item or se_item.item_code - precision = cint(frappe.db.get_default("float_precision")) or 3 - required_qty = sum( - [ - flt(d.required_qty) - for d in subcontract_order.supplied_items - if d.rm_item_code == item_code - ] - ) - - total_allowed = required_qty + (required_qty * (qty_allowance / 100)) - - if not required_qty: - frappe.db.get_value( - f"{self.subcontract_data.order_doctype} Item", - { - "parent": self.get(self.subcontract_data.order_field), - "item_code": se_item.subcontracted_item, - }, - "bom", - ) - - if se_item.allow_alternative_item: - original_item_code = frappe.get_value( - "Item Alternative", {"alternative_item_code": item_code}, "item_code" - ) - - required_qty = sum( - [ - flt(d.required_qty) - for d in subcontract_order.supplied_items - if d.rm_item_code == original_item_code - ] - ) - - total_allowed = required_qty + (required_qty * (qty_allowance / 100)) - - if not required_qty: - frappe.throw( - _("Item {0} not found in 'Raw Materials Supplied' table in {1} {2}").format( - se_item.item_code, - self.subcontract_data.order_doctype, - self.get(self.subcontract_data.order_field), - ) - ) - - se = frappe.qb.DocType("Stock Entry") - se_detail = frappe.qb.DocType("Stock Entry Detail") - - total_supplied = ( - frappe.qb.from_(se) - .inner_join(se_detail) - .on(se.name == se_detail.parent) - .select(Sum(se_detail.transfer_qty)) - .where( - (se.purpose == "Send to Subcontractor") - & (se.docstatus == 1) - & (se_detail.item_code == se_item.item_code) - & ( - ( - (se.purchase_order == self.purchase_order) - & (se_detail.po_detail == se_item.po_detail) - ) - if self.subcontract_data.order_doctype == "Purchase Order" - else ( - (se.subcontracting_order == self.subcontracting_order) - & (se_detail.sco_rm_detail == se_item.sco_rm_detail) - ) - ) - ) - ).run()[0][0] or 0 - - total_returned = 0 - if self.subcontract_data.order_doctype == "Subcontracting Order": - total_returned = ( - frappe.qb.from_(se) - .inner_join(se_detail) - .on(se.name == se_detail.parent) - .select(Sum(se_detail.transfer_qty)) - .where( - (se.purpose == "Material Transfer") - & (se.docstatus == 1) - & (se.is_return == 1) - & (se_detail.item_code == se_item.item_code) - & (se_detail.sco_rm_detail == se_item.sco_rm_detail) - & (se.subcontracting_order == self.subcontracting_order) - ) - ).run()[0][0] or 0 - - if flt(total_supplied + se_item.transfer_qty - total_returned, precision) > flt( - total_allowed, precision - ): - frappe.throw( - _("Row #{0}: Item {1} cannot be transferred more than {2} against {3} {4}").format( - se_item.idx, - se_item.item_code, - total_allowed, - self.subcontract_data.order_doctype, - self.get(self.subcontract_data.order_field), - ) - ) - elif not se_item.get(self.subcontract_data.rm_detail_field): - filters = { - "parent": self.get(self.subcontract_data.order_field), - "docstatus": 1, - "rm_item_code": se_item.item_code, - "main_item_code": se_item.subcontracted_item, - } - - order_rm_detail = frappe.db.get_value( - self.subcontract_data.order_supplied_items_field, filters, "name" - ) - if order_rm_detail: - se_item.db_set(self.subcontract_data.rm_detail_field, order_rm_detail) - else: - if not se_item.allow_alternative_item: - frappe.throw( - _( - "Row {0}# Item {1} not found in 'Raw Materials Supplied' table in {2} {3}" - ).format( - se_item.idx, - se_item.item_code, - self.subcontract_data.order_doctype, - self.get(self.subcontract_data.order_field), - ) - ) - elif backflush_raw_materials_based_on == "Material Transferred for Subcontract": - for row in self.items: - if not row.subcontracted_item: - frappe.throw( - _("Row {0}: Subcontracted Item is mandatory for the raw material {1}").format( - row.idx, frappe.bold(row.item_code) - ) - ) - elif not row.get(self.subcontract_data.rm_detail_field): - filters = { - "parent": self.get(self.subcontract_data.order_field), - "docstatus": 1, - "rm_item_code": row.item_code, - "main_item_code": row.subcontracted_item, - } - - order_rm_detail = frappe.db.get_value( - self.subcontract_data.order_supplied_items_field, filters, "name" - ) - if order_rm_detail: - row.db_set(self.subcontract_data.rm_detail_field, order_rm_detail) - def validate_bom(self): for d in self.get("items"): if d.bom_no and d.is_finished_item: @@ -1714,7 +820,7 @@ class StockEntry(StockController, SubcontractingInwardController): d.is_finished_item = 1 else: d.is_finished_item = 0 - d.type = "" + d.secondary_item_type = "" def get_finished_item(self): finished_item = None @@ -1937,11 +1043,20 @@ class StockEntry(StockController, SubcontractingInwardController): total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) divide_based_on = total_basic_amount - if self.get("additional_costs") and not total_basic_amount: - # if total_basic_amount is 0, distribute additional charges based on qty - divide_based_on = sum(item.qty for item in list(self.get("items"))) + divide_based_on = sum(item.qty for item in self.get("items")) + item_account_wise_additional_cost = self._build_additional_cost_per_item_account( + total_basic_amount, divide_based_on + ) + + if item_account_wise_additional_cost: + self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost) + + self.set_gl_entries_for_landed_cost_voucher(gl_entries, inventory_account_map) + return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + + def _build_additional_cost_per_item_account(self, total_basic_amount, divide_based_on): item_account_wise_additional_cost = {} for t in self.get("additional_costs"): @@ -1957,56 +1072,44 @@ class StockEntry(StockController, SubcontractingInwardController): ) multiply_based_on = d.basic_amount if total_basic_amount else d.qty + entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account] + entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on + entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on - item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += ( - flt(t.amount * multiply_based_on) / divide_based_on + return item_account_wise_additional_cost + + def _append_additional_cost_gl_entries(self, gl_entries, item_account_wise_additional_cost): + for d in self.get("items"): + for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items(): + if not amount: + continue + + gl_entries.append( + self.get_gl_dict( + { + "account": account, + "against": d.expense_account, + "cost_center": d.cost_center, + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "credit_in_account_currency": flt(amount["amount"]), + "credit": flt(amount["base_amount"]), + }, + item=d, + ) ) - item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account][ - "base_amount" - ] += flt(t.base_amount * multiply_based_on) / divide_based_on - - if item_account_wise_additional_cost: - for d in self.get("items"): - for account, amount in item_account_wise_additional_cost.get( - (d.item_code, d.name), {} - ).items(): - if not amount: - continue - - gl_entries.append( - self.get_gl_dict( - { - "account": account, - "against": d.expense_account, - "cost_center": d.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit_in_account_currency": flt(amount["amount"]), - "credit": flt(amount["base_amount"]), - }, - item=d, - ) + gl_entries.append( + self.get_gl_dict( + { + "account": d.expense_account, + "against": account, + "cost_center": d.cost_center, + "remarks": self.get("remarks") or _("Accounting Entry for Stock"), + "credit": -1 * amount["base_amount"], # negative credit instead of debit + }, + item=d, ) - - gl_entries.append( - self.get_gl_dict( - { - "account": d.expense_account, - "against": account, - "cost_center": d.cost_center, - "remarks": self.get("remarks") or _("Accounting Entry for Stock"), - "credit": -1 - * amount[ - "base_amount" - ], # put it as negative credit instead of debit purposefully - }, - item=d, - ) - ) - - self.set_gl_entries_for_landed_cost_voucher(gl_entries, inventory_account_map) - - return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation) + ) def set_gl_entries_for_landed_cost_voucher(self, gl_entries, inventory_account_map): landed_cost_entries = self.get_item_account_wise_lcv_entries() @@ -2064,52 +1167,12 @@ class StockEntry(StockController, SubcontractingInwardController): ) ) - def update_work_order(self): - def _validate_work_order(pro_doc): - msg, title = "", "" - if flt(pro_doc.docstatus) != 1: - msg = f"Work Order {self.work_order} must be submitted" - - if pro_doc.status == "Stopped": - msg = f"Transaction not allowed against stopped Work Order {self.work_order}" - - if msg: - frappe.throw(_(msg), title=title) - - if self.job_card: - job_doc = frappe.get_doc("Job Card", self.job_card) - if self.purpose != "Manufacture": - job_doc.set_transferred_qty(update_status=True) - job_doc.set_transferred_qty_in_job_card_item(self) - else: - job_doc.set_consumed_qty_in_job_card_item(self) - job_doc.set_manufactured_qty() - job_doc.update_work_order() - - if self.work_order: - pro_doc = frappe.get_doc("Work Order", self.work_order) - _validate_work_order(pro_doc) - - if self.fg_completed_qty: - if self.docstatus == 1: - pro_doc.add_additional_items(self) - else: - pro_doc.remove_additional_items(self) - - pro_doc.run_method("update_work_order_qty") - if self.purpose == "Manufacture": - pro_doc.run_method("update_planned_qty") - - pro_doc.run_method("update_status") - if not pro_doc.operations: - pro_doc.set_actual_dates() - - def update_disassembled_order(self): - if not self.work_order: - return - if self.purpose == "Disassemble" and self.fg_completed_qty: - pro_doc = frappe.get_doc("Work Order", self.work_order) - pro_doc.run_method("update_disassembled_qty", self.fg_completed_qty, self._action == "cancel") + @property + def pro_doc(self): + if not getattr(self, "_wo_doc", None): + if self.work_order: + self._wo_doc = frappe.get_doc("Work Order", self.work_order) + return getattr(self, "_wo_doc", None) def make_stock_reserve_for_wip_and_fg(self): if self.is_stock_reserve_for_work_order(): @@ -2173,48 +1236,68 @@ class StockEntry(StockController, SubcontractingInwardController): @frappe.whitelist() def get_item_details(self, args: ItemDetailsCtx | None = None, for_update: bool = False): - item = frappe.qb.DocType("Item") + item = self._fetch_item_data(args) + item_group_defaults = get_item_group_defaults(item.name, self.company) + brand_defaults = get_brand_defaults(item.name, self.company) + + ret = self._build_item_ret(args, item, item_group_defaults, brand_defaults, for_update) + self._apply_account_defaults(ret) + + args["posting_date"] = self.posting_date + args["posting_time"] = self.posting_time + ret.update(get_warehouse_details(args) if args.get("warehouse") else {}) + + if self.purpose == "Send to Subcontractor": + self._resolve_subcontract_item(args, ret) + + barcode_data = get_barcode_data(item_code=item.name) + if barcode_data and len(barcode_data.get(item.name)) == 1: + ret["barcode"] = barcode_data.get(item.name)[0] + + return ret + + def _fetch_item_data(self, args): + item_dt = frappe.qb.DocType("Item") item_default = frappe.qb.DocType("Item Default") - query = ( - frappe.qb.from_(item) + result = ( + frappe.qb.from_(item_dt) .left_join(item_default) - .on((item.name == item_default.parent) & (item_default.company == self.company)) + .on((item_dt.name == item_default.parent) & (item_default.company == self.company)) .select( - item.name, - item.stock_uom, - item.description, - item.image, - item.item_name, - item.item_group, - item.has_batch_no, - item.sample_quantity, - item.has_serial_no, - item.allow_alternative_item, + item_dt.name, + item_dt.stock_uom, + item_dt.description, + item_dt.image, + item_dt.is_stock_item, + item_dt.item_name, + item_dt.item_group, + item_dt.has_batch_no, + item_dt.sample_quantity, + item_dt.has_serial_no, + item_dt.allow_alternative_item, item_default.expense_account, item_default.buying_cost_center, ) .where( - (item.name == args.get("item_code")) - & (item.disabled == 0) + (item_dt.name == args.get("item_code")) + & (item_dt.disabled == 0) & ( - (item.end_of_life.isnull()) - | (item.end_of_life < "1900-01-01") - | (item.end_of_life > nowdate()) + (item_dt.end_of_life.isnull()) + | (item_dt.end_of_life < "1900-01-01") + | (item_dt.end_of_life > nowdate()) ) ) - ) - item = query.run(as_dict=True) + ).run(as_dict=True) - if not item: + if not result: frappe.throw( _("Item {0} is not active or end of life has been reached").format(args.get("item_code")) ) - item = item[0] - item_group_defaults = get_item_group_defaults(item.name, self.company) - brand_defaults = get_brand_defaults(item.name, self.company) + return result[0] + def _build_item_ret(self, args, item, item_group_defaults, brand_defaults, for_update): ret = frappe._dict( { "uom": item.stock_uom, @@ -2234,19 +1317,22 @@ class StockEntry(StockController, SubcontractingInwardController): "has_batch_no": item.has_batch_no, "sample_quantity": item.sample_quantity, "expense_account": item.expense_account or item_group_defaults.get("expense_account"), + "is_stock_item": item.is_stock_item, } ) if self.purpose == "Send to Subcontractor": ret["allow_alternative_item"] = item.allow_alternative_item - # update uom if args.get("uom") and for_update: ret.update(get_uom_details(args.get("item_code"), args.get("uom"), args.get("qty"))) if self.purpose == "Material Issue": ret["expense_account"] = item.get("expense_account") or item_group_defaults.get("expense_account") + return ret + + def _apply_account_defaults(self, ret): if not ret.get("expense_account"): ret["expense_account"] = frappe.get_cached_value( "Company", self.company, "stock_adjustment_account" @@ -2259,34 +1345,21 @@ class StockEntry(StockController, SubcontractingInwardController): if not ret.get(field): ret[field] = frappe.get_cached_value("Company", self.company, company_field) - args["posting_date"] = self.posting_date - args["posting_time"] = self.posting_time + def _resolve_subcontract_item(self, args, ret): + if not (self.get(self.subcontract_data.order_field) and args.get("item_code")): + return - stock_and_rate = get_warehouse_details(args) if args.get("warehouse") else {} - ret.update(stock_and_rate) + subcontract_items = frappe.get_all( + self.subcontract_data.order_supplied_items_field, + { + "parent": self.get(self.subcontract_data.order_field), + "rm_item_code": args.get("item_code"), + }, + "main_item_code", + ) - if ( - self.purpose == "Send to Subcontractor" - and self.get(self.subcontract_data.order_field) - and args.get("item_code") - ): - subcontract_items = frappe.get_all( - self.subcontract_data.order_supplied_items_field, - { - "parent": self.get(self.subcontract_data.order_field), - "rm_item_code": args.get("item_code"), - }, - "main_item_code", - ) - - if subcontract_items and len(subcontract_items) == 1: - ret["subcontracted_item"] = subcontract_items[0].main_item_code - - barcode_data = get_barcode_data(item_code=item.name) - if barcode_data and len(barcode_data.get(item.name)) == 1: - ret["barcode"] = barcode_data.get(item.name)[0] - - return ret + if subcontract_items and len(subcontract_items) == 1: + ret["subcontracted_item"] = subcontract_items[0].main_item_code @frappe.whitelist() def set_items_for_stock_in(self): @@ -2313,475 +1386,19 @@ class StockEntry(StockController, SubcontractingInwardController): }, ) - def get_items_for_disassembly(self): - """Get items for Disassembly Order. - - Priority: - 1. From a specific Manufacture Stock Entry (exact reversal) - 2. From Work Order Manufacture Stock Entries (averaged reversal) - 3. From BOM (standalone disassembly) - """ - - # Auto-set source_stock_entry if WO has exactly one manufacture entry - if not self.get("source_stock_entry") and self.work_order: - manufacture_entries = frappe.get_all( - "Stock Entry", - filters={ - "work_order": self.work_order, - "purpose": "Manufacture", - "docstatus": 1, - }, - pluck="name", - limit_page_length=2, - ) - if len(manufacture_entries) == 1: - self.source_stock_entry = manufacture_entries[0] - - if self.get("source_stock_entry"): - return self._add_items_for_disassembly_from_stock_entry() - - if self.work_order: - return self._add_items_for_disassembly_from_work_order() - - return self._add_items_for_disassembly_from_bom() - - def _add_items_for_disassembly_from_stock_entry(self): - source_fg_qty = frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty") - if not source_fg_qty: - frappe.throw( - _("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry) - ) - - disassemble_qty = flt(self.fg_completed_qty) - scale_factor = disassemble_qty / flt(source_fg_qty) - - self._append_disassembly_row_from_source( - disassemble_qty=disassemble_qty, - scale_factor=scale_factor, - ) - - def _add_items_for_disassembly_from_work_order(self): - wo_produced_qty = frappe.db.get_value("Work Order", self.work_order, "produced_qty") - - wo_produced_qty = flt(wo_produced_qty) - if wo_produced_qty <= 0: - frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order)) - - disassemble_qty = flt(self.fg_completed_qty) - if disassemble_qty <= 0: - frappe.throw(_("Disassemble Qty cannot be less than or equal to 0.")) - - scale_factor = disassemble_qty / wo_produced_qty - - self._append_disassembly_row_from_source( - disassemble_qty=disassemble_qty, - scale_factor=scale_factor, - ) - - def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor): - for source_row in self.get_items_from_manufacture_stock_entry(): - if source_row.is_finished_item: - qty = disassemble_qty - s_warehouse = self.from_warehouse or source_row.t_warehouse - t_warehouse = "" - elif source_row.s_warehouse: - # RM: was consumed FROM s_warehouse -> return TO s_warehouse - qty = flt(source_row.qty * scale_factor) - s_warehouse = "" - t_warehouse = self.to_warehouse or source_row.s_warehouse - else: - # Scrap/secondary: was produced TO t_warehouse -> take FROM t_warehouse - qty = flt(source_row.qty * scale_factor) - s_warehouse = source_row.t_warehouse - t_warehouse = "" - - item = { - "item_code": source_row.item_code, - "item_name": source_row.item_name, - "description": source_row.description, - "stock_uom": source_row.stock_uom, - "uom": source_row.uom, - "conversion_factor": source_row.conversion_factor, - "basic_rate": source_row.basic_rate, - "qty": qty, - "s_warehouse": s_warehouse, - "t_warehouse": t_warehouse, - "is_finished_item": source_row.is_finished_item, - "type": source_row.type, - "is_legacy_scrap_item": source_row.is_legacy_scrap_item, - "bom_secondary_item": source_row.bom_secondary_item, - "bom_no": source_row.bom_no, - # batch and serial bundles built on submit - "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, - } - - if self.source_stock_entry: - item.update( - { - "against_stock_entry": self.source_stock_entry, - "ste_detail": source_row.name, - } - ) - - self.append("items", item) - - def _add_items_for_disassembly_from_bom(self): - if not self.bom_no or not self.fg_completed_qty: - frappe.throw(_("BOM and Finished Good Quantity is mandatory for Disassembly")) - - # Raw Materials - item_dict = self.get_bom_raw_materials(self.fg_completed_qty) - - for item_row in item_dict.values(): - item_row["to_warehouse"] = self.to_warehouse - item_row["from_warehouse"] = "" - item_row["is_finished_item"] = 0 - - self.add_to_stock_entry_detail(item_dict) - - # Secondary/Scrap items (reverse of what set_secondary_items does for Manufacture) - secondary_items = self.get_secondary_items(self.fg_completed_qty) - if secondary_items: - scrap_warehouse = self.from_warehouse - if self.work_order: - wo_values = frappe.db.get_value( - "Work Order", self.work_order, ["scrap_warehouse", "fg_warehouse"], as_dict=True - ) - scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse - - for item in secondary_items.values(): - item["from_warehouse"] = scrap_warehouse - item["to_warehouse"] = "" - item["is_finished_item"] = 0 - - if item.get("process_loss_per"): - item["qty"] -= flt( - item["qty"] * (item["process_loss_per"] / 100), - self.precision("fg_completed_qty"), - ) - - self.add_to_stock_entry_detail(secondary_items, bom_no=self.bom_no) - - # Finished goods - self.load_items_from_bom() - - def get_items_from_manufacture_stock_entry(self): - SE = frappe.qb.DocType("Stock Entry") - SED = frappe.qb.DocType("Stock Entry Detail") - query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1) - - common_fields = [ - SED.item_code, - SED.item_name, - SED.description, - SED.stock_uom, - SED.uom, - SED.basic_rate, - SED.conversion_factor, - SED.is_finished_item, - SED.type, - SED.is_legacy_scrap_item, - SED.bom_secondary_item, - SED.batch_no, - SED.serial_no, - SED.use_serial_batch_fields, - SED.s_warehouse, - SED.t_warehouse, - SED.bom_no, - ] - - if self.source_stock_entry: - return ( - query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields) - .where(SE.name == self.source_stock_entry) - .orderby(SED.idx) - .run(as_dict=True) - ) - - return ( - query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields) - .where(SE.purpose == "Manufacture") - .where(SE.work_order == self.work_order) - .groupby(SED.item_code) - .orderby(SED.idx) - .run(as_dict=True) - ) - @frappe.whitelist() def get_items(self): self.set("items", []) - self.validate_work_order() - - if self.purpose == "Disassemble": - return self.get_items_for_disassembly() - - if not self.posting_date or not self.posting_time: - frappe.throw(_("Posting date and posting time is mandatory")) - - self.set_work_order_details() - backflush_based_on = frappe.db.get_single_value( - "Manufacturing Settings", "backflush_raw_materials_based_on" - ) - - if self.bom_no: - backflush_based_on = self.get_backflush_based_on() - - if self.purpose in [ - "Material Issue", - "Material Transfer", - "Manufacture", - "Repack", - "Send to Subcontractor", - "Material Transfer for Manufacture", - "Material Consumption for Manufacture", - ]: - if self.work_order and self.purpose == "Material Transfer for Manufacture": - item_dict = self.get_pending_raw_materials(backflush_based_on) - if self.to_warehouse and self.pro_doc: - for item in item_dict.values(): - item["to_warehouse"] = self.pro_doc.wip_warehouse - self.add_to_stock_entry_detail(item_dict) - - elif ( - self.work_order - and ( - self.purpose == "Manufacture" - or self.purpose == "Material Consumption for Manufacture" - ) - and not self.pro_doc.skip_transfer - and backflush_based_on == "Material Transferred for Manufacture" - ): - self.add_transfered_raw_materials_in_items() - - elif ( - self.work_order - and ( - self.purpose == "Manufacture" - or self.purpose == "Material Consumption for Manufacture" - ) - and backflush_based_on == "BOM" - and frappe.db.get_single_value("Manufacturing Settings", "material_consumption") == 1 - ): - self.get_unconsumed_raw_materials() - - else: - if not self.fg_completed_qty: - frappe.throw(_("Manufacturing Quantity is mandatory")) - - item_dict = self.get_bom_raw_materials(self.fg_completed_qty) - - # Get Subcontract Order Supplied Items Details - if ( - self.get(self.subcontract_data.order_field) - and self.purpose == "Send to Subcontractor" - ): - # Get Subcontract Order Supplied Items Details - parent = frappe.qb.DocType(self.subcontract_data.order_doctype) - child = frappe.qb.DocType(self.subcontract_data.order_supplied_items_field) - - item_wh = ( - frappe.qb.from_(parent) - .inner_join(child) - .on(parent.name == child.parent) - .select(child.rm_item_code, child.reserve_warehouse) - .where(parent.name == self.get(self.subcontract_data.order_field)) - ).run(as_list=True) - - item_wh = frappe._dict(item_wh) - - for original_item, item in item_dict.items(): - if self.pro_doc and cint(self.pro_doc.from_wip_warehouse): - item["from_warehouse"] = self.pro_doc.wip_warehouse - # Get Reserve Warehouse from Subcontract Order - if ( - self.get(self.subcontract_data.order_field) - and self.purpose == "Send to Subcontractor" - ): - item["from_warehouse"] = item_wh.get(item.item_code) - item["to_warehouse"] = ( - self.to_warehouse if self.purpose == "Send to Subcontractor" else "" - ) - - if isinstance(original_item, str) and original_item != item.get("item_code"): - item["original_item"] = original_item - - self.add_to_stock_entry_detail(item_dict) - - # fetch the serial_no of the first stock entry for the second stock entry - if self.work_order and self.purpose == "Manufacture": - work_order = frappe.get_doc("Work Order", self.work_order) - add_additional_cost(self, work_order) - - # add finished goods item - if self.purpose in ("Manufacture", "Repack"): - self.set_process_loss_qty() - self.load_items_from_bom() + if self.purpose_cls and hasattr(self.purpose_cls, "add_items"): + self.purpose_cls(self).add_items() self.set_serial_batch_from_reserved_entry() - self.set_secondary_items() self.set_actual_qty() self.validate_customer_provided_item() self.calculate_rate_and_amount(raise_error_if_no_rate=False) def set_serial_batch_from_reserved_entry(self): - if self.work_order and frappe.get_cached_value("Work Order", self.work_order, "reserve_stock"): - skip_transfer = frappe.get_cached_value("Work Order", self.work_order, "skip_transfer") - - if ( - self.purpose not in ["Material Transfer for Manufacture"] - and self.get_backflush_based_on() != "BOM" - and not skip_transfer - ): - return - - reservation_entries = self.get_available_reserved_materials() - if not reservation_entries: - return - - new_items_to_add = [] - for d in self.items: - if d.serial_and_batch_bundle or d.serial_no or d.batch_no: - continue - - key = (d.item_code, d.s_warehouse) - if details := reservation_entries.get(key): - original_qty = d.qty - if batches := details.get("batch_no"): - for batch_no, qty in batches.items(): - if original_qty <= 0: - break - - if qty <= 0: - continue - - if d.batch_no and original_qty > 0: - new_row = frappe.copy_doc(d) - new_row.name = None - new_row.batch_no = batch_no - new_row.qty = qty - new_row.idx = d.idx + 1 - if new_row.batch_no and details.get("batchwise_sn"): - new_row.serial_no = "\n".join( - details.get("batchwise_sn")[new_row.batch_no][: cint(new_row.qty)] - ) - - new_items_to_add.append(new_row) - original_qty -= qty - batches[batch_no] -= qty - - if qty >= d.qty and not d.batch_no: - d.batch_no = batch_no - batches[batch_no] -= d.qty - if d.batch_no and details.get("batchwise_sn"): - d.serial_no = "\n".join( - details.get("batchwise_sn")[d.batch_no][: cint(d.qty)] - ) - elif not d.batch_no: - d.batch_no = batch_no - d.qty = qty - original_qty -= qty - batches[batch_no] = 0 - - if d.batch_no and details.get("batchwise_sn"): - d.serial_no = "\n".join( - details.get("batchwise_sn")[d.batch_no][: cint(d.qty)] - ) - - if details.get("serial_no"): - d.serial_no = "\n".join(details.get("serial_no")[: cint(d.qty)]) - - d.use_serial_batch_fields = 1 - - for new_row in new_items_to_add: - self.append("items", new_row) - - sorted_items = sorted(self.items, key=lambda x: x.item_code) - if self.purpose == "Manufacture": - # ensure finished item at last - sorted_items = sorted(sorted_items, key=lambda x: x.t_warehouse) - - idx = 0 - for row in sorted_items: - idx += 1 - row.idx = idx - self.set("items", sorted_items) - - def get_backflush_based_on(self): - from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on - - return get_backflush_based_on(self.bom_no) - - def get_available_reserved_materials(self): - reserved_entries = self.get_reserved_materials() - if not reserved_entries: - return {} - - itemwise_serial_batch_qty = frappe._dict() - - for d in reserved_entries: - key = (d.item_code, d.warehouse) - if key not in itemwise_serial_batch_qty: - itemwise_serial_batch_qty[key] = frappe._dict( - { - "serial_no": [], - "batch_no": defaultdict(float), - "batchwise_sn": defaultdict(list), - } - ) - - details = itemwise_serial_batch_qty[key] - if d.batch_no: - details.batch_no[d.batch_no] += d.qty - if d.serial_no: - details.batchwise_sn[d.batch_no].extend(d.serial_no.split("\n")) - elif d.serial_no: - details.serial_no.append(d.serial_no) - - return itemwise_serial_batch_qty - - def get_reserved_materials(self): - doctype = frappe.qb.DocType("Stock Reservation Entry") - serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry") - - query = ( - frappe.qb.from_(doctype) - .inner_join(serial_batch_doc) - .on(doctype.name == serial_batch_doc.parent) - .select( - serial_batch_doc.serial_no, - serial_batch_doc.batch_no, - serial_batch_doc.qty, - doctype.item_code, - doctype.warehouse, - doctype.name, - doctype.transferred_qty, - doctype.consumed_qty, - ) - .where( - (doctype.docstatus == 1) - & (doctype.voucher_no == (self.work_order or self.subcontracting_order)) - & (serial_batch_doc.delivered_qty < serial_batch_doc.qty) - ) - .orderby(serial_batch_doc.idx) - ) - - return query.run(as_dict=True) - - def set_secondary_items(self): - if self.purpose in ["Manufacture", "Repack"]: - secondary_items_dict = self.get_secondary_items(self.fg_completed_qty) - for item in secondary_items_dict.values(): - if self.pro_doc and item.type: - if self.pro_doc.scrap_warehouse and item.type == "Scrap": - item["to_warehouse"] = self.pro_doc.scrap_warehouse - - if item.process_loss_per: - item["qty"] -= flt( - item["qty"] * (item.process_loss_per / 100), - self.precision("fg_completed_qty"), - ) - - self.add_to_stock_entry_detail(secondary_items_dict, bom_no=self.bom_no) + StockEntrySABB(self).set_serial_batch_based_on_reservation() def set_process_loss_qty(self): if self.purpose not in ("Manufacture", "Repack"): @@ -2819,108 +1436,14 @@ class StockEntry(StockController, SubcontractingInwardController): ) def set_work_order_details(self): - if not getattr(self, "pro_doc", None): - self.pro_doc = frappe._dict() - if self.work_order: # common validations - if not self.pro_doc: - self.pro_doc = frappe.get_doc("Work Order", self.work_order) - if self.pro_doc and not self.pro_doc.track_semi_finished_goods: self.bom_no = self.pro_doc.bom_no else: # invalid work order self.work_order = None - def load_items_from_bom(self): - if self.work_order: - item_code = self.pro_doc.production_item - to_warehouse = self.pro_doc.fg_warehouse - else: - item_code = frappe.db.get_value("BOM", self.bom_no, "item") - to_warehouse = self.to_warehouse - - item = get_item_defaults(item_code, self.company) - - if not self.work_order and not to_warehouse: - # in case of BOM - to_warehouse = item.get("default_warehouse") - - expense_account = item.get("expense_account") - if not expense_account: - expense_account = frappe.get_cached_value("Company", self.company, "stock_adjustment_account") - - args = { - "to_warehouse": to_warehouse, - "from_warehouse": "", - "qty": flt(self.fg_completed_qty) - flt(self.process_loss_qty), - "item_name": item.item_name, - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": expense_account, - "cost_center": item.get("buying_cost_center"), - "is_finished_item": 1, - "sample_quantity": item.get("sample_quantity"), - } - - if self.purpose == "Disassemble": - args.update( - { - "from_warehouse": self.from_warehouse, - "to_warehouse": "", - "qty": flt(self.fg_completed_qty), - } - ) - - if ( - self.work_order - and self.pro_doc.has_batch_no - and not self.pro_doc.has_serial_no - and cint( - frappe.db.get_single_value( - "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True - ) - ) - ): - self.set_batchwise_finished_goods(args, item) - else: - self.add_finished_goods(args, item) - - def set_batchwise_finished_goods(self, args, item): - batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item) - - if not batches: - self.add_finished_goods(args, item) - else: - self.add_batchwise_finished_good(batches, args, item) - - def add_batchwise_finished_good(self, batches, args, item): - qty = flt(self.fg_completed_qty) - row = frappe._dict({"batches_to_be_consume": defaultdict(float)}) - - self.update_batches_to_be_consume(batches, row, qty) - - if not row.batches_to_be_consume: - return - - id = create_serial_and_batch_bundle( - self, - row, - frappe._dict( - { - "item_code": self.pro_doc.production_item, - "warehouse": args.get("to_warehouse"), - } - ), - ) - - args["serial_and_batch_bundle"] = id - self.add_finished_goods(args, item) - - def add_finished_goods(self, args, item): - self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no) - def get_bom_raw_materials(self, qty): from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict @@ -2969,522 +1492,6 @@ class StockEntry(StockController, SubcontractingInwardController): return item_dict - def get_secondary_items(self, qty): - from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict - - if ( - frappe.db.get_single_value( - "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies" - ) - and self.work_order - and frappe.get_cached_value("Work Order", self.work_order, "use_multi_level_bom") - ): - item_dict = get_secondary_items_from_sub_assemblies(self.bom_no, self.company, qty) - else: - # item dict = { item_code: {qty, description, stock_uom} } - item_dict = ( - get_bom_items_as_dict( - self.bom_no, self.company, qty=qty, fetch_exploded=0, fetch_secondary_items=1 - ) - or {} - ) - - for item in item_dict.values(): - item.from_warehouse = "" - - return item_dict - - def set_secondary_items_from_job_card(self): - if self.purpose not in ["Manufacture", "Repack"]: - return - - item_dict = {} - for row in self.get_secondary_items_from_job_card(): - if row.stock_qty <= 0: - continue - - item_dict[row.item_code] = frappe._dict( - { - "uom": row.stock_uom, - "from_warehouse": "", - "qty": row.stock_qty, - "conversion_factor": 1, - "type": row.type, - "item_name": row.item_name, - "description": row.description, - "bom_secondary_item": row.bom_secondary_item, - } - ) - - for item in item_dict.values(): - item.from_warehouse = "" - - self.add_to_stock_entry_detail(item_dict) - - def get_secondary_items_from_job_card(self): - if not hasattr(self, "pro_doc"): - self.pro_doc = None - - if not self.pro_doc: - self.set_work_order_details() - - if not self.pro_doc.operations: - return [] - - job_card = frappe.qb.DocType("Job Card") - job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item") - - other = ( - frappe.qb.from_(job_card) - .select( - Sum(job_card_secondary_item.stock_qty).as_("stock_qty"), - job_card_secondary_item.item_code, - job_card_secondary_item.item_name, - job_card_secondary_item.description, - job_card_secondary_item.stock_uom, - job_card_secondary_item.type, - job_card_secondary_item.bom_secondary_item, - ) - .join(job_card_secondary_item) - .on(job_card_secondary_item.parent == job_card.name) - .where( - (job_card_secondary_item.item_code.isnotnull()) - & (job_card.work_order == self.work_order) - & (job_card.docstatus == 1) - ) - .groupby(job_card_secondary_item.item_code, job_card_secondary_item.type) - .orderby(job_card_secondary_item.idx) - ) - - if self.job_card: - other = other.where(job_card.name == self.job_card) - - other = other.run(as_dict=1) - - if self.job_card: - pending_qty = flt(self.fg_completed_qty) - else: - pending_qty = flt(self.get_completed_job_card_qty()) - flt(self.pro_doc.produced_qty) - - used_secondary_items = self.get_used_secondary_items() - for row in other: - row.stock_qty -= flt(used_secondary_items.get(row.item_code)) - row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty) - - if used_secondary_items.get(row.item_code): - used_secondary_items[row.item_code] -= row.stock_qty - - if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")): - row.stock_qty = frappe.utils.ceil(row.stock_qty) - - return other - - def get_completed_job_card_qty(self): - return flt(min([d.completed_qty for d in self.pro_doc.operations])) - - def get_used_secondary_items(self): - used_secondary_items = defaultdict(float) - - StockEntry = frappe.qb.DocType("Stock Entry") - StockEntryDetail = frappe.qb.DocType("Stock Entry Detail") - data = ( - frappe.qb.from_(StockEntry) - .inner_join(StockEntryDetail) - .on(StockEntryDetail.parent == StockEntry.name) - .select(StockEntryDetail.item_code, StockEntryDetail.qty) - .where( - (StockEntry.work_order == self.work_order) - & ((StockEntryDetail.type.isnotnull()) | (StockEntryDetail.is_legacy_scrap_item == 1)) - & (StockEntry.docstatus == 1) - & (StockEntry.purpose.isin(["Repack", "Manufacture"])) - ) - ).run(as_dict=1) - - for row in data: - used_secondary_items[row.item_code] += row.qty - - return used_secondary_items - - def get_unconsumed_raw_materials(self): - wo = frappe.get_doc("Work Order", self.work_order) - wo_items = frappe.get_all( - "Work Order Item", - filters={"parent": self.work_order}, - fields=["item_code", "source_warehouse", "required_qty", "consumed_qty", "transferred_qty"], - ) - - work_order_qty = wo.material_transferred_for_manufacturing or wo.qty - for item in wo_items: - item_account_details = get_item_defaults(item.item_code, self.company) - # Take into account consumption if there are any. - - wo_item_qty = item.transferred_qty or item.required_qty - - wo_qty_unconsumed = flt(wo_item_qty) - flt(item.consumed_qty) - wo_qty_to_produce = flt(work_order_qty) - flt(wo.produced_qty) - bom_qty_per_unit = item.required_qty / wo.qty # per-unit BOM qty - - req_qty_each = (wo_qty_unconsumed) / (wo_qty_to_produce or 1) - req_qty_each = min(req_qty_each, bom_qty_per_unit) - - qty = req_qty_each * flt(self.fg_completed_qty) - - if qty > 0: - self.add_to_stock_entry_detail( - { - item.item_code: { - "from_warehouse": wo.wip_warehouse or item.source_warehouse, - "to_warehouse": "", - "qty": qty, - "item_name": item.item_name, - "description": item.description, - "stock_uom": item_account_details.stock_uom, - "expense_account": item_account_details.get("expense_account"), - "cost_center": item_account_details.get("buying_cost_center"), - } - } - ) - - def add_transfered_raw_materials_in_items(self) -> None: - available_materials = get_available_materials(self.work_order) - wo_data = frappe.db.get_value( - "Work Order", - self.work_order, - ["qty", "produced_qty", "material_transferred_for_manufacturing as trans_qty"], - as_dict=1, - ) - - precision = frappe.get_precision("Stock Entry Detail", "qty") - for _key, row in available_materials.items(): - remaining_qty_to_produce = flt(wo_data.trans_qty) - flt(wo_data.produced_qty) - if remaining_qty_to_produce <= 0 and not self.is_return: - continue - - qty = flt(row.qty) - if not self.is_return: - qty = (flt(row.qty) * flt(self.fg_completed_qty)) / remaining_qty_to_produce - - item = row.item_details - if cint(frappe.get_cached_value("UOM", item.stock_uom, "must_be_whole_number")): - qty = frappe.utils.ceil(qty) - - if row.batch_details: - row.batches_to_be_consume = defaultdict(float) - batches = row.batch_details - self.update_batches_to_be_consume(batches, row, qty) - - elif row.serial_nos: - serial_nos = row.serial_nos[0 : cint(qty)] - row.serial_nos = serial_nos - - if flt(qty, precision) != 0.0: - self.update_item_in_stock_entry_detail(row, item, qty) - - def update_batches_to_be_consume(self, batches, row, qty): - qty_to_be_consumed = qty - batches = sorted(batches.items(), key=lambda x: x[0]) - - for batch_no, batch_qty in batches: - if qty_to_be_consumed <= 0 or batch_qty <= 0: - continue - - if batch_qty > qty_to_be_consumed: - batch_qty = qty_to_be_consumed - - row.batches_to_be_consume[batch_no] += batch_qty - - if batch_no and row.serial_nos: - serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos) - serial_nos = serial_nos[0 : cint(batch_qty)] - - # remove consumed serial nos from list - for sn in serial_nos: - row.serial_nos.remove(sn) - - if "batch_details" in row: - row.batch_details[batch_no] -= batch_qty - - qty_to_be_consumed -= batch_qty - - def update_item_in_stock_entry_detail(self, row, item, qty) -> None: - if not qty: - return - - use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields") - - ste_item_details = { - "from_warehouse": item.warehouse, - "to_warehouse": "", - "qty": qty, - "item_name": item.item_name, - "serial_and_batch_bundle": create_serial_and_batch_bundle(self, row, item, "Outward") - if not use_serial_batch_fields - else "", - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": item.expense_account, - "cost_center": item.buying_cost_center, - "original_item": item.original_item, - "serial_no": "\n".join(row.serial_nos) - if row.serial_nos and not row.batches_to_be_consume - else "", - "use_serial_batch_fields": use_serial_batch_fields, - } - - if self.is_return: - ste_item_details["to_warehouse"] = item.s_warehouse - - if use_serial_batch_fields and not row.serial_no and row.batches_to_be_consume: - for batch_no, batch_qty in row.batches_to_be_consume.items(): - ste_item_details.update( - { - "batch_no": batch_no, - "qty": batch_qty, - } - ) - - if row.serial_nos: - serial_nos = row.serial_nos[0 : cint(batch_qty)] - ste_item_details["serial_no"] = "\n".join(serial_nos) - - row.serial_nos = [sn for sn in row.serial_nos if sn not in serial_nos] - - self.add_to_stock_entry_detail({item.item_code: ste_item_details}) - else: - self.add_to_stock_entry_detail({item.item_code: ste_item_details}) - - @staticmethod - def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list: - serial_nos = frappe.get_all( - "Serial No", - filters={"batch_no": batch_no, "name": ("in", serial_nos), "warehouse": ("is", "not set")}, - order_by="creation", - ) - - return [d.name for d in serial_nos] - - def get_pending_raw_materials(self, backflush_based_on=None): - """ - issue (item quantity) that is pending to issue or desire to transfer, - whichever is less - """ - item_dict = self.get_pro_order_required_items(backflush_based_on) - - max_qty = flt(self.pro_doc.qty) - - allow_overproduction = False - overproduction_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") - ) - - transfer_extra_materials_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") - ) - - to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt( - self.fg_completed_qty - ) - transfer_limit_qty = max_qty + ((max_qty * overproduction_percentage) / 100) - if transfer_extra_materials_percentage: - transfer_limit_qty = max_qty + ((max_qty * transfer_extra_materials_percentage) / 100) - - if transfer_limit_qty >= to_transfer_qty: - allow_overproduction = True - - for item, item_details in item_dict.items(): - pending_to_issue = flt(item_details.required_qty) - flt(item_details.transferred_qty) - desire_to_transfer = flt(self.fg_completed_qty) * flt(item_details.required_qty) / max_qty - - if ( - desire_to_transfer <= pending_to_issue - or (desire_to_transfer > 0 and backflush_based_on == "Material Transferred for Manufacture") - or allow_overproduction - ): - # "No need for transfer but qty still pending to transfer" case can occur - # when transferring multiple RM in different Stock Entries - item_dict[item]["qty"] = desire_to_transfer if (desire_to_transfer > 0) else pending_to_issue - elif pending_to_issue > 0: - item_dict[item]["qty"] = pending_to_issue - else: - item_dict[item]["qty"] = 0 - - # delete items with 0 qty - list_of_items = list(item_dict.keys()) - for item in list_of_items: - if not item_dict[item]["qty"]: - del item_dict[item] - - # show some message - if not len(item_dict): - frappe.msgprint(_("""All items have already been transferred for this Work Order.""")) - - return item_dict - - def get_pro_order_required_items(self, backflush_based_on=None): - """ - Gets Work Order Required Items only if Stock Entry purpose is **Material Transferred for Manufacture**. - """ - item_dict, job_card_items = frappe._dict(), [] - work_order = frappe.get_doc("Work Order", self.work_order) - - consider_job_card = work_order.transfer_material_against == "Job Card" and self.get("job_card") - if consider_job_card: - job_card_items = self.get_job_card_item_codes(self.get("job_card")) - - if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): - wip_warehouse = work_order.wip_warehouse - else: - wip_warehouse = None - - transfer_extra_materials_percentage = flt( - frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") - ) - - for d in work_order.get("required_items"): - if consider_job_card and (d.item_code not in job_card_items): - continue - - additional_qty = 0.0 - if transfer_extra_materials_percentage: - additional_qty = transfer_extra_materials_percentage * flt(d.required_qty) / 100 - - transfer_pending = flt(d.required_qty) > flt(d.transferred_qty) - if additional_qty: - transfer_pending = (flt(d.required_qty) + additional_qty) > flt(d.transferred_qty) - - can_transfer = transfer_pending or (backflush_based_on == "Material Transferred for Manufacture") - - if not can_transfer: - continue - - if d.include_item_in_manufacturing: - item_row = d.as_dict() - item_row["idx"] = len(item_dict) + 1 - - if consider_job_card: - job_card_item = frappe.db.get_value( - "Job Card Item", {"item_code": d.item_code, "parent": self.get("job_card")} - ) - item_row["job_card_item"] = job_card_item or None - - if d.source_warehouse and not frappe.db.get_value( - "Warehouse", d.source_warehouse, "is_group" - ): - item_row["from_warehouse"] = d.source_warehouse - - item_row["to_warehouse"] = wip_warehouse - if item_row["allow_alternative_item"]: - item_row["allow_alternative_item"] = work_order.allow_alternative_item - - item_dict.setdefault(d.item_code, item_row) - - return item_dict - - def get_job_card_item_codes(self, job_card=None): - if not job_card: - return [] - - job_card_items = frappe.get_all( - "Job Card Item", filters={"parent": job_card}, fields=["item_code"], distinct=True - ) - return [d.item_code for d in job_card_items] - - def add_to_stock_entry_detail(self, item_dict, bom_no=None): - precision = frappe.get_precision("Stock Entry Detail", "qty") - for d in item_dict: - item_row = item_dict[d] - - child_qty = flt(item_row["qty"], precision) - if ( - not self.is_return - and child_qty <= 0 - and not item_row.get("type") - and not item_row.get("is_legacy_scrap_item") - ): - if self.purpose not in ["Receive from Customer", "Send to Subcontractor"]: - continue - - se_child = self.append("items") - stock_uom = item_row.get("stock_uom") or frappe.db.get_value("Item", d, "stock_uom") - se_child.s_warehouse = item_row.get("from_warehouse") - se_child.t_warehouse = item_row.get("to_warehouse") - se_child.item_code = item_row.get("item_code") or cstr(d) - se_child.uom = item_row["uom"] if item_row.get("uom") else stock_uom - se_child.stock_uom = stock_uom - se_child.qty = child_qty if child_qty > 0 else 0 - se_child.allow_alternative_item = item_row.get("allow_alternative_item", 0) - se_child.subcontracted_item = item_row.get("main_item_code") - se_child.cost_center = item_row.get("cost_center") or get_default_cost_center( - item_row, company=self.company - ) - se_child.is_finished_item = item_row.get("is_finished_item", 0) - se_child.po_detail = item_row.get("po_detail") - se_child.sco_rm_detail = item_row.get("sco_rm_detail") - se_child.scio_detail = item_row.get("scio_detail") - se_child.sample_quantity = item_row.get("sample_quantity", 0) - se_child.type = item_row.get("type") - se_child.is_legacy_scrap_item = item_row.get("is_legacy") - se_child.bom_secondary_item = item_row.get("name") or item_row.get("bom_secondary_item") - - for field in [ - self.subcontract_data.rm_detail_field, - "original_item", - "expense_account", - "description", - "item_name", - "serial_and_batch_bundle", - "allow_zero_valuation_rate", - "use_serial_batch_fields", - "batch_no", - "serial_no", - ]: - if item_row.get(field): - se_child.set(field, item_row.get(field)) - - if se_child.s_warehouse is None: - se_child.s_warehouse = self.from_warehouse - if se_child.t_warehouse is None: - se_child.t_warehouse = self.to_warehouse - - # in stock uom - se_child.conversion_factor = flt(item_row.get("conversion_factor")) or 1 - se_child.transfer_qty = flt( - item_row["qty"] * se_child.conversion_factor, se_child.precision("qty") - ) - - se_child.bom_no = bom_no # to be assigned for finished item - se_child.job_card_item = item_row.get("job_card_item") if self.get("job_card") else None - - def validate_with_material_request(self): - for item in self.get("items"): - material_request = item.material_request or None - material_request_item = item.material_request_item or None - if self.purpose == "Material Transfer" and self.outgoing_stock_entry: - parent_se = frappe.get_value( - "Stock Entry Detail", - item.ste_detail, - ["material_request", "material_request_item"], - as_dict=True, - ) - if parent_se: - material_request = parent_se.material_request - material_request_item = parent_se.material_request_item - - if material_request: - mreq_item = frappe.db.get_value( - "Material Request Item", - {"name": material_request_item, "parent": material_request}, - ["item_code", "warehouse", "idx"], - as_dict=True, - ) - if mreq_item.item_code != item.item_code: - frappe.throw( - _("Item for row {0} does not match Material Request").format(item.idx), - frappe.MappingMismatchError, - ) - elif self.purpose == "Material Transfer" and self.add_to_transit: - continue - def validate_batch(self): if self.purpose in [ "Material Transfer for Manufacture", @@ -3493,133 +1500,7 @@ class StockEntry(StockController, SubcontractingInwardController): "Send to Subcontractor", ]: for item in self.get("items"): - if item.batch_no: - disabled = frappe.db.get_value("Batch", item.batch_no, "disabled") - if disabled == 0: - expiry_date = frappe.db.get_value("Batch", item.batch_no, "expiry_date") - if expiry_date: - if getdate(self.posting_date) > getdate(expiry_date): - frappe.throw( - _("Batch {0} of Item {1} has expired.").format( - item.batch_no, item.item_code - ) - ) - else: - frappe.throw( - _("Batch {0} of Item {1} is disabled.").format(item.batch_no, item.item_code) - ) - - def update_subcontract_order_supplied_items(self): - if self.get(self.subcontract_data.order_field) and ( - self.purpose in ["Send to Subcontractor", "Material Transfer"] or self.is_return - ): - # Get Subcontract Order Supplied Items Details - order_supplied_items = frappe.db.get_all( - self.subcontract_data.order_supplied_items_field, - filters={"parent": self.get(self.subcontract_data.order_field)}, - fields=["name", "rm_item_code", "reserve_warehouse"], - ) - - # Get Items Supplied in Stock Entries against Subcontract Order - supplied_items = get_supplied_items( - self.get(self.subcontract_data.order_field), - self.subcontract_data.rm_detail_field, - self.subcontract_data.order_field, - ) - - for row in order_supplied_items: - key, item = row.name, {} - if not supplied_items.get(key): - # no stock transferred against Subcontract Order Supplied Items row - item = {"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0} - else: - item = supplied_items.get(key) - - frappe.db.set_value(self.subcontract_data.order_supplied_items_field, row.name, item) - - # RM Item-Reserve Warehouse Dict - item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in order_supplied_items} - - for d in self.get("items"): - # Update reserved sub contracted quantity in bin based on Supplied Item Details and - item_code = d.get("original_item") or d.get("item_code") - reserve_warehouse = item_wh.get(item_code) - if not (reserve_warehouse and item_code): - continue - stock_bin = get_bin(item_code, reserve_warehouse) - stock_bin.update_reserved_qty_for_sub_contracting() - - def update_transferred_qty(self): - if self.purpose == "Material Transfer" and self.outgoing_stock_entry: - stock_entries = {} - stock_entries_child_list = [] - for d in self.items: - if not (d.against_stock_entry and d.ste_detail): - continue - - stock_entries_child_list.append(d.ste_detail) - transferred_qty = frappe.get_all( - "Stock Entry Detail", - fields=[{"SUM": "transfer_qty", "as": "qty"}], - filters={ - "against_stock_entry": d.against_stock_entry, - "ste_detail": d.ste_detail, - "docstatus": 1, - }, - ) - - if d.docstatus == 1: - transfer_qty = frappe.get_value("Stock Entry Detail", d.ste_detail, "transfer_qty") - - if transferred_qty and transferred_qty[0]: - if transferred_qty[0].qty > transfer_qty: - frappe.throw( - _( - "Row {0}: Transferred quantity cannot be greater than the requested quantity." - ).format(d.idx) - ) - - stock_entries[(d.against_stock_entry, d.ste_detail)] = ( - transferred_qty[0].qty if transferred_qty and transferred_qty[0] else 0.0 - ) or 0.0 - - if not stock_entries: - return None - - cond = "" - for data, transferred_qty in stock_entries.items(): - cond += """ WHEN (parent = {} and name = {}) THEN {} - """.format( - frappe.db.escape(data[0]), - frappe.db.escape(data[1]), - transferred_qty, - ) - - if stock_entries_child_list: - frappe.db.sql( - """ UPDATE `tabStock Entry Detail` - SET - transferred_qty = CASE {cond} END - WHERE - name in ({ste_details}) """.format( - cond=cond, ste_details=",".join(["%s"] * len(stock_entries_child_list)) - ), - tuple(stock_entries_child_list), - ) - - args = { - "source_dt": "Stock Entry Detail", - "target_field": "transferred_qty", - "target_ref_field": "qty", - "target_dt": "Stock Entry Detail", - "join_field": "ste_detail", - "target_parent_dt": "Stock Entry", - "target_parent_field": "per_transferred", - "source_field": "qty", - "percent_join_field": "against_stock_entry", - } - - self._update_percent_field_in_targets(args, update_modified=True) + item.validate_batch() def update_quality_inspection(self): if self.inspection_required: @@ -3636,78 +1517,6 @@ class StockEntry(StockController, SubcontractingInwardController): {"reference_type": reference_type, "reference_name": reference_name}, ) - def set_material_request_transfer_status(self, status): - material_requests = [] - if self.outgoing_stock_entry: - parent_se = frappe.get_value("Stock Entry", self.outgoing_stock_entry, "add_to_transit") - - for item in self.items: - material_request = item.get("material_request") - if self.purpose == "Material Transfer" and material_request not in material_requests: - if self.outgoing_stock_entry and parent_se: - material_request = frappe.get_value( - "Stock Entry Detail", item.ste_detail, "material_request" - ) - - if material_request and material_request not in material_requests: - material_requests.append(material_request) - if status == "Completed": - qty = get_transferred_qty(material_request) - if qty.get("transfer_qty") > qty.get("transferred_qty"): - status = "In Transit" - - frappe.db.set_value("Material Request", material_request, "transfer_status", status) - - def set_serial_no_batch_for_finished_good(self): - if not ( - (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no) - and frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order") - ): - return - - for d in self.items: - if ( - d.is_finished_item - and d.item_code == self.pro_doc.production_item - and not d.serial_and_batch_bundle - ): - serial_nos = self.get_available_serial_nos() - if serial_nos: - row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]}) - - id = create_serial_and_batch_bundle( - self, - row, - frappe._dict( - { - "item_code": d.item_code, - "warehouse": d.t_warehouse, - } - ), - ) - - d.serial_and_batch_bundle = id - d.use_serial_batch_fields = 0 - - def get_available_serial_nos(self) -> list[str]: - serial_nos = [] - data = frappe.get_all( - "Serial No", - filters={ - "item_code": self.pro_doc.production_item, - "warehouse": ("is", "not set"), - "status": "Inactive", - "work_order": self.pro_doc.name, - }, - fields=["name"], - order_by="creation asc", - ) - - for row in data: - serial_nos.append(row.name) - - return serial_nos - def update_subcontracting_order_status(self): if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]: from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( @@ -3728,87 +1537,6 @@ class StockEntry(StockController, SubcontractingInwardController): self.calculate_rate_and_amount() -@frappe.whitelist() -def move_sample_to_retention_warehouse(company: str, items: str | list): - from erpnext.stock.serial_batch_bundle import ( - SerialBatchCreation, - get_batch_nos, - ) - - if isinstance(items, str): - items = json.loads(items) - - retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") - stock_entry = frappe.new_doc("Stock Entry") - stock_entry.company = company - stock_entry.purpose = "Material Transfer" - stock_entry.set_stock_entry_type() - for item in items: - if item.get("sample_quantity") and item.get("serial_and_batch_bundle"): - warehouse = item.get("t_warehouse") or item.get("warehouse") - total_qty = 0 - cls_obj = SerialBatchCreation( - { - "type_of_transaction": "Outward", - "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), - "item_code": item.get("item_code"), - "warehouse": warehouse, - "do_not_save": True, - } - ) - sabb = cls_obj.duplicate_package() - batches = get_batch_nos(item.get("serial_and_batch_bundle")) - sabe_list = [] - for batch_no in batches.keys(): - sample_quantity = validate_sample_quantity( - item.get("item_code"), - item.get("sample_quantity"), - item.get("transfer_qty") or item.get("qty"), - batch_no, - ) - - sabe = next(item for item in sabb.entries if item.batch_no == batch_no) - if sample_quantity: - if sabb.has_serial_no: - new_sabe = [ - entry - for entry in sabb.entries - if entry.batch_no == batch_no - and frappe.db.exists( - "Serial No", {"name": entry.serial_no, "warehouse": warehouse} - ) - ][: int(sample_quantity)] - sabe_list.extend(new_sabe) - total_qty += len(new_sabe) - else: - total_qty += sample_quantity - sabe.qty = sample_quantity - else: - sabb.entries.remove(sabe) - - if total_qty: - if sabe_list: - sabb.entries = sabe_list - sabb.save() - - stock_entry.append( - "items", - { - "item_code": item.get("item_code"), - "s_warehouse": warehouse, - "t_warehouse": retention_warehouse, - "qty": total_qty, - "basic_rate": item.get("valuation_rate"), - "uom": item.get("uom"), - "stock_uom": item.get("stock_uom"), - "conversion_factor": item.get("conversion_factor") or 1.0, - "serial_and_batch_bundle": sabb.name, - }, - ) - if stock_entry.get("items"): - return stock_entry.as_dict() - - @frappe.whitelist() def make_stock_in_entry(source_name: str, target_doc: str | Document | None = None): def set_missing_values(source, target): @@ -3875,28 +1603,33 @@ def get_work_order_details(work_order: str, company: str): } -def get_consumed_operating_cost(wo_name, bom_no): +def get_consumed_operating_cost(wo_name, bom_no, operation_id): table = frappe.qb.DocType("Stock Entry") child_table = frappe.qb.DocType("Landed Cost Taxes and Charges") query = ( frappe.qb.from_(child_table) .join(table) .on(child_table.parent == table.name) - .select(Sum(child_table.amount).as_("consumed_cost")) + .select( + Sum(child_table.amount).as_("consumed_cost"), + Sum(child_table.qty).as_("consumed_qty"), + child_table.operating_component, + ) .where( (table.docstatus == 1) & (table.work_order == wo_name) & (table.purpose == "Manufacture") & (table.bom_no == bom_no) & (child_table.has_operating_cost == 1) + & (child_table.operation_id == operation_id) ) + .groupby(child_table.operation_id, child_table.operating_component) ) - cost = query.run(pluck="consumed_cost") - return cost[0] if cost and cost[0] else 0 + return query.run(as_dict=True) -def get_operating_cost_per_unit(work_order=None, bom_no=None): - operating_cost_per_unit = 0 +def get_remaining_operating_cost(work_order=None, bom_no=None): + remaining_operating_cost = 0 if work_order: if ( bom_no @@ -3911,23 +1644,23 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): bom_no = work_order.bom_no for d in work_order.get("operations"): + consumed_op_cost = get_consumed_operating_cost(work_order.name, bom_no, d.name) or [] + cost = 0 + for row in consumed_op_cost: + cost += flt(row.consumed_cost) + if flt(d.completed_qty): - if not (remaining_qty := flt(d.completed_qty - work_order.produced_qty)): - continue - operating_cost_per_unit += ( - flt(d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no)) - / remaining_qty - ) + remaining_operating_cost += flt(d.actual_operating_cost - cost) elif work_order.qty: - operating_cost_per_unit += flt(d.planned_operating_cost) / flt(work_order.qty) + remaining_operating_cost += flt(d.planned_operating_cost) / flt(work_order.qty) # Get operating cost from BOM if not found in work_order. - if not operating_cost_per_unit and bom_no: + if not remaining_operating_cost and bom_no: bom = frappe.db.get_value("BOM", bom_no, ["operating_cost", "quantity"], as_dict=1) if bom.quantity: - operating_cost_per_unit = flt(bom.operating_cost) / flt(bom.quantity) + remaining_operating_cost = flt(bom.operating_cost) / flt(bom.quantity) - return operating_cost_per_unit + return remaining_operating_cost def get_used_alternative_items( @@ -3961,27 +1694,6 @@ def get_used_alternative_items( return used_alternative_items -def get_valuation_rate_for_finished_good_entry(work_order): - work_order_qty = flt( - frappe.get_cached_value("Work Order", work_order, "material_transferred_for_manufacturing") - ) - - field = "(SUM(total_outgoing_value) / %s) as valuation_rate" % (work_order_qty) - - stock_data = frappe.get_all( - "Stock Entry", - fields=field, - filters={ - "docstatus": 1, - "purpose": "Material Transfer for Manufacture", - "work_order": work_order, - }, - ) - - if stock_data: - return stock_data[0].valuation_rate - - @frappe.whitelist() def get_uom_details(item_code: str, uom: str, qty: float | None): """Returns dict `{"conversion_factor": [value], "transfer_qty": qty * [value]}` @@ -3999,48 +1711,6 @@ def get_uom_details(item_code: str, uom: str, qty: float | None): return ret -@frappe.whitelist() -def get_expired_batch_items(): - from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_auto_batch_nos - - expired_batches = get_expired_batches() - if not expired_batches: - return [] - - expired_batches_stock = get_auto_batch_nos( - frappe._dict( - { - "batch_no": list(expired_batches.keys()), - "for_stock_levels": True, - } - ) - ) - - for row in expired_batches_stock: - row.update(expired_batches.get(row.batch_no)) - - return expired_batches_stock - - -def get_expired_batches(): - batch = frappe.qb.DocType("Batch") - - data = ( - frappe.qb.from_(batch) - .select(batch.item, batch.name.as_("batch_no"), batch.stock_uom) - .where((batch.expiry_date <= nowdate()) & (batch.expiry_date.isnotnull())) - ).run(as_dict=True) - - if not data: - return [] - - expired_batches = frappe._dict() - for row in data: - expired_batches[row.batch_no] = row - - return expired_batches - - @frappe.whitelist() def get_warehouse_details(args: str | dict): if isinstance(args, str): @@ -4061,323 +1731,3 @@ def get_warehouse_details(args: str | dict): "basic_rate": get_incoming_rate(args), } return ret - - -@frappe.whitelist() -def validate_sample_quantity(item_code: str, sample_quantity: int, qty: float, batch_no: str | None = None): - if cint(qty) < cint(sample_quantity): - frappe.throw( - _("Sample quantity {0} cannot be more than received quantity {1}").format(sample_quantity, qty) - ) - retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") - retainted_qty = 0 - if batch_no: - retainted_qty = get_batch_qty(batch_no, retention_warehouse, item_code) - max_retain_qty = frappe.get_value("Item", item_code, "sample_quantity") - if retainted_qty >= max_retain_qty: - frappe.msgprint( - _( - "Maximum Samples - {0} have already been retained for Batch {1} and Item {2} in Batch {3}." - ).format(retainted_qty, batch_no, item_code, batch_no), - alert=True, - ) - sample_quantity = 0 - qty_diff = max_retain_qty - retainted_qty - if cint(sample_quantity) > cint(qty_diff): - frappe.msgprint( - _("Maximum Samples - {0} can be retained for Batch {1} and Item {2}.").format( - max_retain_qty, batch_no, item_code - ), - alert=True, - ) - sample_quantity = qty_diff - return sample_quantity - - -def get_supplied_items( - subcontract_order, rm_detail_field="sco_rm_detail", subcontract_order_field="subcontracting_order" -): - fields = [ - "`tabStock Entry Detail`.`transfer_qty`", - "`tabStock Entry`.`is_return`", - f"`tabStock Entry Detail`.`{rm_detail_field}`", - "`tabStock Entry Detail`.`item_code`", - ] - - filters = [ - ["Stock Entry", "docstatus", "=", 1], - ["Stock Entry", subcontract_order_field, "=", subcontract_order], - ] - - supplied_item_details = {} - for row in frappe.get_all("Stock Entry", fields=fields, filters=filters): - if not row.get(rm_detail_field): - continue - - key = row.get(rm_detail_field) - if key not in supplied_item_details: - supplied_item_details.setdefault( - key, frappe._dict({"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0}) - ) - - supplied_item = supplied_item_details[key] - - if row.is_return: - supplied_item.returned_qty += row.transfer_qty - else: - supplied_item.supplied_qty += row.transfer_qty - - supplied_item.total_supplied_qty = flt(supplied_item.supplied_qty) - flt(supplied_item.returned_qty) - - return supplied_item_details - - -@frappe.whitelist() -def get_items_from_subcontract_order(source_name: str, target_doc: str | Document | None = None): - from erpnext.controllers.subcontracting_controller import make_rm_stock_entry - - if isinstance(target_doc, str): - target_doc = frappe.get_doc(json.loads(target_doc)) - - order_doctype = "Purchase Order" if target_doc.purchase_order else "Subcontracting Order" - target_doc = make_rm_stock_entry( - subcontract_order=source_name, order_doctype=order_doctype, target_doc=target_doc - ) - - return target_doc - - -def get_available_materials(work_order, stock_entry_doc=None) -> dict: - data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc) - - available_materials = {} - for row in data: - key = (row.item_code, row.warehouse) - if row.purpose != "Material Transfer for Manufacture": - key = (row.item_code, row.s_warehouse) - - if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": - key = (row.item_code, row.s_warehouse or row.warehouse) - - if key not in available_materials: - available_materials.setdefault( - key, - frappe._dict( - {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []} - ), - ) - - item_data = available_materials[key] - - if row.purpose == "Material Transfer for Manufacture" or ( - stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture" - ): - item_data.qty += row.qty - if row.batch_no: - item_data.batch_details[row.batch_no] += row.qty - - elif row.batch_nos: - for batch_no, qty in row.batch_nos.items(): - item_data.batch_details[batch_no] += qty - - if row.serial_no: - item_data.serial_nos.extend(get_serial_nos(row.serial_no)) - item_data.serial_nos.sort() - - elif row.serial_nos: - item_data.serial_nos.extend(get_serial_nos(row.serial_nos)) - item_data.serial_nos.sort() - else: - # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' - - item_data.qty -= row.qty - if row.batch_no: - item_data.batch_details[row.batch_no] -= row.qty - - elif row.batch_nos: - for batch_no, qty in row.batch_nos.items(): - item_data.batch_details[batch_no] += qty - - if row.serial_no: - for serial_no in get_serial_nos(row.serial_no): - if serial_no in item_data.serial_nos: - item_data.serial_nos.remove(serial_no) - - elif row.serial_nos: - for serial_no in get_serial_nos(row.serial_nos): - if serial_no in item_data.serial_nos: - item_data.serial_nos.remove(serial_no) - - return available_materials - - -def get_stock_entry_data(work_order, stock_entry_doc=None): - from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( - get_voucher_wise_serial_batch_from_bundle, - ) - - stock_entry = frappe.qb.DocType("Stock Entry") - stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") - - data = ( - frappe.qb.from_(stock_entry) - .from_(stock_entry_detail) - .select( - stock_entry_detail.item_name, - stock_entry_detail.original_item, - stock_entry_detail.item_code, - stock_entry_detail.qty, - (stock_entry_detail.t_warehouse).as_("warehouse"), - (stock_entry_detail.s_warehouse).as_("s_warehouse"), - stock_entry_detail.description, - stock_entry_detail.stock_uom, - stock_entry_detail.expense_account, - stock_entry_detail.cost_center, - stock_entry_detail.serial_and_batch_bundle, - stock_entry_detail.batch_no, - stock_entry_detail.serial_no, - stock_entry.purpose, - stock_entry.name, - ) - .where( - (stock_entry.name == stock_entry_detail.parent) - & (stock_entry.work_order == work_order) - & (stock_entry.docstatus == 1) - ) - .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) - ) - - if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": - data = data.where( - stock_entry.purpose.isin( - [ - "Disassemble", - "Manufacture", - ] - ) - ) - - data = data.where(stock_entry.name != stock_entry_doc.name) - else: - data = data.where( - stock_entry.purpose.isin( - [ - "Manufacture", - "Material Consumption for Manufacture", - "Material Transfer for Manufacture", - ] - ) - ) - - data = data.where(stock_entry_detail.s_warehouse.isnotnull()) - - data = data.run(as_dict=1) - - if not data: - return [] - - voucher_nos = [row.get("name") for row in data if row.get("name")] - if voucher_nos: - bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos) - for row in data: - key = (row.item_code, row.warehouse, row.name) - if row.purpose != "Material Transfer for Manufacture": - key = (row.item_code, row.s_warehouse, row.name) - - if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": - key = (row.item_code, row.s_warehouse or row.warehouse, row.name) - - if bundle_data.get(key): - row.update(bundle_data.get(key)) - - return data - - -def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=None): - item_details = frappe.get_cached_value( - "Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1 - ) - - if not (item_details.has_serial_no or item_details.has_batch_no): - return - - if not type_of_transaction: - type_of_transaction = "Inward" - - doc = frappe.get_doc( - { - "doctype": "Serial and Batch Bundle", - "voucher_type": "Stock Entry", - "item_code": child.item_code, - "warehouse": child.warehouse, - "type_of_transaction": type_of_transaction, - "posting_date": parent_doc.posting_date, - "posting_time": parent_doc.posting_time, - } - ) - - precision = frappe.get_precision("Stock Entry Detail", "qty") - if row.serial_nos and row.batches_to_be_consume: - doc.has_serial_no = 1 - doc.has_batch_no = 1 - batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row) - for batch_no, qty in row.batches_to_be_consume.items(): - while flt(qty, precision) > 0: - qty -= 1 - doc.append( - "entries", - { - "batch_no": batch_no, - "serial_no": batchwise_serial_nos.get(batch_no).pop(0), - "warehouse": row.warehouse, - "qty": -1, - }, - ) - - elif row.serial_nos: - doc.has_serial_no = 1 - for serial_no in row.serial_nos: - doc.append("entries", {"serial_no": serial_no, "warehouse": row.warehouse, "qty": -1}) - - elif row.batches_to_be_consume: - precision = frappe.get_precision("Serial and Batch Entry", "qty") - doc.has_batch_no = 1 - for batch_no, qty in row.batches_to_be_consume.items(): - if flt(qty, precision) > 0: - qty = flt(qty, precision) - doc.append("entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty * -1}) - - if not doc.entries: - return None - - return doc.insert(ignore_permissions=True).name - - -def get_batchwise_serial_nos(item_code, row): - batchwise_serial_nos = {} - - for batch_no in row.batches_to_be_consume: - serial_nos = frappe.get_all( - "Serial No", - filters={"item_code": item_code, "batch_no": batch_no, "name": ("in", row.serial_nos)}, - ) - - if serial_nos: - batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos]) - - return batchwise_serial_nos - - -def get_transferred_qty(material_request): - sed = DocType("Stock Entry Detail") - - query = ( - frappe.qb.from_(sed) - .select( - Sum(sed.transfer_qty).as_("transfer_qty"), - Sum(sed.transferred_qty).as_("transferred_qty"), - ) - .where((sed.material_request == material_request) & (sed.docstatus == 1)) - ).run(as_dict=True) - - return query[0] diff --git a/erpnext/accounts/print_format/sales_invoice_print/__init__.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/__init__.py similarity index 100% rename from erpnext/accounts/print_format/sales_invoice_print/__init__.py rename to erpnext/stock/doctype/stock_entry/stock_entry_handler/__init__.py diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/base.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/base.py new file mode 100644 index 00000000000..4706be97914 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/base.py @@ -0,0 +1,41 @@ +import frappe +from frappe import _ +from frappe.utils import flt + +from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on + + +class BaseStockEntry: + """Shared foundation for all stock entry purpose handlers. + + Provides common lazy-loaded work order document, backflush configuration, + and work order status validation used across multiple handler classes. + """ + + def __init__(self, se_doc): + self.doc = se_doc + + @property + def wo_doc(self): + if not getattr(self, "_wo_doc", None): + if self.doc.work_order: + self._wo_doc = frappe.get_doc("Work Order", self.doc.work_order) + return getattr(self, "_wo_doc", None) + + @property + def backflush_based_on(self): + return get_backflush_based_on(self.doc.bom_no) + + def _validate_work_order(self): + if not self.wo_doc: + return + + msg = "" + if flt(self.wo_doc.docstatus) != 1: + msg = _("Work Order {0} must be submitted").format(self.doc.work_order) + + if self.wo_doc.status == "Stopped": + msg = _("Transaction not allowed against stopped Work Order {0}").format(self.doc.work_order) + + if msg: + frappe.throw(msg) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py new file mode 100644 index 00000000000..767afab2fb7 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/disassemble.py @@ -0,0 +1,529 @@ +from collections import defaultdict + +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import flt + +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.serial_batch_bundle import SerialBatchCreation +from erpnext.stock.utils import get_combine_datetime + +from .base import BaseStockEntry +from .manufacturing import ( + ceil_qty_if_uom_has_whole_number, + get_bom_items, + get_production_item_details, + get_secondary_items, +) + + +class DisassembleStockEntry(BaseStockEntry): + def validate(self): + self.validate_warehouse() + + def validate_warehouse(self): + for row in self.doc.items: + if not row.s_warehouse and not row.t_warehouse: + frappe.throw(_("Source or Target Warehouse is required for item {0}").format(row.item_code)) + + def validate_fg_completed_qty(self): + if not self.doc.source_stock_entry: + return + + from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty + + available_qty = get_disassembly_available_qty(self.doc.source_stock_entry, self.doc.name) + + if flt(self.doc.fg_completed_qty) > available_qty: + frappe.throw( + _( + "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble." + ).format( + self.doc.fg_completed_qty, + self.doc.source_stock_entry, + available_qty, + ), + title=_("Excess Disassembly"), + ) + + def add_items(self): + """ + Priority: + 1. From a specific Manufacture Stock Entry (exact reversal) + 2. From Work Order Manufacture Stock Entries (averaged reversal) + 3. From BOM (standalone disassembly) + """ + + # Auto-set source_stock_entry if WO has exactly one manufacture entry + if not self.doc.get("source_stock_entry") and self.doc.work_order: + manufacture_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": self.doc.work_order, + "purpose": "Manufacture", + "docstatus": 1, + }, + pluck="name", + ) + if len(manufacture_entries) == 1: + self.doc.source_stock_entry = manufacture_entries[0] + + if self.doc.get("source_stock_entry"): + return self._add_items_for_disassembly_from_stock_entry() + + if self.doc.work_order: + return self._add_items_for_disassembly_from_work_order() + + return self._add_items_for_disassembly_from_bom() + + def _add_items_for_disassembly_from_stock_entry(self): + source_fg_qty = frappe.db.get_value("Stock Entry", self.doc.source_stock_entry, "fg_completed_qty") + if not source_fg_qty: + frappe.throw( + _("Source Stock Entry {0} has no finished goods quantity").format(self.doc.source_stock_entry) + ) + + disassemble_qty = flt(self.doc.fg_completed_qty) + scale_factor = disassemble_qty / flt(source_fg_qty) + + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, + ) + + def _add_items_for_disassembly_from_work_order(self): + wo_produced_qty = frappe.db.get_value("Work Order", self.doc.work_order, "produced_qty") + + wo_produced_qty = flt(wo_produced_qty) + if wo_produced_qty <= 0: + frappe.throw(_("Work Order {0} has no produced qty").format(self.doc.work_order)) + + disassemble_qty = flt(self.doc.fg_completed_qty) + if disassemble_qty <= 0: + frappe.throw(_("Disassemble Qty cannot be less than or equal to 0.")) + + scale_factor = disassemble_qty / wo_produced_qty + + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, + ) + + def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor): + for source_row in self.get_items_from_manufacture_stock_entry(): + self._append_disassembly_item(source_row, disassemble_qty, scale_factor) + + def _get_disassembly_warehouses(self, source_row, disassemble_qty, scale_factor): + if source_row.is_finished_item: + return disassemble_qty, self.doc.from_warehouse or source_row.t_warehouse, "" + elif source_row.s_warehouse: + return flt(source_row.qty * scale_factor), "", self.doc.to_warehouse or source_row.s_warehouse + else: + return flt(source_row.qty * scale_factor), source_row.t_warehouse, "" + + def _build_disassembly_item_dict(self, source_row, qty, s_warehouse, t_warehouse): + return { + "item_code": source_row.item_code, + "item_name": source_row.item_name, + "description": source_row.description, + "stock_uom": source_row.stock_uom, + "uom": source_row.uom, + "conversion_factor": source_row.conversion_factor, + "basic_rate": source_row.basic_rate, + "qty": qty, + "s_warehouse": s_warehouse, + "t_warehouse": t_warehouse, + "is_finished_item": source_row.is_finished_item, + "secondary_item_type": source_row.secondary_item_type, + "is_legacy_scrap_item": source_row.is_legacy_scrap_item, + "bom_secondary_item": source_row.bom_secondary_item, + "bom_no": source_row.bom_no, + "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, + } + + def _append_disassembly_item(self, source_row, disassemble_qty, scale_factor): + qty, s_warehouse, t_warehouse = self._get_disassembly_warehouses( + source_row, disassemble_qty, scale_factor + ) + item = self._build_disassembly_item_dict(source_row, qty, s_warehouse, t_warehouse) + if self.doc.source_stock_entry: + item.update({"against_stock_entry": self.doc.source_stock_entry, "ste_detail": source_row.name}) + self.doc.append("items", item) + + def _add_items_for_disassembly_from_bom(self): + if not self.doc.bom_no or not self.doc.fg_completed_qty: + frappe.throw(_("BOM and Finished Good Quantity is mandatory for Disassembly")) + + self.add_raw_materials() + self.add_secondary_items() + self.add_finished_goods() + + def add_raw_materials(self): + # Raw materials will be available after disassembly in target warehouse + items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + + for row in items: + row["t_warehouse"] = self.doc.to_warehouse + row["from_warehouse"] = "" + row["is_finished_item"] = 0 + row["qty"] = flt(row["qty"]) * flt(self.doc.fg_completed_qty) + row["uom"] = row.get("uom") or row.get("stock_uom") + self.doc.append("items", row) + + def add_secondary_items(self): + # Secondary items will be removed from source warehouse + + secondary_items = get_secondary_items(self.doc.bom_no, self.doc.work_order) + for row in secondary_items: + item_args = {} + fields = [ + "item_code", + "item_name", + "uom", + "stock_uom", + "conversion_factor", + "item_group", + "description", + "secondary_item_type", + ] + for field in fields: + item_args[field] = row.get(field) + + item_args["is_legacy_scrap_item"] = row.get("is_legacy") + item_args["s_warehouse"] = self.doc.from_warehouse + item_args["uom"] = item_args.get("uom") or item_args.get("stock_uom") + item_args["bom_secondary_item"] = row.get("name") + + row.qty = row.qty * self.doc.fg_completed_qty + if row.get("process_loss_per"): + row.qty -= flt(row.qty * row.get("process_loss_per") / 100) + item_args["qty"] = ceil_qty_if_uom_has_whole_number(row.qty, item_args["uom"]) + + self.doc.append("items", item_args) + + def add_finished_goods(self): + item_details = get_production_item_details(self.doc.work_order, self.doc.bom_no) + + item_details.update( + { + "conversion_factor": 1, + "uom": item_details.stock_uom, + "qty": self.doc.fg_completed_qty, + "t_warehouse": None, + "s_warehouse": self.doc.from_warehouse, + "is_finished_item": 1, + } + ) + + item_details["item_code"] = item_details["name"] + del item_details["name"] + + self.doc.append("items", item_details) + + def get_items_from_manufacture_stock_entry(self): + SE = frappe.qb.DocType("Stock Entry") + SED = frappe.qb.DocType("Stock Entry Detail") + query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1) + + common_fields = [ + SED.item_code, + SED.item_name, + SED.description, + SED.stock_uom, + SED.uom, + SED.basic_rate, + SED.conversion_factor, + SED.is_finished_item, + SED.secondary_item_type, + SED.is_legacy_scrap_item, + SED.bom_secondary_item, + SED.batch_no, + SED.serial_no, + SED.use_serial_batch_fields, + SED.s_warehouse, + SED.t_warehouse, + SED.bom_no, + ] + + if self.doc.source_stock_entry: + return ( + query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields) + .where(SE.name == self.doc.source_stock_entry) + .orderby(SED.idx) + .run(as_dict=True) + ) + + return ( + query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields) + .where(SE.purpose == "Manufacture") + .where(SE.work_order == self.doc.work_order) + .groupby(SED.item_code) + .orderby(SED.idx) + .run(as_dict=True) + ) + + def on_submit(self): + self.set_serial_batch_for_disassembly() + self.update_disassembled_order() + + def on_cancel(self): + self.update_disassembled_order() + + def set_serial_batch_for_disassembly(self): + if self.doc.get("source_stock_entry"): + self._set_serial_batch_for_disassembly_from_stock_entry() + else: + self._set_serial_batch_for_disassembly_from_available_materials() + + def _set_serial_batch_for_disassembly_from_stock_entry(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_voucher_wise_serial_batch_from_bundle, + ) + + source_fg_qty = flt( + frappe.db.get_value("Stock Entry", self.doc.source_stock_entry, "fg_completed_qty") + ) + scale_factor = flt(self.doc.fg_completed_qty) / source_fg_qty if source_fg_qty else 0 + bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.doc.source_stock_entry]) + source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()} + for row in self.doc.items: + if not row.ste_detail: + continue + source_row = source_rows_by_name.get(row.ste_detail) + if source_row: + self._apply_bundle_to_disassembly_row(row, source_row, bundle_data, scale_factor) + + def _apply_bundle_to_disassembly_row(self, row, source_row, bundle_data, scale_factor): + source_warehouse = source_row.s_warehouse or source_row.t_warehouse + key = (source_row.item_code, source_warehouse, self.doc.source_stock_entry) + source_bundle = bundle_data.get(key, {}) + batches = self._extract_batches(source_row, source_bundle, row, scale_factor) + serial_nos = self._extract_serial_nos(source_row, source_bundle, row) + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) + + def _extract_batches(self, source_row, source_bundle, row, scale_factor): + batches = defaultdict(float) + if source_bundle.get("batch_nos"): + self._allocate_batches(batches, source_bundle["batch_nos"], row.transfer_qty, scale_factor) + elif source_row.batch_no: + batches[source_row.batch_no] = row.transfer_qty + return batches + + def _allocate_batches(self, batches, batch_nos, transfer_qty, scale_factor): + qty_remaining = transfer_qty + for batch_no, batch_qty in batch_nos.items(): + if qty_remaining <= 0: + break + alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining) + batches[batch_no] = alloc + qty_remaining -= alloc + + def _extract_serial_nos(self, source_row, source_bundle, row): + if source_bundle.get("serial_nos"): + return get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)] + elif source_row.serial_no: + return get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)] + return [] + + def _set_serial_batch_for_disassembly_from_available_materials(self): + available_materials = get_available_materials(self.doc.work_order, self.doc) + for row in self.doc.items: + warehouse = row.s_warehouse or row.t_warehouse + materials = available_materials.get((row.item_code, warehouse)) + if materials: + self._apply_available_material_bundle(row, materials) + + def _apply_available_material_bundle(self, row, materials): + batches = self._collect_available_batches(materials.batch_details, row.transfer_qty) + serial_nos = materials.serial_nos[: int(row.transfer_qty)] if materials.serial_nos else [] + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) + + def _collect_available_batches(self, batch_details, transfer_qty): + batches, qty = defaultdict(float), transfer_qty + for batch_no, batch_qty in batch_details.items(): + if qty <= 0: + break + batch_qty = abs(batch_qty) + if batch_qty <= qty: + batches[batch_no], qty = batch_qty, qty - batch_qty + else: + batches[batch_no], qty = qty, 0 + return batches + + def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches): + if not serial_nos and not batches: + return + + warehouse = row.s_warehouse or row.t_warehouse + bundle_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": warehouse, + "posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time), + "voucher_type": self.doc.doctype, + "voucher_no": self.doc.name, + "voucher_detail_no": row.name, + "qty": row.transfer_qty, + "type_of_transaction": "Inward" if row.t_warehouse else "Outward", + "company": self.doc.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) + + row.serial_and_batch_bundle = bundle_doc.name + row.use_serial_batch_fields = 0 + + def update_disassembled_order(self): + if not self.doc.work_order: + return + + if self.doc.fg_completed_qty: + pro_doc = frappe.get_doc("Work Order", self.doc.work_order) + pro_doc.run_method( + "update_disassembled_qty", self.doc.fg_completed_qty, self.doc._action == "cancel" + ) + + +def get_available_materials(work_order, stock_entry_doc=None) -> dict: + data = get_stock_entry_data(work_order, stock_entry_doc=stock_entry_doc) + available_materials = {} + for row in data: + key = _get_material_key(row, stock_entry_doc) + if key not in available_materials: + available_materials[key] = frappe._dict( + {"item_details": row, "batch_details": defaultdict(float), "qty": 0, "serial_nos": []} + ) + _update_material_qty(available_materials[key], row, stock_entry_doc) + return available_materials + + +def _get_material_key(row, stock_entry_doc): + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + return (row.item_code, row.s_warehouse or row.warehouse) + if row.purpose != "Material Transfer for Manufacture": + return (row.item_code, row.s_warehouse) + return (row.item_code, row.warehouse) + + +def _update_material_qty(item_data, row, stock_entry_doc): + is_inward = row.purpose == "Material Transfer for Manufacture" or ( + stock_entry_doc and stock_entry_doc.purpose == "Disassemble" and row.purpose == "Manufacture" + ) + if is_inward: + _add_inward_material_qty(item_data, row) + else: + _deduct_consumed_material_qty(item_data, row) + + +def _add_inward_material_qty(item_data, row): + item_data.qty += row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] += row.qty + elif row.batch_nos: + for batch_no, qty in row.batch_nos.items(): + item_data.batch_details[batch_no] += qty + _extend_serial_nos_from_row(item_data, row) + + +def _extend_serial_nos_from_row(item_data, row): + sn = row.serial_no or row.serial_nos + if sn: + item_data.serial_nos.extend(get_serial_nos(sn)) + item_data.serial_nos.sort() + + +def _deduct_consumed_material_qty(item_data, row): + item_data.qty -= row.qty + if row.batch_no: + item_data.batch_details[row.batch_no] -= row.qty + elif row.batch_nos: + for batch_no, qty in row.batch_nos.items(): + item_data.batch_details[batch_no] += qty + _remove_serial_nos_from_available(item_data, row) + + +def _remove_serial_nos_from_available(item_data, row): + sn = row.serial_no or row.serial_nos + if not sn: + return + for serial_no in get_serial_nos(sn): + if serial_no in item_data.serial_nos: + item_data.serial_nos.remove(serial_no) + + +def get_stock_entry_data(work_order, stock_entry_doc=None): + data = _run_stock_entry_query(work_order, stock_entry_doc) + if not data: + return [] + _enrich_with_bundle_data(data, stock_entry_doc) + return data + + +def _run_stock_entry_query(work_order, stock_entry_doc): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + query = _build_stock_entry_base_query(se, sed, work_order) + query = _apply_stock_entry_purpose_filter(query, se, sed, stock_entry_doc) + return query.run(as_dict=1) + + +def _build_stock_entry_base_query(se, sed, work_order): + return ( + frappe.qb.from_(se) + .from_(sed) + .select( + sed.item_name, + sed.original_item, + sed.item_code, + sed.qty, + sed.t_warehouse.as_("warehouse"), + sed.s_warehouse.as_("s_warehouse"), + sed.description, + sed.stock_uom, + sed.expense_account, + sed.cost_center, + sed.serial_and_batch_bundle, + sed.batch_no, + sed.serial_no, + se.purpose, + se.name, + ) + .where((se.name == sed.parent) & (se.work_order == work_order) & (se.docstatus == 1)) + .orderby(se.creation, sed.item_code, sed.idx) + ) + + +def _apply_stock_entry_purpose_filter(query, se, sed, stock_entry_doc): + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + query = query.where(se.purpose.isin(["Disassemble", "Manufacture"])) + return query.where(se.name != stock_entry_doc.name) + query = query.where( + se.purpose.isin( + ["Manufacture", "Material Consumption for Manufacture", "Material Transfer for Manufacture"] + ) + ) + return query.where(sed.s_warehouse.isnotnull()) + + +def _enrich_with_bundle_data(data, stock_entry_doc): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_voucher_wise_serial_batch_from_bundle, + ) + + voucher_nos = [row.get("name") for row in data if row.get("name")] + if not voucher_nos: + return + bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos) + for row in data: + key = _get_bundle_key(row, stock_entry_doc) + if bundle_data.get(key): + row.update(bundle_data.get(key)) + + +def _get_bundle_key(row, stock_entry_doc): + if stock_entry_doc and stock_entry_doc.purpose == "Disassemble": + return (row.item_code, row.s_warehouse or row.warehouse, row.name) + if row.purpose != "Material Transfer for Manufacture": + return (row.item_code, row.s_warehouse, row.name) + return (row.item_code, row.warehouse, row.name) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py new file mode 100644 index 00000000000..6ce37f7c4ef --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/manufacturing.py @@ -0,0 +1,1154 @@ +import json +from collections import defaultdict + +import frappe +from frappe import _, bold +from frappe.query_builder.functions import Sum +from frappe.utils import ceil, cint, flt, get_link_to_form + +from erpnext.manufacturing.doctype.bom.bom import add_additional_cost +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.serial_batch_bundle import ( + SerialBatchCreation, + get_batch_nos, + get_batches_from_bundle, + get_empty_batches_based_work_order, + get_serial_nos_from_bundle, +) + +from .base import BaseStockEntry +from .serial_batch import create_serial_and_batch_bundle + + +class BaseManufactureStockEntry(BaseStockEntry): + def set_default_warehouse(self): + for row in self.doc.items: + if ( + not row.s_warehouse + and self.doc.from_warehouse + and not row.is_finished_item + and not row.is_legacy_scrap_item + and not row.secondary_item_type + ): + row.s_warehouse = self.doc.from_warehouse + row.t_warehouse = None + + elif ( + not row.t_warehouse + and self.doc.to_warehouse + and (row.is_finished_item or row.is_legacy_scrap_item or row.secondary_item_type) + ): + row.t_warehouse = self.doc.to_warehouse + row.s_warehouse = None + + def validate_warehouse(self): + for row in self.doc.items: + if not row.s_warehouse and not row.t_warehouse: + frappe.throw(_("Source or Target Warehouse is required for item {0}").format(row.item_code)) + + def validate_raw_materials_exists(self): + if frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): + return + + raw_materials = [] + for row in self.doc.items: + if row.s_warehouse: + raw_materials.append(row.item_code) + + if not raw_materials: + frappe.throw( + _( + "At least one raw material item must be present in the stock entry for the type {0}" + ).format(bold(self.doc.purpose)), + title=_("Raw Materials Missing"), + ) + + def get_item_dict(self, row): + item_args = {} + fields = [ + "item_code", + "item_name", + "item_group", + "description", + "uom", + "stock_uom", + "conversion_factor", + "allow_alternative_item", + ] + for field in fields: + if row.get(field): + item_args[field] = row.get(field) + + return item_args + + def add_secondary_items(self): + secondary_items = get_secondary_items(self.doc.bom_no, self.doc.work_order) + for row in secondary_items: + item_args = self.get_item_dict(row) + item_args["is_legacy_scrap_item"] = bool(row.get("is_legacy")) + item_args["secondary_item_type"] = row.secondary_item_type + item_args["bom_secondary_item"] = row.name + + if row.secondary_item_type == "Scrap" and self.wo_doc and self.wo_doc.get("scrap_warehouse"): + item_args["t_warehouse"] = self.wo_doc.scrap_warehouse + else: + item_args["t_warehouse"] = self.doc.to_warehouse + + if not item_args.get("t_warehouse"): + item_args["t_warehouse"] = frappe.get_cached_value( + "BOM", self.doc.bom_no, "default_target_warehouse" + ) + + row.qty = row.qty * self.doc.fg_completed_qty + if row.get("process_loss_per"): + row.qty -= flt( + row.qty * row.get("process_loss_per") / 100, self.doc.precision("fg_completed_qty") + ) + + item_args["qty"] = ceil_qty_if_uom_has_whole_number(row.qty, row.uom) + item_args["transfer_qty"] = item_args["qty"] + self.doc.append("items", item_args) + + def set_process_loss_qty(self): + precision = self.doc.precision("process_loss_qty") + if self.doc.work_order: + data = frappe.get_all( + "Work Order Operation", + filters={"parent": self.doc.work_order}, + fields=[{"MAX": "process_loss_qty", "as": "process_loss_qty"}], + ) + + if data and data[0].process_loss_qty: + process_loss_qty = data[0].process_loss_qty + if flt(self.doc.process_loss_qty, precision) != flt(process_loss_qty, precision): + self.doc.process_loss_qty = flt(process_loss_qty, precision) + + frappe.msgprint( + _("The Process Loss Qty has reset as per job cards Process Loss Qty"), alert=True + ) + + if not self.doc.process_loss_percentage and not self.doc.process_loss_qty: + self.doc.process_loss_percentage = frappe.get_cached_value( + "BOM", self.doc.bom_no, "process_loss_percentage" + ) + + if self.doc.process_loss_percentage and not self.doc.process_loss_qty: + self.doc.process_loss_qty = flt( + (flt(self.doc.fg_completed_qty) * flt(self.doc.process_loss_percentage)) / 100 + ) + elif self.doc.process_loss_qty and not self.doc.process_loss_percentage: + self.doc.process_loss_percentage = flt( + (flt(self.doc.process_loss_qty) / flt(self.doc.fg_completed_qty)) * 100 + ) + + def add_finished_goods(self): + item_details = get_production_item_details(self.doc.work_order, self.doc.bom_no) + fg_item_qty = flt(self.doc.fg_completed_qty) - flt(self.doc.process_loss_qty) + + item_details.update( + { + "conversion_factor": 1, + "uom": item_details.stock_uom, + "qty": ceil_qty_if_uom_has_whole_number(fg_item_qty, item_details.stock_uom), + "t_warehouse": self.doc.to_warehouse + or frappe.get_cached_value("BOM", self.doc.bom_no, "default_target_warehouse"), + "s_warehouse": None, + "is_finished_item": 1, + } + ) + + item_details["item_code"] = item_details["name"] + del item_details["name"] + + item_details["transfer_qty"] = item_details["qty"] + + if self.wo_doc and cint( + frappe.db.get_single_value( + "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True + ) + ): + if self.wo_doc.has_serial_no: + self.set_serial_nos_for_finished_good(item_details) + elif self.wo_doc.has_batch_no: + self.set_batchwise_finished_goods(item_details) + else: + self.doc.append("items", item_details) + + def set_serial_nos_for_finished_good(self, item_details, existing_row=None): + serial_nos = self.get_available_serial_nos_for_fg(item_details.item_code) + if not serial_nos: + return + + row = frappe._dict({"serial_nos": serial_nos[0 : cint(item_details.qty)]}) + + _id = create_serial_and_batch_bundle( + self.doc, + row, + frappe._dict( + { + "item_code": item_details.item_code, + "warehouse": item_details.t_warehouse, + } + ), + ) + + if existing_row: + existing_row.serial_and_batch_bundle = _id + existing_row.use_serial_batch_fields = 0 + else: + item_details.serial_and_batch_bundle = _id + item_details.use_serial_batch_fields = 0 + self.doc.append("items", item_details) + + def get_available_serial_nos_for_fg(self, item_code) -> list[str]: + return frappe.get_all( + "Serial No", + filters={ + "item_code": item_code, + "warehouse": ("is", "not set"), + "status": "Inactive", + "work_order": self.wo_doc.name, + }, + pluck="name", + order_by="creation asc", + ) + + def set_batchwise_finished_goods(self, item_details, existing_row=None): + batches = get_empty_batches_based_work_order(self.doc.work_order, self.wo_doc.production_item) + + if not batches: + if not existing_row: + self.doc.append("items", item_details) + else: + self.add_batchwise_finished_good(batches, item_details, existing_row=existing_row) + + def add_batchwise_finished_good(self, batches, item_details, existing_row=None): + qty = flt(self.doc.fg_completed_qty) + row = frappe._dict({"batches_to_be_consume": defaultdict(float)}) + self.update_batches_to_be_consume(batches, row, qty) + if row.batches_to_be_consume: + self._link_fg_bundle_and_append(item_details, row, existing_row=existing_row) + + def _link_fg_bundle_and_append(self, item_details, row, existing_row=None): + _id = create_serial_and_batch_bundle( + self.doc, + row, + frappe._dict( + {"item_code": self.wo_doc.production_item, "warehouse": item_details.get("t_warehouse")} + ), + ) + if existing_row: + existing_row.serial_and_batch_bundle = _id + existing_row.use_serial_batch_fields = 0 + else: + item_details["serial_and_batch_bundle"] = _id + item_details["use_serial_batch_fields"] = 0 + self.doc.append("items", item_details) + + def update_batches_to_be_consume(self, batches, row, qty): + qty_to_be_consumed = qty + for batch_no, batch_qty in sorted(batches.items(), key=lambda x: x[0]): + if qty_to_be_consumed <= 0 or batch_qty <= 0: + continue + batch_qty = min(batch_qty, qty_to_be_consumed) + self._consume_batch(row, batch_no, batch_qty) + qty_to_be_consumed -= batch_qty + + def _consume_batch(self, row, batch_no, batch_qty): + row.batches_to_be_consume[batch_no] += batch_qty + if batch_no and row.serial_nos: + serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos) + for sn in serial_nos[: cint(batch_qty)]: + row.serial_nos.remove(sn) + if "batch_details" in row: + row.batch_details[batch_no] -= batch_qty + + +class ManufactureStockEntry(BaseManufactureStockEntry): + def before_validate(self): + self.set_default_warehouse() + self.set_job_card_data() + + def validate(self): + self.validate_warehouse() + self.validate_raw_materials_exists() + self.validate_component_and_quantities() + self.validate_finished_good_serial_batch_for_work_order() + + def validate_finished_good_serial_batch_for_work_order(self): + if not ( + self.doc.work_order + and self.wo_doc + and self.wo_doc.track_semi_finished_goods != 1 + and cint( + frappe.db.get_single_value( + "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True + ) + ) + and (self.wo_doc.has_serial_no or self.wo_doc.has_batch_no) + ): + return + + for row in self.doc.items: + if not row.is_finished_item: + continue + + if self.check_invalid_serial_batch_nos_for_finished_good_item(row): + self.reset_serial_batch_on_fg_row(row) + frappe.msgprint( + _( + "Row {0}: Serial/Batch has been reset to values linked with Work Order {1}" + " because the previously selected serial/batch does not belong to this Work Order." + ).format(row.idx, frappe.bold(self.doc.work_order)) + ) + + def check_invalid_serial_batch_nos_for_finished_good_item(self, row) -> bool: + if self.wo_doc.has_serial_no: + serial_nos = get_serial_nos(row.serial_no) if row.serial_no else [] + if not serial_nos and row.serial_and_batch_bundle: + serial_nos = get_serial_nos_from_bundle(row.serial_and_batch_bundle) + if serial_nos: + valid_serial_nos = frappe.get_all( + "Serial No", + filters={"name": ("in", serial_nos), "work_order": self.doc.work_order}, + pluck="name", + ) + return bool(set(serial_nos) - set(valid_serial_nos)) + else: + return True + + if self.wo_doc.has_batch_no: + batch_nos = [row.batch_no] if row.batch_no else [] + if not batch_nos and row.serial_and_batch_bundle: + batch_nos = list(get_batches_from_bundle(row.serial_and_batch_bundle).keys()) + if batch_nos: + valid_batch_nos = frappe.get_all( + "Batch", + filters={"name": ("in", batch_nos), "reference_name": self.doc.work_order}, + pluck="name", + ) + return bool(set(batch_nos) - set(valid_batch_nos)) + else: + return True + + def reset_serial_batch_on_fg_row(self, row): + item_details = frappe._dict( + { + "item_code": row.item_code, + "t_warehouse": row.t_warehouse, + "qty": row.qty, + } + ) + + row.serial_no = None + row.batch_no = None + row.serial_and_batch_bundle = None + + if self.wo_doc.has_serial_no: + self.set_serial_nos_for_finished_good(item_details, existing_row=row) + elif self.wo_doc.has_batch_no: + self.set_batchwise_finished_goods(item_details, existing_row=row) + + def set_job_card_data(self): + if self.doc.job_card and not self.doc.work_order: + data = frappe.db.get_value( + "Job Card", + self.doc.job_card, + ["for_quantity", "work_order", "bom_no", "semi_fg_bom"], + as_dict=1, + ) + self.doc.fg_completed_qty = data.for_quantity + self.doc.work_order = data.work_order + self.doc.from_bom = 1 + self.doc.bom_no = data.semi_fg_bom or data.bom_no + + def validate_component_and_quantities(self): + if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): + return + + if not self.doc.fg_completed_qty: + return + + rm_items = [item for item in self.doc.items if item.s_warehouse] + if not rm_items: + return + + _check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)) + + def validate_work_order(self): + if not self.doc.work_order: + frappe.throw(_("Work Order is mandatory")) + + def add_items(self): + self.add_raw_materials() + self.set_process_loss_qty() + self.add_finished_goods() + self.add_secondary_items() + self.add_additional_cost() + self.add_secondary_items_from_job_card() + + def add_raw_materials(self): + if not frappe.db.get_single_value("Manufacturing Settings", "material_consumption"): + if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer: + self.add_raw_materials_based_on_work_order() + else: + self.add_raw_materials_based_on_transfer() + elif self.backflush_based_on == "BOM": + self.add_unconsumed_raw_materials() + else: + self.add_raw_materials_based_on_transfer() + + def add_unconsumed_raw_materials(self): + wo = self.wo_doc + if not wo: + return + work_order_qty = flt(wo.material_transferred_for_manufacturing) or flt(wo.qty) + wo_qty_to_produce = work_order_qty - flt(wo.produced_qty) + for item in wo.get("required_items"): + self._append_unconsumed_item(item, wo, wo_qty_to_produce) + + def _append_unconsumed_item(self, item, wo, wo_qty_to_produce): + wo_item_qty = flt(item.transferred_qty) or flt(item.required_qty) + wo_qty_unconsumed = wo_item_qty - flt(item.consumed_qty) + bom_qty_per_unit = flt(item.required_qty) / flt(wo.qty) + req_qty_each = min(wo_qty_unconsumed / (wo_qty_to_produce or 1), bom_qty_per_unit) + qty = req_qty_each * flt(self.doc.fg_completed_qty) + if qty <= 0: + return + item_args = self.get_item_dict(item) + item_args.update( + { + "conversion_factor": 1, + "s_warehouse": wo.wip_warehouse or item.source_warehouse, + "uom": item.stock_uom, + "qty": ceil_qty_if_uom_has_whole_number(qty, item.stock_uom), + } + ) + item_args["transfer_qty"] = item_args["qty"] + self.doc.append("items", item_args) + + def add_raw_materials_based_on_work_order(self): + bom_items = ( + self.wo_doc.get("required_items") + if self.wo_doc + else get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + ) + alternative_items = self.get_alternative_items(bom_items) + for row in bom_items: + self._append_wo_raw_material(row, alternative_items) + + def _append_wo_raw_material(self, row, alternative_items): + item_args = self.get_item_dict(row) + item_args.update( + { + "conversion_factor": 1, + "item_group": row.get("item_group"), + "s_warehouse": self._resolve_rm_warehouse(row), + "uom": row.stock_uom, + } + ) + qty = ( + (row.required_qty / self.wo_doc.qty) * self.doc.fg_completed_qty + if self.wo_doc + else flt(row.qty) * self.doc.fg_completed_qty + ) + item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.stock_uom) + item_args["transfer_qty"] = item_args["qty"] + if alt := alternative_items.get(row.item_code): + self.set_alternative_item_details(item_args, alt) + self.doc.append("items", item_args) + + def _resolve_rm_warehouse(self, row): + if self.doc.from_warehouse: + return self.doc.from_warehouse + if self.wo_doc and self.wo_doc.from_wip_warehouse: + return self.wo_doc.wip_warehouse + if s_warehouse := frappe.get_cached_value("BOM", self.doc.bom_no, "default_source_warehouse"): + return s_warehouse + return row.get("source_warehouse") + + def get_alternative_items(self, bom_items): + item_codes_in_bom = [row.item_code for row in bom_items] + data = self._query_alternative_items(item_codes_in_bom) + if not data: + return frappe._dict() + return self._index_alternative_items(data) + + def _query_alternative_items(self, item_codes_in_bom): + doctype = frappe.qb.DocType("Stock Entry") + child_doc = frappe.qb.DocType("Stock Entry Detail") + query = ( + frappe.qb.from_(child_doc) + .inner_join(doctype) + .on(child_doc.parent == doctype.name) + .select( + child_doc.item_code, + child_doc.uom, + child_doc.stock_uom, + child_doc.conversion_factor, + child_doc.item_name, + child_doc.item_group, + child_doc.description, + child_doc.original_item, + ) + .where( + (doctype.work_order == self.doc.work_order) + & (doctype.purpose == "Material Transfer for Manufacture") + & (doctype.docstatus == 1) + ) + ) + if item_codes_in_bom: + query = query.where(child_doc.original_item.isin(item_codes_in_bom)) + return query.run(as_dict=1) + + def _index_alternative_items(self, data): + alternative_items = frappe._dict() + for row in data: + alternative_items[row.original_item] = row + alternative_items[row.original_item].original_item = None + return alternative_items + + def set_alternative_item_details(self, row, alternative_item_details): + if self.doc.work_order and row.get("allow_alternative_item") is None: + row["allow_alternative_item"] = self.wo_doc.allow_alternative_item + + if row["allow_alternative_item"]: + original_item = row["item_code"] + row.update(alternative_item_details) + row["original_item"] = original_item + + def add_raw_materials_based_on_transfer(self): + self.prepare_available_materials_based_on_transfer() + pending_qty_to_mfg = flt(self.doc.fg_completed_qty) + if self.doc.work_order: + pending_qty_to_mfg = flt(self.wo_doc.material_transferred_for_manufacturing) - flt( + self.wo_doc.produced_qty + ) + if pending_qty_to_mfg <= 0 and not self.doc.get("is_return"): + return + for key in self.available_materials: + self._append_transfer_based_rm(self.available_materials[key], pending_qty_to_mfg) + + def _append_transfer_based_rm(self, row, pending_qty_to_mfg): + item_args = self.get_item_dict(row) + is_return = self.doc.get("is_return") + qty = row.qty if is_return else (flt(row.qty) * flt(self.doc.fg_completed_qty)) / pending_qty_to_mfg + item_args["qty"] = ceil_qty_if_uom_has_whole_number(qty, row.uom) + item_args["transfer_qty"] = item_args["qty"] + if is_return: + item_args["s_warehouse"], item_args["t_warehouse"] = row.s_warehouse, row.t_warehouse + else: + item_args["t_warehouse"], item_args["s_warehouse"] = None, row.warehouse + if row.serial_nos or row.batches: + self.assign_serial_batches_to_materials(item_args, row, qty) + else: + self.doc.append("items", item_args) + + def assign_serial_batches_to_materials(self, item_args, row, qty): + if row.serial_nos: + self._append_with_serial_nos(item_args, row, qty) + elif len(row.batches) == 1: + self._append_with_single_batch(item_args, row) + elif row.batches: + self.split_items_based_on_batches(qty, item_args, row) + + def _append_with_serial_nos(self, item_args, row, qty): + if serial_nos := row.serial_nos[: cint(qty)]: + item_args["serial_no"] = "\n".join(serial_nos) + if not item_args.get("uom"): + item_args["uom"] = row.stock_uom + item_args["use_serial_batch_fields"] = 1 + self.doc.append("items", item_args) + + def _append_with_single_batch(self, item_args, row): + item_args["batch_no"] = next(iter(row.batches.keys())) + if not item_args.get("uom"): + item_args["uom"] = row.stock_uom + item_args["use_serial_batch_fields"] = 1 + self.doc.append("items", item_args) + + def split_items_based_on_batches(self, qty, item_args, row): + for batch_no, batch_qty in row.batches.items(): + if qty <= 0: + return + qty = self._append_batch_split_item(item_args, row, batch_no, batch_qty, qty) + + def _append_batch_split_item(self, item_args, row, batch_no, batch_qty, qty): + if batch_qty >= qty: + item_args["qty"], qty = qty, 0 + else: + item_args["qty"] = batch_qty + qty -= batch_qty + row.batches[batch_no] -= batch_qty + if not item_args.get("uom"): + item_args["uom"] = row.stock_uom + item_args["batch_no"] = batch_no + item_args["transfer_qty"] = item_args["qty"] + item_args["use_serial_batch_fields"] = 1 + self.doc.append("items", item_args) + return qty + + def prepare_available_materials_based_on_transfer(self): + self.available_materials = frappe._dict() + self._transfer_entries = self.get_transfer_entries() + if not self._transfer_entries: + return + + self.add_materials_from_transfer() + self._consumption_entries = self.get_consumption_entries() + if not self._consumption_entries: + return + + self.remove_consumed_materials_from_available() + + def return_available_materials_in_source_wh(self): + for row in self.doc.items: + row.s_warehouse, row.t_warehouse = row.t_warehouse, row.s_warehouse + + def get_transfer_entries(self): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .inner_join(stock_entry_detail) + .on(stock_entry.name == stock_entry_detail.parent) + .select(stock_entry_detail.star) + .where( + (stock_entry.work_order == self.doc.work_order) + & (stock_entry.purpose == "Material Transfer for Manufacture") + & (stock_entry.docstatus == 1) + ) + .orderby(stock_entry_detail.idx) + ).run(as_dict=1) + + def add_materials_from_transfer(self): + for row in self._transfer_entries: + row.warehouse = row.t_warehouse + key = (row.item_code, row.warehouse) + if key not in self.available_materials: + self.available_materials[key] = frappe._dict(row) + else: + self.available_materials[key].qty += row.qty + + if row.serial_and_batch_bundle: + self.available_materials[key].update(self.get_sabb_details(row.serial_and_batch_bundle)) + + def get_consumption_entries(self): + stock_entry = frappe.qb.DocType("Stock Entry") + stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(stock_entry) + .inner_join(stock_entry_detail) + .on(stock_entry.name == stock_entry_detail.parent) + .select(stock_entry_detail.star) + .where( + (stock_entry.work_order == self.doc.work_order) + & (stock_entry_detail.s_warehouse.isnotnull()) + & (stock_entry.purpose == "Manufacture") + & (stock_entry.docstatus == 1) + ) + .orderby(stock_entry_detail.idx) + ).run(as_dict=1) + + def remove_consumed_materials_from_available(self): + for row in self._consumption_entries: + row.warehouse = row.s_warehouse + key = (row.item_code, row.warehouse) + self.available_materials[key].qty -= row.qty + if row.serial_and_batch_bundle: + self._deduct_consumed_serial_batch(key, row.serial_and_batch_bundle) + + def _deduct_consumed_serial_batch(self, key, sabb_name): + _details = self.get_sabb_details(sabb_name) + if _details.serial_nos: + for sn in _details.serial_nos: + self.available_materials[key].serial_nos.remove(sn) + elif _details.batches: + for batch_no, qty in _details.batches.items(): + # qty is negative, so add instead of subtract + self.available_materials[key].batches[batch_no] += qty + + def add_additional_cost(self): + if not self.wo_doc: + return + + add_additional_cost(self.doc, self.wo_doc) + + def add_secondary_items_from_job_card(self): + if not self.wo_doc: + return + + secondary_items = self.get_secondary_items_from_job_card() + for row in secondary_items: + row.uom = row.uom or row.stock_uom + row.qty = ceil_qty_if_uom_has_whole_number(row.stock_qty, row.stock_uom) + row.transfer_qty = row.qty + row.s_warehouse = None + row.t_warehouse = row.warehouse or self.doc.to_warehouse + row.is_legacy_scrap_item = row.is_legacy + row.secondary_item_type = row.get("secondary_item_type") + + self.doc.append("items", row) + + def get_secondary_items_from_job_card(self): + if not self.wo_doc.operations: + return [] + secondary_items = get_secondary_items_from_job_card(self.doc.work_order, self.doc.job_card) + pending_qty = self._get_pending_secondary_qty() + used_secondary_items = self.get_used_secondary_items() + self._adjust_secondary_item_qtys(secondary_items, used_secondary_items, pending_qty) + return secondary_items + + def _get_pending_secondary_qty(self): + if self.doc.job_card: + return flt(self.doc.fg_completed_qty) + return flt(self.get_completed_job_card_qty()) - flt(self.wo_doc.produced_qty) + + def _adjust_secondary_item_qtys(self, secondary_items, used_secondary_items, pending_qty): + for row in secondary_items: + row.stock_qty -= flt(used_secondary_items.get(row.item_code)) + row.stock_qty = row.stock_qty * flt(self.doc.fg_completed_qty) / flt(pending_qty) + if used_secondary_items.get(row.item_code): + used_secondary_items[row.item_code] -= row.stock_qty + + def get_used_secondary_items(self): + data = self._query_used_secondary_items() + used_secondary_items = defaultdict(float) + for row in data: + used_secondary_items[row.item_code] += row.qty + return used_secondary_items + + def _query_used_secondary_items(self): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + return ( + frappe.qb.from_(se) + .inner_join(sed) + .on(sed.parent == se.name) + .select(sed.item_code, sed.qty) + .where( + (se.work_order == self.doc.work_order) + & ((sed.secondary_item_type.isnotnull()) | (sed.is_legacy_scrap_item == 1)) + & (se.docstatus == 1) + & (se.purpose.isin(["Repack", "Manufacture"])) + ) + ).run(as_dict=1) + + def get_completed_job_card_qty(self): + return flt(min([d.completed_qty for d in self.wo_doc.operations])) + + def get_sabb_details(self, sabb): + sabb_entries = frappe.get_all( + "Serial and Batch Entry", + filters={"parent": sabb, "docstatus": 1, "is_cancelled": 0}, + fields=["serial_no", "batch_no", "qty"], + order_by="idx", + ) + + serial_nos = [] + batches = defaultdict(float) + + for row in sabb_entries: + if row.serial_no: + serial_nos.append(row.serial_no) + else: + batches[row.batch_no] += row.qty + + return frappe._dict({"serial_nos": serial_nos, "batches": batches}) + + def on_submit(self): + self.update_job_card_and_work_order() + + def on_cancel(self): + self.update_job_card_and_work_order() + + def update_job_card_and_work_order(self): + if self.doc.job_card: + self._update_job_card_on_manufacture() + if self.doc.work_order: + self._update_work_order_on_manufacture() + + def _update_job_card_on_manufacture(self): + job_doc = frappe.get_doc("Job Card", self.doc.job_card) + job_doc.set_consumed_qty_in_job_card_item(self.doc) + job_doc.set_manufactured_qty() + job_doc.update_work_order() + + def _update_work_order_on_manufacture(self): + self._validate_work_order() + if self.doc.fg_completed_qty: + self.wo_doc.run_method("update_work_order_qty") + self.wo_doc.run_method("update_planned_qty") + self.wo_doc.run_method("update_status") + if not self.wo_doc.operations: + self.wo_doc.set_actual_dates() + + +class RepackStockEntry(BaseManufactureStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_raw_materials_exists() + self.validate_repack_entry() + + def validate_repack_entry(self): + fg_items = {row.item_code: row for row in self.doc.items if row.is_finished_item} + + if len(fg_items) > 1 and not all(row.set_basic_rate_manually for row in fg_items.values()): + frappe.throw( + _( + "When there are multiple finished goods ({0}) in a Repack stock entry, the basic rate for all finished goods must be set manually. To set rate manually, enable the checkbox 'Set Basic Rate Manually' in the respective finished good row." + ).format(", ".join(fg_items)), + title=_("Set Basic Rate Manually"), + ) + + def add_items(self): + self.add_raw_materials_based_on_bom() + self.set_process_loss_qty() + self.add_finished_goods() + self.add_secondary_items() + + def add_raw_materials_based_on_bom(self): + bom_items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + + for row in bom_items: + row.s_warehouse = self.doc.from_warehouse + row.qty = row.qty * self.doc.fg_completed_qty + row.transfer_qty = row.qty + if not row.uom: + row.uom = row.stock_uom + + self.doc.append("items", row) + + +class MaterialConsumptionForManufactureStockEntry(ManufactureStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_work_order() + + def add_items(self): + if self.backflush_based_on == "BOM" or self.wo_doc.skip_transfer: + self.add_raw_materials_based_on_work_order() + else: + self.add_raw_materials_based_on_transfer() + + +def get_production_item_details(work_order=None, bom_no=None): + production_item = ( + frappe.get_cached_value("Work Order", work_order, "production_item") + if work_order + else frappe.get_cached_value("BOM", bom_no, "item") + ) + return frappe.get_cached_value( + "Item", + production_item, + ["item_name", "item_group", "description", "stock_uom", "name"], + as_dict=1, + ) + + +def _check_bom_component_qty(doc, bom_items): + """Validate that stock entry items match BOM quantities.""" + precision = frappe.get_precision("Stock Entry Detail", "qty") + for row in bom_items: + row.qty = row.qty * doc.fg_completed_qty + matched_item = next( + ( + item + for item in doc.items + if item.s_warehouse + and (item.item_code == row.item_code or item.original_item == row.item_code) + ), + None, + ) + if matched_item: + if flt(row.qty, precision) != flt(matched_item.qty, precision): + frappe.throw( + _( + "For the item {0}, the consumed quantity should be {1} according to the BOM {2}." + ).format( + bold(row.item_code), + flt(row.qty), + get_link_to_form("BOM", doc.bom_no), + ), + title=_("Incorrect Component Quantity"), + ) + else: + frappe.throw( + _("According to the BOM {0}, the Item '{1}' is missing in the stock entry.").format( + get_link_to_form("BOM", doc.bom_no), bold(row.item_code) + ), + title=_("Missing Item"), + ) + + +def get_bom_items(bom_no, use_multi_level_bom=None, qty=None, fetch_secondary_items=False): + if use_multi_level_bom is None: + use_multi_level_bom = frappe.get_cached_value("BOM", bom_no, "use_multi_level_bom") + qty = qty or 1 + + if fetch_secondary_items: + table_name = "BOM Secondary Item" + else: + table_name = "BOM Explosion Item" if use_multi_level_bom else "BOM Item" + + items = _run_bom_items_query(bom_no, table_name, qty) + return _deduplicate_bom_items(items) + + +def _run_bom_items_query(bom_no, table_name, qty): + bom_doc = frappe.qb.DocType("BOM") + doctype = frappe.qb.DocType(table_name) + query = ( + frappe.qb.from_(doctype) + .inner_join(bom_doc) + .on(doctype.parent == bom_doc.name) + .select( + doctype.item_code, + doctype.item_name, + doctype.stock_uom, + doctype.description, + (doctype.stock_qty / bom_doc.quantity.as_("qty") * qty).as_("qty"), + doctype.rate.as_("basic_rate"), + ) + .where((bom_doc.name == bom_no) & (bom_doc.docstatus == 1)) + .orderby(doctype.idx) + ) + return _add_bom_table_specific_fields(query, doctype, table_name).run(as_dict=1) + + +def _add_bom_table_specific_fields(query, doctype, table_name): + if table_name == "BOM Secondary Item": + return query.select( + doctype.name, + doctype.cost_allocation_per, + doctype.uom, + doctype.process_loss_per, + doctype.secondary_item_type, + doctype.is_legacy, + doctype.conversion_factor, + ) + if table_name == "BOM Item": + return query.select( + doctype.allow_alternative_item, doctype.uom, doctype.conversion_factor, doctype.bom_no + ) + return query + + +def _deduplicate_bom_items(items): + item_dict = {} + for item in items: + if item.item_code in item_dict: + item_dict[item.item_code].qty += item.qty + else: + item_dict[item.item_code] = item + return list(item_dict.values()) + + +def get_secondary_items(bom_no, work_order=None): + if ( + frappe.db.get_single_value( + "Manufacturing Settings", "set_op_cost_and_secondary_items_from_sub_assemblies" + ) + and work_order + and frappe.get_cached_value("Work Order", work_order, "use_multi_level_bom") + ): + return get_secondary_items_from_sub_assemblies(bom_no) + else: + return get_bom_items(bom_no, fetch_secondary_items=True) + + +def get_secondary_items_from_sub_assemblies(bom_no): + items = [] + bom_items = get_bom_items(bom_no) + for row in bom_items: + if not row.bom_no: + continue + + items.extend(get_bom_items(row.bom_no, qty=row.qty, fetch_secondary_items=True)) + items.extend(get_secondary_items_from_sub_assemblies(row.bom_no)) + + return items + + +def get_secondary_items_from_job_card(work_order, jc_name=None): + job_card = frappe.qb.DocType("Job Card") + job_card_secondary_item = frappe.qb.DocType("Job Card Secondary Item") + + secondary_items = ( + frappe.qb.from_(job_card) + .select( + Sum(job_card_secondary_item.stock_qty).as_("stock_qty"), + job_card_secondary_item.item_code, + job_card_secondary_item.item_name, + job_card_secondary_item.description, + job_card_secondary_item.stock_uom, + job_card_secondary_item.secondary_item_type, + job_card_secondary_item.bom_secondary_item, + ) + .join(job_card_secondary_item) + .on(job_card_secondary_item.parent == job_card.name) + .where( + (job_card_secondary_item.item_code.isnotnull()) + & (job_card.work_order == work_order) + & (job_card.docstatus == 1) + ) + .groupby(job_card_secondary_item.item_code, job_card_secondary_item.secondary_item_type) + .orderby(job_card_secondary_item.idx) + ) + + if jc_name: + secondary_items = secondary_items.where(job_card.name == jc_name) + + return secondary_items.run(as_dict=1) + + +def ceil_qty_if_uom_has_whole_number(qty, stock_uom): + if cint(frappe.get_cached_value("UOM", stock_uom, "must_be_whole_number")): + qty = ceil(qty) + + return qty + + +@frappe.whitelist() +def move_sample_to_retention_warehouse(company: str, items: str | list): + if isinstance(items, str): + items = json.loads(items) + + retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") + stock_entry = frappe.new_doc("Stock Entry") + stock_entry.company = company + stock_entry.purpose = "Material Transfer" + stock_entry.set_stock_entry_type() + + for item in items: + if item.get("sample_quantity") and item.get("serial_and_batch_bundle"): + _process_sample_item(stock_entry, item, retention_warehouse) + + if stock_entry.get("items"): + return stock_entry.as_dict() + + +def _process_sample_item(stock_entry, item, retention_warehouse): + warehouse = item.get("t_warehouse") or item.get("warehouse") + sabb = _duplicate_sample_bundle(item, warehouse) + total_qty, sabe_list = _collect_sample_batches(sabb, item, warehouse) + if total_qty: + _append_sample_entry(stock_entry, sabb, item, warehouse, retention_warehouse, total_qty, sabe_list) + + +def _duplicate_sample_bundle(item, warehouse): + return SerialBatchCreation( + { + "type_of_transaction": "Outward", + "serial_and_batch_bundle": item.get("serial_and_batch_bundle"), + "item_code": item.get("item_code"), + "warehouse": warehouse, + "do_not_save": True, + } + ).duplicate_package() + + +def _collect_sample_batches(sabb, item, warehouse): + batches = get_batch_nos(item.get("serial_and_batch_bundle")) + sabe_list, total_qty = [], 0 + for batch_no in batches.keys(): + qty, entries = _process_sample_batch(sabb, item, warehouse, batch_no) + total_qty += qty + sabe_list.extend(entries) + return total_qty, sabe_list + + +def _process_sample_batch(sabb, item, warehouse, batch_no): + sample_quantity = validate_sample_quantity( + item.get("item_code"), + item.get("sample_quantity"), + item.get("transfer_qty") or item.get("qty"), + batch_no, + ) + sabe = next(entry for entry in sabb.entries if entry.batch_no == batch_no) + if not sample_quantity: + sabb.entries.remove(sabe) + return 0, [] + return _apply_sample_quantity(sabb, sabe, warehouse, batch_no, sample_quantity) + + +def _apply_sample_quantity(sabb, sabe, warehouse, batch_no, sample_quantity): + if sabb.has_serial_no: + entries = [ + e + for e in sabb.entries + if e.batch_no == batch_no + and frappe.db.exists("Serial No", {"name": e.serial_no, "warehouse": warehouse}) + ][: int(sample_quantity)] + return len(entries), entries + sabe.qty = sample_quantity + return sample_quantity, [] + + +def _append_sample_entry(stock_entry, sabb, item, warehouse, retention_warehouse, total_qty, sabe_list): + if sabe_list: + sabb.entries = sabe_list + sabb.save() + stock_entry.append( + "items", + { + "item_code": item.get("item_code"), + "s_warehouse": warehouse, + "t_warehouse": retention_warehouse, + "qty": total_qty, + "basic_rate": item.get("valuation_rate"), + "uom": item.get("uom"), + "stock_uom": item.get("stock_uom"), + "conversion_factor": item.get("conversion_factor") or 1.0, + "serial_and_batch_bundle": sabb.name, + }, + ) + + +@frappe.whitelist() +def validate_sample_quantity(item_code: str, sample_quantity: int, qty: float, batch_no: str | None = None): + from erpnext.stock.doctype.batch.batch import get_batch_qty + + if cint(qty) < cint(sample_quantity): + frappe.throw( + _("Sample quantity {0} cannot be more than received quantity {1}").format(sample_quantity, qty) + ) + return _adjust_sample_quantity(item_code, sample_quantity, batch_no, get_batch_qty) + + +def _adjust_sample_quantity(item_code, sample_quantity, batch_no, get_batch_qty): + retention_warehouse = frappe.get_single_value("Stock Settings", "sample_retention_warehouse") + retainted_qty = get_batch_qty(batch_no, retention_warehouse, item_code) if batch_no else 0 + max_retain_qty = frappe.get_value("Item", item_code, "sample_quantity") + if retainted_qty >= max_retain_qty: + _warn_max_retained(retainted_qty, batch_no, item_code) + return 0 + return _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_no, item_code) + + +def _warn_max_retained(retainted_qty, batch_no, item_code): + frappe.msgprint( + _("Maximum Samples - {0} have already been retained for Batch {1} and Item {2} in Batch {3}.").format( + retainted_qty, batch_no, item_code, batch_no + ), + alert=True, + ) + + +def _cap_sample_quantity(sample_quantity, max_retain_qty, retainted_qty, batch_no, item_code): + qty_diff = max_retain_qty - retainted_qty + if cint(sample_quantity) > cint(qty_diff): + frappe.msgprint( + _("Maximum Samples - {0} can be retained for Batch {1} and Item {2}.").format( + max_retain_qty, batch_no, item_code + ), + alert=True, + ) + return qty_diff + return sample_quantity diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_receipt_issue.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_receipt_issue.py new file mode 100644 index 00000000000..7ae15ed2ea2 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_receipt_issue.py @@ -0,0 +1,83 @@ +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum + +from .base import BaseStockEntry +from .manufacturing import get_bom_items + + +class MaterialReceiptStockEntry(BaseStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_warehouse() + + def set_default_warehouse(self): + for row in self.doc.items: + row.s_warehouse = None + if not row.t_warehouse and self.doc.to_warehouse: + row.t_warehouse = self.doc.to_warehouse + + def validate_warehouse(self): + for row in self.doc.items: + if not row.t_warehouse: + frappe.throw(_("Target Warehouse is required for item {0}").format(row.item_code)) + + +class BaseMaterialIssueStockEntry(BaseStockEntry): + def set_default_warehouse(self): + for row in self.doc.items: + row.t_warehouse = None + if not row.s_warehouse and self.doc.from_warehouse: + row.s_warehouse = self.doc.from_warehouse + + def validate_warehouse(self): + for row in self.doc.items: + if not row.s_warehouse: + frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code)) + + +class MaterialIssueStockEntry(BaseMaterialIssueStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_warehouse() + + def add_items(self): + self.add_raw_materials_based_on_bom() + + def add_raw_materials_based_on_bom(self): + bom_items = get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom) + + for row in bom_items: + row.s_warehouse = self.doc.from_warehouse + row.qty = row.qty * self.doc.fg_completed_qty + if not row.uom: + row.uom = row.stock_uom + + self.doc.append("items", row) + + +def get_consumed_items(work_order): + """Get all raw materials consumed through consumption entries for a work order.""" + parent = frappe.qb.DocType("Stock Entry") + child = frappe.qb.DocType("Stock Entry Detail") + + return ( + frappe.qb.from_(parent) + .join(child) + .on(parent.name == child.parent) + .select( + child.item_code, + Sum(child.qty).as_("qty"), + child.original_item, + ) + .where( + (parent.docstatus == 1) + & (parent.purpose == "Material Consumption for Manufacture") + & (parent.work_order == work_order) + ) + .groupby(child.item_code, child.original_item) + ).run(as_dict=True) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_transfer.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_transfer.py new file mode 100644 index 00000000000..661ea04b19b --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/material_transfer.py @@ -0,0 +1,437 @@ +import frappe +from frappe import _ +from frappe.query_builder.functions import Sum +from frappe.utils import cstr, flt + +from .base import BaseStockEntry +from .manufacturing import _check_bom_component_qty, get_bom_items + + +class BaseMaterialTransferStockEntry(BaseStockEntry): + def set_default_warehouse(self): + for row in self.doc.items: + if not row.t_warehouse and self.doc.to_warehouse: + row.t_warehouse = self.doc.to_warehouse + if not row.s_warehouse and self.doc.from_warehouse: + row.s_warehouse = self.doc.from_warehouse + + def validate_warehouse(self): + for row in self.doc.items: + if not row.t_warehouse: + frappe.throw(_("Target Warehouse is required for item {0}").format(row.item_code)) + if not row.s_warehouse: + frappe.throw(_("Source Warehouse is required for item {0}").format(row.item_code)) + + def validate_same_source_target_warehouse(self): + """ + Raises: frappe.ValidationError: If warehouses are same and no inventory dimensions differ + """ + + if not frappe.get_single_value("Stock Settings", "validate_material_transfer_warehouses"): + return + + from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions + + inventory_dimensions = get_inventory_dimensions() + for item in self.doc.items: + if cstr(item.s_warehouse) == cstr(item.t_warehouse): + if not inventory_dimensions: + frappe.throw( + _( + "Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer" + ).format(item.idx), + title=_("Invalid Source and Target Warehouse"), + ) + else: + difference_found = False + for dimension in inventory_dimensions: + fieldname = ( + dimension.source_fieldname + if dimension.source_fieldname.startswith("to_") + else f"to_{dimension.source_fieldname}" + ) + if ( + item.get(dimension.source_fieldname) + and item.get(fieldname) + and item.get(dimension.source_fieldname) != item.get(fieldname) + ): + difference_found = True + break + if not difference_found: + frappe.throw( + _( + "Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer" + ).format(item.idx), + title=_("Invalid Source and Target Warehouse"), + ) + + def update_transferred_qty(self): + if not self.doc.outgoing_stock_entry: + return + + stock_entries, child_list = self._collect_transferred_qtys() + if not stock_entries: + return + + self._bulk_update_transferred_qty(stock_entries, child_list) + self._update_per_transferred_field() + + def _get_item_transferred_qty(self, item): + sed = frappe.qb.DocType("Stock Entry Detail") + result = ( + frappe.qb.from_(sed) + .select(Sum(sed.transfer_qty).as_("qty")) + .where( + (sed.against_stock_entry == item.against_stock_entry) + & (sed.ste_detail == item.ste_detail) + & (sed.docstatus == 1) + ) + ).run(as_dict=True) + return result[0].qty if result and result[0].qty else 0.0 + + def _validate_item_transferred_qty(self, item, transferred_qty): + if item.docstatus != 1: + return + + transfer_qty = frappe.get_value("Stock Entry Detail", item.ste_detail, "transfer_qty") + if transferred_qty > transfer_qty: + frappe.throw( + _("Row {0}: Transferred quantity cannot be greater than the requested quantity.").format( + item.idx + ) + ) + + def _collect_transferred_qtys(self): + stock_entries, child_list = {}, [] + for item in self.doc.items: + if not (item.against_stock_entry and item.ste_detail): + continue + + transferred_qty = self._get_item_transferred_qty(item) + self._validate_item_transferred_qty(item, transferred_qty) + child_list.append(item.ste_detail) + stock_entries[(item.against_stock_entry, item.ste_detail)] = transferred_qty + return stock_entries, child_list + + def _bulk_update_transferred_qty(self, stock_entries, child_list): + sed = frappe.qb.DocType("Stock Entry Detail") + case_expr = self._build_case_expr(sed, stock_entries) + ( + frappe.qb.update(sed) + .set(sed.transferred_qty, case_expr.else_(sed.transferred_qty)) + .where(sed.name.isin(child_list)) + ).run() + + def _build_case_expr(self, sed, stock_entries): + from pypika import Case + + case_expr = Case() + for (parent, name), qty in stock_entries.items(): + case_expr = case_expr.when((sed.parent == parent) & (sed.name == name), qty) + return case_expr + + def _update_per_transferred_field(self): + self.doc._update_percent_field_in_targets(self._get_per_transferred_config(), update_modified=True) + + def _get_per_transferred_config(self): + return { + "source_dt": "Stock Entry Detail", + "target_field": "transferred_qty", + "target_ref_field": "transfer_qty", + "target_dt": "Stock Entry Detail", + "join_field": "ste_detail", + "target_parent_dt": "Stock Entry", + "target_parent_field": "per_transferred", + "source_field": "transfer_qty", + "percent_join_field": "against_stock_entry", + } + + +class MaterialTransferStockEntry(BaseMaterialTransferStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_warehouse() + self.validate_same_source_target_warehouse() + + def on_submit(self): + self.update_transferred_qty() + self.update_subcontract_order_supplied_items() + + def on_cancel(self): + self.update_transferred_qty() + self.update_subcontract_order_supplied_items() + + def update_subcontract_order_supplied_items(self): + if not self.doc.get(self.doc.subcontract_data.order_field): + return + + from .subcontracting import SendToSubcontractorStockEntry + + SendToSubcontractorStockEntry(self.doc).update_subcontract_order_supplied_items() + + +class MaterialTransferForManufactureStockEntry(BaseMaterialTransferStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_warehouse() + self.validate_component_and_quantities() + self.validate_same_source_target_warehouse() + + def validate_component_and_quantities(self): + if not frappe.db.get_single_value("Manufacturing Settings", "validate_components_quantities_per_bom"): + return + + if not self.doc.fg_completed_qty: + return + + _check_bom_component_qty(self.doc, get_bom_items(self.doc.bom_no, self.doc.use_multi_level_bom)) + + def add_items(self): + item_dict = self.get_pending_raw_materials() + + for item in item_dict.values(): + item["s_warehouse"] = item.get("from_warehouse") + if self.wo_doc and not item.get("t_warehouse"): + item["t_warehouse"] = self.wo_doc.wip_warehouse + + for item_code in item_dict: + self.doc.append("items", item_dict[item_code]) + + def get_pending_raw_materials(self): + """Return pending raw material qty to transfer, capped at what's still needed.""" + item_dict = self.get_work_order_required_items() + max_qty = flt(self.wo_doc.qty) + allow_overproduction = self._is_overproduction_allowed(max_qty) + + for item, item_details in item_dict.items(): + item_dict[item]["qty"] = self._calculate_item_transfer_qty( + item_details, allow_overproduction, max_qty + ) + item_dict[item]["transfer_qty"] = flt(item_dict[item]["qty"]) * flt( + item_dict[item].get("conversion_factor") or 1 + ) + + item_dict = {k: v for k, v in item_dict.items() if v["qty"]} + + if not item_dict: + frappe.msgprint(_("All items have already been transferred for this Work Order.")) + + return item_dict + + def _is_overproduction_allowed(self, max_qty): + overproduction_pct = flt( + frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order") + ) + extra_materials_pct = flt( + frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") + ) + to_transfer_qty = flt(self.wo_doc.material_transferred_for_manufacturing) + flt( + self.doc.fg_completed_qty + ) + limit_pct = extra_materials_pct or overproduction_pct + transfer_limit_qty = max_qty + (max_qty * limit_pct / 100) + return transfer_limit_qty >= to_transfer_qty + + def _calculate_item_transfer_qty(self, item_details, allow_overproduction, max_qty): + pending_to_issue = flt(item_details.required_qty) - flt(item_details.transferred_qty) + desire_to_transfer = flt(self.doc.fg_completed_qty) * flt(item_details.required_qty) / max_qty + can_transfer = ( + desire_to_transfer <= pending_to_issue + or (desire_to_transfer > 0 and self.backflush_based_on == "Material Transferred for Manufacture") + or allow_overproduction + ) + return _resolve_transfer_qty(desire_to_transfer, pending_to_issue, can_transfer) + + def get_work_order_required_items(self): + """Gets Work Order Required Items for Material Transfer for Manufacture.""" + work_order = self.wo_doc + consider_job_card = work_order.transfer_material_against == "Job Card" and self.doc.get("job_card") + job_card_items = self.get_job_card_item_codes() if consider_job_card else [] + wip_warehouse = self._resolve_wip_warehouse(work_order) + extra_pct = flt( + frappe.db.get_single_value("Manufacturing Settings", "transfer_extra_materials_percentage") + ) + item_dict = frappe._dict() + for d in work_order.get("required_items"): + self._add_required_item( + item_dict, d, consider_job_card, job_card_items, wip_warehouse, extra_pct, work_order + ) + return item_dict + + def _resolve_wip_warehouse(self, work_order): + if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): + return work_order.wip_warehouse + return None + + def _add_required_item( + self, item_dict, d, consider_job_card, job_card_items, wip_warehouse, extra_pct, work_order + ): + if consider_job_card and d.item_code not in job_card_items: + return + additional_qty = extra_pct * flt(d.required_qty) / 100 if extra_pct else 0.0 + transfer_pending = ( + (flt(d.required_qty) + additional_qty) > flt(d.transferred_qty) + if additional_qty + else flt(d.required_qty) > flt(d.transferred_qty) + ) + can_transfer = transfer_pending or self.backflush_based_on == "Material Transferred for Manufacture" + if not can_transfer or not d.include_item_in_manufacturing: + return + self._build_required_item_row(item_dict, d, consider_job_card, wip_warehouse, work_order) + + def _build_required_item_row(self, item_dict, d, consider_job_card, wip_warehouse, work_order): + item_row = d.as_dict() + item_row["idx"] = len(item_dict) + 1 + if consider_job_card: + item_row["job_card_item"] = self._get_job_card_item(d.item_code) + if d.source_warehouse and not frappe.db.get_value("Warehouse", d.source_warehouse, "is_group"): + item_row["from_warehouse"] = d.source_warehouse + item_row["to_warehouse"] = wip_warehouse + if item_row["allow_alternative_item"]: + item_row["allow_alternative_item"] = work_order.allow_alternative_item + item_dict.setdefault(d.item_code, item_row) + + def _get_job_card_item(self, item_code): + return ( + frappe.db.get_value("Job Card Item", {"item_code": item_code, "parent": self.doc.get("job_card")}) + or None + ) + + def get_job_card_item_codes(self): + if not self.doc.get("job_card"): + return [] + + return frappe.get_all( + "Job Card Item", filters={"parent": self.doc.get("job_card")}, pluck="item_code", distinct=True + ) + + def on_submit(self): + self.update_job_card_and_work_order() + + def on_cancel(self): + self.update_job_card_and_work_order() + + def update_job_card_and_work_order(self): + if self.doc.job_card: + job_doc = frappe.get_doc("Job Card", self.doc.job_card) + job_doc.set_transferred_qty(update_status=True) + job_doc.set_transferred_qty_in_job_card_item(self.doc) + + if self.doc.work_order: + self._validate_work_order() + + if self.doc.fg_completed_qty: + if self.doc.docstatus == 1: + self.wo_doc.add_additional_items(self.doc) + else: + self.wo_doc.remove_additional_items(self.doc) + + self.wo_doc.run_method("update_work_order_qty") + + self.wo_doc.run_method("update_status") + if not self.wo_doc.operations: + self.wo_doc.set_actual_dates() + + +class MaterialRequestStockEntry(BaseMaterialTransferStockEntry): + def before_validate(self): + self.set_default_warehouse() + + def validate(self): + self.validate_warehouse() + self.validate_material_request() + + def get_material_request(self, item_row): + material_request = item_row.material_request or None + material_request_item = item_row.material_request_item or None + + if self.doc.outgoing_stock_entry: + parent_se = frappe.get_value( + "Stock Entry Detail", + item_row.ste_detail, + ["material_request", "material_request_item"], + as_dict=True, + ) + if parent_se: + material_request = parent_se.material_request + material_request_item = parent_se.material_request_item + + return material_request, material_request_item + + def validate_material_request(self): + for row in self.doc.items: + material_request, material_request_item = self.get_material_request(row) + if not material_request: + return + + mreq_item = frappe.db.get_value( + "Material Request Item", + {"name": material_request_item, "parent": material_request}, + ["item_code", "warehouse", "idx"], + as_dict=True, + ) + + if mreq_item.item_code != row.item_code: + frappe.throw( + _("Item for row {0} does not match Material Request").format(row.idx), + frappe.MappingMismatchError, + ) + + def on_submit(self): + self.update_transferred_qty() + if self.doc.add_to_transit: + self.set_material_request_transfer_status("In Transit") + + if self.doc.outgoing_stock_entry: + self.set_material_request_transfer_status("Completed") + + def on_cancel(self): + self.update_transferred_qty() + if self.doc.add_to_transit: + self.set_material_request_transfer_status("Not Started") + + if self.doc.outgoing_stock_entry: + self.set_material_request_transfer_status("In Transit") + + def set_material_request_transfer_status(self, status): + material_requests = [] + parent_se = ( + frappe.get_value("Stock Entry", self.doc.outgoing_stock_entry, "add_to_transit") + if self.doc.outgoing_stock_entry + else None + ) + for item in self.doc.items: + mr = item.get("material_request") + if mr not in material_requests and self.doc.outgoing_stock_entry and parent_se: + mr = frappe.get_value("Stock Entry Detail", item.ste_detail, "material_request") + if mr and mr not in material_requests: + status = self._update_mr_transfer_status(mr, status, material_requests) + + def _update_mr_transfer_status(self, material_request, status, material_requests): + material_requests.append(material_request) + if status == "Completed": + qty = get_transferred_qty(material_request) + if qty.get("transfer_qty") > qty.get("transferred_qty"): + status = "In Transit" + frappe.db.set_value("Material Request", material_request, "transfer_status", status) + return status + + +def _resolve_transfer_qty(desire_to_transfer, pending_to_issue, can_transfer): + # "No need for transfer but qty still pending" can occur when transferring multiple RM in different Stock Entries + if can_transfer: + return desire_to_transfer if desire_to_transfer > 0 else pending_to_issue + return pending_to_issue if pending_to_issue > 0 else 0 + + +def get_transferred_qty(material_request): + sed = frappe.qb.DocType("Stock Entry Detail") + return ( + frappe.qb.from_(sed) + .select(Sum(sed.transfer_qty).as_("transfer_qty"), Sum(sed.transferred_qty).as_("transferred_qty")) + .where((sed.material_request == material_request) & (sed.docstatus == 1)) + ).run(as_dict=True)[0] diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/serial_batch.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/serial_batch.py new file mode 100644 index 00000000000..517affad752 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/serial_batch.py @@ -0,0 +1,373 @@ +from collections import defaultdict + +import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt, nowdate + +from erpnext.manufacturing.doctype.bom.bom import get_backflush_based_on +from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_or_batch_items +from erpnext.stock.utils import get_combine_datetime + +from .base import BaseStockEntry + + +class StockEntrySABB(BaseStockEntry): + def make_serial_and_batch_bundle_for_outward(self): + serial_or_batch_items = get_serial_or_batch_items(self.doc.items) + if not serial_or_batch_items: + return + + serial_nos, batch_nos = self.get_serial_batch_fields_for_subcontracting_inward() + already_picked_serial_nos = [] + + for row in self.doc.items: + if row.use_serial_batch_fields or not row.s_warehouse: + continue + if row.item_code not in serial_or_batch_items: + continue + + bundle_doc = self._create_or_update_bundle_for_row( + row, serial_nos, batch_nos, already_picked_serial_nos + ) + if not bundle_doc: + continue + + for entry in bundle_doc.entries: + if entry.serial_no: + already_picked_serial_nos.append(entry.serial_no) + + row.serial_and_batch_bundle = bundle_doc.name + + def _create_or_update_bundle_for_row(self, row, serial_nos, batch_nos, already_picked_serial_nos): + if row.serial_and_batch_bundle and abs(row.transfer_qty) != abs( + frappe.get_cached_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty") + ): + return SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.s_warehouse, + "serial_and_batch_bundle": row.serial_and_batch_bundle, + "type_of_transaction": "Outward", + "ignore_serial_nos": already_picked_serial_nos, + "qty": row.transfer_qty * -1, + } + ).update_serial_and_batch_entries( + serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name) + ) + + if not row.serial_and_batch_bundle and frappe.get_single_value( + "Stock Settings", "auto_create_serial_and_batch_bundle_for_outward" + ): + return SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": row.s_warehouse, + "posting_datetime": get_combine_datetime(self.doc.posting_date, self.doc.posting_time), + "voucher_type": self.doc.doctype, + "voucher_detail_no": row.name, + "qty": row.transfer_qty * -1, + "ignore_serial_nos": already_picked_serial_nos, + "type_of_transaction": "Outward", + "company": self.doc.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle( + serial_nos=serial_nos.get(row.name), batch_nos=batch_nos.get(row.name) + ) + + return None + + def get_serial_nos_and_batches_from_sres(self, scio_detail, only_pending=True): + serial_nos, batch_nos = [], frappe._dict() + + table = frappe.qb.DocType("Stock Reservation Entry") + child_table = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(table) + .join(child_table) + .on(table.name == child_table.parent) + .select(child_table.serial_no, child_table.batch_no, child_table.qty) + .where((table.docstatus == 1) & (table.voucher_detail_no == scio_detail)) + ) + + if only_pending: + query = query.where(child_table.qty != child_table.delivered_qty) + else: + query = query.where(child_table.delivered_qty > 0) + + for d in query.run(as_dict=True): + if d.serial_no and d.serial_no not in serial_nos: + serial_nos.append(d.serial_no) + if d.batch_no and d.batch_no not in batch_nos: + batch_nos[d.batch_no] = d.qty + + return serial_nos, batch_nos + + def get_serial_batch_fields_for_subcontracting_inward(self): + serial_nos, batch_nos = frappe._dict(), frappe._dict() + for row in self.doc.items: + if self.doc.purpose in [ + "Return Raw Material to Customer", + "Subcontracting Delivery", + "Subcontracting Return", + ]: + if not row.serial_and_batch_bundle: + serial_nos_list, batch_nos_list = self.get_serial_nos_and_batches_from_sres( + row.scio_detail, only_pending=self.doc.purpose != "Subcontracting Return" + ) + + if len(batch_nos_list) > 1: + row.use_serial_batch_fields = 0 + + if row.use_serial_batch_fields: + if serial_nos_list and not row.serial_no: + row.serial_no = "\n".join(serial_nos_list) + if batch_nos_list and not row.batch_no: + row.batch_no = next(iter(batch_nos_list.keys())) + + serial_nos[row.name], batch_nos[row.name] = serial_nos_list, batch_nos_list + + return serial_nos, batch_nos + + def get_available_reserved_materials(self): + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_reserved_materials, + ) + + voucher_no = self.doc.work_order or self.doc.subcontracting_order + reserved_entries = get_reserved_materials(voucher_no) + if not reserved_entries: + return {} + + itemwise_serial_batch_qty = frappe._dict() + + for d in reserved_entries: + key = (d.item_code, d.warehouse) + if key not in itemwise_serial_batch_qty: + itemwise_serial_batch_qty[key] = frappe._dict( + { + "serial_no": [], + "batch_no": defaultdict(float), + "batchwise_sn": defaultdict(list), + } + ) + + details = itemwise_serial_batch_qty[key] + if d.batch_no: + details.batch_no[d.batch_no] += d.qty + if d.serial_no: + details.batchwise_sn[d.batch_no].extend(d.serial_no.split("\n")) + elif d.serial_no: + details.serial_no.append(d.serial_no) + + return itemwise_serial_batch_qty + + def set_serial_batch_based_on_reservation(self): + if self.doc.work_order and frappe.get_cached_value( + "Work Order", self.doc.work_order, "reserve_stock" + ): + skip_transfer = frappe.get_cached_value("Work Order", self.doc.work_order, "skip_transfer") + backflush_based_on = get_backflush_based_on(self.doc.bom_no) + + if ( + self.doc.purpose not in ["Material Transfer for Manufacture"] + and backflush_based_on != "BOM" + and not skip_transfer + ): + return + + reservation_entries = self.get_available_reserved_materials() + if not reservation_entries: + return + + new_items_to_add = [] + for d in self.doc.items: + if d.serial_and_batch_bundle or d.serial_no or d.batch_no: + continue + + key = (d.item_code, d.s_warehouse) + if details := reservation_entries.get(key): + self._apply_batch_reservation_to_item(d, details, new_items_to_add) + d.use_serial_batch_fields = 1 + + for new_row in new_items_to_add: + self.doc.append("items", new_row) + + self._sort_and_reindex_items() + + def _apply_batch_reservation_to_item(self, d, details, new_items_to_add): + original_qty = d.qty + if batches := details.get("batch_no"): + original_qty = self._distribute_batches_to_item( + d, batches, details, new_items_to_add, original_qty + ) + if details.get("serial_no"): + d.serial_no = "\n".join(details.get("serial_no")[: cint(d.qty)]) + + def _distribute_batches_to_item(self, d, batches, details, new_items_to_add, original_qty): + for batch_no, qty in batches.items(): + if original_qty <= 0: + break + if qty <= 0: + continue + if d.batch_no: + original_qty, _ = self._make_overflow_batch_row( + d, batches, details, new_items_to_add, batch_no, qty, original_qty + ) + else: + self._assign_batch_to_item(d, batches, details, batch_no, qty) + return original_qty + + def _make_overflow_batch_row(self, d, batches, details, new_items_to_add, batch_no, qty, original_qty): + new_row = frappe.copy_doc(d) + new_row.name = None + new_row.batch_no = batch_no + new_row.qty = qty + new_row.idx = d.idx + 1 + if new_row.batch_no and details.get("batchwise_sn"): + new_row.serial_no = "\n".join(details.get("batchwise_sn")[new_row.batch_no][: cint(new_row.qty)]) + new_items_to_add.append(new_row) + batches[batch_no] -= qty + return original_qty - qty, new_row + + def _assign_batch_to_item(self, d, batches, details, batch_no, qty): + if qty >= d.qty: + d.batch_no = batch_no + batches[batch_no] -= d.qty + else: + d.batch_no = batch_no + d.qty = qty + batches[batch_no] = 0 + if d.batch_no and details.get("batchwise_sn"): + d.serial_no = "\n".join(details.get("batchwise_sn")[d.batch_no][: cint(d.qty)]) + + def _sort_and_reindex_items(self): + sorted_items = sorted(self.doc.items, key=lambda x: x.item_code) + if self.doc.purpose == "Manufacture": + # ensure finished item at last + sorted_items = sorted(sorted_items, key=lambda x: cstr(x.t_warehouse)) + + for idx, row in enumerate(sorted_items, start=1): + row.idx = idx + + self.doc.set("items", sorted_items) + + +def create_serial_and_batch_bundle(parent_doc, row, child, type_of_transaction=None): + item_details = frappe.get_cached_value( + "Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1 + ) + if not (item_details.has_serial_no or item_details.has_batch_no): + return + doc = _make_bundle_doc(parent_doc, child, type_of_transaction or "Inward") + _populate_bundle_entries(doc, row, child) + if not doc.entries: + return None + return doc.insert(ignore_permissions=True).name + + +def _make_bundle_doc(parent_doc, child, type_of_transaction): + return frappe.get_doc( + { + "doctype": "Serial and Batch Bundle", + "voucher_type": "Stock Entry", + "item_code": child.item_code, + "warehouse": child.warehouse, + "type_of_transaction": type_of_transaction, + "posting_date": parent_doc.posting_date, + "posting_time": parent_doc.posting_time, + } + ) + + +def _populate_bundle_entries(doc, row, child): + precision = frappe.get_precision("Stock Entry Detail", "qty") + if row.serial_nos and row.batches_to_be_consume: + _append_serial_batch_entries(doc, row, child, precision) + elif row.serial_nos: + doc.has_serial_no = 1 + for serial_no in row.serial_nos: + doc.append("entries", {"serial_no": serial_no, "warehouse": row.warehouse, "qty": -1}) + elif row.batches_to_be_consume: + _append_batch_entries(doc, row) + + +def _append_serial_batch_entries(doc, row, child, precision): + doc.has_serial_no = 1 + doc.has_batch_no = 1 + batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row) + for batch_no, qty in row.batches_to_be_consume.items(): + while flt(qty, precision) > 0: + qty -= 1 + doc.append( + "entries", + { + "batch_no": batch_no, + "serial_no": batchwise_serial_nos.get(batch_no).pop(0), + "warehouse": row.warehouse, + "qty": -1, + }, + ) + + +def _append_batch_entries(doc, row): + precision = frappe.get_precision("Serial and Batch Entry", "qty") + doc.has_batch_no = 1 + for batch_no, qty in row.batches_to_be_consume.items(): + if flt(qty, precision) > 0: + doc.append( + "entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": flt(qty, precision) * -1} + ) + + +def get_batchwise_serial_nos(item_code, row): + batchwise_serial_nos = {} + + for batch_no in row.batches_to_be_consume: + serial_nos = frappe.get_all( + "Serial No", + filters={"item_code": item_code, "batch_no": batch_no, "name": ("in", row.serial_nos)}, + ) + + if serial_nos: + batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos]) + + return batchwise_serial_nos + + +@frappe.whitelist() +def get_expired_batch_items(): + expired_batches = get_expired_batches() + if not expired_batches: + return [] + return _enrich_expired_batches_with_stock(expired_batches) + + +def _enrich_expired_batches_with_stock(expired_batches): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import get_auto_batch_nos + + expired_batches_stock = get_auto_batch_nos( + frappe._dict({"batch_no": list(expired_batches.keys()), "for_stock_levels": True}) + ) + for row in expired_batches_stock: + row.update(expired_batches.get(row.batch_no)) + return expired_batches_stock + + +def get_expired_batches(): + batch = frappe.qb.DocType("Batch") + + data = ( + frappe.qb.from_(batch) + .select(batch.item, batch.name.as_("batch_no"), batch.stock_uom) + .where((batch.expiry_date <= nowdate()) & (batch.expiry_date.isnotnull())) + ).run(as_dict=True) + + if not data: + return [] + + expired_batches = frappe._dict() + for row in data: + expired_batches[row.batch_no] = row + + return expired_batches diff --git a/erpnext/stock/doctype/stock_entry/stock_entry_handler/subcontracting.py b/erpnext/stock/doctype/stock_entry/stock_entry_handler/subcontracting.py new file mode 100644 index 00000000000..ef60504b083 --- /dev/null +++ b/erpnext/stock/doctype/stock_entry/stock_entry_handler/subcontracting.py @@ -0,0 +1,264 @@ +import json + +import frappe +from frappe import _, bold +from frappe.model.document import Document +from frappe.query_builder.functions import Sum +from frappe.utils import flt + +from erpnext.stock.utils import get_bin + +from .base import BaseStockEntry + + +class SendToSubcontractorStockEntry(BaseStockEntry): + def validate(self): + self.validate_subcontract_order() + + def validate_subcontract_order(self): + """Throw exception if more raw material is transferred against Subcontract Order than in + the raw materials supplied table""" + backflush_raw_materials_based_on = frappe.db.get_single_value( + "Buying Settings", "backflush_raw_materials_of_subcontract_based_on" + ) + + if backflush_raw_materials_based_on == "BOM": + subcontract_order = frappe.get_doc( + self.doc.subcontract_data.order_doctype, self.doc.get(self.doc.subcontract_data.order_field) + ) + for se_item in self.doc.items: + self.validate_subcontracting_order_for_bom(se_item, subcontract_order) + + elif backflush_raw_materials_based_on == "Material Transferred for Subcontract": + for row in self.doc.items: + self.validate_subcontracting_order_for_transfer(row) + + def validate_subcontracting_order_for_bom(self, child_row, subcontract_order): + item_code = child_row.original_item or child_row.item_code + required_qty = self._get_required_qty_for_bom(item_code, child_row, subcontract_order) + qty_allowance = flt(frappe.db.get_single_value("Buying Settings", "over_transfer_allowance")) + total_allowed = required_qty + (required_qty * qty_allowance / 100) + self._validate_transfer_qty(child_row, item_code, total_allowed) + self._link_rm_detail_if_missing(child_row, item_code) + + def _get_required_qty_for_bom(self, item_code, child_row, subcontract_order): + required_qty = sum( + flt(d.required_qty) for d in subcontract_order.supplied_items if d.rm_item_code == item_code + ) + if not required_qty and child_row.allow_alternative_item: + original_item_code = frappe.get_value( + "Item Alternative", {"alternative_item_code": item_code}, "item_code" + ) + required_qty = sum( + flt(d.required_qty) + for d in subcontract_order.supplied_items + if d.rm_item_code == original_item_code + ) + if not required_qty: + frappe.throw( + _("Item {0} not found in 'Raw Materials Supplied' table in {1} {2}").format( + item_code, + self.doc.subcontract_data.order_doctype, + self.doc.get(self.doc.subcontract_data.order_field), + ) + ) + return required_qty + + def _validate_transfer_qty(self, child_row, item_code, total_allowed): + total_supplied = self.get_total_supplied_qty(child_row) + total_returned = ( + self.get_total_returned_qty(child_row) + if self.doc.subcontract_data.order_doctype == "Subcontracting Order" + else 0 + ) + if flt( + total_supplied + child_row.transfer_qty - total_returned, child_row.precision("transfer_qty") + ) > flt(total_allowed, child_row.precision("transfer_qty")): + frappe.throw( + _("Row #{0}: Item {1} cannot be transferred more than {2} against {3} {4}").format( + child_row.idx, + item_code, + total_allowed, + self.doc.subcontract_data.order_doctype, + self.doc.get(self.doc.subcontract_data.order_field), + ) + ) + + def _link_rm_detail_if_missing(self, child_row, item_code): + if not child_row.get(self.doc.subcontract_data.rm_detail_field): + order_rm_detail = self.get_order_rm_detail(child_row) + if order_rm_detail: + child_row.db_set(self.doc.subcontract_data.rm_detail_field, order_rm_detail) + elif not child_row.allow_alternative_item: + frappe.throw( + _("Row {0}# Item {1} not found in 'Raw Materials Supplied' table in {2} {3}").format( + child_row.idx, + item_code, + self.doc.subcontract_data.order_doctype, + self.doc.get(self.doc.subcontract_data.order_field), + ) + ) + + def validate_subcontracting_order_for_transfer(self, child_row): + if not child_row.subcontracted_item: + frappe.throw( + _("Row {0}: Subcontracted Item is mandatory for the raw material {1}").format( + child_row.idx, bold(child_row.item_code) + ) + ) + elif not child_row.get(self.doc.subcontract_data.rm_detail_field): + order_rm_detail = self.get_order_rm_detail(child_row) + if order_rm_detail: + child_row.db_set(self.doc.subcontract_data.rm_detail_field, order_rm_detail) + + def get_total_supplied_qty(self, child_row): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + order_filter = self._get_supplied_qty_order_filter(se, sed, child_row) + return ( + frappe.qb.from_(se) + .inner_join(sed) + .on(se.name == sed.parent) + .select(Sum(sed.transfer_qty)) + .where( + (se.purpose == "Send to Subcontractor") + & (se.docstatus == 1) + & (sed.item_code == child_row.item_code) + & order_filter + ) + ).run()[0][0] or 0 + + def _get_supplied_qty_order_filter(self, se, sed, child_row): + if self.doc.subcontract_data.order_doctype == "Purchase Order": + return (se.purchase_order == self.doc.purchase_order) & (sed.po_detail == self.doc.po_detail) + return (se.subcontracting_order == self.doc.subcontracting_order) & ( + sed.sco_rm_detail == child_row.sco_rm_detail + ) + + def get_total_returned_qty(self, child_row): + se = frappe.qb.DocType("Stock Entry") + sed = frappe.qb.DocType("Stock Entry Detail") + return ( + frappe.qb.from_(se) + .inner_join(sed) + .on(se.name == sed.parent) + .select(Sum(sed.transfer_qty)) + .where( + (se.purpose == "Material Transfer") + & (se.docstatus == 1) + & (se.is_return == 1) + & (sed.item_code == child_row.item_code) + & (sed.sco_rm_detail == child_row.sco_rm_detail) + & (se.subcontracting_order == self.doc.subcontracting_order) + ) + ).run()[0][0] or 0 + + def get_order_rm_detail(self, child_row): + filters = { + "parent": self.doc.get(self.doc.subcontract_data.order_field), + "docstatus": 1, + "rm_item_code": child_row.item_code, + "main_item_code": child_row.subcontracted_item, + } + + return frappe.db.get_value(self.doc.subcontract_data.order_supplied_items_field, filters, "name") + + def on_submit(self): + self.update_subcontract_order_supplied_items() + + def on_cancel(self): + self.update_subcontract_order_supplied_items() + + def update_subcontract_order_supplied_items(self): + if not self.doc.get(self.doc.subcontract_data.order_field): + return + order_supplied_items = self._get_order_supplied_items() + supplied_items = self._get_supplied_items_details() + self._update_supplied_items_in_order(order_supplied_items, supplied_items) + self._update_reserved_qty_for_subcontracting(order_supplied_items) + + def _get_order_supplied_items(self): + return frappe.db.get_all( + self.doc.subcontract_data.order_supplied_items_field, + filters={"parent": self.doc.get(self.doc.subcontract_data.order_field)}, + fields=["name", "rm_item_code", "reserve_warehouse"], + ) + + def _get_supplied_items_details(self): + return get_supplied_items( + self.doc.get(self.doc.subcontract_data.order_field), + self.doc.subcontract_data.rm_detail_field, + self.doc.subcontract_data.order_field, + ) + + def _update_supplied_items_in_order(self, order_supplied_items, supplied_items): + for row in order_supplied_items: + item = supplied_items.get(row.name) or { + "supplied_qty": 0, + "returned_qty": 0, + "total_supplied_qty": 0, + } + frappe.db.set_value(self.doc.subcontract_data.order_supplied_items_field, row.name, item) + + def _update_reserved_qty_for_subcontracting(self, order_supplied_items): + item_wh = {x.get("rm_item_code"): x.get("reserve_warehouse") for x in order_supplied_items} + for d in self.doc.get("items"): + item_code = d.get("original_item") or d.get("item_code") + reserve_warehouse = item_wh.get(item_code) + if not (reserve_warehouse and item_code): + continue + stock_bin = get_bin(item_code, reserve_warehouse) + stock_bin.update_reserved_qty_for_sub_contracting() + + +def get_supplied_items( + subcontract_order, rm_detail_field="sco_rm_detail", subcontract_order_field="subcontracting_order" +): + fields = [ + "`tabStock Entry Detail`.`transfer_qty`", + "`tabStock Entry`.`is_return`", + f"`tabStock Entry Detail`.`{rm_detail_field}`", + "`tabStock Entry Detail`.`item_code`", + ] + + filters = [ + ["Stock Entry", "docstatus", "=", 1], + ["Stock Entry", subcontract_order_field, "=", subcontract_order], + ] + + supplied_item_details = {} + for row in frappe.get_all("Stock Entry", fields=fields, filters=filters): + if not row.get(rm_detail_field): + continue + + key = row.get(rm_detail_field) + if key not in supplied_item_details: + supplied_item_details.setdefault( + key, frappe._dict({"supplied_qty": 0, "returned_qty": 0, "total_supplied_qty": 0}) + ) + + supplied_item = supplied_item_details[key] + + if row.is_return: + supplied_item.returned_qty += row.transfer_qty + else: + supplied_item.supplied_qty += row.transfer_qty + + supplied_item.total_supplied_qty = flt(supplied_item.supplied_qty) - flt(supplied_item.returned_qty) + + return supplied_item_details + + +@frappe.whitelist() +def get_items_from_subcontract_order(source_name: str, target_doc: str | Document | None = None): + from erpnext.controllers.subcontracting_controller import make_rm_stock_entry + + if isinstance(target_doc, str): + target_doc = frappe.get_doc(json.loads(target_doc)) + + order_doctype = "Purchase Order" if target_doc.purchase_order else "Subcontracting Order" + target_doc = make_rm_stock_entry( + subcontract_order=source_name, order_doctype=order_doctype, target_doc=target_doc + ) + + return target_doc diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index e1b541529e2..e42641cdfa8 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -184,7 +184,7 @@ class TestStockEntry(ERPNextTestSuite): for d in mr.items: items.append(d.item_code) - self.assertTrue(item_code in items) + self.assertIn(item_code, items) def test_add_to_transit_entry(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse @@ -908,7 +908,10 @@ class TestStockEntry(ERPNextTestSuite): if d.s_warehouse: rm_cost += d.amount fg_cost = next(filter(lambda x: x.item_code == "_Test FG Item", s.get("items"))).amount - secondary_item_cost = next(filter(lambda x: x.type or x.is_legacy_scrap_item, s.get("items"))).amount + secondary_item_cost = next( + filter(lambda x: x.secondary_item_type or x.is_legacy_scrap_item, s.get("items")) + ).amount + self.assertEqual(fg_cost, flt(rm_cost - secondary_item_cost, 2)) # When Stock Entry has only FG + Scrap @@ -950,7 +953,7 @@ class TestStockEntry(ERPNextTestSuite): stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) stock_entry.insert() - self.assertTrue("_Test Variant Item-S" in [d.item_code for d in stock_entry.items]) + self.assertIn("_Test Variant Item-S", [d.item_code for d in stock_entry.items]) def test_nagative_stock_for_batch(self): item = make_item( @@ -1026,7 +1029,7 @@ class TestStockEntry(ERPNextTestSuite): basic_rate=row.basic_rate or 100, ) - if row.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: row.item_code = secondary_item row.uom = frappe.db.get_value("Item", secondary_item, "stock_uom") row.stock_uom = frappe.db.get_value("Item", secondary_item, "stock_uom") @@ -1034,10 +1037,16 @@ class TestStockEntry(ERPNextTestSuite): stock_entry.inspection_required = 1 stock_entry.save() - self.assertTrue([row.item_code for row in stock_entry.items if row.type or row.is_legacy_scrap_item]) + self.assertTrue( + [ + row.item_code + for row in stock_entry.items + if row.secondary_item_type or row.is_legacy_scrap_item + ] + ) for row in stock_entry.items: - if not row.type and not row.is_legacy_scrap_item: + if not row.secondary_item_type and not row.is_legacy_scrap_item: qc = frappe.get_doc( { "doctype": "Quality Inspection", @@ -1057,7 +1066,7 @@ class TestStockEntry(ERPNextTestSuite): stock_entry.reload() stock_entry.submit() for row in stock_entry.items: - if row.type or row.is_legacy_scrap_item: + if row.secondary_item_type or row.is_legacy_scrap_item: self.assertFalse(row.quality_inspection) else: self.assertTrue(row.quality_inspection) @@ -1783,7 +1792,7 @@ class TestStockEntry(ERPNextTestSuite): def test_use_serial_and_batch_fields(self): item = make_item( - "Test Use Serial and Batch Item SN Item", + "Test Use Serial and Batch Item SN Item - A", {"has_serial_no": 1, "is_stock_item": 1}, ) @@ -2266,7 +2275,7 @@ class TestStockEntry(ERPNextTestSuite): make_stock_entry(item_code=rm_item2, target=warehouse, qty=5, purpose="Material Receipt") bom_no = make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2]).name - se = make_stock_entry(item_code=fg_item, qty=1, purpose="Manufacture", do_not_save=True) + se = make_stock_entry(item_code=fg_item, qty=1, purpose="Repack", do_not_save=True) se.from_bom = 1 se.use_multi_level_bom = 1 se.bom_no = bom_no @@ -2304,7 +2313,6 @@ class TestStockEntry(ERPNextTestSuite): se.to_warehouse = warehouse se.get_items() - # Verify FG as source (being consumed) fg_items = [d for d in se.items if d.is_finished_item] self.assertEqual(len(fg_items), 1) @@ -2331,7 +2339,9 @@ class TestStockEntry(ERPNextTestSuite): "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} ) def test_sample_retention_stock_entry(self): - from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + move_sample_to_retention_warehouse, + ) warehouse = "_Test Warehouse - _TC" retain_sample_item = make_item( @@ -2493,6 +2503,472 @@ class TestStockEntry(ERPNextTestSuite): self.assertEqual(se.items[2].amount, 5) +class TestStockEntryCoverage(ERPNextTestSuite): + """Tests for functions previously lacking dedicated coverage.""" + + # ── ceil_qty_if_uom_has_whole_number ────────────────────────────────────── + + def test_ceil_qty_rounds_up_for_whole_number_uom(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + ceil_qty_if_uom_has_whole_number, + ) + + frappe.set_value("UOM", "Nos", "must_be_whole_number", 1) + self.assertEqual(ceil_qty_if_uom_has_whole_number(2.3, "Nos"), 3) + frappe.set_value("UOM", "Nos", "must_be_whole_number", 0) + + def test_ceil_qty_no_rounding_for_decimal_uom(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + ceil_qty_if_uom_has_whole_number, + ) + + frappe.set_value("UOM", "Nos", "must_be_whole_number", 0) + self.assertEqual(ceil_qty_if_uom_has_whole_number(2.3, "Nos"), 2.3) + + # ── get_uom_details ──────────────────────────────────────────────────────── + + def test_get_uom_details_returns_conversion_factor_and_transfer_qty(self): + from erpnext.stock.doctype.stock_entry.stock_entry import get_uom_details + + result = get_uom_details("_Test Item", "Nos", 5) + self.assertEqual(flt(result.get("conversion_factor")), 1.0) + self.assertEqual(flt(result.get("transfer_qty")), 5.0) + + # ── get_warehouse_details ────────────────────────────────────────────────── + + def test_get_warehouse_details_returns_actual_qty_and_rate(self): + import json + + from frappe.utils import nowdate, nowtime + + from erpnext.stock.doctype.stock_entry.stock_entry import get_warehouse_details + + make_stock_entry( + item_code="_Test Item", + target="_Test Warehouse - _TC", + qty=10, + basic_rate=100, + ) + + args = { + "item_code": "_Test Item", + "warehouse": "_Test Warehouse - _TC", + "posting_date": nowdate(), + "posting_time": nowtime(), + } + result = get_warehouse_details(json.dumps(args)) + self.assertGreater(result.get("actual_qty", 0), 0) + self.assertGreater(result.get("basic_rate", 0), 0) + + def test_get_warehouse_details_empty_args_returns_empty_dict(self): + from erpnext.stock.doctype.stock_entry.stock_entry import get_warehouse_details + + self.assertEqual(get_warehouse_details({}), {}) + + # ── get_work_order_details ───────────────────────────────────────────────── + + def test_get_work_order_details_returns_correct_fields(self): + from erpnext.stock.doctype.stock_entry.stock_entry import get_work_order_details + + bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1}) + wo = frappe.new_doc("Work Order") + wo.update( + { + "company": "_Test Company", + "fg_warehouse": "_Test Warehouse 1 - _TC", + "production_item": "_Test FG Item 2", + "bom_no": bom_no, + "qty": 3.0, + "stock_uom": "_Test UOM", + "wip_warehouse": "_Test Warehouse - _TC", + } + ) + wo.insert() + wo.submit() + + result = get_work_order_details(wo.name, "_Test Company") + self.assertEqual(result["bom_no"], bom_no) + self.assertEqual(result["fg_completed_qty"], 3.0) + self.assertEqual(result["from_bom"], 1) + self.assertEqual(result["wip_warehouse"], wo.wip_warehouse) + + # ── get_production_item_details ──────────────────────────────────────────── + + def test_get_production_item_details_from_bom(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + get_production_item_details, + ) + + bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1}) + result = get_production_item_details(bom_no=bom_no) + self.assertEqual(result.name, "_Test FG Item 2") + self.assertIsNotNone(result.stock_uom) + + def test_get_production_item_details_from_work_order(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + get_production_item_details, + ) + + bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1}) + wo = frappe.new_doc("Work Order") + wo.update( + { + "company": "_Test Company", + "fg_warehouse": "_Test Warehouse 1 - _TC", + "production_item": "_Test FG Item 2", + "bom_no": bom_no, + "qty": 1.0, + "stock_uom": "_Test UOM", + "wip_warehouse": "_Test Warehouse - _TC", + } + ) + wo.insert() + wo.submit() + + result = get_production_item_details(work_order=wo.name) + self.assertEqual(result.name, "_Test FG Item 2") + + # ── get_bom_items ────────────────────────────────────────────────────────── + + def test_get_bom_items_returns_raw_materials_with_structure(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import get_bom_items + + bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1}) + items = get_bom_items(bom_no) + self.assertGreater(len(items), 0) + for item in items: + self.assertIn("item_code", item) + self.assertIn("qty", item) + + def test_get_bom_items_scales_qty_proportionally(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import get_bom_items + + bom_no = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "is_default": 1, "docstatus": 1}) + items_1 = {i["item_code"]: i["qty"] for i in get_bom_items(bom_no, qty=1)} + items_2 = {i["item_code"]: i["qty"] for i in get_bom_items(bom_no, qty=2)} + for item_code, qty_at_1 in items_1.items(): + self.assertAlmostEqual(items_2[item_code], qty_at_1 * 2, places=4) + + # ── validate_sample_quantity ─────────────────────────────────────────────── + + @ERPNextTestSuite.change_settings( + "Stock Settings", {"sample_retention_warehouse": "_Test Warehouse 1 - _TC"} + ) + def test_validate_sample_quantity_raises_when_sample_exceeds_received_qty(self): + from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import ( + validate_sample_quantity, + ) + + item = make_item( + "_Sample Qty Excess Item", + {"is_stock_item": 1, "retain_sample": 1, "sample_quantity": 2}, + ) + self.assertRaises(frappe.ValidationError, validate_sample_quantity, item.name, 10, 5) + + # ── get_expired_batches ──────────────────────────────────────────────────── + + def test_get_expired_batches_includes_expired_batch(self): + from erpnext.stock.doctype.batch.test_batch import make_new_batch + from erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch import ( + get_expired_batches, + ) + + item = make_item("_Test Expired Batch Item", {"is_stock_item": 1, "has_batch_no": 1}) + batch = make_new_batch( + batch_id=frappe.generate_hash("", 5), + item_code=item.name, + expiry_date=add_days(today(), -1), + ) + + expired = get_expired_batches() + self.assertIn(batch.name, expired) + self.assertEqual(expired[batch.name].item, item.name) + + def test_get_expired_batches_excludes_future_batch(self): + from erpnext.stock.doctype.batch.test_batch import make_new_batch + from erpnext.stock.doctype.stock_entry.stock_entry_handler.serial_batch import ( + get_expired_batches, + ) + + item = make_item("_Test Future Batch Item", {"is_stock_item": 1, "has_batch_no": 1}) + future_batch = make_new_batch( + batch_id=frappe.generate_hash("", 5), + item_code=item.name, + expiry_date=add_days(today(), 10), + ) + + expired = get_expired_batches() + self.assertNotIn(future_batch.name, expired) + + # ── validate_source_stock_entry ──────────────────────────────────────────── + + def test_validate_source_stock_entry_skips_when_no_source(self): + se = frappe.new_doc("Stock Entry") + se.source_stock_entry = None + se.validate_source_stock_entry() # must not raise + + def test_validate_source_stock_entry_throws_on_work_order_mismatch(self): + source_se = make_stock_entry( + item_code="_Test Item", + target="_Test Warehouse - _TC", + qty=1, + basic_rate=100, + ) + frappe.db.set_value("Stock Entry", source_se.name, "work_order", "WO-FAKE-SOURCE-001") + + se = frappe.new_doc("Stock Entry") + se.source_stock_entry = source_se.name + se.work_order = "WO-FAKE-TARGET-999" + + self.assertRaises(frappe.ValidationError, se.validate_source_stock_entry) + + def test_validate_source_stock_entry_passes_with_matching_work_order(self): + source_se = make_stock_entry( + item_code="_Test Item", + target="_Test Warehouse - _TC", + qty=1, + basic_rate=100, + ) + frappe.db.set_value("Stock Entry", source_se.name, "work_order", "WO-SAME-001") + + se = frappe.new_doc("Stock Entry") + se.source_stock_entry = source_se.name + se.work_order = "WO-SAME-001" + se.validate_source_stock_entry() # must not raise + + # ── validate_job_card_fg_item ────────────────────────────────────────────── + + def test_validate_job_card_fg_item_skips_when_no_job_card(self): + se = frappe.new_doc("Stock Entry") + se.job_card = None + se.validate_job_card_fg_item() # must not raise + + def test_validate_job_card_fg_item_throws_when_fg_item_mismatches(self): + wrong_fg = make_item("_JC Wrong FG Item", {"is_stock_item": 1}).name + + jc_name = frappe.db.get_value("Job Card", {"docstatus": 1, "finished_good": ("!=", "")}) + if not jc_name: + return # skip if no suitable job card in test data + + jc = frappe.db.get_value("Job Card", jc_name, ["finished_good"], as_dict=1) + if jc.finished_good == wrong_fg: + return # skip if the wrong_fg happens to match + + se = frappe.new_doc("Stock Entry") + se.job_card = jc_name + se.append("items", {"item_code": wrong_fg, "is_finished_item": 1, "qty": 1}) + self.assertRaises(frappe.ValidationError, se.validate_job_card_fg_item) + + # ── validate_job_card_item ───────────────────────────────────────────────── + + def test_validate_job_card_item_skips_when_no_job_card(self): + se = frappe.new_doc("Stock Entry") + se.job_card = None + se.validate_job_card_item() # must not raise + + def test_validate_job_card_item_skips_for_manufacture_purpose(self): + se = frappe.new_doc("Stock Entry") + se.job_card = "SOME-JC-001" + se.purpose = "Manufacture" + se.validate_job_card_item() # must not raise even with a job card set + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0}) + def test_validate_job_card_item_throws_when_job_card_item_ref_missing(self): + jc_name = frappe.db.get_value("Job Card", {"docstatus": 1}) + if not jc_name: + return # skip if no job cards in test data + + se = frappe.new_doc("Stock Entry") + se.job_card = jc_name + se.purpose = "Material Transfer for Manufacture" + se.append( + "items", + { + "item_code": "_Test Item", + "s_warehouse": "_Test Warehouse - _TC", + "qty": 1, + "job_card_item": None, + }, + ) + self.assertRaises(frappe.ValidationError, se.validate_job_card_item) + + # ── get_available_materials ──────────────────────────────────────────────── + + def test_get_available_materials_tracks_transferred_qty(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as _make_stock_entry, + ) + from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import ( + get_available_materials, + ) + + fg = make_item("_AM FG Item", {"is_stock_item": 1}).name + rm = make_item("_AM RM Item", {"is_stock_item": 1}).name + source_wh = "_Test Warehouse - _TC" + wip_wh = "_Test Warehouse 1 - _TC" + + make_stock_entry(item_code=rm, target=source_wh, qty=20, purpose="Material Receipt") + + bom_no = make_bom(item=fg, raw_materials=[rm]).name + wo = frappe.new_doc("Work Order") + wo.update( + { + "company": "_Test Company", + "fg_warehouse": "_Test Warehouse 1 - _TC", + "production_item": fg, + "bom_no": bom_no, + "qty": 2.0, + "stock_uom": "Nos", + "wip_warehouse": wip_wh, + } + ) + wo.insert() + wo.submit() + + transfer_se = frappe.get_doc(_make_stock_entry(wo.name, "Material Transfer for Manufacture", 2)) + for d in transfer_se.items: + d.s_warehouse = source_wh + transfer_se.insert() + transfer_se.submit() + + materials = get_available_materials(wo.name) + self.assertGreater(len(materials), 0) + + key = (rm, wip_wh) + self.assertIn(key, materials) + self.assertGreater(materials[key].qty, 0) + + def test_get_available_materials_reduces_qty_after_consumption(self): + from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as _make_stock_entry, + ) + from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import ( + get_available_materials, + ) + + fg = make_item("_AM2 FG Item", {"is_stock_item": 1}).name + rm = make_item("_AM2 RM Item", {"is_stock_item": 1}).name + source_wh = "_Test Warehouse - _TC" + wip_wh = "_Test Warehouse 1 - _TC" + + make_stock_entry(item_code=rm, target=source_wh, qty=20, purpose="Material Receipt") + + bom_no = make_bom(item=fg, raw_materials=[rm]).name + wo = frappe.new_doc("Work Order") + wo.update( + { + "company": "_Test Company", + "fg_warehouse": "_Test Warehouse 1 - _TC", + "production_item": fg, + "bom_no": bom_no, + "qty": 2.0, + "stock_uom": "Nos", + "wip_warehouse": wip_wh, + } + ) + wo.insert() + wo.submit() + + transfer_se = frappe.get_doc(_make_stock_entry(wo.name, "Material Transfer for Manufacture", 2)) + for d in transfer_se.items: + d.s_warehouse = source_wh + transfer_se.insert() + transfer_se.submit() + + manufacture_se = frappe.get_doc(_make_stock_entry(wo.name, "Manufacture", 2)) + manufacture_se.insert() + manufacture_se.submit() + + materials = get_available_materials(wo.name) + key = (rm, wip_wh) + if key in materials: + self.assertEqual(materials[key].qty, 0) + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1}) + @ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"}) + def test_validate_fg_resets_invalid_serial_no_on_manufacture(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as _make_stock_entry, + ) + + fg_item = "_FG Serial No Item" + rm_item = "RM for serial item" + create_nested_bom({fg_item: {rm_item: {}}}, prefix="") + + item = frappe.get_doc("Item", fg_item) + item.has_serial_no = 1 + item.serial_no_series = "FSNI-.####" + item.save() + + make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=20, basic_rate=100) + + wo1 = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True) + wo2 = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True) + wo1_serial_nos = frappe.get_all("Serial No", filters={"work_order": wo1.name}, pluck="name") + wo2_serial_nos = frappe.get_all("Serial No", filters={"work_order": wo2.name}, pluck="name") + + se = frappe.get_doc(_make_stock_entry(wo1.name, "Manufacture", 2)) + for row in se.items: + if row.is_finished_item: + row.serial_no = wo2_serial_nos[0] + row.serial_and_batch_bundle = None + + se.save() + + for row in se.items: + if row.is_finished_item: + self.assertIsNone(row.serial_no) + self.assertTrue(row.serial_and_batch_bundle) + for sn in get_serial_nos_from_bundle(row.serial_and_batch_bundle): + self.assertIn(sn, wo1_serial_nos) + + @ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1}) + @ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"}) + def test_validate_fg_resets_invalid_batch_no_on_manufacture(self): + from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record + from erpnext.manufacturing.doctype.work_order.work_order import ( + make_stock_entry as _make_stock_entry, + ) + from erpnext.stock.serial_batch_bundle import get_batches_from_bundle + + fg_item = "_FG Batch No Item" + rm_item = "RM for Batch Item" + create_nested_bom({fg_item: {rm_item: {}}}, prefix="") + + item = frappe.get_doc("Item", fg_item) + item.has_batch_no = 1 + item.create_new_batch = 1 + item.batch_number_series = "FBNI-.####" + item.save() + + make_stock_entry(item_code=rm_item, target="_Test Warehouse - _TC", qty=20, basic_rate=100) + + wo1 = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True) + wo2 = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True) + wo1_batches = frappe.get_all("Batch", filters={"reference_name": wo1.name}, pluck="name") + wo2_batches = frappe.get_all("Batch", filters={"reference_name": wo2.name}, pluck="name") + + se = frappe.get_doc(_make_stock_entry(wo1.name, "Manufacture", 2)) + for row in se.items: + if row.is_finished_item: + row.batch_no = wo2_batches[0] + row.serial_and_batch_bundle = None + + se.save() + + for row in se.items: + if row.is_finished_item: + self.assertIsNone(row.batch_no) + self.assertTrue(row.serial_and_batch_bundle) + for bn in list(get_batches_from_bundle(row.serial_and_batch_bundle).keys()): + self.assertIn(bn, wo1_batches) + + def make_serialized_item(self, **args): args = frappe._dict(args) se = frappe.copy_doc(self.globalTestRecords["Stock Entry"][0]) diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index b446aa1e51e..c21d9ec91cb 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -19,7 +19,7 @@ "col_break2", "is_finished_item", "is_legacy_scrap_item", - "type", + "secondary_item_type", "quality_inspection", "subcontracted_item", "against_fg", @@ -559,7 +559,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.is_legacy_scrap_item && !doc.type", + "depends_on": "eval:!doc.is_legacy_scrap_item && !doc.secondary_item_type", "fieldname": "is_finished_item", "fieldtype": "Check", "label": "Is Finished Item", @@ -653,7 +653,7 @@ }, { "depends_on": "eval:parent.purpose == \"Manufacture\" && doc.t_warehouse && !doc.is_finished_item && !doc.is_legacy_scrap_item", - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "label": "Type", "options": "\nCo-Product\nBy-Product\nScrap\nAdditional Finished Good" @@ -679,7 +679,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-04-27 11:40:38.294196", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry Detail", diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py index 0c1a21fefce..75f8b8a68ed 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.py @@ -1,8 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt - +import frappe +from frappe import _, bold from frappe.model.document import Document +from frappe.utils import ( + flt, + get_link_to_form, + getdate, +) + +from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( + OpeningEntryAccountError, +) +from erpnext.stock.stock_ledger import get_previous_sle class StockEntryDetail(Document): @@ -67,10 +78,149 @@ class StockEntryDetail(Document): t_warehouse: DF.Link | None transfer_qty: DF.Float transferred_qty: DF.Float - type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] uom: DF.Link use_serial_batch_fields: DF.Check valuation_rate: DF.Currency # end: auto-generated types - pass + def validate_batch(self): + if not self.batch_no: + return + + disabled = frappe.db.get_value("Batch", self.batch_no, "disabled") + if disabled: + frappe.throw(_("Batch {0} of Item {1} is disabled.").format(self.batch_no, self.item_code)) + return + + expiry_date = frappe.db.get_value("Batch", self.batch_no, "expiry_date") + if expiry_date and getdate(self.parent_doc.posting_date) > getdate(expiry_date): + frappe.throw(_("Batch {0} of Item {1} has expired.").format(self.batch_no, self.item_code)) + + def validate_and_update_item_details(self, item_details, company, purpose): + if flt(self.qty) and flt(self.qty) < 0: + frappe.throw( + _("Row {0}: The item {1}, quantity must be positive number").format( + self.idx, bold(self.item_code) + ) + ) + + if item_details.get("is_stock_item") != 1: + frappe.throw(_("{0} is not a stock Item").format(self.item_code)) + + reset_fields = ("stock_uom", "item_name") + for field in reset_fields: + self.set(field, item_details.get(field)) + + update_fields = ( + "uom", + "description", + "expense_account", + "cost_center", + "conversion_factor", + "barcode", + ) + for field in update_fields: + if not self.get(field): + self.set(field, item_details.get(field)) + if field == "conversion_factor" and self.uom == item_details.get("stock_uom"): + self.set(field, item_details.get(field)) + + if not self.transfer_qty and self.qty: + self.transfer_qty = flt( + flt(self.qty) * flt(self.conversion_factor), self.precision("transfer_qty") + ) + + if purpose == "Subcontracting Delivery": + self.expense_account = frappe.get_value("Company", company, "default_expense_account") + + def validate_expense_account(self, is_opening, purpose): + if not self.expense_account: + frappe.throw( + _( + "Please enter Difference Account or set default " + "Stock Adjustment Account for company {0}" + ).format(bold(self.parent_doc.company)) + ) + + acc_details = frappe.get_cached_value( + "Account", + self.expense_account, + ["account_type", "report_type"], + as_dict=True, + ) + + if is_opening == "Yes" and acc_details.report_type == "Profit and Loss": + frappe.throw( + _( + "Difference Account must be a Asset/Liability type account " + "(Temporary Opening), since this Stock Entry is an Opening Entry" + ), + OpeningEntryAccountError, + ) + + if acc_details.account_type == "Stock": + frappe.throw( + _("At row #{0}: the Difference Account must not be a Stock type account...").format( + self.idx, get_link_to_form("Account", self.expense_account) + ), + title=_("Difference Account in Items Table"), + ) + + if ( + purpose not in ["Material Issue", "Subcontracting Delivery"] + and acc_details.account_type == "Cost of Goods Sold" + ): + frappe.msgprint( + _("At row #{0}: you have selected the Difference Account {1}...").format( + self.idx, bold(get_link_to_form("Account", self.expense_account)) + ), + indicator="orange", + alert=1, + ) + + def set_transfer_qty(self): + if not flt(self.conversion_factor): + frappe.throw(_("Row {0}: UOM Conversion Factor is mandatory").format(self.idx)) + + self.transfer_qty = flt(flt(self.qty) * flt(self.conversion_factor), self.precision("transfer_qty")) + + if not flt(self.transfer_qty): + frappe.throw( + _("Row {0}: Qty in Stock UOM can not be zero.").format(self.idx), title=_("Zero quantity") + ) + + def set_actual_qty(self, posting_date, posting_time): + previous_sle = get_previous_sle( + { + "item_code": self.item_code, + "warehouse": self.s_warehouse or self.t_warehouse, + "posting_date": posting_date, + "posting_time": posting_time, + } + ) + + # get actual stock at source warehouse + self.actual_qty = previous_sle.get("qty_after_transaction") or 0 + + def delink_asset_repair_sabb(self, asset_repair): + if not self.serial_and_batch_bundle: + return + + voucher_detail_no = frappe.db.get_value( + "Asset Repair Consumed Item", + {"parent": asset_repair, "serial_and_batch_bundle": self.serial_and_batch_bundle}, + "name", + ) + + if not voucher_detail_no: + return + + doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle) + doc.db_set( + { + "voucher_type": "Asset Repair", + "voucher_no": asset_repair, + "voucher_detail_no": voucher_detail_no, + } + ) diff --git a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py index ae2ca3a67d6..c7e4fc0f500 100644 --- a/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py +++ b/erpnext/stock/doctype/stock_entry_type/stock_entry_type.py @@ -123,8 +123,9 @@ class ManufactureEntry: available_serial_batches = self.get_transferred_serial_batches() for item_code, _dict in item_dict.items(): - _dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse - _dict.to_warehouse = "" + _dict.s_warehouse = self.source_wh.get(item_code) or self.wip_warehouse + _dict.t_warehouse = "" + _dict.item_code = item_code if backflush_based_on != "BOM" and not frappe.db.get_value( "Job Card", self.job_card, "skip_material_transfer" @@ -138,7 +139,7 @@ class ManufactureEntry: _dict.qty = calculated_qty self.update_available_serial_batches(_dict, available_serial_batches) - self.stock_entry.add_to_stock_entry_detail(item_dict) + self.stock_entry.append("items", _dict) def parse_available_serial_batches(self, item_dict, available_serial_batches): key = (item_dict.item_code, item_dict.from_warehouse) @@ -309,8 +310,8 @@ class ManufactureEntry: item = get_item_defaults(self.production_item, self.company) args = { - "to_warehouse": self.fg_warehouse, - "from_warehouse": "", + "t_warehouse": self.fg_warehouse, + "s_warehouse": "", "qty": self.for_quantity - self.process_loss_qty, "item_name": item.item_name, "description": item.description, @@ -318,6 +319,7 @@ class ManufactureEntry: "expense_account": item.get("expense_account"), "cost_center": item.get("buying_cost_center"), "is_finished_item": 1, + "item_code": self.production_item, } - self.stock_entry.add_to_stock_entry_detail({self.production_item: args}, bom_no=self.bom_no) + self.stock_entry.append("items", args) diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json index 936dcd13650..a6ff359957f 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json @@ -1,12 +1,12 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_copy": 1, "autoname": "MAT-SLE-.YYYY.-.#####", "creation": "2013-01-29 19:25:42", "doctype": "DocType", "document_type": "Other", "engine": "InnoDB", - "is_submittable": 1, "field_order": [ "item_code", "warehouse", @@ -205,7 +205,7 @@ { "fieldname": "valuation_rate", "fieldtype": "Currency", - "label": "Valuation Rate", + "label": "Average Rate", "oldfieldname": "valuation_rate", "oldfieldtype": "Currency", "options": "Company:company:default_currency", @@ -361,12 +361,13 @@ "idx": 1, "in_create": 1, "index_web_pages_for_search": 1, + "is_submittable": 1, "links": [], - "modified": "2025-10-04 09:59:15.546556", + "modified": "2026-05-26 19:07:43.537450", "modified_by": "Administrator", "module": "Stock", "name": "Stock Ledger Entry", - "naming_rule": "Expression (old style)", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index f4e8f40ebf6..f21f4da7174 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -570,15 +570,18 @@ class StockReconciliation(StockController): def calculate_difference_amount(self, item, item_dict): qty_precision = item.precision("qty") - val_precision = item.precision("valuation_rate") + amount_precision = item.precision("amount") new_qty = flt(item.qty, qty_precision) - new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"), val_precision) + new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate")) current_qty = flt(item_dict.get("qty"), qty_precision) - current_valuation_rate = flt(item_dict.get("rate"), val_precision) + current_valuation_rate = flt(item_dict.get("rate")) - self.difference_amount += (new_qty * new_valuation_rate) - (current_qty * current_valuation_rate) + new_amount = flt(new_qty * new_valuation_rate, amount_precision) + current_amount = flt(current_qty * current_valuation_rate, amount_precision) + + self.difference_amount += new_amount - current_amount def validate_data(self): def _get_msg(row_num, msg): @@ -888,7 +891,7 @@ class StockReconciliation(StockController): "company": self.company, "stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"), "is_cancelled": 1 if self.docstatus == 2 else 0, - "valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")), + "valuation_rate": flt(row.valuation_rate), } ) @@ -1048,84 +1051,6 @@ class StockReconciliation(StockController): else: self._cancel() - def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False): - for row in self.items: - if voucher_detail_no != row.name: - continue - - if row.current_qty < 0: - return - - val_rate = 0.0 - current_qty = 0.0 - if row.current_serial_and_batch_bundle: - current_qty = self.get_current_qty_for_serial_or_batch(row, sle_creation) - elif row.serial_no: - item_dict = get_stock_balance_for( - row.item_code, - row.warehouse, - self.posting_date, - self.posting_time, - row=row, - company=self.company, - ) - - current_qty = item_dict.get("qty") - row.current_serial_no = item_dict.get("serial_nos") - row.current_valuation_rate = item_dict.get("rate") - val_rate = item_dict.get("rate") - elif row.batch_no: - current_qty = get_batch_qty_for_stock_reco( - row.item_code, - row.warehouse, - row.batch_no, - self.posting_date, - self.posting_time, - self.name, - sle_creation, - ) - - precesion = row.precision("current_qty") - if flt(current_qty, precesion) != flt(row.current_qty, precesion): - if not row.serial_no: - val_rate = get_incoming_rate( - frappe._dict( - { - "item_code": row.item_code, - "warehouse": row.warehouse, - "qty": current_qty * -1, - "serial_and_batch_bundle": row.current_serial_and_batch_bundle, - "batch_no": row.batch_no, - "voucher_type": self.doctype, - "voucher_no": self.name, - "company": self.company, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - } - ) - ) - - row.current_valuation_rate = val_rate - row.current_qty = current_qty - row.db_set( - { - "current_qty": row.current_qty, - "current_valuation_rate": row.current_valuation_rate, - "current_amount": flt(row.current_qty * row.current_valuation_rate), - } - ) - - if add_new_sle and not frappe.db.get_value( - "Stock Ledger Entry", - {"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0}, - "name", - ): - if not row.current_serial_and_batch_bundle: - self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True) - row.reload() - - self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation) - def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation): if row.current_qty == 0: return diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 25107ae12e6..02cd0e63e4a 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -1040,11 +1040,11 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): ) batch1 = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle) - self.assertFalse(batch1 == batch) + self.assertNotEqual(batch1, batch) sr.reload() self.assertTrue(sr.items[0].serial_and_batch_bundle) - self.assertTrue(sr.items[0].current_serial_and_batch_bundle) + self.assertFalse(sr.items[0].current_serial_and_batch_bundle) def test_not_reconcile_all_batch(self): from erpnext.stock.doctype.batch.batch import get_batch_qty @@ -1418,7 +1418,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): sr.save() self.assertEqual(sr.items[0].current_valuation_rate, 100) self.assertEqual(sr.difference_amount, 100 * -1) - self.assertTrue(sr.items[0].qty == 0) + self.assertEqual(sr.items[0].qty, 0) def test_stock_reco_recalculate_qty_for_backdated_entry(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -1456,7 +1456,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): pluck="name", ) - self.assertTrue(len(stock_ledgers) == 1) + self.assertEqual(len(stock_ledgers), 1) se = make_stock_entry( item_code=item_code, @@ -1515,7 +1515,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): "status", ) - self.assertTrue(status == "Active") + self.assertEqual(status, "Active") sr = create_stock_reconciliation( item_code=serial_item, @@ -1534,7 +1534,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): "status", ) - self.assertTrue(status == "Active") + self.assertEqual(status, "Active") se = make_stock_entry( item_code=serial_item, @@ -1550,7 +1550,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): "status", ) - self.assertFalse(status == "Active") + self.assertNotEqual(status, "Active") sr.cancel() @@ -1560,7 +1560,7 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin): "status", ) - self.assertFalse(status == "Active") + self.assertNotEqual(status, "Active") def test_change_valuation_of_batch_using_backdated_stock_reco(self): from erpnext.stock.doctype.batch.batch import get_batch_qty diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json index ccca2fc9819..ed48522d770 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.json @@ -1,5 +1,6 @@ { "actions": [], + "allow_bulk_edit": 1, "allow_rename": 1, "beta": 1, "creation": "2021-10-01 10:56:30.814787", @@ -13,6 +14,8 @@ "end_time", "limits_dont_apply_on", "item_based_reposting", + "column_break_mavd", + "do_not_fetch_incoming_rate_from_serial_no", "section_break_dxuf", "enable_parallel_reposting", "no_of_parallel_reposting", @@ -99,13 +102,23 @@ "fieldname": "enable_separate_reposting_for_gl", "fieldtype": "Check", "label": "Enable Separate Reposting for GL" + }, + { + "fieldname": "column_break_mavd", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "For legacy serial nos, do not fetch incoming rate from serial no and calculate it based on the inward transaction", + "fieldname": "do_not_fetch_incoming_rate_from_serial_no", + "fieldtype": "Check", + "label": "Do not fetch incoming rate from Serial No" } ], - "hide_toolbar": 0, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-03-16 13:28:20.978007", + "modified": "2026-05-15 12:59:34.392491", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reposting Settings", diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py index 40f9f1f6d69..8976d260ff9 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py @@ -16,6 +16,7 @@ class StockRepostingSettings(Document): if TYPE_CHECKING: from frappe.types import DF + do_not_fetch_incoming_rate_from_serial_no: DF.Check enable_parallel_reposting: DF.Check enable_separate_reposting_for_gl: DF.Check end_time: DF.Time | None diff --git a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py index 4d73ad62c05..b3c6aedb7a3 100644 --- a/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py +++ b/erpnext/stock/doctype/stock_reposting_settings/test_stock_reposting_settings.py @@ -31,9 +31,9 @@ class TestStockRepostingSettings(ERPNextTestSuite): frappe.db.set_single_value("Stock Reposting Settings", "notify_reposting_error_to_role", "") users = get_recipients() - self.assertFalse(user in users) + self.assertNotIn(user, users) frappe.db.set_single_value("Stock Reposting Settings", "notify_reposting_error_to_role", role) users = get_recipients() - self.assertTrue(user in users) + self.assertIn(user, users) diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index b477fee2228..8fb7a375dcf 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -1898,3 +1898,32 @@ def update_serial_batch_delivered_qty(row, name, is_cancelled=False): ) query.run() + + +def get_reserved_materials(voucher_no): + doctype = frappe.qb.DocType("Stock Reservation Entry") + serial_batch_doc = frappe.qb.DocType("Serial and Batch Entry") + + query = ( + frappe.qb.from_(doctype) + .inner_join(serial_batch_doc) + .on(doctype.name == serial_batch_doc.parent) + .select( + serial_batch_doc.serial_no, + serial_batch_doc.batch_no, + serial_batch_doc.qty, + doctype.item_code, + doctype.warehouse, + doctype.name, + doctype.transferred_qty, + doctype.consumed_qty, + ) + .where( + (doctype.docstatus == 1) + & (doctype.voucher_no == voucher_no) + & (serial_batch_doc.delivered_qty < serial_batch_doc.qty) + ) + .orderby(serial_batch_doc.idx) + ) + + return query.run(as_dict=True) diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 2d85675f2ea..6b6b70b2187 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -8,7 +8,6 @@ import frappe from frappe import _ from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.model.document import Document -from frappe.utils import cint from frappe.utils.html_utils import clean_html from erpnext.stock.utils import check_pending_reposting @@ -320,9 +319,8 @@ def clean_all_descriptions(): @frappe.whitelist() def get_enable_stock_uom_editing(): - return frappe.get_cached_value( + return frappe.get_single_value( "Stock Settings", - None, ["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"], as_dict=1, ) diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index ed17078e750..b6c57865d4a 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -436,6 +436,27 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It fieldname="fixed_asset_account", item=ctx.item_code, company=ctx.company ) + company_values = frappe.get_cached_value( + "Company", + ctx.company, + [ + "stock_delivered_but_not_billed", + "disable_sdbnb_in_sr", + ], + as_dict=True, + ) + + if ( + ctx.doctype == "Delivery Note" + and ctx.is_stock_item + and company_values + and company_values.stock_delivered_but_not_billed + and not ctx.get("is_fixed_asset") + and not ctx.get("is_subcontracted") + ): + if not (ctx.get("is_return") and company_values.disable_sdbnb_in_sr): + expense_account = company_values.stock_delivered_but_not_billed + # Set the UOM to the Default Sales UOM or Default Purchase UOM if configured in the Item Master if not ctx.uom: if ctx.doctype in sales_doctypes: @@ -493,9 +514,7 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It "discount_percentage": 0.0, "discount_amount": flt(ctx.discount_amount) or 0.0, "update_stock": ctx.update_stock if ctx.doctype in ["Sales Invoice", "Purchase Invoice"] else 0, - "delivered_by_supplier": item.delivered_by_supplier - if ctx.doctype in ["Sales Order", "Sales Invoice"] - else 0, + "delivered_by_supplier": item.delivered_by_supplier, "is_fixed_asset": item.is_fixed_asset, "last_purchase_rate": item.last_purchase_rate if ctx.doctype in ["Purchase Order"] else 0, "transaction_date": ctx.transaction_date, @@ -539,10 +558,21 @@ def get_basic_details(ctx: ItemDetailsCtx, item, overwrite_warehouse=True) -> It ctx.name, ctx.conversion_rate, item.name, out.conversion_factor ) + expense_account_field = "default_expense_account" + if ( + item.is_stock_item + and erpnext.is_perpetual_inventory_enabled(ctx.company) + and ( + ctx.doctype == "Purchase Receipt" + or (ctx.doctype == "Purchase Invoice" and ctx.get("update_stock")) + ) + ): + expense_account_field = "stock_received_but_not_billed" + # if default specified in item is for another company, fetch from company for d in [ ["Account", "income_account", "default_income_account"], - ["Account", "expense_account", "default_expense_account"], + ["Account", "expense_account", expense_account_field], ["Cost Center", "cost_center", "cost_center"], ["Warehouse", "warehouse", ""], ]: @@ -1133,7 +1163,7 @@ def insert_item_price(ctx: ItemDetailsCtx): ) item_price.insert() frappe.msgprint( - _("Item Price Added for {0} in Price List {1}").format( + _("Item Price added for {0} in Price List - {1}").format( get_link_to_form("Item", ctx.item_code), ctx.price_list ), alert=True, @@ -1157,9 +1187,10 @@ def insert_item_price(ctx: ItemDetailsCtx): ) item_price.insert() frappe.msgprint( - _("Item Price added for {0} in Price List {1}").format( + _("Item Price added for {0} in Price List - {1}").format( get_link_to_form("Item", ctx.item_code), ctx.price_list - ) + ), + alert=True, ) @@ -1201,9 +1232,15 @@ def get_item_price( if not ignore_party: if pctx.customer: - query = query.where(ip.customer == pctx.customer) + query = query.where( + (ip.customer == pctx.customer) + | ((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == "")) + ).orderby(IfNull(ip.customer, ""), order=frappe.qb.desc) elif pctx.supplier: - query = query.where(ip.supplier == pctx.supplier) + query = query.where( + (ip.supplier == pctx.supplier) + | ((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == "")) + ).orderby(IfNull(ip.supplier, ""), order=frappe.qb.desc) else: query = query.where((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == "")) @@ -1261,9 +1298,6 @@ def get_price_list_rate_for(ctx: ItemDetailsCtx, item_code: str): if desired_qty and check_packing_list(price_list_rate[0].name, desired_qty, item_code): item_price_data = price_list_rate else: - for field in ["customer", "supplier"]: - del pctx[field] - general_price_list_rate = get_item_price(pctx, item_code, ignore_party=ctx.get("ignore_party")) if not general_price_list_rate and ctx.get("uom") != ctx.get("stock_uom"): diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py index ba735d82b03..81fc944a4b3 100644 --- a/erpnext/stock/report/stock_ageing/stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/stock_ageing.py @@ -7,7 +7,7 @@ from operator import itemgetter import frappe from frappe import _ -from frappe.query_builder.functions import Count +from frappe.query_builder.functions import Abs, Count from frappe.utils import cint, date_diff, flt, get_datetime from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -15,10 +15,25 @@ from erpnext.stock.valuation import round_off_if_near_zero Filters = frappe._dict +FIFO_POSTING_DATE_INDEX = -2 +FIFO_QTY_INDEX = 0 +FIFO_DATE_INDEX = 1 +FIFO_VALUE_INDEX = 2 + +BATCH_SLOT_SIZE = 5 +BATCH_SLOT_BATCH_INDEX = 0 +BATCH_SLOT_VALUATION_INDEX = 1 +BATCH_SLOT_QTY_INDEX = 2 +BATCH_SLOT_DATE_INDEX = 3 +BATCH_SLOT_VALUE_INDEX = 4 + +AVERAGE_AGE_COLUMN = 6 +MAX_CHART_ITEMS = 10 + def execute(filters: Filters = None) -> tuple: to_date = filters["to_date"] - filters.ranges = [num.strip() for num in filters.range.split(",") if num.strip().isdigit()] + filters.ranges = get_age_ranges(filters.range) columns = get_columns(filters) item_details = FIFOSlots(filters).generate() @@ -29,107 +44,125 @@ def execute(filters: Filters = None) -> tuple: return columns, data, None, chart_data -def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[dict]: +def get_age_ranges(age_range: str) -> list[str]: + return [num.strip() for num in age_range.split(",") if num.strip().isdigit()] + + +def get_float_precision() -> int: + return cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) + + +def format_report_data(filters: Filters, item_details: dict, to_date: str) -> list[list]: "Returns ordered, formatted data with ranges." - _func = itemgetter(1) data = [] - precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) + precision = get_float_precision() for _item, item_dict in item_details.items(): if not flt(item_dict.get("total_qty"), precision): continue - earliest_age, latest_age = 0, 0 details = item_dict["details"] - - fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func) - + fifo_queue = get_report_fifo_queue(item_dict["fifo_queue"], details.has_batch_no) if not fifo_queue: continue - average_age = get_average_age(fifo_queue, to_date) - earliest_age = date_diff(to_date, fifo_queue[0][1]) - latest_age = date_diff(to_date, fifo_queue[-1][1]) - range_values = get_range_age(filters, fifo_queue, to_date, item_dict) - - check_and_replace_valuations_if_moving_average( - range_values, details.valuation_method, details.valuation_rate, filters.get("company") - ) - - row = [details.name, details.item_name, details.description, details.item_group, details.brand] - - if filters.get("show_warehouse_wise_stock"): - row.append(details.warehouse) - - row.extend( - [ - flt(item_dict.get("total_qty"), precision), - average_age, - *range_values, - earliest_age, - latest_age, - details.stock_uom, - ] - ) - - data.append(row) + data.append(get_report_row(filters, item_dict, fifo_queue, to_date, precision)) return data -def check_and_replace_valuations_if_moving_average( - range_values, item_valuation_method, valuation_rate, company -): - if item_valuation_method == "Moving Average" or ( - not item_valuation_method - and frappe.get_cached_value("Company", company, "valuation_method") == "Moving Average" - ): - for i in range(0, len(range_values), 2): - range_values[i + 1] = range_values[i] * valuation_rate +def get_report_fifo_queue(fifo_queue: list, has_batch_no: bool) -> list: + get_posting_date = itemgetter(FIFO_POSTING_DATE_INDEX) + fifo_queue = sorted([slot for slot in fifo_queue if get_posting_date(slot)], key=get_posting_date) + + if has_batch_no: + return [get_batch_report_slot(slot) for slot in fifo_queue] + + return fifo_queue + + +def get_batch_report_slot(slot: list) -> list: + if is_batch_slot(slot): + return slot[BATCH_SLOT_QTY_INDEX:] + + return slot + + +def get_report_row(filters: Filters, item_dict: dict, fifo_queue: list, to_date: str, precision: int) -> list: + details = item_dict["details"] + range_values = get_range_age(filters, fifo_queue, to_date, item_dict, precision) + row = [details.name, details.item_name, details.description, details.item_group, details.brand] + + if filters.get("show_warehouse_wise_stock"): + row.append(details.warehouse) + + row.extend( + [ + flt(item_dict.get("total_qty"), precision), + get_average_age(fifo_queue, to_date), + *range_values, + date_diff(to_date, fifo_queue[0][FIFO_DATE_INDEX]), + date_diff(to_date, fifo_queue[-1][FIFO_DATE_INDEX]), + details.stock_uom, + ] + ) + + return row def get_average_age(fifo_queue: list, to_date: str) -> float: - batch_age = age_qty = total_qty = 0.0 - for batch in fifo_queue: - batch_age = date_diff(to_date, batch[1]) - - if isinstance(batch[0], int | float): - age_qty += batch_age * batch[0] - total_qty += batch[0] - else: - age_qty += batch_age * 1 - total_qty += 1 + age_qty = total_qty = 0.0 + for slot in fifo_queue: + qty = get_slot_qty(slot) + age_qty += date_diff(to_date, slot[FIFO_DATE_INDEX]) * qty + total_qty += qty return flt(age_qty / total_qty, 2) if total_qty else 0.0 -def get_range_age(filters: Filters, fifo_queue: list, to_date: str, item_dict: dict) -> list: - precision = cint(frappe.db.get_single_value("System Settings", "float_precision", cache=True)) +def get_slot_qty(slot: list) -> float: + if is_qty_slot(slot): + return slot[FIFO_QTY_INDEX] + + return 1.0 + + +def get_range_age( + filters: Filters, fifo_queue: list, to_date: str, item_dict: dict, precision: int | None = None +) -> list: + precision = precision if precision is not None else get_float_precision() range_values = [0.0] * ((len(filters.ranges) * 2) + 2) - for item in fifo_queue: - age = flt(date_diff(to_date, item[1])) - qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0 - stock_value = flt(item[2]) - - for i, age_limit in enumerate(filters.ranges): - if age <= flt(age_limit): - i *= 2 - range_values[i] = flt(range_values[i] + qty, precision) - range_values[i + 1] = flt(range_values[i + 1] + stock_value, precision) - if range_values[i] == 0.0 and round_off_if_near_zero(range_values[i + 1], 2) == 0: - range_values[i + 1] = 0.0 - break - else: - range_values[-2] = flt(range_values[-2] + qty, precision) - range_values[-1] = flt(range_values[-1] + stock_value, precision) - if range_values[-2] == 0.0 and round_off_if_near_zero(range_values[-1], 2) == 0: - range_values[-1] = 0.0 + for slot in fifo_queue: + bucket_index = get_age_bucket_index(filters.ranges, slot, to_date) + qty = 1.0 if item_dict["has_serial_no"] else flt(slot[FIFO_QTY_INDEX]) + stock_value = flt(slot[FIFO_VALUE_INDEX]) + add_to_range_bucket(range_values, bucket_index, qty, stock_value, precision) return range_values +def get_age_bucket_index(age_ranges: list, slot: list, to_date: str) -> int: + age = flt(date_diff(to_date, slot[FIFO_DATE_INDEX])) + + for index, age_limit in enumerate(age_ranges): + if age <= flt(age_limit): + return index * 2 + + return len(age_ranges) * 2 + + +def add_to_range_bucket( + range_values: list, bucket_index: int, qty: float, stock_value: float, precision: int +) -> None: + range_values[bucket_index] = flt(range_values[bucket_index] + qty, precision) + range_values[bucket_index + 1] = flt(range_values[bucket_index + 1] + stock_value, precision) + + if range_values[bucket_index] == 0.0 and round_off_if_near_zero(range_values[bucket_index + 1], 2) == 0: + range_values[bucket_index + 1] = 0.0 + + def get_columns(filters: Filters) -> list[dict]: range_columns = [] setup_ageing_columns(filters, range_columns) @@ -199,14 +232,14 @@ def get_chart_data(data: list, filters: Filters) -> dict: if filters.get("show_warehouse_wise_stock"): return {} - data.sort(key=lambda row: row[6], reverse=True) + data.sort(key=lambda row: row[AVERAGE_AGE_COLUMN], reverse=True) - if len(data) > 10: - data = data[:10] + if len(data) > MAX_CHART_ITEMS: + data = data[:MAX_CHART_ITEMS] for row in data: labels.append(row[0]) - datapoints.append(row[6]) + datapoints.append(row[AVERAGE_AGE_COLUMN]) return { "data": {"labels": labels, "datasets": [{"name": _("Average Age"), "values": datapoints}]}, @@ -217,9 +250,9 @@ def get_chart_data(data: list, filters: Filters) -> dict: def setup_ageing_columns(filters: Filters, range_columns: list): prev_range_value = 0 ranges = [] - for range in filters.ranges: - ranges.append(f"{prev_range_value} - {range}") - prev_range_value = cint(range) + 1 + for age_range in filters.ranges: + ranges.append(f"{prev_range_value} - {age_range}") + prev_range_value = cint(age_range) + 1 ranges.append(f"{prev_range_value} - Above") @@ -233,19 +266,29 @@ def add_column(range_columns: list, label: str, fieldname: str, fieldtype: str = range_columns.append(dict(label=label, fieldname=fieldname, fieldtype=fieldtype, width=width)) +def is_batch_slot(slot: list) -> bool: + return len(slot) == BATCH_SLOT_SIZE + + +def is_qty_slot(slot: list) -> bool: + return isinstance(slot[FIFO_QTY_INDEX], int | float) + + class FIFOSlots: "Returns FIFO computed slots of inwarded stock as per date." def __init__(self, filters: dict | None = None, sle: list | None = None): self.item_details = {} self.transferred_item_details = {} - self.serial_no_batch_purchase_details = {} + self.serial_no_details = {} + self.batch_no_details = {} + self.batchwise_valuation_by_batch = {} self.filters = filters self.sle = sle def generate(self) -> dict: """ - Returns dict of the foll.g structure: + Returns dict of the following structure: Key = Item A / (Item A, Warehouse A) Key: { 'details' -> Dict: ** item details **, @@ -253,80 +296,136 @@ class FIFOSlots: consumed/updated and maintained via FIFO. ** } """ - from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle - stock_ledger_entries = self.sle - - bundle_wise_serial_nos = frappe._dict({}) - if stock_ledger_entries is None: - bundle_wise_serial_nos = self.__get_bundle_wise_serial_nos() + bundle_wise_serial_nos, bundle_wise_batch_nos = self._get_bundle_wise_details(stock_ledger_entries) # prepare single sle voucher detail lookup self.prepare_stock_reco_voucher_wise_count() with frappe.db.unbuffered_cursor(): if stock_ledger_entries is None: - stock_ledger_entries = self.__get_stock_ledger_entries() + stock_ledger_entries = self._get_stock_ledger_entries() - for d in stock_ledger_entries: - key, fifo_queue, transferred_item_key = self.__init_key_stores(d) - prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) - - if d.voucher_type == "Stock Reconciliation" and ( - not d.batch_no or d.serial_no or d.serial_and_batch_bundle - ): - if d.voucher_detail_no in self.stock_reco_voucher_wise_count: - # for legacy recon with single sle has qty_after_transaction and stock_value_difference without outward entry - # for exisitng handle emptying the existing queue and details. - d.stock_value_difference = flt(d.qty_after_transaction * d.valuation_rate) - d.actual_qty = d.qty_after_transaction - self.item_details[key]["qty_after_transaction"] = 0 - self.item_details[key]["total_qty"] = 0 - fifo_queue.clear() - else: - d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) - - elif d.voucher_type == "Stock Reconciliation": - # get difference in qty shift as actual qty - d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty) - - serial_nos = get_serial_nos(d.serial_no) if d.serial_no else [] - if d.serial_and_batch_bundle and d.has_serial_no: - if bundle_wise_serial_nos: - serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or [] - else: - serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or [] - - serial_nos = self.uppercase_serial_nos(serial_nos) - if d.actual_qty > 0: - self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos) - else: - self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos) - - self.__update_balances(d, key) - - # handle serial nos misconsumption - if d.has_serial_no: - qty_after = cint(self.item_details[key]["qty_after_transaction"]) - if qty_after <= 0: - fifo_queue.clear() - elif len(fifo_queue) > qty_after: - fifo_queue[:] = fifo_queue[:qty_after] + for row in stock_ledger_entries: + self._process_stock_ledger_entry(row, bundle_wise_serial_nos, bundle_wise_batch_nos) # Note that stock_ledger_entries is an iterator, you can not reuse it like a list del stock_ledger_entries if not self.filters.get("show_warehouse_wise_stock"): # (Item 1, WH 1), (Item 1, WH 2) => (Item 1) - self.item_details = self.__aggregate_details_by_item(self.item_details) + self.item_details = self._aggregate_details_by_item(self.item_details) return self.item_details + def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]: + if stock_ledger_entries is not None: + return frappe._dict({}), frappe._dict({}) + + return self._get_bundle_wise_serial_nos(), self._get_bundle_wise_batch_nos() + + def _process_stock_ledger_entry( + self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict + ) -> None: + key, fifo_queue, transferred_item_key = self._init_key_stores(row) + prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0) + + self._set_stock_reconciliation_actual_qty(row, key, fifo_queue, prev_balance_qty) + serial_nos, batch_nos = self._get_serial_and_batch_nos( + row, bundle_wise_serial_nos, bundle_wise_batch_nos + ) + + if row.actual_qty > 0: + self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + else: + self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos) + + self._update_balances(row, key) + self._trim_serial_fifo_queue(row, key, fifo_queue) + + def _set_stock_reconciliation_actual_qty( + self, row: dict, key: tuple, fifo_queue: list, prev_balance_qty: float + ) -> None: + if row.voucher_type != "Stock Reconciliation": + return + + if not row.batch_no or row.serial_no or row.serial_and_batch_bundle: + if row.voucher_detail_no in self.stock_reco_voucher_wise_count: + # Legacy reconciliation with a single SLE has qty_after_transaction and + # stock_value_difference without an outward entry, so reset the queue first. + row.stock_value_difference = flt(row.qty_after_transaction * row.valuation_rate) + row.actual_qty = row.qty_after_transaction + self.item_details[key]["qty_after_transaction"] = 0 + self.item_details[key]["total_qty"] = 0 + fifo_queue.clear() + return + + # Stock reconciliation stores the final balance; FIFO needs the movement delta. + row.actual_qty = flt(row.qty_after_transaction) - flt(prev_balance_qty) + + def _get_serial_and_batch_nos( + self, row: dict, bundle_wise_serial_nos: dict, bundle_wise_batch_nos: dict + ) -> tuple[list, list]: + from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle + + serial_nos = get_serial_nos(row.serial_no) if row.serial_no else [] + batch_nos = self._get_row_batch_nos(row) + + if row.serial_and_batch_bundle: + if row.has_serial_no: + if bundle_wise_serial_nos: + serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle) or [] + else: + serial_nos = sorted(get_serial_nos_from_bundle(row.serial_and_batch_bundle)) or [] + elif row.has_batch_no: + if bundle_wise_batch_nos: + batch_nos = bundle_wise_batch_nos.get(row.serial_and_batch_bundle) or [] + else: + batch_nos = ( + self._get_bundle_wise_batch_nos(row.serial_and_batch_bundle).get( + row.serial_and_batch_bundle + ) + or [] + ) + + return self.uppercase_serial_nos(serial_nos), batch_nos + + def _get_row_batch_nos(self, row: dict) -> list: + if not row.batch_no: + return [] + + return [ + [ + row.batch_no.upper(), + self._get_batchwise_valuation(row.batch_no), + abs(row.actual_qty), + abs(row.stock_value_difference), + ] + ] + + def _trim_serial_fifo_queue(self, row: dict, key: tuple, fifo_queue: list) -> None: + if not row.has_serial_no: + return + + qty_after = cint(self.item_details[key]["qty_after_transaction"]) + if qty_after <= 0: + fifo_queue.clear() + elif len(fifo_queue) > qty_after: + fifo_queue[:] = fifo_queue[:qty_after] + def uppercase_serial_nos(self, serial_nos): "Convert serial nos to uppercase for uniformity." return [sn.upper() for sn in serial_nos] - def __init_key_stores(self, row: dict) -> tuple: + def _get_batchwise_valuation(self, batch_no: str): + if batch_no not in self.batchwise_valuation_by_batch: + self.batchwise_valuation_by_batch[batch_no] = frappe.db.get_value( + "Batch", batch_no, "use_batchwise_valuation" + ) + + return self.batchwise_valuation_by_batch[batch_no] + + def _init_key_stores(self, row: dict) -> tuple: "Initialise keys and FIFO Queue." key = (row.name, row.warehouse) @@ -338,57 +437,209 @@ class FIFOSlots: return key, fifo_queue, transferred_item_key - def __compute_incoming_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): + def _compute_incoming_stock( + self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + ): "Update FIFO Queue on inward stock." - transfer_data = self.transferred_item_details.get(transfer_key) if transfer_data: # inward/outward from same voucher, item & warehouse # eg: Repack with same item, Stock reco for batch item # consume transfer data and add stock to fifo queue - self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row) + self._adjust_incoming_transfer_qty( + transfer_data, + fifo_queue, + row, + batch_nos, + serial_nos=serial_nos if row.get("has_serial_no") else None, + ) + elif serial_nos and row.get("has_serial_no"): + self._add_serial_fifo_slots(row, fifo_queue, serial_nos) + elif batch_nos and row.get("has_batch_no"): + self._add_batch_fifo_slots(row, fifo_queue, batch_nos) + elif fifo_queue and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0: + self._add_to_negative_fifo_head(row, fifo_queue) else: - if not serial_nos and not row.get("has_serial_no"): - if fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock - fifo_queue[0][0] += flt(row.actual_qty) - fifo_queue[0][1] = row.posting_date - fifo_queue[0][2] += flt(row.stock_value_difference) - else: - fifo_queue.append( - [flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)] - ) - return + fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)]) - valuation = row.stock_value_difference / row.actual_qty - for serial_no in serial_nos: - if self.serial_no_batch_purchase_details.get(serial_no): - fifo_queue.append( - [serial_no, self.serial_no_batch_purchase_details.get(serial_no), valuation] - ) - else: - self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date) - fifo_queue.append([serial_no, row.posting_date, valuation]) + def _add_serial_fifo_slots(self, row: dict, fifo_queue: list, serial_nos: list) -> None: + valuation = row.stock_value_difference / row.actual_qty + for serial_no in serial_nos: + posting_date = self.serial_no_details.setdefault(serial_no, row.posting_date) + fifo_queue.append([serial_no, posting_date, valuation]) - def __compute_outgoing_stock(self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list): + def _add_batch_fifo_slots(self, row: dict, fifo_queue: list, batch_nos: list) -> None: + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + qty, stock_value_difference = self._neutralize_negative_batch_stock( + fifo_queue, row, batch_no, use_batchwise_valuation, qty, stock_value_difference + ) + + if not qty: + continue + + posting_date = self.batch_no_details.setdefault(batch_no, row.posting_date) + fifo_queue.append([batch_no, use_batchwise_valuation, qty, posting_date, stock_value_difference]) + + def _neutralize_negative_batch_stock( + self, + fifo_queue: list, + row: dict, + batch_no: str, + use_batchwise_valuation: bool, + qty: float, + stock_value_difference: float, + ) -> tuple[float, float]: + qty = flt(qty) + stock_value_difference = flt(stock_value_difference) + + if not qty: + return qty, stock_value_difference + + for slot in list(fifo_queue): + if not self._is_matching_negative_batch_slot(slot, batch_no, use_batchwise_valuation): + continue + + qty_to_adjust = min(qty, abs(flt(slot[BATCH_SLOT_QTY_INDEX]))) + value_to_adjust = ( + stock_value_difference + if qty_to_adjust == qty + else flt(stock_value_difference * (qty_to_adjust / qty)) + ) + + slot[BATCH_SLOT_QTY_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX]) + qty_to_adjust + slot[BATCH_SLOT_DATE_INDEX] = row.posting_date + slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_VALUE_INDEX]) + value_to_adjust + + qty = flt(qty - qty_to_adjust) + stock_value_difference = flt(stock_value_difference - value_to_adjust) + + if not flt(slot[BATCH_SLOT_QTY_INDEX]) and not flt(slot[BATCH_SLOT_VALUE_INDEX]): + fifo_queue.remove(slot) + + if not qty: + break + + return qty, stock_value_difference + + def _is_matching_negative_batch_slot( + self, slot: list, batch_no: str, use_batchwise_valuation: bool, include_zero_qty: bool = False + ) -> bool: + if not is_batch_slot(slot): + return False + + qty = flt(slot[BATCH_SLOT_QTY_INDEX]) + + return ( + slot[BATCH_SLOT_BATCH_INDEX] == batch_no + and slot[BATCH_SLOT_VALUATION_INDEX] == use_batchwise_valuation + and (qty <= 0 if include_zero_qty else qty < 0) + ) + + def _add_to_negative_fifo_head(self, row: dict, fifo_queue: list) -> None: + fifo_queue[0][FIFO_QTY_INDEX] += flt(row.actual_qty) + fifo_queue[0][FIFO_DATE_INDEX] = row.posting_date + fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference) + + def _compute_outgoing_stock( + self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list + ): "Update FIFO Queue on outward stock." if serial_nos: - fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos] - return + self._consume_serial_fifo_slots(fifo_queue, serial_nos) + elif batch_nos: + self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos) + else: + self._consume_fifo_slots(row, fifo_queue, transfer_key) + def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None: + fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos] + + def _consume_batch_fifo_slots( + self, row: dict, fifo_queue: list, transfer_key: tuple, batch_nos: list + ) -> None: + for batch_no, use_batchwise_valuation, qty, stock_value_difference in batch_nos: + items_to_remove = [] + + for slot in fifo_queue: + if not self._can_consume_batch_slot(slot, batch_no, use_batchwise_valuation): + continue + + slot_qty = flt(slot[BATCH_SLOT_QTY_INDEX]) + slot_stock_value = flt(slot[BATCH_SLOT_VALUE_INDEX]) + + if slot_qty <= qty: + qty -= slot_qty + stock_value_difference -= slot_stock_value + self.transferred_item_details[transfer_key].append( + [slot_qty, slot[BATCH_SLOT_DATE_INDEX], slot_stock_value] + ) + items_to_remove.append(slot) + else: + slot[BATCH_SLOT_QTY_INDEX] = slot_qty - qty + # Preserve ledger valuation (moving average / SLE value), not slot proportional value. + slot[BATCH_SLOT_VALUE_INDEX] = slot_stock_value - stock_value_difference + self.transferred_item_details[transfer_key].append( + [qty, slot[BATCH_SLOT_DATE_INDEX], stock_value_difference] + ) + qty = 0 + stock_value_difference = 0 + break + + for item in items_to_remove: + fifo_queue.remove(item) + + if qty: + self._append_negative_batch_slot( + row, + fifo_queue, + transfer_key, + batch_no, + use_batchwise_valuation, + qty, + stock_value_difference, + ) + + def _can_consume_batch_slot(self, slot: list, batch_no: str, use_batchwise_valuation: bool) -> bool: + if not is_batch_slot(slot): + return False + + if flt(slot[BATCH_SLOT_QTY_INDEX]) <= 0: + return False + + if use_batchwise_valuation: + return slot[BATCH_SLOT_BATCH_INDEX] == batch_no + + return not slot[BATCH_SLOT_VALUATION_INDEX] + + def _append_negative_batch_slot( + self, + row: dict, + fifo_queue: list, + transfer_key: tuple, + batch_no: str, + use_batchwise_valuation: bool, + qty: float, + stock_value_difference: float, + ) -> None: + fifo_queue.append( + [batch_no, use_batchwise_valuation, -(qty), row.posting_date, -(stock_value_difference)] + ) + self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference]) + + def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None: qty_to_pop = abs(row.actual_qty) stock_value = abs(row.stock_value_difference) while qty_to_pop: slot = fifo_queue[0] if fifo_queue else [0, None, 0] - if 0 < flt(slot[0]) <= qty_to_pop: - # qty to pop >= slot qty - # if +ve and not enough or exactly same balance in current slot, consume whole slot - qty_to_pop -= flt(slot[0]) - stock_value -= flt(slot[2]) + slot_qty = flt(slot[FIFO_QTY_INDEX]) + slot_value = flt(slot[FIFO_VALUE_INDEX]) + + if 0 < slot_qty <= qty_to_pop: + qty_to_pop -= slot_qty + stock_value -= slot_value self.transferred_item_details[transfer_key].append(fifo_queue.pop(0)) elif not fifo_queue: - # negative stock, no balance but qty yet to consume fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)]) self.transferred_item_details[transfer_key].append( [qty_to_pop, row.posting_date, stock_value] @@ -396,48 +647,179 @@ class FIFOSlots: qty_to_pop = 0 stock_value = 0 else: - # qty to pop < slot qty, ample balance - # consume actual_qty from first slot - slot[0] = flt(slot[0]) - qty_to_pop - slot[2] = flt(slot[2]) - stock_value - self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1], stock_value]) + slot[FIFO_QTY_INDEX] = slot_qty - qty_to_pop + slot[FIFO_VALUE_INDEX] = slot_value - stock_value + self.transferred_item_details[transfer_key].append( + [qty_to_pop, slot[FIFO_DATE_INDEX], stock_value] + ) qty_to_pop = 0 stock_value = 0 - def __adjust_incoming_transfer_qty(self, transfer_data: dict, fifo_queue: list, row: dict): + def _adjust_incoming_transfer_qty( + self, + transfer_data: dict, + fifo_queue: list, + row: dict, + batch_nos: list | None = None, + serial_nos: list | None = None, + ): "Add previously removed stock back to FIFO Queue." transfer_qty_to_pop = flt(row.actual_qty) stock_value = flt(row.stock_value_difference) - - def add_to_fifo_queue(slot): - if fifo_queue and flt(fifo_queue[0][0]) <= 0: - # neutralize 0/negative stock by adding positive stock - fifo_queue[0][0] += flt(slot[0]) - fifo_queue[0][1] = slot[1] - fifo_queue[0][2] += flt(slot[2]) - else: - fifo_queue.append(slot) + batch_nos = [list(batch_no) for batch_no in batch_nos or []] + serial_nos = list(serial_nos or []) while transfer_qty_to_pop: - if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop: + if transfer_data and 0 < flt(transfer_data[0][FIFO_QTY_INDEX]) <= transfer_qty_to_pop: # bucket qty is not enough, consume whole - transfer_qty_to_pop -= transfer_data[0][0] - stock_value -= transfer_data[0][2] - add_to_fifo_queue(transfer_data.pop(0)) + transfer_qty = flt(transfer_data[0][FIFO_QTY_INDEX]) + transfer_date = transfer_data[0][FIFO_DATE_INDEX] + transfer_value = flt(transfer_data[0][FIFO_VALUE_INDEX]) + transfer_qty_to_pop -= transfer_qty + stock_value -= transfer_value + self._add_incoming_transfer_slots( + fifo_queue, batch_nos, transfer_qty, transfer_date, transfer_value, serial_nos + ) + transfer_data.pop(0) elif not transfer_data: # transfer bucket is empty, extra incoming qty - add_to_fifo_queue([transfer_qty_to_pop, row.posting_date, stock_value]) + self._add_incoming_transfer_slots( + fifo_queue, batch_nos, transfer_qty_to_pop, row.posting_date, stock_value, serial_nos + ) transfer_qty_to_pop = 0 stock_value = 0 else: # ample bucket qty to consume - transfer_data[0][0] -= transfer_qty_to_pop - transfer_data[0][2] -= stock_value - add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1], stock_value]) + transfer_data[0][FIFO_QTY_INDEX] -= transfer_qty_to_pop + transfer_data[0][FIFO_VALUE_INDEX] -= stock_value + self._add_incoming_transfer_slots( + fifo_queue, + batch_nos, + transfer_qty_to_pop, + transfer_data[0][FIFO_DATE_INDEX], + stock_value, + serial_nos, + ) transfer_qty_to_pop = 0 stock_value = 0 - def __update_balances(self, row: dict, key: tuple | str): + def _add_incoming_transfer_slots( + self, + fifo_queue: list, + batch_nos: list, + qty: float, + posting_date: str, + value: float, + serial_nos: list | None = None, + ) -> None: + for slot in self._get_incoming_transfer_slots(batch_nos, qty, posting_date, value, serial_nos): + self._add_transfer_slot_to_fifo_queue(fifo_queue, slot) + + def _get_incoming_transfer_slots( + self, + batch_nos: list, + qty: float, + posting_date: str, + value: float, + serial_nos: list | None = None, + ) -> list: + if serial_nos: + return self._get_serial_incoming_transfer_slots(serial_nos, qty, posting_date, value) + + if not batch_nos: + return [[qty, posting_date, value]] + + incoming_slots = [] + remaining_qty = flt(qty) + remaining_value = flt(value) + + while remaining_qty and batch_nos: + batch_no, use_batchwise_valuation, batch_qty, _ = batch_nos[0] + batch_qty = flt(batch_qty) + slot_qty = min(batch_qty, remaining_qty) + slot_value = ( + remaining_value + if slot_qty == remaining_qty + else flt(remaining_value * (slot_qty / remaining_qty)) + ) + + incoming_slots.append([batch_no, use_batchwise_valuation, slot_qty, posting_date, slot_value]) + + batch_nos[0][2] = flt(batch_qty - slot_qty) + if not batch_nos[0][2]: + batch_nos.pop(0) + + remaining_qty = flt(remaining_qty - slot_qty) + remaining_value = flt(remaining_value - slot_value) + + if remaining_qty: + incoming_slots.append([remaining_qty, posting_date, remaining_value]) + + return incoming_slots + + def _get_serial_incoming_transfer_slots( + self, serial_nos: list, qty: float, posting_date: str, value: float + ) -> list: + incoming_slots = [] + remaining_value = flt(value) + serial_count = min(cint(qty), len(serial_nos)) + + for index in range(serial_count): + serial_no = serial_nos.pop(0) + serial_value = remaining_value if index == serial_count - 1 else flt(value / serial_count) + serial_posting_date = self.serial_no_details.setdefault(serial_no, posting_date) + + incoming_slots.append([serial_no, serial_posting_date, serial_value]) + remaining_value = flt(remaining_value - serial_value) + + return incoming_slots + + def _add_transfer_slot_to_fifo_queue(self, fifo_queue: list, slot: list) -> None: + matching_negative_batch_slot = self._get_matching_negative_batch_slot(fifo_queue, slot) + + if ( + fifo_queue + and is_qty_slot(fifo_queue[0]) + and is_qty_slot(slot) + and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0 + ): + fifo_queue[0][FIFO_QTY_INDEX] += flt(slot[FIFO_QTY_INDEX]) + fifo_queue[0][FIFO_DATE_INDEX] = slot[FIFO_DATE_INDEX] + fifo_queue[0][FIFO_VALUE_INDEX] += flt(slot[FIFO_VALUE_INDEX]) + elif matching_negative_batch_slot: + matching_negative_batch_slot[BATCH_SLOT_QTY_INDEX] += flt(slot[BATCH_SLOT_QTY_INDEX]) + matching_negative_batch_slot[BATCH_SLOT_DATE_INDEX] = slot[BATCH_SLOT_DATE_INDEX] + matching_negative_batch_slot[BATCH_SLOT_VALUE_INDEX] += flt(slot[BATCH_SLOT_VALUE_INDEX]) + if self._is_empty_batch_slot(matching_negative_batch_slot): + fifo_queue.remove(matching_negative_batch_slot) + else: + fifo_queue.append(slot) + + def _is_empty_batch_slot(self, slot: list) -> bool: + return ( + not flt(slot[BATCH_SLOT_QTY_INDEX]) + and round_off_if_near_zero(slot[BATCH_SLOT_VALUE_INDEX], 2) == 0 + ) + + def _get_matching_negative_batch_slot(self, fifo_queue: list, slot: list) -> list | None: + if not is_batch_slot(slot): + return None + + return next( + ( + existing_slot + for existing_slot in fifo_queue + if self._is_matching_negative_batch_slot( + existing_slot, + slot[BATCH_SLOT_BATCH_INDEX], + slot[BATCH_SLOT_VALUATION_INDEX], + include_zero_qty=True, + ) + ), + None, + ) + + def _update_balances(self, row: dict, key: tuple | str): self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction if "total_qty" not in self.item_details[key]: self.item_details[key]["total_qty"] = row.actual_qty @@ -445,9 +827,10 @@ class FIFOSlots: self.item_details[key]["total_qty"] += row.actual_qty self.item_details[key]["has_serial_no"] = row.has_serial_no + self.item_details[key]["has_batch_no"] = row.has_batch_no self.item_details[key]["details"].valuation_rate = row.valuation_rate - def __aggregate_details_by_item(self, wh_wise_data: dict) -> dict: + def _aggregate_details_by_item(self, wh_wise_data: dict) -> dict: "Aggregate Item-Wh wise data into single Item entry." item_aggregated_data = {} for key, row in wh_wise_data.items(): @@ -468,12 +851,13 @@ class FIFOSlots: item_row["qty_after_transaction"] += flt(row["qty_after_transaction"]) item_row["total_qty"] += flt(row["total_qty"]) item_row["has_serial_no"] = row["has_serial_no"] + item_row["has_batch_no"] = row["has_batch_no"] return item_aggregated_data - def __get_stock_ledger_entries(self) -> Iterator[dict]: + def _get_stock_ledger_entries(self) -> Iterator[dict]: sle = frappe.qb.DocType("Stock Ledger Entry") - item = self.__get_item_query() # used as derived table in sle query + item = self._get_item_query() # used as derived table in sle query to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") sle_query = ( @@ -486,8 +870,8 @@ class FIFOSlots: item.brand, item.description, item.stock_uom, + item.has_batch_no, item.has_serial_no, - item.valuation_method, sle.actual_qty, sle.stock_value_difference, sle.valuation_rate, @@ -510,7 +894,7 @@ class FIFOSlots: ) if self.filters.get("warehouse"): - sle_query = self.__get_warehouse_conditions(sle, sle_query) + sle_query = self._get_warehouse_conditions(sle, sle_query) elif self.filters.get("warehouse_type"): warehouses = frappe.get_all( "Warehouse", @@ -525,7 +909,7 @@ class FIFOSlots: return sle_query.run(as_dict=True, as_iterator=True) - def __get_bundle_wise_serial_nos(self) -> dict: + def _get_bundle_wise_serial_nos(self) -> dict: bundle = frappe.qb.DocType("Serial and Batch Bundle") entry = frappe.qb.DocType("Serial and Batch Entry") @@ -548,7 +932,7 @@ class FIFOSlots: query = query.where(bundle[field] == self.filters.get(field)) if self.filters.get("warehouse"): - query = self.__get_warehouse_conditions(bundle, query) + query = self._get_warehouse_conditions(bundle, query) bundle_wise_serial_nos = frappe._dict({}) for bundle_name, serial_no in query.run(): @@ -556,7 +940,52 @@ class FIFOSlots: return bundle_wise_serial_nos - def __get_item_query(self) -> str: + def _get_bundle_wise_batch_nos(self, sabb_name=None) -> dict: + bundle = frappe.qb.DocType("Serial and Batch Bundle") + entry = frappe.qb.DocType("Serial and Batch Entry") + batch = frappe.qb.DocType("Batch") + + to_date = get_datetime(self.filters.get("to_date") + " 23:59:59") + query = ( + frappe.qb.from_(bundle) + .join(entry) + .on(bundle.name == entry.parent) + .join(batch) + .on(entry.batch_no == batch.name) + .select( + bundle.name, + entry.batch_no, + batch.use_batchwise_valuation, + Abs(entry.qty).as_("qty"), + Abs(entry.stock_value_difference).as_("stock_value_difference"), + ) + .where( + (bundle.docstatus == 1) + & (entry.batch_no.isnotnull()) + & (bundle.company == self.filters.get("company")) + & (bundle.posting_datetime <= to_date) + ) + ) + + for field in ["item_code"]: + if self.filters.get(field): + query = query.where(bundle[field] == self.filters.get(field)) + + if self.filters.get("warehouse"): + query = self._get_warehouse_conditions(bundle, query) + + if sabb_name: + query = query.where(bundle.name == sabb_name) + + bundle_wise_batch_nos = frappe._dict({}) + for bundle_name, batch_no, use_batchwise_valuation, qty, stock_value_difference in query.run(): + bundle_wise_batch_nos.setdefault(bundle_name, []).append( + [batch_no.upper(), use_batchwise_valuation, qty, stock_value_difference] + ) + + return bundle_wise_batch_nos + + def _get_item_query(self) -> str: item_table = frappe.qb.DocType("Item") item = frappe.qb.from_("Item").select( @@ -567,7 +996,7 @@ class FIFOSlots: "brand", "item_group", "has_serial_no", - "valuation_method", + "has_batch_no", ) if self.filters.get("item_code"): @@ -578,7 +1007,7 @@ class FIFOSlots: return item - def __get_warehouse_conditions(self, sle, sle_query) -> str: + def _get_warehouse_conditions(self, sle, sle_query) -> str: warehouse = frappe.qb.DocType("Warehouse") lft, rgt = frappe.db.get_value("Warehouse", self.filters.get("warehouse"), ["lft", "rgt"]) diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py index 4e2e5ca9d88..3fdae7ca281 100644 --- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py +++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py @@ -868,6 +868,555 @@ class TestStockAgeing(ERPNextTestSuite): range_valuations = range_values[1::2] self.assertEqual(range_valuations, [15, 7.5, 20, 5]) + def test_batch_item_report_formatting_preserves_mixed_fifo_slots(self): + item_details = { + "Batch Mixed Item": { + "details": frappe._dict( + name="Batch Mixed Item", + item_name="Batch Mixed Item", + description="Batch Mixed Item", + item_group=None, + brand=None, + has_batch_no=True, + stock_uom="Nos", + ), + "fifo_queue": [ + ["SA-BATCH-MIXED-SLOT", 1, 5.0, "2021-12-01", 50.0], + [3.0, "2021-12-02", 30.0], + ], + "has_serial_no": False, + "total_qty": 8.0, + } + } + + report_data = format_report_data(self.filters, item_details, self.filters["to_date"]) + + self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + def test_serial_transfer_replay_preserves_serial_slots(self): + fifo_slots = FIFOSlots(self.filters, []) + transfer_key = ("001", "Serial Item", "WH 1") + fifo_slots.transferred_item_details[transfer_key] = [[2, "2021-12-01", 20]] + + row = frappe._dict( + name="Serial Item", + actual_qty=2, + stock_value_difference=20, + posting_date="2021-12-05", + has_serial_no=True, + ) + fifo_queue = [] + + fifo_slots._compute_incoming_stock(row, fifo_queue, transfer_key, ["SN-A", "SN-B"], []) + + self.assertEqual(fifo_queue, [["SN-A", "2021-12-01", 10.0], ["SN-B", "2021-12-01", 10.0]]) + self.assertFalse(fifo_slots.transferred_item_details[transfer_key]) + + def test_batch_transfer_replay_removes_zeroed_negative_slot(self): + fifo_slots = FIFOSlots(self.filters, []) + fifo_queue = [["SA-ZERO-BATCH", 1, -4, "2021-12-01", -40]] + + fifo_slots._add_transfer_slot_to_fifo_queue(fifo_queue, ["SA-ZERO-BATCH", 1, 4, "2021-12-02", 40]) + + self.assertEqual(fifo_queue, []) + + def test_batchwise_valuation(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Valuation", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + def make_batch(batch_id, use_batchwise_valuation): + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation) + + batchwise_above_90 = "SA-BATCHWISE-ABOVE-90" + non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90" + batchwise_61_90 = "SA-BATCHWISE-61-90" + non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90" + batchwise_31_60 = "SA-BATCHWISE-31-60" + non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60" + batchwise_0_30 = "SA-BATCHWISE-0-30" + non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30" + + for batch_id, use_batchwise_valuation in { + batchwise_above_90: 1, + non_batchwise_above_90: 0, + batchwise_61_90: 1, + non_batchwise_61_90: 0, + batchwise_31_60: 1, + non_batchwise_31_60: 0, + batchwise_0_30: 1, + non_batchwise_0_30: 0, + }.items(): + make_batch(batch_id, use_batchwise_valuation) + + qty_after_transaction = 0 + + def make_sle(posting_date, voucher_no, batch_no, actual_qty, stock_value_difference): + nonlocal qty_after_transaction + + qty_after_transaction += actual_qty + return frappe._dict( + name=item_code, + actual_qty=actual_qty, + qty_after_transaction=qty_after_transaction, + stock_value_difference=stock_value_difference, + warehouse="WH 1", + posting_date=posting_date, + voucher_type="Stock Entry", + voucher_no=voucher_no, + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + + sle = [ + make_sle("2021-08-01", "001", batchwise_above_90, 50, 500), + make_sle("2021-08-10", "002", non_batchwise_above_90, 60, 600), + make_sle("2021-08-20", "003", batchwise_above_90, -10, -100), + make_sle("2021-09-01", "004", non_batchwise_above_90, -15, -150), + make_sle("2021-09-20", "005", batchwise_61_90, 40, 400), + make_sle("2021-09-25", "006", non_batchwise_61_90, 50, 500), + make_sle("2021-09-30", "007", batchwise_61_90, -5, -50), + make_sle("2021-10-05", "008", non_batchwise_above_90, -20, -200), + make_sle("2021-10-20", "009", batchwise_31_60, 30, 300), + make_sle("2021-10-25", "010", non_batchwise_31_60, 40, 400), + make_sle("2021-10-30", "011", batchwise_31_60, -8, -80), + make_sle("2021-11-05", "012", non_batchwise_above_90, -25, -250), + make_sle("2021-11-20", "013", batchwise_0_30, 20, 200), + make_sle("2021-11-25", "014", non_batchwise_0_30, 30, 300), + make_sle("2021-11-30", "015", batchwise_0_30, -6, -60), + make_sle("2021-12-01", "016", non_batchwise_61_90, -10, -100), + ] + + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 221.0) + self.assertEqual( + item_result["fifo_queue"], + [ + [batchwise_above_90, 1, 40.0, "2021-08-01", 400.0], + [batchwise_61_90, 1, 35.0, "2021-09-20", 350.0], + [non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0], + [batchwise_31_60, 1, 22.0, "2021-10-20", 220.0], + [non_batchwise_31_60, 0, 40, "2021-10-25", 400], + [batchwise_0_30, 1, 14.0, "2021-11-20", 140.0], + [non_batchwise_0_30, 0, 30, "2021-11-25", 300], + ], + ) + + report_data = format_report_data(self.filters, slots, self.filters["to_date"]) + range_values = report_data[0][7:15] + self.assertEqual(range_values, [44.0, 440.0, 62.0, 620.0, 75.0, 750.0, 40.0, 400.0]) + + def test_batchwise_valuation_same_voucher_transfer(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Transfer", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + def make_batch(batch_id): + if not frappe.db.exists("Batch", batch_id): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1) + + source_batch = "SA-BATCHWISE-TRANSFER-SOURCE" + target_batch = "SA-BATCHWISE-TRANSFER-TARGET" + make_batch(source_batch) + make_batch(target_batch) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=20, + qty_after_transaction=20, + stock_value_difference=200, + warehouse="WH 1", + posting_date="2021-09-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=source_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=-15, + qty_after_transaction=5, + stock_value_difference=-150, + warehouse="WH 1", + posting_date="2021-10-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=source_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=10, + qty_after_transaction=15, + stock_value_difference=100, + warehouse="WH 1", + posting_date="2021-10-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=target_batch, + valuation_rate=10, + ), + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["total_qty"], 15.0) + self.assertEqual( + item_result["fifo_queue"], + [ + [source_batch, 1, 5.0, "2021-09-01", 50.0], + [target_batch, 1, 10.0, "2021-09-01", 100.0], + ], + ) + self.assertEqual( + fifo_slots.transferred_item_details[("002", item_code, "WH 1")], + [[5.0, "2021-09-01", 50.0]], + ) + + def test_batchwise_valuation_negative_stock_same_voucher(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Stock", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + batch_no = "SA-BATCHWISE-NEGATIVE-STOCK" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]]) + self.assertEqual( + fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]] + ) + + sle.append( + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=-4, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ) + ) + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]]) + self.assertEqual( + fifo_slots.transferred_item_details[("001", item_code, "WH 1")], + [[4.0, "2021-12-01", 40.0]], + ) + + def test_batchwise_valuation_neutralizes_non_head_negative_batch(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Non Head", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER" + negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD" + for batch_no in [buffer_batch, negative_batch]: + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=5, + qty_after_transaction=5, + stock_value_difference=50, + warehouse="WH 1", + posting_date="2021-11-30", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=buffer_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-5, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=negative_batch, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=1, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-12-01", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=negative_batch, + valuation_rate=10, + ), + ] + + fifo_slots = FIFOSlots(self.filters, sle) + slots = fifo_slots.generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual( + item_result["fifo_queue"], + [ + [buffer_batch, 1, 5, "2021-11-30", 50], + [negative_batch, 1, -4.0, "2021-12-01", -40.0], + ], + ) + self.assertEqual( + fifo_slots.transferred_item_details[("002", item_code, "WH 1")], + [[4.0, "2021-12-01", 40.0]], + ) + + def test_batchwise_valuation_negative_stock_later_voucher(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_code = make_item( + "Test Stock Ageing Batchwise Negative Later Voucher", + { + "is_stock_item": 1, + "has_batch_no": 1, + "valuation_method": "FIFO", + }, + ).name + + batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER" + if not frappe.db.exists("Batch", batch_no): + frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_no, + "item": item_code, + } + ).insert(ignore_permissions=True) + + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + sle = [ + frappe._dict( + name=item_code, + actual_qty=-10, + qty_after_transaction=-10, + stock_value_difference=-100, + warehouse="WH 1", + posting_date="2021-11-01", + voucher_type="Stock Entry", + voucher_no="001", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ), + frappe._dict( + name=item_code, + actual_qty=6, + qty_after_transaction=-4, + stock_value_difference=60, + warehouse="WH 1", + posting_date="2021-11-10", + voucher_type="Stock Entry", + voucher_no="002", + has_serial_no=False, + has_batch_no=True, + serial_no=None, + batch_no=batch_no, + valuation_rate=10, + ), + ] + + slots = FIFOSlots(self.filters, sle).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], -4.0) + self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]]) + + def test_batchwise_valuation_stock_reconciliation_with_bundle(self): + from frappe.utils import add_days, getdate, nowdate + + from erpnext.stock.doctype.item.test_item import make_item + from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( + get_batch_from_bundle, + ) + from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( + create_stock_reconciliation, + ) + + suffix = frappe.generate_hash(length=8).upper() + item_code = make_item( + f"Test Stock Ageing Batch Reco {suffix}", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": f"SA-RECO-{suffix}-.###", + "valuation_method": "FIFO", + }, + ).name + warehouse = "_Test Warehouse - _TC" + base_date = nowdate() + + opening_reco = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=12, + rate=10, + posting_date=add_days(base_date, -2), + posting_time="10:00:00", + ) + batch_no = get_batch_from_bundle(opening_reco.items[0].serial_and_batch_bundle) + frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1) + + create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + rate=10, + batch_no=batch_no, + posting_date=add_days(base_date, -1), + posting_time="10:00:00", + ) + + filters = frappe._dict( + company="_Test Company", + to_date=base_date, + ranges=["30", "60", "90"], + item_code=item_code, + ) + slots = FIFOSlots(filters).generate() + item_result = slots[item_code] + + self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"]) + self.assertEqual(item_result["total_qty"], 5.0) + self.assertEqual( + item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]] + ) + def generate_item_and_item_wh_wise_slots(filters, sle): "Return results with and without 'show_warehouse_wise_stock'" diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index a76f40d713b..f6abae53f5e 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -108,10 +108,11 @@ def execute(filters=None): if sle.serial_no: update_available_serial_nos(available_serial_nos, sle) - if sle.actual_qty: + if sle.actual_qty < 0: sle["in_out_rate"] = flt(sle.stock_value_difference / sle.actual_qty, precision) + sle["incoming_rate"] = 0 - elif sle.voucher_type == "Stock Reconciliation": + elif sle.voucher_type == "Stock Reconciliation" and sle.actual_qty < 0: sle["in_out_rate"] = sle.valuation_rate if ( @@ -192,7 +193,7 @@ def get_segregated_bundle_entries(sle, bundle_details, batch_balance_dict, filte new_sle.update(row) new_sle.update( { - "in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty else 0, + "in_out_rate": flt(new_sle.stock_value_difference / row.qty) if row.qty < 0 else 0, "in_qty": row.qty if row.qty > 0 else 0, "out_qty": row.qty if row.qty < 0 else 0, "qty_after_transaction": qty_before_transaction + row.qty, @@ -374,7 +375,7 @@ def get_columns(filters): "convertible": "rate", }, { - "label": _("Valuation Rate"), + "label": _("Outgoing Rate"), "fieldname": "in_out_rate", "fieldtype": filters.valuation_field_type, "width": 140, diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py index ebfa039f82a..2f853252723 100644 --- a/erpnext/stock/stock_balance.py +++ b/erpnext/stock/stock_balance.py @@ -284,7 +284,7 @@ def set_stock_balance_as_per_serial_no( if not posting_time: posting_time = nowtime() - condition = " and item.name='%s'" % item_code.replace("'", "'") if item_code else "" + condition = " and item.name=%s" % frappe.db.escape(item_code, percent=False) if item_code else "" bin = frappe.db.sql( """select bin.item_code, bin.warehouse, bin.actual_qty, item.stock_uom diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 4404ea7a44c..f1b0e2035ea 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -17,7 +17,6 @@ from frappe.utils import ( format_date, get_datetime, get_link_to_form, - getdate, now, nowdate, nowtime, @@ -434,8 +433,7 @@ def get_reposting_data(file_path) -> dict: except Exception: return frappe._dict() - if data := json.loads(data.decode("utf-8")): - data = data + data = json.loads(data.decode("utf-8")) return parse_json(data) @@ -874,15 +872,6 @@ class update_entries_after: if not self.args.get("sle_id"): self.get_dynamic_incoming_outgoing_rate(sle) - if ( - sle.voucher_type == "Stock Reconciliation" - and (sle.serial_and_batch_bundle) - and sle.voucher_detail_no - and not self.args.get("sle_id") - and sle.is_cancelled == 0 - ): - self.reset_actual_qty_for_stock_reco(sle) - if ( sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"] and sle.voucher_detail_no @@ -904,7 +893,7 @@ class update_entries_after: # Only run in reposting self.get_serialized_values(sle) self.wh_data.qty_after_transaction += flt(sle.actual_qty) - if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: + if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and has_correct_data(sle): self.wh_data.qty_after_transaction = sle.qty_after_transaction self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt( @@ -1059,31 +1048,6 @@ class update_entries_after: if not allow_zero_rate: self.wh_data.valuation_rate = self.get_fallback_rate(sle) - def reset_actual_qty_for_stock_reco(self, sle): - doc = frappe.get_doc("Stock Reconciliation", sle.voucher_no) - doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) - - if sle.actual_qty < 0: - doc.reload() - - sle.actual_qty = ( - flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty")) - * -1 - ) - - if abs(sle.actual_qty) == 0.0: - sle.is_cancelled = 1 - - if sle.serial_and_batch_bundle: - for row in doc.items: - if row.name == sle.voucher_detail_no: - row.db_set("current_serial_and_batch_bundle", "") - - sabb_doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle) - sabb_doc.voucher_detail_no = None - sabb_doc.voucher_no = None - sabb_doc.cancel() - def calculate_valuation_for_serial_batch_bundle(self, sle): if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle): return @@ -1491,8 +1455,7 @@ class update_entries_after: item.amount = flt(item.qty) * flt(item.valuation_rate) item.quantity_difference = item.qty - item.current_qty item.amount_difference = item.amount - item.current_amount - else: - sr.difference_amount = sum([item.amount_difference for item in sr.items]) + sr.difference_amount = sum([item.amount_difference for item in sr.items]) sr.db_update() for item in sr.items: @@ -2433,7 +2396,9 @@ def get_stock_value_difference( ) if voucher_detail_no: - query = query.where(table.voucher_detail_no != voucher_detail_no) + query = query.where( + (table.voucher_detail_no != voucher_detail_no) | (table.voucher_detail_no.isnull()) + ) elif voucher_no: query = query.where(table.voucher_no != voucher_no) @@ -2502,3 +2467,28 @@ def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj, company): @frappe.request_cache def is_repack_entry(stock_entry_id): return frappe.get_cached_value("Stock Entry", stock_entry_id, "purpose") == "Repack" + + +def has_correct_data(sle): + previous_sle = get_previous_sle( + { + "item_code": sle.item_code, + "warehouse": sle.warehouse, + "posting_date": sle.posting_date, + "posting_time": sle.posting_time, + "creation": sle.creation, + "sle": sle.name, + } + ) + + if not previous_sle: + return True + + previous_qty = previous_sle.get("qty_after_transaction") or 0 + if previous_qty and not frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_detail_no": sle.voucher_detail_no, "is_cancelled": 0, "actual_qty": ("<", 0)}, + ): + return False + + return True diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py index bda5f68c1e6..77d7530b3c7 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.py @@ -372,13 +372,14 @@ class SubcontractingInwardOrder(SubcontractingController): items_dict = { rm_item.get("rm_item_code"): { "scio_detail": rm_item.get("name"), + "item_code": rm_item.get("rm_item_code"), "qty": calculate_qty_as_per_bom(rm_item), - "to_warehouse": rm_item.get("warehouse"), + "t_warehouse": rm_item.get("warehouse"), "stock_uom": rm_item.get("stock_uom"), } } - stock_entry.add_to_stock_entry_detail(items_dict) + stock_entry.append("items", items_dict[rm_item.get("rm_item_code")]) if target_doc: return stock_entry @@ -413,13 +414,16 @@ class SubcontractingInwardOrder(SubcontractingController): items_dict = { rm_item.get("rm_item_code"): { "scio_detail": rm_item.get("name"), + "item_code": rm_item.get("rm_item_code"), "qty": rm_item.received_qty - rm_item.work_order_qty - rm_item.returned_qty, - "from_warehouse": rm_item.get("warehouse"), + "s_warehouse": rm_item.get("warehouse"), "stock_uom": rm_item.get("stock_uom"), } } - stock_entry.add_to_stock_entry_detail(items_dict) + ste_item = items_dict[rm_item.get("rm_item_code")] + if ste_item.get("qty"): + stock_entry.append("items", ste_item) if target_doc: return stock_entry @@ -465,14 +469,15 @@ class SubcontractingInwardOrder(SubcontractingController): items_dict = { fg_item.item_code: { "qty": qty, - "from_warehouse": fg_item.delivery_warehouse, + "item_code": fg_item.item_code, + "s_warehouse": fg_item.delivery_warehouse, "stock_uom": fg_item.stock_uom, "scio_detail": fg_item.name, "is_finished_item": 1, } } - stock_entry.add_to_stock_entry_detail(items_dict) + stock_entry.append("items", items_dict[fg_item.item_code]) if ( frappe.get_single_value("Selling Settings", "deliver_secondary_items") @@ -490,14 +495,15 @@ class SubcontractingInwardOrder(SubcontractingController): items_dict = { secondary_item.item_code: { "qty": secondary_item.produced_qty - secondary_item.delivered_qty, - "from_warehouse": secondary_item.warehouse, + "item_code": secondary_item.item_code, + "s_warehouse": secondary_item.warehouse, "stock_uom": secondary_item.stock_uom, "scio_detail": secondary_item.name, - "type": secondary_item.type, + "secondary_item_type": secondary_item.secondary_item_type, } } - stock_entry.add_to_stock_entry_detail(items_dict) + stock_entry.append("items", items_dict[secondary_item.item_code]) if target_doc: return stock_entry @@ -536,13 +542,14 @@ class SubcontractingInwardOrder(SubcontractingController): items_dict = { fg_item.item_code: { "qty": qty, + "item_code": fg_item.item_code, "stock_uom": fg_item.stock_uom, "scio_detail": fg_item.name, "is_finished_item": 1, } } - stock_entry.add_to_stock_entry_detail(items_dict) + stock_entry.append("items", items_dict[fg_item.item_code]) if target_doc: return stock_entry diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py index d035f4ddcb9..a8aeca2f423 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py @@ -240,6 +240,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): delivery = frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()) delivery.items[0].use_serial_batch_fields = 1 delivery.save() + delivery.submit() delivery_serial_list, _ = get_serial_batch_list_from_item(delivery.items[0]) self.assertEqual(sorted(serial_list), sorted(delivery_serial_list)) @@ -327,7 +328,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite): def test_secondary_items_delivery(self): new_bom = frappe.copy_doc(frappe.get_doc("BOM", "BOM-Basic FG Item-001")) new_bom.secondary_items.append( - frappe.new_doc("BOM Secondary Item", item_code="Basic RM 2", qty=1, type="Scrap") + frappe.new_doc("BOM Secondary Item", item_code="Basic RM 2", qty=1, secondary_item_type="Scrap") ) new_bom.submit() sc_bom = frappe.get_doc("Subcontracting BOM", "SB-0001") diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json index 94a640b41ce..01e4c63ad2f 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.json @@ -7,7 +7,7 @@ "engine": "InnoDB", "field_order": [ "column_break_rptg", - "type", + "secondary_item_type", "reference_name", "column_break_jkzt", "item_code", @@ -97,7 +97,7 @@ "fieldtype": "Column Break" }, { - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "label": "Type", "no_copy": 1, @@ -114,7 +114,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-02-27 15:15:40.009957", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Inward Order Secondary Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py index 767f216921a..9fcc8b20135 100644 --- a/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_inward_order_secondary_item/subcontracting_inward_order_secondary_item.py @@ -23,7 +23,7 @@ class SubcontractingInwardOrderSecondaryItem(Document): produced_qty: DF.Float reference_name: DF.Data stock_uom: DF.Link - type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_type: DF.Literal["Co-Product", "By-Product", "Scrap", "Additional Finished Good"] warehouse: DF.Link # end: auto-generated types diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js index 76f1cc52094..3e0d15cf553 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js @@ -577,7 +577,7 @@ frappe.ui.form.on("Subcontracting Order", { }, get_materials_from_supplier: function (frm) { - let sco_rm_details = []; + const sco_rm_details = []; if (frm.doc.status != "Closed" && frm.doc.supplied_items) { frm.doc.supplied_items.forEach((d) => { @@ -591,21 +591,16 @@ frappe.ui.form.on("Subcontracting Order", { frm.add_custom_button( __("Return of Components"), () => { - frm.call({ + frappe.model.open_mapped_doc({ method: "erpnext.controllers.subcontracting_controller.get_materials_from_supplier", - freeze: true, - freeze_message: __("Creating Stock Entry"), + frm: frm, args: { subcontract_order: frm.doc.name, rm_details: sco_rm_details, - order_doctype: cur_frm.doc.doctype, - }, - callback: function (r) { - if (r && r.message) { - const doc = frappe.model.sync(r.message); - frappe.set_route("Form", doc[0].doctype, doc[0].name); - } + order_doctype: frm.doc.doctype, }, + freeze: true, + freeze_message: __("Creating Return of Components ..."), }); }, __("Create") diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 4e74a714977..f5588d3b064 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -198,9 +198,8 @@ class SubcontractingOrder(SubcontractingController): item.amount = item.qty * item.rate total_qty += flt(item.qty) total += flt(item.amount) - else: - self.total_qty = total_qty - self.total = total + self.total_qty = total_qty + self.total = total def update_ordered_qty_for_subcontracting(self, sco_item_rows=None): item_wh_list = [] diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index f0803733d53..68af5555d28 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -554,7 +554,12 @@ class TestSubcontractingOrder(ERPNextTestSuite): scr.submit() # Get RM from Supplier - ste = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items]) + frappe.flags.args = frappe._dict( + subcontract_order=sco.name, + rm_details=[d.name for d in sco.supplied_items], + order_doctype=sco.doctype, + ) + ste = get_materials_from_supplier(sco.name) ste.save() ste.submit() @@ -640,7 +645,7 @@ class TestSubcontractingOrder(ERPNextTestSuite): qty=10, ) - self.assertTrue(mr.docstatus == 1) + self.assertEqual(mr.docstatus, 1) new_requested_qty = frappe.db.get_value( "Bin", @@ -726,7 +731,7 @@ class TestSubcontractingOrder(ERPNextTestSuite): sco.submit() sre_list = get_sre_details_for_voucher("Subcontracting Order", sco.name) - self.assertTrue(len(sre_list) > 0) + self.assertGreater(len(sre_list), 0) se_dict = make_rm_stock_entry(sco.name) se = frappe.get_doc(se_dict) @@ -843,9 +848,8 @@ def create_subcontracting_order(**args): warehouses = [] for item in po.items: warehouses.append(item.warehouse) - else: - for idx, val in enumerate(sco.items): - val.warehouse = warehouses[idx] + for idx, val in enumerate(sco.items): + val.warehouse = warehouses[idx] warehouses = set() for item in sco.items: diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 26ad0039070..f9646699ca2 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -12,7 +12,6 @@ from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate import erpnext from erpnext.accounts.utils import get_account_currency -from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.subcontracting_controller import SubcontractingController from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults @@ -216,9 +215,7 @@ class SubcontractingReceipt(SubcontractingController): self.create_raw_materials_supplied_or_received() def validate_closed_subcontracting_order(self): - for item in self.items: - if item.subcontracting_order: - check_on_hold_or_closed_status("Subcontracting Order", item.subcontracting_order) + self.check_for_on_hold_or_closed_status("Subcontracting Order", "subcontracting_order") def update_job_card(self): for row in self.get("items"): @@ -420,7 +417,7 @@ class SubcontractingReceipt(SubcontractingController): self.append( "items", { - "type": secondary_item.type, + "secondary_item_type": secondary_item.secondary_item_type, "is_legacy_scrap_item": secondary_item.is_legacy, "reference_name": item.name, "item_code": secondary_item.item_code, @@ -448,7 +445,7 @@ class SubcontractingReceipt(SubcontractingController): def remove_secondary_items(self): for item in list(self.items): - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: self.remove(item) else: item.secondary_items_cost_per_qty = 0 @@ -490,11 +487,10 @@ class SubcontractingReceipt(SubcontractingController): supplied_items_details[item.name][ supplied_item.rm_item_code ] += supplied_item.available_qty - else: - for item in self.get("supplied_items"): - item.available_qty_for_consumption = supplied_items_details.get(item.reference_name, {}).get( - item.rm_item_code, 0 - ) + for item in self.get("supplied_items"): + item.available_qty_for_consumption = supplied_items_details.get(item.reference_name, {}).get( + item.rm_item_code, 0 + ) def calculate_items_qty_and_amount(self): rm_cost_map = {} @@ -508,7 +504,7 @@ class SubcontractingReceipt(SubcontractingController): secondary_items_cost_map = {} for item in self.get("items") or []: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: qty = ( flt(item.qty) if item.is_legacy_scrap_item @@ -523,7 +519,7 @@ class SubcontractingReceipt(SubcontractingController): total_qty = total_amount = 0 for item in self.get("items") or []: - if not item.type and not item.is_legacy_scrap_item: + if not item.secondary_item_type and not item.is_legacy_scrap_item: if item.qty: if item.name in rm_cost_map: item.rm_supp_cost = rm_cost_map[item.name] @@ -561,13 +557,12 @@ class SubcontractingReceipt(SubcontractingController): total_qty += flt(item.qty) + flt(item.rejected_qty) total_amount += item.amount - else: - self.total_qty = total_qty - self.total = total_amount + self.total_qty = total_qty + self.total = total_amount def validate_secondary_items(self): for item in self.items: - if item.type or item.is_legacy_scrap_item: + if item.secondary_item_type or item.is_legacy_scrap_item: if not item.qty: frappe.throw( _("Row #{0}: Secondary Item Qty cannot be zero").format(item.idx), diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 95264201c44..7154619f382 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -1220,7 +1220,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite): scr.get_secondary_items() scr_secondary_items = set( - [item.item_code for item in scr.items if item.type or item.is_legacy_scrap_item] + [item.item_code for item in scr.items if item.secondary_item_type or item.is_legacy_scrap_item] ) self.assertEqual(len(scr.items), 3) # 1 FG Item + 2 Scrap Items self.assertEqual(scr_secondary_items, set(secondary_items)) @@ -1311,7 +1311,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite): # Step - 8: Cancel Subcontracting Receipt scr.cancel() - self.assertTrue(scr.docstatus == 2) + self.assertEqual(scr.docstatus, 2) def test_subcontract_return_from_rejected_warehouse(self): from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index b6d07f66b98..71f262d7663 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -9,7 +9,7 @@ "field_order": [ "item_code", "is_legacy_scrap_item", - "type", + "secondary_item_type", "column_break_2", "item_name", "section_break_4", @@ -162,12 +162,12 @@ "label": "Accepted Qty", "no_copy": 1, "print_width": "100px", - "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item", + "read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item", "width": "100px" }, { "columns": 1, - "depends_on": "eval:!parent.is_return && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!parent.is_return && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rejected_qty", "fieldtype": "Float", "in_list_view": 1, @@ -175,7 +175,7 @@ "no_copy": 1, "print_hide": 1, "print_width": "100px", - "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item", + "read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item", "width": "100px" }, { @@ -234,7 +234,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rm_cost_per_qty", "fieldtype": "Currency", "label": "Raw Material Cost Per Qty", @@ -244,7 +244,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "service_cost_per_qty", "fieldtype": "Currency", "label": "Service Cost Per Qty", @@ -254,7 +254,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "additional_cost_per_qty", "fieldtype": "Currency", "label": "Additional Cost Per Qty", @@ -278,7 +278,7 @@ "width": "100px" }, { - "depends_on": "eval: !parent.is_return && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval: !parent.is_return && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rejected_warehouse", "fieldtype": "Link", "ignore_user_permissions": 1, @@ -290,7 +290,7 @@ "width": "100px" }, { - "depends_on": "eval:!doc.__islocal && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.__islocal && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "quality_inspection", "fieldtype": "Link", "label": "Quality Inspection", @@ -372,7 +372,7 @@ "no_copy": 1, "options": "BOM", "print_hide": 1, - "read_only_depends_on": "eval:doc.type || doc.is_legacy_scrap_item" + "read_only_depends_on": "eval:doc.secondary_item_type || doc.is_legacy_scrap_item" }, { "fetch_from": "item_code.brand", @@ -499,7 +499,7 @@ "print_hide": 1 }, { - "depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:(doc.use_serial_batch_fields === 0 || doc.docstatus === 1) && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "rejected_serial_and_batch_bundle", "fieldtype": "Link", "label": "Rejected Serial and Batch Bundle", @@ -564,7 +564,7 @@ "label": "Add Serial / Batch Bundle" }, { - "depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:doc.use_serial_batch_fields === 0 && !doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "add_serial_batch_for_rejected_qty", "fieldtype": "Button", "label": "Add Serial / Batch No (Rejected Qty)" @@ -578,7 +578,7 @@ "search_index": 1 }, { - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "landed_cost_voucher_amount", "fieldtype": "Currency", "label": "Landed Cost Voucher Amount", @@ -596,7 +596,7 @@ "options": "Account" }, { - "fieldname": "type", + "fieldname": "secondary_item_type", "fieldtype": "Select", "label": "Type", "no_copy": 1, @@ -606,7 +606,7 @@ }, { "default": "0", - "depends_on": "eval:!doc.type && !doc.is_legacy_scrap_item", + "depends_on": "eval:!doc.secondary_item_type && !doc.is_legacy_scrap_item", "fieldname": "secondary_items_cost_per_qty", "fieldtype": "Currency", "label": "Secondary Items Cost Per Qty", @@ -635,7 +635,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2026-03-09 15:11:16.977539", + "modified": "2026-06-01 10:00:00.000000", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item", diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py index c6233b841a2..47cfd9a1648 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.py @@ -62,7 +62,7 @@ class SubcontractingReceiptItem(Document): subcontracting_order: DF.Link | None subcontracting_order_item: DF.Data | None subcontracting_receipt_item: DF.Data | None - type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] + secondary_item_type: DF.Literal["", "Co-Product", "By-Product", "Scrap", "Additional Finished Good"] use_serial_batch_fields: DF.Check warehouse: DF.Link | None # end: auto-generated types diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index a325c32dbd2..75822ff2247 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -218,11 +218,13 @@ def get_issue_list(doctype, txt, filters, limit_start, limit_page_length=20, ord @frappe.whitelist() def set_multiple_status(names: str, status: str): for name in json.loads(names): - frappe.db.set_value("Issue", name, "status", status) + set_status(name, status) @frappe.whitelist() def set_status(name: str, status: str): + frappe.has_permission("Issue", "write", name, throw=True) + frappe.db.set_value("Issue", name, "status", status) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 8351c40fa97..24665fff94a 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -245,7 +245,7 @@ class TestIssue(TestSetUp): issue = make_issue(frappe.flags.current_time, index=1) create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) - self.assertTrue(issue.status == "Open") + self.assertEqual(issue.status, "Open") # send a reply within response SLA frappe.flags.current_time = get_datetime("2021-11-02 11:00") diff --git a/erpnext/templates/includes/announcement/announcement_row.html b/erpnext/templates/includes/announcement/announcement_row.html index 3099441e344..1eda74784ca 100644 --- a/erpnext/templates/includes/announcement/announcement_row.html +++ b/erpnext/templates/includes/announcement/announcement_row.html @@ -24,10 +24,10 @@ if(content.length > show_char) { var c = content.substr(0, show_char) - var h = content.substr(show_char, content.length - show_char); - html = c + '  ...' - $(this).html(html); + // Set as text (not HTML) so DOM text isn't re-interpreted as + // markup (XSS). \u00a0 is a non-breaking space (same as  ). + $(this).text(c + '\u00a0\u00a0...'); } }); }); diff --git a/erpnext/templates/includes/projects/project_search_box.html b/erpnext/templates/includes/projects/project_search_box.html index d7466873dda..8bebd0be244 100644 --- a/erpnext/templates/includes/projects/project_search_box.html +++ b/erpnext/templates/includes/projects/project_search_box.html @@ -18,7 +18,7 @@ frappe.ready(function() { } var thread = null; function findResult(t) { - window.location.href="/projects?project={{doc.name}}&q=" + t; + window.location.href="/projects?project={{doc.name}}&q=" + encodeURIComponent(t); } $("#project-search").keyup(function() { diff --git a/erpnext/templates/pages/task_info.html b/erpnext/templates/pages/task_info.html index fe4d304a398..4a98b425e73 100644 --- a/erpnext/templates/pages/task_info.html +++ b/erpnext/templates/pages/task_info.html @@ -1,11 +1,11 @@ {% extends "templates/web.html" %} -{% block title %} {{ doc.name }} {% endblock %} +{% block title %} {{ doc.name|e }} {% endblock %} {% block breadcrumbs %} -