Compare commits

..

58 Commits

Author SHA1 Message Date
MochaMind
5c6631f6af chore: update POT file (#56900) 2026-07-05 20:30:21 +02:00
ruthra kumar
d27bc77644 Merge pull request #56876 from frappe/mergify/bp/version-16-hotfix/pr-56852
fix: race condition in process pcv (backport #56852)
2026-07-04 16:32:51 +05:30
ruthra kumar
5a9d40ce04 chore: linter fix
(cherry picked from commit a9ffdac806)
2026-07-04 10:53:20 +00:00
ruthra kumar
9cf7f441fb refactor(test): row name based utility methods
(cherry picked from commit dbc409736a)
2026-07-04 10:53:19 +00:00
ruthra kumar
d759574f9a refactor: prevent whole table scan while scheduling next date
- helps in concurrency isolation

(cherry picked from commit 21f4603144)
2026-07-04 10:53:19 +00:00
ruthra kumar
019b02adcd fix: prevent repeatable read related concurrency errors
Process Period Closing Voucher and Process Period Closing Voucher
Details are trackers how the jobs are processed. Keep transactions on
them very short.

(cherry picked from commit 7e4045e828)
2026-07-04 10:53:19 +00:00
ruthra kumar
6d9f5fac76 fix: race condition and repeatable read in process pcv
- Update using child table name to avoid scanning whole table, which
eventually leads to mariadb 1020 (REPEATABLE READ).
 - Avoid race condition in final summarization

(cherry picked from commit ff6881764b)
2026-07-04 10:53:19 +00:00
Mihir Kandoi
8277d62025 Merge pull request #56870 from frappe/mergify/bp/version-16-hotfix/pr-56859
Fix(stock): item form permission errors (backport #56859)
2026-07-04 14:18:51 +05:30
pandiyan
c964811293 fix: skip item prices tab render for users without item price read access
(cherry picked from commit ef794f390c)
2026-07-04 08:38:42 +00:00
pandiyan
a39753ee08 fix: clear stray permission message when item dashboard has no warehouse access
(cherry picked from commit 8c7b2f4d3c)
2026-07-04 08:38:42 +00:00
Pandiyan P
3c1f163e3b Merge pull request #56863 from frappe/mergify/bp/version-16-hotfix/pr-56837
fix: remove company default on cost center in stock entry detail  (backport #56837)
2026-07-04 12:57:19 +05:30
Pandiyan P
3aad7fee24 fix: resolve conflicts 2026-07-04 12:46:32 +05:30
rohitwaghchaure
17f2de42f3 fix: auto fetch serial no from previous operation output (backport to v16) (#56861) 2026-07-03 17:32:05 +00:00
mergify[bot]
1da28f2278 fix: use live source warehouse valuation for internal transfer purchase receipts (backport #56431) (#56677)
fix: use live source warehouse valuation for internal transfer purchase receipts (#56431)

fix: anchor incoming SLE rate to DN rate for intra-company PR transfers
(cherry picked from commit 35de9deb0a)

Co-authored-by: Shllokkk <140623894+Shllokkk@users.noreply.github.com>
2026-07-03 22:48:35 +05:30
pandiyan
149af49d01 test: cover cost center fallback to item group default in manufacture entry
the existing test_cost_center_for_manufacture only checks a raw material
row against an item-level override, which is set independently of the
":company" default guard and never exercised the bug.

(cherry picked from commit a168bb7ea4)
2026-07-03 15:43:14 +00:00
pandiyan
a1fe6cee5d fix: remove company default on cost center in stock entry detail
the ":company" default pre-filled every row before set_default_cost_center()
ran, so its "if not row.cost_center" guard was always false and the
project/item group/brand priority chain in get_default_cost_center()
never ran.

(cherry picked from commit edfa0a7a1d)

# Conflicts:
#	erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json
2026-07-03 15:43:14 +00:00
Mihir Kandoi
d20b18a209 Merge pull request #56825 from aerele/backport-56806
fix: skip stock reservation for opted-out production plans
2026-07-03 13:56:00 +05:30
Mihir Kandoi
f6916c6f2d Merge pull request #56839 from frappe/mergify/bp/version-16-hotfix/pr-56826
fix: FIFO queue checks and incorrect entries filter in stock ledger reports (backport #56826)
2026-07-03 13:51:41 +05:30
Mihir Kandoi
feb58caf1e test: drop redundant cleanup, db rolls back after each test
(cherry picked from commit 3b1e57966e)
2026-07-03 07:50:22 +00:00
Mihir Kandoi
056195ce07 fix: address review comments
- restore mutated SLE after test via addCleanup
- explicit return False in has_difference
- comment the fifo_stock_diff guard for non-queue predecessors

(cherry picked from commit ef5f47fafd)
2026-07-03 07:50:22 +00:00
Mihir Kandoi
3571512101 fix: FIFO queue checks and incorrect entries filter in stock ledger reports
- 'Show Incorrect Entries' always returned an empty result (regression
  from #43619); now returns entries from one row before the first
  incorrect one
- FIFO queue columns were computed for serialized/batched SLEs that
  don't maintain a stock queue, showing false differences; left empty
  for such rows
- compare value/valuation differences at currency precision, qty at
  float precision

(cherry picked from commit 94ab09e4a3)

# Conflicts:
#	erpnext/stock/report/stock_ledger_invariant_check/test_stock_ledger_invariant_check.py
2026-07-03 07:50:22 +00:00
Khushi Rawat
5034e110cf Merge pull request #56813 from frappe/mergify/bp/version-16-hotfix/pr-56811
fix: render letter head footer in print formats (backport #56811)
2026-07-03 12:11:49 +05:30
Khushi Rawat
b09514465d Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-56811 2026-07-03 11:38:18 +05:30
pandiyan
91a319c9e3 test: cover reserve stock gating on purchase receipt submit
(backport of #56798)

Co-Authored-By: pandiyan <pandiyanpalani37@gmail.com>
2026-07-03 11:06:39 +05:30
pandiyan
17598e2626 fix: skip stock reservation for opted-out production plans
(backport of #56798)

Co-Authored-By: pandiyan <pandiyanpalani37@gmail.com>
2026-07-03 11:06:29 +05:30
mergify[bot]
8c56a5ac0c fix: don't attempt to create SABB for non-serialized / non-batch items (backport #56627) (#56803)
* fix: don't attempt to create SABB for non-serialized / non-batch items (#56627)

* fix: don't attempt to create SABB for non-serialized / non-batch items

* fix(stock): skip serial batch lookup for rows without item code

(cherry picked from commit 5b738b7b0d)

# Conflicts:
#	erpnext/stock/services/serial_batch_bundle_service.py

* chore: resolve conflicts

---------

Co-authored-by: Raffael Meyer <14891507+barredterra@users.noreply.github.com>
2026-07-03 10:35:43 +05:30
khushi8112
0371e8eaf0 fix: add page numbers to print format footer
(cherry picked from commit 2d0c0a8c09)
2026-07-02 21:13:53 +00:00
khushi8112
fa4d32dcdb fix: render letter head footer in print formats
(cherry picked from commit e60a467972)
2026-07-02 21:13:53 +00:00
Shllokkk
8907e8a492 Merge pull request #56805 from frappe/mergify/bp/version-16-hotfix/pr-56804
fix: guard against missing DocType in onboarding steps patch (backport #56804)
2026-07-03 02:12:52 +05:30
Shllokkk
f72289e27c fix: guard against missing DocType in onboarding steps patch (#56804)
(cherry picked from commit caa4358057)
2026-07-02 14:04:47 +00:00
mergify[bot]
003b6554c4 fix(company): ignore user permissions for link fields having link to Account and Cost Center (backport #56748) (#56795)
* fix(company): ignore user permissions for link fields having link to `Account` and `Cost Center` (#56748)

(cherry picked from commit 9cea43b006)

# Conflicts:
#	erpnext/setup/doctype/company/company.json

* chore: resolves conflict

---------

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-07-02 12:55:16 +00:00
Mihir Kandoi
0e25e62750 Merge pull request #56712 from aerele/backport-56552-version-16-hotfix
fix: set conversion_rate on quotation created from customer
2026-07-02 15:20:39 +05:30
mergify[bot]
19d03fee46 fix: block serialized to non-serialized item change when SABB exists (backport #56773) (#56775)
* fix: block serialized to non-serialized item change when SABB exists (#56773)

(cherry picked from commit 0e8ae7548d)

# Conflicts:
#	erpnext/stock/doctype/item/item.py
#	erpnext/stock/doctype/item/test_item.py

* chore: fix conflicts

Remove validation for standard cost change and adjust serialized item change validation.

* chore: fix conflicts

Removed test for opening stock with serial and batch numbers.

---------

Co-authored-by: rohitwaghchaure <rohitw1991@gmail.com>
2026-07-02 09:31:16 +00:00
mergify[bot]
4573cd15a9 fix: restore Save button on reverse journal entry (backport #56770) (#56772)
fix: restore Save button on reverse journal entry (#56770)

Reversing a submitted Journal Entry opened a draft with reversal_of set,
which called frm.set_read_only(). That strips the write and submit perms
from frm.perm, so the toolbar never rendered the Save (or later Submit)
button and the reversal could not be saved.

Lock the fields and the accounts grid as read_only instead, leaving perms
intact so Save and Submit still work while nothing stays editable.

Ticket: 72857
(cherry picked from commit 0a05dd4426)

Co-authored-by: Jatin3128 <140256508+Jatin3128@users.noreply.github.com>
2026-07-02 13:50:57 +05:30
Nabin Hait
0f9405646b Merge pull request #55970 from frappe/mergify/bp/version-16-hotfix/pr-55947
fix(accounts): clear clearance date when amending reconciled voucher (backport #55947)
2026-07-02 13:22:20 +05:30
Mihir Kandoi
84af478eec Merge pull request #56753 from aerele/backport-56670
fix: recompute transferred qty before deciding work order status
2026-07-02 13:16:16 +05:30
Mihir Kandoi
5b133dcf18 Merge pull request #56764 from frappe/mergify/bp/version-16-hotfix/pr-56757
fix: don't treat batch slot at FIFO queue head as qty slot (backport #56757)
2026-07-02 13:09:15 +05:30
Mihir Kandoi
16bc78834e test: assert full negative batch slot in ageing regression test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 8928b42d5d)
2026-07-02 07:14:31 +00:00
Mihir Kandoi
20f6dd0224 fix: don't treat batch slot at FIFO queue head as qty slot
An incoming SLE without resolvable serial/batch details hit the
negative-head branch in _compute_incoming_stock even when the head was
a batch slot, because flt() on the batch number string returns 0.0.
_add_to_negative_fifo_head then crashed with
"TypeError: can only concatenate str (not 'float') to str".

Guard the branch with is_qty_slot, mirroring the existing check in
_add_transfer_slot_to_fifo_queue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c47a95a4d2)
2026-07-02 07:14:31 +00:00
ruthra kumar
d88ca43e52 Merge pull request #56756 from frappe/mergify/bp/version-16-hotfix/pr-56754
refactor: update title for process statement of accounts (backport #56754)
2026-07-02 12:16:01 +05:30
ruthra kumar
ef162f9a9e refactor: update title for process statement of accounts
(cherry picked from commit 04468c3c33)
2026-07-02 06:42:55 +00:00
pandiyan
bc952f40d9 fix: recompute transferred qty before deciding work order status
work order status was decided using a stale transferred-qty value,
computed before the current stock entry's transfer got recomputed.
this left work orders stuck at "not started" for pick-list-driven
transfers, since those entries never set fg_completed_qty and their
transferred qty can only be known from actual item-level transfers.

an earlier attempt fixed this by setting fg_completed_qty from the pick
list's for_qty, but that broke two things tied to fg_completed_qty
being zero: the excess-transfer guard, and the partial-transfer
fraction logic used to avoid marking a work order as fully supplied too
early.

recompute the transferred qty first, then decide status from the fresh
value. revert the fg_completed_qty change since it's no longer needed.
2026-07-02 11:22:57 +05:30
mergify[bot]
eea4756ce9 fix: validate reverse GL entries on current date under immutable ledger (backport #56709) (#56752)
fix: validate reverse GL entries on current date under immutable ledger (#56709)

* fix: validate reverse GL entries on current date under immutable ledger

When Immutable Ledger is enabled, the reverse GL entry is posted on the
current date, but the closed-period checks in make_reverse_gl_entries still
validate against the original (backdated) posting date. This blocks cancelling
a backdated voucher, such as a suspense Journal Entry for a migrated NPA loan,
with a books-closed error even though the reverse entry lands in an open period.

Validate both check_freezing_date and validate_against_pcv against the current
date when Immutable Ledger is enabled. When it is disabled, behaviour is
unchanged.

Follow-up to #55268.

* test: reset frozen till date after reverse entry test

The freeze date set on the company was not reset, so it leaked into the next
test which posts entries in that period. Reset it in a finally block.

* fix: prefer explicit posting_date under immutable ledger

Prefer the posting_date argument before frappe.form_dict and getdate, at both
the validation and the GL entry site, so an explicit date passed by the caller
is honoured and validation still matches the posted date.

(cherry picked from commit cab1b129c0)

Co-authored-by: Nihantra C. Patel <141945075+Nihantra-Patel@users.noreply.github.com>
2026-07-02 04:57:11 +00:00
Mihir Kandoi
8dafd2b684 Merge pull request #56742 from frappe/mergify/bp/version-16-hotfix/pr-56741
fix(item): rework multiple variant dialog for large numeric ranges (backport #56741)
2026-07-02 00:31:03 +05:30
Mihir Kandoi
04c834d6a9 fix(item): error on uncommitted input and escape values in variant dialog
Address review feedback:
- A typed-but-not-selected value passed validation yet was dropped by
  get_selected_attributes (reads committed pills only). Treat any pending
  input as an error so it is never silently omitted from creation.
- Escape pill / pending values before interpolating them into the HTML
  error message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit d4da9a3d7d)
2026-07-01 18:14:18 +00:00
Mihir Kandoi
025d0cd7f3 fix(item): rework multiple variant dialog for large numeric ranges
The 'Create Multiple Variants' dialog rendered one checkbox per attribute
value and read the numeric config from the variant attribute child row. This
broke in several ways:

- A template whose attribute was made numeric after being added kept
  numeric_values=0 on the child row, so the dialog treated it as non-numeric,
  queried the empty Item Attribute Value table, and showed no values.
- Enumerating a large range (e.g. 1-100000) into checkboxes froze the browser.

Rework the dialog:

- Read numeric_values / from_range / to_range / increment from the Item
  Attribute master, and guard increment > 0.
- Replace the checkbox-per-value list with one MultiSelectPills per attribute,
  with a search placeholder.
- Stop enumerating numeric ranges: preview the first few values and validate
  typed input against the range on demand, so huge ranges stay instant.
- Block variant creation with a modal error if any selected value or pending
  input is invalid (out of range, off-increment, or not a number), so garbage
  like '00A' can't reach creation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 99152b8300)
2026-07-01 18:14:18 +00:00
Mihir Kandoi
374b340e73 fix(item-attribute): clear attribute values when marking numeric
Marking an attribute numeric hides the Item Attribute Values grid but leaves
its rows in the doc, whose mandatory Attribute Value / Abbreviation block the
save client-side before the server can clear them. Clear the table on the
client too so the save goes through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit 4afbd4d3d9)
2026-07-01 18:14:18 +00:00
ervishnucs
26905bc142 fix: set conversion_rate on quotation created from customer 2026-07-01 22:27:14 +05:30
mergify[bot]
d9aa4491f1 feat: capture user persona during setup (backport #56705) (#56707) 2026-07-01 18:15:55 +05:30
mergify[bot]
19c318df68 fix(banking): handle blank password protected PDFs and negative amounts in CR/DR columns (backport #56690) (#56694)
fix(banking): handle blank password protected PDFs and negative amounts in CR/DR columns (#56690)

* fix(banking): strip signs from amount if column has CR/DR values

* fix(banking): try decrypting PDF with a blank password

(cherry picked from commit 300471da12)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-01 08:24:28 +00:00
mergify[bot]
2cb577b912 fix(banking): use custom renderer for translated strings and parser for rules (backport #56643) (#56647)
fix(banking): use custom renderer for translated strings and parser for rules (#56643)

fix(banking): use custom renderer for translated strings and parser for formula evaluation

(cherry picked from commit 8447f551e7)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-01 13:31:12 +05:30
mergify[bot]
53c17bf331 chore: update dependencies in banking app (backport #56685) (#56689)
chore: update dependencies in banking app (#56685)

chore: update deps in banking app
(cherry picked from commit 26583ae357)

Co-authored-by: Nikhil Kothari <nik.kothari22@live.com>
2026-07-01 13:30:32 +05:30
Mihir Kandoi
e083d195cf Merge pull request #56672 from aerele/backport/qi-stock-entry-by-purpose-v16
fix(stock): support quality inspection for stock entry by purpose (ba…
2026-07-01 12:45:07 +05:30
Mihir Kandoi
7f1ef59dc5 Merge pull request #56671 from aerele/backport/dn-credit-note-per-billed-v16
fix(selling): update sales order per billed on credit note submission
2026-07-01 09:29:38 +05:30
Sudharsanan11
710e021638 test(selling): add test to validate the per billed after credit note submission 2026-07-01 09:06:01 +05:30
Sudharsanan11
224cf19f33 fix(selling): update sales order per billed on credit note submission 2026-07-01 09:06:01 +05:30
Sudharsanan11
40ca3b5e5d fix(stock): support quality inspection for stock entry by purpose (backport #56446) 2026-07-01 00:16:21 +05:30
Nabin Hait
6f281356a6 fix(accounts): clear clearance date when amending reconciled voucher
The framework ignores `no_copy` while amending, so a reconciled voucher
carried a stale clearance date into its amendment even though the linked
bank transaction gets unreconciled on cancellation. Reset it via a shared
`before_insert` hook on AccountsController.

Fixes #54909

(cherry picked from commit 1a8d73cbbe)
2026-06-16 09:59:41 +00:00
70 changed files with 4068 additions and 2574 deletions

View File

@@ -14,35 +14,35 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tailwindcss/vite": "^4.3.0",
"@tailwindcss/vite": "^4.3.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.24",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react": "^6.0.3",
"chrono-node": "^2.9.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"frappe-react-sdk": "^1.15.0",
"frappe-react-sdk": "^1.17.0",
"fuse.js": "^7.3.0",
"jotai": "^2.20.0",
"jotai-family": "^1.0.1",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"lodash.isplainobject": "^4.0.6",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"radix-ui": "^1.6.1",
"react": "^19.2.7",
"react-currency-input-field": "^4.0.5",
"react-day-picker": "9.14.0",
"react-dom": "^19.2.6",
"react-dom": "^19.2.7",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.75.0",
"react-hotkeys-hook": "^5.3.2",
"react-markdown": "^10.1.0",
"react-router": "^7.15.0",
"react-router-dom": "^7.15.0",
"react-router": "^8.1.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"safe-expr-eval": "^1.0.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.0",
@@ -51,15 +51,15 @@
"vite": "^8.0.16"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.4",
"@types/node": "^25.3.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.4.24",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0"
"typescript-eslint": "^8.62.1"
}
}

View File

@@ -1,5 +1,5 @@
import { lazy, useEffect } from 'react'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import { BrowserRouter, Navigate, Route, Routes } from 'react-router'
import { FrappeProvider } from 'frappe-react-sdk'
import { Toaster } from '@/components/ui/sonner'
import BankReconciliation from '@/pages/BankReconciliation'

View File

@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo, useState } from "react"
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
@@ -26,6 +25,7 @@ import { Form } from "@/components/ui/form"
import { useForm } from "react-hook-form"
import { DateField } from "@/components/ui/form-elements"
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankClearanceSummary = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -203,14 +203,14 @@ const BankClearanceSummaryView = () => {
[accountCurrency, bankAccount, companyID, mutate, onCopy],
)
const content = _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -18,6 +18,7 @@ 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 { evaluateAmountFormula } from "@/lib/amountFormula"
import { flt, formatCurrency } from "@/lib/numbers"
import { cn } from "@/lib/utils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -215,38 +216,13 @@ const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: Unreconci
})
} 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;
}
const transactionAmount = selectedTransaction.unallocated_amount ?? 0
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
const computedDebit = acc?.debit ? flt(evaluateAmountFormula(acc.debit, transactionAmount), 2) : 0
const computedCredit = acc?.credit ? flt(evaluateAmountFormula(acc.credit, transactionAmount), 2) : 0
totalDebits = flt(totalDebits + computedDebit, 2)
totalCredits = flt(totalCredits + computedCredit, 2)

View File

@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import { useCallback, useMemo } from "react"
import type { ColumnDef } from "@tanstack/react-table"
import { useFrappeGetCall } from "frappe-react-sdk"
@@ -19,6 +18,7 @@ import _ from "@/lib/translate"
import { toast } from "sonner"
import { useCopyToClipboard } from "usehooks-ts"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const BankReconciliationStatement = () => {
const bankAccount = useAtomValue(selectedBankAccountAtom)
@@ -189,14 +189,14 @@ const BankReconciliationStatementView = () => {
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
}, [data])
const content = _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
}} />
</Paragraph>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -1,7 +1,6 @@
import { useAtomValue, useSetAtom } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { Paragraph } from "@/components/ui/typography"
import { formatDate } from "@/lib/date"
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
@@ -23,6 +22,7 @@ import { useCallback, useMemo, useState } from "react"
import { Link } from "react-router"
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
import MarkdownRenderer from "@/components/ui/markdown"
const BankTransactions = () => {
const selectedBank = useAtomValue(selectedBankAccountAtom)
@@ -243,14 +243,14 @@ const BankTransactionListView = () => {
}, [data, search, amountFilter, typeFilter, status])
const content = _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-2 py-2">
<div className="flex gap-2 justify-between items-center">
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
</Paragraph>
<span className="text-p-sm">
<MarkdownRenderer content={content} />
</span>
<Button size='md' variant='subtle' asChild>
<Link to="/statement-importer">

View File

@@ -2,7 +2,6 @@ import { useAtomValue } from "jotai"
import { MissingFiltersBanner } from "./MissingFiltersBanner"
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
import { Paragraph } from "@/components/ui/typography"
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useMemo } from "react"
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
@@ -18,6 +17,7 @@ import { PartyPopper } from "lucide-react"
import ErrorBanner from "@/components/ui/error-banner"
import _ from "@/lib/translate"
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
import MarkdownRenderer from "@/components/ui/markdown"
const IncorrectlyClearedEntries = () => {
const companyID = useCurrentCompany()
@@ -177,22 +177,22 @@ const IncorrectlyClearedEntriesView = () => {
[accountCurrency, onClearClick],
)
const content = _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
const entriesContent = _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
return <div className="space-y-4 py-2">
<div>
<Paragraph className="text-sm">
<span dangerouslySetInnerHTML={{
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
}} />
<span className="text-p-sm">
<MarkdownRenderer content={content} />
<br />
{data && data.message.result.length > 0 && <span>
<span dangerouslySetInnerHTML={{
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
}} />
<MarkdownRenderer content={entriesContent} />
<br />
{_("You can reset the clearing dates of these entries here.")}
</span>}
</Paragraph>
</span>
</div>
{error && <ErrorBanner error={error} />}

View File

@@ -11,6 +11,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { H4, Paragraph } from "@/components/ui/typography"
import { today } from "@/lib/date"
import { evaluateAmountFormula } from "@/lib/amountFormula"
import _ from "@/lib/translate"
import { cn } from "@/lib/utils"
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
@@ -445,11 +446,10 @@ const AmountFormulaRenderer = ({ value }: { value?: string }) => {
// If it's a string and cannot be a number, then show it as a formula
if (isNaN(Number(value))) {
let calculatedValue = "";
try {
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
calculatedValue = String(evaluateAmountFormula(value ?? "", 200));
} catch (error: unknown) {
console.error(error);
calculatedValue = "Error";

View File

@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
import { toast } from 'sonner'
import ErrorBanner from '@/components/ui/error-banner'
import { Link, useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router'
import { useMemo, useState } from 'react'
import { Progress } from '@/components/ui/progress'
import { useSetAtom } from 'jotai'

View File

@@ -0,0 +1,26 @@
import { Parser } from 'safe-expr-eval'
const parser = new Parser()
const PLAIN_NUMBER_PATTERN = /^-?\d+(\.\d+)?$/
export function evaluateAmountFormula(expression: string, transactionAmount: number): number {
const trimmed = expression.trim()
if (!trimmed) {
return 0
}
if (PLAIN_NUMBER_PATTERN.test(trimmed)) {
return Number(trimmed)
}
try {
const result = parser.parse(trimmed).evaluate({ transaction_amount: transactionAmount })
if (typeof result !== 'number' || !Number.isFinite(result)) {
return 0
}
return result
} catch {
return 0
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import frappe
from frappe.model.document import Document
from frappe.utils.user import is_website_user
__version__ = "16.26.2"
__version__ = "16.25.0"
def get_default_company(user=None):

View File

@@ -829,7 +829,9 @@ def compute_final_transactions(transaction_rows: list, date_format: str, amount_
if amount_format == 'Amount column has "CR"/"DR" values':
amount = transaction_row.get("amount")
float_amount = get_float_amount(amount)
# If the amount column has CR/DR in it - we should remove any signs (negative or positive) from the amount
float_amount = abs(get_float_amount(amount) or 0)
if "cr" in amount.lower():
return 0, float_amount
else:
@@ -932,14 +934,18 @@ def extract_pdf_tables(content: bytes, password: str | None = None) -> list[dict
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(content))
if reader.is_encrypted and (not password or not reader.decrypt(password)):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
if reader.is_encrypted:
# Try opening the PDF with a password - if no password is provided, try with a blank password
if not password:
password = ""
if not reader.decrypt(password):
frappe.throw(
_(
"This PDF is password protected. Please set the correct statement password on the"
" Bank Account and try again."
),
title=_("Password Required"),
)
text_settings = {"vertical_strategy": "text", "horizontal_strategy": "text"}
tables = []

View File

@@ -104,6 +104,36 @@ class TestBankTransaction(ERPNextTestSuite):
self.assertEqual(bank_transaction.unallocated_amount, 1700)
self.assertEqual(bank_transaction.payment_entries, [])
# Amending a reconciled payment entry must not carry over its clearance date
def test_clearance_date_cleared_on_amend(self):
bank_transaction = frappe.get_doc(
"Bank Transaction",
dict(description="1512567 BG/000003025 OPSKATTUZWXXX AT776000000098709849 Herr G"),
)
payment = frappe.get_doc("Payment Entry", dict(party="Mr G", paid_amount=1700))
vouchers = json.dumps(
[
{
"payment_doctype": "Payment Entry",
"payment_name": payment.name,
"amount": bank_transaction.unallocated_amount,
}
]
)
reconcile_vouchers(bank_transaction.name, vouchers)
self.assertTrue(frappe.db.get_value("Payment Entry", payment.name, "clearance_date"))
payment.reload()
payment.cancel()
amended = frappe.copy_doc(payment)
amended.amended_from = payment.name
amended.docstatus = 0
amended.insert()
self.assertFalse(amended.clearance_date)
# Check if ERPNext can correctly filter a linked payments based on the debit/credit amount
def test_debit_credit_output(self):
bank_transaction = frappe.get_doc(

View File

@@ -9,6 +9,48 @@ from frappe.model.document import Document
from erpnext.accounts.doctype.bank_transaction.bank_transaction import BankTransaction
PLAIN_NUMBER_PATTERN = re.compile(r"^-?\d+(\.\d+)?$")
# Tokens accepted by safe-expr-eval on the frontend (must stay in sync).
ALLOWED_FORMULA_TOKEN = re.compile(r"\s+|transaction_amount|\d+(?:\.\d+)?|[+\-*/%^()]")
PYTHON_ONLY_OPERATORS = ("**", "//")
def _is_expr_eval_formula(formula: str) -> bool:
position = 0
while position < len(formula):
match = ALLOWED_FORMULA_TOKEN.match(formula, position)
if not match:
return False
position = match.end()
return formula.count("(") == formula.count(")")
def validate_amount_formula(formula: str) -> None:
if not formula:
return
stripped = formula.strip()
if PLAIN_NUMBER_PATTERN.match(stripped):
return
if any(operator in stripped for operator in PYTHON_ONLY_OPERATORS):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not _is_expr_eval_formula(stripped):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
# expr-eval uses ^ for exponentiation; translate for a smoke-test evaluation only.
python_formula = stripped.replace("^", "**")
try:
result = frappe.safe_eval(python_formula, eval_globals=None, eval_locals={"transaction_amount": 1})
except Exception:
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
if not isinstance(result, (int | float)):
frappe.throw(_("Invalid debit/credit formula: {0}").format(formula))
class BankTransactionRule(Document):
# begin: auto-generated types
@@ -86,6 +128,11 @@ class BankTransactionRule(Document):
frappe.throw(
_("The last account row must not have any debit or credit amounts set.")
)
else:
if account.debit:
validate_amount_formula(account.debit)
if account.credit:
validate_amount_formula(account.credit)
# Validate regex
for rule in self.description_rules:

View File

@@ -231,3 +231,45 @@ class TestBankTransactionRule(ERPNextTestSuite, AccountsTestMixin):
doc = self._rule("bad_rx", [{"check": "Regex", "value": "["}])
with self.assertRaises(ValidationError):
doc.insert()
def _multiple_accounts_rule(self, prefix: str, accounts, **fields):
return self._rule(
prefix,
[{"check": "Contains", "value": "x"}],
classify_as="Bank Entry",
bank_entry_type="Multiple Accounts",
accounts=accounts,
**fields,
)
def test_validate_bank_entry_multiple_valid_amount_formulas(self):
doc = self._multiple_accounts_rule(
"be_formula",
accounts=[
{"account": self.bank, "debit": "200", "credit": ""},
{"account": self.cash, "debit": "", "credit": "transaction_amount * 0.25"},
{"account": self.cash, "debit": "", "credit": ""},
],
)
doc.insert()
self.assertTrue(doc.name)
def test_validate_bank_entry_multiple_invalid_amount_formulas(self):
malicious_formulas = [
"__import__('os')",
"eval('1+1')",
"open('/etc/passwd')",
"transaction_amount ** 2",
"transaction_amount // 2",
]
for formula in malicious_formulas:
with self.subTest(formula=formula):
doc = self._multiple_accounts_rule(
"be_bad_formula",
accounts=[
{"account": self.bank, "debit": formula, "credit": ""},
{"account": self.cash, "debit": "", "credit": ""},
],
)
with self.assertRaises(ValidationError):
doc.insert()

View File

@@ -71,7 +71,7 @@ frappe.ui.form.on("Journal Entry", {
refresh: function (frm) {
if (frm.doc.reversal_of && (frm.is_new() || frm.doc.docstatus == 0)) {
frm.set_read_only();
erpnext.journal_entry.lock_reversal_entry(frm);
}
erpnext.toggle_naming_series();
@@ -564,6 +564,13 @@ $.extend(erpnext.journal_entry, {
});
},
lock_reversal_entry: function (frm) {
frm.fields
.filter((field) => field.has_input)
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},
set_debit_credit_in_company_currency: function (frm, cdt, cdn) {
var row = locals[cdt][cdn];

View File

@@ -352,12 +352,15 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", "2021-12-31")
try:
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
)
finally:
frappe.db.set_value("Company", "Test PCV Company", "accounts_frozen_till_date", None)
totals_after_cancel = frappe.db.sql(
"""

View File

@@ -89,50 +89,55 @@ class ProcessPeriodClosingVoucher(Document):
cancel_pcv_processing(self.name)
def initialize_parallel_threads(docname: str):
threads = 4
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(threads)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
x.name,
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=x.name,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(4)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": x.processing_date,
"parent": docname,
"report_type": x.report_type,
"parentfield": x.parentfield,
},
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
initialize_parallel_threads(docname)
@frappe.whitelist()
@@ -250,11 +255,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
@@ -264,15 +269,15 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": to_process[0].processing_date,
"parent": docname,
"report_type": to_process[0].report_type,
"parentfield": to_process[0].parentfield,
},
to_process[0].name,
"status",
"Running",
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
@@ -280,6 +285,7 @@ def schedule_next_date(docname: str):
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=to_process[0].name,
date=to_process[0].processing_date,
report_type=to_process[0].report_type,
parentfield=to_process[0].parentfield,
@@ -444,6 +450,11 @@ def summarize_and_post_ledger_entries(docname):
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@@ -529,10 +540,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
def process_individual_date(docname: str, date, report_type, parentfield):
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
)
if current_date_status != "Running":
@@ -579,17 +590,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
"Completed",
)
# commit heavy computation before touching PPCV or PPCVD
if not frappe.in_test:
frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)

View File

@@ -48,18 +48,27 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
ppcv.save()
return ppcv
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
def set_processing_date_status(self, row_name, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
row_name,
"status",
status,
)
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
def get_row_name(self, ppcv_name, rpt_type, parentfield):
return frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
order_by="report_type, idx",
pluck="name",
limit=1,
)[0]
def get_processing_date_closing_balance(self, row_name):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
row_name,
"closing_balance",
)
@@ -97,11 +106,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
@@ -117,11 +125,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Balance sheet balance
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
@@ -138,11 +145,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.assertEqual(len(bal), 2)
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
expected_opening_cash = {

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe.model.document import Document
@@ -24,3 +24,10 @@ class ProcessPeriodClosingVoucherDetail(Document):
# end: auto-generated types
pass
def on_doctype_update():
frappe.db.add_index(
"Process Period Closing Voucher Detail",
["parent", "status", "parentfield", "idx", "processing_date"],
)

View File

@@ -13,7 +13,7 @@
</div>
{% endif %}
</div>
<h2 class="text-center">{{ _("GENERAL LEDGER") }}</h2>
<h2 class="text-center">{{ _("STATEMENT OF ACCOUNTS") }}</h2>
<div>
{% if filters.party[0] == filters.party_name[0] %}
<h5 style="float: left;">{{ _("Customer: ") }} <b>{{ filters.party_name[0] }}</b></h5>

View File

@@ -716,13 +716,15 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
if immutable_ledger_enabled:
validation_date = posting_date or frappe.form_dict.get("posting_date") or getdate()
else:
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
check_freezing_date(validation_date, gl_entries[0]["company"], adv_adj)
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
@@ -789,7 +791,7 @@ def make_reverse_gl_entries(
if immutable_ledger_enabled:
new_gle["is_cancelled"] = 0
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
new_gle["posting_date"] = posting_date or frappe.form_dict.get("posting_date") or getdate()
elif posting_date:
new_gle["posting_date"] = posting_date

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -140,6 +140,26 @@ class AccountsController(TransactionBase):
if self.doctype in relevant_docs:
self.set_payment_schedule()
def before_insert(self):
self.clear_clearance_date_on_amend()
def clear_clearance_date_on_amend(self):
"""Drop the bank reconciliation clearance date copied over while amending.
The framework copies `no_copy` fields when amending, so a reconciled
voucher would carry a stale clearance date into its amendment even though
the linked bank transaction gets unreconciled on cancellation.
"""
if not self.get("amended_from"):
return
if self.meta.has_field("clearance_date"):
self.clearance_date = None
for payment in self.get("payments") or []:
if payment.meta.has_field("clearance_date"):
payment.clearance_date = None
def on_update(self):
from erpnext.controllers.taxes_and_totals import process_item_wise_tax_details

View File

@@ -387,6 +387,10 @@ class StockController(AccountsController):
parent_details = self.get_parent_details_for_packed_items()
for row in self.get(table_name):
item_code = row.get("rm_item_code") or row.get("item_code")
if not item_code or not self.is_serial_batch_item(item_code):
continue
if (
not via_landed_cost_voucher
and row.serial_and_batch_bundle

File diff suppressed because it is too large Load Diff

View File

@@ -1720,7 +1720,7 @@ def make_material_request(source_name, target_doc=None):
@frappe.whitelist()
def make_stock_entry(source_name, target_doc=None):
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
def update_item(source, target, source_parent):
target.t_warehouse = source_parent.wip_warehouse
@@ -1752,6 +1752,8 @@ def make_stock_entry(source_name, target_doc=None):
target.set_missing_values()
target.set_stock_entry_type()
from erpnext.stock.doctype.stock_entry.stock_entry import set_previous_operation_serial_batch
wo_allows_alternate_item = frappe.db.get_value(
"Work Order", target.work_order, "allow_alternative_item"
)
@@ -1760,6 +1762,7 @@ def make_stock_entry(source_name, target_doc=None):
wo_allows_alternate_item
and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
)
set_previous_operation_serial_batch(target, item)
doclist = get_mapped_doc(
"Job Card",

View File

@@ -1057,6 +1057,9 @@ class TestJobCard(ERPNextTestSuite):
job_card.submit()
for row in fg_bom.items:
if row.item_code == sfg.name:
continue
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
@@ -1067,9 +1070,295 @@ class TestJobCard(ERPNextTestSuite):
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
sfg_row = next(row for row in manufacturing_entry.items if row.item_code == sfg.name)
self.assertEqual(flt(sfg_row.basic_rate, 3), 95.0)
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("Auto Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("Auto Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("Auto Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"Auto Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "AP-SFG-.#####",
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "Auto Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "Auto Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-01-01 08:00:00",
"to_time": "2024-01-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
self.assertTrue(sfg_fg_row.serial_and_batch_bundle)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_b.for_quantity,
},
)
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
sfg_consume_row = next(r for r in me_b.items if r.item_code == sfg and r.s_warehouse)
self.assertTrue(
sfg_consume_row.serial_and_batch_bundle,
"Previous operation's batch was not auto-pulled into the semi-finished consumption row",
)
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_semi_fg_auto_pull_with_uom_conversion(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry import set_previous_operation_serial_batch
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("UOM Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("UOM Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("UOM Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"UOM Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "UP-SFG-.#####",
"uoms": [{"uom": "Box", "conversion_factor": 5}],
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "UOM Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "UOM Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=5, basic_rate=100, posting_date="2024-01-01")
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "UOM Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
se = frappe.new_doc("Stock Entry")
se.company = "_Test Company"
se.purpose = "Material Transfer"
se.work_order = work_order.name
se.set_stock_entry_type()
row = se.append(
"items",
{
"item_code": sfg,
"qty": 1,
"uom": "Box",
"conversion_factor": 5,
"s_warehouse": warehouse,
"t_warehouse": "_Test Warehouse - _TC",
},
)
set_previous_operation_serial_batch(se, row)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
abs(frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")),
5.0,
)
se.save()
se.submit()
se.reload()
row = se.items[0]
consumed_batches = get_batches_from_bundle(row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
self.assertEqual(abs(sum(consumed_batches.values())), 5.0)
def test_secondary_items_without_sfg(self):
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:

View File

@@ -2322,6 +2322,145 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertTrue(len(reserved_entries) == 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_no_stock_reservation_via_purchase_receipt_when_reserve_stock_disabled(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.material_request.material_request import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
bom_tree = {"FG For SR No Auto Reserve": {"RM For SR No Auto Reserve": {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
warehouse = "_Test Warehouse - _TC"
# reserve_stock is deliberately left unset (defaults to 0): this is what happens when
# "Auto Reserve Stock" is off and nobody ticks "Reserve Stock" on the Production Plan by hand.
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
self.assertEqual(plan.reserve_stock, 0)
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
material_requests = frappe.get_all(
"Material Request", filters={"production_plan": plan.name}, pluck="name"
)
self.assertGreater(len(material_requests), 0)
for mr_name in list(set(material_requests)):
po = make_purchase_order(mr_name)
po.supplier = "_Test Supplier"
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
sre = StockReservation(plan)
reserved_entries = sre.get_reserved_entries("Production Plan", plan.name)
self.assertEqual(len(reserved_entries), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_ignores_production_plans_with_reserve_stock_off_on_shared_purchase_order(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
frappe.db.set_single_value("Stock Settings", "auto_reserve_stock", 0)
warehouse = "_Test Warehouse - _TC"
bom_reserve = create_nested_bom({"FG SR Mixed Reserve": {"RM SR Mixed Reserve": {}}}, prefix="")
bom_skip = create_nested_bom({"FG SR Mixed Skip": {"RM SR Mixed Skip": {}}}, prefix="")
def make_submitted_plan(item_code, reserve_stock):
plan = create_production_plan(
item_code=item_code,
planned_qty=5,
ignore_existing_ordered_qty=1,
do_not_submit=1,
warehouse=warehouse,
for_warehouse=warehouse,
reserve_stock=reserve_stock,
)
plan.get_sub_assembly_items()
plan.set("mr_items", [])
for d in get_items_for_material_requests(plan.as_dict()):
plan.append("mr_items", d)
plan.save()
plan.submit()
plan.submit_material_request = 1
plan.make_material_request()
return plan
plan_reserve = make_submitted_plan(bom_reserve.item, reserve_stock=1)
plan_skip = make_submitted_plan(bom_skip.item, reserve_stock=0)
self.assertEqual(plan_reserve.reserve_stock, 1)
self.assertEqual(plan_skip.reserve_stock, 0)
mr_reserve = frappe.get_all(
"Material Request", filters={"production_plan": plan_reserve.name}, pluck="name"
)[0]
mr_skip = frappe.get_all(
"Material Request", filters={"production_plan": plan_skip.name}, pluck="name"
)[0]
# One Purchase Order pulling rows from both Material Requests, so the Purchase Receipt made
# from it has both a reservable and a non-reservable Production Plan reference in `doc.items`.
po = frappe.new_doc("Purchase Order")
po.supplier = "_Test Supplier"
po.company = plan_reserve.company
po.schedule_date = nowdate()
for mr_name in (mr_reserve, mr_skip):
mr = frappe.get_doc("Material Request", mr_name)
for item in mr.items:
po.append(
"items",
{
"item_code": item.item_code,
"qty": item.qty,
"rate": 100,
"schedule_date": nowdate(),
"warehouse": warehouse,
"material_request": mr.name,
"material_request_item": item.name,
},
)
po.submit()
pr = make_purchase_receipt(po.name)
pr.submit()
reserved_for_plan_reserve = StockReservation(plan_reserve).get_reserved_entries(
"Production Plan", plan_reserve.name
)
reserved_for_plan_skip = StockReservation(plan_skip).get_reserved_entries(
"Production Plan", plan_skip.name
)
self.assertGreater(len(reserved_for_plan_reserve), 0)
self.assertEqual(len(reserved_for_plan_skip), 0)
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_of_serial_nos_against_production_plan(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom

View File

@@ -686,6 +686,28 @@ class TestWorkOrder(ERPNextTestSuite):
ste = make_stock_entry(wo_order.name, "Material Transfer for Manufacture", wo_order.qty)
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
# "_Test Item Group" is master data with buying_cost_center already set to
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
# BOM need to be created, since no existing item in that group has one.
fg_item = make_item(
"_Test FG Item For Item Group Cost Center",
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
)
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
wo_order = make_wo_order_test_record(
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
)
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
ste.insert()
fg_row = next(d for d in ste.items if d.is_finished_item)
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
def test_operation_time_with_batch_size(self):
fg_item = "Test Batch Size Item For BOM"
rm1 = "Test Batch Size Item RM 1 For BOM"

View File

@@ -266,6 +266,7 @@ class WorkOrder(Document):
self.validate_sales_order()
self.set_default_warehouse()
self.set_operation_warehouses()
self.validate_warehouse_belongs_to_company()
self.check_wip_warehouse_skip()
self.calculate_operating_cost()
@@ -649,6 +650,12 @@ class WorkOrder(Document):
def update_status(self, status=None):
"""Update status of work order if unknown"""
if self.docstatus == 1:
# Refresh material_transferred_for_manufacturing before deciding status so pick-list-
# driven transfers (where this qty is derived from item transfers, not fg_completed_qty)
# are reflected immediately, instead of only after the next status update call.
self.refresh_material_transferred_for_manufacturing()
if self.status != "Closed":
if status not in ["Stopped", "Closed"]:
status = self.get_status(status)
@@ -1404,6 +1411,23 @@ class WorkOrder(Document):
self.set("operations", operations)
self.calculate_time()
self.set_operation_warehouses()
def set_operation_warehouses(self):
if not self.track_semi_finished_goods or not self.operations:
return
operations = self.operations
last_idx = len(operations) - 1
for idx, op in enumerate(operations):
if not op.source_warehouse:
op.source_warehouse = self.source_warehouse
if not op.fg_warehouse:
op.fg_warehouse = self.fg_warehouse if idx == last_idx else self.source_warehouse
if not op.wip_warehouse:
op.wip_warehouse = self.wip_warehouse
def calculate_time(self):
for d in self.get("operations"):
@@ -1672,6 +1696,31 @@ class WorkOrder(Document):
if self.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
row_wise_serial_batch = frappe._dict({})
if self.reserve_stock:
row_wise_serial_batch = get_row_wise_serial_batch(self.name)
for row in self.required_items:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.reserve_stock:
self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def refresh_material_transferred_for_manufacturing(self):
"""Recompute material_transferred_for_manufacturing only, without touching per-row
transferred_qty or stock reservations. Used to get a status decision (Not Started vs
In Process) based on fresh data, ahead of the fuller update_required_items() pass.
"""
if self.skip_transfer:
return
transferred_items = self._material_transfer_qty_by_item(is_return=0)
self.recompute_material_transferred_for_manufacturing(transferred_items)
def _material_transfer_qty_by_item(self, is_return):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
@@ -1688,25 +1737,13 @@ class WorkOrder(Document):
(ste.docstatus == 1)
& (ste.work_order == self.name)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
& (ste.is_return == is_return)
)
.groupby(ste_child.item_code)
)
data = query.run(as_dict=1) or []
transferred_items = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
row_wise_serial_batch = frappe._dict({})
if self.reserve_stock:
row_wise_serial_batch = get_row_wise_serial_batch(self.name)
for row in self.required_items:
transferred_qty = transferred_items.get(row.item_code) or 0.0
row.db_set("transferred_qty", transferred_qty, update_modified=False)
if self.reserve_stock:
self.update_qty_in_stock_reservation(row, transferred_qty, row_wise_serial_batch)
self.recompute_material_transferred_for_manufacturing(transferred_items)
return frappe._dict({d.original_item or d.item_code: d.qty for d in data})
def recompute_material_transferred_for_manufacturing(self, transferred_items):
"""Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
@@ -1777,29 +1814,7 @@ class WorkOrder(Document):
doc.update_reserved_stock_in_bin()
def update_returned_qty(self):
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
query = (
frappe.qb.from_(ste)
.inner_join(ste_child)
.on(ste_child.parent == ste.name)
.select(
ste_child.item_code,
ste_child.original_item,
fn.Sum(ste_child.transfer_qty).as_("qty"),
)
.where(
(ste.docstatus == 1)
& (ste.work_order == self.name)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 1)
)
.groupby(ste_child.item_code)
)
data = query.run(as_dict=1) or []
returned_dict = frappe._dict({d.original_item or d.item_code: d.qty for d in data})
returned_dict = self._material_transfer_qty_by_item(is_return=1)
for row in self.required_items:
row.db_set("returned_qty", (returned_dict.get(row.item_code) or 0.0), update_modified=False)

View File

@@ -34,6 +34,7 @@ def complete_onboarding_steps_if_record_exists(steps):
if (
step.action == "Create Entry"
and step.reference_document
and frappe.db.exists("DocType", step.reference_document)
and frappe.get_all(step.reference_document, limit=1)
):
frappe.db.set_value("Onboarding Step", step.name, "is_complete", 1, update_modified=False)

View File

@@ -19,6 +19,101 @@ frappe.setup.on("before_load", function () {
});
erpnext.setup.slides_settings = [
{
// Persona — help us tailor the setup
name: "persona",
title: __("A little about you"),
// subtitle shown under the title
help: __("A few quick questions so we can set things up the way you work."),
fields: [
{
fieldname: "persona_implementing_for",
label: __("Who are you setting this up for?"),
fieldtype: "Select",
options: ["", "My own business", "A company I work for", "A client I'm consulting for"].join(
"\n"
),
reqd: 1,
},
{
fieldname: "persona_company_size",
label: __("How big is the team?"),
fieldtype: "Select",
options: ["", "110", "1150", "51200", "2011,000", "1,000+"].join("\n"),
reqd: 1,
},
{
fieldname: "persona_industry",
label: __("What kind of work do you do?"),
fieldtype: "Select",
options: [
"",
"Manufacturing",
"Retail",
"Wholesale / Distribution",
"E-commerce",
"Services / Consulting",
"Construction / Real Estate",
"Technology / Software",
"Healthcare",
"Education",
"Agriculture",
"Food & Beverage",
"Non Profit",
"Other",
].join("\n"),
reqd: 1,
},
{
fieldname: "persona_current_system",
label: __("What do you use today?"),
fieldtype: "Select",
options: [
"",
"Tally",
"QuickBooks",
"Zoho",
"Sage",
"SAP",
"Microsoft Dynamics",
"Oracle NetSuite",
"Xero",
"Excel / Spreadsheets",
"Nothing yet - starting fresh",
"Other",
].join("\n"),
reqd: 1,
},
{
fieldtype: "Section Break",
description: __("Select the modules that you plan to implement"),
},
{ fieldname: "module_accounting", label: __("Accounting"), fieldtype: "Check" },
{ fieldname: "module_stock", label: __("Stock"), fieldtype: "Check" },
{ fieldtype: "Column Break" },
{ fieldname: "module_manufacturing", label: __("Manufacturing"), fieldtype: "Check" },
{ fieldname: "module_projects", label: __("Project Management"), fieldtype: "Check" },
],
onload: function (slide) {
this.bind_industry_modules(slide);
},
bind_industry_modules: function (slide) {
let me = this;
slide.get_input("persona_industry").on("change", function () {
me.apply_industry_modules(slide);
});
},
apply_industry_modules: function (slide) {
let industry = slide.get_field("persona_industry").get_value();
let modules = erpnext.setup.industry_modules[industry] || ["accounting"];
["accounting", "stock", "manufacturing", "projects"].forEach(function (module) {
slide.get_field("module_" + module).set_value(modules.includes(module) ? 1 : 0);
});
},
},
{
// Organization
name: "organization",
@@ -243,6 +338,24 @@ erpnext.setup.slides_settings = [
},
];
// Modules pre-selected on the persona slide based on the chosen industry.
// Keys must match the persona_industry option values. Accounting is always on.
erpnext.setup.industry_modules = {
Manufacturing: ["accounting", "stock", "manufacturing"],
Retail: ["accounting", "stock"],
"Wholesale / Distribution": ["accounting", "stock"],
"E-commerce": ["accounting", "stock"],
"Services / Consulting": ["accounting", "projects"],
"Construction / Real Estate": ["accounting", "stock", "projects"],
"Technology / Software": ["accounting", "projects"],
Healthcare: ["accounting", "stock"],
Education: ["accounting", "projects"],
Agriculture: ["accounting", "stock"],
"Food & Beverage": ["accounting", "stock", "manufacturing"],
"Non Profit": ["accounting", "projects"],
Other: ["accounting"],
};
// Source: https://en.wikipedia.org/wiki/Fiscal_year
// default 1st Jan - 31st Dec

View File

@@ -11,6 +11,7 @@ from frappe.contacts.address_and_contact import (
delete_contact_and_address,
load_address_and_contact,
)
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
@@ -444,7 +445,7 @@ class Customer(TransactionBase):
@frappe.whitelist()
def make_quotation(source_name, target_doc=None):
def make_quotation(source_name: str, target_doc: str | Document | None = None) -> Document:
def set_missing_values(source, target):
_set_missing_values(source, target)
@@ -457,9 +458,6 @@ def make_quotation(source_name, target_doc=None):
)
target_doc.quotation_to = "Customer"
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
price_list, currency = frappe.db.get_value(
"Customer", {"name": source_name}, ["default_price_list", "default_currency"]
@@ -469,6 +467,10 @@ def make_quotation(source_name, target_doc=None):
if currency:
target_doc.currency = currency
target_doc.run_method("set_missing_values")
target_doc.run_method("set_other_charges")
target_doc.run_method("calculate_taxes_and_totals")
return target_doc

View File

@@ -5,19 +5,67 @@
import json
import frappe
from frappe.utils import flt
from frappe.utils import flt, nowdate
from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import (
get_credit_limit,
get_customer_outstanding,
make_quotation,
parse_full_name,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestCustomer(ERPNextTestSuite):
def test_quotation_from_customer_uses_actual_exchange_rate(self):
company = "_Test Company"
company_currency = frappe.get_cached_value("Company", company, "default_currency")
foreign_currency = "USD" if company_currency != "USD" else "EUR"
frappe.defaults.set_user_default("company", company)
self.addCleanup(frappe.defaults.clear_user_default, "company")
# Seed a deterministic rate so the test does not depend on the live exchange-rate API.
rate = 83.0
exchange_filters = {
"date": nowdate(),
"from_currency": foreign_currency,
"to_currency": company_currency,
}
existing = frappe.db.exists("Currency Exchange", exchange_filters)
if existing:
frappe.db.set_value("Currency Exchange", existing, "exchange_rate", rate)
else:
exchange = frappe.get_doc(
{
"doctype": "Currency Exchange",
**exchange_filters,
"exchange_rate": rate,
"for_selling": 1,
"for_buying": 1,
}
).insert()
self.addCleanup(frappe.delete_doc, "Currency Exchange", exchange.name, force=1)
customer = frappe.get_doc(
{
"doctype": "Customer",
"customer_name": "_Test Customer FX Quotation",
"customer_type": "Company",
"default_currency": foreign_currency,
}
).insert()
self.addCleanup(frappe.delete_doc, "Customer", customer.name, force=1)
quotation = make_quotation(customer.name)
self.assertEqual(quotation.currency, foreign_currency)
self.assertNotEqual(flt(quotation.conversion_rate), 1.0)
self.assertNotEqual(flt(quotation.conversion_rate), 0.0)
self.assertEqual(flt(quotation.conversion_rate), rate)
def test_get_customer_group_details(self):
doc = frappe.new_doc("Customer Group")
doc.customer_group_name = "_Testing Customer Group"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -346,33 +346,48 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "write_off_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Write Off Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Exchange Gain / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_exchange_gain_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Exchange Gain/Loss Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -499,15 +514,19 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "accumulated_depreciation_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Accumulated Depreciation Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_expense_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Depreciation Expense Account",
"no_copy": 1,
"options": "Account"
@@ -522,29 +541,39 @@
"fieldtype": "Column Break"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "disposal_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Gain/Loss Account on Asset Disposal",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "depreciation_cost_center",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Depreciation Cost Center",
"no_copy": 1,
"options": "Cost Center"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "capital_work_in_progress_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Capital Work In Progress Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "asset_received_but_not_billed",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Asset Received But Not Billed",
"no_copy": 1,
"options": "Account"
},
{
@@ -676,15 +705,21 @@
"options": "Warehouse"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "unrealized_profit_loss_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Unrealized Profit / Loss Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "default_discount_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Payment Discount Account",
"no_copy": 1,
"options": "Account"
},
{
@@ -726,8 +761,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_received_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Received Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -736,8 +773,10 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/advance-in-separate-party-account",
"fieldname": "default_advance_paid_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Default Advance Paid Account",
"mandatory_depends_on": "book_advance_payments_as_liability",
"no_copy": 1,
"options": "Account"
},
{
@@ -817,9 +856,12 @@
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "round_off_for_opening",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Round Off for Opening",
"no_copy": 1,
"options": "Account"
},
{
@@ -970,7 +1012,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
"modified": "2026-04-17 17:11:46.586135",
"modified": "2026-07-02 07:21:21.794533",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",

View File

@@ -80,6 +80,7 @@ class Company(NestedSet):
default_operating_cost_account: DF.Link | None
default_payable_account: DF.Link | None
default_provisional_account: DF.Link | None
default_purchase_price_variance_account: DF.Link | None
default_receivable_account: DF.Link | None
default_sales_contact: DF.Link | None
default_scrap_warehouse: DF.Link | None

View File

@@ -4,12 +4,13 @@
import frappe
from frappe import _
from frappe.utils.telemetry import capture
from erpnext.setup.demo import setup_demo_data
from erpnext.setup.setup_wizard.operations import install_fixtures as fixtures
def get_setup_stages(args=None):
def get_setup_stages(args=None): # nosemgrep
stages = [
{
"status": _("Installing presets"),
@@ -28,6 +29,13 @@ def get_setup_stages(args=None):
{"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")},
],
},
{
"status": _("Personalizing your setup"),
"fail_msg": _("Failed to personalize your setup"),
"tasks": [
{"fn": capture_user_persona, "args": args, "fail_msg": _("Failed to personalize your setup")}
],
},
]
if args.get("setup_demo"):
@@ -42,15 +50,38 @@ def get_setup_stages(args=None):
return stages
def stage_fixtures(args):
def capture_user_persona(args): # nosemgrep
"""Send the persona answers captured on the setup slide to telemetry."""
if not args:
return
capture(
"user_persona_submitted",
"erpnext",
properties={
"implementing_for": args.get("persona_implementing_for"),
"company_size": args.get("persona_company_size"),
"industry": args.get("persona_industry"),
"current_system": args.get("persona_current_system"),
"module_accounting": bool(args.get("module_accounting")),
"module_stock": bool(args.get("module_stock")),
"module_manufacturing": bool(args.get("module_manufacturing")),
"module_projects": bool(args.get("module_projects")),
"country": args.get("country"),
"language": args.get("language"),
},
)
def stage_fixtures(args): # nosemgrep
fixtures.install(args.get("country"))
def setup_company(args):
def setup_company(args): # nosemgrep
fixtures.install_company(args)
def setup_defaults(args):
def setup_defaults(args): # nosemgrep
fixtures.install_defaults(frappe._dict(args))
@@ -59,7 +90,7 @@ def setup_demo(args): # nosemgrep
# Only for programmatical use
def setup_complete(args=None):
def setup_complete(args=None): # nosemgrep
stage_fixtures(args)
setup_company(args)
setup_defaults(args)

View File

@@ -12,6 +12,9 @@ def get_data(
item_code=None, warehouse=None, item_group=None, start=0, sort_by="actual_qty", sort_order="desc"
):
"""Return data to render the item dashboard"""
if not frappe.has_permission("Bin", "read"):
return []
filters = []
if item_code:
filters.append(["item_code", "=", item_code])
@@ -33,7 +36,10 @@ def get_data(
if build_match_conditions("Warehouse", user=frappe.session.user):
filters.append(["warehouse", "in", [w.name for w in frappe.get_list("Warehouse")]])
except frappe.PermissionError:
# user does not have access on warehouse
# user does not have access on warehouse; build_match_conditions already queued a
# "Not permitted" message via frappe.throw before this was caught, drop it so the
# client doesn't show a spurious error for a request that's failing gracefully here
frappe.clear_last_message()
return []
items = frappe.db.get_all(

View File

@@ -9,6 +9,7 @@ from frappe import _
from frappe.contacts.doctype.address.address import get_company_address
from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
from frappe.query_builder import DocType
@@ -813,7 +814,9 @@ def get_returned_qty_map(delivery_note):
@frappe.whitelist()
def make_sales_invoice(source_name, target_doc=None, args=None):
def make_sales_invoice(
source_name: str, target_doc: Document | str | None = None, args: dict | str | None = None
):
if args is None:
args = {}
if isinstance(args, str):
@@ -919,7 +922,12 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
)
if not doc.is_return:
if doc.is_return:
# A credit note made from a return Delivery Note should roll back the billed
# amount on the linked Sales Order too, so that per_billed stays consistent with
# per_delivered (which the return already reset).
doc.update_billed_amount_in_sales_order = True
else:
so, doctype, fieldname = doc.get_order_details()
if (
doc.linked_order_has_payment_terms(so, fieldname, doctype)

View File

@@ -2627,6 +2627,92 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.per_returned, 100)
self.assertEqual(returned.status, "Return")
def _assert_credit_note_from_return_dn_resets_per_billed(self, so, dn):
"""Given a fully billed Sales Order and a submitted Delivery Note that delivers it,
a credit note made from the return of that Delivery Note must reset per_billed to 0
while leaving the delivery quantities exactly as the return already set them."""
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
so.load_from_db()
self.assertEqual(so.per_delivered, 100)
self.assertEqual(so.per_billed, 100)
return_dn = make_sales_return(dn.name)
return_dn.insert()
return_dn.submit()
# the return reverses the delivery quantities
so.load_from_db()
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
credit_note = make_sales_invoice(return_dn.name)
self.assertTrue(credit_note.is_return)
self.assertTrue(credit_note.update_billed_amount_in_sales_order)
# A Delivery Note-linked invoice can't update stock (validate_delivery_note), so the
# credit note only rolls back billing and never re-reverses the delivery quantities.
self.assertFalse(credit_note.update_stock)
credit_note.insert()
credit_note.submit()
# per_billed is reset, and the delivery state stays exactly as the return left it
so.load_from_db()
self.assertEqual(so.per_billed, 0)
self.assertEqual(so.per_delivered, 0)
self.assertEqual(so.items[0].delivered_qty, 0)
self.assertEqual(so.items[0].returned_qty, 0)
# Cancelling the credit note should restore the billed amount on the Sales Order.
credit_note.cancel()
so.load_from_db()
self.assertEqual(so.per_billed, 100)
def test_sales_order_per_billed_after_credit_note_from_return_dn(self):
# Reported flow: SO -> SI (from SO) -> DN (from SI) -> return DN -> credit note.
# The DN carries si_detail in this path.
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice as make_si_from_so
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_delivery_note(si.name)
dn.insert()
dn.submit()
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_sales_order_per_billed_after_credit_note_from_so_derived_dn(self):
# SO billed and delivered separately (SO -> SI, SO -> DN), then return DN -> credit note.
# SO per_billed rolls back via the status_updater in update_prevdoc_status.
from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as make_dn_from_so,
)
from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice as make_si_from_so,
)
make_stock_entry(item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=100)
so = make_sales_order(qty=2)
si = make_si_from_so(so.name)
si.insert()
si.submit()
dn = make_dn_from_so(so.name)
dn.insert()
dn.submit()
self.assertIsNone(dn.items[0].si_detail)
self._assert_credit_note_from_return_dn_resets_per_billed(so, dn)
def test_packed_item_serial_no_status(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -668,6 +668,13 @@ $.extend(erpnext.item, {
render_item_prices: function (frm) {
if (frm.doc.__islocal) return;
if (!frappe.model.can_read("Item Price")) {
frm.toggle_display("prices_html", false);
return;
}
frm.toggle_display("prices_html", true);
const requested_item = frm.doc.name;
const container = frm.fields_dict["prices_html"].$wrapper;

View File

@@ -225,6 +225,7 @@ class Item(Document):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
self.validate_serialized_change_with_bundle()
self.validate_item_tax_net_rate_range()
if not self.is_new():
@@ -1102,6 +1103,25 @@ class Item(Document):
frappe.throw(msg, title=_("Linked with submitted documents"))
def validate_serialized_change_with_bundle(self):
"""Block turning a serialized item non-serialized while any Serial and Batch Bundle still exists
for it. Such bundles carry the item's serial numbers; the user must delete or cancel them first."""
if self.is_new() or self.has_serial_no or not self._doc_before_save:
return
# Only relevant when the item was serialized before and is now being unset.
if not self._doc_before_save.has_serial_no:
return
# Draft (docstatus 0) or submitted (docstatus 1) bundles block the change; cancelled ones don't.
if frappe.db.count("Serial and Batch Bundle", {"item_code": self.name, "docstatus": ("<", 2)}):
frappe.throw(
_(
"Cannot change Item {0} from serialized to non-serialized because a Serial and Batch Bundle exists for it. Please delete or cancel the Serial and Batch Bundle first."
).format(frappe.bold(self.name)),
title=_("Serial and Batch Bundle Exists"),
)
def _get_linked_submitted_documents(self, changed_fields: list[str]) -> dict[str, str] | None:
linked_doctypes = [
"Delivery Note Item",

View File

@@ -1044,6 +1044,47 @@ class TestItem(ERPNextTestSuite):
msg="Different Variant UOM should not be allowed when `allow_different_uom` is disabled.",
)
def test_cannot_unset_serialized_while_bundle_exists(self):
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
make_serial_batch_bundle,
)
item = make_item(
properties={"has_serial_no": 1, "is_stock_item": 1, "serial_no_series": "TSN-UNSET-.####"}
).name
serial_no = f"{item}-SN-01"
frappe.get_doc(
{"doctype": "Serial No", "serial_no": serial_no, "item_code": item, "company": "_Test Company"}
).insert()
# A draft (unsubmitted) Serial and Batch Bundle for the item must block the change.
bundle = make_serial_batch_bundle(
{
"item_code": item,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
"qty": 1,
"rate": 100,
"voucher_type": "Stock Entry",
"serial_nos": [serial_no],
"type_of_transaction": "Inward",
"do_not_submit": True,
"ignore_sabb_validation": True,
}
)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
self.assertRaises(frappe.ValidationError, doc.save)
# Once the bundle is removed, the item can be made non-serialized.
frappe.delete_doc("Serial and Batch Bundle", bundle.name, force=True)
doc = frappe.get_doc("Item", item)
doc.has_serial_no = 0
doc.save()
self.assertEqual(frappe.db.get_value("Item", item, "has_serial_no"), 0)
def set_item_variant_settings(fields):
doc = frappe.get_doc("Item Variant Settings")

View File

@@ -1034,6 +1034,13 @@ class PurchaseReceipt(BuyingController):
return
production_plan_references = self.get_production_plan_references()
if not production_plan_references:
return
reservable_plans = self.get_reservable_production_plans(production_plan_references)
if not reservable_plans:
return
production_plan_items = []
self.reload()
@@ -1041,6 +1048,9 @@ class PurchaseReceipt(BuyingController):
for row in self.items:
if row.material_request_item and row.material_request_item in production_plan_references:
_ref = production_plan_references[row.material_request_item]
if _ref.production_plan not in reservable_plans:
continue
docnames.append(_ref.production_plan)
row.update(
{
@@ -1066,6 +1076,25 @@ class PurchaseReceipt(BuyingController):
docnames, from_doctype="Production Plan", to_doctype="Work Order"
)
def get_reservable_production_plans(self, production_plan_references) -> set:
"""Production Plans that opted into stock reservation (``reserve_stock``).
A Production Plan only gets this flag set if "Auto Reserve Stock" was enabled in
Stock Settings when it was created, or the user ticked "Reserve Stock" manually.
Without this check, a Purchase Receipt would auto-reserve stock for every
Production Plan whenever "Enable Stock Reservation" is on, ignoring both of those.
"""
plan_names = {ref.production_plan for ref in production_plan_references.values()}
return {
p.name
for p in frappe.get_all(
"Production Plan",
filters={"name": ["in", list(plan_names)]},
fields=["name", "reserve_stock"],
)
if p.reserve_stock
}
def get_production_plan_references(self):
production_plan_references = frappe._dict()
material_request_items = []

View File

@@ -1976,6 +1976,92 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(query[0].value, 0)
def test_internal_transfer_pr_incoming_sle_anchored_to_dn_rate(self):
"""Internal-transfer PR's inward SLE must use DN.incoming_rate even when
PR.item.valuation_rate was wrong at submit, so divisional_loss does not
leak to COGS."""
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
from erpnext.stock.stock_ledger import update_entries_after
prepare_data_for_internal_transfer()
customer = "_Test Internal Customer 2"
company = "_Test Company with perpetual inventory"
from_warehouse = create_warehouse("_Test Drift From", company=company)
transit_warehouse = create_warehouse("_Test Drift Transit", company=company)
to_warehouse = create_warehouse("_Test Drift Receiver", company=company)
item_doc = create_item("Test Internal Drift Item")
make_purchase_receipt(
item_code=item_doc.name,
company=company,
posting_date=add_days(today(), -1),
warehouse=from_warehouse,
qty=10,
rate=100,
)
dn = create_delivery_note(
item_code=item_doc.name,
company=company,
customer=customer,
cost_center="Main - TCP1",
expense_account="Cost of Goods Sold - TCP1",
qty=1,
rate=100,
warehouse=from_warehouse,
target_warehouse=transit_warehouse,
)
self.assertEqual(flt(dn.items[0].incoming_rate), 100.0)
pr = make_inter_company_purchase_receipt(dn.name)
pr.items[0].warehouse = to_warehouse
pr.submit()
# Simulate the failure path
frappe.db.set_value(
"Purchase Receipt Item",
pr.items[0].name,
{"sales_incoming_rate": 0, "valuation_rate": 80},
)
inward_sle = frappe.db.get_value(
"Stock Ledger Entry",
{
"voucher_type": "Purchase Receipt",
"voucher_no": pr.name,
"warehouse": to_warehouse,
"is_cancelled": 0,
},
["name", "item_code", "warehouse", "posting_date", "posting_time", "creation"],
as_dict=True,
)
frappe.db.set_value(
"Stock Ledger Entry",
inward_sle.name,
{"incoming_rate": 80, "stock_value_difference": 80},
)
update_entries_after(
{
"item_code": inward_sle.item_code,
"warehouse": inward_sle.warehouse,
"posting_date": inward_sle.posting_date,
"posting_time": inward_sle.posting_time,
"sle_id": inward_sle.name,
"creation": inward_sle.creation,
}
)
refreshed = frappe.db.get_value(
"Stock Ledger Entry",
inward_sle.name,
["incoming_rate", "stock_value_difference"],
as_dict=True,
)
self.assertEqual(flt(refreshed.incoming_rate), 100.0)
self.assertEqual(flt(refreshed.stock_value_difference), 100.0)
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_invoice(
self,
):

View File

@@ -4727,3 +4727,136 @@ def get_transferred_qty(material_request):
).run(as_dict=True)
return query[0]
def get_previous_operation_output_sn_batch(work_order, item_code, warehouse):
"""Serial nos / batches that an earlier operation produced for ``item_code`` (a
semi-finished good) and are still available in ``warehouse`` -- i.e. produced by a
prior operation's Manufacture entry minus whatever later entries already pulled out
of that warehouse. Returns an empty result for ordinary raw materials."""
result = frappe._dict(serial_nos=[], batches=defaultdict(float))
if not (work_order and item_code and warehouse):
return result
if not frappe.db.exists("Work Order Operation", {"parent": work_order, "finished_good": item_code}):
return result
item_details = frappe.get_cached_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1)
if not item_details or not (item_details.has_serial_no or item_details.has_batch_no):
return result
produced = _get_operation_sn_batch(work_order, item_code, warehouse, produced=True)
consumed = _get_operation_sn_batch(work_order, item_code, warehouse, produced=False)
for serial_no in produced.serial_nos:
if serial_no not in consumed.serial_nos:
result.serial_nos.append(serial_no)
for batch_no, qty in produced.batches.items():
available = flt(qty) - flt(consumed.batches.get(batch_no))
if available > 0:
result.batches[batch_no] = available
return result
def _get_operation_sn_batch(work_order, item_code, warehouse, produced=True):
bundles = _get_operation_bundles(work_order, item_code, warehouse, produced)
result = frappe._dict(serial_nos=[], batches=defaultdict(float))
if not bundles:
return result
sbe = frappe.qb.DocType("Serial and Batch Entry")
entries = (
frappe.qb.from_(sbe)
.select(sbe.serial_no, sbe.batch_no, sbe.qty)
.where((sbe.parent.isin(bundles)) & (sbe.is_cancelled == 0))
.orderby(sbe.parent)
.orderby(sbe.idx)
).run(as_dict=True)
for row in entries:
if row.serial_no:
result.serial_nos.append(row.serial_no)
if row.batch_no:
result.batches[row.batch_no] += abs(flt(row.qty))
return result
def _get_operation_bundles(work_order, item_code, warehouse, produced):
se = frappe.qb.DocType("Stock Entry")
sed = frappe.qb.DocType("Stock Entry Detail")
warehouse_field = sed.t_warehouse if produced else sed.s_warehouse
query = (
frappe.qb.from_(se)
.inner_join(sed)
.on(sed.parent == se.name)
.select(sed.serial_and_batch_bundle)
.where(
(se.work_order == work_order)
& (se.docstatus == 1)
& (sed.item_code == item_code)
& (warehouse_field == warehouse)
& (sed.serial_and_batch_bundle.isnotnull())
)
)
if produced:
query = query.where((se.purpose == "Manufacture") & (sed.is_finished_item == 1))
return [row[0] for row in query.run()]
def _cap_pool_to_qty(pool, qty):
"""Trim the available serial/batch pool to at most ``qty`` (fill what's available)."""
serial_nos, batches = [], frappe._dict()
if pool.serial_nos:
serial_nos = pool.serial_nos[: cint(qty)]
elif pool.batches:
remaining = flt(qty)
for batch_no, batch_qty in pool.batches.items():
if remaining <= 0:
break
use = min(flt(batch_qty), remaining)
batches[batch_no] = use
remaining -= use
return serial_nos, batches
def set_previous_operation_serial_batch(parent_doc, row):
"""Auto-pull serial nos / batches produced by a previous operation onto a
consumption / transfer-out ``row`` of a Stock Entry, filling what is available and
leaving any shortfall blank for the user. No-op for ordinary raw materials or when
the row already carries serial/batch."""
warehouse = row.get("s_warehouse") or row.get("from_warehouse")
qty = flt(row.get("qty")) * flt(row.get("conversion_factor") or 1)
if not parent_doc.get("work_order") or not warehouse or qty <= 0:
return
if row.get("serial_and_batch_bundle") or row.get("serial_no") or row.get("batch_no"):
return
pool = get_previous_operation_output_sn_batch(parent_doc.work_order, row.item_code, warehouse)
serial_nos, batches = _cap_pool_to_qty(pool, qty)
if not serial_nos and not batches:
return
bundle = SerialBatchCreation(
{
"item_code": row.item_code,
"warehouse": warehouse,
"posting_datetime": get_combine_datetime(parent_doc.posting_date, parent_doc.posting_time),
"voucher_type": "Stock Entry",
"company": parent_doc.company,
"type_of_transaction": "Outward",
"qty": flt(qty),
"serial_nos": serial_nos,
"batches": batches,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
if bundle and bundle.get("name"):
row.serial_and_batch_bundle = bundle.name
row.use_serial_batch_fields = 0

View File

@@ -345,7 +345,6 @@
"print_hide": 1
},
{
"default": ":Company",
"depends_on": "eval:cint(erpnext.is_perpetual_inventory_enabled(parent.company))",
"fieldname": "cost_center",
"fieldtype": "Link",
@@ -679,7 +678,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-30 12:18:34.132425",
"modified": "2026-07-03 12:11:53.714931",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry Detail",

View File

@@ -106,6 +106,8 @@ class ManufactureEntry:
)
def add_raw_materials(self):
from erpnext.stock.doctype.stock_entry.stock_entry import set_previous_operation_serial_batch
if self.job_card:
item_dict = {}
if not item_dict:
@@ -127,9 +129,7 @@ class ManufactureEntry:
_dict.from_warehouse = self.source_wh.get(item_code) or self.wip_warehouse
_dict.to_warehouse = ""
if backflush_based_on != "BOM" and not frappe.db.get_value(
"Job Card", self.job_card, "skip_material_transfer"
):
if backflush_based_on != "BOM" and not self.skip_material_transfer:
calculated_qty = flt(_dict.transferred_qty) - flt(_dict.consumed_qty)
if calculated_qty < 0:
frappe.throw(
@@ -138,6 +138,8 @@ class ManufactureEntry:
_dict.qty = calculated_qty
self.update_available_serial_batches(_dict, available_serial_batches)
elif self.skip_material_transfer:
set_previous_operation_serial_batch(self.stock_entry, _dict)
self.stock_entry.add_to_stock_entry_detail(item_dict)

File diff suppressed because one or more lines are too long

View File

@@ -492,7 +492,7 @@ class FIFOSlots:
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:
elif fifo_queue and is_qty_slot(fifo_queue[0]) and flt(fifo_queue[0][FIFO_QTY_INDEX]) <= 0:
self._add_to_negative_fifo_head(row, fifo_queue)
else:
fifo_queue.append([flt(row.actual_qty), row.posting_date, flt(row.stock_value_difference)])

View File

@@ -1434,6 +1434,47 @@ class TestStockAgeing(ERPNextTestSuite):
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_untagged_receipt_with_negative_batch_head(self):
"""An incoming SLE without batch details must not treat a negative
batch slot at the queue head as a qty slot (TypeError: str += float)."""
sle = [
frappe._dict(
name="Enclosure Item",
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="QI-06448",
),
frappe._dict(
name="Enclosure Item",
actual_qty=45,
qty_after_transaction=35,
stock_value_difference=1051.65,
warehouse="WH 1",
posting_date="2021-12-05",
voucher_type="Purchase Receipt",
voucher_no="002",
has_serial_no=False,
serial_no=None,
batch_no=None,
serial_and_batch_bundle="SABB-00001294",
),
]
slots = FIFOSlots(self.filters, sle).generate()
queue = slots["Enclosure Item"]["fifo_queue"]
self.assertEqual(slots["Enclosure Item"]["total_qty"], 35.0)
self.assertEqual(queue[0], ["QI-06448", None, -10.0, "2021-12-01", -100.0])
self.assertEqual(queue[1], [45.0, "2021-12-05", 1051.65])
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate

View File

@@ -20,6 +20,7 @@ SLE_FIELDS = (
"outgoing_rate",
"stock_queue",
"batch_no",
"serial_no",
"stock_value",
"stock_value_difference",
"valuation_rate",
@@ -52,16 +53,16 @@ def add_invariant_check_fields(sles, filters):
balance_qty = 0.0
balance_stock_value = 0.0
incorrect_idx = 0
precision = frappe.get_precision("Stock Ledger Entry", "actual_qty")
incorrect_idx = None
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
)
for idx, sle in enumerate(sles):
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
fifo_qty = 0.0
fifo_value = 0.0
for qty, rate in queue:
fifo_qty += qty
fifo_value += qty * rate
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value(
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
)
if sle.actual_qty < 0:
sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
@@ -77,57 +78,67 @@ def add_invariant_check_fields(sles, filters):
if balance_qty is None:
balance_qty = sle.qty_after_transaction
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.balance_value_by_qty = (
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
)
sle.expected_qty_after_transaction = balance_qty
sle.stock_value_from_diff = balance_stock_value
# set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
sle.fifo_value_diff = sle.stock_value - fifo_value
sle.fifo_valuation_diff = (
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
)
sle.valuation_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
)
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
if not incorrect_idx and filters.get("show_incorrect_entries"):
if is_sle_has_correct_data(sle, precision):
continue
else:
incorrect_idx = idx
if maintains_fifo_queue(sle):
add_fifo_fields(sle, sles[idx - 1] if idx else None)
if idx > 0:
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
if sle.batch_no:
sle.use_batchwise_valuation = frappe.db.get_value(
"Batch", sle.batch_no, "use_batchwise_valuation", cache=True
)
if incorrect_idx is None and not is_sle_has_correct_data(sle, float_precision, currency_precision):
incorrect_idx = idx
if filters.get("show_incorrect_entries"):
if incorrect_idx > 0:
sles = sles[cint(incorrect_idx) - 1 :]
return []
if incorrect_idx is None:
return []
return sles[max(incorrect_idx - 1, 0) :]
return sles
def is_sle_has_correct_data(sle, precision):
if flt(sle.difference_in_qty, precision) != 0.0 or flt(sle.diff_value_diff, precision) != 0:
print(flt(sle.difference_in_qty, precision), flt(sle.diff_value_diff, precision))
return False
def maintains_fifo_queue(sle):
# no queue is maintained for serialized/batchwise-valued stock
return not (
sle.serial_and_batch_bundle or sle.serial_no or (sle.batch_no and sle.use_batchwise_valuation)
)
return True
def add_fifo_fields(sle, prev_sle):
queue = json.loads(sle.stock_queue) if sle.stock_queue else []
fifo_qty = 0.0
fifo_value = 0.0
for qty, rate in queue:
fifo_qty += qty
fifo_value += qty * rate
sle.fifo_queue_qty = fifo_qty
sle.fifo_stock_value = fifo_value
sle.fifo_valuation_rate = fifo_value / fifo_qty if fifo_qty else None
sle.fifo_qty_diff = sle.qty_after_transaction - fifo_qty
sle.fifo_value_diff = sle.stock_value - fifo_value
sle.fifo_valuation_diff = (
sle.valuation_rate - sle.fifo_valuation_rate if sle.fifo_valuation_rate else None
)
# prev row may not maintain a queue; H and H - F stay blank across the gap
if prev_sle and prev_sle.fifo_stock_value is not None:
sle.fifo_stock_diff = sle.fifo_stock_value - prev_sle.fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
def is_sle_has_correct_data(sle, float_precision, currency_precision):
return (
flt(sle.difference_in_qty, float_precision) == 0.0
and flt(sle.diff_value_diff, currency_precision) == 0.0
)
def get_columns():

View File

@@ -0,0 +1,76 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.report.stock_ledger_invariant_check.stock_ledger_invariant_check import execute
from erpnext.tests.utils import ERPNextTestSuite
WAREHOUSE = "Stores - _TC"
COMPANY = "_Test Company"
ITEM = "_Test Item"
class TestStockLedgerInvariantCheck(ERPNextTestSuite):
def run_report(self, **extra):
filters = frappe._dict({"company": COMPANY, "warehouse": WAREHOUSE})
filters.update(extra)
return execute(filters)[1]
def make_movements(self) -> str:
frappe.db.set_value("Item", ITEM, "valuation_method", "FIFO")
make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=10, rate=100, posting_date="2026-06-01")
make_stock_entry(item_code=ITEM, to_warehouse=WAREHOUSE, qty=5, rate=120, posting_date="2026-06-02")
make_stock_entry(item_code=ITEM, from_warehouse=WAREHOUSE, qty=4, rate=0, posting_date="2026-06-03")
return ITEM
def test_diagnostic_rows_have_no_discrepancy(self):
item = self.make_movements()
data = self.run_report(item_code=item)
self.assertEqual(len(data), 3)
for row in data:
self.assertLess(abs(row.difference_in_qty), 0.01)
self.assertLess(abs(row.fifo_qty_diff), 0.01)
self.assertLess(abs(row.diff_value_diff), 0.01)
def test_running_balance_matches(self):
item = self.make_movements()
data = self.run_report(item_code=item)
self.assertEqual(data[-1].qty_after_transaction, 11)
def test_show_incorrect_entries(self):
item = self.make_movements()
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])
sle = frappe.get_last_doc(
"Stock Ledger Entry", {"item_code": item, "warehouse": WAREHOUSE, "is_cancelled": 0}
)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "qty_after_transaction", sle.qty_after_transaction + 5
)
data = self.run_report(item_code=item, show_incorrect_entries=1)
self.assertEqual(len(data), 2) # incorrect entry + one before it for context
self.assertEqual(data[-1].name, sle.name)
def test_batch_item_skips_fifo_queue_checks(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(
properties={"has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "SLIC-BAT-.####"}
).name
make_stock_entry(item_code=item, to_warehouse=WAREHOUSE, qty=10, rate=100)
data = self.run_report(item_code=item)
self.assertTrue(data)
for row in data:
self.assertIsNone(row.fifo_qty_diff)
self.assertIsNone(row.fifo_value_diff)
self.assertEqual(self.run_report(item_code=item, show_incorrect_entries=1), [])

View File

@@ -205,7 +205,10 @@ def get_data(filters=None):
data = []
if item_warehouse_map:
precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
float_precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) or 3
currency_precision = (
cint(frappe.db.get_single_value("System Settings", "currency_precision")) or float_precision
)
for item_warehouse in item_warehouse_map:
report_data = stock_ledger_invariant_check(item_warehouse)
@@ -215,7 +218,11 @@ def get_data(filters=None):
for row in report_data:
if has_difference(
row, precision, filters.difference_in, item_warehouse.valuation_method or valuation_method
row,
float_precision,
currency_precision,
filters.difference_in,
item_warehouse.valuation_method or valuation_method,
):
row.update(
{
@@ -261,23 +268,26 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict:
return query.run(as_dict=1)
def has_difference(row, precision, difference_in, valuation_method):
def has_difference(row, float_precision, currency_precision, difference_in, valuation_method):
if valuation_method == "Moving Average":
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
valuation_diff = flt(row.valuation_diff, precision)
qty_diff = flt(row.difference_in_qty, float_precision)
value_diff = flt(row.diff_value_diff, currency_precision)
valuation_diff = flt(row.valuation_diff, currency_precision)
else:
qty_diff = flt(row.difference_in_qty, precision)
value_diff = flt(row.diff_value_diff, precision)
qty_diff = flt(row.difference_in_qty, float_precision)
value_diff = flt(row.diff_value_diff, currency_precision)
if row.stock_queue and json.loads(row.stock_queue):
value_diff = value_diff or (
flt(row.fifo_value_diff, precision) or flt(row.fifo_difference_diff, precision)
flt(row.fifo_value_diff, currency_precision)
or flt(row.fifo_difference_diff, currency_precision)
)
qty_diff = qty_diff or flt(row.fifo_qty_diff, precision)
qty_diff = qty_diff or flt(row.fifo_qty_diff, float_precision)
valuation_diff = flt(row.valuation_diff, precision) or flt(row.fifo_valuation_diff, precision)
valuation_diff = flt(row.valuation_diff, currency_precision) or flt(
row.fifo_valuation_diff, currency_precision
)
if difference_in == "Qty" and qty_diff:
return True
@@ -287,3 +297,5 @@ def has_difference(row, precision, difference_in, valuation_method):
return True
elif difference_in not in ["Qty", "Value", "Valuation"] and (qty_diff or value_diff or valuation_diff):
return True
return False

View File

@@ -877,10 +877,16 @@ class update_entries_after:
if (
sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]
and sle.voucher_detail_no
and sle.actual_qty < 0
and is_internal_transfer(sle)
):
sle.outgoing_rate = get_incoming_rate_for_inter_company_transfer(sle)
# Anchor both legs of an internal-transfer PR/PI to the DN/SI incoming_rate;
# otherwise an inward SLE that inherits a stale PR.valuation_rate leaks the
# gap to COGS via divisional_loss.
rate = get_incoming_rate_for_inter_company_transfer(sle)
if sle.actual_qty < 0:
sle.outgoing_rate = rate
elif rate:
sle.incoming_rate = rate
dimensions = get_inventory_dimensions()
has_dimensions = False